summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rwxr-xr-xhetznertool2573
-rw-r--r--test.hz5
2 files changed, 578 insertions, 0 deletions
diff --git a/hetznertool2 b/hetznertool2
new file mode 100755
index 0000000..8b22cb5
--- /dev/null
+++ b/hetznertool2
@@ -0,0 +1,573 @@
+#!/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()
diff --git a/test.hz b/test.hz
new file mode 100644
index 0000000..e44839f
--- /dev/null
+++ b/test.hz
@@ -0,0 +1,5 @@
+defaults:
+ image: debian-9
+ type: cx11
+hosts:
+ - name: testi