Skip to content

Commit 649c6ff

Browse files
committed
feat: resolve a host's boot interface from predictions before its first lease
The admin boot actions learned to resolve a host still awaiting its first DHCP lease in #2528 -- from its predicted boot interface -- but the machine-controller never did: it read only `machine_interfaces` rows, so a zero-DPU or NIC-mode host (which gets no primary row at ingestion) had nothing to boot-target and its boot states waited. This gives the controller the same fallback: before the first lease creates a real row, the host's predicted boot interface answers, and the real row wins the moment it exists. The selection is now one shared `pick_boot_prediction` -- the declared `ExpectedHostNic.primary` (recorded on the prediction in #2657), else the sole non-underlay prediction, else refuse to guess. Both the machine-controller and the admin resolver route through it, so a multi-NIC host with a declared primary resolves instead of refusing, and the two paths can't drift. - Add `pick_boot_prediction` (api-model) and give the controller's `boot_interface_target`/`resolve_boot_interface` a prediction fallback, loaded per resolve via `load_boot_predictions`. `AwaitingNic` now fires only when there is no row and no usable prediction. - Route admin `resolve_admin_boot_interface_target` through the same selector, so it honors the declared-primary flag for multi-NIC hosts (it used to refuse). - The real `machine_interfaces` row always wins the moment it exists; a host with one expected boot NIC resolves from its sole prediction. Tests cover `pick_boot_prediction`'s precedence, the controller's row-wins / prediction-fallback / MAC-only / wait composition (split out so it unit-tests without a full snapshot), and the admin declared-primary case. Part of #2658 (epic #2660). Signed-off-by: Chet Nichols III <chetn@nvidia.com>
1 parent 79e54c0 commit 649c6ff

5 files changed

Lines changed: 309 additions & 45 deletions

File tree

crates/api-core/src/handlers/bmc_endpoint_explorer.rs

