from charms import layer from charmhelpers.core.hookenv import log from subprocess import CalledProcessError from subprocess import check_output from subprocess import STDOUT import os def etcdctl_command(): if os.path.isfile("/snap/bin/etcd.etcdctl"): return "/snap/bin/etcd.etcdctl" return "etcdctl" class EtcdCtl: """etcdctl modeled as a python class. This python wrapper consumes and exposes some of the commands contained in etcdctl. Related to unit registration, cluster health, and other operations""" class CommandFailed(Exception): pass def register(self, cluster_data): """Perform self registration against the etcd leader and returns the raw output response. @params cluster_data - a dict of data to fill out the request to push our registration to the leader requires keys: leader_address, port, unit_name, cluster_address, management_port """ # Build a connection string for the cluster data. connection = get_connection_string( [cluster_data["cluster_address"]], cluster_data["management_port"] ) command = "member add {} {}".format(cluster_data["unit_name"], connection) try: result = self.run(command, endpoints=cluster_data["leader_address"], api=2) except EtcdCtl.CommandFailed: log("Notice: Unit failed self registration", "WARNING") raise # ['Added member named etcd12 with ID b9ab5b5a2e4baec5 to cluster', # '', 'ETCD_NAME="etcd12"', # 'ETCD_INITIAL_CLUSTER="etcd11=https://10.113.96.26:2380,etcd12=https://10.113.96.206:2380"', # noqa # 'ETCD_INITIAL_CLUSTER_STATE="existing"', ''] reg = {} for line in result.split("\n"): if "ETCD_INITIAL_CLUSTER=" in line: reg["cluster"] = line.split('="')[-1].rstrip('"') return reg def unregister(self, unit_id, leader_address=None): """Perform self deregistration during unit teardown @params unit_id - the ID for the unit assigned by etcd. Obtainable from member_list method. @params leader_address - The endpoint to communicate with the leader in the event of self deregistration. """ return self.run(["member", "remove", unit_id], endpoints=leader_address, api=2) def member_list(self, leader_address=False): """Returns the output from `etcdctl member list` as a python dict organized by unit_name, containing all the data-points in the resulting response.""" command = "member list" members = {} out = self.run(command, endpoints=leader_address, api=2) raw_member_list = out.strip("\n").split("\n") # Expect output like this: # 4f24ee16c889f6c1: name=etcd20 peerURLs=https://10.113.96.197:2380 clientURLs=https://10.113.96.197:2379 # noqa # edc04bb81479d7e8: name=etcd21 peerURLs=https://10.113.96.243:2380 clientURLs=https://10.113.96.243:2379 # noqa # edc0dsa81479d7e8[unstarted]: peerURLs=https://10.113.96.124:2380 # noqa for unit in raw_member_list: if "[unstarted]" in unit: unit_guid = unit.split("[")[0] members["unstarted"] = {"unit_id": unit_guid} if "peerURLs=" in unit: peer_urls = unit.split(" ")[1].split("=")[-1] members["unstarted"]["peer_urls"] = peer_urls continue unit_guid = unit.split(":")[0] unit_name = unit.split(" ")[1].split("=")[-1] peer_urls = unit.split(" ")[2].split("=")[-1] client_urls = unit.split(" ")[3].split("=")[-1] members[unit_name] = { "unit_id": unit_guid, "name": unit_name, "peer_urls": peer_urls, "client_urls": client_urls, } return members def member_update(self, unit_id, uri): """Update the etcd cluster member by unit_id with a new uri. This allows us to change protocol, address or port. @params unit_id: The string ID of the unit in the cluster. @params uri: The string universal resource indicator of where to contact the peer.""" out = "" try: command = "member update {} {}".format(unit_id, uri) log(command) # Run the member update command for the existing unit_id. out = self.run(command) except EtcdCtl.CommandFailed: log("Failed to update member {}".format(unit_id), "WARNING") return out def cluster_health(self, output_only=False): """Returns the output of etcdctl cluster-health as a python dict organized by topical information with detailed unit output""" health = {} try: out = self.run("cluster-health", endpoints=False, api=2) if output_only: return out health_output = out.strip("\n").split("\n") health["status"] = health_output[-1] health["units"] = health_output[0:-2] except EtcdCtl.CommandFailed: log("Notice: Unit failed cluster-health check", "WARNING") health["status"] = "cluster is unhealthy see log file for details." health["units"] = [] return health def run(self, arguments, endpoints=None, api=3): """Wrapper to subprocess calling output. This is a convenience method to clean up the calls to subprocess and append TLS data""" env = {} command = [etcdctl_command()] opts = layer.options("tls-client") ca_path = opts["ca_certificate_path"] crt_path = opts["server_certificate_path"] key_path = opts["server_key_path"] if api == 3: env["ETCDCTL_API"] = "3" env["ETCDCTL_CACERT"] = ca_path env["ETCDCTL_CERT"] = crt_path env["ETCDCTL_KEY"] = key_path if endpoints is None: endpoints = "http://127.0.0.1:4001" elif api == 2: env["ETCDCTL_API"] = "2" env["ETCDCTL_CA_FILE"] = ca_path env["ETCDCTL_CERT_FILE"] = crt_path env["ETCDCTL_KEY_FILE"] = key_path if endpoints is None: endpoints = ":4001" else: raise NotImplementedError("etcd api version {} not supported".format(api)) if isinstance(arguments, str): command.extend(arguments.split()) elif isinstance(arguments, list) or isinstance(arguments, tuple): command.extend(arguments) else: raise RuntimeError( "arguments not correct type; must be string, list or tuple" ) if endpoints is not False: if api == 3: command.extend(["--endpoints", endpoints]) elif api == 2: command.insert(1, "--endpoint") command.insert(2, endpoints) try: return check_output(command, env=env, stderr=STDOUT).decode("utf-8") except CalledProcessError as e: log(command, "ERROR") log(env, "ERROR") log(e.stdout, "ERROR") log(e.stderr, "ERROR") raise EtcdCtl.CommandFailed() from e def version(self): """Return the version of etcdctl""" out = check_output( [etcdctl_command(), "version"], env={"ETCDCTL_API": "3"} ).decode("utf-8") if out == "No help topic for 'version'\n": # Probably on etcd2 out = check_output([etcdctl_command(), "--version"]).decode("utf-8") return out.split("\n")[0].split()[2] def get_connection_string(members, port, protocol="https"): """Return a connection string for the list of members using the provided port and protocol (defaults to https)""" connections = [] for address in members: connections.append("{}://{}:{}".format(protocol, address, port)) connection_string = ",".join(connections) return connection_string