summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLars Wirzenius <liw@liw.fi>2018-06-30 13:24:30 +0300
committerLars Wirzenius <liw@liw.fi>2018-06-30 13:24:30 +0300
commit84dd1af404a948fc4adf5b44a70317eb2d7b04a5 (patch)
treee97fc543193300825e635edcfb0b27b9556cd3fe
parent817a03175395405b1f9c5620b1998f71c62d1b88 (diff)
downloadhetznertool-84dd1af404a948fc4adf5b44a70317eb2d7b04a5.tar.gz
Change: name hetznertool2 to hetznertool
We don't need the old one anymore.
-rwxr-xr-xhetznertool776
-rwxr-xr-xhetznertool2573
2 files changed, 494 insertions, 855 deletions
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()