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.
|
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()) |