Skip to main content

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}}", &region_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}