cloud/cloud_init.rs
1//! Cloud-init template renderer for mirror-machine bootstrap (R040-F4).
2//!
3//! Loads the YAML template from `.yah/cloud/cloud-init/mirror.yml` (with a
4//! built-in fallback for portability) and substitutes per-machine values:
5//! machine name, yah-yubaba release-archive URL + sha256, Headscale pre-auth
6//! key, and mesh tags. The output is the `user_data` string passed to
7//! `MachineProvider::create_server`.
8//!
9//! Hetzner caps `user_data` at 32KiB so the yubaba binary cannot be
10//! base64-embedded (R040-F11). The first-boot `runcmd` fetches the release
11//! tar.gz (matching what `.github/workflows/release.yml` publishes), verifies
12//! sha256 against the archive, extracts, and installs `/usr/local/bin/yubaba`
13//! before the systemd hand-off.
14//!
15//! @yah:ticket(R040-F11, "yah-yubaba delivery: fetch from URL instead of base64 (Hetzner 32KiB user_data cap)")
16//! @yah:at(2026-05-05T00:00:42Z)
17//! @yah:status(review)
18//! @yah:assignee(agent:claude)
19//! @yah:parent(R040)
20//! @arch:see(architecture/PHASE_1_MIRROR_BOOTSTRAP.md)
21//! @yah:verify("cargo test -p cloud")
22//! @yah:verify("cargo test -p yah --bin yah cloud::")
23//! @yah:verify("yah cloud machine provision noisetable-pdx-1 --path /Users/user/ss/noisetable --dry-run --yubaba-url https://example.com/yubaba --yubaba /tmp/yubaba — renders curl + sha256sum runcmd lines, computes sha from local file")
24//! @yah:handoff("Cloud-init now fetches yah-yubaba via URL + sha256 verify instead of base64 inline (Hetzner 32KiB user_data cap). RenderInput swapped warden_base64 for warden_url + warden_sha256; template runcmd does curl -o + chmod +x + 'echo SHA bin | sha256sum -c -'. CLI provision flags: --yubaba-url <URL> required for live; --yubaba-sha256 <HEX> and/or --yubaba <PATH> (compute or assert sha); --dry-run falls back to placeholders. New helper resolve_warden_delivery() in app/yah/cli/src/cloud.rs covers all five flag combos with 7 unit tests. Cumulative: 30 cloud-crate tests pass (incl. new render_stays_under_hetzner_user_data_cap which fails the build if rendering ever blows past 32 KiB), 17 yah cloud:: tests pass. PHASE_1_MIRROR_BOOTSTRAP.md updated. Side fix: status.rs FakeProvider needed a one-line delete_bucket stub to compile (R040-F12 has the real impl in review).")
25//! @yah:next("Operator runbook (R040-T6) is now unblocked: publish yah-yubaba Linux musl binary at a stable URL (GitHub release artifact for the workspace.package version), then run `yah cloud machine provision noisetable-$region-1 --yubaba-url <URL> --yubaba <local-copy> --path /Users/user/ss/noisetable` for region in pdx iad fsn.")
26//! @yah:next("Optional polish: derive a default --yubaba-url from workspace.package.version + a hardcoded GitHub repo so operators don't have to retype the URL per region. Skip until release-plz (R038-F3) is wired so the artifact actually exists at a predictable path.")
27//!
28//! @yah:ticket(R040-F15, "Cloudflare Tunnel in cloud-init: cloudflared install + token, no public ports needed")
29//! @yah:at(2026-05-05T00:00:42Z)
30//! @yah:assignee(agent:claude)
31//! @yah:status(review)
32//! @yah:parent(R040)
33//! @yah:handoff("Architecture decision (this session, recorded so future-self doesn't re-litigate): public ingress for yah-cloud nodes is Cloudflare Tunnels — `cloudflared` runs on each origin, makes an OUTBOUND connection to CF edge, CF proxies inbound traffic through the tunnel. Origin needs zero inbound exposure (no port 80/443 open, no stable IPv4, no floating IP plumbing). DNS records point at `<tunnel-id>.cfargotunnel.com` and never churn when boxes are replaced. Inter-node TCP (Postgres replication, gRPC streams, NATS) goes over Headscale mesh — see R040-F16. Combined effect: Hetzner inline IPs are fine forever, IPv6-only is even an option (saves ~€0.50/mo per box; needs validating that tailscale install + apt mirrors work over v6).")
34//! @yah:next("cloud-init template: add `cloudflared service install --token {{CLOUDFLARED_TOKEN}}` + `systemctl enable --now cloudflared` to runcmd, parallel to the existing tailscale install. Token comes from RenderInput.cloudflared_token, sourced from `keys::KeysStore::open().get(\"cloudflare-tunnel-token\")` in cli/src/cloud.rs::handle_provision.")
35//! @yah:next("MachineConfig.cloudflared: Option<String> — tunnel-id this machine joins. Empty/None means \"no tunnel\" (mesh-only nodes that don't need public ingress). When set, the cloud-init renderer wires the right token in.")
36//! @yah:next("Optional: `yah cloud tunnel {create,list,destroy}` subcommand against the CF API. Lower-priority — operators can use cloudflared CLI directly until this matters at fleet scale.")
37//! @yah:next("Caveat to flag in handoff: Free + Pro tier CF Tunnels is HTTP/WebSocket/gRPC only. Raw TCP/UDP ingress (rare for yah, but possible — e.g. a public Postgres for some integration) needs CF Spectrum (paid) OR a primary IP for that one service. Don't pre-build the second path; design records this as a known constraint.")
38//! @arch:see(architecture/PHASE_1_MIRROR_BOOTSTRAP.md)
39//!
40//! @yah:ticket(R441-B3, "cloud_init mirror.yml drift between cloud/templates and .yah/infra/cloud-init")
41//! @yah:assignee(agent:claude)
42//! @yah:at(2026-06-04T22:56:14Z)
43//! @yah:status(review)
44//! @yah:parent(R441)
45//! @yah:next("embedded_template_matches_workspace_canonical at cloud_init.rs:489 panics: the two mirror.yml files diverged. crates/yah/cloud/templates/mirror.yml (canonical, R406-T13/W154) has yubaba + kamaji + yubaba.slice systemd units plus the kamaji.service unit; .yah/infra/cloud-init/mirror.yml still has the older single yah-yubaba.service shape with no kamaji.")
46//! @yah:next("Pick the source of truth (the canonical-template path that the embedded test enforces was meant to be the cloud/templates copy) and sync the other file to match. Re-run the test to confirm.")
47//! @yah:verify("cargo test -p cloud --lib cloud_init::tests::embedded_template_matches_workspace_canonical passes")
48//! @yah:handoff("Overwrote crates/yah/cloud/templates/mirror.yml to match .yah/infra/cloud-init/mirror.yml (W154/R406-T13 yubaba+kamaji+yubaba.slice format). The workspace file was the canonical updated version; the embedded template hadn't been synced. cargo test -p cloud --lib cloud_init::tests::embedded_template_matches_workspace_canonical passes.")
49//!
50//! @yah:ticket(R589-F1, "Rename service/binary emitters warden→yubaba in the provision path so new nodes provision as yubaba")
51//! @yah:status(review)
52//! @yah:at(2026-07-06T06:13:22Z)
53//! @yah:assignee(agent:claude)
54//! @yah:parent(R589)
55//! @yah:handoff("Hard-cut warden→yubaba across the provision-path emitters (no shims/aliases). cloud_init.rs: RenderInput fields warden_url/warden_sha256/warden_channel/warden_cosign_identity_regexp → yubaba_*; placeholders {{YAH_WARDEN_URL}}/{{YAH_WARDEN_SHA256}}/{{WARDEN_CHANNEL}}/{{UFW_WARDEN_RULE}} → {{YAH_YUBABA_URL}}/{{YAH_YUBABA_SHA256}}/{{YUBABA_CHANNEL}}/{{UFW_YUBABA_RULE}}; PLACEHOLDER_WARDEN_URL/SHA256 → PLACEHOLDER_YUBABA_URL/SHA256; DEFAULT_WARDEN_CHANNEL → DEFAULT_YUBABA_CHANNEL; compute_warden_sha256() → compute_yubaba_sha256(). templates/mirror.yml + its workspace-canonical twin .yah/infra/cloud-init/mirror.yml (drift-tested against each other) got matching placeholder/env renames, incl. the systemd drop-in's Environment=WARDEN_CHANNEL→YUBABA_CHANNEL. provision.rs's build_request() params + release_manifest.rs's WardenReleaseManifest→YubabaReleaseManifest / DEFAULT_WARDEN_COSIGN_IDENTITY→DEFAULT_YUBABA_COSIGN_IDENTITY followed (they feed straight into RenderInput). yubaba-test-harness/src/lib.rs: YAH_WARDEN_URL/SHA256 env vars → YAH_YUBABA_URL/SHA256, plus the local build_smoke_cloud_init()/wait_for_warden_health() helpers renamed to match. local_docker.rs's cloud_init_boots_warden_service test renamed + its template placeholders updated. Cross-crate consumers outside oss/yubaba also updated in the same pass (real compile deps, not board-protocol scope creep): app/yah/cli/src/{cloud.rs,cli.rs,yubaba_fetch.rs} + tests/camp_yubaba_fetch.rs. Fenced OFF (belongs to R592-T4, wire-layer type/client renames): WardenHandle, YubabaRaft, YubabaRequest, WardenRoute wire types in yubaba/raft/*, yubaba-test-harness's WardenHandle, and camp.rs's WardenContainerSpec/build_warden_run_spec/DEFAULT_WARDEN_IMAGE/DEFAULT_WARDEN_HTTP_PORT/read_warden_pond_port/WardenDeploy (pond continuous-deploy runtime, not the cloud-init provisioning path). Also left untouched: name-neutral state paths (/var/lib/yah-cloud/identity.json, /run/constable/constable.sock) per this ticket's own scope fence, and stale warden_test_harness/warden_test_macros doc-comment crate names in yubaba-test-macros (pre-existing drift from an earlier, unrelated crate rename — not provision-path env/template residue). Verify: cargo check/test -p cloud -p yubaba-test-harness (oss/yubaba workspace) clean; cargo check -p yah + cargo test -p yah --lib yubaba_fetch:: + --test camp_yubaba_fetch clean from repo root. Zero remaining WARDEN_ env/placeholder refs in the provision path (grep-verified).")
56//!
57//! @yah:relay(R702, "Bounded disk everywhere — no process on a yah box grows without an explicit ceiling")
58//! @yah:at(2026-08-03T01:29:40Z)
59//! @yah:status(open)
60//! @yah:assignee(agent:bundle-anthropic-glimmerstone)
61//! @yah:next("DOCTRINE (operator, 2026-08-02, emphatic): a full disk brings the best software to its knees, and apps - Docker especially - love to eat all the space. Every single process that ever runs on a yah box must be explicitly forbidden from unbounded growth. A default that happens to be a fraction of the disk is NOT a bound. This relay makes that auditable rather than aspirational.")
62//! @yah:next("DONE ALREADY, not part of the remaining children: journald bounded on all three cloud nodes via /etc/systemd/journald.conf.d/10-yah-disk-bounds.conf (SystemMaxUse=500M, SystemKeepFree=1G, SystemMaxFileSize=50M, RuntimeMaxUse=64M) and the same block added to the TOP of runcmd in BOTH twin cloud-init templates (oss/yubaba/crates/cloud/templates/mirror.yml + .yah/infra/cloud-init/mirror.yml) so it lands before anything else on the box starts writing. us-south-001 went 755.5M -> 483M on restart. RuntimeMaxUse matters twice over: /run is tmpfs, so it bounds RAM too.")
63//! @yah:gotcha("LIVE UNBOUNDED CACHE IN OUR OWN CODE, RIGHT NOW. kamaji's mesofact bundle cache has working, unit-tested LRU eviction (BundleCache::evict_to_budget, oss/yah-base/crates/mesofact-bundle/src/store.rs:243) that is DISABLED in production: BundleBackend::cache_budget defaults to 0 (oss/kamaji/crates/kamaji-bin/src/server.rs:297) and 0 explicitly means 'unbounded, eviction off' (store.rs:207). There is no --bundle-cache-budget CLI flag in kamaji-bin/src/main.rs, and the live drop-in /etc/systemd/system/kamaji.service.d/20-bundle.conf on us-east-001 passes only --bundle-cache-dir and --bundle-origin. Net effect: every release wave materializes a new digest-keyed tree under /var/lib/yah/kamaji/bundles/ and NOTHING ever removes the old one. Only 6.3M on east today because there have been few waves - it grows monotonically with release count, forever.")
64//! @yah:gotcha("Fix shape for that child: add the flag, and make the DEFAULT non-zero rather than shipping another opt-in bound (an opt-in ceiling is how this happened). If 0-means-unbounded stays as an escape hatch it should require passing 0 explicitly, so the default path is always bounded.")
65//! @yah:gotcha("Measured baseline 2026-08-02 for whoever picks this up. us-south-001 (30G disk, 1 vCPU / 961MB): 7.2G used, /var 1.4G of which journal 755M, /var/lib/apt 295M, /var/cache 177M. us-east-001 (99G): 2.0G used, /usr 1.1G, /var 786M, containerd 154M, kamaji bundles 6.3M. us-west-001 (99G): 1.9G used. Nothing is near full today - this relay is about the derivative, not the current level.")
66//! @yah:verify("cargo test -p yah-cloud --lib cloud_init # 25/25 pass after the template edit, incl. embedded_template_matches_workspace_canonical (twin-drift guard) and rendered_runcmd_entries_are_all_strings (the colon-space YAML footgun guard) - ALREADY GREEN 2026-08-02")
67//! @yah:verify("Audit gate for closing this relay: on a freshly provisioned node, every path under /var that any yah-owned process writes to has a named ceiling, and each ceiling is asserted somewhere (unit test, systemd directive, or config) rather than assumed.")
68//! @yah:verify("journalctl --disk-usage on each cloud node stays under 500M across a week of normal operation.")
69//! @yah:assumes("The LAN/appliance nodes (us-west-011/013/014/015) need the same treatment and probably need it MORE (us-west-014 is the arm64 Pi appliance prototype - SD-card-class storage). They were not touched in the 2026-08-02 pass, which covered only the three cloud VMs.")
70//! @yah:gotcha("us-west-014 (Pi 5) VERIFIED 2026-08-02 and it is a two-filesystem box, which changes what 'bounded' means there. The NVMe design IS implemented and working - /dev/nvme0n1p1 is bind-mounted onto /var/lib/docker, /var/lib/yah-cloud and /srv/build, plus an 8 GB swapfile (enabled, 0 used), 234 G at 4%. But `/` is a 2.5 G SD-backed ext4 at 68% with ~745 M free, and /var itself is NOT on the NVMe - only those three subdirectories are. So /var/log (123 M) and /var/cache apt (170 M) sit on the tightest filesystem in the fleet. journald capped at 200M/400M-keepfree there (deliberately smaller than the cloud nodes' 500M). The general rule for this box: anything new writing under /var lands on the SD unless it gets its own bind, so a per-node ceiling has to be sized to the filesystem it actually lands on, not copied from the cloud nodes.")
71//! @yah:gotcha("Docker on us-west-014 is the ONE docker install in the fleet that is already safe by placement (data-root bind-mounted to a 234 G NVMe at 4%). Do not let that make the containerd/docker GC child look optional - the cloud nodes have containerd on the root filesystem with no GC policy (154 M on us-east-001 today).")
72//! @yah:handoff("PI APPLIANCE IMAGE BOUNDED (2026-08-02), ahead of flashing us-west-011 + us-west-013. Two unbounded growers ship with stock docker and both hit build workers hardest: json-file logging defaults to no max-size/max-file, and the BuildKit cache grows forever without builder.gc. Neither had any config - /etc/docker/daemon.json did not exist on us-west-014 or in the image layer. Added to .yah/infra/pi-image/layer/yah-build-worker.yaml: a mkdir -p hook plus a baked /etc/docker/daemon.json (json-file, max-size 10m, max-file 3, builder.gc enabled with defaultKeepStorage 20GB) and /etc/systemd/journald.conf.d/10-yah-disk-bounds.conf at 200M/400M-keepfree/25M-maxfile/32M-runtime. Layer YAML re-parses (14 hooks) and the emitted JSON body validates.")
73//! @yah:handoff("LOCKSTEP DEBT PAID. mirror.yml's new journald runcmd block obliged a matching change in its documented twin .yah/infra/cloud-init/stand-up-yubaba.sh (the SSH-deliverable transcription used for LAN nodes, W257 step 6). Written WRITE-IF-ABSENT on purpose: the standup default is the cloud-node 500M, but the Pi image bakes a tighter 200M sized to its 2.5 G SD root, and the standup runs AFTER first boot - an unconditional write would have silently regressed every Pi it touched. An existing ceiling always wins and the script echoes what it kept. bash -n clean.")
74//! @yah:handoff("W257 runbook step 5 gained a disk-ceiling verification block. The load-bearing assertion is `systemctl is-active docker`: dockerd refuses to start on a daemon.json it cannot parse, and a docker-less build worker is the entire purpose of the node gone. Runbook names the likely culprit after a docker bump (defaultKeepStorage deprecated in favour of builder.gc.policy in 27+; trixie ships 26.1.5, which honours the old key).")
75//! @yah:handoff("X86 FLEET PROVISIONING LANDED (2026-08-02), and it closes the arch-specific-ceiling gap this relay opened. New .yah/infra/preseed/{yah-x86-worker.cfg, build-iso.sh, .gitignore}. Deliberately the SAME shape as the Pi path rather than a new idiom: a Debian container does the work, the operator key is injected at build time instead of committed, and one artifact provisions every x86 box with per-box identity applied after install. build-iso.sh caches the stock trixie amd64 netinst, renders the preseed with ~/.ssh/yah.pub substituted for @@SSH_PUBKEY@@, injects it into the installer initrd (so the install is hands-off with no boot prompt to type at), regenerates md5sum.txt, and repacks a UEFI hybrid ISO with xorriso. Neither path is a roll-your-own distro - one configures rpi-image-gen, the other configures debian-installer.")
76//! @yah:handoff("PARTITIONING IS THE STRUCTURAL HALF OF THIS RELAY, and the preseed is where it finally gets decided up front instead of discovered. Separate LVs for /, /var and /var/lib/docker on VG `yah`, ~40 GB left unallocated for online lvextend. A runaway BuildKit cache now fills /var/lib/docker and NOTHING else - root stays writable, sshd keeps accepting, journald keeps recording, the box stays reachable to clean up. That is the backstop for a MISSING ceiling; it does not replace the ceilings, which the preseed late_command also writes (journald 500M + docker daemon.json with log rotation and builder.gc at 100GB, sized to the 512 GB disk).")
77//! @yah:handoff("DOCKER CEILING IS NO LONGER A PROPERTY OF ONE IMAGE. stand-up-yubaba.sh now applies /etc/docker/daemon.json write-if-absent on any node where docker is present (it installs containerd, not docker, so this is a conditional), and restarts docker THERE so a parse failure surfaces during standup rather than at the next reboot - dockerd refuses to start on a daemon.json it cannot parse. Three provisioning paths now converge on the same ceilings: Pi image (baked), preseed late_command (baked), standup script (retrofit). bash -n clean on both scripts.")
78//! @yah:handoff("Scaffolded .yah/infra/machines/us-west-012.toml for the GEEKOM A5 (Ryzen 7 5825U 8C/16T, 16 GB, 512 GB NVMe): arch:x86 + os:linux + build-worker/qed, taints copied from us-west-002 for day one. Recorded WHY it is a better arch:x86 host than 002 - 002 is a WSL2 box that sleeps and reboots with Windows, this is dedicated always-on Debian - so dropping no-server/no-appliance later is a deliberate re-decision rather than drift. Stays no-voter regardless: a residential uplink must never be able to stall the raft. allocatable is from the vendor spec with an explicit instruction to re-verify via nproc + free -m on the box, since the OVH nodes shipped wrong for months. Parses: cargo test -p yah-cloud --lib machine 24/24, `yah cloud validate` ok.")
79//! @yah:verify("UNPROVEN, and the one thing to watch: the partman-auto/expert_recipe in yah-x86-worker.cfg has never been run. A malformed recipe fails mid-install with an error that does not always name the offending stanza. Watch the first install of any ISO revision; once it completes cleanly the same ISO is proven for every later box. It also assumes ONE disk (early_command picks `list-devices disk | head -n1`), so a two-disk box needs the target pinned.")
80//! @yah:verify("UNPROVEN: build-iso.sh has not been executed - the initrd inject + xorriso repack path is written but not run, and the ISO URL pins DEBIAN_VERSION=13.1.0 which should be bumped to whatever trixie point release is current at build time (override with the env var).")
81//! @yah:verify("Cheap de-risk available before touching hardware: boot the built ISO in QEMU against a scratch qcow2 and let the unattended install run to completion. That proves the recipe, the initrd inject and the late_command without burning a USB or a trip to the box.")
82//! @yah:handoff("RENUMBERED (operator correction, 2026-08-02): the GEEKOM mini PC is us-west-003, NOT us-west-012 — the 01x block is reserved for the small LAN/appliance class and another Pi is taking 012. The convention is by HARDWARE CLASS, not arrival order: 00x = PC/server (001 OVH VPS, 002 WSL2 gamer box, 003 GEEKOM), 01x = small LAN boxes (011-014 Pis, 015 arm64 Mac). Recorded at the top of us-west-003.toml and in W257, because it is not derivable from the existing files and it is what tells a reader which of the two provisioning paths a node took. The us-west-012.toml scaffold was untracked and never committed, so it was removed rather than renamed; no stale refs remain (grep clean, `yah cloud validate` ok).")
83//! @yah:gotcha("LAN IP for us-west-003 is ASSUMED, not confirmed. W257's convention is us-west-0NN -> 192.168.10.NN and it holds for every 01x node, but the only existing 00x LAN box breaks it: us-west-002 sits at 192.168.10.30, not .2. The scaffold uses .3 with a CONFIRM-BEFORE-BRING-UP note inline. If the 00x block actually lives in the .3x range on the router, .3 is wrong and both [connect].address and [connect].ssh need correcting before step 4.")
84//! @yah:next("Filed 2026-09-21: R702-B2 (kamaji bundle-cache budget, bug/high), R702-T3 (containerd GC), R702-T4 (apt/kernel retention), R702-T5 (disk-headroom surfacing), R702-T6 (doctrine W-doc). All open, ready to claim.")
85//!
86//! @yah:ticket(R702-T3, "containerd image/snapshot GC policy for cloud nodes — no eviction, root-filesystem growth is unbounded")
87//! @yah:at(2026-09-22T05:26:57Z)
88//! @yah:status(open)
89//! @yah:assignee(agent:bundle-anthropic-miravel)
90//! @yah:parent(R702)
91//! @yah:next("Tier: Thief -- config/policy addition (containerd GC settings), no novel design.")
92//! @yah:next("Add a containerd config.toml GC policy (image/snapshot retention, analogous to the docker builder.gc block already baked for the Pi fleet in .yah/infra/pi-image/layer/yah-build-worker.yaml) to both cloud-init templates (oss/yubaba/crates/cloud/templates/mirror.yml + .yah/infra/cloud-init/mirror.yml, kept as twins per the drift guard test in this file) and to stand-up-yubaba.sh as a retrofit for already-provisioned nodes, same write-if-absent pattern used for the docker daemon.json ceiling.")
93//! @yah:next("Verify: containerd disk usage on a cloud node stays bounded across a week of image pulls, same standard as the journald 500M check R702 already established.")
94//! @yah:gotcha("The cloud nodes (us-east-001 etc) run bare containerd (`ctr -n yah`) on the root filesystem with no GC/eviction policy configured -- 154M on us-east-001 as of 2026-08-02 baseline, not urgent today but unbounded by design. us-west-014's docker install is already safe by placement (data-root bind-mounted to a 234G NVMe at 4%) -- don't let that make this child look optional; the cloud nodes have no equivalent bind.")
95//!
96//! @yah:ticket(R702-T4, "apt archive + old-kernel retention policy for the fleet")
97//! @yah:at(2026-09-22T05:27:16Z)
98//! @yah:status(open)
99//! @yah:assignee(agent:bundle-anthropic-miravel)
100//! @yah:parent(R702)
101//! @yah:next("Tier: Thief -- standard apt hygiene config (APT::Clean-Interval / apt-get autoremove --purge cron, kernel retention count), no novel design.")
102//! @yah:next("Add an apt cleanup policy to both cloud-init templates and stand-up-yubaba.sh (same write-if-absent, twin-template pattern used for journald and docker/containerd ceilings): periodic `apt-get clean` + `apt-get autoremove --purge`, and a bounded old-kernel retention count so unattended-upgrades doesn't accumulate every kernel version forever.")
103//! @yah:next("Size it per-filesystem, not copied from the cloud nodes: us-west-014's /var/cache sits on the 2.5G SD root, not the NVMe, so its retention count needs to be tighter than the 99G cloud nodes'.")
104//! @yah:gotcha("Measured 2026-08-02 baselines: us-south-001 /var/lib/apt 295M + /var/cache 177M on a 30G disk; us-west-014 /var/cache apt 170M on the tightest filesystem in the fleet (2.5G SD root). Nothing near full today -- this is about the derivative (apt archives + old kernels accumulate forever across unattended-upgrades runs), not the current level.")
105//!
106//! @yah:ticket(R702-T5, "surface per-node disk headroom in yubaba node status so pressure is visible before it is fatal")
107//! @yah:at(2026-09-22T05:27:36Z)
108//! @yah:status(open)
109//! @yah:assignee(agent:bundle-anthropic-miravel)
110//! @yah:parent(R702)
111//! @yah:next("Tier: Cleric -- needs to pick the right existing status/reporting surface to extend and reason about what 'before it's fatal' means as a threshold, but no deep architecture change.")
112//! @yah:next("Add per-filesystem free-space (or percent-full) to whatever struct/RPC yubaba already uses for node status, threading it through to wherever `yah cloud` surfaces node health.")
113//! @yah:next("Pick a threshold and surfacing mechanism (warn in `yah cloud status` output at minimum; consider whether it belongs in the same channel as existing node-health alerts, if one exists).")
114//! @yah:next("us-west-014 is the concrete motivating case (two filesystems, the tighter one is the SD root, not the NVMe) -- make sure whatever ships reports per-filesystem, not just per-node, or it would have missed exactly the failure that prompted this ticket.")
115//! @yah:gotcha("This relay's whole premise is that a full disk takes a node down silently -- the SD-card failure recorded on R702-B1 (us-west-014, 2026-09) is the concrete case: nothing surfaced the disk pressure building up before the card died. Every ceiling filed under R702 bounds growth, but none of them make the CURRENT headroom visible to an operator before it's fatal.")
116//! @yah:gotcha("Implementation likely lands in yubaba's node status/reporting path (oss/yubaba/crates/yubaba/src/lib.rs and/or wherever `yah cloud` node status is assembled), NOT in this file -- oss/yubaba/crates/yubaba/src/lib.rs is dirty (uncommitted) as of 2026-09-21, check for a live peer there before editing. This ticket is filed here per board-filing convention (one annotation per ID, homed at the parent relay's own file) -- the real edit target is named in this gotcha, not this file.")
117//!
118//! @yah:ticket(R702-T6, "write a W-doc: bounded-disk doctrine + review checklist for every new on-disk writer")
119//! @yah:at(2026-09-22T05:27:53Z)
120//! @yah:status(open)
121//! @yah:assignee(agent:bundle-anthropic-miravel)
122//! @yah:parent(R702)
123//! @yah:next("Tier: Thief -- writing up doctrine + checklist that already exists in R702's ticket history, not deriving anything new.")
124//! @yah:next("Write .yah/docs/working/W###-bounded-disk-doctrine.md: the doctrine paragraph above, plus a concrete example table like the one this relay produced (journald, docker daemon.json, containerd GC, apt retention, kamaji bundle-cache) as a worked reference.")
125//! @yah:next("Add a review-checklist line: every new on-disk writer declares its ceiling (unit test, systemd directive, or config) at the same time it declares the path -- and say where that checklist item should live (PR template? CLAUDE.md? code-review skill?) so it's actually seen at review time, not just written down.")
126//! @yah:gotcha("Operator doctrine (2026-08-02, emphatic, recorded on R702 itself): a full disk brings the best software to its knees, and apps -- Docker especially -- love to eat all the space. Every single process that ever runs on a yah box must be explicitly forbidden from unbounded growth. A default that happens to be a fraction of the disk is NOT a bound. This ticket is what makes that auditable rather than aspirational -- right now the doctrine lives only in R702's own ticket prose, nowhere a future PR reviewer would find it.")
127
128use crate::config::MachineConfig;
129use crate::release_manifest::ReleaseTrust;
130use anyhow::{bail, Context, Result};
131use sha2::{Digest, Sha256};
132use std::path::Path;
133
134/// Built-in fallback template, used when `.yah/infra/cloud-init/mirror.yml`
135/// is absent. Keeps the binary self-contained for tests + new workspaces.
136pub const DEFAULT_TEMPLATE: &str = include_str!("../templates/mirror.yml");
137
138/// Inputs needed to render `mirror.yml` for a single machine.
139#[derive(Debug)]
140pub struct RenderInput<'a> {
141 pub machine: &'a MachineConfig,
142 /// HTTPS URL of the yah-yubaba release tar.gz (e.g. a GitHub release
143 /// asset published by `.github/workflows/release.yml`). Cloud-init's
144 /// `runcmd` downloads it, verifies sha256 against [`Self::yubaba_sha256`],
145 /// extracts, and installs `/usr/local/bin/yubaba`. Use
146 /// `PLACEHOLDER_YUBABA_URL` for dry-run previews.
147 pub yubaba_url: String,
148 /// Lowercase hex sha256 of the tar.gz at `yubaba_url`. Cloud-init verifies
149 /// this with `sha256sum -c -` against the downloaded archive and fails
150 /// the boot if it doesn't match.
151 pub yubaba_sha256: String,
152 /// Release channel passed to `yah-yubaba serve --channel`. One of
153 /// `"stable"` or `"beta"`. Use [`DEFAULT_YUBABA_CHANNEL`] for Phase 1.
154 pub yubaba_channel: String,
155 /// Headscale pre-auth key. `Some` ⟺ this machine is joining an existing
156 /// mesh: the renderer emits the tailscaled install + `tailscale up` join
157 /// block (gated on this being `Some`, see [`render`]). `None` ⟺ standalone
158 /// / coordinator-to-be — no mesh to join yet, so no join block is emitted
159 /// (the node becomes the coordinator later via `yah mesh bootstrap`).
160 pub headscale_preauth_key: Option<String>,
161 /// Stable URL of the Headscale coordinator (`https://mesh.<domain>`).
162 /// When set (R040-F18), the rendered cloud-init passes
163 /// `--login-server <url>` to `tailscale up` so the new machine joins
164 /// the camp's Headscale instead of Tailscale SaaS. When `None`, the
165 /// `{{MESH_LOGIN_SERVER_ARG}}` placeholder is replaced with an empty
166 /// string, preserving the existing Tailscale SaaS behaviour.
167 pub mesh_url: Option<String>,
168 /// Cloudflare Tunnel token for `cloudflared service install --token ...`.
169 /// `None` → no cloudflared install (mesh-only node). When `Some`, the
170 /// renderer emits the full cloudflared apt-repo install + service enable
171 /// block into `runcmd` in place of `{{CLOUDFLARED_BLOCK}}`.
172 pub cloudflared_token: Option<String>,
173 /// When `Some`, render the cosign install + `cosign verify-blob` runcmd
174 /// block into `{{COSIGN_VERIFY_BLOCK}}`, gating the yubaba tarball on a
175 /// keyless OIDC signature whose certificate identity matches this regexp
176 /// (e.g. `^https://github\.com/yah-ai/yah/`). The sha256 check stays
177 /// in parallel as belt-and-suspenders (R330-F20, W203 §1.4). When `None`
178 /// the placeholder substitutes to an empty string and the bootstrap stays
179 /// on the sha256-only path.
180 pub yubaba_cosign_identity_regexp: Option<String>,
181}
182
183/// Placeholders used when the user requests a dry-run without supplying real
184/// substitutes. Makes the rendered YAML obviously non-shippable while still
185/// preserving the structure for review.
186pub const PLACEHOLDER_YUBABA_URL: &str = "<YUBABA_URL_PLACEHOLDER>";
187pub const PLACEHOLDER_YUBABA_SHA256: &str = "<YUBABA_SHA256_PLACEHOLDER>";
188pub const PLACEHOLDER_PREAUTH_KEY: &str = "<HEADSCALE_PREAUTH_KEY_PLACEHOLDER>";
189pub const PLACEHOLDER_CLOUDFLARED_TOKEN: &str = "<CLOUDFLARED_TOKEN_PLACEHOLDER>";
190
191/// Phase 1 defaults for new provisioning keys.
192pub const DEFAULT_YUBABA_CHANNEL: &str = "stable";
193/// Pinned cosign release used by the cloud-init verify-blob block. Bump in
194/// lockstep with [`COSIGN_SHA256_AMD64`] / [`COSIGN_SHA256_ARM64`] when
195/// upgrading the verifier — supply-chain hygiene (W203 §1.4, R330-F22). v3.x
196/// on purpose: cosign 3's sigstore-bundle format (`--bundle`) is current best
197/// practice, replacing the old separate `.sig`/`.cert` file pair everywhere
198/// in this pipeline (install.sh, release_manifest.rs, yubaba_fetch.rs).
199pub const COSIGN_VERSION: &str = "v3.1.3";
200/// sha256 of the cosign-linux-amd64 binary at [`COSIGN_VERSION`], from
201/// cosign's own published `cosign_checksums.txt` for that release. Cloud-init
202/// verifies the downloaded binary against this before chmod+exec. Pinning the
203/// verifier itself closes the bootstrap-trust gap: TLS to github.com proves
204/// origin, sha256 proves bytes, then cosign proves the yubaba tarball.
205pub const COSIGN_SHA256_AMD64: &str =
206 "4629c757b7618056f8ddd7e2625ae9fdd94c0372a65049520bc7d9df9efc7f71";
207/// sha256 of the cosign-linux-arm64 binary at [`COSIGN_VERSION`].
208pub const COSIGN_SHA256_ARM64: &str =
209 "c5d324e091826b0d7a78eb16fef316450b4eb9aaec045611c08ba06f5e73220a";
210/// Sigstore Fulcio OIDC issuer for GitHub-Actions-rooted keyless signing.
211/// Re-exported from [`crate::release_manifest`], which owns the canonical
212/// copy — the shell verify path here and the Rust verify path in the yah CLI
213/// must pin the same issuer or one of them silently accepts the other's
214/// rejects (R605-F1).
215pub use crate::release_manifest::COSIGN_OIDC_ISSUER;
216
217/// Where the twin-drift guard should look for the canonical `mirror.yml`.
218///
219/// The canonical copy lives at `<monorepo root>/.yah/infra/cloud-init/mirror.yml`
220/// and is what [`load_template`] — and therefore every real provision — actually
221/// reads. The embedded [`DEFAULT_TEMPLATE`] is only the fallback for when that
222/// file is absent, so the two must not be allowed to diverge.
223///
224/// Resolving that root is not as simple as "first ancestor with a `.yah/`".
225/// `oss/yubaba` is a deliberately independent cargo workspace (monorepo
226/// CLAUDE.md, "Co-developed OSS repos"), and its own `.yah/` carries nothing
227/// but a `.gitignore` — so an ancestor walk keyed on `.yah/` stops AT
228/// `oss/yubaba`, where the canonical file can never exist. That is exactly how
229/// `embedded_template_matches_workspace_canonical` spent months taking its
230/// "file not there yet, nothing to compare" branch and asserting nothing while
231/// the twin drifted 128 lines behind (R870-B25).
232///
233/// The discriminator is `.yah/infra/`: the monorepo has it (machines,
234/// cloud-init, preseed, rules, …), and a standalone export of this subtree —
235/// where the repo root genuinely *is* `oss/yubaba` — does not.
236#[derive(Debug, PartialEq, Eq)]
237pub enum CanonicalHome {
238 /// Built inside the yah monorepo. The canonical `mirror.yml` MUST exist
239 /// under this root; its absence is a defect, never a bootstrap case.
240 Monorepo(std::path::PathBuf),
241 /// Built from the standalone `yubaba` export, which ships no `.yah/infra/`
242 /// and therefore no canonical copy. Nothing on disk to compare against —
243 /// the embedded template is the only template there is.
244 StandaloneExport,
245}
246
247/// Walk `start`'s ancestors for the monorepo root that owns the canonical
248/// cloud-init template. See [`CanonicalHome`] for why `.yah/infra/` is the
249/// marker rather than `.yah/`.
250pub fn locate_canonical_home(start: &Path) -> CanonicalHome {
251 match start
252 .ancestors()
253 .find(|p| p.join(".yah").join("infra").is_dir())
254 {
255 Some(root) => CanonicalHome::Monorepo(root.to_path_buf()),
256 None => CanonicalHome::StandaloneExport,
257 }
258}
259
260/// Load the cloud-init template for a workspace.
261///
262/// Prefers `<workspace_root>/.yah/infra/cloud-init/mirror.yml` if it exists,
263/// otherwise returns the embedded [`DEFAULT_TEMPLATE`]. In the monorepo that
264/// file always exists, so **the on-disk canonical copy is what provisioning
265/// ships** — the embedded one is a fallback for fresh/exported workspaces, not
266/// the live path (R870-B25).
267pub fn load_template(workspace_root: &Path) -> Result<String> {
268 let custom = crate::paths::cloud_init_template(workspace_root);
269 if custom.exists() {
270 std::fs::read_to_string(&custom).with_context(|| format!("reading {}", custom.display()))
271 } else {
272 Ok(DEFAULT_TEMPLATE.to_string())
273 }
274}
275
276/// Substitute `{{KEY}}` placeholders. Fails loudly if any unsubstituted
277/// placeholder remains — better than silently shipping a broken cloud-init.
278pub fn render(template: &str, input: &RenderInput) -> Result<String> {
279 // Tailscale's ACL model only accepts `tag:`-prefixed values in
280 // `--advertise-tags`; mesh_tags also carries placement predicates like
281 // `arch:x86` / `os:linux` that aren't ACL tags at all, and passing those
282 // through makes `tailscale up` reject the whole join (observed manually
283 // on us-west-003/011, R757).
284 let advertise_tags: Vec<&str> = input
285 .machine
286 .mesh_tags
287 .iter()
288 .map(String::as_str)
289 .filter(|t| t.starts_with("tag:"))
290 .collect();
291 let tags = if advertise_tags.is_empty() {
292 // Tailscale rejects empty `--advertise-tags=`; emit a single tag derived from the machine name.
293 format!("tag:{}", input.machine.name)
294 } else {
295 advertise_tags.join(",")
296 };
297
298 let mesh_login_server_arg = match &input.mesh_url {
299 Some(url) => format!(" --login-server {url}"),
300 None => String::new(),
301 };
302
303 let cloudflared_block = match &input.cloudflared_token {
304 Some(token) => build_cloudflared_block(token),
305 None => String::new(),
306 };
307
308 // The tailscale-up/join block is emitted iff we have a preauth key, i.e.
309 // this machine is joining an existing mesh (R330-F28). Standalone /
310 // coordinator-to-be nodes carry no preauth and get no join block — they
311 // come up as bare yubaba and become the coordinator via `yah mesh bootstrap`.
312 let operator_bridge_block = match &input.headscale_preauth_key {
313 Some(key) => build_operator_bridge_block(key, &mesh_login_server_arg, &tags),
314 None => String::new(),
315 };
316
317 // Yubaba RPC (7443) firewall rule keys off the same axis (R330-F28 #13).
318 // JOINING (preauth present): deny public 7443 — the join block above adds
319 // an `allow in on tailscale0` so yubaba is mesh-reachable only. STANDALONE
320 // (no preauth): allow public 7443 so the operator can attach + bootstrap
321 // the coordinator before any mesh exists to reach it over.
322 let ufw_yubaba_rule = match &input.headscale_preauth_key {
323 Some(_) => " - ufw deny 7443".to_string(),
324 None => " - ufw allow 7443".to_string(),
325 };
326
327 // Coordinator pre-stage (standalone only, R330-F28 #15). yubaba runs under
328 // ProtectSystem=strict so it cannot write the headscale systemd unit nor
329 // open the firewall; cloud-init (unsandboxed) lays both down here so a later
330 // `yah mesh bootstrap` only needs to write config + `systemctl enable --now
331 // headscale`. The unit's ExecStart matches DEFAULT_HEADSCALE_DIR. Joining
332 // nodes never self-bootstrap a coordinator, so the block is empty for them.
333 let coordinator_prestage_block = match &input.headscale_preauth_key {
334 Some(_) => String::new(),
335 None => build_coordinator_prestage_block(),
336 };
337
338 let cosign_verify_block = match &input.yubaba_cosign_identity_regexp {
339 Some(regexp) => build_cosign_verify_block(&input.yubaba_url, regexp),
340 None => String::new(),
341 };
342
343 // R937-F4: the shipped yubaba.service ExecStart never carries `--region`
344 // (it ships byte-identical across every camp, so nothing per-node can
345 // live there — same reasoning as the channel drop-in above). Reach the
346 // daemon via the env fallback on `--region` instead (`YUBABA_REGION`,
347 // oss/yubaba/crates/yubaba/src/main.rs), written into the same
348 // yubaba.service.d drop-in as YUBABA_CHANNEL. A machine with no declared
349 // region emits nothing, matching this daemon's existing "unset means
350 // untagged" contract for `--region`.
351 let region_env_block = match &input.machine.region {
352 Some(region) => build_region_env_block(region),
353 None => String::new(),
354 };
355
356 let rendered = template
357 .replace("{{MACHINE_NAME}}", &input.machine.name)
358 .replace("{{YAH_YUBABA_URL}}", &input.yubaba_url)
359 .replace("{{YAH_YUBABA_SHA256}}", &input.yubaba_sha256)
360 .replace("{{YUBABA_CHANNEL}}", &input.yubaba_channel)
361 .replace(
362 "{{HEADSCALE_PREAUTH_KEY}}",
363 input.headscale_preauth_key.as_deref().unwrap_or(""),
364 )
365 .replace("{{MESH_LOGIN_SERVER_ARG}}", &mesh_login_server_arg)
366 .replace("{{TAGS}}", &tags)
367 .replace("{{CLOUDFLARED_BLOCK}}", &cloudflared_block)
368 .replace("{{OPERATOR_BRIDGE_BLOCK}}", &operator_bridge_block)
369 .replace("{{UFW_YUBABA_RULE}}", &ufw_yubaba_rule)
370 .replace(
371 "{{COORDINATOR_PRESTAGE_BLOCK}}",
372 &coordinator_prestage_block,
373 )
374 .replace("{{COSIGN_VERIFY_BLOCK}}", &cosign_verify_block)
375 .replace("{{REGION_ENV_BLOCK}}", ®ion_env_block);
376
377 if let Some(remnant) = find_unsubstituted(&rendered) {
378 bail!("cloud-init template has unsubstituted placeholder: {remnant}");
379 }
380 Ok(rendered)
381}
382
383/// Read a yah-yubaba release tar.gz from disk and return its lowercase hex
384/// sha256. Used both as the cloud-init verification digest and for asserting
385/// that a local copy matches an expected `--yubaba-sha256` value. The path
386/// should point to the release archive (matching what cloud-init downloads),
387/// not a bare binary.
388pub fn compute_yubaba_sha256(path: &Path) -> Result<String> {
389 let bytes = std::fs::read(path)
390 .with_context(|| format!("reading yah-yubaba archive at {}", path.display()))?;
391 let mut hasher = Sha256::new();
392 hasher.update(&bytes);
393 Ok(hex::encode(hasher.finalize()))
394}
395
396/// Build the cloudflared apt-repo install + tunnel-connect block for `runcmd`.
397/// Each line is a valid cloud-init sequence entry (two-space indent + `- `).
398/// The block replaces `{{CLOUDFLARED_BLOCK}}` in the template; when empty it
399/// leaves a blank line in the rendered YAML (harmless to the YAML parser).
400fn build_cloudflared_block(token: &str) -> String {
401 [
402 " - mkdir -p --mode=0755 /usr/share/keyrings".to_string(),
403 " - sh -c 'curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg | gpg --dearmor > /usr/share/keyrings/cloudflare-main.gpg'".to_string(),
404 " - sh -c 'echo \"deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared bookworm main\" > /etc/apt/sources.list.d/cloudflared.list'".to_string(),
405 " - apt-get update -qq".to_string(),
406 " - apt-get install -y cloudflared".to_string(),
407 // The token is a POSITIONAL argument, not a `--token` flag: the
408 // `service install` subcommand has no `--token` option, so
409 // `cloudflared service install --token <tok>` fails arg-parsing with
410 // "flag provided but not defined: -token", prints usage, and NEVER
411 // creates cloudflared.service. The `systemctl enable --now` below then
412 // fails with "Unit file cloudflared.service does not exist", leaving
413 // the tunnel inactive with an empty journal (R330-B29, found on the
414 // us-west-001 from-zero validation). `service install <TOKEN>` already
415 // installs+enables+starts the unit; the explicit enable is redundant
416 // but harmless now that the unit exists.
417 format!(" - cloudflared service install {token}"),
418 " - systemctl enable --now cloudflared".to_string(),
419 ]
420 .join("\n")
421}
422
423/// Build the `runcmd` line that writes `YUBABA_REGION=<region>` into the same
424/// `yubaba.service.d/channel.conf` drop-in the channel line above writes
425/// (R937-F4). One line, valid cloud-init sequence entry (two-space indent +
426/// `- `); when the machine has no declared region the caller substitutes an
427/// empty string instead of calling this, matching the CLOUDFLARED_BLOCK /
428/// COSIGN_VERIFY_BLOCK convention of "empty means this axis is unset here".
429fn build_region_env_block(region: &str) -> String {
430 // Appended (`>>`, no repeated `[Service]` header) to the same drop-in the
431 // channel line above just created with `>` — the file already ends inside
432 // a `[Service]` section, and systemd reads further `Environment=` lines in
433 // that same section without needing the header repeated.
434 format!(
435 " - sh -c 'printf \"Environment=YUBABA_REGION={region}\\n\" >> /etc/systemd/system/yubaba.service.d/channel.conf'"
436 )
437}
438
439/// Build the cosign install + `cosign verify-blob` block for `runcmd`. Each
440/// line is a valid cloud-init sequence entry (two-space indent + `- `). The
441/// `.sigstore.json` bundle URL derives by appending that suffix to the
442/// tarball URL — matching what the release pipeline publishes alongside the
443/// canonical artifact (R330-F19). Architecture is detected at boot via
444/// `dpkg --print-architecture` so one rendered template serves both x86_64
445/// and aarch64 Hetzner machines.
446///
447/// R605-F1: `identity_spec` is parsed as a [`ReleaseTrust`], so a fleet whose
448/// releases are cut on QED (key-based cosign, no Fulcio) provisions by setting
449/// `key:<pubkey-ref>`. cosign 3.x's bundle carries the signature (and, for
450/// keyless, the certificate + transparency proof) as one file either way, so
451/// unlike the pre-bundle format there is no second sidecar fetch that drops
452/// out for key trust — the bundle download is unconditional.
453fn build_cosign_verify_block(yubaba_url: &str, identity_spec: &str) -> String {
454 // ARCH + SHA must be set, checked, and consumed inside the same `sh -c`
455 // process — cloud-init runcmd entries are independent shells, so a
456 // multi-step download-then-verify-then-install split would lose the
457 // variables between lines. One `sh -c` keeps the pin atomic.
458 //
459 // NB: runcmd entries are emitted as bare (unquoted) YAML scalars, so a
460 // `: ` (colon-space) anywhere in the command makes the YAML parser read
461 // the entry as a `key: value` MAPPING — cloud-init's shellify then chokes
462 // on the dict and SKIPS THE ENTIRE runcmd block (R330-F28 from-zero
463 // validation, pothole #12). Keep this string colon-space-free: the error
464 // message says "unsupported arch <ARCH>", not "unsupported arch: <ARCH>".
465 let install_and_verify_cosign = format!(
466 " - sh -c 'set -e; ARCH=$(dpkg --print-architecture); \
467 case \"$ARCH\" in \
468 amd64) SHA=\"{amd64}\";; \
469 arm64) SHA=\"{arm64}\";; \
470 *) echo \"unsupported arch $ARCH\" >&2; exit 1;; \
471 esac; \
472 curl -fsSL \"https://github.com/sigstore/cosign/releases/download/{ver}/cosign-linux-${{ARCH}}\" -o /usr/local/bin/cosign; \
473 echo \"${{SHA}} /usr/local/bin/cosign\" | sha256sum -c -; \
474 chmod +x /usr/local/bin/cosign'",
475 amd64 = COSIGN_SHA256_AMD64,
476 arm64 = COSIGN_SHA256_ARM64,
477 ver = COSIGN_VERSION
478 );
479 let trust = ReleaseTrust::parse(identity_spec);
480 // Single-quote each VALUE token: the regexp arm carries backslashes and
481 // `^` that the boot shell would otherwise eat, and the key arm carries a
482 // URI. Flag names (the `--xxx` tokens, including standalone boolean
483 // flags like `--insecure-ignore-tlog` that take no value) are emitted
484 // bare — they're static literals, never operator input. This can't
485 // assume flag/value PAIRS any more: the key arm's flag list is now
486 // [--key, key_ref, --insecure-ignore-tlog], an odd length, because
487 // cosign key-mode signs with no transparency-log upload (see
488 // ReleaseTrust::verify_flags) and the verify side has to say so too. No
489 // value may contain a `'` — a spec that does is an operator error, not a
490 // case to escape, since it can't be a valid identity regexp or key ref.
491 let trust_flags = trust
492 .verify_flags()
493 .into_iter()
494 .map(|tok| {
495 if tok.starts_with("--") {
496 tok
497 } else {
498 format!("'{tok}'")
499 }
500 })
501 .collect::<Vec<_>>()
502 .join(" ");
503
504 let lines = vec![
505 install_and_verify_cosign,
506 format!(" - curl -fsSL -o /tmp/yah-yubaba.tar.gz.sigstore.json {yubaba_url}.sigstore.json"),
507 format!(
508 " - cosign verify-blob {trust_flags} --bundle /tmp/yah-yubaba.tar.gz.sigstore.json /tmp/yah-yubaba.tar.gz"
509 ),
510 ];
511 lines.join("\n")
512}
513
514/// Build the tailscaled operator-bridge block for `runcmd`.
515/// Each line is a valid cloud-init sequence entry (two-space indent + `- `).
516/// The block replaces `{{OPERATOR_BRIDGE_BLOCK}}` in the template; when the
517/// machine does not host operator-bridge workloads the placeholder is replaced
518/// with an empty string (harmless blank line in the rendered YAML).
519fn build_operator_bridge_block(
520 preauth_key: &str,
521 mesh_login_server_arg: &str,
522 tags: &str,
523) -> String {
524 // `--accept-dns=false` is load-bearing, not a preference (R624-B1).
525 //
526 // With MagicDNS accepted, tailscaled rewrites /etc/resolv.conf to point at
527 // its own resolver (100.100.100.100). If tailscaled is then down — as it is
528 // on every boot before it connects — nothing answers that address, so the
529 // node cannot resolve the control server, so tailscaled can never come up.
530 // The loop is self-sustaining and survives reboots and restarts; it took
531 // us-south-001 (a raft voter) off the mesh for 30+ hours until a human
532 // edited resolv.conf by hand. Ordering the unit After=tailscaled does NOT
533 // help: tailscaled starts fine, it just can never CONNECT.
534 //
535 // Server nodes have nothing to lose here. Nothing on a node resolves a
536 // mesh name: yubaba binds a literal 100.64.0.0/10 address, the machine
537 // TOMLs carry literal IPs, and kamaji reaches its peers over a unix socket.
538 // MagicDNS buys a convenience we never use, at the cost of a node that can
539 // permanently strand itself.
540 [
541 " - curl -fsSL https://tailscale.com/install.sh | sh".to_string(),
542 format!(" - tailscale up{mesh_login_server_arg} --auth-key={preauth_key} --advertise-tags={tags} --accept-dns=false"),
543 " - ufw allow in on tailscale0 to any port 7443".to_string(),
544 ]
545 .join("\n")
546}
547
548/// Build the coordinator pre-stage block for `runcmd` (R330-F28 #15). Emitted
549/// only for STANDALONE nodes (no preauth). cloud-init runs unsandboxed at first
550/// boot, so it lays down the headscale systemd unit + opens ufw 80/443 (the LE
551/// HTTP-01 challenge + the headscale `listen_addr :443`). yubaba runs under
552/// `ProtectSystem=strict` and cannot write `/etc/systemd/system` or `/etc/ufw`,
553/// so `yah mesh bootstrap` only downloads the headscale binary, writes config,
554/// and `systemctl enable --now headscale` against this pre-staged unit.
555///
556/// The unit's `ExecStart` must match yubaba's `DEFAULT_HEADSCALE_DIR`
557/// (`/var/lib/yah-cloud/headscale` — under the systemd StateDirectory, writable
558/// at runtime; see R330-F28 #14). Kept colon-space-free so the bare YAML scalar
559/// stays a string (#12). `enable` (not `--now`) here: the binary + config don't
560/// exist until bootstrap, so we only wire it for boot, not start it now.
561fn build_coordinator_prestage_block() -> String {
562 let unit = "[Unit]\\n\
563 Description=Headscale coordinator (yah-managed)\\n\
564 After=network-online.target\\n\\n\
565 [Service]\\n\
566 ExecStart=/var/lib/yah-cloud/headscale/headscale serve --config /var/lib/yah-cloud/headscale/config.yaml\\n\
567 Restart=on-failure\\n\
568 RestartSec=5\\n\\n\
569 [Install]\\n\
570 WantedBy=multi-user.target\\n";
571 [
572 format!(" - sh -c 'printf \"{unit}\" > /etc/systemd/system/headscale.service'"),
573 " - systemctl enable headscale".to_string(),
574 " - ufw allow 80".to_string(),
575 " - ufw allow 443".to_string(),
576 ]
577 .join("\n")
578}
579
580/// Look for an unsubstituted placeholder of the form `{{NAME}}` (no spaces inside braces).
581/// Documentation comments deliberately use `{{ NAME }}` (with spaces) so they survive rendering.
582fn find_unsubstituted(s: &str) -> Option<&str> {
583 let mut cursor = 0;
584 while let Some(start_off) = s[cursor..].find("{{") {
585 let start = cursor + start_off;
586 let after = &s[start + 2..];
587 let end_off = after.find("}}")?;
588 let inner = &after[..end_off];
589 if !inner.is_empty() && !inner.starts_with(' ') && !inner.ends_with(' ') {
590 return Some(&s[start..start + 2 + end_off + 2]);
591 }
592 cursor = start + 2 + end_off + 2;
593 }
594 None
595}
596
597#[cfg(test)]
598mod tests {
599 use super::*;
600 use crate::config::{BucketSpec, MachineConfig};
601 use std::path::Path;
602
603 fn sample_machine() -> MachineConfig {
604 MachineConfig {
605 name: "noisetable-pdx-1".into(),
606 provider: "hetzner".into(),
607 location: Some("pdx".into()),
608 server_type: Some("cpx22".into()),
609 hosts_mirrors: vec!["noisetable".into(), "yah".into()],
610 mesh_tags: vec!["tag:region-pdx".into(), "tag:tier-t2".into()],
611 region: None,
612 zone: None,
613 arch: None,
614 bucket: Some(BucketSpec {
615 name: "noisetable-assets-pdx-1".into(),
616 public_read: false,
617 }),
618 vendor: None,
619 nickname: None,
620 legacy_hostkey_fingerprint: None,
621 registration: Default::default(),
622 ssh_keys: vec![],
623 cloudflared: None,
624 hosts_operator_bridge: false,
625 connect: None,
626 allocatable: None,
627 taints: vec![],
628 sovereign_group: None,
629 sovereign_role: None,
630 ingress_floating_ip: None,
631 }
632 }
633
634 fn minimal_input(machine: &MachineConfig) -> RenderInput<'_> {
635 RenderInput {
636 machine,
637 yubaba_url: "https://example.com/yah-yubaba".into(),
638 yubaba_sha256: "deadbeef".into(),
639 yubaba_channel: DEFAULT_YUBABA_CHANNEL.into(),
640 // Default: standalone (no join block). Tests that exercise the
641 // mesh-join path set `headscale_preauth_key = Some(...)`.
642 headscale_preauth_key: None,
643 mesh_url: None,
644 cloudflared_token: None,
645 yubaba_cosign_identity_regexp: None,
646 }
647 }
648
649 #[test]
650 fn render_substitutes_all_placeholders() {
651 let machine = sample_machine();
652 // Enable operator bridge so tailscale content (preauth key, tags) is emitted.
653 let mut input = minimal_input(&machine);
654 input.headscale_preauth_key = Some("tskey-test".into());
655 let out = render(DEFAULT_TEMPLATE, &input).unwrap();
656 assert!(out.contains("noisetable-pdx-1"));
657 assert!(out.contains("https://example.com/yah-yubaba"));
658 assert!(out.contains("deadbeef"));
659 assert!(out.contains(DEFAULT_YUBABA_CHANNEL));
660 assert!(out.contains("apt-get install -y containerd"));
661 assert!(out.contains("tskey-test"));
662 assert!(out.contains("tag:region-pdx,tag:tier-t2"));
663 // Real placeholders must all be substituted.
664 // Documentation {{ KEY }} (with inner spaces) is allowed to survive.
665 assert!(find_unsubstituted(&out).is_none());
666 }
667
668 #[test]
669 fn render_with_mesh_url_adds_login_server() {
670 let machine = sample_machine();
671 let mut input = minimal_input(&machine);
672 input.mesh_url = Some("https://mesh.example.com".into());
673 input.headscale_preauth_key = Some("tskey-test".into());
674 let out = render(DEFAULT_TEMPLATE, &input).unwrap();
675 assert!(out.contains("--login-server https://mesh.example.com"));
676 assert!(find_unsubstituted(&out).is_none());
677 }
678
679 #[test]
680 fn render_without_mesh_url_no_login_server() {
681 let machine = sample_machine();
682 let mut input = minimal_input(&machine);
683 input.headscale_preauth_key = Some("tskey-test".into());
684 let out = render(DEFAULT_TEMPLATE, &input).unwrap();
685 // Without a mesh_url the MESH_LOGIN_SERVER_ARG substitutes to empty string,
686 // so the tailscale up line should not contain a --login-server=https:// arg.
687 assert!(!out.contains("--login-server https://"));
688 assert!(find_unsubstituted(&out).is_none());
689 }
690
691 #[test]
692 fn render_without_region_omits_region_env_block() {
693 let machine = sample_machine();
694 let input = minimal_input(&machine);
695 let out = render(DEFAULT_TEMPLATE, &input).unwrap();
696 // The doc-comment header names YUBABA_REGION unconditionally (it
697 // documents the placeholder), so assert on the actual drop-in write
698 // rather than the bare env-var name.
699 assert!(!out.contains("Environment=YUBABA_REGION"));
700 assert!(find_unsubstituted(&out).is_none());
701 }
702
703 #[test]
704 fn render_with_region_appends_env_drop_in() {
705 let mut machine = sample_machine();
706 machine.region = Some("us-west".into());
707 let input = minimal_input(&machine);
708 let out = render(DEFAULT_TEMPLATE, &input).unwrap();
709 assert!(out.contains(
710 "printf \"Environment=YUBABA_REGION=us-west\\n\" >> /etc/systemd/system/yubaba.service.d/channel.conf"
711 ));
712 assert!(find_unsubstituted(&out).is_none());
713 }
714
715 #[test]
716 fn render_with_placeholders_for_dry_run() {
717 let machine = sample_machine();
718 let input = RenderInput {
719 machine: &machine,
720 yubaba_url: PLACEHOLDER_YUBABA_URL.into(),
721 yubaba_sha256: PLACEHOLDER_YUBABA_SHA256.into(),
722 yubaba_channel: DEFAULT_YUBABA_CHANNEL.into(),
723 // A preauth key present → join block emitted, so the placeholder appears in output.
724 headscale_preauth_key: Some(PLACEHOLDER_PREAUTH_KEY.into()),
725 mesh_url: None,
726 cloudflared_token: None,
727 yubaba_cosign_identity_regexp: None,
728 };
729 let out = render(DEFAULT_TEMPLATE, &input).unwrap();
730 assert!(out.contains(PLACEHOLDER_YUBABA_URL));
731 assert!(out.contains(PLACEHOLDER_YUBABA_SHA256));
732 assert!(out.contains(PLACEHOLDER_PREAUTH_KEY));
733 }
734
735 #[test]
736 fn render_stays_under_hetzner_user_data_cap() {
737 // Backward-compat alias — see renders_under_user_data_cap below.
738 renders_under_user_data_cap();
739 }
740
741 #[test]
742 fn renders_under_user_data_cap() {
743 // Hetzner refuses user_data > 32 KiB (R040-F11). Worst-case: all blocks
744 // enabled (cloudflared + operator_bridge), long URL and sha, full tag list.
745 let machine = sample_machine();
746 let input = RenderInput {
747 machine: &machine,
748 yubaba_url: "https://github.com/yah-ai/yah/releases/download/v0.7.0/yah-yubaba-v0.7.0-x86_64-unknown-linux-musl.tar.gz".into(),
749 yubaba_sha256: "0".repeat(64),
750 yubaba_channel: "stable".into(),
751 headscale_preauth_key: Some("tskey-auth-keylongenoughforrealism123456".into()),
752 mesh_url: Some("https://mesh.example.com".into()),
753 cloudflared_token: Some(PLACEHOLDER_CLOUDFLARED_TOKEN.into()),
754 // Worst-case includes the cosign block so the 32 KiB cap test
755 // covers the bootstrap-channel signing path too (W203 §1.4).
756 yubaba_cosign_identity_regexp: Some("^https://github\\.com/yah-ai/yah/".into()),
757 };
758 let out = render(DEFAULT_TEMPLATE, &input).unwrap();
759 assert!(
760 out.len() < 32 * 1024,
761 "rendered cloud-init is {} bytes (Hetzner cap is 32 KiB)",
762 out.len()
763 );
764 }
765
766 #[test]
767 fn render_rejects_unknown_placeholder() {
768 let machine = sample_machine();
769 let input = RenderInput {
770 machine: &machine,
771 yubaba_url: "x".into(),
772 yubaba_sha256: "y".into(),
773 yubaba_channel: "stable".into(),
774 headscale_preauth_key: None,
775 mesh_url: None,
776 cloudflared_token: None,
777 yubaba_cosign_identity_regexp: None,
778 };
779 let bad = "foo: {{NOT_A_KEY}}\n";
780 let err = render(bad, &input).unwrap_err().to_string();
781 assert!(err.contains("{{NOT_A_KEY}}"), "unexpected error: {err}");
782 }
783
784 #[test]
785 fn render_falls_back_to_machine_tag_when_mesh_tags_empty() {
786 let mut machine = sample_machine();
787 machine.mesh_tags = vec![];
788 let mut input = minimal_input(&machine);
789 input.headscale_preauth_key = Some("tskey-test".into());
790 let out = render(DEFAULT_TEMPLATE, &input).unwrap();
791 assert!(out.contains("--advertise-tags=tag:noisetable-pdx-1"));
792 }
793
794 #[test]
795 fn render_advertise_tags_filters_non_tag_prefixed_mesh_tags() {
796 let mut machine = sample_machine();
797 machine.mesh_tags = vec![
798 "tag:build-worker".into(),
799 "arch:x86".into(),
800 "os:linux".into(),
801 "tag:qed".into(),
802 ];
803 let mut input = minimal_input(&machine);
804 input.headscale_preauth_key = Some("tskey-test".into());
805 let out = render(DEFAULT_TEMPLATE, &input).unwrap();
806 assert!(out.contains("--advertise-tags=tag:build-worker,tag:qed"));
807 assert!(!out.contains("arch:x86"));
808 assert!(!out.contains("os:linux"));
809 }
810
811 #[test]
812 fn load_template_uses_workspace_override_when_present() {
813 let dir = tempfile::tempdir().unwrap();
814 let custom_dir = crate::paths::cloud_init_dir(dir.path());
815 std::fs::create_dir_all(&custom_dir).unwrap();
816 std::fs::write(
817 custom_dir.join("mirror.yml"),
818 "#cloud-config\ncustom: true\n",
819 )
820 .unwrap();
821 let loaded = load_template(dir.path()).unwrap();
822 assert!(loaded.contains("custom: true"));
823 }
824
825 #[test]
826 fn load_template_falls_back_to_default_when_absent() {
827 let dir = tempfile::tempdir().unwrap();
828 let loaded = load_template(dir.path()).unwrap();
829 assert_eq!(loaded, DEFAULT_TEMPLATE);
830 }
831
832 #[test]
833 fn render_preserves_documentation_comments() {
834 let machine = sample_machine();
835 let input = minimal_input(&machine);
836 let out = render(DEFAULT_TEMPLATE, &input).unwrap();
837 // The header comment uses `{{ KEY }}` (with spaces) so it survives the
838 // `{{KEY}}` (no-spaces) substitution and stays readable.
839 assert!(out.contains("{{ MACHINE_NAME }}"));
840 assert!(out.contains("{{ YAH_YUBABA_URL }}"));
841 assert!(out.contains("{{ YAH_YUBABA_SHA256 }}"));
842 }
843
844 #[test]
845 fn render_with_cloudflared_token_emits_install_block() {
846 let machine = sample_machine();
847 let mut input = minimal_input(&machine);
848 input.cloudflared_token = Some("tok_abc123".into());
849 let out = render(DEFAULT_TEMPLATE, &input).unwrap();
850 // R330-B29: token is a POSITIONAL arg, NOT a `--token` flag. The flag
851 // form fails arg-parsing and never creates cloudflared.service.
852 assert!(
853 out.contains("cloudflared service install tok_abc123"),
854 "install line missing"
855 );
856 assert!(
857 !out.contains("service install --token"),
858 "must not use the bogus --token flag (R330-B29)"
859 );
860 assert!(
861 out.contains("systemctl enable --now cloudflared"),
862 "enable line missing"
863 );
864 assert!(out.contains("pkg.cloudflare.com"), "apt-repo setup missing");
865 assert!(find_unsubstituted(&out).is_none());
866 }
867
868 #[test]
869 fn render_without_cloudflared_token_omits_install_block() {
870 let machine = sample_machine();
871 let input = minimal_input(&machine);
872 let out = render(DEFAULT_TEMPLATE, &input).unwrap();
873 // Template header mentions "cloudflared service install" in prose; check
874 // for the token-bearing form which is only present in the actual runcmd.
875 assert!(
876 !out.contains("cloudflared service install --token"),
877 "install line should be absent"
878 );
879 assert!(
880 !out.contains("pkg.cloudflare.com"),
881 "apt-repo setup should be absent"
882 );
883 assert!(find_unsubstituted(&out).is_none());
884 }
885
886 #[test]
887 fn operator_bridge_block_emitted_when_enabled() {
888 let machine = sample_machine();
889 let mut input = minimal_input(&machine);
890 input.headscale_preauth_key = Some("tskey-test".into());
891 let out = render(DEFAULT_TEMPLATE, &input).unwrap();
892 assert!(
893 out.contains("tailscale.com/install.sh"),
894 "tailscale install missing"
895 );
896 assert!(out.contains("tailscale up"), "tailscale up missing");
897 assert!(
898 out.contains("ufw allow in on tailscale0"),
899 "ufw tailscale rule missing"
900 );
901 assert!(find_unsubstituted(&out).is_none());
902 }
903
904 /// R624-B1: a provisioned node must never let tailscaled own
905 /// `/etc/resolv.conf`.
906 ///
907 /// Accepting MagicDNS points the resolver at 100.100.100.100, which only
908 /// answers while tailscaled is up — so a tailscaled that is down cannot
909 /// resolve its control server and can never come up again. That deadlock
910 /// took a raft voter off the mesh for 30+ hours. This assertion is the
911 /// guard: dropping the flag silently re-arms the trap on every node
912 /// provisioned afterwards, and the damage only shows up at the next reboot.
913 #[test]
914 fn tailscale_join_refuses_magicdns_so_a_node_cannot_strand_itself() {
915 let machine = sample_machine();
916 let mut input = minimal_input(&machine);
917 input.headscale_preauth_key = Some("tskey-test".into());
918 let out = render(DEFAULT_TEMPLATE, &input).unwrap();
919 assert!(
920 out.contains("--accept-dns=false"),
921 "tailscale up MUST pass --accept-dns=false — without it tailscaled \
922 rewrites /etc/resolv.conf to MagicDNS and a node that boots with \
923 tailscaled down can never resolve its control server again \
924 (R624-B1). Rendered:\n{out}"
925 );
926 }
927
928 #[test]
929 fn operator_bridge_block_omitted_when_disabled() {
930 let machine = sample_machine();
931 let input = minimal_input(&machine);
932 let out = render(DEFAULT_TEMPLATE, &input).unwrap();
933 assert!(
934 !out.contains("tailscale.com/install.sh"),
935 "tailscale install should be absent"
936 );
937 // Template header mentions "tailscale up" in prose; check for the auth-key
938 // bearing form which is only present in the actual runcmd block.
939 assert!(
940 !out.contains("tailscale up --auth-key"),
941 "tailscale join should be absent"
942 );
943 assert!(find_unsubstituted(&out).is_none());
944 }
945
946 /// R330-F28 pothole #12: the from-zero validation found that cloud-init's
947 /// `runcmd` is emitted as a sequence of BARE (unquoted) YAML scalars, so a
948 /// `: ` (colon-space) anywhere in a command — e.g. the cosign block's old
949 /// `echo "unsupported arch: $ARCH"` — makes the YAML parser read the entry
950 /// as a `{key: value}` mapping. cloud-init's `shellify` then rejects the
951 /// dict and SKIPS THE ENTIRE runcmd block, so yubaba never installs. This
952 /// test parses the worst-case rendered cloud-init as real YAML and asserts
953 /// every runcmd entry is a string, catching any future colon-space footgun.
954 #[test]
955 fn rendered_runcmd_entries_are_all_strings() {
956 let machine = sample_machine();
957 // Worst case: every conditional block present (cosign + cloudflared +
958 // operator-bridge), since that's where dynamic strings are injected.
959 let input = RenderInput {
960 machine: &machine,
961 yubaba_url: "https://cdn.yah.dev/yubaba/0.8.13/x86_64-unknown-linux-musl/yah-yubaba-x86_64-unknown-linux-musl.tar.gz".into(),
962 yubaba_sha256: "0".repeat(64),
963 yubaba_channel: "stable".into(),
964 headscale_preauth_key: Some("tskey-auth-keylongenoughforrealism123456".into()),
965 mesh_url: Some("https://cloud.mesh.yah.dev".into()),
966 cloudflared_token: Some("tok_abc123".into()),
967 yubaba_cosign_identity_regexp: Some(r"^https://github\.com/yah-ai/yah/".into()),
968 };
969 let out = render(DEFAULT_TEMPLATE, &input).unwrap();
970 let doc: serde_yaml::Value =
971 serde_yaml::from_str(&out).expect("rendered cloud-init must be valid YAML");
972 let runcmd = doc
973 .get("runcmd")
974 .and_then(|v| v.as_sequence())
975 .expect("cloud-init must have a runcmd sequence");
976 assert!(!runcmd.is_empty(), "runcmd should not be empty");
977 for (i, entry) in runcmd.iter().enumerate() {
978 assert!(
979 entry.is_string(),
980 "runcmd[{i}] parsed as {entry:?}, not a string — a `: ` (colon-space) \
981 in a bare YAML scalar turned it into a mapping (pothole #12)"
982 );
983 }
984 }
985
986 /// R330-F28 #13: the yubaba-port firewall rule keys off mesh role. A
987 /// JOINING node (preauth present) denies public 7443 (mesh-only via the
988 /// tailscale0 allow in the join block); a STANDALONE coordinator (no
989 /// preauth) allows public 7443 so the operator can attach + bootstrap it
990 /// before any mesh exists.
991 #[test]
992 fn ufw_yubaba_rule_keys_off_mesh_role() {
993 let machine = sample_machine();
994
995 // Standalone: allow public 7443.
996 let standalone = minimal_input(&machine);
997 let out = render(DEFAULT_TEMPLATE, &standalone).unwrap();
998 assert!(
999 out.contains("ufw allow 7443"),
1000 "standalone must allow public 7443"
1001 );
1002 assert!(
1003 !out.contains("ufw deny 7443"),
1004 "standalone must not deny 7443"
1005 );
1006
1007 // Joining: deny public 7443 (join block adds the tailscale0 allow).
1008 let mut joining = minimal_input(&machine);
1009 joining.headscale_preauth_key = Some("tskey-test".into());
1010 let out = render(DEFAULT_TEMPLATE, &joining).unwrap();
1011 assert!(
1012 out.contains("ufw deny 7443"),
1013 "joining node must deny public 7443"
1014 );
1015 assert!(
1016 !out.contains("ufw allow 7443"),
1017 "joining node must not allow public 7443"
1018 );
1019 assert!(
1020 out.contains("ufw allow in on tailscale0"),
1021 "joining node needs the tailscale0 allow"
1022 );
1023 }
1024
1025 /// R330-F28 #15: a STANDALONE coordinator pre-stages the headscale unit +
1026 /// opens ufw 80/443 via cloud-init (yubaba's sandbox can't). A JOINING node
1027 /// must never do this. Both renders must stay valid, parseable YAML.
1028 #[test]
1029 fn coordinator_prestage_only_for_standalone() {
1030 let machine = sample_machine();
1031
1032 // Standalone: pre-stage present.
1033 let standalone = minimal_input(&machine);
1034 let out = render(DEFAULT_TEMPLATE, &standalone).unwrap();
1035 assert!(
1036 out.contains("/etc/systemd/system/headscale.service"),
1037 "standalone must stage the headscale unit"
1038 );
1039 assert!(
1040 out.contains("/var/lib/yah-cloud/headscale/headscale serve"),
1041 "unit ExecStart must match DEFAULT_HEADSCALE_DIR"
1042 );
1043 assert!(
1044 out.contains("ufw allow 80"),
1045 "standalone must open ufw 80 (LE HTTP-01)"
1046 );
1047 assert!(
1048 out.contains("ufw allow 443"),
1049 "standalone must open ufw 443 (headscale)"
1050 );
1051 // Must stay parseable YAML with all-string runcmd entries.
1052 let doc: serde_yaml::Value =
1053 serde_yaml::from_str(&out).expect("standalone cloud-init must be valid YAML");
1054 for entry in doc["runcmd"].as_sequence().expect("runcmd seq") {
1055 assert!(
1056 entry.is_string(),
1057 "standalone runcmd entry parsed as {entry:?}, not a string"
1058 );
1059 }
1060
1061 // Joining: no pre-stage.
1062 let mut joining = minimal_input(&machine);
1063 joining.headscale_preauth_key = Some("tskey-test".into());
1064 let out = render(DEFAULT_TEMPLATE, &joining).unwrap();
1065 assert!(
1066 !out.contains("/etc/systemd/system/headscale.service"),
1067 "joining node must not stage a headscale unit"
1068 );
1069 assert!(
1070 !out.contains("ufw allow 443"),
1071 "joining node must not open 443"
1072 );
1073 }
1074
1075 #[test]
1076 fn render_yubaba_channel_in_output() {
1077 let machine = sample_machine();
1078 let mut input = minimal_input(&machine);
1079 input.yubaba_channel = "beta".into();
1080 let out = render(DEFAULT_TEMPLATE, &input).unwrap();
1081 // Channel rides a systemd drop-in (Environment=YUBABA_CHANNEL=…), not a
1082 // --channel flag (the ExecStart bakes defaults; the drop-in tunes it).
1083 assert!(
1084 out.contains("YUBABA_CHANNEL=beta"),
1085 "yubaba channel missing"
1086 );
1087 // containerd is installed unpinned (R330-T9).
1088 assert!(
1089 out.contains("apt-get install -y containerd\n"),
1090 "containerd install missing"
1091 );
1092 assert!(find_unsubstituted(&out).is_none());
1093 }
1094
1095 #[test]
1096 fn embedded_template_matches_workspace_canonical() {
1097 // Drift test: .yah/infra/cloud-init/mirror.yml must stay in sync with
1098 // the embedded DEFAULT_TEMPLATE (templates/mirror.yml). Edit both files
1099 // together; this test catches divergence.
1100 //
1101 // It only catches it because the root is resolved via
1102 // locate_canonical_home rather than "first ancestor with a `.yah/`" —
1103 // the latter stops at oss/yubaba, whose .yah/ holds only a .gitignore,
1104 // so the canonical file was never found, the old `if exists` branch
1105 // never fired, and this test asserted NOTHING for months (R870-B25).
1106 // A missing canonical file inside the monorepo is now a hard failure,
1107 // not a bootstrap case.
1108 match locate_canonical_home(Path::new(env!("CARGO_MANIFEST_DIR"))) {
1109 CanonicalHome::Monorepo(root) => {
1110 let canonical_path = crate::paths::cloud_init_template(&root);
1111 let canonical = std::fs::read_to_string(&canonical_path).unwrap_or_else(|e| {
1112 panic!(
1113 "{} is unreadable ({e}) — in the monorepo this file is REQUIRED, not \
1114 optional: cloud_init::load_template prefers it over the embedded \
1115 template, so it is the copy real provisions ship",
1116 canonical_path.display()
1117 )
1118 });
1119 assert_eq!(
1120 canonical.trim_end(),
1121 DEFAULT_TEMPLATE.trim_end(),
1122 "{} drifted from the embedded templates/mirror.yml — edit both files \
1123 together. The on-disk copy is the one provisioning actually reads.",
1124 canonical_path.display()
1125 );
1126 }
1127 CanonicalHome::StandaloneExport => {
1128 // The exported yubaba repo carries no .yah/infra/ and therefore
1129 // no canonical copy; the embedded template is the only one.
1130 // This is the ONLY branch allowed to skip the comparison, and
1131 // locate_canonical_home_* below pin what can reach it.
1132 }
1133 }
1134 }
1135
1136 /// Anchors that must appear in BOTH `mirror.yml` and its SSH twin
1137 /// `.yah/infra/cloud-init/stand-up-yubaba.sh` for the two to count as level
1138 /// on what they install.
1139 ///
1140 /// Every entry is asserted against the template as well as the script, so
1141 /// the list cannot rot into pinning a step the template has since dropped —
1142 /// a stale anchor goes red on the template side instead of quietly
1143 /// over-constraining the script.
1144 ///
1145 /// This is deliberately an ANCHOR list, not a diff: the two files are
1146 /// different languages with different runtime contracts (cloud-init runs
1147 /// once as root on a fresh box; the script is idempotent, re-runnable and
1148 /// `$SUDO`-prefixed), and several divergences are correct — the script's
1149 /// `enable` + `restart` instead of `enable --now`, its write-if-absent
1150 /// journald ceiling, its cluster-KEK install, its loopback bind. What must
1151 /// NOT diverge is the set of artifacts a node ends up carrying.
1152 const STAND_UP_TWIN_ANCHORS: &[(&str, &str)] = &[
1153 // R858-F17 durability helpers. Absent → kamaji refuses to deploy any
1154 // workload declaring a `yah.durability.tier` (kamaji-bin/src/hydrate.rs).
1155 (
1156 "/usr/local/bin/turso-backup-hydrate",
1157 "durability helper binary",
1158 ),
1159 (
1160 "/usr/local/bin/turso-backup-tail",
1161 "durability helper binary",
1162 ),
1163 ("KAMAJI_HYDRATE_HELPER", "kamaji drop-in env var"),
1164 ("KAMAJI_TAIL_HELPER", "kamaji drop-in env var"),
1165 (
1166 "/etc/systemd/system/kamaji.service.d",
1167 "drop-in dir the helper env vars land in",
1168 ),
1169 // R858 headscale DB continuity. The unit is staged and never enabled —
1170 // leader.rs starts it on gaining the ingress owner role — and yubaba
1171 // runs ProtectSystem=strict, so a node that did not get it at stand-up
1172 // can never acquire it at runtime.
1173 ("litestream-headscale.service", "staged replication unit"),
1174 ("/usr/local/bin/litestream", "replicate/restore binary"),
1175 // R858-T4: every node is a coordinator candidate, and the appliance is
1176 // a NATIVE workload kamaji forks rather than pulls.
1177 (
1178 "/var/lib/yah-cloud/headscale/headscale",
1179 "pre-staged headscale binary",
1180 ),
1181 ];
1182
1183 /// Maximal runs of lowercase hex exactly 64 chars long — the sha256 pins
1184 /// for third-party downloads. `{{YAH_YUBABA_SHA256}}` is a placeholder, not
1185 /// hex, so it is not picked up; the four real pins (litestream and
1186 /// headscale, amd64 and arm64) are.
1187 fn sha256_pins(s: &str) -> std::collections::BTreeSet<String> {
1188 s.split(|c: char| !c.is_ascii_hexdigit() || c.is_ascii_uppercase())
1189 .filter(|t| t.len() == 64)
1190 .map(str::to_string)
1191 .collect()
1192 }
1193
1194 /// Every `https://github.com/<owner>/<repo>/releases/download/<tag>/`
1195 /// prefix in `s`. Owner, repo and tag are the pinned part; the asset
1196 /// filename after the tag is arch-templated and left free.
1197 fn upstream_release_pins(s: &str) -> std::collections::BTreeSet<String> {
1198 const MARK: &str = "https://github.com/";
1199 let mut out = std::collections::BTreeSet::new();
1200 for (i, _) in s.match_indices(MARK) {
1201 let rest = &s[i..];
1202 let Some(dl) = rest.find("/releases/download/") else {
1203 continue;
1204 };
1205 let after = &rest[dl + "/releases/download/".len()..];
1206 let Some(slash) = after.find('/') else {
1207 continue;
1208 };
1209 out.insert(rest[..dl + "/releases/download/".len() + slash + 1].to_string());
1210 }
1211 out
1212 }
1213
1214 #[test]
1215 fn stand_up_script_carries_the_templates_install_steps() {
1216 // THIRD-TWIN GUARD (R870-B25). mirror.yml had two copies and one
1217 // vacuous guard between them; the SSH transcription
1218 // .yah/infra/cloud-init/stand-up-yubaba.sh is a third copy of the same
1219 // install list with NO guard at all, which is how it came to be missing
1220 // the R858-F17 durability helpers and the R858 litestream/headscale
1221 // pre-stage while both mirror.yml copies carried them.
1222 //
1223 // The pins below are DERIVED FROM THE TEMPLATE rather than restated, so
1224 // bumping litestream or headscale in mirror.yml alone turns this red
1225 // instead of leaving the script pinned to a superseded checksum.
1226 let root = match locate_canonical_home(Path::new(env!("CARGO_MANIFEST_DIR"))) {
1227 CanonicalHome::Monorepo(root) => root,
1228 // Same single permitted skip as the mirror.yml drift guard: the
1229 // exported yubaba repo ships no .yah/infra/, so there is no script.
1230 // drift_guard_cannot_go_vacuous_in_the_monorepo pins that this
1231 // branch is unreachable from inside the monorepo.
1232 CanonicalHome::StandaloneExport => return,
1233 };
1234 let script_path = crate::paths::stand_up_script(&root);
1235 let script = std::fs::read_to_string(&script_path).unwrap_or_else(|e| {
1236 panic!(
1237 "{} is unreadable ({e}) — it is mirror.yml's SSH twin for LAN nodes \
1238 (W257 step 6) and must stay level with it",
1239 script_path.display()
1240 )
1241 });
1242
1243 for (anchor, what) in STAND_UP_TWIN_ANCHORS {
1244 assert!(
1245 DEFAULT_TEMPLATE.contains(anchor),
1246 "STAND_UP_TWIN_ANCHORS is stale: templates/mirror.yml no longer mentions \
1247 {anchor} ({what}) — drop the anchor here rather than holding the script \
1248 to a step the template abandoned"
1249 );
1250 assert!(
1251 script.contains(anchor),
1252 "{} is behind templates/mirror.yml: no {anchor} ({what}). A LAN node stood \
1253 up by this script would not carry it. Transcribe the step in the script's \
1254 own idiom ($SUDO install from $D, tee for drop-ins, non-fatal WARNING) — \
1255 this is not a byte-diff, only the resulting artifacts must match.",
1256 script_path.display()
1257 );
1258 }
1259
1260 let pins = sha256_pins(DEFAULT_TEMPLATE);
1261 assert!(
1262 pins.len() >= 4,
1263 "expected the template's four third-party sha256 pins (litestream and \
1264 headscale, amd64 and arm64), found {} — the extractor is broken, and a \
1265 broken extractor makes this guard vacuous",
1266 pins.len()
1267 );
1268 for pin in &pins {
1269 assert!(
1270 script.contains(pin.as_str()),
1271 "{} is missing the sha256 pin {pin} that templates/mirror.yml verifies. \
1272 An unpinned or stale-pinned download is the failure this guard exists \
1273 for — copy the checksum across when you bump the version.",
1274 script_path.display()
1275 );
1276 }
1277
1278 let releases = upstream_release_pins(DEFAULT_TEMPLATE);
1279 assert!(
1280 releases.len() >= 2,
1281 "expected the litestream and headscale release pins in the template, found {}",
1282 releases.len()
1283 );
1284 for release in &releases {
1285 assert!(
1286 script.contains(release.as_str()),
1287 "{} does not fetch {release} — the script and the template must pin the \
1288 SAME upstream version, or a LAN node and a cloud node run different \
1289 headscale/litestream builds against the same replicated DB.",
1290 script_path.display()
1291 );
1292 }
1293 }
1294
1295 #[test]
1296 fn locate_canonical_home_finds_monorepo_root_not_the_inner_workspace() {
1297 // Shape of the monorepo: repo root carries .yah/infra/, the inner
1298 // oss/yubaba workspace carries a .yah/ with no infra/ (a .gitignore
1299 // lives there in the real tree). The walk must climb PAST the inner one.
1300 let tmp = tempfile::tempdir().unwrap();
1301 let root = tmp.path();
1302 std::fs::create_dir_all(root.join(".yah/infra/cloud-init")).unwrap();
1303 let crate_dir = root.join("oss/yubaba/crates/cloud");
1304 std::fs::create_dir_all(&crate_dir).unwrap();
1305 std::fs::create_dir_all(root.join("oss/yubaba/.yah")).unwrap();
1306 std::fs::write(root.join("oss/yubaba/.yah/.gitignore"), "*\n").unwrap();
1307
1308 assert_eq!(
1309 locate_canonical_home(&crate_dir),
1310 CanonicalHome::Monorepo(root.to_path_buf()),
1311 "walk stopped at the inner independent workspace instead of the monorepo root"
1312 );
1313 }
1314
1315 #[test]
1316 fn locate_canonical_home_reports_standalone_export() {
1317 // Shape of the exported yubaba repo: no .yah/infra/ anywhere above the
1318 // crate. Nothing to compare against, and that must be a distinct,
1319 // named verdict rather than a silent miss.
1320 let tmp = tempfile::tempdir().unwrap();
1321 let crate_dir = tmp.path().join("crates/cloud");
1322 std::fs::create_dir_all(&crate_dir).unwrap();
1323 std::fs::create_dir_all(tmp.path().join(".yah")).unwrap();
1324 std::fs::write(tmp.path().join(".yah/.gitignore"), "*\n").unwrap();
1325
1326 assert_eq!(
1327 locate_canonical_home(&crate_dir),
1328 CanonicalHome::StandaloneExport
1329 );
1330 }
1331
1332 #[test]
1333 fn drift_guard_cannot_go_vacuous_in_the_monorepo() {
1334 // Guards the guard, off a signal INDEPENDENT of the `.yah/infra/`
1335 // marker locate_canonical_home uses — otherwise this would just restate
1336 // it. `git subtree split --prefix=oss/yubaba` (scripts/export-oss.sh)
1337 // strips that prefix, so a manifest dir still ending in
1338 // `oss/yubaba/crates/cloud` means we are in the monorepo, where the
1339 // comparison MUST happen. Re-break root resolution and this goes red
1340 // instead of the drift guard going quietly green.
1341 let manifest = Path::new(env!("CARGO_MANIFEST_DIR"));
1342 if manifest.ends_with("oss/yubaba/crates/cloud") {
1343 let home = locate_canonical_home(manifest);
1344 let root = match &home {
1345 CanonicalHome::Monorepo(root) => root,
1346 CanonicalHome::StandaloneExport => panic!(
1347 "running inside the monorepo at {} but the drift guard resolved \
1348 StandaloneExport — it would skip the comparison and assert nothing",
1349 manifest.display()
1350 ),
1351 };
1352 assert!(
1353 crate::paths::cloud_init_template(root).is_file(),
1354 "monorepo root {} has .yah/infra/ but no cloud-init/mirror.yml",
1355 root.display()
1356 );
1357 }
1358 }
1359
1360 #[test]
1361 fn cosign_block_shell_form_well_quoted() {
1362 // YAML parsability spot-check: the block uses double-quoted strings inside
1363 // a single-quoted `sh -c '...'`. Confirm no apostrophes leak into the
1364 // single-quoted body and the case/esac terminators are present.
1365 let machine = sample_machine();
1366 let mut input = minimal_input(&machine);
1367 input.yubaba_cosign_identity_regexp = Some("^https://github\\.com/yah-ai/yah/".into());
1368 let out = render(DEFAULT_TEMPLATE, &input).unwrap();
1369 let sh_line = out
1370 .lines()
1371 .find(|l| l.contains("sh -c") && l.contains("cosign"))
1372 .expect("cosign install sh -c line missing");
1373 // Body of the sh -c is enclosed in a single quoted region; we
1374 // intentionally use double-quotes around shell vars inside. If a stray
1375 // apostrophe slipped through we'd see an odd count of `'`.
1376 let single_quotes = sh_line.matches('\'').count();
1377 assert!(
1378 single_quotes == 2,
1379 "expected exactly 2 enclosing single quotes in sh -c body, found {single_quotes}: {sh_line}"
1380 );
1381 assert!(sh_line.contains("case \"$ARCH\""), "case opener missing");
1382 assert!(sh_line.contains("esac"), "case terminator missing");
1383 // The block must be a sequence of valid `runcmd` entries: each line
1384 // starts with ` - ` (two-space indent + dash + space) so cloud-init
1385 // parses it as a YAML list item.
1386 for line in out.lines().filter(|l| l.contains("cosign")) {
1387 // Skip the header comment lines (start with `#`).
1388 let stripped = line.trim_start();
1389 if stripped.starts_with('#') || stripped.is_empty() {
1390 continue;
1391 }
1392 assert!(
1393 line.starts_with(" - "),
1394 "cosign block line is not a runcmd list entry: {line:?}"
1395 );
1396 }
1397 }
1398
1399 /// R605-F1: a fleet whose releases are cut on QED verifies against a
1400 /// pinned public key, not a Fulcio certificate identity.
1401 #[test]
1402 fn render_with_key_trust_emits_key_verify() {
1403 let machine = sample_machine();
1404 let mut input = minimal_input(&machine);
1405 input.yubaba_url = "https://cdn.yah.dev/yubaba/0.9.0/x86_64-unknown-linux-musl/yah-yubaba-x86_64-unknown-linux-musl.tar.gz".into();
1406 input.yubaba_cosign_identity_regexp =
1407 Some("key:https://cdn.yah.dev/keys/yah-release.pub".into());
1408 let out = render(DEFAULT_TEMPLATE, &input).unwrap();
1409
1410 assert!(
1411 out.contains(
1412 "cosign verify-blob --key 'https://cdn.yah.dev/keys/yah-release.pub' \
1413 --insecure-ignore-tlog --bundle /tmp/yah-yubaba.tar.gz.sigstore.json"
1414 ),
1415 "key-based verify-blob line missing:\n{out}"
1416 );
1417 assert!(
1418 !out.contains("--certificate-identity-regexp"),
1419 "keyless flags must not survive into a key-trust render"
1420 );
1421 // Still a well-formed runcmd sequence.
1422 for line in out.lines().filter(|l| l.contains("cosign")) {
1423 let stripped = line.trim_start();
1424 if stripped.starts_with('#') || stripped.is_empty() {
1425 continue;
1426 }
1427 assert!(
1428 line.starts_with(" - "),
1429 "cosign block line is not a runcmd list entry: {line:?}"
1430 );
1431 // R330-F28 pothole #12: a `: ` anywhere makes cloud-init read the
1432 // entry as a YAML mapping and skip the whole runcmd block.
1433 assert!(
1434 !line.contains(": "),
1435 "colon-space in runcmd entry would break cloud-init parsing: {line:?}"
1436 );
1437 }
1438 }
1439
1440 #[test]
1441 fn render_with_cosign_identity_emits_verify_block() {
1442 let machine = sample_machine();
1443 let mut input = minimal_input(&machine);
1444 input.yubaba_url = "https://cdn.yah.dev/yubaba/0.9.0/x86_64-unknown-linux-musl/yah-yubaba-x86_64-unknown-linux-musl.tar.gz".into();
1445 input.yubaba_cosign_identity_regexp = Some("^https://github\\.com/yah-ai/yah/".into());
1446 let out = render(DEFAULT_TEMPLATE, &input).unwrap();
1447 assert!(
1448 out.contains("cosign verify-blob --certificate-identity-regexp '^https://github\\.com/yah-ai/yah/'"),
1449 "verify-blob line missing or identity-regexp not threaded"
1450 );
1451 assert!(out.contains(COSIGN_OIDC_ISSUER), "oidc-issuer flag missing");
1452 // The bundle sibling URL is derived by suffixing the tarball URL.
1453 assert!(
1454 out.contains(&format!("{}.sigstore.json", input.yubaba_url)),
1455 "bundle sibling URL missing"
1456 );
1457 // cosign install is pinned to a specific release version (no `latest`).
1458 assert!(
1459 out.contains(&format!(
1460 "/releases/download/{}/cosign-linux-",
1461 COSIGN_VERSION
1462 )),
1463 "pinned cosign release URL missing"
1464 );
1465 // R330-F22: cosign binary itself is sha256-pinned (both arches).
1466 // Bumping COSIGN_VERSION without bumping the sha256s should break this
1467 // assertion before it breaks a boot.
1468 assert!(
1469 out.contains(COSIGN_SHA256_AMD64),
1470 "cosign amd64 sha256 pin missing from rendered block"
1471 );
1472 assert!(
1473 out.contains(COSIGN_SHA256_ARM64),
1474 "cosign arm64 sha256 pin missing from rendered block"
1475 );
1476 assert!(
1477 out.contains("sha256sum -c -"),
1478 "cosign binary sha256 verify line missing"
1479 );
1480 // sha256 verify still runs in parallel — belt + suspenders during rollout.
1481 assert!(
1482 out.contains("sha256sum -c -"),
1483 "sha256 verify dropped — should run in parallel with cosign"
1484 );
1485 assert!(find_unsubstituted(&out).is_none());
1486 }
1487
1488 #[test]
1489 fn render_without_cosign_identity_omits_verify_block() {
1490 // Byte-equivalence check against today's sha256-only render: when
1491 // yubaba_cosign_identity_regexp is None, no cosign content appears.
1492 let machine = sample_machine();
1493 let input = minimal_input(&machine);
1494 let out = render(DEFAULT_TEMPLATE, &input).unwrap();
1495 // Comment-line in mirror.yml mentions "cosign verify-blob" in prose;
1496 // check for the flag-bearing form which is only emitted when the
1497 // verify-blob runcmd block actually runs.
1498 assert!(
1499 !out.contains("cosign verify-blob --certificate-identity-regexp"),
1500 "cosign block should be absent when identity_regexp is None"
1501 );
1502 assert!(
1503 !out.contains("/sigstore/cosign/releases/download/"),
1504 "cosign install line should be absent when identity_regexp is None"
1505 );
1506 // sha256 verify is the trust gate in this mode.
1507 assert!(
1508 out.contains("sha256sum -c -"),
1509 "sha256 verify must remain in the no-cosign render path"
1510 );
1511 assert!(find_unsubstituted(&out).is_none());
1512 }
1513
1514 #[test]
1515 fn compute_yubaba_sha256_matches_known_value() {
1516 // sha256("hello") = 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824
1517 let dir = tempfile::tempdir().unwrap();
1518 let bin_path = dir.path().join("yah-yubaba");
1519 std::fs::write(&bin_path, b"hello").unwrap();
1520 let sha = compute_yubaba_sha256(&bin_path).unwrap();
1521 assert_eq!(
1522 sha,
1523 "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
1524 );
1525 }
1526}