From 84dd1af404a948fc4adf5b44a70317eb2d7b04a5 Mon Sep 17 00:00:00 2001 From: Lars Wirzenius Date: Sat, 30 Jun 2018 13:24:30 +0300 Subject: Change: name hetznertool2 to hetznertool We don't need the old one anymore. --- hetznertool | 776 +++++++++++++++++++++++++++++++++++++---------------------- hetznertool2 | 573 ------------------------------------------- 2 files changed, 494 insertions(+), 855 deletions(-) delete mode 100755 hetznertool2 diff --git a/hetznertool b/hetznertool index 806bbf5..8b22cb5 100755 --- a/hetznertool +++ b/hetznertool @@ -1,361 +1,573 @@ #!/usr/bin/env python3 + import argparse import copy import os import subprocess +import sys +import tempfile import yaml -CONFIG_DIR = os.path.expanduser('~/.config/hetznertool') -DEFAULT_PROFILE = 'hetznertool' +CONFIG_FILENAME = os.path.expanduser('~/.config/hetznertool/hetznertool2.yaml') -default_config = { - 'ssh-key': None, - 'ns1': 'root@ns1.qvarnlabs.net', +expect_script='''\ +if {[llength $argv] != 2} { + puts stderr "Usage: $argv0 name token + exit 2 } +lassign $argv name token -def main(): - profile = os.environ.get('HETZNERTOOL_PROFILE', DEFAULT_PROFILE) - config = read_config(profile) - parser = create_parser(config) - args = vars(parser.parse_args()) - func = args['func'] - func(config, args) +log_user 0 +spawn hcloud context create $name +expect { + "Token:" { + send "$token\n" + exp_continue + } + timeout {close} +} +lassign [wait] wait_pid spawn_id exec_rc wait_code childkilled +# couldn't exec pesign +if {$exec_rc != 0} { + exit 1 +} +# killed by signal (e.g. timeout) +if {$childkilled == "CHILDKILLED"} { + exit 1 +} +# all good? +exit $wait_code +''' -def hcloud(*args): - argv = ['hcloud'] + list(args) - output = subprocess.check_output(argv) - return output.decode('UTF-8').splitlines() +class HetznerTool: + def run(self): + try: + self.main() + except Exception as e: + sys.stderr.write('ERROR: {}\n'.format(str(e))) -def use_context(context): - hcloud('context', 'use', context) + def main(self): + config = self.create_config() + parser = Parser(config) + parser.execute() + def create_config(self): + config = Config() + config.add('ssh-key') + config.add('pass-dir') + config.add('pass-subdir') + config.add('ns1') + config.add('domain') + config.add('dnszone-dir') + config.add('dnszone-file') + config.add('ansible-inventory-dir') + config.add('style') -def list_contexts(): - lines = hcloud('context', 'list') - assert len(lines) > 0 - assert lines[0] == "NAME" - del lines[0] # This is just the title line, says "NAME" - return lines + config.load(CONFIG_FILENAME) + return config -def list_servers(): - lines = hcloud('server', 'list') - assert len(lines) > 0 - del lines[0] # This is just the title line - return [parse_server_line(line) for line in lines] +class Config: + def __init__(self): + self.config_vars = [] + self.profiles = {} -def parse_server_line(line): - words = line.split() - return { - 'name': words[1], - 'ipv4': words[3], - } + def dump(self): + for profile in self.profiles: + for name in self.profiles[profile]: + print(profile, name, repr(self.get(profile, name))) + def load(self, filename): + with open(filename) as f: + obj = yaml.load(f) + for profile in obj: + for name in self.config_vars: + if name not in obj[profile]: + raise Exception( + 'Profile {} is missing variable {}'.format( + profile, name)) + for name in obj[profile]: + if name not in self.config_vars: + raise Exception( + 'Profile {} has extra variable {}'.format( + profile, name)) + self.set(profile, name, obj[profile][name]) -def dns_name(context, name, domain): - return '{}-{}.{}'.format(context, name, domain) + def add(self, name): + assert name not in self.config_vars + self.config_vars.append(name) + def get(self, profile, name): + return self.profiles.get(profile).get(name, '') -class ServerSpecification(dict): + def get_profile(self, profile): + return { + name: self.get(profile, name) + for name in self.config_vars + } - def from_dict(self, as_dict): - self.clear() - self.update(copy.deepcopy(as_dict)) + def set(self, profile, name, value): + if profile not in self.profiles: + self.profiles[profile] = {} + self.profiles[profile][name] = value - def from_yaml(self, stream): - self.from_dict(yaml.safe_load(stream)) - def from_file(self, filename): - with open(filename) as f: - self.from_yaml(f) +class Parser: - def _hosts(self): - return self.get('hosts', []) + def __init__(self, config): + self.config = config - def _defaults(self): - return copy.deepcopy(self.get('defaults', {})) + def execute(self): + parser = self.create_parser() + args = vars(parser.parse_args()) + profile = self.config.get_profile(args['profile']) + profile.update(args) + func = args['func'] + func(profile) - def _get(self, name): - hosts = self._hosts() - for host in hosts: - if host.get('name') == name: - return copy.deepcopy(host) + def create_parser(self): + parser = argparse.ArgumentParser( + description='Manage VMs in Hetzner Cloud, with DNS updates.') - def get_servers(self): - return [host['name'] for host in self._hosts()] + parser.add_argument( + '-n', '--no-act', dest='act', default=True, action='store_false') + parser.add_argument( + '-p', '--profile', dest='profile', required=True) - def get_server(self, name): - server = self._defaults() - server.update(self._get(name)) - return server - + factory = parser.add_subparsers() + + create = factory.add_parser('create') + create.add_argument( + 'context', help='hcloud context to create VMs in') + create.add_argument( + 'specfile', help='file to read VM specifications from') + servers = factory.add_parser('list') -def create_func(config, args): - spec = ServerSpecification() - spec.from_file(args['specfile']) + contexts = factory.add_parser('contexts') - context = args['context'] - use_context(context) + zonefile = factory.add_parser('zonefile') - for name in spec.get_servers(): - server = spec.get_server(name) - print('creating {} in {}'.format(name, context)) - if args['act']: - hcloud( - 'server', 'create', - '--name', name, - '--image', server['image'], - '--type', server['type'], - '--ssh-key', args['ssh_key'], - ) - if args['act']: - zonedir = get_zonedir_for_context(config, context) - zonefile = get_zonefile_for_context(config, context) - kick = get_kick_for_context(config, context) - domain = get_domain_for_context(config, context) - style = get_style_for_context(config, context) - update_zone_file(style, domain, kick, args, zonedir, zonefile) - write_inventory_files(config) + create_context = factory.add_parser('create-context') + create_context.add_argument( + 'context', help='create new hcloud context') + create_context.add_argument( + 'token', help='token for hcloud context') + delete = factory.add_parser('delete') + delete.add_argument( + 'context', help='hcloud context to delete all VMs from') -def list_func(config, args): - contexts = list_contexts() - for context in contexts: - use_context(context) - domain = get_domain_for_context(config, context) - assert domain is not None - for info in list_servers(): - name = dns_name(context, info['name'], domain) - print(name, info['ipv4']) + parser.set_defaults(func=self.usage) + servers.set_defaults(func=List().run) + create.set_defaults(func=Create().run) + delete.set_defaults(func=Delete().run) + contexts.set_defaults(func=ListContexts().run) + create_context.set_defaults(func=CreateContext().run) + zonefile.set_defaults(func=Zonefile().run) + return parser -def get_config_for_context(config, context): - return config.get('contexts', {}).get(context, {}) + def usage(self, args): + raise Exception('Missing operation') -def get_domain_for_context(config, context): - cc = get_config_for_context(config, context) - return cc.get('domain') +class Zone: + def __init__(self, domain): + self.domain = domain + self.servers = [] -def get_zonedir_for_context(config, context): - cc = get_config_for_context(config, context) - return cc.get('dnszone-dir') + def servername(self, context, server): + return '{}-{}'.format(context, server) + def dnsname(self, context, server): + return '{}.{}'.format(self.servername(context, server), self.domain) -def get_zonefile_for_context(config, context): - cc = get_config_for_context(config, context) - return cc.get('dnszone-file') + def add(self, context, server, addr): + self.servers.append((context, server, addr)) + def __iter__(self): + return iter(self.servers) -def get_inventorydir_for_context(config, context): - cc = get_config_for_context(config, context) - return cc.get('ansible-inventory-dir') +class ZoneWriter: -def get_kick_for_context(config, context): - cc = get_config_for_context(config, context) - return cc.get('kick_bind9') + def __init__(self, zonedir, zonefile, ns1): + self.zonedir = zonedir + self.zonefile = zonefile + self.ns1 = ns1 + def write(self, zone): + git = Git(self.zonedir) + if not git.isclean(): + raise Exception('git dir unclean: {}'.format(self.zonedir)) -def get_style_for_context(config, context): - cc = get_config_for_context(config, context) - return cc.get('style') + git.pull() + pathname = os.path.join(self.zonedir, self.zonefile) + with open(pathname, 'w') as f: + self.write_zone(zone, f) -def get_inventory_style_for_context(config, context): - cc = get_config_for_context(config, context) - return cc.get('inventory-style') + if not git.isclean(): + git.add('.') + git.commit('update zone file') + git.push() + def write_zone(self, zone, f): + raise NotImplementedError() -def delete_func(config, args): - context = args['context'] - use_context(context) - domain = get_domain_for_context(config, context) - for info in list_servers(): - name = dns_name(args['context'], info['name'], domain) - print( - 'deleting {} ({} in {})'.format( - name, info['name'], args['context'])) - if args['act']: - hcloud('server', 'delete', info['name']) - if args['act']: - zonedir = get_zonedir_for_context(config, context) - zonefile = get_zonefile_for_context(config, context) - kick = get_kick_for_context(config, context) - style = get_style_for_context(config, context) - update_zone_file(style, domain, kick, args, zonedir, zonefile) - write_inventory_files(config) +class ZoneWriterDnsApi(ZoneWriter): + def write_zone(self, zone, f): + for context, server, addr in zone: + name = zone.dnsname(context, server) + f.write('+{}:{}:60\n'.format(name, addr)) -def update_zone_file(style, domain, kick, args, dirname, basename): - filename = os.path.join(dirname, basename) - serial_name = os.path.join(dirname, 'serial') - print('Updating zone file {}'.format(filename)) +class ZoneWriterBind9(ZoneWriter): - subprocess.check_call(['git', 'pull'], cwd=dirname) + preamble = '''\ +$TTL 30 +$ORIGIN h.qvarnlabs.eu. - filenames = [basename] +@ IN SOA ns1.qvarnlabs.net. ops.qvarnlabs.com ({serial} 30 30 8640000 15 ) +@ IN NS ns1.qvarnlabs.net. +@ IN NS ns2.qvarnlabs.net. - if style == 'qvarnlabs': - serial = int(open(serial_name).readline().strip()) +''' + + def write_zone(self, zone, f): + serial = self.increment_serial() + f.write(self.preamble.format(serial=serial)) + for context, server, addr in zone: + name = zone.servername(context, server) + f.write('{} IN A {}\n'.format(name, addr)) + self.kick_bind9() + + def increment_serial(self): + filename = os.path.join(self.zonedir, 'serial') + with open(filename) as f: + serial = int(f.readline().strip()) serial += 1 - open(serial_name, 'w').write('{}\n'.format(serial)) with open(filename, 'w') as f: - write_qvarnlabs_zone(f, serial) - filenames.append(serial_name) - elif style == 'dns-api': - with open(filename, 'w') as f: - write_dnsapi_zone(f, domain) - else: - assert 0 + f.write(str(serial)) + return serial - subprocess.call( - ['git', 'commit', '-m', 'automatic zone update'] + filenames, - cwd=dirname) + def kick_bind9(self): + target = '{}:/etc/bind/{}'.format(self.ns1, self.zonefile) + filename = os.path.join(self.zonedir, self.zonefile) + subprocess.check_call(['scp', '-q', filename, target]) + subprocess.check_call(['ssh', self.ns1, 'systemctl', 'reload', 'bind9']) - subprocess.check_call(['git', 'push'], cwd=dirname) - if kick: - kick_bind9(args['ns1'], filename, basename) +class Inventory: + def __init__(self, dirname): + self.dirname = dirname -def write_qvarnlabs_zone(stream, serial): - stream.write(''' -$TTL 30 -$ORIGIN h.qvarnlabs.eu. + def write(self, zone, contexts): + for context in contexts: + filename = os.path.join(self.dirname, 'hosts.{}'.format(context)) + with open(filename, 'w') as f: + servers = [ + server + for ctx, server, _ in zone + if ctx == context + ] + for server in servers: + f.write('{} ansible_ssh_host={}\n'.format( + server, zone.dnsname(context, server))) -@ IN SOA ns1.qvarnlabs.net. ops.qvarnlabs.com ( - {} 30 30 8640000 15 ) -@ IN NS ns1.qvarnlabs.net. -@ IN NS ns2.qvarnlabs.net. +class Operation: + + def __init__(self): + self.hcloud = Hcloud() + + def run(self, profile): + raise NotImplementedError() + + def new_pass(self, profile): + return Pass(profile['pass-dir']) + + def create_hcloud_contexts(self, profile): + p = self.new_pass(profile) + pass_subdir = profile['pass-subdir'] + pass_contexts = p.listdir(pass_subdir) + hcloud_contexts = self.hcloud.list_contexts() + for context in pass_contexts: + if context not in hcloud_contexts: + token = p.get_token(pass_subdir, context) + self.hcloud.create_context(context, token) + + def update_zone_file(self, profile): + zonedir = profile['dnszone-dir'] + filename = profile['dnszone-file'] + ns1 = profile['ns1'] + + zone, contexts = self.create_zone(profile) + klass = self.get_zone_writer(profile) + writer = klass(zonedir, filename, ns1) + writer.write(zone) + + self.write_inventory(profile, zone, contexts) + + def create_zone(self, profile): + zone = Zone(profile['domain']) + + p = self.new_pass(profile) + pass_contexts = p.listdir(profile['pass-subdir']) + for context in pass_contexts: + for server, addr in self.hcloud.list_servers(context): + zone.add(context, server, addr) + + return zone, pass_contexts + + def get_zone_writer(self, profile): + styles = { + 'dns-api': ZoneWriterDnsApi, + 'qvarnlabs': ZoneWriterBind9, + } + + style = profile['style'] + if style not in styles: + raise Exception('unknown zone file style: {}'.format(style)) + return styles[style] + + def write_inventory(self, profile, zone, contexts): + inventory = Inventory(profile['ansible-inventory-dir']) + inventory.write(zone, contexts) + + +class Zonefile(Operation): + + def run(self, profile): + self.update_zone_file(profile) + + +class List(Operation): + + def run(self, profile): + self.create_hcloud_contexts(profile) + + p = self.new_pass(profile) + pass_contexts = p.listdir(profile['pass-subdir']) + domain = profile['domain'] + zone = Zone(domain) + for context in pass_contexts: + for server, addr in self.hcloud.list_servers(context): + print(zone.dnsname(context, server), addr) + + +class Create(Operation): + + def run(self, profile): + if profile['act']: + self.create_hcloud_contexts(profile) + + ssh_key = profile['ssh-key'] + context = profile['context'] + specfile = SpecFile(profile['specfile']) + zone = Zone(profile['domain']) + for server in specfile.servers(): + if profile['act']: + self.hcloud.create_server(context, ssh_key=ssh_key, **server) + print('created', zone.dnsname(context, server['name'])) + else: + print('pretending to create', zone.dnsname(context, server['name'])) + + if profile['act']: + self.update_zone_file(profile) + -'''.format(serial)) - - for context in list_contexts(): - use_context(context) - for info in list_servers(): - stream.write( - '{}-{} IN A {}\n'.format(context, info['name'], info['ipv4'])) - - -def write_dnsapi_zone(stream, domain): - for context in list_contexts(): - use_context(context) - for info in list_servers(): - stream.write( - '+{}-{}.{}:{}:60\n'.format( - context, info['name'], domain, info['ipv4'])) - - -def write_inventory_files(config): - for context in list_contexts(): - style = get_inventory_style_for_context(config, context) - if style == 'ipv4': - write_inventory_files_with_ipv4(config, context) - elif style == 'dns': - write_inventory_files_with_dns_names(config, context) - else: - assert 0 - - -def write_inventory_files_with_ipv4(config, context): - use_context(context) - dirname = get_inventorydir_for_context(config, context) - filename = os.path.join(dirname, 'hosts.{}'.format(context)) - print('Writing Ansible inventory file {}'.format(filename)) - with open(filename, 'w') as f: - for info in list_servers(): - f.write( - '{} ansible_ssh_host={}\n'.format( - info['name'], info['ipv4'])) - - -def write_inventory_files_with_dns_names(config, context): - domain = get_domain_for_context(config, context) - use_context(context) - dirname = get_inventorydir_for_context(config, context) - filename = os.path.join(dirname, 'hosts.{}'.format(context)) - print('Writing Ansible inventory file {}'.format(filename)) - with open(filename, 'w') as f: - for info in list_servers(): - name = dns_name(context, info['name'], domain) - f.write( - '{} ansible_ssh_host={}\n'.format(info['name'], name)) - - -def kick_bind9(ssh_target, filename, basename): - target = '{}:/etc/bind/{}'.format(ssh_target, basename) - subprocess.check_call(['scp', filename, target]) - subprocess.check_call(['ssh', ssh_target, 'systemctl', 'reload', 'bind9']) - - - -def read_config(profile): - config = copy.deepcopy(default_config) - filename = os.path.join(CONFIG_DIR, '{}.yaml'.format(profile)) - if os.path.exists(filename): +class Delete(Operation): + + def run(self, profile): + if profile['act']: + self.create_hcloud_contexts(profile) + + ssh_key = profile['ssh-key'] + context = profile['context'] + zone = Zone(profile['domain']) + for server, _ in self.hcloud.list_servers(context): + if profile['act']: + print('deleting', zone.dnsname(context, server)) + self.hcloud.delete_server(context, server) + else: + print('pretend-deleting', zone.dnsname(context, server)) + + if profile['act']: + self.update_zone_file(profile) + + +class ListContexts(Operation): + + def run(self, profile): + p = self.new_pass(profile) + pass_subdir = profile['pass-subdir'] + for context in p.listdir(pass_subdir): + print(context) + + +class CreateContext(Operation): + + def run(self, profile): + context = profile['context'] + token = profile['token'] + pass_dir = profile['pass-dir'] + pass_subdir = profile['pass-subdir'] + + git = Git(pass_dir) + if not git.isclean(): + raise Exception('pass word store is not clean') + git.pull() + + p = self.new_pass(profile) + p.create(pass_subdir, context, token) + + git.push() + + self.hcloud.create_context(context, token) + + +class Hcloud: + + def run(self, *args): + argv = ['hcloud'] + list(args) + output = subprocess.check_output(argv) + return output.decode('UTF-8').splitlines() + + def list_contexts(self): + lines = self.run('context', 'list') + return sorted(lines[1:]) + + def use_context(self, context): + self.run('context', 'use', context) + + def list_servers(self, context): + self.use_context(context) + servers = [] + lines = self.run('server', 'list') + for line in lines[1:]: + words = line.split() + servers.append((words[1], words[3])) + return servers + + def create_server( + self, context, name, image=None, ssh_key=None, server_type=None): + assert image is not None + assert ssh_key is not None + assert server_type is not None + self.use_context(context) + self.run( + 'server', 'create', + '--name', name, + '--ssh-key', ssh_key, + '--image', image, + '--type', server_type, + ) + + def delete_server(self, context, name): + self.use_context(context) + self.run('server', 'delete', name) + + def create_context(self, name, token): + fd, filename = tempfile.mkstemp() + os.write(fd, expect_script.encode('UTF-8')) + os.close(fd) + + argv = ['expect', '-f', filename, name, token] + subprocess.check_output(argv) + + +class Pass: + + def __init__(self, pwdir): + self.env = dict(os.environ) + self.env['PASSWORD_STORE_DIR'] = pwdir + + def run(self, *args, **kwargs): + argv = ['pass'] + list(args) + return subprocess.check_output(argv, env=self.env, **kwargs) + + def run_lines(self, *args): + text = self.run(*args) + return text.decode('UTF-8').splitlines() + + def listdir(self, dirname): + names = [] + for line in self.run_lines('show', dirname): + words = line.split() + if len(words) == 2: + names.append(words[1]) + return sorted(names) + + def get_token(self, dirname, basename): + bin = self.run('show', '{}/{}'.format(dirname, basename)) + return bin.decode('UTF-8').strip() + + def create(self, dirname, basename, value): + fd, filename = tempfile.mkstemp() + os.remove(filename) + os.write(fd, value.encode('UTF-8')) + os.lseek(fd, 0, os.SEEK_SET) + + pathname = os.path.join(dirname, basename) + self.run('insert', '-m', pathname, stdin=fd) + + os.close(fd) + + +class Git: + + def __init__(self, dirname): + self.dirname = dirname + + def run(self, *args, **kwargs): + argv = ['git'] + list(args) + return subprocess.check_output(argv, cwd=self.dirname, **kwargs) + + def isclean(self): + bin = self.run('status', '--porcelain') + return bin == b'' + + def add(self, filename): + self.run('add', filename) + + def commit(self, msg): + self.run('commit', '-m', msg, '-q') + + def pull(self): + self.run('pull', '-q') + + def push(self): + self.run('push', '-q') + + +class SpecFile: + + def __init__(self, filename): with open(filename) as f: - config.update(yaml.safe_load(f)) - return config - - -def create_parser(config): - parser = argparse.ArgumentParser( - description='Manage VMs in Hetzner Cloud for QvarnLabs.') - - factory = parser.add_subparsers() - - create = factory.add_parser('create') - create.add_argument( - '-n', '--no-act', dest='act', default=True, action='store_false') - create.add_argument( - 'context', help='hcloud context to create VMs in') - create.add_argument( - 'specfile', help='file to read VM specifications from') - create.add_argument( - '--ssh-key', - metavar='KEYNAME', - required='ssh-key' not in config, - default=config['ssh-key'], - help='create VM so it allow login via ssh key uploaded as KEYNAME') - create.add_argument( - '--ns1', default=config['ns1'], - required='ns1' not in config, - metavar='USER@ADDRESS', - help='copy zone file to primary DNS server via ssh') - - servers = factory.add_parser('list') - - delete = factory.add_parser('delete') - delete.add_argument( - '-n', '--no-act', dest='act', default=True, action='store_false') - delete.add_argument( - 'context', help='hcloud context to delete all VMs from') - delete.add_argument( - '--ns1', default=config['ns1'], - required='ns1' not in config, - metavar='USER@ADDRESS', - help='copy zone file to primary DNS server via ssh') - - create.set_defaults(func=create_func) - servers.set_defaults(func=list_func) - delete.set_defaults(func=delete_func) - - return parser - - -if __name__ == '__main__': - main() + self.obj = yaml.safe_load(f) + + def servers(self): + servers = [] + for server in self.obj['hosts']: + server = copy.deepcopy(server) + server.update(self.obj['defaults']) + server['server_type'] = server.pop('type') + servers.append(server) + return servers + + +HetznerTool().run() diff --git a/hetznertool2 b/hetznertool2 deleted file mode 100755 index 8b22cb5..0000000 --- a/hetznertool2 +++ /dev/null @@ -1,573 +0,0 @@ -#!/usr/bin/env python3 - - -import argparse -import copy -import os -import subprocess -import sys -import tempfile - -import yaml - - -CONFIG_FILENAME = os.path.expanduser('~/.config/hetznertool/hetznertool2.yaml') - - -expect_script='''\ -if {[llength $argv] != 2} { - puts stderr "Usage: $argv0 name token - exit 2 -} - -lassign $argv name token - -log_user 0 -spawn hcloud context create $name -expect { - "Token:" { - send "$token\n" - exp_continue - } - timeout {close} -} -lassign [wait] wait_pid spawn_id exec_rc wait_code childkilled -# couldn't exec pesign -if {$exec_rc != 0} { - exit 1 -} -# killed by signal (e.g. timeout) -if {$childkilled == "CHILDKILLED"} { - exit 1 -} -# all good? -exit $wait_code -''' - - -class HetznerTool: - - def run(self): - try: - self.main() - except Exception as e: - sys.stderr.write('ERROR: {}\n'.format(str(e))) - - def main(self): - config = self.create_config() - parser = Parser(config) - parser.execute() - - def create_config(self): - config = Config() - config.add('ssh-key') - config.add('pass-dir') - config.add('pass-subdir') - config.add('ns1') - config.add('domain') - config.add('dnszone-dir') - config.add('dnszone-file') - config.add('ansible-inventory-dir') - config.add('style') - - config.load(CONFIG_FILENAME) - return config - - -class Config: - - def __init__(self): - self.config_vars = [] - self.profiles = {} - - def dump(self): - for profile in self.profiles: - for name in self.profiles[profile]: - print(profile, name, repr(self.get(profile, name))) - - def load(self, filename): - with open(filename) as f: - obj = yaml.load(f) - for profile in obj: - for name in self.config_vars: - if name not in obj[profile]: - raise Exception( - 'Profile {} is missing variable {}'.format( - profile, name)) - for name in obj[profile]: - if name not in self.config_vars: - raise Exception( - 'Profile {} has extra variable {}'.format( - profile, name)) - self.set(profile, name, obj[profile][name]) - - def add(self, name): - assert name not in self.config_vars - self.config_vars.append(name) - - def get(self, profile, name): - return self.profiles.get(profile).get(name, '') - - def get_profile(self, profile): - return { - name: self.get(profile, name) - for name in self.config_vars - } - - def set(self, profile, name, value): - if profile not in self.profiles: - self.profiles[profile] = {} - self.profiles[profile][name] = value - - -class Parser: - - def __init__(self, config): - self.config = config - - def execute(self): - parser = self.create_parser() - args = vars(parser.parse_args()) - profile = self.config.get_profile(args['profile']) - profile.update(args) - func = args['func'] - func(profile) - - def create_parser(self): - parser = argparse.ArgumentParser( - description='Manage VMs in Hetzner Cloud, with DNS updates.') - - parser.add_argument( - '-n', '--no-act', dest='act', default=True, action='store_false') - parser.add_argument( - '-p', '--profile', dest='profile', required=True) - - factory = parser.add_subparsers() - - create = factory.add_parser('create') - create.add_argument( - 'context', help='hcloud context to create VMs in') - create.add_argument( - 'specfile', help='file to read VM specifications from') - - servers = factory.add_parser('list') - - contexts = factory.add_parser('contexts') - - zonefile = factory.add_parser('zonefile') - - create_context = factory.add_parser('create-context') - create_context.add_argument( - 'context', help='create new hcloud context') - create_context.add_argument( - 'token', help='token for hcloud context') - - delete = factory.add_parser('delete') - delete.add_argument( - 'context', help='hcloud context to delete all VMs from') - - parser.set_defaults(func=self.usage) - servers.set_defaults(func=List().run) - create.set_defaults(func=Create().run) - delete.set_defaults(func=Delete().run) - contexts.set_defaults(func=ListContexts().run) - create_context.set_defaults(func=CreateContext().run) - zonefile.set_defaults(func=Zonefile().run) - - return parser - - def usage(self, args): - raise Exception('Missing operation') - - -class Zone: - - def __init__(self, domain): - self.domain = domain - self.servers = [] - - def servername(self, context, server): - return '{}-{}'.format(context, server) - - def dnsname(self, context, server): - return '{}.{}'.format(self.servername(context, server), self.domain) - - def add(self, context, server, addr): - self.servers.append((context, server, addr)) - - def __iter__(self): - return iter(self.servers) - - -class ZoneWriter: - - def __init__(self, zonedir, zonefile, ns1): - self.zonedir = zonedir - self.zonefile = zonefile - self.ns1 = ns1 - - def write(self, zone): - git = Git(self.zonedir) - if not git.isclean(): - raise Exception('git dir unclean: {}'.format(self.zonedir)) - - git.pull() - - pathname = os.path.join(self.zonedir, self.zonefile) - with open(pathname, 'w') as f: - self.write_zone(zone, f) - - if not git.isclean(): - git.add('.') - git.commit('update zone file') - git.push() - - def write_zone(self, zone, f): - raise NotImplementedError() - - -class ZoneWriterDnsApi(ZoneWriter): - - def write_zone(self, zone, f): - for context, server, addr in zone: - name = zone.dnsname(context, server) - f.write('+{}:{}:60\n'.format(name, addr)) - - -class ZoneWriterBind9(ZoneWriter): - - preamble = '''\ -$TTL 30 -$ORIGIN h.qvarnlabs.eu. - -@ IN SOA ns1.qvarnlabs.net. ops.qvarnlabs.com ({serial} 30 30 8640000 15 ) -@ IN NS ns1.qvarnlabs.net. -@ IN NS ns2.qvarnlabs.net. - -''' - - def write_zone(self, zone, f): - serial = self.increment_serial() - f.write(self.preamble.format(serial=serial)) - for context, server, addr in zone: - name = zone.servername(context, server) - f.write('{} IN A {}\n'.format(name, addr)) - self.kick_bind9() - - def increment_serial(self): - filename = os.path.join(self.zonedir, 'serial') - with open(filename) as f: - serial = int(f.readline().strip()) - serial += 1 - with open(filename, 'w') as f: - f.write(str(serial)) - return serial - - def kick_bind9(self): - target = '{}:/etc/bind/{}'.format(self.ns1, self.zonefile) - filename = os.path.join(self.zonedir, self.zonefile) - subprocess.check_call(['scp', '-q', filename, target]) - subprocess.check_call(['ssh', self.ns1, 'systemctl', 'reload', 'bind9']) - - -class Inventory: - - def __init__(self, dirname): - self.dirname = dirname - - def write(self, zone, contexts): - for context in contexts: - filename = os.path.join(self.dirname, 'hosts.{}'.format(context)) - with open(filename, 'w') as f: - servers = [ - server - for ctx, server, _ in zone - if ctx == context - ] - for server in servers: - f.write('{} ansible_ssh_host={}\n'.format( - server, zone.dnsname(context, server))) - - -class Operation: - - def __init__(self): - self.hcloud = Hcloud() - - def run(self, profile): - raise NotImplementedError() - - def new_pass(self, profile): - return Pass(profile['pass-dir']) - - def create_hcloud_contexts(self, profile): - p = self.new_pass(profile) - pass_subdir = profile['pass-subdir'] - pass_contexts = p.listdir(pass_subdir) - hcloud_contexts = self.hcloud.list_contexts() - for context in pass_contexts: - if context not in hcloud_contexts: - token = p.get_token(pass_subdir, context) - self.hcloud.create_context(context, token) - - def update_zone_file(self, profile): - zonedir = profile['dnszone-dir'] - filename = profile['dnszone-file'] - ns1 = profile['ns1'] - - zone, contexts = self.create_zone(profile) - klass = self.get_zone_writer(profile) - writer = klass(zonedir, filename, ns1) - writer.write(zone) - - self.write_inventory(profile, zone, contexts) - - def create_zone(self, profile): - zone = Zone(profile['domain']) - - p = self.new_pass(profile) - pass_contexts = p.listdir(profile['pass-subdir']) - for context in pass_contexts: - for server, addr in self.hcloud.list_servers(context): - zone.add(context, server, addr) - - return zone, pass_contexts - - def get_zone_writer(self, profile): - styles = { - 'dns-api': ZoneWriterDnsApi, - 'qvarnlabs': ZoneWriterBind9, - } - - style = profile['style'] - if style not in styles: - raise Exception('unknown zone file style: {}'.format(style)) - return styles[style] - - def write_inventory(self, profile, zone, contexts): - inventory = Inventory(profile['ansible-inventory-dir']) - inventory.write(zone, contexts) - - -class Zonefile(Operation): - - def run(self, profile): - self.update_zone_file(profile) - - -class List(Operation): - - def run(self, profile): - self.create_hcloud_contexts(profile) - - p = self.new_pass(profile) - pass_contexts = p.listdir(profile['pass-subdir']) - domain = profile['domain'] - zone = Zone(domain) - for context in pass_contexts: - for server, addr in self.hcloud.list_servers(context): - print(zone.dnsname(context, server), addr) - - -class Create(Operation): - - def run(self, profile): - if profile['act']: - self.create_hcloud_contexts(profile) - - ssh_key = profile['ssh-key'] - context = profile['context'] - specfile = SpecFile(profile['specfile']) - zone = Zone(profile['domain']) - for server in specfile.servers(): - if profile['act']: - self.hcloud.create_server(context, ssh_key=ssh_key, **server) - print('created', zone.dnsname(context, server['name'])) - else: - print('pretending to create', zone.dnsname(context, server['name'])) - - if profile['act']: - self.update_zone_file(profile) - - -class Delete(Operation): - - def run(self, profile): - if profile['act']: - self.create_hcloud_contexts(profile) - - ssh_key = profile['ssh-key'] - context = profile['context'] - zone = Zone(profile['domain']) - for server, _ in self.hcloud.list_servers(context): - if profile['act']: - print('deleting', zone.dnsname(context, server)) - self.hcloud.delete_server(context, server) - else: - print('pretend-deleting', zone.dnsname(context, server)) - - if profile['act']: - self.update_zone_file(profile) - - -class ListContexts(Operation): - - def run(self, profile): - p = self.new_pass(profile) - pass_subdir = profile['pass-subdir'] - for context in p.listdir(pass_subdir): - print(context) - - -class CreateContext(Operation): - - def run(self, profile): - context = profile['context'] - token = profile['token'] - pass_dir = profile['pass-dir'] - pass_subdir = profile['pass-subdir'] - - git = Git(pass_dir) - if not git.isclean(): - raise Exception('pass word store is not clean') - git.pull() - - p = self.new_pass(profile) - p.create(pass_subdir, context, token) - - git.push() - - self.hcloud.create_context(context, token) - - -class Hcloud: - - def run(self, *args): - argv = ['hcloud'] + list(args) - output = subprocess.check_output(argv) - return output.decode('UTF-8').splitlines() - - def list_contexts(self): - lines = self.run('context', 'list') - return sorted(lines[1:]) - - def use_context(self, context): - self.run('context', 'use', context) - - def list_servers(self, context): - self.use_context(context) - servers = [] - lines = self.run('server', 'list') - for line in lines[1:]: - words = line.split() - servers.append((words[1], words[3])) - return servers - - def create_server( - self, context, name, image=None, ssh_key=None, server_type=None): - assert image is not None - assert ssh_key is not None - assert server_type is not None - self.use_context(context) - self.run( - 'server', 'create', - '--name', name, - '--ssh-key', ssh_key, - '--image', image, - '--type', server_type, - ) - - def delete_server(self, context, name): - self.use_context(context) - self.run('server', 'delete', name) - - def create_context(self, name, token): - fd, filename = tempfile.mkstemp() - os.write(fd, expect_script.encode('UTF-8')) - os.close(fd) - - argv = ['expect', '-f', filename, name, token] - subprocess.check_output(argv) - - -class Pass: - - def __init__(self, pwdir): - self.env = dict(os.environ) - self.env['PASSWORD_STORE_DIR'] = pwdir - - def run(self, *args, **kwargs): - argv = ['pass'] + list(args) - return subprocess.check_output(argv, env=self.env, **kwargs) - - def run_lines(self, *args): - text = self.run(*args) - return text.decode('UTF-8').splitlines() - - def listdir(self, dirname): - names = [] - for line in self.run_lines('show', dirname): - words = line.split() - if len(words) == 2: - names.append(words[1]) - return sorted(names) - - def get_token(self, dirname, basename): - bin = self.run('show', '{}/{}'.format(dirname, basename)) - return bin.decode('UTF-8').strip() - - def create(self, dirname, basename, value): - fd, filename = tempfile.mkstemp() - os.remove(filename) - os.write(fd, value.encode('UTF-8')) - os.lseek(fd, 0, os.SEEK_SET) - - pathname = os.path.join(dirname, basename) - self.run('insert', '-m', pathname, stdin=fd) - - os.close(fd) - - -class Git: - - def __init__(self, dirname): - self.dirname = dirname - - def run(self, *args, **kwargs): - argv = ['git'] + list(args) - return subprocess.check_output(argv, cwd=self.dirname, **kwargs) - - def isclean(self): - bin = self.run('status', '--porcelain') - return bin == b'' - - def add(self, filename): - self.run('add', filename) - - def commit(self, msg): - self.run('commit', '-m', msg, '-q') - - def pull(self): - self.run('pull', '-q') - - def push(self): - self.run('push', '-q') - - -class SpecFile: - - def __init__(self, filename): - with open(filename) as f: - self.obj = yaml.safe_load(f) - - def servers(self): - servers = [] - for server in self.obj['hosts']: - server = copy.deepcopy(server) - server.update(self.obj['defaults']) - server['server_type'] = server.pop('type') - servers.append(server) - return servers - - -HetznerTool().run() -- cgit v1.2.1