diff --git a/Cargo.lock b/Cargo.lock index 5df23e97b3a..40a6ede3b87 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1594,7 +1594,7 @@ dependencies = [ [[package]] name = "client-common" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/maghemite?rev=ce015cde17a1ebc839ae482026d522cf0e369a3b#ce015cde17a1ebc839ae482026d522cf0e369a3b" +source = "git+https://github.com/oxidecomputer/maghemite?rev=bf831acede9b125a854bfda5615ae9248d1e2fa8#bf831acede9b125a854bfda5615ae9248d1e2fa8" [[package]] name = "clipboard-win" @@ -1703,7 +1703,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] @@ -2651,7 +2651,7 @@ dependencies = [ [[package]] name = "ddm-admin-client" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/maghemite?rev=ce015cde17a1ebc839ae482026d522cf0e369a3b#ce015cde17a1ebc839ae482026d522cf0e369a3b" +source = "git+https://github.com/oxidecomputer/maghemite?rev=bf831acede9b125a854bfda5615ae9248d1e2fa8#bf831acede9b125a854bfda5615ae9248d1e2fa8" dependencies = [ "ddm-api-types-versions", "oxnet", @@ -2665,7 +2665,7 @@ dependencies = [ [[package]] name = "ddm-api-types-versions" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/maghemite?rev=ce015cde17a1ebc839ae482026d522cf0e369a3b#ce015cde17a1ebc839ae482026d522cf0e369a3b" +source = "git+https://github.com/oxidecomputer/maghemite?rev=bf831acede9b125a854bfda5615ae9248d1e2fa8#bf831acede9b125a854bfda5615ae9248d1e2fa8" dependencies = [ "ddm-protocol", "oxnet", @@ -2678,7 +2678,7 @@ dependencies = [ [[package]] name = "ddm-protocol" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/maghemite?rev=ce015cde17a1ebc839ae482026d522cf0e369a3b#ce015cde17a1ebc839ae482026d522cf0e369a3b" +source = "git+https://github.com/oxidecomputer/maghemite?rev=bf831acede9b125a854bfda5615ae9248d1e2fa8#bf831acede9b125a854bfda5615ae9248d1e2fa8" dependencies = [ "oxnet", "schemars 0.8.22", @@ -3841,7 +3841,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -5393,7 +5393,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.3", + "socket2 0.5.10", "system-configuration", "tokio", "tower-layer", @@ -6091,7 +6091,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi 0.5.2", "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -6905,7 +6905,7 @@ dependencies = [ [[package]] name = "mg-admin-client" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/maghemite?rev=ce015cde17a1ebc839ae482026d522cf0e369a3b#ce015cde17a1ebc839ae482026d522cf0e369a3b" +source = "git+https://github.com/oxidecomputer/maghemite?rev=bf831acede9b125a854bfda5615ae9248d1e2fa8#bf831acede9b125a854bfda5615ae9248d1e2fa8" dependencies = [ "chrono", "client-common", @@ -6925,7 +6925,7 @@ dependencies = [ [[package]] name = "mg-api-types" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/maghemite?rev=ce015cde17a1ebc839ae482026d522cf0e369a3b#ce015cde17a1ebc839ae482026d522cf0e369a3b" +source = "git+https://github.com/oxidecomputer/maghemite?rev=bf831acede9b125a854bfda5615ae9248d1e2fa8#bf831acede9b125a854bfda5615ae9248d1e2fa8" dependencies = [ "mg-api-types-versions", ] @@ -6933,9 +6933,10 @@ dependencies = [ [[package]] name = "mg-api-types-versions" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/maghemite?rev=ce015cde17a1ebc839ae482026d522cf0e369a3b#ce015cde17a1ebc839ae482026d522cf0e369a3b" +source = "git+https://github.com/oxidecomputer/maghemite?rev=bf831acede9b125a854bfda5615ae9248d1e2fa8#bf831acede9b125a854bfda5615ae9248d1e2fa8" dependencies = [ "chrono", + "client-common", "nom 8.0.0", "num_enum 0.7.6", "oxnet", @@ -8503,7 +8504,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -8649,7 +8650,7 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" dependencies = [ - "proc-macro-crate 3.4.0", + "proc-macro-crate 1.3.1", "proc-macro2", "quote", "syn 2.0.117", @@ -12138,9 +12139,9 @@ dependencies = [ "cfg_aliases 0.2.1", "libc", "once_cell", - "socket2 0.6.3", + "socket2 0.5.10", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -13165,7 +13166,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -13606,9 +13607,9 @@ checksum = "1bc711410fbe7399f390ca1c3b60ad0f53f80e95c5eb935e52268a0e2cd49acc" [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -13656,22 +13657,22 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.3", ] [[package]] @@ -14870,7 +14871,7 @@ version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1c97747dbf44bb1ca44a561ece23508e99cb592e862f22222dcf42f51d1e451" dependencies = [ - "heck 0.5.0", + "heck 0.4.1", "proc-macro2", "quote", "syn 2.0.117", @@ -15422,6 +15423,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "sync-ptr" version = "0.1.4" @@ -15594,7 +15606,7 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8c27177b12a6399ffc08b98f76f7c9a1f4fe9fc967c784c5a071fa8d93cf7e1" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -17896,7 +17908,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 4a0dde6330e..4cf26e9255a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -629,8 +629,8 @@ ntp-admin-api = { path = "ntp-admin/api" } ntp-admin-client = { path = "clients/ntp-admin-client" } ntp-admin-types = { path = "ntp-admin/types" } ntp-admin-types-versions = { path = "ntp-admin/types/versions" } -mg-admin-client = { git = "https://github.com/oxidecomputer/maghemite", rev = "ce015cde17a1ebc839ae482026d522cf0e369a3b" } -ddm-admin-client = { git = "https://github.com/oxidecomputer/maghemite", rev = "ce015cde17a1ebc839ae482026d522cf0e369a3b" } +mg-admin-client = { git = "https://github.com/oxidecomputer/maghemite", rev = "bf831acede9b125a854bfda5615ae9248d1e2fa8" } +ddm-admin-client = { git = "https://github.com/oxidecomputer/maghemite", rev = "bf831acede9b125a854bfda5615ae9248d1e2fa8" } multimap = "0.10.1" nexus-auth = { path = "nexus/auth" } nexus-background-task-interface = { path = "nexus/background-task-interface" } @@ -773,7 +773,7 @@ rats-corim = { git = "https://github.com/oxidecomputer/rats-corim.git", rev = "f raw-cpuid = { git = "https://github.com/oxidecomputer/rust-cpuid.git", rev = "a4cf01df76f35430ff5d39dc2fe470bcb953503b" } rayon = "1.10" rcgen = { version = "0.12.1", default-features = false, features = ["aws_lc_rs", "pem"] } -mg-api-types = { git = "https://github.com/oxidecomputer/maghemite", rev = "ce015cde17a1ebc839ae482026d522cf0e369a3b" } +mg-api-types = { git = "https://github.com/oxidecomputer/maghemite", rev = "bf831acede9b125a854bfda5615ae9248d1e2fa8" } reconfigurator-cli = { path = "dev-tools/reconfigurator-cli" } reedline = "0.40.0" ref-cast = "1.0" diff --git a/common/src/lib.rs b/common/src/lib.rs index 2ce4140b728..f7bfd2f2387 100644 --- a/common/src/lib.rs +++ b/common/src/lib.rs @@ -26,6 +26,7 @@ pub mod disk; pub mod policy; pub mod resolvable_files; pub mod snake_case_result; +pub mod tfport; pub mod update; pub mod vlan; pub mod zpool_name; diff --git a/common/src/tfport.rs b/common/src/tfport.rs new file mode 100644 index 00000000000..78f8780be98 --- /dev/null +++ b/common/src/tfport.rs @@ -0,0 +1,161 @@ +// 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/. + +//! Names of data links created by tfportd. + +use std::fmt; +use std::str::FromStr; + +const PREFIX: &str = "tfport"; + +/// The name of a data link created by tfportd. +/// +/// tfportd derives these names from a Dendrite port ID and link ID using the +/// form `tfport{port_id}_{link_id}`. +#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct TfportInterfaceName(String); + +impl TfportInterfaceName { + /// Construct the tfport interface for link 0 of a Dendrite port. + pub fn from_port_name( + port_name: &str, + ) -> Result { + Self::from_port_and_link(port_name, 0) + } + + /// Construct a tfport interface from a Dendrite port and link ID. + pub fn from_port_and_link( + port_name: &str, + link_id: u8, + ) -> Result { + if !valid_port_name(port_name) { + return Err(ParseTfportInterfaceNameError(format!( + "{PREFIX}{port_name}_{link_id}" + ))); + } + Ok(Self::from_valid_parts(port_name, link_id)) + } + + /// Construct the tfport interface for link 0 of a rear port. + pub fn rear_port(port: u8) -> Self { + Self::from_valid_parts(&format!("rear{port}"), 0) + } + + /// Return the Dendrite port name encoded in this interface name. + pub fn port_name(&self) -> &str { + let body = self.0.strip_prefix(PREFIX).unwrap(); + body.rsplit_once('_').unwrap().0 + } + + /// Return the Dendrite link ID encoded in this interface name. + pub fn link_id(&self) -> u8 { + self.0.rsplit_once('_').unwrap().1.parse().unwrap() + } + + /// Return the full tfport interface name. + pub fn as_str(&self) -> &str { + &self.0 + } + + fn from_valid_parts(port_name: &str, link_id: u8) -> Self { + Self(format!("{PREFIX}{port_name}_{link_id}")) + } +} + +impl AsRef for TfportInterfaceName { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +impl fmt::Display for TfportInterfaceName { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl FromStr for TfportInterfaceName { + type Err = ParseTfportInterfaceNameError; + + fn from_str(name: &str) -> Result { + let Some(body) = name.strip_prefix(PREFIX) else { + return Err(ParseTfportInterfaceNameError(name.to_owned())); + }; + let Some((port_name, link_id)) = body.rsplit_once('_') else { + return Err(ParseTfportInterfaceNameError(name.to_owned())); + }; + let Ok(link_id) = link_id.parse::() else { + return Err(ParseTfportInterfaceNameError(name.to_owned())); + }; + if !valid_port_name(port_name) { + return Err(ParseTfportInterfaceNameError(name.to_owned())); + } + + let parsed = Self::from_valid_parts(port_name, link_id); + if parsed.as_str() != name { + return Err(ParseTfportInterfaceNameError(name.to_owned())); + } + Ok(parsed) + } +} + +fn valid_port_name(port_name: &str) -> bool { + !port_name.is_empty() + && port_name + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit()) +} + +/// An invalid tfport interface name. +#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)] +#[error("invalid tfport interface name {0:?}")] +pub struct ParseTfportInterfaceNameError(String); + +#[cfg(test)] +mod tests { + use super::TfportInterfaceName; + + #[test] + fn constructs_front_and_rear_port_names() { + let front = TfportInterfaceName::from_port_name("qsfp10").unwrap(); + let rear = + TfportInterfaceName::from_port_and_link("rear31", 1).unwrap(); + + assert_eq!(front.as_str(), "tfportqsfp10_0"); + assert_eq!(rear.as_str(), "tfportrear31_1"); + assert_eq!(rear.link_id(), 1); + } + + #[test] + fn parses_interface_name() { + let name = "tfportqsfp10_1".parse::().unwrap(); + + assert_eq!(name.port_name(), "qsfp10"); + assert_eq!(name.as_str(), "tfportqsfp10_1"); + } + + #[test] + fn rejects_invalid_interface_names() { + for invalid in [ + "qsfp10_0", + "tfportqsfp10", + "tfportqsfp10_", + "tfportqsfp10_256", + "tfportqsfp10_00", + "tfportqsfp_10_0", + ] { + assert!( + invalid.parse::().is_err(), + "{invalid}" + ); + } + } + + #[test] + fn rejects_invalid_port_names_during_construction() { + for invalid in ["", "QSFP0", "qsfp_0", "qsfp0.1"] { + assert!(TfportInterfaceName::from_port_name(invalid).is_err()); + } + } +} diff --git a/illumos-utils/src/dladm.rs b/illumos-utils/src/dladm.rs index dab967458b8..6fc15b82a39 100644 --- a/illumos-utils/src/dladm.rs +++ b/illumos-utils/src/dladm.rs @@ -8,6 +8,7 @@ use crate::link::{Link, LinkKind}; use crate::zone::IPADM; use crate::{ExecutionError, PFEXEC, execute_async}; use omicron_common::api::external::MacAddr; +use omicron_common::tfport::TfportInterfaceName; use omicron_common::vlan::VlanID; use serde::{Deserialize, Serialize}; use std::str::FromStr; @@ -463,8 +464,8 @@ impl Dladm { } /// Returns simnet links masquerading as tfport devices - pub async fn get_simulated_tfports() -> Result, GetSimnetError> - { + pub async fn get_simulated_tfports() + -> Result, GetSimnetError> { let mut command = Command::new(PFEXEC); let cmd = command.args(&[DLADM, "show-simnet", "-p", "-o", "LINK"]); let output = @@ -472,13 +473,7 @@ impl Dladm { let tfports = String::from_utf8_lossy(&output.stdout) .lines() - .filter_map(|name| { - if name.starts_with("tfport") { - Some(name.to_owned()) - } else { - None - } - }) + .filter_map(|name| name.parse().ok()) .collect(); Ok(tfports) } diff --git a/nexus/external-api/output/nexus_tags.txt b/nexus/external-api/output/nexus_tags.txt index 7b9bbd9edc6..e4a6ca5692e 100644 --- a/nexus/external-api/output/nexus_tags.txt +++ b/nexus/external-api/output/nexus_tags.txt @@ -273,6 +273,9 @@ networking_bgp_exported GET /v1/system/networking/bgp-expo networking_bgp_imported GET /v1/system/networking/bgp-imported networking_bgp_message_history GET /v1/system/networking/bgp-message-history networking_bgp_status GET /v1/system/networking/bgp-status +networking_bgp_unnumbered_interface_list GET /v1/system/networking/bgp-unnumbered-interfaces +networking_bgp_unnumbered_interface_view GET /v1/system/networking/bgp-unnumbered-interfaces/{switch_slot}/{interface_name} +networking_bgp_unnumbered_manager_status GET /v1/system/networking/bgp-unnumbered-manager networking_inbound_icmp_update PUT /v1/system/networking/inbound-icmp networking_inbound_icmp_view GET /v1/system/networking/inbound-icmp networking_loopback_address_create POST /v1/system/networking/loopback-address diff --git a/nexus/external-api/src/lib.rs b/nexus/external-api/src/lib.rs index 38c45eb43a8..2e5f88dd921 100644 --- a/nexus/external-api/src/lib.rs +++ b/nexus/external-api/src/lib.rs @@ -35,6 +35,7 @@ use nexus_types_versions::v2026_01_22_00; use nexus_types_versions::v2026_01_30_01; use nexus_types_versions::v2026_01_31_00; use nexus_types_versions::v2026_02_13_01; +use nexus_types_versions::v2026_03_06_01; use nexus_types_versions::v2026_04_16_00; use nexus_types_versions::v2026_06_05_00; use omicron_common::address::IpRange; @@ -86,6 +87,7 @@ api_versions!([ // | date-based version should be at the top of the list. // v // (next_yyyy_mm_dd_nn, IDENT), + (2026_08_18_00, BGP_UNNUMBERED_STATUS), (2026_08_17_00, SUPPORT_BUNDLES_STABLE), (2026_08_14_00, ALERT_LIST), (2026_08_12_00, SLED_SLOT), @@ -5831,11 +5833,35 @@ pub trait NexusExternalApi { method = GET, path = "/v1/system/networking/bgp-status", tags = ["system/networking"], - versions = VERSION_BGP_UNNUMBERED_PEERS.., + versions = VERSION_BGP_UNNUMBERED_STATUS.., }] async fn networking_bgp_status( rqctx: RequestContext, - ) -> Result>, HttpError>; + ) -> Result< + HttpResponseOk< + latest::networking::SwitchResults< + latest::networking::BgpPeerStatuses, + >, + >, + HttpError, + >; + + /// Get BGP peer status + #[endpoint { + operation_id = "networking_bgp_status", + method = GET, + path = "/v1/system/networking/bgp-status", + tags = ["system/networking"], + versions = VERSION_BGP_UNNUMBERED_PEERS..VERSION_BGP_UNNUMBERED_STATUS, + }] + async fn networking_bgp_status_v2026_02_13_01( + rqctx: RequestContext, + ) -> Result< + HttpResponseOk>, + HttpError, + > { + Ok(HttpResponseOk(Self::networking_bgp_status(rqctx).await?.0.into())) + } //TODO pagination? the normal by-name/by-id stuff does not work here /// Get BGP peer status @@ -5853,11 +5879,11 @@ pub trait NexusExternalApi { HttpError, > { Ok(HttpResponseOk( - Self::networking_bgp_status(rqctx) + Self::networking_bgp_status_v2026_02_13_01(rqctx) .await? .0 .into_iter() - .map(v2025_12_12_00::networking::BgpPeerStatus::from) + .map(Into::into) .collect(), )) } @@ -5890,11 +5916,35 @@ pub trait NexusExternalApi { method = GET, path = "/v1/system/networking/bgp-exported", tags = ["system/networking"], - versions = VERSION_BGP_UNNUMBERED_PEERS.., + versions = VERSION_BGP_UNNUMBERED_STATUS.., }] async fn networking_bgp_exported( rqctx: RequestContext, - ) -> Result>, HttpError>; + ) -> Result< + HttpResponseOk< + latest::networking::SwitchResults< + latest::networking::BgpExportedRoutes, + >, + >, + HttpError, + >; + + /// List BGP exported routes + #[endpoint { + operation_id = "networking_bgp_exported", + method = GET, + path = "/v1/system/networking/bgp-exported", + tags = ["system/networking"], + versions = VERSION_BGP_UNNUMBERED_PEERS..VERSION_BGP_UNNUMBERED_STATUS, + }] + async fn networking_bgp_exported_v2026_02_13_01( + rqctx: RequestContext, + ) -> Result< + HttpResponseOk>, + HttpError, + > { + Ok(HttpResponseOk(Self::networking_bgp_exported(rqctx).await?.0.into())) + } //TODO pagination? the normal by-name/by-id stuff does not work here /// Get BGP exported routes @@ -5911,7 +5961,8 @@ pub trait NexusExternalApi { HttpResponseOk, HttpError, > { - let result = Self::networking_bgp_exported(rqctx).await?.0; + let result = + Self::networking_bgp_exported_v2026_02_13_01(rqctx).await?.0; Ok(HttpResponseOk(result.into())) } @@ -5920,15 +5971,43 @@ pub trait NexusExternalApi { method = GET, path = "/v1/system/networking/bgp-message-history", tags = ["system/networking"], + versions = VERSION_BGP_UNNUMBERED_STATUS.., }] async fn networking_bgp_message_history( rqctx: RequestContext, query_params: Query, ) -> Result< - HttpResponseOk, + HttpResponseOk< + latest::networking::SwitchResults< + latest::networking::BgpMessageHistories, + >, + >, HttpError, >; + /// Get BGP router message history + #[endpoint { + operation_id = "networking_bgp_message_history", + method = GET, + path = "/v1/system/networking/bgp-message-history", + tags = ["system/networking"], + versions = ..VERSION_BGP_UNNUMBERED_STATUS, + }] + async fn networking_bgp_message_history_v2025_11_20_00( + rqctx: RequestContext, + query_params: Query, + ) -> Result< + HttpResponseOk, + HttpError, + > { + Ok(HttpResponseOk( + Self::networking_bgp_message_history(rqctx, query_params) + .await? + .0 + .into(), + )) + } + //TODO pagination? the normal by-name/by-id stuff does not work here /// Get imported IPv4 BGP routes #[endpoint { @@ -5950,12 +6029,39 @@ pub trait NexusExternalApi { method = GET, path = "/v1/system/networking/bgp-imported", tags = ["system/networking"], - versions = VERSION_BGP_UNNUMBERED_PEERS.., + versions = VERSION_BGP_UNNUMBERED_STATUS.., }] async fn networking_bgp_imported( rqctx: RequestContext, query_params: Query, - ) -> Result>, HttpError>; + ) -> Result< + HttpResponseOk< + latest::networking::SwitchResults< + latest::networking::BgpImportedRoutes, + >, + >, + HttpError, + >; + + /// Get imported BGP routes + #[endpoint { + operation_id = "networking_bgp_imported", + method = GET, + path = "/v1/system/networking/bgp-imported", + tags = ["system/networking"], + versions = VERSION_BGP_UNNUMBERED_PEERS..VERSION_BGP_UNNUMBERED_STATUS, + }] + async fn networking_bgp_imported_v2026_02_13_01( + rqctx: RequestContext, + query_params: Query, + ) -> Result< + HttpResponseOk>, + HttpError, + > { + Ok(HttpResponseOk( + Self::networking_bgp_imported(rqctx, query_params).await?.0.into(), + )) + } /// Delete BGP configuration #[endpoint { @@ -6100,11 +6206,37 @@ pub trait NexusExternalApi { method = GET, path = "/v1/system/networking/bfd-status", tags = ["system/networking"], - versions = VERSION_SWITCH_SLOT_ENUM.., + versions = VERSION_BGP_UNNUMBERED_STATUS.., }] async fn networking_bfd_status( rqctx: RequestContext, - ) -> Result>, HttpError>; + ) -> Result< + HttpResponseOk< + latest::networking::SwitchResults, + >, + HttpError, + >; + + /// Get BFD status + #[endpoint { + operation_id = "networking_bfd_status", + method = GET, + path = "/v1/system/networking/bfd-status", + tags = ["system/networking"], + versions = VERSION_SWITCH_SLOT_ENUM..VERSION_BGP_UNNUMBERED_STATUS, + }] + async fn networking_bfd_status_v2026_03_06_01( + rqctx: RequestContext, + ) -> Result>, HttpError> + { + Self::networking_bfd_status(rqctx).await?.try_map(|statuses| { + statuses.try_into().map_err(|_| { + HttpError::for_internal_error( + "failed to query BFD status from a switch".to_string(), + ) + }) + }) + } /// Get BFD status #[endpoint { @@ -6118,12 +6250,66 @@ pub trait NexusExternalApi { rqctx: RequestContext, ) -> Result>, HttpError> { - Self::networking_bfd_status(rqctx).await.map(|response| { - response - .map(|statuses| statuses.into_iter().map(From::from).collect()) - }) + Self::networking_bfd_status_v2026_03_06_01(rqctx).await.map( + |response| { + response.map(|statuses| { + statuses.into_iter().map(From::from).collect() + }) + }, + ) } + /// Get BGP Unnumbered manager state + #[endpoint { + method = GET, + path = "/v1/system/networking/bgp-unnumbered-manager", + tags = ["system/networking"], + versions = VERSION_BGP_UNNUMBERED_STATUS.., + }] + async fn networking_bgp_unnumbered_manager_status( + rqctx: RequestContext, + ) -> Result< + HttpResponseOk< + latest::networking::SwitchResults< + latest::networking::UnnumberedManagerState, + >, + >, + HttpError, + >; + + /// List BGP Unnumbered interfaces + #[endpoint { + method = GET, + path = "/v1/system/networking/bgp-unnumbered-interfaces", + tags = ["system/networking"], + versions = VERSION_BGP_UNNUMBERED_STATUS.., + }] + async fn networking_bgp_unnumbered_interface_list( + rqctx: RequestContext, + ) -> Result< + HttpResponseOk< + latest::networking::SwitchResults< + latest::networking::UnnumberedInterfaces, + >, + >, + HttpError, + >; + + /// Get BGP Unnumbered interface state + #[endpoint { + method = GET, + path = "/v1/system/networking/bgp-unnumbered-interfaces/{switch_slot}/{interface_name}", + tags = ["system/networking"], + versions = VERSION_BGP_UNNUMBERED_STATUS.., + }] + async fn networking_bgp_unnumbered_interface_view( + rqctx: RequestContext, + path_params: Path, + ) -> Result< + HttpResponseOk, + HttpError, + >; + /// Get user-facing services IP allowlist #[endpoint { method = GET, diff --git a/nexus/src/app/bfd.rs b/nexus/src/app/bfd.rs index f3c94e10d57..9e3f59f08d9 100644 --- a/nexus/src/app/bfd.rs +++ b/nexus/src/app/bfd.rs @@ -38,37 +38,36 @@ impl super::Nexus { pub async fn bfd_status( &self, _opctx: &OpContext, - ) -> Result, Error> { + ) -> Result, Error> { // ask each rack switch about all its BFD sessions. This will need to // be updated for multirack. let mg_clients = self.mg_clients().await.map_err(|err| { Error::internal_error(&format!("failed to get mg clients: {err}")) })?; - let mut result = Vec::new(); - for switch_slot in [SwitchSlot::Switch0, SwitchSlot::Switch1] { - // If we only have one scrimlet, we won't have an entry in - // `mg_clients` for one of the switch locations. Log that, but - // continue so we can still report status from whichever switch we - // do have. - let Some(mg_client) = mg_clients.get(&switch_slot) else { - warn!( - self.log, "no mgd client found for switch slot"; - "switch-slot" => ?switch_slot, - ); - continue; - }; - let status = mg_client - .get_bfd_peers() - .await - .map_err(|e| { - Error::internal_error(&format!( - "maghemite get bfd peers: {e}" - )) - })? - .into_inner(); - - for info in status.iter() { - result.push(bfd::BfdStatus { + let query = |switch_slot| { + let mg_clients = &mg_clients; + async move { + // If we only have one scrimlet, we won't have an entry in + // `mg_clients` for one of the switch locations. Log that, but + // continue so we can still report status from whichever switch we + // do have. + let Some(mg_client) = mg_clients.get(&switch_slot) else { + warn!( + self.log, "no mgd client found for switch slot"; + "switch-slot" => ?switch_slot, + ); + return networking::SwitchResult::Err { + error: networking::SwitchError::MgdUnresolved, + }; + }; + match mg_client.get_bfd_peers().await { + Ok(status) => networking::SwitchResult::Ok { + value: bfd::BfdPeerStatuses( + status + .into_inner() + .iter() + .map(|info| { + bfd::BfdPeerStatus { peer: info.config.peer, state: match info.state { BfdPeerState::Up => bfd::BfdState::Up, @@ -76,7 +75,6 @@ impl super::Nexus { BfdPeerState::Init => bfd::BfdState::Init, BfdPeerState::AdminDown => bfd::BfdState::AdminDown, }, - switch_slot, local: Some(info.config.listen), detection_threshold: info.config.detection_threshold.into(), required_rx: info.config.required_rx, @@ -88,9 +86,25 @@ impl super::Nexus { BfdMode::MultiHop } }, - }) + } + }) + .collect(), + ), + }, + Err(err) => { + error!( + self.log, "failed to get BFD peers"; + "switch-slot" => ?switch_slot, + "error" => %err, + ); + networking::SwitchResult::Err { error: err.into() } + } + } } - } - Ok(result) + }; + Ok(networking::SwitchResults { + switch0: query(SwitchSlot::Switch0).await, + switch1: query(SwitchSlot::Switch1).await, + }) } } diff --git a/nexus/src/app/bgp.rs b/nexus/src/app/bgp.rs index b0034cbc5ab..7574af7291b 100644 --- a/nexus/src/app/bgp.rs +++ b/nexus/src/app/bgp.rs @@ -17,8 +17,29 @@ use omicron_common::api::external::{ use omicron_uuid_kinds::BgpAnnounceSetUuid; use omicron_uuid_kinds::BgpConfigUuid; use omicron_uuid_kinds::GenericUuid; +use sled_agent_types::early_networking::SwitchSlot; use slog_error_chain::InlineErrorChain; +/// Controls how a failure in one constituent query is represented. +/// +/// An aggregate query may first enumerate its constituents and then query each +/// one individually. This policy applies when one of those constituent queries +/// fails after enumeration has succeeded. +#[derive(Clone, Copy)] +enum ConstituentErrorPolicy { + /// Treat the constituent failure as a failure of the aggregate query. + /// + /// Any values already collected from other constituents are + /// discarded so the response cannot present incomplete data as complete. + FailSlot, + + /// Skip the failed constituent query and return other constituents' values. + /// + /// A successful aggregate result may therefore contain partial data without + /// identifying the omitted constituent. + RetainPartial, +} + impl super::Nexus { pub async fn bgp_config_create( &self, @@ -196,215 +217,366 @@ impl super::Nexus { pub async fn bgp_peer_status( &self, opctx: &OpContext, - ) -> ListResultVec { + ) -> Result< + networking::SwitchResults, + external::Error, + > { + self.query_bgp_peer_status(opctx, ConstituentErrorPolicy::FailSlot) + .await + } + + pub async fn bgp_peer_status_v2025_11_20_00( + &self, + opctx: &OpContext, + ) -> Result< + networking::SwitchResults, + external::Error, + > { + self.query_bgp_peer_status(opctx, ConstituentErrorPolicy::RetainPartial) + .await + } + + async fn query_bgp_peer_status( + &self, + opctx: &OpContext, + error_policy: ConstituentErrorPolicy, + ) -> Result< + networking::SwitchResults, + external::Error, + > { opctx.authorize(authz::Action::Read, &authz::FLEET).await?; - let mut result = Vec::new(); - for (switch_slot, client) in self.mg_clients().await.map_err(|e| { + let mg_clients = self.mg_clients().await.map_err(|e| { external::Error::internal_error(&format!( "failed to get mg clients: {e}" )) - })? { - let router_info = match client.read_routers().await { - Ok(result) => result.into_inner(), - Err(e) => { - error!( - self.log, "failed to get routers from switch"; + })?; + let query = |switch_slot| { + let mg_clients = &mg_clients; + async move { + let Some(client) = mg_clients.get(&switch_slot) else { + warn!( + self.log, "no mgd client found for switch slot"; "switch_slot" => ?switch_slot, - InlineErrorChain::new(&e), ); - continue; - } - }; - - for r in &router_info { - let asn = r.asn; - let peers = match client.get_neighbors(asn).await { + return networking::SwitchResult::Err { + error: networking::SwitchError::MgdUnresolved, + }; + }; + let router_info = match client.read_routers().await { Ok(result) => result.into_inner(), Err(e) => { error!( - self.log, - "failed to get peers for asn {asn} from switch"; + self.log, "failed to get routers from switch"; "switch_slot" => ?switch_slot, InlineErrorChain::new(&e), ); - continue; + return networking::SwitchResult::Err { + error: e.into(), + }; } }; - for (peer_id, info) in peers { - result.push(networking::BgpPeerStatus { - switch: switch_slot, - peer_id: peer_id.clone(), - addr: info.remote_ip, - local_asn: r.asn, - remote_asn: info.asn.unwrap_or(0), - state: info.fsm_state.into(), - state_duration_millis: u64::try_from( - info.fsm_state_duration.as_millis(), - ) - .unwrap_or(u64::MAX), - }); + + let mut statuses = Vec::new(); + for r in &router_info { + let asn = r.asn; + let peers = match client.get_neighbors(asn).await { + Ok(result) => result.into_inner(), + Err(e) => { + error!( + self.log, + "failed to get peers for asn {asn} from switch"; + "switch_slot" => ?switch_slot, + InlineErrorChain::new(&e), + ); + match error_policy { + ConstituentErrorPolicy::FailSlot => { + return networking::SwitchResult::Err { + error: e.into(), + }; + } + ConstituentErrorPolicy::RetainPartial => { + continue; + } + } + } + }; + for (peer_id, info) in peers { + statuses.push(networking::BgpPeerStatus { + peer_id: peer_id.clone(), + addr: info.remote_ip, + local_asn: r.asn, + remote_asn: info.asn.unwrap_or(0), + state: info.fsm_state.into(), + state_duration_millis: u64::try_from( + info.fsm_state_duration.as_millis(), + ) + .unwrap_or(u64::MAX), + }); + } + } + networking::SwitchResult::Ok { + value: networking::BgpPeerStatuses(statuses), } } - } - Ok(result) + }; + Ok(networking::SwitchResults { + switch0: query(SwitchSlot::Switch0).await, + switch1: query(SwitchSlot::Switch1).await, + }) } pub async fn bgp_exported( &self, opctx: &OpContext, - ) -> LookupResult> { + ) -> Result< + networking::SwitchResults, + external::Error, + > { + self.query_bgp_exported(opctx, ConstituentErrorPolicy::FailSlot).await + } + + pub async fn bgp_exported_v2025_11_20_00( + &self, + opctx: &OpContext, + ) -> Result< + networking::SwitchResults, + external::Error, + > { + self.query_bgp_exported(opctx, ConstituentErrorPolicy::RetainPartial) + .await + } + + async fn query_bgp_exported( + &self, + opctx: &OpContext, + error_policy: ConstituentErrorPolicy, + ) -> Result< + networking::SwitchResults, + external::Error, + > { opctx.authorize(authz::Action::Read, &authz::FLEET).await?; - let mut result = vec![]; - for (switch_slot, client) in self.mg_clients().await.map_err(|e| { + let mg_clients = self.mg_clients().await.map_err(|e| { external::Error::internal_error(&format!( "failed to get mg clients: {e}" )) - })? { - let router_info = match client.read_routers().await { - Ok(result) => result.into_inner(), - Err(e) => { - error!( - self.log, "failed to get routers from switch"; + })?; + let query = |switch_slot| { + let mg_clients = &mg_clients; + async move { + let Some(client) = mg_clients.get(&switch_slot) else { + warn!( + self.log, "no mgd client found for switch slot"; "switch_slot" => ?switch_slot, - InlineErrorChain::new(&e), ); - continue; - } - }; - - for r in &router_info { - let asn = r.asn; - let selector = mg_api_types::bgp::session::ExportedSelector { - afi: None, - asn, - peer: None, + return networking::SwitchResult::Err { + error: networking::SwitchError::MgdUnresolved, + }; }; - - let exported = match client.get_exported(&selector).await { + let router_info = match client.read_routers().await { Ok(result) => result.into_inner(), Err(e) => { error!( - self.log, - "failed to get exports for asn {asn} from switch"; + self.log, "failed to get routers from switch"; "switch_slot" => ?switch_slot, InlineErrorChain::new(&e), ); - continue; + return networking::SwitchResult::Err { + error: e.into(), + }; } }; - for (peer_id, exports) in exported { - for ex in exports.iter() { - let export = networking::BgpExported { - peer_id: peer_id.clone(), - switch: switch_slot, - prefix: *ex, + let mut routes = Vec::new(); + for r in &router_info { + let asn = r.asn; + let selector = + mg_api_types::bgp::session::ExportedSelector { + afi: None, + asn, + peer: None, }; - result.push(export); + + let exported = match client.get_exported(&selector).await { + Ok(result) => result.into_inner(), + Err(e) => { + error!( + self.log, + "failed to get exports for asn {asn} from switch"; + "switch_slot" => ?switch_slot, + InlineErrorChain::new(&e), + ); + match error_policy { + ConstituentErrorPolicy::FailSlot => { + return networking::SwitchResult::Err { + error: e.into(), + }; + } + ConstituentErrorPolicy::RetainPartial => { + continue; + } + } + } + }; + + for (peer_id, exports) in exported { + for ex in exports.iter() { + let export = networking::BgpExported { + peer_id: peer_id.clone(), + prefix: *ex, + }; + routes.push(export); + } } } + networking::SwitchResult::Ok { + value: networking::BgpExportedRoutes(routes), + } } - } - Ok(result) + }; + Ok(networking::SwitchResults { + switch0: query(SwitchSlot::Switch0).await, + switch1: query(SwitchSlot::Switch1).await, + }) } pub async fn bgp_message_history( &self, opctx: &OpContext, sel: &networking::BgpRouteSelector, - ) -> ListResultVec { + ) -> Result< + networking::SwitchResults, + external::Error, + > { opctx.authorize(authz::Action::Read, &authz::FLEET).await?; - - let mut result = Vec::new(); - for (switch_slot, client) in self.mg_clients().await.map_err(|e| { + let mg_clients = self.mg_clients().await.map_err(|e| { external::Error::internal_error(&format!( "failed to get mg clients: {e}" )) - })? { - let history = match client - .message_history(&MessageHistoryRequest { - asn: sel.asn, - direction: None, - peer: None, - }) - .await - { - Ok(result) => result.into_inner().by_peer.clone(), - Err(e) => { - error!( - self.log, "failed to get bgp history from switch"; + })?; + let query = |switch_slot| { + let mg_clients = &mg_clients; + async move { + let Some(client) = mg_clients.get(&switch_slot) else { + warn!( + self.log, "no mgd client found for switch slot"; "switch_slot" => ?switch_slot, - InlineErrorChain::new(&e), ); - continue; - } - }; - - result.push(networking::SwitchBgpHistory { - switch: switch_slot, - history: history - .into_iter() - .map(|(k, v)| (k, networking::BgpMessageHistory::new(v))) - .collect(), - }); - } + return networking::SwitchResult::Err { + error: networking::SwitchError::MgdUnresolved, + }; + }; + let history = match client + .message_history(&MessageHistoryRequest { + asn: sel.asn, + direction: None, + peer: None, + }) + .await + { + Ok(result) => result.into_inner().by_peer.clone(), + Err(e) => { + error!( + self.log, "failed to get bgp history from switch"; + "switch_slot" => ?switch_slot, + InlineErrorChain::new(&e), + ); + return networking::SwitchResult::Err { + error: e.into(), + }; + } + }; - Ok(result) + networking::SwitchResult::Ok { + value: networking::BgpMessageHistories( + history + .into_iter() + .map(|(k, v)| { + (k, networking::BgpMessageHistory::new(v)) + }) + .collect(), + ), + } + } + }; + Ok(networking::SwitchResults { + switch0: query(SwitchSlot::Switch0).await, + switch1: query(SwitchSlot::Switch1).await, + }) } pub async fn bgp_imported_routes( &self, opctx: &OpContext, _sel: &networking::BgpRouteSelector, - ) -> ListResultVec { + ) -> Result< + networking::SwitchResults, + external::Error, + > { opctx.authorize(authz::Action::Read, &authz::FLEET).await?; - let mut result = Vec::new(); - for (switch_slot, client) in self.mg_clients().await.map_err(|e| { + let mg_clients = self.mg_clients().await.map_err(|e| { external::Error::internal_error(&format!( "failed to get mg clients: {e}" )) - })? { - let mut imported: Vec = Vec::new(); - match client.get_rib_imported(None, None).await { - Ok(result) => { - for (prefix, paths) in result.into_inner().iter() { - let ipnet = match prefix.parse() { - Ok(p) => p, - Err(e) => { - error!( - self.log, - "failed to parse prefix {prefix}: {e}" - ); - continue; - } - }; - for p in paths.iter() { - let x = networking::BgpImported { - switch: switch_slot, - prefix: ipnet, - id: p - .bgp - .as_ref() - .map(|bgp| bgp.id) - .unwrap_or(0), - nexthop: p.nexthop, + })?; + let query = |switch_slot| { + let mg_clients = &mg_clients; + async move { + let Some(client) = mg_clients.get(&switch_slot) else { + warn!( + self.log, "no mgd client found for switch slot"; + "switch_slot" => ?switch_slot, + ); + return networking::SwitchResult::Err { + error: networking::SwitchError::MgdUnresolved, + }; + }; + let mut imported = Vec::new(); + match client.get_rib_imported(None, None).await { + Ok(result) => { + for (prefix, paths) in result.into_inner().iter() { + let ipnet = match prefix.parse() { + Ok(p) => p, + Err(e) => { + error!( + self.log, + "failed to parse prefix {prefix}: {e}" + ); + continue; + } }; - imported.push(x); + for p in paths.iter() { + let x = networking::BgpImported { + prefix: ipnet, + id: p + .bgp + .as_ref() + .map(|bgp| bgp.id) + .unwrap_or(0), + nexthop: p.nexthop, + }; + imported.push(x); + } } } + Err(e) => { + error!( + self.log, "failed to get BGP imported from switch"; + "switch_slot" => ?switch_slot, + InlineErrorChain::new(&e), + ); + return networking::SwitchResult::Err { + error: e.into(), + }; + } + }; + networking::SwitchResult::Ok { + value: networking::BgpImportedRoutes(imported), } - Err(e) => { - error!( - self.log, "failed to get BGP imported from switch"; - "switch_slot" => ?switch_slot, - InlineErrorChain::new(&e), - ); - continue; - } - }; - - result.extend_from_slice(&imported); - } - Ok(result) + } + }; + Ok(networking::SwitchResults { + switch0: query(SwitchSlot::Switch0).await, + switch1: query(SwitchSlot::Switch1).await, + }) } } diff --git a/nexus/src/app/mod.rs b/nexus/src/app/mod.rs index 4554b8ab1d1..eef43349e18 100644 --- a/nexus/src/app/mod.rs +++ b/nexus/src/app/mod.rs @@ -109,6 +109,7 @@ mod switch_port; mod system_networking; pub mod test_interfaces; mod trust_quorum; +mod unnumbered; mod update; mod utilization; mod volume; diff --git a/nexus/src/app/unnumbered.rs b/nexus/src/app/unnumbered.rs new file mode 100644 index 00000000000..af5f23e5c68 --- /dev/null +++ b/nexus/src/app/unnumbered.rs @@ -0,0 +1,141 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +use nexus_db_queries::context::OpContext; +use nexus_types::external_api::networking::{ + SwitchError, SwitchResult, SwitchResults, SwitchUnnumberedInterface, + UnnumberedInterfaces, UnnumberedManagerState, +}; +use omicron_common::api::external::Error; +use omicron_common::tfport::TfportInterfaceName; +use sled_agent_types::early_networking::SwitchSlot; + +impl super::Nexus { + pub async fn bgp_unnumbered_manager_status( + &self, + _opctx: &OpContext, + ) -> Result, Error> { + // Ask each switch about the BGP unnumbered interfaces it manages. + let mg_clients = self.mg_clients().await.map_err(|err| { + Error::internal_error(&format!("failed to get mg clients: {err}")) + })?; + let query = |switch_slot| { + let mg_clients = &mg_clients; + async move { + // Log an error if we only have one scrimlet, but keep going. + // We still want to return anything we're able to collect. + let Some(mg_client) = mg_clients.get(&switch_slot) else { + warn!( + self.log, "no mgd client found for switch slot"; + "switch-slot" => ?switch_slot, + ); + return SwitchResult::Err { + error: SwitchError::MgdUnresolved, + }; + }; + match mg_client.get_bgp_unnumbered_manager_state().await { + Ok(status) => { + SwitchResult::Ok { value: status.into_inner().into() } + } + Err(err) => { + error!( + self.log, + "failed to get BGP unnumbered manager state"; + "switch-slot" => ?switch_slot, + "error" => %err, + ); + SwitchResult::Err { error: err.into() } + } + } + } + }; + Ok(SwitchResults { + switch0: query(SwitchSlot::Switch0).await, + switch1: query(SwitchSlot::Switch1).await, + }) + } + + pub async fn bgp_unnumbered_interfaces( + &self, + _opctx: &OpContext, + ) -> Result, Error> { + // Ask each switch about the BGP unnumbered interfaces it manages. + let mg_clients = self.mg_clients().await.map_err(|err| { + Error::internal_error(&format!("failed to get mg clients: {err}")) + })?; + let query = |switch_slot| { + let mg_clients = &mg_clients; + async move { + // Log an error if we only have one scrimlet, but keep going. + // We still want to return anything we're able to collect. + let Some(mg_client) = mg_clients.get(&switch_slot) else { + warn!( + self.log, "no mgd client found for switch slot"; + "switch-slot" => ?switch_slot, + ); + return SwitchResult::Err { + error: SwitchError::MgdUnresolved, + }; + }; + match mg_client.get_bgp_unnumbered_interfaces().await { + Ok(interfaces) => SwitchResult::Ok { + value: UnnumberedInterfaces( + interfaces + .into_inner() + .into_iter() + .map(Into::into) + .collect(), + ), + }, + Err(err) => { + error!( + self.log, "failed to get BGP unnumbered interfaces"; + "switch-slot" => ?switch_slot, + "error" => %err, + ); + SwitchResult::Err { error: err.into() } + } + } + } + }; + Ok(SwitchResults { + switch0: query(SwitchSlot::Switch0).await, + switch1: query(SwitchSlot::Switch1).await, + }) + } + + pub async fn bgp_unnumbered_interface( + &self, + _opctx: &OpContext, + switch_slot: SwitchSlot, + interface_name: String, + ) -> Result { + let mg_clients = self.mg_clients().await.map_err(|err| { + Error::internal_error(&format!("failed to get mg clients: {err}")) + })?; + let mg_client = mg_clients.get(&switch_slot).ok_or_else(|| { + Error::internal_error(&format!( + "no mgd client found for switch slot {switch_slot:?}" + )) + })?; + + let interface_name = + TfportInterfaceName::from_port_name(&interface_name) + .map_err(|err| Error::invalid_request(&err.to_string()))?; + let interface = mg_client + .get_bgp_unnumbered_interface_detail(interface_name.as_str()) + .await + .map_err(|e| { + Error::internal_error(&format!( + "maghemite get BGP unnumbered interface detail: {e}" + )) + })? + .into_inner(); + + Ok(SwitchUnnumberedInterface { + switch_slot, + interface: interface.into(), + }) + } +} diff --git a/nexus/src/external_api/http_entrypoints.rs b/nexus/src/external_api/http_entrypoints.rs index 1046148aca1..e1e8bdc4e04 100644 --- a/nexus/src/external_api/http_entrypoints.rs +++ b/nexus/src/external_api/http_entrypoints.rs @@ -49,7 +49,7 @@ use nexus_types::external_api::{ timeseries, update, user, vpc, }; // Type imports for API implementations (per RFD 619) -use nexus_types::external_api::bfd::BfdStatus; +use nexus_types::external_api::bfd::BfdPeerStatuses; use nexus_types::external_api::certificate::Certificate; use nexus_types::external_api::floating_ip::FloatingIp; use nexus_types::external_api::headers::RangeRequest; @@ -58,6 +58,10 @@ use nexus_types::external_api::image::Image; use nexus_types::external_api::ip_pool::IpPool; use nexus_types::external_api::ip_pool::IpPoolRange; use nexus_types::external_api::metrics::SystemMetricsPathParam; +use nexus_types::external_api::networking::{ + SwitchResults, SwitchUnnumberedInterface, UnnumberedInterfacePath, + UnnumberedInterfaces, UnnumberedManagerState, +}; use nexus_types::external_api::physical_disk::{ PhysicalDisk, PhysicalDiskAdoptionRequest, PhysicalDiskAdoptionRequestPath, PhysicalDiskManufacturerIdentity, UnadoptedPhysicalDisk, @@ -75,6 +79,8 @@ use nexus_types::external_api::user::{Group, User, UserBuiltin}; use nexus_types::external_api::vpc::{Vpc, VpcRouter, VpcSubnet}; use nexus_types_versions::v2025_11_20_00; use nexus_types_versions::v2026_01_01_00; +use nexus_types_versions::v2026_02_13_01; +use nexus_types_versions::v2026_03_06_01; use omicron_common::address::IpRange; use omicron_common::api::external::DataPageParams; use omicron_common::api::external::Disk; @@ -114,6 +120,8 @@ use propolis_client::support::tungstenite::protocol::{ }; use range_requests::PotentialRange; use ref_cast::RefCast; +use schemars::JsonSchema; +use serde::Serialize; use trust_quorum_types::types::Epoch; type NexusApiDescription = ApiDescription; @@ -126,6 +134,20 @@ pub(crate) fn external_api() -> NexusApiDescription { enum NexusExternalApiImpl {} +fn switch_results_response< + T: JsonSchema + Send + Serialize + Sync + 'static, +>( + results: SwitchResults, +) -> Result>, HttpError> { + if results.all_operational_failures() { + return Err(HttpError::for_unavail( + Some("SwitchQueryFailed".to_owned()), + "neither switch could be queried".to_owned(), + )); + } + Ok(HttpResponseOk(results)) +} + impl NexusExternalApi for NexusExternalApiImpl { type Context = ApiContext; @@ -4542,13 +4564,39 @@ impl NexusExternalApi for NexusExternalApiImpl { //TODO pagination? the normal by-name/by-id stuff does not work here async fn networking_bgp_status( rqctx: RequestContext, - ) -> Result>, HttpError> { + ) -> Result< + HttpResponseOk>, + HttpError, + > { let apictx = rqctx.context(); let opctx = crate::context::op_context_for_external_api(&rqctx).await?; let handler = async { let nexus = &apictx.context.nexus; let result = nexus.bgp_peer_status(&opctx).await?; - Ok(HttpResponseOk(result)) + switch_results_response(result) + }; + apictx + .context + .external_latencies + .instrument_dropshot_handler(&rqctx, handler) + .await + } + + async fn networking_bgp_status_v2026_02_13_01( + rqctx: RequestContext, + ) -> Result< + HttpResponseOk>, + HttpError, + > { + let apictx = rqctx.context(); + let opctx = crate::context::op_context_for_external_api(&rqctx).await?; + let handler = async { + let result = apictx + .context + .nexus + .bgp_peer_status_v2025_11_20_00(&opctx) + .await?; + Ok(HttpResponseOk(result.into())) }; apictx .context @@ -4559,13 +4607,41 @@ impl NexusExternalApi for NexusExternalApiImpl { async fn networking_bgp_exported( rqctx: RequestContext, - ) -> Result>, HttpError> { + ) -> Result< + HttpResponseOk< + networking::SwitchResults, + >, + HttpError, + > { let apictx = rqctx.context(); let opctx = crate::context::op_context_for_external_api(&rqctx).await?; let handler = async { let nexus = &apictx.context.nexus; let result = nexus.bgp_exported(&opctx).await?; - Ok(HttpResponseOk(result)) + switch_results_response(result) + }; + apictx + .context + .external_latencies + .instrument_dropshot_handler(&rqctx, handler) + .await + } + + async fn networking_bgp_exported_v2026_02_13_01( + rqctx: RequestContext, + ) -> Result< + HttpResponseOk>, + HttpError, + > { + let apictx = rqctx.context(); + let opctx = crate::context::op_context_for_external_api(&rqctx).await?; + let handler = async { + let result = apictx + .context + .nexus + .bgp_exported_v2025_11_20_00(&opctx) + .await?; + Ok(HttpResponseOk(result.into())) }; apictx .context @@ -4577,17 +4653,41 @@ impl NexusExternalApi for NexusExternalApiImpl { async fn networking_bgp_message_history( rqctx: RequestContext, query_params: Query, - ) -> Result, HttpError> - { + ) -> Result< + HttpResponseOk< + networking::SwitchResults, + >, + HttpError, + > { let apictx = rqctx.context(); let opctx = crate::context::op_context_for_external_api(&rqctx).await?; let handler = async { let nexus = &apictx.context.nexus; let sel = query_params.into_inner(); let result = nexus.bgp_message_history(&opctx, &sel).await?; - Ok(HttpResponseOk(networking::AggregateBgpMessageHistory::new( - result, - ))) + switch_results_response(result) + }; + apictx + .context + .external_latencies + .instrument_dropshot_handler(&rqctx, handler) + .await + } + + async fn networking_bgp_message_history_v2025_11_20_00( + rqctx: RequestContext, + query_params: Query, + ) -> Result< + HttpResponseOk, + HttpError, + > { + let apictx = rqctx.context(); + let opctx = crate::context::op_context_for_external_api(&rqctx).await?; + let handler = async { + let sel = query_params.into_inner(); + let result = + apictx.context.nexus.bgp_message_history(&opctx, &sel).await?; + Ok(HttpResponseOk(result.into())) }; apictx .context @@ -4610,6 +4710,8 @@ impl NexusExternalApi for NexusExternalApiImpl { let nexus = &apictx.context.nexus; let sel = query_params.into_inner(); let all_routes = nexus.bgp_imported_routes(&opctx, &sel).await?; + let all_routes: Vec = + all_routes.into(); let result: Vec = all_routes.into_iter().flat_map(|r| r.try_into().ok()).collect(); Ok(HttpResponseOk(result)) @@ -4621,17 +4723,44 @@ impl NexusExternalApi for NexusExternalApiImpl { .await } + async fn networking_bgp_imported_v2026_02_13_01( + rqctx: RequestContext, + query_params: Query, + ) -> Result< + HttpResponseOk>, + HttpError, + > { + let apictx = rqctx.context(); + let opctx = crate::context::op_context_for_external_api(&rqctx).await?; + let handler = async { + let sel = query_params.into_inner(); + let result = + apictx.context.nexus.bgp_imported_routes(&opctx, &sel).await?; + Ok(HttpResponseOk(result.into())) + }; + apictx + .context + .external_latencies + .instrument_dropshot_handler(&rqctx, handler) + .await + } + async fn networking_bgp_imported( rqctx: RequestContext, query_params: Query, - ) -> Result>, HttpError> { + ) -> Result< + HttpResponseOk< + networking::SwitchResults, + >, + HttpError, + > { let apictx = rqctx.context(); let opctx = crate::context::op_context_for_external_api(&rqctx).await?; let handler = async { let nexus = &apictx.context.nexus; let sel = query_params.into_inner(); let result = nexus.bgp_imported_routes(&opctx, &sel).await?; - Ok(HttpResponseOk(result)) + switch_results_response(result) }; apictx .context @@ -4775,7 +4904,7 @@ impl NexusExternalApi for NexusExternalApiImpl { async fn networking_bfd_status( rqctx: RequestContext, - ) -> Result>, HttpError> { + ) -> Result>, HttpError> { let apictx = rqctx.context(); let handler = async { let nexus = &apictx.context.nexus; @@ -4783,6 +4912,96 @@ impl NexusExternalApi for NexusExternalApiImpl { crate::context::op_context_for_external_api(&rqctx).await?; opctx.authorize(authz::Action::ListChildren, &authz::FLEET).await?; let status = nexus.bfd_status(&opctx).await?; + switch_results_response(status) + }; + apictx + .context + .external_latencies + .instrument_dropshot_handler(&rqctx, handler) + .await + } + + async fn networking_bfd_status_v2026_03_06_01( + rqctx: RequestContext, + ) -> Result>, HttpError> + { + let apictx = rqctx.context(); + let handler = async { + let opctx = + crate::context::op_context_for_external_api(&rqctx).await?; + opctx.authorize(authz::Action::ListChildren, &authz::FLEET).await?; + let statuses = apictx.context.nexus.bfd_status(&opctx).await?; + Ok(HttpResponseOk(statuses.try_into().map_err(|_| { + HttpError::for_internal_error( + "failed to query BFD status from a switch".to_owned(), + ) + })?)) + }; + apictx + .context + .external_latencies + .instrument_dropshot_handler(&rqctx, handler) + .await + } + + async fn networking_bgp_unnumbered_manager_status( + rqctx: RequestContext, + ) -> Result>, HttpError> + { + let apictx = rqctx.context(); + let handler = async { + let nexus = &apictx.context.nexus; + let opctx = + crate::context::op_context_for_external_api(&rqctx).await?; + opctx.authorize(authz::Action::ListChildren, &authz::FLEET).await?; + let status = nexus.bgp_unnumbered_manager_status(&opctx).await?; + switch_results_response(status) + }; + apictx + .context + .external_latencies + .instrument_dropshot_handler(&rqctx, handler) + .await + } + + async fn networking_bgp_unnumbered_interface_list( + rqctx: RequestContext, + ) -> Result>, HttpError> + { + let apictx = rqctx.context(); + let handler = async { + let nexus = &apictx.context.nexus; + let opctx = + crate::context::op_context_for_external_api(&rqctx).await?; + opctx.authorize(authz::Action::ListChildren, &authz::FLEET).await?; + let status = nexus.bgp_unnumbered_interfaces(&opctx).await?; + switch_results_response(status) + }; + apictx + .context + .external_latencies + .instrument_dropshot_handler(&rqctx, handler) + .await + } + + async fn networking_bgp_unnumbered_interface_view( + rqctx: RequestContext, + path_params: Path, + ) -> Result, HttpError> { + let apictx = rqctx.context(); + let handler = async { + let nexus = &apictx.context.nexus; + let path = path_params.into_inner(); + let opctx = + crate::context::op_context_for_external_api(&rqctx).await?; + opctx.authorize(authz::Action::ListChildren, &authz::FLEET).await?; + let status = nexus + .bgp_unnumbered_interface( + &opctx, + path.switch_slot, + path.interface_name, + ) + .await?; Ok(HttpResponseOk(status)) }; apictx @@ -9278,3 +9497,67 @@ impl NexusExternalApi for NexusExternalApiImpl { .await } } + +#[cfg(test)] +mod switch_results_response_tests { + use nexus_types::external_api::networking::{ + SwitchError, SwitchResult, UnnumberedInterfaces, + }; + + use super::*; + + fn rejected() -> SwitchResult { + SwitchResult::Err { + error: SwitchError::RequestRejected { + error_code: Some("query-error".to_owned()), + message: "query failed".to_owned(), + upstream_request_id: "request-id".to_owned(), + }, + } + } + + fn unavailable() -> SwitchResult { + SwitchResult::Err { error: SwitchError::QueryFailed } + } + + #[test] + fn returns_success_when_both_switches_reject_the_query() { + let results = + SwitchResults { switch0: rejected(), switch1: rejected() }; + + assert!(switch_results_response(results).is_ok()); + } + + #[test] + fn returns_success_for_application_and_operational_errors() { + let results = + SwitchResults { switch0: rejected(), switch1: unavailable() }; + + assert!(switch_results_response(results).is_ok()); + } + + #[test] + fn returns_success_for_value_and_operational_error() { + let results = SwitchResults { + switch0: SwitchResult::Ok { + value: UnnumberedInterfaces(Vec::new()), + }, + switch1: unavailable(), + }; + + assert!(switch_results_response(results).is_ok()); + } + + #[test] + fn returns_service_unavailable_when_both_switches_fail_operationally() { + let results = SwitchResults { + switch0: unavailable(), + switch1: SwitchResult::Err { error: SwitchError::MgdUnresolved }, + }; + + let Err(error) = switch_results_response(results) else { + panic!("two operational failures unexpectedly returned success"); + }; + assert_eq!(error.status_code.as_u16(), 503); + } +} diff --git a/nexus/tests/integration_tests/bfd.rs b/nexus/tests/integration_tests/bfd.rs index c66d634ed69..6e7d7e601df 100644 --- a/nexus/tests/integration_tests/bfd.rs +++ b/nexus/tests/integration_tests/bfd.rs @@ -7,7 +7,10 @@ use nexus_test_utils::http_testing::AuthnMode; use nexus_test_utils::http_testing::NexusRequest; use nexus_test_utils_macros::nexus_test; -use nexus_types::external_api::bfd::BfdStatus; +use nexus_types::external_api::bfd::BfdPeerStatuses; +use nexus_types::external_api::networking::{ + SwitchError, SwitchResult, SwitchResults, +}; type ControlPlaneTestContext = nexus_test_utils::ControlPlaneTestContext; @@ -20,11 +23,28 @@ async fn test_empty_bfd_status(cptestctx: &ControlPlaneTestContext) { let status = NexusRequest::object_get(client, STATUS_URL) .authn_as(AuthnMode::PrivilegedUser) - .execute_and_parse_unwrap::>() + .execute_and_parse_unwrap::>() .await; // `#[nexus_test]` doesn't set up BFD, so we should have no status. But we // should still be able to ask for that! (#[nexus_test] also only sets up // one fake scrimlet - that used to cause this endpoint to fail.) - assert_eq!(status, Vec::new()); + let mut available = 0; + let mut unavailable = 0; + for result in [status.switch0, status.switch1] { + match result { + SwitchResult::Ok { value: BfdPeerStatuses(statuses) } => { + assert!(statuses.is_empty()); + available += 1; + } + SwitchResult::Err { error: SwitchError::MgdUnresolved } => { + unavailable += 1; + } + SwitchResult::Err { error } => { + panic!("BFD query unexpectedly failed: {error:?}"); + } + } + } + assert_eq!(available, 1); + assert_eq!(unavailable, 1); } diff --git a/nexus/tests/integration_tests/bgp_unnumbered.rs b/nexus/tests/integration_tests/bgp_unnumbered.rs new file mode 100644 index 00000000000..4e0030854f2 --- /dev/null +++ b/nexus/tests/integration_tests/bgp_unnumbered.rs @@ -0,0 +1,176 @@ +// 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/. + +//! Tests BGP status APIs. + +use dropshot::test_util::ClientTestContext; +use http::Method; +use nexus_test_interface::NexusServer; +use nexus_test_utils::http_testing::{AuthnMode, NexusRequest, RequestBuilder}; +use nexus_test_utils_macros::nexus_test; +use nexus_types::external_api::networking::{ + BgpMessageHistories, SwitchError, SwitchResult, SwitchResults, + UnnumberedInterfaces, UnnumberedManagerState, +}; +use serde_json::{Value, json}; + +type ControlPlaneTestContext = + nexus_test_utils::ControlPlaneTestContext; + +#[nexus_test] +async fn test_bgp_unnumbered_status_by_switch( + cptestctx: &ControlPlaneTestContext, +) { + let client = &cptestctx.external_client; + + let manager = NexusRequest::object_get( + client, + "/v1/system/networking/bgp-unnumbered-manager", + ) + .authn_as(AuthnMode::PrivilegedUser) + .execute_and_parse_unwrap::>() + .await; + match manager.switch0 { + SwitchResult::Ok { value } => { + assert!(value.interfaces.is_empty()); + } + SwitchResult::Err { error } => { + panic!("switch0 manager-state query failed: {error:?}") + } + } + assert_mgd_unresolved(manager.switch1); + + let interfaces = NexusRequest::object_get( + client, + "/v1/system/networking/bgp-unnumbered-interfaces", + ) + .authn_as(AuthnMode::PrivilegedUser) + .execute_and_parse_unwrap::>() + .await; + match interfaces.switch0 { + SwitchResult::Ok { value: UnnumberedInterfaces(interfaces) } => { + assert!(interfaces.is_empty()); + } + SwitchResult::Err { error } => { + panic!("switch0 interface query failed: {error:?}") + } + } + assert_mgd_unresolved(interfaces.switch1); +} + +#[nexus_test] +async fn test_bgp_aggregate_status_api_versions( + cptestctx: &ControlPlaneTestContext, +) { + let client = &cptestctx.external_client; + let available_empty = json!({ + "switch0": { "status": "ok", "value": [] }, + "switch1": { + "status": "err", + "error": { + "type": "mgd_unresolved", + }, + }, + }); + for url in [ + "/v1/system/networking/bgp-status", + "/v1/system/networking/bgp-exported", + "/v1/system/networking/bgp-imported?asn=64512", + ] { + let response: Value = NexusRequest::object_get(client, url) + .authn_as(AuthnMode::PrivilegedUser) + .execute_and_parse_unwrap() + .await; + assert_eq!(response, available_empty, "unexpected response from {url}"); + } + let response = NexusRequest::object_get( + client, + "/v1/system/networking/bgp-message-history?asn=64512", + ) + .authn_as(AuthnMode::PrivilegedUser) + .execute_and_parse_unwrap::>() + .await; + match response.switch0 { + SwitchResult::Err { + error: + SwitchError::RequestRejected { + error_code, + message, + upstream_request_id, + }, + } => { + assert_eq!(error_code, None); + assert_eq!(message, "Not Found"); + assert!(!upstream_request_id.is_empty()); + } + SwitchResult::Ok { .. } => panic!("unknown ASN unexpectedly succeeded"), + SwitchResult::Err { error } => { + panic!("unknown ASN returned an unexpected error: {error:?}") + } + } + assert_mgd_unresolved(response.switch1); + + let server_addr = cptestctx.server.get_http_server_external_address(); + let versioned_client = + ClientTestContext::new(server_addr, cptestctx.logctx.log.clone()); + let old_version = + nexus_external_api::VERSION_BGP_UNNUMBERED_PEERS.to_string(); + + for url in [ + "/v1/system/networking/bgp-status", + "/v1/system/networking/bgp-exported", + "/v1/system/networking/bgp-imported?asn=64512", + ] { + let response = + versioned_get(&versioned_client, url, &old_version).await; + assert_eq!(response, json!([]), "unexpected response from {url}"); + } + let response = versioned_get( + &versioned_client, + "/v1/system/networking/bgp-message-history?asn=64512", + &old_version, + ) + .await; + assert_eq!( + response, + json!({ + "switch_histories": [], + }) + ); + + let initial_version = nexus_external_api::VERSION_INITIAL.to_string(); + let response = versioned_get( + &versioned_client, + "/v1/system/networking/bgp-routes-ipv4?asn=64512", + &initial_version, + ) + .await; + assert_eq!(response, json!([])); +} + +async fn versioned_get( + client: &ClientTestContext, + url: &str, + version: &str, +) -> Value { + NexusRequest::new( + RequestBuilder::new(client, Method::GET, url) + .header(omicron_common::api::VERSION_HEADER, version), + ) + .authn_as(AuthnMode::PrivilegedUser) + .execute_and_parse_unwrap() + .await +} + +fn assert_mgd_unresolved(result: SwitchResult) { + match result { + SwitchResult::Err { error: SwitchError::MgdUnresolved } => {} + SwitchResult::Ok { .. } => { + panic!("switch1 was unexpectedly available"); + } + SwitchResult::Err { error } => { + panic!("switch1 failed unexpectedly: {error:?}") + } + } +} diff --git a/nexus/tests/integration_tests/endpoints.rs b/nexus/tests/integration_tests/endpoints.rs index 20f6e856b4e..f9be14148db 100644 --- a/nexus/tests/integration_tests/endpoints.rs +++ b/nexus/tests/integration_tests/endpoints.rs @@ -1045,6 +1045,12 @@ pub const DEMO_BGP_ROUTES_IPV4_URL: &'static str = "/v1/system/networking/bgp-imported?asn=47"; pub const DEMO_BGP_MESSAGE_HISTORY_URL: &'static str = "/v1/system/networking/bgp-message-history?asn=47"; +pub const DEMO_BGP_UNNUMBERED_MANAGER_URL: &'static str = + "/v1/system/networking/bgp-unnumbered-manager"; +pub const DEMO_BGP_UNNUMBERED_INTERFACES_URL: &'static str = + "/v1/system/networking/bgp-unnumbered-interfaces"; +pub const DEMO_BGP_UNNUMBERED_INTERFACE_URL: &'static str = + "/v1/system/networking/bgp-unnumbered-interfaces/switch0/qsfp0"; pub const DEMO_BFD_STATUS_URL: &'static str = "/v1/system/networking/bfd-status"; @@ -3488,6 +3494,24 @@ pub static VERIFY_ENDPOINTS: LazyLock> = LazyLock::new( unprivileged_access: UnprivilegedAccess::None, allowed_methods: vec![AllowedMethod::GetNonexistent], }, + VerifyEndpoint { + url: &DEMO_BGP_UNNUMBERED_MANAGER_URL, + visibility: Visibility::Public, + unprivileged_access: UnprivilegedAccess::None, + allowed_methods: vec![AllowedMethod::GetNonexistent], + }, + VerifyEndpoint { + url: &DEMO_BGP_UNNUMBERED_INTERFACES_URL, + visibility: Visibility::Public, + unprivileged_access: UnprivilegedAccess::None, + allowed_methods: vec![AllowedMethod::GetNonexistent], + }, + VerifyEndpoint { + url: &DEMO_BGP_UNNUMBERED_INTERFACE_URL, + visibility: Visibility::Public, + unprivileged_access: UnprivilegedAccess::None, + allowed_methods: vec![AllowedMethod::GetNonexistent], + }, VerifyEndpoint { url: &DEMO_BFD_STATUS_URL, visibility: Visibility::Public, diff --git a/nexus/tests/integration_tests/mod.rs b/nexus/tests/integration_tests/mod.rs index 195ccbb0f11..0a56f1ebb90 100644 --- a/nexus/tests/integration_tests/mod.rs +++ b/nexus/tests/integration_tests/mod.rs @@ -16,6 +16,7 @@ mod authn_http; mod authz; mod basic; mod bfd; +mod bgp_unnumbered; mod certificates; mod cockroach; mod commands; diff --git a/nexus/types/versions/src/bgp_unnumbered_status_2026_07_29/bfd.rs b/nexus/types/versions/src/bgp_unnumbered_status_2026_07_29/bfd.rs new file mode 100644 index 00000000000..a9e904ae994 --- /dev/null +++ b/nexus/types/versions/src/bgp_unnumbered_status_2026_07_29/bfd.rs @@ -0,0 +1,114 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +use crate::v2025_11_20_00::bfd::BfdState; +use crate::v2026_03_06_01; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use sled_agent_types_versions::v1::early_networking::BfdMode; +use std::net::IpAddr; + +use super::networking::{SwitchError, SwitchResults}; + +#[derive( + Clone, + Debug, + Serialize, + Deserialize, + JsonSchema, + PartialOrd, + Ord, + PartialEq, + Eq, +)] +pub struct BfdPeerStatus { + pub peer: IpAddr, + pub state: BfdState, + pub local: Option, + pub detection_threshold: u8, + pub required_rx: u64, + pub mode: BfdMode, +} + +#[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)] +pub struct BfdPeerStatuses(pub Vec); + +impl TryFrom> + for Vec +{ + type Error = SwitchError; + + fn try_from( + results: SwitchResults, + ) -> Result { + let mut statuses = Vec::new(); + for (switch_slot, result) in results { + let value = match result.into_result() { + Ok(value) => value, + Err(SwitchError::MgdUnresolved) => { + continue; + } + Err(error) => return Err(error), + }; + statuses.extend(value.0.into_iter().map(|status| { + v2026_03_06_01::bfd::BfdStatus { + peer: status.peer, + state: status.state, + switch_slot, + local: status.local, + detection_threshold: status.detection_threshold, + required_rx: status.required_rx, + mode: status.mode, + } + })); + } + Ok(statuses) + } +} + +#[cfg(test)] +mod tests { + use super::super::networking::{SwitchError, SwitchResult}; + use super::*; + use sled_agent_types_versions::v1::early_networking::SwitchSlot; + use std::net::{IpAddr, Ipv6Addr}; + + #[test] + fn conversion_restores_switch_slots_and_omits_unavailable_switches() { + let peer = IpAddr::V6(Ipv6Addr::LOCALHOST); + let results = SwitchResults { + switch0: SwitchResult::Ok { + value: BfdPeerStatuses(vec![BfdPeerStatus { + peer, + state: BfdState::Up, + local: None, + detection_threshold: 3, + required_rx: 1000, + mode: BfdMode::SingleHop, + }]), + }, + switch1: SwitchResult::Err { error: SwitchError::MgdUnresolved }, + }; + + let statuses = + Vec::::try_from(results).unwrap(); + assert_eq!(statuses.len(), 1); + assert_eq!(statuses[0].peer, peer); + assert_eq!(statuses[0].switch_slot, SwitchSlot::Switch0); + assert_eq!(statuses[0].state, BfdState::Up); + } + + #[test] + fn conversion_preserves_query_failure() { + let results = SwitchResults { + switch0: SwitchResult::Err { error: SwitchError::QueryFailed }, + switch1: SwitchResult::Ok { value: BfdPeerStatuses(Vec::new()) }, + }; + + assert_eq!( + Vec::::try_from(results), + Err(SwitchError::QueryFailed), + ); + } +} diff --git a/nexus/types/versions/src/bgp_unnumbered_status_2026_07_29/mod.rs b/nexus/types/versions/src/bgp_unnumbered_status_2026_07_29/mod.rs new file mode 100644 index 00000000000..38ebeb1b1fb --- /dev/null +++ b/nexus/types/versions/src/bgp_unnumbered_status_2026_07_29/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/. + +//! BGP unnumbered and BFD status types. + +pub mod bfd; +pub mod networking; diff --git a/nexus/types/versions/src/bgp_unnumbered_status_2026_07_29/networking.rs b/nexus/types/versions/src/bgp_unnumbered_status_2026_07_29/networking.rs new file mode 100644 index 00000000000..7a1a963e5ba --- /dev/null +++ b/nexus/types/versions/src/bgp_unnumbered_status_2026_07_29/networking.rs @@ -0,0 +1,717 @@ +// 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/. + +//! BGP unnumbered status networking types. + +use std::collections::{BTreeMap, HashMap}; +use std::net::{IpAddr, Ipv6Addr}; +use std::time::Duration; + +use mg_admin_client::types::{ + RouterDiscoveryRuntimeState as MgRouterDiscoveryRuntimeState, + UnnumberedInterface as MgUnnumberedInterface, + UnnumberedInterfaceStatus as MgUnnumberedInterfaceStatus, + UnnumberedManagerState as MgUnnumberedManagerState, +}; +use mg_api_types::unnumbered::DiscoveredRouter as MgDiscoveredRouter; +use omicron_common::tfport::TfportInterfaceName; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use sled_agent_types_versions::v1::early_networking::SwitchSlot; + +fn nexus_interface_name(interface: String) -> String { + match interface.parse::() { + Ok(interface_name) if interface_name.link_id() == 0 => { + interface_name.port_name().to_owned() + } + Ok(_) | Err(_) => interface, + } +} + +#[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)] +pub struct UnnumberedInterfacePath { + pub switch_slot: SwitchSlot, + pub interface_name: String, +} + +/// Results of querying both switches. +// +// A successful Nexus response means that Nexus obtained authoritative +// outcomes from enough of the switch fanout to return this value. It does not +// mean that either switch returned a value: both fields can contain errors +// when both switches reject a query, for example when a requested BGP ASN does +// not exist. Nexus returns a top-level server error only when both switch +// queries fail operationally. +#[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)] +#[schemars(rename = "{T}SwitchResults")] +pub struct SwitchResults { + pub switch0: SwitchResult, + pub switch1: SwitchResult, +} + +/// The outcome of querying one switch. +#[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)] +#[serde(tag = "status", rename_all = "snake_case")] +#[schemars(rename = "{T}SwitchResult")] +pub enum SwitchResult { + Ok { value: T }, + Err { error: SwitchError }, +} + +/// A normalized failure from one switch query. +#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum SwitchError { + /// The switch authoritatively rejected the query. + RequestRejected { + error_code: Option, + message: String, + upstream_request_id: String, + }, + + /// The switch returned an operational error. + UpstreamUnavailable { + error_code: Option, + message: String, + upstream_request_id: String, + }, + + /// Nexus could not resolve an MGD client for the switch. + MgdUnresolved, + + /// Nexus could not obtain a valid response from the switch. + QueryFailed, +} + +impl From> + for SwitchError +{ + fn from( + error: mg_admin_client::Error, + ) -> Self { + match error { + mg_admin_client::Error::ErrorResponse(response) + if is_authoritative_rejection(response.status()) => + { + Self::RequestRejected { + error_code: response.error_code.clone(), + message: response.message.clone(), + upstream_request_id: response.request_id.clone(), + } + } + mg_admin_client::Error::ErrorResponse(response) => { + Self::UpstreamUnavailable { + error_code: response.error_code.clone(), + message: response.message.clone(), + upstream_request_id: response.request_id.clone(), + } + } + _ => Self::QueryFailed, + } + } +} + +fn is_authoritative_rejection(status: http::StatusCode) -> bool { + // These statuses describe the request or the queried resource. Treat all + // other error responses, including authentication, timeout, throttling, + // and server failures, as operational failures of the upstream service. + status == http::StatusCode::BAD_REQUEST + || status == http::StatusCode::NOT_FOUND + || status == http::StatusCode::CONFLICT + || status == http::StatusCode::GONE + || status == http::StatusCode::UNPROCESSABLE_ENTITY +} + +/// The current status of a BGP peer. +#[derive(Clone, Debug, Deserialize, JsonSchema, Serialize, PartialEq)] +pub struct BgpPeerStatus { + /// IP address of the peer. + pub addr: IpAddr, + + /// Interface name. + pub peer_id: String, + + /// Local autonomous system number. + pub local_asn: u32, + + /// Remote autonomous system number. + pub remote_asn: u32, + + /// State of the peer. + pub state: crate::v2025_12_12_00::networking::BgpPeerState, + + /// Time of last state change. + pub state_duration_millis: u64, +} + +#[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)] +pub struct BgpPeerStatuses(pub Vec); + +/// A route exported to a BGP peer. +#[derive(Clone, Debug, Deserialize, JsonSchema, Serialize, PartialEq)] +pub struct BgpExported { + /// Identifier for the BGP peer. + pub peer_id: String, + + /// The destination network prefix. + pub prefix: oxnet::IpNet, +} + +#[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)] +pub struct BgpExportedRoutes(pub Vec); + +/// BGP message history indexed by peer address. +#[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)] +pub struct BgpMessageHistories( + pub HashMap, +); + +/// A route imported from a BGP peer. +#[derive(Clone, Debug, Deserialize, JsonSchema, Serialize, PartialEq)] +pub struct BgpImported { + /// The destination network prefix. + pub prefix: oxnet::IpNet, + + /// The nexthop the prefix is reachable through. + pub nexthop: IpAddr, + + /// BGP identifier of the originating router. + pub id: u32, +} + +#[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)] +pub struct BgpImportedRoutes(pub Vec); + +impl From> + for Vec +{ + fn from(results: SwitchResults) -> Self { + let mut statuses = Vec::new(); + for (switch, result) in results { + let SwitchResult::Ok { value } = result else { + continue; + }; + statuses.extend(value.0.into_iter().map(|status| { + crate::v2026_02_13_01::networking::BgpPeerStatus { + addr: status.addr, + peer_id: status.peer_id, + local_asn: status.local_asn, + remote_asn: status.remote_asn, + state: status.state, + state_duration_millis: status.state_duration_millis, + switch, + } + })); + } + statuses + } +} + +impl From> + for Vec +{ + fn from(results: SwitchResults) -> Self { + let mut routes = Vec::new(); + for (switch, result) in results { + let SwitchResult::Ok { value } = result else { + continue; + }; + routes.extend(value.0.into_iter().map(|route| { + crate::v2026_02_13_01::networking::BgpExported { + peer_id: route.peer_id, + switch, + prefix: route.prefix, + } + })); + } + routes + } +} + +impl From> + for crate::v2025_11_20_00::networking::AggregateBgpMessageHistory +{ + fn from(results: SwitchResults) -> Self { + let mut switch_histories = Vec::new(); + for (switch, result) in results { + let SwitchResult::Ok { value } = result else { + continue; + }; + switch_histories.push( + crate::v2025_11_20_00::networking::SwitchBgpHistory { + switch, + history: value.0, + }, + ); + } + Self { switch_histories } + } +} + +impl From> + for Vec +{ + fn from(results: SwitchResults) -> Self { + let mut routes = Vec::new(); + for (switch, result) in results { + let SwitchResult::Ok { value } = result else { + continue; + }; + routes.extend(value.0.into_iter().map(|route| { + crate::v2026_02_13_01::networking::BgpImported { + prefix: route.prefix, + nexthop: route.nexthop, + id: route.id, + switch, + } + })); + } + routes + } +} + +#[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)] +pub struct UnnumberedManagerState { + pub monitor_running: bool, + pub interfaces: BTreeMap, +} + +impl From for UnnumberedManagerState { + fn from(value: MgUnnumberedManagerState) -> Self { + let MgUnnumberedManagerState { monitor_running, interfaces } = value; + + Self { + monitor_running, + interfaces: interfaces + .into_iter() + .map(|(interface, status)| { + (nexus_interface_name(interface), status.into()) + }) + .collect(), + } + } +} + +/// Status of an interface configured for unnumbered operation. +#[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum UnnumberedInterfaceStatus { + /// Configured but not yet available on the system. + Pending { + /// Configured router lifetime (seconds) + router_lifetime: u16, + }, + /// Active for unnumbered operation. + Active { + /// Local IPv6 link-local address + local_address: Ipv6Addr, + /// IPv6 scope ID (interface index) + scope_id: u32, + /// Router lifetime advertised by this router (seconds) + router_lifetime: u16, + /// Information about the discovered peer. None if no peer has been + /// discovered or the discovered entry has expired. + discovered_peer: Option, + /// Runtime state for router discovery on this interface + runtime_state: RouterDiscoveryRuntimeState, + }, +} + +impl From for UnnumberedInterfaceStatus { + fn from(value: MgUnnumberedInterfaceStatus) -> Self { + match value { + MgUnnumberedInterfaceStatus::Pending { router_lifetime } => { + Self::Pending { router_lifetime } + } + MgUnnumberedInterfaceStatus::Active { + local_address, + scope_id, + router_lifetime, + discovered_peer, + runtime_state, + } => Self::Active { + local_address, + scope_id, + router_lifetime, + discovered_peer: discovered_peer.map(Into::into), + runtime_state: runtime_state.into(), + }, + } + } +} + +#[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)] +pub struct PendingUnnumberedInterface { + /// Interface name + pub interface: String, + /// Configured router lifetime (seconds) + pub router_lifetime: u16, +} + +#[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)] +pub struct RouterDiscoveryRuntimeState { + /// ICMPv6 Router Advertisement transmit loop is running + pub tx: bool, + /// ICMPv6 Router Advertisement receive loop is running + pub rx: bool, +} + +impl From for RouterDiscoveryRuntimeState { + fn from(value: MgRouterDiscoveryRuntimeState) -> Self { + let MgRouterDiscoveryRuntimeState { tx_running, rx_running } = value; + Self { tx: tx_running, rx: rx_running } + } +} + +#[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)] +pub struct UnnumberedInterface { + /// Interface name (e.g. "qsfp0") + pub interface: String, + /// IPv6 link-local address of this interface. + pub local_address: Ipv6Addr, + /// Router Lifetime advertised in ICMPv6 Router Advertisements sent on this + /// interface. + pub router_lifetime: u16, + /// Information about discovered peer + pub discovered_peer: Option, + /// State of rx/tx loops (None if interface not active in NDP) + pub ndp_state: RouterDiscoveryRuntimeState, +} + +#[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)] +pub struct UnnumberedInterfaces(pub Vec); + +#[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)] +pub struct SwitchUnnumberedInterface { + pub switch_slot: SwitchSlot, + pub interface: UnnumberedInterface, +} + +impl From for UnnumberedInterface { + fn from(value: MgUnnumberedInterface) -> Self { + let MgUnnumberedInterface { + interface, + local_address, + scope_id: _, + router_lifetime, + discovered_peer, + runtime_state, + } = value; + + Self { + interface: nexus_interface_name(interface), + local_address, + router_lifetime, + discovered_peer: discovered_peer.map(Into::into), + ndp_state: runtime_state.into(), + } + } +} + +/// Information about a router discovered through router advertisements. +#[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)] +pub struct DiscoveredRouter { + /// Router IPv6 address + pub address: Ipv6Addr, + /// Time elapsed since the router was first discovered + pub time_since_discovered: Duration, + /// Time elapsed since the most recent Router Advertisement was received + pub time_since_last_rx: Duration, + /// Effective reachable time governing expiry of this entry + pub effective_reachable_time: Duration, + /// Router lifetime from RA + pub router_lifetime: Duration, + /// Reachable time from RA + pub reachable_time: Duration, + /// Retransmit timer from RA + pub retrans_timer: Duration, +} + +impl From for DiscoveredRouter { + fn from(value: MgDiscoveredRouter) -> Self { + let MgDiscoveredRouter { + address, + time_since_discovered, + time_since_last_rx, + effective_reachable_time, + router_lifetime, + reachable_time, + retrans_timer, + } = value; + Self { + address, + time_since_discovered, + time_since_last_rx, + effective_reachable_time, + router_lifetime, + reachable_time, + retrans_timer, + } + } +} + +#[cfg(test)] +mod tests { + use std::net::{IpAddr, Ipv4Addr}; + + use http::{HeaderMap, StatusCode}; + use sled_agent_types_versions::v1::early_networking::SwitchSlot; + + use super::*; + + const ERROR_CODE: &str = "ObjectNotFound"; + const ERROR_MESSAGE: &str = "requested object does not exist"; + const REQUEST_ID: &str = "upstream-request-id"; + + fn mgd_error_response( + status: StatusCode, + ) -> mg_admin_client::Error { + mg_admin_client::Error::ErrorResponse( + mg_admin_client::ResponseValue::new( + mg_admin_client::types::Error { + error_code: Some(ERROR_CODE.to_owned()), + message: ERROR_MESSAGE.to_owned(), + request_id: REQUEST_ID.to_owned(), + }, + status, + HeaderMap::new(), + ), + ) + } + + #[test] + fn authoritative_mgd_responses_are_rejected_requests() { + for status in [ + StatusCode::BAD_REQUEST, + StatusCode::NOT_FOUND, + StatusCode::CONFLICT, + StatusCode::GONE, + StatusCode::UNPROCESSABLE_ENTITY, + ] { + let error = SwitchError::from(mgd_error_response(status)); + + assert_eq!( + error, + SwitchError::RequestRejected { + error_code: Some(ERROR_CODE.to_owned()), + message: ERROR_MESSAGE.to_owned(), + upstream_request_id: REQUEST_ID.to_owned(), + }, + "unexpected classification for HTTP {status}", + ); + } + } + + #[test] + fn operational_mgd_responses_are_upstream_unavailable() { + for status in [ + StatusCode::UNAUTHORIZED, + StatusCode::FORBIDDEN, + StatusCode::REQUEST_TIMEOUT, + StatusCode::TOO_MANY_REQUESTS, + StatusCode::INTERNAL_SERVER_ERROR, + StatusCode::SERVICE_UNAVAILABLE, + ] { + let error = SwitchError::from(mgd_error_response(status)); + + assert_eq!( + error, + SwitchError::UpstreamUnavailable { + error_code: Some(ERROR_CODE.to_owned()), + message: ERROR_MESSAGE.to_owned(), + upstream_request_id: REQUEST_ID.to_owned(), + }, + "unexpected classification for HTTP {status}", + ); + } + } + + #[test] + fn converts_link_zero_tfport_name() { + assert_eq!(nexus_interface_name("tfportqsfp0_0".into()), "qsfp0"); + } + + #[test] + fn preserves_nonzero_and_malformed_tfport_names() { + assert_eq!( + nexus_interface_name("tfportqsfp0_1".into()), + "tfportqsfp0_1" + ); + assert_eq!(nexus_interface_name("tfport-bad".into()), "tfport-bad"); + } + + #[test] + fn peer_status_conversion_restores_switch_slot_and_omits_query_failure() { + let addr = IpAddr::V4(Ipv4Addr::LOCALHOST); + let results = SwitchResults { + switch0: SwitchResult::Ok { + value: BgpPeerStatuses(vec![BgpPeerStatus { + addr, + peer_id: "tfportqsfp0_0".to_owned(), + local_asn: 64512, + remote_asn: 64513, + state: crate::v2025_12_12_00::networking::BgpPeerState::Established, + state_duration_millis: 100, + }]), + }, + switch1: SwitchResult::Err { + error: SwitchError::QueryFailed, + }, + }; + + let statuses: Vec = + results.into(); + assert_eq!( + statuses, + vec![crate::v2026_02_13_01::networking::BgpPeerStatus { + addr, + peer_id: "tfportqsfp0_0".to_owned(), + local_asn: 64512, + remote_asn: 64513, + state: + crate::v2025_12_12_00::networking::BgpPeerState::Established, + state_duration_millis: 100, + switch: SwitchSlot::Switch0, + }] + ); + + let collision_state_statuses: Vec< + crate::v2025_12_12_00::networking::BgpPeerStatus, + > = statuses.into_iter().map(Into::into).collect(); + assert_eq!(collision_state_statuses.len(), 1); + assert_eq!(collision_state_statuses[0].addr, addr); + assert_eq!(collision_state_statuses[0].switch, SwitchSlot::Switch0); + } + + #[test] + fn exported_routes_conversion_restores_switch_slot_and_omits_unresolved() { + let prefix = "192.0.2.0/24".parse().unwrap(); + let results = SwitchResults { + switch0: SwitchResult::Err { error: SwitchError::MgdUnresolved }, + switch1: SwitchResult::Ok { + value: BgpExportedRoutes(vec![BgpExported { + peer_id: "peer".to_owned(), + prefix, + }]), + }, + }; + + let routes: Vec = + results.into(); + assert_eq!( + routes, + vec![crate::v2026_02_13_01::networking::BgpExported { + peer_id: "peer".to_owned(), + switch: SwitchSlot::Switch1, + prefix, + }] + ); + + let legacy: crate::v2025_11_20_00::networking::BgpExported = + routes.into(); + assert_eq!( + legacy.exports["peer"], + vec!["192.0.2.0/24".parse().unwrap()] + ); + } + + #[test] + fn imported_routes_conversion_uses_switch_order() { + let route = |id| BgpImported { + prefix: "192.0.2.0/24".parse().unwrap(), + nexthop: IpAddr::V4(Ipv4Addr::LOCALHOST), + id, + }; + let results = SwitchResults { + switch0: SwitchResult::Ok { + value: BgpImportedRoutes(vec![route(0)]), + }, + switch1: SwitchResult::Ok { + value: BgpImportedRoutes(vec![route(1)]), + }, + }; + + let routes: Vec = + results.into(); + assert_eq!( + routes, + vec![ + crate::v2026_02_13_01::networking::BgpImported { + prefix: "192.0.2.0/24".parse().unwrap(), + nexthop: IpAddr::V4(Ipv4Addr::LOCALHOST), + id: 0, + switch: SwitchSlot::Switch0, + }, + crate::v2026_02_13_01::networking::BgpImported { + prefix: "192.0.2.0/24".parse().unwrap(), + nexthop: IpAddr::V4(Ipv4Addr::LOCALHOST), + id: 1, + switch: SwitchSlot::Switch1, + }, + ] + ); + + let ipv4_routes: Vec< + crate::v2025_11_20_00::networking::BgpImportedRouteIpv4, + > = routes + .into_iter() + .map(TryInto::try_into) + .collect::>() + .unwrap(); + assert_eq!(ipv4_routes.len(), 2); + assert_eq!(ipv4_routes[0].switch, SwitchSlot::Switch0); + assert_eq!(ipv4_routes[1].switch, SwitchSlot::Switch1); + } + + #[test] + fn message_history_conversion_omits_unavailable_switch() { + let results = SwitchResults { + switch0: SwitchResult::Ok { + value: BgpMessageHistories(HashMap::new()), + }, + switch1: SwitchResult::Err { error: SwitchError::QueryFailed }, + }; + + let history: crate::v2025_11_20_00::networking::AggregateBgpMessageHistory = + results.into(); + assert_eq!(history.switch_histories.len(), 1); + assert_eq!(history.switch_histories[0].switch, SwitchSlot::Switch0); + } + + #[test] + fn switch_errors_preserve_normalized_details_on_the_wire() { + let unresolved = SwitchResult::::Err { + error: SwitchError::MgdUnresolved, + }; + let rejected = SwitchResult::::Err { + error: SwitchError::RequestRejected { + error_code: Some("ObjectNotFound".to_owned()), + message: "ASN not found".to_owned(), + upstream_request_id: "request-id".to_owned(), + }, + }; + + assert_eq!( + serde_json::to_value(unresolved).unwrap(), + serde_json::json!({ + "status": "err", + "error": { + "type": "mgd_unresolved", + }, + }) + ); + assert_eq!( + serde_json::to_value(rejected).unwrap(), + serde_json::json!({ + "status": "err", + "error": { + "type": "request_rejected", + "error_code": "ObjectNotFound", + "message": "ASN not found", + "upstream_request_id": "request-id", + }, + }) + ); + } +} diff --git a/nexus/types/versions/src/impls/networking.rs b/nexus/types/versions/src/impls/networking.rs index 7c1d03d6933..f7a910e23d7 100644 --- a/nexus/types/versions/src/impls/networking.rs +++ b/nexus/types/versions/src/impls/networking.rs @@ -6,8 +6,71 @@ use crate::latest; use omicron_common::api::external::IdentityMetadataCreateParams; use omicron_common::api::external::SimpleIdentity; use oxnet::IpNet; +use sled_agent_types_versions::v1::early_networking::SwitchSlot; use uuid::Uuid; +impl latest::networking::SwitchResult { + pub fn into_result(self) -> Result { + match self { + Self::Ok { value } => Ok(value), + Self::Err { error } => Err(error), + } + } + + pub fn is_operational_failure(&self) -> bool { + matches!( + self, + Self::Err { + error: latest::networking::SwitchError::UpstreamUnavailable { .. } + | latest::networking::SwitchError::MgdUnresolved + | latest::networking::SwitchError::QueryFailed, + } + ) + } +} + +impl latest::networking::SwitchResults { + pub fn iter( + &self, + ) -> std::array::IntoIter< + (SwitchSlot, &latest::networking::SwitchResult), + 2, + > { + [ + (SwitchSlot::Switch0, &self.switch0), + (SwitchSlot::Switch1, &self.switch1), + ] + .into_iter() + } + + pub fn all_operational_failures(&self) -> bool { + self.switch0.is_operational_failure() + && self.switch1.is_operational_failure() + } +} + +impl<'a, T> IntoIterator for &'a latest::networking::SwitchResults { + type Item = (SwitchSlot, &'a latest::networking::SwitchResult); + type IntoIter = std::array::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + self.iter() + } +} + +impl IntoIterator for latest::networking::SwitchResults { + type Item = (SwitchSlot, latest::networking::SwitchResult); + type IntoIter = std::array::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + [ + (SwitchSlot::Switch0, self.switch0), + (SwitchSlot::Switch1, self.switch1), + ] + .into_iter() + } +} + impl From for latest::networking::AddressLotBlockCreate { fn from(ipnet: IpNet) -> Self { match ipnet { @@ -47,14 +110,6 @@ impl latest::networking::BgpMessageHistory { } } -impl latest::networking::AggregateBgpMessageHistory { - pub fn new( - switch_histories: Vec, - ) -> Self { - Self { switch_histories } - } -} - impl latest::networking::SwitchPortSettingsCreate { pub fn new(identity: IdentityMetadataCreateParams) -> Self { Self { @@ -77,3 +132,39 @@ impl SimpleIdentity for latest::networking::LldpNeighbor { self.id } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn switch_results_iterators_include_slots_in_order() { + use latest::networking::{SwitchResult, SwitchResults}; + + let results = SwitchResults { + switch0: SwitchResult::Ok { value: 0 }, + switch1: SwitchResult::Ok { value: 1 }, + }; + + let borrowed: Vec<_> = results + .iter() + .map(|(slot, result)| { + let SwitchResult::Ok { value } = result else { + panic!("expected an available result"); + }; + (slot, *value) + }) + .collect(); + assert_eq!( + borrowed, + vec![(SwitchSlot::Switch0, 0), (SwitchSlot::Switch1, 1)] + ); + assert_eq!((&results).into_iter().count(), 2); + + let owned: Vec<_> = results + .into_iter() + .map(|(slot, result)| (slot, result.into_result().unwrap())) + .collect(); + assert_eq!(owned, borrowed); + } +} diff --git a/nexus/types/versions/src/latest.rs b/nexus/types/versions/src/latest.rs index 38d11bb09cf..8a177328fbe 100644 --- a/nexus/types/versions/src/latest.rs +++ b/nexus/types/versions/src/latest.rs @@ -73,7 +73,9 @@ pub mod audit { pub mod bfd { pub use crate::v2025_11_20_00::bfd::BfdState; - pub use crate::v2026_03_06_01::bfd::BfdStatus; + + pub use crate::v2026_07_29_00::bfd::BfdPeerStatus; + pub use crate::v2026_07_29_00::bfd::BfdPeerStatuses; } pub mod device { @@ -273,7 +275,6 @@ pub mod networking { pub use crate::v2025_11_20_00::networking::AddressLotKind; pub use crate::v2025_11_20_00::networking::AddressLotSelector; pub use crate::v2025_11_20_00::networking::AddressLotViewResponse; - pub use crate::v2025_11_20_00::networking::AggregateBgpMessageHistory; pub use crate::v2025_11_20_00::networking::BgpAnnounceListSelector; pub use crate::v2025_11_20_00::networking::BgpAnnounceSet; pub use crate::v2025_11_20_00::networking::BgpAnnounceSetCreate; @@ -290,7 +291,6 @@ pub mod networking { pub use crate::v2025_11_20_00::networking::LldpNeighbor; pub use crate::v2025_11_20_00::networking::Route; pub use crate::v2025_11_20_00::networking::RouteConfig; - pub use crate::v2025_11_20_00::networking::SwitchBgpHistory; pub use crate::v2025_11_20_00::networking::SwitchInterfaceConfigCreate; pub use crate::v2025_11_20_00::networking::SwitchInterfaceKind; pub use crate::v2025_11_20_00::networking::SwitchPortAddressConfig; @@ -316,10 +316,6 @@ pub mod networking { pub use crate::v2026_02_13_01::networking::BgpConfig; pub use crate::v2026_02_13_01::networking::BgpConfigCreate; - pub use crate::v2026_02_13_01::networking::BgpExported; - pub use crate::v2026_02_13_01::networking::BgpImported; - pub use crate::v2026_02_13_01::networking::BgpPeerStatus; - pub use crate::v2026_03_06_01::networking::BfdSessionDisable; pub use crate::v2026_03_06_01::networking::BfdSessionEnable; pub use crate::v2026_03_06_01::networking::LldpPortPathSelector; @@ -338,6 +334,26 @@ pub mod networking { pub use crate::v2026_05_07_00::networking::SwitchPortSettings; pub use crate::v2026_06_10_00::networking::BgpConfigUpdate; + + pub use crate::v2026_07_29_00::networking::BgpExported; + pub use crate::v2026_07_29_00::networking::BgpExportedRoutes; + pub use crate::v2026_07_29_00::networking::BgpImported; + pub use crate::v2026_07_29_00::networking::BgpImportedRoutes; + pub use crate::v2026_07_29_00::networking::BgpMessageHistories; + pub use crate::v2026_07_29_00::networking::BgpPeerStatus; + pub use crate::v2026_07_29_00::networking::BgpPeerStatuses; + pub use crate::v2026_07_29_00::networking::DiscoveredRouter; + pub use crate::v2026_07_29_00::networking::PendingUnnumberedInterface; + pub use crate::v2026_07_29_00::networking::RouterDiscoveryRuntimeState; + pub use crate::v2026_07_29_00::networking::SwitchError; + pub use crate::v2026_07_29_00::networking::SwitchResult; + pub use crate::v2026_07_29_00::networking::SwitchResults; + pub use crate::v2026_07_29_00::networking::SwitchUnnumberedInterface; + pub use crate::v2026_07_29_00::networking::UnnumberedInterface; + pub use crate::v2026_07_29_00::networking::UnnumberedInterfacePath; + pub use crate::v2026_07_29_00::networking::UnnumberedInterfaceStatus; + pub use crate::v2026_07_29_00::networking::UnnumberedInterfaces; + pub use crate::v2026_07_29_00::networking::UnnumberedManagerState; } pub mod oxql { diff --git a/nexus/types/versions/src/lib.rs b/nexus/types/versions/src/lib.rs index 231850a6c55..56ee27a224f 100644 --- a/nexus/types/versions/src/lib.rs +++ b/nexus/types/versions/src/lib.rs @@ -97,6 +97,8 @@ pub mod v2026_06_08_00; pub mod v2026_06_10_00; #[path = "add_system_ip_pool_apis/mod.rs"] pub mod v2026_06_11_00; +#[path = "bgp_unnumbered_status_2026_07_29/mod.rs"] +pub mod v2026_07_29_00; #[path = "sled_slot/mod.rs"] pub mod v2026_08_12_00; #[path = "alert_list/mod.rs"] diff --git a/openapi/nexus/nexus-2026081700.0.0-2315cf.json.gitstub b/openapi/nexus/nexus-2026081700.0.0-2315cf.json.gitstub new file mode 100644 index 00000000000..2ac5b8257a1 --- /dev/null +++ b/openapi/nexus/nexus-2026081700.0.0-2315cf.json.gitstub @@ -0,0 +1 @@ +6db4c7ead553e1792bb8ebe4e860b6087b68e460:openapi/nexus/nexus-2026081700.0.0-2315cf.json diff --git a/openapi/nexus/nexus-2026081700.0.0-2315cf.json b/openapi/nexus/nexus-2026081800.0.0-6422a0.json similarity index 97% rename from openapi/nexus/nexus-2026081700.0.0-2315cf.json rename to openapi/nexus/nexus-2026081800.0.0-6422a0.json index 0b5d5f15529..c8886a8501e 100644 --- a/openapi/nexus/nexus-2026081700.0.0-2315cf.json +++ b/openapi/nexus/nexus-2026081800.0.0-6422a0.json @@ -7,7 +7,7 @@ "url": "https://oxide.computer", "email": "api@oxide.computer" }, - "version": "2026081700.0.0" + "version": "2026081800.0.0" }, "paths": { "/device/auth": { @@ -10241,11 +10241,7 @@ "content": { "application/json": { "schema": { - "title": "Array_of_BfdStatus", - "type": "array", - "items": { - "$ref": "#/components/schemas/BfdStatus" - } + "$ref": "#/components/schemas/BfdPeerStatusesSwitchResults" } } } @@ -10614,11 +10610,7 @@ "content": { "application/json": { "schema": { - "title": "Array_of_BgpExported", - "type": "array", - "items": { - "$ref": "#/components/schemas/BgpExported" - } + "$ref": "#/components/schemas/BgpExportedRoutesSwitchResults" } } } @@ -10658,11 +10650,7 @@ "content": { "application/json": { "schema": { - "title": "Array_of_BgpImported", - "type": "array", - "items": { - "$ref": "#/components/schemas/BgpImported" - } + "$ref": "#/components/schemas/BgpImportedRoutesSwitchResults" } } } @@ -10702,7 +10690,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AggregateBgpMessageHistory" + "$ref": "#/components/schemas/BgpMessageHistoriesSwitchResults" } } } @@ -10729,11 +10717,106 @@ "content": { "application/json": { "schema": { - "title": "Array_of_BgpPeerStatus", - "type": "array", - "items": { - "$ref": "#/components/schemas/BgpPeerStatus" - } + "$ref": "#/components/schemas/BgpPeerStatusesSwitchResults" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/v1/system/networking/bgp-unnumbered-interfaces": { + "get": { + "tags": [ + "system/networking" + ], + "summary": "List BGP Unnumbered interfaces", + "operationId": "networking_bgp_unnumbered_interface_list", + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnnumberedInterfacesSwitchResults" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/v1/system/networking/bgp-unnumbered-interfaces/{switch_slot}/{interface_name}": { + "get": { + "tags": [ + "system/networking" + ], + "summary": "Get BGP Unnumbered interface state", + "operationId": "networking_bgp_unnumbered_interface_view", + "parameters": [ + { + "in": "path", + "name": "interface_name", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "switch_slot", + "required": true, + "schema": { + "$ref": "#/components/schemas/SwitchSlot" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SwitchUnnumberedInterface" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/v1/system/networking/bgp-unnumbered-manager": { + "get": { + "tags": [ + "system/networking" + ], + "summary": "Get BGP Unnumbered manager state", + "operationId": "networking_bgp_unnumbered_manager_status", + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnnumberedManagerStateSwitchResults" } } } @@ -16223,22 +16306,6 @@ } ] }, - "AggregateBgpMessageHistory": { - "description": "BGP message history for rack switches.", - "type": "object", - "properties": { - "switch_histories": { - "description": "BGP history organized by switch.", - "type": "array", - "items": { - "$ref": "#/components/schemas/SwitchBgpHistory" - } - } - }, - "required": [ - "switch_histories" - ] - }, "Alert": { "description": "An alert.\n\nAlerts provide notifications about events that occurred in the system at a point in time. See the guide-level documentation on alerts for details.", "type": "object", @@ -17311,6 +17378,106 @@ "multi_hop" ] }, + "BfdPeerStatus": { + "type": "object", + "properties": { + "detection_threshold": { + "type": "integer", + "format": "uint8", + "minimum": 0 + }, + "local": { + "nullable": true, + "type": "string", + "format": "ip" + }, + "mode": { + "$ref": "#/components/schemas/BfdMode" + }, + "peer": { + "type": "string", + "format": "ip" + }, + "required_rx": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "state": { + "$ref": "#/components/schemas/BfdState" + } + }, + "required": [ + "detection_threshold", + "mode", + "peer", + "required_rx", + "state" + ] + }, + "BfdPeerStatuses": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BfdPeerStatus" + } + }, + "BfdPeerStatusesSwitchResult": { + "description": "The outcome of querying one switch.", + "oneOf": [ + { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "ok" + ] + }, + "value": { + "$ref": "#/components/schemas/BfdPeerStatuses" + } + }, + "required": [ + "status", + "value" + ] + }, + { + "type": "object", + "properties": { + "error": { + "$ref": "#/components/schemas/SwitchError" + }, + "status": { + "type": "string", + "enum": [ + "err" + ] + } + }, + "required": [ + "error", + "status" + ] + } + ] + }, + "BfdPeerStatusesSwitchResults": { + "description": "Results of querying both switches.", + "type": "object", + "properties": { + "switch0": { + "$ref": "#/components/schemas/BfdPeerStatusesSwitchResult" + }, + "switch1": { + "$ref": "#/components/schemas/BfdPeerStatusesSwitchResult" + } + }, + "required": [ + "switch0", + "switch1" + ] + }, "BfdSessionDisable": { "description": "Information needed to disable a BFD session", "type": "object", @@ -17418,47 +17585,6 @@ } ] }, - "BfdStatus": { - "type": "object", - "properties": { - "detection_threshold": { - "type": "integer", - "format": "uint8", - "minimum": 0 - }, - "local": { - "nullable": true, - "type": "string", - "format": "ip" - }, - "mode": { - "$ref": "#/components/schemas/BfdMode" - }, - "peer": { - "type": "string", - "format": "ip" - }, - "required_rx": { - "type": "integer", - "format": "uint64", - "minimum": 0 - }, - "state": { - "$ref": "#/components/schemas/BfdState" - }, - "switch_slot": { - "$ref": "#/components/schemas/SwitchSlot" - } - }, - "required": [ - "detection_threshold", - "mode", - "peer", - "required_rx", - "state", - "switch_slot" - ] - }, "BgpAnnounceSet": { "description": "Represents a BGP announce set by id. The id can be used with other API calls to view and manage the announce set.", "type": "object", @@ -17742,7 +17868,7 @@ } }, "BgpExported": { - "description": "Route exported to a peer.", + "description": "A route exported to a BGP peer.", "type": "object", "properties": { "peer_id": { @@ -17756,34 +17882,88 @@ "$ref": "#/components/schemas/IpNet" } ] - }, - "switch": { - "description": "Switch the route is exported from.", - "allOf": [ - { - "$ref": "#/components/schemas/SwitchSlot" - } - ] } }, "required": [ "peer_id", - "prefix", - "switch" + "prefix" ] }, - "BgpImported": { - "description": "A route imported from a BGP peer.", - "type": "object", - "properties": { - "id": { - "description": "BGP identifier of the originating router.", - "type": "integer", - "format": "uint32", - "minimum": 0 + "BgpExportedRoutes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BgpExported" + } + }, + "BgpExportedRoutesSwitchResult": { + "description": "The outcome of querying one switch.", + "oneOf": [ + { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "ok" + ] + }, + "value": { + "$ref": "#/components/schemas/BgpExportedRoutes" + } + }, + "required": [ + "status", + "value" + ] }, - "nexthop": { - "description": "The nexthop the prefix is reachable through.", + { + "type": "object", + "properties": { + "error": { + "$ref": "#/components/schemas/SwitchError" + }, + "status": { + "type": "string", + "enum": [ + "err" + ] + } + }, + "required": [ + "error", + "status" + ] + } + ] + }, + "BgpExportedRoutesSwitchResults": { + "description": "Results of querying both switches.", + "type": "object", + "properties": { + "switch0": { + "$ref": "#/components/schemas/BgpExportedRoutesSwitchResult" + }, + "switch1": { + "$ref": "#/components/schemas/BgpExportedRoutesSwitchResult" + } + }, + "required": [ + "switch0", + "switch1" + ] + }, + "BgpImported": { + "description": "A route imported from a BGP peer.", + "type": "object", + "properties": { + "id": { + "description": "BGP identifier of the originating router.", + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "nexthop": { + "description": "The nexthop the prefix is reachable through.", "type": "string", "format": "ip" }, @@ -17794,21 +17974,139 @@ "$ref": "#/components/schemas/IpNet" } ] + } + }, + "required": [ + "id", + "nexthop", + "prefix" + ] + }, + "BgpImportedRoutes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BgpImported" + } + }, + "BgpImportedRoutesSwitchResult": { + "description": "The outcome of querying one switch.", + "oneOf": [ + { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "ok" + ] + }, + "value": { + "$ref": "#/components/schemas/BgpImportedRoutes" + } + }, + "required": [ + "status", + "value" + ] }, - "switch": { - "description": "Switch the route is imported into.", - "allOf": [ - { - "$ref": "#/components/schemas/SwitchSlot" + { + "type": "object", + "properties": { + "error": { + "$ref": "#/components/schemas/SwitchError" + }, + "status": { + "type": "string", + "enum": [ + "err" + ] } + }, + "required": [ + "error", + "status" ] } + ] + }, + "BgpImportedRoutesSwitchResults": { + "description": "Results of querying both switches.", + "type": "object", + "properties": { + "switch0": { + "$ref": "#/components/schemas/BgpImportedRoutesSwitchResult" + }, + "switch1": { + "$ref": "#/components/schemas/BgpImportedRoutesSwitchResult" + } }, "required": [ - "id", - "nexthop", - "prefix", - "switch" + "switch0", + "switch1" + ] + }, + "BgpMessageHistories": { + "description": "BGP message history indexed by peer address.", + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/BgpMessageHistory" + } + }, + "BgpMessageHistoriesSwitchResult": { + "description": "The outcome of querying one switch.", + "oneOf": [ + { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "ok" + ] + }, + "value": { + "$ref": "#/components/schemas/BgpMessageHistories" + } + }, + "required": [ + "status", + "value" + ] + }, + { + "type": "object", + "properties": { + "error": { + "$ref": "#/components/schemas/SwitchError" + }, + "status": { + "type": "string", + "enum": [ + "err" + ] + } + }, + "required": [ + "error", + "status" + ] + } + ] + }, + "BgpMessageHistoriesSwitchResults": { + "description": "Results of querying both switches.", + "type": "object", + "properties": { + "switch0": { + "$ref": "#/components/schemas/BgpMessageHistoriesSwitchResult" + }, + "switch1": { + "$ref": "#/components/schemas/BgpMessageHistoriesSwitchResult" + } + }, + "required": [ + "switch0", + "switch1" ] }, "BgpMessageHistory": {}, @@ -18046,7 +18344,7 @@ "minimum": 0 }, "peer_id": { - "description": "Interface name", + "description": "Interface name.", "type": "string" }, "remote_asn": { @@ -18068,14 +18366,6 @@ "type": "integer", "format": "uint64", "minimum": 0 - }, - "switch": { - "description": "Switch with the peer session.", - "allOf": [ - { - "$ref": "#/components/schemas/SwitchSlot" - } - ] } }, "required": [ @@ -18084,8 +18374,70 @@ "peer_id", "remote_asn", "state", - "state_duration_millis", - "switch" + "state_duration_millis" + ] + }, + "BgpPeerStatuses": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BgpPeerStatus" + } + }, + "BgpPeerStatusesSwitchResult": { + "description": "The outcome of querying one switch.", + "oneOf": [ + { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "ok" + ] + }, + "value": { + "$ref": "#/components/schemas/BgpPeerStatuses" + } + }, + "required": [ + "status", + "value" + ] + }, + { + "type": "object", + "properties": { + "error": { + "$ref": "#/components/schemas/SwitchError" + }, + "status": { + "type": "string", + "enum": [ + "err" + ] + } + }, + "required": [ + "error", + "status" + ] + } + ] + }, + "BgpPeerStatusesSwitchResults": { + "description": "Results of querying both switches.", + "type": "object", + "properties": { + "switch0": { + "$ref": "#/components/schemas/BgpPeerStatusesSwitchResult" + }, + "switch1": { + "$ref": "#/components/schemas/BgpPeerStatusesSwitchResult" + } + }, + "required": [ + "switch0", + "switch1" ] }, "BinRangedouble": { @@ -20039,6 +20391,74 @@ } ] }, + "DiscoveredRouter": { + "description": "Information about a router discovered through router advertisements.", + "type": "object", + "properties": { + "address": { + "description": "Router IPv6 address", + "type": "string", + "format": "ipv6" + }, + "effective_reachable_time": { + "description": "Effective reachable time governing expiry of this entry", + "allOf": [ + { + "$ref": "#/components/schemas/Duration" + } + ] + }, + "reachable_time": { + "description": "Reachable time from RA", + "allOf": [ + { + "$ref": "#/components/schemas/Duration" + } + ] + }, + "retrans_timer": { + "description": "Retransmit timer from RA", + "allOf": [ + { + "$ref": "#/components/schemas/Duration" + } + ] + }, + "router_lifetime": { + "description": "Router lifetime from RA", + "allOf": [ + { + "$ref": "#/components/schemas/Duration" + } + ] + }, + "time_since_discovered": { + "description": "Time elapsed since the router was first discovered", + "allOf": [ + { + "$ref": "#/components/schemas/Duration" + } + ] + }, + "time_since_last_rx": { + "description": "Time elapsed since the most recent Router Advertisement was received", + "allOf": [ + { + "$ref": "#/components/schemas/Duration" + } + ] + } + }, + "required": [ + "address", + "effective_reachable_time", + "reachable_time", + "retrans_timer", + "router_lifetime", + "time_since_discovered", + "time_since_last_rx" + ] + }, "Disk": { "description": "View of a Disk", "type": "object", @@ -20659,6 +21079,25 @@ "sum_of_samples" ] }, + "Duration": { + "type": "object", + "properties": { + "nanos": { + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "secs": { + "type": "integer", + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "nanos", + "secs" + ] + }, "EphemeralIpCreate": { "description": "Parameters for creating an ephemeral IP address for an instance.", "type": "object", @@ -27092,6 +27531,23 @@ } ] }, + "RouterDiscoveryRuntimeState": { + "type": "object", + "properties": { + "rx": { + "description": "ICMPv6 Router Advertisement receive loop is running", + "type": "boolean" + }, + "tx": { + "description": "ICMPv6 Router Advertisement transmit loop is running", + "type": "boolean" + } + }, + "required": [ + "rx", + "tx" + ] + }, "RouterLifetimeConfig": { "description": "Router lifetime in seconds for unnumbered BGP peers", "type": "integer", @@ -29158,29 +29614,93 @@ "time_modified" ] }, - "SwitchBgpHistory": { - "description": "BGP message history for a particular switch.", - "type": "object", - "properties": { - "history": { - "description": "Message history indexed by peer address.", + "SwitchError": { + "description": "A normalized failure from one switch query.", + "oneOf": [ + { + "description": "The switch authoritatively rejected the query.", "type": "object", - "additionalProperties": { - "$ref": "#/components/schemas/BgpMessageHistory" - } + "properties": { + "error_code": { + "nullable": true, + "type": "string" + }, + "message": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "request_rejected" + ] + }, + "upstream_request_id": { + "type": "string" + } + }, + "required": [ + "message", + "type", + "upstream_request_id" + ] }, - "switch": { - "description": "Switch this message history is associated with.", - "allOf": [ - { - "$ref": "#/components/schemas/SwitchSlot" + { + "description": "The switch returned an operational error.", + "type": "object", + "properties": { + "error_code": { + "nullable": true, + "type": "string" + }, + "message": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "upstream_unavailable" + ] + }, + "upstream_request_id": { + "type": "string" + } + }, + "required": [ + "message", + "type", + "upstream_request_id" + ] + }, + { + "description": "Nexus could not resolve an MGD client for the switch.", + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "mgd_unresolved" + ] + } + }, + "required": [ + "type" + ] + }, + { + "description": "Nexus could not obtain a valid response from the switch.", + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "query_failed" + ] } + }, + "required": [ + "type" ] } - }, - "required": [ - "history", - "switch" ] }, "SwitchInterfaceConfig": { @@ -29931,6 +30451,21 @@ } ] }, + "SwitchUnnumberedInterface": { + "type": "object", + "properties": { + "interface": { + "$ref": "#/components/schemas/UnnumberedInterface" + }, + "switch_slot": { + "$ref": "#/components/schemas/SwitchSlot" + } + }, + "required": [ + "interface", + "switch_slot" + ] + }, "SystemNetworkingSettings": { "description": "Fleet-wide networking settings. Only fleet viewers may view these settings. Only fleet admins can modify them.", "type": "object", @@ -30342,6 +30877,271 @@ } ] }, + "UnnumberedInterface": { + "type": "object", + "properties": { + "discovered_peer": { + "nullable": true, + "description": "Information about discovered peer", + "allOf": [ + { + "$ref": "#/components/schemas/DiscoveredRouter" + } + ] + }, + "interface": { + "description": "Interface name (e.g. \"qsfp0\")", + "type": "string" + }, + "local_address": { + "description": "IPv6 link-local address of this interface.", + "type": "string", + "format": "ipv6" + }, + "ndp_state": { + "description": "State of rx/tx loops (None if interface not active in NDP)", + "allOf": [ + { + "$ref": "#/components/schemas/RouterDiscoveryRuntimeState" + } + ] + }, + "router_lifetime": { + "description": "Router Lifetime advertised in ICMPv6 Router Advertisements sent on this interface.", + "type": "integer", + "format": "uint16", + "minimum": 0 + } + }, + "required": [ + "interface", + "local_address", + "ndp_state", + "router_lifetime" + ] + }, + "UnnumberedInterfaceStatus": { + "description": "Status of an interface configured for unnumbered operation.", + "oneOf": [ + { + "description": "Configured but not yet available on the system.", + "type": "object", + "properties": { + "pending": { + "type": "object", + "properties": { + "router_lifetime": { + "description": "Configured router lifetime (seconds)", + "type": "integer", + "format": "uint16", + "minimum": 0 + } + }, + "required": [ + "router_lifetime" + ] + } + }, + "required": [ + "pending" + ], + "additionalProperties": false + }, + { + "description": "Active for unnumbered operation.", + "type": "object", + "properties": { + "active": { + "type": "object", + "properties": { + "discovered_peer": { + "nullable": true, + "description": "Information about the discovered peer. None if no peer has been discovered or the discovered entry has expired.", + "allOf": [ + { + "$ref": "#/components/schemas/DiscoveredRouter" + } + ] + }, + "local_address": { + "description": "Local IPv6 link-local address", + "type": "string", + "format": "ipv6" + }, + "router_lifetime": { + "description": "Router lifetime advertised by this router (seconds)", + "type": "integer", + "format": "uint16", + "minimum": 0 + }, + "runtime_state": { + "description": "Runtime state for router discovery on this interface", + "allOf": [ + { + "$ref": "#/components/schemas/RouterDiscoveryRuntimeState" + } + ] + }, + "scope_id": { + "description": "IPv6 scope ID (interface index)", + "type": "integer", + "format": "uint32", + "minimum": 0 + } + }, + "required": [ + "local_address", + "router_lifetime", + "runtime_state", + "scope_id" + ] + } + }, + "required": [ + "active" + ], + "additionalProperties": false + } + ] + }, + "UnnumberedInterfaces": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UnnumberedInterface" + } + }, + "UnnumberedInterfacesSwitchResult": { + "description": "The outcome of querying one switch.", + "oneOf": [ + { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "ok" + ] + }, + "value": { + "$ref": "#/components/schemas/UnnumberedInterfaces" + } + }, + "required": [ + "status", + "value" + ] + }, + { + "type": "object", + "properties": { + "error": { + "$ref": "#/components/schemas/SwitchError" + }, + "status": { + "type": "string", + "enum": [ + "err" + ] + } + }, + "required": [ + "error", + "status" + ] + } + ] + }, + "UnnumberedInterfacesSwitchResults": { + "description": "Results of querying both switches.", + "type": "object", + "properties": { + "switch0": { + "$ref": "#/components/schemas/UnnumberedInterfacesSwitchResult" + }, + "switch1": { + "$ref": "#/components/schemas/UnnumberedInterfacesSwitchResult" + } + }, + "required": [ + "switch0", + "switch1" + ] + }, + "UnnumberedManagerState": { + "type": "object", + "properties": { + "interfaces": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/UnnumberedInterfaceStatus" + } + }, + "monitor_running": { + "type": "boolean" + } + }, + "required": [ + "interfaces", + "monitor_running" + ] + }, + "UnnumberedManagerStateSwitchResult": { + "description": "The outcome of querying one switch.", + "oneOf": [ + { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "ok" + ] + }, + "value": { + "$ref": "#/components/schemas/UnnumberedManagerState" + } + }, + "required": [ + "status", + "value" + ] + }, + { + "type": "object", + "properties": { + "error": { + "$ref": "#/components/schemas/SwitchError" + }, + "status": { + "type": "string", + "enum": [ + "err" + ] + } + }, + "required": [ + "error", + "status" + ] + } + ] + }, + "UnnumberedManagerStateSwitchResults": { + "description": "Results of querying both switches.", + "type": "object", + "properties": { + "switch0": { + "$ref": "#/components/schemas/UnnumberedManagerStateSwitchResult" + }, + "switch1": { + "$ref": "#/components/schemas/UnnumberedManagerStateSwitchResult" + } + }, + "required": [ + "switch0", + "switch1" + ] + }, "UpdateStatus": { "type": "object", "properties": { diff --git a/openapi/nexus/nexus-latest.json b/openapi/nexus/nexus-latest.json index 8b19c87f5a5..09ec1c2cd29 120000 --- a/openapi/nexus/nexus-latest.json +++ b/openapi/nexus/nexus-latest.json @@ -1 +1 @@ -nexus-2026081700.0.0-2315cf.json \ No newline at end of file +nexus-2026081800.0.0-6422a0.json \ No newline at end of file diff --git a/package-manifest.toml b/package-manifest.toml index edb753576ce..70797900cc3 100644 --- a/package-manifest.toml +++ b/package-manifest.toml @@ -683,10 +683,10 @@ source.repo = "maghemite" # `tools/maghemite_openapi_version`. Failing to do so will cause a failure when # building `ddm-admin-client` (which will instruct you to update # `tools/maghemite_openapi_version`). -source.commit = "ce015cde17a1ebc839ae482026d522cf0e369a3b" +source.commit = "bf831acede9b125a854bfda5615ae9248d1e2fa8" # The SHA256 digest is automatically posted to: # https://buildomat.eng.oxide.computer/public/file/oxidecomputer/maghemite/image//mg-ddm-gz.sha256.txt -source.sha256 = "279cb103525d4a554082fbea11f7b841fc1515f337cf895849b5e7c0950b925d" +source.sha256 = "895a0a975c75e1b93f9a8567187ada2af185751d6a6e07019a50ad9c41292cd4" output.type = "tarball" [package.mg-ddm] @@ -699,10 +699,10 @@ source.repo = "maghemite" # `tools/maghemite_openapi_version`. Failing to do so will cause a failure when # building `ddm-admin-client` (which will instruct you to update # `tools/maghemite_openapi_version`). -source.commit = "ce015cde17a1ebc839ae482026d522cf0e369a3b" +source.commit = "bf831acede9b125a854bfda5615ae9248d1e2fa8" # The SHA256 digest is automatically posted to: # https://buildomat.eng.oxide.computer/public/file/oxidecomputer/maghemite/image//mg-ddm.sha256.txt -source.sha256 = "742d0bec146634695f40133b41f9da8a2de2bc36a905de0927c7515a6672e1ce" +source.sha256 = "95321f5f1db83943e619a6aad02845d9bc75346781371c157918898988c7f568" output.type = "zone" output.intermediate_only = true @@ -714,10 +714,10 @@ source.repo = "maghemite" # `tools/maghemite_openapi_version`. Failing to do so will cause a failure when # building `ddm-admin-client` (which will instruct you to update # `tools/maghemite_openapi_version`). -source.commit = "ce015cde17a1ebc839ae482026d522cf0e369a3b" +source.commit = "bf831acede9b125a854bfda5615ae9248d1e2fa8" # The SHA256 digest is automatically posted to: # https://buildomat.eng.oxide.computer/public/file/oxidecomputer/maghemite/image//mgd.sha256.txt -source.sha256 = "a8ef9f24f794c9ac3c4e66b60e738d50aa1b22f86c7aa7baf7b07d300411ecde" +source.sha256 = "ada0d1513ea9a3582e0c6562ae71b8e637bc6fbdf50f68f0631a9578b3d5ab9c" output.type = "zone" output.intermediate_only = true diff --git a/sled-agent/scrimlet-reconcilers/src/mgd_reconciler/bgp_reconciler.rs b/sled-agent/scrimlet-reconcilers/src/mgd_reconciler/bgp_reconciler.rs index fd2ff708a59..ef970ec790a 100644 --- a/sled-agent/scrimlet-reconcilers/src/mgd_reconciler/bgp_reconciler.rs +++ b/sled-agent/scrimlet-reconcilers/src/mgd_reconciler/bgp_reconciler.rs @@ -26,6 +26,7 @@ use mg_api_types::bgp::history::Origin6 as MgdOrigin6; use mg_api_types::bgp::policy::ImportExportPolicy4 as MgdImportExportPolicy4; use mg_api_types::bgp::policy::ImportExportPolicy6 as MgdImportExportPolicy6; use mg_api_types::rib::BestpathFanoutRequest as MgdBestpathFanoutRequest; +use omicron_common::tfport::TfportInterfaceName; use oxnet::IpNet; use oxnet::Ipv4Net; use oxnet::Ipv6Net; @@ -1426,7 +1427,10 @@ impl DiffableBgpConfig { match addr { RouterPeerType::Unnumbered { router_lifetime } => { - let interface = format!("tfport{port_name}_0"); + let interface = + TfportInterfaceName::from_port_name(port_name) + .context("invalid port name for tfport interface")? + .to_string(); if let Some(_prev) = unnumbered_peers.insert( interface.clone(), DiffableBgpUnnumberedPeerConfig { diff --git a/sled-agent/scrimlet-reconcilers/src/mgd_reconciler/bgp_reconciler/tests.rs b/sled-agent/scrimlet-reconcilers/src/mgd_reconciler/bgp_reconciler/tests.rs index f2da4afba69..1587feb23c9 100644 --- a/sled-agent/scrimlet-reconcilers/src/mgd_reconciler/bgp_reconciler/tests.rs +++ b/sled-agent/scrimlet-reconcilers/src/mgd_reconciler/bgp_reconciler/tests.rs @@ -90,7 +90,7 @@ struct TestInput { #[strategy( btree_map( - "[0-9a-zA-Z]{0,16}", + "[0-9a-z]{1,16}", any::<(RouterLifetimeConfig, BgpPeerConfig)>() .prop_map(move |(router_lifetime, mut peer)| { peer.asn = #asn; diff --git a/sled-agent/src/services.rs b/sled-agent/src/services.rs index f88b6d975c0..c14b46e58e5 100644 --- a/sled-agent/src/services.rs +++ b/sled-agent/src/services.rs @@ -83,6 +83,7 @@ use omicron_common::backoff::{ BackoffError, retry_notify, retry_policy_internal_service_aggressive, }; use omicron_common::disk::{DatasetKind, DatasetName}; +use omicron_common::tfport::TfportInterfaceName; use omicron_ddm_admin_client::DdmError; use omicron_uuid_kinds::OmicronZoneUuid; use omicron_uuid_kinds::RackUuid; @@ -3103,8 +3104,10 @@ impl ServiceManager { // future to include a subset of // "front" ports too, when racks are // cabled together. + let interface = + TfportInterfaceName::rear_port(i); AddrObject::new( - &format!("tfportrear{}_0", i), + interface.as_str(), IPV6_LINK_LOCAL_ADDROBJ_NAME, ) .unwrap() @@ -3334,7 +3337,11 @@ impl ServiceManager { ..Default::default() }; filesystems.push(softnpu_filesystem); - data_links = Dladm::get_simulated_tfports().await?; + data_links = Dladm::get_simulated_tfports() + .await? + .into_iter() + .map(|name| name.to_string()) + .collect(); } vec![ SwitchService::Dendrite { asic }, diff --git a/tools/maghemite_ddm_openapi_version b/tools/maghemite_ddm_openapi_version index f0271268b96..7cbbbb5ea7e 100644 --- a/tools/maghemite_ddm_openapi_version +++ b/tools/maghemite_ddm_openapi_version @@ -1 +1 @@ -COMMIT="ce015cde17a1ebc839ae482026d522cf0e369a3b" +COMMIT="bf831acede9b125a854bfda5615ae9248d1e2fa8" diff --git a/tools/maghemite_mg_openapi_version b/tools/maghemite_mg_openapi_version index f0271268b96..7cbbbb5ea7e 100644 --- a/tools/maghemite_mg_openapi_version +++ b/tools/maghemite_mg_openapi_version @@ -1 +1 @@ -COMMIT="ce015cde17a1ebc839ae482026d522cf0e369a3b" +COMMIT="bf831acede9b125a854bfda5615ae9248d1e2fa8" diff --git a/tools/maghemite_mgd_checksums b/tools/maghemite_mgd_checksums index c1c5300e1e7..da7ae10ab46 100644 --- a/tools/maghemite_mgd_checksums +++ b/tools/maghemite_mgd_checksums @@ -1,4 +1,4 @@ -CIDL_SHA256="a8ef9f24f794c9ac3c4e66b60e738d50aa1b22f86c7aa7baf7b07d300411ecde" -MGD_LINUX_SHA256="87dd10c30ae6f8fe23e944dd1abb32bc0c92550015bb5dbd59dd573babc36b0f" -MG_DDM_SHA256="742d0bec146634695f40133b41f9da8a2de2bc36a905de0927c7515a6672e1ce" -DDMD_LINUX_SHA256="fd3cd3fee51bd9eaf7d422d8349df607f340b8ac1ece68ac837fa4ba50e386f1" \ No newline at end of file +CIDL_SHA256="ada0d1513ea9a3582e0c6562ae71b8e637bc6fbdf50f68f0631a9578b3d5ab9c" +MGD_LINUX_SHA256="9283214ba6f519ec4c2df3320719a55f02497be2dd48c659848543a9e9e94ae0" +MG_DDM_SHA256="95321f5f1db83943e619a6aad02845d9bc75346781371c157918898988c7f568" +DDMD_LINUX_SHA256="6b374b1526871f76c77f0136ee49304af85cc9cc2d8de2ec06f40ca1b881f298" \ No newline at end of file diff --git a/tools/update_maghemite.sh b/tools/update_maghemite.sh index b93b3fe43ac..6da613e0414 100755 --- a/tools/update_maghemite.sh +++ b/tools/update_maghemite.sh @@ -27,6 +27,7 @@ PACKAGES=( CRATES=( "mg-admin-client" "ddm-admin-client" + "mg-api-types" ) REPO="oxidecomputer/maghemite" . "$SOURCE_DIR/update_helpers.sh" diff --git a/workspace-hack/Cargo.toml b/workspace-hack/Cargo.toml index 8cfab998290..7b71596c55d 100644 --- a/workspace-hack/Cargo.toml +++ b/workspace-hack/Cargo.toml @@ -124,8 +124,8 @@ rustls = { version = "0.23.41" } schemars = { version = "0.8.22", features = ["bytes", "chrono", "semver", "url", "uuid1"] } scopeguard = { version = "1.2.0" } semver = { version = "1.0.28", features = ["serde"] } -serde = { version = "1.0.228", features = ["alloc", "derive", "rc"] } -serde_core = { version = "1.0.228", features = ["alloc", "rc"] } +serde = { version = "1.0.229", features = ["alloc", "derive", "rc"] } +serde_core = { version = "1.0.229", features = ["alloc", "rc"] } serde_json = { version = "1.0.151", features = ["alloc", "raw_value", "unbounded_depth"] } serde_with = { version = "3.21.0", features = ["hex", "schemars_0_8"] } sha2 = { version = "0.10.9", features = ["oid"] } @@ -152,6 +152,7 @@ tokio-util = { version = "0.7.18", features = ["codec", "io-util", "rt", "time"] toml = { version = "0.7.8" } toml_datetime = { version = "0.6.11", default-features = false, features = ["serde"] } toml_edit-3c51e837cfc5589a = { package = "toml_edit", version = "0.22.27", features = ["serde"] } +toml_edit-cdcf2f9584511fe6 = { package = "toml_edit", version = "0.19.15", features = ["serde"] } toml_parser = { version = "1.1.2" } tough = { version = "0.22.0", default-features = false, features = ["http"] } tracing = { version = "0.1.44", features = ["log"] } @@ -277,8 +278,8 @@ rustls = { version = "0.23.41" } schemars = { version = "0.8.22", features = ["bytes", "chrono", "semver", "url", "uuid1"] } scopeguard = { version = "1.2.0" } semver = { version = "1.0.28", features = ["serde"] } -serde = { version = "1.0.228", features = ["alloc", "derive", "rc"] } -serde_core = { version = "1.0.228", features = ["alloc", "rc"] } +serde = { version = "1.0.229", features = ["alloc", "derive", "rc"] } +serde_core = { version = "1.0.229", features = ["alloc", "rc"] } serde_json = { version = "1.0.151", features = ["alloc", "raw_value", "unbounded_depth"] } serde_with = { version = "3.21.0", features = ["hex", "schemars_0_8"] } serde_with_macros = { version = "3.21.0", default-features = false, features = ["schemars_0_8"] } @@ -308,6 +309,7 @@ tokio-util = { version = "0.7.18", features = ["codec", "io-util", "rt", "time"] toml = { version = "0.7.8" } toml_datetime = { version = "0.6.11", default-features = false, features = ["serde"] } toml_edit-3c51e837cfc5589a = { package = "toml_edit", version = "0.22.27", features = ["serde"] } +toml_edit-cdcf2f9584511fe6 = { package = "toml_edit", version = "0.19.15", features = ["serde"] } toml_parser = { version = "1.1.2" } tough = { version = "0.22.0", default-features = false, features = ["http"] } tracing = { version = "0.1.44", features = ["log"] } @@ -432,7 +434,6 @@ object = { version = "0.37.3", default-features = false, features = ["read", "st rustix-d585fab2519d2d1 = { package = "rustix", version = "0.38.44", features = ["fs", "stdio", "system", "termios"] } rustix-dff4ba8e3ae991db = { package = "rustix", version = "1.1.4", features = ["fs", "stdio", "termios"] } tokio-rustls = { version = "0.26.4", default-features = false, features = ["aws-lc-rs"] } -toml_edit-cdcf2f9584511fe6 = { package = "toml_edit", version = "0.19.15", features = ["serde"] } winnow = { version = "1.0.3" } [target.x86_64-unknown-illumos.build-dependencies] @@ -453,7 +454,6 @@ object = { version = "0.37.3", default-features = false, features = ["read", "st rustix-d585fab2519d2d1 = { package = "rustix", version = "0.38.44", features = ["fs", "stdio", "system", "termios"] } rustix-dff4ba8e3ae991db = { package = "rustix", version = "1.1.4", features = ["fs", "stdio", "termios"] } tokio-rustls = { version = "0.26.4", default-features = false, features = ["aws-lc-rs"] } -toml_edit-cdcf2f9584511fe6 = { package = "toml_edit", version = "0.19.15", features = ["serde"] } winnow = { version = "1.0.3" } ### END HAKARI SECTION