source/projects/kook/bin/kook

742 lines
20 KiB
Python
Executable file

#!/usr/bin/env python3
# See kook --help for usage.
import argparse
from datetime import datetime
import getpass
import json
import re
from subprocess import call
import sys
from time import sleep
from typing import Iterable
import jinja2
from kook.client import KookClient, lock
# Note colored is an optional dependency
try:
from colored import attr, fg
except ImportError:
# Provide stub functions
def fg(_):
return ""
def attr(_):
return ""
def _var_filter_ctor(s: str):
if s.startswith("!"):
s = s[1:]
if ":" in s:
s = s.split(":", 1)
return ("not_match", s[0], s[1])
else:
return ("unset", s)
elif ":" in s:
return ("match", *s.split(":", 1))
else:
raise ValueError("Got invalid filter")
def _compile_filters(filters):
if not filters:
return lambda x: True
else:
unset = object()
def _compile_filter(f):
if f[0] == "unset":
return lambda x: x.canonicalized_vars().get(f[1], unset) is unset
elif f[0] in ["match", "not_match"]:
# There's no reasonable thing such as regex antimatch, so we do a
# positive match and invert the result, which makes the two regex
# operations differ only by a complement() which python doesn't have so
# we do it with a lambda.
pattern = re.compile(f[2])
def _helper(x):
v = x.canonicalized_vars().get(f[1], unset)
if v is unset:
return False
else:
return re.match(pattern, v) is not None
if f[0] == "match":
return _helper
else:
return lambda x: not _helper(x)
_filters = [_compile_filter(f) for f in filters]
return lambda x: all(f(x) for f in _filters)
def _hostlist(client, opts, key=lambda h: h.name):
"""Given the shared host/group machinery, build and return a host list."""
hosts = set()
if not opts.hosts and not opts.groups:
for host in client.hosts():
hosts.add(host)
elif opts.hosts:
for host in opts.hosts:
h = client.host(host)
if h is not None:
hosts.add(h)
elif opts.groups:
for group in opts.groups:
g = client.group(group)
if g is not None:
for h in g.hosts():
hosts.add(h)
hosts = list(filter(_compile_filters(opts.var_filters), hosts))
return sorted(hosts, key=key)
class ExtendAction(argparse.Action):
def __call__(self, parser, namespace, values, option_string=None):
items = getattr(namespace, self.dest) or []
items.extend(values)
setattr(namespace, self.dest, items)
class CommandRegistry(object):
def __init__(self):
self._registry = {}
def __call__(self, *args, **kwargs):
return self.register(*args, **kwargs)
def register(self, command_tuple):
"""Intended for use as a decorator.
Registers a function into the registry as a command."""
def _wrapper(func):
self._registry[command_tuple] = func
return func
return _wrapper
def dispatch(self, command_tuple):
"""Look up the longest prefix match, returning its function."""
match, val = None, None
for _tuple, func in self._registry.items():
if command_tuple >= _tuple and all(
a == b for a, b in zip(command_tuple, _tuple)
):
if val and len(_tuple) > len(match):
match, val = _tuple, func
elif match is None:
match, val = _tuple, func
return match, val
def __iter__(self):
return iter(self._registry.items())
REGISTRY = CommandRegistry()
register_command = REGISTRY.__call__
CMD_COLOR = 2
TEMPLATE_ENVIRONMENT = jinja2.Environment()
TEMPLATE_ENVIRONMENT.filters['datetime'] = lambda o: datetime.fromtimestamp(int(o))
TEMPLATE_ENVIRONMENT.filters['rpad'] = lambda s, width: str(s) + " " * max(0, width - len(str(s)))
TEMPLATE_ENVIRONMENT.filters['lpad'] = lambda s, width: " " * max(0, width - len(str(s))) + str(s)
LIST_TEMPLATE = """{{host_name | rpad(32)}} {{ geo | rpad(9)}} {{rack | rpad(9)}} {{host_reported_address | rpad(15)}} {{host_checkin | default(-1) | datetime}} ({{host_checkin | default(-1)}})"""
@register_command(("format",))
def cli_format(client: KookClient, opts, args: Iterable[str]):
"""Usage: kook format
Wipe all Kook data out of Zookeeper.
"""
print(f"Deleted {client.config.group_prefix}")
if opts.execute:
client.delete(client.config.group_prefix, recursive=True)
print(f"Deleted {client.config.host_prefix}")
if opts.execute:
client.delete(client.config.host_prefix, recursive=True)
def set_in(dict, path, blob):
if len(path) == 1:
dict[path[0]] = blob
return blob
elif len(path) > 1:
k = path[0]
if k not in dict:
dict[k] = {}
return set_in(dict[k], path[1:], blob)
@register_command(("dump",))
def cli_dump(client: KookClient, opts, args: Iterable[str]):
"""Usage: kook dump
Create a JSON archive of all durable host and group datat which could be
loaded with the `kook load` command.
"""
groups = {}
for g in client.groups():
group_json, _stat = g.fetch()
groups[g.name] = group_json
hosts = {}
for h in client.hosts():
host_json, _stat = h.fetch()
hosts[h.name] = host_json
meta = {}
blob = {"__meta__": meta}
for type, k, data in [('groups', client.config.group_prefix, groups),
('hosts', client.config.host_prefix, hosts)]:
meta[type] = k
blob[k] = data
json.dump(blob, sys.stdout, indent=2, sort_keys=True)
@register_command(("load",))
def cli_load(client: KookClient, opts, args: Iterable[str]):
"""Usage: kook load [--execute]
Format the store, read a JSON archive from stdin and backfill the store from
the JSON archive.
"""
blob = json.load(sys.stdin)
assert "__meta__" in blob, "Sanity check failed, no __meta__ header present!"
assert isinstance(blob["__meta__"], dict), "Sanity check failed, header was not a dict!"
assert "groups" in blob["__meta__"], "Sanity check failed, no 'groups' source specified in the header!"
assert blob["__meta__"]["groups"] in blob, "Sanity check failed, specified 'groups' source is absent!"
assert isinstance(blob[blob["__meta__"]["groups"]], dict), "Sanity check failed, specified 'groups' source was not a dict!"
assert "hosts" in blob["__meta__"], "Sanity check failed, no 'hosts' source specified in the header!"
assert blob["__meta__"]["hosts"] in blob, "Sanity check failed, specified 'hosts' source is absent!"
assert isinstance(blob[blob["__meta__"]["hosts"]], dict), "Sanity check failed, specified 'hosts' source was not a dict!"
cli_format(client, opts, args) # Destroy any state currently in Kook
# Insert all groups first
for group_name, group_blob in blob[blob["__meta__"]["groups"]].items():
k, v = f"{client.config.group_prefix}/{group_name}", json.dumps(group_blob).encode("utf-8")
print(f"Set {k} => {v}")
if opts.execute:
client.create(k, v, makepath=True)
# Insert all groups first
for host_name, host_blob in blob[blob["__meta__"]["hosts"]].items():
k, v = f"{client.config.host_prefix}/{host_name}", json.dumps(host_blob).encode("utf-8")
print(f"Set {k} => {v}")
if opts.execute:
client.create(k, v, makepath=True)
@register_command(("run",))
def run(client: KookClient, opts, args: Iterable[str]):
"""Usage: kook [-H <hostname> | -g <group>]+ run [-u username] [cmd ...]
Run a command on all selected hosts, printing output.
"""
reset = attr("reset")
for h in _hostlist(client, opts):
with lock(h.lock):
cmd = [
"ssh",
"-o",
"ConnectTimeout=5",
f"{opts.user}@{h.vars().get('host_reported_address')}",
*args,
]
print(fg(CMD_COLOR) + f"exec] {h.name}] " + " ".join(cmd), reset, file=sys.stderr)
res = call(cmd)
print()
@register_command(("reboot",))
def reboot(client: KookClient, opts, args: Iterable[str]):
"""Usage: kook [-H <hostname> | -g <group>]+ reboot
Power cycle all the host(s), maintaining the host lock(s) to prevent other automation intervening.
"""
reset = attr("reset")
for h in _hostlist(client, opts):
with lock(h.lock):
prefix = [
"ssh",
"-o",
"ConnectTimeout=3",
f"{opts.user}@{h.vars().get('host_reported_address')}",
]
rbt = prefix + ["reboot"]
upt = prefix + ["uptime"]
print(fg(CMD_COLOR) + "exec] " + " ".join(rbt), reset, file=sys.stderr)
call(rbt)
while True:
sleep(5)
if call(upt) == 0:
print()
break
########################################
# Host CRUD
@register_command(("host", "create"))
def cli_host_create(client: KookClient, opts, args: Iterable[str]):
"""Usage: kook host create <hostname>
Enter a host into the Kook service with no key/value pairs.
Attributes such as `ansible_host` and groups must be added separately.
"""
try:
hostname, *_ = args
except ValueError:
return
h = client.create_host(hostname)
print("Created host", h, file=sys.stderr)
json.dump({"_created": [hostname]}, sys.stdout, indent=2, sort_keys=True)
@register_command(("host", "delete"))
def cli_host_delete(client: KookClient, opts, args: Iterable[str]):
"""Usage: kook host delete [<hostname>]+
Delete host(s) out of the Kook service.
"""
hosts = []
for hostname in args:
h = client.host(hostname)
if h is not None:
print("Deleting host", h, file=sys.stderr)
h.delete()
hosts.append(hostname)
json.dump({"_deleted": hosts}, sys.stdout, indent=2, sort_keys=True)
@register_command(("host", "list"))
def list_hosts(client: KookClient, opts, args: Iterable[str]):
"""Usage: kook [-g <group>] host list [--format FMT]
If -g is provided, list hosts in the specified groups.
Otherwise, list all hosts.
"""
def _key(h):
vars = h.canonicalized_vars()
return (vars.get("rack", ""), vars.get("host_address", ""))
hosts = _hostlist(client, opts, key=_key)
if opts.format:
t = TEMPLATE_ENVIRONMENT.from_string(opts.format)
for h in hosts:
vars = h.canonicalized_vars()
#print(repr(vars))
print(t.render(**vars))
else:
json.dump(
{"all": [h.name for h in hosts]}, sys.stdout, indent=2, sort_keys=True
)
@register_command(("host", "details"))
def cli_host_details(client: KookClient, opts, args: Iterable[str]):
"""Usage: kook -H <hostname> host details
Print host details.
"""
json.dump(
{
host.name: host.canonicalized_vars(meta=opts.meta)
for host in _hostlist(client, opts)
},
sys.stdout,
indent=2,
sort_keys=True,
)
########################################
# Group CRUD
@register_command(("group", "create"))
def cli_group_create(client: KookClient, opts, args: Iterable[str]):
"""Usage: kook group create [<group>]+
Create the specified group (if a group of the same name does not exist).
"""
groups = []
for group in args:
g = client.create_group(group)
print("Created group", g, file=sys.stderr)
groups.append(group)
json.dump({"_created": groups}, sys.stdout, indent=2, sort_keys=True)
@register_command(("group", "add"))
def cli_group_add(client: KookClient, opts, args: Iterable[str]):
"""Usage: kook [-H <hostname> | -g <group>]+ group add <group2>
Adds all specified hosts and groups to the named group - here <group2>.
"""
try:
group, *_ = args
except ValueError:
print("Usage: group add <group>", file=sys.stderr)
return
group_to_add = client.create_group(group)
for group in opts.groups:
g = client.group(group)
if g:
g.add_group(group_to_add)
print("Added group to group", g, file=sys.stderr)
else:
print("No such group", group, file=sys.stderr)
for host in opts.hosts:
h = client.host(host)
if f:
h.add_group(group_to_add)
print("Added group to host", h, file=sys.stderr)
else:
print("No such host", host, file=sys.stderr)
@register_command(("group", "remove"))
def cli_group_group(client: KookClient, opts, args: Iterable[str]):
"""Usage: kook [-H <hostname> | -g <group>]+ group remove <group2>
Removes all specified hosts and groups from the named group - here <group2>.
"""
try:
group, *_ = args
except ValueError:
print("Usage: group add <group>", file=sys.stderr)
return
group_to_remove = client.group(group)
if not group_to_remove:
return
for group in opts.groups:
g = client.group(group)
if g:
g.remove_group(group_to_remove)
print(f"Removed {group_to_remove} from {g}", file=sys.stderr)
else:
print("No such group", group, file=sys.stderr)
for host in opts.hosts:
h = client.host(host)
if f:
h.remove_group(group_to_remove)
print("Removed host from group", h, file=sys.stderr)
else:
print("No such host", host, file=sys.stderr)
@register_command(("group", "list"))
def cli_group_list(client: KookClient, opts, args: Iterable[str]):
"""Usage: kook group list
Produce a list of all groups.
"""
data = {"all": [g.name for g in client.groups()]}
json.dump(data, sys.stdout, indent=2, sort_keys=True)
@register_command(("group", "details"))
def cli_group_detail(client: KookClient, opts, args: Iterable[str]):
"""Usage: kook [-g <group>]+ group details
Print details (vars) for each named group.
"""
data = {}
for groupname in opts.groups:
group = client.group(groupname)
if group:
data[groupname] = v = group.canonicalized_vars(meta=opts.meta)
v["_hostvars"] = group.host_vars()
else:
print("Unable to locate group", group, file=sys.stderr)
data[groupname] = None
json.dump(data, sys.stdout, indent=2, sort_keys=True)
@register_command(("group", "delete"))
def cli_group_delete(client: KookClient, opts, args: Iterable[str]):
"""Usage: kook group delete [<group>]+
Delete the named group(s).
"""
groups = []
for group in args:
g = client.group(group)
if g is not None:
g.delete()
print("Deleted group", g, file=sys.stderr)
groups.append(group)
json.dump({"_deleted": groups}, sys.stdout, indent=2, sort_keys=True)
########################################
# Attribute CRUD
@register_command(("var", "create"))
def cli_var_add(client: KookClient, opts, args: Iterable[str]):
"""Usage: kook [-H <hostname> | -g <group>]+ var add <var> <value>
Add a var to groups and hosts.
"""
try:
key, value = args
except ValueError:
print("Usage: var create <key> <value>", file=sys.stderr)
return
for group in opts.groups:
g = client.group(group)
if g:
g.set_var(key, value)
print("Set var on group", g, file=sys.stderr)
else:
print("No such group", group, file=sys.stderr)
for host in opts.hosts:
h = client.host(host)
if h:
h.set_var(key, value)
print("Set var on host", h, file=sys.stderr)
else:
print("No such host", host, file=sys.stderr)
@register_command(("var", "delete"))
def cli_var_remove(client: KookClient, opts, args: Iterable[str]):
"""Usage: kook [-H <hostname> | -g <group>]+ var remove <var>
Delete a var from provided group(s) and host(s).
"""
try:
var, *_ = args
except ValueError:
print("Usage: var remove <var>")
return
for group in opts.groups:
g = client.group(group)
if g:
g.del_var(var)
print("Removed var on group", g, file=sys.stderr)
else:
print("No such group", group, file=sys.stderr)
for host in opts.hosts:
h = client.host(host)
if h:
h.del_var(var)
print("Removed var on host", h, file=sys.stderr)
else:
print("No such host", host, file=sys.stderr)
########################################
# And now for argparsing...
PARSER = argparse.ArgumentParser("kook")
PARSER.register("action", "extend", ExtendAction)
PARSER.add_argument(
"-b",
"--bootstrap-server",
help="host:port at which to connect to zookeeper, overriding the config file(s)",
)
PARSER.add_argument(
"-u", "--user", dest="user", help="User to run as", default=getpass.getuser()
)
PARSER.add_argument(
"--format",
metavar="FORMAT",
dest="format",
default=LIST_TEMPLATE,
)
PARSER.add_argument(
"-1",
action="store_const",
const="{{host_name}}",
dest="format",
)
PARSER.add_argument(
"--execute",
dest="execute",
action="store_true",
default=False,
help="Execute changes instead of dryrunning them"
)
# --hosts and --groups are mutually exclusive
HG = PARSER.add_mutually_exclusive_group()
HG.add_argument(
"-H",
"--hosts",
help="Comma joined list of hostnames on which to operate. Many may be provided.",
# Lower, split, trim whitespace, and reduce to the first name segment
type=lambda s: [e.strip() for e in s.lower().split(",")],
action="extend",
dest="hosts",
default=[],
)
HG.add_argument(
"-g",
"--groups",
help="Comma joined list of group(s) on which to operate",
type=lambda s: [e.strip() for e in s.split(",")],
action="extend",
dest="groups",
default=[],
)
PARSER.add_argument(
"-v",
"--var",
help="Filters to applied to hosts. Either <var>:<regexp> for positive match or !<var> for unset or !<var>:<regexp> for antimatch.",
dest="var_filters",
type=_var_filter_ctor,
action="append",
default=[],
)
# --synchronization and --no-synchronization are mutually exclusive
SG = PARSER.add_mutually_exclusive_group()
SG.add_argument(
"--synchronization",
dest="use_synchronization",
default=True,
action="store_true",
help="Enable synchronization reading Kook data",
)
SG.add_argument(
"--no-synchronization",
dest="use_synchronization",
action="store_false",
help="DISABLE synchronization reading Kook data",
)
META = PARSER.add_mutually_exclusive_group()
META.add_argument(
"--meta", dest="meta", action="store_true", help="Enable metadata in details"
)
META.add_argument(
"--no-meta",
dest="meta",
default=False,
action="store_false",
help="DISABLE metadata in details",
)
PARSER.add_argument("subcommand", nargs="+", help="The subcommand to execute.")
def indent(str, width):
lines = str.strip().split("\n")
lines = [(" " * width) + line.strip() for line in lines]
return "\n".join(lines)
PARSER.usage = "\n".join(
[
"\n kook [-b BOOSTRAP_SERVER = zookeeper:2181] \\\n"
" [-H <hostname>]* \\\n"
" [-g <group>]* \\\n"
" [--[no-]synchronization] \\\n"
" [--[no-]meta] \\\n"
" <subcommand> & args\n"
]
+ ["subcommands:\n" "--------------------------------------------------"]
+ [
"\n "
+ " ".join(cmd)
+ "\n"
+ " --------------------\n"
+ indent(f.__doc__ or f.__name__, 4)
for cmd, f in iter(REGISTRY)
]
)
########################################
# And now for main...
if __name__ == "__main__":
opts = PARSER.parse_args(sys.argv[1:])
command = tuple(opts.subcommand)
client = KookClient(
hosts=opts.bootstrap_server, use_synchronization=opts.use_synchronization
)
match, f = REGISTRY.dispatch(command)
if not opts.execute:
print("Running in dryrun mode, no changes will be committed", file=sys.stderr)
if f is not None:
f(client, opts, command[len(match) :])
else:
print(PARSER.format_usage())