Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/admin-cli/src/boot_interface/candidates/cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,7 @@ mod tests {
mac_address: "aa:bb:cc:00:00:09".to_string(),
interface_id: None,
}),
reconciliation: None,
}
}

Expand Down
12 changes: 6 additions & 6 deletions crates/admin-cli/src/boot_interface/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,8 @@ pub enum Cmd {
managed machine), `predicted_machine_interfaces` (pre-first-lease candidates), \
the `explored_endpoints` default (for endpoints without a machine), and the \
retained post-deletion pairs (including stale records). Also reports the \
effective boot interface the system would select and flags when the stores \
disagree. Read-only."
effective boot interface, flags when the stores disagree, and shows desired-state \
reconciliation progress when one exists. Read-only."
)]
Show(show::Args),
#[clap(
Expand All @@ -64,10 +64,10 @@ pub enum Cmd {
about = "Set the boot interface for a machine (promotes it to the primary interface)",
long_about = "Make an interface the boot interface for a machine by promoting it to \
the primary interface -- the designation every boot flow keys on. This is the \
same operation as `managed-host set-primary-interface`: the BMC boot order is \
updated first, then the primary flag moves in the database. The interface can be \
named by machine-interface UUID or by MAC address; a MAC must match exactly one \
managed interface row on the machine."
same operation as `managed-host set-primary-interface`: the primary row and desired \
target commit together, then machine-controller reconciles the BMC when the host is \
eligible. The interface can be named by machine-interface UUID or by MAC address; a \
MAC must match exactly one managed interface row on the machine."
)]
Set(set::Args),
}
14 changes: 11 additions & 3 deletions crates/admin-cli/src/boot_interface/set/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,9 @@ Set it by machine-interface UUID (exact, works even with duplicate MACs):
$ nico-admin-cli boot-interface set 12345678-1234-5678-90ab-cdef01234567 \
abcdef01-2345-6789-abcd-ef0123456789

Set and reboot the host so the new boot order takes effect immediately:
Request another reconciliation for the selected interface:
$ nico-admin-cli boot-interface set 12345678-1234-5678-90ab-cdef01234567 \
00:11:22:33:44:55 --reboot
00:11:22:33:44:55 --force-reconcile

Tip: 'boot-interface candidates <MACHINE_ID>' lists the candidate NICs with their MACs and UUIDs.
")]
Expand All @@ -43,7 +43,15 @@ pub struct Args {
pub machine: MachineId,
#[clap(help = "The interface to boot from -- a machine-interface UUID or a MAC address")]
pub interface: InterfaceSelector,
#[clap(long, help = "Reboot the host after the update")]
#[clap(
long,
help = "Request a fresh machine-controller reconciliation even when this interface is already selected"
)]
pub force_reconcile: bool,
#[clap(
long,
help = "Deprecated compatibility alias; use --force-reconcile with current servers"
)]
Comment thread
coderabbitai[bot] marked this conversation as resolved.
pub reboot: bool,
}

Expand Down
24 changes: 13 additions & 11 deletions crates/admin-cli/src/boot_interface/set/cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,10 @@
//! Set a machine's boot interface by promoting the chosen interface to the
//! machine's primary -- the designation `pick_boot_interface` keys on. A thin
//! front for the same `SetPrimaryInterface` RPC behind
//! `managed-host set-primary-interface`: the server updates the BMC boot
//! order first, then moves the primary flag. The only client-side work is
//! resolving an operator-entered MAC to its managed interface row.
//! `managed-host set-primary-interface`: the server commits the selected row
//! and desired target together, then machine-controller converges Redfish.
//! The only client-side work is resolving an operator-entered MAC to its
//! managed interface row.

use ::rpc::forge as forgerpc;
use carbide_uuid::machine::MachineInterfaceId;
Expand All @@ -39,14 +40,14 @@ pub async fn handle_set(args: Args, api_client: &ApiClient) -> CarbideCliResult<
}
};

api_client
.0
.set_primary_interface(forgerpc::SetPrimaryInterfaceRequest {
host_machine_id: Some(args.machine),
interface_id: Some(interface_id),
reboot: args.reboot,
})
.await?;
#[allow(deprecated)] // Keep `--reboot` functional when this CLI calls an older server.
let request = forgerpc::SetPrimaryInterfaceRequest {
host_machine_id: Some(args.machine),
interface_id: Some(interface_id),
reboot: args.reboot,
force_reconcile: args.force_reconcile || args.reboot,
};
api_client.0.set_primary_interface(request).await?;
Ok(())
}

Expand Down Expand Up @@ -126,6 +127,7 @@ mod tests {
divergent: false,
default_boot_interface: None,
predicted_boot_interface: None,
reconciliation: None,
}
}

Expand Down
171 changes: 169 additions & 2 deletions crates/admin-cli/src/boot_interface/show/cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,14 @@
//! RPC) as an ASCII table, JSON, or YAML. The view gathers the four stores a
//! host's boot interface can live in -- managed interface rows, predictions, the
//! explored endpoint default, and the retained post-deletion pairs -- plus the
//! effective boot interface the system would select and a divergence flag.
//! effective boot interface, store divergence, and desired-state reconciliation.

