nixfleet_reconciler/planner_gates/
wave_promotion.rs

1//! Wave-promotion gate (new-shape). Host's wave index must not exceed
2//! the rollout's `current_wave`. Wave index comes from the verified
3//! `FleetResolved.waves[channel]` (positional). `current_wave` lives on
4//! `RolloutSummary` and is maintained by the applier.
5//!
6//! **Default-deny on inconsistent inputs.** A silent pass when
7//! host-wave resolution returns None (host not listed in any wave for
8//! its channel) would mask operator misconfiguration. An unstaged
9//! host in a channel that declares waves is blocked with
10//! `GateBlock::HostUnstaged`. The "channel has no waves at all"
11//! shape (typical of all-at-once policies; mk-fleet builds
12//! `fleet.waves[channel] = []` when the policy declares no explicit
13//! waves) still passes — that's intentional "no wave gating", not
14//! misconfiguration.
15
16use 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    // Lookup `fleet.waves[channel]`.
29    //   None or Some([]) → channel has no wave structure (typical of
30    //                      all-at-once policies); no gating to apply.
31    //   Some(non-empty) but host not in any wave → operator
32    //                      misconfiguration; default-deny.
33    //   Some(non-empty) and host in wave N → normal wave-promotion
34    //                      check against current_wave.
35    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            // Default-deny: a channel with declared waves but no
47            // assignment for this host is a misconfiguration. Block
48            // dispatch rather than silently passing.
49            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    /// Default-deny regression: a channel that declares waves but
105    /// where the host being evaluated is not listed in any of them
106    /// MUST block with `HostUnstaged`. A silent pass would let an
107    /// unstaged host dispatch outside the wave plan.
108    #[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    /// Channel with no waves at all (typical all-at-once shape post
132    /// mk-fleet build with empty policy.waves) passes — intentional
133    /// "no wave gating", not a misconfiguration.
134    #[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    /// Wave-1 host blocks when current_wave is 0 (the canonical
152    /// happy-path block).
153    #[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    /// Wave-0 host passes when current_wave is 0.
179    #[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}