Charmed-Kubernetes/etcd/lib/etcdctl.py

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