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→install→restart of the signed yubaba+kamaji pair.
5//! It lives here — in the crate BOTH the CLI orchestrator and yubaba itself
6//! depend on — so the two apply transports share a single, trusted script and
7//! 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 is a state-preserving, atomic transcription of the install tail of
15//! `stand-up-yubaba.sh`: fetch the signed release tarball, `sha256 -c` it against
16//! the digest the signed manifest already resolved (callers only ever pass
17//! manifest-derived values — there is no path for an AI or a wire request to
18//! fabricate a version/url/digest), extract, stage each file next to its target
19//! on the same filesystem, then `mv` it into place so a half-written
20//! `/usr/local/bin/yubaba` can never appear. yubaba + kamaji install as one
21//! atomic pair (W275 OQ5).
22//!
23//! **Never touches durable state.** The script contains no reference to
24//! `/var/lib/yah-cloud/identity.json` (the ed25519 host identity — wiping it
25//! forces a re-TOFU and breaks hostkey-drift detection, the R589 gotcha) or the
26//! raft log dir. A roll moves `/usr/local/bin` bytes + unit files, nothing else.
27//! The [`tests::script_never_touches_durable_state`] test is the guard.
28
29/// Single-quote a value for safe embedding inside the generated bash. Callers
30/// only ever embed manifest-derived URLs/digests (already constrained) and a
31/// version string, but we quote defensively regardless.
32fn sh_squote(s: &str) -> String {
33    format!("'{}'", s.replace('\'', r"'\''"))
34}
35
36/// Build the self-contained install script for the yubaba+kamaji pair.
37///
38/// `sudo` is `true` when the executing user is not root (e.g. an SSH login as
39/// `debian@…`), matching `stand-up-yubaba.sh`'s `SUDO` convention. The mesh
40/// (self-update) path runs the script as root inside a `systemd-run` transient
41/// unit, so it passes `sudo = false`. The script is idempotent and atomic; it
42/// restarts kamaji then yubaba (W154 supervision order) and echoes the installed
43/// versions so the caller can log them.
44///
45/// `version`/`url`/`sha256` MUST come from a signed release manifest — this
46/// builder does no verification of its own beyond emitting the `sha256sum -c`
47/// check; integrity rests on the caller only ever passing manifest-resolved
48/// values.
49pub fn build_install_script(version: &str, url: &str, sha256: &str, sudo: bool) -> String {
50    let sudo_kw = if sudo { "sudo" } else { "" };
51    format!(
52        r#"set -euo pipefail
53SUDO={sudo_kw}
54URL={url}
55SHA={sha}
56VER={ver}
57WORK="$(mktemp -d /tmp/yah-roll.XXXXXX)"
58trap 'rm -rf "$WORK"' EXIT
59cd "$WORK"
60echo "== fetch + verify (sha256 from signed manifest) =="
61curl -fsSL -o pair.tar.gz "$URL"
62printf '%s  pair.tar.gz\n' "$SHA" | sha256sum -c -
63mkdir x && tar -xzf pair.tar.gz -C x
64D="$(find x -maxdepth 1 -type d -name 'yubaba-*' | head -1)"
65[ -n "$D" ] || {{ echo 'tarball layout unexpected: no yubaba-* dir' >&2; exit 1; }}
66# Atomic install: stage next to the target on the SAME filesystem, then rename.
67# A rename within a filesystem is atomic, so a half-written binary/unit is
68# never observable. This script touches /usr/local/bin bytes + unit files ONLY
69# (durable host state is deliberately out of scope — see the module docs).
70install_atomic() {{ # src mode dest
71  $SUDO install -m"$2" "$1" "$3.roll-new.$$"
72  $SUDO mv -f "$3.roll-new.$$" "$3"
73}}
74install_atomic "$D/yubaba"         0755 /usr/local/bin/yubaba
75install_atomic "$D/kamaji"         0755 /usr/local/bin/kamaji
76install_atomic "$D/yubaba.slice"   0644 /etc/systemd/system/yubaba.slice
77install_atomic "$D/kamaji.service" 0644 /etc/systemd/system/kamaji.service
78install_atomic "$D/yubaba.service" 0644 /etc/systemd/system/yubaba.service
79echo "== restart supervision tree (kamaji then yubaba, W154 order) =="
80$SUDO systemctl daemon-reload
81$SUDO systemctl restart kamaji.service
82$SUDO systemctl restart yubaba.service
83echo "installed target=$VER yubaba=$(/usr/local/bin/yubaba --version 2>/dev/null) kamaji=$(/usr/local/bin/kamaji --version 2>/dev/null)"
84"#,
85        sudo_kw = sudo_kw,
86        url = sh_squote(url),
87        sha = sh_squote(sha256),
88        ver = sh_squote(version),
89    )
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95
96    #[test]
97    fn script_embeds_the_signed_digest_and_url() {
98        let url = "https://cdn.yah.dev/yubaba/0.8.19/yubaba-0.8.19-x86_64-unknown-linux-musl.tar.gz";
99        let sha = "abc123def456";
100        let s = build_install_script("0.8.19", url, sha, false);
101        assert!(s.contains(sha), "script must carry the manifest sha256");
102        assert!(s.contains(url), "script must carry the manifest url");
103        assert!(s.contains("sha256sum -c -"), "script must verify the digest");
104    }
105
106    #[test]
107    fn script_is_atomic_and_installs_the_whole_pair() {
108        let s = build_install_script("0.8.19", "u", "d", false);
109        // Atomic: stage-then-rename, never a direct write to the live path.
110        assert!(s.contains("mv -f"), "install must be an atomic rename");
111        assert!(s.contains(".roll-new.$$"), "install must stage to a temp name");
112        // Both binaries + all three unit files.
113        for target in [
114            "/usr/local/bin/yubaba",
115            "/usr/local/bin/kamaji",
116            "/etc/systemd/system/yubaba.slice",
117            "/etc/systemd/system/kamaji.service",
118            "/etc/systemd/system/yubaba.service",
119        ] {
120            assert!(s.contains(target), "script must install {target}");
121        }
122        // Restart order: kamaji before yubaba (W154).
123        let k = s.find("restart kamaji.service").unwrap();
124        let y = s.find("restart yubaba.service").unwrap();
125        assert!(k < y, "kamaji must restart before yubaba");
126        assert!(s.contains("daemon-reload"));
127    }
128
129    #[test]
130    fn script_never_touches_durable_state() {
131        // The load-bearing safety property: a roll moves binaries + unit files
132        // ONLY. Wiping identity.json forces a re-TOFU (R589 gotcha); touching
133        // the raft dir corrupts consensus. The script must reference neither.
134        let s = build_install_script("0.8.19", "u", "d", true);
135        assert!(!s.contains("identity.json"), "must not touch host identity");
136        assert!(!s.contains("/var/lib/yah-cloud"), "must not touch state dir");
137        assert!(!s.contains("raft"), "must not touch the raft log dir");
138        assert!(!s.contains("rm -rf /"), "must not wipe system paths");
139    }
140
141    #[test]
142    fn sudo_prefix_tracks_the_caller() {
143        let s = build_install_script("0.8.19", "u", "d", true);
144        assert!(s.contains("SUDO=sudo"));
145        let s = build_install_script("0.8.19", "u", "d", false);
146        assert!(s.contains("SUDO=\n") || s.contains("SUDO="));
147    }
148}