#!/usr/bin/env python3 import argparse import copy import os import subprocess import yaml CONFIG_DIR = os.path.expanduser('~/.config/hetznertool') DEFAULT_PROFILE = 'hetznertool' default_config = { 'ssh-key': None, 'ns1': 'root@ns1.qvarnlabs.net', } 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) def hcloud(*args): argv = ['hcloud'] + list(args) output = subprocess.check_output(argv) return output.decode('UTF-8').splitlines() def use_context(context): hcloud('context', 'use', context) 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 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] def parse_server_line(line): words = line.split() return { 'name': words[1], 'ipv4': words[3], } def dns_name(context, name, domain): return '{}-{}.{}'.format(context, name, domain) class ServerSpecification(dict): def from_dict(self, as_dict): self.clear() self.update(copy.deepcopy(as_dict)) 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) def _hosts(self): return self.get('hosts', []) def _defaults(self): return copy.deepcopy(self.get('defaults', {})) def _get(self, name): hosts = self._hosts() for host in hosts: if host.get('name') == name: return copy.deepcopy(host) def get_servers(self): return [host['name'] for host in self._hosts()] def get_server(self, name): server = self._defaults() server.update(self._get(name)) return server def create_func(config, args): spec = ServerSpecification() spec.from_file(args['specfile']) context = args['context'] use_context(context) 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) 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']) def get_config_for_context(config, context): return config.get('contexts', {}).get(context, {}) def get_domain_for_context(config, context): cc = get_config_for_context(config, context) return cc.get('domain') def get_zonedir_for_context(config, context): cc = get_config_for_context(config, context) return cc.get('dnszone-dir') def get_zonefile_for_context(config, context): cc = get_config_for_context(config, context) return cc.get('dnszone-file') def get_inventorydir_for_context(config, context): cc = get_config_for_context(config, context) return cc.get('ansible-inventory-dir') def get_kick_for_context(config, context): cc = get_config_for_context(config, context) return cc.get('kick_bind9') def get_style_for_context(config, context): cc = get_config_for_context(config, context) return cc.get('style') def get_inventory_style_for_context(config, context): cc = get_config_for_context(config, context) return cc.get('inventory-style') 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) 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)) subprocess.check_call(['git', 'pull'], cwd=dirname) filenames = [basename] if style == 'qvarnlabs': serial = int(open(serial_name).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 subprocess.call( ['git', 'commit', '-m', 'automatic zone update'] + filenames, cwd=dirname) subprocess.check_call(['git', 'push'], cwd=dirname) if kick: kick_bind9(args['ns1'], filename, basename) def write_qvarnlabs_zone(stream, serial): stream.write(''' $TTL 30 $ORIGIN h.qvarnlabs.eu. @ IN SOA ns1.qvarnlabs.net. ops.qvarnlabs.com ( {} 30 30 8640000 15 ) @ IN NS ns1.qvarnlabs.net. @ IN NS ns2.qvarnlabs.net. '''.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): 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()