Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/vole.git

Write over SSH with a registered key:

git clone ssh://git@rodent.software:2222/vole.git

A free and open-source forge that lives in your Git repo.

Blame · scripts/check-deploy.py

Line attribution on main · 325 line(s)

dbae2e01
Keenan Nemetz
3d ago
1 #!/usr/bin/env python3
2 """Fail when a file under deploy/ would break someone's first deployment.
3
4 The deployment files are the ones nobody runs before a user does: a cloud-init
9bce3723
Keenan Nemetz
3d ago
5 template with a stray placeholder, or a boot script with a shell syntax error,
6 fails on somebody's first boot, minutes after they spent money on a machine.
dbae2e01
Keenan Nemetz
3d ago
7
8 What is checked:
9
10 - every YAML file under deploy/ parses;
e21f6c5e
Keenan Nemetz
3d ago
11 - each cloud-init template renders with the placeholder list that
12 deploy/cloud-init/render.sh uses for it, and no placeholder is left behind;
13 - the rendered user data is a cloud-config document with the files it promises,
14 and its boot script is valid `sh`;
15 - the packaged runner unit, its argument file, and the template that configures
16 them agree on the names, because three files agreeing by memory is how a
17 runner ends up enabled but idle.
dbae2e01
Keenan Nemetz
3d ago
18
51624dc6
Keenan Nemetz
3d ago
19 It needs python3 and PyYAML, and nothing else: no container runtime, no cloud
20 credentials, and no network.
dbae2e01
Keenan Nemetz
3d ago
21 """
22
23 import os
24 import re
25 import subprocess
26 import sys
27
28 import yaml
29
30 ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
31 DEPLOY = os.path.join(ROOT, "deploy")
32
6ce635a8
Keenan Nemetz
1d ago
33 # Values with the right shapes: a bare name, a real looking ed25519 key, an
34 # https URL, and a plain instance name. They are never used against anything.
dbae2e01
Keenan Nemetz
3d ago
35 SAMPLE = {
36 "hostname": "forge.example.com",
37 "admin_account": "admin",
38 "admin_ssh_key": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl admin@example.com",
39 "admin_email": "admin@example.com",
40 "instance_name": "Example Forge",
41 "tls_mode": "acme",
ed03b3d1
Keenan Nemetz
6h ago
42 "vole_package_url": "https://rodent.software/vole/releases/v0.2.0/artifacts/vole_0.2.0-1_amd64.deb",
e21f6c5e
Keenan Nemetz
3d ago
43 "runner_hostname": "vole-runner",
44 "forge_host": "forge.example.com",
45 "runner_runtime": "podman",
46 "runner_network": "bridge",
47 "runner_dir": "/var/lib/vole-runner",
18c559ea
Keenan Nemetz
3d ago
48 "runner_jobs": "--jobs 2",
4e3759da
Keenan Nemetz
4h ago
49 "runner_memory": "4G",
50 "runner_pids": "2048",
51 "runner_tmpfs": "1G",
52 "runner_disk_reserve": "2G",
e21f6c5e
Keenan Nemetz
3d ago
53 "runner_labels": "--label linux --label amd64",
54 }
55
56 # What each template has to produce. The files are the contract between the
57 # template and the script that installs them.
58 TEMPLATES = {
59 "vole.yaml": {
60 "files": [
61 "/opt/vole-deploy/vole.acme.toml",
62 "/opt/vole-deploy/vole.proxy.toml",
63 "/opt/vole-deploy/bind.conf",
64 "/opt/vole-deploy/admin.pub",
65 "/usr/local/sbin/vole-first-boot",
66 ],
67 "scripts": ["/usr/local/sbin/vole-first-boot"],
68 },
69 "runner.yaml": {
70 "files": [
71 "/opt/vole-runner/runner.env",
72 "/usr/local/sbin/vole-runner-first-boot",
73 ],
74 "scripts": ["/usr/local/sbin/vole-runner-first-boot"],
75 },
dbae2e01
Keenan Nemetz
3d ago
76 }
77
78
79 def fail(message, where=""):
80 print(f"{where}: {message}" if where else message, file=sys.stderr)
81 return 1
82
83
84 def check_yaml_files():
85 bad = 0
86 for root, dirs, files in os.walk(DEPLOY):
87 for name in files:
88 if not name.endswith((".yaml", ".yml")):
89 continue
90 path = os.path.join(root, name)
91 with open(path, encoding="utf-8") as f:
92 try:
93 yaml.safe_load(f)
94 except yaml.YAMLError as err:
95 bad |= fail(f"not valid YAML: {err}", os.path.relpath(path, ROOT))
96 return bad
97
98
e21f6c5e
Keenan Nemetz
3d ago
99 def placeholder_lists():
100 """The placeholders per template, taken from the one place that lists them.
101
102 render.sh names the template and then the placeholders it substitutes, so
103 that line is the contract between the script and the file.
104 """
dbae2e01
Keenan Nemetz
3d ago
105 path = os.path.join(DEPLOY, "cloud-init", "render.sh")
106 with open(path, encoding="utf-8") as f:
107 text = f.read()
e21f6c5e
Keenan Nemetz
3d ago
108 lists = {}
109 for match in re.finditer(r"^\s*render (\S+\.yaml) (.+)$", text, re.MULTILINE):
110 lists[match.group(1)] = match.group(2).split()
111 if not lists:
112 raise SystemExit(f"{path}: no render lines naming a template")
113 return lists
dbae2e01
Keenan Nemetz
3d ago
114
115
e21f6c5e
Keenan Nemetz
3d ago
116 def render(template, names):
dbae2e01
Keenan Nemetz
3d ago
117 missing = [n for n in names if n not in SAMPLE]
118 if missing:
119 raise SystemExit(f"render.sh names placeholders with no sample value: {missing}")
e21f6c5e
Keenan Nemetz
3d ago
120 path = os.path.join(DEPLOY, "cloud-init", template)
dbae2e01
Keenan Nemetz
3d ago
121 with open(path, encoding="utf-8") as f:
122 text = f.read()
123 for name in names:
124 text = text.replace("${" + name + "}", SAMPLE[name])
125 return text
126
127
e21f6c5e
Keenan Nemetz
3d ago
128 def check_cloud_init(template, names):
dbae2e01
Keenan Nemetz
3d ago
129 bad = 0
e21f6c5e
Keenan Nemetz
3d ago
130 where = f"deploy/cloud-init/{template}"
131 rendered = render(template, names)
132
dbae2e01
Keenan Nemetz
3d ago
133 if not rendered.startswith("#cloud-config"):
e21f6c5e
Keenan Nemetz
3d ago
134 bad |= fail("the first line must be #cloud-config", where)
dbae2e01
Keenan Nemetz
3d ago
135
136 left = re.findall(r"\$\{\w+\}", rendered)
137 if left:
e21f6c5e
Keenan Nemetz
3d ago
138 bad |= fail(f"placeholders left after rendering: {sorted(set(left))}", where)
dbae2e01
Keenan Nemetz
3d ago
139
140 doc = yaml.safe_load(rendered)
141 if not isinstance(doc, dict):
e21f6c5e
Keenan Nemetz
3d ago
142 return bad | fail("the document is not a mapping", where)
dbae2e01
Keenan Nemetz
3d ago
143
144 for key in ("hostname", "packages", "write_files", "runcmd"):
145 if key not in doc:
e21f6c5e
Keenan Nemetz
3d ago
146 bad |= fail(f"missing {key}", where)
dbae2e01
Keenan Nemetz
3d ago
147
e21f6c5e
Keenan Nemetz
3d ago
148 if doc.get("hostname") not in (SAMPLE["hostname"], SAMPLE["runner_hostname"]):
149 bad |= fail("hostname was not substituted", where)
dbae2e01
Keenan Nemetz
3d ago
150
151 files = {f.get("path"): f for f in doc.get("write_files") or []}
e21f6c5e
Keenan Nemetz
3d ago
152 for wanted in TEMPLATES[template]["files"]:
dbae2e01
Keenan Nemetz
3d ago
153 if wanted not in files:
e21f6c5e
Keenan Nemetz
3d ago
154 bad |= fail(f"no write_files entry for {wanted}", where)
dbae2e01
Keenan Nemetz
3d ago
155
e21f6c5e
Keenan Nemetz
3d ago
156 for path in TEMPLATES[template]["files"]:
157 mode = files.get(path, {}).get("permissions")
158 if not isinstance(mode, str) or not re.fullmatch(r"0[0-7]{3}", mode):
159 bad |= fail(f"{path} permissions {mode!r} is not a literal mode", where)
160
161 for path in TEMPLATES[template]["scripts"]:
162 script = files.get(path, {}).get("content")
163 if not script:
164 bad |= fail(f"{path} has no content", where)
165 continue
166 bad |= check_sh(script, f"{where} ({os.path.basename(path)})")
167
168 return bad
169
170
171 def check_vole_template(names):
172 """The forge template's two configurations, and which one the script installs."""
173 bad = 0
174 where = "deploy/cloud-init/vole.yaml"
175 doc = yaml.safe_load(render("vole.yaml", names))
176 files = {f.get("path"): f for f in doc.get("write_files") or []}
dbae2e01
Keenan Nemetz
3d ago
177
e21f6c5e
Keenan Nemetz
3d ago
178 # A public key is not a secret, and the boot script reads it as the vole user.
179 if files.get("/opt/vole-deploy/admin.pub", {}).get("permissions") not in ("0644", "0444", "0640", "0440"):
180 bad |= fail("admin.pub must be readable by the account that registers it", where)
dbae2e01
Keenan Nemetz
3d ago
181
e21f6c5e
Keenan Nemetz
3d ago
182 script = files.get("/usr/local/sbin/vole-first-boot", {}).get("content", "")
183 # The configuration the script installs has to be chosen by the mode, not
184 # hardcoded to one of them.
dbae2e01
Keenan Nemetz
3d ago
185 if 'vole.$TLS_MODE.toml" /etc/vole/vole.toml' not in script:
e21f6c5e
Keenan Nemetz
3d ago
186 bad |= fail("the boot script does not install the configuration the mode names", where)
dbae2e01
Keenan Nemetz
3d ago
187 if "TLS_MODE='" not in script:
e21f6c5e
Keenan Nemetz
3d ago
188 bad |= fail("the boot script does not assign TLS_MODE", where)
189 return bad
190
191
192 def check_runner_template(names):
193 """The runner template, the packaged unit, and the argument file agree.
194
195 Three files describe one runner by hand: the template writes its arguments,
196 the unit reads them, and the package ships the example the operator copies.
9704d735
Keenan Nemetz
5h ago
197 A name that drifts between them leaves a machine enabled and idle. The same
198 goes for the two runtime drop-ins and the one sandbox setting that lets a
199 rootless runtime map the caller into a container: a machine that loses any of
200 them takes a job and fails it before a step runs.
e21f6c5e
Keenan Nemetz
3d ago
201 """
202 bad = 0
203 where = "deploy/cloud-init/runner.yaml"
204 doc = yaml.safe_load(render("runner.yaml", names))
205 files = {f.get("path"): f for f in doc.get("write_files") or []}
dbae2e01
Keenan Nemetz
3d ago
206
e21f6c5e
Keenan Nemetz
3d ago
207 env = files.get("/opt/vole-runner/runner.env", {}).get("content", "")
208 args = [line for line in env.splitlines() if line.startswith("VOLE_RUNNER_ARGS=")]
209 if len(args) != 1:
210 return fail("runner.env must set VOLE_RUNNER_ARGS exactly once", where)
211 if f"--remote ssh://git@{SAMPLE['forge_host']}:2222" not in args[0]:
212 bad |= fail("VOLE_RUNNER_ARGS does not point at the instance", where)
213 if SAMPLE["runner_labels"] not in args[0]:
214 bad |= fail("VOLE_RUNNER_ARGS does not carry the labels", where)
18c559ea
Keenan Nemetz
3d ago
215 if SAMPLE["runner_jobs"] not in args[0]:
216 bad |= fail("VOLE_RUNNER_ARGS does not carry the job count", where)
4e3759da
Keenan Nemetz
4h ago
217 # A runner built from this file must come up with finite limits. The container
218 # limits are the operator's settings, and a machine with none is one job away from
219 # being out of service for every repository it serves (#211).
220 for flag, value in (("--container-memory", "4G"), ("--container-pids", "2048"),
221 ("--container-tmpfs", "1G"), ("--disk-reserve", "2G")):
222 if f"{flag} {value}" not in args[0]:
223 bad |= fail(f"VOLE_RUNNER_ARGS does not set {flag}", where)
e21f6c5e
Keenan Nemetz
3d ago
224
225 script = files.get("/usr/local/sbin/vole-runner-first-boot", {}).get("content", "")
226 for needed in ("install -m 0640 /opt/vole-runner/runner.env /etc/vole/runner.env",
227 "systemctl enable vole-runner",
228 "/etc/vole/runner_known_hosts"):
229 if needed not in script:
230 bad |= fail(f"the boot script never mentions {needed!r}", where)
231 if "--known-hosts /etc/vole/runner_known_hosts" not in args[0]:
232 bad |= fail("the runner checks a different known_hosts than the script pins", where)
233
234 unit = read("packaging/systemd/vole-runner.service")
235 if "ExecStart=/usr/bin/vole runner $VOLE_RUNNER_ARGS" not in unit:
236 bad |= fail("the unit does not run the runner with VOLE_RUNNER_ARGS",
237 "packaging/systemd/vole-runner.service")
238 if "EnvironmentFile=-/etc/vole/runner.env" not in unit:
239 bad |= fail("the unit does not read /etc/vole/runner.env",
240 "packaging/systemd/vole-runner.service")
241
242 example = read("packaging/runner.env")
243 if "VOLE_RUNNER_ARGS=--remote ssh://git@" not in example:
244 bad |= fail("the shipped example has no working argument line", "packaging/runner.env")
245
246 package = read("packaging/nfpm.yaml")
247 for needed in ("packaging/systemd/vole-runner.service", "packaging/runner.env"):
248 if needed not in package:
249 bad |= fail(f"{needed} is not in the package", "packaging/nfpm.yaml")
9704d735
Keenan Nemetz
5h ago
250
251 # What makes a fresh Debian or Ubuntu machine able to create a container at
252 # all: no unit sandbox that defeats setuid newuidmap, a registry to resolve a
253 # short image name against, and a cgroup manager that needs no user session.
254 # Missing any of them reads on the instance as a repository whose build
255 # failed, so each is checked by name and by the setting it exists for.
256 if "NoNewPrivileges=false" not in unit:
257 bad |= fail("the unit stops rootless runtimes from mapping the caller into a container",
258 "packaging/systemd/vole-runner.service")
259 dropins = (
260 ("packaging/containers/registries.conf",
261 "/etc/containers/registries.conf.d/50-vole-runner.conf",
262 "unqualified-search-registries"),
263 ("packaging/containers/containers.conf",
264 "/etc/containers/containers.conf.d/50-vole-runner.conf",
265 "cgroup_manager"),
266 )
267 for src, dst, setting in dropins:
268 if not os.path.exists(os.path.join(ROOT, src)):
269 bad |= fail("missing", src)
270 continue
271 if setting not in read(src):
272 bad |= fail(f"does not set {setting}", src)
273 if f"src: {src}" not in package or f"dst: {dst}" not in package:
274 bad |= fail(f"{src} is not installed as {dst}", "packaging/nfpm.yaml")
dbae2e01
Keenan Nemetz
3d ago
275 return bad
276
277
e21f6c5e
Keenan Nemetz
3d ago
278 def read(rel):
279 with open(os.path.join(ROOT, rel), encoding="utf-8") as f:
280 return f.read()
281
282
dbae2e01
Keenan Nemetz
3d ago
283 def check_sh(text, where):
284 result = subprocess.run(["sh", "-n"], input=text, text=True, capture_output=True)
285 if result.returncode != 0:
286 return fail(result.stderr.strip(), where)
287 return 0
288
289
290 def check_scripts():
291 bad = 0
9bce3723
Keenan Nemetz
3d ago
292 for rel in ("cloud-init/render.sh",):
dbae2e01
Keenan Nemetz
3d ago
293 path = os.path.join(DEPLOY, rel)
294 if not os.path.exists(path):
295 bad |= fail("missing", f"deploy/{rel}")
296 continue
297 if not os.access(path, os.X_OK):
298 bad |= fail("is not executable", f"deploy/{rel}")
299 with open(path, encoding="utf-8") as f:
300 bad |= check_sh(f.read(), f"deploy/{rel}")
301 return bad
302
303
304 def main():
305 bad = 0
306 bad |= check_yaml_files()
e21f6c5e
Keenan Nemetz
3d ago
307
308 lists = placeholder_lists()
309 for template in TEMPLATES:
310 if template not in lists:
311 bad |= fail(f"render.sh does not render it", f"deploy/cloud-init/{template}")
312 continue
313 bad |= check_cloud_init(template, lists[template])
314
315 if "vole.yaml" in lists:
316 bad |= check_vole_template(lists["vole.yaml"])
317 if "runner.yaml" in lists:
318 bad |= check_runner_template(lists["runner.yaml"])
319
dbae2e01
Keenan Nemetz
3d ago
320 bad |= check_scripts()
321 return 1 if bad else 0
322
323
324 if __name__ == "__main__":
325 sys.exit(main())