nixfleet_reconciler/planner_gates/
wave_promotion.rs1use crate::planner_gates::GateBlock;
17use crate::planner_types::{FleetState, HostId, RolloutId, SignedManifestSet};
18
19pub fn check(
20 fleet_state: &FleetState,
21 manifests: &SignedManifestSet,
22 host: &HostId,
23 rollout_id: &RolloutId,
24) -> Option<GateBlock> {
25 let fleet = manifests.fleet();
26 let channel = fleet.hosts.get(host).map(|h| h.channel.as_str())?;
27
28 let waves = match fleet.waves.get(channel) {
36 Some(w) if !w.is_empty() => w,
37 _ => return None,
38 };
39 let host_wave = match waves
40 .iter()
41 .position(|w| w.hosts.iter().any(|h| h == host))
42 .map(|i| i as u32)
43 {
44 Some(idx) => idx,
45 None => {
46 return Some(GateBlock::HostUnstaged {
50 channel: channel.to_string(),
51 });
52 }
53 };
54
55 let current_wave = fleet_state
56 .rollouts
57 .get(rollout_id)
58 .map(|r| r.current_wave)
59 .unwrap_or(0);
60
61 if host_wave > current_wave {
62 Some(GateBlock::WavePromotion {
63 host_wave,
64 current_wave,
65 })
66 } else {
67 None
68 }
69}
70
71#[cfg(test)]
72mod tests {
73 use super::*;
74 use crate::planner_types::{FleetState, RolloutSummary};
75 use crate::verify::Verified;
76 use nixfleet_proto::testing::FleetBuilder;
77 use std::collections::HashMap;
78
79 fn fleet_state_with_rollout(
80 rollout_id: &RolloutId,
81 channel: &str,
82 current_wave: u32,
83 ) -> FleetState {
84 let mut rollouts = HashMap::new();
85 rollouts.insert(
86 rollout_id.clone(),
87 RolloutSummary {
88 rollout_id: rollout_id.clone(),
89 channel: channel.to_string(),
90 target_ref: "test-ref".to_string(),
91 opened_at: chrono::Utc::now(),
92 terminal_at: None,
93 current_wave,
94 budgets: vec![],
95 },
96 );
97 FleetState {
98 host_states: HashMap::new(),
99 rollouts,
100 outstanding_failing_enforce_probes: HashMap::new(),
101 }
102 }
103
104 #[test]
109 fn host_not_in_any_wave_blocks_with_host_unstaged() {
110 let fleet = FleetBuilder::new()
111 .host("h1", "stable")
112 .host("h2", "stable")
113 .host("ghost-host", "stable")
114 .wave("stable", &["h1"])
115 .wave("stable", &["h2"])
116 .build();
117 let manifests = SignedManifestSet {
118 fleet: Verified::unverified_for_tests(fleet, chrono::Utc::now()),
119 rollouts: HashMap::new(),
120 };
121 let rollout_id: RolloutId = "stable@ref".into();
122 let fs = fleet_state_with_rollout(&rollout_id, "stable", 0);
123
124 let block = check(&fs, &manifests, &"ghost-host".to_string(), &rollout_id);
125 assert!(
126 matches!(block, Some(GateBlock::HostUnstaged { ref channel }) if channel == "stable"),
127 "host not in any wave MUST block with HostUnstaged, got {block:?}"
128 );
129 }
130
131 #[test]
135 fn channel_with_empty_waves_passes_silently() {
136 let fleet = FleetBuilder::new().host("h1", "stable").build();
137 let manifests = SignedManifestSet {
138 fleet: Verified::unverified_for_tests(fleet, chrono::Utc::now()),
139 rollouts: HashMap::new(),
140 };
141 let rollout_id: RolloutId = "stable@ref".into();
142 let fs = fleet_state_with_rollout(&rollout_id, "stable", 0);
143
144 let block = check(&fs, &manifests, &"h1".to_string(), &rollout_id);
145 assert!(
146 block.is_none(),
147 "all-at-once channel (no declared waves) must pass; got {block:?}"
148 );
149 }
150
151 #[test]
154 fn host_in_later_wave_blocks_when_current_wave_is_earlier() {
155 let fleet = FleetBuilder::new()
156 .host("h1", "stable")
157 .host("h2", "stable")
158 .wave("stable", &["h1"])
159 .wave("stable", &["h2"])
160 .build();
161 let manifests = SignedManifestSet {
162 fleet: Verified::unverified_for_tests(fleet, chrono::Utc::now()),
163 rollouts: HashMap::new(),
164 };
165 let rollout_id: RolloutId = "stable@ref".into();
166 let fs = fleet_state_with_rollout(&rollout_id, "stable", 0);
167
168 let block = check(&fs, &manifests, &"h2".to_string(), &rollout_id);
169 assert!(matches!(
170 block,
171 Some(GateBlock::WavePromotion {
172 host_wave: 1,
173 current_wave: 0,
174 })
175 ));
176 }
177
178 #[test]
180 fn host_in_current_wave_passes() {
181 let fleet = FleetBuilder::new()
182 .host("h1", "stable")
183 .host("h2", "stable")
184 .wave("stable", &["h1"])
185 .wave("stable", &["h2"])
186 .build();
187 let manifests = SignedManifestSet {
188 fleet: Verified::unverified_for_tests(fleet, chrono::Utc::now()),
189 rollouts: HashMap::new(),
190 };
191 let rollout_id: RolloutId = "stable@ref".into();
192 let fs = fleet_state_with_rollout(&rollout_id, "stable", 0);
193
194 let block = check(&fs, &manifests, &"h1".to_string(), &rollout_id);
195 assert!(
196 block.is_none(),
197 "host in current_wave must pass; got {block:?}"
198 );
199 }
200}