-
-
Notifications
You must be signed in to change notification settings - Fork 366
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 ]; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same here, as it's done by the |
||
}; | ||
} |
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; | ||
}; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -22,6 +22,7 @@ in | |
./keys.nix | ||
./gce.nix | ||
./hetzner.nix | ||
./hetzner-cloud.nix | ||
./container.nix | ||
./libvirtd.nix | ||
]; | ||
|
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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not needed when we already have an import for |
||
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')) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Probably would make sense to use upper-case |
||
|
||
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("{ ... }", { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why use a function here with |
||
'imports': [ RawValue('<nixpkgs/nixos/modules/profiles/qemu-guest.nix>') ], | ||
('boot', 'loader', 'grub', 'device'): 'nodev', | ||
('fileSystems', '/'): { 'device': '/dev/sda1', 'fsType': 'ext4'}, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
('users', 'extraUsers', 'root', 'openssh', 'authorizedKeys', 'keys'): [self.depl.active_resources.get('ssh-key').public_key], | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
}) | ||
|
||
def get_ssh_private_key_file(self): | ||
return self.write_ssh_private_key(self.depl.active_resources.get('ssh-key').private_key) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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)) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
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') | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Parentheses are not needed for |
||
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, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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)) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It should not be needed to redirect |
||
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) |
There was a problem hiding this comment.
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
.