from charms import layer from charmhelpers.core.hookenv import log from subprocess import CalledProcessError from subprocess import check_output 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 ).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