diff --git a/book/src/SUMMARY.md b/book/src/SUMMARY.md
index 23af3043c1..2c3d9172a0 100644
--- a/book/src/SUMMARY.md
+++ b/book/src/SUMMARY.md
@@ -21,6 +21,7 @@
- [NIC and Port selection](architecture/infiniband/nic_selection.md)
- [State Machines]()
- [Managed Host](architecture/state_machines/managedhost.md)
+ - [Switch](architecture/state_machines/switch.md)
# Manuals
diff --git a/book/src/architecture/state_machines/switch.md b/book/src/architecture/state_machines/switch.md
new file mode 100644
index 0000000000..e318b8da71
--- /dev/null
+++ b/book/src/architecture/state_machines/switch.md
@@ -0,0 +1,81 @@
+# Switch State Diagram
+
+This document describes the Finite State Machine (FSM) for Switches in Carbide: lifecycle from creation through configuration, validation, ready, optional reprovisioning, and deletion.
+
+## High-Level Overview
+
+The main flow shows the primary states and transitions:
+
+
+
+```plantuml
+@startuml
+skinparam state {
+ BackgroundColor White
+}
+
+state "Initializing" as Initializing
+state "Configuring\n(RotateOsPassword)" as Configuring
+state "Validating" as Validating
+state "BomValidating" as BomValidating
+state "Ready" as Ready
+state "ReProvisioning\n(Start → WaitFirmware)" as ReProvisioning
+state "Error" as Error
+state "Deleting" as Deleting
+
+[*] --> Initializing : Switch created
+
+Initializing --> Configuring : init complete
+Configuring --> Validating : rotate password done
+Validating --> BomValidating : validation complete
+BomValidating --> Ready : BOM validation complete
+
+Ready --> Deleting : marked for deletion
+Ready --> ReProvisioning : reprovision requested
+
+ReProvisioning --> Ready : firmware upgrade Completed
+ReProvisioning --> Error : firmware upgrade Failed
+
+Error --> Deleting : marked for deletion
+
+Deleting --> [*] : final delete
+@enduml
+```
+
+
+
+## States
+
+| State | Description |
+|-------|-------------|
+| **Initializing** | Switch is created in Carbide; controller performs initial setup. |
+| **Configuring** | Switch is being configured (rotate OS password). Sub-state: `RotateOsPassword`. |
+| **Validating** | Switch is being validated. Sub-state: `ValidateComplete`. |
+| **BomValidating** | BOM (Bill of Materials) validation. Sub-state: `BomValidateComplete`. |
+| **Ready** | Switch is ready for use. From here it can be deleted, or reprovisioning can be requested. |
+| **ReProvisioning** | Reprovisioning (e.g. firmware update) in progress. Sub-states: `Start`, `WaitFirmwareUpdateCompletion`. Completion is driven by `firmware_upgrade_status` (Completed → Ready, Failed → Error). |
+| **Error** | Switch is in error (e.g. firmware upgrade failed). Can transition to Deleting if marked for deletion; otherwise waits for manual intervention. |
+| **Deleting** | Switch is being removed; ends in final delete (terminal). |
+
+## Transitions (by trigger)
+
+| From | To | Trigger / Condition |
+|------|-----|----------------------|
+| *(create)* | Initializing | Switch created |
+| Initializing | Configuring (RotateOsPassword) | Initialization complete |
+| Configuring (RotateOsPassword) | Validating (ValidateComplete) | OS password rotated |
+| Validating (ValidateComplete) | BomValidating (BomValidateComplete) | Validation complete |
+| BomValidating (BomValidateComplete) | Ready | BOM validation complete |
+| Ready | Deleting | `deleted` set (marked for deletion) |
+| Ready | ReProvisioning (Start) | `switch_reprovisioning_requested` is set |
+| ReProvisioning (Start) | ReProvisioning (WaitFirmwareUpdateCompletion) | Reprovision triggered |
+| ReProvisioning (WaitFirmwareUpdateCompletion) | Ready | `firmware_upgrade_status == Completed` |
+| ReProvisioning (WaitFirmwareUpdateCompletion) | Error | `firmware_upgrade_status == Failed { cause }` |
+| Error | Deleting | `deleted` set (marked for deletion) |
+| Deleting | *(end)* | Final delete committed |
+
+## Implementation
+
+- **State type**: `SwitchControllerState` in `crates/api-model/src/switch/mod.rs`.
+- **Handlers**: `crates/api/src/state_controller/switch/` — one module per top-level state (`initializing`, `configuring`, `validating`, `bom_validating`, `ready`, `reprovisioning`, `error_state`, `deleting`).
+- **Orchestration**: `SwitchStateHandler` in `handler.rs` delegates to the handler for the current `controller_state`.
diff --git a/crates/admin-cli/src/expected_switch/add/args.rs b/crates/admin-cli/src/expected_switch/add/args.rs
index 3cba806162..4224742638 100644
--- a/crates/admin-cli/src/expected_switch/add/args.rs
+++ b/crates/admin-cli/src/expected_switch/add/args.rs
@@ -37,6 +37,8 @@ pub struct Args {
)]
pub switch_serial_number: String,
+ #[clap(long, help = "NVOS MAC address of the expected switch")]
+ pub nvos_mac_address: Option,
#[clap(long, help = "NVOS username of the expected switch")]
pub nvos_username: Option,
#[clap(long, help = "NVOS password of the expected switch")]
@@ -89,6 +91,7 @@ impl From for rpc::forge::ExpectedSwitch {
switch_serial_number: value.switch_serial_number,
metadata: Some(metadata),
rack_id: value.rack_id,
+ nvos_mac_address: value.nvos_mac_address.map(|m| m.to_string()),
nvos_username: value.nvos_username,
nvos_password: value.nvos_password,
}
diff --git a/crates/admin-cli/src/expected_switch/common.rs b/crates/admin-cli/src/expected_switch/common.rs
index 9324d0a32c..16c6288ed6 100644
--- a/crates/admin-cli/src/expected_switch/common.rs
+++ b/crates/admin-cli/src/expected_switch/common.rs
@@ -25,6 +25,7 @@ pub struct ExpectedSwitchJson {
pub bmc_username: String,
pub bmc_password: String,
pub switch_serial_number: String,
+ pub nvos_mac_address: Option,
pub nvos_username: Option,
pub nvos_password: Option,
#[serde(default)]
diff --git a/crates/admin-cli/src/expected_switch/show/cmd.rs b/crates/admin-cli/src/expected_switch/show/cmd.rs
index 03aae36764..e02a2a7e3d 100644
--- a/crates/admin-cli/src/expected_switch/show/cmd.rs
+++ b/crates/admin-cli/src/expected_switch/show/cmd.rs
@@ -100,6 +100,7 @@ fn convert_and_print_into_nice_table(
table.set_titles(row![
"Serial Number",
"BMC Mac",
+ "NVOS Mac",
"Interface IP",
"Associated Machine",
"Name",
@@ -141,6 +142,7 @@ fn convert_and_print_into_nice_table(
table.add_row(row![
expected_switch.switch_serial_number,
expected_switch.bmc_mac_address,
+ expected_switch.nvos_mac_address.as_deref().unwrap_or_default(),
machine_interface
.map(|x| x.address.join("\n"))
.unwrap_or("Undiscovered".to_string()),
diff --git a/crates/admin-cli/src/expected_switch/update/args.rs b/crates/admin-cli/src/expected_switch/update/args.rs
index 91d7449556..277de8e89e 100644
--- a/crates/admin-cli/src/expected_switch/update/args.rs
+++ b/crates/admin-cli/src/expected_switch/update/args.rs
@@ -27,6 +27,9 @@ use uuid::Uuid;
"bmc_username",
"bmc_password",
"switch_serial_number",
+"nvos_mac_address",
+"nvos_username",
+"nvos_password",
])))]
pub struct Args {
#[clap(short = 'a', long, help = "BMC MAC Address of the expected switch")]
@@ -59,6 +62,12 @@ pub struct Args {
)]
pub switch_serial_number: Option,
+ #[clap(
+ long,
+ group = "group",
+ help = "NVOS MAC address of the expected switch"
+ )]
+ pub nvos_mac_address: Option,
#[clap(long, group = "group", help = "NVOS username of the expected switch")]
pub nvos_username: Option,
#[clap(long, group = "group", help = "NVOS password of the expected switch")]
@@ -140,6 +149,7 @@ impl TryFrom for rpc::forge::ExpectedSwitch {
labels: crate::metadata::parse_rpc_labels(args.labels.unwrap_or_default()),
}),
rack_id: args.rack_id,
+ nvos_mac_address: args.nvos_mac_address.map(|m| m.to_string()),
})
}
}
diff --git a/crates/admin-cli/src/rpc.rs b/crates/admin-cli/src/rpc.rs
index 6778d12ca3..f92a742aec 100644
--- a/crates/admin-cli/src/rpc.rs
+++ b/crates/admin-cli/src/rpc.rs
@@ -564,7 +564,6 @@ impl ApiClient {
Ok(self.0.update_expected_machine(request).await?)
}
-
pub async fn replace_all_expected_machines(
&self,
expected_machine_list: Vec,
@@ -635,6 +634,7 @@ impl ApiClient {
bmc_username: switch.bmc_username,
bmc_password: switch.bmc_password,
switch_serial_number: switch.switch_serial_number,
+ nvos_mac_address: switch.nvos_mac_address.map(|m| m.to_string()),
nvos_username: switch.nvos_username,
nvos_password: switch.nvos_password,
metadata: switch.metadata,
diff --git a/crates/api-db/migrations/20260316120000_switch_reprovisioning_requested.sql b/crates/api-db/migrations/20260316120000_switch_reprovisioning_requested.sql
new file mode 100644
index 0000000000..448cd08f88
--- /dev/null
+++ b/crates/api-db/migrations/20260316120000_switch_reprovisioning_requested.sql
@@ -0,0 +1,6 @@
+-- Add switch_reprovisioning_requested and firmware_upgrade_status columns to switches table.
+-- switch_reprovisioning_requested: when set by an external entity, the state controller (when switch is Ready) transitions to ReProvisioning::Start.
+-- firmware_upgrade_status: used during ReProvisioning (WaitFirmwareUpdateCompletion): Started, InProgress, Completed, Failed.
+ALTER TABLE switches
+ ADD COLUMN switch_reprovisioning_requested JSONB,
+ ADD COLUMN firmware_upgrade_status JSONB;
diff --git a/crates/api-db/migrations/20260316120002_expected_switches_nvos_mac_address.sql b/crates/api-db/migrations/20260316120002_expected_switches_nvos_mac_address.sql
new file mode 100644
index 0000000000..718043c5a2
--- /dev/null
+++ b/crates/api-db/migrations/20260316120002_expected_switches_nvos_mac_address.sql
@@ -0,0 +1,3 @@
+-- Add nvos_mac_address column to expected_switches table (NVOS host MAC, similar to bmc_mac_address).
+ALTER TABLE expected_switches
+ ADD COLUMN nvos_mac_address macaddr;
diff --git a/crates/api-db/src/expected_switch.rs b/crates/api-db/src/expected_switch.rs
index 8e3ac1e900..6ebae1738e 100644
--- a/crates/api-db/src/expected_switch.rs
+++ b/crates/api-db/src/expected_switch.rs
@@ -165,9 +165,9 @@ pub async fn create(
) -> DatabaseResult {
let id = switch.expected_switch_id.unwrap_or_else(Uuid::new_v4);
let query = "INSERT INTO expected_switches
- (expected_switch_id, bmc_mac_address, bmc_username, bmc_password, serial_number, metadata_name, metadata_description, rack_id, metadata_labels, nvos_username, nvos_password)
+ (expected_switch_id, bmc_mac_address, bmc_username, bmc_password, serial_number, metadata_name, metadata_description, rack_id, metadata_labels, nvos_username, nvos_password, nvos_mac_address)
VALUES
- ($1::uuid, $2::macaddr, $3::varchar, $4::varchar, $5::varchar, $6::varchar, $7::varchar, $8::varchar, $9::jsonb, $10::varchar, $11::varchar) RETURNING *";
+ ($1::uuid, $2::macaddr, $3::varchar, $4::varchar, $5::varchar, $6::varchar, $7::varchar, $8::varchar, $9::jsonb, $10::varchar, $11::varchar, $12::macaddr) RETURNING *";
sqlx::query_as(query)
.bind(id)
@@ -181,6 +181,7 @@ pub async fn create(
.bind(sqlx::types::Json(&switch.metadata.labels))
.bind(&switch.nvos_username)
.bind(&switch.nvos_password)
+ .bind(switch.nvos_mac_address)
.fetch_one(txn)
.await
.map_err(|err: sqlx::Error| match err {
diff --git a/crates/api-db/src/switch.rs b/crates/api-db/src/switch.rs
index bc5f00b4dd..7fafb68b3f 100644
--- a/crates/api-db/src/switch.rs
+++ b/crates/api-db/src/switch.rs
@@ -22,7 +22,9 @@ use chrono::prelude::*;
use config_version::{ConfigVersion, Versioned};
use futures::StreamExt;
use model::controller_outcome::PersistentStateHandlerOutcome;
-use model::switch::{NewSwitch, Switch, SwitchControllerState};
+use model::switch::{
+ FirmwareUpgradeStatus, NewSwitch, Switch, SwitchControllerState, SwitchReprovisionRequest,
+};
use sqlx::PgConnection;
use crate::{
@@ -81,6 +83,8 @@ pub async fn create(txn: &mut PgConnection, new_switch: &NewSwitch) -> DatabaseR
version,
},
controller_state_outcome: None,
+ switch_reprovisioning_requested: None,
+ firmware_upgrade_status: None,
})
}
@@ -128,6 +132,18 @@ pub async fn find_by_id(txn: &mut PgConnection, id: &SwitchId) -> DatabaseResult
}
}
+pub async fn find_by_host_mac_address(
+ txn: &mut PgConnection,
+ host_mac_address: &MacAddress,
+) -> DatabaseResult