Charmed-Kubernetes/etcd/lib/etcdctl.py

211 lines
8.0 KiB
Python

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