Skip to main content

cloud/
release_manifest.rs

1//! Yubaba release-manifest fetch + per-triple resolution (R330-F21).
2//!
3//! `.github/workflows/release.yml`'s `yubaba-release-manifest` job publishes
4//! `https://cdn.yah.dev/yubaba/release-manifest.json` after every release tag
5//! (R330-F19). Operators do not need to memorise per-release URLs and sha256s —
6//! `yah cloud machine provision <name>` fetches the manifest once, looks up
7//! the entry matching the machine's architecture, and threads the resolved
8//! URL + sha256 + sig/cert URLs into the cloud-init render.
9//!
10//! Per W203 §1.5 + R330-F19's per-triple entries:
11//!
12//! ```json
13//! {
14//!   "version": "0.9.0",
15//!   "triples": {
16//!     "x86_64-unknown-linux-musl": {
17//!       "url":      "https://cdn.yah.dev/yubaba/0.9.0/x86_64-unknown-linux-musl/yah-yubaba-x86_64-unknown-linux-musl.tar.gz",
18//!       "size":     2345678,
19//!       "sha256":   "abc…",
20//!       "sig_url":  "https://cdn.yah.dev/yubaba/.../...tar.gz.sig",
21//!       "cert_url": "https://cdn.yah.dev/yubaba/.../...tar.gz.cert"
22//!     },
23//!     "aarch64-unknown-linux-musl": { … }
24//!   }
25//! }
26//! ```
27//!
28//! Identity-regexp is *not* per-release — it identifies the workspace's
29//! GitHub OIDC identity (matches the cosign sign-blob step in release.yml).
30//! See [`DEFAULT_YUBABA_COSIGN_IDENTITY`].
31//!
32//! @yah:ticket(R599-F1, "R2 bundle store: append-only blob/manifest publish + node-side materialize into kamaji LRU cache")
33//! @yah:status(review)
34//! @yah:assignee(agent:bundle-anthropic-ashguard)
35//! @yah:at(2026-07-20T04:20:25Z)
36//! @yah:phase(P1)
37//! @yah:parent(R599)
38//! @yah:depends_on(R599-F2)
39//! @yah:next("R599-F2: wire mesofact-build to EMIT a BundleManifest during a build (add yah-mesofact-bundle default-features=false; assemble app/ + optional bins/ tree, per-file blake3, write manifest.toml). Type already exists -> F2 is 'emit', not 'define'.")
40//! @yah:next("R599-F4: kamaji deploy dispatch calls materialize_bundle/BundleCache into kamaji state dir on Deploy of the mesofact bundle variant (add yah-mesofact-bundle features=store + yah-object-store to kamaji).")
41//! @yah:next("R599-F8: services-tab sync arm calls cloud reconciler::bundle_store::publish_bundle_to_r2 after building a bundle.")
42//! @yah:handoff("LANDED (types + store, 16 tests). New crate oss/yah-base/crates/mesofact-bundle (yah-mesofact-bundle). Default features = serde+blake3+toml TYPES ONLY: BundleManifest, BundleRuntime (self | mesofact/<ver>), BundleHash 64-hex, digest() over canonical length-prefixed fields, blob_key/manifest_key layout. This is what R599-F2's mesofact-build consumes via default-features=false.")
43//! @yah:handoff("Feature `store` adds yah-object-store + publish_bundle (append-only blob dedupe via HEAD + blake3 verify + immutable manifest-by-digest PUT), materialize_bundle (fetch+verify+atomic-rename into cache tree, idempotent, path-traversal guarded), BundleCache (LRU-by-digest, bytes budget, 0=unbounded). Store tests via InMemoryObjectStore+tempdir.")
44//! @yah:handoff("Canonical annotation kept here in release_manifest.rs (one-block-per-ID); the real work is the new crate (its lib.rs has a prose pointer, not a 2nd @yah block). Wired: yah-base workspace member + root + oss/yubaba [patch.crates-io]. cloud deps it (features=store) -> reconciler::bundle_store::publish_bundle_to_r2 (spawn_blocking R2ObjectStore wrapper) + BundlePublishReport re-export. Type home = new minimal crate (user-approved via ask_user): NOT workload-spec (avoids ts-rs/schemars weight at mesofact boundary), NOT mesofact-build (avoids drift); store logic feature-gated so the boundary stays serde-only.")
45//! @yah:verify("cd oss/yah-base && cargo test -p yah-mesofact-bundle (8 pass, types-only)")
46//! @yah:verify("cd oss/yah-base && cargo test -p yah-mesofact-bundle --features store (16 pass)")
47//! @yah:verify("cd oss/yubaba && cargo test -p cloud --lib reconciler::bundle_store (1 pass)")
48
49use anyhow::{anyhow, bail, Context, Result};
50use serde::{Deserialize, Serialize};
51use std::collections::BTreeMap;
52use std::time::Duration;
53
54/// Canonical manifest URL published by `.github/workflows/release.yml`'s
55/// `yubaba-release-manifest` job. Hardcoded so operators don't need to memorise
56/// it; operators *can* still override by passing `--yubaba-manifest-url`.
57pub const DEFAULT_RELEASE_MANIFEST_URL: &str = "https://cdn.yah.dev/yubaba/release-manifest.json";
58
59/// Default cosign keyless OIDC identity regexp the yubaba release pipeline
60/// signs against. Matches the `cosign sign-blob` certificate identity emitted
61/// by GitHub Actions when running under `yah-ai/yah` (R330-F19). Operators
62/// override with `--yubaba-cosign-identity` to point at a fork's OIDC subject.
63pub const DEFAULT_YUBABA_COSIGN_IDENTITY: &str = r"^https://github\.com/yah-ai/yah/";
64
65/// Sigstore Fulcio OIDC issuer for GitHub-Actions-rooted keyless signing —
66/// what `.github/workflows/release.yml`'s `cosign sign-blob` step produces
67/// (R330-F19). The canonical copy; `cloud_init` and the yah CLI's
68/// `yubaba_fetch` both read it from here so the two verify paths can't drift.
69pub const COSIGN_OIDC_ISSUER: &str = "https://token.actions.githubusercontent.com";
70
71/// Prefix that selects key-based verification in a cosign-identity spec.
72/// See [`ReleaseTrust::parse`].
73pub const KEY_TRUST_PREFIX: &str = "key:";
74
75/// How a consumer proves a release artifact is ours (R605-F1).
76///
77/// The two arms are the verify-side mirror of `yah_qed::SigningIdentity`, and
78/// they exist for the same reason: a release cut on QED instead of GitHub
79/// cannot use Fulcio keyless, because the public-good Fulcio only issues certs
80/// for OIDC issuers on its own configured allowlist. W235 §"Off-GHA forfeits
81/// GitHub OIDC keyless cosign" makes the call — key-based cosign, key vaulted
82/// in kamaji — so every consumer that verifies a yubaba/CLI artifact has to be
83/// able to express both.
84///
85/// This is carried through the config plumbing as a plain string (the existing
86/// `--yubaba-cosign-identity` value, the `RenderInput` field, the manifest's
87/// identity regexp) and parsed at the two points that actually shell `cosign`.
88/// Keeping the wire type a string is deliberate: it means flipping a fleet to
89/// key-based trust is a value change, not a schema migration.
90#[derive(Debug, Clone, PartialEq, Eq)]
91pub enum ReleaseTrust {
92    /// Fulcio keyless: pin the certificate identity + the OIDC issuer.
93    Keyless {
94        identity_regexp: String,
95        oidc_issuer: String,
96    },
97    /// Key-based: pin the cosign *public* key. `key_ref` is whatever
98    /// `cosign verify-blob --key` accepts — a `.pub` path, an `https://` URL,
99    /// or a KMS URI (`awskms://…`, `hashivault://…`).
100    Key { key_ref: String },
101}
102
103impl ReleaseTrust {
104    /// Parse an operator-supplied cosign-identity spec.
105    ///
106    /// `key:<ref>` selects key-based verification; anything else is a keyless
107    /// certificate-identity regexp against [`COSIGN_OIDC_ISSUER`]. The prefix
108    /// is explicit rather than sniffed, because a KMS URI and a regexp are
109    /// both arbitrary strings and guessing between them would silently pick
110    /// the wrong trust model.
111    pub fn parse(spec: &str) -> Self {
112        match spec.strip_prefix(KEY_TRUST_PREFIX) {
113            Some(key_ref) => Self::Key {
114                key_ref: key_ref.trim().to_string(),
115            },
116            None => Self::Keyless {
117                identity_regexp: spec.to_string(),
118                oidc_issuer: COSIGN_OIDC_ISSUER.to_string(),
119            },
120        }
121    }
122
123    /// Whether verification consumes the `.cert` sidecar. Key-based signatures
124    /// have no Fulcio certificate, so a consumer must not fail the download
125    /// when the release published no `.cert` alongside the `.sig`.
126    pub fn needs_certificate(&self) -> bool {
127        matches!(self, Self::Keyless { .. })
128    }
129
130    /// The `cosign verify-blob` flags this trust model contributes, in order.
131    /// The caller appends `--signature`/`--certificate`/the blob itself.
132    pub fn verify_flags(&self) -> Vec<String> {
133        match self {
134            Self::Keyless {
135                identity_regexp,
136                oidc_issuer,
137            } => vec![
138                "--certificate-identity-regexp".into(),
139                identity_regexp.clone(),
140                "--certificate-oidc-issuer".into(),
141                oidc_issuer.clone(),
142            ],
143            Self::Key { key_ref } => vec!["--key".into(), key_ref.clone()],
144        }
145    }
146}
147
148/// One per-triple entry in the manifest. Field order matches what
149/// release.yml's `Emit per-triple manifest fragment` step writes.
150#[derive(Debug, Clone, Serialize, Deserialize)]
151pub struct ManifestTripleEntry {
152    pub url: String,
153    pub size: u64,
154    pub sha256: String,
155    pub sig_url: String,
156    pub cert_url: String,
157}
158
159/// Top-level shape of `release-manifest.json`.
160///
161/// The two epoch fields (R625-F2) are **release-wide, not per-triple** — the
162/// raft protocol and on-disk layout are architecture-independent, so they sit
163/// alongside `version` rather than inside each [`ManifestTripleEntry`]. They are
164/// `Option` because manifests published before this field existed carry neither;
165/// see [`YubabaReleaseManifest::cluster_epochs`] for why absent must never be
166/// read as "compatible".
167#[derive(Debug, Clone, Serialize, Deserialize)]
168pub struct YubabaReleaseManifest {
169    pub version: String,
170    /// Wire-compatibility epoch: may a node running this release sit in one raft
171    /// cluster with a node running some other release. Mixed operation is
172    /// permitted iff every node shares this integer (W275 "Cluster compatibility
173    /// epochs"). Sourced from `oss/yubaba/crates/yubaba/cluster-epochs.json`,
174    /// the same file the binary parses at compile time.
175    #[serde(default, skip_serializing_if = "Option::is_none")]
176    pub cluster_protocol: Option<u32>,
177    /// On-disk state epoch: can this release read the previous release's raft
178    /// log/snapshot, and can you roll **back** to it. Tracked separately from
179    /// `cluster_protocol` on purpose — openraft 0.9→0.10 broke both at once,
180    /// which is exactly how one combined flag would have hidden the rollback
181    /// hazard (W275 §5 "Roll-back is symmetric").
182    #[serde(default, skip_serializing_if = "Option::is_none")]
183    pub state_epoch: Option<u32>,
184    pub triples: BTreeMap<String, ManifestTripleEntry>,
185}
186
187impl YubabaReleaseManifest {
188    /// Look up the manifest entry for the given Rust target triple. Returns a
189    /// descriptive error listing the available triples so operators can spot a
190    /// typo or a missing matrix leg at a glance.
191    pub fn entry(&self, triple: &str) -> Result<&ManifestTripleEntry> {
192        self.triples.get(triple).ok_or_else(|| {
193            let available: Vec<_> = self.triples.keys().cloned().collect();
194            anyhow!(
195                "release-manifest has no entry for triple '{triple}' (available: {available:?})"
196            )
197        })
198    }
199}
200
201/// Map a Hetzner server-type code to a Rust target triple. The current matrix
202/// (W203 §1.2) is x86_64 + aarch64 Linux musl. Hetzner's ARM line uses the
203/// `cax` prefix (cax11 / cax21 / …) — every other prefix (cpx*, ccx*, cx*) is
204/// Intel/AMD x86_64. This is operational truth, not heuristic — Hetzner
205/// publishes that mapping in their public pricing page and the catalog crate.
206pub fn server_type_to_triple(server_type: &str) -> Result<&'static str> {
207    if server_type.starts_with("cax") {
208        Ok("aarch64-unknown-linux-musl")
209    } else if server_type.starts_with("cpx")
210        || server_type.starts_with("ccx")
211        || server_type.starts_with("cx")
212    {
213        Ok("x86_64-unknown-linux-musl")
214    } else {
215        bail!(
216            "unknown Hetzner server_type '{server_type}' — cannot infer architecture. \
217             Pass --yubaba-url + --yubaba-sha256 explicitly to skip manifest resolution."
218        )
219    }
220}
221
222/// Fetch the release-manifest from the canonical URL. Caller picks the runtime;
223/// inside `yah cloud machine provision` the existing `tokio::runtime::Runtime`
224/// drives this. Surfaces every failure mode loud (no silent placeholder
225/// fallback) — operators see an actionable message and pass `--yubaba-url`
226/// explicitly to bypass.
227pub async fn fetch_release_manifest(url: &str) -> Result<YubabaReleaseManifest> {
228    let client = reqwest::Client::builder()
229        .timeout(Duration::from_secs(15))
230        .user_agent("yah/cloud release-manifest fetcher")
231        .build()
232        .context("building http client for release-manifest fetch")?;
233    let resp = client
234        .get(url)
235        .send()
236        .await
237        .with_context(|| format!("fetching release-manifest from {url}"))?;
238    if !resp.status().is_success() {
239        bail!(
240            "release-manifest fetch from {url} returned HTTP {} — \
241             pass --yubaba-url explicitly if the manifest is unreachable",
242            resp.status()
243        );
244    }
245    let body = resp
246        .bytes()
247        .await
248        .with_context(|| format!("reading release-manifest body from {url}"))?;
249    let manifest: YubabaReleaseManifest = serde_json::from_slice(&body).with_context(|| {
250        format!(
251            "parsing release-manifest from {url} — pass --yubaba-url explicitly if the manifest is malformed"
252        )
253    })?;
254    if manifest.triples.is_empty() {
255        bail!(
256            "release-manifest from {url} has no triples — \
257             pass --yubaba-url explicitly to bypass manifest resolution"
258        );
259    }
260    Ok(manifest)
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266
267    // ── R605-F1 release trust spec ────────────────────────────────────────
268
269    #[test]
270    fn bare_spec_is_keyless_against_the_default_issuer() {
271        // Every existing config value must keep meaning exactly what it meant
272        // before the key arm existed.
273        assert_eq!(
274            ReleaseTrust::parse(DEFAULT_YUBABA_COSIGN_IDENTITY),
275            ReleaseTrust::Keyless {
276                identity_regexp: DEFAULT_YUBABA_COSIGN_IDENTITY.into(),
277                oidc_issuer: COSIGN_OIDC_ISSUER.into(),
278            }
279        );
280    }
281
282    #[test]
283    fn key_prefix_selects_key_trust_and_trims() {
284        assert_eq!(
285            ReleaseTrust::parse("key: awskms:///alias/yah-release "),
286            ReleaseTrust::Key {
287                key_ref: "awskms:///alias/yah-release".into()
288            }
289        );
290    }
291
292    #[test]
293    fn a_kms_uri_without_the_prefix_stays_keyless() {
294        // Deliberate: sniffing a URI scheme out of a free-form regexp would
295        // silently swap the trust model. The prefix is the only signal.
296        assert!(matches!(
297            ReleaseTrust::parse("awskms:///alias/yah-release"),
298            ReleaseTrust::Keyless { .. }
299        ));
300    }
301
302    #[test]
303    fn verify_flags_and_cert_need_track_the_arm() {
304        let keyless = ReleaseTrust::parse(DEFAULT_YUBABA_COSIGN_IDENTITY);
305        assert!(keyless.needs_certificate());
306        assert_eq!(
307            keyless.verify_flags(),
308            vec![
309                "--certificate-identity-regexp".to_string(),
310                DEFAULT_YUBABA_COSIGN_IDENTITY.to_string(),
311                "--certificate-oidc-issuer".to_string(),
312                COSIGN_OIDC_ISSUER.to_string(),
313            ]
314        );
315
316        let keyed = ReleaseTrust::parse("key:cosign.pub");
317        assert!(!keyed.needs_certificate());
318        assert_eq!(
319            keyed.verify_flags(),
320            vec!["--key".to_string(), "cosign.pub".to_string()]
321        );
322    }
323
324    #[test]
325    fn server_type_to_triple_maps_arm_prefix() {
326        assert_eq!(
327            server_type_to_triple("cax11").unwrap(),
328            "aarch64-unknown-linux-musl"
329        );
330        assert_eq!(
331            server_type_to_triple("cax21").unwrap(),
332            "aarch64-unknown-linux-musl"
333        );
334        assert_eq!(
335            server_type_to_triple("cax41").unwrap(),
336            "aarch64-unknown-linux-musl"
337        );
338    }
339
340    #[test]
341    fn server_type_to_triple_maps_x86_prefixes() {
342        assert_eq!(
343            server_type_to_triple("cpx22").unwrap(),
344            "x86_64-unknown-linux-musl"
345        );
346        assert_eq!(
347            server_type_to_triple("ccx13").unwrap(),
348            "x86_64-unknown-linux-musl"
349        );
350        assert_eq!(
351            server_type_to_triple("cx32").unwrap(),
352            "x86_64-unknown-linux-musl"
353        );
354    }
355
356    #[test]
357    fn server_type_to_triple_rejects_unknown_prefix() {
358        let err = server_type_to_triple("xxx99").unwrap_err().to_string();
359        assert!(err.contains("unknown Hetzner server_type"), "msg: {err}");
360        assert!(
361            err.contains("--yubaba-url"),
362            "msg points to override: {err}"
363        );
364    }
365
366    #[test]
367    fn manifest_round_trips_through_json() {
368        let raw = r#"{
369            "version": "0.9.0",
370            "triples": {
371                "x86_64-unknown-linux-musl": {
372                    "url": "https://cdn.yah.dev/yubaba/0.9.0/x86_64-unknown-linux-musl/yah-yubaba-x86_64-unknown-linux-musl.tar.gz",
373                    "size": 2345678,
374                    "sha256": "abc123",
375                    "sig_url": "https://cdn.yah.dev/yubaba/0.9.0/x86_64-unknown-linux-musl/yah-yubaba-x86_64-unknown-linux-musl.tar.gz.sig",
376                    "cert_url": "https://cdn.yah.dev/yubaba/0.9.0/x86_64-unknown-linux-musl/yah-yubaba-x86_64-unknown-linux-musl.tar.gz.cert"
377                }
378            }
379        }"#;
380        let parsed: YubabaReleaseManifest = serde_json::from_str(raw).unwrap();
381        assert_eq!(parsed.version, "0.9.0");
382        // This manifest predates R625-F2 — the epochs are absent, not zero.
383        assert_eq!(parsed.cluster_protocol, None);
384        assert_eq!(parsed.state_epoch, None);
385        let entry = parsed.entry("x86_64-unknown-linux-musl").unwrap();
386        assert_eq!(entry.size, 2345678);
387        assert_eq!(entry.sha256, "abc123");
388        assert!(entry.sig_url.ends_with(".tar.gz.sig"));
389        assert!(entry.cert_url.ends_with(".tar.gz.cert"));
390    }
391
392    #[test]
393    fn manifest_entry_missing_triple_lists_available() {
394        let raw = r#"{"version":"0.9.0","triples":{"x86_64-unknown-linux-musl":{"url":"u","size":1,"sha256":"s","sig_url":"u.sig","cert_url":"u.cert"}}}"#;
395        let parsed: YubabaReleaseManifest = serde_json::from_str(raw).unwrap();
396        let err = parsed
397            .entry("aarch64-unknown-linux-musl")
398            .unwrap_err()
399            .to_string();
400        assert!(err.contains("aarch64-unknown-linux-musl"));
401        assert!(
402            err.contains("x86_64-unknown-linux-musl"),
403            "lists available: {err}"
404        );
405    }
406
407    #[test]
408    fn manifest_carries_the_two_cluster_epochs() {
409        // R625-F2. The shape release.yml now publishes: two release-wide
410        // integers alongside `version`, NOT per-triple (raft protocol and
411        // on-disk layout are architecture-independent).
412        let raw = r#"{
413            "version": "0.8.22",
414            "cluster_protocol": 2,
415            "state_epoch": 2,
416            "triples": {
417                "x86_64-unknown-linux-musl": {
418                    "url": "u", "size": 1, "sha256": "s", "sig_url": "a", "cert_url": "b"
419                }
420            }
421        }"#;
422        let parsed: YubabaReleaseManifest = serde_json::from_str(raw).unwrap();
423        assert_eq!(parsed.cluster_protocol, Some(2));
424        assert_eq!(parsed.state_epoch, Some(2));
425
426        // Round-trip keeps them, and a manifest that declares nothing emits
427        // nothing (rather than an implicit 0, which would read as "declared").
428        let back: YubabaReleaseManifest =
429            serde_json::from_str(&serde_json::to_string(&parsed).unwrap()).unwrap();
430        assert_eq!(back.cluster_protocol, Some(2));
431        assert_eq!(back.state_epoch, Some(2));
432
433        let undeclared = YubabaReleaseManifest {
434            version: "0.8.20".into(),
435            cluster_protocol: None,
436            state_epoch: None,
437            triples: BTreeMap::new(),
438        };
439        let json = serde_json::to_string(&undeclared).unwrap();
440        assert!(!json.contains("cluster_protocol"), "{json}");
441        assert!(!json.contains("state_epoch"), "{json}");
442    }
443
444    #[test]
445    fn default_identity_regexp_matches_yah_ai_yah() {
446        // Sanity: the constant matches what release.yml's cosign sign-blob
447        // step emits as the keyless certificate identity. Bumping the org
448        // requires updating both this constant AND release.yml in lockstep.
449        assert!(DEFAULT_YUBABA_COSIGN_IDENTITY.contains("yah-ai/yah"));
450    }
451}