nixfleet_reconciler/planner_gates/host_edges.rs
1//! Host-edges gate. Per-host DAG within a single rollout:
2//! `Edge { gated: A, gates: B }` holds A's dispatch until B is
3//! ordering-eligible โ Converged (canonical "health-verified at
4//! target") OR Deferred (activation staged, live-switch pending
5//! operator reboot per RFC-0005 ยง3 terminal-for-ordering).
6//!
7//! LOADBEARING: Deferred counts as ordering-eligible. Without this,
8//! a single host that hit `DeferredPendingReboot` (framework upgrade
9//! touching dbus/systemd/kernel/init) would halt the cascade
10//! indefinitely on downstream host-edge dependencies. Deferred is
11//! "this host is done participating in the rollout step from an
12//! ordering standpoint"; actual health verification (probes, soak)
13//! runs once the operator reboots.
14
15use nixfleet_state_machine::HostState;
16
17use crate::planner_gates::GateBlock;
18use crate::planner_types::{FleetState, HostId, RolloutId, SignedManifestSet};
19
20pub fn check(
21 fleet_state: &FleetState,
22 manifests: &SignedManifestSet,
23 host: &HostId,
24 rollout_id: &RolloutId,
25) -> Option<GateBlock> {
26 let fleet = manifests.fleet();
27 let host_channel = fleet.hosts.get(host).map(|h| h.channel.as_str())?;
28
29 for edge in fleet.edges.iter().filter(|e| e.gated == *host) {
30 // Cross-channel guard: silently skip edges where the gating host
31 // is on a different channel โ that's `channel_edges`'s job, and
32 // looking up such a host in this rollout's host_states would
33 // always miss and block forever (the bug the old gate's
34 // cross-channel filter was added to prevent).
35 let same_channel = fleet
36 .hosts
37 .get(&edge.gates)
38 .map(|h| h.channel == host_channel)
39 .unwrap_or(false);
40 if !same_channel {
41 continue;
42 }
43
44 // Look up the gating host's state within THIS rollout. Absence
45 // means the gating host hasn't started yet โ block.
46 let key = (rollout_id.clone(), edge.gates.clone());
47 let state = fleet_state.host_states.get(&key).map(|s| s.state);
48 let ordering_eligible = matches!(
49 state,
50 Some(HostState::Converged) | Some(HostState::Deferred)
51 );
52 if !ordering_eligible {
53 return Some(GateBlock::HostEdge {
54 gating_host: edge.gates.clone(),
55 });
56 }
57 }
58 None
59}