summaryrefslogtreecommitdiff
path: root/nix-build-omatic
blob: ce8a7bbe48849a8a76e0128a317a321568b599b1 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
#!/usr/bin/env python3
"""Provision a large virtual machine on Digital Ocean and run a nix remote
build, then destroy the VM.

I tested this like so:

   $ ./nix-build-omatic "'<nixpkgs>'" -A ocaml

The special quoting is necessary until I setup an argparse CLI, currently it
just passes all args through to the nix-build call. But it works!

"""

import json
import os
import sys
import subprocess
import time

def noti(msg):
    print(f"nix-build-omatic: {msg}")

class Builder:
    def __init__(self):
        # TODO: generate a temporary privkey, and use that instead
        self.keys = [k["id"] for k in json.loads(subprocess.check_output(["doctl", "compute", "ssh-key", "list", "--output", "json"]).decode('utf-8'))]
        self.privkey_path = os.path.expanduser("~/.ssh/id_rsa")
        self.max_jobs = 32
        self.ip = None

    def __enter__(self):
        "Startup a droplet."
        noti("preparing droplet...")
        res = self.droplet(
            "create",
            # this is my custom nixos image, I had to download from nixos.org and then
            # upload it to DO. There's gotta be a better way to do this..
            "--image", "121034270",
            "--size", "c2-32vcpu-64gb", # TODO: set this in argparse, match '32vcpu' with 'max_jobs'
            "--region", "nyc3",
            "--tag-name", "nix-build-omatic",
            "--ssh-keys", ",".join([str(k) for k in self.keys]),
            "nix-build-omatic"
        )
        self.id = str(res["id"])
        self.active = False
        while not self.active:
            res = self.droplet("get", self.id)
            if res["status"] == "active":
                self.active = True
                noti("droplet ready!")
            else:
                continue
        while self.ip is None:
            res = self.droplet("get", self.id)
            self.ip = [n["ip_address"] for n in res["networks"]["v4"] if n["type"] == "public" ][0]
        self.url = f"ssh://root@{self.ip}"
        self.do_stupid_handshake()
        return self

    def __exit__(self, type, value, traceback):
        "Destroy the droplet."
        self.droplet("delete", self.id, "--force", parse=False)
        noti("droplet destroyed")

    def droplet(self, *args, parse=True):
        "Run a `doctl compute droplet *args` command"
        proc = subprocess.run(
            ["doctl", "compute", "droplet", *args, "--output", "json"],
            capture_output=True
        )
        stderr = proc.stderr.decode('utf-8')
        stdout = proc.stdout.decode('utf-8')
        if proc.returncode > 0:
            noti("stderr:", stderr)
            noti("stdout:", stdout)
        if parse:
            return json.loads(stdout)[0]
        else:
            return stdout

    def do_stupid_handshake(self):
        """
        this whole thing is doing a key handshake, which shouldn't be necessary!
        i should just generate the keys on my end and set them via the API! or
        retrieve them via the API! i'm already authenticated! wtf digital ocean
        """
        noti("exchanging keys...")
        keyscan = None
        while keyscan is None:
            cmd = f"ssh-keyscan -t ed25519 {self.ip}"
            proc = subprocess.run(cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
            stderr = proc.stderr.decode('utf-8')
            stdout = proc.stdout.decode('utf-8')
            if proc.returncode > 0:
                continue
            elif stdout == "":
                continue
            else:
                keyscan = stdout
        with open(os.path.expanduser("~/.ssh/known_hosts"), 'a') as known_hosts:
            known_hosts.write(keyscan)
        self.host_pubkey = subprocess.check_output(f"ssh root@{self.ip} cat /etc/ssh/ssh_host_ed25519_key.pub | base64 -w0", shell=True).decode('utf-8')


if __name__ == '__main__':
    # TODO: use argparse, properly quote '<nixpkgs>' and so on
    args = sys.argv[1:]
    start_time = time.time()

    with Builder() as b:
        cmd = " ".join([
            "nix-build",
            *args,
            # TODO: obv get rid of this when I'm not testing...
            "--option", "substitute", "false",
            # TODO: make this --builders line structured data somehow
            "--builders", f"'{b.url} x86_64-linux {b.privkey_path} {b.max_jobs} - - - {b.host_pubkey}'"
        ])
        noti(f"running: {cmd}")
        subprocess.check_call(cmd, shell=True)

    duration = time.time() - start_time
    duration_human = time.strftime('%H:%M:%S', duration)
    # the 32 vCPU machine costs $1/hr, so convert that to cost per second
    cost = (1/60/60)*duration
    print(f"duration: {duration_human}")
    print(f"estimated cost: {cost:0.2f} USD")