Skip to content

Commit

Permalink
Initial commit adding support for hetzner cloud
Browse files Browse the repository at this point in the history
This is based on the digital ocean backend. It also uses nixos-infect. I extended nixos-infect to be generic
for both backends.

Fixes #855
  • Loading branch information
goodraven authored and asymmetric committed Apr 14, 2019
1 parent 3b55fc8 commit 556c8b5
Show file tree
Hide file tree
Showing 6 changed files with 354 additions and 23 deletions.
12 changes: 12 additions & 0 deletions examples/trivial-hetzner-cloud.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
resources.sshKeyPairs.ssh-key = {};

machine = { config, pkgs, ... }: {
services.openssh.enable = true;

deployment.targetEnv = "hetznerCloud";
deployment.hetznerCloud.serverType = "cx11";

networking.firewall.allowedTCPPorts = [ 22 ];
};
}
1 change: 1 addition & 0 deletions nix/eval-machine-info.nix
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,7 @@ rec {
digitalOcean = optionalAttrs (v.config.deployment.targetEnv == "digitalOcean") v.config.deployment.digitalOcean;
gce = optionalAttrs (v.config.deployment.targetEnv == "gce") v.config.deployment.gce;
hetzner = optionalAttrs (v.config.deployment.targetEnv == "hetzner") v.config.deployment.hetzner;
hetznerCloud = optionalAttrs (v.config.deployment.targetEnv == "hetznerCloud") v.config.deployment.hetznerCloud;
container = optionalAttrs (v.config.deployment.targetEnv == "container") v.config.deployment.container;
route53 = v.config.deployment.route53;
virtualbox =
Expand Down
56 changes: 56 additions & 0 deletions nix/hetzner-cloud.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
{ config, pkgs, lib, utils, ... }:

with utils;
with lib;
with import ./lib.nix lib;

let
cfg = config.deployment.hetznerCloud;
in
{
###### interface
options = {

deployment.hetznerCloud.authToken = mkOption {
default = "";
example = "8b2f4e96af3997853bfd4cd8998958eab871d9614e35d63fab45a5ddf981c4da";
type = types.str;
description = ''
The API auth token. We're checking the environment for
<envar>HETZNER_CLOUD_AUTH_TOKEN</envar> first and if that is
not set we try this auth token.
'';
};

deployment.hetznerCloud.datacenter = mkOption {
example = "fsn1-dc8";
default = null;
type = types.nullOr types.str;
description = ''
The datacenter.
'';
};

deployment.hetznerCloud.location = mkOption {
example = "fsn1";
default = null;
type = types.nullOr types.str;
description = ''
The location.
'';
};

deployment.hetznerCloud.serverType = mkOption {
example = "cx11";
type = types.str;
description = ''
Name or id of server types.
'';
};
};

config = mkIf (config.deployment.targetEnv == "hetznerCloud") {
nixpkgs.system = mkOverride 900 "x86_64-linux";
services.openssh.enable = true;
};
}
1 change: 1 addition & 0 deletions nix/options.nix
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ in
./keys.nix
./gce.nix
./hetzner.nix
./hetzner-cloud.nix
./container.nix
./libvirtd.nix
];
Expand Down
230 changes: 230 additions & 0 deletions nixops/backends/hetzner_cloud.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
# -*- coding: utf-8 -*-
"""
A backend for hetzner cloud.
This backend uses nixos-infect (which uses nixos LUSTRATE) to infect a
hetzner cloud instance. The setup requires two reboots, one for
the infect itself, another after we pushed the nixos image.
"""
import os
import os.path
import time
import socket

import requests

import nixops.resources
from nixops.backends import MachineDefinition, MachineState
from nixops.nix_expr import Function, RawValue
import nixops.util
import nixops.known_hosts

infect_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'data', 'nixos-infect'))

API_HOST = 'api.hetzner.cloud'

class ApiError(Exception):
pass

class ApiNotFoundError(ApiError):
pass

class HetznerCloudDefinition(MachineDefinition):
@classmethod
def get_type(cls):
return "hetznerCloud"

def __init__(self, xml, config):
MachineDefinition.__init__(self, xml, config)
self.auth_token = config["hetznerCloud"]["authToken"]
self.location = config["hetznerCloud"]["location"]
self.datacenter = config["hetznerCloud"]["datacenter"]
self.server_type = config["hetznerCloud"]["serverType"]

def show_type(self):
return "{0} [{1}]".format(self.get_type(), self.location or self.datacenter or 'any location')


class HetznerCloudState(MachineState):
@classmethod
def get_type(cls):
return "hetznerCloud"

state = nixops.util.attr_property("state", MachineState.MISSING, int) # override
public_ipv4 = nixops.util.attr_property("publicIpv4", None)
public_ipv6 = nixops.util.attr_property("publicIpv6", None)
location = nixops.util.attr_property("hetznerCloud.location", None)
datacenter = nixops.util.attr_property("hetznerCloud.datacenter", None)
server_type = nixops.util.attr_property("hetznerCloud.serverType", None)
auth_token = nixops.util.attr_property("hetznerCloud.authToken", None)
server_id = nixops.util.attr_property("hetznerCloud.serverId", None, int)

def __init__(self, depl, name, id):
MachineState.__init__(self, depl, name, id)
self.name = name

def get_ssh_name(self):
return self.public_ipv4

