vole
Clone
Read only, no credentials:
https://rodent.software/git/vole.git
Write over SSH with a registered key:
git clone ssh://git@0.0.0.0:2222/vole.git
A free and open-source forge that lives in your Git repo.
Merge change request 01M70WES5ZZDJ6RP226WB11BBZ: Let people rename and remove their own passkeys
keenan · 16m ago
4b8c9e1d
| 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 |
| 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. |
| 7 | |
| 8 | What is checked: |
| 9 | |
| 10 | - every YAML file under deploy/ parses; |
| 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. |
| 18 | |
| 19 | It needs python3 and PyYAML, and nothing else: no container runtime, no cloud |
| 20 | credentials, and no network. |
| 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 | |
| 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. |
| 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", |
| 42 | "vole_package_url": "https://rodent.software/vole/releases/v0.2.0/artifacts/vole_0.2.0-1_amd64.deb", |
| 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", |
| 48 | "runner_jobs": "--jobs 2", |
| 49 | "runner_memory": "4G", |
| 50 | "runner_pids": "2048", |
| 51 | "runner_tmpfs": "1G", |
| 52 | "runner_disk_reserve": "2G", |
| 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 | }, |
| 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 | |
| 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 | """ |
| 105 | path = os.path.join(DEPLOY, "cloud-init", "render.sh") |
| 106 | with open(path, encoding="utf-8") as f: |
| 107 | text = f.read() |
| 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 |
| 114 | |
| 115 | |
| 116 | def render(template, names): |
| 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}") |
| 120 | path = os.path.join(DEPLOY, "cloud-init", template) |
| 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 | |
| 128 | def check_cloud_init(template, names): |
| 129 | bad = 0 |
| 130 | where = f"deploy/cloud-init/{template}" |
| 131 | rendered = render(template, names) |
| 132 | |
| 133 | if not rendered.startswith("#cloud-config"): |
| 134 | bad |= fail("the first line must be #cloud-config", where) |
| 135 | |
| 136 | left = re.findall(r"\$\{\w+\}", rendered) |
| 137 | if left: |
| 138 | bad |= fail(f"placeholders left after rendering: {sorted(set(left))}", where) |
| 139 | |
| 140 | doc = yaml.safe_load(rendered) |
| 141 | if not isinstance(doc, dict): |
| 142 | return bad | fail("the document is not a mapping", where) |
| 143 | |
| 144 | for key in ("hostname", "packages", "write_files", "runcmd"): |
| 145 | if key not in doc: |
| 146 | bad |= fail(f"missing {key}", where) |
| 147 | |
| 148 | if doc.get("hostname") not in (SAMPLE["hostname"], SAMPLE["runner_hostname"]): |
| 149 | bad |= fail("hostname was not substituted", where) |
| 150 | |
| 151 | files = {f.get("path"): f for f in doc.get("write_files") or []} |
| 152 | for wanted in TEMPLATES[template]["files"]: |
| 153 | if wanted not in files: |
| 154 | bad |= fail(f"no write_files entry for {wanted}", where) |
| 155 | |
| 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 []} |
| 177 | |
| 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) |
| 181 | |
| 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. |
| 185 | if 'vole.$TLS_MODE.toml" /etc/vole/vole.toml' not in script: |
| 186 | bad |= fail("the boot script does not install the configuration the mode names", where) |
| 187 | if "TLS_MODE='" not in script: |
| 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. |
| 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. |
| 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 []} |
| 206 | |
| 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) |
| 215 | if SAMPLE["runner_jobs"] not in args[0]: |
| 216 | bad |= fail("VOLE_RUNNER_ARGS does not carry the job count", where) |
| 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) |
| 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") |
| 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") |
| 275 | return bad |
| 276 | |
| 277 | |
| 278 | def read(rel): |
| 279 | with open(os.path.join(ROOT, rel), encoding="utf-8") as f: |
| 280 | return f.read() |
| 281 | |
| 282 | |
| 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 |
| 292 | for rel in ("cloud-init/render.sh",): |
| 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() |
| 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 | |
| 320 | bad |= check_scripts() |
| 321 | return 1 if bad else 0 |
| 322 | |
| 323 | |
| 324 | if __name__ == "__main__": |
| 325 | sys.exit(main()) |
| 326 |