nixfleet_proto/
rollout_manifest.rs1use 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 pub display_name: String,
24
25 pub channel: String,
26
27 pub channel_ref: String,
28
29 pub fleet_resolved_hash: String,
33
34 pub host_set: Vec<HostWave>,
37
38 pub health_gate: HealthGate,
39
40 #[serde(default)]
47 pub disruption_budgets: Vec<RolloutBudget>,
48
49 pub meta: Meta,
50}
51
52#[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 pub wave_index: u32,
72 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 #[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}