def get_ssh_flags(self, *args, **kwargs):
super_flags = super(HetznerCloudState, self).get_ssh_flags(*args, **kwargs)
return super_flags + [
'-o', 'UserKnownHostsFile=/dev/null',
'-o', 'StrictHostKeyChecking=no',
'-i', self.get_ssh_private_key_file(),
]

def get_physical_spec(self):
return Function("{ ... }", {
'imports': [ RawValue('<nixpkgs/nixos/modules/profiles/qemu-guest.nix>') ],
('boot', 'loader', 'grub', 'device'): 'nodev',
('fileSystems', '/'): { 'device': '/dev/sda1', 'fsType': 'ext4'},
('users', 'extraUsers', 'root', 'openssh', 'authorizedKeys', 'keys'): [self.depl.active_resources.get('ssh-key').public_key],
})

def get_ssh_private_key_file(self):
return self.write_ssh_private_key(self.depl.active_resources.get('ssh-key').private_key)

def create_after(self, resources, defn):
# make sure the ssh key exists before we do anything else
return {
r for r in resources if
isinstance(r, nixops.resources.ssh_keypair.SSHKeyPairState)
}

def get_auth_token(self):
return os.environ.get('HETZNER_CLOUD_AUTH_TOKEN', self.auth_token)

def _api(self, path, method=None, data=None, json=True):
"""Basic wrapper around requests that handles auth and serialization."""
assert path[0] == '/'
url = 'https://%s%s' % (API_HOST, path)
token = self.get_auth_token()
if not token:
raise Exception('No hetzner cloud auth token set')
headers = {
'Authorization': 'Bearer '+self.get_auth_token(),
}
res = requests.request(
method=method,
url=url,
json=data,
headers=headers)

if res.status_code == 404:
raise ApiNotFoundError('Not Found: %r' % path)
elif not res.ok:
raise ApiError('Response for %s %s has status code %d: %s' % (method, path, res.status_code, res.content))
if not json:
return
try:
res_data = res.json()
except ValueError as e:
raise ApiError('Response for %s %s has invalid JSON (%s): %r' % (method, path, e, res.content))
return res_data


def destroy(self, wipe=False):
if not self.server_id:
self.log('server {} was never made'.format(self.name))
return
self.log('destroying server {} with id {}'.format(self.name, self.server_id))
try:
res = self._api('/v1/servers/%s' % (self.server_id), method='DELETE')
except ApiNotFoundError:
self.log("server not found - assuming it's been destroyed already")

self.public_ipv4 = None
self.server_id = None

return True

def _create_ssh_key(self, public_key):
"""Create or get an ssh key and return an id."""
public_key = public_key.strip()
res = self._api('/v1/ssh_keys', method='GET')
name = 'nixops-%s-%s' % (self.depl.uuid, self.name)
deletes = []
for key in res['ssh_keys']:
if key['public_key'].strip() == public_key:
return key['id']
if key['name'] == name:
deletes.append(key['id'])
for d in deletes:
# This reply is empty, so don't decode json.
self._api('/v1/ssh_keys/%d' % d, method='DELETE', json=False)
res = self._api('/v1/ssh_keys', method='POST', data={
'name': name,
'public_key': public_key,
})
return res['ssh_key']['id']

def create(self, defn, check, allow_reboot, allow_recreate):
ssh_key = self.depl.active_resources.get('ssh-key')
if ssh_key is None:
raise Exception('Please specify a ssh-key resource (resources.sshKeyPairs.ssh-key = {}).')

self.set_common_state(defn)

if self.server_id is not None:
return

ssh_key_id = self._create_ssh_key(ssh_key.public_key)

req = {
'name': self.name,
'server_type': defn.server_type,
'start_after_create': True,
'image': 'debian-9',
'ssh_keys': [
ssh_key_id,
],
}

if defn.datacenter:
req['datacenter'] = defn.datacenter
elif defn.location:
req['location'] = defn.location

self.log_start("creating server ...")
create_res = self._api('/v1/servers', method='POST', data=req)
self.server_id = create_res['server']['id']
self.public_ipv4 = create_res['server']['public_net']['ipv4']['ip']
self.public_ipv6 = create_res['server']['public_net']['ipv6']['ip']
self.datacenter = create_res['server']['datacenter']['name']
self.location = create_res['server']['datacenter']['location']['name']

action = create_res['action']
action_path = '/v1/servers/%d/actions/%d' % (self.server_id, action['id'])

while action['status'] == 'running':
time.sleep(1)
res = self._api(action_path, method='GET')
action = res['action']

if action['status'] != 'success':
raise Exception('unexpected status: %s' % action['status'])

self.log_end("{}".format(self.public_ipv4))

self.wait_for_ssh()
self.log_start("running nixos-infect")
self.run_command('bash </dev/stdin 2>&1', stdin=open(infect_path))
self.reboot_sync()

def reboot(self, hard=False):
if hard:
self.log("sending hard reset to server...")
res = self._api('/v1/servers/%d/actions/reset' % self.server_id, method='POST')
action = res['action']
action_path = '/v1/servers/%d/actions/%d' % (self.server_id, action['id'])
while action['status'] == 'running':
time.sleep(1)
res = self._api(action_path, method='GET')
action = res['action']
if action['status'] != 'success':
raise Exception('unexpected status: %s' % action['status'])
self.wait_for_ssh()
self.state = self.STARTING
else:
MachineState.reboot(self, hard=hard)
Loading

0 comments on commit 556c8b5

Please sign in to comment.