From 319605b0e4499be9c515b1e2cd20e1a3cccf673b Mon Sep 17 00:00:00 2001 From: John Gallagher Date: Mon, 2 Mar 2026 22:28:34 -0500 Subject: [PATCH 01/23] wip: introduce stronger unnumbered types --- sled-agent/api/src/lib.rs | 1 + .../src/early_networking/serialization.rs | 3 +- .../versions/src/impls/early_networking.rs | 50 +++ sled-agent/types/versions/src/latest.rs | 20 +- sled-agent/types/versions/src/lib.rs | 2 + .../early_networking.rs | 306 ++++++++++++++++++ .../src/stronger_bgp_unnumbered_types/mod.rs | 8 + .../stronger_bgp_unnumbered_types/uplink.rs | 26 ++ 8 files changed, 408 insertions(+), 8 deletions(-) create mode 100644 sled-agent/types/versions/src/stronger_bgp_unnumbered_types/early_networking.rs create mode 100644 sled-agent/types/versions/src/stronger_bgp_unnumbered_types/mod.rs create mode 100644 sled-agent/types/versions/src/stronger_bgp_unnumbered_types/uplink.rs diff --git a/sled-agent/api/src/lib.rs b/sled-agent/api/src/lib.rs index a326908216b..23ad9b5780b 100644 --- a/sled-agent/api/src/lib.rs +++ b/sled-agent/api/src/lib.rs @@ -37,6 +37,7 @@ api_versions!([ // | example for the next person. // v // (next_int, IDENT), + (29, STRONGER_BGP_UNNUMBERED_TYPES), (28, MODIFY_SERVICES_IN_INVENTORY), (27, RENAME_SWITCH_LOCATION_TO_SWITCH_SLOT), (26, RACK_NETWORK_CONFIG_NOT_OPTIONAL), diff --git a/sled-agent/types/src/early_networking/serialization.rs b/sled-agent/types/src/early_networking/serialization.rs index 24169ec6734..3519957490b 100644 --- a/sled-agent/types/src/early_networking/serialization.rs +++ b/sled-agent/types/src/early_networking/serialization.rs @@ -50,7 +50,7 @@ use bootstore::schemes::v0 as bootstore; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; -use sled_agent_types_versions::{latest, v20, v26}; +use sled_agent_types_versions::{latest, v20, v26, v27}; use slog_error_chain::SlogInlineError; #[derive(Debug, thiserror::Error, SlogInlineError)] @@ -303,6 +303,7 @@ impl EarlyNetworkConfigEnvelope { let f = versioned_decode!( v20::early_networking::EarlyNetworkConfigBody, v26::early_networking::EarlyNetworkConfigBody, + v27::early_networking::EarlyNetworkConfigBody, ); f(self.schema_version, self.body.clone()) } diff --git a/sled-agent/types/versions/src/impls/early_networking.rs b/sled-agent/types/versions/src/impls/early_networking.rs index 861a79be7df..dd85a77dc70 100644 --- a/sled-agent/types/versions/src/impls/early_networking.rs +++ b/sled-agent/types/versions/src/impls/early_networking.rs @@ -16,9 +16,11 @@ use crate::latest::early_networking::SwitchSlot; use crate::latest::early_networking::UplinkAddressConfig; use omicron_common::api::external; use oxnet::IpNet; +use oxnet::IpNetParseError; use std::fmt; use std::net::IpAddr; use std::net::Ipv6Addr; +use std::net::AddrParseError; use std::str::FromStr; impl BgpPeerConfig { @@ -114,6 +116,54 @@ impl std::fmt::Display for RouterLifetimeConfig { } } +impl std::fmt::Display for SpecifiedIpNet { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(f) + } +} + +impl std::fmt::Display for SpecifiedIpAddr { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(f) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum SpecifiedIpNetParseError { + #[error("invalid IP net")] + IpNetParseError(#[from] IpNetParseError), + #[error(transparent)] + UnspecifiedIpError(#[from] UnspecifiedIpError), +} + +impl FromStr for SpecifiedIpNet { + type Err = SpecifiedIpNetParseError; + + fn from_str(s: &str) -> Result { + let ip = IpNet::from_str(s)?; + let addr = Self::try_from(ip)?; + Ok(addr) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum SpecifiedIpAddrParseError { + #[error(transparent)] + AddrParseError(#[from] AddrParseError), + #[error(transparent)] + UnspecifiedIpError(#[from] UnspecifiedIpError), +} + +impl FromStr for SpecifiedIpAddr { + type Err = SpecifiedIpAddrParseError; + + fn from_str(s: &str) -> Result { + let ip = IpAddr::from_str(s)?; + let addr = Self::try_from(ip)?; + Ok(addr) + } +} + impl UplinkAddressConfig { /// Construct an `UplinkAddressConfig` with no VLAN ID. pub fn without_vlan(address: IpNet) -> Self { diff --git a/sled-agent/types/versions/src/latest.rs b/sled-agent/types/versions/src/latest.rs index 2020f0997f5..7e9c79f973d 100644 --- a/sled-agent/types/versions/src/latest.rs +++ b/sled-agent/types/versions/src/latest.rs @@ -65,17 +65,22 @@ pub mod early_networking { pub use crate::v1::early_networking::TxEqConfig; pub use crate::v20::early_networking::BgpConfig; - pub use crate::v20::early_networking::BgpPeerConfig; pub use crate::v20::early_networking::MaxPathConfig; pub use crate::v20::early_networking::MaxPathConfigError; - pub use crate::v20::early_networking::PortConfig; - pub use crate::v20::early_networking::RackNetworkConfig; pub use crate::v20::early_networking::RouterLifetimeConfig; pub use crate::v20::early_networking::RouterLifetimeConfigError; - pub use crate::v20::early_networking::UplinkAddressConfig; - pub use crate::v26::early_networking::EarlyNetworkConfigBody; - pub use crate::v26::early_networking::WriteNetworkConfigRequest; + pub use crate::v29::early_networking::BgpPeerConfig; + pub use crate::v29::early_networking::EarlyNetworkConfigBody; + pub use crate::v29::early_networking::PortConfig; + pub use crate::v29::early_networking::RackNetworkConfig; + pub use crate::v29::early_networking::RouterPeerAddress; + pub use crate::v29::early_networking::SpecifiedIpAddr; + pub use crate::v29::early_networking::SpecifiedIpNet; + pub use crate::v29::early_networking::UnspecifiedIpError; + pub use crate::v29::early_networking::UplinkAddress; + pub use crate::v29::early_networking::UplinkAddressConfig; + pub use crate::v29::early_networking::WriteNetworkConfigRequest; } pub mod firewall_rules { @@ -220,8 +225,9 @@ pub mod trust_quorum { } pub mod uplink { - pub use crate::v20::uplink::HostPortConfig; pub use crate::v20::uplink::SwitchPorts; + + pub use crate::v26::uplink::HostPortConfig; } pub mod zone_bundle { diff --git a/sled-agent/types/versions/src/lib.rs b/sled-agent/types/versions/src/lib.rs index 3819e76271a..7d6e53721bd 100644 --- a/sled-agent/types/versions/src/lib.rs +++ b/sled-agent/types/versions/src/lib.rs @@ -67,6 +67,8 @@ pub mod v25; pub mod v26; #[path = "modify_services_in_inventory/mod.rs"] pub mod v28; +#[path = "stronger_bgp_unnumbered_types/mod.rs"] +pub mod v29; #[path = "add_switch_zone_operator_policy/mod.rs"] pub mod v3; #[path = "add_nexus_lockstep_port_to_inventory/mod.rs"] diff --git a/sled-agent/types/versions/src/stronger_bgp_unnumbered_types/early_networking.rs b/sled-agent/types/versions/src/stronger_bgp_unnumbered_types/early_networking.rs new file mode 100644 index 00000000000..b2ec63c3171 --- /dev/null +++ b/sled-agent/types/versions/src/stronger_bgp_unnumbered_types/early_networking.rs @@ -0,0 +1,306 @@ +// 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 for network setup required to bring up the control plane. +//! +//! Changes in this version: +//! * TODO-john + +use crate::v1::early_networking as v1; +use crate::v20::early_networking as v20; +use crate::v26::early_networking as v26; +use oxnet::{IpNet, Ipv6Net}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use std::net::IpAddr; + +#[derive(Debug, thiserror::Error)] +#[error("IP address must not be the unspecified address (0.0.0.0 or ::)")] +pub struct UnspecifiedIpError; + +#[derive( + Clone, Debug, Deserialize, Serialize, PartialEq, Eq, JsonSchema, Hash, +)] +pub enum UplinkAddress { + LinkLocal, + Address(SpecifiedIpNet), +} + +#[derive( + Clone, Debug, Deserialize, Serialize, PartialEq, Eq, JsonSchema, Hash, +)] +pub enum RouterPeerAddress { + Unnumbered, + Numbered(SpecifiedIpAddr), +} + +#[derive( + Clone, Debug, Deserialize, Serialize, PartialEq, Eq, JsonSchema, Hash, +)] +#[serde(try_from = "IpNet", into = "IpNet")] +pub struct SpecifiedIpNet(pub(crate) IpNet); + +// These conversion implementations are defined here instead of in +// `crate::impls::*` because they're tied to how we derive `Deserialize` and +// `Serialize`. +impl From for IpNet { + fn from(value: SpecifiedIpNet) -> Self { + value.0 + } +} + +impl TryFrom for SpecifiedIpNet { + type Error = UnspecifiedIpError; + + fn try_from(value: IpNet) -> Result { + if value.addr().is_unspecified() { + Err(UnspecifiedIpError) + } else { + Ok(Self(value)) + } + } +} + +#[derive( + Clone, Debug, Deserialize, Serialize, PartialEq, Eq, JsonSchema, Hash, +)] +#[serde(try_from = "IpAddr", into = "IpAddr")] +pub struct SpecifiedIpAddr(pub(crate) IpAddr); + +// As with `SpecifiedIpNet`, these conversion implementations are defined here +// instead of in `crate::impls::*` because they're tied to how we derive +// `Deserialize` and `Serialize`. +impl From for IpAddr { + fn from(value: SpecifiedIpAddr) -> Self { + value.0 + } +} + +impl TryFrom for SpecifiedIpAddr { + type Error = UnspecifiedIpError; + + fn try_from(value: IpAddr) -> Result { + if value.is_unspecified() { + Err(UnspecifiedIpError) + } else { + Ok(Self(value)) + } + } +} + +#[derive( + Clone, Debug, Deserialize, Serialize, PartialEq, Eq, JsonSchema, Hash, +)] +pub struct UplinkAddressConfig { + /// The address to be used on the uplink. + pub address: UplinkAddress, + + /// The VLAN id (if any) associated with this address. + #[serde(default)] + pub vlan_id: Option, +} + +impl From for UplinkAddressConfig { + fn from(value: v20::UplinkAddressConfig) -> Self { + let address = match value.address.map(SpecifiedIpNet::try_from) { + Some(Ok(net)) => UplinkAddress::Address(net), + Some(Err(UnspecifiedIpError)) | None => UplinkAddress::LinkLocal, + }; + Self { address, vlan_id: value.vlan_id } + } +} + +#[derive( + Clone, Debug, Deserialize, Serialize, PartialEq, Eq, Hash, JsonSchema, +)] +pub struct PortConfig { + /// The set of routes associated with this port. + pub routes: Vec, + /// This port's addresses and optional vlan IDs + pub addresses: Vec, + /// Switch the port belongs to. + pub switch: v1::SwitchLocation, + /// Nmae of the port this config applies to. + pub port: String, + /// Port speed. + pub uplink_port_speed: v1::PortSpeed, + /// Port forward error correction type. + pub uplink_port_fec: Option, + /// BGP peers on this port + pub bgp_peers: Vec, + /// Whether or not to set autonegotiation + #[serde(default)] + pub autoneg: bool, + /// LLDP configuration for this port + pub lldp: Option, + /// TX-EQ configuration for this port + pub tx_eq: Option, +} + +impl From for PortConfig { + fn from(value: v20::PortConfig) -> Self { + Self { + routes: value.routes, + addresses: value.addresses.into_iter().map(From::from).collect(), + switch: value.switch, + port: value.port, + uplink_port_speed: value.uplink_port_speed, + uplink_port_fec: value.uplink_port_fec, + bgp_peers: value.bgp_peers.into_iter().map(From::from).collect(), + autoneg: value.autoneg, + lldp: value.lldp, + tx_eq: value.tx_eq, + } + } +} + +#[derive( + Clone, Debug, Deserialize, Serialize, PartialEq, Eq, Hash, JsonSchema, +)] +pub struct BgpPeerConfig { + /// The autonomous system number of the router the peer belongs to. + pub asn: u32, + /// Switch port the peer is reachable on. + pub port: String, + /// Address of the peer. + pub addr: RouterPeerAddress, + /// How long to keep a session alive without a keepalive in seconds. + /// Defaults to 6. + pub hold_time: Option, + /// How long to keep a peer in idle after a state machine reset in seconds. + pub idle_hold_time: Option, + /// How long to delay sending open messages to a peer. In seconds. + pub delay_open: Option, + /// The interval in seconds between peer connection retry attempts. + pub connect_retry: Option, + /// The interval to send keepalive messages at. + pub keepalive: Option, + /// Require that a peer has a specified ASN. + #[serde(default)] + pub remote_asn: Option, + /// Require messages from a peer have a minimum IP time to live field. + #[serde(default)] + pub min_ttl: Option, + /// Use the given key for TCP-MD5 authentication with the peer. + #[serde(default)] + pub md5_auth_key: Option, + /// Apply the provided multi-exit discriminator (MED) updates sent to the peer. + #[serde(default)] + pub multi_exit_discriminator: Option, + /// Include the provided communities in updates sent to the peer. + #[serde(default)] + pub communities: Vec, + /// Apply a local preference to routes received from this peer. + #[serde(default)] + pub local_pref: Option, + /// Enforce that the first AS in paths received from this peer is the peer's AS. + #[serde(default)] + pub enforce_first_as: bool, + /// Define import policy for a peer. + #[serde(default)] + pub allowed_import: v1::ImportExportPolicy, + /// Define export policy for a peer. + #[serde(default)] + pub allowed_export: v1::ImportExportPolicy, + /// Associate a VLAN ID with a BGP peer session. + #[serde(default)] + pub vlan_id: Option, + /// Router lifetime in seconds for unnumbered BGP peers. + #[serde(default)] + pub router_lifetime: v20::RouterLifetimeConfig, +} + +impl From for BgpPeerConfig { + fn from(value: v20::BgpPeerConfig) -> Self { + let addr = match SpecifiedIpAddr::try_from(value.addr) { + Ok(ip) => RouterPeerAddress::Numbered(ip), + Err(UnspecifiedIpError) => RouterPeerAddress::Unnumbered, + }; + Self { + asn: value.asn, + port: value.port, + addr, + hold_time: value.hold_time, + idle_hold_time: value.idle_hold_time, + delay_open: value.delay_open, + connect_retry: value.connect_retry, + keepalive: value.keepalive, + remote_asn: value.remote_asn, + min_ttl: value.min_ttl, + md5_auth_key: value.md5_auth_key, + multi_exit_discriminator: value.multi_exit_discriminator, + communities: value.communities, + local_pref: value.local_pref, + enforce_first_as: value.enforce_first_as, + allowed_import: value.allowed_import, + allowed_export: value.allowed_export, + vlan_id: value.vlan_id, + router_lifetime: value.router_lifetime, + } + } +} + +/// Initial network configuration +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, JsonSchema)] +pub struct RackNetworkConfig { + pub rack_subnet: Ipv6Net, + // TODO: #3591 Consider making infra-ip ranges implicit for uplinks + /// First ip address to be used for configuring network infrastructure + pub infra_ip_first: IpAddr, + /// Last ip address to be used for configuring network infrastructure + pub infra_ip_last: IpAddr, + /// Uplinks for connecting the rack to external networks + pub ports: Vec, + /// BGP configurations for connecting the rack to external networks + pub bgp: Vec, + /// BFD configuration for connecting the rack to external networks + #[serde(default)] + pub bfd: Vec, +} + +/// This is the actual configuration of EarlyNetworking. +#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema, PartialEq)] +pub struct EarlyNetworkConfigBody { + // Rack network configuration as delivered from RSS or Nexus + pub rack_network_config: RackNetworkConfig, +} + +impl EarlyNetworkConfigBody { + pub const SCHEMA_VERSION: u32 = 4; +} + +// We're required to implement `TryFrom` for our deserialization machinery, but +// this conversion is infallible. +impl TryFrom for EarlyNetworkConfigBody { + type Error = anyhow::Error; + + fn try_from(old: v26::EarlyNetworkConfigBody) -> Result { + let old = old.rack_network_config; + + Ok(Self { + rack_network_config: RackNetworkConfig { + rack_subnet: old.rack_subnet, + infra_ip_first: old.infra_ip_first, + infra_ip_last: old.infra_ip_last, + ports: old.ports.into_iter().map(From::from).collect(), + bgp: old.bgp, + bfd: old.bfd, + }, + }) + } +} + +/// Structure for requests from Nexus to sled-agent to write a new +/// `EarlyNetworkConfigBody` into the replicated bootstore. +/// +/// [`WriteNetworkConfigRequest`] INTENTIONALLY does not have a `From` +/// implementation from prior API versions. It is critically important that +/// sled-agent not attempt to rewrite old `EarlyNetworkConfigBody` types to the +/// latest version. For more about this, see the comments on the relevant +/// endpoint in `sled-agent-api`. +#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema, PartialEq)] +pub struct WriteNetworkConfigRequest { + pub generation: u64, + pub body: EarlyNetworkConfigBody, +} diff --git a/sled-agent/types/versions/src/stronger_bgp_unnumbered_types/mod.rs b/sled-agent/types/versions/src/stronger_bgp_unnumbered_types/mod.rs new file mode 100644 index 00000000000..8cc2b258ccd --- /dev/null +++ b/sled-agent/types/versions/src/stronger_bgp_unnumbered_types/mod.rs @@ -0,0 +1,8 @@ +// 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/. + +//! Version `STRONGER_BGP_UNNUMBERED_TYPES` of the Sled Agent API. + +pub mod early_networking; +pub mod uplink; diff --git a/sled-agent/types/versions/src/stronger_bgp_unnumbered_types/uplink.rs b/sled-agent/types/versions/src/stronger_bgp_unnumbered_types/uplink.rs new file mode 100644 index 00000000000..15e8bfa3946 --- /dev/null +++ b/sled-agent/types/versions/src/stronger_bgp_unnumbered_types/uplink.rs @@ -0,0 +1,26 @@ +// 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/. + +//! Uplink-related types for the Sled Agent API. +//! +//! Changes in this version: +//! * TODO-john + +use crate::v1; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, JsonSchema)] +pub struct HostPortConfig { + /// Switchport to use for external connectivity + pub port: String, + + /// IP Address and prefix (e.g., `192.168.0.1/16`) to apply to switchport + /// (must be in infra_ip pool). May also include an optional VLAN ID. + pub addrs: Vec, + + pub lldp: Option, + + pub tx_eq: Option, +} From 79ba415cb34c30896f257e847695dc5a477c4057 Mon Sep 17 00:00:00 2001 From: John Gallagher Date: Tue, 3 Mar 2026 15:57:07 -0500 Subject: [PATCH 02/23] use new stronger types in sled-agent API --- Cargo.lock | 1 + clients/bootstrap-agent-client/Cargo.toml | 1 + .../src/lib.rs | 10 + clients/nexus-lockstep-client/src/lib.rs | 2 + nexus/db-model/src/bgp.rs | 18 +- .../src/test_util/host_phase_2_test_state.rs | 8 + .../tasks/sync_switch_configuration.rs | 41 ++-- nexus/src/app/rack.rs | 18 +- openapi/bootstrap-agent-lockstep.json | 98 ++++++++- openapi/nexus-lockstep.json | 98 ++++++++- .../sled-agent-28.0.0-415efe.json.gitstub | 1 + ...efe.json => sled-agent-29.0.0-7b533d.json} | 100 ++++++++- openapi/sled-agent/sled-agent-latest.json | 2 +- openapi/wicketd.json | 64 +++++- sled-agent/api/src/lib.rs | 33 ++- sled-agent/src/bootstrap/early_networking.rs | 207 +++++++++--------- sled-agent/src/http_entrypoints.rs | 30 ++- sled-agent/src/rack_setup/service.rs | 9 +- sled-agent/src/sim/http_entrypoints.rs | 31 ++- .../tests/integration_tests/early_network.rs | 10 +- .../src/early_networking/serialization.rs | 20 +- .../types/versions/src/bgp_v6/rack_init.rs | 2 +- .../versions/src/impls/early_networking.rs | 81 ++++--- sled-agent/types/versions/src/latest.rs | 10 +- .../early_networking.rs | 136 +++++++++++- .../src/stronger_bgp_unnumbered_types/mod.rs | 1 + .../rack_init.rs | 123 +++++++++++ .../stronger_bgp_unnumbered_types/uplink.rs | 24 ++ wicket-common/src/example.rs | 9 +- wicket-common/src/rack_setup.rs | 3 +- wicket/src/cli/rack_setup/config_toml.rs | 13 +- wicket/src/ui/panes/rack_setup.rs | 20 +- wicketd/src/preflight_check/uplink.rs | 11 +- wicketd/src/rss_config.rs | 117 +++------- 34 files changed, 1015 insertions(+), 337 deletions(-) create mode 100644 openapi/sled-agent/sled-agent-28.0.0-415efe.json.gitstub rename openapi/sled-agent/{sled-agent-28.0.0-415efe.json => sled-agent-29.0.0-7b533d.json} (99%) create mode 100644 sled-agent/types/versions/src/stronger_bgp_unnumbered_types/rack_init.rs diff --git a/Cargo.lock b/Cargo.lock index 60d0c7e05fd..f1a42411715 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -869,6 +869,7 @@ dependencies = [ "schemars 0.8.22", "serde", "serde_json", + "sled-agent-types", "sled-hardware-types", "slog", "uuid", diff --git a/clients/bootstrap-agent-client/Cargo.toml b/clients/bootstrap-agent-client/Cargo.toml index 3762c6747f3..b042bd1488f 100644 --- a/clients/bootstrap-agent-client/Cargo.toml +++ b/clients/bootstrap-agent-client/Cargo.toml @@ -15,6 +15,7 @@ reqwest = { workspace = true, features = [ "json", "rustls", "stream" ] } schemars.workspace = true serde.workspace = true serde_json.workspace = true +sled-agent-types.workspace = true sled-hardware-types.workspace = true slog.workspace = true uuid.workspace = true diff --git a/clients/bootstrap-agent-lockstep-client/src/lib.rs b/clients/bootstrap-agent-lockstep-client/src/lib.rs index 5363afcf598..ea733cfaf81 100644 --- a/clients/bootstrap-agent-lockstep-client/src/lib.rs +++ b/clients/bootstrap-agent-lockstep-client/src/lib.rs @@ -26,8 +26,18 @@ progenitor::generate_api!( replace = { AllowedSourceIps = omicron_common::api::external::AllowedSourceIps, Baseboard = sled_hardware_types::Baseboard, + BgpPeerConfig = sled_agent_types::early_networking::BgpPeerConfig, ImportExportPolicy = sled_agent_types::early_networking::ImportExportPolicy, + LldpAdminStatus = sled_agent_types::early_networking::LldpAdminStatus, + LldpPortConfig = sled_agent_types::early_networking::LldpPortConfig, + PortConfig = sled_agent_types::early_networking::PortConfig, + PortFec = sled_agent_types::early_networking::PortFec, + PortSpeed = sled_agent_types::early_networking::PortSpeed, + RouteConfig = sled_agent_types::early_networking::RouteConfig, + RouterLifetimeConfig = sled_agent_types::early_networking::RouterLifetimeConfig, SwitchSlot = sled_agent_types::early_networking::SwitchSlot, + TxEqConfig = sled_agent_types::early_networking::TxEqConfig, + UplinkAddressConfig = sled_agent_types::early_networking::UplinkAddressConfig, }, ); diff --git a/clients/nexus-lockstep-client/src/lib.rs b/clients/nexus-lockstep-client/src/lib.rs index 61e88d38355..913819d6cd4 100644 --- a/clients/nexus-lockstep-client/src/lib.rs +++ b/clients/nexus-lockstep-client/src/lib.rs @@ -69,10 +69,12 @@ progenitor::generate_api!( ReconfiguratorConfigParam = nexus_types::deployment::ReconfiguratorConfigParam, ReconfiguratorConfigView = nexus_types::deployment::ReconfiguratorConfigView, RecoverySiloConfig = sled_agent_types_versions::latest::rack_init::RecoverySiloConfig, + RouterPeerAddress = sled_agent_types::early_networking::RouterPeerAddress, SledAgentUpdateStatus = nexus_types::internal_api::views::SledAgentUpdateStatus, SwitchSlot = sled_agent_types::early_networking::SwitchSlot, TrustQuorumConfig = nexus_types::trust_quorum::TrustQuorumConfig, UpdateStatus = nexus_types::internal_api::views::UpdateStatus, + UplinkAddressConfig = sled_agent_types::early_networking::UplinkAddressConfig, ZoneStatus = nexus_types::internal_api::views::ZoneStatus, ZpoolName = omicron_common::zpool_name::ZpoolName, }, diff --git a/nexus/db-model/src/bgp.rs b/nexus/db-model/src/bgp.rs index bb74a13eb75..5b8f4dbf9c4 100644 --- a/nexus/db-model/src/bgp.rs +++ b/nexus/db-model/src/bgp.rs @@ -19,9 +19,10 @@ use sled_agent_types::early_networking::ImportExportPolicy; use sled_agent_types::early_networking::MaxPathConfig; use sled_agent_types::early_networking::RouterLifetimeConfig; use sled_agent_types::early_networking::RouterLifetimeConfigError; +use sled_agent_types::early_networking::RouterPeerAddress; +use sled_agent_types::early_networking::SpecifiedIpAddr; +use sled_agent_types::early_networking::UnspecifiedIpError; use slog_error_chain::InlineErrorChain; -use std::net::IpAddr; -use std::net::Ipv6Addr; use uuid::Uuid; #[derive( @@ -179,10 +180,15 @@ impl TryFrom for BgpPeerConfig { fn try_from(value: BgpPeerView) -> Result { // For unnumbered peers (addr is None), use UNSPECIFIED - let addr = match value.addr { - None => IpAddr::V6(Ipv6Addr::UNSPECIFIED), - Some(addr) => addr.ip(), - }; + // + // TODO-john stronger type? NULL? + let addr = + match value.addr.map(|addr| SpecifiedIpAddr::try_from(addr.ip())) { + Some(Ok(ip)) => RouterPeerAddress::Numbered { ip }, + None | Some(Err(UnspecifiedIpError)) => { + RouterPeerAddress::Unnumbered + } + }; // TODO-correctness We should have db constraints to ensure these can't // fail. diff --git a/nexus/mgs-updates/src/test_util/host_phase_2_test_state.rs b/nexus/mgs-updates/src/test_util/host_phase_2_test_state.rs index 8f448eb690a..7a1b98c35de 100644 --- a/nexus/mgs-updates/src/test_util/host_phase_2_test_state.rs +++ b/nexus/mgs-updates/src/test_util/host_phase_2_test_state.rs @@ -271,6 +271,7 @@ mod api_impl { use sled_agent_types_versions::v20; use sled_agent_types_versions::v25; use sled_agent_types_versions::v26; + use sled_agent_types_versions::v28; use sled_diagnostics::SledDiagnosticsQueryOutput; use std::collections::BTreeMap; use std::collections::BTreeSet; @@ -771,6 +772,13 @@ mod api_impl { unimplemented!() } + async fn write_network_bootstore_config_v28( + _rqctx: RequestContext, + _body: TypedBody, + ) -> Result { + unimplemented!() + } + async fn write_network_bootstore_config_v26( _rqctx: RequestContext, _body: TypedBody, diff --git a/nexus/src/app/background/tasks/sync_switch_configuration.rs b/nexus/src/app/background/tasks/sync_switch_configuration.rs index 4ee08c1caa1..fdeba230534 100644 --- a/nexus/src/app/background/tasks/sync_switch_configuration.rs +++ b/nexus/src/app/background/tasks/sync_switch_configuration.rs @@ -50,9 +50,6 @@ use omicron_common::{ use rdb_types::{Prefix, Prefix4, Prefix6}; use serde_json::json; use sled_agent_client::types::HostPortConfig; -use sled_agent_types::early_networking::BfdPeerConfig; -use sled_agent_types::early_networking::BgpConfig as SledBgpConfig; -use sled_agent_types::early_networking::BgpPeerConfig as SledBgpPeerConfig; use sled_agent_types::early_networking::EarlyNetworkConfigBody; use sled_agent_types::early_networking::EarlyNetworkConfigEnvelope; use sled_agent_types::early_networking::ImportExportPolicy; @@ -66,6 +63,13 @@ use sled_agent_types::early_networking::SwitchSlot; use sled_agent_types::early_networking::TxEqConfig; use sled_agent_types::early_networking::UplinkAddressConfig; use sled_agent_types::early_networking::WriteNetworkConfigRequest; +use sled_agent_types::early_networking::{BfdPeerConfig, SpecifiedIpNet}; +use sled_agent_types::early_networking::{ + BgpConfig as SledBgpConfig, UplinkAddress, +}; +use sled_agent_types::early_networking::{ + BgpPeerConfig as SledBgpPeerConfig, RouterPeerAddress, UnspecifiedIpError, +}; use slog_error_chain::InlineErrorChain; use std::{ collections::{HashMap, HashSet, hash_map::Entry}, @@ -1107,7 +1111,15 @@ impl BackgroundTask for SwitchPortSettingsManager { .iter() .map(|a| UplinkAddressConfig { - address: if a.address.addr().is_unspecified() {None} else {Some(a.address)}, + // TODO-john stronger type on a.address? + address: match SpecifiedIpNet::try_from(a.address) { + Ok(ip_net) => UplinkAddress::Address { + ip_net, + }, + Err(UnspecifiedIpError) => { + UplinkAddress::LinkLocal + } + }, vlan_id: a.vlan_id } ).collect(), @@ -1160,11 +1172,14 @@ impl BackgroundTask for SwitchPortSettingsManager { ; for peer in port_config.bgp_peers.iter_mut() { - // For unnumbered peers (addr is UNSPECIFIED), pass None - let peer_addr_for_lookup = if peer.addr.is_unspecified() { - None - } else { - Some(IpNetwork::from(peer.addr)) + // For unnumbered peers, pass None + // + // TODO-john stronger type? + let peer_addr_for_lookup = match peer.addr { + RouterPeerAddress::Unnumbered => None, + RouterPeerAddress::Numbered { ip } => { + Some(IpNetwork::from(IpAddr::from(ip))) + } }; peer.communities = match self @@ -1724,10 +1739,10 @@ fn uplinks( .addresses .iter() .map(|a| UplinkAddressConfig { - address: if a.address.addr().is_unspecified() { - None - } else { - Some(a.address) + // TODO-john stronger type on a.address? + address: match SpecifiedIpNet::try_from(a.address) { + Ok(ip_net) => UplinkAddress::Address { ip_net }, + Err(UnspecifiedIpError) => UplinkAddress::LinkLocal, }, vlan_id: a.vlan_id, }) diff --git a/nexus/src/app/rack.rs b/nexus/src/app/rack.rs index e7b6be32dd3..a802fdfe21e 100644 --- a/nexus/src/app/rack.rs +++ b/nexus/src/app/rack.rs @@ -42,18 +42,17 @@ use omicron_common::api::external::NameOrId; use omicron_common::api::external::ResourceType; use omicron_uuid_kinds::SledUuid; use oxnet::IpNet; -use oxnet::Ipv6Net; use sled_agent_client::types::AddSledRequest; use sled_agent_client::types::StartSledAgentRequest; use sled_agent_client::types::StartSledAgentRequestBody; use sled_agent_types::early_networking::LldpAdminStatus; +use sled_agent_types::early_networking::RouterPeerAddress; use sled_hardware_types::BaseboardId; use slog_error_chain::InlineErrorChain; use std::collections::BTreeMap; use std::collections::BTreeSet; use std::collections::HashMap; -use std::net::Ipv6Addr; use std::num::NonZeroU32; use std::str::FromStr; use uuid::Uuid; @@ -552,9 +551,10 @@ impl super::Nexus { .iter() .map(|a| networking::Address { address_lot: NameOrId::Name(address_lot_name.clone()), - address: a.address.unwrap_or_else(|| { - IpNet::V6(Ipv6Net::host_net(Ipv6Addr::UNSPECIFIED)) - }), + // TODO-john do we want to update the external API? + address: a + .address + .ip_net_squashing_link_local_to_unspecified(), vlan_id: a.vlan_id, }) .collect(); @@ -591,10 +591,10 @@ impl super::Nexus { format!("as{}", r.asn).parse().unwrap(), ), interface_name: link_name.clone(), - addr: if r.addr.is_unspecified() { - None - } else { - Some(r.addr) + // TODO-john do we want to update the external API? + addr: match r.addr { + RouterPeerAddress::Unnumbered => None, + RouterPeerAddress::Numbered { ip } => Some(ip.into()), }, hold_time: r.hold_time() as u32, idle_hold_time: r.idle_hold_time() as u32, diff --git a/openapi/bootstrap-agent-lockstep.json b/openapi/bootstrap-agent-lockstep.json index aef19840c28..c1e37dc0d5e 100644 --- a/openapi/bootstrap-agent-lockstep.json +++ b/openapi/bootstrap-agent-lockstep.json @@ -294,9 +294,12 @@ "type": "object", "properties": { "addr": { - "description": "Address of the peer. Use `UNSPECIFIED` to indicate an unnumbered BGP session established over the interface specified by `port`.", - "type": "string", - "format": "ip" + "description": "Address of the peer.", + "allOf": [ + { + "$ref": "#/components/schemas/RouterPeerAddress" + } + ] }, "allowed_export": { "description": "Define export policy for a peer.", @@ -1258,6 +1261,42 @@ "minimum": 0, "maximum": 9000 }, + "RouterPeerAddress": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "unnumbered" + ] + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "ip": { + "$ref": "#/components/schemas/SpecifiedIpAddr" + }, + "type": { + "type": "string", + "enum": [ + "numbered" + ] + } + }, + "required": [ + "ip", + "type" + ] + } + ] + }, "RssStep": { "description": "Steps we go through during initial rack setup. Keep this list in order that they happen.", "oneOf": [ @@ -1473,6 +1512,13 @@ } ] }, + "SpecifiedIpAddr": { + "type": "string", + "format": "ip" + }, + "SpecifiedIpNet": { + "$ref": "#/components/schemas/IpNet" + }, "SwitchSlot": { "description": "Identifies switch physical location", "oneOf": [ @@ -1528,15 +1574,50 @@ } } }, + "UplinkAddress": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "link_local" + ] + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "ip_net": { + "$ref": "#/components/schemas/SpecifiedIpNet" + }, + "type": { + "type": "string", + "enum": [ + "address" + ] + } + }, + "required": [ + "ip_net", + "type" + ] + } + ] + }, "UplinkAddressConfig": { "type": "object", "properties": { "address": { - "nullable": true, - "description": "The address to be used on the uplink. Set to `None` for an Ipv6 Link Local address.", + "description": "The address to be used on the uplink.", "allOf": [ { - "$ref": "#/components/schemas/IpNet" + "$ref": "#/components/schemas/UplinkAddress" } ] }, @@ -1548,7 +1629,10 @@ "format": "uint16", "minimum": 0 } - } + }, + "required": [ + "address" + ] }, "UserId": { "title": "A username for a local-only user", diff --git a/openapi/nexus-lockstep.json b/openapi/nexus-lockstep.json index da2fd317fb3..a61f053fb11 100644 --- a/openapi/nexus-lockstep.json +++ b/openapi/nexus-lockstep.json @@ -1745,9 +1745,12 @@ "type": "object", "properties": { "addr": { - "description": "Address of the peer. Use `UNSPECIFIED` to indicate an unnumbered BGP session established over the interface specified by `port`.", - "type": "string", - "format": "ip" + "description": "Address of the peer.", + "allOf": [ + { + "$ref": "#/components/schemas/RouterPeerAddress" + } + ] }, "allowed_export": { "description": "Define export policy for a peer.", @@ -8356,6 +8359,42 @@ "minimum": 0, "maximum": 9000 }, + "RouterPeerAddress": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "unnumbered" + ] + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "ip": { + "$ref": "#/components/schemas/SpecifiedIpAddr" + }, + "type": { + "type": "string", + "enum": [ + "numbered" + ] + } + }, + "required": [ + "ip", + "type" + ] + } + ] + }, "Saga": { "description": "Sagas\n\nThese are currently only intended for observability by developers. We will eventually want to flesh this out into something more observable for end users.", "type": "object", @@ -8889,6 +8928,13 @@ "switch" ] }, + "SpecifiedIpAddr": { + "type": "string", + "format": "ip" + }, + "SpecifiedIpNet": { + "$ref": "#/components/schemas/IpNet" + }, "Srv": { "type": "object", "properties": { @@ -9387,15 +9433,50 @@ "sleds" ] }, + "UplinkAddress": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "link_local" + ] + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "ip_net": { + "$ref": "#/components/schemas/SpecifiedIpNet" + }, + "type": { + "type": "string", + "enum": [ + "address" + ] + } + }, + "required": [ + "ip_net", + "type" + ] + } + ] + }, "UplinkAddressConfig": { "type": "object", "properties": { "address": { - "nullable": true, - "description": "The address to be used on the uplink. Set to `None` for an Ipv6 Link Local address.", + "description": "The address to be used on the uplink.", "allOf": [ { - "$ref": "#/components/schemas/IpNet" + "$ref": "#/components/schemas/UplinkAddress" } ] }, @@ -9407,7 +9488,10 @@ "format": "uint16", "minimum": 0 } - } + }, + "required": [ + "address" + ] }, "UserId": { "title": "A username for a local-only user", diff --git a/openapi/sled-agent/sled-agent-28.0.0-415efe.json.gitstub b/openapi/sled-agent/sled-agent-28.0.0-415efe.json.gitstub new file mode 100644 index 00000000000..a8377752515 --- /dev/null +++ b/openapi/sled-agent/sled-agent-28.0.0-415efe.json.gitstub @@ -0,0 +1 @@ +789f68549117d5f7cf59b3679969301cfcb72443:openapi/sled-agent/sled-agent-28.0.0-415efe.json diff --git a/openapi/sled-agent/sled-agent-28.0.0-415efe.json b/openapi/sled-agent/sled-agent-29.0.0-7b533d.json similarity index 99% rename from openapi/sled-agent/sled-agent-28.0.0-415efe.json rename to openapi/sled-agent/sled-agent-29.0.0-7b533d.json index d85b782acbd..a2f7279b6fd 100644 --- a/openapi/sled-agent/sled-agent-28.0.0-415efe.json +++ b/openapi/sled-agent/sled-agent-29.0.0-7b533d.json @@ -7,7 +7,7 @@ "url": "https://oxide.computer", "email": "api@oxide.computer" }, - "version": "28.0.0" + "version": "29.0.0" }, "paths": { "/artifacts": { @@ -3335,9 +3335,12 @@ "type": "object", "properties": { "addr": { - "description": "Address of the peer. Use `UNSPECIFIED` to indicate an unnumbered BGP session established over the interface specified by `port`.", - "type": "string", - "format": "ip" + "description": "Address of the peer.", + "allOf": [ + { + "$ref": "#/components/schemas/RouterPeerAddress" + } + ] }, "allowed_export": { "description": "Define export policy for a peer.", @@ -8853,6 +8856,42 @@ "minimum": 0, "maximum": 9000 }, + "RouterPeerAddress": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "unnumbered" + ] + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "ip": { + "$ref": "#/components/schemas/SpecifiedIpAddr" + }, + "type": { + "type": "string", + "enum": [ + "numbered" + ] + } + }, + "required": [ + "ip", + "type" + ] + } + ] + }, "RouterTarget": { "description": "The target for a given router entry.", "oneOf": [ @@ -9351,6 +9390,13 @@ } ] }, + "SpecifiedIpAddr": { + "type": "string", + "format": "ip" + }, + "SpecifiedIpNet": { + "$ref": "#/components/schemas/IpNet" + }, "StartSledAgentRequest": { "description": "Configuration information for launching a Sled Agent.", "type": "object", @@ -9823,15 +9869,50 @@ } } }, + "UplinkAddress": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "link_local" + ] + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "ip_net": { + "$ref": "#/components/schemas/SpecifiedIpNet" + }, + "type": { + "type": "string", + "enum": [ + "address" + ] + } + }, + "required": [ + "ip_net", + "type" + ] + } + ] + }, "UplinkAddressConfig": { "type": "object", "properties": { "address": { - "nullable": true, - "description": "The address to be used on the uplink. Set to `None` for an Ipv6 Link Local address.", + "description": "The address to be used on the uplink.", "allOf": [ { - "$ref": "#/components/schemas/IpNet" + "$ref": "#/components/schemas/UplinkAddress" } ] }, @@ -9843,7 +9924,10 @@ "format": "uint16", "minimum": 0 } - } + }, + "required": [ + "address" + ] }, "VirtioDisk": { "description": "A disk that presents a virtio-block interface to the guest.", diff --git a/openapi/sled-agent/sled-agent-latest.json b/openapi/sled-agent/sled-agent-latest.json index 7f2b470816a..1fdf276b0e6 120000 --- a/openapi/sled-agent/sled-agent-latest.json +++ b/openapi/sled-agent/sled-agent-latest.json @@ -1 +1 @@ -sled-agent-28.0.0-415efe.json \ No newline at end of file +sled-agent-29.0.0-7b533d.json \ No newline at end of file diff --git a/openapi/wicketd.json b/openapi/wicketd.json index e2c196b18ae..3181b93d31d 100644 --- a/openapi/wicketd.json +++ b/openapi/wicketd.json @@ -3893,6 +3893,13 @@ "nexthop" ] }, + "RouterLifetimeConfig": { + "description": "Router lifetime in seconds for unnumbered BGP peers", + "type": "integer", + "format": "uint16", + "minimum": 0, + "maximum": 9000 + }, "RssStep": { "description": "Steps we go through during initial rack setup. Keep this list in order that they happen.\n\n
JSON schema\n\n```json { \"description\": \"Steps we go through during initial rack setup. Keep this list in order that they happen.\", \"oneOf\": [ { \"type\": \"object\", \"required\": [ \"status\" ], \"properties\": { \"status\": { \"type\": \"string\", \"enum\": [ \"requested\" ] } } }, { \"type\": \"object\", \"required\": [ \"status\" ], \"properties\": { \"status\": { \"type\": \"string\", \"enum\": [ \"starting\" ] } } }, { \"type\": \"object\", \"required\": [ \"status\" ], \"properties\": { \"status\": { \"type\": \"string\", \"enum\": [ \"load_existing_plan\" ] } } }, { \"type\": \"object\", \"required\": [ \"status\" ], \"properties\": { \"status\": { \"type\": \"string\", \"enum\": [ \"create_sled_plan\" ] } } }, { \"type\": \"object\", \"required\": [ \"status\" ], \"properties\": { \"status\": { \"type\": \"string\", \"enum\": [ \"init_trust_quorum\" ] } } }, { \"type\": \"object\", \"required\": [ \"status\" ], \"properties\": { \"status\": { \"type\": \"string\", \"enum\": [ \"network_config_update\" ] } } }, { \"type\": \"object\", \"required\": [ \"status\" ], \"properties\": { \"status\": { \"type\": \"string\", \"enum\": [ \"sled_init\" ] } } }, { \"type\": \"object\", \"required\": [ \"status\" ], \"properties\": { \"status\": { \"type\": \"string\", \"enum\": [ \"init_dns\" ] } } }, { \"type\": \"object\", \"required\": [ \"status\" ], \"properties\": { \"status\": { \"type\": \"string\", \"enum\": [ \"configure_dns\" ] } } }, { \"type\": \"object\", \"required\": [ \"status\" ], \"properties\": { \"status\": { \"type\": \"string\", \"enum\": [ \"init_ntp\" ] } } }, { \"type\": \"object\", \"required\": [ \"status\" ], \"properties\": { \"status\": { \"type\": \"string\", \"enum\": [ \"wait_for_time_sync\" ] } } }, { \"type\": \"object\", \"required\": [ \"status\" ], \"properties\": { \"status\": { \"type\": \"string\", \"enum\": [ \"wait_for_database\" ] } } }, { \"type\": \"object\", \"required\": [ \"status\" ], \"properties\": { \"status\": { \"type\": \"string\", \"enum\": [ \"cluster_init\" ] } } }, { \"type\": \"object\", \"required\": [ \"status\" ], \"properties\": { \"status\": { \"type\": \"string\", \"enum\": [ \"zones_init\" ] } } }, { \"type\": \"object\", \"required\": [ \"status\" ], \"properties\": { \"status\": { \"type\": \"string\", \"enum\": [ \"nexus_handoff\" ] } } } ] } ```
", "oneOf": [ @@ -4632,6 +4639,9 @@ "switch" ] }, + "SpecifiedIpNet": { + "$ref": "#/components/schemas/IpNet" + }, "StartUpdateOptions": { "type": "object", "properties": { @@ -7416,15 +7426,50 @@ } ] }, + "UplinkAddress": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "link_local" + ] + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "ip_net": { + "$ref": "#/components/schemas/SpecifiedIpNet" + }, + "type": { + "type": "string", + "enum": [ + "address" + ] + } + }, + "required": [ + "ip_net", + "type" + ] + } + ] + }, "UplinkAddressConfig": { "type": "object", "properties": { "address": { - "nullable": true, - "description": "The address to be used on the uplink. Set to `None` for an Ipv6 Link Local address.", + "description": "The address to be used on the uplink.", "allOf": [ { - "$ref": "#/components/schemas/IpNet" + "$ref": "#/components/schemas/UplinkAddress" } ] }, @@ -7436,7 +7481,10 @@ "format": "uint16", "minimum": 0 } - } + }, + "required": [ + "address" + ] }, "UplinkPreflightStepId": { "oneOf": [ @@ -7701,9 +7749,11 @@ "router_lifetime": { "description": "Router lifetime in seconds for unnumbered BGP peers.", "default": 0, - "type": "integer", - "format": "uint16", - "minimum": 0 + "allOf": [ + { + "$ref": "#/components/schemas/RouterLifetimeConfig" + } + ] }, "vlan_id": { "nullable": true, diff --git a/sled-agent/api/src/lib.rs b/sled-agent/api/src/lib.rs index 23ad9b5780b..214f15bdf1d 100644 --- a/sled-agent/api/src/lib.rs +++ b/sled-agent/api/src/lib.rs @@ -21,7 +21,7 @@ use omicron_common::api::internal::{ }; use sled_agent_types_versions::{ latest, v1, v4, v6, v7, v9, v10, v11, v12, v14, v16, v17, v20, v22, v24, - v25, v26, + v25, v26, v29, }; use sled_diagnostics::SledDiagnosticsQueryOutput; @@ -745,13 +745,25 @@ pub trait SledAgentApi { #[endpoint { method = POST, path = "/switch-ports", - versions = VERSION_BGP_V6.., + versions = VERSION_STRONGER_BGP_UNNUMBERED_TYPES.., }] async fn uplink_ensure( rqctx: RequestContext, body: TypedBody, ) -> Result; + #[endpoint { + method = POST, + path = "/switch-ports", + versions = VERSION_BGP_V6..VERSION_STRONGER_BGP_UNNUMBERED_TYPES, + }] + async fn uplink_ensure_v20( + rqctx: RequestContext, + body: TypedBody, + ) -> Result { + Self::uplink_ensure(rqctx, body.map(From::from)).await + } + #[endpoint { method = POST, path = "/switch-ports", @@ -761,7 +773,7 @@ pub trait SledAgentApi { rqctx: RequestContext, body: TypedBody, ) -> Result { - Self::uplink_ensure(rqctx, body.map(From::from)).await + Self::uplink_ensure_v20(rqctx, body.map(From::from)).await } /// This API endpoint is only reading the local sled agent's view of the @@ -847,7 +859,20 @@ pub trait SledAgentApi { #[endpoint { method = PUT, path = "/network-bootstore-config", - versions = VERSION_RACK_NETWORK_CONFIG_NOT_OPTIONAL.., + versions = VERSION_STRONGER_BGP_UNNUMBERED_TYPES.., + operation_id = "write_network_bootstore_config", + }] + async fn write_network_bootstore_config_v28( + rqctx: RequestContext, + body: TypedBody, + ) -> Result; + + // As described above, this must not forward to newer versions; sled-agent + // must implement this by faithfully serializing the requested version. + #[endpoint { + method = PUT, + path = "/network-bootstore-config", + versions = VERSION_RACK_NETWORK_CONFIG_NOT_OPTIONAL..VERSION_STRONGER_BGP_UNNUMBERED_TYPES, operation_id = "write_network_bootstore_config", }] async fn write_network_bootstore_config_v26( diff --git a/sled-agent/src/bootstrap/early_networking.rs b/sled-agent/src/bootstrap/early_networking.rs index 02809fb7475..8eecb58b526 100644 --- a/sled-agent/src/bootstrap/early_networking.rs +++ b/sled-agent/src/bootstrap/early_networking.rs @@ -40,7 +40,7 @@ use oxnet::IpNet; use rdb_types::{Prefix, Prefix4, Prefix6}; use sled_agent_types::early_networking::{ BfdMode, BgpConfig, BgpPeerConfig, ImportExportPolicy, PortConfig, PortFec, - PortSpeed, RackNetworkConfig, SwitchSlot, + PortSpeed, RackNetworkConfig, RouterPeerAddress, SwitchSlot, UplinkAddress, }; use slog::Logger; use slog_error_chain::InlineErrorChain; @@ -554,104 +554,107 @@ impl<'a> EarlyNetworkSetup<'a> { } } - // Determine if this is a numbered or unnumbered peer based on - // whether an address is specified (unspecified = unnumbered) - if !peer.addr.is_unspecified() { - let addr = peer.addr; + match peer.addr { // Numbered peer - identified by address - let bpc = MgBgpPeerConfig { - name: format!("{}", addr), - host: format!("{}:179", addr), - hold_time: peer - .hold_time - .unwrap_or(BgpPeerConfig::DEFAULT_HOLD_TIME), - idle_hold_time: peer - .idle_hold_time - .unwrap_or(BgpPeerConfig::DEFAULT_IDLE_HOLD_TIME), - delay_open: peer - .delay_open - .unwrap_or(BgpPeerConfig::DEFAULT_DELAY_OPEN), - connect_retry: peer - .connect_retry - .unwrap_or(BgpPeerConfig::DEFAULT_CONNECT_RETRY), - keepalive: peer - .keepalive - .unwrap_or(BgpPeerConfig::DEFAULT_KEEPALIVE), - resolution: BGP_SESSION_RESOLUTION, - passive: false, - remote_asn: peer.remote_asn, - min_ttl: peer.min_ttl, - md5_auth_key: peer.md5_auth_key.clone(), - multi_exit_discriminator: peer.multi_exit_discriminator, - communities: peer.communities.clone(), - local_pref: peer.local_pref, - enforce_first_as: peer.enforce_first_as, - ipv4_unicast: Some(build_ipv4_unicast(peer)), - ipv6_unicast: Some(build_ipv6_unicast(peer)), - vlan_id: peer.vlan_id, - connect_retry_jitter: Some(JitterRange { - max: 1.0, - min: 0.75, - }), - deterministic_collision_resolution: false, - idle_hold_jitter: None, - }; - match bgp_peer_configs.get_mut(&port.port) { - Some(peers) => { - peers.push(bpc); - } - None => { - bgp_peer_configs - .insert(port.port.clone(), vec![bpc]); + RouterPeerAddress::Numbered { ip: addr } => { + let bpc = MgBgpPeerConfig { + name: format!("{}", addr), + host: format!("{}:179", addr), + hold_time: peer + .hold_time + .unwrap_or(BgpPeerConfig::DEFAULT_HOLD_TIME), + idle_hold_time: peer.idle_hold_time.unwrap_or( + BgpPeerConfig::DEFAULT_IDLE_HOLD_TIME, + ), + delay_open: peer + .delay_open + .unwrap_or(BgpPeerConfig::DEFAULT_DELAY_OPEN), + connect_retry: peer.connect_retry.unwrap_or( + BgpPeerConfig::DEFAULT_CONNECT_RETRY, + ), + keepalive: peer + .keepalive + .unwrap_or(BgpPeerConfig::DEFAULT_KEEPALIVE), + resolution: BGP_SESSION_RESOLUTION, + passive: false, + remote_asn: peer.remote_asn, + min_ttl: peer.min_ttl, + md5_auth_key: peer.md5_auth_key.clone(), + multi_exit_discriminator: peer + .multi_exit_discriminator, + communities: peer.communities.clone(), + local_pref: peer.local_pref, + enforce_first_as: peer.enforce_first_as, + ipv4_unicast: Some(build_ipv4_unicast(peer)), + ipv6_unicast: Some(build_ipv6_unicast(peer)), + vlan_id: peer.vlan_id, + connect_retry_jitter: Some(JitterRange { + max: 1.0, + min: 0.75, + }), + deterministic_collision_resolution: false, + idle_hold_jitter: None, + }; + match bgp_peer_configs.get_mut(&port.port) { + Some(peers) => { + peers.push(bpc); + } + None => { + bgp_peer_configs + .insert(port.port.clone(), vec![bpc]); + } } } - } else { + // Unnumbered peer - identified by interface - let bpc = MgUnnumberedBgpPeerConfig { - name: format!("unnumbered-{}", port.port), - interface: format!("tfport{}_0", port.port), - hold_time: peer - .hold_time - .unwrap_or(BgpPeerConfig::DEFAULT_HOLD_TIME), - idle_hold_time: peer - .idle_hold_time - .unwrap_or(BgpPeerConfig::DEFAULT_IDLE_HOLD_TIME), - delay_open: peer - .delay_open - .unwrap_or(BgpPeerConfig::DEFAULT_DELAY_OPEN), - connect_retry: peer - .connect_retry - .unwrap_or(BgpPeerConfig::DEFAULT_CONNECT_RETRY), - keepalive: peer - .keepalive - .unwrap_or(BgpPeerConfig::DEFAULT_KEEPALIVE), - resolution: BGP_SESSION_RESOLUTION, - passive: false, - remote_asn: peer.remote_asn, - min_ttl: peer.min_ttl, - md5_auth_key: peer.md5_auth_key.clone(), - multi_exit_discriminator: peer.multi_exit_discriminator, - communities: peer.communities.clone(), - local_pref: peer.local_pref, - enforce_first_as: peer.enforce_first_as, - ipv4_unicast: Some(build_ipv4_unicast(peer)), - ipv6_unicast: Some(build_ipv6_unicast(peer)), - vlan_id: peer.vlan_id, - connect_retry_jitter: Some(JitterRange { - max: 1.0, - min: 0.75, - }), - deterministic_collision_resolution: false, - idle_hold_jitter: None, - router_lifetime: peer.router_lifetime.as_u16(), - }; - match bgp_unnumbered_peer_configs.get_mut(&port.port) { - Some(peers) => { - peers.push(bpc); - } - None => { - bgp_unnumbered_peer_configs - .insert(port.port.clone(), vec![bpc]); + RouterPeerAddress::Unnumbered => { + let bpc = MgUnnumberedBgpPeerConfig { + name: format!("unnumbered-{}", port.port), + interface: format!("tfport{}_0", port.port), + hold_time: peer + .hold_time + .unwrap_or(BgpPeerConfig::DEFAULT_HOLD_TIME), + idle_hold_time: peer.idle_hold_time.unwrap_or( + BgpPeerConfig::DEFAULT_IDLE_HOLD_TIME, + ), + delay_open: peer + .delay_open + .unwrap_or(BgpPeerConfig::DEFAULT_DELAY_OPEN), + connect_retry: peer.connect_retry.unwrap_or( + BgpPeerConfig::DEFAULT_CONNECT_RETRY, + ), + keepalive: peer + .keepalive + .unwrap_or(BgpPeerConfig::DEFAULT_KEEPALIVE), + resolution: BGP_SESSION_RESOLUTION, + passive: false, + remote_asn: peer.remote_asn, + min_ttl: peer.min_ttl, + md5_auth_key: peer.md5_auth_key.clone(), + multi_exit_discriminator: peer + .multi_exit_discriminator, + communities: peer.communities.clone(), + local_pref: peer.local_pref, + enforce_first_as: peer.enforce_first_as, + ipv4_unicast: Some(build_ipv4_unicast(peer)), + ipv6_unicast: Some(build_ipv6_unicast(peer)), + vlan_id: peer.vlan_id, + connect_retry_jitter: Some(JitterRange { + max: 1.0, + min: 0.75, + }), + deterministic_collision_resolution: false, + idle_hold_jitter: None, + router_lifetime: peer.router_lifetime.as_u16(), + }; + match bgp_unnumbered_peer_configs.get_mut(&port.port) { + Some(peers) => { + peers.push(bpc); + } + None => { + bgp_unnumbered_peer_configs + .insert(port.port.clone(), vec![bpc]); + } } } } @@ -830,13 +833,15 @@ impl<'a> EarlyNetworkSetup<'a> { let mut addrs = Vec::new(); for a in &port_config.addresses { - if a.addr().is_unspecified() { - continue; + match a.address { + UplinkAddress::LinkLocal => continue, + UplinkAddress::Address { ip_net } => { + // TODO We're discarding the `uplink_cidr.prefix()` here and + // only using the IP address; at some point we probably need + // to give the full CIDR to dendrite? + addrs.push(ip_net.addr()); + } } - // TODO We're discarding the `uplink_cidr.prefix()` here and only using - // the IP address; at some point we probably need to give the full CIDR - // to dendrite? - addrs.push(a.addr()); } let link_settings = LinkSettings { diff --git a/sled-agent/src/http_entrypoints.rs b/sled-agent/src/http_entrypoints.rs index 203d476d533..17f163fea51 100644 --- a/sled-agent/src/http_entrypoints.rs +++ b/sled-agent/src/http_entrypoints.rs @@ -78,7 +78,7 @@ use trust_quorum_types::messages::{ use trust_quorum_types::status::{CommitStatus, CoordinatorStatus, NodeStatus}; // Fixed identifiers for prior versions only -use sled_agent_types_versions::{v1, v20, v25, v26}; +use sled_agent_types_versions::{v1, v20, v25, v26, v28}; use sled_diagnostics::{ SledDiagnosticsCommandHttpOutput, SledDiagnosticsQueryOutput, }; @@ -957,6 +957,7 @@ impl SledAgentApi for SledAgentImpl { // // Use shorter names so rustfmt doesn't give up on this function. use v20::early_networking::EarlyNetworkConfigBody as BodyV20; + use v26::early_networking::EarlyNetworkConfigBody as BodyV26; type LatestEnvelope = EarlyNetworkConfigEnvelope; let sa = rqctx.context(); @@ -972,7 +973,7 @@ impl SledAgentApi for SledAgentImpl { let config = match config { Some(config) => { - let body: BodyV20 = + let latest_version_body = LatestEnvelope::deserialize_from_bootstore(&config) .and_then(|envelope| { envelope.deserialize_body() @@ -983,8 +984,9 @@ impl SledAgentApi for SledAgentImpl { early network config: {}", InlineErrorChain::new(&err), )) - })? - .into(); + })?; + let body = + BodyV20::from(BodyV26::from(latest_version_body)); v20::early_networking::EarlyNetworkConfig { generation: config.generation, schema_version: BodyV20::SCHEMA_VERSION, @@ -1004,6 +1006,26 @@ impl SledAgentApi for SledAgentImpl { .await } + async fn write_network_bootstore_config_v28( + rqctx: RequestContext, + body: TypedBody, + ) -> Result { + let sa = rqctx.context(); + let bs = sa.bootstore(); + let body = body.into_inner(); + let config = EarlyNetworkConfigEnvelope::from(&body.body) + .serialize_to_bootstore_with_generation(body.generation); + + bs.update_network_config(config).await.map_err(|e| { + HttpError::for_internal_error(format!( + "failed to write updated config to boot store: {}", + InlineErrorChain::new(&e), + )) + })?; + + Ok(HttpResponseUpdatedNoContent()) + } + async fn write_network_bootstore_config_v26( rqctx: RequestContext, body: TypedBody, diff --git a/sled-agent/src/rack_setup/service.rs b/sled-agent/src/rack_setup/service.rs index 56355429627..b39b233c94f 100644 --- a/sled-agent/src/rack_setup/service.rs +++ b/sled-agent/src/rack_setup/service.rs @@ -888,14 +888,7 @@ impl ServiceInner { rib_priority: r.rib_priority, }) .collect(), - addresses: config - .addresses - .iter() - .map(|a| NexusTypes::UplinkAddressConfig { - address: a.address, - vlan_id: a.vlan_id, - }) - .collect(), + addresses: config.addresses.clone(), switch: config.switch, uplink_port_speed: config.uplink_port_speed, uplink_port_fec: config.uplink_port_fec, diff --git a/sled-agent/src/sim/http_entrypoints.rs b/sled-agent/src/sim/http_entrypoints.rs index 2866f23d58b..e0b07d65364 100644 --- a/sled-agent/src/sim/http_entrypoints.rs +++ b/sled-agent/src/sim/http_entrypoints.rs @@ -87,6 +87,7 @@ use sled_agent_types_versions::v1; use sled_agent_types_versions::v20; use sled_agent_types_versions::v25; use sled_agent_types_versions::v26; +use sled_agent_types_versions::v28; use sled_diagnostics::SledDiagnosticsQueryOutput; use slog_error_chain::InlineErrorChain; use std::collections::BTreeMap; @@ -403,7 +404,8 @@ impl SledAgentApi for SledAgentSimImpl { > { // Read the current envelope, then convert it back down to the version // we have to report for this (now-removed!) API endpoint. - use v20::early_networking::EarlyNetworkConfigBody; + use v20::early_networking::EarlyNetworkConfigBody as BodyV20; + use v26::early_networking::EarlyNetworkConfigBody as BodyV26; let config = rqctx.context().bootstore_network_config.lock().unwrap().clone(); @@ -416,23 +418,38 @@ impl SledAgentApi for SledAgentSimImpl { InlineErrorChain::new(&err) )) })?; - let body: EarlyNetworkConfigBody = envelope - .deserialize_body() - .map_err(|err| { + let latest_version_body = + envelope.deserialize_body().map_err(|err| { HttpError::for_internal_error(format!( "could not deserialize early network config body: {}", InlineErrorChain::new(&err) )) - })? - .into(); + })?; + + // Downconvert from the current version to the v20 version we have to + // return from this endpoint. + let body = BodyV20::from(BodyV26::from(latest_version_body)); Ok(HttpResponseOk(v20::early_networking::EarlyNetworkConfig { generation: config.generation, - schema_version: EarlyNetworkConfigBody::SCHEMA_VERSION, + schema_version: BodyV20::SCHEMA_VERSION, body, })) } + async fn write_network_bootstore_config_v28( + rqctx: RequestContext, + body: TypedBody, + ) -> Result { + let mut config = + rqctx.context().bootstore_network_config.lock().unwrap(); + let body = body.into_inner(); + + *config = EarlyNetworkConfigEnvelope::from(&body.body) + .serialize_to_bootstore_with_generation(body.generation); + Ok(HttpResponseUpdatedNoContent()) + } + async fn write_network_bootstore_config_v26( rqctx: RequestContext, body: TypedBody, diff --git a/sled-agent/tests/integration_tests/early_network.rs b/sled-agent/tests/integration_tests/early_network.rs index af0942874e5..74ad99d8fe9 100644 --- a/sled-agent/tests/integration_tests/early_network.rs +++ b/sled-agent/tests/integration_tests/early_network.rs @@ -10,7 +10,7 @@ use sled_agent_types::early_networking::{ BgpConfig, BgpPeerConfig, EarlyNetworkConfigBody, EarlyNetworkConfigEnvelope, ImportExportPolicy, LldpAdminStatus, LldpPortConfig, MaxPathConfig, PortConfig, PortFec, PortSpeed, - RackNetworkConfig, SwitchSlot, UplinkAddressConfig, + RackNetworkConfig, RouterPeerAddress, SwitchSlot, UplinkAddressConfig, }; const BLOB_PATH: &str = "tests/data/early_network_blobs.txt"; @@ -174,7 +174,9 @@ fn current_config_example() -> (&'static str, EarlyNetworkConfigEnvelope) { bgp_peers: vec![BgpPeerConfig { asn: 65002, port: "qsfp18".to_owned(), - addr: "172.20.15.51".parse().unwrap(), + addr: RouterPeerAddress::Numbered { + ip: "172.20.15.51".parse().unwrap(), + }, hold_time: Some(6), idle_hold_time: Some(3), delay_open: Some(3), @@ -219,7 +221,9 @@ fn current_config_example() -> (&'static str, EarlyNetworkConfigEnvelope) { bgp_peers: vec![BgpPeerConfig { asn: 65002, port: "qsfp18".to_owned(), - addr: "172.20.15.43".parse().unwrap(), + addr: RouterPeerAddress::Numbered { + ip: "172.20.15.43".parse().unwrap(), + }, hold_time: Some(6), idle_hold_time: Some(0), delay_open: Some(3), diff --git a/sled-agent/types/src/early_networking/serialization.rs b/sled-agent/types/src/early_networking/serialization.rs index 3519957490b..a1aeb27301a 100644 --- a/sled-agent/types/src/early_networking/serialization.rs +++ b/sled-agent/types/src/early_networking/serialization.rs @@ -50,7 +50,7 @@ use bootstore::schemes::v0 as bootstore; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; -use sled_agent_types_versions::{latest, v20, v26, v27}; +use sled_agent_types_versions::{latest, v20, v26, v29}; use slog_error_chain::SlogInlineError; #[derive(Debug, thiserror::Error, SlogInlineError)] @@ -303,7 +303,7 @@ impl EarlyNetworkConfigEnvelope { let f = versioned_decode!( v20::early_networking::EarlyNetworkConfigBody, v26::early_networking::EarlyNetworkConfigBody, - v27::early_networking::EarlyNetworkConfigBody, + v29::early_networking::EarlyNetworkConfigBody, ); f(self.schema_version, self.body.clone()) } @@ -357,3 +357,19 @@ impl From<&'_ v26::early_networking::EarlyNetworkConfigBody> } } } +impl From<&'_ v29::early_networking::EarlyNetworkConfigBody> + for EarlyNetworkConfigEnvelope +{ + fn from(value: &'_ v29::early_networking::EarlyNetworkConfigBody) -> Self { + Self { + schema_version: + v29::early_networking::EarlyNetworkConfigBody::SCHEMA_VERSION, + // We're serializing in-memory; this can only fail if + // `EarlyNetworkConfigBody` contains types that can't be represented + // as JSON, which (a) should never happened and (b) we should catch + // immediately in tests. + body: serde_json::to_value(value) + .expect("EarlyNetworkConfigBody can be serialized as JSON"), + } + } +} diff --git a/sled-agent/types/versions/src/bgp_v6/rack_init.rs b/sled-agent/types/versions/src/bgp_v6/rack_init.rs index 2cf94d0fa90..d35b01888d0 100644 --- a/sled-agent/types/versions/src/bgp_v6/rack_init.rs +++ b/sled-agent/types/versions/src/bgp_v6/rack_init.rs @@ -113,7 +113,7 @@ struct UnvalidatedRackInitializeRequest { allowed_source_ips: AllowedSourceIps, } -#[derive(Debug, Clone)] +#[derive(Clone)] pub struct RackInitializeRequestParams { pub rack_initialize_request: RackInitializeRequest, pub skip_timesync: bool, diff --git a/sled-agent/types/versions/src/impls/early_networking.rs b/sled-agent/types/versions/src/impls/early_networking.rs index dd85a77dc70..82d44b7dd44 100644 --- a/sled-agent/types/versions/src/impls/early_networking.rs +++ b/sled-agent/types/versions/src/impls/early_networking.rs @@ -12,15 +12,20 @@ use crate::latest::early_networking::PortFec; use crate::latest::early_networking::PortSpeed; use crate::latest::early_networking::RouterLifetimeConfig; use crate::latest::early_networking::RouterLifetimeConfigError; +use crate::latest::early_networking::SpecifiedIpAddr; +use crate::latest::early_networking::SpecifiedIpNet; use crate::latest::early_networking::SwitchSlot; +use crate::latest::early_networking::UnspecifiedIpError; +use crate::latest::early_networking::UplinkAddress; use crate::latest::early_networking::UplinkAddressConfig; use omicron_common::api::external; use oxnet::IpNet; use oxnet::IpNetParseError; +use oxnet::Ipv6Net; use std::fmt; +use std::net::AddrParseError; use std::net::IpAddr; use std::net::Ipv6Addr; -use std::net::AddrParseError; use std::str::FromStr; impl BgpPeerConfig { @@ -128,6 +133,12 @@ impl std::fmt::Display for SpecifiedIpAddr { } } +impl SpecifiedIpNet { + pub const fn addr(&self) -> IpAddr { + self.0.addr() + } +} + #[derive(Debug, thiserror::Error)] pub enum SpecifiedIpNetParseError { #[error("invalid IP net")] @@ -164,42 +175,52 @@ impl FromStr for SpecifiedIpAddr { } } -impl UplinkAddressConfig { - /// Construct an `UplinkAddressConfig` with no VLAN ID. - pub fn without_vlan(address: IpNet) -> Self { - // TODO-cleanup Squash unspecified addresses down to `None`. We want - // better types here: - // . - let address = - if address.addr().is_unspecified() { None } else { Some(address) }; - Self { address, vlan_id: None } +impl UplinkAddress { + /// Squash this address down to a flat IP address by converting + /// [`UplinkAddress::LinkLocal`] to `::`. + /// + /// Uses of this function probably indicate places where we could consider + /// using stronger types. + pub fn addr_squashing_link_local_to_unspecified(&self) -> IpAddr { + match self { + UplinkAddress::LinkLocal => IpAddr::V6(Ipv6Addr::UNSPECIFIED), + UplinkAddress::Address { ip_net } => ip_net.addr(), + } } - pub fn addr(&self) -> IpAddr { - match self.address { - Some(ipaddr) => ipaddr.addr(), - None => IpAddr::V6(Ipv6Addr::UNSPECIFIED), + /// Squash this address down to an [`IpNet`] address by converting + /// [`UplinkAddress::LinkLocal`] to `::/128`. + /// + /// Uses of this function probably indicate places where we could consider + /// using stronger types. + pub fn ip_net_squashing_link_local_to_unspecified(&self) -> IpNet { + match *self { + UplinkAddress::LinkLocal => { + IpNet::V6(Ipv6Net::host_net(Ipv6Addr::UNSPECIFIED)) + } + UplinkAddress::Address { ip_net } => ip_net.into(), } } +} + +impl UplinkAddressConfig { + /// Helper to construct an `UplinkAddressConfig` with a specified IP net and + /// no VLAN ID. + pub fn without_vlan(ip_net: SpecifiedIpNet) -> Self { + Self { address: UplinkAddress::Address { ip_net }, vlan_id: None } + } + /// Format `self` appropriately for passing to `uplinkd`'s SMF properties. pub fn to_uplinkd_smf_property(&self) -> String { - fn addr_string(addr: &oxnet::IpNet) -> String { - if addr.addr().is_unspecified() { - "link-local".into() - } else { - addr.to_string() - } - } - - // TODO-cleanup for now, squash address values of both `None` and - // `Some(UNSPECIFIED)` down to "link-local". We want better types here: - // . - match (&self.address, self.vlan_id) { - (Some(addr), None) => addr_string(addr), - (Some(addr), Some(v)) => format!("{};{v}", addr_string(addr)), - (None, None) => "link-local".to_string(), - (None, Some(v)) => format!("link-local;{v}"), + let addr: &dyn fmt::Display = match self.address { + UplinkAddress::LinkLocal => &"link-local", + UplinkAddress::Address { ip_net } => &ip_net.addr(), + }; + + match self.vlan_id { + Some(v) => format!("{addr};{v}"), + None => addr.to_string(), } } } diff --git a/sled-agent/types/versions/src/latest.rs b/sled-agent/types/versions/src/latest.rs index 7e9c79f973d..d0d94a62296 100644 --- a/sled-agent/types/versions/src/latest.rs +++ b/sled-agent/types/versions/src/latest.rs @@ -180,9 +180,10 @@ pub mod rack_init { pub use crate::bootstrap_v1::rack_init::RecoverySiloConfig; pub use crate::v20::rack_init::BootstrapAddressDiscovery; - pub use crate::v20::rack_init::RackInitializeRequest; - pub use crate::v20::rack_init::RackInitializeRequestParams; pub use crate::v20::rack_init::RackInitializeRequestParseError; + + pub use crate::v29::rack_init::RackInitializeRequest; + pub use crate::v29::rack_init::RackInitializeRequestParams; } pub mod rot { @@ -225,9 +226,8 @@ pub mod trust_quorum { } pub mod uplink { - pub use crate::v20::uplink::SwitchPorts; - - pub use crate::v26::uplink::HostPortConfig; + pub use crate::v29::uplink::HostPortConfig; + pub use crate::v29::uplink::SwitchPorts; } pub mod zone_bundle { diff --git a/sled-agent/types/versions/src/stronger_bgp_unnumbered_types/early_networking.rs b/sled-agent/types/versions/src/stronger_bgp_unnumbered_types/early_networking.rs index b2ec63c3171..6d51315dedb 100644 --- a/sled-agent/types/versions/src/stronger_bgp_unnumbered_types/early_networking.rs +++ b/sled-agent/types/versions/src/stronger_bgp_unnumbered_types/early_networking.rs @@ -13,30 +13,62 @@ use crate::v26::early_networking as v26; use oxnet::{IpNet, Ipv6Net}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use std::net::IpAddr; +use std::net::{IpAddr, Ipv6Addr}; #[derive(Debug, thiserror::Error)] #[error("IP address must not be the unspecified address (0.0.0.0 or ::)")] pub struct UnspecifiedIpError; #[derive( - Clone, Debug, Deserialize, Serialize, PartialEq, Eq, JsonSchema, Hash, + Clone, + Copy, + Debug, + Deserialize, + Serialize, + PartialEq, + Eq, + JsonSchema, + Hash, + PartialOrd, + Ord, )] +#[serde(tag = "type", rename_all = "snake_case")] pub enum UplinkAddress { LinkLocal, - Address(SpecifiedIpNet), + Address { ip_net: SpecifiedIpNet }, } #[derive( - Clone, Debug, Deserialize, Serialize, PartialEq, Eq, JsonSchema, Hash, + Clone, + Copy, + Debug, + Deserialize, + Serialize, + PartialEq, + Eq, + JsonSchema, + Hash, + PartialOrd, + Ord, )] +#[serde(tag = "type", rename_all = "snake_case")] pub enum RouterPeerAddress { Unnumbered, - Numbered(SpecifiedIpAddr), + Numbered { ip: SpecifiedIpAddr }, } #[derive( - Clone, Debug, Deserialize, Serialize, PartialEq, Eq, JsonSchema, Hash, + Clone, + Copy, + Debug, + Deserialize, + Serialize, + PartialEq, + Eq, + JsonSchema, + Hash, + PartialOrd, + Ord, )] #[serde(try_from = "IpNet", into = "IpNet")] pub struct SpecifiedIpNet(pub(crate) IpNet); @@ -63,7 +95,17 @@ impl TryFrom for SpecifiedIpNet { } #[derive( - Clone, Debug, Deserialize, Serialize, PartialEq, Eq, JsonSchema, Hash, + Clone, + Copy, + Debug, + Deserialize, + Serialize, + PartialEq, + Eq, + JsonSchema, + Hash, + PartialOrd, + Ord, )] #[serde(try_from = "IpAddr", into = "IpAddr")] pub struct SpecifiedIpAddr(pub(crate) IpAddr); @@ -104,13 +146,23 @@ pub struct UplinkAddressConfig { impl From for UplinkAddressConfig { fn from(value: v20::UplinkAddressConfig) -> Self { let address = match value.address.map(SpecifiedIpNet::try_from) { - Some(Ok(net)) => UplinkAddress::Address(net), + Some(Ok(ip_net)) => UplinkAddress::Address { ip_net }, Some(Err(UnspecifiedIpError)) | None => UplinkAddress::LinkLocal, }; Self { address, vlan_id: value.vlan_id } } } +impl From for v20::UplinkAddressConfig { + fn from(value: UplinkAddressConfig) -> Self { + let address = match value.address { + UplinkAddress::LinkLocal => None, + UplinkAddress::Address { ip_net } => Some(ip_net.into()), + }; + Self { address, vlan_id: value.vlan_id } + } +} + #[derive( Clone, Debug, Deserialize, Serialize, PartialEq, Eq, Hash, JsonSchema, )] @@ -120,7 +172,7 @@ pub struct PortConfig { /// This port's addresses and optional vlan IDs pub addresses: Vec, /// Switch the port belongs to. - pub switch: v1::SwitchLocation, + pub switch: v1::SwitchSlot, /// Nmae of the port this config applies to. pub port: String, /// Port speed. @@ -155,6 +207,23 @@ impl From for PortConfig { } } +impl From for v20::PortConfig { + fn from(value: PortConfig) -> Self { + Self { + routes: value.routes, + addresses: value.addresses.into_iter().map(From::from).collect(), + switch: value.switch, + port: value.port, + uplink_port_speed: value.uplink_port_speed, + uplink_port_fec: value.uplink_port_fec, + bgp_peers: value.bgp_peers.into_iter().map(From::from).collect(), + autoneg: value.autoneg, + lldp: value.lldp, + tx_eq: value.tx_eq, + } + } +} + #[derive( Clone, Debug, Deserialize, Serialize, PartialEq, Eq, Hash, JsonSchema, )] @@ -214,7 +283,7 @@ pub struct BgpPeerConfig { impl From for BgpPeerConfig { fn from(value: v20::BgpPeerConfig) -> Self { let addr = match SpecifiedIpAddr::try_from(value.addr) { - Ok(ip) => RouterPeerAddress::Numbered(ip), + Ok(ip) => RouterPeerAddress::Numbered { ip }, Err(UnspecifiedIpError) => RouterPeerAddress::Unnumbered, }; Self { @@ -241,6 +310,36 @@ impl From for BgpPeerConfig { } } +impl From for v20::BgpPeerConfig { + fn from(value: BgpPeerConfig) -> Self { + let addr = match value.addr { + RouterPeerAddress::Unnumbered => IpAddr::V6(Ipv6Addr::UNSPECIFIED), + RouterPeerAddress::Numbered { ip } => ip.into(), + }; + Self { + asn: value.asn, + port: value.port, + addr, + hold_time: value.hold_time, + idle_hold_time: value.idle_hold_time, + delay_open: value.delay_open, + connect_retry: value.connect_retry, + keepalive: value.keepalive, + remote_asn: value.remote_asn, + min_ttl: value.min_ttl, + md5_auth_key: value.md5_auth_key, + multi_exit_discriminator: value.multi_exit_discriminator, + communities: value.communities, + local_pref: value.local_pref, + enforce_first_as: value.enforce_first_as, + allowed_import: value.allowed_import, + allowed_export: value.allowed_export, + vlan_id: value.vlan_id, + router_lifetime: value.router_lifetime, + } + } +} + /// Initial network configuration #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, JsonSchema)] pub struct RackNetworkConfig { @@ -291,6 +390,23 @@ impl TryFrom for EarlyNetworkConfigBody { } } +impl From for v26::EarlyNetworkConfigBody { + fn from(new: EarlyNetworkConfigBody) -> Self { + let new = new.rack_network_config; + + Self { + rack_network_config: v20::RackNetworkConfig { + rack_subnet: new.rack_subnet, + infra_ip_first: new.infra_ip_first, + infra_ip_last: new.infra_ip_last, + ports: new.ports.into_iter().map(From::from).collect(), + bgp: new.bgp, + bfd: new.bfd, + }, + } + } +} + /// Structure for requests from Nexus to sled-agent to write a new /// `EarlyNetworkConfigBody` into the replicated bootstore. /// diff --git a/sled-agent/types/versions/src/stronger_bgp_unnumbered_types/mod.rs b/sled-agent/types/versions/src/stronger_bgp_unnumbered_types/mod.rs index 8cc2b258ccd..f979e11f7bc 100644 --- a/sled-agent/types/versions/src/stronger_bgp_unnumbered_types/mod.rs +++ b/sled-agent/types/versions/src/stronger_bgp_unnumbered_types/mod.rs @@ -5,4 +5,5 @@ //! Version `STRONGER_BGP_UNNUMBERED_TYPES` of the Sled Agent API. pub mod early_networking; +pub mod rack_init; pub mod uplink; diff --git a/sled-agent/types/versions/src/stronger_bgp_unnumbered_types/rack_init.rs b/sled-agent/types/versions/src/stronger_bgp_unnumbered_types/rack_init.rs new file mode 100644 index 00000000000..bd247c31a66 --- /dev/null +++ b/sled-agent/types/versions/src/stronger_bgp_unnumbered_types/rack_init.rs @@ -0,0 +1,123 @@ +// 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/. + +//! Rack initialization types +//! +//! Changes in this version: +//! * TODO-john + +use super::early_networking::RackNetworkConfig; +use crate::bootstrap_v1::rack_init::RecoverySiloConfig; +use crate::impls::rack_init::default_allowed_source_ips; +use crate::impls::rack_init::validate_external_dns; +use crate::v20::rack_init::BootstrapAddressDiscovery; +use anyhow::Result; +use omicron_common::{ + address::IpRange, + api::{external::AllowedSourceIps, internal::nexus::Certificate}, +}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use sled_hardware_types::Baseboard; +use std::net::IpAddr; + +/// Configuration for the "rack setup service". +/// +/// The Rack Setup Service should be responsible for one-time setup actions, +/// such as CockroachDB placement and initialization. Without operator +/// intervention, however, these actions need a way to be automated in our +/// deployment. +#[derive(Clone, Deserialize, Serialize, PartialEq, JsonSchema)] +#[serde(try_from = "UnvalidatedRackInitializeRequest")] +pub struct RackInitializeRequest { + /// The set of peer_ids required to initialize trust quorum + /// + /// The value is `None` if we are not using trust quorum + pub trust_quorum_peers: Option>, + + /// Describes how bootstrap addresses should be collected during RSS. + pub bootstrap_discovery: BootstrapAddressDiscovery, + + /// The external NTP server addresses. + pub ntp_servers: Vec, + + /// The external DNS server addresses. + pub dns_servers: Vec, + + /// Ranges of the service IP pool which may be used for internal services. + // TODO(https://github.com/oxidecomputer/omicron/issues/1530): Eventually, + // we want to configure multiple pools. + pub internal_services_ip_pool_ranges: Vec, + + /// Service IP addresses on which we run external DNS servers. + /// + /// Each address must be present in `internal_services_ip_pool_ranges`. + pub external_dns_ips: Vec, + + /// DNS name for the DNS zone delegated to the rack for external DNS + pub external_dns_zone_name: String, + + /// initial TLS certificates for the external API + pub external_certificates: Vec, + + /// Configuration of the Recovery Silo (the initial Silo) + pub recovery_silo: RecoverySiloConfig, + + /// Initial rack network configuration + pub rack_network_config: RackNetworkConfig, + + /// IPs or subnets allowed to make requests to user-facing services + #[serde(default = "default_allowed_source_ips")] + pub allowed_source_ips: AllowedSourceIps, +} + +// "Shadow" copy of `RackInitializeRequest` that does no validation on its +// fields. +#[derive(Clone, Deserialize)] +struct UnvalidatedRackInitializeRequest { + trust_quorum_peers: Option>, + bootstrap_discovery: BootstrapAddressDiscovery, + ntp_servers: Vec, + dns_servers: Vec, + internal_services_ip_pool_ranges: Vec, + external_dns_ips: Vec, + external_dns_zone_name: String, + external_certificates: Vec, + recovery_silo: RecoverySiloConfig, + rack_network_config: RackNetworkConfig, + #[serde(default = "default_allowed_source_ips")] + allowed_source_ips: AllowedSourceIps, +} + +#[derive(Debug, Clone)] +pub struct RackInitializeRequestParams { + pub rack_initialize_request: RackInitializeRequest, + pub skip_timesync: bool, +} + +impl TryFrom for RackInitializeRequest { + type Error = anyhow::Error; + + fn try_from(value: UnvalidatedRackInitializeRequest) -> Result { + validate_external_dns( + &value.external_dns_ips, + &value.internal_services_ip_pool_ranges, + )?; + + Ok(Self { + trust_quorum_peers: value.trust_quorum_peers, + bootstrap_discovery: value.bootstrap_discovery, + ntp_servers: value.ntp_servers, + dns_servers: value.dns_servers, + internal_services_ip_pool_ranges: value + .internal_services_ip_pool_ranges, + external_dns_ips: value.external_dns_ips, + external_dns_zone_name: value.external_dns_zone_name, + external_certificates: value.external_certificates, + recovery_silo: value.recovery_silo, + rack_network_config: value.rack_network_config, + allowed_source_ips: value.allowed_source_ips, + }) + } +} diff --git a/sled-agent/types/versions/src/stronger_bgp_unnumbered_types/uplink.rs b/sled-agent/types/versions/src/stronger_bgp_unnumbered_types/uplink.rs index 15e8bfa3946..46faed0e6f3 100644 --- a/sled-agent/types/versions/src/stronger_bgp_unnumbered_types/uplink.rs +++ b/sled-agent/types/versions/src/stronger_bgp_unnumbered_types/uplink.rs @@ -8,9 +8,22 @@ //! * TODO-john use crate::v1; +use crate::v20; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +/// A set of switch uplinks. +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] +pub struct SwitchPorts { + pub uplinks: Vec, +} + +impl From for SwitchPorts { + fn from(value: v20::uplink::SwitchPorts) -> Self { + Self { uplinks: value.uplinks.into_iter().map(From::from).collect() } + } +} + #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, JsonSchema)] pub struct HostPortConfig { /// Switchport to use for external connectivity @@ -24,3 +37,14 @@ pub struct HostPortConfig { pub tx_eq: Option, } + +impl From for HostPortConfig { + fn from(value: v20::uplink::HostPortConfig) -> Self { + Self { + port: value.port, + addrs: value.addrs.into_iter().map(From::from).collect(), + lldp: value.lldp, + tx_eq: value.tx_eq, + } + } +} diff --git a/wicket-common/src/example.rs b/wicket-common/src/example.rs index 9c2de608d22..5ab747d5294 100644 --- a/wicket-common/src/example.rs +++ b/wicket-common/src/example.rs @@ -14,7 +14,8 @@ use omicron_common::{ }; use sled_agent_types::early_networking::{ BgpConfig, BgpPeerConfig, LldpAdminStatus, LldpPortConfig, MaxPathConfig, - PortFec, PortSpeed, RouteConfig, TxEqConfig, UplinkAddressConfig, + PortFec, PortSpeed, RouteConfig, RouterLifetimeConfig, TxEqConfig, + UplinkAddressConfig, }; use sled_hardware_types::Baseboard; @@ -117,7 +118,7 @@ impl ExampleRackSetupData { "127.0.0.1/8".parse().unwrap(), ]), vlan_id: None, - router_lifetime: 0, + router_lifetime: RouterLifetimeConfig::new(0).unwrap(), }, UserSpecifiedBgpPeerConfig { asn: 28, @@ -141,7 +142,7 @@ impl ExampleRackSetupData { ]), allowed_export: UserSpecifiedImportExportPolicy::Allow(vec![]), vlan_id: None, - router_lifetime: 0, + router_lifetime: RouterLifetimeConfig::new(0).unwrap(), }, ]; @@ -166,7 +167,7 @@ impl ExampleRackSetupData { ]), allowed_export: UserSpecifiedImportExportPolicy::NoFiltering, vlan_id: None, - router_lifetime: 0, + router_lifetime: RouterLifetimeConfig::new(0).unwrap(), }]; let switch0_port0_lldp = Some(LldpPortConfig { diff --git a/wicket-common/src/rack_setup.rs b/wicket-common/src/rack_setup.rs index 798e437d29f..9e99e7cf8ce 100644 --- a/wicket-common/src/rack_setup.rs +++ b/wicket-common/src/rack_setup.rs @@ -23,6 +23,7 @@ use sled_agent_types::early_networking::LldpPortConfig; use sled_agent_types::early_networking::PortFec; use sled_agent_types::early_networking::PortSpeed; use sled_agent_types::early_networking::RouteConfig; +use sled_agent_types::early_networking::RouterLifetimeConfig; use sled_agent_types::early_networking::SwitchSlot; use sled_agent_types::early_networking::TxEqConfig; use sled_agent_types::early_networking::UplinkAddressConfig; @@ -252,7 +253,7 @@ pub struct UserSpecifiedBgpPeerConfig { pub vlan_id: Option, /// Router lifetime in seconds for unnumbered BGP peers. #[serde(default)] - pub router_lifetime: u16, + pub router_lifetime: RouterLifetimeConfig, } impl UserSpecifiedBgpPeerConfig { diff --git a/wicket/src/cli/rack_setup/config_toml.rs b/wicket/src/cli/rack_setup/config_toml.rs index a45a7ea9ce5..7611db45c5e 100644 --- a/wicket/src/cli/rack_setup/config_toml.rs +++ b/wicket/src/cli/rack_setup/config_toml.rs @@ -11,6 +11,7 @@ use serde::Serialize; use sled_agent_types::early_networking::BgpConfig; use sled_agent_types::early_networking::LldpPortConfig; use sled_agent_types::early_networking::RouteConfig; +use sled_agent_types::early_networking::UplinkAddress; use sled_agent_types::early_networking::UplinkAddressConfig; use sled_hardware_types::Baseboard; use std::borrow::Cow; @@ -373,8 +374,12 @@ fn populate_uplink_table(cfg: &UserSpecifiedPortConfig) -> Table { for a in addresses { let UplinkAddressConfig { address, vlan_id } = a; let mut x = InlineTable::new(); - if let Some(address) = address { - x.insert("address", string_value(address)); + match address { + // TODO-john fix this + UplinkAddress::LinkLocal => (), + UplinkAddress::Address { ip_net } => { + x.insert("address", string_value(ip_net)); + } } if let Some(vlan_id) = vlan_id { x.insert("vlan_id", i64_value(i64::from(*vlan_id))); @@ -514,10 +519,10 @@ fn populate_uplink_table(cfg: &UserSpecifiedPortConfig) -> Table { } // router_lifetime - if *router_lifetime != 0 { + if router_lifetime.as_u16() != 0 { peer.insert( "router_lifetime", - i64_item(i64::from(*router_lifetime)), + i64_item(i64::from(router_lifetime.as_u16())), ); } diff --git a/wicket/src/ui/panes/rack_setup.rs b/wicket/src/ui/panes/rack_setup.rs index a292cd85c0c..9a1503be22a 100644 --- a/wicket/src/ui/panes/rack_setup.rs +++ b/wicket/src/ui/panes/rack_setup.rs @@ -37,6 +37,8 @@ use sled_agent_types::early_networking::LldpAdminStatus; use sled_agent_types::early_networking::LldpPortConfig; use sled_agent_types::early_networking::RouteConfig; use sled_agent_types::early_networking::SwitchSlot; +use sled_agent_types::early_networking::UplinkAddress; +use sled_agent_types::early_networking::UplinkAddressConfig; use std::borrow::Cow; use wicket_common::rack_setup::BgpAuthKeyInfo; use wicket_common::rack_setup::BgpAuthKeyStatus; @@ -867,15 +869,17 @@ fn rss_config_text<'a>( }); let addresses = addresses.iter().map(|a| { + let UplinkAddressConfig { address, vlan_id } = a; + let addr_description = match address { + UplinkAddress::LinkLocal => Cow::Borrowed("link-local"), + UplinkAddress::Address { ip_net } => { + Cow::Owned(ip_net.to_string()) + } + }; let mut items = vec![Span::styled(" • Address : ", label_style)]; - if let Some(address) = a.address { - items.push(Span::styled(address.to_string(), ok_style)); - } else { - items - .push(Span::styled("link-local".to_string(), ok_style)); - } - if let Some(vlan_id) = a.vlan_id { + items.push(Span::styled(addr_description, ok_style)); + if let Some(vlan_id) = vlan_id { items.extend([ Span::styled(" (vlan_id=", label_style), Span::styled(vlan_id.to_string(), ok_style), @@ -1002,7 +1006,7 @@ fn rss_config_text<'a>( Span::styled(vlan_id.to_string(), ok_style), ]); } - if *router_lifetime != 0 { + if router_lifetime.as_u16() != 0 { settings.extend([ Span::styled(" router_lifetime=", label_style), Span::styled( diff --git a/wicketd/src/preflight_check/uplink.rs b/wicketd/src/preflight_check/uplink.rs index ebf58591826..364af240345 100644 --- a/wicketd/src/preflight_check/uplink.rs +++ b/wicketd/src/preflight_check/uplink.rs @@ -28,6 +28,7 @@ use oxnet::IpNet; use sled_agent_types::early_networking::PortFec as OmicronPortFec; use sled_agent_types::early_networking::PortSpeed as OmicronPortSpeed; use sled_agent_types::early_networking::SwitchSlot; +use sled_agent_types::early_networking::UplinkAddress; use slog::Logger; use slog::error; use slog::o; @@ -377,7 +378,7 @@ fn add_steps_for_single_local_uplink_preflight_check<'a>( 'waiting_for_addr: loop { match addr.address { // When we are using numbered uplinks - Some(uplink_cidr) => { + UplinkAddress::Address { ip_net: uplink_cidr } => { let ipadm_out = match execute_command(&[ IPADM, "show-addr", @@ -409,7 +410,7 @@ fn add_steps_for_single_local_uplink_preflight_check<'a>( } } // unnumbered uplinks - None => { + UplinkAddress::LinkLocal => { // look for a new unnumbered uplink let new_count = match execute_command(&[ IPADM, @@ -837,7 +838,11 @@ fn build_port_settings( let mut port_settings = PortSettings { links: HashMap::new() }; - let addrs = uplink.addresses.iter().map(|a| a.addr()).collect(); + let addrs = uplink + .addresses + .iter() + .map(|a| a.address.addr_squashing_link_local_to_unspecified()) + .collect(); port_settings.links.insert( link_id.to_string(), diff --git a/wicketd/src/rss_config.rs b/wicketd/src/rss_config.rs index eb272b2824f..c97167df65b 100644 --- a/wicketd/src/rss_config.rs +++ b/wicketd/src/rss_config.rs @@ -12,7 +12,6 @@ use anyhow::bail; use bootstrap_agent_lockstep_client::types::BootstrapAddressDiscovery; use bootstrap_agent_lockstep_client::types::Certificate; use bootstrap_agent_lockstep_client::types::Name; -use bootstrap_agent_lockstep_client::types::PortConfig as BaPortConfig; use bootstrap_agent_lockstep_client::types::RackInitializeRequest; use bootstrap_agent_lockstep_client::types::RecoverySiloConfig; use bootstrap_agent_lockstep_client::types::UserId; @@ -24,7 +23,9 @@ use omicron_common::address::Ipv4Range; use omicron_common::address::Ipv6Range; use omicron_common::api::external::AllowedSourceIps; use oxnet::Ipv6Net; +use sled_agent_types::early_networking::PortConfig; 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; @@ -642,16 +643,17 @@ fn validate_rack_network_config( // iterate through each port config for (_, _, port_config) in config.iter_uplinks() { for addr in &port_config.addresses { - if addr.addr().is_unspecified() { - continue; - } + let addr = match addr.address { + UplinkAddress::LinkLocal => continue, + UplinkAddress::Address { ip_net } => ip_net.addr(), + }; // ... and check that it contains `uplink_ip`. - if addr.addr() < infra_ip_range.first_address() - || addr.addr() > infra_ip_range.last_address() + if addr < infra_ip_range.first_address() + || addr > infra_ip_range.last_address() { bail!( - "`uplink_cidr`'s IP address must be in the range defined by \ - `infra_ip_first` and `infra_ip_last`" + "`uplink_cidr`'s IP address must be in the range defined \ + by `infra_ip_first` and `infra_ip_last`" ); } } @@ -740,7 +742,7 @@ pub fn validate_rack_subnet( Ipv6Net::new(rack_subnet_address, 56).map_err(|e| e.to_string()) } -/// Builds a `BaPortConfig` from a `UserSpecifiedPortConfig`. +/// Builds a [`PortConfig`] from a [`UserSpecifiedPortConfig`]. /// /// Assumes that all auth keys are present in `bgp_auth_keys`. fn build_port_config( @@ -748,40 +750,16 @@ fn build_port_config( port: &str, config: &UserSpecifiedPortConfig, bgp_auth_keys: &BTreeMap>, -) -> BaPortConfig { - use bootstrap_agent_lockstep_client::types::BgpPeerConfig as BaBgpPeerConfig; - use bootstrap_agent_lockstep_client::types::LldpAdminStatus as BaLldpAdminStatus; - use bootstrap_agent_lockstep_client::types::LldpPortConfig as BaLldpPortConfig; - use bootstrap_agent_lockstep_client::types::PortFec as BaPortFec; - use bootstrap_agent_lockstep_client::types::PortSpeed as BaPortSpeed; - use bootstrap_agent_lockstep_client::types::RouteConfig as BaRouteConfig; - use bootstrap_agent_lockstep_client::types::RouterLifetimeConfig as BaRouterLifetimeConfig; - use bootstrap_agent_lockstep_client::types::TxEqConfig as BaTxEqConfig; - use bootstrap_agent_lockstep_client::types::UplinkAddressConfig as BaUplinkAddressConfig; - use sled_agent_types::early_networking::LldpAdminStatus; - use sled_agent_types::early_networking::PortFec; - use sled_agent_types::early_networking::PortSpeed; - - BaPortConfig { +) -> PortConfig { + use sled_agent_types::early_networking::BgpPeerConfig; + use sled_agent_types::early_networking::RouterPeerAddress; + use sled_agent_types::early_networking::SpecifiedIpAddr; + use sled_agent_types::early_networking::UnspecifiedIpError; + + PortConfig { port: port.to_owned(), - routes: config - .routes - .iter() - .map(|r| BaRouteConfig { - destination: r.destination, - nexthop: r.nexthop, - vlan_id: r.vlan_id, - rib_priority: r.rib_priority, - }) - .collect(), - addresses: config - .addresses - .iter() - .map(|a| BaUplinkAddressConfig { - address: a.address, - vlan_id: a.vlan_id, - }) - .collect(), + routes: config.routes.clone(), + addresses: config.addresses.clone(), bgp_peers: config .bgp_peers .iter() @@ -805,10 +783,14 @@ fn build_port_config( key }); - BaBgpPeerConfig { - addr: p - .addr - .unwrap_or_else(|| IpAddr::V6(Ipv6Addr::UNSPECIFIED)), + BgpPeerConfig { + // TODO-john make `p.addr` stronger typed + addr: match p.addr.map(SpecifiedIpAddr::try_from) { + Some(Ok(ip)) => RouterPeerAddress::Numbered { ip }, + None | Some(Err(UnspecifiedIpError)) => { + RouterPeerAddress::Unnumbered + } + }, asn: p.asn, port: p.port.clone(), hold_time: p.hold_time, @@ -826,49 +808,16 @@ fn build_port_config( allowed_export: p.allowed_export.clone().into(), allowed_import: p.allowed_import.clone().into(), vlan_id: p.vlan_id, - router_lifetime: BaRouterLifetimeConfig(p.router_lifetime), + router_lifetime: p.router_lifetime, } }) .collect(), switch, - uplink_port_speed: match config.uplink_port_speed { - PortSpeed::Speed0G => BaPortSpeed::Speed0G, - PortSpeed::Speed1G => BaPortSpeed::Speed1G, - PortSpeed::Speed10G => BaPortSpeed::Speed10G, - PortSpeed::Speed25G => BaPortSpeed::Speed25G, - PortSpeed::Speed40G => BaPortSpeed::Speed40G, - PortSpeed::Speed50G => BaPortSpeed::Speed50G, - PortSpeed::Speed100G => BaPortSpeed::Speed100G, - PortSpeed::Speed200G => BaPortSpeed::Speed200G, - PortSpeed::Speed400G => BaPortSpeed::Speed400G, - }, - uplink_port_fec: config.uplink_port_fec.map(|fec| match fec { - PortFec::Firecode => BaPortFec::Firecode, - PortFec::None => BaPortFec::None, - PortFec::Rs => BaPortFec::Rs, - }), + uplink_port_speed: config.uplink_port_speed, + uplink_port_fec: config.uplink_port_fec, autoneg: config.autoneg, - lldp: config.lldp.as_ref().map(|c| BaLldpPortConfig { - status: match c.status { - LldpAdminStatus::Enabled => BaLldpAdminStatus::Enabled, - LldpAdminStatus::Disabled => BaLldpAdminStatus::Disabled, - LldpAdminStatus::TxOnly => BaLldpAdminStatus::TxOnly, - LldpAdminStatus::RxOnly => BaLldpAdminStatus::RxOnly, - }, - chassis_id: c.chassis_id.clone(), - port_id: c.port_id.clone(), - system_name: c.system_name.clone(), - system_description: c.system_description.clone(), - port_description: c.port_description.clone(), - management_addrs: c.management_addrs.clone(), - }), - tx_eq: config.tx_eq.as_ref().map(|c| BaTxEqConfig { - pre1: c.pre1, - pre2: c.pre2, - main: c.main, - post2: c.post2, - post1: c.post1, - }), + lldp: config.lldp.clone(), + tx_eq: config.tx_eq, } } From 683343be70c8e1cf9d2a21218591fd6867169f25 Mon Sep 17 00:00:00 2001 From: John Gallagher Date: Tue, 10 Mar 2026 17:24:54 -0400 Subject: [PATCH 03/23] add more briding helper methods --- .../tasks/sync_switch_configuration.rs | 32 +++++++------------ .../versions/src/impls/early_networking.rs | 30 +++++++++++++++++ 2 files changed, 41 insertions(+), 21 deletions(-) diff --git a/nexus/src/app/background/tasks/sync_switch_configuration.rs b/nexus/src/app/background/tasks/sync_switch_configuration.rs index fdeba230534..871df6c9d9c 100644 --- a/nexus/src/app/background/tasks/sync_switch_configuration.rs +++ b/nexus/src/app/background/tasks/sync_switch_configuration.rs @@ -50,6 +50,7 @@ use omicron_common::{ use rdb_types::{Prefix, Prefix4, Prefix6}; use serde_json::json; use sled_agent_client::types::HostPortConfig; +use sled_agent_types::early_networking::BfdPeerConfig; use sled_agent_types::early_networking::EarlyNetworkConfigBody; use sled_agent_types::early_networking::EarlyNetworkConfigEnvelope; use sled_agent_types::early_networking::ImportExportPolicy; @@ -63,12 +64,11 @@ use sled_agent_types::early_networking::SwitchSlot; use sled_agent_types::early_networking::TxEqConfig; use sled_agent_types::early_networking::UplinkAddressConfig; use sled_agent_types::early_networking::WriteNetworkConfigRequest; -use sled_agent_types::early_networking::{BfdPeerConfig, SpecifiedIpNet}; use sled_agent_types::early_networking::{ BgpConfig as SledBgpConfig, UplinkAddress, }; use sled_agent_types::early_networking::{ - BgpPeerConfig as SledBgpPeerConfig, RouterPeerAddress, UnspecifiedIpError, + BgpPeerConfig as SledBgpPeerConfig, RouterPeerAddress, }; use slog_error_chain::InlineErrorChain; use std::{ @@ -1109,20 +1109,13 @@ impl BackgroundTask for SwitchPortSettingsManager { addresses: info .addresses .iter() - .map(|a| + .map(|a| { + let address = UplinkAddress::from_ip_net_treating_unspecified_as_link_local(a.address); UplinkAddressConfig { - // TODO-john stronger type on a.address? - address: match SpecifiedIpNet::try_from(a.address) { - Ok(ip_net) => UplinkAddress::Address { - ip_net, - }, - Err(UnspecifiedIpError) => { - UplinkAddress::LinkLocal - } - }, + address, vlan_id: a.vlan_id } - ).collect(), + }).collect(), autoneg: info .links .get(0) //TODO breakout support @@ -1174,7 +1167,8 @@ impl BackgroundTask for SwitchPortSettingsManager { for peer in port_config.bgp_peers.iter_mut() { // For unnumbered peers, pass None // - // TODO-john stronger type? + // TODO-cleanup Push `RouterPeerAddress` down to all the + // datastore methods below instead of an `Option`. let peer_addr_for_lookup = match peer.addr { RouterPeerAddress::Unnumbered => None, RouterPeerAddress::Numbered { ip } => { @@ -1738,13 +1732,9 @@ fn uplinks( addrs: config .addresses .iter() - .map(|a| UplinkAddressConfig { - // TODO-john stronger type on a.address? - address: match SpecifiedIpNet::try_from(a.address) { - Ok(ip_net) => UplinkAddress::Address { ip_net }, - Err(UnspecifiedIpError) => UplinkAddress::LinkLocal, - }, - vlan_id: a.vlan_id, + .map(|a| { + let address = UplinkAddress::from_ip_net_treating_unspecified_as_link_local(a.address); + UplinkAddressConfig { address, vlan_id: a.vlan_id } }) .collect(), lldp, diff --git a/sled-agent/types/versions/src/impls/early_networking.rs b/sled-agent/types/versions/src/impls/early_networking.rs index 82d44b7dd44..a4921b98895 100644 --- a/sled-agent/types/versions/src/impls/early_networking.rs +++ b/sled-agent/types/versions/src/impls/early_networking.rs @@ -18,6 +18,7 @@ use crate::latest::early_networking::SwitchSlot; use crate::latest::early_networking::UnspecifiedIpError; use crate::latest::early_networking::UplinkAddress; use crate::latest::early_networking::UplinkAddressConfig; +use crate::latest::early_networking::RouterPeerAddress; use omicron_common::api::external; use oxnet::IpNet; use oxnet::IpNetParseError; @@ -175,6 +176,22 @@ impl FromStr for SpecifiedIpAddr { } } +impl RouterPeerAddress { + /// Convert an arbitrary [`IpAddr`] into a [`RouterPeerAddress`] by + /// converting an unspecified IP to [`RouterPeerAddress::Unnumbered`]. + /// + /// Uses of this function probably indicate places where we could consider + /// using stronger types. + pub fn from_ip_treating_unspecified_as_unnumbered( + ip: IpAddr, + ) -> Self { + match SpecifiedIpAddr::try_from(ip) { + Ok(ip) => Self::Numbered { ip }, + Err(UnspecifiedIpError) => Self::Unnumbered, + } + } +} + impl UplinkAddress { /// Squash this address down to a flat IP address by converting /// [`UplinkAddress::LinkLocal`] to `::`. @@ -202,6 +219,19 @@ impl UplinkAddress { } } + /// Convert an arbitrary [`IpNet`] into an [`UplinkAddress`] by converting + /// an unspecified IP to [`UplinkAddress::LinkLocal`]. + /// + /// Uses of this function probably indicate places where we could consider + /// using stronger types. + pub fn from_ip_net_treating_unspecified_as_link_local( + ip_net: IpNet, + ) -> Self { + match SpecifiedIpNet::try_from(ip_net) { + Ok(ip_net) => Self::Address { ip_net }, + Err(UnspecifiedIpError) => Self::LinkLocal, + } + } } impl UplinkAddressConfig { From ecf4f66e02568b6ac8bec9968b58e3a2c4142431 Mon Sep 17 00:00:00 2001 From: John Gallagher Date: Mon, 16 Mar 2026 11:52:22 -0400 Subject: [PATCH 04/23] helper method for optional IP conversion --- nexus/db-model/src/bgp.rs | 18 +++++++--------- .../versions/src/impls/early_networking.rs | 21 +++++++++++++++---- 2 files changed, 25 insertions(+), 14 deletions(-) diff --git a/nexus/db-model/src/bgp.rs b/nexus/db-model/src/bgp.rs index 5b8f4dbf9c4..0b2637f7e71 100644 --- a/nexus/db-model/src/bgp.rs +++ b/nexus/db-model/src/bgp.rs @@ -181,16 +181,14 @@ impl TryFrom for BgpPeerConfig { fn try_from(value: BgpPeerView) -> Result { // For unnumbered peers (addr is None), use UNSPECIFIED // - // TODO-john stronger type? NULL? - let addr = - match value.addr.map(|addr| SpecifiedIpAddr::try_from(addr.ip())) { - Some(Ok(ip)) => RouterPeerAddress::Numbered { ip }, - None | Some(Err(UnspecifiedIpError)) => { - RouterPeerAddress::Unnumbered - } - }; - - // TODO-correctness We should have db constraints to ensure these can't + // TODO-cleanup This allows any of three DB values (NULL, `0.0.0.0`, + // `::`) to be converted to `RouterPeerAddress::Unnumbered`. Should we + // add db constraints to squish that down to one (probably NULL)? + let addr = RouterPeerAddress::from_optional_ip_treating_unspecified_as_unnumbered( + value.addr.map(|addr| addr.ip()), + ); + + // TODO-correctness We should have db constraints to ensure this can't // fail. let router_lifetime = RouterLifetimeConfig::new(value.router_lifetime.0) diff --git a/sled-agent/types/versions/src/impls/early_networking.rs b/sled-agent/types/versions/src/impls/early_networking.rs index a4921b98895..63e8f608885 100644 --- a/sled-agent/types/versions/src/impls/early_networking.rs +++ b/sled-agent/types/versions/src/impls/early_networking.rs @@ -12,13 +12,13 @@ use crate::latest::early_networking::PortFec; use crate::latest::early_networking::PortSpeed; use crate::latest::early_networking::RouterLifetimeConfig; use crate::latest::early_networking::RouterLifetimeConfigError; +use crate::latest::early_networking::RouterPeerAddress; use crate::latest::early_networking::SpecifiedIpAddr; use crate::latest::early_networking::SpecifiedIpNet; use crate::latest::early_networking::SwitchSlot; use crate::latest::early_networking::UnspecifiedIpError; use crate::latest::early_networking::UplinkAddress; use crate::latest::early_networking::UplinkAddressConfig; -use crate::latest::early_networking::RouterPeerAddress; use omicron_common::api::external; use oxnet::IpNet; use oxnet::IpNetParseError; @@ -182,14 +182,27 @@ impl RouterPeerAddress { /// /// Uses of this function probably indicate places where we could consider /// using stronger types. - pub fn from_ip_treating_unspecified_as_unnumbered( - ip: IpAddr, - ) -> Self { + pub fn from_ip_treating_unspecified_as_unnumbered(ip: IpAddr) -> Self { match SpecifiedIpAddr::try_from(ip) { Ok(ip) => Self::Numbered { ip }, Err(UnspecifiedIpError) => Self::Unnumbered, } } + + /// Convert an arbitrary `Option` into a [`RouterPeerAddress`] by + /// converting both `None` and `Some(UNSPECIFIED)` + /// [`RouterPeerAddress::Unnumbered`]. + /// + /// Uses of this function probably indicate places where we could consider + /// using stronger types. + pub fn from_optional_ip_treating_unspecified_as_unnumbered( + ip: Option, + ) -> Self { + let Some(ip) = ip else { + return Self::Unnumbered; + }; + Self::from_ip_treating_unspecified_as_unnumbered(ip) + } } impl UplinkAddress { From 3ce05bb6e90fb608455bb0dbbe53045d91e3dac1 Mon Sep 17 00:00:00 2001 From: John Gallagher Date: Mon, 16 Mar 2026 13:44:27 -0400 Subject: [PATCH 05/23] clean up wicket interface --- .../early_networking.rs | 2 +- wicket-common/src/example.rs | 21 ++- wicket-common/src/rack_setup.rs | 126 +++++++++++++++++- wicket/src/cli/rack_setup/config_toml.rs | 33 +++-- wicket/src/ui/panes/rack_setup.rs | 9 +- wicketd/src/preflight_check/uplink.rs | 5 +- wicketd/src/rss_config.rs | 13 +- 7 files changed, 171 insertions(+), 38 deletions(-) diff --git a/sled-agent/types/versions/src/stronger_bgp_unnumbered_types/early_networking.rs b/sled-agent/types/versions/src/stronger_bgp_unnumbered_types/early_networking.rs index 6d51315dedb..9bc8faa94f4 100644 --- a/sled-agent/types/versions/src/stronger_bgp_unnumbered_types/early_networking.rs +++ b/sled-agent/types/versions/src/stronger_bgp_unnumbered_types/early_networking.rs @@ -132,7 +132,7 @@ impl TryFrom for SpecifiedIpAddr { } #[derive( - Clone, Debug, Deserialize, Serialize, PartialEq, Eq, JsonSchema, Hash, + Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq, JsonSchema, Hash, )] pub struct UplinkAddressConfig { /// The address to be used on the uplink. diff --git a/wicket-common/src/example.rs b/wicket-common/src/example.rs index 5ab747d5294..161097fb8b9 100644 --- a/wicket-common/src/example.rs +++ b/wicket-common/src/example.rs @@ -14,8 +14,8 @@ use omicron_common::{ }; use sled_agent_types::early_networking::{ BgpConfig, BgpPeerConfig, LldpAdminStatus, LldpPortConfig, MaxPathConfig, - PortFec, PortSpeed, RouteConfig, RouterLifetimeConfig, TxEqConfig, - UplinkAddressConfig, + PortFec, PortSpeed, RouteConfig, RouterLifetimeConfig, RouterPeerAddress, + TxEqConfig, }; use sled_hardware_types::Baseboard; @@ -26,6 +26,7 @@ use crate::{ CurrentRssUserConfigInsensitive, PutRssUserConfigInsensitive, UserSpecifiedBgpPeerConfig, UserSpecifiedImportExportPolicy, UserSpecifiedPortConfig, UserSpecifiedRackNetworkConfig, + UserSpecifiedUplinkAddressConfig, }, }; @@ -99,7 +100,9 @@ impl ExampleRackSetupData { let switch0_port0_bgp_peers = vec![ UserSpecifiedBgpPeerConfig { asn: 47, - addr: Some("10.2.3.4".parse().unwrap()), + addr: RouterPeerAddress::Numbered { + ip: "10.2.3.4".parse().unwrap(), + }, port: "port0".into(), hold_time: Some(BgpPeerConfig::DEFAULT_HOLD_TIME), idle_hold_time: Some(BgpPeerConfig::DEFAULT_IDLE_HOLD_TIME), @@ -122,7 +125,9 @@ impl ExampleRackSetupData { }, UserSpecifiedBgpPeerConfig { asn: 28, - addr: Some("10.2.3.5".parse().unwrap()), + addr: RouterPeerAddress::Numbered { + ip: "10.2.3.5".parse().unwrap(), + }, port: "port0".into(), remote_asn: Some(200), hold_time: Some(10), @@ -148,7 +153,9 @@ impl ExampleRackSetupData { let switch1_port0_bgp_peers = vec![UserSpecifiedBgpPeerConfig { asn: 47, - addr: Some("10.2.3.4".parse().unwrap()), + addr: RouterPeerAddress::Numbered { + ip: "10.2.3.4".parse().unwrap(), + }, port: "port0".into(), hold_time: Some(BgpPeerConfig::DEFAULT_HOLD_TIME), idle_hold_time: Some(BgpPeerConfig::DEFAULT_IDLE_HOLD_TIME), @@ -208,7 +215,7 @@ impl ExampleRackSetupData { #[rustfmt::skip] switch0: btreemap! { "port0".to_owned() => UserSpecifiedPortConfig { - addresses: vec![UplinkAddressConfig::without_vlan( + addresses: vec![UserSpecifiedUplinkAddressConfig::without_vlan( "172.30.0.1/24".parse().unwrap(), )], routes: vec![RouteConfig { @@ -230,7 +237,7 @@ impl ExampleRackSetupData { // Use the same port name as in switch0 to test that it doesn't // collide. "port0".to_owned() => UserSpecifiedPortConfig { - addresses: vec![UplinkAddressConfig::without_vlan( + addresses: vec![UserSpecifiedUplinkAddressConfig::without_vlan( "172.32.0.1/24".parse().unwrap(), )], routes: vec![RouteConfig { diff --git a/wicket-common/src/rack_setup.rs b/wicket-common/src/rack_setup.rs index 9e99e7cf8ce..fc3465b01a8 100644 --- a/wicket-common/src/rack_setup.rs +++ b/wicket-common/src/rack_setup.rs @@ -24,8 +24,11 @@ use sled_agent_types::early_networking::PortFec; use sled_agent_types::early_networking::PortSpeed; use sled_agent_types::early_networking::RouteConfig; use sled_agent_types::early_networking::RouterLifetimeConfig; +use sled_agent_types::early_networking::RouterPeerAddress; +use sled_agent_types::early_networking::SpecifiedIpNet; use sled_agent_types::early_networking::SwitchSlot; use sled_agent_types::early_networking::TxEqConfig; +use sled_agent_types::early_networking::UplinkAddress; use sled_agent_types::early_networking::UplinkAddressConfig; use sled_hardware_types::Baseboard; use std::collections::BTreeMap; @@ -180,7 +183,7 @@ impl UserSpecifiedRackNetworkConfig { #[serde(deny_unknown_fields)] pub struct UserSpecifiedPortConfig { pub routes: Vec, - pub addresses: Vec, + pub addresses: Vec, pub uplink_port_speed: PortSpeed, pub uplink_port_fec: Option, pub autoneg: bool, @@ -192,6 +195,81 @@ pub struct UserSpecifiedPortConfig { pub tx_eq: Option, } +/// User-specified version of +/// [`sled_agent_types::early_networking::UplinkAddressConfig`]. +/// +/// This allows us to have a nicer TOML representation of [`UplinkAddress`]. +#[derive( + Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize, JsonSchema, +)] +#[serde(deny_unknown_fields)] +pub struct UserSpecifiedUplinkAddressConfig { + /// The address to be used on the uplink. + // This type is used in both JSON (via OpenAPI) and TOML (for operator + // uploads to wicket). For the TOML case specifically, we want to use a more + // user-friendly representation, so we serialize/deserialize this field as a + // string. See the `uplink_address_serde` module below for the specific + // mapping. + #[serde(with = "uplink_address_serde")] + #[schemars(with = "String")] + pub address: UplinkAddress, + + /// The VLAN id (if any) associated with this address. + #[serde(default)] + pub vlan_id: Option, +} + +impl From for UplinkAddressConfig { + fn from(value: UserSpecifiedUplinkAddressConfig) -> Self { + Self { address: value.address, vlan_id: value.vlan_id } + } +} + +impl UserSpecifiedUplinkAddressConfig { + /// String representation for [`UplinkAddress::LinkLocal`] when + /// serializing/deserializing [`UserSpecifiedUplinkAddressConfig`]. + pub const LINK_LOCAL: &str = "link-local"; + + /// Helper to construct a `UserSpecifiedUplinkAddressConfig` with a + /// specified IP net and no VLAN ID. + pub fn without_vlan(ip_net: SpecifiedIpNet) -> Self { + Self { address: UplinkAddress::Address { ip_net }, vlan_id: None } + } +} + +/// Special handling to serialize/deserialize [`UplinkAddress`] as a flat +/// string for a nicer TOML representation. +mod uplink_address_serde { + use super::{UplinkAddress, UserSpecifiedUplinkAddressConfig}; + use serde::{Deserialize, Deserializer, Serializer}; + + pub fn serialize( + addr: &UplinkAddress, + s: S, + ) -> Result { + match addr { + UplinkAddress::LinkLocal => { + s.serialize_str(UserSpecifiedUplinkAddressConfig::LINK_LOCAL) + } + UplinkAddress::Address { ip_net } => { + s.serialize_str(&ip_net.to_string()) + } + } + } + + pub fn deserialize<'de, D: Deserializer<'de>>( + d: D, + ) -> Result { + let s = String::deserialize(d)?; + if s == UserSpecifiedUplinkAddressConfig::LINK_LOCAL { + Ok(UplinkAddress::LinkLocal) + } else { + let ip_net = s.parse().map_err(serde::de::Error::custom)?; + Ok(UplinkAddress::Address { ip_net }) + } + } +} + /// User-specified version of [`BgpPeerConfig`]. /// /// This is similar to [`BgpPeerConfig`], except it doesn't have the sensitive @@ -205,7 +283,14 @@ pub struct UserSpecifiedBgpPeerConfig { /// Switch port the peer is reachable on. pub port: String, /// Address of the peer. - pub addr: Option, + // This type is used in both JSON (via OpenAPI) and TOML (for operator + // uploads to wicket). For the TOML case specifically, we want to use a more + // user-friendly representation, so we serialize/deserialize this field as a + // string. See the `bgp_peer_addr_serde` module below for the specific + // mapping. + #[serde(with = "bgp_peer_addr_serde")] + #[schemars(with = "String")] + pub addr: RouterPeerAddress, /// How long to keep a session alive without a keepalive in seconds. /// Defaults to 6 seconds. pub hold_time: Option, @@ -256,7 +341,44 @@ pub struct UserSpecifiedBgpPeerConfig { pub router_lifetime: RouterLifetimeConfig, } +/// Special handling to serialize/deserialize [`RouterPeerAddress`] as a flat +/// string for a nicer TOML representation. +mod bgp_peer_addr_serde { + use super::{RouterPeerAddress, UserSpecifiedBgpPeerConfig}; + use serde::{Deserialize, Deserializer, Serializer}; + + pub fn serialize( + addr: &RouterPeerAddress, + s: S, + ) -> Result { + match addr { + RouterPeerAddress::Unnumbered => { + s.serialize_str(UserSpecifiedBgpPeerConfig::UNNUMBERED_PEER) + } + RouterPeerAddress::Numbered { ip } => { + s.serialize_str(&ip.to_string()) + } + } + } + + pub fn deserialize<'de, D: Deserializer<'de>>( + d: D, + ) -> Result { + let s = String::deserialize(d)?; + if s == UserSpecifiedBgpPeerConfig::UNNUMBERED_PEER { + Ok(RouterPeerAddress::Unnumbered) + } else { + let ip = s.parse().map_err(serde::de::Error::custom)?; + Ok(RouterPeerAddress::Numbered { ip }) + } + } +} + impl UserSpecifiedBgpPeerConfig { + /// String representation for [`RouterPeerAddress::Unnumbered`] when + /// serializing/deserializing [`UserSpecifiedBgpPeerConfig`]. + pub const UNNUMBERED_PEER: &str = "unnumbered"; + pub fn hold_time(&self) -> u64 { self.hold_time.unwrap_or(BgpPeerConfig::DEFAULT_HOLD_TIME) } diff --git a/wicket/src/cli/rack_setup/config_toml.rs b/wicket/src/cli/rack_setup/config_toml.rs index 7611db45c5e..30d36e19ca7 100644 --- a/wicket/src/cli/rack_setup/config_toml.rs +++ b/wicket/src/cli/rack_setup/config_toml.rs @@ -11,8 +11,8 @@ use serde::Serialize; use sled_agent_types::early_networking::BgpConfig; use sled_agent_types::early_networking::LldpPortConfig; use sled_agent_types::early_networking::RouteConfig; +use sled_agent_types::early_networking::RouterPeerAddress; use sled_agent_types::early_networking::UplinkAddress; -use sled_agent_types::early_networking::UplinkAddressConfig; use sled_hardware_types::Baseboard; use std::borrow::Cow; use std::collections::BTreeSet; @@ -32,6 +32,7 @@ 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::UserSpecifiedUplinkAddressConfig; static TEMPLATE: &str = include_str!("config_template.toml"); @@ -372,15 +373,17 @@ fn populate_uplink_table(cfg: &UserSpecifiedPortConfig) -> Table { // addresses = [] let mut addresses_out = Array::new(); for a in addresses { - let UplinkAddressConfig { address, vlan_id } = a; + let UserSpecifiedUplinkAddressConfig { address, vlan_id } = a; let mut x = InlineTable::new(); - match address { - // TODO-john fix this - UplinkAddress::LinkLocal => (), - UplinkAddress::Address { ip_net } => { - x.insert("address", string_value(ip_net)); - } - } + x.insert( + "address", + string_value(match address { + UplinkAddress::LinkLocal => { + UserSpecifiedUplinkAddressConfig::LINK_LOCAL.to_owned() + } + UplinkAddress::Address { ip_net } => ip_net.to_string(), + }), + ); if let Some(vlan_id) = vlan_id { x.insert("vlan_id", i64_value(i64::from(*vlan_id))); } @@ -437,9 +440,15 @@ fn populate_uplink_table(cfg: &UserSpecifiedPortConfig) -> Table { peer.insert("port", string_item(port)); // addr = "" - if let Some(x) = addr { - peer.insert("addr", string_item(x)); - } + peer.insert( + "addr", + string_item(match addr { + RouterPeerAddress::Unnumbered => { + UserSpecifiedBgpPeerConfig::UNNUMBERED_PEER.to_owned() + } + RouterPeerAddress::Numbered { ip } => ip.to_string(), + }), + ); // hold_time if let Some(x) = hold_time { diff --git a/wicket/src/ui/panes/rack_setup.rs b/wicket/src/ui/panes/rack_setup.rs index 9a1503be22a..8fbdd4fba45 100644 --- a/wicket/src/ui/panes/rack_setup.rs +++ b/wicket/src/ui/panes/rack_setup.rs @@ -36,9 +36,9 @@ use sled_agent_types::early_networking::BgpConfig; use sled_agent_types::early_networking::LldpAdminStatus; use sled_agent_types::early_networking::LldpPortConfig; use sled_agent_types::early_networking::RouteConfig; +use sled_agent_types::early_networking::RouterPeerAddress; use sled_agent_types::early_networking::SwitchSlot; use sled_agent_types::early_networking::UplinkAddress; -use sled_agent_types::early_networking::UplinkAddressConfig; use std::borrow::Cow; use wicket_common::rack_setup::BgpAuthKeyInfo; use wicket_common::rack_setup::BgpAuthKeyStatus; @@ -47,6 +47,7 @@ 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::UserSpecifiedUplinkAddressConfig; use wicketd_client::types::CurrentRssUserConfig; use wicketd_client::types::CurrentRssUserConfigSensitive; use wicketd_client::types::RackOperationStatus; @@ -869,7 +870,7 @@ fn rss_config_text<'a>( }); let addresses = addresses.iter().map(|a| { - let UplinkAddressConfig { address, vlan_id } = a; + let UserSpecifiedUplinkAddressConfig { address, vlan_id } = a; let addr_description = match address { UplinkAddress::LinkLocal => Cow::Borrowed("link-local"), UplinkAddress::Address { ip_net } => { @@ -918,8 +919,8 @@ fn rss_config_text<'a>( } = p; let addr_string = match addr { - Some(a) => a.to_string(), - None => "unnumbered".to_string(), + RouterPeerAddress::Unnumbered => "unnumbered".to_string(), + RouterPeerAddress::Numbered { ip } => ip.to_string(), }; let mut lines = vec![ diff --git a/wicketd/src/preflight_check/uplink.rs b/wicketd/src/preflight_check/uplink.rs index 364af240345..8906008af37 100644 --- a/wicketd/src/preflight_check/uplink.rs +++ b/wicketd/src/preflight_check/uplink.rs @@ -29,6 +29,7 @@ use sled_agent_types::early_networking::PortFec as OmicronPortFec; use sled_agent_types::early_networking::PortSpeed as OmicronPortSpeed; use sled_agent_types::early_networking::SwitchSlot; use sled_agent_types::early_networking::UplinkAddress; +use sled_agent_types::early_networking::UplinkAddressConfig; use slog::Logger; use slog::error; use slog::o; @@ -308,7 +309,9 @@ fn add_steps_for_single_local_uplink_preflight_check<'a>( let uplink_property = UplinkProperty(format!("uplinks/{}_0", port)); - for addr in &uplink.addresses { + for &addr in &uplink.addresses { + let addr = UplinkAddressConfig::from(addr); + // count current number of link-local addresses let addrconf_count = match execute_command(&[ IPADM, diff --git a/wicketd/src/rss_config.rs b/wicketd/src/rss_config.rs index c97167df65b..95580f60acc 100644 --- a/wicketd/src/rss_config.rs +++ b/wicketd/src/rss_config.rs @@ -752,14 +752,11 @@ fn build_port_config( bgp_auth_keys: &BTreeMap>, ) -> PortConfig { use sled_agent_types::early_networking::BgpPeerConfig; - use sled_agent_types::early_networking::RouterPeerAddress; - use sled_agent_types::early_networking::SpecifiedIpAddr; - use sled_agent_types::early_networking::UnspecifiedIpError; PortConfig { port: port.to_owned(), routes: config.routes.clone(), - addresses: config.addresses.clone(), + addresses: config.addresses.iter().copied().map(From::from).collect(), bgp_peers: config .bgp_peers .iter() @@ -784,13 +781,7 @@ fn build_port_config( }); BgpPeerConfig { - // TODO-john make `p.addr` stronger typed - addr: match p.addr.map(SpecifiedIpAddr::try_from) { - Some(Ok(ip)) => RouterPeerAddress::Numbered { ip }, - None | Some(Err(UnspecifiedIpError)) => { - RouterPeerAddress::Unnumbered - } - }, + addr: p.addr, asn: p.asn, port: p.port.clone(), hold_time: p.hold_time, From 823ed21267772b14b299ebc9cbc0db297243c3f0 Mon Sep 17 00:00:00 2001 From: John Gallagher Date: Mon, 16 Mar 2026 13:51:50 -0400 Subject: [PATCH 06/23] unused imports --- nexus/db-model/src/bgp.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/nexus/db-model/src/bgp.rs b/nexus/db-model/src/bgp.rs index 0b2637f7e71..0dedaf58829 100644 --- a/nexus/db-model/src/bgp.rs +++ b/nexus/db-model/src/bgp.rs @@ -20,8 +20,6 @@ use sled_agent_types::early_networking::MaxPathConfig; use sled_agent_types::early_networking::RouterLifetimeConfig; use sled_agent_types::early_networking::RouterLifetimeConfigError; use sled_agent_types::early_networking::RouterPeerAddress; -use sled_agent_types::early_networking::SpecifiedIpAddr; -use sled_agent_types::early_networking::UnspecifiedIpError; use slog_error_chain::InlineErrorChain; use uuid::Uuid; From 97bd63e0c507789d4322acf03d5cbd95c527839f Mon Sep 17 00:00:00 2001 From: John Gallagher Date: Mon, 16 Mar 2026 13:52:07 -0400 Subject: [PATCH 07/23] fixups from rebase --- nexus/mgs-updates/src/test_util/host_phase_2_test_state.rs | 6 +++--- sled-agent/api/src/lib.rs | 2 +- sled-agent/src/http_entrypoints.rs | 6 +++--- sled-agent/src/sim/http_entrypoints.rs | 6 +++--- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/nexus/mgs-updates/src/test_util/host_phase_2_test_state.rs b/nexus/mgs-updates/src/test_util/host_phase_2_test_state.rs index 7a1b98c35de..021ab9c1846 100644 --- a/nexus/mgs-updates/src/test_util/host_phase_2_test_state.rs +++ b/nexus/mgs-updates/src/test_util/host_phase_2_test_state.rs @@ -271,7 +271,7 @@ mod api_impl { use sled_agent_types_versions::v20; use sled_agent_types_versions::v25; use sled_agent_types_versions::v26; - use sled_agent_types_versions::v28; + use sled_agent_types_versions::v29; use sled_diagnostics::SledDiagnosticsQueryOutput; use std::collections::BTreeMap; use std::collections::BTreeSet; @@ -772,9 +772,9 @@ mod api_impl { unimplemented!() } - async fn write_network_bootstore_config_v28( + async fn write_network_bootstore_config_v29( _rqctx: RequestContext, - _body: TypedBody, + _body: TypedBody, ) -> Result { unimplemented!() } diff --git a/sled-agent/api/src/lib.rs b/sled-agent/api/src/lib.rs index 214f15bdf1d..c18009d9fa1 100644 --- a/sled-agent/api/src/lib.rs +++ b/sled-agent/api/src/lib.rs @@ -862,7 +862,7 @@ pub trait SledAgentApi { versions = VERSION_STRONGER_BGP_UNNUMBERED_TYPES.., operation_id = "write_network_bootstore_config", }] - async fn write_network_bootstore_config_v28( + async fn write_network_bootstore_config_v29( rqctx: RequestContext, body: TypedBody, ) -> Result; diff --git a/sled-agent/src/http_entrypoints.rs b/sled-agent/src/http_entrypoints.rs index 17f163fea51..cac2d8d47aa 100644 --- a/sled-agent/src/http_entrypoints.rs +++ b/sled-agent/src/http_entrypoints.rs @@ -78,7 +78,7 @@ use trust_quorum_types::messages::{ use trust_quorum_types::status::{CommitStatus, CoordinatorStatus, NodeStatus}; // Fixed identifiers for prior versions only -use sled_agent_types_versions::{v1, v20, v25, v26, v28}; +use sled_agent_types_versions::{v1, v20, v25, v26, v29}; use sled_diagnostics::{ SledDiagnosticsCommandHttpOutput, SledDiagnosticsQueryOutput, }; @@ -1006,9 +1006,9 @@ impl SledAgentApi for SledAgentImpl { .await } - async fn write_network_bootstore_config_v28( + async fn write_network_bootstore_config_v29( rqctx: RequestContext, - body: TypedBody, + body: TypedBody, ) -> Result { let sa = rqctx.context(); let bs = sa.bootstore(); diff --git a/sled-agent/src/sim/http_entrypoints.rs b/sled-agent/src/sim/http_entrypoints.rs index e0b07d65364..3a8556b89ef 100644 --- a/sled-agent/src/sim/http_entrypoints.rs +++ b/sled-agent/src/sim/http_entrypoints.rs @@ -87,7 +87,7 @@ use sled_agent_types_versions::v1; use sled_agent_types_versions::v20; use sled_agent_types_versions::v25; use sled_agent_types_versions::v26; -use sled_agent_types_versions::v28; +use sled_agent_types_versions::v29; use sled_diagnostics::SledDiagnosticsQueryOutput; use slog_error_chain::InlineErrorChain; use std::collections::BTreeMap; @@ -437,9 +437,9 @@ impl SledAgentApi for SledAgentSimImpl { })) } - async fn write_network_bootstore_config_v28( + async fn write_network_bootstore_config_v29( rqctx: RequestContext, - body: TypedBody, + body: TypedBody, ) -> Result { let mut config = rqctx.context().bootstore_network_config.lock().unwrap(); From ffc8114d1b21a7f78a94628302ecd1f7ba47aa22 Mon Sep 17 00:00:00 2001 From: John Gallagher Date: Mon, 16 Mar 2026 13:52:23 -0400 Subject: [PATCH 08/23] openapi --- openapi/wicketd.json | 92 ++++++++++++-------------------------------- 1 file changed, 25 insertions(+), 67 deletions(-) diff --git a/openapi/wicketd.json b/openapi/wicketd.json index 3181b93d31d..6fc9323572e 100644 --- a/openapi/wicketd.json +++ b/openapi/wicketd.json @@ -4639,9 +4639,6 @@ "switch" ] }, - "SpecifiedIpNet": { - "$ref": "#/components/schemas/IpNet" - }, "StartUpdateOptions": { "type": "object", "properties": { @@ -7426,66 +7423,6 @@ } ] }, - "UplinkAddress": { - "oneOf": [ - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "link_local" - ] - } - }, - "required": [ - "type" - ] - }, - { - "type": "object", - "properties": { - "ip_net": { - "$ref": "#/components/schemas/SpecifiedIpNet" - }, - "type": { - "type": "string", - "enum": [ - "address" - ] - } - }, - "required": [ - "ip_net", - "type" - ] - } - ] - }, - "UplinkAddressConfig": { - "type": "object", - "properties": { - "address": { - "description": "The address to be used on the uplink.", - "allOf": [ - { - "$ref": "#/components/schemas/UplinkAddress" - } - ] - }, - "vlan_id": { - "nullable": true, - "description": "The VLAN id (if any) associated with this address.", - "default": null, - "type": "integer", - "format": "uint16", - "minimum": 0 - } - }, - "required": [ - "address" - ] - }, "UplinkPreflightStepId": { "oneOf": [ { @@ -7621,10 +7558,8 @@ "type": "object", "properties": { "addr": { - "nullable": true, "description": "Address of the peer.", - "type": "string", - "format": "ip" + "type": "string" }, "allowed_export": { "description": "Apply export policy to this peer with an allow list.", @@ -7765,6 +7700,7 @@ } }, "required": [ + "addr", "asn", "port" ], @@ -7784,7 +7720,7 @@ "addresses": { "type": "array", "items": { - "$ref": "#/components/schemas/UplinkAddressConfig" + "$ref": "#/components/schemas/UserSpecifiedUplinkAddressConfig" } }, "autoneg": { @@ -7886,6 +7822,28 @@ ], "additionalProperties": false }, + "UserSpecifiedUplinkAddressConfig": { + "description": "User-specified version of [`sled_agent_types::early_networking::UplinkAddressConfig`].\n\nThis allows us to have a nicer TOML representation of [`UplinkAddress`].", + "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 + }, "Vendor": { "description": "Vendor-specific information about a transceiver module.", "type": "object", From 25ca92c765e0d99da51eff1372806da24f29e7cf Mon Sep 17 00:00:00 2001 From: John Gallagher Date: Mon, 16 Mar 2026 14:18:12 -0400 Subject: [PATCH 09/23] address some TODOs --- nexus/src/app/rack.rs | 11 +++++----- .../versions/src/impls/early_networking.rs | 12 +++++++++++ .../early_networking.rs | 20 ++++++++++++++++++- .../rack_init.rs | 4 +++- .../stronger_bgp_unnumbered_types/uplink.rs | 13 +++++++----- 5 files changed, 47 insertions(+), 13 deletions(-) diff --git a/nexus/src/app/rack.rs b/nexus/src/app/rack.rs index a802fdfe21e..962501f513d 100644 --- a/nexus/src/app/rack.rs +++ b/nexus/src/app/rack.rs @@ -551,7 +551,8 @@ impl super::Nexus { .iter() .map(|a| networking::Address { address_lot: NameOrId::Name(address_lot_name.clone()), - // TODO-john do we want to update the external API? + // TODO-cleanup Extend stronger types out to the external + // API (omiron#9832). address: a .address .ip_net_squashing_link_local_to_unspecified(), @@ -591,11 +592,9 @@ impl super::Nexus { format!("as{}", r.asn).parse().unwrap(), ), interface_name: link_name.clone(), - // TODO-john do we want to update the external API? - addr: match r.addr { - RouterPeerAddress::Unnumbered => None, - RouterPeerAddress::Numbered { ip } => Some(ip.into()), - }, + // TODO-cleanup Extend stronger types out to the external + // API (omiron#9832). + addr: r.addr.ip_squashing_unnumbered_to_none(), hold_time: r.hold_time() as u32, idle_hold_time: r.idle_hold_time() as u32, delay_open: r.delay_open() as u32, diff --git a/sled-agent/types/versions/src/impls/early_networking.rs b/sled-agent/types/versions/src/impls/early_networking.rs index 63e8f608885..bd4ca3ad303 100644 --- a/sled-agent/types/versions/src/impls/early_networking.rs +++ b/sled-agent/types/versions/src/impls/early_networking.rs @@ -177,6 +177,18 @@ impl FromStr for SpecifiedIpAddr { } impl RouterPeerAddress { + /// Squash this address down to an [`Option`] by converting + /// [`RouterPeerAddress::Unnumbered`] to `None`. + /// + /// Uses of this function probably indicate places where we could consider + /// using stronger types. + pub fn ip_squashing_unnumbered_to_none(&self) -> Option { + match *self { + Self::Unnumbered => None, + Self::Numbered { ip } => Some(ip.into()), + } + } + /// Convert an arbitrary [`IpAddr`] into a [`RouterPeerAddress`] by /// converting an unspecified IP to [`RouterPeerAddress::Unnumbered`]. /// diff --git a/sled-agent/types/versions/src/stronger_bgp_unnumbered_types/early_networking.rs b/sled-agent/types/versions/src/stronger_bgp_unnumbered_types/early_networking.rs index 9bc8faa94f4..d75005e75ea 100644 --- a/sled-agent/types/versions/src/stronger_bgp_unnumbered_types/early_networking.rs +++ b/sled-agent/types/versions/src/stronger_bgp_unnumbered_types/early_networking.rs @@ -5,7 +5,25 @@ //! Types for network setup required to bring up the control plane. //! //! Changes in this version: -//! * TODO-john +//! +//! * Introduce [`SpecifiedIpNet`], a newtype wrapper around [`IpNet`] that does +//! not allow unspecified IP addresses. +//! * Introduce [`SpecifiedIpAddr`], a newtype wrapper around [`IpAddr`] that +//! does not allow unspecified IP addresses. +//! * Introduce [`UplinkAddress`], a stronger type for specifying +//! possibly-link-local IP nets. This is the new type of +//! [`UplinkAddressConfig::address`], which was previously an [`IpNet`] where +//! a value with an unspecified IP address was treated as link-local. +//! * Introduce [`RouterPeerAddress`], a stronger type for specifying +//! possibly-unnumbered BGP peer addresses. This is the new type of +//! [`BgpPeerConfig::addr`], which was previously an [`IpAddr`] where an +//! unspecified address was treated as unnumbered. +//! * Update types that transitively contain the newly-updated +//! [`UplinkAddressConfig`] or [`BgpPeerConfig`]: +//! * [`EarlyNetworkConfigBody`] +//! * [`PortConfig`] +//! * [`RackNetworkConfig`] +//! * [`WriteNetworkConfigRequest`] use crate::v1::early_networking as v1; use crate::v20::early_networking as v20; diff --git a/sled-agent/types/versions/src/stronger_bgp_unnumbered_types/rack_init.rs b/sled-agent/types/versions/src/stronger_bgp_unnumbered_types/rack_init.rs index bd247c31a66..deea32d7ec6 100644 --- a/sled-agent/types/versions/src/stronger_bgp_unnumbered_types/rack_init.rs +++ b/sled-agent/types/versions/src/stronger_bgp_unnumbered_types/rack_init.rs @@ -5,7 +5,9 @@ //! Rack initialization types //! //! Changes in this version: -//! * TODO-john +//! * New [`RackInitializeRequest`] to pick up new [`RackNetworkConfig`]. +//! * New [`RackInitializeRequestParams`] to pick up new +//! [`RackInitializeRequest`]. use super::early_networking::RackNetworkConfig; use crate::bootstrap_v1::rack_init::RecoverySiloConfig; diff --git a/sled-agent/types/versions/src/stronger_bgp_unnumbered_types/uplink.rs b/sled-agent/types/versions/src/stronger_bgp_unnumbered_types/uplink.rs index 46faed0e6f3..47f7f1a9958 100644 --- a/sled-agent/types/versions/src/stronger_bgp_unnumbered_types/uplink.rs +++ b/sled-agent/types/versions/src/stronger_bgp_unnumbered_types/uplink.rs @@ -5,9 +5,12 @@ //! Uplink-related types for the Sled Agent API. //! //! Changes in this version: -//! * TODO-john +//! * New [`HostPortConfig`] to pick up new [`UplinkAddressConfig`]. +//! * New [`SwitchPorts`] to pick up new -use crate::v1; +use super::early_networking::UplinkAddressConfig; +use crate::v1::early_networking::LldpPortConfig; +use crate::v1::early_networking::TxEqConfig; use crate::v20; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -31,11 +34,11 @@ pub struct HostPortConfig { /// IP Address and prefix (e.g., `192.168.0.1/16`) to apply to switchport /// (must be in infra_ip pool). May also include an optional VLAN ID. - pub addrs: Vec, + pub addrs: Vec, - pub lldp: Option, + pub lldp: Option, - pub tx_eq: Option, + pub tx_eq: Option, } impl From for HostPortConfig { From 64da05a2b4fee343d8c5f44e26b7d7ae9f5c701b Mon Sep 17 00:00:00 2001 From: John Gallagher Date: Mon, 16 Mar 2026 14:50:50 -0400 Subject: [PATCH 10/23] comment cleanup --- nexus/db-model/src/bgp.rs | 3 ++- nexus/src/app/rack.rs | 4 ++-- .../src/stronger_bgp_unnumbered_types/early_networking.rs | 7 ++++--- .../versions/src/stronger_bgp_unnumbered_types/uplink.rs | 2 +- 4 files changed, 9 insertions(+), 7 deletions(-) diff --git a/nexus/db-model/src/bgp.rs b/nexus/db-model/src/bgp.rs index 0dedaf58829..9096f9019a8 100644 --- a/nexus/db-model/src/bgp.rs +++ b/nexus/db-model/src/bgp.rs @@ -177,7 +177,8 @@ impl TryFrom for BgpPeerConfig { type Error = BgpPeerConfigDataError; fn try_from(value: BgpPeerView) -> Result { - // For unnumbered peers (addr is None), use UNSPECIFIED + // Convert weaker database representation IP address back to a + // strongly-typed `RouterPeerAddress`. // // TODO-cleanup This allows any of three DB values (NULL, `0.0.0.0`, // `::`) to be converted to `RouterPeerAddress::Unnumbered`. Should we diff --git a/nexus/src/app/rack.rs b/nexus/src/app/rack.rs index 962501f513d..23221e7504e 100644 --- a/nexus/src/app/rack.rs +++ b/nexus/src/app/rack.rs @@ -552,7 +552,7 @@ impl super::Nexus { .map(|a| networking::Address { address_lot: NameOrId::Name(address_lot_name.clone()), // TODO-cleanup Extend stronger types out to the external - // API (omiron#9832). + // API (omicron#9832). address: a .address .ip_net_squashing_link_local_to_unspecified(), @@ -593,7 +593,7 @@ impl super::Nexus { ), interface_name: link_name.clone(), // TODO-cleanup Extend stronger types out to the external - // API (omiron#9832). + // API (omicron#9832). addr: r.addr.ip_squashing_unnumbered_to_none(), hold_time: r.hold_time() as u32, idle_hold_time: r.idle_hold_time() as u32, diff --git a/sled-agent/types/versions/src/stronger_bgp_unnumbered_types/early_networking.rs b/sled-agent/types/versions/src/stronger_bgp_unnumbered_types/early_networking.rs index d75005e75ea..e23185bf9d6 100644 --- a/sled-agent/types/versions/src/stronger_bgp_unnumbered_types/early_networking.rs +++ b/sled-agent/types/versions/src/stronger_bgp_unnumbered_types/early_networking.rs @@ -12,8 +12,9 @@ //! does not allow unspecified IP addresses. //! * Introduce [`UplinkAddress`], a stronger type for specifying //! possibly-link-local IP nets. This is the new type of -//! [`UplinkAddressConfig::address`], which was previously an [`IpNet`] where -//! a value with an unspecified IP address was treated as link-local. +//! [`UplinkAddressConfig::address`], which was previously an +//! [`Option`] where both `None` and `Some(UNSPECIFIED)` were treated +//! as link-local. //! * Introduce [`RouterPeerAddress`], a stronger type for specifying //! possibly-unnumbered BGP peer addresses. This is the new type of //! [`BgpPeerConfig::addr`], which was previously an [`IpAddr`] where an @@ -191,7 +192,7 @@ pub struct PortConfig { pub addresses: Vec, /// Switch the port belongs to. pub switch: v1::SwitchSlot, - /// Nmae of the port this config applies to. + /// Name of the port this config applies to. pub port: String, /// Port speed. pub uplink_port_speed: v1::PortSpeed, diff --git a/sled-agent/types/versions/src/stronger_bgp_unnumbered_types/uplink.rs b/sled-agent/types/versions/src/stronger_bgp_unnumbered_types/uplink.rs index 47f7f1a9958..ce6b5fe7c71 100644 --- a/sled-agent/types/versions/src/stronger_bgp_unnumbered_types/uplink.rs +++ b/sled-agent/types/versions/src/stronger_bgp_unnumbered_types/uplink.rs @@ -6,7 +6,7 @@ //! //! Changes in this version: //! * New [`HostPortConfig`] to pick up new [`UplinkAddressConfig`]. -//! * New [`SwitchPorts`] to pick up new +//! * New [`SwitchPorts`] to pick up new [`HostPortConfig`]. use super::early_networking::UplinkAddressConfig; use crate::v1::early_networking::LldpPortConfig; From c696e2f44ec9af53968571e7f4ec9f3e5878f51d Mon Sep 17 00:00:00 2001 From: John Gallagher Date: Mon, 16 Mar 2026 15:03:45 -0400 Subject: [PATCH 11/23] SpecifiedIpNet::addr(): return SpecifiedIpAddr --- nexus/src/app/rack.rs | 1 - sled-agent/src/bootstrap/early_networking.rs | 2 +- .../versions/src/impls/early_networking.rs | 19 ++++++++++++++++--- wicketd/src/rss_config.rs | 4 ++-- 4 files changed, 19 insertions(+), 7 deletions(-) diff --git a/nexus/src/app/rack.rs b/nexus/src/app/rack.rs index 23221e7504e..74b80584094 100644 --- a/nexus/src/app/rack.rs +++ b/nexus/src/app/rack.rs @@ -46,7 +46,6 @@ use sled_agent_client::types::AddSledRequest; use sled_agent_client::types::StartSledAgentRequest; use sled_agent_client::types::StartSledAgentRequestBody; use sled_agent_types::early_networking::LldpAdminStatus; -use sled_agent_types::early_networking::RouterPeerAddress; use sled_hardware_types::BaseboardId; use slog_error_chain::InlineErrorChain; diff --git a/sled-agent/src/bootstrap/early_networking.rs b/sled-agent/src/bootstrap/early_networking.rs index 8eecb58b526..5072e152ff1 100644 --- a/sled-agent/src/bootstrap/early_networking.rs +++ b/sled-agent/src/bootstrap/early_networking.rs @@ -839,7 +839,7 @@ impl<'a> EarlyNetworkSetup<'a> { // TODO We're discarding the `uplink_cidr.prefix()` here and // only using the IP address; at some point we probably need // to give the full CIDR to dendrite? - addrs.push(ip_net.addr()); + addrs.push(ip_net.addr().into()); } } } diff --git a/sled-agent/types/versions/src/impls/early_networking.rs b/sled-agent/types/versions/src/impls/early_networking.rs index bd4ca3ad303..62d5b20e815 100644 --- a/sled-agent/types/versions/src/impls/early_networking.rs +++ b/sled-agent/types/versions/src/impls/early_networking.rs @@ -135,8 +135,21 @@ impl std::fmt::Display for SpecifiedIpAddr { } impl SpecifiedIpNet { - pub const fn addr(&self) -> IpAddr { - self.0.addr() + pub const fn addr(&self) -> SpecifiedIpAddr { + let ip = self.0.addr(); + + // We're bypassing `SpecifiedIpAddr::try_from()` so we can remain a + // `const` function, and because we enforce the same invariants. This + // check documents that fact and provides a runtime guard if we + // accidentally break it. + if ip.is_unspecified() { + panic!( + "SpecifiedIpNet contains an unspecified IP address \ + (this should be impossible!)" + ); + } + + SpecifiedIpAddr(ip) } } @@ -226,7 +239,7 @@ impl UplinkAddress { pub fn addr_squashing_link_local_to_unspecified(&self) -> IpAddr { match self { UplinkAddress::LinkLocal => IpAddr::V6(Ipv6Addr::UNSPECIFIED), - UplinkAddress::Address { ip_net } => ip_net.addr(), + UplinkAddress::Address { ip_net } => ip_net.addr().into(), } } diff --git a/wicketd/src/rss_config.rs b/wicketd/src/rss_config.rs index 95580f60acc..a509b3c04e3 100644 --- a/wicketd/src/rss_config.rs +++ b/wicketd/src/rss_config.rs @@ -643,9 +643,9 @@ fn validate_rack_network_config( // iterate through each port config for (_, _, port_config) in config.iter_uplinks() { for addr in &port_config.addresses { - let addr = match addr.address { + let addr: IpAddr = match addr.address { UplinkAddress::LinkLocal => continue, - UplinkAddress::Address { ip_net } => ip_net.addr(), + UplinkAddress::Address { ip_net } => ip_net.addr().into(), }; // ... and check that it contains `uplink_ip`. if addr < infra_ip_range.first_address() From 87dcf694d9f7d16abc7e9d4cce141072eca3bd8b Mon Sep 17 00:00:00 2001 From: John Gallagher Date: Mon, 16 Mar 2026 15:42:24 -0400 Subject: [PATCH 12/23] better wicket parsing + unit tests --- wicket-common/src/rack_setup.rs | 180 ++++++++++++++++++++++++++++++-- 1 file changed, 172 insertions(+), 8 deletions(-) diff --git a/wicket-common/src/rack_setup.rs b/wicket-common/src/rack_setup.rs index fc3465b01a8..4c75d0075ff 100644 --- a/wicket-common/src/rack_setup.rs +++ b/wicket-common/src/rack_setup.rs @@ -241,7 +241,9 @@ impl UserSpecifiedUplinkAddressConfig { /// string for a nicer TOML representation. mod uplink_address_serde { use super::{UplinkAddress, UserSpecifiedUplinkAddressConfig}; + use oxnet::IpNet; use serde::{Deserialize, Deserializer, Serializer}; + use sled_agent_types::early_networking::SpecifiedIpNet; pub fn serialize( addr: &UplinkAddress, @@ -261,10 +263,23 @@ mod uplink_address_serde { d: D, ) -> Result { let s = String::deserialize(d)?; - if s == UserSpecifiedUplinkAddressConfig::LINK_LOCAL { + if s.eq_ignore_ascii_case(UserSpecifiedUplinkAddressConfig::LINK_LOCAL) + { Ok(UplinkAddress::LinkLocal) } else { - let ip_net = s.parse().map_err(serde::de::Error::custom)?; + let ip_net: IpNet = s.parse().map_err(|_| { + serde::de::Error::custom(format!( + "invalid uplink address `{s}`: \ + expected `link-local` or an IP network", + )) + })?; + let ip_net = SpecifiedIpNet::try_from(ip_net).map_err(|_| { + serde::de::Error::custom(format!( + "invalid uplink address `{s}`: \ + uplink addresses cannot have an unspecified IP; \ + use `link-local` for link local addresses", + )) + })?; Ok(UplinkAddress::Address { ip_net }) } } @@ -346,6 +361,8 @@ pub struct UserSpecifiedBgpPeerConfig { mod bgp_peer_addr_serde { use super::{RouterPeerAddress, UserSpecifiedBgpPeerConfig}; use serde::{Deserialize, Deserializer, Serializer}; + use sled_agent_types::early_networking::SpecifiedIpAddr; + use std::net::IpAddr; pub fn serialize( addr: &RouterPeerAddress, @@ -365,10 +382,22 @@ mod bgp_peer_addr_serde { d: D, ) -> Result { let s = String::deserialize(d)?; - if s == UserSpecifiedBgpPeerConfig::UNNUMBERED_PEER { + if s.eq_ignore_ascii_case(UserSpecifiedBgpPeerConfig::UNNUMBERED_PEER) { Ok(RouterPeerAddress::Unnumbered) } else { - let ip = s.parse().map_err(serde::de::Error::custom)?; + let ip: IpAddr = s.parse().map_err(|_| { + serde::de::Error::custom(format!( + "invalid BGP peer address `{s}`: \ + expected `unnumbered` or an IP address", + )) + })?; + let ip = SpecifiedIpAddr::try_from(ip).map_err(|_| { + serde::de::Error::custom(format!( + "invalid BGP peer address `{s}`: \ + peer address cannot be an unspecified IP; \ + use `unnumbered` for unnumbered peers", + )) + })?; Ok(RouterPeerAddress::Numbered { ip }) } } @@ -710,13 +739,13 @@ mod tests { ]; for input in &inputs { - let input = Wrapper { policy: input.clone() }; + let input = ImportExportPolicyWrapper { policy: input.clone() }; eprintln!("** input: {:?}, testing JSON", input); // Check that serialization to JSON and back works. let serialized = serde_json::to_string(&input).unwrap(); eprintln!("serialized JSON: {serialized}"); - let deserialized: Wrapper = + let deserialized: ImportExportPolicyWrapper = serde_json::from_str(&serialized).unwrap(); assert_eq!(input, deserialized); @@ -724,14 +753,149 @@ mod tests { // Check that serialization to TOML and back works. let serialized = toml::to_string(&input).unwrap(); eprintln!("serialized TOML: {serialized}"); - let deserialized: Wrapper = toml::from_str(&serialized).unwrap(); + let deserialized: ImportExportPolicyWrapper = + toml::from_str(&serialized).unwrap(); assert_eq!(input, deserialized); } } #[derive(Debug, Deserialize, Serialize, PartialEq, Eq)] - struct Wrapper { + struct ImportExportPolicyWrapper { #[serde(default)] policy: UserSpecifiedImportExportPolicy, } + + #[test] + fn roundtrip_router_peer_address() { + let inputs = [ + (RouterPeerAddress::Unnumbered, "unnumbered"), + ( + RouterPeerAddress::Numbered { ip: "1.1.1.1".parse().unwrap() }, + "1.1.1.1", + ), + ( + RouterPeerAddress::Numbered { ip: "ff80::1".parse().unwrap() }, + "ff80::1", + ), + ]; + + for (input, expected_str) in inputs { + let input = RouterPeerAddressWrapper { addr: input }; + + eprintln!("** input: {:?}, testing JSON", input); + // Check that serialization to JSON and back works. + let serialized = serde_json::to_string(&input).unwrap(); + eprintln!("serialized JSON: {serialized}"); + let deserialized: RouterPeerAddressWrapper = + serde_json::from_str(&serialized).unwrap(); + assert_eq!(input, deserialized); + + eprintln!("** input: {:?}, testing TOML", input); + // Check that serialization to TOML and back works. + let serialized = toml::to_string(&input).unwrap(); + eprintln!("serialized TOML: {serialized}"); + let deserialized: RouterPeerAddressWrapper = + toml::from_str(&serialized).unwrap(); + assert_eq!(input, deserialized); + + assert_eq!(serialized, format!("addr = \"{expected_str}\"\n")); + } + } + + #[test] + fn invalid_router_peer_address() { + let invalid_inputs = ["0.0.0.0", "::", "foobar"]; + + for input in invalid_inputs { + let toml_input = format!("addr = \"{input}\"\n"); + match toml::from_str::(&toml_input) { + Ok(addr) => panic!("unexpected success: parsed {addr:?}"), + Err(err) => { + let err = err.to_string(); + assert!( + err.contains(&format!( + "invalid BGP peer address `{input}`" + )), + "unexpected error for input `{input}`: {err}" + ); + } + } + } + } + + #[derive(Debug, Deserialize, Serialize, PartialEq, Eq)] + struct RouterPeerAddressWrapper { + // This attribute matches the one on `UserSpecifiedBgpPeerConfig::addr` + // above. + #[serde(with = "bgp_peer_addr_serde")] + pub addr: RouterPeerAddress, + } + + #[test] + fn roundtrip_uplink_address() { + let inputs = [ + (UplinkAddress::LinkLocal, "link-local"), + ( + UplinkAddress::Address { + ip_net: "1.1.1.0/24".parse().unwrap(), + }, + "1.1.1.0/24", + ), + ( + UplinkAddress::Address { ip_net: "ff80::/64".parse().unwrap() }, + "ff80::/64", + ), + ]; + + for (input, expected_str) in inputs { + let input = UplinkAddressWrapper { addr: input }; + + eprintln!("** input: {:?}, testing JSON", input); + // Check that serialization to JSON and back works. + let serialized = serde_json::to_string(&input).unwrap(); + eprintln!("serialized JSON: {serialized}"); + let deserialized: UplinkAddressWrapper = + serde_json::from_str(&serialized).unwrap(); + assert_eq!(input, deserialized); + + eprintln!("** input: {:?}, testing TOML", input); + // Check that serialization to TOML and back works. + let serialized = toml::to_string(&input).unwrap(); + eprintln!("serialized TOML: {serialized}"); + let deserialized: UplinkAddressWrapper = + toml::from_str(&serialized).unwrap(); + assert_eq!(input, deserialized); + + assert_eq!(serialized, format!("addr = \"{expected_str}\"\n")); + } + } + + #[test] + fn invalid_uplink_address() { + let invalid_inputs = ["0.0.0.0/0", "::/128", "foobar"]; + + for input in invalid_inputs { + let toml_input = format!("addr = \"{input}\"\n"); + match toml::from_str::(&toml_input) { + Ok(addr) => panic!("unexpected success: parsed {addr:?}"), + Err(err) => { + let err = err.to_string(); + assert!( + err.contains(&format!( + "invalid uplink address `{input}`" + )), + "unexpected error for input `{input}`: {err}" + ); + } + } + } + } + + #[derive(Debug, Deserialize, Serialize, PartialEq, Eq)] + struct UplinkAddressWrapper { + // This attribute matches the one on + // `UserSpecifiedUplinkAddressConfig::address` above. + #[serde(with = "uplink_address_serde")] + pub addr: UplinkAddress, + } } From b4ed2a267fbc633f5567f3cfdd20c1edaef864bf Mon Sep 17 00:00:00 2001 From: John Gallagher Date: Mon, 16 Mar 2026 16:23:39 -0400 Subject: [PATCH 13/23] make wicket example config more varied --- wicket-common/src/example.rs | 13 ++++++------- wicket/tests/output/example_non_empty.toml | 4 ++-- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/wicket-common/src/example.rs b/wicket-common/src/example.rs index 161097fb8b9..192a90d6996 100644 --- a/wicket-common/src/example.rs +++ b/wicket-common/src/example.rs @@ -15,7 +15,7 @@ use omicron_common::{ use sled_agent_types::early_networking::{ BgpConfig, BgpPeerConfig, LldpAdminStatus, LldpPortConfig, MaxPathConfig, PortFec, PortSpeed, RouteConfig, RouterLifetimeConfig, RouterPeerAddress, - TxEqConfig, + TxEqConfig, UplinkAddress, }; use sled_hardware_types::Baseboard; @@ -100,9 +100,7 @@ impl ExampleRackSetupData { let switch0_port0_bgp_peers = vec![ UserSpecifiedBgpPeerConfig { asn: 47, - addr: RouterPeerAddress::Numbered { - ip: "10.2.3.4".parse().unwrap(), - }, + addr: RouterPeerAddress::Unnumbered, port: "port0".into(), hold_time: Some(BgpPeerConfig::DEFAULT_HOLD_TIME), idle_hold_time: Some(BgpPeerConfig::DEFAULT_IDLE_HOLD_TIME), @@ -215,9 +213,10 @@ impl ExampleRackSetupData { #[rustfmt::skip] switch0: btreemap! { "port0".to_owned() => UserSpecifiedPortConfig { - addresses: vec![UserSpecifiedUplinkAddressConfig::without_vlan( - "172.30.0.1/24".parse().unwrap(), - )], + addresses: vec![UserSpecifiedUplinkAddressConfig { + address: UplinkAddress::LinkLocal, + vlan_id: Some(1), + }], routes: vec![RouteConfig { destination: "0.0.0.0/0".parse().unwrap(), nexthop: "172.30.0.10".parse().unwrap(), diff --git a/wicket/tests/output/example_non_empty.toml b/wicket/tests/output/example_non_empty.toml index 46879c07eb5..be16c8c7cec 100644 --- a/wicket/tests/output/example_non_empty.toml +++ b/wicket/tests/output/example_non_empty.toml @@ -75,7 +75,7 @@ rack_subnet_address = "fd00:1122:3344:100::" [rack_network_config.switch0.port0] routes = [{ nexthop = "172.30.0.10", destination = "0.0.0.0/0", vlan_id = 1 }] -addresses = [{ address = "172.30.0.1/24" }] +addresses = [{ address = "link-local", vlan_id = 1 }] uplink_port_speed = "speed400_g" uplink_port_fec = "firecode" autoneg = true @@ -83,7 +83,7 @@ autoneg = true [[rack_network_config.switch0.port0.bgp_peers]] asn = 47 port = "port0" -addr = "10.2.3.4" +addr = "unnumbered" hold_time = 6 idle_hold_time = 3 delay_open = 0 From 7aaf0463851b66ee8cde5a5dbe3fc37e3261e001 Mon Sep 17 00:00:00 2001 From: John Gallagher Date: Tue, 17 Mar 2026 14:13:13 -0400 Subject: [PATCH 14/23] fix display of uplink addresses (include subnet) --- sled-agent/types/versions/src/impls/early_networking.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sled-agent/types/versions/src/impls/early_networking.rs b/sled-agent/types/versions/src/impls/early_networking.rs index 62d5b20e815..3572fc9e050 100644 --- a/sled-agent/types/versions/src/impls/early_networking.rs +++ b/sled-agent/types/versions/src/impls/early_networking.rs @@ -281,9 +281,9 @@ impl UplinkAddressConfig { /// Format `self` appropriately for passing to `uplinkd`'s SMF properties. pub fn to_uplinkd_smf_property(&self) -> String { - let addr: &dyn fmt::Display = match self.address { + let addr: &dyn fmt::Display = match &self.address { UplinkAddress::LinkLocal => &"link-local", - UplinkAddress::Address { ip_net } => &ip_net.addr(), + UplinkAddress::Address { ip_net } => ip_net, }; match self.vlan_id { From 7c92647c48ce57376ed86c89717846f957472f8f Mon Sep 17 00:00:00 2001 From: John Gallagher Date: Tue, 17 Mar 2026 14:16:21 -0400 Subject: [PATCH 15/23] constants over string literals --- wicket/src/ui/panes/rack_setup.rs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/wicket/src/ui/panes/rack_setup.rs b/wicket/src/ui/panes/rack_setup.rs index 8fbdd4fba45..60c6f90419e 100644 --- a/wicket/src/ui/panes/rack_setup.rs +++ b/wicket/src/ui/panes/rack_setup.rs @@ -872,7 +872,9 @@ fn rss_config_text<'a>( let addresses = addresses.iter().map(|a| { let UserSpecifiedUplinkAddressConfig { address, vlan_id } = a; let addr_description = match address { - UplinkAddress::LinkLocal => Cow::Borrowed("link-local"), + UplinkAddress::LinkLocal => Cow::Borrowed( + UserSpecifiedUplinkAddressConfig::LINK_LOCAL, + ), UplinkAddress::Address { ip_net } => { Cow::Owned(ip_net.to_string()) } @@ -919,8 +921,12 @@ fn rss_config_text<'a>( } = p; let addr_string = match addr { - RouterPeerAddress::Unnumbered => "unnumbered".to_string(), - RouterPeerAddress::Numbered { ip } => ip.to_string(), + RouterPeerAddress::Unnumbered => Cow::Borrowed( + UserSpecifiedBgpPeerConfig::UNNUMBERED_PEER, + ), + RouterPeerAddress::Numbered { ip } => { + Cow::Owned(ip.to_string()) + } }; let mut lines = vec![ From 8e373711efa45a607f8fdf23333aa57cc96af95c Mon Sep 17 00:00:00 2001 From: John Gallagher Date: Tue, 17 Mar 2026 14:22:17 -0400 Subject: [PATCH 16/23] macro to reduce copy/paste in body -> envelope conversions --- .../src/early_networking/serialization.rs | 76 +++++++------------ 1 file changed, 27 insertions(+), 49 deletions(-) diff --git a/sled-agent/types/src/early_networking/serialization.rs b/sled-agent/types/src/early_networking/serialization.rs index a1aeb27301a..f671cdb2130 100644 --- a/sled-agent/types/src/early_networking/serialization.rs +++ b/sled-agent/types/src/early_networking/serialization.rs @@ -321,55 +321,33 @@ fn deserialize_body( } // We need to be able to construct [`EarlyNetworkConfigEnvelope`]s for every -// version of `EarlyNetworkConfigBody` (starting from the current version of -// `EarlyNetworkConfigBody` when `EarlyNetworkConfigEnvelope` was introduced). +// version of `EarlyNetworkConfigBody` (starting from the version of +// `EarlyNetworkConfigBody` that was current when `EarlyNetworkConfigEnvelope` +// was introduced; i.e., v20). // -// Put those `From` impls here. -impl From<&'_ v20::early_networking::EarlyNetworkConfigBody> - for EarlyNetworkConfigEnvelope -{ - fn from(value: &'_ v20::early_networking::EarlyNetworkConfigBody) -> Self { - Self { - schema_version: - v20::early_networking::EarlyNetworkConfigBody::SCHEMA_VERSION, - // We're serializing in-memory; this can only fail if - // `EarlyNetworkConfigBody` contains types that can't be represented - // as JSON, which (a) should never happen and (b) we should catch - // immediately in tests. - body: serde_json::to_value(value) - .expect("EarlyNetworkConfigBody can be serialized as JSON"), - } - } -} -impl From<&'_ v26::early_networking::EarlyNetworkConfigBody> - for EarlyNetworkConfigEnvelope -{ - fn from(value: &'_ v26::early_networking::EarlyNetworkConfigBody) -> Self { - Self { - schema_version: - v26::early_networking::EarlyNetworkConfigBody::SCHEMA_VERSION, - // We're serializing in-memory; this can only fail if - // `EarlyNetworkConfigBody` contains types that can't be represented - // as JSON, which (a) should never happen and (b) we should catch - // immediately in tests. - body: serde_json::to_value(value) - .expect("EarlyNetworkConfigBody can be serialized as JSON"), - } - } -} -impl From<&'_ v29::early_networking::EarlyNetworkConfigBody> - for EarlyNetworkConfigEnvelope -{ - fn from(value: &'_ v29::early_networking::EarlyNetworkConfigBody) -> Self { - Self { - schema_version: - v29::early_networking::EarlyNetworkConfigBody::SCHEMA_VERSION, - // We're serializing in-memory; this can only fail if - // `EarlyNetworkConfigBody` contains types that can't be represented - // as JSON, which (a) should never happened and (b) we should catch - // immediately in tests. - body: serde_json::to_value(value) - .expect("EarlyNetworkConfigBody can be serialized as JSON"), +// Put those `From` impls here. They're all identical, so we use this macro to +// remain consistent; in particular, we _must_ ensure that we set +// `schema_version` to the `SCHEMA_VERSION` of the specific type we're +// converting from. +macro_rules! from_body_for_envelope { + ($body_type:path) => { + impl From<&'_ $body_type> for EarlyNetworkConfigEnvelope { + fn from(value: &'_ $body_type) -> Self { + Self { + schema_version: <$body_type>::SCHEMA_VERSION, + // We're serializing in-memory; this can only fail if + // the body type contains types that can't be represented + // as JSON, which (a) should never happen and (b) we + // should catch immediately in tests. + body: serde_json::to_value(value).expect(concat!( + stringify!($path), + " can be serialized as JSON" + )), + } + } } - } + }; } +from_body_for_envelope!(v20::early_networking::EarlyNetworkConfigBody); +from_body_for_envelope!(v26::early_networking::EarlyNetworkConfigBody); +from_body_for_envelope!(v29::early_networking::EarlyNetworkConfigBody); From 4aca7005f1795bc192951d02ed11bab5793b82f6 Mon Sep 17 00:00:00 2001 From: John Gallagher Date: Tue, 17 Mar 2026 14:28:50 -0400 Subject: [PATCH 17/23] add test for new early network blob format --- sled-agent/tests/data/early_network_blobs.txt | 1 + .../tests/integration_tests/early_network.rs | 18 +++++++++--------- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/sled-agent/tests/data/early_network_blobs.txt b/sled-agent/tests/data/early_network_blobs.txt index a99bb01ff6c..47b9cbab853 100644 --- a/sled-agent/tests/data/early_network_blobs.txt +++ b/sled-agent/tests/data/early_network_blobs.txt @@ -1,3 +1,4 @@ 2026-01-22 r17,{"generation":114,"schema_version":2,"body":{"ntp_servers":[],"rack_network_config":{"rack_subnet":"fd00:1122:3344:100::/56","infra_ip_first":"172.20.15.21","infra_ip_last":"172.20.15.22","ports":[{"routes":[],"addresses":[],"switch":"switch1","port":"qsfp0","uplink_port_speed":"speed100_g","uplink_port_fec":null,"bgp_peers":[],"autoneg":false,"lldp":null,"tx_eq":null},{"routes":[],"addresses":[],"switch":"switch1","port":"qsfp26","uplink_port_speed":"speed100_g","uplink_port_fec":"rs","bgp_peers":[],"autoneg":false,"lldp":{"status":"disabled","chassis_id":null,"port_id":null,"port_description":null,"system_name":null,"system_description":null,"management_addrs":null},"tx_eq":null},{"routes":[],"addresses":[{"address":"172.20.15.53/29","vlan_id":null}],"switch":"switch1","port":"qsfp18","uplink_port_speed":"speed100_g","uplink_port_fec":"rs","bgp_peers":[{"asn":65002,"port":"qsfp18","addr":"172.20.15.51","hold_time":6,"idle_hold_time":3,"delay_open":3,"connect_retry":3,"keepalive":2,"remote_asn":null,"min_ttl":null,"md5_auth_key":null,"multi_exit_discriminator":null,"communities":[],"local_pref":null,"enforce_first_as":false,"allowed_import":{"type":"no_filtering"},"allowed_export":{"type":"allow","value":["172.20.52.0/22","172.20.26.0/24"]},"vlan_id":null}],"autoneg":false,"lldp":{"status":"disabled","chassis_id":null,"port_id":null,"port_description":null,"system_name":null,"system_description":null,"management_addrs":null},"tx_eq":null},{"routes":[],"addresses":[{"address":"172.20.15.45/29","vlan_id":null}],"switch":"switch0","port":"qsfp18","uplink_port_speed":"speed100_g","uplink_port_fec":"rs","bgp_peers":[{"asn":65002,"port":"qsfp18","addr":"172.20.15.43","hold_time":6,"idle_hold_time":0,"delay_open":3,"connect_retry":3,"keepalive":2,"remote_asn":null,"min_ttl":null,"md5_auth_key":null,"multi_exit_discriminator":null,"communities":[],"local_pref":null,"enforce_first_as":false,"allowed_import":{"type":"no_filtering"},"allowed_export":{"type":"allow","value":["172.20.52.0/22","172.20.26.0/24"]},"vlan_id":null}],"autoneg":false,"lldp":{"status":"disabled","chassis_id":null,"port_id":null,"port_description":null,"system_name":null,"system_description":null,"management_addrs":null},"tx_eq":null},{"routes":[],"addresses":[],"switch":"switch0","port":"qsfp0","uplink_port_speed":"speed100_g","uplink_port_fec":null,"bgp_peers":[],"autoneg":false,"lldp":null,"tx_eq":null},{"routes":[],"addresses":[],"switch":"switch0","port":"qsfp26","uplink_port_speed":"speed100_g","uplink_port_fec":"rs","bgp_peers":[],"autoneg":false,"lldp":{"status":"disabled","chassis_id":null,"port_id":null,"port_description":null,"system_name":null,"system_description":null,"management_addrs":null},"tx_eq":null}],"bgp":[{"asn":65002,"originate":["172.20.52.0/22","172.20.26.0/24"],"shaper":null,"checker":null}],"bfd":[]}}} 2026-02-27 r18,{"schema_version":2,"body":{"ntp_servers":[],"rack_network_config":{"bfd":[],"bgp":[{"asn":65002,"checker":null,"max_paths":1,"originate":["172.20.52.0/22","172.20.26.0/24"],"shaper":null}],"infra_ip_first":"172.20.15.21","infra_ip_last":"172.20.15.22","ports":[{"addresses":[],"autoneg":false,"bgp_peers":[],"lldp":null,"port":"qsfp0","routes":[],"switch":"switch1","tx_eq":null,"uplink_port_fec":null,"uplink_port_speed":"speed100_g"},{"addresses":[],"autoneg":false,"bgp_peers":[],"lldp":{"chassis_id":null,"management_addrs":null,"port_description":null,"port_id":null,"status":"disabled","system_description":null,"system_name":null},"port":"qsfp26","routes":[],"switch":"switch1","tx_eq":null,"uplink_port_fec":"rs","uplink_port_speed":"speed100_g"},{"addresses":[{"address":"172.20.15.53/29","vlan_id":null}],"autoneg":false,"bgp_peers":[{"addr":"172.20.15.51","allowed_export":{"type":"allow","value":["172.20.52.0/22","172.20.26.0/24"]},"allowed_import":{"type":"no_filtering"},"asn":65002,"communities":[],"connect_retry":3,"delay_open":3,"enforce_first_as":false,"hold_time":6,"idle_hold_time":3,"keepalive":2,"local_pref":null,"md5_auth_key":null,"min_ttl":null,"multi_exit_discriminator":null,"port":"qsfp18","remote_asn":null,"router_lifetime":0,"vlan_id":null}],"lldp":{"chassis_id":null,"management_addrs":null,"port_description":null,"port_id":null,"status":"disabled","system_description":null,"system_name":null},"port":"qsfp18","routes":[],"switch":"switch1","tx_eq":null,"uplink_port_fec":"rs","uplink_port_speed":"speed100_g"},{"addresses":[{"address":"172.20.15.45/29","vlan_id":null}],"autoneg":false,"bgp_peers":[{"addr":"172.20.15.43","allowed_export":{"type":"allow","value":["172.20.52.0/22","172.20.26.0/24"]},"allowed_import":{"type":"no_filtering"},"asn":65002,"communities":[],"connect_retry":3,"delay_open":3,"enforce_first_as":false,"hold_time":6,"idle_hold_time":0,"keepalive":2,"local_pref":null,"md5_auth_key":null,"min_ttl":null,"multi_exit_discriminator":null,"port":"qsfp18","remote_asn":null,"router_lifetime":0,"vlan_id":null}],"lldp":{"chassis_id":null,"management_addrs":null,"port_description":null,"port_id":null,"status":"disabled","system_description":null,"system_name":null},"port":"qsfp18","routes":[],"switch":"switch0","tx_eq":null,"uplink_port_fec":"rs","uplink_port_speed":"speed100_g"},{"addresses":[],"autoneg":false,"bgp_peers":[],"lldp":null,"port":"qsfp0","routes":[],"switch":"switch0","tx_eq":null,"uplink_port_fec":null,"uplink_port_speed":"speed100_g"},{"addresses":[],"autoneg":false,"bgp_peers":[],"lldp":{"chassis_id":null,"management_addrs":null,"port_description":null,"port_id":null,"status":"disabled","system_description":null,"system_name":null},"port":"qsfp26","routes":[],"switch":"switch0","tx_eq":null,"uplink_port_fec":"rs","uplink_port_speed":"speed100_g"}],"rack_subnet":"fd00:1122:3344:100::/56"}}} 2026-02-27 pre-r19,{"schema_version":3,"body":{"rack_network_config":{"bfd":[],"bgp":[{"asn":65002,"checker":null,"max_paths":1,"originate":["172.20.52.0/22","172.20.26.0/24"],"shaper":null}],"infra_ip_first":"172.20.15.21","infra_ip_last":"172.20.15.22","ports":[{"addresses":[],"autoneg":false,"bgp_peers":[],"lldp":null,"port":"qsfp0","routes":[],"switch":"switch1","tx_eq":null,"uplink_port_fec":null,"uplink_port_speed":"speed100_g"},{"addresses":[],"autoneg":false,"bgp_peers":[],"lldp":{"chassis_id":null,"management_addrs":null,"port_description":null,"port_id":null,"status":"disabled","system_description":null,"system_name":null},"port":"qsfp26","routes":[],"switch":"switch1","tx_eq":null,"uplink_port_fec":"rs","uplink_port_speed":"speed100_g"},{"addresses":[{"address":"172.20.15.53/29","vlan_id":null}],"autoneg":false,"bgp_peers":[{"addr":"172.20.15.51","allowed_export":{"type":"allow","value":["172.20.52.0/22","172.20.26.0/24"]},"allowed_import":{"type":"no_filtering"},"asn":65002,"communities":[],"connect_retry":3,"delay_open":3,"enforce_first_as":false,"hold_time":6,"idle_hold_time":3,"keepalive":2,"local_pref":null,"md5_auth_key":null,"min_ttl":null,"multi_exit_discriminator":null,"port":"qsfp18","remote_asn":null,"router_lifetime":0,"vlan_id":null}],"lldp":{"chassis_id":null,"management_addrs":null,"port_description":null,"port_id":null,"status":"disabled","system_description":null,"system_name":null},"port":"qsfp18","routes":[],"switch":"switch1","tx_eq":null,"uplink_port_fec":"rs","uplink_port_speed":"speed100_g"},{"addresses":[{"address":"172.20.15.45/29","vlan_id":null}],"autoneg":false,"bgp_peers":[{"addr":"172.20.15.43","allowed_export":{"type":"allow","value":["172.20.52.0/22","172.20.26.0/24"]},"allowed_import":{"type":"no_filtering"},"asn":65002,"communities":[],"connect_retry":3,"delay_open":3,"enforce_first_as":false,"hold_time":6,"idle_hold_time":0,"keepalive":2,"local_pref":null,"md5_auth_key":null,"min_ttl":null,"multi_exit_discriminator":null,"port":"qsfp18","remote_asn":null,"router_lifetime":0,"vlan_id":null}],"lldp":{"chassis_id":null,"management_addrs":null,"port_description":null,"port_id":null,"status":"disabled","system_description":null,"system_name":null},"port":"qsfp18","routes":[],"switch":"switch0","tx_eq":null,"uplink_port_fec":"rs","uplink_port_speed":"speed100_g"},{"addresses":[],"autoneg":false,"bgp_peers":[],"lldp":null,"port":"qsfp0","routes":[],"switch":"switch0","tx_eq":null,"uplink_port_fec":null,"uplink_port_speed":"speed100_g"},{"addresses":[],"autoneg":false,"bgp_peers":[],"lldp":{"chassis_id":null,"management_addrs":null,"port_description":null,"port_id":null,"status":"disabled","system_description":null,"system_name":null},"port":"qsfp26","routes":[],"switch":"switch0","tx_eq":null,"uplink_port_fec":"rs","uplink_port_speed":"speed100_g"}],"rack_subnet":"fd00:1122:3344:100::/56"}}} +2026-03-17 pre-r19,{"schema_version":4,"body":{"rack_network_config":{"bfd":[],"bgp":[{"asn":65002,"checker":null,"max_paths":1,"originate":["172.20.52.0/22","172.20.26.0/24"],"shaper":null}],"infra_ip_first":"172.20.15.21","infra_ip_last":"172.20.15.22","ports":[{"addresses":[],"autoneg":false,"bgp_peers":[],"lldp":null,"port":"qsfp0","routes":[],"switch":"switch1","tx_eq":null,"uplink_port_fec":null,"uplink_port_speed":"speed100_g"},{"addresses":[],"autoneg":false,"bgp_peers":[],"lldp":{"chassis_id":null,"management_addrs":null,"port_description":null,"port_id":null,"status":"disabled","system_description":null,"system_name":null},"port":"qsfp26","routes":[],"switch":"switch1","tx_eq":null,"uplink_port_fec":"rs","uplink_port_speed":"speed100_g"},{"addresses":[{"address":{"type":"link_local"},"vlan_id":1}],"autoneg":false,"bgp_peers":[{"addr":{"type":"unnumbered"},"allowed_export":{"type":"allow","value":["172.20.52.0/22","172.20.26.0/24"]},"allowed_import":{"type":"no_filtering"},"asn":65002,"communities":[],"connect_retry":3,"delay_open":3,"enforce_first_as":false,"hold_time":6,"idle_hold_time":3,"keepalive":2,"local_pref":null,"md5_auth_key":null,"min_ttl":null,"multi_exit_discriminator":null,"port":"qsfp18","remote_asn":null,"router_lifetime":0,"vlan_id":1}],"lldp":{"chassis_id":null,"management_addrs":null,"port_description":null,"port_id":null,"status":"disabled","system_description":null,"system_name":null},"port":"qsfp18","routes":[],"switch":"switch1","tx_eq":null,"uplink_port_fec":"rs","uplink_port_speed":"speed100_g"},{"addresses":[{"address":{"ip_net":"172.20.15.45/29","type":"address"},"vlan_id":null}],"autoneg":false,"bgp_peers":[{"addr":{"ip":"172.20.15.43","type":"numbered"},"allowed_export":{"type":"allow","value":["172.20.52.0/22","172.20.26.0/24"]},"allowed_import":{"type":"no_filtering"},"asn":65002,"communities":[],"connect_retry":3,"delay_open":3,"enforce_first_as":false,"hold_time":6,"idle_hold_time":0,"keepalive":2,"local_pref":null,"md5_auth_key":null,"min_ttl":null,"multi_exit_discriminator":null,"port":"qsfp18","remote_asn":null,"router_lifetime":0,"vlan_id":null}],"lldp":{"chassis_id":null,"management_addrs":null,"port_description":null,"port_id":null,"status":"disabled","system_description":null,"system_name":null},"port":"qsfp18","routes":[],"switch":"switch0","tx_eq":null,"uplink_port_fec":"rs","uplink_port_speed":"speed100_g"},{"addresses":[],"autoneg":false,"bgp_peers":[],"lldp":null,"port":"qsfp0","routes":[],"switch":"switch0","tx_eq":null,"uplink_port_fec":null,"uplink_port_speed":"speed100_g"},{"addresses":[],"autoneg":false,"bgp_peers":[],"lldp":{"chassis_id":null,"management_addrs":null,"port_description":null,"port_id":null,"status":"disabled","system_description":null,"system_name":null},"port":"qsfp26","routes":[],"switch":"switch0","tx_eq":null,"uplink_port_fec":"rs","uplink_port_speed":"speed100_g"}],"rack_subnet":"fd00:1122:3344:100::/56"}}} diff --git a/sled-agent/tests/integration_tests/early_network.rs b/sled-agent/tests/integration_tests/early_network.rs index 74ad99d8fe9..bf0f5e3c93e 100644 --- a/sled-agent/tests/integration_tests/early_network.rs +++ b/sled-agent/tests/integration_tests/early_network.rs @@ -10,7 +10,8 @@ use sled_agent_types::early_networking::{ BgpConfig, BgpPeerConfig, EarlyNetworkConfigBody, EarlyNetworkConfigEnvelope, ImportExportPolicy, LldpAdminStatus, LldpPortConfig, MaxPathConfig, PortConfig, PortFec, PortSpeed, - RackNetworkConfig, RouterPeerAddress, SwitchSlot, UplinkAddressConfig, + RackNetworkConfig, RouterPeerAddress, SwitchSlot, UplinkAddress, + UplinkAddressConfig, }; const BLOB_PATH: &str = "tests/data/early_network_blobs.txt"; @@ -123,7 +124,7 @@ fn early_network_blobs_deserialize() { /// future, older blobs can still be deserialized correctly. fn current_config_example() -> (&'static str, EarlyNetworkConfigEnvelope) { // NOTE: the description must not contain commas or newlines. - let description = "2026-02-27 pre-r19"; + let description = "2026-03-17 pre-r19"; let config = EarlyNetworkConfigEnvelope::from(&EarlyNetworkConfigBody { rack_network_config: RackNetworkConfig { rack_subnet: "fd00:1122:3344:100::/56".parse().unwrap(), @@ -164,9 +165,10 @@ fn current_config_example() -> (&'static str, EarlyNetworkConfigEnvelope) { }, PortConfig { routes: vec![], - addresses: vec![UplinkAddressConfig::without_vlan( - "172.20.15.53/29".parse().unwrap(), - )], + addresses: vec![UplinkAddressConfig { + address: UplinkAddress::LinkLocal, + vlan_id: Some(1), + }], switch: SwitchSlot::Switch1, port: "qsfp18".to_owned(), uplink_port_speed: PortSpeed::Speed100G, @@ -174,9 +176,7 @@ fn current_config_example() -> (&'static str, EarlyNetworkConfigEnvelope) { bgp_peers: vec![BgpPeerConfig { asn: 65002, port: "qsfp18".to_owned(), - addr: RouterPeerAddress::Numbered { - ip: "172.20.15.51".parse().unwrap(), - }, + addr: RouterPeerAddress::Unnumbered, hold_time: Some(6), idle_hold_time: Some(3), delay_open: Some(3), @@ -194,7 +194,7 @@ fn current_config_example() -> (&'static str, EarlyNetworkConfigEnvelope) { "172.20.52.0/22".parse().unwrap(), "172.20.26.0/24".parse().unwrap(), ]), - vlan_id: None, + vlan_id: Some(1), router_lifetime: Default::default(), }], autoneg: false, From 1f0336ba0fe2fc954747e111f7f0a05c1413b2be Mon Sep 17 00:00:00 2001 From: John Gallagher Date: Tue, 17 Mar 2026 14:31:32 -0400 Subject: [PATCH 18/23] fix test RSS TOML files --- smf/sled-agent/gimlet-standalone/config-rss.toml | 2 +- smf/sled-agent/non-gimlet/config-rss.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/smf/sled-agent/gimlet-standalone/config-rss.toml b/smf/sled-agent/gimlet-standalone/config-rss.toml index e1c8dc3e3b2..1982f710394 100644 --- a/smf/sled-agent/gimlet-standalone/config-rss.toml +++ b/smf/sled-agent/gimlet-standalone/config-rss.toml @@ -102,7 +102,7 @@ bgp = [] # Routes associated with this port. routes = [{nexthop = "192.168.1.199", destination = "0.0.0.0/0"}] # Addresses associated with this port. -addresses = [{address = "192.168.1.30/32"}] +addresses = [{address = {type = "address", ip_net = "192.168.1.30/32"}}] # Name of the uplink port. This should always be "qsfp0" when using softnpu. port = "qsfp0" # The speed of this port. diff --git a/smf/sled-agent/non-gimlet/config-rss.toml b/smf/sled-agent/non-gimlet/config-rss.toml index cd42d00635d..0fa1080c005 100644 --- a/smf/sled-agent/non-gimlet/config-rss.toml +++ b/smf/sled-agent/non-gimlet/config-rss.toml @@ -102,7 +102,7 @@ bgp = [] # Routes associated with this port. routes = [{nexthop = "192.168.1.199", destination = "0.0.0.0/0"}] # Addresses associated with this port. -addresses = [{address = "192.168.1.30/24"}] +addresses = [{address = {type = "address", ip_net = "192.168.1.30/24"}}] # Name of the uplink port. This should always be "qsfp0" when using softnpu. port = "qsfp0" # The speed of this port. From 902f7e413deadaf7471746a63b8f68ade64a5757 Mon Sep 17 00:00:00 2001 From: John Gallagher Date: Tue, 17 Mar 2026 14:56:25 -0400 Subject: [PATCH 19/23] add proptests for specified IP parsing and deserialization --- Cargo.lock | 2 + sled-agent/types/versions/Cargo.toml | 2 + .../versions/src/impls/early_networking.rs | 101 ++++++++++++++++++ 3 files changed, 105 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index a6924fc646d..4405efc1f12 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13462,6 +13462,7 @@ name = "sled-agent-types-versions" version = "0.1.0" dependencies = [ "anyhow", + "assert_matches", "async-trait", "bootstore", "camino", @@ -13491,6 +13492,7 @@ dependencies = [ "strum 0.27.2", "test-strategy", "thiserror 2.0.18", + "toml 0.8.23", "trust-quorum-types-versions", "tufaceous-artifact", "uuid", diff --git a/sled-agent/types/versions/Cargo.toml b/sled-agent/types/versions/Cargo.toml index 2dd8b8792b3..ab34b4da425 100644 --- a/sled-agent/types/versions/Cargo.toml +++ b/sled-agent/types/versions/Cargo.toml @@ -42,9 +42,11 @@ tufaceous-artifact.workspace = true uuid.workspace = true [dev-dependencies] +assert_matches.workspace = true omicron-test-utils.workspace = true proptest.workspace = true test-strategy.workspace = true +toml.workspace = true [features] testing = ["dep:proptest", "dep:test-strategy"] diff --git a/sled-agent/types/versions/src/impls/early_networking.rs b/sled-agent/types/versions/src/impls/early_networking.rs index 3572fc9e050..3ec1d0b7a42 100644 --- a/sled-agent/types/versions/src/impls/early_networking.rs +++ b/sled-agent/types/versions/src/impls/early_networking.rs @@ -353,3 +353,104 @@ impl fmt::Display for PortFec { } } } + +#[cfg(test)] +mod tests { + use super::*; + use assert_matches::assert_matches; + use serde::{Deserialize, Serialize}; + use test_strategy::proptest; + + #[proptest] + fn test_specified_ip_parsing(ip: IpAddr) { + // Test both SpecifiedIpAddr and SpecifiedIpNet; we don't bother + // proptesting the network side of `IpNet` because that's not relevant + // to any of our specific parsing. + let ip_net = IpNet::new(ip, 24).unwrap(); + let ip_string = ip.to_string(); + let ip_net_string = ip_net.to_string(); + let ip_result = ip_string.parse::(); + let ip_net_result = ip_net_string.parse::(); + + if ip.is_unspecified() { + assert_matches!( + ip_result, + Err(SpecifiedIpAddrParseError::UnspecifiedIpError( + UnspecifiedIpError + )) + ); + assert_matches!( + ip_net_result, + Err(SpecifiedIpNetParseError::UnspecifiedIpError( + UnspecifiedIpError + )) + ); + } else { + let parsed_ip = ip_result.expect("parsing succeeded"); + assert_eq!(parsed_ip.0, ip); + let parsed_ip_net = ip_net_result.expect("parsing succeeded"); + assert_eq!(parsed_ip_net.0, ip_net); + } + } + + #[proptest] + fn test_specified_ip_serialization(ip: IpAddr) { + // Test both SpecifiedIpAddr and SpecifiedIpNet; we don't bother + // proptesting the network side of `IpNet` because that's not relevant + // to any of our specific serialization. + #[derive(Debug, Serialize, Deserialize)] + struct PlainWrapper { + ip: IpAddr, + ip_net: IpNet, + } + #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] + struct SpecifiedWrapper { + ip: SpecifiedIpAddr, + ip_net: SpecifiedIpNet, + } + + let ip_net = IpNet::new(ip, 24).unwrap(); + let plain_wrapped = PlainWrapper { ip, ip_net }; + + let jsonified = serde_json::to_string(&plain_wrapped).unwrap(); + let tomlified = toml::to_string(&plain_wrapped).unwrap(); + + if ip.is_unspecified() { + // We should fail to deserialize unspecified IPs... + let json_result = + serde_json::from_str::(&jsonified); + let toml_result = toml::from_str::(&tomlified); + assert_matches!( + json_result, + Err(err) + if err + .to_string() + .contains("must not be the unspecified address") + ); + assert_matches!( + toml_result, + Err(err) + if err + .to_string() + .contains("must not be the unspecified address") + ); + } else { + // ... but successfully deserialize specified ones. And our + // serialized form should exactly match the wrapped types. + let json_result = + serde_json::from_str::(&jsonified) + .expect("deserialized"); + let toml_result = toml::from_str::(&tomlified) + .expect("deserialized"); + + assert_eq!(json_result, toml_result); + assert_eq!(json_result.ip.0, ip); + assert_eq!(json_result.ip_net.0, ip_net); + + let jsonified2 = serde_json::to_string(&json_result).unwrap(); + let tomlified2 = toml::to_string(&json_result).unwrap(); + assert_eq!(jsonified, jsonified2); + assert_eq!(tomlified, tomlified2); + } + } +} From 3a44f891da0cfdf67496ae3ade9602c845033d54 Mon Sep 17 00:00:00 2001 From: John Gallagher Date: Tue, 17 Mar 2026 20:49:32 -0400 Subject: [PATCH 20/23] add test for `UplinkAddress::to_uplinkd_smf_property()` --- .../versions/src/impls/early_networking.rs | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/sled-agent/types/versions/src/impls/early_networking.rs b/sled-agent/types/versions/src/impls/early_networking.rs index 3ec1d0b7a42..f656af362f4 100644 --- a/sled-agent/types/versions/src/impls/early_networking.rs +++ b/sled-agent/types/versions/src/impls/early_networking.rs @@ -358,9 +358,47 @@ impl fmt::Display for PortFec { mod tests { use super::*; use assert_matches::assert_matches; + use oxnet::Ipv4Net; use serde::{Deserialize, Serialize}; use test_strategy::proptest; + #[test] + fn test_uplink_smf_property_formatting() { + for (address, expected_addr) in [ + ( + UplinkAddress::Address { + ip_net: SpecifiedIpNet::try_from(IpNet::V6( + Ipv6Net::new("ff80::123".parse().unwrap(), 16).unwrap(), + )) + .unwrap(), + }, + "ff80::123/16", + ), + ( + UplinkAddress::Address { + ip_net: SpecifiedIpNet::try_from(IpNet::V4( + Ipv4Net::new("10.0.0.1".parse().unwrap(), 8).unwrap(), + )) + .unwrap(), + }, + "10.0.0.1/8", + ), + (UplinkAddress::LinkLocal, "link-local"), + ] { + for (vlan_id, expected_vlan) in + [(Some(1), ";1"), (Some(1234), ";1234"), (None, "")] + { + let config = UplinkAddressConfig { address, vlan_id }; + let expected = format!("{expected_addr}{expected_vlan}"); + assert_eq!( + config.to_uplinkd_smf_property(), + expected, + "unexpected SMF property for {config:?}" + ); + } + } + } + #[proptest] fn test_specified_ip_parsing(ip: IpAddr) { // Test both SpecifiedIpAddr and SpecifiedIpNet; we don't bother From f5449adbfe710893ffcf3490bbdb85bba9d47af9 Mon Sep 17 00:00:00 2001 From: John Gallagher Date: Tue, 17 Mar 2026 21:07:27 -0400 Subject: [PATCH 21/23] add tests for type conversion round trips --- .../early_networking.rs | 145 ++++++++++++++++++ 1 file changed, 145 insertions(+) diff --git a/sled-agent/types/versions/src/stronger_bgp_unnumbered_types/early_networking.rs b/sled-agent/types/versions/src/stronger_bgp_unnumbered_types/early_networking.rs index e23185bf9d6..fbd074fed65 100644 --- a/sled-agent/types/versions/src/stronger_bgp_unnumbered_types/early_networking.rs +++ b/sled-agent/types/versions/src/stronger_bgp_unnumbered_types/early_networking.rs @@ -439,3 +439,148 @@ pub struct WriteNetworkConfigRequest { pub generation: u64, pub body: EarlyNetworkConfigBody, } + +#[cfg(test)] +mod tests { + use std::net::Ipv4Addr; + + use crate::v20::early_networking::RouterLifetimeConfig; + + use super::*; + + #[test] + fn test_uplink_address_conversions() { + // Confirm we can convert old -> new -> old. In some cases the `new -> + // old` produces an `old` that isn't quite the same; we fill in + // `expected_old` in those cases. Specifically: `old` could contain an + // address of `None`, `Some("0.0.0.0/x")`, or `Some("::/x")`, which will + // always come back as `None` after the round trip. + for (old, new, expected_old) in [ + ( + Some("10.0.0.0/8".parse::().unwrap()), + UplinkAddress::Address { + ip_net: "10.0.0.0/8".parse().unwrap(), + }, + None, + ), + ( + Some("fe80:1234::/64".parse::().unwrap()), + UplinkAddress::Address { + ip_net: "fe80:1234::/64".parse().unwrap(), + }, + None, + ), + (None, UplinkAddress::LinkLocal, None), + ( + Some("0.0.0.0/8".parse::().unwrap()), + UplinkAddress::LinkLocal, + Some(None), + ), + ( + Some("::/128".parse::().unwrap()), + UplinkAddress::LinkLocal, + Some(None), + ), + ] { + for vlan_id in [Some(1234), None] { + let expected_old = expected_old.unwrap_or(old); + let old = v20::UplinkAddressConfig { address: old, vlan_id }; + let new = UplinkAddressConfig { address: new, vlan_id }; + let expected_old = + v20::UplinkAddressConfig { address: expected_old, vlan_id }; + + assert_eq!(UplinkAddressConfig::from(old), new); + assert_eq!(v20::UplinkAddressConfig::from(new), expected_old); + } + } + } + + #[test] + fn test_router_peer_address_conversions() { + fn make_new_bgp_peer_config(addr: RouterPeerAddress) -> BgpPeerConfig { + BgpPeerConfig { + asn: 1, + port: "port".to_owned(), + addr, + hold_time: None, + idle_hold_time: None, + delay_open: None, + connect_retry: None, + keepalive: None, + remote_asn: None, + min_ttl: None, + md5_auth_key: None, + multi_exit_discriminator: None, + communities: Vec::new(), + local_pref: None, + enforce_first_as: false, + allowed_import: v1::ImportExportPolicy::NoFiltering, + allowed_export: v1::ImportExportPolicy::NoFiltering, + vlan_id: None, + router_lifetime: RouterLifetimeConfig::default(), + } + } + fn make_old_bgp_peer_config(addr: IpAddr) -> v20::BgpPeerConfig { + v20::BgpPeerConfig { + asn: 1, + port: "port".to_owned(), + addr, + hold_time: None, + idle_hold_time: None, + delay_open: None, + connect_retry: None, + keepalive: None, + remote_asn: None, + min_ttl: None, + md5_auth_key: None, + multi_exit_discriminator: None, + communities: Vec::new(), + local_pref: None, + enforce_first_as: false, + allowed_import: v1::ImportExportPolicy::NoFiltering, + allowed_export: v1::ImportExportPolicy::NoFiltering, + vlan_id: None, + router_lifetime: RouterLifetimeConfig::default(), + } + } + + // Confirm we can convert old -> new -> old. In some cases the `new -> + // old` produces an `old` that isn't quite the same; we fill in + // `expected_old` in those cases. Specifically: `old` could contain an + // address of `0.0.0.0` or `::`; either will come back as `::` after the + // round trip. + for (old, new, expected_old) in [ + ( + "10.0.0.1".parse::().unwrap(), + RouterPeerAddress::Numbered { ip: "10.0.0.1".parse().unwrap() }, + None, + ), + ( + "fe80:1234::3".parse().unwrap(), + RouterPeerAddress::Numbered { + ip: "fe80:1234::3".parse().unwrap(), + }, + None, + ), + ( + IpAddr::V4(Ipv4Addr::UNSPECIFIED), + RouterPeerAddress::Unnumbered, + Some(IpAddr::V6(Ipv6Addr::UNSPECIFIED)), + ), + ( + IpAddr::V6(Ipv6Addr::UNSPECIFIED), + RouterPeerAddress::Unnumbered, + None, + ), + ] { + let expected_old = expected_old.unwrap_or(old); + + let old = make_old_bgp_peer_config(old); + let new = make_new_bgp_peer_config(new); + let expected_old = make_old_bgp_peer_config(expected_old); + + assert_eq!(BgpPeerConfig::from(old), new); + assert_eq!(v20::BgpPeerConfig::from(new), expected_old); + } + } +} From 039ae868854569ff6b0a675ee3021ac3eca868b8 Mon Sep 17 00:00:00 2001 From: John Gallagher Date: Tue, 17 Mar 2026 21:12:06 -0400 Subject: [PATCH 22/23] openapi --- openapi/bootstrap-agent-lockstep.json | 2 +- openapi/nexus-lockstep.json | 2 +- ...d-agent-29.0.0-7b533d.json => sled-agent-29.0.0-e876bb.json} | 2 +- openapi/sled-agent/sled-agent-latest.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) rename openapi/sled-agent/{sled-agent-29.0.0-7b533d.json => sled-agent-29.0.0-e876bb.json} (99%) diff --git a/openapi/bootstrap-agent-lockstep.json b/openapi/bootstrap-agent-lockstep.json index c1e37dc0d5e..c9837663a5c 100644 --- a/openapi/bootstrap-agent-lockstep.json +++ b/openapi/bootstrap-agent-lockstep.json @@ -780,7 +780,7 @@ ] }, "port": { - "description": "Nmae of the port this config applies to.", + "description": "Name of the port this config applies to.", "type": "string" }, "routes": { diff --git a/openapi/nexus-lockstep.json b/openapi/nexus-lockstep.json index d17b3acc562..148979e49d1 100644 --- a/openapi/nexus-lockstep.json +++ b/openapi/nexus-lockstep.json @@ -7733,7 +7733,7 @@ ] }, "port": { - "description": "Nmae of the port this config applies to.", + "description": "Name of the port this config applies to.", "type": "string" }, "routes": { diff --git a/openapi/sled-agent/sled-agent-29.0.0-7b533d.json b/openapi/sled-agent/sled-agent-29.0.0-e876bb.json similarity index 99% rename from openapi/sled-agent/sled-agent-29.0.0-7b533d.json rename to openapi/sled-agent/sled-agent-29.0.0-e876bb.json index a2f7279b6fd..afd6048cd90 100644 --- a/openapi/sled-agent/sled-agent-29.0.0-7b533d.json +++ b/openapi/sled-agent/sled-agent-29.0.0-e876bb.json @@ -8042,7 +8042,7 @@ ] }, "port": { - "description": "Nmae of the port this config applies to.", + "description": "Name of the port this config applies to.", "type": "string" }, "routes": { diff --git a/openapi/sled-agent/sled-agent-latest.json b/openapi/sled-agent/sled-agent-latest.json index 1fdf276b0e6..77423df7543 120000 --- a/openapi/sled-agent/sled-agent-latest.json +++ b/openapi/sled-agent/sled-agent-latest.json @@ -1 +1 @@ -sled-agent-29.0.0-7b533d.json \ No newline at end of file +sled-agent-29.0.0-e876bb.json \ No newline at end of file From 8c72b8347bfbc05e2bb7d07d4b5b37ab84e589fc Mon Sep 17 00:00:00 2001 From: John Gallagher Date: Tue, 17 Mar 2026 21:19:34 -0400 Subject: [PATCH 23/23] typo --- sled-agent/types/src/early_networking/serialization.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sled-agent/types/src/early_networking/serialization.rs b/sled-agent/types/src/early_networking/serialization.rs index f671cdb2130..759bc5825b4 100644 --- a/sled-agent/types/src/early_networking/serialization.rs +++ b/sled-agent/types/src/early_networking/serialization.rs @@ -340,7 +340,7 @@ macro_rules! from_body_for_envelope { // as JSON, which (a) should never happen and (b) we // should catch immediately in tests. body: serde_json::to_value(value).expect(concat!( - stringify!($path), + stringify!($body_type), " can be serialized as JSON" )), }