215 lines
8.1 KiB
Python
215 lines
8.1 KiB
Python
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
|