From d6e2697083225fe7ffecde8c8ef70abbf3339962 Mon Sep 17 00:00:00 2001 From: "Andrew J. Stone" Date: Tue, 19 May 2026 20:33:55 +0000 Subject: [PATCH 1/6] Multirack: Wicketd API for cluster join config Add 2 new API methods to get and put a minimum configuration required for joining a new rack to an uninitialized cluster. This multirack join configuration is mutually exlcusive with the rss configuration, and so wicketd will only maintain one of them at a time. A wrapper enum, `RssOrMultirackJoinConfig`, was added to support this. A couple of new wrapper types: `BgpAuthKeys` and `SledInventory` were introduced to commonize logic between RSS and Mulitrack Join. RSS behavior should not have changed except in the case when a multirack join configuration has been uploaded and a user requests to get the current RSS config. In the old code, a default struct would be returned, now a `NotFound` is returned. The Join code follows the patterns of the RSS code as much as possible. --- wicket-common/src/inventory.rs | 119 +++++++++++++++- wicket-common/src/lib.rs | 1 + wicket-common/src/multirack_setup.rs | 51 +++++++ wicketd-api/src/lib.rs | 24 ++++ wicketd/src/bgp_auth_keys.rs | 152 ++++++++++++++++++++ wicketd/src/context.rs | 92 +++++++++++- wicketd/src/http_entrypoints.rs | 192 +++++++++++++++++++++---- wicketd/src/lib.rs | 4 +- wicketd/src/multirack_config.rs | 154 ++++++++++++++++++++ wicketd/src/rss_config.rs | 201 ++++----------------------- 10 files changed, 788 insertions(+), 202 deletions(-) create mode 100644 wicket-common/src/multirack_setup.rs create mode 100644 wicketd/src/bgp_auth_keys.rs create mode 100644 wicketd/src/multirack_config.rs diff --git a/wicket-common/src/inventory.rs b/wicket-common/src/inventory.rs index 366166ddad2..ae1d2724d91 100644 --- a/wicket-common/src/inventory.rs +++ b/wicket-common/src/inventory.rs @@ -16,11 +16,17 @@ use omicron_common::snake_case_result::SnakeCaseResult; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use sled_agent_types::early_networking::SwitchSlot; -use std::{collections::HashMap, time::Duration}; +use sled_hardware_types::Baseboard; +use slog::debug; +use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::net::Ipv6Addr; +use std::time::Duration; use transceiver_controller::{ Datapath, Monitors, PowerMode, VendorInfo, message::ExtendedStatus, }; +use crate::rack_setup::BootstrapSledDescription; + /// The current state of the v1 Rack as known to wicketd #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] #[serde(tag = "rack_inventory", rename_all = "snake_case")] @@ -44,6 +50,117 @@ pub struct MgsV1Inventory { pub sps: Vec, } +// Inventory about sleds derived from MGS inventory and DDM +#[derive(Default, Clone)] +pub struct SledInventory { + pub sleds: BTreeSet, +} + +impl From> for SledInventory { + fn from(sleds: BTreeSet) -> Self { + SledInventory { sleds } + } +} + +impl SledInventory { + pub fn new( + inventory: &MgsV1Inventory, + bootstrap_sleds: &BTreeMap, + log: &slog::Logger, + ) -> Self { + let sleds = inventory + .sps + .iter() + .filter_map(|sp| { + if sp.id.type_ != SpType::Sled { + return None; + } + + let Some(state) = sp.state.as_ref() else { + debug!( + log, + "sled inventory: filtering out SP with no state"; + "sp" => ?sp, + ); + return None; + }; + let baseboard = Baseboard::new_gimlet( + state.serial_number.clone(), + state.model.clone(), + state.revision, + ); + let bootstrap_ip = bootstrap_sleds.get(&baseboard).copied(); + Some(BootstrapSledDescription { + id: sp.id, + baseboard, + bootstrap_ip, + }) + }) + .collect(); + SledInventory { sleds } + } + + /// Ensure our baseboard is included in inventory, extract the slot for + /// our baseboard, and then ensure that the slot is included in the user + /// supplied `bootstrap_sled_slots`. + pub fn verify_our_baseboard_is_in_inventory_slot( + &self, + bootstrap_sled_slots: &BTreeSet, + our_baseboard: Option<&Baseboard>, + ) -> Result<(), String> { + if let Some(our_baseboard @ Baseboard::Gimlet { .. }) = our_baseboard { + let our_slot = self + .sleds + .iter() + .find_map(|sled| { + if sled.baseboard == *our_baseboard { + Some(sled.id.slot) + } else { + None + } + }) + .ok_or_else(|| { + format!( + "Inventory is missing the scrimlet where wicketd is \ + running ({our_baseboard:?})", + ) + })?; + if !bootstrap_sled_slots.contains(&our_slot) { + return Err(format!( + "Cannot remove the scrimlet where wicketd is running \ + (sled {our_slot}: {our_baseboard:?}) \ + from bootstrap_sleds" + )); + } + } + + Ok(()) + } + + /// Find all bootstrap sleds in inventory with slots matching + /// `bootstrap_sled_slots` and return them. If the slot doesn't exist in + /// inventory, then return an error. + pub fn load_bootstrap_sleds( + &self, + bootstrap_sled_slots: BTreeSet, + ) -> Result, String> { + let mut bootstrap_sleds = BTreeSet::new(); + for slot in bootstrap_sled_slots { + let sled = + self.sleds + .iter() + .find(|sled| sled.id.slot == slot) + .ok_or_else(|| { + format!( + "cannot add unknown sled {slot} to bootstrap_sleds", + ) + })?; + bootstrap_sleds.insert(sled.clone()); + } + Ok(bootstrap_sleds) + } +} + /// SP-related data #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(tag = "sp_inventory", rename_all = "snake_case")] diff --git a/wicket-common/src/lib.rs b/wicket-common/src/lib.rs index 7ad676e976f..ae1f2473c0f 100644 --- a/wicket-common/src/lib.rs +++ b/wicket-common/src/lib.rs @@ -8,6 +8,7 @@ use std::time::Duration; pub mod example; pub mod inventory; +pub mod multirack_setup; pub mod preflight_check; pub mod rack_setup; pub mod rack_update; diff --git a/wicket-common/src/multirack_setup.rs b/wicket-common/src/multirack_setup.rs new file mode 100644 index 00000000000..1ef54a43029 --- /dev/null +++ b/wicket-common/src/multirack_setup.rs @@ -0,0 +1,51 @@ +// 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/. + +//! Types used for adopting an uninitialized rack into an existing regional +//! cluster. + +use crate::rack_setup::{ + BootstrapSledDescription, GetBgpAuthKeyInfoResponse, + UserSpecifiedRackNetworkConfig, +}; +use omicron_common::api::internal::shared::AllowedSourceIps; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use std::collections::BTreeSet; + +/// Input from a user for adopting an uninitialized rack into an existing +/// regional cluster. +/// +/// This type is provided in the form of a TOML file uploaded via the wicket +/// CLI. It does not contain sensitive user input such as BGP keys. Those are +/// input separately. +#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] +pub struct MultirackJoinConfigBaseUserInput { + /// List of slot numbers only. + pub bootstrap_slots: BTreeSet, + pub rack_network_config: UserSpecifiedRackNetworkConfig, + pub allowed_source_ips: AllowedSourceIps, +} + +/// A version of the multirack join configuration which contains learned +/// bootstrap sleds and a redacted form of BGP auth keys if they exist. +/// +/// This is returned to the user via wicketd and displayed in wicket. +/// +/// Note that there are no optional fields here unlike in +/// `CurrentRssUserConfigInsensitive`. This is because wicketd defaults +/// to filling in an empty RSS config for backwards compatibility and +/// allows uploading BGP auth keys before actually uploading the RSS +/// config. However, since we never default to an empty version of the +/// `CurrentMultirackJoinConfig`, we can only fill in the BGP auth keys for +/// this structure after the `MultirackJoinConfigBaseUserInput` is uploaded from +/// a user. +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)] +pub struct CurrentMultirackJoinUserConfig { + pub bootstrap_sleds: BTreeSet, + pub rack_network_config: UserSpecifiedRackNetworkConfig, + pub allowed_source_ips: AllowedSourceIps, + pub bgp_auth_keys: GetBgpAuthKeyInfoResponse, +} diff --git a/wicketd-api/src/lib.rs b/wicketd-api/src/lib.rs index addbf09aa31..813dd15ebe1 100644 --- a/wicketd-api/src/lib.rs +++ b/wicketd-api/src/lib.rs @@ -26,6 +26,8 @@ use tufaceous_artifact::ArtifactHashId; use wicket_common::inventory::RackV1Inventory; use wicket_common::inventory::SpIdentifier; use wicket_common::inventory::SpType; +use wicket_common::multirack_setup::CurrentMultirackJoinUserConfig; +use wicket_common::multirack_setup::MultirackJoinConfigBaseUserInput; use wicket_common::preflight_check; use wicket_common::rack_setup::BgpAuthKey; use wicket_common::rack_setup::BgpAuthKeyId; @@ -78,6 +80,28 @@ pub trait WicketdApi { body: TypedBody, ) -> Result; + /// Get the current status of the multirack join configuration. + #[endpoint { + method = GET, + path = "/rack-setup/config/multirack" + }] + async fn get_multirack_join_config( + rqctx: RequestContext, + ) -> Result, HttpError>; + + /// Upload a configuration for joining this rack into an existing regional + /// cluster. + /// + /// A multirack join configuration is mutually exclusive with an RSS config. + #[endpoint { + method = PUT, + path = "/rack-setup/config/multirack" + }] + async fn put_multirack_join_config( + rqctx: RequestContext, + body: TypedBody, + ) -> Result; + /// Add an external certificate. /// /// This must be paired with its private key. They may be posted in either diff --git a/wicketd/src/bgp_auth_keys.rs b/wicketd/src/bgp_auth_keys.rs new file mode 100644 index 00000000000..a5329a4bb85 --- /dev/null +++ b/wicketd/src/bgp_auth_keys.rs @@ -0,0 +1,152 @@ +// 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::BTreeMap; +use std::collections::btree_map; +use thiserror::Error; +use wicket_common::rack_setup::BgpAuthKey; +use wicket_common::rack_setup::BgpAuthKeyId; +use wicket_common::rack_setup::BgpAuthKeyStatus; +use wicket_common::rack_setup::DisplaySlice; +use wicketd_api::SetBgpAuthKeyStatus; + +#[derive(Clone, Debug, PartialEq, Eq, Error)] +pub(crate) enum BgpAuthKeyError { + #[error( + "rack network config not set (upload the config before setting a key)" + )] + RackNetworkConfigNotSet, + + #[error( + "key IDs not found: {} (valid key IDs: {})", + DisplaySlice(.not_found), + DisplaySlice(.valid_keys), + )] + KeyIdsNotFound { + not_found: Vec, + valid_keys: Vec, + }, +} + +/// BGP auth keys are identified by the key ID. +/// +/// It is an invariant that any key IDs defined in `rack_network_config` exist +/// here. +/// +/// Currently these are always TCP-MD5 keys. +#[derive(Default, Clone)] +pub struct BgpAuthKeys { + keys: BTreeMap>, +} + +impl BgpAuthKeys { + pub(crate) fn get( + &self, + key_id: &BgpAuthKeyId, + ) -> Option<&Option> { + self.keys.get(key_id) + } + + pub(crate) fn iter( + &self, + ) -> btree_map::Iter<'_, BgpAuthKeyId, Option> { + self.keys.iter() + } + + pub(crate) fn check_valid<'a>( + &self, + check_valid: impl IntoIterator, + ) -> Result<(), BgpAuthKeyError> { + let not_found: Vec<_> = check_valid + .into_iter() + .filter(|key_id| !self.keys.contains_key(key_id)) + .cloned() + .collect(); + if !not_found.is_empty() { + return Err(self.make_key_ids_not_found_error(not_found)); + } + + Ok(()) + } + + pub(crate) fn get_data(&self) -> BTreeMap { + self.keys + .iter() + .map(|(key_id, key)| { + let status = key + .as_ref() + .map(|key| BgpAuthKeyStatus::Set { info: key.info() }) + .unwrap_or(BgpAuthKeyStatus::Unset); + (key_id.clone(), status) + }) + .collect() + } + + pub(crate) fn set_key( + &mut self, + key_id: BgpAuthKeyId, + key: BgpAuthKey, + ) -> Result { + match self.keys.entry(key_id.clone()) { + btree_map::Entry::Occupied(mut entry) => match entry.get() { + Some(old_key) if old_key == &key => { + Ok(SetBgpAuthKeyStatus::Unchanged) + } + Some(_) => { + entry.insert(Some(key)); + Ok(SetBgpAuthKeyStatus::Replaced) + } + None => { + // This is a new key; we don't have it yet. + entry.insert(Some(key)); + Ok(SetBgpAuthKeyStatus::Added) + } + }, + btree_map::Entry::Vacant(_) => { + Err(self.make_key_ids_not_found_error(vec![key_id])) + } + } + } + + /// Sync the key map with a new set of key IDs, preserving existing keys + /// where possible and dropping keys that are no longer referenced. + pub(crate) fn sync_keys( + &mut self, + new_key_ids: impl IntoIterator, + ) { + let mut old_keys = std::mem::take(&mut self.keys); + self.keys = new_key_ids + .into_iter() + .map(|key_id| { + ( + key_id.clone(), + // For each new key, either grab the corresponding old key, + // or initialize to None. + old_keys.remove(&key_id).unwrap_or_else(|| None), + ) + }) + .collect(); + } + + #[must_use] + fn make_key_ids_not_found_error( + &self, + key_ids: Vec, + ) -> BgpAuthKeyError { + let valid_key_ids = self.keys.keys().cloned().collect(); + BgpAuthKeyError::KeyIdsNotFound { + not_found: key_ids, + valid_keys: valid_key_ids, + } + } + + #[cfg(test)] + pub(crate) fn insert( + &mut self, + key_id: BgpAuthKeyId, + key: Option, + ) { + self.keys.insert(key_id, key); + } +} diff --git a/wicketd/src/context.rs b/wicketd/src/context.rs index 66ff7a6cb98..7519dd6124b 100644 --- a/wicketd/src/context.rs +++ b/wicketd/src/context.rs @@ -5,7 +5,9 @@ //! User provided dropshot server context use crate::MgsHandle; +use crate::bgp_auth_keys::BgpAuthKeyError; use crate::bootstrap_addrs::BootstrapPeers; +use crate::multirack_config::CurrentMultirackJoinConfig; use crate::preflight_check::PreflightCheckerHandler; use crate::rss_config::CurrentRssConfig; use crate::transceivers::Handle as TransceiverHandle; @@ -16,12 +18,100 @@ use anyhow::bail; use internal_dns_resolver::Resolver; use sled_hardware_types::Baseboard; use slog::info; +use std::collections::BTreeMap; use std::net::Ipv6Addr; use std::net::SocketAddrV6; use std::sync::Arc; use std::sync::Mutex; use std::sync::OnceLock; use wicket_common::inventory::SpIdentifier; +use wicket_common::rack_setup::BgpAuthKey; +use wicket_common::rack_setup::BgpAuthKeyId; +use wicket_common::rack_setup::BgpAuthKeyStatus; +use wicketd_api::SetBgpAuthKeyStatus; + +#[allow(clippy::large_enum_variant)] +pub(crate) enum RssOrMultirackJoinConfig { + Rss(CurrentRssConfig), + MultirackJoin(CurrentMultirackJoinConfig), +} + +impl Default for RssOrMultirackJoinConfig { + fn default() -> Self { + // Backwards compatibility + Self::default_rss_config() + } +} + +impl RssOrMultirackJoinConfig { + pub fn default_rss_config() -> RssOrMultirackJoinConfig { + Self::Rss(CurrentRssConfig::default()) + } + + /// Return the [`CurrentRssConfig`] from the `Rss` variant if set. + pub fn rss_config_mut(&mut self) -> Option<&mut CurrentRssConfig> { + match self { + Self::Rss(c) => Some(c), + _ => None, + } + } + + /// Return the [`CurrentMultirackJoinConfig`] from the `MultirackJoin` + /// variant if set. + pub fn multirack_join_config_mut( + &mut self, + ) -> Option<&mut CurrentMultirackJoinConfig> { + match self { + Self::MultirackJoin(c) => Some(c), + _ => None, + } + } + + /// Return the a mutable reference to the `Rss` variant if set or initialize + /// `self` with `CurrentRssConfig::default()` and return a mutable reference + /// to that. + /// + /// This is essentially an entry API that overwrites other variants as necessary. + pub fn rss_config_mut_or_default(&mut self) -> &mut CurrentRssConfig { + match self { + Self::Rss(c) => c, + _ => { + *self = Self::default_rss_config(); + self.rss_config_mut().unwrap() + } + } + } + + pub(crate) fn check_bgp_auth_keys_valid<'a>( + &self, + check_valid: impl IntoIterator, + ) -> Result<(), BgpAuthKeyError> { + match self { + Self::Rss(c) => c.check_bgp_auth_keys_valid(check_valid), + Self::MultirackJoin(c) => c.check_bgp_auth_keys_valid(check_valid), + } + } + + pub(crate) fn get_bgp_auth_key_data( + &self, + ) -> BTreeMap { + match self { + Self::Rss(c) => c.get_bgp_auth_key_data(), + Self::MultirackJoin(c) => c.get_bgp_auth_key_data(), + } + } + + pub(crate) fn set_bgp_auth_key( + &mut self, + key_id: BgpAuthKeyId, + key: BgpAuthKey, + ) -> Result { + match self { + Self::Rss(c) => c.set_bgp_auth_key(key_id, key), + Self::MultirackJoin(c) => c.set_bgp_auth_key(key_id, key), + } + } +} /// Shared state used by API handlers pub struct ServerContext { @@ -38,7 +128,7 @@ pub struct ServerContext { pub(crate) bootstrap_peers: BootstrapPeers, pub(crate) update_tracker: Arc, pub(crate) baseboard: Option, - pub(crate) rss_config: Mutex, + pub(crate) rss_or_multirack_join_config: Mutex, pub(crate) preflight_checker: PreflightCheckerHandler, pub(crate) internal_dns_resolver: Arc>>, } diff --git a/wicketd/src/http_entrypoints.rs b/wicketd/src/http_entrypoints.rs index 10fd427805a..02b132b0e99 100644 --- a/wicketd/src/http_entrypoints.rs +++ b/wicketd/src/http_entrypoints.rs @@ -5,16 +5,19 @@ //! HTTP entrypoint functions for wicketd use crate::SmfConfigValues; +use crate::context::RssOrMultirackJoinConfig; use crate::helpers::SpIdentifierDisplay; use crate::helpers::sps_to_string; use crate::mgs::GetInventoryError as GetMgsInventoryError; use crate::mgs::GetInventoryResponse as GetMgsInventoryResponse; use crate::mgs::MgsHandle; use crate::mgs::ShutdownInProgress; +use crate::multirack_config::CurrentMultirackJoinConfig; use crate::transceivers::GetTransceiversResponse; use crate::transceivers::Handle as TransceiverHandle; use bootstrap_agent_lockstep_client::types::RackOperationStatus; use dropshot::ApiDescription; +use dropshot::ClientErrorStatusCode; use dropshot::HttpError; use dropshot::HttpResponseOk; use dropshot::HttpResponseUpdatedNoContent; @@ -37,6 +40,8 @@ use wicket_common::inventory::RackV1Inventory; use wicket_common::inventory::SpIdentifier; use wicket_common::inventory::SpType; use wicket_common::inventory::TransceiverInventorySnapshot; +use wicket_common::multirack_setup::CurrentMultirackJoinUserConfig; +use wicket_common::multirack_setup::MultirackJoinConfigBaseUserInput; use wicket_common::rack_setup::GetBgpAuthKeyInfoResponse; use wicket_common::rack_setup::PutRssUserConfigInsensitive; use wicket_common::rack_update::AbortUpdateOptions; @@ -85,57 +90,153 @@ impl WicketdApi for WicketdApiImpl { mgs_inventory_or_unavail(&ctx.mgs_handle, &ctx.transceiver_handle) .await?; - let mut config = ctx.rss_config.lock().unwrap(); + let mut config = ctx.rss_or_multirack_join_config.lock().unwrap(); + let rss_config = config.rss_config_mut().ok_or_else(|| { + HttpError::for_not_found(None, "rss config not found".to_string()) + })?; + let inventory = inventory .mgs .expect("verified by `inventory_or_unavail`") .inventory; - config.update_with_inventory_and_bootstrap_peers( + rss_config.update_with_inventory_and_bootstrap_peers( &inventory, &ctx.bootstrap_peers, &ctx.log, ); - Ok(HttpResponseOk((&*config).into())) + Ok(HttpResponseOk((&*rss_config).into())) } - async fn put_rss_config( + async fn get_multirack_join_config( rqctx: RequestContext, - body: TypedBody, - ) -> Result { + ) -> Result, HttpError> { let ctx = rqctx.context(); - // We can't run RSS if we don't have an inventory from MGS yet; we always - // need to fill in the bootstrap sleds first. + // We can't join a multirack cluster if we don't have an inventory from + // MGS yet; we always need to fill in the bootstrap sleds first. let inventory = mgs_inventory_or_unavail(&ctx.mgs_handle, &ctx.transceiver_handle) .await?; - let mut config = ctx.rss_config.lock().unwrap(); + let mut config = ctx.rss_or_multirack_join_config.lock().unwrap(); + let join_config = + config.multirack_join_config_mut().ok_or_else(|| { + HttpError::for_not_found( + None, + "multirack join config not found".to_string(), + ) + })?; + let inventory = inventory .mgs .expect("verified by `inventory_or_unavail`") .inventory; - config.update_with_inventory_and_bootstrap_peers( + + join_config.update_with_inventory_and_bootstrap_peers( &inventory, &ctx.bootstrap_peers, &ctx.log, ); - config + + Ok(HttpResponseOk((&*join_config).into())) + } + + async fn put_rss_config( + rqctx: RequestContext, + body: TypedBody, + ) -> Result { + let ctx = rqctx.context(); + + // We can't run RSS if we don't have an inventory from MGS yet; we always + // need to fill in the bootstrap sleds first. + let inventory = + mgs_inventory_or_unavail(&ctx.mgs_handle, &ctx.transceiver_handle) + .await? + .mgs + .expect("verified by `inventory_or_unavail`") + .inventory; + + let mut config = ctx.rss_or_multirack_join_config.lock().unwrap(); + + // Overwrite any non-rss config + let rss_config = config.rss_config_mut_or_default(); + + rss_config.update_with_inventory_and_bootstrap_peers( + &inventory, + &ctx.bootstrap_peers, + &ctx.log, + ); + rss_config .update(body.into_inner(), ctx.baseboard.as_ref()) .map_err(|err| HttpError::for_bad_request(None, err))?; Ok(HttpResponseUpdatedNoContent()) } + async fn put_multirack_join_config( + rqctx: RequestContext, + body: TypedBody, + ) -> Result { + let ctx = rqctx.context(); + + // We can't join a multirack cluster if we don't have an inventory from + // MGS yet; we always need to fill in the bootstrap sleds first. + let inventory = + mgs_inventory_or_unavail(&ctx.mgs_handle, &ctx.transceiver_handle) + .await? + .mgs + .expect("verified by `inventory_or_unavail`") + .inventory; + + let mut config = ctx.rss_or_multirack_join_config.lock().unwrap(); + + // We don't have a default (empty) version of a `join_config` like we do with + // an `rss_config` so we have two different paths here. + if let Some(join_config) = config.multirack_join_config_mut() { + join_config.update_with_inventory_and_bootstrap_peers( + &inventory, + &ctx.bootstrap_peers, + &ctx.log, + ); + join_config + .update(body.into_inner(), ctx.baseboard.as_ref()) + .map_err(|err| HttpError::for_bad_request(None, err))?; + } else { + // Overwrite any non-multirack-join config + *config = RssOrMultirackJoinConfig::MultirackJoin( + CurrentMultirackJoinConfig::new_with_inventory_and_bootstrap_peers( + ctx.baseboard.as_ref(), + body.into_inner(), + &inventory, + &ctx.bootstrap_peers, + &ctx.log, + ) + .map_err(|err| HttpError::for_bad_request(None, err))?, + ); + } + + Ok(HttpResponseUpdatedNoContent()) + } + async fn post_rss_config_cert( rqctx: RequestContext, body: TypedBody, ) -> Result, HttpError> { let ctx = rqctx.context(); - let mut config = ctx.rss_config.lock().unwrap(); - let response = config + let mut config = ctx.rss_or_multirack_join_config.lock().unwrap(); + + let rss_config = config.rss_config_mut().ok_or_else(|| { + HttpError::for_client_error( + Some("Conflict".to_string()), + ClientErrorStatusCode::CONFLICT, + "cannot post certificates when not preparing for RSS" + .to_string(), + ) + })?; + + let response = rss_config .push_cert(body.into_inner()) .map_err(|err| HttpError::for_bad_request(None, err))?; @@ -148,8 +249,17 @@ impl WicketdApi for WicketdApiImpl { ) -> Result, HttpError> { let ctx = rqctx.context(); - let mut config = ctx.rss_config.lock().unwrap(); - let response = config + let mut config = ctx.rss_or_multirack_join_config.lock().unwrap(); + let rss_config = config.rss_config_mut().ok_or_else(|| { + HttpError::for_client_error( + Some("Conflict".to_string()), + ClientErrorStatusCode::CONFLICT, + "cannot post private keys when not preparing for RSS" + .to_string(), + ) + })?; + + let response = rss_config .push_key(body.into_inner()) .map_err(|err| HttpError::for_bad_request(None, err))?; @@ -165,7 +275,7 @@ impl WicketdApi for WicketdApiImpl { let ctx = rqctx.context(); let params = params.into_inner(); - let config = ctx.rss_config.lock().unwrap(); + let config = ctx.rss_or_multirack_join_config.lock().unwrap(); config .check_bgp_auth_keys_valid(¶ms.check_valid) .map_err(|err| HttpError::for_bad_request(None, err.to_string()))?; @@ -182,7 +292,7 @@ impl WicketdApi for WicketdApiImpl { let ctx = rqctx.context(); let params = params.into_inner(); - let mut config = ctx.rss_config.lock().unwrap(); + let mut config = ctx.rss_or_multirack_join_config.lock().unwrap(); let status = config .set_bgp_auth_key(params.key_id, body.into_inner().key) .map_err(|err| HttpError::for_bad_request(None, err.to_string()))?; @@ -196,8 +306,18 @@ impl WicketdApi for WicketdApiImpl { ) -> Result { let ctx = rqctx.context(); - let mut config = ctx.rss_config.lock().unwrap(); - config.set_recovery_user_password_hash(body.into_inner().hash); + let mut config = ctx.rss_or_multirack_join_config.lock().unwrap(); + + let rss_config = config.rss_config_mut().ok_or_else(|| { + HttpError::for_client_error( + Some("Conflict".to_string()), + ClientErrorStatusCode::CONFLICT, + "cannot put recovery user password when not preparing for RSS" + .to_string(), + ) + })?; + + rss_config.set_recovery_user_password_hash(body.into_inner().hash); Ok(HttpResponseUpdatedNoContent()) } @@ -207,8 +327,12 @@ impl WicketdApi for WicketdApiImpl { ) -> Result { let ctx = rqctx.context(); - let mut config = ctx.rss_config.lock().unwrap(); - *config = Default::default(); + let mut config = ctx.rss_or_multirack_join_config.lock().unwrap(); + let rss_config = config.rss_config_mut().ok_or_else(|| { + HttpError::for_not_found(None, "rss config not found".to_string()) + })?; + + *rss_config = Default::default(); Ok(HttpResponseUpdatedNoContent()) } @@ -271,8 +395,18 @@ impl WicketdApi for WicketdApiImpl { })?; let request = { - let mut config = ctx.rss_config.lock().unwrap(); - config.start_rss_request(&ctx.bootstrap_peers, log).map_err( + let mut config = ctx.rss_or_multirack_join_config.lock().unwrap(); + + let rss_config = config.rss_config_mut().ok_or_else(|| { + HttpError::for_client_error( + Some("Conflict".to_string()), + ClientErrorStatusCode::CONFLICT, + "cannot run rack setup when not preparing for RSS" + .to_string(), + ) + })?; + + rss_config.start_rss_request(&ctx.bootstrap_peers, log).map_err( |err| HttpError::for_bad_request(None, format!("{err:#}")), )? }; @@ -823,8 +957,16 @@ impl WicketdApi for WicketdApiImpl { }; let (network_config, dns_servers, ntp_servers) = { - let rss_config = rqctx.rss_config.lock().unwrap(); - + let mut config = rqctx.rss_or_multirack_join_config.lock().unwrap(); + + let rss_config = config.rss_config_mut().ok_or_else(|| { + HttpError::for_client_error( + Some("Conflict".to_string()), + ClientErrorStatusCode::CONFLICT, + "cannot run preflight when not preparing for RSS" + .to_string(), + ) + })?; let network_config = rss_config .user_specified_rack_network_config() .cloned() diff --git a/wicketd/src/lib.rs b/wicketd/src/lib.rs index c46208eace9..fcd5723ae5e 100644 --- a/wicketd/src/lib.rs +++ b/wicketd/src/lib.rs @@ -3,6 +3,7 @@ // file, You can obtain one at https://mozilla.org/MPL/2.0/. mod artifacts; +mod bgp_auth_keys; mod bootstrap_addrs; mod config; mod context; @@ -10,6 +11,7 @@ mod helpers; mod http_entrypoints; mod installinator_progress; pub mod mgs; +mod multirack_config; mod nexus_proxy; mod preflight_check; mod rss_config; @@ -203,7 +205,7 @@ impl Server { bootstrap_peers, update_tracker: update_tracker.clone(), baseboard: args.baseboard, - rss_config: Default::default(), + rss_or_multirack_join_config: Default::default(), preflight_checker: PreflightCheckerHandler::new(&log), internal_dns_resolver, }, diff --git a/wicketd/src/multirack_config.rs b/wicketd/src/multirack_config.rs new file mode 100644 index 00000000000..0fa5a012039 --- /dev/null +++ b/wicketd/src/multirack_config.rs @@ -0,0 +1,154 @@ +// 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/. + +//! Support for user-provided multirack cluster join configuration options. + +use crate::bgp_auth_keys::BgpAuthKeyError; +use crate::bgp_auth_keys::BgpAuthKeys; +use crate::bootstrap_addrs::BootstrapPeers; +use omicron_common::api::external::AllowedSourceIps; +use sled_hardware_types::Baseboard; +use std::collections::BTreeMap; +use std::collections::BTreeSet; +use std::mem; +use wicket_common::inventory::MgsV1Inventory; +use wicket_common::inventory::SledInventory; +use wicket_common::multirack_setup::CurrentMultirackJoinUserConfig; +use wicket_common::multirack_setup::MultirackJoinConfigBaseUserInput; +use wicket_common::rack_setup::BgpAuthKey; +use wicket_common::rack_setup::BgpAuthKeyId; +use wicket_common::rack_setup::BgpAuthKeyStatus; +use wicket_common::rack_setup::BootstrapSledDescription; +use wicket_common::rack_setup::GetBgpAuthKeyInfoResponse; +use wicket_common::rack_setup::UserSpecifiedRackNetworkConfig; +use wicketd_api::SetBgpAuthKeyStatus; + +pub(crate) struct CurrentMultirackJoinConfig { + inventory: SledInventory, + bootstrap_sleds: BTreeSet, + rack_network_config: UserSpecifiedRackNetworkConfig, + bgp_auth_keys: BgpAuthKeys, + allowed_source_ips: AllowedSourceIps, +} + +impl CurrentMultirackJoinConfig { + pub(crate) fn check_bgp_auth_keys_valid<'a>( + &self, + check_valid: impl IntoIterator, + ) -> Result<(), BgpAuthKeyError> { + self.bgp_auth_keys.check_valid(check_valid) + } + + pub(crate) fn get_bgp_auth_key_data( + &self, + ) -> BTreeMap { + self.bgp_auth_keys.get_data() + } + + pub(crate) fn set_bgp_auth_key( + &mut self, + key_id: BgpAuthKeyId, + key: BgpAuthKey, + ) -> Result { + self.bgp_auth_keys.set_key(key_id, key) + } + + pub(crate) fn update_with_inventory_and_bootstrap_peers( + &mut self, + inventory: &MgsV1Inventory, + bootstrap_peers: &BootstrapPeers, + log: &slog::Logger, + ) { + let bootstrap_sleds = bootstrap_peers.sleds(); + self.inventory = SledInventory::new(inventory, &bootstrap_sleds, log); + + // If the user has already uploaded a config specifying bootstrap_sleds, + // also update our knowledge of those sleds' bootstrap addresses. + let our_bootstrap_sleds = mem::take(&mut self.bootstrap_sleds); + self.bootstrap_sleds = our_bootstrap_sleds + .into_iter() + .map(|mut sled_desc| { + sled_desc.bootstrap_ip = + bootstrap_sleds.get(&sled_desc.baseboard).copied(); + sled_desc + }) + .collect(); + } + + pub(crate) fn update( + &mut self, + config: MultirackJoinConfigBaseUserInput, + our_baseboard: Option<&Baseboard>, + ) -> Result<(), String> { + self.inventory.verify_our_baseboard_is_in_inventory_slot( + &config.bootstrap_slots, + our_baseboard, + )?; + self.bootstrap_sleds = + self.inventory.load_bootstrap_sleds(config.bootstrap_slots)?; + + let new_bgp_auth_key_ids = + config.rack_network_config.get_bgp_auth_key_ids(); + self.bgp_auth_keys.sync_keys(new_bgp_auth_key_ids); + self.rack_network_config = config.rack_network_config; + self.allowed_source_ips = config.allowed_source_ips; + + Ok(()) + } + + pub(crate) fn new_with_inventory_and_bootstrap_peers( + our_baseboard: Option<&Baseboard>, + config: MultirackJoinConfigBaseUserInput, + inventory: &MgsV1Inventory, + bootstrap_peers: &BootstrapPeers, + log: &slog::Logger, + ) -> Result { + let bootstrap_sleds = bootstrap_peers.sleds(); + + let sled_inventory = + SledInventory::new(inventory, &bootstrap_sleds, log); + + sled_inventory.verify_our_baseboard_is_in_inventory_slot( + &config.bootstrap_slots, + our_baseboard, + )?; + + let bootstrap_sleds = + sled_inventory.load_bootstrap_sleds(config.bootstrap_slots)?; + + let mut bgp_auth_keys = BgpAuthKeys::default(); + let new_bgp_auth_key_ids = + config.rack_network_config.get_bgp_auth_key_ids(); + bgp_auth_keys.sync_keys(new_bgp_auth_key_ids); + + Ok(Self { + inventory: sled_inventory, + bootstrap_sleds, + rack_network_config: config.rack_network_config, + bgp_auth_keys, + allowed_source_ips: config.allowed_source_ips, + }) + } +} + +impl From<&'_ CurrentMultirackJoinConfig> for CurrentMultirackJoinUserConfig { + fn from(config: &'_ CurrentMultirackJoinConfig) -> Self { + // If the user has selected bootstrap sleds, use those; otherwise, + // default to the full inventory list. + let bootstrap_sleds = if !config.bootstrap_sleds.is_empty() { + config.bootstrap_sleds.clone() + } else { + config.inventory.sleds.clone() + }; + + CurrentMultirackJoinUserConfig { + bootstrap_sleds, + rack_network_config: config.rack_network_config.clone(), + allowed_source_ips: config.allowed_source_ips.clone(), + bgp_auth_keys: GetBgpAuthKeyInfoResponse { + data: config.get_bgp_auth_key_data(), + }, + } + } +} diff --git a/wicketd/src/rss_config.rs b/wicketd/src/rss_config.rs index 22ce64baf4f..2d6732b97b6 100644 --- a/wicketd/src/rss_config.rs +++ b/wicketd/src/rss_config.rs @@ -4,6 +4,8 @@ //! Support for user-provided RSS configuration options. +use crate::bgp_auth_keys::BgpAuthKeyError; +use crate::bgp_auth_keys::BgpAuthKeys; use crate::bootstrap_addrs::BootstrapPeers; use anyhow::Context; use anyhow::Result; @@ -29,23 +31,19 @@ use sled_agent_types::early_networking::RouterPeerType; use sled_agent_types::early_networking::SwitchSlot; use sled_agent_types::early_networking::UplinkAddress; use sled_hardware_types::Baseboard; -use slog::debug; use slog::warn; use std::collections::BTreeMap; use std::collections::BTreeSet; -use std::collections::btree_map; use std::mem; use std::net::IpAddr; use std::net::Ipv6Addr; -use thiserror::Error; use wicket_common::inventory::MgsV1Inventory; -use wicket_common::inventory::SpType; +use wicket_common::inventory::SledInventory; use wicket_common::rack_setup::BgpAuthKey; use wicket_common::rack_setup::BgpAuthKeyId; use wicket_common::rack_setup::BgpAuthKeyStatus; use wicket_common::rack_setup::BootstrapSledDescription; use wicket_common::rack_setup::CurrentRssUserConfigInsensitive; -use wicket_common::rack_setup::DisplaySlice; use wicket_common::rack_setup::GetBgpAuthKeyInfoResponse; use wicket_common::rack_setup::PutRssUserConfigInsensitive; use wicket_common::rack_setup::UserSpecifiedPortConfig; @@ -69,7 +67,7 @@ struct PartialCertificate { /// the user to fill it in piecemeal. #[derive(Default)] pub(crate) struct CurrentRssConfig { - inventory: BTreeSet, + inventory: SledInventory, bootstrap_sleds: BTreeSet, ntp_servers: Vec, @@ -80,11 +78,7 @@ pub(crate) struct CurrentRssConfig { external_certificates: Vec, recovery_silo_password_hash: Option, rack_network_config: Option, - // BGP auth keys are identified by the key ID. It is an invariant that any - // key IDs defined in `rack_network_config` exist here. - // - // Currently these are always TCP-MD5 keys, - bgp_auth_keys: BTreeMap>, + bgp_auth_keys: BgpAuthKeys, allowed_source_ips: Option, // External certificates are uploaded in two separate actions (cert then // key, or vice versa). Here we store a partial certificate; once we have @@ -115,37 +109,7 @@ impl CurrentRssConfig { log: &slog::Logger, ) { let bootstrap_sleds = bootstrap_peers.sleds(); - - self.inventory = inventory - .sps - .iter() - .filter_map(|sp| { - if sp.id.type_ != SpType::Sled { - return None; - } - - let Some(state) = sp.state.as_ref() else { - debug!( - log, - "in update_with_inventory_and_bootstrap_peers, \ - filtering out SP with no state"; - "sp" => ?sp, - ); - return None; - }; - let baseboard = Baseboard::new_gimlet( - state.serial_number.clone(), - state.model.clone(), - state.revision, - ); - let bootstrap_ip = bootstrap_sleds.get(&baseboard).copied(); - Some(BootstrapSledDescription { - id: sp.id, - baseboard, - bootstrap_ip, - }) - }) - .collect(); + self.inventory = SledInventory::new(inventory, &bootstrap_sleds, log); // If the user has already uploaded a config specifying bootstrap_sleds, // also update our knowledge of those sleds' bootstrap addresses. @@ -398,31 +362,13 @@ impl CurrentRssConfig { return Err(BgpAuthKeyError::RackNetworkConfigNotSet); } - let not_found: Vec<_> = check_valid - .into_iter() - .filter(|key_id| !self.bgp_auth_keys.contains_key(key_id)) - .cloned() - .collect(); - if !not_found.is_empty() { - return Err(self.make_bgp_key_ids_not_found_error(not_found)); - } - - Ok(()) + self.bgp_auth_keys.check_valid(check_valid) } pub(crate) fn get_bgp_auth_key_data( &self, ) -> BTreeMap { - self.bgp_auth_keys - .iter() - .map(|(key_id, key)| { - let status = key - .as_ref() - .map(|key| BgpAuthKeyStatus::Set { info: key.info() }) - .unwrap_or(BgpAuthKeyStatus::Unset); - (key_id.clone(), status) - }) - .collect() + self.bgp_auth_keys.get_data() } pub(crate) fn set_bgp_auth_key( @@ -434,39 +380,7 @@ impl CurrentRssConfig { return Err(BgpAuthKeyError::RackNetworkConfigNotSet); } - match self.bgp_auth_keys.entry(key_id.clone()) { - btree_map::Entry::Occupied(mut entry) => { - match entry.get() { - Some(old_key) if old_key == &key => { - Ok(SetBgpAuthKeyStatus::Unchanged) - } - Some(_) => { - entry.insert(Some(key)); - Ok(SetBgpAuthKeyStatus::Replaced) - } - None => { - // This is a new key; we don't have it yet. - entry.insert(Some(key)); - Ok(SetBgpAuthKeyStatus::Added) - } - } - } - btree_map::Entry::Vacant(_) => { - Err(self.make_bgp_key_ids_not_found_error(vec![key_id])) - } - } - } - - #[must_use] - fn make_bgp_key_ids_not_found_error( - &self, - key_ids: Vec, - ) -> BgpAuthKeyError { - let valid_key_ids = self.bgp_auth_keys.keys().cloned().collect(); - BgpAuthKeyError::KeyIdsNotFound { - not_found: key_ids, - valid_keys: valid_key_ids, - } + self.bgp_auth_keys.set_key(key_id, key) } pub(crate) fn update( @@ -484,47 +398,15 @@ impl CurrentRssConfig { // First, confirm we have ourself in the inventory _and_ the user didn't // remove us from the list. - if let Some(our_baseboard @ Baseboard::Gimlet { .. }) = our_baseboard { - let our_slot = self - .inventory - .iter() - .find_map(|sled| { - if sled.baseboard == *our_baseboard { - Some(sled.id.slot) - } else { - None - } - }) - .ok_or_else(|| { - format!( - "Inventory is missing the scrimlet where wicketd is \ - running ({our_baseboard:?})", - ) - })?; - if !value.bootstrap_sleds.contains(&our_slot) { - return Err(format!( - "Cannot remove the scrimlet where wicketd is running \ - (sled {our_slot}: {our_baseboard:?}) \ - from bootstrap_sleds" - )); - } - } + self.inventory.verify_our_baseboard_is_in_inventory_slot( + &value.bootstrap_sleds, + our_baseboard, + )?; // Next, confirm the user's list only consists of sleds in our - // inventory. - let mut bootstrap_sleds = BTreeSet::new(); - for slot in value.bootstrap_sleds { - let sled = - self.inventory - .iter() - .find(|sled| sled.id.slot == slot) - .ok_or_else(|| { - format!( - "cannot add unknown sled {slot} to bootstrap_sleds", - ) - })?; - bootstrap_sleds.insert(sled.clone()); - } + // inventory and return those sleds. + let bootstrap_sleds = + self.inventory.load_bootstrap_sleds(value.bootstrap_sleds)?; self.bootstrap_sleds = bootstrap_sleds; self.ntp_servers = value.ntp_servers; @@ -535,22 +417,11 @@ impl CurrentRssConfig { self.external_dns_zone_name = value.external_dns_zone_name; self.allowed_source_ips = Some(value.allowed_source_ips); - // Build a new auth key map, dropping all old keys from the map. + // Sync the auth key map with the new config, preserving existing + // keys and dropping ones no longer referenced. let new_bgp_auth_key_ids = value.rack_network_config.get_bgp_auth_key_ids(); - let mut old_bgp_auth_keys = std::mem::take(&mut self.bgp_auth_keys); - let new_bgp_auth_keys = new_bgp_auth_key_ids - .into_iter() - .map(|key_id| { - ( - key_id.clone(), - // For each new key, either grab the corresponding old key, - // or initialize to None. - old_bgp_auth_keys.remove(&key_id).unwrap_or_else(|| None), - ) - }) - .collect(); - self.bgp_auth_keys = new_bgp_auth_keys; + self.bgp_auth_keys.sync_keys(new_bgp_auth_key_ids); self.rack_network_config = Some(value.rack_network_config); @@ -565,7 +436,7 @@ impl From<&'_ CurrentRssConfig> for CurrentRssUserConfig { let bootstrap_sleds = if !rss.bootstrap_sleds.is_empty() { rss.bootstrap_sleds.clone() } else { - rss.inventory.clone() + rss.inventory.sleds.clone() }; Self { @@ -594,27 +465,9 @@ impl From<&'_ CurrentRssConfig> for CurrentRssUserConfig { } } -#[derive(Clone, Debug, PartialEq, Eq, Error)] -pub(crate) enum BgpAuthKeyError { - #[error( - "rack network config not set (upload the config before setting a key)" - )] - RackNetworkConfigNotSet, - - #[error( - "key IDs not found: {} (valid key IDs: {})", - DisplaySlice(.not_found), - DisplaySlice(.valid_keys), - )] - KeyIdsNotFound { - not_found: Vec, - valid_keys: Vec, - }, -} - fn validate_rack_network_config( config: &UserSpecifiedRackNetworkConfig, - bgp_auth_keys: &BTreeMap>, + bgp_auth_keys: &BgpAuthKeys, ) -> Result { use bootstrap_agent_lockstep_client::types::BgpConfig as BaBgpConfig; use bootstrap_agent_lockstep_client::types::MaxPathConfig as BaMaxPathConfig; @@ -682,7 +535,7 @@ fn validate_rack_network_config( } // Check that all auth keys are present. - for (key_id, key) in bgp_auth_keys { + for (key_id, key) in bgp_auth_keys.iter() { if key.is_none() { bail!("No BGP MD5 auth key provided for key ID {key_id}"); } @@ -771,7 +624,7 @@ fn build_port_config( switch: SwitchSlot, port: &str, config: &UserSpecifiedPortConfig, - bgp_auth_keys: &BTreeMap>, + bgp_auth_keys: &BgpAuthKeys, ) -> PortConfig { use sled_agent_types::early_networking::BgpPeerConfig; @@ -898,14 +751,14 @@ mod tests { // Default should be okay and have at least one BGP peer. let example = ExampleRackSetupData::non_empty(); let bgp_auth_keys = { - let mut m = BTreeMap::new(); + let mut keys = BgpAuthKeys::default(); for id in example.bgp_auth_keys { - m.insert( + keys.insert( id, Some(BgpAuthKey::TcpMd5 { key: "dummy".to_owned() }), ); } - m + keys }; let rack_network_config = example.put_insensitive.rack_network_config; validate_rack_network_config(&rack_network_config, &bgp_auth_keys) @@ -989,7 +842,7 @@ mod tests { // XXX: This is a hack -- ideally we'd go through the front door of // update_with_inventory_and_bootstrap_peers. But it works for now. - current_config.inventory = example.inventory; + current_config.inventory = example.inventory.into(); current_config .update( From 83e615f812c3f6b9b54c3aa289d5cc787ab11994 Mon Sep 17 00:00:00 2001 From: "Andrew J. Stone" Date: Wed, 20 May 2026 16:26:12 +0000 Subject: [PATCH 2/6] openapi --- openapi/wicketd.json | 105 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) diff --git a/openapi/wicketd.json b/openapi/wicketd.json index 08e0548e44e..2ded811a258 100644 --- a/openapi/wicketd.json +++ b/openapi/wicketd.json @@ -608,6 +608,56 @@ } } }, + "/rack-setup/config/multirack": { + "get": { + "summary": "Get the current status of the multirack join configuration.", + "operationId": "get_multirack_join_config", + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CurrentMultirackJoinUserConfig" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "put": { + "summary": "Upload a configuration for joining this rack into an existing regional", + "description": "cluster.\n\nA multirack join configuration is mutually exclusive with an RSS config.", + "operationId": "put_multirack_join_config", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MultirackJoinConfigBaseUserInput" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, "/rack-setup/config/recovery-user-password-hash": { "put": { "summary": "Update the RSS config recovery silo user password hash.", @@ -1585,6 +1635,34 @@ "tx_output_status" ] }, + "CurrentMultirackJoinUserConfig": { + "description": "A version of the multirack join configuration which contains learned bootstrap sleds and a redacted form of BGP auth keys if they exist.\n\nThis is returned to the user via wicketd and displayed in wicket.\n\nNote that there are no optional fields here unlike in `CurrentRssUserConfigInsensitive`. This is because wicketd defaults to filling in an empty RSS config for backwards compatibility and allows uploading BGP auth keys before actually uploading the RSS config. However, since we never default to an empty version of the `CurrentMultirackJoinConfig`, we can only fill in the BGP auth keys for this structure after the `MultirackJoinConfigBaseUserInput` is uploaded from a user.", + "type": "object", + "properties": { + "allowed_source_ips": { + "$ref": "#/components/schemas/AllowedSourceIps" + }, + "bgp_auth_keys": { + "$ref": "#/components/schemas/GetBgpAuthKeyInfoResponse" + }, + "bootstrap_sleds": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BootstrapSledDescription" + }, + "uniqueItems": true + }, + "rack_network_config": { + "$ref": "#/components/schemas/UserSpecifiedRackNetworkConfig" + } + }, + "required": [ + "allowed_source_ips", + "bgp_auth_keys", + "bootstrap_sleds", + "rack_network_config" + ] + }, "CurrentRssUserConfig": { "type": "object", "properties": { @@ -2543,6 +2621,33 @@ } } }, + "MultirackJoinConfigBaseUserInput": { + "description": "Input from a user for adopting an uninitialized rack into an existing regional cluster.\n\nThis type is provided in the form of a TOML file uploaded via the wicket CLI. It does not contain sensitive user input such as BGP keys. Those are input separately.", + "type": "object", + "properties": { + "allowed_source_ips": { + "$ref": "#/components/schemas/AllowedSourceIps" + }, + "bootstrap_slots": { + "description": "List of slot numbers only.", + "type": "array", + "items": { + "type": "integer", + "format": "uint16", + "minimum": 0 + }, + "uniqueItems": true + }, + "rack_network_config": { + "$ref": "#/components/schemas/UserSpecifiedRackNetworkConfig" + } + }, + "required": [ + "allowed_source_ips", + "bootstrap_slots", + "rack_network_config" + ] + }, "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.", From bb42a8433969bbb7fa0a88bc8da73f18fd46e821 Mon Sep 17 00:00:00 2001 From: "Andrew J. Stone" Date: Thu, 21 May 2026 17:27:56 +0000 Subject: [PATCH 3/6] Add support for auto ddm config --- wicket-common/src/example.rs | 20 +++++++----- wicket-common/src/rack_setup.rs | 40 +++++++++++++++++------- wicket/src/cli/rack_setup/config_toml.rs | 8 +++-- wicket/src/ui/panes/rack_setup.rs | 4 +-- wicketd/src/preflight_check/uplink.rs | 10 ++++-- wicketd/src/rss_config.rs | 33 ++++++++++++------- 6 files changed, 79 insertions(+), 36 deletions(-) diff --git a/wicket-common/src/example.rs b/wicket-common/src/example.rs index 5abf054d7e8..60e10490155 100644 --- a/wicket-common/src/example.rs +++ b/wicket-common/src/example.rs @@ -23,10 +23,11 @@ use crate::{ inventory::SpIdentifier, rack_setup::{ BgpAuthKeyId, BootstrapSledDescription, - CurrentRssUserConfigInsensitive, PutRssUserConfigInsensitive, - UserSpecifiedBgpPeerConfig, UserSpecifiedImportExportPolicy, - UserSpecifiedPortConfig, UserSpecifiedRackNetworkConfig, - UserSpecifiedRouterPeerAddr, UserSpecifiedUplinkAddressConfig, + CurrentRssUserConfigInsensitive, ManualPortConfig, + PutRssUserConfigInsensitive, UserSpecifiedBgpPeerConfig, + UserSpecifiedImportExportPolicy, UserSpecifiedPortConfig, + UserSpecifiedRackNetworkConfig, UserSpecifiedRouterPeerAddr, + UserSpecifiedUplinkAddressConfig, }, }; @@ -212,7 +213,7 @@ impl ExampleRackSetupData { infra_ip_last: "172.30.0.10".parse().unwrap(), #[rustfmt::skip] switch0: btreemap! { - "port0".to_owned() => UserSpecifiedPortConfig { + "port0".to_owned() => UserSpecifiedPortConfig::Manual(ManualPortConfig { addresses: vec![UserSpecifiedUplinkAddressConfig { address: UplinkAddress::AddrConf, vlan_id: Some(1), @@ -229,13 +230,13 @@ impl ExampleRackSetupData { lldp: switch0_port0_lldp, tx_eq, autoneg: true, - }, + }), }, #[rustfmt::skip] switch1: btreemap! { // Use the same port name as in switch0 to test that it doesn't // collide. - "port0".to_owned() => UserSpecifiedPortConfig { + "port0".to_owned() => UserSpecifiedPortConfig::Manual(ManualPortConfig { addresses: vec![UserSpecifiedUplinkAddressConfig::without_vlan( "172.30.0.1/24".parse().unwrap(), )], @@ -251,7 +252,7 @@ impl ExampleRackSetupData { lldp: switch1_port0_lldp, tx_eq, autoneg: true, - }, + }), }, bgp: vec![BgpConfig { asn: 47, @@ -333,6 +334,9 @@ fn apply_tweak( let rnc = current_insensitive.rack_network_config.as_mut().unwrap(); for (_, _, port) in rnc.iter_uplinks_mut() { // Remove all but the first BGP peer. + let UserSpecifiedPortConfig::Manual(port) = port else { + unimplemented!("DdmAutoPortConfig currently unsupported") + }; port.bgp_peers.drain(1..); } } diff --git a/wicket-common/src/rack_setup.rs b/wicket-common/src/rack_setup.rs index c2ff6377e01..97b763b0d03 100644 --- a/wicket-common/src/rack_setup.rs +++ b/wicket-common/src/rack_setup.rs @@ -141,17 +141,20 @@ impl UserSpecifiedRackNetworkConfig { /// Returns an iterator over all uplinks -- (switch, port, config) triples. pub fn iter_uplinks( &self, - ) -> impl Iterator - { - let iter0 = self - .switch0 - .iter() - .map(|(port, cfg)| (SwitchSlot::Switch0, port.as_str(), cfg)); + ) -> impl Iterator { + let iter0 = self.switch0.iter().filter_map(|(port, cfg)| match cfg { + UserSpecifiedPortConfig::Manual(cfg) => { + Some((SwitchSlot::Switch0, port.as_str(), cfg)) + } + UserSpecifiedPortConfig::DdmAutoPortConfig => None, + }); - let iter1 = self - .switch1 - .iter() - .map(|(port, cfg)| (SwitchSlot::Switch1, port.as_str(), cfg)); + let iter1 = self.switch1.iter().filter_map(|(port, cfg)| match cfg { + UserSpecifiedPortConfig::Manual(cfg) => { + Some((SwitchSlot::Switch1, port.as_str(), cfg)) + } + UserSpecifiedPortConfig::DdmAutoPortConfig => None, + }); iter0.chain(iter1) } @@ -182,7 +185,7 @@ impl UserSpecifiedRackNetworkConfig { /// the fields other than the port name. #[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] #[serde(deny_unknown_fields)] -pub struct UserSpecifiedPortConfig { +pub struct ManualPortConfig { pub routes: Vec, pub addresses: Vec, pub uplink_port_speed: LinkSpeed, @@ -196,6 +199,21 @@ pub struct UserSpecifiedPortConfig { pub tx_eq: Option, } +#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] +pub enum UserSpecifiedPortConfig { + Manual(ManualPortConfig), + DdmAutoPortConfig, +} + +impl UserSpecifiedPortConfig { + pub fn manual(&self) -> Option<&ManualPortConfig> { + match self { + Self::Manual(cfg) => Some(cfg), + Self::DdmAutoPortConfig => None, + } + } +} + /// User-specified version of /// [`sled_agent_types::early_networking::UplinkAddressConfig`]. /// diff --git a/wicket/src/cli/rack_setup/config_toml.rs b/wicket/src/cli/rack_setup/config_toml.rs index 97e3f65ca48..bc36ad7e40d 100644 --- a/wicket/src/cli/rack_setup/config_toml.rs +++ b/wicket/src/cli/rack_setup/config_toml.rs @@ -27,6 +27,7 @@ use toml_edit::Value; use wicket_common::inventory::SpType; use wicket_common::rack_setup::BootstrapSledDescription; use wicket_common::rack_setup::CurrentRssUserConfigInsensitive; +use wicket_common::rack_setup::ManualPortConfig; use wicket_common::rack_setup::UserSpecifiedBgpPeerConfig; use wicket_common::rack_setup::UserSpecifiedImportExportPolicy; use wicket_common::rack_setup::UserSpecifiedPortConfig; @@ -339,7 +340,7 @@ fn populate_network_table( #[must_use] fn populate_uplink_table(cfg: &UserSpecifiedPortConfig) -> Table { // This style ensures that if a new field is added, this fails loudly. - let UserSpecifiedPortConfig { + let UserSpecifiedPortConfig::Manual(ManualPortConfig { routes, addresses, uplink_port_speed, @@ -348,7 +349,10 @@ fn populate_uplink_table(cfg: &UserSpecifiedPortConfig) -> Table { bgp_peers, lldp, tx_eq, - } = cfg; + }) = cfg + else { + unimplemented!("DdmAutoPortConfig currently unsupported") + }; let mut uplink = Table::new(); diff --git a/wicket/src/ui/panes/rack_setup.rs b/wicket/src/ui/panes/rack_setup.rs index 76a1b391441..97b447859ce 100644 --- a/wicket/src/ui/panes/rack_setup.rs +++ b/wicket/src/ui/panes/rack_setup.rs @@ -42,9 +42,9 @@ use std::borrow::Cow; use wicket_common::rack_setup::BgpAuthKeyInfo; use wicket_common::rack_setup::BgpAuthKeyStatus; use wicket_common::rack_setup::CurrentRssUserConfigInsensitive; +use wicket_common::rack_setup::ManualPortConfig; use wicket_common::rack_setup::UserSpecifiedBgpPeerConfig; use wicket_common::rack_setup::UserSpecifiedImportExportPolicy; -use wicket_common::rack_setup::UserSpecifiedPortConfig; use wicket_common::rack_setup::UserSpecifiedRackNetworkConfig; use wicket_common::rack_setup::UserSpecifiedRouterPeerAddr; use wicket_common::rack_setup::UserSpecifiedUplinkAddressConfig; @@ -785,7 +785,7 @@ fn rss_config_text<'a>( } = cfg; for (i, (switch, port, uplink)) in cfg.iter_uplinks().enumerate() { - let UserSpecifiedPortConfig { + let ManualPortConfig { routes, addresses, uplink_port_speed, diff --git a/wicketd/src/preflight_check/uplink.rs b/wicketd/src/preflight_check/uplink.rs index 71e1ad97009..f10ff586558 100644 --- a/wicketd/src/preflight_check/uplink.rs +++ b/wicketd/src/preflight_check/uplink.rs @@ -53,6 +53,7 @@ use wicket_common::preflight_check::StepWarning; use wicket_common::preflight_check::UpdateEngine; use wicket_common::preflight_check::UplinkPreflightStepId; use wicket_common::preflight_check::UplinkPreflightTerminalError; +use wicket_common::rack_setup::ManualPortConfig; use wicket_common::rack_setup::UserSpecifiedPortConfig; use wicket_common::rack_setup::UserSpecifiedRackNetworkConfig; @@ -95,6 +96,11 @@ pub(super) async fn run_local_uplink_preflight_check( let mut engine = UpdateEngine::new(log, sender); for (port, uplink) in network_config.port_map(our_switch_slot) { + let UserSpecifiedPortConfig::Manual(uplink) = uplink else { + error!(log, "DdmAutoPortConfig not supported for uplinks",); + continue; + }; + add_steps_for_single_local_uplink_preflight_check( &mut engine, &dpd_client, @@ -134,7 +140,7 @@ fn add_steps_for_single_local_uplink_preflight_check<'a>( engine: &mut UpdateEngine<'a>, dpd_client: &'a DpdClient, port: &'a str, - uplink: &'a UserSpecifiedPortConfig, + uplink: &'a ManualPortConfig, dns_servers: &'a [IpAddr], ntp_servers: &'a [String], dns_name_to_query: Option<&'a str>, @@ -819,7 +825,7 @@ fn add_steps_for_single_local_uplink_preflight_check<'a>( } fn build_port_settings( - uplink: &UserSpecifiedPortConfig, + uplink: &ManualPortConfig, link_id: &LinkId, ) -> PortSettings { // Map from omicron_common types to dpd_client types diff --git a/wicketd/src/rss_config.rs b/wicketd/src/rss_config.rs index 2d6732b97b6..7e3ad8b5c26 100644 --- a/wicketd/src/rss_config.rs +++ b/wicketd/src/rss_config.rs @@ -45,8 +45,8 @@ use wicket_common::rack_setup::BgpAuthKeyStatus; use wicket_common::rack_setup::BootstrapSledDescription; use wicket_common::rack_setup::CurrentRssUserConfigInsensitive; use wicket_common::rack_setup::GetBgpAuthKeyInfoResponse; +use wicket_common::rack_setup::ManualPortConfig; use wicket_common::rack_setup::PutRssUserConfigInsensitive; -use wicket_common::rack_setup::UserSpecifiedPortConfig; use wicket_common::rack_setup::UserSpecifiedRackNetworkConfig; use wicket_common::rack_setup::UserSpecifiedRouterPeerAddr; use wicketd_api::CertificateUploadResponse; @@ -623,7 +623,7 @@ pub fn validate_rack_subnet( fn build_port_config( switch: SwitchSlot, port: &str, - config: &UserSpecifiedPortConfig, + config: &ManualPortConfig, bgp_auth_keys: &BgpAuthKeys, ) -> PortConfig { use sled_agent_types::early_networking::BgpPeerConfig; @@ -769,6 +769,8 @@ mod tests { .first_key_value() .expect("at least one switch0 port") .1 + .manual() + .unwrap() .bgp_peers .is_empty() ); @@ -776,14 +778,17 @@ mod tests { // Combine unnumbered with a non-default router_lifetime - fine. let mut valid_router_lifetime = rack_network_config.clone(); { - let peer = valid_router_lifetime + let mut peer = valid_router_lifetime .switch0 .first_entry() .unwrap() .into_mut() + .manual() + .unwrap() .bgp_peers - .get_mut(0) - .unwrap(); + .get(0) + .unwrap() + .clone(); peer.addr = UserSpecifiedRouterPeerAddr::Unnumbered; peer.router_lifetime = RouterLifetimeConfig::new(1234).unwrap(); } @@ -794,14 +799,17 @@ mod tests { // should fail with a reasonable error. let mut invalid_router_lifetime = valid_router_lifetime.clone(); { - let peer = invalid_router_lifetime + let mut peer = invalid_router_lifetime .switch0 .first_entry() .unwrap() .into_mut() + .manual() + .unwrap() .bgp_peers - .get_mut(0) - .unwrap(); + .get(0) + .unwrap() + .clone(); peer.addr = UserSpecifiedRouterPeerAddr::Numbered( "1.2.3.4".parse().unwrap(), ); @@ -820,14 +828,17 @@ mod tests { // Keep numbered peer but switch router_lifetime back to default - fine. let mut valid_router_lifetime = invalid_router_lifetime.clone(); { - let peer = valid_router_lifetime + let mut peer = valid_router_lifetime .switch0 .first_entry() .unwrap() .into_mut() + .manual() + .unwrap() .bgp_peers - .get_mut(0) - .unwrap(); + .get(0) + .unwrap() + .clone(); peer.router_lifetime = RouterLifetimeConfig::default() } validate_rack_network_config(&valid_router_lifetime, &bgp_auth_keys) From bc3a0ed3c35b88a3ddc10b662d5cd597bcb9bc74 Mon Sep 17 00:00:00 2001 From: "Andrew J. Stone" Date: Thu, 21 May 2026 17:38:17 +0000 Subject: [PATCH 4/6] openapi --- openapi/wicketd.json | 136 +++++++++++++++++++------------- wicket-common/src/rack_setup.rs | 1 + 2 files changed, 81 insertions(+), 56 deletions(-) diff --git a/openapi/wicketd.json b/openapi/wicketd.json index 2ded811a258..3f1ef34b71c 100644 --- a/openapi/wicketd.json +++ b/openapi/wicketd.json @@ -7906,68 +7906,92 @@ } }, "UserSpecifiedPortConfig": { - "description": "User-specified version of `PortConfig`.\n\nAll of `PortConfig` is user-specified. But we expect the port name to be a key, rather than a field as in `PortConfig`. So this has all of the fields other than the port name.", - "type": "object", - "properties": { - "addresses": { - "type": "array", - "items": { - "$ref": "#/components/schemas/UserSpecifiedUplinkAddressConfig" - } - }, - "autoneg": { - "type": "boolean" - }, - "bgp_peers": { - "default": [], - "type": "array", - "items": { - "$ref": "#/components/schemas/UserSpecifiedBgpPeerConfig" - } - }, - "lldp": { - "nullable": true, - "default": null, - "allOf": [ - { - "$ref": "#/components/schemas/LldpPortConfig" - } - ] - }, - "routes": { - "type": "array", - "items": { - "$ref": "#/components/schemas/RouteConfig" - } - }, - "tx_eq": { - "nullable": true, - "default": null, - "allOf": [ - { - "$ref": "#/components/schemas/TxEqConfig" + "oneOf": [ + { + "description": "User-specified version of `PortConfig`.\n\nAll of `PortConfig` is user-specified. But we expect the port name to be a key, rather than a field as in `PortConfig`. So this has all of the fields other than the port name.", + "type": "object", + "properties": { + "addresses": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UserSpecifiedUplinkAddressConfig" + } + }, + "autoneg": { + "type": "boolean" + }, + "bgp_peers": { + "default": [], + "type": "array", + "items": { + "$ref": "#/components/schemas/UserSpecifiedBgpPeerConfig" + } + }, + "lldp": { + "nullable": true, + "default": null, + "allOf": [ + { + "$ref": "#/components/schemas/LldpPortConfig" + } + ] + }, + "routes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RouteConfig" + } + }, + "tx_eq": { + "nullable": true, + "default": null, + "allOf": [ + { + "$ref": "#/components/schemas/TxEqConfig" + } + ] + }, + "type": { + "type": "string", + "enum": [ + "manual" + ] + }, + "uplink_port_fec": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/LinkFec" + } + ] + }, + "uplink_port_speed": { + "$ref": "#/components/schemas/LinkSpeed" } + }, + "required": [ + "addresses", + "autoneg", + "routes", + "type", + "uplink_port_speed" ] }, - "uplink_port_fec": { - "nullable": true, - "allOf": [ - { - "$ref": "#/components/schemas/LinkFec" + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "ddm_auto_port_config" + ] } + }, + "required": [ + "type" ] - }, - "uplink_port_speed": { - "$ref": "#/components/schemas/LinkSpeed" } - }, - "required": [ - "addresses", - "autoneg", - "routes", - "uplink_port_speed" - ], - "additionalProperties": false + ] }, "UserSpecifiedRackNetworkConfig": { "description": "User-specified parts of `RackNetworkConfig`.", diff --git a/wicket-common/src/rack_setup.rs b/wicket-common/src/rack_setup.rs index 97b763b0d03..b18ebbff01c 100644 --- a/wicket-common/src/rack_setup.rs +++ b/wicket-common/src/rack_setup.rs @@ -200,6 +200,7 @@ pub struct ManualPortConfig { } #[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] +#[serde(tag = "type", rename_all = "snake_case")] pub enum UserSpecifiedPortConfig { Manual(ManualPortConfig), DdmAutoPortConfig, From 0da58dc0fc0793b6e5edb83cd3a013807abd92f3 Mon Sep 17 00:00:00 2001 From: "Andrew J. Stone" Date: Thu, 21 May 2026 19:07:39 +0000 Subject: [PATCH 5/6] fix tests --- wicket-common/src/rack_setup.rs | 10 +++++++- wicket/src/cli/rack_setup/config_toml.rs | 1 + wicket/tests/output/example_non_empty.toml | 2 ++ wicketd/src/rss_config.rs | 27 ++++++++++------------ 4 files changed, 24 insertions(+), 16 deletions(-) diff --git a/wicket-common/src/rack_setup.rs b/wicket-common/src/rack_setup.rs index b18ebbff01c..657e068cb96 100644 --- a/wicket-common/src/rack_setup.rs +++ b/wicket-common/src/rack_setup.rs @@ -200,7 +200,8 @@ pub struct ManualPortConfig { } #[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(tag = "type", rename_all = "snake_case")] +#[serde(rename_all = "snake_case", tag = "type")] +#[allow(clippy::large_enum_variant)] pub enum UserSpecifiedPortConfig { Manual(ManualPortConfig), DdmAutoPortConfig, @@ -213,6 +214,13 @@ impl UserSpecifiedPortConfig { Self::DdmAutoPortConfig => None, } } + + pub fn manual_mut(&mut self) -> Option<&mut ManualPortConfig> { + match self { + Self::Manual(cfg) => Some(cfg), + Self::DdmAutoPortConfig => None, + } + } } /// User-specified version of diff --git a/wicket/src/cli/rack_setup/config_toml.rs b/wicket/src/cli/rack_setup/config_toml.rs index bc36ad7e40d..44ab06b9d95 100644 --- a/wicket/src/cli/rack_setup/config_toml.rs +++ b/wicket/src/cli/rack_setup/config_toml.rs @@ -355,6 +355,7 @@ fn populate_uplink_table(cfg: &UserSpecifiedPortConfig) -> Table { }; let mut uplink = Table::new(); + uplink.insert("type", string_item("manual")); // routes = [] let mut routes_out = Array::new(); diff --git a/wicket/tests/output/example_non_empty.toml b/wicket/tests/output/example_non_empty.toml index 0537bfdeae4..dbc7336f845 100644 --- a/wicket/tests/output/example_non_empty.toml +++ b/wicket/tests/output/example_non_empty.toml @@ -74,6 +74,7 @@ infra_ip_last = "172.30.0.10" rack_subnet_address = "fd00:1122:3344:100::" [rack_network_config.switch0.port0] +type = "manual" routes = [{ nexthop = "172.30.0.10", destination = "0.0.0.0/0", vlan_id = 1 }] addresses = [{ address = "addrconf", vlan_id = 1 }] uplink_port_speed = "speed400_g" @@ -128,6 +129,7 @@ post1 = 0 post2 = 0 [rack_network_config.switch1.port0] +type = "manual" routes = [{ nexthop = "172.33.0.10", destination = "0.0.0.0/0", vlan_id = 1 }] addresses = [{ address = "172.30.0.1/24" }] uplink_port_speed = "speed400_g" diff --git a/wicketd/src/rss_config.rs b/wicketd/src/rss_config.rs index 7e3ad8b5c26..1e27459d15e 100644 --- a/wicketd/src/rss_config.rs +++ b/wicketd/src/rss_config.rs @@ -778,17 +778,16 @@ mod tests { // Combine unnumbered with a non-default router_lifetime - fine. let mut valid_router_lifetime = rack_network_config.clone(); { - let mut peer = valid_router_lifetime + let peer = valid_router_lifetime .switch0 .first_entry() .unwrap() .into_mut() - .manual() + .manual_mut() .unwrap() .bgp_peers - .get(0) - .unwrap() - .clone(); + .get_mut(0) + .unwrap(); peer.addr = UserSpecifiedRouterPeerAddr::Unnumbered; peer.router_lifetime = RouterLifetimeConfig::new(1234).unwrap(); } @@ -799,17 +798,16 @@ mod tests { // should fail with a reasonable error. let mut invalid_router_lifetime = valid_router_lifetime.clone(); { - let mut peer = invalid_router_lifetime + let peer = invalid_router_lifetime .switch0 .first_entry() .unwrap() .into_mut() - .manual() + .manual_mut() .unwrap() .bgp_peers - .get(0) - .unwrap() - .clone(); + .get_mut(0) + .unwrap(); peer.addr = UserSpecifiedRouterPeerAddr::Numbered( "1.2.3.4".parse().unwrap(), ); @@ -828,17 +826,16 @@ mod tests { // Keep numbered peer but switch router_lifetime back to default - fine. let mut valid_router_lifetime = invalid_router_lifetime.clone(); { - let mut peer = valid_router_lifetime + let peer = valid_router_lifetime .switch0 .first_entry() .unwrap() .into_mut() - .manual() + .manual_mut() .unwrap() .bgp_peers - .get(0) - .unwrap() - .clone(); + .get_mut(0) + .unwrap(); peer.router_lifetime = RouterLifetimeConfig::default() } validate_rack_network_config(&valid_router_lifetime, &bgp_auth_keys) From 6f23ab764582ee94aee650796e160f7ea39fdb3e Mon Sep 17 00:00:00 2001 From: "Andrew J. Stone" Date: Thu, 21 May 2026 19:27:14 +0000 Subject: [PATCH 6/6] fix doc link --- wicketd/src/rss_config.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wicketd/src/rss_config.rs b/wicketd/src/rss_config.rs index 1e27459d15e..9590c2ba52d 100644 --- a/wicketd/src/rss_config.rs +++ b/wicketd/src/rss_config.rs @@ -617,7 +617,7 @@ pub fn validate_rack_subnet( Ipv6Net::new(rack_subnet_address, 56).map_err(|e| e.to_string()) } -/// Builds a [`PortConfig`] from a [`UserSpecifiedPortConfig`]. +/// Builds a [`PortConfig`] from a [`wicket_common::rack_setup::UserSpecifiedPortConfig`]. /// /// Assumes that all auth keys are present in `bgp_auth_keys`. fn build_port_config(