Skip to main content

workload_spec/
control_plane_install.rs

1//! Shared install-script builder for a control-plane (yubaba + kamaji) roll.
2//!
3//! This is the ONE net-new mechanism of the rolling-upgrade envelope (R608):
4//! the atomic fetch→verify→anchor→install→assert→restart of the signed
5//! yubaba+kamaji pair. It lives here — in the crate BOTH the CLI orchestrator
6//! and yubaba itself depend on — so the two apply transports share a single,
7//! trusted script and cannot drift:
8//!
9//! - **SSH transport** (R608-F5, `app/yah/cli/src/rollout/apply.rs::apply_over_ssh`)
10//!   pipes the script to `ssh <node> bash -s` from the orchestrator.
11//! - **Mesh transport** (R608-F10, yubaba `POST /self-update`) runs the *same*
12//!   script locally on the node via a `systemd-run` transient unit — no SSH.
13//!
14//! **The script body is not written here.** It lives beside this file as
15//! [`control_plane_install.sh`](./control_plane_install.sh) and is `include_str!`d,
16//! because a third caller — `scripts/roll-node.sh`, the one-node operator SSH
17//! job (R755-F3) — has to run the identical bytes from bash with no Rust in the
18//! loop. This function only prepends the four-variable prologue the template
19//! declares (`URL` / `SHA` / `VER` / `SUDO`); `roll-node.sh` prepends the same
20//! four. Keeping the body in a `format!` string would have forced that script to
21//! become a fourth transcription of the most safety-critical code in the fleet
22//! (`stand-up-yubaba.sh`'s install tail is already the second).
23//!
24//! The script is a state-preserving, atomic transcription of the install tail of
25//! `stand-up-yubaba.sh`: fetch the signed release tarball, `sha256 -c` it against
26//! the digest the signed manifest already resolved (callers only ever pass
27//! manifest-derived values — there is no path for an AI or a wire request to
28//! fabricate a version/url/digest), extract, leave a dated rollback anchor beside
29//! every file it is about to replace, stage each file next to its target on the
30//! same filesystem, then `mv` it into place so a half-written
31//! `/usr/local/bin/yubaba` can never appear. yubaba + kamaji install as one
32//! atomic pair (W275 OQ5).
33//!
34//! **Success is proved by content, never by `--version`.** After the rename the
35//! script hashes each installed binary against the file it extracted from the
36//! manifest-verified tarball. The version string is the workspace version baked
37//! in at build time and can be right on a binary that predates the code it
38//! claims — us-east-001 reported kamaji 0.8.22 while carrying none of the 0.8.22
39//! tree (R746-T3). The hash chain manifest → tarball → extracted → installed has
40//! no version string in it.
41//!
42//! **Never touches durable state.** The script contains no reference to
43//! `/var/lib/yah-cloud/identity.json` (the ed25519 host identity — wiping it
44//! forces a re-TOFU and breaks hostkey-drift detection, the R589 gotcha) or the
45//! raft log dir. A roll moves `/usr/local/bin` bytes + unit files, nothing else.
46//! The [`tests::script_never_touches_durable_state`] test is the guard.
47
48/// Single-quote a value for safe embedding inside the generated bash. Callers
49/// only ever embed manifest-derived URLs/digests (already constrained) and a
50/// version string, but we quote defensively regardless.
51fn sh_squote(s: &str) -> String {
52    format!("'{}'", s.replace('\'', r"'\''"))
53}
54
55/// The canonical roll script body, shared verbatim with `scripts/roll-node.sh`.
56/// Expects the four-variable prologue [`build_install_script`] emits.
57pub const INSTALL_SCRIPT_TEMPLATE: &str = include_str!("control_plane_install.sh");
58
59/// Build the self-contained install script for the yubaba+kamaji pair.
60///
61/// `sudo` is `true` when the executing user is not root (e.g. an SSH login as
62/// `debian@…`), matching `stand-up-yubaba.sh`'s `SUDO` convention. The mesh
63/// (self-update) path runs the script as root inside a `systemd-run` transient
64/// unit, so it passes `sudo = false`. The script is idempotent and atomic; it
65/// anchors what it is about to replace, restarts kamaji then yubaba (W154
66/// supervision order) and echoes the installed versions so the caller can log
67/// them — after having already proved the install by hash, not by those strings.
68///
69/// `version`/`url`/`sha256` MUST come from a signed release manifest — this
70/// builder does no verification of its own beyond emitting the `sha256sum -c`
71/// check; integrity rests on the caller only ever passing manifest-resolved
72/// values.
73pub fn build_install_script(version: &str, url: &str, sha256: &str, sudo: bool) -> String {
74    let sudo_kw = if sudo { "sudo" } else { "" };
75    format!(
76        "SUDO={sudo_kw}\nURL={url}\nSHA={sha}\nVER={ver}\n{body}",
77        sudo_kw = sudo_kw,
78        url = sh_squote(url),
79        sha = sh_squote(sha256),
80        ver = sh_squote(version),
81        body = INSTALL_SCRIPT_TEMPLATE,
82    )
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88
89    #[test]
90    fn script_embeds_the_signed_digest_and_url() {
91        let url = "https://cdn.yah.dev/yubaba/0.8.19/yubaba-0.8.19-x86_64-unknown-linux-musl.tar.gz";
92        let sha = "abc123def456";
93        let s = build_install_script("0.8.19", url, sha, false);
94        assert!(s.contains(sha), "script must carry the manifest sha256");
95        assert!(s.contains(url), "script must carry the manifest url");
96        assert!(s.contains("sha256sum -c -"), "script must verify the digest");
97    }
98
99    #[test]
100    fn script_is_atomic_and_installs_the_whole_pair() {
101        let s = build_install_script("0.8.19", "u", "d", false);
102        // Atomic: stage-then-rename, never a direct write to the live path.
103        assert!(s.contains("mv -f"), "install must be an atomic rename");
104        assert!(s.contains(".roll-new.$$"), "install must stage to a temp name");
105        // Every binary + all four unit files (scryer and the passway pair
106        // conditionally — see the dedicated tests below).
107        for target in [
108            "/usr/local/bin/yubaba",
109            "/usr/local/bin/kamaji",
110            "/usr/local/bin/yah-scryer",
111            "/usr/local/bin/passway",
112            "/usr/local/bin/passway-demux",
113            "/etc/systemd/system/yubaba.slice",
114            "/etc/systemd/system/kamaji.service",
115            "/etc/systemd/system/yubaba.service",
116            "/etc/systemd/system/yah-scryer.service",
117        ] {
118            assert!(s.contains(target), "script must install {target}");
119        }
120        // Restart order: kamaji before yubaba (W154).
121        let k = s.find("restart kamaji.service").unwrap();
122        let y = s.find("restart yubaba.service").unwrap();
123        assert!(k < y, "kamaji must restart before yubaba");
124        assert!(s.contains("daemon-reload"));
125    }
126
127    #[test]
128    fn scryer_install_is_conditional_on_the_tarball_carrying_it() {
129        // R556-F6 gate (b): yah-scryer joined the tarball at 0.8.32. A
130        // rollback to a pre-scryer release must still succeed — the script
131        // must gate every scryer action on the member existing rather than
132        // failing on it, and must leave an already-installed scryer alone.
133        let s = build_install_script("0.8.32", "u", "d", true);
134        assert!(
135            s.contains(r#"if [ -e "$D/yah-scryer" ]; then"#),
136            "scryer install must be gated on the tarball carrying the binary"
137        );
138        assert!(
139            s.contains(r#"assert_installed_bytes "$D/yah-scryer" /usr/local/bin/yah-scryer"#),
140            "installed scryer bytes must be content-asserted like the pair"
141        );
142        // First install has never been enabled; later rolls no-op.
143        assert!(s.contains("enable yah-scryer.service"));
144        // The scryer restart must come after the W154 pair restart — it is
145        // located by yubaba, not driven by it (A049), and must not perturb
146        // the kamaji→yubaba order.
147        assert!(
148            s.find("restart yubaba.service").unwrap()
149                < s.find("restart yah-scryer.service").unwrap(),
150            "scryer restarts after the supervision pair"
151        );
152    }
153
154    #[test]
155    fn passway_installs_conditionally_and_never_restarts_the_front_door() {
156        // R870-B2: passway + passway-demux joined the tarball at 0.8.33, giving
157        // the sovereign front door its first distribution path. Same
158        // conditional shape as scryer, so a rollback to a pre-0.8.33 release
159        // still succeeds.
160        let s = build_install_script("0.8.33", "u", "d", true);
161        assert!(
162            s.contains(r#"if [ -e "$D/passway" ]; then"#),
163            "passway install must be gated on the tarball carrying the binary"
164        );
165        for bin in ["passway", "passway-demux"] {
166            assert!(
167                s.contains(&format!(
168                    r#"assert_installed_bytes "$D/{bin}"       /usr/local/bin/{bin}"#
169                )) || s.contains(&format!(
170                    r#"assert_installed_bytes "$D/{bin}" /usr/local/bin/{bin}"#
171                )),
172                "installed {bin} bytes must be content-asserted like the pair"
173            );
174        }
175        // THE LOAD-BEARING NEGATIVE. passway cannot hot-swap a cert (tls.rs
176        // "The reload gap"), so `systemctl restart` on a door drops in-flight
177        // connections on public :443. A roll stages the bytes; the operator
178        // chooses when to take the blip. If someone later adds a restart here,
179        // every fleet roll starts cutting live traffic on yah.dev — and it
180        // would look like an obvious omission being fixed.
181        let exec = executable_lines(&s);
182        for unit in [
183            "passway.service",
184            "passway-test.service",
185            "passway-demux.service",
186            "passway-http-router.service",
187        ] {
188            assert!(
189                !exec.contains(unit),
190                "a roll must not name {unit} — see R870-T3 for the graceful path"
191            );
192        }
193    }
194
195    #[test]
196    fn the_http_router_rides_its_own_conditional_not_the_passway_pairs() {
197        // R870-F1: the :80 tier joined at 0.8.34, one release AFTER the passway
198        // pair. Gating it on HAS_PASSWAY would make this script fail against
199        // every 0.8.33 tarball — which is exactly the rollback path the
200        // conditional shape exists to keep working.
201        let s = build_install_script("0.8.34", "u", "d", true);
202        assert!(
203            s.contains(r#"if [ -e "$D/passway-http-router" ]; then"#),
204            "the :80 tier must be gated on its own tarball member"
205        );
206        assert!(
207            s.contains(
208                r#"assert_installed_bytes "$D/passway-http-router" /usr/local/bin/passway-http-router"#
209            ),
210            "installed http-router bytes must be content-asserted like the pair"
211        );
212        assert!(
213            s.contains("anchor /usr/local/bin/passway-http-router"),
214            "there must be a way back from a :80 roll"
215        );
216    }
217
218    #[test]
219    fn the_graceful_upgrade_helper_rides_the_roll_but_its_dropin_does_not() {
220        // R870-T3. The helper is the ExecReload= that makes a cert rotation a
221        // process swap: fleet-wide, no node state, so it installs like a binary
222        // and gets the same rollback anchor and content assertion.
223        let s = build_install_script("0.8.34", "u", "d", true);
224        assert!(
225            s.contains(r#"if [ -e "$D/passway-graceful-upgrade" ]; then"#),
226            "the helper must be gated on its own tarball member, like the :80 tier"
227        );
228        assert!(
229            s.contains(
230                r#"assert_installed_bytes "$D/passway-graceful-upgrade" /usr/local/bin/passway-graceful-upgrade"#
231            ),
232            "installed helper bytes must be content-asserted"
233        );
234        assert!(
235            s.contains("anchor /usr/local/bin/passway-graceful-upgrade"),
236            "there must be a way back from a helper roll"
237        );
238        // The DROP-IN that arms it is node state: it lands in
239        // /etc/systemd/system/<unit>.service.d/ and the unit name differs per
240        // door. Naming it in an `echo` is how an operator learns the reload verb
241        // exists; writing it would put a roll in the business of rewriting a
242        // live door's unit configuration.
243        for line in executable_lines(&s)
244            .lines()
245            .filter(|l| l.contains("passway-graceful-upgrade.conf"))
246        {
247            assert!(
248                line.trim_start().starts_with("echo "),
249                "the script may print the drop-in's name, never install it: {line}"
250            );
251        }
252    }
253
254    /// The script with every comment line removed — i.e. only the lines bash
255    /// will actually execute. The template documents the durable-state rule in
256    /// prose *by naming the paths it must not touch*, so the guard below has to
257    /// look at commands, not at the whole file, or the doc comment describing
258    /// the property would be what breaks the test asserting it.
259    fn executable_lines(script: &str) -> String {
260        script
261            .lines()
262            .filter(|l| !l.trim_start().starts_with('#'))
263            .collect::<Vec<_>>()
264            .join("\n")
265    }
266
267    #[test]
268    fn script_never_touches_durable_state() {
269        // The load-bearing safety property: a roll moves binaries + unit files
270        // ONLY. Wiping identity.json forces a re-TOFU (R589 gotcha); touching
271        // the raft dir corrupts consensus. The script must reference neither.
272        let s = executable_lines(&build_install_script("0.8.19", "u", "d", true));
273        assert!(!s.contains("identity.json"), "must not touch host identity");
274        assert!(!s.contains("/var/lib/yah-cloud"), "must not touch state dir");
275        assert!(!s.contains("raft"), "must not touch the raft log dir");
276        assert!(!s.contains("rm -rf /"), "must not wipe system paths");
277        // Scryer's events.db is durable per-node state the same way — a roll
278        // replaces the binary + unit, never the store (R556-F6 gate (b)).
279        assert!(
280            !s.contains("/var/lib/yah/scryer"),
281            "must not touch the scryer event store"
282        );
283    }
284
285    #[test]
286    fn script_anchors_every_file_it_replaces_before_replacing_it() {
287        // R755-F3. Every path the script installs must first be copied to a
288        // dated `.rollback-YYYYMMDD` sibling — the convention the fleet's boxes
289        // already carry — and the anchoring must happen BEFORE the install, or
290        // the anchor holds the new build and there is no way back.
291        let s = build_install_script("0.8.19", "u", "d", true);
292        let first_anchor = s.find("anchor /usr/local/bin/yubaba").expect("anchors");
293        let first_install = s.find("install_atomic \"$D/yubaba\"").expect("installs");
294        assert!(
295            first_anchor < first_install,
296            "anchors must be written before anything is replaced"
297        );
298        assert!(s.contains(r#"STAMP="$(date -u +%Y%m%d)""#));
299        for target in [
300            "/usr/local/bin/yubaba",
301            "/usr/local/bin/kamaji",
302            "/usr/local/bin/yah-scryer",
303            "/usr/local/bin/passway",
304            "/usr/local/bin/passway-demux",
305            "/etc/systemd/system/yubaba.slice",
306            "/etc/systemd/system/kamaji.service",
307            "/etc/systemd/system/yubaba.service",
308            "/etc/systemd/system/yah-scryer.service",
309        ] {
310            assert!(
311                s.contains(&format!("anchor {target}\n")),
312                "every installed path needs a rollback anchor, missing {target}"
313            );
314        }
315    }
316
317    #[test]
318    fn anchoring_is_idempotent_within_a_day() {
319        // The subtle half: a second roll on the same day must NOT re-anchor,
320        // or the escape hatch gets overwritten with the build being escaped.
321        let s = build_install_script("0.8.19", "u", "d", true);
322        assert!(
323            s.contains(r#"if [ ! -e "$1.rollback-$STAMP" ]; then"#),
324            "anchor must refuse to overwrite an existing same-day anchor"
325        );
326    }
327
328    #[test]
329    fn success_is_asserted_by_content_not_by_version_string() {
330        // R746-T3's trap: `--version` reports the workspace version baked in at
331        // build time and was right on a binary carrying none of that version's
332        // code. The proof has to be a hash of the installed bytes against the
333        // bytes extracted from the manifest-verified tarball.
334        let s = build_install_script("0.8.19", "u", "d", true);
335        for pair in [
336            r#"assert_installed_bytes "$D/yubaba" /usr/local/bin/yubaba"#,
337            r#"assert_installed_bytes "$D/kamaji" /usr/local/bin/kamaji"#,
338        ] {
339            assert!(s.contains(pair), "missing content assertion: {pair}");
340        }
341        // …and a mismatch must fail the roll, not merely print.
342        let body = &s[s.find("assert_installed_bytes() {").expect("assert fn")..];
343        assert!(
344            body.contains("content assertion FAILED") && body.contains("exit 1"),
345            "a content mismatch must exit nonzero so the caller sees a failed roll"
346        );
347        // The assertion must land before the restart — restarting onto bytes
348        // you haven't proved is the failure mode this closes.
349        assert!(
350            s.find("assert_installed_bytes \"$D/yubaba\"").unwrap()
351                < s.find("systemctl restart kamaji.service").unwrap(),
352            "content must be proved before the supervision tree restarts"
353        );
354    }
355
356    #[test]
357    fn the_template_is_the_only_copy_of_the_body() {
358        // scripts/roll-node.sh runs these same bytes with no Rust in the loop,
359        // so build_install_script must be a prologue over the template and
360        // nothing more. If this drifts, the SSH job and the mesh self-update
361        // stop being the same roll.
362        let s = build_install_script("0.8.19", "u", "d", false);
363        assert!(
364            s.ends_with(INSTALL_SCRIPT_TEMPLATE),
365            "the built script must be prologue + the shared template, verbatim"
366        );
367        let prologue = &s[..s.len() - INSTALL_SCRIPT_TEMPLATE.len()];
368        assert_eq!(
369            prologue.lines().count(),
370            4,
371            "the prologue is exactly URL/SHA/VER/SUDO — anything else belongs in the template"
372        );
373    }
374
375    #[test]
376    fn sudo_prefix_tracks_the_caller() {
377        let s = build_install_script("0.8.19", "u", "d", true);
378        assert!(s.contains("SUDO=sudo"));
379        let s = build_install_script("0.8.19", "u", "d", false);
380        assert!(s.contains("SUDO=\n") || s.contains("SUDO="));
381    }
382}