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:next("Children to file as work begins: (F) kamaji bundle-cache budget - THE live unbounded one, see gotcha; (T) containerd image/snapshot GC policy; (T) apt archive + old-kernel retention; (T) surface per-node disk headroom in yubaba node status so pressure is visible before it is fatal; (D) a W doc carrying the doctrine plus a review checklist item - every new on-disk writer declares its ceiling at the same time it declares the path.")
64//! @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.")
65//! @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.")
66//! @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.")
67//! @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")
68//! @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.")
69//! @yah:verify("journalctl --disk-usage on each cloud node stays under 500M across a week of normal operation.")
70//! @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.")
71//! @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.")
72//! @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).")
73//! @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.")
74//! @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.")
75//! @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).")
76//! @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.")
77//! @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).")
78//! @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.")
79//! @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.")
80//! @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.")
81//! @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).")
82//! @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.")
83//! @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).")
84//! @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.")
85
86use crate::config::MachineConfig;
87use crate::release_manifest::ReleaseTrust;
88use anyhow::{bail, Context, Result};
89use sha2::{Digest, Sha256};
90use std::path::Path;
91
92/// Built-in fallback template, used when `.yah/infra/cloud-init/mirror.yml`
93/// is absent. Keeps the binary self-contained for tests + new workspaces.
94pub const DEFAULT_TEMPLATE: &str = include_str!("../templates/mirror.yml");
95
96/// Inputs needed to render `mirror.yml` for a single machine.
97#[derive(Debug)]
98pub struct RenderInput<'a> {
99    pub machine: &'a MachineConfig,
100    /// HTTPS URL of the yah-yubaba release tar.gz (e.g. a GitHub release
101    /// asset published by `.github/workflows/release.yml`). Cloud-init's
102    /// `runcmd` downloads it, verifies sha256 against [`Self::yubaba_sha256`],
103    /// extracts, and installs `/usr/local/bin/yubaba`. Use
104    /// `PLACEHOLDER_YUBABA_URL` for dry-run previews.
105    pub yubaba_url: String,
106    /// Lowercase hex sha256 of the tar.gz at `yubaba_url`. Cloud-init verifies
107    /// this with `sha256sum -c -` against the downloaded archive and fails
108    /// the boot if it doesn't match.
109    pub yubaba_sha256: String,
110    /// Release channel passed to `yah-yubaba serve --channel`. One of
111    /// `"stable"` or `"beta"`. Use [`DEFAULT_YUBABA_CHANNEL`] for Phase 1.
112    pub yubaba_channel: String,
113    /// Headscale pre-auth key. `Some` ⟺ this machine is joining an existing
114    /// mesh: the renderer emits the tailscaled install + `tailscale up` join
115    /// block (gated on this being `Some`, see [`render`]). `None` ⟺ standalone
116    /// / coordinator-to-be — no mesh to join yet, so no join block is emitted
117    /// (the node becomes the coordinator later via `yah mesh bootstrap`).
118    pub headscale_preauth_key: Option<String>,
119    /// Stable URL of the Headscale coordinator (`https://mesh.<domain>`).
120    /// When set (R040-F18), the rendered cloud-init passes
121    /// `--login-server <url>` to `tailscale up` so the new machine joins
122    /// the camp's Headscale instead of Tailscale SaaS. When `None`, the
123    /// `{{MESH_LOGIN_SERVER_ARG}}` placeholder is replaced with an empty
124    /// string, preserving the existing Tailscale SaaS behaviour.
125    pub mesh_url: Option<String>,
126    /// Cloudflare Tunnel token for `cloudflared service install --token ...`.
127    /// `None` → no cloudflared install (mesh-only node). When `Some`, the
128    /// renderer emits the full cloudflared apt-repo install + service enable
129    /// block into `runcmd` in place of `{{CLOUDFLARED_BLOCK}}`.
130    pub cloudflared_token: Option<String>,
131    /// When `Some`, render the cosign install + `cosign verify-blob` runcmd
132    /// block into `{{COSIGN_VERIFY_BLOCK}}`, gating the yubaba tarball on a
133    /// keyless OIDC signature whose certificate identity matches this regexp
134    /// (e.g. `^https://github\.com/yah-ai/yah/`). The sha256 check stays
135    /// in parallel as belt-and-suspenders (R330-F20, W203 §1.4). When `None`
136    /// the placeholder substitutes to an empty string and the bootstrap stays
137    /// on the sha256-only path.
138    pub yubaba_cosign_identity_regexp: Option<String>,
139}
140
141/// Placeholders used when the user requests a dry-run without supplying real
142/// substitutes. Makes the rendered YAML obviously non-shippable while still
143/// preserving the structure for review.
144pub const PLACEHOLDER_YUBABA_URL: &str = "<YUBABA_URL_PLACEHOLDER>";
145pub const PLACEHOLDER_YUBABA_SHA256: &str = "<YUBABA_SHA256_PLACEHOLDER>";
146pub const PLACEHOLDER_PREAUTH_KEY: &str = "<HEADSCALE_PREAUTH_KEY_PLACEHOLDER>";
147pub const PLACEHOLDER_CLOUDFLARED_TOKEN: &str = "<CLOUDFLARED_TOKEN_PLACEHOLDER>";
148
149/// Phase 1 defaults for new provisioning keys.
150pub const DEFAULT_YUBABA_CHANNEL: &str = "stable";
151/// Pinned cosign release used by the cloud-init verify-blob block. Bump in
152/// lockstep with [`COSIGN_SHA256_AMD64`] / [`COSIGN_SHA256_ARM64`] when
153/// upgrading the verifier — supply-chain hygiene (W203 §1.4, R330-F22). v3.x
154/// on purpose: cosign 3's sigstore-bundle format (`--bundle`) is current best
155/// practice, replacing the old separate `.sig`/`.cert` file pair everywhere
156/// in this pipeline (install.sh, release_manifest.rs, yubaba_fetch.rs).
157pub const COSIGN_VERSION: &str = "v3.1.3";
158/// sha256 of the cosign-linux-amd64 binary at [`COSIGN_VERSION`], from
159/// cosign's own published `cosign_checksums.txt` for that release. Cloud-init
160/// verifies the downloaded binary against this before chmod+exec. Pinning the
161/// verifier itself closes the bootstrap-trust gap: TLS to github.com proves
162/// origin, sha256 proves bytes, then cosign proves the yubaba tarball.
163pub const COSIGN_SHA256_AMD64: &str =
164    "4629c757b7618056f8ddd7e2625ae9fdd94c0372a65049520bc7d9df9efc7f71";
165/// sha256 of the cosign-linux-arm64 binary at [`COSIGN_VERSION`].
166pub const COSIGN_SHA256_ARM64: &str =
167    "c5d324e091826b0d7a78eb16fef316450b4eb9aaec045611c08ba06f5e73220a";
168/// Sigstore Fulcio OIDC issuer for GitHub-Actions-rooted keyless signing.
169/// Re-exported from [`crate::release_manifest`], which owns the canonical
170/// copy — the shell verify path here and the Rust verify path in the yah CLI
171/// must pin the same issuer or one of them silently accepts the other's
172/// rejects (R605-F1).
173pub use crate::release_manifest::COSIGN_OIDC_ISSUER;
174
175/// Where the twin-drift guard should look for the canonical `mirror.yml`.
176///
177/// The canonical copy lives at `<monorepo root>/.yah/infra/cloud-init/mirror.yml`
178/// and is what [`load_template`] — and therefore every real provision — actually
179/// reads. The embedded [`DEFAULT_TEMPLATE`] is only the fallback for when that
180/// file is absent, so the two must not be allowed to diverge.
181///
182/// Resolving that root is not as simple as "first ancestor with a `.yah/`".
183/// `oss/yubaba` is a deliberately independent cargo workspace (monorepo
184/// CLAUDE.md, "Co-developed OSS repos"), and its own `.yah/` carries nothing
185/// but a `.gitignore` — so an ancestor walk keyed on `.yah/` stops AT
186/// `oss/yubaba`, where the canonical file can never exist. That is exactly how
187/// `embedded_template_matches_workspace_canonical` spent months taking its
188/// "file not there yet, nothing to compare" branch and asserting nothing while
189/// the twin drifted 128 lines behind (R870-B25).
190///
191/// The discriminator is `.yah/infra/`: the monorepo has it (machines,
192/// cloud-init, preseed, rules, …), and a standalone export of this subtree —
193/// where the repo root genuinely *is* `oss/yubaba` — does not.
194#[derive(Debug, PartialEq, Eq)]
195pub enum CanonicalHome {
196    /// Built inside the yah monorepo. The canonical `mirror.yml` MUST exist
197    /// under this root; its absence is a defect, never a bootstrap case.
198    Monorepo(std::path::PathBuf),
199    /// Built from the standalone `yubaba` export, which ships no `.yah/infra/`
200    /// and therefore no canonical copy. Nothing on disk to compare against —
201    /// the embedded template is the only template there is.
202    StandaloneExport,
203}
204
205/// Walk `start`'s ancestors for the monorepo root that owns the canonical
206/// cloud-init template. See [`CanonicalHome`] for why `.yah/infra/` is the
207/// marker rather than `.yah/`.
208pub fn locate_canonical_home(start: &Path) -> CanonicalHome {
209    match start
210        .ancestors()
211        .find(|p| p.join(".yah").join("infra").is_dir())
212    {
213        Some(root) => CanonicalHome::Monorepo(root.to_path_buf()),
214        None => CanonicalHome::StandaloneExport,
215    }
216}
217
218/// Load the cloud-init template for a workspace.
219///
220/// Prefers `<workspace_root>/.yah/infra/cloud-init/mirror.yml` if it exists,
221/// otherwise returns the embedded [`DEFAULT_TEMPLATE`]. In the monorepo that
222/// file always exists, so **the on-disk canonical copy is what provisioning
223/// ships** — the embedded one is a fallback for fresh/exported workspaces, not
224/// the live path (R870-B25).
225pub fn load_template(workspace_root: &Path) -> Result<String> {
226    let custom = crate::paths::cloud_init_template(workspace_root);
227    if custom.exists() {
228        std::fs::read_to_string(&custom).with_context(|| format!("reading {}", custom.display()))
229    } else {
230        Ok(DEFAULT_TEMPLATE.to_string())
231    }
232}
233
234/// Substitute `{{KEY}}` placeholders. Fails loudly if any unsubstituted
235/// placeholder remains — better than silently shipping a broken cloud-init.
236pub fn render(template: &str, input: &RenderInput) -> Result<String> {
237    // Tailscale's ACL model only accepts `tag:`-prefixed values in
238    // `--advertise-tags`; mesh_tags also carries placement predicates like
239    // `arch:x86` / `os:linux` that aren't ACL tags at all, and passing those
240    // through makes `tailscale up` reject the whole join (observed manually
241    // on us-west-003/011, R757).
242    let advertise_tags: Vec<&str> = input
243        .machine
244        .mesh_tags
245        .iter()
246        .map(String::as_str)
247        .filter(|t| t.starts_with("tag:"))
248        .collect();
249    let tags = if advertise_tags.is_empty() {
250        // Tailscale rejects empty `--advertise-tags=`; emit a single tag derived from the machine name.
251        format!("tag:{}", input.machine.name)
252    } else {
253        advertise_tags.join(",")
254    };
255
256    let mesh_login_server_arg = match &input.mesh_url {
257        Some(url) => format!(" --login-server {url}"),
258        None => String::new(),
259    };
260
261    let cloudflared_block = match &input.cloudflared_token {
262        Some(token) => build_cloudflared_block(token),
263        None => String::new(),
264    };
265
266    // The tailscale-up/join block is emitted iff we have a preauth key, i.e.
267    // this machine is joining an existing mesh (R330-F28). Standalone /
268    // coordinator-to-be nodes carry no preauth and get no join block — they
269    // come up as bare yubaba and become the coordinator via `yah mesh bootstrap`.
270    let operator_bridge_block = match &input.headscale_preauth_key {
271        Some(key) => build_operator_bridge_block(key, &mesh_login_server_arg, &tags),
272        None => String::new(),
273    };
274
275    // Yubaba RPC (7443) firewall rule keys off the same axis (R330-F28 #13).
276    // JOINING (preauth present): deny public 7443 — the join block above adds
277    // an `allow in on tailscale0` so yubaba is mesh-reachable only. STANDALONE
278    // (no preauth): allow public 7443 so the operator can attach + bootstrap
279    // the coordinator before any mesh exists to reach it over.
280    let ufw_yubaba_rule = match &input.headscale_preauth_key {
281        Some(_) => "  - ufw deny 7443".to_string(),
282        None => "  - ufw allow 7443".to_string(),
283    };
284
285    // Coordinator pre-stage (standalone only, R330-F28 #15). yubaba runs under
286    // ProtectSystem=strict so it cannot write the headscale systemd unit nor
287    // open the firewall; cloud-init (unsandboxed) lays both down here so a later
288    // `yah mesh bootstrap` only needs to write config + `systemctl enable --now
289    // headscale`. The unit's ExecStart matches DEFAULT_HEADSCALE_DIR. Joining
290    // nodes never self-bootstrap a coordinator, so the block is empty for them.
291    let coordinator_prestage_block = match &input.headscale_preauth_key {
292        Some(_) => String::new(),
293        None => build_coordinator_prestage_block(),
294    };
295
296    let cosign_verify_block = match &input.yubaba_cosign_identity_regexp {
297        Some(regexp) => build_cosign_verify_block(&input.yubaba_url, regexp),
298        None => String::new(),
299    };
300
301    let rendered = template
302        .replace("{{MACHINE_NAME}}", &input.machine.name)
303        .replace("{{YAH_YUBABA_URL}}", &input.yubaba_url)
304        .replace("{{YAH_YUBABA_SHA256}}", &input.yubaba_sha256)
305        .replace("{{YUBABA_CHANNEL}}", &input.yubaba_channel)
306        .replace(
307            "{{HEADSCALE_PREAUTH_KEY}}",
308            input.headscale_preauth_key.as_deref().unwrap_or(""),
309        )
310        .replace("{{MESH_LOGIN_SERVER_ARG}}", &mesh_login_server_arg)
311        .replace("{{TAGS}}", &tags)
312        .replace("{{CLOUDFLARED_BLOCK}}", &cloudflared_block)
313        .replace("{{OPERATOR_BRIDGE_BLOCK}}", &operator_bridge_block)
314        .replace("{{UFW_YUBABA_RULE}}", &ufw_yubaba_rule)
315        .replace(
316            "{{COORDINATOR_PRESTAGE_BLOCK}}",
317            &coordinator_prestage_block,
318        )
319        .replace("{{COSIGN_VERIFY_BLOCK}}", &cosign_verify_block);
320
321    if let Some(remnant) = find_unsubstituted(&rendered) {
322        bail!("cloud-init template has unsubstituted placeholder: {remnant}");
323    }
324    Ok(rendered)
325}
326
327/// Read a yah-yubaba release tar.gz from disk and return its lowercase hex
328/// sha256. Used both as the cloud-init verification digest and for asserting
329/// that a local copy matches an expected `--yubaba-sha256` value. The path
330/// should point to the release archive (matching what cloud-init downloads),
331/// not a bare binary.
332pub fn compute_yubaba_sha256(path: &Path) -> Result<String> {
333    let bytes = std::fs::read(path)
334        .with_context(|| format!("reading yah-yubaba archive at {}", path.display()))?;
335    let mut hasher = Sha256::new();
336    hasher.update(&bytes);
337    Ok(hex::encode(hasher.finalize()))
338}
339
340/// Build the cloudflared apt-repo install + tunnel-connect block for `runcmd`.
341/// Each line is a valid cloud-init sequence entry (two-space indent + `- `).
342/// The block replaces `{{CLOUDFLARED_BLOCK}}` in the template; when empty it
343/// leaves a blank line in the rendered YAML (harmless to the YAML parser).
344fn build_cloudflared_block(token: &str) -> String {
345    [
346        "  - mkdir -p --mode=0755 /usr/share/keyrings".to_string(),
347        "  - sh -c 'curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg | gpg --dearmor > /usr/share/keyrings/cloudflare-main.gpg'".to_string(),
348        "  - 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(),
349        "  - apt-get update -qq".to_string(),
350        "  - apt-get install -y cloudflared".to_string(),
351        // The token is a POSITIONAL argument, not a `--token` flag: the
352        // `service install` subcommand has no `--token` option, so
353        // `cloudflared service install --token <tok>` fails arg-parsing with
354        // "flag provided but not defined: -token", prints usage, and NEVER
355        // creates cloudflared.service. The `systemctl enable --now` below then
356        // fails with "Unit file cloudflared.service does not exist", leaving
357        // the tunnel inactive with an empty journal (R330-B29, found on the
358        // us-west-001 from-zero validation). `service install <TOKEN>` already
359        // installs+enables+starts the unit; the explicit enable is redundant
360        // but harmless now that the unit exists.
361        format!("  - cloudflared service install {token}"),
362        "  - systemctl enable --now cloudflared".to_string(),
363    ]
364    .join("\n")
365}
366
367/// Build the cosign install + `cosign verify-blob` block for `runcmd`. Each
368/// line is a valid cloud-init sequence entry (two-space indent + `- `). The
369/// `.sigstore.json` bundle URL derives by appending that suffix to the
370/// tarball URL — matching what the release pipeline publishes alongside the
371/// canonical artifact (R330-F19). Architecture is detected at boot via
372/// `dpkg --print-architecture` so one rendered template serves both x86_64
373/// and aarch64 Hetzner machines.
374///
375/// R605-F1: `identity_spec` is parsed as a [`ReleaseTrust`], so a fleet whose
376/// releases are cut on QED (key-based cosign, no Fulcio) provisions by setting
377/// `key:<pubkey-ref>`. cosign 3.x's bundle carries the signature (and, for
378/// keyless, the certificate + transparency proof) as one file either way, so
379/// unlike the pre-bundle format there is no second sidecar fetch that drops
380/// out for key trust — the bundle download is unconditional.
381fn build_cosign_verify_block(yubaba_url: &str, identity_spec: &str) -> String {
382    // ARCH + SHA must be set, checked, and consumed inside the same `sh -c`
383    // process — cloud-init runcmd entries are independent shells, so a
384    // multi-step download-then-verify-then-install split would lose the
385    // variables between lines. One `sh -c` keeps the pin atomic.
386    //
387    // NB: runcmd entries are emitted as bare (unquoted) YAML scalars, so a
388    // `: ` (colon-space) anywhere in the command makes the YAML parser read
389    // the entry as a `key: value` MAPPING — cloud-init's shellify then chokes
390    // on the dict and SKIPS THE ENTIRE runcmd block (R330-F28 from-zero
391    // validation, pothole #12). Keep this string colon-space-free: the error
392    // message says "unsupported arch <ARCH>", not "unsupported arch: <ARCH>".
393    let install_and_verify_cosign = format!(
394        "  - sh -c 'set -e; ARCH=$(dpkg --print-architecture); \
395            case \"$ARCH\" in \
396              amd64) SHA=\"{amd64}\";; \
397              arm64) SHA=\"{arm64}\";; \
398              *) echo \"unsupported arch $ARCH\" >&2; exit 1;; \
399            esac; \
400            curl -fsSL \"https://github.com/sigstore/cosign/releases/download/{ver}/cosign-linux-${{ARCH}}\" -o /usr/local/bin/cosign; \
401            echo \"${{SHA}}  /usr/local/bin/cosign\" | sha256sum -c -; \
402            chmod +x /usr/local/bin/cosign'",
403        amd64 = COSIGN_SHA256_AMD64,
404        arm64 = COSIGN_SHA256_ARM64,
405        ver = COSIGN_VERSION
406    );
407    let trust = ReleaseTrust::parse(identity_spec);
408    // Single-quote each VALUE token: the regexp arm carries backslashes and
409    // `^` that the boot shell would otherwise eat, and the key arm carries a
410    // URI. Flag names (the `--xxx` tokens, including standalone boolean
411    // flags like `--insecure-ignore-tlog` that take no value) are emitted
412    // bare — they're static literals, never operator input. This can't
413    // assume flag/value PAIRS any more: the key arm's flag list is now
414    // [--key, key_ref, --insecure-ignore-tlog], an odd length, because
415    // cosign key-mode signs with no transparency-log upload (see
416    // ReleaseTrust::verify_flags) and the verify side has to say so too. No
417    // value may contain a `'` — a spec that does is an operator error, not a
418    // case to escape, since it can't be a valid identity regexp or key ref.
419    let trust_flags = trust
420        .verify_flags()
421        .into_iter()
422        .map(|tok| {
423            if tok.starts_with("--") {
424                tok
425            } else {
426                format!("'{tok}'")
427            }
428        })
429        .collect::<Vec<_>>()
430        .join(" ");
431
432    let lines = vec![
433        install_and_verify_cosign,
434        format!("  - curl -fsSL -o /tmp/yah-yubaba.tar.gz.sigstore.json {yubaba_url}.sigstore.json"),
435        format!(
436            "  - cosign verify-blob {trust_flags} --bundle /tmp/yah-yubaba.tar.gz.sigstore.json /tmp/yah-yubaba.tar.gz"
437        ),
438    ];
439    lines.join("\n")
440}
441
442/// Build the tailscaled operator-bridge block for `runcmd`.
443/// Each line is a valid cloud-init sequence entry (two-space indent + `- `).
444/// The block replaces `{{OPERATOR_BRIDGE_BLOCK}}` in the template; when the
445/// machine does not host operator-bridge workloads the placeholder is replaced
446/// with an empty string (harmless blank line in the rendered YAML).
447fn build_operator_bridge_block(
448    preauth_key: &str,
449    mesh_login_server_arg: &str,
450    tags: &str,
451) -> String {
452    // `--accept-dns=false` is load-bearing, not a preference (R624-B1).
453    //
454    // With MagicDNS accepted, tailscaled rewrites /etc/resolv.conf to point at
455    // its own resolver (100.100.100.100). If tailscaled is then down — as it is
456    // on every boot before it connects — nothing answers that address, so the
457    // node cannot resolve the control server, so tailscaled can never come up.
458    // The loop is self-sustaining and survives reboots and restarts; it took
459    // us-south-001 (a raft voter) off the mesh for 30+ hours until a human
460    // edited resolv.conf by hand. Ordering the unit After=tailscaled does NOT
461    // help: tailscaled starts fine, it just can never CONNECT.
462    //
463    // Server nodes have nothing to lose here. Nothing on a node resolves a
464    // mesh name: yubaba binds a literal 100.64.0.0/10 address, the machine
465    // TOMLs carry literal IPs, and kamaji reaches its peers over a unix socket.
466    // MagicDNS buys a convenience we never use, at the cost of a node that can
467    // permanently strand itself.
468    [
469        "  - curl -fsSL https://tailscale.com/install.sh | sh".to_string(),
470        format!("  - tailscale up{mesh_login_server_arg} --auth-key={preauth_key} --advertise-tags={tags} --accept-dns=false"),
471        "  - ufw allow in on tailscale0 to any port 7443".to_string(),
472    ]
473    .join("\n")
474}
475
476/// Build the coordinator pre-stage block for `runcmd` (R330-F28 #15). Emitted
477/// only for STANDALONE nodes (no preauth). cloud-init runs unsandboxed at first
478/// boot, so it lays down the headscale systemd unit + opens ufw 80/443 (the LE
479/// HTTP-01 challenge + the headscale `listen_addr :443`). yubaba runs under
480/// `ProtectSystem=strict` and cannot write `/etc/systemd/system` or `/etc/ufw`,
481/// so `yah mesh bootstrap` only downloads the headscale binary, writes config,
482/// and `systemctl enable --now headscale` against this pre-staged unit.
483///
484/// The unit's `ExecStart` must match yubaba's `DEFAULT_HEADSCALE_DIR`
485/// (`/var/lib/yah-cloud/headscale` — under the systemd StateDirectory, writable
486/// at runtime; see R330-F28 #14). Kept colon-space-free so the bare YAML scalar
487/// stays a string (#12). `enable` (not `--now`) here: the binary + config don't
488/// exist until bootstrap, so we only wire it for boot, not start it now.
489fn build_coordinator_prestage_block() -> String {
490    let unit = "[Unit]\\n\
491                Description=Headscale coordinator (yah-managed)\\n\
492                After=network-online.target\\n\\n\
493                [Service]\\n\
494                ExecStart=/var/lib/yah-cloud/headscale/headscale serve --config /var/lib/yah-cloud/headscale/config.yaml\\n\
495                Restart=on-failure\\n\
496                RestartSec=5\\n\\n\
497                [Install]\\n\
498                WantedBy=multi-user.target\\n";
499    [
500        format!("  - sh -c 'printf \"{unit}\" > /etc/systemd/system/headscale.service'"),
501        "  - systemctl enable headscale".to_string(),
502        "  - ufw allow 80".to_string(),
503        "  - ufw allow 443".to_string(),
504    ]
505    .join("\n")
506}
507
508/// Look for an unsubstituted placeholder of the form `{{NAME}}` (no spaces inside braces).
509/// Documentation comments deliberately use `{{ NAME }}` (with spaces) so they survive rendering.
510fn find_unsubstituted(s: &str) -> Option<&str> {
511    let mut cursor = 0;
512    while let Some(start_off) = s[cursor..].find("{{") {
513        let start = cursor + start_off;
514        let after = &s[start + 2..];
515        let end_off = after.find("}}")?;
516        let inner = &after[..end_off];
517        if !inner.is_empty() && !inner.starts_with(' ') && !inner.ends_with(' ') {
518            return Some(&s[start..start + 2 + end_off + 2]);
519        }
520        cursor = start + 2 + end_off + 2;
521    }
522    None
523}
524
525#[cfg(test)]
526mod tests {
527    use super::*;
528    use crate::config::{BucketSpec, MachineConfig};
529    use std::path::Path;
530
531    fn sample_machine() -> MachineConfig {
532        MachineConfig {
533            name: "noisetable-pdx-1".into(),
534            provider: "hetzner".into(),
535            location: Some("pdx".into()),
536            server_type: Some("cpx22".into()),
537            hosts_mirrors: vec!["noisetable".into(), "yah".into()],
538            mesh_tags: vec!["tag:region-pdx".into(), "tag:tier-t2".into()],
539            region: None,
540            zone: None,
541            arch: None,
542            bucket: Some(BucketSpec {
543                name: "noisetable-assets-pdx-1".into(),
544                public_read: false,
545            }),
546            vendor: None,
547            nickname: None,
548            legacy_hostkey_fingerprint: None,
549            registration: Default::default(),
550            ssh_keys: vec![],
551            cloudflared: None,
552            hosts_operator_bridge: false,
553            connect: None,
554            allocatable: None,
555            taints: vec![],
556            sovereign_group: None,
557            sovereign_role: None,
558            ingress_floating_ip: None,
559        }
560    }
561
562    fn minimal_input(machine: &MachineConfig) -> RenderInput<'_> {
563        RenderInput {
564            machine,
565            yubaba_url: "https://example.com/yah-yubaba".into(),
566            yubaba_sha256: "deadbeef".into(),
567            yubaba_channel: DEFAULT_YUBABA_CHANNEL.into(),
568            // Default: standalone (no join block). Tests that exercise the
569            // mesh-join path set `headscale_preauth_key = Some(...)`.
570            headscale_preauth_key: None,
571            mesh_url: None,
572            cloudflared_token: None,
573            yubaba_cosign_identity_regexp: None,
574        }
575    }
576
577    #[test]
578    fn render_substitutes_all_placeholders() {
579        let machine = sample_machine();
580        // Enable operator bridge so tailscale content (preauth key, tags) is emitted.
581        let mut input = minimal_input(&machine);
582        input.headscale_preauth_key = Some("tskey-test".into());
583        let out = render(DEFAULT_TEMPLATE, &input).unwrap();
584        assert!(out.contains("noisetable-pdx-1"));
585        assert!(out.contains("https://example.com/yah-yubaba"));
586        assert!(out.contains("deadbeef"));
587        assert!(out.contains(DEFAULT_YUBABA_CHANNEL));
588        assert!(out.contains("apt-get install -y containerd"));
589        assert!(out.contains("tskey-test"));
590        assert!(out.contains("tag:region-pdx,tag:tier-t2"));
591        // Real placeholders must all be substituted.
592        // Documentation {{ KEY }} (with inner spaces) is allowed to survive.
593        assert!(find_unsubstituted(&out).is_none());
594    }
595
596    #[test]
597    fn render_with_mesh_url_adds_login_server() {
598        let machine = sample_machine();
599        let mut input = minimal_input(&machine);
600        input.mesh_url = Some("https://mesh.example.com".into());
601        input.headscale_preauth_key = Some("tskey-test".into());
602        let out = render(DEFAULT_TEMPLATE, &input).unwrap();
603        assert!(out.contains("--login-server https://mesh.example.com"));
604        assert!(find_unsubstituted(&out).is_none());
605    }
606
607    #[test]
608    fn render_without_mesh_url_no_login_server() {
609        let machine = sample_machine();
610        let mut input = minimal_input(&machine);
611        input.headscale_preauth_key = Some("tskey-test".into());
612        let out = render(DEFAULT_TEMPLATE, &input).unwrap();
613        // Without a mesh_url the MESH_LOGIN_SERVER_ARG substitutes to empty string,
614        // so the tailscale up line should not contain a --login-server=https:// arg.
615        assert!(!out.contains("--login-server https://"));
616        assert!(find_unsubstituted(&out).is_none());
617    }
618
619    #[test]
620    fn render_with_placeholders_for_dry_run() {
621        let machine = sample_machine();
622        let input = RenderInput {
623            machine: &machine,
624            yubaba_url: PLACEHOLDER_YUBABA_URL.into(),
625            yubaba_sha256: PLACEHOLDER_YUBABA_SHA256.into(),
626            yubaba_channel: DEFAULT_YUBABA_CHANNEL.into(),
627            // A preauth key present → join block emitted, so the placeholder appears in output.
628            headscale_preauth_key: Some(PLACEHOLDER_PREAUTH_KEY.into()),
629            mesh_url: None,
630            cloudflared_token: None,
631            yubaba_cosign_identity_regexp: None,
632        };
633        let out = render(DEFAULT_TEMPLATE, &input).unwrap();
634        assert!(out.contains(PLACEHOLDER_YUBABA_URL));
635        assert!(out.contains(PLACEHOLDER_YUBABA_SHA256));
636        assert!(out.contains(PLACEHOLDER_PREAUTH_KEY));
637    }
638
639    #[test]
640    fn render_stays_under_hetzner_user_data_cap() {
641        // Backward-compat alias — see renders_under_user_data_cap below.
642        renders_under_user_data_cap();
643    }
644
645    #[test]
646    fn renders_under_user_data_cap() {
647        // Hetzner refuses user_data > 32 KiB (R040-F11). Worst-case: all blocks
648        // enabled (cloudflared + operator_bridge), long URL and sha, full tag list.
649        let machine = sample_machine();
650        let input = RenderInput {
651            machine: &machine,
652            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(),
653            yubaba_sha256: "0".repeat(64),
654            yubaba_channel: "stable".into(),
655            headscale_preauth_key: Some("tskey-auth-keylongenoughforrealism123456".into()),
656            mesh_url: Some("https://mesh.example.com".into()),
657            cloudflared_token: Some(PLACEHOLDER_CLOUDFLARED_TOKEN.into()),
658            // Worst-case includes the cosign block so the 32 KiB cap test
659            // covers the bootstrap-channel signing path too (W203 §1.4).
660            yubaba_cosign_identity_regexp: Some("^https://github\\.com/yah-ai/yah/".into()),
661        };
662        let out = render(DEFAULT_TEMPLATE, &input).unwrap();
663        assert!(
664            out.len() < 32 * 1024,
665            "rendered cloud-init is {} bytes (Hetzner cap is 32 KiB)",
666            out.len()
667        );
668    }
669
670    #[test]
671    fn render_rejects_unknown_placeholder() {
672        let machine = sample_machine();
673        let input = RenderInput {
674            machine: &machine,
675            yubaba_url: "x".into(),
676            yubaba_sha256: "y".into(),
677            yubaba_channel: "stable".into(),
678            headscale_preauth_key: None,
679            mesh_url: None,
680            cloudflared_token: None,
681            yubaba_cosign_identity_regexp: None,
682        };
683        let bad = "foo: {{NOT_A_KEY}}\n";
684        let err = render(bad, &input).unwrap_err().to_string();
685        assert!(err.contains("{{NOT_A_KEY}}"), "unexpected error: {err}");
686    }
687
688    #[test]
689    fn render_falls_back_to_machine_tag_when_mesh_tags_empty() {
690        let mut machine = sample_machine();
691        machine.mesh_tags = vec![];
692        let mut input = minimal_input(&machine);
693        input.headscale_preauth_key = Some("tskey-test".into());
694        let out = render(DEFAULT_TEMPLATE, &input).unwrap();
695        assert!(out.contains("--advertise-tags=tag:noisetable-pdx-1"));
696    }
697
698    #[test]
699    fn render_advertise_tags_filters_non_tag_prefixed_mesh_tags() {
700        let mut machine = sample_machine();
701        machine.mesh_tags = vec![
702            "tag:build-worker".into(),
703            "arch:x86".into(),
704            "os:linux".into(),
705            "tag:qed".into(),
706        ];
707        let mut input = minimal_input(&machine);
708        input.headscale_preauth_key = Some("tskey-test".into());
709        let out = render(DEFAULT_TEMPLATE, &input).unwrap();
710        assert!(out.contains("--advertise-tags=tag:build-worker,tag:qed"));
711        assert!(!out.contains("arch:x86"));
712        assert!(!out.contains("os:linux"));
713    }
714
715    #[test]
716    fn load_template_uses_workspace_override_when_present() {
717        let dir = tempfile::tempdir().unwrap();
718        let custom_dir = crate::paths::cloud_init_dir(dir.path());
719        std::fs::create_dir_all(&custom_dir).unwrap();
720        std::fs::write(
721            custom_dir.join("mirror.yml"),
722            "#cloud-config\ncustom: true\n",
723        )
724        .unwrap();
725        let loaded = load_template(dir.path()).unwrap();
726        assert!(loaded.contains("custom: true"));
727    }
728
729    #[test]
730    fn load_template_falls_back_to_default_when_absent() {
731        let dir = tempfile::tempdir().unwrap();
732        let loaded = load_template(dir.path()).unwrap();
733        assert_eq!(loaded, DEFAULT_TEMPLATE);
734    }
735
736    #[test]
737    fn render_preserves_documentation_comments() {
738        let machine = sample_machine();
739        let input = minimal_input(&machine);
740        let out = render(DEFAULT_TEMPLATE, &input).unwrap();
741        // The header comment uses `{{ KEY }}` (with spaces) so it survives the
742        // `{{KEY}}` (no-spaces) substitution and stays readable.
743        assert!(out.contains("{{ MACHINE_NAME }}"));
744        assert!(out.contains("{{ YAH_YUBABA_URL }}"));
745        assert!(out.contains("{{ YAH_YUBABA_SHA256 }}"));
746    }
747
748    #[test]
749    fn render_with_cloudflared_token_emits_install_block() {
750        let machine = sample_machine();
751        let mut input = minimal_input(&machine);
752        input.cloudflared_token = Some("tok_abc123".into());
753        let out = render(DEFAULT_TEMPLATE, &input).unwrap();
754        // R330-B29: token is a POSITIONAL arg, NOT a `--token` flag. The flag
755        // form fails arg-parsing and never creates cloudflared.service.
756        assert!(
757            out.contains("cloudflared service install tok_abc123"),
758            "install line missing"
759        );
760        assert!(
761            !out.contains("service install --token"),
762            "must not use the bogus --token flag (R330-B29)"
763        );
764        assert!(
765            out.contains("systemctl enable --now cloudflared"),
766            "enable line missing"
767        );
768        assert!(out.contains("pkg.cloudflare.com"), "apt-repo setup missing");
769        assert!(find_unsubstituted(&out).is_none());
770    }
771
772    #[test]
773    fn render_without_cloudflared_token_omits_install_block() {
774        let machine = sample_machine();
775        let input = minimal_input(&machine);
776        let out = render(DEFAULT_TEMPLATE, &input).unwrap();
777        // Template header mentions "cloudflared service install" in prose; check
778        // for the token-bearing form which is only present in the actual runcmd.
779        assert!(
780            !out.contains("cloudflared service install --token"),
781            "install line should be absent"
782        );
783        assert!(
784            !out.contains("pkg.cloudflare.com"),
785            "apt-repo setup should be absent"
786        );
787        assert!(find_unsubstituted(&out).is_none());
788    }
789
790    #[test]
791    fn operator_bridge_block_emitted_when_enabled() {
792        let machine = sample_machine();
793        let mut input = minimal_input(&machine);
794        input.headscale_preauth_key = Some("tskey-test".into());
795        let out = render(DEFAULT_TEMPLATE, &input).unwrap();
796        assert!(
797            out.contains("tailscale.com/install.sh"),
798            "tailscale install missing"
799        );
800        assert!(out.contains("tailscale up"), "tailscale up missing");
801        assert!(
802            out.contains("ufw allow in on tailscale0"),
803            "ufw tailscale rule missing"
804        );
805        assert!(find_unsubstituted(&out).is_none());
806    }
807
808    /// R624-B1: a provisioned node must never let tailscaled own
809    /// `/etc/resolv.conf`.
810    ///
811    /// Accepting MagicDNS points the resolver at 100.100.100.100, which only
812    /// answers while tailscaled is up — so a tailscaled that is down cannot
813    /// resolve its control server and can never come up again. That deadlock
814    /// took a raft voter off the mesh for 30+ hours. This assertion is the
815    /// guard: dropping the flag silently re-arms the trap on every node
816    /// provisioned afterwards, and the damage only shows up at the next reboot.
817    #[test]
818    fn tailscale_join_refuses_magicdns_so_a_node_cannot_strand_itself() {
819        let machine = sample_machine();
820        let mut input = minimal_input(&machine);
821        input.headscale_preauth_key = Some("tskey-test".into());
822        let out = render(DEFAULT_TEMPLATE, &input).unwrap();
823        assert!(
824            out.contains("--accept-dns=false"),
825            "tailscale up MUST pass --accept-dns=false — without it tailscaled \
826             rewrites /etc/resolv.conf to MagicDNS and a node that boots with \
827             tailscaled down can never resolve its control server again \
828             (R624-B1). Rendered:\n{out}"
829        );
830    }
831
832    #[test]
833    fn operator_bridge_block_omitted_when_disabled() {
834        let machine = sample_machine();
835        let input = minimal_input(&machine);
836        let out = render(DEFAULT_TEMPLATE, &input).unwrap();
837        assert!(
838            !out.contains("tailscale.com/install.sh"),
839            "tailscale install should be absent"
840        );
841        // Template header mentions "tailscale up" in prose; check for the auth-key
842        // bearing form which is only present in the actual runcmd block.
843        assert!(
844            !out.contains("tailscale up --auth-key"),
845            "tailscale join should be absent"
846        );
847        assert!(find_unsubstituted(&out).is_none());
848    }
849
850    /// R330-F28 pothole #12: the from-zero validation found that cloud-init's
851    /// `runcmd` is emitted as a sequence of BARE (unquoted) YAML scalars, so a
852    /// `: ` (colon-space) anywhere in a command — e.g. the cosign block's old
853    /// `echo "unsupported arch: $ARCH"` — makes the YAML parser read the entry
854    /// as a `{key: value}` mapping. cloud-init's `shellify` then rejects the
855    /// dict and SKIPS THE ENTIRE runcmd block, so yubaba never installs. This
856    /// test parses the worst-case rendered cloud-init as real YAML and asserts
857    /// every runcmd entry is a string, catching any future colon-space footgun.
858    #[test]
859    fn rendered_runcmd_entries_are_all_strings() {
860        let machine = sample_machine();
861        // Worst case: every conditional block present (cosign + cloudflared +
862        // operator-bridge), since that's where dynamic strings are injected.
863        let input = RenderInput {
864            machine: &machine,
865            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(),
866            yubaba_sha256: "0".repeat(64),
867            yubaba_channel: "stable".into(),
868            headscale_preauth_key: Some("tskey-auth-keylongenoughforrealism123456".into()),
869            mesh_url: Some("https://cloud.mesh.yah.dev".into()),
870            cloudflared_token: Some("tok_abc123".into()),
871            yubaba_cosign_identity_regexp: Some(r"^https://github\.com/yah-ai/yah/".into()),
872        };
873        let out = render(DEFAULT_TEMPLATE, &input).unwrap();
874        let doc: serde_yaml::Value =
875            serde_yaml::from_str(&out).expect("rendered cloud-init must be valid YAML");
876        let runcmd = doc
877            .get("runcmd")
878            .and_then(|v| v.as_sequence())
879            .expect("cloud-init must have a runcmd sequence");
880        assert!(!runcmd.is_empty(), "runcmd should not be empty");
881        for (i, entry) in runcmd.iter().enumerate() {
882            assert!(
883                entry.is_string(),
884                "runcmd[{i}] parsed as {entry:?}, not a string — a `: ` (colon-space) \
885                 in a bare YAML scalar turned it into a mapping (pothole #12)"
886            );
887        }
888    }
889
890    /// R330-F28 #13: the yubaba-port firewall rule keys off mesh role. A
891    /// JOINING node (preauth present) denies public 7443 (mesh-only via the
892    /// tailscale0 allow in the join block); a STANDALONE coordinator (no
893    /// preauth) allows public 7443 so the operator can attach + bootstrap it
894    /// before any mesh exists.
895    #[test]
896    fn ufw_yubaba_rule_keys_off_mesh_role() {
897        let machine = sample_machine();
898
899        // Standalone: allow public 7443.
900        let standalone = minimal_input(&machine);
901        let out = render(DEFAULT_TEMPLATE, &standalone).unwrap();
902        assert!(
903            out.contains("ufw allow 7443"),
904            "standalone must allow public 7443"
905        );
906        assert!(
907            !out.contains("ufw deny 7443"),
908            "standalone must not deny 7443"
909        );
910
911        // Joining: deny public 7443 (join block adds the tailscale0 allow).
912        let mut joining = minimal_input(&machine);
913        joining.headscale_preauth_key = Some("tskey-test".into());
914        let out = render(DEFAULT_TEMPLATE, &joining).unwrap();
915        assert!(
916            out.contains("ufw deny 7443"),
917            "joining node must deny public 7443"
918        );
919        assert!(
920            !out.contains("ufw allow 7443"),
921            "joining node must not allow public 7443"
922        );
923        assert!(
924            out.contains("ufw allow in on tailscale0"),
925            "joining node needs the tailscale0 allow"
926        );
927    }
928
929    /// R330-F28 #15: a STANDALONE coordinator pre-stages the headscale unit +
930    /// opens ufw 80/443 via cloud-init (yubaba's sandbox can't). A JOINING node
931    /// must never do this. Both renders must stay valid, parseable YAML.
932    #[test]
933    fn coordinator_prestage_only_for_standalone() {
934        let machine = sample_machine();
935
936        // Standalone: pre-stage present.
937        let standalone = minimal_input(&machine);
938        let out = render(DEFAULT_TEMPLATE, &standalone).unwrap();
939        assert!(
940            out.contains("/etc/systemd/system/headscale.service"),
941            "standalone must stage the headscale unit"
942        );
943        assert!(
944            out.contains("/var/lib/yah-cloud/headscale/headscale serve"),
945            "unit ExecStart must match DEFAULT_HEADSCALE_DIR"
946        );
947        assert!(
948            out.contains("ufw allow 80"),
949            "standalone must open ufw 80 (LE HTTP-01)"
950        );
951        assert!(
952            out.contains("ufw allow 443"),
953            "standalone must open ufw 443 (headscale)"
954        );
955        // Must stay parseable YAML with all-string runcmd entries.
956        let doc: serde_yaml::Value =
957            serde_yaml::from_str(&out).expect("standalone cloud-init must be valid YAML");
958        for entry in doc["runcmd"].as_sequence().expect("runcmd seq") {
959            assert!(
960                entry.is_string(),
961                "standalone runcmd entry parsed as {entry:?}, not a string"
962            );
963        }
964
965        // Joining: no pre-stage.
966        let mut joining = minimal_input(&machine);
967        joining.headscale_preauth_key = Some("tskey-test".into());
968        let out = render(DEFAULT_TEMPLATE, &joining).unwrap();
969        assert!(
970            !out.contains("/etc/systemd/system/headscale.service"),
971            "joining node must not stage a headscale unit"
972        );
973        assert!(
974            !out.contains("ufw allow 443"),
975            "joining node must not open 443"
976        );
977    }
978
979    #[test]
980    fn render_yubaba_channel_in_output() {
981        let machine = sample_machine();
982        let mut input = minimal_input(&machine);
983        input.yubaba_channel = "beta".into();
984        let out = render(DEFAULT_TEMPLATE, &input).unwrap();
985        // Channel rides a systemd drop-in (Environment=YUBABA_CHANNEL=…), not a
986        // --channel flag (the ExecStart bakes defaults; the drop-in tunes it).
987        assert!(
988            out.contains("YUBABA_CHANNEL=beta"),
989            "yubaba channel missing"
990        );
991        // containerd is installed unpinned (R330-T9).
992        assert!(
993            out.contains("apt-get install -y containerd\n"),
994            "containerd install missing"
995        );
996        assert!(find_unsubstituted(&out).is_none());
997    }
998
999    #[test]
1000    fn embedded_template_matches_workspace_canonical() {
1001        // Drift test: .yah/infra/cloud-init/mirror.yml must stay in sync with
1002        // the embedded DEFAULT_TEMPLATE (templates/mirror.yml). Edit both files
1003        // together; this test catches divergence.
1004        //
1005        // It only catches it because the root is resolved via
1006        // locate_canonical_home rather than "first ancestor with a `.yah/`" —
1007        // the latter stops at oss/yubaba, whose .yah/ holds only a .gitignore,
1008        // so the canonical file was never found, the old `if exists` branch
1009        // never fired, and this test asserted NOTHING for months (R870-B25).
1010        // A missing canonical file inside the monorepo is now a hard failure,
1011        // not a bootstrap case.
1012        match locate_canonical_home(Path::new(env!("CARGO_MANIFEST_DIR"))) {
1013            CanonicalHome::Monorepo(root) => {
1014                let canonical_path = crate::paths::cloud_init_template(&root);
1015                let canonical = std::fs::read_to_string(&canonical_path).unwrap_or_else(|e| {
1016                    panic!(
1017                        "{} is unreadable ({e}) — in the monorepo this file is REQUIRED, not \
1018                         optional: cloud_init::load_template prefers it over the embedded \
1019                         template, so it is the copy real provisions ship",
1020                        canonical_path.display()
1021                    )
1022                });
1023                assert_eq!(
1024                    canonical.trim_end(),
1025                    DEFAULT_TEMPLATE.trim_end(),
1026                    "{} drifted from the embedded templates/mirror.yml — edit both files \
1027                     together. The on-disk copy is the one provisioning actually reads.",
1028                    canonical_path.display()
1029                );
1030            }
1031            CanonicalHome::StandaloneExport => {
1032                // The exported yubaba repo carries no .yah/infra/ and therefore
1033                // no canonical copy; the embedded template is the only one.
1034                // This is the ONLY branch allowed to skip the comparison, and
1035                // locate_canonical_home_* below pin what can reach it.
1036            }
1037        }
1038    }
1039
1040    /// Anchors that must appear in BOTH `mirror.yml` and its SSH twin
1041    /// `.yah/infra/cloud-init/stand-up-yubaba.sh` for the two to count as level
1042    /// on what they install.
1043    ///
1044    /// Every entry is asserted against the template as well as the script, so
1045    /// the list cannot rot into pinning a step the template has since dropped —
1046    /// a stale anchor goes red on the template side instead of quietly
1047    /// over-constraining the script.
1048    ///
1049    /// This is deliberately an ANCHOR list, not a diff: the two files are
1050    /// different languages with different runtime contracts (cloud-init runs
1051    /// once as root on a fresh box; the script is idempotent, re-runnable and
1052    /// `$SUDO`-prefixed), and several divergences are correct — the script's
1053    /// `enable` + `restart` instead of `enable --now`, its write-if-absent
1054    /// journald ceiling, its cluster-KEK install, its loopback bind. What must
1055    /// NOT diverge is the set of artifacts a node ends up carrying.
1056    const STAND_UP_TWIN_ANCHORS: &[(&str, &str)] = &[
1057        // R858-F17 durability helpers. Absent → kamaji refuses to deploy any
1058        // workload declaring a `yah.durability.tier` (kamaji-bin/src/hydrate.rs).
1059        (
1060            "/usr/local/bin/turso-backup-hydrate",
1061            "durability helper binary",
1062        ),
1063        (
1064            "/usr/local/bin/turso-backup-tail",
1065            "durability helper binary",
1066        ),
1067        ("KAMAJI_HYDRATE_HELPER", "kamaji drop-in env var"),
1068        ("KAMAJI_TAIL_HELPER", "kamaji drop-in env var"),
1069        (
1070            "/etc/systemd/system/kamaji.service.d",
1071            "drop-in dir the helper env vars land in",
1072        ),
1073        // R858 headscale DB continuity. The unit is staged and never enabled —
1074        // leader.rs starts it on gaining the ingress owner role — and yubaba
1075        // runs ProtectSystem=strict, so a node that did not get it at stand-up
1076        // can never acquire it at runtime.
1077        ("litestream-headscale.service", "staged replication unit"),
1078        ("/usr/local/bin/litestream", "replicate/restore binary"),
1079        // R858-T4: every node is a coordinator candidate, and the appliance is
1080        // a NATIVE workload kamaji forks rather than pulls.
1081        (
1082            "/var/lib/yah-cloud/headscale/headscale",
1083            "pre-staged headscale binary",
1084        ),
1085    ];
1086
1087    /// Maximal runs of lowercase hex exactly 64 chars long — the sha256 pins
1088    /// for third-party downloads. `{{YAH_YUBABA_SHA256}}` is a placeholder, not
1089    /// hex, so it is not picked up; the four real pins (litestream and
1090    /// headscale, amd64 and arm64) are.
1091    fn sha256_pins(s: &str) -> std::collections::BTreeSet<String> {
1092        s.split(|c: char| !c.is_ascii_hexdigit() || c.is_ascii_uppercase())
1093            .filter(|t| t.len() == 64)
1094            .map(str::to_string)
1095            .collect()
1096    }
1097
1098    /// Every `https://github.com/<owner>/<repo>/releases/download/<tag>/`
1099    /// prefix in `s`. Owner, repo and tag are the pinned part; the asset
1100    /// filename after the tag is arch-templated and left free.
1101    fn upstream_release_pins(s: &str) -> std::collections::BTreeSet<String> {
1102        const MARK: &str = "https://github.com/";
1103        let mut out = std::collections::BTreeSet::new();
1104        for (i, _) in s.match_indices(MARK) {
1105            let rest = &s[i..];
1106            let Some(dl) = rest.find("/releases/download/") else {
1107                continue;
1108            };
1109            let after = &rest[dl + "/releases/download/".len()..];
1110            let Some(slash) = after.find('/') else {
1111                continue;
1112            };
1113            out.insert(rest[..dl + "/releases/download/".len() + slash + 1].to_string());
1114        }
1115        out
1116    }
1117
1118    #[test]
1119    fn stand_up_script_carries_the_templates_install_steps() {
1120        // THIRD-TWIN GUARD (R870-B25). mirror.yml had two copies and one
1121        // vacuous guard between them; the SSH transcription
1122        // .yah/infra/cloud-init/stand-up-yubaba.sh is a third copy of the same
1123        // install list with NO guard at all, which is how it came to be missing
1124        // the R858-F17 durability helpers and the R858 litestream/headscale
1125        // pre-stage while both mirror.yml copies carried them.
1126        //
1127        // The pins below are DERIVED FROM THE TEMPLATE rather than restated, so
1128        // bumping litestream or headscale in mirror.yml alone turns this red
1129        // instead of leaving the script pinned to a superseded checksum.
1130        let root = match locate_canonical_home(Path::new(env!("CARGO_MANIFEST_DIR"))) {
1131            CanonicalHome::Monorepo(root) => root,
1132            // Same single permitted skip as the mirror.yml drift guard: the
1133            // exported yubaba repo ships no .yah/infra/, so there is no script.
1134            // drift_guard_cannot_go_vacuous_in_the_monorepo pins that this
1135            // branch is unreachable from inside the monorepo.
1136            CanonicalHome::StandaloneExport => return,
1137        };
1138        let script_path = crate::paths::stand_up_script(&root);
1139        let script = std::fs::read_to_string(&script_path).unwrap_or_else(|e| {
1140            panic!(
1141                "{} is unreadable ({e}) — it is mirror.yml's SSH twin for LAN nodes \
1142                 (W257 step 6) and must stay level with it",
1143                script_path.display()
1144            )
1145        });
1146
1147        for (anchor, what) in STAND_UP_TWIN_ANCHORS {
1148            assert!(
1149                DEFAULT_TEMPLATE.contains(anchor),
1150                "STAND_UP_TWIN_ANCHORS is stale: templates/mirror.yml no longer mentions \
1151                 {anchor} ({what}) — drop the anchor here rather than holding the script \
1152                 to a step the template abandoned"
1153            );
1154            assert!(
1155                script.contains(anchor),
1156                "{} is behind templates/mirror.yml: no {anchor} ({what}). A LAN node stood \
1157                 up by this script would not carry it. Transcribe the step in the script's \
1158                 own idiom ($SUDO install from $D, tee for drop-ins, non-fatal WARNING) — \
1159                 this is not a byte-diff, only the resulting artifacts must match.",
1160                script_path.display()
1161            );
1162        }
1163
1164        let pins = sha256_pins(DEFAULT_TEMPLATE);
1165        assert!(
1166            pins.len() >= 4,
1167            "expected the template's four third-party sha256 pins (litestream and \
1168             headscale, amd64 and arm64), found {} — the extractor is broken, and a \
1169             broken extractor makes this guard vacuous",
1170            pins.len()
1171        );
1172        for pin in &pins {
1173            assert!(
1174                script.contains(pin.as_str()),
1175                "{} is missing the sha256 pin {pin} that templates/mirror.yml verifies. \
1176                 An unpinned or stale-pinned download is the failure this guard exists \
1177                 for — copy the checksum across when you bump the version.",
1178                script_path.display()
1179            );
1180        }
1181
1182        let releases = upstream_release_pins(DEFAULT_TEMPLATE);
1183        assert!(
1184            releases.len() >= 2,
1185            "expected the litestream and headscale release pins in the template, found {}",
1186            releases.len()
1187        );
1188        for release in &releases {
1189            assert!(
1190                script.contains(release.as_str()),
1191                "{} does not fetch {release} — the script and the template must pin the \
1192                 SAME upstream version, or a LAN node and a cloud node run different \
1193                 headscale/litestream builds against the same replicated DB.",
1194                script_path.display()
1195            );
1196        }
1197    }
1198
1199    #[test]
1200    fn locate_canonical_home_finds_monorepo_root_not_the_inner_workspace() {
1201        // Shape of the monorepo: repo root carries .yah/infra/, the inner
1202        // oss/yubaba workspace carries a .yah/ with no infra/ (a .gitignore
1203        // lives there in the real tree). The walk must climb PAST the inner one.
1204        let tmp = tempfile::tempdir().unwrap();
1205        let root = tmp.path();
1206        std::fs::create_dir_all(root.join(".yah/infra/cloud-init")).unwrap();
1207        let crate_dir = root.join("oss/yubaba/crates/cloud");
1208        std::fs::create_dir_all(&crate_dir).unwrap();
1209        std::fs::create_dir_all(root.join("oss/yubaba/.yah")).unwrap();
1210        std::fs::write(root.join("oss/yubaba/.yah/.gitignore"), "*\n").unwrap();
1211
1212        assert_eq!(
1213            locate_canonical_home(&crate_dir),
1214            CanonicalHome::Monorepo(root.to_path_buf()),
1215            "walk stopped at the inner independent workspace instead of the monorepo root"
1216        );
1217    }
1218
1219    #[test]
1220    fn locate_canonical_home_reports_standalone_export() {
1221        // Shape of the exported yubaba repo: no .yah/infra/ anywhere above the
1222        // crate. Nothing to compare against, and that must be a distinct,
1223        // named verdict rather than a silent miss.
1224        let tmp = tempfile::tempdir().unwrap();
1225        let crate_dir = tmp.path().join("crates/cloud");
1226        std::fs::create_dir_all(&crate_dir).unwrap();
1227        std::fs::create_dir_all(tmp.path().join(".yah")).unwrap();
1228        std::fs::write(tmp.path().join(".yah/.gitignore"), "*\n").unwrap();
1229
1230        assert_eq!(
1231            locate_canonical_home(&crate_dir),
1232            CanonicalHome::StandaloneExport
1233        );
1234    }
1235
1236    #[test]
1237    fn drift_guard_cannot_go_vacuous_in_the_monorepo() {
1238        // Guards the guard, off a signal INDEPENDENT of the `.yah/infra/`
1239        // marker locate_canonical_home uses — otherwise this would just restate
1240        // it. `git subtree split --prefix=oss/yubaba` (scripts/export-oss.sh)
1241        // strips that prefix, so a manifest dir still ending in
1242        // `oss/yubaba/crates/cloud` means we are in the monorepo, where the
1243        // comparison MUST happen. Re-break root resolution and this goes red
1244        // instead of the drift guard going quietly green.
1245        let manifest = Path::new(env!("CARGO_MANIFEST_DIR"));
1246        if manifest.ends_with("oss/yubaba/crates/cloud") {
1247            let home = locate_canonical_home(manifest);
1248            let root = match &home {
1249                CanonicalHome::Monorepo(root) => root,
1250                CanonicalHome::StandaloneExport => panic!(
1251                    "running inside the monorepo at {} but the drift guard resolved \
1252                     StandaloneExport — it would skip the comparison and assert nothing",
1253                    manifest.display()
1254                ),
1255            };
1256            assert!(
1257                crate::paths::cloud_init_template(root).is_file(),
1258                "monorepo root {} has .yah/infra/ but no cloud-init/mirror.yml",
1259                root.display()
1260            );
1261        }
1262    }
1263
1264    #[test]
1265    fn cosign_block_shell_form_well_quoted() {
1266        // YAML parsability spot-check: the block uses double-quoted strings inside
1267        // a single-quoted `sh -c '...'`. Confirm no apostrophes leak into the
1268        // single-quoted body and the case/esac terminators are present.
1269        let machine = sample_machine();
1270        let mut input = minimal_input(&machine);
1271        input.yubaba_cosign_identity_regexp = Some("^https://github\\.com/yah-ai/yah/".into());
1272        let out = render(DEFAULT_TEMPLATE, &input).unwrap();
1273        let sh_line = out
1274            .lines()
1275            .find(|l| l.contains("sh -c") && l.contains("cosign"))
1276            .expect("cosign install sh -c line missing");
1277        // Body of the sh -c is enclosed in a single quoted region; we
1278        // intentionally use double-quotes around shell vars inside. If a stray
1279        // apostrophe slipped through we'd see an odd count of `'`.
1280        let single_quotes = sh_line.matches('\'').count();
1281        assert!(
1282            single_quotes == 2,
1283            "expected exactly 2 enclosing single quotes in sh -c body, found {single_quotes}: {sh_line}"
1284        );
1285        assert!(sh_line.contains("case \"$ARCH\""), "case opener missing");
1286        assert!(sh_line.contains("esac"), "case terminator missing");
1287        // The block must be a sequence of valid `runcmd` entries: each line
1288        // starts with `  - ` (two-space indent + dash + space) so cloud-init
1289        // parses it as a YAML list item.
1290        for line in out.lines().filter(|l| l.contains("cosign")) {
1291            // Skip the header comment lines (start with `#`).
1292            let stripped = line.trim_start();
1293            if stripped.starts_with('#') || stripped.is_empty() {
1294                continue;
1295            }
1296            assert!(
1297                line.starts_with("  - "),
1298                "cosign block line is not a runcmd list entry: {line:?}"
1299            );
1300        }
1301    }
1302
1303    /// R605-F1: a fleet whose releases are cut on QED verifies against a
1304    /// pinned public key, not a Fulcio certificate identity.
1305    #[test]
1306    fn render_with_key_trust_emits_key_verify() {
1307        let machine = sample_machine();
1308        let mut input = minimal_input(&machine);
1309        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();
1310        input.yubaba_cosign_identity_regexp =
1311            Some("key:https://cdn.yah.dev/keys/yah-release.pub".into());
1312        let out = render(DEFAULT_TEMPLATE, &input).unwrap();
1313
1314        assert!(
1315            out.contains(
1316                "cosign verify-blob --key 'https://cdn.yah.dev/keys/yah-release.pub' \
1317                 --insecure-ignore-tlog --bundle /tmp/yah-yubaba.tar.gz.sigstore.json"
1318            ),
1319            "key-based verify-blob line missing:\n{out}"
1320        );
1321        assert!(
1322            !out.contains("--certificate-identity-regexp"),
1323            "keyless flags must not survive into a key-trust render"
1324        );
1325        // Still a well-formed runcmd sequence.
1326        for line in out.lines().filter(|l| l.contains("cosign")) {
1327            let stripped = line.trim_start();
1328            if stripped.starts_with('#') || stripped.is_empty() {
1329                continue;
1330            }
1331            assert!(
1332                line.starts_with("  - "),
1333                "cosign block line is not a runcmd list entry: {line:?}"
1334            );
1335            // R330-F28 pothole #12: a `: ` anywhere makes cloud-init read the
1336            // entry as a YAML mapping and skip the whole runcmd block.
1337            assert!(
1338                !line.contains(": "),
1339                "colon-space in runcmd entry would break cloud-init parsing: {line:?}"
1340            );
1341        }
1342    }
1343
1344    #[test]
1345    fn render_with_cosign_identity_emits_verify_block() {
1346        let machine = sample_machine();
1347        let mut input = minimal_input(&machine);
1348        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();
1349        input.yubaba_cosign_identity_regexp = Some("^https://github\\.com/yah-ai/yah/".into());
1350        let out = render(DEFAULT_TEMPLATE, &input).unwrap();
1351        assert!(
1352            out.contains("cosign verify-blob --certificate-identity-regexp '^https://github\\.com/yah-ai/yah/'"),
1353            "verify-blob line missing or identity-regexp not threaded"
1354        );
1355        assert!(out.contains(COSIGN_OIDC_ISSUER), "oidc-issuer flag missing");
1356        // The bundle sibling URL is derived by suffixing the tarball URL.
1357        assert!(
1358            out.contains(&format!("{}.sigstore.json", input.yubaba_url)),
1359            "bundle sibling URL missing"
1360        );
1361        // cosign install is pinned to a specific release version (no `latest`).
1362        assert!(
1363            out.contains(&format!(
1364                "/releases/download/{}/cosign-linux-",
1365                COSIGN_VERSION
1366            )),
1367            "pinned cosign release URL missing"
1368        );
1369        // R330-F22: cosign binary itself is sha256-pinned (both arches).
1370        // Bumping COSIGN_VERSION without bumping the sha256s should break this
1371        // assertion before it breaks a boot.
1372        assert!(
1373            out.contains(COSIGN_SHA256_AMD64),
1374            "cosign amd64 sha256 pin missing from rendered block"
1375        );
1376        assert!(
1377            out.contains(COSIGN_SHA256_ARM64),
1378            "cosign arm64 sha256 pin missing from rendered block"
1379        );
1380        assert!(
1381            out.contains("sha256sum -c -"),
1382            "cosign binary sha256 verify line missing"
1383        );
1384        // sha256 verify still runs in parallel — belt + suspenders during rollout.
1385        assert!(
1386            out.contains("sha256sum -c -"),
1387            "sha256 verify dropped — should run in parallel with cosign"
1388        );
1389        assert!(find_unsubstituted(&out).is_none());
1390    }
1391
1392    #[test]
1393    fn render_without_cosign_identity_omits_verify_block() {
1394        // Byte-equivalence check against today's sha256-only render: when
1395        // yubaba_cosign_identity_regexp is None, no cosign content appears.
1396        let machine = sample_machine();
1397        let input = minimal_input(&machine);
1398        let out = render(DEFAULT_TEMPLATE, &input).unwrap();
1399        // Comment-line in mirror.yml mentions "cosign verify-blob" in prose;
1400        // check for the flag-bearing form which is only emitted when the
1401        // verify-blob runcmd block actually runs.
1402        assert!(
1403            !out.contains("cosign verify-blob --certificate-identity-regexp"),
1404            "cosign block should be absent when identity_regexp is None"
1405        );
1406        assert!(
1407            !out.contains("/sigstore/cosign/releases/download/"),
1408            "cosign install line should be absent when identity_regexp is None"
1409        );
1410        // sha256 verify is the trust gate in this mode.
1411        assert!(
1412            out.contains("sha256sum -c -"),
1413            "sha256 verify must remain in the no-cosign render path"
1414        );
1415        assert!(find_unsubstituted(&out).is_none());
1416    }
1417
1418    #[test]
1419    fn compute_yubaba_sha256_matches_known_value() {
1420        // sha256("hello") = 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824
1421        let dir = tempfile::tempdir().unwrap();
1422        let bin_path = dir.path().join("yah-yubaba");
1423        std::fs::write(&bin_path, b"hello").unwrap();
1424        let sha = compute_yubaba_sha256(&bin_path).unwrap();
1425        assert_eq!(
1426            sha,
1427            "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
1428        );
1429    }
1430}