]>
Commit | Line | Data |
---|---|---|
8415083e IB |
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 |