Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add initial support for hetzner cloud #944

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is shouldn't needed, as it's already enabled in nix/hetzner-cloud.nix.


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

networking.firewall.allowedTCPPorts = [ 22 ];
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here, as it's done by the sshd module (openFirewall is enabled by default).

};
}
1 change: 1 addition & 0 deletions nix/eval-machine-info.nix
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not needed when we already have an import for os.

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'))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably would make sense to use upper-case INFECT_PATH.


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("{ ... }", {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why use a function here with { ... } when an attrset is enough?

'imports': [ RawValue('<nixpkgs/nixos/modules/profiles/qemu-guest.nix>') ],
('boot', 'loader', 'grub', 'device'): 'nodev',
('fileSystems', '/'): { 'device': '/dev/sda1', 'fsType': 'ext4'},
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if the user wants to have a different file system or partitioning scheme? And is the block device name always /dev/sda?

('users', 'extraUsers', 'root', 'openssh', 'authorizedKeys', 'keys'): [self.depl.active_resources.get('ssh-key').public_key],
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

users.users because extraUsers is deprecated.

})

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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd either internalize this for every machine state or allow to specify a specific resource rather than a hardcoded one. The former would have the advantage that it would require less options and later on we can still add an option so users can use an ssh key resource instead of the internal one.


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))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

s/made/created/ maybe?

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')
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Parentheses are not needed for (self.server_id).

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,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indentation: 4 spaces.

'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))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It should not be needed to redirect /dev/stdin here as bash reads from stdin by default if not connected to a terminal.

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