diff --git a/Cargo.lock b/Cargo.lock index dab633eda70..155d23802b5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8945,6 +8945,7 @@ dependencies = [ "serde_json", "sled-agent-api", "wicketd-api", + "wicketd-commission-api", ] [[package]] @@ -17512,6 +17513,7 @@ dependencies = [ "slog", "slog-dtrace", "slog-error-chain", + "sp-sim", "subprocess", "tar", "thiserror 2.0.18", @@ -17530,14 +17532,16 @@ dependencies = [ "wicket-common", "wicketd-api", "wicketd-client", + "wicketd-commission-api", + "wicketd-commission-client", "wicketd-commission-types", + "wicketd-commission-types-versions", ] [[package]] name = "wicketd-api" version = "0.1.0" dependencies = [ - "bootstrap-agent-lockstep-client", "bootstrap-agent-lockstep-types", "dropshot", "gateway-client", @@ -17581,6 +17585,35 @@ dependencies = [ "wicketd-commission-types-versions", ] +[[package]] +name = "wicketd-commission-api" +version = "0.1.0" +dependencies = [ + "dropshot", + "dropshot-api-manager-types", + "iddqd", + "omicron-uuid-kinds", + "omicron-workspace-hack", + "wicketd-commission-types-versions", +] + +[[package]] +name = "wicketd-commission-client" +version = "0.1.0" +dependencies = [ + "iddqd", + "omicron-uuid-kinds", + "omicron-workspace-hack", + "oxnet", + "progenitor 0.14.0", + "regress 0.10.5", + "reqwest 0.13.2", + "schemars 0.8.22", + "serde", + "slog", + "wicketd-commission-types-versions", +] + [[package]] name = "wicketd-commission-types" version = "0.1.0" @@ -17594,11 +17627,14 @@ name = "wicketd-commission-types-versions" version = "0.1.0" dependencies = [ "gateway-types-versions", + "iddqd", "omicron-common", + "omicron-uuid-kinds", "omicron-workspace-hack", "oxnet", "proptest", "schemars 0.8.22", + "semver 1.0.28", "serde", "serde_json", "sled-agent-types-versions", diff --git a/Cargo.toml b/Cargo.toml index ce3774b626a..5beff31fa5e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,7 @@ members = [ "clients/repo-depot-client", "clients/sled-agent-client", "clients/wicketd-client", + "clients/wicketd-commission-client", "cockroach-admin", "cockroach-admin/api", "cockroach-admin/types", @@ -183,6 +184,7 @@ members = [ "wicket", "wicketd", "wicketd-api", + "wicketd-commission-api", "wicketd-commission-types", "wicketd-commission-types/versions", "workspace-hack", @@ -216,6 +218,7 @@ default-members = [ "clients/repo-depot-client", "clients/sled-agent-client", "clients/wicketd-client", + "clients/wicketd-commission-client", "cockroach-admin", "cockroach-admin/api", "cockroach-admin/types", @@ -376,6 +379,7 @@ default-members = [ "wicket", "wicketd", "wicketd-api", + "wicketd-commission-api", "wicketd-commission-types", "wicketd-commission-types/versions", "workspace-hack", @@ -895,6 +899,8 @@ wicket = { path = "wicket" } wicket-common = { path = "wicket-common" } wicketd-api = { path = "wicketd-api" } wicketd-client = { path = "clients/wicketd-client" } +wicketd-commission-api = { path = "wicketd-commission-api" } +wicketd-commission-client = { path = "clients/wicketd-commission-client" } wicketd-commission-types = { path = "wicketd-commission-types" } wicketd-commission-types-versions = { path = "wicketd-commission-types/versions" } x509-cert = { version = "0.2.5", default-features = false } diff --git a/clients/wicketd-commission-client/Cargo.toml b/clients/wicketd-commission-client/Cargo.toml new file mode 100644 index 00000000000..c4a91cd5f7e --- /dev/null +++ b/clients/wicketd-commission-client/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "wicketd-commission-client" +version = "0.1.0" +edition.workspace = true +license = "MPL-2.0" + +[lints] +workspace = true + +[dependencies] +iddqd.workspace = true +omicron-uuid-kinds.workspace = true +oxnet.workspace = true +progenitor.workspace = true +regress.workspace = true +reqwest = { workspace = true, features = ["json", "rustls", "stream"] } +schemars.workspace = true +serde.workspace = true +slog.workspace = true +wicketd-commission-types-versions.workspace = true +omicron-workspace-hack.workspace = true diff --git a/clients/wicketd-commission-client/src/lib.rs b/clients/wicketd-commission-client/src/lib.rs new file mode 100644 index 00000000000..52fe0071639 --- /dev/null +++ b/clients/wicketd-commission-client/src/lib.rs @@ -0,0 +1,100 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +//! Interface for making requests to the wicketd commissioning API. + +progenitor::generate_api!( + spec = "../../openapi/wicketd-commission/wicketd-commission-latest.json", + interface = Positional, + inner_type = slog::Logger, + pre_hook = (|log: &slog::Logger, request: &reqwest::Request| { + slog::debug!(log, "client request"; + "method" => %request.method(), + "uri" => %request.url(), + "body" => ?&request.body(), + ); + }), + post_hook = (|log: &slog::Logger, result: &Result<_, _>| { + slog::debug!(log, "client response"; "result" => ?result); + }), + derives = [schemars::JsonSchema], + crates = { + "iddqd" = "*", + "omicron-uuid-kinds" = "*", + "oxnet" = "0.1.0", + }, + replace = { + AllowedSourceIps = wicketd_commission_types_versions::latest::rack_setup::AllowedSourceIps, + BgpAuthKeyId = wicketd_commission_types_versions::latest::rack_setup::BgpAuthKeyId, + BgpConfig = wicketd_commission_types_versions::latest::rack_setup::BgpConfig, + BootstrapSled = wicketd_commission_types_versions::latest::inventory::BootstrapSled, + Caboose = wicketd_commission_types_versions::latest::inventory::Caboose, + CertificateUploadResponse = wicketd_commission_types_versions::latest::rack_setup::CertificateUploadResponse, + ClearUpdateStateParams = wicketd_commission_types_versions::latest::update::ClearUpdateStateParams, + ClearUpdateStateResponse = wicketd_commission_types_versions::latest::update::ClearUpdateStateResponse, + CmisDatapath = wicketd_commission_types_versions::latest::inventory::CmisDatapath, + CmisDatapathState = wicketd_commission_types_versions::latest::inventory::CmisDatapathState, + CmisLaneStatus = wicketd_commission_types_versions::latest::inventory::CmisLaneStatus, + FaultFlag = wicketd_commission_types_versions::latest::inventory::FaultFlag, + IgnitionFaults = wicketd_commission_types_versions::latest::inventory::IgnitionFaults, + IpRange = wicketd_commission_types_versions::latest::rack_setup::IpRange, + Ipv4Range = wicketd_commission_types_versions::latest::rack_setup::Ipv4Range, + Ipv6Range = wicketd_commission_types_versions::latest::rack_setup::Ipv6Range, + LinkFec = wicketd_commission_types_versions::latest::rack_setup::LinkFec, + LinkSpeed = wicketd_commission_types_versions::latest::rack_setup::LinkSpeed, + LldpAdminStatus = wicketd_commission_types_versions::latest::rack_setup::LldpAdminStatus, + LldpPortConfig = wicketd_commission_types_versions::latest::rack_setup::LldpPortConfig, + LocationInfo = wicketd_commission_types_versions::latest::inventory::LocationInfo, + ManualPortConfig = wicketd_commission_types_versions::latest::rack_setup::ManualPortConfig, + MaxPathConfig = wicketd_commission_types_versions::latest::rack_setup::MaxPathConfig, + PowerState = wicketd_commission_types_versions::latest::inventory::PowerState, + PutRecoveryUserPasswordHash = wicketd_commission_types_versions::latest::rack_setup::PutRecoveryUserPasswordHash, + PutRssUserConfigInsensitive = wicketd_commission_types_versions::latest::rack_setup::PutRssUserConfigInsensitive, + RackOperationStatus = wicketd_commission_types_versions::latest::rack_setup::RackOperationStatus, + RepositoryDescription = wicketd_commission_types_versions::latest::update::RepositoryDescription, + RotInfo = wicketd_commission_types_versions::latest::inventory::RotInfo, + RotSlot = wicketd_commission_types_versions::latest::inventory::RotSlot, + RouteConfig = wicketd_commission_types_versions::latest::rack_setup::RouteConfig, + RouterLifetimeConfig = wicketd_commission_types_versions::latest::rack_setup::RouterLifetimeConfig, + RssStepInfo = wicketd_commission_types_versions::latest::rack_setup::RssStepInfo, + RunningProgress = wicketd_commission_types_versions::latest::update::RunningProgress, + Sff8636LaneFaults = wicketd_commission_types_versions::latest::inventory::Sff8636LaneFaults, + SlotCaboose = wicketd_commission_types_versions::latest::inventory::SlotCaboose, + SpIdentifier = wicketd_commission_types_versions::latest::inventory::SpIdentifier, + SpIgnitionInfo = wicketd_commission_types_versions::latest::inventory::SpIgnitionInfo, + SpInfo = wicketd_commission_types_versions::latest::inventory::SpInfo, + SpInventory = wicketd_commission_types_versions::latest::inventory::SpInventory, + SpInventoryParams = wicketd_commission_types_versions::latest::inventory::SpInventoryParams, + SpStateInfo = wicketd_commission_types_versions::latest::inventory::SpStateInfo, + SpType = wicketd_commission_types_versions::latest::inventory::SpType, + Stage0Caboose = wicketd_commission_types_versions::latest::inventory::Stage0Caboose, + SpUpdateProgress = wicketd_commission_types_versions::latest::update::SpUpdateProgress, + StartUpdateOptions = wicketd_commission_types_versions::latest::update::StartUpdateOptions, + StartUpdateParams = wicketd_commission_types_versions::latest::update::StartUpdateParams, + StepOutcome = wicketd_commission_types_versions::latest::update::StepOutcome, + StepProgress = wicketd_commission_types_versions::latest::update::StepProgress, + SwitchSlot = wicketd_commission_types_versions::latest::inventory::SwitchSlot, + SwitchTransceivers = wicketd_commission_types_versions::latest::inventory::SwitchTransceivers, + Transceiver = wicketd_commission_types_versions::latest::inventory::Transceiver, + TransceiverDatapath = wicketd_commission_types_versions::latest::inventory::TransceiverDatapath, + TransceiverInventory = wicketd_commission_types_versions::latest::inventory::TransceiverInventory, + TransceiverMonitors = wicketd_commission_types_versions::latest::inventory::TransceiverMonitors, + TransceiverStatus = wicketd_commission_types_versions::latest::inventory::TransceiverStatus, + TransceiverVendor = wicketd_commission_types_versions::latest::inventory::TransceiverVendor, + TxEqConfig = wicketd_commission_types_versions::latest::rack_setup::TxEqConfig, + UpdateProgress = wicketd_commission_types_versions::latest::update::UpdateProgress, + UpdateState = wicketd_commission_types_versions::latest::update::UpdateState, + UpdateStep = wicketd_commission_types_versions::latest::update::UpdateStep, + UpdateTargets = wicketd_commission_types_versions::latest::update::UpdateTargets, + UpdateStepStatus = wicketd_commission_types_versions::latest::update::UpdateStepStatus, + UserSpecifiedBgpPeerConfig = wicketd_commission_types_versions::latest::rack_setup::UserSpecifiedBgpPeerConfig, + UserSpecifiedImportExportPolicy = wicketd_commission_types_versions::latest::rack_setup::UserSpecifiedImportExportPolicy, + UserSpecifiedPortConfig = wicketd_commission_types_versions::latest::rack_setup::UserSpecifiedPortConfig, + UserSpecifiedRackNetworkConfig = wicketd_commission_types_versions::latest::rack_setup::UserSpecifiedRackNetworkConfig, + UserSpecifiedRouterPeerAddr = wicketd_commission_types_versions::latest::rack_setup::UserSpecifiedRouterPeerAddr, + UserSpecifiedUplinkAddressConfig = wicketd_commission_types_versions::latest::rack_setup::UserSpecifiedUplinkAddressConfig, + }, +); + +pub type ClientError = crate::Error; diff --git a/common/src/address.rs b/common/src/address.rs index 7848eaf56b7..4fc6483508e 100644 --- a/common/src/address.rs +++ b/common/src/address.rs @@ -248,6 +248,7 @@ pub const DDMD_PORT: u16 = 8000; pub const MGS_PORT: u16 = 12225; pub const WICKETD_PORT: u16 = 12226; pub const BOOTSTRAP_ARTIFACT_PORT: u16 = 12227; +pub const WICKETD_COMMISSION_PORT: u16 = 12234; pub const CRUCIBLE_PANTRY_PORT: u16 = 17000; pub const TFPORTD_PORT: u16 = 12231; pub const NEXUS_INTERNAL_PORT: u16 = 12221; diff --git a/dev-tools/dropshot-apis/Cargo.toml b/dev-tools/dropshot-apis/Cargo.toml index 3418a2b7f08..77f0618aec5 100644 --- a/dev-tools/dropshot-apis/Cargo.toml +++ b/dev-tools/dropshot-apis/Cargo.toml @@ -30,6 +30,7 @@ serde.workspace = true serde_json.workspace = true sled-agent-api.workspace = true wicketd-api.workspace = true +wicketd-commission-api.workspace = true [lints] workspace = true diff --git a/dev-tools/dropshot-apis/src/main.rs b/dev-tools/dropshot-apis/src/main.rs index 5935c05be45..2f2c0f5cdee 100644 --- a/dev-tools/dropshot-apis/src/main.rs +++ b/dev-tools/dropshot-apis/src/main.rs @@ -30,6 +30,7 @@ use repo_depot_api::*; use serde::{Deserialize, Serialize}; use sled_agent_api::*; use wicketd_api::*; +use wicketd_commission_api::*; fn environment() -> anyhow::Result { // The workspace root is two levels up from this crate's directory. @@ -339,6 +340,22 @@ fn all_apis() -> anyhow::Result { api_description: wicketd_api_mod::stub_api_description, ident: "wicketd", }), + ManagedApi::from(ManagedApiConfig { + title: "Wicketd Commission API", + versions: Versions::new_versioned( + wicketd_commission_api::supported_versions(), + ), + metadata: ManagedApiMetadata { + description: Some( + "Stable API for rack commissioning (RFD 710)", + ), + contact_url: Some("https://oxide.computer"), + contact_email: Some("api@oxide.computer"), + extra: to_value(ApiBoundary::External), + }, + api_description: wicketd_commission_api_mod::stub_api_description, + ident: "wicketd-commission", + }), ]; let apis = ManagedApis::new(apis) diff --git a/dev-tools/ls-apis/api-manifest.toml b/dev-tools/ls-apis/api-manifest.toml index d40b5177d40..e40221572ff 100644 --- a/dev-tools/ls-apis/api-manifest.toml +++ b/dev-tools/ls-apis/api-manifest.toml @@ -432,6 +432,17 @@ notes = """ wicketd-client is only used by wicket, which is deployed in a unit with wicketd. """ +[[apis]] +client_package_name = "wicketd-commission-client" +label = "Wicketd Commission" +server_package_name = "wicketd-commission-api" +versioned_how = "server" +notes = """ +Stable rack commissioning API (RFD 710), served by wicketd on its own port. The +production consumer is rkdeploy, which lives outside this workspace and is +not currently modeled by ls-apis. +""" + [[apis]] client_package_name = "crucible-control-client" label = "Crucible Control (for testing only)" diff --git a/dev-tools/ls-apis/tests/api_check.out b/dev-tools/ls-apis/tests/api_check.out index 2f620577cd0..17e266691c7 100644 --- a/dev-tools/ls-apis/tests/api_check.out +++ b/dev-tools/ls-apis/tests/api_check.out @@ -20,6 +20,7 @@ Server-managed APIs: Propolis (propolis-client, exposed by propolis-server) Sled Agent (sled-agent-client, exposed by omicron-sled-agent) Wicketd (wicketd-client, exposed by wicketd) + Wicketd Commission (wicketd-commission-client, exposed by wicketd) Client-managed API: diff --git a/dev-tools/ls-apis/tests/api_dependencies.out b/dev-tools/ls-apis/tests/api_dependencies.out index e0db8fcd1cd..fef6a89c966 100644 --- a/dev-tools/ls-apis/tests/api_dependencies.out +++ b/dev-tools/ls-apis/tests/api_dependencies.out @@ -3,7 +3,7 @@ Bootstrap Agent (client: bootstrap-agent-client) consumed by: wicketd (omicron/wicketd) via 1 path Bootstrap Agent Lockstep API (client: bootstrap-agent-lockstep-client) - consumed by: wicketd (omicron/wicketd) via 2 paths + consumed by: wicketd (omicron/wicketd) via 1 path Clickhouse Cluster Admin for Keepers (client: clickhouse-admin-keeper-client) consumed by: omicron-nexus (omicron/nexus) via 3 paths @@ -108,3 +108,5 @@ Sled Agent (client: sled-agent-client) Wicketd (client: wicketd-client) +Wicketd Commission (client: wicketd-commission-client) + diff --git a/gateway-types/versions/src/initial/component.rs b/gateway-types/versions/src/initial/component.rs index d958995c5c9..de4e256e3fb 100644 --- a/gateway-types/versions/src/initial/component.rs +++ b/gateway-types/versions/src/initial/component.rs @@ -115,6 +115,7 @@ pub struct SpState { Copy, PartialEq, Eq, + Hash, PartialOrd, Ord, Serialize, diff --git a/gateway-types/versions/src/initial/rot.rs b/gateway-types/versions/src/initial/rot.rs index 6e01912fd26..e1cc1735e3f 100644 --- a/gateway-types/versions/src/initial/rot.rs +++ b/gateway-types/versions/src/initial/rot.rs @@ -55,6 +55,7 @@ pub enum RotState { Copy, PartialEq, Eq, + Hash, PartialOrd, Ord, Deserialize, diff --git a/openapi/wicketd-commission/wicketd-commission-1.0.0-71519e.json b/openapi/wicketd-commission/wicketd-commission-1.0.0-71519e.json new file mode 100644 index 00000000000..4a00ed37510 --- /dev/null +++ b/openapi/wicketd-commission/wicketd-commission-1.0.0-71519e.json @@ -0,0 +1,3446 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "Wicketd Commission API", + "description": "Stable API for rack commissioning (RFD 710)", + "contact": { + "url": "https://oxide.computer", + "email": "api@oxide.computer" + }, + "version": "1.0.0" + }, + "paths": { + "/bootstrap-sleds": { + "get": { + "summary": "List all sleds known to wicketd and their bootstrap-network addresses", + "operationId": "get_bootstrap_sleds", + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "title": "IdOrdMap_of_BootstrapSled", + "x-rust-type": { + "crate": "iddqd", + "parameters": [ + { + "$ref": "#/components/schemas/BootstrapSled" + } + ], + "path": "iddqd::IdOrdMap", + "version": "*" + }, + "type": "array", + "items": { + "$ref": "#/components/schemas/BootstrapSled" + }, + "uniqueItems": true + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/clear-update-state": { + "post": { + "summary": "Clear update state for one or more service processors", + "description": "Use this to reset update state after a completed or failed update. This fails with a 400 if any of the targeted service processors are currently being updated. Otherwise, the response reports which targets had update state cleared and which had no update data to clear.", + "operationId": "post_clear_update_state", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ClearUpdateStateParams" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ClearUpdateStateResponse" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/inventory/sps": { + "get": { + "summary": "Get inventory of all service processors visible to wicketd", + "description": "If `force_refresh` is non-empty, the request will wait for the listed SPs to report fresh data, or return a 503 if they do not respond within the server-side timeout.\n\nReturns 400 if the SP is unknown.", + "operationId": "get_sp_inventory", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SpInventoryParams" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SpInventory" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/location": { + "get": { + "summary": "Report the physical location (switch and sled) wicketd is running at", + "description": "Returns 503 if the location is not yet known (typically because wicketd hasn't been able to connect to MGS).", + "operationId": "get_location", + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LocationInfo" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/rack-setup": { + "get": { + "summary": "Query the current state of rack setup", + "operationId": "get_rack_setup_state", + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RackOperationStatus" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "post": { + "summary": "Run rack setup", + "description": "Returns an error if the rack setup configuration has not been fully populated.", + "operationId": "post_run_rack_setup", + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RackInitUuid" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/rack-setup/config": { + "put": { + "summary": "Update (a subset of) the current RSS configuration", + "description": "Sensitive values (certificates and the recovery password hash) are not set through this endpoint.", + "operationId": "put_rss_config", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PutRssUserConfigInsensitive" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "delete": { + "summary": "Reset all RSS configuration to default values", + "operationId": "delete_rss_config", + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/rack-setup/config/cert": { + "post": { + "summary": "Add an external certificate", + "description": "A certificate must be paired with its private key; the two may be posted in either order. Posting a certificate again before the pair completes replaces the pending certificate. Re-uploading a certificate and key that have already been accepted is detected as a duplicate: nothing is stored, and the response is `cert_key_duplicate_ignored`. If validation of a completed cert/key pair fails, both pending halves are discarded and must be re-posted.", + "operationId": "post_rss_config_cert", + "requestBody": { + "content": { + "application/json": { + "schema": { + "title": "String", + "type": "string" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CertificateUploadResponse" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/rack-setup/config/key": { + "post": { + "summary": "Add the private key of an external certificate", + "description": "A private key must be paired with its certificate; the two may be posted in either order. Posting a key again before the pair completes replaces the pending key. Re-uploading a certificate and key that have already been accepted is detected as a duplicate: nothing is stored, and the response is `cert_key_duplicate_ignored`. If validation of a completed cert/key pair fails, both pending halves are discarded and must be re-posted.", + "operationId": "post_rss_config_key", + "requestBody": { + "content": { + "application/json": { + "schema": { + "title": "String", + "type": "string" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CertificateUploadResponse" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/rack-setup/config/recovery-user-password-hash": { + "put": { + "summary": "Set the recovery-silo user password hash", + "operationId": "put_rss_config_recovery_user_password_hash", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PutRecoveryUserPasswordHash" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/repository": { + "get": { + "summary": "Describe the TUF repository currently held by wicketd", + "operationId": "get_repository", + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryDescription" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "put": { + "summary": "Upload a TUF repository to wicketd", + "description": "At any given time, wicketd holds at most one TUF repository, extracted to ephemeral storage; any previously-uploaded repository is discarded. This request is rejected with a 400 while any service-processor update is in progress. A successful upload clears all update progress state, so a subsequent `GET /update-progress` returns an empty list.", + "operationId": "put_repository", + "requestBody": { + "content": { + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/update": { + "post": { + "summary": "Start updating one or more service processors", + "description": "A target that already has update state (even from a successful update) is rejected until that state is cleared with `/clear-update-state` or a new repository is uploaded.", + "operationId": "post_start_update", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StartUpdateParams" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/update-progress": { + "get": { + "summary": "Report the update progress of every service processor with update state", + "operationId": "get_update_progress", + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "title": "IdOrdMap_of_SpUpdateProgress", + "x-rust-type": { + "crate": "iddqd", + "parameters": [ + { + "$ref": "#/components/schemas/SpUpdateProgress" + } + ], + "path": "iddqd::IdOrdMap", + "version": "*" + }, + "type": "array", + "items": { + "$ref": "#/components/schemas/SpUpdateProgress" + }, + "uniqueItems": true + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + } + }, + "components": { + "schemas": { + "AllowedSourceIps": { + "description": "Description of source IPs allowed to reach rack services.", + "oneOf": [ + { + "description": "Allow traffic from any external IP address.", + "type": "object", + "properties": { + "allow": { + "type": "string", + "enum": [ + "any" + ] + } + }, + "required": [ + "allow" + ] + }, + { + "description": "Restrict access to a specific set of source IP addresses or subnets.\n\nAll others are prevented from reaching rack services.", + "type": "object", + "properties": { + "allow": { + "type": "string", + "enum": [ + "list" + ] + }, + "ips": { + "type": "array", + "items": { + "$ref": "#/components/schemas/IpNet" + } + } + }, + "required": [ + "allow", + "ips" + ] + } + ] + }, + "BgpAuthKeyId": { + "description": "The key identifier for authentication to use with a BGP peer.", + "allOf": [ + { + "$ref": "#/components/schemas/Name" + } + ] + }, + "BgpConfig": { + "type": "object", + "properties": { + "asn": { + "description": "The autonomous system number for the BGP configuration.", + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "checker": { + "nullable": true, + "description": "Checker to apply to incoming messages.", + "default": null, + "type": "string" + }, + "max_paths": { + "description": "Maximum number of paths to use when multiple \"best paths\" exist", + "default": 1, + "allOf": [ + { + "$ref": "#/components/schemas/MaxPathConfig" + } + ] + }, + "originate": { + "description": "The set of prefixes for the BGP router to originate.", + "type": "array", + "items": { + "$ref": "#/components/schemas/IpNet" + } + }, + "shaper": { + "nullable": true, + "description": "Shaper to apply to outgoing messages.", + "default": null, + "type": "string" + } + }, + "required": [ + "asn", + "originate" + ] + }, + "BootstrapSled": { + "description": "A sled as seen on the bootstrap network.\n\nA sled is reported here once its service processor's state has been read from MGS. (A populated cubby whose state has not yet been polled is absent until it is.)\n\nA sled's `ip` becomes `Some` once it has been discovered on the bootstrap network; sleds still missing an address report `None`.", + "type": "object", + "properties": { + "id": { + "description": "The service processor for this sled (its type and slot).", + "allOf": [ + { + "$ref": "#/components/schemas/SpIdentifier" + } + ] + }, + "ip": { + "nullable": true, + "description": "The sled's bootstrap-network address, once it has been discovered.", + "type": "string", + "format": "ipv6" + }, + "serial_number": { + "description": "The sled's baseboard serial number.", + "type": "string" + } + }, + "required": [ + "id", + "serial_number" + ] + }, + "Caboose": { + "description": "A minimal projection of a firmware caboose.\n\nOnly the fields the commissioning client needs are included; the remaining caboose fields (git commit, name, epoch, and so on) are intentionally omitted.", + "type": "object", + "properties": { + "board": { + "description": "The board name the firmware was built for.", + "type": "string" + }, + "sign": { + "nullable": true, + "description": "The signer of this firmware image, if the image is signed.\n\nFor a root of trust, this is the hex-encoded hash of the public key used to sign the image; it is `None` for firmware that carries no signature (such as service-processor images). Commissioning uses it to match an active RoT slot against the corresponding signed TUF artifact.", + "type": "string" + }, + "version": { + "description": "The firmware version string.", + "type": "string" + } + }, + "required": [ + "board", + "version" + ] + }, + "CertificateUploadResponse": { + "description": "The result of uploading half of a certificate/key pair.", + "oneOf": [ + { + "description": "The key has been uploaded, but we're waiting on its corresponding certificate chain.", + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "waiting_on_cert" + ] + } + }, + "required": [ + "status" + ] + }, + { + "description": "The cert chain has been uploaded, but we're waiting on its corresponding private key.", + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "waiting_on_key" + ] + } + }, + "required": [ + "status" + ] + }, + { + "description": "A cert chain and its key have been accepted.", + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "cert_key_accepted" + ] + } + }, + "required": [ + "status" + ] + }, + { + "description": "A cert chain and its key are valid, but have already been uploaded.", + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "cert_key_duplicate_ignored" + ] + } + }, + "required": [ + "status" + ] + } + ] + }, + "ClearUpdateStateParams": { + "description": "Parameters for clearing update state.", + "type": "object", + "properties": { + "targets": { + "description": "The service processors to clear update state for.", + "allOf": [ + { + "$ref": "#/components/schemas/UpdateTargets" + } + ] + } + }, + "required": [ + "targets" + ], + "additionalProperties": false + }, + "ClearUpdateStateResponse": { + "description": "Response to an instruction to clear update data.", + "type": "object", + "properties": { + "cleared": { + "description": "The SPs for which update data was cleared.", + "type": "array", + "items": { + "$ref": "#/components/schemas/SpIdentifier" + }, + "uniqueItems": true + }, + "no_update_data": { + "description": "The SPs that had no update state to clear.", + "type": "array", + "items": { + "$ref": "#/components/schemas/SpIdentifier" + }, + "uniqueItems": true + } + }, + "required": [ + "cleared", + "no_update_data" + ] + }, + "CmisDatapath": { + "description": "One active CMIS datapath (application).", + "type": "object", + "properties": { + "application": { + "description": "The application selector code identifying this datapath.", + "type": "integer", + "format": "uint8", + "minimum": 0 + }, + "lanes": { + "title": "IdOrdMap", + "description": "The status of each lane in this datapath, keyed by lane number.", + "x-rust-type": { + "crate": "iddqd", + "parameters": [ + { + "$ref": "#/components/schemas/CmisLaneStatus" + } + ], + "path": "iddqd::IdOrdMap", + "version": "*" + }, + "type": "array", + "items": { + "$ref": "#/components/schemas/CmisLaneStatus" + }, + "uniqueItems": true + } + }, + "required": [ + "application", + "lanes" + ] + }, + "CmisDatapathState": { + "description": "The datapath state of a CMIS lane.\n\nThis mirrors the CMIS datapath state machine (CMIS 5.0 section 8.9.1).", + "oneOf": [ + { + "description": "The datapath is deactivated.", + "type": "string", + "enum": [ + "deactivated" + ] + }, + { + "description": "The datapath is initializing.", + "type": "string", + "enum": [ + "init" + ] + }, + { + "description": "The datapath is deinitializing.", + "type": "string", + "enum": [ + "deinit" + ] + }, + { + "description": "The datapath is activated.", + "type": "string", + "enum": [ + "activated" + ] + }, + { + "description": "The transmitter is turning on.", + "type": "string", + "enum": [ + "tx_turn_on" + ] + }, + { + "description": "The transmitter is turning off.", + "type": "string", + "enum": [ + "tx_turn_off" + ] + }, + { + "description": "The datapath is initialized.", + "type": "string", + "enum": [ + "initialized" + ] + } + ] + }, + "CmisLaneStatus": { + "description": "The status of one CMIS lane.", + "type": "object", + "properties": { + "lane": { + "description": "The lane number within the module.", + "type": "integer", + "format": "uint8", + "minimum": 0 + }, + "rx_lol": { + "description": "Media-side (receive) loss of lock.", + "allOf": [ + { + "$ref": "#/components/schemas/FaultFlag" + } + ] + }, + "rx_los": { + "description": "Media-side (receive) loss of signal.", + "allOf": [ + { + "$ref": "#/components/schemas/FaultFlag" + } + ] + }, + "state": { + "description": "The datapath state of this lane (CMIS 5.0 section 8.9.1).", + "allOf": [ + { + "$ref": "#/components/schemas/CmisDatapathState" + } + ] + }, + "tx_fault": { + "description": "A fault in the transmitter and/or laser.\n\nCMIS calls this `TxFailure`; it is named `tx_fault` here for cross-spec consistency with SFF-8636.", + "allOf": [ + { + "$ref": "#/components/schemas/FaultFlag" + } + ] + }, + "tx_lol": { + "description": "Host-side (transmit) loss of lock.", + "allOf": [ + { + "$ref": "#/components/schemas/FaultFlag" + } + ] + }, + "tx_los": { + "description": "Host-side (transmit) loss of signal.", + "allOf": [ + { + "$ref": "#/components/schemas/FaultFlag" + } + ] + } + }, + "required": [ + "lane", + "rx_lol", + "rx_los", + "state", + "tx_fault", + "tx_lol", + "tx_los" + ] + }, + "Duration": { + "type": "object", + "properties": { + "nanos": { + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "secs": { + "type": "integer", + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "nanos", + "secs" + ] + }, + "Error": { + "description": "Error information from a response.", + "type": "object", + "properties": { + "error_code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "request_id": { + "type": "string" + } + }, + "required": [ + "message", + "request_id" + ] + }, + "FaultFlag": { + "description": "Whether a lane fault flag is asserted.\n\nSFF-8636 modules always report every flag, but a CMIS module may not support reporting a given flag. `Unsupported` distinguishes \"the module does not report this flag\" from a definite `Clear`.", + "oneOf": [ + { + "description": "The fault flag is asserted.", + "type": "string", + "enum": [ + "asserted" + ] + }, + { + "description": "The fault flag is not asserted.", + "type": "string", + "enum": [ + "clear" + ] + }, + { + "description": "The module does not support reporting this flag.", + "type": "string", + "enum": [ + "unsupported" + ] + } + ] + }, + "IgnitionFaults": { + "description": "The faults ignition reports for a present service processor.", + "type": "object", + "properties": { + "a2": { + "description": "The A2 power fault.", + "type": "boolean" + }, + "a3": { + "description": "The A3 power fault.", + "type": "boolean" + }, + "rot": { + "description": "The root-of-trust fault.", + "type": "boolean" + }, + "sp": { + "description": "The service-processor fault.", + "type": "boolean" + } + }, + "required": [ + "a2", + "a3", + "rot", + "sp" + ] + }, + "IpNet": { + "x-rust-type": { + "crate": "oxnet", + "path": "oxnet::IpNet", + "version": "0.1.0" + }, + "oneOf": [ + { + "title": "v4", + "allOf": [ + { + "$ref": "#/components/schemas/Ipv4Net" + } + ] + }, + { + "title": "v6", + "allOf": [ + { + "$ref": "#/components/schemas/Ipv6Net" + } + ] + } + ] + }, + "IpRange": { + "oneOf": [ + { + "title": "v4", + "allOf": [ + { + "$ref": "#/components/schemas/Ipv4Range" + } + ] + }, + { + "title": "v6", + "allOf": [ + { + "$ref": "#/components/schemas/Ipv6Range" + } + ] + } + ] + }, + "Ipv4Net": { + "example": "192.168.1.0/24", + "title": "An IPv4 subnet", + "description": "An IPv4 subnet, including prefix and prefix length", + "x-rust-type": { + "crate": "oxnet", + "path": "oxnet::Ipv4Net", + "version": "0.1.0" + }, + "type": "string", + "pattern": "^(([0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])\\.){3}([0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])/([0-9]|1[0-9]|2[0-9]|3[0-2])$" + }, + "Ipv4Range": { + "description": "A non-decreasing IPv4 address range, inclusive of both ends.\n\nThe first address must be less than or equal to the last address.", + "type": "object", + "properties": { + "first": { + "type": "string", + "format": "ipv4" + }, + "last": { + "type": "string", + "format": "ipv4" + } + }, + "required": [ + "first", + "last" + ] + }, + "Ipv6Net": { + "example": "fd12:3456::/64", + "title": "An IPv6 subnet", + "description": "An IPv6 subnet, including prefix and subnet mask", + "x-rust-type": { + "crate": "oxnet", + "path": "oxnet::Ipv6Net", + "version": "0.1.0" + }, + "type": "string", + "pattern": "^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8])$" + }, + "Ipv6Range": { + "description": "A non-decreasing IPv6 address range, inclusive of both ends.\n\nThe first address must be less than or equal to the last address.", + "type": "object", + "properties": { + "first": { + "type": "string", + "format": "ipv6" + }, + "last": { + "type": "string", + "format": "ipv6" + } + }, + "required": [ + "first", + "last" + ] + }, + "LinkFec": { + "description": "The forward error correction mode of a link.", + "oneOf": [ + { + "description": "Firecode forward error correction.", + "type": "string", + "enum": [ + "firecode" + ] + }, + { + "description": "No forward error correction.", + "type": "string", + "enum": [ + "none" + ] + }, + { + "description": "Reed-Solomon forward error correction.", + "type": "string", + "enum": [ + "rs" + ] + } + ] + }, + "LinkSpeed": { + "description": "The speed of a link.", + "oneOf": [ + { + "description": "Zero gigabits per second.", + "type": "string", + "enum": [ + "speed0_g" + ] + }, + { + "description": "1 gigabit per second.", + "type": "string", + "enum": [ + "speed1_g" + ] + }, + { + "description": "10 gigabits per second.", + "type": "string", + "enum": [ + "speed10_g" + ] + }, + { + "description": "25 gigabits per second.", + "type": "string", + "enum": [ + "speed25_g" + ] + }, + { + "description": "40 gigabits per second.", + "type": "string", + "enum": [ + "speed40_g" + ] + }, + { + "description": "50 gigabits per second.", + "type": "string", + "enum": [ + "speed50_g" + ] + }, + { + "description": "100 gigabits per second.", + "type": "string", + "enum": [ + "speed100_g" + ] + }, + { + "description": "200 gigabits per second.", + "type": "string", + "enum": [ + "speed200_g" + ] + }, + { + "description": "400 gigabits per second.", + "type": "string", + "enum": [ + "speed400_g" + ] + } + ] + }, + "LldpAdminStatus": { + "description": "To what extent should this port participate in LLDP", + "type": "string", + "enum": [ + "enabled", + "disabled", + "rx_only", + "tx_only" + ] + }, + "LldpPortConfig": { + "description": "Per-port LLDP configuration settings. Only the \"status\" setting is mandatory. All other fields have natural defaults or may be inherited from the switch.", + "type": "object", + "properties": { + "chassis_id": { + "nullable": true, + "description": "Chassis ID to advertise. If this is set, it will be advertised as a LocallyAssigned ID type. If this is not set, it will be inherited from the switch-level settings.", + "type": "string" + }, + "management_addrs": { + "nullable": true, + "description": "Management IP addresses to advertise. If this is not set, it will be inherited from the switch-level settings.", + "type": "array", + "items": { + "type": "string", + "format": "ip" + } + }, + "port_description": { + "nullable": true, + "description": "Port description to advertise. If this is not set, no description will be advertised.", + "type": "string" + }, + "port_id": { + "nullable": true, + "description": "Port ID to advertise. If this is set, it will be advertised as a LocallyAssigned ID type. If this is not set, it will be set to the port name. e.g., qsfp0/0.", + "type": "string" + }, + "status": { + "description": "To what extent should this port participate in LLDP", + "allOf": [ + { + "$ref": "#/components/schemas/LldpAdminStatus" + } + ] + }, + "system_description": { + "nullable": true, + "description": "System description to advertise. If this is not set, it will be inherited from the switch-level settings.", + "type": "string" + }, + "system_name": { + "nullable": true, + "description": "System name to advertise. If this is not set, it will be inherited from the switch-level settings.", + "type": "string" + } + }, + "required": [ + "status" + ] + }, + "LocationInfo": { + "description": "The physical location of the sled wicketd is running on.", + "type": "object", + "properties": { + "sled_serial": { + "nullable": true, + "description": "The serial number of the sled wicketd is running on, if known.", + "type": "string" + }, + "switch_serial": { + "nullable": true, + "description": "The serial number of that switch's service processor, if known.", + "type": "string" + }, + "switch_slot": { + "description": "The slot of the switch this sled is cabled to.", + "allOf": [ + { + "$ref": "#/components/schemas/SwitchSlot" + } + ] + } + }, + "required": [ + "switch_slot" + ] + }, + "ManualPortConfig": { + "description": "User-specified per-port configuration.\n\nThis contains all of the fields of a port configuration other than the port name, which is used as the map key.", + "type": "object", + "properties": { + "addresses": { + "description": "Addresses configured on this port.", + "type": "array", + "items": { + "$ref": "#/components/schemas/UserSpecifiedUplinkAddressConfig" + } + }, + "autoneg": { + "description": "Whether autonegotiation is enabled.", + "type": "boolean" + }, + "bgp_peers": { + "description": "BGP peers reachable on this port.", + "default": [], + "type": "array", + "items": { + "$ref": "#/components/schemas/UserSpecifiedBgpPeerConfig" + } + }, + "lldp": { + "nullable": true, + "description": "LLDP configuration for this port.", + "default": null, + "allOf": [ + { + "$ref": "#/components/schemas/LldpPortConfig" + } + ] + }, + "routes": { + "description": "Static routes for this port.", + "type": "array", + "items": { + "$ref": "#/components/schemas/RouteConfig" + } + }, + "tx_eq": { + "nullable": true, + "description": "Transmit equalization overrides for this port.", + "default": null, + "allOf": [ + { + "$ref": "#/components/schemas/TxEqConfig" + } + ] + }, + "uplink_port_fec": { + "nullable": true, + "description": "The forward error correction mode, if any.", + "allOf": [ + { + "$ref": "#/components/schemas/LinkFec" + } + ] + }, + "uplink_port_speed": { + "description": "The port speed.", + "allOf": [ + { + "$ref": "#/components/schemas/LinkSpeed" + } + ] + } + }, + "required": [ + "addresses", + "autoneg", + "routes", + "uplink_port_speed" + ], + "additionalProperties": false + }, + "MaxPathConfig": { + "type": "integer", + "format": "uint8", + "minimum": 1, + "maximum": 32 + }, + "Name": { + "title": "A name unique within the parent collection", + "description": "Names must begin with a lower case ASCII letter, be composed exclusively of lowercase ASCII, uppercase ASCII, numbers, and '-', and may not end with a '-'. Names cannot be a UUID, but they may contain a UUID. They can be at most 63 characters long.", + "type": "string", + "pattern": "^(?![0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$)^[a-z]([a-zA-Z0-9-]*[a-zA-Z0-9]+)?$", + "minLength": 1, + "maxLength": 63 + }, + "PowerState": { + "description": "See RFD 81.\n\nThis enum only lists power states the SP is able to control; higher power states are controlled by ignition.", + "type": "string", + "enum": [ + "A0", + "A1", + "A2" + ] + }, + "PutRecoveryUserPasswordHash": { + "description": "The body of a request to set the recovery-silo user password hash.", + "type": "object", + "properties": { + "hash": { + "description": "The password hash, in PHC string format.", + "type": "string" + } + }, + "required": [ + "hash" + ], + "additionalProperties": false + }, + "PutRssUserConfigInsensitive": { + "description": "The portion of the RSS configuration that can be posted in one shot.\n\nIt is provided by the operator uploading a TOML file. Sensitive values (certificates and the recovery password hash) are set separately.", + "type": "object", + "properties": { + "allowed_source_ips": { + "description": "IPs or subnets allowed to make requests to user-facing services.", + "allOf": [ + { + "$ref": "#/components/schemas/AllowedSourceIps" + } + ] + }, + "bootstrap_sleds": { + "description": "The slot numbers of the sleds to bring up during RSS.\n\nwicketd maps these back to sleds with the correct identifiers based on the bootstrap sleds it reports.", + "type": "array", + "items": { + "type": "integer", + "format": "uint16", + "minimum": 0 + }, + "uniqueItems": true + }, + "dns_servers": { + "description": "The external DNS server addresses.", + "type": "array", + "items": { + "type": "string", + "format": "ip" + } + }, + "external_dns_ips": { + "description": "Service IP addresses on which external DNS servers are run.", + "type": "array", + "items": { + "type": "string", + "format": "ip" + } + }, + "external_dns_zone_name": { + "description": "The DNS zone name delegated to the rack for external DNS.", + "type": "string" + }, + "external_jumbo_frames_opt_in_enabled": { + "description": "Enable the fleet-wide jumbo-frames opt-in.", + "default": false, + "type": "boolean" + }, + "internal_services_ip_pool_ranges": { + "description": "Ranges of the service IP pool which may be used for internal services.", + "type": "array", + "items": { + "$ref": "#/components/schemas/IpRange" + } + }, + "ntp_servers": { + "description": "The external NTP server addresses.", + "type": "array", + "items": { + "type": "string" + } + }, + "rack_network_config": { + "description": "The user-specified rack network configuration.", + "allOf": [ + { + "$ref": "#/components/schemas/UserSpecifiedRackNetworkConfig" + } + ] + } + }, + "required": [ + "allowed_source_ips", + "bootstrap_sleds", + "dns_servers", + "external_dns_ips", + "external_dns_zone_name", + "internal_services_ip_pool_ranges", + "ntp_servers", + "rack_network_config" + ], + "additionalProperties": false + }, + "RackInitUuid": { + "x-rust-type": { + "crate": "omicron-uuid-kinds", + "path": "omicron_uuid_kinds::RackInitUuid", + "version": "*" + }, + "type": "string", + "format": "uuid" + }, + "RackOperationStatus": { + "description": "The current status of any rack-level operation being performed.", + "oneOf": [ + { + "description": "Rack initialization is in progress.", + "type": "object", + "properties": { + "id": { + "description": "The ID of the initialization operation.", + "allOf": [ + { + "$ref": "#/components/schemas/RackInitUuid" + } + ] + }, + "status": { + "type": "string", + "enum": [ + "initializing" + ] + }, + "step": { + "description": "Information about the current step.", + "allOf": [ + { + "$ref": "#/components/schemas/RssStepInfo" + } + ] + } + }, + "required": [ + "id", + "status", + "step" + ] + }, + { + "description": "The rack is initialized. `id` is `None` if the rack was already initialized on startup.", + "type": "object", + "properties": { + "id": { + "nullable": true, + "description": "The ID of the initialization operation, if one was performed.", + "allOf": [ + { + "$ref": "#/components/schemas/RackInitUuid" + } + ] + }, + "status": { + "type": "string", + "enum": [ + "initialized" + ] + } + }, + "required": [ + "status" + ] + }, + { + "description": "Rack initialization failed.", + "type": "object", + "properties": { + "id": { + "description": "The ID of the initialization operation.", + "allOf": [ + { + "$ref": "#/components/schemas/RackInitUuid" + } + ] + }, + "message": { + "description": "A message describing the failure.", + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "initialization_failed" + ] + } + }, + "required": [ + "id", + "message", + "status" + ] + }, + { + "description": "Rack initialization panicked.", + "type": "object", + "properties": { + "id": { + "description": "The ID of the initialization operation.", + "allOf": [ + { + "$ref": "#/components/schemas/RackInitUuid" + } + ] + }, + "status": { + "type": "string", + "enum": [ + "initialization_panicked" + ] + } + }, + "required": [ + "id", + "status" + ] + }, + { + "description": "The rack is being reset.", + "type": "object", + "properties": { + "id": { + "description": "The ID of the reset operation.", + "allOf": [ + { + "$ref": "#/components/schemas/RackResetUuid" + } + ] + }, + "status": { + "type": "string", + "enum": [ + "resetting" + ] + } + }, + "required": [ + "id", + "status" + ] + }, + { + "description": "The rack is uninitialized. `reset_id` is `None` if it was uninitialized on startup, or `Some` if a reset operation completed.", + "type": "object", + "properties": { + "reset_id": { + "nullable": true, + "description": "The ID of the reset operation, if one was performed.", + "allOf": [ + { + "$ref": "#/components/schemas/RackResetUuid" + } + ] + }, + "status": { + "type": "string", + "enum": [ + "uninitialized" + ] + } + }, + "required": [ + "status" + ] + }, + { + "description": "Rack reset failed.", + "type": "object", + "properties": { + "id": { + "description": "The ID of the reset operation.", + "allOf": [ + { + "$ref": "#/components/schemas/RackResetUuid" + } + ] + }, + "message": { + "description": "A message describing the failure.", + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "reset_failed" + ] + } + }, + "required": [ + "id", + "message", + "status" + ] + }, + { + "description": "Rack reset panicked.", + "type": "object", + "properties": { + "id": { + "description": "The ID of the reset operation.", + "allOf": [ + { + "$ref": "#/components/schemas/RackResetUuid" + } + ] + }, + "status": { + "type": "string", + "enum": [ + "reset_panicked" + ] + } + }, + "required": [ + "id", + "status" + ] + } + ] + }, + "RackResetUuid": { + "x-rust-type": { + "crate": "omicron-uuid-kinds", + "path": "omicron_uuid_kinds::RackResetUuid", + "version": "*" + }, + "type": "string", + "format": "uuid" + }, + "RepositoryDescription": { + "description": "A description of the TUF repository currently held by wicketd.", + "type": "object", + "properties": { + "system_version": { + "nullable": true, + "description": "The system version of the uploaded TUF repository, if one is present.", + "type": "string", + "pattern": "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\\+([0-9a-zA-Z-]+(?:\\.[0-9a-zA-Z-]+)*))?$" + } + } + }, + "RotInfo": { + "description": "Root-of-trust information for a single service processor.", + "type": "object", + "properties": { + "active": { + "description": "The currently-active RoT image slot.", + "allOf": [ + { + "$ref": "#/components/schemas/RotSlot" + } + ] + }, + "caboose_a": { + "description": "The caboose of the image in slot A.", + "allOf": [ + { + "$ref": "#/components/schemas/SlotCaboose" + } + ] + }, + "caboose_b": { + "description": "The caboose of the image in slot B.", + "allOf": [ + { + "$ref": "#/components/schemas/SlotCaboose" + } + ] + }, + "caboose_stage0": { + "description": "The caboose of the stage0 bootloader.", + "allOf": [ + { + "$ref": "#/components/schemas/Stage0Caboose" + } + ] + }, + "caboose_stage0next": { + "description": "The caboose of the pending stage0next bootloader.", + "allOf": [ + { + "$ref": "#/components/schemas/Stage0Caboose" + } + ] + } + }, + "required": [ + "active", + "caboose_a", + "caboose_b", + "caboose_stage0", + "caboose_stage0next" + ] + }, + "RotSlot": { + "oneOf": [ + { + "type": "object", + "properties": { + "slot": { + "type": "string", + "enum": [ + "a" + ] + } + }, + "required": [ + "slot" + ] + }, + { + "type": "object", + "properties": { + "slot": { + "type": "string", + "enum": [ + "b" + ] + } + }, + "required": [ + "slot" + ] + } + ] + }, + "RouteConfig": { + "type": "object", + "properties": { + "destination": { + "description": "The destination of the route.", + "allOf": [ + { + "$ref": "#/components/schemas/IpNet" + } + ] + }, + "nexthop": { + "description": "The nexthop/gateway address.", + "type": "string", + "format": "ip" + }, + "rib_priority": { + "nullable": true, + "description": "The RIB priority (i.e. Admin Distance) associated with this route.", + "default": null, + "type": "integer", + "format": "uint8", + "minimum": 0 + }, + "vlan_id": { + "nullable": true, + "description": "The VLAN id associated with this route.", + "default": null, + "type": "integer", + "format": "uint16", + "minimum": 0 + } + }, + "required": [ + "destination", + "nexthop" + ] + }, + "RouterLifetimeConfig": { + "description": "Router lifetime in seconds for unnumbered BGP peers", + "type": "integer", + "format": "uint16", + "minimum": 0, + "maximum": 9000 + }, + "RssStepInfo": { + "description": "Information about the current RSS step.", + "type": "object", + "properties": { + "description": { + "description": "A human-readable description of the current step.", + "type": "string" + }, + "step": { + "description": "The 1-based index of the current step.", + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "total_steps": { + "description": "The total number of RSS steps.", + "type": "integer", + "format": "uint32", + "minimum": 0 + } + }, + "required": [ + "description", + "step", + "total_steps" + ] + }, + "RunningProgress": { + "description": "The most recent progress reported by a running step.", + "oneOf": [ + { + "description": "The update engine has not received any progress message for this step attempt yet.\n\nThe engine reports this immediately after a step starts, and again after a retry or progress reset. It does not indicate a stall or timeout.", + "type": "object", + "properties": { + "state": { + "type": "string", + "enum": [ + "waiting_for_progress" + ] + } + }, + "required": [ + "state" + ] + }, + { + "description": "The step reported progress.\n\n`progress` carries the counter if the report included one.", + "type": "object", + "properties": { + "progress": { + "nullable": true, + "description": "The reported progress counter, if the report included one.", + "allOf": [ + { + "$ref": "#/components/schemas/StepProgress" + } + ] + }, + "state": { + "type": "string", + "enum": [ + "counter" + ] + } + }, + "required": [ + "state" + ] + } + ] + }, + "Sff8636LaneFaults": { + "description": "The fault flags for one SFF-8636 lane.\n\nSFF-8636 always reports all five flags, so these are plain booleans.", + "type": "object", + "properties": { + "rx_lol": { + "description": "Media-side (receive) loss of lock.", + "type": "boolean" + }, + "rx_los": { + "description": "Media-side (receive) loss of signal.", + "type": "boolean" + }, + "tx_fault": { + "description": "A fault in the transmitter and/or laser.", + "type": "boolean" + }, + "tx_lol": { + "description": "Host-side (transmit) loss of lock.", + "type": "boolean" + }, + "tx_los": { + "description": "Host-side (transmit) loss of signal.", + "type": "boolean" + } + }, + "required": [ + "rx_lol", + "rx_los", + "tx_fault", + "tx_lol", + "tx_los" + ] + }, + "SlotCaboose": { + "description": "The caboose for a single firmware slot, including whether it was read.", + "oneOf": [ + { + "description": "The caboose for this slot has not been read yet.", + "type": "object", + "properties": { + "state": { + "type": "string", + "enum": [ + "not_read" + ] + } + }, + "required": [ + "state" + ] + }, + { + "description": "The caboose for this slot was read.", + "type": "object", + "properties": { + "caboose": { + "description": "The caboose that was read.", + "allOf": [ + { + "$ref": "#/components/schemas/Caboose" + } + ] + }, + "state": { + "type": "string", + "enum": [ + "read" + ] + } + }, + "required": [ + "caboose", + "state" + ] + } + ] + }, + "SpIdentifier": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "format": "uint16", + "minimum": 0 + }, + "type": { + "$ref": "#/components/schemas/SpType" + } + }, + "required": [ + "slot", + "type" + ] + }, + "SpIgnitionInfo": { + "description": "The ignition state of a service processor.", + "oneOf": [ + { + "description": "The ignition state has not been read yet.", + "type": "object", + "properties": { + "state": { + "type": "string", + "enum": [ + "not_read" + ] + } + }, + "required": [ + "state" + ] + }, + { + "description": "Ignition reports the service processor as present.", + "type": "object", + "properties": { + "faults": { + "description": "The faults ignition reports for the service processor.", + "allOf": [ + { + "$ref": "#/components/schemas/IgnitionFaults" + } + ] + }, + "power": { + "description": "Whether the service processor is powered on.", + "type": "boolean" + }, + "state": { + "type": "string", + "enum": [ + "present" + ] + } + }, + "required": [ + "faults", + "power", + "state" + ] + }, + { + "description": "Ignition reports the service processor as absent.", + "type": "object", + "properties": { + "state": { + "type": "string", + "enum": [ + "absent" + ] + } + }, + "required": [ + "state" + ] + } + ] + }, + "SpInfo": { + "description": "A single service processor's inventory.", + "type": "object", + "properties": { + "caboose_active": { + "description": "The caboose of the active service-processor firmware slot.", + "allOf": [ + { + "$ref": "#/components/schemas/SlotCaboose" + } + ] + }, + "caboose_inactive": { + "description": "The caboose of the inactive service-processor firmware slot.", + "allOf": [ + { + "$ref": "#/components/schemas/SlotCaboose" + } + ] + }, + "id": { + "description": "Identifies the service processor by type and slot.", + "allOf": [ + { + "$ref": "#/components/schemas/SpIdentifier" + } + ] + }, + "ignition": { + "description": "The ignition state of this service processor.", + "allOf": [ + { + "$ref": "#/components/schemas/SpIgnitionInfo" + } + ] + }, + "rot": { + "nullable": true, + "description": "Root-of-trust information, if it has been read.", + "allOf": [ + { + "$ref": "#/components/schemas/RotInfo" + } + ] + }, + "state": { + "nullable": true, + "description": "The service processor's state, if it has been read.", + "allOf": [ + { + "$ref": "#/components/schemas/SpStateInfo" + } + ] + } + }, + "required": [ + "caboose_active", + "caboose_inactive", + "id", + "ignition" + ] + }, + "SpInventory": { + "description": "Inventory across all service processors.\n\nThis type cannot derive `Eq` because transceiver optical-power readings are floating-point.", + "type": "object", + "properties": { + "mgs_last_seen": { + "description": "How long it has been since wicketd last heard from MGS.\n\nA large value indicates that MGS might be down.", + "allOf": [ + { + "$ref": "#/components/schemas/Duration" + } + ] + }, + "sps": { + "title": "IdOrdMap", + "description": "The inventory of each SP.", + "x-rust-type": { + "crate": "iddqd", + "parameters": [ + { + "$ref": "#/components/schemas/SpInfo" + } + ], + "path": "iddqd::IdOrdMap", + "version": "*" + }, + "type": "array", + "items": { + "$ref": "#/components/schemas/SpInfo" + }, + "uniqueItems": true + }, + "transceivers": { + "description": "The switch transceiver (optical module) inventory.", + "allOf": [ + { + "$ref": "#/components/schemas/TransceiverInventory" + } + ] + } + }, + "required": [ + "mgs_last_seen", + "sps", + "transceivers" + ] + }, + "SpInventoryParams": { + "description": "Parameters for the SP inventory endpoint.", + "type": "object", + "properties": { + "force_refresh": { + "description": "Refresh the state of these service processors from MGS before returning, rather than returning their cached state. Service processors not listed here are returned from the cache.", + "default": [], + "type": "array", + "items": { + "$ref": "#/components/schemas/SpIdentifier" + } + } + }, + "additionalProperties": false + }, + "SpStateInfo": { + "description": "The service processor's state, read together from MGS.\n\nThe serial number and power state are always read together from the same service-processor state, so they are presented as a single unit that is either entirely present or entirely absent.", + "type": "object", + "properties": { + "power_state": { + "description": "The host power state.", + "allOf": [ + { + "$ref": "#/components/schemas/PowerState" + } + ] + }, + "serial_number": { + "description": "The service processor's serial number.", + "type": "string" + } + }, + "required": [ + "power_state", + "serial_number" + ] + }, + "SpType": { + "type": "string", + "enum": [ + "sled", + "power", + "switch" + ] + }, + "SpUpdateProgress": { + "description": "Update progress for a single service processor.\n\nAn SP's update progress is only provided once an update has been started for it.", + "type": "object", + "properties": { + "progress": { + "description": "The update progress for that service processor.", + "allOf": [ + { + "$ref": "#/components/schemas/UpdateProgress" + } + ] + }, + "sp": { + "description": "The service processor this progress describes.", + "allOf": [ + { + "$ref": "#/components/schemas/SpIdentifier" + } + ] + } + }, + "required": [ + "progress", + "sp" + ] + }, + "Stage0Caboose": { + "description": "The stage0 (or pending stage0next) bootloader caboose for a root of trust.\n\nThe three states are kept distinct because they mean different things to a caller:\n\n* `unsupported`: this RoT version does not report a stage0 bootloader caboose at all. * `not_read`: the RoT version supports reporting a stage0 caboose, but it has not been read yet. * `read`: the stage0 caboose was read.", + "oneOf": [ + { + "description": "This RoT version does not report a stage0 bootloader caboose.", + "type": "object", + "properties": { + "state": { + "type": "string", + "enum": [ + "unsupported" + ] + } + }, + "required": [ + "state" + ] + }, + { + "description": "The stage0 bootloader caboose is supported but has not been read yet.", + "type": "object", + "properties": { + "state": { + "type": "string", + "enum": [ + "not_read" + ] + } + }, + "required": [ + "state" + ] + }, + { + "description": "The stage0 bootloader caboose was read.", + "type": "object", + "properties": { + "caboose": { + "description": "The caboose that was read.", + "allOf": [ + { + "$ref": "#/components/schemas/Caboose" + } + ] + }, + "state": { + "type": "string", + "enum": [ + "read" + ] + } + }, + "required": [ + "caboose", + "state" + ] + } + ] + }, + "StartUpdateOptions": { + "description": "Options controlling how an update is performed.", + "type": "object", + "properties": { + "skip_rot_bootloader_version_check": { + "description": "If true, skip the check on the current RoT bootloader version and always update it regardless of whether the update appears to be needed.", + "default": false, + "type": "boolean" + }, + "skip_rot_version_check": { + "description": "If true, skip the check on the current RoT version and always update it regardless of whether the update appears to be needed.", + "default": false, + "type": "boolean" + }, + "skip_sp_version_check": { + "description": "If true, skip the check on the current SP version and always update it regardless of whether the update appears to be needed.", + "default": false, + "type": "boolean" + } + }, + "additionalProperties": false + }, + "StartUpdateParams": { + "description": "Parameters for starting an update.", + "type": "object", + "properties": { + "options": { + "description": "Options controlling the update.", + "allOf": [ + { + "$ref": "#/components/schemas/StartUpdateOptions" + } + ] + }, + "targets": { + "description": "The service processors to update.", + "allOf": [ + { + "$ref": "#/components/schemas/UpdateTargets" + } + ] + } + }, + "required": [ + "options", + "targets" + ], + "additionalProperties": false + }, + "StepOutcome": { + "description": "The outcome of a step that ran to completion.", + "oneOf": [ + { + "description": "The step succeeded, optionally with a message.", + "type": "object", + "properties": { + "message": { + "nullable": true, + "description": "An optional message describing the success.", + "type": "string" + }, + "outcome": { + "type": "string", + "enum": [ + "success" + ] + } + }, + "required": [ + "outcome" + ] + }, + { + "description": "The step succeeded but produced a warning.", + "type": "object", + "properties": { + "message": { + "description": "The warning message.", + "type": "string" + }, + "outcome": { + "type": "string", + "enum": [ + "warning" + ] + } + }, + "required": [ + "message", + "outcome" + ] + }, + { + "description": "The step was skipped.", + "type": "object", + "properties": { + "message": { + "description": "The message describing why the step was skipped.", + "type": "string" + }, + "outcome": { + "type": "string", + "enum": [ + "skipped" + ] + } + }, + "required": [ + "message", + "outcome" + ] + } + ] + }, + "StepProgress": { + "description": "A step's progress towards completion.", + "type": "object", + "properties": { + "current": { + "description": "The current progress value.", + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "total": { + "nullable": true, + "description": "The total value this progress counts towards, if known.", + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "units": { + "description": "The units of `current` and `total` (for example, \"bytes\").", + "type": "string" + } + }, + "required": [ + "current", + "units" + ] + }, + "SwitchSlot": { + "description": "Identifies switch physical location", + "oneOf": [ + { + "description": "Switch in upper slot", + "type": "string", + "enum": [ + "switch0" + ] + }, + { + "description": "Switch in lower slot", + "type": "string", + "enum": [ + "switch1" + ] + } + ] + }, + "SwitchTransceivers": { + "description": "The transceivers in a single switch.", + "type": "object", + "properties": { + "switch": { + "description": "The switch these transceivers belong to.", + "allOf": [ + { + "$ref": "#/components/schemas/SwitchSlot" + } + ] + }, + "transceivers": { + "title": "IdOrdMap", + "description": "The transceivers in this switch, keyed by front port.", + "x-rust-type": { + "crate": "iddqd", + "parameters": [ + { + "$ref": "#/components/schemas/Transceiver" + } + ], + "path": "iddqd::IdOrdMap", + "version": "*" + }, + "type": "array", + "items": { + "$ref": "#/components/schemas/Transceiver" + }, + "uniqueItems": true + } + }, + "required": [ + "switch", + "transceivers" + ] + }, + "Transceiver": { + "description": "A single transceiver (optical module) in a switch front port.\n\nThe four categories of state (`status`, `vendor`, `monitors`, and `datapath`) are read independently, so each can be reported or fail on its own.", + "type": "object", + "properties": { + "datapath": { + "description": "Per-lane datapath fault state.", + "allOf": [ + { + "$ref": "#/components/schemas/TransceiverDatapath" + } + ] + }, + "monitors": { + "description": "Optical power monitors.", + "allOf": [ + { + "$ref": "#/components/schemas/TransceiverMonitors" + } + ] + }, + "port": { + "description": "The front port the transceiver sits in (for example, `qsfp0`).", + "type": "string" + }, + "status": { + "description": "Module presence and power status.", + "allOf": [ + { + "$ref": "#/components/schemas/TransceiverStatus" + } + ] + }, + "vendor": { + "description": "Vendor identification.", + "allOf": [ + { + "$ref": "#/components/schemas/TransceiverVendor" + } + ] + } + }, + "required": [ + "datapath", + "monitors", + "port", + "status", + "vendor" + ] + }, + "TransceiverDatapath": { + "description": "The per-lane datapath fault state of a transceiver module.\n\nSFF-8636 and CMIS modules describe their datapaths very differently, so this projection preserves each spec's native structure rather than flattening both into a single lane list. Clients can aggregate faults across both specs using the `iter_lane_faults` helper on this type.", + "oneOf": [ + { + "description": "The datapath state could not be read.", + "type": "object", + "properties": { + "message": { + "description": "The error encountered while reading the datapath state.", + "type": "string" + }, + "state": { + "type": "string", + "enum": [ + "error" + ] + } + }, + "required": [ + "message", + "state" + ] + }, + { + "description": "An SFF-8636 module.", + "type": "object", + "properties": { + "lanes": { + "description": "The fault flags for each of the module's four lanes, in lane order.", + "type": "array", + "items": { + "$ref": "#/components/schemas/Sff8636LaneFaults" + }, + "minItems": 4, + "maxItems": 4 + }, + "state": { + "type": "string", + "enum": [ + "sff8636" + ] + } + }, + "required": [ + "lanes", + "state" + ] + }, + { + "description": "A CMIS module.", + "type": "object", + "properties": { + "datapaths": { + "title": "IdOrdMap", + "description": "The active datapaths, keyed by application selector code.", + "x-rust-type": { + "crate": "iddqd", + "parameters": [ + { + "$ref": "#/components/schemas/CmisDatapath" + } + ], + "path": "iddqd::IdOrdMap", + "version": "*" + }, + "type": "array", + "items": { + "$ref": "#/components/schemas/CmisDatapath" + }, + "uniqueItems": true + }, + "state": { + "type": "string", + "enum": [ + "cmis" + ] + } + }, + "required": [ + "datapaths", + "state" + ] + } + ] + }, + "TransceiverInventory": { + "description": "The switch transceiver (optical module) inventory.\n\nwicketd reads transceiver state from the switches independently of MGS, so it is presented as its own read/not-read unit rather than folded into the per-SP inventory.", + "oneOf": [ + { + "description": "The transceiver inventory has not been read yet.", + "type": "object", + "properties": { + "state": { + "type": "string", + "enum": [ + "not_read" + ] + } + }, + "required": [ + "state" + ] + }, + { + "description": "The transceiver inventory was read.", + "type": "object", + "properties": { + "last_seen": { + "description": "How long it has been since the transceiver inventory was last read.", + "allOf": [ + { + "$ref": "#/components/schemas/Duration" + } + ] + }, + "state": { + "type": "string", + "enum": [ + "read" + ] + }, + "switches": { + "title": "IdOrdMap", + "description": "The transceivers in each switch.", + "x-rust-type": { + "crate": "iddqd", + "parameters": [ + { + "$ref": "#/components/schemas/SwitchTransceivers" + } + ], + "path": "iddqd::IdOrdMap", + "version": "*" + }, + "type": "array", + "items": { + "$ref": "#/components/schemas/SwitchTransceivers" + }, + "uniqueItems": true + } + }, + "required": [ + "last_seen", + "state", + "switches" + ] + } + ] + }, + "TransceiverMonitors": { + "description": "The optical power monitors of a transceiver module.", + "oneOf": [ + { + "description": "The monitoring data could not be read.", + "type": "object", + "properties": { + "message": { + "description": "The error encountered while reading the monitoring data.", + "type": "string" + }, + "state": { + "type": "string", + "enum": [ + "error" + ] + } + }, + "required": [ + "message", + "state" + ] + }, + { + "description": "The monitoring data was read.", + "type": "object", + "properties": { + "rx_power_mw": { + "nullable": true, + "description": "Per-lane received optical power, in milliwatts.\n\n`None` if the module does not report received power.", + "type": "array", + "items": { + "type": "number", + "format": "float" + } + }, + "state": { + "type": "string", + "enum": [ + "read" + ] + }, + "tx_power_mw": { + "nullable": true, + "description": "Per-lane transmitted optical power, in milliwatts.\n\n`None` if the module does not report transmitted power.", + "type": "array", + "items": { + "type": "number", + "format": "float" + } + } + }, + "required": [ + "state" + ] + } + ] + }, + "TransceiverStatus": { + "description": "The presence and power status of a transceiver module.", + "oneOf": [ + { + "description": "The module status could not be read.", + "type": "object", + "properties": { + "message": { + "description": "The error encountered while reading the status.", + "type": "string" + }, + "state": { + "type": "string", + "enum": [ + "error" + ] + } + }, + "required": [ + "message", + "state" + ] + }, + { + "description": "The module status was read.", + "type": "object", + "properties": { + "enabled": { + "description": "Whether the module is enabled (powered on).", + "type": "boolean" + }, + "power_good": { + "description": "Whether the module's power is good.", + "type": "boolean" + }, + "present": { + "description": "Whether a transceiver module is present in the port.", + "type": "boolean" + }, + "state": { + "type": "string", + "enum": [ + "read" + ] + } + }, + "required": [ + "enabled", + "power_good", + "present", + "state" + ] + } + ] + }, + "TransceiverVendor": { + "description": "The vendor identification of a transceiver module.", + "oneOf": [ + { + "description": "The vendor information could not be read.", + "type": "object", + "properties": { + "message": { + "description": "The error encountered while reading the vendor information.", + "type": "string" + }, + "state": { + "type": "string", + "enum": [ + "error" + ] + } + }, + "required": [ + "message", + "state" + ] + }, + { + "description": "The vendor information was read.", + "type": "object", + "properties": { + "name": { + "description": "The vendor name.", + "type": "string" + }, + "part": { + "description": "The vendor part number.", + "type": "string" + }, + "serial": { + "description": "The module serial number.", + "type": "string" + }, + "state": { + "type": "string", + "enum": [ + "read" + ] + } + }, + "required": [ + "name", + "part", + "serial", + "state" + ] + } + ] + }, + "TxEqConfig": { + "description": "Per-port tx-eq overrides. This can be used to fine-tune the transceiver equalization settings to improve signal integrity.", + "type": "object", + "properties": { + "main": { + "nullable": true, + "description": "Main tap", + "type": "integer", + "format": "int32" + }, + "post1": { + "nullable": true, + "description": "Post-cursor tap1", + "type": "integer", + "format": "int32" + }, + "post2": { + "nullable": true, + "description": "Post-cursor tap2", + "type": "integer", + "format": "int32" + }, + "pre1": { + "nullable": true, + "description": "Pre-cursor tap1", + "type": "integer", + "format": "int32" + }, + "pre2": { + "nullable": true, + "description": "Pre-cursor tap2", + "type": "integer", + "format": "int32" + } + } + }, + "UpdateProgress": { + "description": "The progress of a single update execution: its overall state and its steps.", + "type": "object", + "properties": { + "state": { + "description": "The overall rollup state of this execution.", + "allOf": [ + { + "$ref": "#/components/schemas/UpdateState" + } + ] + }, + "steps": { + "description": "The steps of this execution, in order.", + "type": "array", + "items": { + "$ref": "#/components/schemas/UpdateStep" + } + } + }, + "required": [ + "state", + "steps" + ] + }, + "UpdateState": { + "description": "The state of an update execution.", + "oneOf": [ + { + "description": "An update has been started, but execution has not begun yet.", + "type": "object", + "properties": { + "state": { + "type": "string", + "enum": [ + "waiting" + ] + } + }, + "required": [ + "state" + ] + }, + { + "description": "The update is currently running.", + "type": "object", + "properties": { + "state": { + "type": "string", + "enum": [ + "running" + ] + } + }, + "required": [ + "state" + ] + }, + { + "description": "The update ran to completion.\n\nWhether individual steps performed work or were skipped (for example, because a component was already at its target version) is recorded in the per-step outcomes.", + "type": "object", + "properties": { + "state": { + "type": "string", + "enum": [ + "completed" + ] + } + }, + "required": [ + "state" + ] + }, + { + "description": "The update failed.", + "type": "object", + "properties": { + "message": { + "description": "A human-readable description of the failure, combining the failed step and its error message (including any nested causes).", + "type": "string" + }, + "state": { + "type": "string", + "enum": [ + "failed" + ] + } + }, + "required": [ + "message", + "state" + ] + }, + { + "description": "The update was aborted.", + "type": "object", + "properties": { + "message": { + "description": "A human-readable description of the abort, combining the aborted step and its message.", + "type": "string" + }, + "state": { + "type": "string", + "enum": [ + "aborted" + ] + } + }, + "required": [ + "message", + "state" + ] + } + ] + }, + "UpdateStep": { + "description": "A single node in the update step tree.\n\nA step may spawn nested executions (for example, a per-component update that runs its own engine). Those executions appear in `children`.", + "type": "object", + "properties": { + "children": { + "description": "The nested executions this step spawned, in order. Empty if the step ran no nested engine.", + "type": "array", + "items": { + "$ref": "#/components/schemas/UpdateProgress" + } + }, + "description": { + "description": "A human-readable description of this step.", + "type": "string" + }, + "status": { + "description": "The status of this step.", + "allOf": [ + { + "$ref": "#/components/schemas/UpdateStepStatus" + } + ] + } + }, + "required": [ + "children", + "description", + "status" + ] + }, + "UpdateStepStatus": { + "description": "The status of a single step in the update step tree.", + "oneOf": [ + { + "description": "The step has not started yet.", + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "not_started" + ] + } + }, + "required": [ + "status" + ] + }, + { + "description": "The step is currently running.", + "type": "object", + "properties": { + "progress": { + "description": "The most recent progress reported by the step.", + "allOf": [ + { + "$ref": "#/components/schemas/RunningProgress" + } + ] + }, + "status": { + "type": "string", + "enum": [ + "running" + ] + } + }, + "required": [ + "progress", + "status" + ] + }, + { + "description": "The step completed.", + "type": "object", + "properties": { + "outcome": { + "description": "The outcome of the completed step.", + "allOf": [ + { + "$ref": "#/components/schemas/StepOutcome" + } + ] + }, + "status": { + "type": "string", + "enum": [ + "completed" + ] + } + }, + "required": [ + "outcome", + "status" + ] + }, + { + "description": "The step failed.", + "type": "object", + "properties": { + "causes": { + "description": "The chain of underlying causes for the failure, outermost first.", + "type": "array", + "items": { + "type": "string" + } + }, + "message": { + "description": "A human-readable description of the failure.", + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "failed" + ] + } + }, + "required": [ + "causes", + "message", + "status" + ] + }, + { + "description": "The step was aborted while running.", + "type": "object", + "properties": { + "message": { + "description": "A human-readable description of the abort.", + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "aborted" + ] + } + }, + "required": [ + "message", + "status" + ] + }, + { + "description": "The step will not be run because a prior step failed or was aborted.", + "type": "object", + "properties": { + "reason": { + "description": "A human-readable description of why the step will not be run, naming the causal step.", + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "will_not_be_run" + ] + } + }, + "required": [ + "reason", + "status" + ] + } + ] + }, + "UpdateTargets": { + "description": "A non-empty set of service processors to act on.", + "type": "array", + "items": { + "$ref": "#/components/schemas/SpIdentifier" + }, + "minItems": 1, + "uniqueItems": true + }, + "UserSpecifiedBgpPeerConfig": { + "description": "User-specified configuration for a BGP peer.\n\nThis is similar to the internal BGP peer configuration, except it does not carry the sensitive `md5_auth_key`; the operator provides the key separately, referenced by `auth_key_id`.", + "type": "object", + "properties": { + "addr": { + "description": "Address of the peer.", + "allOf": [ + { + "$ref": "#/components/schemas/UserSpecifiedRouterPeerAddr" + } + ] + }, + "allowed_export": { + "description": "Apply export policy to this peer with an allow list.", + "default": null, + "allOf": [ + { + "$ref": "#/components/schemas/UserSpecifiedImportExportPolicy" + } + ] + }, + "allowed_import": { + "description": "Apply import policy to this peer with an allow list.", + "default": null, + "allOf": [ + { + "$ref": "#/components/schemas/UserSpecifiedImportExportPolicy" + } + ] + }, + "asn": { + "description": "The autonomous system number of the router the peer belongs to.", + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "auth_key_id": { + "nullable": true, + "description": "The key identifier for authentication to use with the peer.", + "default": null, + "allOf": [ + { + "$ref": "#/components/schemas/BgpAuthKeyId" + } + ] + }, + "communities": { + "description": "Include the provided communities in updates sent to the peer.", + "default": [], + "type": "array", + "items": { + "type": "integer", + "format": "uint32", + "minimum": 0 + } + }, + "connect_retry": { + "nullable": true, + "description": "The interval in seconds between peer connection retry attempts. Defaults to 3 seconds.", + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "delay_open": { + "nullable": true, + "description": "How long to delay sending open messages to a peer, in seconds. Defaults to 0.", + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "enforce_first_as": { + "description": "Enforce that the first AS in paths received from this peer is the peer's AS.", + "default": false, + "type": "boolean" + }, + "hold_time": { + "nullable": true, + "description": "How long to keep a session alive without a keepalive, in seconds. Defaults to 6 seconds.", + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "idle_hold_time": { + "nullable": true, + "description": "How long to keep a peer in idle after a state machine reset, in seconds. Defaults to 3 seconds.", + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "keepalive": { + "nullable": true, + "description": "The interval to send keepalive messages at, in seconds. Defaults to 2 seconds.", + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "local_pref": { + "nullable": true, + "description": "Apply a local preference to routes received from this peer.", + "default": null, + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "min_ttl": { + "nullable": true, + "description": "Require messages from a peer have a minimum IP time to live field.", + "default": null, + "type": "integer", + "format": "uint8", + "minimum": 0 + }, + "multi_exit_discriminator": { + "nullable": true, + "description": "Apply the provided multi-exit discriminator (MED) updates sent to the peer.", + "default": null, + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "port": { + "description": "Switch port the peer is reachable on.", + "type": "string" + }, + "remote_asn": { + "nullable": true, + "description": "Require that a peer has a specified ASN.", + "default": null, + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "router_lifetime": { + "description": "Router lifetime in seconds for unnumbered BGP peers.", + "default": 0, + "allOf": [ + { + "$ref": "#/components/schemas/RouterLifetimeConfig" + } + ] + }, + "vlan_id": { + "nullable": true, + "description": "Associate a VLAN ID with a BGP peer session.", + "default": null, + "type": "integer", + "format": "uint16", + "minimum": 0 + } + }, + "required": [ + "addr", + "asn", + "port" + ], + "additionalProperties": false + }, + "UserSpecifiedImportExportPolicy": { + "nullable": true, + "type": "array", + "items": { + "$ref": "#/components/schemas/IpNet" + } + }, + "UserSpecifiedPortConfig": { + "description": "A user-specified port configuration.", + "anyOf": [ + { + "description": "A manually-configured port.", + "allOf": [ + { + "$ref": "#/components/schemas/ManualPortConfig" + } + ] + }, + { + "description": "A port configured automatically via DDM.", + "type": "object", + "additionalProperties": false + } + ] + }, + "UserSpecifiedRackNetworkConfig": { + "description": "User-specified parts of the rack network configuration.", + "type": "object", + "properties": { + "bgp": { + "description": "BGP configuration for the rack.", + "type": "array", + "items": { + "$ref": "#/components/schemas/BgpConfig" + } + }, + "infra_ip_first": { + "description": "The first address of the infrastructure IP range.", + "type": "string", + "format": "ip" + }, + "infra_ip_last": { + "description": "The last address of the infrastructure IP range.", + "type": "string", + "format": "ip" + }, + "rack_subnet_address": { + "nullable": true, + "description": "The rack subnet address, if statically assigned.", + "type": "string", + "format": "ipv6" + }, + "switch0": { + "description": "Per-port configuration for switch 0, keyed by port name.", + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/UserSpecifiedPortConfig" + } + }, + "switch1": { + "description": "Per-port configuration for switch 1, keyed by port name.", + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/UserSpecifiedPortConfig" + } + } + }, + "required": [ + "bgp", + "infra_ip_first", + "infra_ip_last", + "switch0", + "switch1" + ], + "additionalProperties": false + }, + "UserSpecifiedRouterPeerAddr": { + "type": "string" + }, + "UserSpecifiedUplinkAddressConfig": { + "description": "A user-specified uplink address configuration.\n\nThis provides a friendlier TOML representation of an uplink address.", + "type": "object", + "properties": { + "address": { + "description": "The address to be used on the uplink.", + "type": "string" + }, + "vlan_id": { + "nullable": true, + "description": "The VLAN id (if any) associated with this address.", + "default": null, + "type": "integer", + "format": "uint16", + "minimum": 0 + } + }, + "required": [ + "address" + ], + "additionalProperties": false + } + }, + "responses": { + "Error": { + "description": "Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } +} diff --git a/openapi/wicketd-commission/wicketd-commission-latest.json b/openapi/wicketd-commission/wicketd-commission-latest.json new file mode 120000 index 00000000000..88669f9b82f --- /dev/null +++ b/openapi/wicketd-commission/wicketd-commission-latest.json @@ -0,0 +1 @@ +wicketd-commission-1.0.0-71519e.json \ No newline at end of file diff --git a/sled-agent/bootstrap-agent-lockstep-types/src/lib.rs b/sled-agent/bootstrap-agent-lockstep-types/src/lib.rs index 141281ea715..bf4c1531d05 100644 --- a/sled-agent/bootstrap-agent-lockstep-types/src/lib.rs +++ b/sled-agent/bootstrap-agent-lockstep-types/src/lib.rs @@ -339,6 +339,27 @@ impl RssStep { } return 0; } + + pub fn description(&self) -> &'static str { + match self { + Self::Requested => "Requested", + Self::Starting => "Starting", + Self::LoadExistingPlan => "Loading existing plan", + Self::CreateSledPlan => "Creating sled plan", + Self::InitTrustQuorum => "Initializing trust quorum", + Self::InitialNetworkConfigUpdate => "Initial network config update", + Self::SledInit => "Initializing sleds", + Self::FinalNetworkConfigUpdate => "Final network config update", + Self::InitDns => "Initializing DNS", + Self::ConfigureDns => "Configuring DNS", + Self::InitNtp => "Initializing NTP", + Self::WaitForTimeSync => "Waiting for time sync", + Self::WaitForDatabase => "Waiting for database", + Self::ClusterInit => "Initializing cluster", + Self::ZonesInit => "Initializing zones", + Self::NexusHandoff => "Handing off to Nexus", + } + } } /// Wrapper for optional contents of the replicated network config. diff --git a/sled-agent/src/services.rs b/sled-agent/src/services.rs index e2c11084da8..8797ad1184d 100644 --- a/sled-agent/src/services.rs +++ b/sled-agent/src/services.rs @@ -68,6 +68,7 @@ use omicron_common::address::NTP_ADMIN_PORT; use omicron_common::address::RACK_PREFIX_LENGTH; use omicron_common::address::SLED_PREFIX_LENGTH; use omicron_common::address::TFPORTD_PORT; +use omicron_common::address::WICKETD_COMMISSION_PORT; use omicron_common::address::WICKETD_NEXUS_PROXY_PORT; use omicron_common::address::WICKETD_PORT; use omicron_common::address::{BOOTSTRAP_ARTIFACT_PORT, COCKROACH_ADMIN_PORT}; @@ -2677,6 +2678,11 @@ impl ServiceManager { "astring", &format!("[::1]:{WICKETD_PORT}"), ) + .add_property( + "commission-address", + "astring", + &format!("[::1]:{WICKETD_COMMISSION_PORT}"), + ) .add_property( "artifact-address", "astring", diff --git a/smf/wicketd/manifest.xml b/smf/wicketd/manifest.xml index 349d7b5a57f..05dd72a41fb 100644 --- a/smf/wicketd/manifest.xml +++ b/smf/wicketd/manifest.xml @@ -17,7 +17,7 @@ @@ -47,6 +47,7 @@ + diff --git a/wicket/README.md b/wicket/README.md index dc145dd774b..94534f184c8 100644 --- a/wicket/README.md +++ b/wicket/README.md @@ -167,7 +167,7 @@ location = { switch0 = ["sled", 1], switch1 = ["sled", 1] } Taking the port number mentioned above, run: ``` -cargo run -p wicketd -- run wicketd/examples/config.toml --address '[::1]:12226' --artifact-address '[::]:12227' --nexus-proxy-address '[::1]:12228' --mgs-address '[::1]:12225' +cargo run -p wicketd -- run wicketd/examples/config.toml --address '[::1]:12226' --artifact-address '[::]:12227' --commission-address '[::1]:12234' --nexus-proxy-address '[::1]:12228' --mgs-address '[::1]:12225' ``` In this case, the port number in `--address` provides the interface between diff --git a/wicketd-api/Cargo.toml b/wicketd-api/Cargo.toml index c0da85843d7..0603f1e0b96 100644 --- a/wicketd-api/Cargo.toml +++ b/wicketd-api/Cargo.toml @@ -7,7 +7,6 @@ edition.workspace = true workspace = true [dependencies] -bootstrap-agent-lockstep-client.workspace = true bootstrap-agent-lockstep-types.workspace = true dropshot.workspace = true gateway-client.workspace = true diff --git a/wicketd-commission-api/Cargo.toml b/wicketd-commission-api/Cargo.toml new file mode 100644 index 00000000000..eb456a24b62 --- /dev/null +++ b/wicketd-commission-api/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "wicketd-commission-api" +version = "0.1.0" +edition.workspace = true +license = "MPL-2.0" + +[lints] +workspace = true + +[dependencies] +dropshot.workspace = true +dropshot-api-manager-types.workspace = true +iddqd.workspace = true +omicron-uuid-kinds.workspace = true +wicketd-commission-types-versions.workspace = true +omicron-workspace-hack.workspace = true diff --git a/wicketd-commission-api/src/lib.rs b/wicketd-commission-api/src/lib.rs new file mode 100644 index 00000000000..26d32b481a2 --- /dev/null +++ b/wicketd-commission-api/src/lib.rs @@ -0,0 +1,235 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +//! The stable, versioned commissioning API served by wicketd. +//! +//! This is a small surface intended for the rack commissioning tool (rkdeploy). + +use dropshot::{ + HttpError, HttpResponseOk, HttpResponseUpdatedNoContent, RequestContext, + StreamingBody, TypedBody, +}; +use dropshot_api_manager_types::api_versions; +use iddqd::IdOrdMap; +use omicron_uuid_kinds::RackInitUuid; +use wicketd_commission_types_versions::latest; + +api_versions!([(1, INITIAL),]); + +/// Full release repositories are currently (2026) well over 1 GiB and continue +/// to grow. +const PUT_REPOSITORY_MAX_BYTES: usize = 4 * 1024 * 1024 * 1024; + +#[dropshot::api_description] +pub trait WicketdCommissionApi { + type Context; + + /// Get inventory of all service processors visible to wicketd + /// + /// If `force_refresh` is non-empty, the request will wait for the listed SPs + /// to report fresh data, or return a 503 if they do not respond within the + /// server-side timeout. + /// + /// Returns 400 if the SP is unknown. + #[endpoint { + method = GET, + path = "/inventory/sps", + }] + async fn get_sp_inventory( + rqctx: RequestContext, + params: TypedBody, + ) -> Result, HttpError>; + + /// Report the physical location (switch and sled) wicketd is running at + /// + /// Returns 503 if the location is not yet known (typically because + /// wicketd hasn't been able to connect to MGS). + #[endpoint { + method = GET, + path = "/location", + }] + async fn get_location( + rqctx: RequestContext, + ) -> Result, HttpError>; + + /// List all sleds known to wicketd and their bootstrap-network addresses + #[endpoint { + method = GET, + path = "/bootstrap-sleds", + }] + async fn get_bootstrap_sleds( + rqctx: RequestContext, + ) -> Result< + HttpResponseOk>, + HttpError, + >; + + /// Describe the TUF repository currently held by wicketd + #[endpoint { + method = GET, + path = "/repository", + }] + async fn get_repository( + rqctx: RequestContext, + ) -> Result, HttpError>; + + /// Upload a TUF repository to wicketd + /// + /// At any given time, wicketd holds at most one TUF repository, extracted to + /// ephemeral storage; any previously-uploaded repository is discarded. This + /// request is rejected with a 400 while any service-processor update is in + /// progress. A successful upload clears all update progress state, so a + /// subsequent `GET /update-progress` returns an empty list. + #[endpoint { + method = PUT, + path = "/repository", + request_body_max_bytes = PUT_REPOSITORY_MAX_BYTES, + }] + async fn put_repository( + rqctx: RequestContext, + body: StreamingBody, + ) -> Result; + + /// Report the update progress of every service processor with update state + #[endpoint { + method = GET, + path = "/update-progress", + }] + async fn get_update_progress( + rqctx: RequestContext, + ) -> Result< + HttpResponseOk>, + HttpError, + >; + + /// Start updating one or more service processors + /// + /// A target that already has update state (even from a successful update) is + /// rejected until that state is cleared with `/clear-update-state` or a new + /// repository is uploaded. + #[endpoint { + method = POST, + path = "/update", + }] + async fn post_start_update( + rqctx: RequestContext, + params: TypedBody, + ) -> Result; + + /// Clear update state for one or more service processors + /// + /// Use this to reset update state after a completed or failed update. This + /// fails with a 400 if any of the targeted service processors are currently + /// being updated. Otherwise, the response reports which targets had update + /// state cleared and which had no update data to clear. + #[endpoint { + method = POST, + path = "/clear-update-state", + }] + async fn post_clear_update_state( + rqctx: RequestContext, + params: TypedBody, + ) -> Result< + HttpResponseOk, + HttpError, + >; + + /// Query the current state of rack setup + #[endpoint { + method = GET, + path = "/rack-setup", + }] + async fn get_rack_setup_state( + rqctx: RequestContext, + ) -> Result< + HttpResponseOk, + HttpError, + >; + + /// Update (a subset of) the current RSS configuration + /// + /// Sensitive values (certificates and the recovery password hash) are not + /// set through this endpoint. + #[endpoint { + method = PUT, + path = "/rack-setup/config", + }] + async fn put_rss_config( + rqctx: RequestContext, + body: TypedBody, + ) -> Result; + + /// Reset all RSS configuration to default values + #[endpoint { + method = DELETE, + path = "/rack-setup/config", + }] + async fn delete_rss_config( + rqctx: RequestContext, + ) -> Result; + + /// Add an external certificate + /// + /// A certificate must be paired with its private key; the two may be posted + /// in either order. Posting a certificate again before the pair completes + /// replaces the pending certificate. Re-uploading a certificate and key that + /// have already been accepted is detected as a duplicate: nothing is stored, + /// and the response is `cert_key_duplicate_ignored`. If validation of a + /// completed cert/key pair fails, both pending halves are discarded and must + /// be re-posted. + #[endpoint { + method = POST, + path = "/rack-setup/config/cert", + }] + async fn post_rss_config_cert( + rqctx: RequestContext, + body: TypedBody, + ) -> Result< + HttpResponseOk, + HttpError, + >; + + /// Add the private key of an external certificate + /// + /// A private key must be paired with its certificate; the two may be posted + /// in either order. Posting a key again before the pair completes replaces + /// the pending key. Re-uploading a certificate and key that have already + /// been accepted is detected as a duplicate: nothing is stored, and the + /// response is `cert_key_duplicate_ignored`. If validation of a completed + /// cert/key pair fails, both pending halves are discarded and must be + /// re-posted. + #[endpoint { + method = POST, + path = "/rack-setup/config/key", + }] + async fn post_rss_config_key( + rqctx: RequestContext, + body: TypedBody, + ) -> Result< + HttpResponseOk, + HttpError, + >; + + /// Set the recovery-silo user password hash + #[endpoint { + method = PUT, + path = "/rack-setup/config/recovery-user-password-hash", + }] + async fn put_rss_config_recovery_user_password_hash( + rqctx: RequestContext, + body: TypedBody, + ) -> Result; + + /// Run rack setup + /// + /// Returns an error if the rack setup configuration has not been fully + /// populated. + #[endpoint { + method = POST, + path = "/rack-setup", + }] + async fn post_run_rack_setup( + rqctx: RequestContext, + ) -> Result, HttpError>; +} diff --git a/wicketd-commission-types/src/inventory.rs b/wicketd-commission-types/src/inventory.rs new file mode 100644 index 00000000000..fb76e0cfe17 --- /dev/null +++ b/wicketd-commission-types/src/inventory.rs @@ -0,0 +1,5 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +pub use wicketd_commission_types_versions::latest::inventory::*; diff --git a/wicketd-commission-types/src/lib.rs b/wicketd-commission-types/src/lib.rs index 4875c574dd0..41102369421 100644 --- a/wicketd-commission-types/src/lib.rs +++ b/wicketd-commission-types/src/lib.rs @@ -7,5 +7,6 @@ //! Business logic and the wicketd implementation use this crate so they do not //! have to depend on `wicketd-commission-types-versions` directly. See RFD 619. +pub mod inventory; pub mod rack_setup; pub mod update; diff --git a/wicketd-commission-types/versions/Cargo.toml b/wicketd-commission-types/versions/Cargo.toml index 83c1aead950..2265e462251 100644 --- a/wicketd-commission-types/versions/Cargo.toml +++ b/wicketd-commission-types/versions/Cargo.toml @@ -9,10 +9,13 @@ workspace = true [dependencies] gateway-types-versions.workspace = true +iddqd.workspace = true omicron-common.workspace = true +omicron-uuid-kinds.workspace = true omicron-workspace-hack.workspace = true oxnet.workspace = true schemars.workspace = true +semver.workspace = true serde.workspace = true sled-agent-types-versions.workspace = true slog-error-chain.workspace = true diff --git a/wicketd-commission-types/versions/src/impls/inventory.rs b/wicketd-commission-types/versions/src/impls/inventory.rs new file mode 100644 index 00000000000..eb01d978190 --- /dev/null +++ b/wicketd-commission-types/versions/src/impls/inventory.rs @@ -0,0 +1,183 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +use crate::v1::inventory::{ + CmisLaneStatus, FaultFlag, Sff8636LaneFaults, TransceiverDatapath, +}; + +impl FaultFlag { + pub fn is_asserted(&self) -> bool { + match self { + FaultFlag::Asserted => true, + FaultFlag::Clear | FaultFlag::Unsupported => false, + } + } + + fn from_sff(flag: bool) -> Self { + if flag { FaultFlag::Asserted } else { FaultFlag::Clear } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct LaneFaultsView { + pub rx_los: FaultFlag, + pub tx_los: FaultFlag, + pub rx_lol: FaultFlag, + pub tx_lol: FaultFlag, + pub tx_fault: FaultFlag, +} + +impl LaneFaultsView { + fn from_sff8636(lane: &Sff8636LaneFaults) -> Self { + let Sff8636LaneFaults { rx_los, tx_los, rx_lol, tx_lol, tx_fault } = + lane; + LaneFaultsView { + rx_los: FaultFlag::from_sff(*rx_los), + tx_los: FaultFlag::from_sff(*tx_los), + rx_lol: FaultFlag::from_sff(*rx_lol), + tx_lol: FaultFlag::from_sff(*tx_lol), + tx_fault: FaultFlag::from_sff(*tx_fault), + } + } + + fn from_cmis(lane: &CmisLaneStatus) -> Self { + let CmisLaneStatus { + rx_los, + tx_los, + rx_lol, + tx_lol, + tx_fault, + lane: _, + state: _, + } = lane; + LaneFaultsView { + rx_los: *rx_los, + tx_los: *tx_los, + rx_lol: *rx_lol, + tx_lol: *tx_lol, + tx_fault: *tx_fault, + } + } +} + +impl TransceiverDatapath { + pub fn iter_lane_faults( + &self, + ) -> impl Iterator + '_ { + let views: Vec = match self { + TransceiverDatapath::Error { message: _ } => Vec::new(), + TransceiverDatapath::Sff8636 { lanes } => { + lanes.iter().map(LaneFaultsView::from_sff8636).collect() + } + TransceiverDatapath::Cmis { datapaths } => datapaths + .iter() + .flat_map(|datapath| datapath.lanes.iter()) + .map(LaneFaultsView::from_cmis) + .collect(), + }; + views.into_iter() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::v1::inventory::{CmisDatapath, CmisDatapathState}; + use iddqd::IdOrdMap; + + #[test] + fn fault_flag_is_asserted() { + assert!(FaultFlag::Asserted.is_asserted()); + assert!(!FaultFlag::Clear.is_asserted()); + assert!(!FaultFlag::Unsupported.is_asserted()); + } + + #[test] + fn iter_lane_faults_error_is_empty() { + let datapath = + TransceiverDatapath::Error { message: "boom".to_string() }; + assert_eq!(datapath.iter_lane_faults().count(), 0); + } + + #[test] + fn iter_lane_faults_sff8636_lifts_bools() { + let datapath = TransceiverDatapath::Sff8636 { + lanes: [ + Sff8636LaneFaults { + rx_los: true, + tx_los: false, + rx_lol: false, + tx_lol: false, + tx_fault: false, + }, + Sff8636LaneFaults { + rx_los: false, + tx_los: false, + rx_lol: false, + tx_lol: false, + tx_fault: true, + }, + Sff8636LaneFaults { + rx_los: false, + tx_los: false, + rx_lol: false, + tx_lol: false, + tx_fault: false, + }, + Sff8636LaneFaults { + rx_los: false, + tx_los: true, + rx_lol: false, + tx_lol: false, + tx_fault: false, + }, + ], + }; + let views: Vec<_> = datapath.iter_lane_faults().collect(); + assert_eq!(views.len(), 4); + assert_eq!(views[0].rx_los, FaultFlag::Asserted); + assert_eq!(views[0].tx_los, FaultFlag::Clear); + assert_eq!(views[1].tx_fault, FaultFlag::Asserted); + assert_eq!(views[3].tx_los, FaultFlag::Asserted); + } + + #[test] + fn iter_lane_faults_cmis_passes_through_flags() { + let lane = |lane: u8, rx_los: FaultFlag| CmisLaneStatus { + lane, + state: CmisDatapathState::Activated, + rx_los, + tx_los: FaultFlag::Clear, + rx_lol: FaultFlag::Unsupported, + tx_lol: FaultFlag::Clear, + tx_fault: FaultFlag::Asserted, + }; + let datapaths: IdOrdMap = [ + CmisDatapath { + application: 1, + lanes: [ + lane(0, FaultFlag::Asserted), + lane(1, FaultFlag::Clear), + ] + .into_iter() + .collect(), + }, + CmisDatapath { + application: 2, + lanes: [lane(0, FaultFlag::Unsupported)].into_iter().collect(), + }, + ] + .into_iter() + .collect(); + let datapath = TransceiverDatapath::Cmis { datapaths }; + let views: Vec<_> = datapath.iter_lane_faults().collect(); + assert_eq!(views.len(), 3); + assert!(views.iter().all(|v| v.rx_lol == FaultFlag::Unsupported)); + assert!(views.iter().all(|v| v.tx_fault == FaultFlag::Asserted)); + let rx_los: Vec<_> = views.iter().map(|v| v.rx_los).collect(); + assert!(rx_los.contains(&FaultFlag::Asserted)); + assert!(rx_los.contains(&FaultFlag::Clear)); + assert!(rx_los.contains(&FaultFlag::Unsupported)); + } +} diff --git a/wicketd-commission-types/versions/src/impls/mod.rs b/wicketd-commission-types/versions/src/impls/mod.rs index a626608a60b..fa5ffdf2920 100644 --- a/wicketd-commission-types/versions/src/impls/mod.rs +++ b/wicketd-commission-types/versions/src/impls/mod.rs @@ -4,5 +4,6 @@ //! Functional code for the latest versions of types. +mod inventory; mod rack_setup; mod update; diff --git a/wicketd-commission-types/versions/src/impls/update.rs b/wicketd-commission-types/versions/src/impls/update.rs index e4e8cc500b4..09ebd878d9f 100644 --- a/wicketd-commission-types/versions/src/impls/update.rs +++ b/wicketd-commission-types/versions/src/impls/update.rs @@ -6,7 +6,9 @@ use std::collections::BTreeSet; use gateway_types_versions::v1::component::SpIdentifier; -use crate::latest::update::UpdateTargets; +use crate::latest::update::{ + UpdateProgress, UpdateStep, UpdateStepStatus, UpdateTargets, +}; impl UpdateTargets { /// Create a new `UpdateTargets` set containing a single target. @@ -49,6 +51,42 @@ impl<'a> IntoIterator for &'a UpdateTargets { } } +impl UpdateProgress { + pub fn iter_steps(&self) -> impl Iterator + '_ { + self.steps.iter().flat_map(UpdateStep::iter) + } + + pub fn innermost_running_steps( + &self, + ) -> impl Iterator + '_ { + self.iter_steps().filter(|step| { + step.is_running() + && !step + .children + .iter() + .flat_map(|progress| progress.steps.iter()) + .any(UpdateStep::is_running) + }) + } +} + +impl UpdateStep { + pub fn is_running(&self) -> bool { + matches!(self.status, UpdateStepStatus::Running { .. }) + } + + pub fn iter(&self) -> impl Iterator + '_ { + let mut stack = vec![self]; + std::iter::from_fn(move || { + let step = stack.pop()?; + for progress in step.children.iter().rev() { + stack.extend(progress.steps.iter().rev()); + } + Some(step) + }) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/wicketd-commission-types/versions/src/initial/inventory.rs b/wicketd-commission-types/versions/src/initial/inventory.rs new file mode 100644 index 00000000000..867be5befea --- /dev/null +++ b/wicketd-commission-types/versions/src/initial/inventory.rs @@ -0,0 +1,543 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +//! Inventory and discovery types for the commissioning API. +//! +//! These are deliberately minimal projections of wicketd's internal inventory +//! shapes: they carry only the fields the commissioning client needs, so that +//! the large and less stable internal inventory type graph (MGS inventory) does +//! not leak into this stable API. + +use std::net::Ipv6Addr; +use std::time::Duration; + +use iddqd::{IdOrdItem, IdOrdMap, id_upcast}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +// Re-exports of pinned gateway types since they are also published by this API. +pub use gateway_types_versions::v1::component::{ + PowerState, SpIdentifier, SpType, +}; +pub use gateway_types_versions::v1::rot::RotSlot; +pub use sled_agent_types_versions::v1::early_networking::SwitchSlot; + +/// A minimal projection of a firmware caboose. +/// +/// Only the fields the commissioning client needs are included; the remaining +/// caboose fields (git commit, name, epoch, and so on) are intentionally +/// omitted. +#[derive( + Debug, + Clone, + PartialEq, + Eq, + Hash, + PartialOrd, + Ord, + Serialize, + Deserialize, + JsonSchema, +)] +pub struct Caboose { + /// The firmware version string. + pub version: String, + /// The board name the firmware was built for. + pub board: String, + /// The signer of this firmware image, if the image is signed. + /// + /// For a root of trust, this is the hex-encoded hash of the public key used + /// to sign the image; it is `None` for firmware that carries no signature + /// (such as service-processor images). Commissioning uses it to match an + /// active RoT slot against the corresponding signed TUF artifact. + pub sign: Option, +} + +/// The stage0 (or pending stage0next) bootloader caboose for a root of trust. +/// +/// The three states are kept distinct because they mean different things to a +/// caller: +/// +/// * `unsupported`: this RoT version does not report a stage0 bootloader +/// caboose at all. +/// * `not_read`: the RoT version supports reporting a stage0 caboose, but it +/// has not been read yet. +/// * `read`: the stage0 caboose was read. +#[derive( + Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema, +)] +#[serde(tag = "state", rename_all = "snake_case")] +pub enum Stage0Caboose { + /// This RoT version does not report a stage0 bootloader caboose. + Unsupported, + /// The stage0 bootloader caboose is supported but has not been read yet. + NotRead, + /// The stage0 bootloader caboose was read. + Read { + /// The caboose that was read. + caboose: Caboose, + }, +} + +/// The caboose for a single firmware slot, including whether it was read. +#[derive( + Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema, +)] +#[serde(tag = "state", rename_all = "snake_case")] +pub enum SlotCaboose { + /// The caboose for this slot has not been read yet. + NotRead, + /// The caboose for this slot was read. + Read { + /// The caboose that was read. + caboose: Caboose, + }, +} + +/// Root-of-trust information for a single service processor. +#[derive( + Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema, +)] +pub struct RotInfo { + /// The currently-active RoT image slot. + pub active: RotSlot, + /// The caboose of the image in slot A. + pub caboose_a: SlotCaboose, + /// The caboose of the image in slot B. + pub caboose_b: SlotCaboose, + /// The caboose of the stage0 bootloader. + pub caboose_stage0: Stage0Caboose, + /// The caboose of the pending stage0next bootloader. + pub caboose_stage0next: Stage0Caboose, +} + +/// The service processor's state, read together from MGS. +/// +/// The serial number and power state are always read together from the same +/// service-processor state, so they are presented as a single unit that is +/// either entirely present or entirely absent. +#[derive( + Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema, +)] +pub struct SpStateInfo { + /// The service processor's serial number. + pub serial_number: String, + /// The host power state. + pub power_state: PowerState, +} + +/// The faults ignition reports for a present service processor. +#[derive( + Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema, +)] +pub struct IgnitionFaults { + /// The A3 power fault. + pub a3: bool, + /// The A2 power fault. + pub a2: bool, + /// The root-of-trust fault. + pub rot: bool, + /// The service-processor fault. + pub sp: bool, +} + +/// The ignition state of a service processor. +#[derive( + Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema, +)] +#[serde(tag = "state", rename_all = "snake_case")] +pub enum SpIgnitionInfo { + /// The ignition state has not been read yet. + NotRead, + /// Ignition reports the service processor as present. + Present { + /// Whether the service processor is powered on. + power: bool, + /// The faults ignition reports for the service processor. + faults: IgnitionFaults, + }, + /// Ignition reports the service processor as absent. + Absent, +} + +/// A single service processor's inventory. +#[derive( + Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema, +)] +pub struct SpInfo { + /// Identifies the service processor by type and slot. + pub id: SpIdentifier, + /// The service processor's state, if it has been read. + pub state: Option, + /// The ignition state of this service processor. + pub ignition: SpIgnitionInfo, + /// The caboose of the active service-processor firmware slot. + pub caboose_active: SlotCaboose, + /// The caboose of the inactive service-processor firmware slot. + pub caboose_inactive: SlotCaboose, + /// Root-of-trust information, if it has been read. + pub rot: Option, +} + +impl IdOrdItem for SpInfo { + type Key<'a> = SpIdentifier; + + fn key(&self) -> Self::Key<'_> { + self.id + } + + id_upcast!(); +} + +/// Inventory across all service processors. +/// +/// This type cannot derive `Eq` because transceiver optical-power readings are +/// floating-point. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct SpInventory { + /// How long it has been since wicketd last heard from MGS. + /// + /// A large value indicates that MGS might be down. + pub mgs_last_seen: Duration, + /// The inventory of each SP. + pub sps: IdOrdMap, + /// The switch transceiver (optical module) inventory. + pub transceivers: TransceiverInventory, +} + +/// The switch transceiver (optical module) inventory. +/// +/// wicketd reads transceiver state from the switches independently of MGS, so +/// it is presented as its own read/not-read unit rather than folded into the +/// per-SP inventory. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(tag = "state", rename_all = "snake_case")] +pub enum TransceiverInventory { + /// The transceiver inventory has not been read yet. + NotRead, + /// The transceiver inventory was read. + Read { + /// How long it has been since the transceiver inventory was last read. + last_seen: Duration, + /// The transceivers in each switch. + switches: IdOrdMap, + }, +} + +/// The transceivers in a single switch. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct SwitchTransceivers { + /// The switch these transceivers belong to. + pub switch: SwitchSlot, + /// The transceivers in this switch, keyed by front port. + pub transceivers: IdOrdMap, +} + +impl IdOrdItem for SwitchTransceivers { + type Key<'a> = SwitchSlot; + + fn key(&self) -> Self::Key<'_> { + self.switch + } + + id_upcast!(); +} + +/// A single transceiver (optical module) in a switch front port. +/// +/// The four categories of state (`status`, `vendor`, `monitors`, and +/// `datapath`) are read independently, so each can be reported or fail on its +/// own. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct Transceiver { + /// The front port the transceiver sits in (for example, `qsfp0`). + pub port: String, + /// Module presence and power status. + pub status: TransceiverStatus, + /// Vendor identification. + pub vendor: TransceiverVendor, + /// Optical power monitors. + pub monitors: TransceiverMonitors, + /// Per-lane datapath fault state. + pub datapath: TransceiverDatapath, +} + +impl IdOrdItem for Transceiver { + type Key<'a> = &'a str; + + fn key(&self) -> Self::Key<'_> { + &self.port + } + + id_upcast!(); +} + +/// The presence and power status of a transceiver module. +#[derive( + Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema, +)] +#[serde(tag = "state", rename_all = "snake_case")] +pub enum TransceiverStatus { + /// The module status could not be read. + Error { + /// The error encountered while reading the status. + message: String, + }, + /// The module status was read. + Read { + /// Whether a transceiver module is present in the port. + present: bool, + /// Whether the module is enabled (powered on). + enabled: bool, + /// Whether the module's power is good. + power_good: bool, + }, +} + +/// The vendor identification of a transceiver module. +#[derive( + Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema, +)] +#[serde(tag = "state", rename_all = "snake_case")] +pub enum TransceiverVendor { + /// The vendor information could not be read. + Error { + /// The error encountered while reading the vendor information. + message: String, + }, + /// The vendor information was read. + Read { + /// The vendor name. + name: String, + /// The vendor part number. + part: String, + /// The module serial number. + serial: String, + }, +} + +/// The optical power monitors of a transceiver module. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(tag = "state", rename_all = "snake_case")] +pub enum TransceiverMonitors { + /// The monitoring data could not be read. + Error { + /// The error encountered while reading the monitoring data. + message: String, + }, + /// The monitoring data was read. + Read { + /// Per-lane received optical power, in milliwatts. + /// + /// `None` if the module does not report received power. + rx_power_mw: Option>, + /// Per-lane transmitted optical power, in milliwatts. + /// + /// `None` if the module does not report transmitted power. + tx_power_mw: Option>, + }, +} + +/// The per-lane datapath fault state of a transceiver module. +/// +/// SFF-8636 and CMIS modules describe their datapaths very differently, so this +/// projection preserves each spec's native structure rather than flattening +/// both into a single lane list. Clients can aggregate faults across both specs +/// using the `iter_lane_faults` helper on this type. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(tag = "state", rename_all = "snake_case")] +pub enum TransceiverDatapath { + /// The datapath state could not be read. + Error { + /// The error encountered while reading the datapath state. + message: String, + }, + /// An SFF-8636 module. + Sff8636 { + /// The fault flags for each of the module's four lanes, in lane order. + lanes: [Sff8636LaneFaults; 4], + }, + /// A CMIS module. + Cmis { + /// The active datapaths, keyed by application selector code. + datapaths: IdOrdMap, + }, +} + +/// Whether a lane fault flag is asserted. +/// +/// SFF-8636 modules always report every flag, but a CMIS module may not support +/// reporting a given flag. `Unsupported` distinguishes "the module does not +/// report this flag" from a definite `Clear`. +#[derive( + Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema, +)] +#[serde(rename_all = "snake_case")] +pub enum FaultFlag { + /// The fault flag is asserted. + Asserted, + /// The fault flag is not asserted. + Clear, + /// The module does not support reporting this flag. + Unsupported, +} + +/// The fault flags for one SFF-8636 lane. +/// +/// SFF-8636 always reports all five flags, so these are plain booleans. +#[derive( + Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema, +)] +pub struct Sff8636LaneFaults { + /// Media-side (receive) loss of signal. + pub rx_los: bool, + /// Host-side (transmit) loss of signal. + pub tx_los: bool, + /// Media-side (receive) loss of lock. + pub rx_lol: bool, + /// Host-side (transmit) loss of lock. + pub tx_lol: bool, + /// A fault in the transmitter and/or laser. + pub tx_fault: bool, +} + +/// One active CMIS datapath (application). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct CmisDatapath { + /// The application selector code identifying this datapath. + pub application: u8, + /// The status of each lane in this datapath, keyed by lane number. + pub lanes: IdOrdMap, +} + +impl IdOrdItem for CmisDatapath { + type Key<'a> = u8; + + fn key(&self) -> Self::Key<'_> { + self.application + } + + id_upcast!(); +} + +/// The status of one CMIS lane. +#[derive( + Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema, +)] +pub struct CmisLaneStatus { + /// The lane number within the module. + pub lane: u8, + /// The datapath state of this lane (CMIS 5.0 section 8.9.1). + pub state: CmisDatapathState, + /// Media-side (receive) loss of signal. + pub rx_los: FaultFlag, + /// Host-side (transmit) loss of signal. + pub tx_los: FaultFlag, + /// Media-side (receive) loss of lock. + pub rx_lol: FaultFlag, + /// Host-side (transmit) loss of lock. + pub tx_lol: FaultFlag, + /// A fault in the transmitter and/or laser. + /// + /// CMIS calls this `TxFailure`; it is named `tx_fault` here for cross-spec + /// consistency with SFF-8636. + pub tx_fault: FaultFlag, +} + +impl IdOrdItem for CmisLaneStatus { + type Key<'a> = u8; + + fn key(&self) -> Self::Key<'_> { + self.lane + } + + id_upcast!(); +} + +/// The datapath state of a CMIS lane. +/// +/// This mirrors the CMIS datapath state machine (CMIS 5.0 section 8.9.1). +#[derive( + Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema, +)] +#[serde(rename_all = "snake_case")] +pub enum CmisDatapathState { + /// The datapath is deactivated. + Deactivated, + /// The datapath is initializing. + Init, + /// The datapath is deinitializing. + Deinit, + /// The datapath is activated. + Activated, + /// The transmitter is turning on. + TxTurnOn, + /// The transmitter is turning off. + TxTurnOff, + /// The datapath is initialized. + Initialized, +} + +/// The physical location of the sled wicketd is running on. +#[derive( + Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema, +)] +pub struct LocationInfo { + /// The slot of the switch this sled is cabled to. + pub switch_slot: SwitchSlot, + /// The serial number of that switch's service processor, if known. + pub switch_serial: Option, + /// The serial number of the sled wicketd is running on, if known. + pub sled_serial: Option, +} + +/// Parameters for the SP inventory endpoint. +#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SpInventoryParams { + /// Refresh the state of these service processors from MGS before returning, + /// rather than returning their cached state. Service processors not listed + /// here are returned from the cache. + #[serde(default)] + pub force_refresh: Vec, +} + +/// A sled as seen on the bootstrap network. +/// +/// A sled is reported here once its service processor's state has been read +/// from MGS. (A populated cubby whose state has not yet been polled is absent +/// until it is.) +/// +/// A sled's `ip` becomes `Some` once it has been discovered on the bootstrap +/// network; sleds still missing an address report `None`. +#[derive( + Debug, + Clone, + PartialEq, + Eq, + Hash, + PartialOrd, + Ord, + Serialize, + Deserialize, + JsonSchema, +)] +pub struct BootstrapSled { + /// The service processor for this sled (its type and slot). + pub id: SpIdentifier, + /// The sled's baseboard serial number. + pub serial_number: String, + /// The sled's bootstrap-network address, once it has been discovered. + pub ip: Option, +} + +impl IdOrdItem for BootstrapSled { + type Key<'a> = SpIdentifier; + + fn key(&self) -> Self::Key<'_> { + self.id + } + + id_upcast!(); +} diff --git a/wicketd-commission-types/versions/src/initial/mod.rs b/wicketd-commission-types/versions/src/initial/mod.rs index f0ded5f69eb..4e2bcd80167 100644 --- a/wicketd-commission-types/versions/src/initial/mod.rs +++ b/wicketd-commission-types/versions/src/initial/mod.rs @@ -4,5 +4,6 @@ //! Version `INITIAL` of the wicketd commissioning API. +pub mod inventory; pub mod rack_setup; pub mod update; diff --git a/wicketd-commission-types/versions/src/initial/rack_setup.rs b/wicketd-commission-types/versions/src/initial/rack_setup.rs index 73ae99ee056..f67ac275769 100644 --- a/wicketd-commission-types/versions/src/initial/rack_setup.rs +++ b/wicketd-commission-types/versions/src/initial/rack_setup.rs @@ -4,19 +4,14 @@ //! Rack setup (RSS) types for the commissioning API. //! -//! The RSS configuration tree (rooted at [`PutRssUserConfigInsensitive`]) is -//! copied verbatim from `wicket-common`, `sled-agent-types`, and -//! `omicron-common` so that its serde shape is byte-for-byte compatible with -//! the internal types (see the round-trip tests in wicketd). The functional -//! machinery of the originals (validation error types, inherent methods, and -//! conversions to internal types) lives elsewhere: validation and conversion -//! happen at the wicketd boundary. +//! The root struct is [`PutRssUserConfigInsensitive`]. use std::collections::{BTreeMap, BTreeSet}; use std::fmt; use std::net::{IpAddr, Ipv6Addr}; use omicron_common::api::external::Name; +use omicron_uuid_kinds::{RackInitUuid, RackResetUuid}; use oxnet::IpNet; use schemars::JsonSchema; use serde::{Deserialize, Serialize, Serializer}; @@ -586,3 +581,85 @@ impl JsonSchema for UserSpecifiedImportExportPolicy { Option::>::json_schema(r#gen) } } + +/// A recovery-silo user password hash, in PHC string format. +/// +/// This shares its name with the validated `omicron_passwords::NewPasswordHash` +/// it converts into, but holds an unvalidated string. The hash is validated (as +/// an Argon2id PHC string) only at the wicketd conversion boundary. +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +#[serde(transparent)] +pub struct NewPasswordHash(pub String); + +/// The body of a request to set the recovery-silo user password hash. +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct PutRecoveryUserPasswordHash { + /// The password hash, in PHC string format. + pub hash: NewPasswordHash, +} + +/// Information about the current RSS step. +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +pub struct RssStepInfo { + /// The 1-based index of the current step. + pub step: u32, + /// The total number of RSS steps. + pub total_steps: u32, + /// A human-readable description of the current step. + pub description: String, +} + +/// The current status of any rack-level operation being performed. +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +#[serde(tag = "status", rename_all = "snake_case")] +pub enum RackOperationStatus { + /// Rack initialization is in progress. + Initializing { + /// The ID of the initialization operation. + id: RackInitUuid, + /// Information about the current step. + step: RssStepInfo, + }, + /// The rack is initialized. `id` is `None` if the rack was already + /// initialized on startup. + Initialized { + /// The ID of the initialization operation, if one was performed. + id: Option, + }, + /// Rack initialization failed. + InitializationFailed { + /// The ID of the initialization operation. + id: RackInitUuid, + /// A message describing the failure. + message: String, + }, + /// Rack initialization panicked. + InitializationPanicked { + /// The ID of the initialization operation. + id: RackInitUuid, + }, + /// The rack is being reset. + Resetting { + /// The ID of the reset operation. + id: RackResetUuid, + }, + /// The rack is uninitialized. `reset_id` is `None` if it was uninitialized + /// on startup, or `Some` if a reset operation completed. + Uninitialized { + /// The ID of the reset operation, if one was performed. + reset_id: Option, + }, + /// Rack reset failed. + ResetFailed { + /// The ID of the reset operation. + id: RackResetUuid, + /// A message describing the failure. + message: String, + }, + /// Rack reset panicked. + ResetPanicked { + /// The ID of the reset operation. + id: RackResetUuid, + }, +} diff --git a/wicketd-commission-types/versions/src/initial/update.rs b/wicketd-commission-types/versions/src/initial/update.rs index f0bb3856c75..9e826b48fef 100644 --- a/wicketd-commission-types/versions/src/initial/update.rs +++ b/wicketd-commission-types/versions/src/initial/update.rs @@ -2,14 +2,18 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. -//! Update types shared across wicketd's update APIs. +//! MUPdate types for the commissioning API. +//! +//! The progress types here are a projection of wicketd's internal event buffers. use std::collections::BTreeSet; +use iddqd::{IdOrdItem, id_upcast}; use schemars::JsonSchema; +use semver::Version; use serde::{Deserialize, Serialize}; -use gateway_types_versions::v1::component::SpIdentifier; +use crate::v1::inventory::SpIdentifier; /// A non-empty set of service processors to act on. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] @@ -94,3 +98,219 @@ impl std::fmt::Display for EmptyUpdateTargets { } impl std::error::Error for EmptyUpdateTargets {} + +/// A description of the TUF repository currently held by wicketd. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct RepositoryDescription { + /// The system version of the uploaded TUF repository, if one is present. + pub system_version: Option, +} + +/// Options controlling how an update is performed. +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq, + Default, + Serialize, + Deserialize, + JsonSchema, +)] +#[serde(deny_unknown_fields)] +pub struct StartUpdateOptions { + /// If true, skip the check on the current RoT bootloader version and always + /// update it regardless of whether the update appears to be needed. + #[serde(default)] + pub skip_rot_bootloader_version_check: bool, + + /// If true, skip the check on the current RoT version and always update it + /// regardless of whether the update appears to be needed. + #[serde(default)] + pub skip_rot_version_check: bool, + + /// If true, skip the check on the current SP version and always update it + /// regardless of whether the update appears to be needed. + #[serde(default)] + pub skip_sp_version_check: bool, +} + +/// Parameters for starting an update. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct StartUpdateParams { + /// The service processors to update. + pub targets: UpdateTargets, + /// Options controlling the update. + pub options: StartUpdateOptions, +} + +/// Parameters for clearing update state. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ClearUpdateStateParams { + /// The service processors to clear update state for. + pub targets: UpdateTargets, +} + +/// Update progress for a single service processor. +/// +/// An SP's update progress is only provided once an update has been started for +/// it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct SpUpdateProgress { + /// The service processor this progress describes. + pub sp: SpIdentifier, + /// The update progress for that service processor. + pub progress: UpdateProgress, +} + +impl IdOrdItem for SpUpdateProgress { + type Key<'a> = SpIdentifier; + + fn key(&self) -> Self::Key<'_> { + self.sp + } + + id_upcast!(); +} + +/// The progress of a single update execution: its overall state and its steps. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct UpdateProgress { + /// The overall rollup state of this execution. + pub state: UpdateState, + /// The steps of this execution, in order. + pub steps: Vec, +} + +/// The state of an update execution. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(tag = "state", rename_all = "snake_case")] +pub enum UpdateState { + /// An update has been started, but execution has not begun yet. + Waiting, + /// The update is currently running. + Running, + /// The update ran to completion. + /// + /// Whether individual steps performed work or were skipped (for example, + /// because a component was already at its target version) is recorded in + /// the per-step outcomes. + Completed, + /// The update failed. + Failed { + /// A human-readable description of the failure, combining the failed + /// step and its error message (including any nested causes). + message: String, + }, + /// The update was aborted. + Aborted { + /// A human-readable description of the abort, combining the aborted + /// step and its message. + message: String, + }, +} + +/// A single node in the update step tree. +/// +/// A step may spawn nested executions (for example, a per-component update that +/// runs its own engine). Those executions appear in `children`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct UpdateStep { + /// A human-readable description of this step. + pub description: String, + /// The status of this step. + pub status: UpdateStepStatus, + /// The nested executions this step spawned, in order. Empty if the step ran + /// no nested engine. + pub children: Vec, +} + +/// The status of a single step in the update step tree. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(tag = "status", rename_all = "snake_case")] +pub enum UpdateStepStatus { + /// The step has not started yet. + NotStarted, + /// The step is currently running. + Running { + /// The most recent progress reported by the step. + progress: RunningProgress, + }, + /// The step completed. + Completed { + /// The outcome of the completed step. + outcome: StepOutcome, + }, + /// The step failed. + Failed { + /// A human-readable description of the failure. + message: String, + /// The chain of underlying causes for the failure, outermost first. + causes: Vec, + }, + /// The step was aborted while running. + Aborted { + /// A human-readable description of the abort. + message: String, + }, + /// The step will not be run because a prior step failed or was aborted. + WillNotBeRun { + /// A human-readable description of why the step will not be run, + /// naming the causal step. + reason: String, + }, +} + +/// The most recent progress reported by a running step. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(tag = "state", rename_all = "snake_case")] +pub enum RunningProgress { + /// The update engine has not received any progress message for this step + /// attempt yet. + /// + /// The engine reports this immediately after a step starts, and again after + /// a retry or progress reset. It does not indicate a stall or timeout. + WaitingForProgress, + /// The step reported progress. + /// + /// `progress` carries the counter if the report included one. + Counter { + /// The reported progress counter, if the report included one. + progress: Option, + }, +} + +/// The outcome of a step that ran to completion. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(tag = "outcome", rename_all = "snake_case")] +pub enum StepOutcome { + /// The step succeeded, optionally with a message. + Success { + /// An optional message describing the success. + message: Option, + }, + /// The step succeeded but produced a warning. + Warning { + /// The warning message. + message: String, + }, + /// The step was skipped. + Skipped { + /// The message describing why the step was skipped. + message: String, + }, +} + +/// A step's progress towards completion. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct StepProgress { + /// The current progress value. + pub current: u64, + /// The total value this progress counts towards, if known. + pub total: Option, + /// The units of `current` and `total` (for example, "bytes"). + pub units: String, +} diff --git a/wicketd-commission-types/versions/src/latest.rs b/wicketd-commission-types/versions/src/latest.rs index f433d8ddcf9..2a7ea8ec6b0 100644 --- a/wicketd-commission-types/versions/src/latest.rs +++ b/wicketd-commission-types/versions/src/latest.rs @@ -4,6 +4,38 @@ //! Re-exports of the latest versions of all published types. +pub mod inventory { + pub use crate::v1::inventory::BootstrapSled; + pub use crate::v1::inventory::Caboose; + pub use crate::v1::inventory::CmisDatapath; + pub use crate::v1::inventory::CmisDatapathState; + pub use crate::v1::inventory::CmisLaneStatus; + pub use crate::v1::inventory::FaultFlag; + pub use crate::v1::inventory::IgnitionFaults; + pub use crate::v1::inventory::LocationInfo; + pub use crate::v1::inventory::PowerState; + pub use crate::v1::inventory::RotInfo; + pub use crate::v1::inventory::RotSlot; + pub use crate::v1::inventory::Sff8636LaneFaults; + pub use crate::v1::inventory::SlotCaboose; + pub use crate::v1::inventory::SpIdentifier; + pub use crate::v1::inventory::SpIgnitionInfo; + pub use crate::v1::inventory::SpInfo; + pub use crate::v1::inventory::SpInventory; + pub use crate::v1::inventory::SpInventoryParams; + pub use crate::v1::inventory::SpStateInfo; + pub use crate::v1::inventory::SpType; + pub use crate::v1::inventory::Stage0Caboose; + pub use crate::v1::inventory::SwitchSlot; + pub use crate::v1::inventory::SwitchTransceivers; + pub use crate::v1::inventory::Transceiver; + pub use crate::v1::inventory::TransceiverDatapath; + pub use crate::v1::inventory::TransceiverInventory; + pub use crate::v1::inventory::TransceiverMonitors; + pub use crate::v1::inventory::TransceiverStatus; + pub use crate::v1::inventory::TransceiverVendor; +} + pub mod rack_setup { pub use crate::v1::rack_setup::AllowedSourceIps; pub use crate::v1::rack_setup::BgpAuthKeyId; @@ -19,10 +51,14 @@ pub mod rack_setup { pub use crate::v1::rack_setup::LldpPortConfig; pub use crate::v1::rack_setup::ManualPortConfig; pub use crate::v1::rack_setup::MaxPathConfig; + pub use crate::v1::rack_setup::NewPasswordHash; + pub use crate::v1::rack_setup::PutRecoveryUserPasswordHash; pub use crate::v1::rack_setup::PutRssUserConfigInsensitive; + pub use crate::v1::rack_setup::RackOperationStatus; pub use crate::v1::rack_setup::RouteConfig; pub use crate::v1::rack_setup::RouterLifetimeConfig; pub use crate::v1::rack_setup::RouterPeerIpAddr; + pub use crate::v1::rack_setup::RssStepInfo; pub use crate::v1::rack_setup::TxEqConfig; pub use crate::v1::rack_setup::UplinkAddress; pub use crate::v1::rack_setup::UplinkIpNet; @@ -35,7 +71,19 @@ pub mod rack_setup { } pub mod update { + pub use crate::v1::update::ClearUpdateStateParams; pub use crate::v1::update::ClearUpdateStateResponse; pub use crate::v1::update::EmptyUpdateTargets; + pub use crate::v1::update::RepositoryDescription; + pub use crate::v1::update::RunningProgress; + pub use crate::v1::update::SpUpdateProgress; + pub use crate::v1::update::StartUpdateOptions; + pub use crate::v1::update::StartUpdateParams; + pub use crate::v1::update::StepOutcome; + pub use crate::v1::update::StepProgress; + pub use crate::v1::update::UpdateProgress; + pub use crate::v1::update::UpdateState; + pub use crate::v1::update::UpdateStep; + pub use crate::v1::update::UpdateStepStatus; pub use crate::v1::update::UpdateTargets; } diff --git a/wicketd/Cargo.toml b/wicketd/Cargo.toml index ef87a7c5011..901e2017a39 100644 --- a/wicketd/Cargo.toml +++ b/wicketd/Cargo.toml @@ -75,6 +75,7 @@ oxide-update-engine.workspace = true oxide-update-engine-types.workspace = true wicket-common.workspace = true wicketd-api.workspace = true +wicketd-commission-api.workspace = true wicketd-commission-types.workspace = true wicketd-client.workspace = true omicron-workspace-hack.workspace = true @@ -88,6 +89,9 @@ doc = false [dev-dependencies] expectorate.workspace = true flate2.workspace = true +omicron-common.workspace = true +wicketd-commission-client.workspace = true +wicketd-commission-types-versions.workspace = true fs-err.workspace = true gateway-test-utils.workspace = true http.workspace = true @@ -102,6 +106,7 @@ sled-agent-config-reconciler = { workspace = true, features = ["testing"] } sled-agent-resolvable-files.workspace = true sled-agent-resolvable-files-examples.workspace = true sled-storage.workspace = true +sp-sim.workspace = true subprocess.workspace = true tar.workspace = true tokio = { workspace = true, features = ["test-util"] } diff --git a/wicketd/src/artifacts/store.rs b/wicketd/src/artifacts/store.rs index 555e0dbd064..153af2b2323 100644 --- a/wicketd/src/artifacts/store.rs +++ b/wicketd/src/artifacts/store.rs @@ -38,6 +38,18 @@ impl WicketdArtifactStore { self.replace(artifacts_with_plan); } + /// Returns the system version. + /// + /// Avoids cloning the entire artifact list the way + /// `system_version_and_artifact_ids` does. + pub(crate) fn system_version(&self) -> Option { + self.artifacts_with_plan + .lock() + .unwrap() + .as_ref() + .map(|artifacts| artifacts.plan().system_version.clone()) + } + pub(crate) fn system_version_and_artifact_ids( &self, ) -> Option<(Version, Vec)> { diff --git a/wicketd/src/bin/wicketd.rs b/wicketd/src/bin/wicketd.rs index 042df085f67..f9a41dd0074 100644 --- a/wicketd/src/bin/wicketd.rs +++ b/wicketd/src/bin/wicketd.rs @@ -32,6 +32,11 @@ enum Args { #[clap(long, action)] artifact_address: SocketAddrV6, + /// The address (expected to be on localhost) for the stable + /// commissioning API server + #[clap(long, action)] + commission_address: SocketAddrV6, + /// The address (expected to be on localhost) for MGS #[clap(long, action)] mgs_address: SocketAddrV6, @@ -84,6 +89,7 @@ async fn do_run() -> Result<(), CmdError> { config_file_path, address, artifact_address, + commission_address, mgs_address, nexus_proxy_address, baseboard_file, @@ -127,6 +133,7 @@ async fn do_run() -> Result<(), CmdError> { let args = wicketd::Args { address, artifact_address, + commission_address, mgs_address, nexus_proxy_address, baseboard, diff --git a/wicketd/src/commission/conversions.rs b/wicketd/src/commission/conversions.rs new file mode 100644 index 00000000000..4852e468b6b --- /dev/null +++ b/wicketd/src/commission/conversions.rs @@ -0,0 +1,820 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +//! Conversions between internal and commission types. + +use bootstrap_agent_lockstep_types as bootstrap; +use iddqd::IdOrdMap; +use sled_agent_types::early_networking::SwitchSlot; +use transceiver_controller::message::ExtendedStatus; +use transceiver_controller::{ + CmisDatapath, CmisDatapathState, CmisLaneStatus, Datapath, Monitors, + Sff8636Datapath, Vendor, VendorInfo, +}; +use wicket_common::inventory::{ + RotInventory, SpComponentCaboose, SpIgnition, SpInventory, Transceiver, +}; +use wicketd_commission_types::inventory as ct_inv; +use wicketd_commission_types::rack_setup::{ + NewPasswordHash, RackOperationStatus, RssStepInfo, +}; +use wicketd_commission_types::update::StartUpdateOptions; + +use crate::transceivers::GetTransceiversResponse; + +fn caboose_to_ct(caboose: SpComponentCaboose) -> ct_inv::Caboose { + let SpComponentCaboose { + version, + board, + sign, + // The remaining fields are not projected by the commission API. + git_commit: _, + name: _, + epoch: _, + } = caboose; + ct_inv::Caboose { version, board, sign } +} + +fn slot_caboose_to_ct( + caboose: Option, +) -> ct_inv::SlotCaboose { + match caboose { + None => ct_inv::SlotCaboose::NotRead, + Some(caboose) => { + ct_inv::SlotCaboose::Read { caboose: caboose_to_ct(caboose) } + } + } +} + +fn stage0_caboose_to_ct( + caboose: Option>, +) -> ct_inv::Stage0Caboose { + match caboose { + None => ct_inv::Stage0Caboose::Unsupported, + Some(None) => ct_inv::Stage0Caboose::NotRead, + Some(Some(caboose)) => { + ct_inv::Stage0Caboose::Read { caboose: caboose_to_ct(caboose) } + } + } +} + +fn rot_info_to_ct(rot: RotInventory) -> ct_inv::RotInfo { + let RotInventory { + active, + caboose_a, + caboose_b, + caboose_stage0, + caboose_stage0next, + } = rot; + ct_inv::RotInfo { + active, + caboose_a: slot_caboose_to_ct(caboose_a), + caboose_b: slot_caboose_to_ct(caboose_b), + caboose_stage0: stage0_caboose_to_ct(caboose_stage0), + caboose_stage0next: stage0_caboose_to_ct(caboose_stage0next), + } +} + +fn ignition_to_ct(ignition: Option) -> ct_inv::SpIgnitionInfo { + match ignition { + None => ct_inv::SpIgnitionInfo::NotRead, + Some(SpIgnition::Absent) => ct_inv::SpIgnitionInfo::Absent, + Some(SpIgnition::Present { + power, + flt_a3, + flt_a2, + flt_rot, + flt_sp, + // The remaining fields are not projected by the commission API. + id: _, + ctrl_detect_0: _, + ctrl_detect_1: _, + }) => ct_inv::SpIgnitionInfo::Present { + power, + faults: ct_inv::IgnitionFaults { + a3: flt_a3, + a2: flt_a2, + rot: flt_rot, + sp: flt_sp, + }, + }, + } +} + +pub(crate) fn sp_info_to_ct(sp: SpInventory) -> ct_inv::SpInfo { + let SpInventory { + id, + ignition, + state, + caboose_active, + caboose_inactive, + rot, + // The raw MGS component list is not projected by the commission API. + components: _, + } = sp; + ct_inv::SpInfo { + id, + state: state.map(|s| ct_inv::SpStateInfo { + serial_number: s.serial_number, + power_state: s.power_state, + }), + ignition: ignition_to_ct(ignition), + caboose_active: slot_caboose_to_ct(caboose_active), + caboose_inactive: slot_caboose_to_ct(caboose_inactive), + rot: rot.map(rot_info_to_ct), + } +} + +pub(crate) fn transceivers_to_ct( + response: GetTransceiversResponse, +) -> ct_inv::TransceiverInventory { + match response { + GetTransceiversResponse::Unavailable => { + ct_inv::TransceiverInventory::NotRead + } + GetTransceiversResponse::Response { + transceivers, + transceivers_last_seen, + } => { + let switches = IdOrdMap::from_iter_unique( + transceivers.into_iter().map(|(switch, transceivers)| { + switch_transceivers_to_ct(switch, transceivers) + }), + ) + .expect( + "the internal transceiver inventory is keyed by SwitchSlot, so \ + the projected SwitchTransceivers have unique switch keys", + ); + ct_inv::TransceiverInventory::Read { + last_seen: transceivers_last_seen, + switches, + } + } + } +} + +fn switch_transceivers_to_ct( + switch: SwitchSlot, + transceivers: Vec, +) -> ct_inv::SwitchTransceivers { + let transceivers = IdOrdMap::from_iter_unique( + transceivers.into_iter().map(transceiver_to_ct), + ) + .expect( + "a switch reports at most one transceiver per front port, so the \ + projected Transceivers have unique port keys", + ); + ct_inv::SwitchTransceivers { switch, transceivers } +} + +fn transceiver_to_ct(transceiver: Transceiver) -> ct_inv::Transceiver { + let Transceiver { + port, + status, + vendor, + datapath, + monitors, + // The power mode is not projected by the commission API. + power: _, + } = transceiver; + ct_inv::Transceiver { + port, + status: transceiver_status_to_ct(status), + vendor: transceiver_vendor_to_ct(vendor), + monitors: transceiver_monitors_to_ct(monitors), + datapath: transceiver_datapath_to_ct(datapath), + } +} + +fn transceiver_status_to_ct( + status: Result, +) -> ct_inv::TransceiverStatus { + match status { + Err(message) => ct_inv::TransceiverStatus::Error { message }, + Ok(status) => ct_inv::TransceiverStatus::Read { + present: status.contains(ExtendedStatus::PRESENT), + enabled: status.contains(ExtendedStatus::ENABLED), + power_good: status.contains(ExtendedStatus::POWER_GOOD), + }, + } +} + +fn transceiver_vendor_to_ct( + vendor: Result, +) -> ct_inv::TransceiverVendor { + match vendor { + Err(message) => ct_inv::TransceiverVendor::Error { message }, + Ok(VendorInfo { + vendor: + Vendor { + name, + part, + serial, + // The remaining vendor fields are not projected. + oui: _, + revision: _, + date: _, + }, + // The SFF-8024 identifier is not projected. + identifier: _, + }) => ct_inv::TransceiverVendor::Read { name, part, serial }, + } +} + +fn transceiver_monitors_to_ct( + monitors: Result, +) -> ct_inv::TransceiverMonitors { + match monitors { + Err(message) => ct_inv::TransceiverMonitors::Error { message }, + Ok(Monitors { + receiver_power, + transmitter_power, + // The remaining monitors are not projected. + temperature: _, + supply_voltage: _, + transmitter_bias_current: _, + aux_monitors: _, + }) => ct_inv::TransceiverMonitors::Read { + rx_power_mw: receiver_power.map(|powers| { + powers.into_iter().map(|power| power.value()).collect() + }), + tx_power_mw: transmitter_power, + }, + } +} + +fn transceiver_datapath_to_ct( + datapath: Result, +) -> ct_inv::TransceiverDatapath { + match datapath { + Err(message) => ct_inv::TransceiverDatapath::Error { message }, + Ok(Datapath::Sff8636 { lanes, connector: _, specification: _ }) => { + let lanes = lanes.map(sff8636_lane_to_ct); + ct_inv::TransceiverDatapath::Sff8636 { lanes } + } + Ok(Datapath::Cmis { datapaths, connector: _, supported_lanes: _ }) => { + let datapaths = IdOrdMap::from_iter_unique( + datapaths.into_iter().map(|(application, datapath)| { + cmis_datapath_to_ct(application, datapath) + }), + ) + .expect( + "the internal CMIS datapaths are keyed by application selector \ + code (a BTreeMap), so the projected CmisDatapaths have \ + unique application keys", + ); + ct_inv::TransceiverDatapath::Cmis { datapaths } + } + } +} + +fn sff8636_lane_to_ct(lane: Sff8636Datapath) -> ct_inv::Sff8636LaneFaults { + let Sff8636Datapath { + rx_los, + tx_los, + rx_lol, + tx_lol, + tx_fault, + tx_enabled: _, + tx_adaptive_eq_fault: _, + tx_cdr_enabled: _, + rx_cdr_enabled: _, + } = lane; + ct_inv::Sff8636LaneFaults { rx_los, tx_los, rx_lol, tx_lol, tx_fault } +} + +fn cmis_datapath_to_ct( + application: u8, + datapath: CmisDatapath, +) -> ct_inv::CmisDatapath { + let CmisDatapath { lane_status, application: _ } = datapath; + let lanes = IdOrdMap::from_iter_unique( + lane_status + .into_iter() + .map(|(lane, status)| cmis_lane_to_ct(lane, status)), + ) + .expect( + "the internal CMIS lane statuses are keyed by lane number (a \ + BTreeMap), so the projected CmisLaneStatuses have unique lane \ + keys", + ); + ct_inv::CmisDatapath { application, lanes } +} + +fn cmis_lane_to_ct(lane: u8, status: CmisLaneStatus) -> ct_inv::CmisLaneStatus { + let CmisLaneStatus { + state, + rx_los, + tx_los, + rx_lol, + tx_lol, + tx_failure, + tx_input_polarity: _, + tx_output_enabled: _, + tx_auto_squelch_disable: _, + tx_force_squelch: _, + rx_output_polarity: _, + rx_output_enabled: _, + rx_auto_squelch_disable: _, + rx_output_status: _, + tx_output_status: _, + tx_adaptive_eq_fail: _, + } = status; + ct_inv::CmisLaneStatus { + lane, + state: cmis_datapath_state_to_ct(state), + rx_los: cmis_flag_to_ct(rx_los), + tx_los: cmis_flag_to_ct(tx_los), + rx_lol: cmis_flag_to_ct(rx_lol), + tx_lol: cmis_flag_to_ct(tx_lol), + tx_fault: cmis_flag_to_ct(tx_failure), + } +} + +fn cmis_flag_to_ct(flag: Option) -> ct_inv::FaultFlag { + match flag { + None => ct_inv::FaultFlag::Unsupported, + Some(true) => ct_inv::FaultFlag::Asserted, + Some(false) => ct_inv::FaultFlag::Clear, + } +} + +fn cmis_datapath_state_to_ct( + state: CmisDatapathState, +) -> ct_inv::CmisDatapathState { + match state { + CmisDatapathState::Deactivated => { + ct_inv::CmisDatapathState::Deactivated + } + CmisDatapathState::Init => ct_inv::CmisDatapathState::Init, + CmisDatapathState::Deinit => ct_inv::CmisDatapathState::Deinit, + CmisDatapathState::Activated => ct_inv::CmisDatapathState::Activated, + CmisDatapathState::TxTurnOn => ct_inv::CmisDatapathState::TxTurnOn, + CmisDatapathState::TxTurnOff => ct_inv::CmisDatapathState::TxTurnOff, + CmisDatapathState::Initialized => { + ct_inv::CmisDatapathState::Initialized + } + } +} + +fn rss_step_to_ct(step: bootstrap::RssStep) -> RssStepInfo { + RssStepInfo { + // index() is 0-based, so add 1 to get the 1-based step index. + step: step.index() as u32 + 1, + total_steps: step.max_step() as u32, + description: step.description().to_string(), + } +} + +pub(crate) fn rack_operation_status_to_ct( + status: bootstrap::RackOperationStatus, +) -> RackOperationStatus { + use bootstrap::RackOperationStatus as B; + match status { + B::Initializing { id, step } => { + RackOperationStatus::Initializing { id, step: rss_step_to_ct(step) } + } + B::Initialized { id } => RackOperationStatus::Initialized { id }, + B::InitializationFailed { id, message } => { + RackOperationStatus::InitializationFailed { id, message } + } + B::InitializationPanicked { id } => { + RackOperationStatus::InitializationPanicked { id } + } + B::Resetting { id } => RackOperationStatus::Resetting { id }, + B::Uninitialized { reset_id } => { + RackOperationStatus::Uninitialized { reset_id } + } + B::ResetFailed { id, message } => { + RackOperationStatus::ResetFailed { id, message } + } + B::ResetPanicked { id } => RackOperationStatus::ResetPanicked { id }, + } +} + +pub(crate) fn start_update_options_to_internal( + options: StartUpdateOptions, +) -> wicket_common::rack_update::StartUpdateOptions { + let StartUpdateOptions { + skip_rot_bootloader_version_check, + skip_rot_version_check, + skip_sp_version_check, + } = options; + // The commission API deliberately does not expose the test-only knobs + // provided by the internal update API. + wicket_common::rack_update::StartUpdateOptions { + test_error: None, + test_step_seconds: None, + test_simulate_rot_bootloader_result: None, + test_simulate_rot_result: None, + test_simulate_sp_result: None, + skip_rot_bootloader_version_check, + skip_rot_version_check, + skip_sp_version_check, + } +} + +pub(crate) fn password_hash_to_internal( + hash: NewPasswordHash, +) -> Result { + hash.0.parse::().map_err(|err| { + format!("invalid recovery password hash (PHC string): {err}") + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::{BTreeMap, HashMap}; + use std::time::Duration; + use transceiver_controller::{ + ApplicationDescriptor, CmisDatapathState, ConnectorType, + HostElectricalInterfaceId, Identifier, MediaInterfaceId, MediaType, + Oui, OutputStatus, ReceiverPower, SffComplianceCode, + }; + + fn sample_application_descriptor() -> ApplicationDescriptor { + ApplicationDescriptor { + host_id: HostElectricalInterfaceId::from(0), + media_id: MediaInterfaceId::from_u8(MediaType::MultiModeFiber, 0) + .expect("multi-mode fiber has a media interface id"), + host_lane_count: 4, + media_lane_count: 4, + host_lane_assignment_options: 0x01, + media_lane_assignment_options: 0x01, + } + } + + fn sample_vendor_info() -> VendorInfo { + VendorInfo { + identifier: Identifier::Qsfp28, + vendor: Vendor { + name: "Acme".to_string(), + oui: Oui([0, 1, 2]), + part: "PN-1".to_string(), + revision: "A".to_string(), + serial: "SN-1".to_string(), + date: None, + }, + } + } + + fn sample_cmis_lane() -> CmisLaneStatus { + CmisLaneStatus { + state: CmisDatapathState::Activated, + tx_input_polarity: None, + tx_output_enabled: None, + tx_auto_squelch_disable: None, + tx_force_squelch: None, + rx_output_polarity: None, + rx_output_enabled: None, + rx_auto_squelch_disable: None, + rx_output_status: OutputStatus::Valid, + tx_output_status: OutputStatus::Valid, + tx_failure: Some(true), + tx_los: None, + tx_lol: Some(true), + tx_adaptive_eq_fail: None, + rx_los: Some(true), + rx_lol: None, + } + } + + #[test] + fn caboose_projects_sign() { + let with_sign = SpComponentCaboose { + git_commit: "abc".to_string(), + board: "gimlet".to_string(), + name: "sp".to_string(), + version: "1.0.0".to_string(), + sign: Some("deadbeef".to_string()), + epoch: None, + }; + assert_eq!( + caboose_to_ct(with_sign), + ct_inv::Caboose { + version: "1.0.0".to_string(), + board: "gimlet".to_string(), + sign: Some("deadbeef".to_string()), + }, + ); + + let without_sign = SpComponentCaboose { + git_commit: "abc".to_string(), + board: "gimlet".to_string(), + name: "sp".to_string(), + version: "1.0.0".to_string(), + sign: None, + epoch: None, + }; + assert_eq!(caboose_to_ct(without_sign).sign, None); + } + + #[test] + fn transceiver_status_projects_flags() { + assert_eq!( + transceiver_status_to_ct(Ok( + ExtendedStatus::PRESENT | ExtendedStatus::POWER_GOOD + )), + ct_inv::TransceiverStatus::Read { + present: true, + enabled: false, + power_good: true, + }, + ); + assert_eq!( + transceiver_status_to_ct(Err("status read failed".to_string())), + ct_inv::TransceiverStatus::Error { + message: "status read failed".to_string(), + }, + ); + } + + #[test] + fn transceiver_vendor_projects_identity() { + assert_eq!( + transceiver_vendor_to_ct(Ok(sample_vendor_info())), + ct_inv::TransceiverVendor::Read { + name: "Acme".to_string(), + part: "PN-1".to_string(), + serial: "SN-1".to_string(), + }, + ); + assert_eq!( + transceiver_vendor_to_ct(Err("vendor read failed".to_string())), + ct_inv::TransceiverVendor::Error { + message: "vendor read failed".to_string(), + }, + ); + } + + #[test] + fn transceiver_monitors_projects_power() { + let monitors = Monitors { + receiver_power: Some(vec![ + ReceiverPower::Average(1.5), + ReceiverPower::PeakToPeak(2.5), + ]), + transmitter_power: Some(vec![3.5]), + ..Default::default() + }; + assert_eq!( + transceiver_monitors_to_ct(Ok(monitors)), + ct_inv::TransceiverMonitors::Read { + rx_power_mw: Some(vec![1.5, 2.5]), + tx_power_mw: Some(vec![3.5]), + }, + ); + + // A module reporting no power monitoring stays None. + assert_eq!( + transceiver_monitors_to_ct(Ok(Monitors::default())), + ct_inv::TransceiverMonitors::Read { + rx_power_mw: None, + tx_power_mw: None, + }, + ); + + assert_eq!( + transceiver_monitors_to_ct(Err("monitors read failed".to_string())), + ct_inv::TransceiverMonitors::Error { + message: "monitors read failed".to_string(), + }, + ); + } + + #[test] + fn transceiver_datapath_projects_sff8636_lanes() { + let lanes = [ + Sff8636Datapath { rx_los: true, ..Default::default() }, + Sff8636Datapath { tx_fault: true, ..Default::default() }, + Sff8636Datapath::default(), + Sff8636Datapath { + tx_lol: true, + rx_lol: true, + ..Default::default() + }, + ]; + let datapath = Datapath::Sff8636 { + connector: ConnectorType::Unknown, + specification: SffComplianceCode::new(0x04, 0), + lanes, + }; + let projected = transceiver_datapath_to_ct(Ok(datapath)); + let ct_inv::TransceiverDatapath::Sff8636 { lanes } = projected else { + panic!("expected Sff8636 datapath, got {projected:?}"); + }; + assert_eq!( + lanes, + [ + ct_inv::Sff8636LaneFaults { + rx_los: true, + tx_los: false, + rx_lol: false, + tx_lol: false, + tx_fault: false, + }, + ct_inv::Sff8636LaneFaults { + rx_los: false, + tx_los: false, + rx_lol: false, + tx_lol: false, + tx_fault: true, + }, + ct_inv::Sff8636LaneFaults { + rx_los: false, + tx_los: false, + rx_lol: false, + tx_lol: false, + tx_fault: false, + }, + ct_inv::Sff8636LaneFaults { + rx_los: false, + tx_los: false, + rx_lol: true, + tx_lol: true, + tx_fault: false, + }, + ], + ); + } + + #[test] + fn cmis_lane_projects_fault_flags_and_state() { + let lane = CmisLaneStatus { + state: CmisDatapathState::TxTurnOn, + tx_failure: Some(true), + tx_los: Some(false), + rx_los: None, + rx_lol: Some(true), + tx_lol: None, + ..sample_cmis_lane() + }; + assert_eq!( + cmis_lane_to_ct(7, lane), + ct_inv::CmisLaneStatus { + lane: 7, + state: ct_inv::CmisDatapathState::TxTurnOn, + rx_los: ct_inv::FaultFlag::Unsupported, + tx_los: ct_inv::FaultFlag::Clear, + rx_lol: ct_inv::FaultFlag::Asserted, + tx_lol: ct_inv::FaultFlag::Unsupported, + tx_fault: ct_inv::FaultFlag::Asserted, + }, + ); + } + + #[test] + fn cmis_datapath_state_maps_all_variants() { + let cases = [ + ( + CmisDatapathState::Deactivated, + ct_inv::CmisDatapathState::Deactivated, + ), + (CmisDatapathState::Init, ct_inv::CmisDatapathState::Init), + (CmisDatapathState::Deinit, ct_inv::CmisDatapathState::Deinit), + ( + CmisDatapathState::Activated, + ct_inv::CmisDatapathState::Activated, + ), + (CmisDatapathState::TxTurnOn, ct_inv::CmisDatapathState::TxTurnOn), + ( + CmisDatapathState::TxTurnOff, + ct_inv::CmisDatapathState::TxTurnOff, + ), + ( + CmisDatapathState::Initialized, + ct_inv::CmisDatapathState::Initialized, + ), + ]; + for (internal, expected) in cases { + assert_eq!(cmis_datapath_state_to_ct(internal), expected); + } + } + + #[test] + fn transceiver_datapath_projects_cmis_preserving_keys() { + let mut lane_status = BTreeMap::new(); + lane_status.insert(0, sample_cmis_lane()); + lane_status.insert( + 3, + CmisLaneStatus { rx_los: Some(false), ..sample_cmis_lane() }, + ); + let mut datapaths = BTreeMap::new(); + datapaths.insert( + 2u8, + CmisDatapath { + application: sample_application_descriptor(), + lane_status, + }, + ); + let projected = transceiver_datapath_to_ct(Ok(Datapath::Cmis { + connector: ConnectorType::Unknown, + supported_lanes: 0x0f, + datapaths, + })); + let ct_inv::TransceiverDatapath::Cmis { datapaths } = projected else { + panic!("expected Cmis datapath, got {projected:?}"); + }; + let datapath = + datapaths.get(&2).expect("application selector code 2 present"); + assert_eq!(datapath.application, 2); + assert_eq!(datapath.lanes.len(), 2); + let lane0 = datapath.lanes.get(&0).expect("lane 0 present"); + assert_eq!(lane0.lane, 0); + assert_eq!(lane0.rx_los, ct_inv::FaultFlag::Asserted); + let lane3 = datapath.lanes.get(&3).expect("lane 3 present"); + assert_eq!(lane3.lane, 3); + assert_eq!(lane3.rx_los, ct_inv::FaultFlag::Clear); + } + + #[test] + fn transceiver_datapath_projects_empty_cmis_and_error() { + let datapath = Datapath::Cmis { + connector: ConnectorType::Unknown, + supported_lanes: 0x0f, + datapaths: BTreeMap::new(), + }; + let projected = transceiver_datapath_to_ct(Ok(datapath)); + let ct_inv::TransceiverDatapath::Cmis { datapaths } = projected else { + panic!("expected Cmis datapath, got {projected:?}"); + }; + assert!(datapaths.is_empty()); + assert_eq!( + transceiver_datapath_to_ct(Err("datapath read failed".to_string())), + ct_inv::TransceiverDatapath::Error { + message: "datapath read failed".to_string(), + }, + ); + } + + #[test] + fn transceivers_unavailable_is_not_read() { + assert_eq!( + transceivers_to_ct(GetTransceiversResponse::Unavailable), + ct_inv::TransceiverInventory::NotRead, + ); + } + + #[test] + fn transceivers_response_projects_by_switch_and_port() { + let transceiver = Transceiver { + port: "qsfp0".to_string(), + status: Ok(ExtendedStatus::PRESENT + | ExtendedStatus::ENABLED + | ExtendedStatus::POWER_GOOD), + // The power mode is not included in the projection. + power: Err("power mode not read".to_string()), + vendor: Ok(sample_vendor_info()), + datapath: Ok(Datapath::Sff8636 { + connector: ConnectorType::Unknown, + specification: SffComplianceCode::new(0x04, 0), + lanes: [Sff8636Datapath::default(); 4], + }), + monitors: Ok(Monitors::default()), + }; + let response = GetTransceiversResponse::Response { + transceivers: HashMap::from([( + SwitchSlot::Switch0, + vec![transceiver], + )]), + transceivers_last_seen: Duration::from_secs(3), + }; + + let projected = transceivers_to_ct(response); + let ct_inv::TransceiverInventory::Read { last_seen, switches } = + projected + else { + panic!("expected Read inventory, got {projected:?}"); + }; + assert_eq!(last_seen, Duration::from_secs(3)); + + let switch = switches + .iter() + .find(|s| s.switch == SwitchSlot::Switch0) + .expect("switch 0 present"); + assert_eq!(switch.transceivers.len(), 1); + let port = switch + .transceivers + .iter() + .find(|t| t.port == "qsfp0") + .expect("qsfp0 present"); + assert_eq!( + port.status, + ct_inv::TransceiverStatus::Read { + present: true, + enabled: true, + power_good: true, + }, + ); + assert_eq!( + port.vendor, + ct_inv::TransceiverVendor::Read { + name: "Acme".to_string(), + part: "PN-1".to_string(), + serial: "SN-1".to_string(), + }, + ); + } +} diff --git a/wicketd/src/commission/http_entrypoints.rs b/wicketd/src/commission/http_entrypoints.rs new file mode 100644 index 00000000000..f55dc8a14e4 --- /dev/null +++ b/wicketd/src/commission/http_entrypoints.rs @@ -0,0 +1,401 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +use std::sync::Arc; +use std::time::Duration; + +use bootstrap_agent_lockstep_client::ClientInfo as _; +use dropshot::{ + ApiDescription, HttpError, HttpResponseOk, HttpResponseUpdatedNoContent, + RequestContext, StreamingBody, TypedBody, +}; +use iddqd::IdOrdMap; +use omicron_uuid_kinds::RackInitUuid; +use wicket_common::inventory::SledInventory; +use wicketd_commission_api::{ + WicketdCommissionApi, wicketd_commission_api_mod, +}; +use wicketd_commission_types::inventory::{ + BootstrapSled, LocationInfo, SpIdentifier, SpInventory, SpInventoryParams, + SwitchSlot, +}; +use wicketd_commission_types::rack_setup::{ + CertificateUploadResponse, PutRecoveryUserPasswordHash, + PutRssUserConfigInsensitive, RackOperationStatus, +}; +use wicketd_commission_types::update::{ + ClearUpdateStateParams, ClearUpdateStateResponse, RepositoryDescription, + SpUpdateProgress, StartUpdateParams, +}; + +use super::conversions; +use super::progress; +use crate::ServerContext; +use crate::helpers::SpIdentifierDisplay; +use crate::http_helpers::{ + ba_lockstep_client, ba_lockstep_error_to_http, http_error_with_message, + inventory_unavailable, mgs_inventory_or_unavail, start_update, +}; +use crate::mgs::GetInventoryResponse as MgsInventoryResponse; + +/// How long to wait for a forced SP refresh. +/// +/// This is comfortably above the ~1s it takes to do a normal refresh, and below +/// Progenitor's default 15s client timeout. +const SP_REFRESH_TIMEOUT: Duration = Duration::from_secs(10); + +type CommissionApiDescription = ApiDescription>; + +pub fn api() -> CommissionApiDescription { + wicketd_commission_api_mod::api_description::() + .expect("registered commission entrypoints") +} + +pub enum WicketdCommissionApiImpl {} + +impl WicketdCommissionApi for WicketdCommissionApiImpl { + type Context = Arc; + + async fn get_sp_inventory( + rqctx: RequestContext, + params: TypedBody, + ) -> Result, HttpError> { + let ctx = rqctx.context(); + let force_refresh = params.into_inner().force_refresh; + + let response = tokio::time::timeout( + SP_REFRESH_TIMEOUT, + ctx.mgs_handle.get_inventory_refreshing_sps(force_refresh.clone()), + ) + .await + .map_err(|_elapsed| { + http_error_with_message( + dropshot::ErrorStatusCode::SERVICE_UNAVAILABLE, + Some("SpRefreshTimeout".to_string()), + format!( + "timed out after {}s waiting for refreshed state of [{}]; \ + the SPs may be unresponsive or MGS may be down (see \ + wicketd logs for details)", + SP_REFRESH_TIMEOUT.as_secs(), + force_refresh + .iter() + .map(|id| SpIdentifierDisplay(*id).to_string()) + .collect::>() + .join(", "), + ), + ) + })? + .map_err(|err| err.to_http_error())?; + + match response { + MgsInventoryResponse::Response { inventory, mgs_last_seen } => { + let sps = IdOrdMap::from_iter_unique( + inventory.sps.into_iter().map(conversions::sp_info_to_ct), + ) + .expect( + "MGS inventory holds at most one entry per SpIdentifier, \ + so the projected SpInfos have unique ids", + ); + let transceivers = conversions::transceivers_to_ct( + ctx.transceiver_handle.get_transceivers(), + ); + Ok(HttpResponseOk(SpInventory { + mgs_last_seen, + sps, + transceivers, + })) + } + MgsInventoryResponse::Unavailable => Err(inventory_unavailable()), + } + } + + async fn get_location( + rqctx: RequestContext, + ) -> Result, HttpError> { + let ctx = rqctx.context(); + let inventory = mgs_inventory_or_unavail(&ctx.mgs_handle).await?; + + let switch_id = + ctx.local_switch_id().await.map_err(|err| err.to_http_error())?; + + let switch_serial = inventory + .sps + .iter() + .find(|sp| sp.id == switch_id) + .and_then(|sp| sp.state.as_ref()) + .map(|state| state.serial_number.clone()); + + let sled_serial = + ctx.baseboard.as_ref().map(|b| b.identifier().to_string()); + + let switch_slot = match switch_id.slot { + 0 => SwitchSlot::Switch0, + 1 => SwitchSlot::Switch1, + other => { + return Err(http_error_with_message( + dropshot::ErrorStatusCode::INTERNAL_SERVER_ERROR, + None, + format!( + "wicketd derived an invalid local switch slot \ + ({other}); expected 0 or 1" + ), + )); + } + }; + + Ok(HttpResponseOk(LocationInfo { + switch_slot, + switch_serial, + sled_serial, + })) + } + + async fn get_bootstrap_sleds( + rqctx: RequestContext, + ) -> Result>, HttpError> { + let ctx = rqctx.context(); + let inventory = mgs_inventory_or_unavail(&ctx.mgs_handle).await?; + + let ddm_discovered_sleds = ctx.bootstrap_peers.sleds(); + let sled_inventory = + SledInventory::new(&inventory, &ddm_discovered_sleds, &ctx.log); + + let sleds = IdOrdMap::from_iter_unique( + sled_inventory.sleds.into_iter().map(|desc| BootstrapSled { + id: desc.id, + serial_number: desc.baseboard.identifier().to_string(), + ip: desc.bootstrap_ip, + }), + ) + .expect( + "SledInventory holds at most one sled per SpIdentifier, so the \ + projected BootstrapSleds have unique ids", + ); + + Ok(HttpResponseOk(sleds)) + } + + async fn get_repository( + rqctx: RequestContext, + ) -> Result, HttpError> { + let ctx = rqctx.context(); + let system_version = ctx.update_tracker.system_version().await; + Ok(HttpResponseOk(RepositoryDescription { system_version })) + } + + async fn put_repository( + rqctx: RequestContext, + body: StreamingBody, + ) -> Result { + let ctx = rqctx.context(); + ctx.update_tracker.put_repository(body.into_stream()).await?; + Ok(HttpResponseUpdatedNoContent()) + } + + async fn get_update_progress( + rqctx: RequestContext, + ) -> Result>, HttpError> { + let ctx = rqctx.context(); + let event_reports = ctx.update_tracker.event_reports().await; + + // event_reports is keyed by (sp_type, slot), so the derived + // SpIdentifiers are unique by construction. + // + // TODO: once rkdeploy is on the published API, we can make + // `event_reports` be an IdOrdMap and make this much simpler. + let mut entries = IdOrdMap::new(); + for (sp_type, slots) in event_reports { + for (slot, report) in slots { + entries + .insert_unique(progress::sp_update_progress( + SpIdentifier { typ: sp_type, slot }, + report, + )) + .expect( + "event_reports is keyed by (sp_type, slot), so SP ids \ + are unique", + ); + } + } + + Ok(HttpResponseOk(entries)) + } + + async fn post_start_update( + rqctx: RequestContext, + params: TypedBody, + ) -> Result { + let ctx = rqctx.context(); + let log = &rqctx.log; + let params = params.into_inner(); + + let options = + conversions::start_update_options_to_internal(params.options); + + start_update(ctx, log, params.targets, options).await?; + Ok(HttpResponseUpdatedNoContent()) + } + + async fn post_clear_update_state( + rqctx: RequestContext, + params: TypedBody, + ) -> Result, HttpError> { + let ctx = rqctx.context(); + let targets = params.into_inner().targets; + + let response = ctx + .update_tracker + .clear_update_state(targets) + .await + .map_err(|err| err.to_http_error())?; + Ok(HttpResponseOk(response)) + } + + async fn get_rack_setup_state( + rqctx: RequestContext, + ) -> Result, HttpError> { + let ctx = rqctx.context(); + + let client = ba_lockstep_client(ctx)?; + + let op_status = client + .rack_initialization_status() + .await + .map_err(|err| ba_lockstep_error_to_http(err, "rack setup"))? + .into_inner(); + + Ok(HttpResponseOk(conversions::rack_operation_status_to_ct(op_status))) + } + + async fn put_rss_config( + rqctx: RequestContext, + body: TypedBody, + ) -> Result { + let ctx = rqctx.context(); + + let config = body.into_inner(); + + let inventory = mgs_inventory_or_unavail(&ctx.mgs_handle).await?; + + let mut guard = ctx.rss_or_multirack_join_config.lock().unwrap(); + let rss_config = guard.rss_config_mut_or_default(); + + let ddm_discovered_sleds = ctx.bootstrap_peers.sleds(); + rss_config + .update( + config, + ctx.baseboard.as_ref(), + &inventory, + &ddm_discovered_sleds, + &ctx.log, + ) + .map_err(|err| HttpError::for_bad_request(None, err))?; + + Ok(HttpResponseUpdatedNoContent()) + } + + async fn delete_rss_config( + rqctx: RequestContext, + ) -> Result { + let ctx = rqctx.context(); + + let mut guard = ctx.rss_or_multirack_join_config.lock().unwrap(); + let rss_config = guard.rss_config_mut_or_conflict( + "cannot delete RSS config when not preparing for RSS", + )?; + + *rss_config = Default::default(); + + Ok(HttpResponseUpdatedNoContent()) + } + + async fn post_rss_config_cert( + rqctx: RequestContext, + body: TypedBody, + ) -> Result, HttpError> { + let ctx = rqctx.context(); + + let mut guard = ctx.rss_or_multirack_join_config.lock().unwrap(); + let rss_config = guard.rss_config_mut_or_conflict( + "cannot post certificates when not preparing for RSS", + )?; + + let response = rss_config + .push_cert(body.into_inner()) + .map_err(|err| HttpError::for_bad_request(None, err))?; + + Ok(HttpResponseOk(response)) + } + + async fn post_rss_config_key( + rqctx: RequestContext, + body: TypedBody, + ) -> Result, HttpError> { + let ctx = rqctx.context(); + + let mut guard = ctx.rss_or_multirack_join_config.lock().unwrap(); + let rss_config = guard.rss_config_mut_or_conflict( + "cannot post private keys when not preparing for RSS", + )?; + + let response = rss_config + .push_key(body.into_inner()) + .map_err(|err| HttpError::for_bad_request(None, err))?; + + Ok(HttpResponseOk(response)) + } + + async fn put_rss_config_recovery_user_password_hash( + rqctx: RequestContext, + body: TypedBody, + ) -> Result { + let ctx = rqctx.context(); + + let hash = + conversions::password_hash_to_internal(body.into_inner().hash) + .map_err(|err| HttpError::for_bad_request(None, err))?; + + let mut guard = ctx.rss_or_multirack_join_config.lock().unwrap(); + let rss_config = guard.rss_config_mut_or_conflict( + "cannot put recovery user password when not preparing for RSS", + )?; + + rss_config.set_recovery_user_password_hash(hash); + + Ok(HttpResponseUpdatedNoContent()) + } + + async fn post_run_rack_setup( + rqctx: RequestContext, + ) -> Result, HttpError> { + let ctx = rqctx.context(); + let log = &rqctx.log; + + let client = ba_lockstep_client(ctx)?; + + let request = { + let mut guard = ctx.rss_or_multirack_join_config.lock().unwrap(); + let rss_config = guard.rss_config_mut_or_conflict( + "cannot run rack setup when not preparing for RSS", + )?; + rss_config.start_rss_request(&ctx.bootstrap_peers, log).map_err( + |err| HttpError::for_bad_request(None, format!("{err:#}")), + )? + }; + + slog::info!( + ctx.log, + "Sending RSS initialize request to {}", + client.baseurl() + ); + + let init_id = client + .rack_initialize(&request) + .await + .map_err(|err| ba_lockstep_error_to_http(err, "rack setup"))? + .into_inner(); + + Ok(HttpResponseOk(init_id)) + } +} diff --git a/wicketd/src/commission/mod.rs b/wicketd/src/commission/mod.rs new file mode 100644 index 00000000000..bbd03d2398e --- /dev/null +++ b/wicketd/src/commission/mod.rs @@ -0,0 +1,18 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +//! The stable wicketd commissioning API. +//! +//! This API is used by automated tooling such as rkdeploy to commission new +//! racks. It is a reduced subset of the full, unstable wicketd API. +//! +//! **Automation must always use the stable API!** +//! +//! For more information, see RFD 710. + +mod conversions; +mod http_entrypoints; +mod progress; + +pub use http_entrypoints::api; diff --git a/wicketd/src/commission/progress.rs b/wicketd/src/commission/progress.rs new file mode 100644 index 00000000000..5df11b6d71f --- /dev/null +++ b/wicketd/src/commission/progress.rs @@ -0,0 +1,383 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +use std::collections::HashMap; + +use oxide_update_engine_types::buffer::{ + AbortReason, CompletionReason, ExecutionStatus, ExecutionSummary, + FailureReason, StepKey, StepStatus, TerminalKind, WillNotBeRunReason, +}; +use oxide_update_engine_types::events::{ + ExecutionUuid, LeafProgress, ProgressCounter, ProgressEventKind, + StepOutcome as EngineStepOutcome, +}; +use oxide_update_engine_types::spec::{EngineSpec, GenericSpec}; +use wicket_common::update_events::{ + EventBuffer, EventBufferStepData, EventReport, +}; +use wicketd_commission_types::inventory::SpIdentifier; +use wicketd_commission_types::update::{ + RunningProgress, SpUpdateProgress, StepOutcome, StepProgress, + UpdateProgress, UpdateState, UpdateStep, UpdateStepStatus, +}; + +fn completed_outcome(reason: &CompletionReason) -> StepOutcome { + match reason { + CompletionReason::StepCompleted(info) => engine_outcome(&info.outcome), + // The engine can't record an outcome for a step completed only because + // a later step started. Report success but say that the outcome was + // inferred. + CompletionReason::SubsequentStarted { .. } => StepOutcome::Success { + message: Some( + "step completed; exact outcome not recorded (inferred from a \ + subsequent step starting)" + .to_string(), + ), + }, + // Same for a step being marked completed because the parent was + // completed. + CompletionReason::ParentCompleted { parent_info, .. } => { + StepOutcome::Success { + message: Some(format!( + "step completed; exact outcome not recorded (inferred \ + from parent completion; {})", + parent_outcome_detail(&parent_info.outcome), + )), + } + } + } +} + +fn engine_outcome(outcome: &EngineStepOutcome) -> StepOutcome { + match outcome { + EngineStepOutcome::Success { message, .. } => { + StepOutcome::Success { message: message.clone().map(Into::into) } + } + EngineStepOutcome::Warning { message, .. } => { + StepOutcome::Warning { message: message.to_string() } + } + EngineStepOutcome::Skipped { message, .. } => { + StepOutcome::Skipped { message: message.to_string() } + } + } +} + +fn parent_outcome_detail(outcome: &EngineStepOutcome) -> String { + match outcome { + EngineStepOutcome::Success { message: Some(message), .. } => { + format!("parent succeeded: {message}") + } + EngineStepOutcome::Success { message: None, .. } => { + "parent succeeded".to_string() + } + EngineStepOutcome::Warning { message, .. } => { + format!("parent completed with warning: {message}") + } + EngineStepOutcome::Skipped { message, .. } => { + format!("parent was skipped: {message}") + } + } +} + +fn terminal_message( + description: &str, + detail: String, + empty_fallback: &str, +) -> String { + let line = if description.is_empty() { + detail + } else if detail.is_empty() { + description.to_string() + } else { + format!("{description}: {detail}") + }; + if line.is_empty() { empty_fallback.to_string() } else { line } +} + +fn failed_message(event_buffer: &EventBuffer, step_key: &StepKey) -> String { + let data = event_buffer + .get(step_key) + .expect("terminal step key is present in the buffer it came from"); + let reason = data + .step_status() + .failure_reason() + .expect("step with TerminalKind::Failed has StepStatus::Failed"); + terminal_message( + data.step_info().description.as_ref(), + reason.message_display(event_buffer).to_string(), + "update failed; no failure details were recorded in the event buffer", + ) +} + +fn aborted_message(event_buffer: &EventBuffer, step_key: &StepKey) -> String { + let data = event_buffer + .get(step_key) + .expect("terminal step key is present in the buffer it came from"); + let reason = data + .step_status() + .abort_reason() + .expect("step with TerminalKind::Aborted has StepStatus::Aborted"); + terminal_message( + data.step_info().description.as_ref(), + reason.message_display(event_buffer).to_string(), + "update aborted; no abort details were recorded in the event buffer", + ) +} + +fn step_progress_from_counter(counter: &ProgressCounter) -> StepProgress { + StepProgress { + current: counter.current, + total: counter.total, + units: counter.units.0.to_string(), + } +} + +fn running_progress( + kind: &ProgressEventKind, +) -> RunningProgress { + match kind.leaf_progress() { + LeafProgress::Progress(counter) => RunningProgress::Counter { + progress: counter.map(step_progress_from_counter), + }, + LeafProgress::WaitingForProgress => RunningProgress::WaitingForProgress, + // Unknown means an event couldn't be deserialized. The EventBuffer + // drops these events so this branch is unreachable in practice. + LeafProgress::Unknown => RunningProgress::Counter { progress: None }, + } +} + +fn step_description( + event_buffer: &EventBuffer, + step_key: &StepKey, +) -> Option { + event_buffer + .get(step_key) + .map(|data| data.step_info().description.to_string()) +} + +fn will_not_be_run_message( + event_buffer: &EventBuffer, + reason: &WillNotBeRunReason, +) -> String { + let (relation, outcome, step) = match reason { + WillNotBeRunReason::PreviousStepFailed { step } => { + ("a previous", "failed", step) + } + WillNotBeRunReason::ParentStepFailed { step } => { + ("a parent", "failed", step) + } + WillNotBeRunReason::PreviousStepAborted { step } => { + ("a previous", "was aborted", step) + } + WillNotBeRunReason::ParentAborted { step } => { + ("a parent", "was aborted", step) + } + }; + match step_description(event_buffer, step) { + Some(description) => format!( + "will not run because {relation} step (\"{description}\") {outcome}" + ), + None => format!("will not run because {relation} step {outcome}"), + } +} + +struct ProgressBuilder<'a> { + event_buffer: &'a EventBuffer, + // A cache of the execution summary for each execution ID. + summaries: HashMap, +} + +impl<'a> ProgressBuilder<'a> { + fn new(event_buffer: &'a EventBuffer) -> Self { + let summaries: HashMap<_, _> = + event_buffer.steps().summarize().into_iter().collect(); + Self { event_buffer, summaries } + } + + fn build_execution(&self, execution_id: ExecutionUuid) -> UpdateProgress { + let state = self.execution_state(execution_id); + let steps = self + .event_buffer + .iter_steps_for_execution(execution_id) + .map(|(_, data)| self.build_step(data)) + .collect(); + UpdateProgress { state, steps } + } + + fn build_step(&self, data: &EventBufferStepData) -> UpdateStep { + let description = data.step_info().description.to_string(); + let status = self.map_status(data.step_status()); + let children = data + .child_execution_ids() + .iter() + .map(|child_exec| self.build_execution(*child_exec)) + .collect(); + UpdateStep { description, status, children } + } + + /// Maps a [`StepStatus`] to a published [`UpdateStepStatus`]. + fn map_status( + &self, + status: &StepStatus, + ) -> UpdateStepStatus { + match status { + StepStatus::NotStarted => UpdateStepStatus::NotStarted, + StepStatus::Running { progress_event, .. } => { + UpdateStepStatus::Running { + progress: running_progress(&progress_event.kind), + } + } + StepStatus::Completed { reason } => UpdateStepStatus::Completed { + outcome: completed_outcome(reason), + }, + StepStatus::Failed { reason } => match reason { + FailureReason::StepFailed(info) => UpdateStepStatus::Failed { + message: info.message.clone(), + causes: info.causes.clone(), + }, + FailureReason::ParentFailed { .. } => { + UpdateStepStatus::Failed { + // message_display names the parent and provides its + // causes inline, so leave the `causes` list empty in + // this case. + message: reason + .message_display(self.event_buffer) + .to_string(), + causes: Vec::new(), + } + } + }, + StepStatus::Aborted { reason, .. } => { + let message = match reason { + AbortReason::StepAborted(info) => info.message.clone(), + AbortReason::ParentAborted { .. } => { + reason.message_display(self.event_buffer).to_string() + } + }; + UpdateStepStatus::Aborted { message } + } + StepStatus::WillNotBeRun { reason } => { + UpdateStepStatus::WillNotBeRun { + reason: will_not_be_run_message(self.event_buffer, reason), + } + } + } + } + + fn execution_state(&self, execution_id: ExecutionUuid) -> UpdateState { + let Some(summary) = self.summaries.get(&execution_id) else { + return UpdateState::Waiting; + }; + match &summary.execution_status { + ExecutionStatus::NotStarted => UpdateState::Waiting, + ExecutionStatus::Running { .. } => UpdateState::Running, + ExecutionStatus::Terminal(info) => match info.kind { + TerminalKind::Completed => UpdateState::Completed, + TerminalKind::Failed => UpdateState::Failed { + message: failed_message(self.event_buffer, &info.step_key), + }, + TerminalKind::Aborted => UpdateState::Aborted { + message: aborted_message(self.event_buffer, &info.step_key), + }, + }, + } + } +} + +pub(crate) fn sp_update_progress( + sp: SpIdentifier, + report: EventReport, +) -> SpUpdateProgress { + SpUpdateProgress { sp, progress: update_progress(report) } +} + +fn update_progress(report: EventReport) -> UpdateProgress { + let mut event_buffer = EventBuffer::default(); + event_buffer.add_event_report(report); + + let Some(root_id) = event_buffer.root_execution_id() else { + return UpdateProgress { + state: UpdateState::Waiting, + steps: Vec::new(), + }; + }; + + ProgressBuilder::new(&event_buffer).build_execution(root_id) +} + +#[cfg(test)] +mod tests { + use super::*; + use oxide_update_engine_types::events::ProgressUnits; + use wicketd_commission_types::inventory::SpType; + + #[test] + fn empty_report_projects_waiting() { + let sp = SpIdentifier { typ: SpType::Sled, slot: 0 }; + let result = sp_update_progress(sp, EventReport::default()); + assert_eq!(result.sp, sp); + assert_eq!(result.progress.state, UpdateState::Waiting); + assert!( + result.progress.steps.is_empty(), + "an empty report doesn't have any steps, but these were found: {:?}", + result.progress.steps, + ); + } + + #[test] + fn counter_projects_to_step_progress() { + let counter = ProgressCounter { + current: 3, + total: Some(10), + units: ProgressUnits::new("bytes"), + }; + assert_eq!( + step_progress_from_counter(&counter), + StepProgress { + current: 3, + total: Some(10), + units: "bytes".to_string(), + }, + ); + } + + fn fake_step_key() -> StepKey { + StepKey { execution_id: ExecutionUuid::nil(), index: 0 } + } + + #[test] + fn will_not_be_run_messages_distinguish_reason_variants() { + let buffer = EventBuffer::default(); + let step = fake_step_key(); + + assert_eq!( + will_not_be_run_message( + &buffer, + &WillNotBeRunReason::PreviousStepFailed { step }, + ), + "will not run because a previous step failed", + ); + assert_eq!( + will_not_be_run_message( + &buffer, + &WillNotBeRunReason::ParentStepFailed { step }, + ), + "will not run because a parent step failed", + ); + assert_eq!( + will_not_be_run_message( + &buffer, + &WillNotBeRunReason::PreviousStepAborted { step }, + ), + "will not run because a previous step was aborted", + ); + assert_eq!( + will_not_be_run_message( + &buffer, + &WillNotBeRunReason::ParentAborted { step }, + ), + "will not run because a parent step was aborted", + ); + } +} diff --git a/wicketd/src/context.rs b/wicketd/src/context.rs index 9eefaefb0b8..51b8e7cf56b 100644 --- a/wicketd/src/context.rs +++ b/wicketd/src/context.rs @@ -275,7 +275,16 @@ impl RssOrMultirackJoinConfig { /// Shared state used by API handlers pub struct ServerContext { + /// The main wicketd API address, stored as `config.address` in the SMF + /// configuration. + /// + /// This address is used to reject SMF updates which attempt to change it. pub(crate) bind_address: SocketAddrV6, + /// The commission API address, stored as `config.commission-address` in the + /// SMF configuration. + /// + /// This address is used to reject SMF updates which attempt to change it. + pub(crate) commission_bind_address: SocketAddrV6, pub mgs_handle: MgsHandle, pub mgs_client: gateway_client::Client, pub transceiver_handle: TransceiverHandle, diff --git a/wicketd/src/http_entrypoints.rs b/wicketd/src/http_entrypoints.rs index a28326ee147..c5d3b88d921 100644 --- a/wicketd/src/http_entrypoints.rs +++ b/wicketd/src/http_entrypoints.rs @@ -696,6 +696,13 @@ impl WicketdApi for WicketdApiImpl { "listening address cannot be reconfigured".to_string(), )); } + if rqctx.commission_bind_address != smf_values.commission_address { + return Err(HttpError::for_bad_request( + None, + "commission listening address cannot be reconfigured" + .to_string(), + )); + } if let Some(rack_subnet) = smf_values.rack_subnet { let resolver = Resolver::new_from_subnet( diff --git a/wicketd/src/http_helpers.rs b/wicketd/src/http_helpers.rs index 1e698186304..4af75238c6b 100644 --- a/wicketd/src/http_helpers.rs +++ b/wicketd/src/http_helpers.rs @@ -4,9 +4,9 @@ //! Helper and utility code for the wicketd HTTP APIs. //! -//! Currently this is only used for the main wicketd API implementation defined -//! in `http_entrypoints.rs`. In the future, the same code will be used for the -//! commission API (RFD 710). +//! These helpers are shared by both the unstable wicketd API (defined in +//! `http_entrypoints.rs`) and the stable commission API (defined in the +//! `commission` module). use std::collections::BTreeMap; use std::collections::BTreeSet; diff --git a/wicketd/src/lib.rs b/wicketd/src/lib.rs index b3f9a7db4f4..6b5c9adf865 100644 --- a/wicketd/src/lib.rs +++ b/wicketd/src/lib.rs @@ -5,6 +5,7 @@ mod artifacts; mod bgp_auth_keys; mod bootstrap_addrs; +mod commission; mod config; mod context; mod helpers; @@ -55,6 +56,7 @@ use wicketd_client::ClientInfo as _; pub struct Args { pub address: SocketAddrV6, pub artifact_address: SocketAddrV6, + pub commission_address: SocketAddrV6, pub mgs_address: SocketAddrV6, pub nexus_proxy_address: SocketAddrV6, pub baseboard: Option, @@ -63,6 +65,7 @@ pub struct Args { pub struct SmfConfigValues { pub address: SocketAddrV6, + pub commission_address: SocketAddrV6, pub rack_subnet: Option>, } @@ -74,6 +77,7 @@ impl SmfConfigValues { const CONFIG_PG: &str = "config"; const PROP_RACK_SUBNET: &str = "rack-subnet"; const PROP_ADDRESS: &str = "address"; + const PROP_COMMISSION_ADDRESS: &str = "commission-address"; let scf = ScfHandle::new()?; let instance = scf.self_instance()?; @@ -104,7 +108,18 @@ impl SmfConfigValues { })? }; - Ok(Self { address, rack_subnet }) + let commission_address = { + let commission_address = + config.value_as_string(PROP_COMMISSION_ADDRESS)?; + commission_address.parse().with_context(|| { + format!( + "failed to parse {CONFIG_PG}/{PROP_COMMISSION_ADDRESS} \ + value {commission_address:?} as a socket address" + ) + })? + }; + + Ok(Self { address, commission_address, rack_subnet }) } #[cfg(not(target_os = "illumos"))] @@ -115,6 +130,7 @@ impl SmfConfigValues { pub struct Server { pub wicketd_server: HttpServer>, + pub commission_server: HttpServer>, pub installinator_server: HttpServer, pub artifact_store: WicketdArtifactStore, pub update_tracker: Arc, @@ -193,32 +209,64 @@ impl Server { anyhow!(err).context("failed to start Nexus TCP proxy") })?; + let mgs_client = make_mgs_client(log.clone(), args.mgs_address); + + let preflight_checker = PreflightCheckerHandler::new(&log); + + // Shared server context across the wicketd and commission API servers. + let server_context = Arc::new(ServerContext { + bind_address: args.address, + commission_bind_address: args.commission_address, + mgs_handle, + mgs_client, + transceiver_handle, + log: log.clone(), + local_switch_id: OnceLock::new(), + bootstrap_peers, + update_tracker: update_tracker.clone(), + baseboard: args.baseboard, + rss_or_multirack_join_config: Default::default(), + preflight_checker, + internal_dns_resolver, + }); + let wicketd_server = { let ds_log = log.new(o!("component" => "dropshot (wicketd)")); - let mgs_client = make_mgs_client(log.clone(), args.mgs_address); dropshot::ServerBuilder::new( http_entrypoints::api(), - Arc::new(ServerContext { - bind_address: args.address, - mgs_handle, - mgs_client, - transceiver_handle, - log: log.clone(), - local_switch_id: OnceLock::new(), - bootstrap_peers, - update_tracker: update_tracker.clone(), - baseboard: args.baseboard, - rss_or_multirack_join_config: Default::default(), - preflight_checker: PreflightCheckerHandler::new(&log), - internal_dns_resolver, - }), + Arc::clone(&server_context), ds_log, ) - .config(dropshot_config) + .config(dropshot_config.clone()) .start() .map_err(|err| anyhow!(err).context("initializing http server"))? }; + let commission_server = { + let ds_log = + log.new(o!("component" => "dropshot (wicketd-commission)")); + let commission_config = ConfigDropshot { + bind_address: SocketAddr::V6(args.commission_address), + ..dropshot_config + }; + dropshot::ServerBuilder::new( + commission::api(), + Arc::clone(&server_context), + ds_log, + ) + .config(commission_config) + .version_policy(dropshot::VersionPolicy::Dynamic(Box::new( + dropshot::ClientSpecifiesVersionInHeader::new( + omicron_common::api::VERSION_HEADER, + wicketd_commission_api::latest_version(), + ), + ))) + .start() + .map_err(|err| { + anyhow!(err).context("initializing commission http server") + })? + }; + let installinator_server = { let installinator_config = installinator_api::default_config( SocketAddr::V6(args.artifact_address), @@ -253,6 +301,7 @@ impl Server { Ok(Self { wicketd_server, + commission_server, installinator_server, artifact_store: store, update_tracker, @@ -265,8 +314,9 @@ impl Server { pub async fn close(mut self) -> Result<()> { // Close the servers concurrently and shut down the proxy, then report // every error that occurred. - let (wicketd, installinator) = tokio::join!( + let (wicketd, commission, installinator) = tokio::join!( self.wicketd_server.close(), + self.commission_server.close(), self.installinator_server.close(), ); self.nexus_tcp_proxy.shutdown(); @@ -275,6 +325,9 @@ impl Server { wicketd.map_err(|error| { anyhow!("error closing wicketd server: {error}") }), + commission.map_err(|error| { + anyhow!("error closing commission server: {error}") + }), installinator.map_err(|error| { anyhow!("error closing artifact server: {error}") }), @@ -286,8 +339,8 @@ impl Server { } pub async fn wait_for_finish(self) -> Result<(), String> { - // Both servers should keep running indefinitely unless close() is - // called. Bail if either server exits. + // All servers should keep running indefinitely unless close() is + // called. Bail if any server exits. tokio::select! { res = self.wicketd_server => { match res { @@ -295,6 +348,12 @@ impl Server { Err(err) => Err(format!("running wicketd server: {err}")), } } + res = self.commission_server => { + match res { + Ok(()) => Err("commission server exited unexpectedly".to_owned()), + Err(err) => Err(format!("running commission server: {err}")), + } + } res = self.installinator_server => { match res { Ok(()) => Err("artifact server exited unexpectedly".to_owned()), diff --git a/wicketd/src/update_tracker.rs b/wicketd/src/update_tracker.rs index b5e241edcb8..cf8125d207a 100644 --- a/wicketd/src/update_tracker.rs +++ b/wicketd/src/update_tracker.rs @@ -386,22 +386,23 @@ impl UpdateTracker { None => (None, Vec::new()), }; - let mut event_reports = BTreeMap::new(); - for (sp, update_data) in &update_data.sp_update_data { - let event_report = - update_data.event_buffer.lock().unwrap().generate_report(); - let inner: &mut BTreeMap<_, _> = - event_reports.entry(sp.typ).or_default(); - inner.insert(sp.slot, event_report); - } - GetArtifactsAndEventReportsResponse { system_version, artifacts, - event_reports, + event_reports: update_data.event_reports(), } } + pub(crate) async fn system_version(&self) -> Option { + self.sp_update_data.lock().await.artifact_store.system_version() + } + + pub(crate) async fn event_reports( + &self, + ) -> BTreeMap> { + self.sp_update_data.lock().await.event_reports() + } + pub(crate) async fn event_report(&self, sp: SpIdentifier) -> EventReport { let mut update_data = self.sp_update_data.lock().await; match update_data.sp_update_data.entry(sp) { @@ -677,6 +678,21 @@ impl UpdateTrackerData { Self { artifact_store, sp_update_data: BTreeMap::new() } } + // TODO: once rkdeploy is on the published API, change the return type here + // and elsewhere to be an `IdOrdMap` where `SpEventReport`'s + // key is an `SpIdentifier`. + fn event_reports(&self) -> BTreeMap> { + let mut event_reports = BTreeMap::new(); + for (sp, update_data) in &self.sp_update_data { + let event_report = + update_data.event_buffer.lock().unwrap().generate_report(); + let inner: &mut BTreeMap<_, _> = + event_reports.entry(sp.typ).or_default(); + inner.insert(sp.slot, event_report); + } + event_reports + } + fn clear_update_state( &mut self, sps: &UpdateTargets, diff --git a/wicketd/tests/integration_tests/commission.rs b/wicketd/tests/integration_tests/commission.rs new file mode 100644 index 00000000000..fd68ab97da8 --- /dev/null +++ b/wicketd/tests/integration_tests/commission.rs @@ -0,0 +1,591 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +use std::collections::BTreeSet; +use std::time::Duration; + +use super::setup::{ + Cond, WicketdTestContext, assert_client_error, assert_client_error_message, + wait_for_sled0_progress, +}; +use camino_tempfile::Utf8TempDir; +use clap::Parser; +use gateway_messages::SpPort; +use gateway_test_utils::setup as gateway_setup; +use http::StatusCode; +use iddqd::IdOrdMap; +use omicron_test_utils::dev::poll::{CondCheckError, wait_for_condition}; +use sp_sim::ROT_STAGING_DEVEL_SIGN; +use wicket_common::example::ExampleRackSetupData; +use wicket_common::rack_setup::{BgpAuthKey, CurrentRssUserConfigInsensitive}; +use wicketd_client::types::PutBgpAuthKeyBody; +use wicketd_commission_types::rack_setup::PutRssUserConfigInsensitive; +use wicketd_commission_types_versions::latest::inventory::{ + IgnitionFaults, LocationInfo, PowerState, SlotCaboose, SpIdentifier, + SpIgnitionInfo, SpInfo, SpInventoryParams, SpStateInfo, SpType, SwitchSlot, + TransceiverInventory, +}; +use wicketd_commission_types_versions::latest::rack_setup::{ + NewPasswordHash, PutRecoveryUserPasswordHash, +}; +use wicketd_commission_types_versions::latest::update::{ + StartUpdateOptions, StartUpdateParams, UpdateState, UpdateTargets, +}; + +/// Wait for the SP inventory to become ready. +async fn wait_for_sp_inventory( + ctx: &WicketdTestContext, + ready: impl Fn(&IdOrdMap) -> bool, +) -> IdOrdMap { + wait_for_condition( + || async { + let result: Cond> = match ctx + .commission_client + .get_sp_inventory(&SpInventoryParams::default()) + .await + { + Ok(resp) => { + let sps = resp.into_inner().sps; + if ready(&sps) { + Ok(sps) + } else { + Err(CondCheckError::NotYet { status: None }) + } + } + // The main retryable error here is 503, meaning MGS + // inventory isn't available yet. + Err(err) if err.is_retryable() => { + Err(CondCheckError::NotYet { status: None }) + } + Err(err) => Err(CondCheckError::Failed(err)), + }; + result + }, + &Duration::from_millis(100), + &Duration::from_secs(30), + ) + .await + .expect("sp inventory became available") +} + +/// Test inventory on the commissioning API. +#[tokio::test] +async fn test_commission_inventory() { + let gateway = + gateway_setup::test_setup("test_commission_inventory", SpPort::One) + .await; + let ctx = WicketdTestContext::setup(gateway).await; + + // Wait for MGS inventory, ignition, and cabooses. + let sps = wait_for_sp_inventory(&ctx, |sps| { + [0u16, 1].iter().all(|slot| { + sps.get(&SpIdentifier { typ: SpType::Sled, slot: *slot }) + .is_some_and(|sp| { + sp.state.is_some() + && sp.ignition != SpIgnitionInfo::NotRead + && sled_cabooses_ready(sp) + }) + }) + }) + .await; + + assert_eq!(sps.len(), 4, "four simulated SPs"); + + let sled0 = sps + .get(&SpIdentifier { typ: SpType::Sled, slot: 0 }) + .expect("sled 0 present"); + assert_eq!( + sled0.state, + Some(sim_gimlet_state("SimGimlet00")), + "sled 0 state as reported by sp-sim" + ); + + let SlotCaboose::Read { caboose: sp_caboose } = &sled0.caboose_active + else { + panic!("sled 0 active SP caboose should be read: {sled0:?}"); + }; + assert_eq!( + sp_caboose.sign, None, + "sp-sim service-processor cabooses carry no signature: {sp_caboose:?}", + ); + let rot = sled0.rot.as_ref().expect("sled 0 RoT info present"); + let SlotCaboose::Read { caboose: rot_caboose } = &rot.caboose_a else { + panic!("sled 0 RoT slot A caboose should be read: {rot:?}"); + }; + assert_eq!( + rot_caboose.sign.as_deref(), + Some(ROT_STAGING_DEVEL_SIGN), + "sp-sim RoT cabooses are signed with the staging/devel key: \ + {rot_caboose:?}", + ); + + let sled1 = sps + .get(&SpIdentifier { typ: SpType::Sled, slot: 1 }) + .expect("sled 1 present"); + assert_eq!( + sled1.state, + Some(sim_gimlet_state("SimGimlet01")), + "sled 1 state as reported by sp-sim" + ); + + for sled in [&sled0, &sled1] { + assert_eq!( + sled.ignition, + sim_ignition_present(), + "sp-sim sleds report ignition present with no faults: {sled:?}" + ); + } + + // Force a refresh. + let force_refresh: Vec<_> = sps.iter().map(|sp| sp.id).collect(); + let refreshed = ctx + .commission_client + .get_sp_inventory(&SpInventoryParams { force_refresh }) + .await + .expect("get_sp_inventory with force_refresh succeeded") + .into_inner(); + assert!( + refreshed.mgs_last_seen < Duration::from_secs(30), + "a just-completed forced refresh implies MGS was seen recently, \ + but mgs_last_seen is {:?}", + refreshed.mgs_last_seen, + ); + assert_eq!( + refreshed.sps.len(), + 4, + "four simulated SPs after forced refresh" + ); + assert_eq!( + refreshed.transceivers, + TransceiverInventory::NotRead, + "the test harness has no switch transceiver interface, so the \ + transceiver inventory is never read: {:?}", + refreshed.transceivers, + ); + let refreshed_sled0 = refreshed + .sps + .get(&SpIdentifier { typ: SpType::Sled, slot: 0 }) + .expect("sled 0 present after forced refresh"); + assert_eq!( + refreshed_sled0.state, + Some(sim_gimlet_state("SimGimlet00")), + "sled 0 state after forced refresh: {refreshed:?}" + ); + + let err = ctx + .commission_client + .get_sp_inventory(&SpInventoryParams { + force_refresh: vec![SpIdentifier { typ: SpType::Sled, slot: 99 }], + }) + .await + .expect_err("force_refresh of a nonexistent SP is rejected"); + assert_client_error_message(&err, StatusCode::BAD_REQUEST, "sled 99"); + + let bootstrap = ctx + .commission_client + .get_bootstrap_sleds() + .await + .expect("get_bootstrap_sleds succeeded") + .into_inner(); + let ids: Vec<_> = bootstrap.iter().map(|s| s.id).collect(); + assert_eq!( + ids, + vec![ + SpIdentifier { typ: SpType::Sled, slot: 0 }, + SpIdentifier { typ: SpType::Sled, slot: 1 }, + ], + ); + let serial_numbers: Vec<_> = + bootstrap.iter().map(|s| s.serial_number.clone()).collect(); + assert_eq!(serial_numbers, vec!["SimGimlet00", "SimGimlet01"]); + assert!( + bootstrap.iter().all(|s| s.ip.is_none()), + "no bootstrap IPs discovered in the test environment" + ); + + let location = wait_for_condition( + || async { + let result: Cond = + match ctx.commission_client.get_location().await { + Ok(resp) => Ok(resp.into_inner()), + Err(err) if err.is_retryable() => { + Err(CondCheckError::NotYet { status: None }) + } + Err(err) => Err(CondCheckError::Failed(err)), + }; + result + }, + &Duration::from_millis(100), + &Duration::from_secs(30), + ) + .await + .expect("location became available"); + + assert_eq!( + location.switch_slot, + SwitchSlot::Switch0, + "cabled to switch slot 0" + ); + assert_eq!( + location.switch_serial.as_deref(), + Some("SimSidecar0"), + "switch 0 serial reported by sp-sim" + ); + assert_eq!( + location.sled_serial, None, + "the harness sets no baseboard, so there is no sled serial" + ); + + ctx.teardown().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_commission_start_update() { + let gateway = + gateway_setup::test_setup("test_commission_start_update", SpPort::One) + .await; + let ctx = WicketdTestContext::setup(gateway).await; + let log = ctx.log(); + + // Since a repository hasn't been uploaded yet, the described system version + // is absent. + let repo = ctx + .commission_client + .get_repository() + .await + .expect("get_repository succeeded") + .into_inner(); + assert_eq!( + repo.system_version, None, + "no repository uploaded yet, so no system version" + ); + + // Wait until sled 0's SP is populated. + wait_for_sp_inventory(&ctx, |sps| { + sps.get(&SpIdentifier { typ: SpType::Sled, slot: 0 }) + .is_some_and(|sp| sp.state.is_some()) + }) + .await; + + // A sled absent from inventory is rejected with a 400 that names the + // missing inventory state. + let absent = StartUpdateParams { + targets: UpdateTargets::single(SpIdentifier { + typ: SpType::Sled, + slot: 30, + }), + options: StartUpdateOptions::default(), + }; + let err = ctx + .commission_client + .post_start_update(&absent) + .await + .expect_err("updating a sled absent from inventory is rejected"); + assert_client_error_message( + &err, + StatusCode::BAD_REQUEST, + "no inventory state present", + ); + + // Now upload a fake TUF repository so a real update can start. + let temp_dir = Utf8TempDir::new().expect("temp dir created"); + let archive_path = temp_dir.path().join("archive.zip"); + let args = tufaceous::Args::try_parse_from([ + "tufaceous", + "assemble", + "../update-common/manifests/fake.toml", + archive_path.as_str(), + ]) + .expect("args parsed correctly"); + args.exec(log).await.expect("assemble command completed successfully"); + let zip_bytes = + fs_err::read(&archive_path).expect("archive read correctly"); + ctx.commission_client + .put_repository(zip_bytes) + .await + .expect("put_repository succeeded"); + + let params = StartUpdateParams { + targets: UpdateTargets::single(SpIdentifier { + typ: SpType::Sled, + slot: 0, + }), + options: StartUpdateOptions::default(), + }; + ctx.commission_client + .post_start_update(¶ms) + .await + .expect("post_start_update on sled 0 succeeded"); + + let entry = wait_for_sled0_progress( + &ctx, + "sled 0 reached Running with a running step", + |p| { + p.progress.state == UpdateState::Running + && p.progress.innermost_running_steps().next().is_some() + }, + ) + .await; + assert_eq!( + entry.progress.state, + UpdateState::Running, + "sled 0 rolls up to Running", + ); + assert!( + !entry.progress.steps.is_empty(), + "at least one top-level step: {:?}", + entry.progress.steps, + ); + let running: Vec<_> = entry.progress.innermost_running_steps().collect(); + assert!(!running.is_empty(), "at least one running step: {running:?}"); + assert!( + running.iter().all(|step| !step.description.is_empty()), + "each running step has a description: {running:?}", + ); + + ctx.teardown().await; +} + +// Test setting up RSS via the commissioning client. +#[tokio::test] +async fn test_commission_rss_config() { + let gateway = + gateway_setup::test_setup("test_commission_rss_config", SpPort::One) + .await; + let ctx = WicketdTestContext::setup(gateway).await; + + // We can't actually start RSS since we don't have a bootstrap agent in the + // backend, but at least ensure that the endpoints return a 503. + let err = ctx + .commission_client + .get_rack_setup_state() + .await + .expect_err("get_rack_setup_state fails without a bootstrap agent"); + assert_client_error_message( + &err, + StatusCode::SERVICE_UNAVAILABLE, + "bootstrap agent address not yet known", + ); + + let err = ctx + .commission_client + .post_run_rack_setup() + .await + .expect_err("post_run_rack_setup fails without a bootstrap agent"); + assert_client_error_message( + &err, + StatusCode::SERVICE_UNAVAILABLE, + "bootstrap agent address not yet known", + ); + + // Upload a certificate first -- wicketd will wait for the corresponding + // key. + let response = ctx + .commission_client + .post_rss_config_cert("a garbage certificate") + .await + .expect("post_rss_config_cert succeeded") + .into_inner(); + assert_eq!( + response, + wicketd_commission_types_versions::latest::rack_setup::CertificateUploadResponse::WaitingOnKey, + ); + + // Upload the matching key. Validation will reject the garbage pair with a + // 400. + let err = ctx + .commission_client + .post_rss_config_key("a garbage key") + .await + .expect_err("post_rss_config_key rejects an invalid pair"); + assert_client_error(&err, StatusCode::BAD_REQUEST); + + // An invalid recovery password hash is rejected with a 400. + let err = ctx + .commission_client + .put_rss_config_recovery_user_password_hash(&recovery_hash( + "not-a-phc-string", + )) + .await + .expect_err("invalid password hash rejected"); + assert_client_error(&err, StatusCode::BAD_REQUEST); + + // A valid PHC hash is accepted. + ctx.commission_client + .put_rss_config_recovery_user_password_hash(&recovery_hash( + VALID_PHC_HASH, + )) + .await + .expect("valid password hash accepted"); + + // Clear out the user password hash. + ctx.commission_client + .delete_rss_config() + .await + .expect("delete_rss_config succeeded"); + + // Wait until both simulated sleds' SPs are available. + wait_for_sp_inventory(&ctx, |sps| { + [0u16, 1].iter().all(|slot| { + sps.get(&SpIdentifier { typ: SpType::Sled, slot: *slot }) + .is_some_and(|sp| sp.state.is_some()) + }) + }) + .await; + + // Test that a config with an unknown sled is rejected. + let mut config = example_put_config(); + config.bootstrap_sleds = std::iter::once(200u16).collect(); + let err = ctx + .commission_client + .put_rss_config(&config) + .await + .expect_err("config referencing an unknown sled is rejected"); + assert_client_error(&err, StatusCode::BAD_REQUEST); + + // Ensure that roundtripping the RSS config works. + let mut internal = ExampleRackSetupData::non_empty().put_insensitive; + internal.bootstrap_sleds = [0u16, 1].into_iter().collect(); + + ctx.commission_client + .put_rss_config(&internal) + .await + .expect("config with sleds present in inventory accepted"); + + let current = ctx + .wicketd_client + .get_rss_config() + .await + .expect("get_rss_config succeeded") + .into_inner(); + + let PutRssUserConfigInsensitive { + bootstrap_sleds: expected_slots, + ntp_servers: expected_ntp_servers, + dns_servers: expected_dns_servers, + internal_services_ip_pool_ranges: expected_pool_ranges, + external_dns_ips: expected_external_dns_ips, + external_dns_zone_name: expected_dns_zone_name, + rack_network_config: expected_rack_network_config, + allowed_source_ips: expected_allowed_source_ips, + external_jumbo_frames_opt_in_enabled: expected_jumbo_frames, + } = internal; + + let CurrentRssUserConfigInsensitive { + bootstrap_sleds, + ntp_servers, + dns_servers, + internal_services_ip_pool_ranges, + external_dns_ips, + external_dns_zone_name, + rack_network_config, + allowed_source_ips, + external_jumbo_frames_opt_in_enabled, + } = current.insensitive; + + let stored_slots: BTreeSet = + bootstrap_sleds.iter().map(|sled| sled.id.slot).collect(); + assert_eq!(stored_slots, expected_slots, "bootstrap sled slots stored"); + assert_eq!(ntp_servers, expected_ntp_servers, "ntp_servers stored"); + assert_eq!(dns_servers, expected_dns_servers, "dns_servers stored"); + assert_eq!( + internal_services_ip_pool_ranges, expected_pool_ranges, + "internal_services_ip_pool_ranges stored" + ); + assert_eq!( + external_dns_ips, expected_external_dns_ips, + "external_dns_ips stored" + ); + assert_eq!( + external_dns_zone_name, expected_dns_zone_name, + "external_dns_zone_name stored" + ); + assert_eq!( + rack_network_config, + Some(expected_rack_network_config), + "rack_network_config round-tripped through the v1 conversions" + ); + assert_eq!( + allowed_source_ips, + Some(expected_allowed_source_ips), + "allowed_source_ips round-tripped through the v1 conversions" + ); + assert_eq!( + external_jumbo_frames_opt_in_enabled, expected_jumbo_frames, + "external_jumbo_frames_opt_in_enabled stored" + ); + + let key_body = PutBgpAuthKeyBody { + key: BgpAuthKey::TcpMd5 { key: "md5-secret".to_owned() }, + }; + let status = ctx + .wicketd_client + .put_bgp_auth_key( + &"bgp-key-1".parse().expect("bgp-key-1 is a valid key ID"), + &key_body, + ) + .await + .expect("valid BGP auth key ID accepted") + .into_inner() + .status; + match status { + wicketd_client::types::SetBgpAuthKeyStatus::Added => {} + other => panic!("expected the key to be added, got {other:?}"), + } + + ctx.teardown().await; +} + +fn recovery_hash(hash: &str) -> PutRecoveryUserPasswordHash { + PutRecoveryUserPasswordHash { hash: NewPasswordHash(hash.to_string()) } +} + +fn slot_caboose_is_read(caboose: &SlotCaboose) -> bool { + match caboose { + SlotCaboose::Read { .. } => true, + SlotCaboose::NotRead => false, + } +} + +fn sled_cabooses_ready(sp: &SpInfo) -> bool { + // Cabooses are ready once active SP and RoT slot A are read. + slot_caboose_is_read(&sp.caboose_active) + && sp + .rot + .as_ref() + .is_some_and(|rot| slot_caboose_is_read(&rot.caboose_a)) +} + +fn sim_gimlet_state(serial_number: &str) -> SpStateInfo { + SpStateInfo { + serial_number: serial_number.to_string(), + // sp-sim gimlets start in power state A0 (see sp-sim/src/gimlet.rs). + power_state: PowerState::A0, + } +} + +fn sim_ignition_present() -> SpIgnitionInfo { + // sp-sim's initial ignition state is powered on with no faults + // (see sp-sim/src/sidecar.rs, initial_ignition_state). + SpIgnitionInfo::Present { + power: true, + faults: IgnitionFaults { a3: false, a2: false, rot: false, sp: false }, + } +} + +// A hash for the password "oxide". This is (obviously) only intended for +// transient deployments in development with no sensitive data or resources. You +// can change this value to any other supported hash. The only thing that needs +// to be changed with this hash are the instructions given to individuals +// running this program who then want to log in as this user. For more on what's +// supported, see the API docs for this type and the specific constraints in the +// nexus-passwords crate. +// +// The hash was generated via: +// `cargo run --example argon2 -- --input oxide`. +const VALID_PHC_HASH: &str = "$argon2id$v=19$m=98304,t=23,p=1$Effh/p6M2ZKdnpJFeGqtGQ$ZtUwcVODAvUAVK6EQ5FJMv+GMlUCo9PQQsy9cagL+EU"; + +fn example_put_config() -> PutRssUserConfigInsensitive { + ExampleRackSetupData::non_empty().put_insensitive +} diff --git a/wicketd/tests/integration_tests/mod.rs b/wicketd/tests/integration_tests/mod.rs index 656dd798182..bd53c035420 100644 --- a/wicketd/tests/integration_tests/mod.rs +++ b/wicketd/tests/integration_tests/mod.rs @@ -2,6 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. +mod commission; mod inventory; mod setup; mod updates; diff --git a/wicketd/tests/integration_tests/setup.rs b/wicketd/tests/integration_tests/setup.rs index 5b8db1fde8f..df867185ce8 100644 --- a/wicketd/tests/integration_tests/setup.rs +++ b/wicketd/tests/integration_tests/setup.rs @@ -3,9 +3,17 @@ // file, You can obtain one at https://mozilla.org/MPL/2.0/. use std::net::{Ipv6Addr, SocketAddr, SocketAddrV6}; +use std::time::Duration; use dropshot::test_util::ClientTestContext; use gateway_test_utils::setup::GatewayTestContext; +use http::StatusCode; +use omicron_test_utils::dev::poll::{CondCheckError, wait_for_condition}; +use wicketd_commission_client::Error; +use wicketd_commission_types_versions::latest::inventory::{ + SpIdentifier, SpType, +}; +use wicketd_commission_types_versions::latest::update::SpUpdateProgress; pub struct WicketdTestContext { pub wicketd_addr: SocketAddrV6, @@ -19,6 +27,7 @@ pub struct WicketdTestContext { // this way. #[allow(dead_code)] pub artifact_client: installinator_client::Client, + pub commission_client: wicketd_commission_client::Client, pub server: wicketd::Server, pub gateway: GatewayTestContext, } @@ -42,6 +51,7 @@ impl WicketdTestContext { let args = wicketd::Args { address: LOCALHOST_PORT_0, artifact_address: LOCALHOST_PORT_0, + commission_address: LOCALHOST_PORT_0, mgs_address, nexus_proxy_address: LOCALHOST_PORT_0, baseboard: None, @@ -84,12 +94,27 @@ impl WicketdTestContext { log.new(slog::o!("component" => "wicketd test, raw client")), ); + let commission_addr = + assert_ipv6(server.commission_server.local_addr()); + let commission_client = { + let endpoint = format!( + "http://[{}]:{}", + commission_addr.ip(), + commission_addr.port() + ); + wicketd_commission_client::Client::new( + &endpoint, + log.new(slog::o!("component" => "commission test client")), + ) + }; + Self { wicketd_addr, wicketd_client, wicketd_raw_client, artifact_addr, artifact_client, + commission_client, server, gateway, } @@ -113,3 +138,66 @@ fn assert_ipv6(addr: SocketAddr) -> SocketAddrV6 { } } } + +pub type CommissionClientError = Error; +pub type Cond = Result>; + +/// Poll get_update_progress until sled 0's entry satisfies `reached`. +/// +/// Panics on failure with `expect_msg`. +pub async fn wait_for_sled0_progress( + ctx: &WicketdTestContext, + expect_msg: &str, + reached: impl Fn(&SpUpdateProgress) -> bool, +) -> SpUpdateProgress { + wait_for_condition( + || async { + let result: Cond = + match ctx.commission_client.get_update_progress().await { + Ok(resp) => { + match resp + .into_inner() + .get(&SpIdentifier { typ: SpType::Sled, slot: 0 }) + { + Some(entry) if reached(entry) => Ok(entry.clone()), + _ => Err(CondCheckError::NotYet { status: None }), + } + } + Err(err) => Err(CondCheckError::Failed(err)), + }; + result + }, + &Duration::from_millis(50), + &Duration::from_secs(30), + ) + .await + .expect(expect_msg) +} + +pub fn assert_client_error(err: &CommissionClientError, expected: StatusCode) { + match err { + Error::ErrorResponse(rv) => { + assert_eq!(rv.status(), expected, "unexpected status: {err:?}"); + assert!(!rv.message.is_empty(), "error carries a message: {err:?}"); + } + other => panic!("expected ErrorResponse, got {other:?}"), + } +} + +pub fn assert_client_error_message( + err: &CommissionClientError, + expected: StatusCode, + needle: &str, +) { + match err { + Error::ErrorResponse(rv) => { + assert_eq!(rv.status(), expected, "unexpected status: {err:?}"); + assert!( + rv.message.contains(needle), + "error message {:?} should contain {needle:?}", + rv.message, + ); + } + other => panic!("expected an error response, got {other:?}"), + } +} diff --git a/wicketd/tests/integration_tests/updates.rs b/wicketd/tests/integration_tests/updates.rs index ee8d6a85d55..751ccc9266a 100644 --- a/wicketd/tests/integration_tests/updates.rs +++ b/wicketd/tests/integration_tests/updates.rs @@ -6,12 +6,15 @@ use std::{collections::BTreeSet, sync::Arc, time::Duration}; -use super::setup::WicketdTestContext; +use super::setup::{ + WicketdTestContext, assert_client_error_message, wait_for_sled0_progress, +}; use camino::Utf8Path; use camino_tempfile::Utf8TempDir; use clap::Parser; use gateway_messages::SpPort; use gateway_test_utils::setup as gateway_setup; +use http::StatusCode; use installinator::HOST_PHASE_2_FILE_NAME; use maplit::btreeset; use omicron_common::{ @@ -44,8 +47,9 @@ use wicketd::{RunningUpdateState, StartUpdateError}; use wicketd_client::types::{ GetInventoryParams, GetInventoryResponse, StartUpdateParams, }; -use wicketd_commission_types::update::{ - ClearUpdateStateResponse, UpdateTargets, +use wicketd_commission_types_versions::latest::update::{ + self, ClearUpdateStateParams, ClearUpdateStateResponse, StepOutcome, + UpdateStepStatus, UpdateTargets, }; /// The list of zone file names defined in fake-non-semver.toml. @@ -292,6 +296,28 @@ async fn test_updates() { assert!(filtered.components.is_empty()); } + // The commission API will report this as a Failed progress entry carrying a + // non-empty operator message. + let entry = wait_for_sled0_progress( + &wicketd_testctx, + "sled 0 reached Failed", + |p| matches!(p.progress.state, update::UpdateState::Failed { .. }), + ) + .await; + // Assert the failure message: + // + // * The step that fails is "Get host type". + // * The step fails because the simulated gimlet reports model + // "i86pc" (`FAKE_GIMLET_MODEL`), which is not a known Oxide host + // type. + assert_eq!( + entry.progress.state, + update::UpdateState::Failed { + message: "Get host type: Unknown host type i86pc".to_string(), + }, + "the failed update carries the expected operator-facing message", + ); + // Try starting the update again -- this should fail because we require that // update state is cleared before starting a new one. { @@ -786,6 +812,28 @@ async fn test_update_races() { .await .expect("bytes read and archived"); + // Also check that the repository is available via the commissioning API, + // and that there's no progress there yet. + let repo = wicketd_testctx + .commission_client + .get_repository() + .await + .expect("get_repository succeeded") + .into_inner(); + assert_eq!( + repo.system_version, + Some("1.0.0".parse().unwrap()), + "system version from the fake manifest" + ); + + let progress = wicketd_testctx + .commission_client + .get_update_progress() + .await + .expect("get_update_progress succeeded") + .into_inner(); + assert!(progress.is_empty(), "no updates in progress yet"); + // Now start an update. let sp = SpIdentifier { slot: 0, typ: SpType::Sled }; let sps = UpdateTargets::single(sp); @@ -823,6 +871,60 @@ async fn test_update_races() { ); } + // Clearing a target whose update is still running (here, it is + // deterministically blocked on the oneshot channel passed into + // start_fake_update) is rejected with a 400 naming the in-progress target. + let running = ClearUpdateStateParams { + targets: UpdateTargets::single(SpIdentifier { + typ: SpType::Sled, + slot: 0, + }), + }; + let err = wicketd_testctx + .commission_client + .post_clear_update_state(&running) + .await + .expect_err("clearing a running update is rejected"); + assert_client_error_message( + &err, + StatusCode::BAD_REQUEST, + "targets are currently being updated", + ); + + // Check that the commission API reports sled 0 as Running with reasonable + // step counts while the fake update is running. + let entry = wait_for_sled0_progress( + &wicketd_testctx, + "sled 0 reached Running with its single step running", + |p| { + p.progress.state == update::UpdateState::Running + && p.progress.innermost_running_steps().count() == 1 + }, + ) + .await; + assert_eq!( + entry.progress.state, + update::UpdateState::Running, + "sled 0 rolls up to Running", + ); + assert_eq!( + entry.progress.steps.len(), + 1, + "the fake update has one top-level step: {:?}", + entry.progress.steps, + ); + let running: Vec<_> = entry.progress.innermost_running_steps().collect(); + assert_eq!( + running.len(), + 1, + "exactly one running step in the tree: {running:?}", + ); + assert!( + running[0].description.contains("Fake step"), + "running step is the fake step: {}", + running[0].description, + ); + // Unblock the update, letting it run to completion. let (final_sender, final_receiver) = oneshot::channel(); sender.send(final_sender).expect("receiver kept open by update engine"); @@ -840,6 +942,113 @@ async fn test_update_races() { "last event is execution completed: {last_event:#?}" ); + // Check that the commission API reports the fake update as having + // completed. + let entry = wait_for_sled0_progress( + &wicketd_testctx, + "sled 0 reached Completed", + |p| p.progress.state == update::UpdateState::Completed, + ) + .await; + assert_eq!( + entry.progress, + update::UpdateProgress { + state: update::UpdateState::Completed, + steps: vec![update::UpdateStep { + description: "Fake step that waits for receiver to resolve" + .to_string(), + status: UpdateStepStatus::Completed { + outcome: StepOutcome::Success { message: None }, + }, + children: Vec::new(), + }], + }, + "the fake update rolls up to Completed with its single clean step", + ); + + // sled 0's update completed, so it reports as cleared and no_update_data is + // empty. + let sled0_id = SpIdentifier { typ: SpType::Sled, slot: 0 }; + let params = + ClearUpdateStateParams { targets: UpdateTargets::single(sled0_id) }; + let response = wicketd_testctx + .commission_client + .post_clear_update_state(¶ms) + .await + .expect("clearing sled 0 succeeded") + .into_inner(); + assert_eq!( + response, + ClearUpdateStateResponse { + cleared: std::iter::once(sled0_id).collect(), + no_update_data: BTreeSet::new(), + }, + "clearing sled 0 reports exactly sled 0 as cleared", + ); + + // sled 1 never had an update, so it reports under no_update_data with + // nothing cleared. + let sled1_id = SpIdentifier { typ: SpType::Sled, slot: 1 }; + let params = + ClearUpdateStateParams { targets: UpdateTargets::single(sled1_id) }; + let response = wicketd_testctx + .commission_client + .post_clear_update_state(¶ms) + .await + .expect("clearing sled 1 succeeded") + .into_inner(); + assert_eq!( + response, + ClearUpdateStateResponse { + cleared: BTreeSet::new(), + no_update_data: std::iter::once(sled1_id).collect(), + }, + "clearing sled 1 reports it as having no update data", + ); + + let progress = wicketd_testctx + .commission_client + .get_update_progress() + .await + .expect("get_update_progress succeeded") + .into_inner(); + assert!(progress.is_empty(), "update progress cleared for sled 0"); + + let event_buffer = wicketd_testctx + .wicketd_client + .get_update_sp(&SpType::Sled, 0) + .await + .expect("received event buffer successfully"); + assert!( + event_buffer.step_events.is_empty(), + "event buffer cleared for sled 0: {event_buffer:#?}" + ); + + // Run a second fake update to completion so the event buffer is populated + // again -- otherwise the re-upload check below would be vacuous now that + // the clear emptied the buffer. + let sps = UpdateTargets::single(sp); + let (sender, receiver) = oneshot::channel(); + wicketd_testctx + .server + .update_tracker + .start_fake_update(sps, receiver) + .await + .expect("start_fake_update successful"); + let (final_sender, final_receiver) = oneshot::channel(); + sender.send(final_sender).expect("receiver kept open by update engine"); + final_receiver.await.expect("update engine completed successfully"); + + let event_buffer = wicketd_testctx + .wicketd_client + .get_update_sp(&SpType::Sled, 0) + .await + .expect("received event buffer successfully"); + assert!( + !event_buffer.step_events.is_empty(), + "event buffer populated by the second update: {event_buffer:#?}" + ); + // Try uploading the repository again -- since no updates are running, this // should succeed. wicketd_testctx