Lines changed: 40 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@ use model::expected_entity::ExpectedEntity;
2929
use model::machine::machine_search_config::MachineSearchConfig;
3030
use model::machine::{LoadSnapshotOptions, MachineInterfaceSnapshot};
3131
use model::machine_boot_interface::MachineBootInterface;
32-
use model::network_segment::NetworkSegmentType;
3332
use model::predicted_machine_interface::PredictedMachineInterface;
3433
use model::site_explorer::{NicMode, PreingestionState};
3534
use sqlx::PgConnection;
@@ -53,15 +52,14 @@ use crate::api::{Api, log_machine_id, log_request_data};
5352
/// machine awaiting its first DHCP lease) resolves from its
5453
/// `predicted_machine_interfaces` instead: the predicted NIC's MAC and
5554
/// recorded Redfish interface id form the same [`MachineBootInterface`] the
56-
/// real row will hold once the lease promotes it. Predictions answer only
57-
/// when unambiguous -- exactly one non-underlay prediction. Predictions now
58-
/// carry a `primary_interface` flag, but this resolver doesn't consult it yet,
59-
/// so with several (e.g. a host whose report lists SuperNICs alongside the boot
60-
/// NIC) the declared `ExpectedHostNic.primary` is not applied here; resolution
61-
/// refuses to guess and the action keeps requiring an explicit MAC, which the
62-
/// matching prediction's recorded id completes.
63-
/// The machine-controller does not consult predictions at all yet -- its
64-
/// boot states wait out this window -- a known follow-up.
55+
/// real row will hold once the lease promotes it. The candidate is chosen by
56+
/// the shared `pick_boot_prediction` -- the declared `ExpectedHostNic.primary`
57+
/// (recorded on the prediction), else the sole non-underlay prediction. With
58+
/// several (e.g. a host whose report lists SuperNICs alongside the boot NIC) and
59+
/// none declared primary the boot NIC is unknowable; resolution refuses to guess
60+
/// and the action keeps requiring an explicit MAC, which the matching
61+
/// prediction's recorded id completes. The machine-controller resolves the same
62+
/// way, through the same `pick_boot_prediction`.
6563
///
6664
/// Site-explorer's stored default (`ExploredEndpoint::boot_interface()`)
6765
/// answers only for endpoints no machine owns. An owned machine resolves
@@ -117,15 +115,12 @@ fn resolve_admin_boot_interface_target(
117115
// machine-controller's boot_interface_target.
118116
return Some(target_for(picked.mac_address, picked.boot_interface()));
119117
}
120-
// The rows offered no boot candidate: the machine's predicted
121-
// NICs answer, but only when unambiguous -- exactly one
122-
// non-underlay prediction. Predictions now carry a primary flag,
123-
// but this resolver doesn't consult it yet, so with several the
124-
// declared intent isn't applied here.
125-
let mut bootable = candidates.predicted.iter().filter(|predicted| {
126-
predicted.expected_network_segment_type != NetworkSegmentType::Underlay
127-
});
128-
if let (Some(predicted), None) = (bootable.next(), bootable.next()) {
118+
// The rows offered no boot candidate: the machine's predicted NICs
119+
// answer, via the shared `pick_boot_prediction` -- the declared
120+
// primary, else the sole non-underlay prediction. With several and
121+
// none declared primary the boot NIC is unknowable, so it returns
122+
// `None` and the action keeps requiring an explicit MAC.
123+
if let Some(predicted) = model::machine::pick_boot_prediction(&candidates.predicted) {
129124
return Some(target_for(
130125
predicted.mac_address,
131126
predicted.boot_interface(),
@@ -1123,6 +1118,8 @@ pub(crate) async fn validate_and_complete_bmc_endpoint_request(
11231118

11241119
#[cfg(test)]
11251120
mod tests {
1121+
use model::network_segment::NetworkSegmentType;
1122+
11261123
use super::*;
11271124

11281125
fn row(mac: &str, primary: bool, boot_interface_id: Option<&str>) -> MachineInterfaceSnapshot {
@@ -1357,6 +1354,30 @@ mod tests {
13571354
);
13581355
}
13591356

1357+
// A declared-primary prediction disambiguates a multi-prediction host:
1358+
// `pick_boot_prediction` selects it, so resolution targets the declared NIC
1359+
// rather than refusing. (Multiple NON-primary predictions still refuse --
1360+
// see `no_mac_multiple_predictions_refuse_to_guess_a_boot_device`.)
1361+
#[test]
1362+
fn no_mac_declared_primary_prediction_wins_over_other_predictions() {
1363+
let declared_primary = PredictedMachineInterface {
1364+
primary_interface: true,
1365+
..predicted("00:00:5e:00:53:01", Some("NIC.Embedded.1-1-1"))
1366+
};
1367+
let other = predicted("00:00:5e:00:53:02", Some("NIC.Slot.7-1-1"));
1368+
let c = BootInterfaceCandidates {
1369+
interfaces: vec![],
1370+
predicted: vec![other, declared_primary],
1371+
};
1372+
assert_eq!(
1373+
resolve_admin_boot_interface_target(None, Some(&c), None),
1374+
Some(BootInterfaceTarget::Pair(pair(
1375+
"00:00:5e:00:53:01",
1376+
"NIC.Embedded.1-1-1"
1377+
))),
1378+
);
1379+
}
1380+
13601381
#[test]
13611382
fn no_mac_underlay_only_rows_let_a_sole_prediction_answer() {
13621383
// Real rows exist but none is a boot candidate (declared bmc/oob NICs

crates/api-model/src/machine/mod.rs

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ use crate::machine_interface::InterfaceType;
6565
use crate::machine_interface_address::InterfaceAssociationType;
6666
use crate::network_segment::NetworkSegmentType;
6767
use crate::power_manager::PowerOptions;
68+
use crate::predicted_machine_interface::PredictedMachineInterface;
6869
use crate::state_history::StateHistoryRecord;
6970

7071
pub mod slas;
@@ -306,6 +307,38 @@ fn pick_boot_interface_pair(
306307
pick_boot_interface(interfaces).and_then(MachineInterfaceSnapshot::boot_interface)
307308
}
308309

310+
/// Pick the predicted interface a host should boot from in the window before
311+
/// its first DHCP lease creates a real `machine_interfaces` row. Mirrors
312+
/// `pick_boot_interface`'s precedence, one rung down -- predictions, not rows:
313+
///
314+
/// 1. A prediction flagged `primary_interface` wins -- the declared
315+
/// `ExpectedHostNic.primary`, recorded onto the prediction at minting.
316+
/// 2. Otherwise the sole non-underlay prediction. With several and none
317+
/// declared primary the boot NIC is unknowable (e.g. a host whose report
318+
/// lists SuperNICs alongside the boot NIC), so this returns `None` rather
319+
/// than guess against whichever NIC happens to sort first.
320+
///
321+
/// Public because both the machine-controller and admin boot-interface
322+
/// resolution apply it for the pre-first-lease window, when the real rows
323+
/// offer no candidate yet. Callers map the chosen prediction to a boot target
324+
/// via `PredictedMachineInterface::boot_interface()`.
325+
pub fn pick_boot_prediction(
326+
predictions: &[PredictedMachineInterface],
327+
) -> Option<&PredictedMachineInterface> {
328+
// The declared primary wins, exactly as in `pick_boot_interface`.
329+
if let Some(primary) = predictions.iter().find(|p| p.primary_interface) {
330+
return Some(primary);
331+
}
332+
// Otherwise only a sole non-underlay prediction is unambiguous.
333+
let mut non_underlay = predictions
334+
.iter()
335+
.filter(|p| p.expected_network_segment_type != NetworkSegmentType::Underlay);
336+
match (non_underlay.next(), non_underlay.next()) {
337+
(Some(only), None) => Some(only),
338+
_ => None,
339+
}
340+
}
341+
309342
impl ManagedHostStateSnapshot {
310343
/// Returns `true` if this managed host has at least one DPU snapshot
311344
/// attached -- i.e. a DPU we actively manage as a `Machine`.
@@ -3451,6 +3484,77 @@ mod tests {
34513484
assert_eq!(pick_boot_interface_pair(&[primary]), None);
34523485
}
34533486

3487+
/// Build a mock `PredictedMachineInterface` with the fields
3488+
/// `pick_boot_prediction` inspects (MAC, primary flag, segment type).
3489+
fn build_mock_prediction(
3490+
mac: &str,
3491+
primary: bool,
3492+
segment_type: NetworkSegmentType,
3493+
) -> PredictedMachineInterface {
3494+
PredictedMachineInterface {
3495+
id: uuid::Uuid::nil(),
3496+
machine_id: MachineId::from_str(
3497+
"fm100ds7blqjsadm2uuh3qqbf1h7k8pmf47um6v9uckrg7l03po8mhqgvng",
3498+
)
3499+
.unwrap(),
3500+
mac_address: mac.parse().unwrap(),
3501+
expected_network_segment_type: segment_type,
3502+
boot_interface_id: None,
3503+
primary_interface: primary,
3504+
}
3505+
}
3506+
3507+
// A declared-primary prediction wins outright, mirroring pick_boot_interface
3508+
// -- regardless of how many other predictions there are.
3509+
#[test]
3510+
fn pick_boot_prediction_returns_the_declared_primary() {
3511+
let predictions = vec![
3512+
build_mock_prediction("05:00:00:00:00:01", false, NetworkSegmentType::HostInband),
3513+
build_mock_prediction("10:00:00:00:00:01", true, NetworkSegmentType::HostInband),
3514+
build_mock_prediction("20:00:00:00:00:01", false, NetworkSegmentType::HostInband),
3515+
];
3516+
assert_eq!(
3517+
pick_boot_prediction(&predictions).map(|p| p.mac_address),
3518+
Some("10:00:00:00:00:01".parse().unwrap())
3519+
);
3520+
}
3521+
3522+
// With no declared primary, a sole non-underlay prediction is unambiguous.
3523+
#[test]
3524+
fn pick_boot_prediction_returns_the_sole_non_underlay_prediction() {
3525+
let predictions = vec![
3526+
build_mock_prediction("01:00:00:00:00:01", false, NetworkSegmentType::Underlay),
3527+
build_mock_prediction("10:00:00:00:00:01", false, NetworkSegmentType::HostInband),
3528+
];
3529+
assert_eq!(
3530+
pick_boot_prediction(&predictions).map(|p| p.mac_address),
3531+
Some("10:00:00:00:00:01".parse().unwrap())
3532+
);
3533+
}
3534+
3535+
// Several non-underlay predictions and none declared primary: the boot NIC
3536+
// is unknowable, so refuse to guess rather than program boot order against
3537+
// whichever sorts first (the Gigawatt SuperNIC safety case).
3538+
#[test]
3539+
fn pick_boot_prediction_refuses_multiple_non_primary_predictions() {
3540+
let predictions = vec![
3541+
build_mock_prediction("10:00:00:00:00:01", false, NetworkSegmentType::HostInband),
3542+
build_mock_prediction("20:00:00:00:00:01", false, NetworkSegmentType::HostInband),
3543+
];
3544+
assert!(pick_boot_prediction(&predictions).is_none());
3545+
}
3546+
3547+
// Underlay predictions are never a boot candidate on their own.
3548+
#[test]
3549+
fn pick_boot_prediction_ignores_underlay_only_predictions() {
3550+
let predictions = vec![build_mock_prediction(
3551+
"01:00:00:00:00:01",
3552+
false,
3553+
NetworkSegmentType::Underlay,
3554+
)];
3555+
assert!(pick_boot_prediction(&predictions).is_none());
3556+
}
3557+
34543558
// Check the case where only the BMC has been discovered so far (which
34553559
// is common during early ingestion). In this case, there's no valid boot MAC
34563560
// yet; callers fall back to the `::NoDpu` handling downstream.

crates/machine-controller/src/boot_interface.rs

Lines changed: 121 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -17,26 +17,63 @@
1717
//! Resolving how to target a host's boot interface for Redfish setup calls.
1818
1919
use carbide_redfish::boot_interface::BootInterfaceTarget;
20-
use model::machine::ManagedHostStateSnapshot;
20+
use mac_address::MacAddress;
21+
use model::machine::{ManagedHostStateSnapshot, pick_boot_prediction};
22+
use model::machine_boot_interface::MachineBootInterface;
23+
use model::predicted_machine_interface::PredictedMachineInterface;
2124

2225
/// Resolve how to target this host's boot interface for Redfish setup calls.
2326
///
24-
/// Uses the host's primary `machine_interface`: when that row has a captured
25-
/// Redfish interface id, the full pair is returned (enabling the MAC-first /
26-
/// interface-id fallback); otherwise it targets the MAC alone. Both come from the
27-
/// same row, so the pair can never name a different interface than the MAC.
27+
/// The host's own `machine_interface` row wins the moment it exists: when that
28+
/// row has a captured Redfish interface id, the full pair is returned (enabling
29+
/// the MAC-first / interface-id fallback); otherwise it targets the MAC alone.
30+
/// Both come from the same row, so the pair can never name a different interface
31+
/// than the MAC.
2832
///
29-
/// Returns `None` only when the host has no boot interface at all (e.g. only the
30-
/// BMC has been discovered, or the primary NIC hasn't appeared yet).
33+
/// Before that first DHCP lease creates a row -- the window a zero-DPU or
34+
/// NIC-mode host sits in, since it gets no primary row at ingestion -- the host's
35+
/// `predictions` answer instead, via `pick_boot_prediction` (the declared
36+
/// primary, else the sole non-underlay prediction). The predicted MAC and
37+
/// recorded id form the same pair the real row will hold once the lease promotes
38+
/// it.
39+
///
40+
/// Returns `None` only when the host has no boot interface at all -- no row and
41+
/// no usable prediction (e.g. only the BMC has been discovered, or several
42+
/// predictions with no declared primary).
3143
pub fn boot_interface_target(
3244
mh_snapshot: &ManagedHostStateSnapshot,
45+
predictions: &[PredictedMachineInterface],
46+
) -> Option<BootInterfaceTarget> {
47+
boot_interface_target_from(
48+
mh_snapshot.boot_interface(),
49+
mh_snapshot.boot_interface_mac(),
50+
predictions,
51+
)
52+
}
53+
54+
/// The boot-target decision, split out from the snapshot lookup so it can be
55+
/// unit-tested directly without constructing a full `ManagedHostStateSnapshot`
56+
/// -- the same split `pick_boot_interface_mac`/`_pair` use in api-model. The
57+
/// host's own row wins (its captured pair, else its MAC alone); only when it
58+
/// owns no boot row does the predicted boot interface answer.
59+
fn boot_interface_target_from(
60+
row_pair: Option<MachineBootInterface>,
61+
row_mac: Option<MacAddress>,
62+
predictions: &[PredictedMachineInterface],
3363
) -> Option<BootInterfaceTarget> {
34-
if let Some(boot_interface) = mh_snapshot.boot_interface() {
35-
return Some(BootInterfaceTarget::Pair(boot_interface));
64+
if let Some(pair) = row_pair {
65+
return Some(BootInterfaceTarget::Pair(pair));
66+
}
67+
if let Some(mac) = row_mac {
68+
return Some(BootInterfaceTarget::MacOnly(mac));
3669
}
37-
mh_snapshot
38-
.boot_interface_mac()
39-
.map(BootInterfaceTarget::MacOnly)
70+
// No real row yet -- fall back to the host's predicted boot interface.
71+
pick_boot_prediction(predictions).map(|prediction| {
72+
prediction.boot_interface().map_or(
73+
BootInterfaceTarget::MacOnly(prediction.mac_address),
74+
BootInterfaceTarget::Pair,
75+
)
76+
})
4077
}
4178

4279
/// What a Redfish boot step should do with a host's boot interface.
@@ -51,17 +88,21 @@ pub fn boot_interface_target(
5188
pub enum BootInterfaceResolution {
5289
/// The boot interface resolved; target it.
5390
Ready(BootInterfaceTarget),
54-
/// A zero-DPU host whose boot NIC has not been discovered yet -- wait.
91+
/// A zero-DPU host with no boot interface yet -- neither a real row nor a
92+
/// usable prediction -- so wait for its boot NIC to appear.
5593
AwaitingNic,
5694
/// A host that should already have a boot interface is missing one.
5795
Missing,
5896
}
5997

6098
/// Resolve this host's boot interface for a Redfish boot step, classifying a
6199
/// missing one as either "wait for the NIC" (zero-DPU) or "fault".
62-
pub fn resolve_boot_interface(mh_snapshot: &ManagedHostStateSnapshot) -> BootInterfaceResolution {
100+
pub fn resolve_boot_interface(
101+
mh_snapshot: &ManagedHostStateSnapshot,
102+
predictions: &[PredictedMachineInterface],
103+
) -> BootInterfaceResolution {
63104
classify_boot_interface(
64-
boot_interface_target(mh_snapshot),
105+
boot_interface_target(mh_snapshot, predictions),
65106
mh_snapshot.has_managed_dpus(),
66107
)
67108
}
@@ -82,9 +123,74 @@ fn classify_boot_interface(
82123
#[cfg(test)]
83124
mod tests {
84125
use mac_address::MacAddress;
126+
use model::network_segment::NetworkSegmentType;
85127

86128
use super::*;
87129

130+
fn prediction(mac: &str, boot_interface_id: Option<&str>) -> PredictedMachineInterface {
131+
PredictedMachineInterface {
132+
id: uuid::Uuid::nil(),
133+
machine_id: "fm100ds7blqjsadm2uuh3qqbf1h7k8pmf47um6v9uckrg7l03po8mhqgvng"
134+
.parse()
135+
.unwrap(),
136+
mac_address: mac.parse().unwrap(),
137+
expected_network_segment_type: NetworkSegmentType::HostInband,
138+
boot_interface_id: boot_interface_id.map(String::from),
139+
primary_interface: false,
140+
}
141+
}
142+
143+
// The host's own row wins over any prediction: a captured id gives the pair.
144+
#[test]
145+
fn boot_target_prefers_the_owned_row_over_predictions() {
146+
let pair = MachineBootInterface {
147+
mac_address: "10:00:00:00:00:01".parse().unwrap(),
148+
interface_id: "NIC.Slot.5-1".to_string(),
149+
};
150+
assert_eq!(
151+
boot_interface_target_from(
152+
Some(pair.clone()),
153+
Some("10:00:00:00:00:01".parse().unwrap()),
154+
&[prediction("20:00:00:00:00:01", Some("NIC.Embedded.1-1-1"))],
155+
),
156+
Some(BootInterfaceTarget::Pair(pair)),
157+
);
158+
}
159+
160+
// No owned row yet: the predicted boot interface answers (pre-first-lease),
161+
// as a full pair when the prediction carries the Redfish id.
162+
#[test]
163+
fn boot_target_falls_back_to_the_prediction_before_the_first_lease() {
164+
assert_eq!(
165+
boot_interface_target_from(
166+
None,
167+
None,
168+
&[prediction("20:00:00:00:00:01", Some("NIC.Embedded.1-1-1"))],
169+
),
170+
Some(BootInterfaceTarget::Pair(MachineBootInterface {
171+
mac_address: "20:00:00:00:00:01".parse().unwrap(),
172+
interface_id: "NIC.Embedded.1-1-1".to_string(),
173+
})),
174+
);
175+
}
176+
177+
// A prediction with no captured id targets the MAC alone.
178+
#[test]
179+
fn boot_target_prediction_without_id_is_mac_only() {
180+
assert_eq!(
181+
boot_interface_target_from(None, None, &[prediction("20:00:00:00:00:01", None)]),
182+
Some(BootInterfaceTarget::MacOnly(
183+
"20:00:00:00:00:01".parse().unwrap()
184+
)),
185+
);
186+
}
187+
188+
// No row and no usable prediction: nothing to target, so the caller waits.
189+
#[test]
190+
fn boot_target_is_none_without_row_or_prediction() {
191+
assert_eq!(boot_interface_target_from(None, None, &[]), None);
192+
}
193+
88194
#[test]
89195
fn classify_waits_for_a_zero_dpu_host_without_a_boot_interface() {
90196
// The zero-DPU host's boot NIC has not taken its first lease yet: wait

0 commit comments

Comments
 (0)