use std::fmt::Write as _;

use ::rpc::admin_cli::OutputFormat;
use ::rpc::forge as forgerpc;
use ::rpc::forge::get_machine_boot_interfaces_response::Reconciliation as RpcReconciliation;
use ::rpc::forge::get_machine_boot_interfaces_response::reconciliation::State as RpcReconciliationState;
use carbide_uuid::machine::MachineId;
use prettytable::{Cell, Row, Table};
use serde::Serialize;
Expand All @@ -51,6 +53,22 @@ struct BootInterfacesReport {
effective_boot_interface_id: Option<String>,
/// True when the stores disagree about which MAC boots this machine.
divergent: bool,
/// Desired generation and the machine controller's progress toward it.
reconciliation: Option<ReconciliationReport>,
}

/// Machine-readable and ASCII-ready view of desired boot reconciliation.
#[derive(Debug, Serialize)]
struct ReconciliationReport {
desired_boot_interface: Option<forgerpc::MachineBootInterface>,
desired_version: String,
verified_version: Option<String>,
observed_at: Option<String>,
is_compatibility_baseline: bool,
reconciliation_state: String,
machine_state: String,
reconciling_version: Option<String>,
failure: Option<String>,
}

#[derive(Debug, Serialize)]
Expand Down Expand Up @@ -128,6 +146,29 @@ impl From<forgerpc::GetMachineBootInterfacesResponse> for BootInterfacesReport {
effective_boot_interface_mac: r.effective_boot_interface_mac,
effective_boot_interface_id: r.effective_boot_interface_id,
divergent: r.divergent,
reconciliation: r.reconciliation.map(Into::into),
}
}
}

impl From<RpcReconciliation> for ReconciliationReport {
fn from(status: RpcReconciliation) -> Self {
let reconciliation_state = RpcReconciliationState::try_from(status.reconciliation_state)
.map_or_else(
|_| format!("Unknown({})", status.reconciliation_state),
|state| state.as_str_name().to_string(),
);

Self {
desired_boot_interface: status.desired_boot_interface,
desired_version: status.desired_version,
verified_version: status.verified_version,
observed_at: status.observed_at.map(|timestamp| timestamp.to_string()),
is_compatibility_baseline: status.is_compatibility_baseline,
reconciliation_state,
machine_state: status.machine_state,
reconciling_version: status.reconciling_version,
failure: status.failure,
Comment thread
chet marked this conversation as resolved.
}
}
}
Expand Down Expand Up @@ -279,7 +320,8 @@ fn render_tables(report: &BootInterfacesReport) -> String {
}
let _ = write!(out, "{retained}");

// Summary: the effective pick and the divergence flag.
// Summary: the effective pick, store agreement, and controller progress
// toward the persisted desired target.
let _ = writeln!(
out,
"\nEffective boot interface MAC: {}",
Expand All @@ -291,12 +333,60 @@ fn render_tables(report: &BootInterfacesReport) -> String {
dash(&report.effective_boot_interface_id)
);
let _ = writeln!(out, "Stores diverge on boot MAC: {}", report.divergent);
if let Some(reconciliation) = &report.reconciliation {
let desired_boot_interface = reconciliation
.desired_boot_interface
.as_ref()
.map(|target| match &target.interface_id {
Some(interface_id) => format!("{} ({interface_id})", target.mac_address),
None => target.mac_address.clone(),
})
.unwrap_or_else(|| "-".to_string());
let observation = reconciliation.observed_at.as_ref().map_or_else(
|| "-".to_string(),
|observed_at| {
let kind = if reconciliation.is_compatibility_baseline {
"compatibility baseline"
} else {
"Redfish verified"
};
format!("{observed_at} ({kind})")
},
);
writeln!(out, "Desired boot interface: {desired_boot_interface}").ok();
writeln!(
out,
"Reconciliation: {} (desired {}, verified {})",
reconciliation.reconciliation_state,
reconciliation.desired_version,
dash(&reconciliation.verified_version),
)
.ok();
writeln!(
out,
"Machine controller: {} (active {})",
reconciliation.machine_state,
dash(&reconciliation.reconciling_version),
)
.ok();
writeln!(out, "Last observation: {observation}").ok();
writeln!(
out,
"Reconciliation failure: {}",
dash(&reconciliation.failure),
)
.ok();
} else {
writeln!(out, "Reconciliation: -").ok();
}

out
}

