nixfleet_proto/
rollout_manifest.rs

1//! Signed per-channel rollout manifest (`releases/rollouts/<rolloutId>.json`).
2//! LOADBEARING: per RFC-0008 ยง6.3, `rolloutId` is the canonical semantic
3//! identifier `RolloutId::new(&m.channel, &m.channel_ref)` (i.e.
4//! `"{channel}@{channel_ref}"`), not a content hash. Verifiers MUST (1)
5//! cryptographically verify the signed sidecar via
6//! `verify_rollout_manifest`, then (2) discriminate the parsed manifest's
7//! reconstructed `RolloutId` against the advertised identifier they
8//! requested. Authenticity comes from the signature; identity-substitution
9//! defense comes from the parsed-id equality check. Both checks together
10//! replace the prior content-addressed `sha256(bytes) == rolloutId`
11//! tautology, which has no anchor under the semantic identifier.
12
13use serde::{Deserialize, Serialize};
14
15use crate::fleet_resolved::{HealthGate, Meta, Selector};
16
17#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
18#[serde(rename_all = "camelCase")]
19pub struct RolloutManifest {
20    pub schema_version: u32,
21
22    /// `<channel>@<short-ci-commit>` - display only, not the identifier.
23    pub display_name: String,
24
25    pub channel: String,
26
27    pub channel_ref: String,
28
29    /// LOADBEARING: anchors the manifest to one signed `fleet.resolved`
30    /// snapshot - different snapshot produces a different rolloutId, blocking
31    /// cross-snapshot mix-and-match.
32    pub fleet_resolved_hash: String,
33
34    /// FOOTGUN: MUST be sorted by `hostname` ascending - JCS sorts object
35    /// keys but not array elements; producer's order IS the canonical order.
36    pub host_set: Vec<HostWave>,
37
38    pub health_gate: HealthGate,
39
40    /// Disruption-budget snapshot resolved against `fleet.hosts.tags` at
41    /// projection time and frozen for the rollout's life - mid-rollout
42    /// retag does NOT reshape these. Cross-rollout in-flight counting
43    /// matches by `selector` equality so fleet-wide enforcement survives
44    /// the snapshot model. FOOTGUN: per-budget `hosts` MUST be sorted
45    /// alphabetically (JCS canonicalizes keys, not array elements).
46    #[serde(default)]
47    pub disruption_budgets: Vec<RolloutBudget>,
48
49    pub meta: Meta,
50}
51
52/// Per-rollout snapshot of a fleet-wide disruption budget. Selector is
53/// preserved so cross-rollout sums match by intent even when host
54/// membership has shifted between rollout opens.
55#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
56#[serde(rename_all = "camelCase")]
57pub struct RolloutBudget {
58    pub selector: Selector,
59    pub hosts: Vec<String>,
60    #[serde(default)]
61    pub max_in_flight: Option<u32>,
62    #[serde(default)]
63    pub max_in_flight_pct: Option<u32>,
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
67#[serde(rename_all = "camelCase")]
68pub struct HostWave {
69    pub hostname: String,
70    /// Frozen at projection time; reshaping waves produces a new rolloutId.
71    pub wave_index: u32,
72    /// Per-host closure. Agent re-asserts this against the CP-advertised
73    /// closure to detect retargeting.
74    pub target_closure: String,
75}
76
77#[cfg(test)]
78mod tests {
79    use super::*;
80    use crate::fleet_resolved::Meta;
81    use nixfleet_canonicalize::canonicalize;
82
83    fn meta_v1() -> Meta {
84        Meta {
85            schema_version: 1,
86            signed_at: Some("2026-04-30T12:00:00Z".parse().unwrap()),
87            ci_commit: Some("def45678".into()),
88            signature_algorithm: Some("ed25519".into()),
89        }
90    }
91
92    fn sample_manifest() -> RolloutManifest {
93        RolloutManifest {
94            schema_version: 1,
95            display_name: "stable@def4567".into(),
96            channel: "stable".into(),
97            channel_ref: "def4567abc123def4567abc123def4567abc123d".into(),
98            fleet_resolved_hash: "1111111111111111111111111111111111111111111111111111111111111111"
99                .into(),
100            host_set: vec![
101                HostWave {
102                    hostname: "agent-01".into(),
103                    wave_index: 0,
104                    target_closure: "0000000000000000000000000000000000000000-host-a".into(),
105                },
106                HostWave {
107                    hostname: "agent-02".into(),
108                    wave_index: 1,
109                    target_closure: "1111111111111111111111111111111111111111-host-b".into(),
110                },
111            ],
112            health_gate: HealthGate::default(),
113            disruption_budgets: vec![],
114            meta: meta_v1(),
115        }
116    }
117
118    /// LOADBEARING: every signed-manifest verification recomputes the canonical
119    /// bytes from the parsed struct and re-checks the signature. A round-trip
120    /// that mutates byte order or representation would break signature
121    /// verification across every agent in the fleet.
122    #[test]
123    fn manifest_canonical_bytes_stable_across_round_trip() {
124        let m = sample_manifest();
125        let raw1 = serde_json::to_string(&m).unwrap();
126        let canon1 = canonicalize(&raw1).unwrap();
127
128        let parsed: RolloutManifest = serde_json::from_str(&canon1).unwrap();
129        let raw2 = serde_json::to_string(&parsed).unwrap();
130        let canon2 = canonicalize(&raw2).unwrap();
131
132        assert_eq!(canon1, canon2);
133    }
134
135    #[test]
136    fn manifest_host_set_order_changes_canonical_bytes() {
137        let mut m1 = sample_manifest();
138        let mut m2 = sample_manifest();
139        m2.host_set.reverse();
140
141        let canon1 = canonicalize(&serde_json::to_string(&m1).unwrap()).unwrap();
142        let canon2 = canonicalize(&serde_json::to_string(&m2).unwrap()).unwrap();
143
144        assert_ne!(
145            canon1, canon2,
146            "host_set order must affect canonical bytes (CI must emit sorted)"
147        );
148
149        m2.host_set.sort_by(|a, b| a.hostname.cmp(&b.hostname));
150        let canon2_resorted = canonicalize(&serde_json::to_string(&m2).unwrap()).unwrap();
151        assert_eq!(canon1, canon2_resorted);
152
153        let _ = &mut m1;
154    }
155
156    #[test]
157    fn fleet_resolved_hash_change_changes_canonical_bytes() {
158        let m1 = sample_manifest();
159        let mut m2 = sample_manifest();
160        m2.fleet_resolved_hash =
161            "2222222222222222222222222222222222222222222222222222222222222222".into();
162
163        let canon1 = canonicalize(&serde_json::to_string(&m1).unwrap()).unwrap();
164        let canon2 = canonicalize(&serde_json::to_string(&m2).unwrap()).unwrap();
165
166        assert_ne!(canon1, canon2);
167    }
168
169    #[test]
170    fn host_target_closure_change_changes_canonical_bytes() {
171        let m1 = sample_manifest();
172        let mut m2 = sample_manifest();
173        m2.host_set[0].target_closure = "9999999999999999999999999999999999999999-perturbed".into();
174
175        let canon1 = canonicalize(&serde_json::to_string(&m1).unwrap()).unwrap();
176        let canon2 = canonicalize(&serde_json::to_string(&m2).unwrap()).unwrap();
177
178        assert_ne!(canon1, canon2);
179    }
180
181    #[test]
182    fn host_wave_round_trip() {
183        let h = HostWave {
184            hostname: "agent-03".into(),
185            wave_index: 2,
186            target_closure: "abcdef1234567890abcdef1234567890abcdef12-test".into(),
187        };
188        let s = serde_json::to_string(&h).unwrap();
189        let parsed: HostWave = serde_json::from_str(&s).unwrap();
190        assert_eq!(parsed, h);
191        assert!(s.contains("\"waveIndex\":2"));
192        assert!(s.contains("\"targetClosure\""));
193    }
194}