]> git.immae.eu Git - perso/Immae/Config/Nix.git/blob - overlays/nixops/hetzner_cloud.patch
Add new machine to nixops
[perso/Immae/Config/Nix.git] / overlays / nixops / hetzner_cloud.patch
1 From 272e50d0b0262e49cdcaad42cdab57aad183d1c2 Mon Sep 17 00:00:00 2001
2 From: goodraven
3 <employee-pseudonym-7f597def-7eeb-47f8-b10a-0724f2ba59a9@google.com>
4 Date: Thu, 3 May 2018 22:24:58 -0700
5 Subject: [PATCH] Initial commit adding support for hetzner cloud
6
7 This is based on the digital ocean backend. It also uses nixos-infect. I extended nixos-infect to be generic
8 for both backends.
9
10 Fixes #855
11 ---
12 examples/trivial-hetzner-cloud.nix | 12 ++
13 nix/eval-machine-info.nix | 1 +
14 nix/hetzner-cloud.nix | 56 +++++++
15 nix/options.nix | 1 +
16 nixops/backends/hetzner_cloud.py | 230 +++++++++++++++++++++++++++++
17 nixops/data/nixos-infect | 77 +++++++---
18 6 files changed, 354 insertions(+), 23 deletions(-)
19 create mode 100644 examples/trivial-hetzner-cloud.nix
20 create mode 100644 nix/hetzner-cloud.nix
21 create mode 100644 nixops/backends/hetzner_cloud.py
22
23 diff --git a/examples/trivial-hetzner-cloud.nix b/examples/trivial-hetzner-cloud.nix
24 new file mode 100644
25 index 000000000..c61add6bb
26 --- /dev/null
27 +++ b/examples/trivial-hetzner-cloud.nix
28 @@ -0,0 +1,12 @@
29 +{
30 + resources.sshKeyPairs.ssh-key = {};
31 +
32 + machine = { config, pkgs, ... }: {
33 + services.openssh.enable = true;
34 +
35 + deployment.targetEnv = "hetznerCloud";
36 + deployment.hetznerCloud.serverType = "cx11";
37 +
38 + networking.firewall.allowedTCPPorts = [ 22 ];
39 + };
40 +}
41 diff --git a/nix/eval-machine-info.nix b/nix/eval-machine-info.nix
42 index 2884b4b47..6a7205786 100644
43 --- a/nix/eval-machine-info.nix
44 +++ b/nix/eval-machine-info.nix
45 @@ -309,6 +309,7 @@ rec {
46 digitalOcean = optionalAttrs (v.config.deployment.targetEnv == "digitalOcean") v.config.deployment.digitalOcean;
47 gce = optionalAttrs (v.config.deployment.targetEnv == "gce") v.config.deployment.gce;
48 hetzner = optionalAttrs (v.config.deployment.targetEnv == "hetzner") v.config.deployment.hetzner;
49 + hetznerCloud = optionalAttrs (v.config.deployment.targetEnv == "hetznerCloud") v.config.deployment.hetznerCloud;
50 container = optionalAttrs (v.config.deployment.targetEnv == "container") v.config.deployment.container;
51 route53 = v.config.deployment.route53;
52 virtualbox =
53 diff --git a/nix/hetzner-cloud.nix b/nix/hetzner-cloud.nix
54 new file mode 100644
55 index 000000000..21d148c1a
56 --- /dev/null
57 +++ b/nix/hetzner-cloud.nix
58 @@ -0,0 +1,56 @@
59 +{ config, pkgs, lib, utils, ... }:
60 +
61 +with utils;
62 +with lib;
63 +with import ./lib.nix lib;
64 +
65 +let
66 + cfg = config.deployment.hetznerCloud;
67 +in
68 +{
69 + ###### interface
70 + options = {
71 +
72 + deployment.hetznerCloud.authToken = mkOption {
73 + default = "";
74 + example = "8b2f4e96af3997853bfd4cd8998958eab871d9614e35d63fab45a5ddf981c4da";
75 + type = types.str;
76 + description = ''
77 + The API auth token. We're checking the environment for
78 + <envar>HETZNER_CLOUD_AUTH_TOKEN</envar> first and if that is
79 + not set we try this auth token.
80 + '';
81 + };
82 +
83 + deployment.hetznerCloud.datacenter = mkOption {
84 + example = "fsn1-dc8";
85 + default = null;
86 + type = types.nullOr types.str;
87 + description = ''
88 + The datacenter.
89 + '';
90 + };
91 +
92 + deployment.hetznerCloud.location = mkOption {
93 + example = "fsn1";
94 + default = null;
95 + type = types.nullOr types.str;
96 + description = ''
97 + The location.
98 + '';
99 + };
100 +
101 + deployment.hetznerCloud.serverType = mkOption {
102 + example = "cx11";
103 + type = types.str;
104 + description = ''
105 + Name or id of server types.
106 + '';
107 + };
108 + };
109 +
110 + config = mkIf (config.deployment.targetEnv == "hetznerCloud") {
111 + nixpkgs.system = mkOverride 900 "x86_64-linux";
112 + services.openssh.enable = true;
113 + };
114 +}
115 diff --git a/nix/options.nix b/nix/options.nix
116 index 0866c3ab8..db021f74d 100644
117 --- a/nix/options.nix
118 +++ b/nix/options.nix
119 @@ -22,6 +22,7 @@ in
120 ./keys.nix
121 ./gce.nix
122 ./hetzner.nix
123 + ./hetzner-cloud.nix
124 ./container.nix
125 ./libvirtd.nix
126 ];
127 diff --git a/nixops/backends/hetzner_cloud.py b/nixops/backends/hetzner_cloud.py
128 new file mode 100644
129 index 000000000..a2cb176b9
130 --- /dev/null
131 +++ b/nixops/backends/hetzner_cloud.py
132 @@ -0,0 +1,230 @@
133 +# -*- coding: utf-8 -*-
134 +"""
135 +A backend for hetzner cloud.
136 +
137 +This backend uses nixos-infect (which uses nixos LUSTRATE) to infect a
138 +hetzner cloud instance. The setup requires two reboots, one for
139 +the infect itself, another after we pushed the nixos image.
140 +"""
141 +import os
142 +import os.path
143 +import time
144 +import socket
145 +
146 +import requests
147 +
148 +import nixops.resources
149 +from nixops.backends import MachineDefinition, MachineState
150 +from nixops.nix_expr import Function, RawValue
151 +import nixops.util
152 +import nixops.known_hosts
153 +
154 +infect_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'data', 'nixos-infect'))
155 +
156 +API_HOST = 'api.hetzner.cloud'
157 +
158 +class ApiError(Exception):
159 + pass
160 +
161 +class ApiNotFoundError(ApiError):
162 + pass
163 +
164 +class HetznerCloudDefinition(MachineDefinition):
165 + @classmethod
166 + def get_type(cls):
167 + return "hetznerCloud"
168 +
169 + def __init__(self, xml, config):
170 + MachineDefinition.__init__(self, xml, config)
171 + self.auth_token = config["hetznerCloud"]["authToken"]
172 + self.location = config["hetznerCloud"]["location"]
173 + self.datacenter = config["hetznerCloud"]["datacenter"]
174 + self.server_type = config["hetznerCloud"]["serverType"]
175 +
176 + def show_type(self):
177 + return "{0} [{1}]".format(self.get_type(), self.location or self.datacenter or 'any location')
178 +
179 +
180 +class HetznerCloudState(MachineState):
181 + @classmethod
182 + def get_type(cls):
183 + return "hetznerCloud"
184 +
185 + state = nixops.util.attr_property("state", MachineState.MISSING, int) # override
186 + public_ipv4 = nixops.util.attr_property("publicIpv4", None)
187 + public_ipv6 = nixops.util.attr_property("publicIpv6", None)
188 + location = nixops.util.attr_property("hetznerCloud.location", None)
189 + datacenter = nixops.util.attr_property("hetznerCloud.datacenter", None)
190 + server_type = nixops.util.attr_property("hetznerCloud.serverType", None)
191 + auth_token = nixops.util.attr_property("hetznerCloud.authToken", None)
192 + server_id = nixops.util.attr_property("hetznerCloud.serverId", None, int)
193 +
194 + def __init__(self, depl, name, id):
195 + MachineState.__init__(self, depl, name, id)
196 + self.name = name
197 +
198 + def get_ssh_name(self):
199 + return self.public_ipv4
200 +
201 + def get_ssh_flags(self, *args, **kwargs):
202 + super_flags = super(HetznerCloudState, self).get_ssh_flags(*args, **kwargs)
203 + return super_flags + [
204 + '-o', 'UserKnownHostsFile=/dev/null',
205 + '-o', 'StrictHostKeyChecking=no',
206 + '-i', self.get_ssh_private_key_file(),
207 + ]
208 +
209 + def get_physical_spec(self):
210 + return Function("{ ... }", {
211 + 'imports': [ RawValue('<nixpkgs/nixos/modules/profiles/qemu-guest.nix>') ],
212 + ('boot', 'loader', 'grub', 'device'): 'nodev',
213 + ('fileSystems', '/'): { 'device': '/dev/sda1', 'fsType': 'ext4'},
214 + ('users', 'extraUsers', 'root', 'openssh', 'authorizedKeys', 'keys'): [self.depl.active_resources.get('ssh-key').public_key],
215 + })
216 +
217 + def get_ssh_private_key_file(self):
218 + return self.write_ssh_private_key(self.depl.active_resources.get('ssh-key').private_key)
219 +
220 + def create_after(self, resources, defn):
221 + # make sure the ssh key exists before we do anything else
222 + return {
223 + r for r in resources if
224 + isinstance(r, nixops.resources.ssh_keypair.SSHKeyPairState)
225 + }
226 +
227 + def get_auth_token(self):
228 + return os.environ.get('HETZNER_CLOUD_AUTH_TOKEN', self.auth_token)
229 +
230 + def _api(self, path, method=None, data=None, json=True):
231 + """Basic wrapper around requests that handles auth and serialization."""
232 + assert path[0] == '/'
233 + url = 'https://%s%s' % (API_HOST, path)
234 + token = self.get_auth_token()
235 + if not token:
236 + raise Exception('No hetzner cloud auth token set')
237 + headers = {
238 + 'Authorization': 'Bearer '+self.get_auth_token(),
239 + }
240 + res = requests.request(
241 + method=method,
242 + url=url,
243 + json=data,
244 + headers=headers)
245 +
246 + if res.status_code == 404:
247 + raise ApiNotFoundError('Not Found: %r' % path)
248 + elif not res.ok:
249 + raise ApiError('Response for %s %s has status code %d: %s' % (method, path, res.status_code, res.content))
250 + if not json:
251 + return
252 + try:
253 + res_data = res.json()
254 + except ValueError as e:
255 + raise ApiError('Response for %s %s has invalid JSON (%s): %r' % (method, path, e, res.content))
256 + return res_data
257 +
258 +
259 + def destroy(self, wipe=False):
260 + if not self.server_id:
261 + self.log('server {} was never made'.format(self.name))
262 + return
263 + self.log('destroying server {} with id {}'.format(self.name, self.server_id))
264 + try:
265 + res = self._api('/v1/servers/%s' % (self.server_id), method='DELETE')
266 + except ApiNotFoundError:
267 + self.log("server not found - assuming it's been destroyed already")
268 +
269 + self.public_ipv4 = None
270 + self.server_id = None
271 +
272 + return True
273 +
274 + def _create_ssh_key(self, public_key):
275 + """Create or get an ssh key and return an id."""
276 + public_key = public_key.strip()
277 + res = self._api('/v1/ssh_keys', method='GET')
278 + name = 'nixops-%s-%s' % (self.depl.uuid, self.name)
279 + deletes = []
280 + for key in res['ssh_keys']:
281 + if key['public_key'].strip() == public_key:
282 + return key['id']
283 + if key['name'] == name:
284 + deletes.append(key['id'])
285 + for d in deletes:
286 + # This reply is empty, so don't decode json.
287 + self._api('/v1/ssh_keys/%d' % d, method='DELETE', json=False)
288 + res = self._api('/v1/ssh_keys', method='POST', data={
289 + 'name': name,
290 + 'public_key': public_key,
291 + })
292 + return res['ssh_key']['id']
293 +
294 + def create(self, defn, check, allow_reboot, allow_recreate):
295 + ssh_key = self.depl.active_resources.get('ssh-key')
296 + if ssh_key is None:
297 + raise Exception('Please specify a ssh-key resource (resources.sshKeyPairs.ssh-key = {}).')
298 +
299 + self.set_common_state(defn)
300 +
301 + if self.server_id is not None:
302 + return
303 +
304 + ssh_key_id = self._create_ssh_key(ssh_key.public_key)
305 +
306 + req = {
307 + 'name': self.name,
308 + 'server_type': defn.server_type,
309 + 'start_after_create': True,
310 + 'image': 'debian-9',
311 + 'ssh_keys': [
312 + ssh_key_id,
313 + ],
314 + }
315 +
316 + if defn.datacenter:
317 + req['datacenter'] = defn.datacenter
318 + elif defn.location:
319 + req['location'] = defn.location
320 +
321 + self.log_start("creating server ...")
322 + create_res = self._api('/v1/servers', method='POST', data=req)
323 + self.server_id = create_res['server']['id']
324 + self.public_ipv4 = create_res['server']['public_net']['ipv4']['ip']
325 + self.public_ipv6 = create_res['server']['public_net']['ipv6']['ip']
326 + self.datacenter = create_res['server']['datacenter']['name']
327 + self.location = create_res['server']['datacenter']['location']['name']
328 +
329 + action = create_res['action']
330 + action_path = '/v1/servers/%d/actions/%d' % (self.server_id, action['id'])
331 +
332 + while action['status'] == 'running':
333 + time.sleep(1)
334 + res = self._api(action_path, method='GET')
335 + action = res['action']
336 +
337 + if action['status'] != 'success':
338 + raise Exception('unexpected status: %s' % action['status'])
339 +
340 + self.log_end("{}".format(self.public_ipv4))
341 +
342 + self.wait_for_ssh()
343 + self.log_start("running nixos-infect")
344 + self.run_command('bash </dev/stdin 2>&1', stdin=open(infect_path))
345 + self.reboot_sync()
346 +
347 + def reboot(self, hard=False):
348 + if hard:
349 + self.log("sending hard reset to server...")
350 + res = self._api('/v1/servers/%d/actions/reset' % self.server_id, method='POST')
351 + action = res['action']
352 + action_path = '/v1/servers/%d/actions/%d' % (self.server_id, action['id'])
353 + while action['status'] == 'running':
354 + time.sleep(1)
355 + res = self._api(action_path, method='GET')
356 + action = res['action']
357 + if action['status'] != 'success':
358 + raise Exception('unexpected status: %s' % action['status'])
359 + self.wait_for_ssh()
360 + self.state = self.STARTING
361 + else:
362 + MachineState.reboot(self, hard=hard)
363 diff --git a/nixops/data/nixos-infect b/nixops/data/nixos-infect
364 index 66634357b..437a2ec61 100644
365 --- a/nixops/data/nixos-infect
366 +++ b/nixops/data/nixos-infect
367 @@ -68,26 +68,49 @@ makeConf() {
368 }
369 EOF
370 # (nixos-generate-config will add qemu-user and bind-mounts, so avoid)
371 + local disk
372 + if [ -e /dev/sda ]; then
373 + disk=/dev/sda
374 + else
375 + disk=/dev/vda
376 + fi
377 cat > /etc/nixos/hardware-configuration.nix << EOF
378 { ... }:
379 {
380 imports = [ <nixpkgs/nixos/modules/profiles/qemu-guest.nix> ];
381 - boot.loader.grub.device = "/dev/vda";
382 - fileSystems."/" = { device = "/dev/vda1"; fsType = "ext4"; };
383 + boot.loader.grub.device = "${disk}";
384 + fileSystems."/" = { device = "${disk}1"; fsType = "ext4"; };
385 }
386 EOF
387
388 local IFS=$'\n'
389 - ens3_ip4s=($(ip address show dev eth0 | grep 'inet ' | sed -r 's|.*inet ([0-9.]+)/([0-9]+).*|{ address="\1"; prefixLength=\2; }|'))
390 - ens3_ip6s=($(ip address show dev eth0 | grep 'inet6 .*global' | sed -r 's|.*inet6 ([0-9a-f:]+)/([0-9]+).*|{ address="\1"; prefixLength=\2; }|'))
391 - ens4_ip4s=($(ip address show dev eth1 | grep 'inet ' | sed -r 's|.*inet ([0-9.]+)/([0-9]+).*|{ address="\1"; prefixLength=\2; }|'))
392 - ens4_ip6s=($(ip address show dev eth1 | grep 'inet6 .*global' | sed -r 's|.*inet6 ([0-9a-f:]+)/([0-9]+).*|{ address="\1"; prefixLength=\2; }|'))
393 - gateway=($(ip route show dev eth0 | grep default | sed -r 's|default via ([0-9.]+).*|\1|'))
394 - gateway6=($(ip -6 route show dev eth0 | grep default | sed -r 's|default via ([0-9a-f:]+).*|\1|'))
395 - ether0=($(ip address show dev eth0 | grep link/ether | sed -r 's|.*link/ether ([0-9a-f:]+) .*|\1|'))
396 - ether1=($(ip address show dev eth1 | grep link/ether | sed -r 's|.*link/ether ([0-9a-f:]+) .*|\1|'))
397 + gateway=($(ip route show | grep default | sed -r 's|default via ([0-9.]+).*|\1|'))
398 + gateway6=($(ip -6 route show | grep default | sed -r 's|default via ([0-9a-f:]+).*|\1|'))
399 + interfaces=($(ip link | awk -F ': ' '/^[0-9]*: / {if ($2 != "lo") {print $2}}'))
400 nameservers=($(grep ^nameserver /etc/resolv.conf | cut -f2 -d' '))
401
402 + # Predict the predictable name for each interface since that is enabled in
403 + # the nixos system.
404 + declare -A predictable_names
405 + for interface in ${interfaces[@]}; do
406 + # udevadm prints out the candidate names which will be selected if
407 + # available in this order.
408 + local name=$(udevadm info /sys/class/net/$interface | awk -F = '
409 + /^E: ID_NET_NAME_FROM_DATABASE=/ {arr[1]=$2}
410 + /^E: ID_NET_NAME_ONBOARD=/ {arr[2]=$2}
411 + /^E: ID_NET_NAME_SLOT=/ {arr[3]=$2}
412 + /^E: ID_NET_NAME_PATH=/ {arr[4]=$2}
413 + /^E: ID_NET_NAME_MAC=/ {arr[5]=$2}
414 + END {for (i=1;i<6;i++) {if (length(arr[i]) > 0) { print arr[i]; break}}}')
415 + if [ -z "$name" ]; then
416 + echo Could not determine predictable name for interface $interface
417 + fi
418 + predictable_names[$interface]=$name
419 + done
420 +
421 + # Take a gamble on the first interface being able to reach the gateway.
422 + local default_interface=${predictable_names[${interfaces[0]}]}
423 +
424 cat > /etc/nixos/networking.nix << EOF
425 { ... }: {
426 # This file was populated at runtime with the networking
427 @@ -96,25 +119,27 @@ EOF
428 nameservers = [$(for a in ${nameservers[@]}; do echo -n "
429 \"$a\""; done)
430 ];
431 - defaultGateway = "${gateway}";
432 - defaultGateway6 = "${gateway6}";
433 + defaultGateway = {address = "${gateway}"; interface = "${default_interface}";};
434 + defaultGateway6 = {address = "${gateway6}"; interface = "${default_interface}";};
435 interfaces = {
436 - ens3 = {
437 - ip4 = [$(for a in ${ens3_ip4s[@]}; do echo -n "
438 - $a"; done)
439 - ];
440 - ip6 = [$(for a in ${ens3_ip6s[@]}; do echo -n "
441 - $a"; done)
442 - ];
443 - };
444 - ens4 = {
445 - ip4 = [$(for a in ${ens4_ip4s[@]}; do echo -n "
446 +EOF
447 +
448 + for interface in ${interfaces[@]}; do
449 + ip4s=($(ip address show dev $interface | grep 'inet ' | sed -r 's|.*inet ([0-9.]+)/([0-9]+).*|{ address="\1"; prefixLength=\2; }|'))
450 + ip6s=($(ip address show dev $interface | grep 'inet6 .*global' | sed -r 's|.*inet6 ([0-9a-f:]+)/([0-9]+).*|{ address="\1"; prefixLength=\2; }|'))
451 + cat >> /etc/nixos/networking.nix << EOF
452 + ${predictable_names[$interface]} = {
453 + ip4 = [$(for a in ${ip4s[@]}; do echo -n "
454 $a"; done)
455 ];
456 - ip6 = [$(for a in ${ens4_ip6s[@]}; do echo -n "
457 + ip6 = [$(for a in ${ip6s[@]}; do echo -n "
458 $a"; done)
459 ];
460 };
461 +EOF
462 + done
463 +
464 + cat >> /etc/nixos/networking.nix << EOF
465 };
466 };
467 }
468 @@ -154,6 +179,12 @@ export HOME="/root"
469 groupadd -r nixbld -g 30000
470 seq 1 10 | xargs -I{} useradd -c "Nix build user {}" -d /var/empty -g nixbld -G nixbld -M -N -r -s `which nologin` nixbld{}
471
472 +if ! which curl >/dev/null 2>/dev/null; then
473 + if which apt-get >/dev/null 2>/dev/null; then
474 + apt-get update && apt-get install -y curl
475 + fi
476 +fi
477 +
478 curl https://nixos.org/nix/install | sh
479
480 source ~/.nix-profile/etc/profile.d/nix.sh