#[cfg(test)]
mod tests {
use carbide_test_support::value_scenarios;

use super::*;

/// A fixed report exercising every store, a captured pair, a stale retained
Expand Down Expand Up @@ -330,9 +420,43 @@ mod tests {
effective_boot_interface_mac: Some("aa:bb:cc:00:00:01".to_string()),
effective_boot_interface_id: Some("NIC.Slot.1-1-1".to_string()),
divergent: true,
reconciliation: Some(ReconciliationReport {
desired_boot_interface: Some(forgerpc::MachineBootInterface {
mac_address: "aa:bb:cc:00:00:01".to_string(),
interface_id: Some("NIC.Slot.1-1-1".to_string()),
}),
desired_version: "V7-T700".to_string(),
verified_version: Some("V6-T600".to_string()),
observed_at: Some("2026-06-02T00:00:00Z".to_string()),
is_compatibility_baseline: false,
reconciliation_state: "Failed".to_string(),
machine_state: "BootConfiguring/Failed".to_string(),
reconciling_version: Some("V7-T700".to_string()),
failure: Some("BIOS job retries exhausted".to_string()),
}),
}
}

#[test]
fn reconciliation_report_names_known_and_unknown_states() {
value_scenarios!(
run = |reconciliation_state| {
ReconciliationReport::from(RpcReconciliation {
reconciliation_state,
..Default::default()
})
.reconciliation_state
};
"known state uses its protobuf name" {
RpcReconciliationState::Pending as i32 => "Pending".to_string(),
}

"unknown state preserves its numeric value" {
i32::MAX => format!("Unknown({})", i32::MAX),
}
);
}

#[test]
fn ascii_table_shows_each_store_and_summary() {
let table = render_tables(&sample_report());
Expand All @@ -352,6 +476,14 @@ mod tests {
// The effective pick and divergence flag.
assert!(table.contains("Effective boot interface MAC: aa:bb:cc:00:00:01"));
assert!(table.contains("Stores diverge on boot MAC: true"));
assert!(table.contains("Desired boot interface: aa:bb:cc:00:00:01 (NIC.Slot.1-1-1)"));
assert!(
table.contains(
"Reconciliation: Failed (desired V7-T700, verified V6-T600)"
)
);
assert!(table.contains("Machine controller: BootConfiguring/Failed"));
assert!(table.contains("Reconciliation failure: BIOS job retries exhausted"));
}

#[test]
Expand All @@ -365,6 +497,8 @@ mod tests {
assert!(json.contains("2026-06-01T00:00:00Z"));
assert!(json.contains("\"primary_interface\": true"));
assert!(json.contains("\"divergent\": true"));
assert!(json.contains("\"reconciliation\""));
assert!(json.contains("\"reconciliation_state\": \"Failed\""));

// Round-trips into a generic JSON value with the expected structure.
let value: serde_json::Value = serde_json::from_str(&json).expect("parse json");
Expand All @@ -379,6 +513,11 @@ mod tests {
"2026-06-01T00:00:00Z"
);
assert_eq!(value["effective_boot_interface_mac"], "aa:bb:cc:00:00:01");
assert_eq!(value["reconciliation"]["desired_version"], "V7-T700");
assert_eq!(
value["reconciliation"]["failure"],
"BIOS job retries exhausted"
);
}

#[test]
Expand All @@ -390,6 +529,8 @@ mod tests {
assert!(yaml.contains("recorded_at:"));
assert!(yaml.contains("divergent: true"));
assert!(yaml.contains("primary_interface: true"));
assert!(yaml.contains("reconciliation:"));
assert!(yaml.contains("reconciliation_state: Failed"));

// Round-trips back into a generic YAML value.
let value: serde_yaml::Value = serde_yaml::from_str(&yaml).expect("parse yaml");
Expand Down Expand Up @@ -433,6 +574,20 @@ mod tests {
divergent: false,
default_boot_interface: None,
predicted_boot_interface: None,
reconciliation: Some(RpcReconciliation {
desired_boot_interface: Some(forgerpc::MachineBootInterface {
mac_address: "aa:bb:cc:00:00:01".to_string(),
interface_id: Some("NIC.Slot.1-1-1".to_string()),
}),
desired_version: "V7-T700".to_string(),
verified_version: Some("V6-T600".to_string()),
observed_at: Some(Default::default()),
is_compatibility_baseline: true,
reconciliation_state: RpcReconciliationState::Pending as i32,
machine_state: "Assigned/Ready".to_string(),
reconciling_version: None,
failure: None,
}),
};

let report = BootInterfacesReport::from(response);
Expand All @@ -454,5 +609,17 @@ mod tests {
report.retained_interfaces[0].recorded_at.as_deref(),
Some("1970-01-01T00:00:00Z")
);
let reconciliation = report
.reconciliation
.expect("desired reconciliation should be mapped");
assert_eq!(reconciliation.reconciliation_state, "Pending");
assert_eq!(reconciliation.desired_version, "V7-T700");
assert_eq!(reconciliation.verified_version.as_deref(), Some("V6-T600"));
assert_eq!(
reconciliation.observed_at.as_deref(),
Some("1970-01-01T00:00:00Z")
);
assert!(reconciliation.is_compatibility_baseline);
assert_eq!(reconciliation.machine_state, "Assigned/Ready");
}
}
Loading
Loading