From 1e02e341d68787b763ec9feb58d39d7a347e4f93 Mon Sep 17 00:00:00 2001 From: Trey Aspelund Date: Mon, 22 Jun 2026 15:58:23 -0700 Subject: [PATCH 01/16] tools: include mg-api-types in update_maghemite.sh Now that maghemite has migrated to the RFD 619 convention, it exposes most types via the mg-api-types crate. This crate should have its hash updated alongside mg-admin-client and ddm-admin-client. Signed-off-by: Trey Aspelund --- tools/update_maghemite.sh | 1 + 1 file changed, 1 insertion(+) 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" From abc17a63d625c7f869820553fb4f28428a8e323e Mon Sep 17 00:00:00 2001 From: Trey Aspelund Date: Mon, 22 Jun 2026 17:25:23 -0700 Subject: [PATCH 02/16] Expose BGP unnumbered interface status externally Adds external API endpoints to GET the BGP unnumbered status of interfaces and the unnumbered manager. This provides insights into the Router Discovery state (have we learned about a BGP peer via NDP) and the readiness of an interface that's been configured for BGP unnumbered (does the interface exist in the OS? does it have an IPv6 link-local IP? or is it active?). These are all important when diagnosing failures or delays in the establishment of BGP unnumbered peers, since these are all prerequisites for the BGP FSM to complete connections. This is the omicron side of maghemite#800 Signed-off-by: Trey Aspelund --- Cargo.lock | 22 +- Cargo.toml | 6 +- nexus/external-api/output/nexus_tags.txt | 3 + nexus/external-api/src/lib.rs | 44 +++ nexus/src/app/mod.rs | 1 + nexus/src/app/unnumbered.rs | 124 ++++++++ nexus/src/external_api/http_entrypoints.rs | 70 +++++ .../versions/src/bgp_unnumbered_status/mod.rs | 5 + .../src/bgp_unnumbered_status/networking.rs | 187 +++++++++++ nexus/types/versions/src/latest.rs | 9 + nexus/types/versions/src/lib.rs | 2 + .../nexus-2026061000.0.0-9779e2.json.gitstub | 1 + ....json => nexus-2026062500.0.0-dccd9c.json} | 297 +++++++++++++++++- openapi/nexus/nexus-latest.json | 2 +- package-manifest.toml | 12 +- tools/maghemite_ddm_openapi_version | 2 +- tools/maghemite_mg_openapi_version | 2 +- tools/maghemite_mgd_checksums | 8 +- workspace-hack/Cargo.toml | 4 +- 19 files changed, 771 insertions(+), 30 deletions(-) create mode 100644 nexus/src/app/unnumbered.rs create mode 100644 nexus/types/versions/src/bgp_unnumbered_status/mod.rs create mode 100644 nexus/types/versions/src/bgp_unnumbered_status/networking.rs create mode 100644 openapi/nexus/nexus-2026061000.0.0-9779e2.json.gitstub rename openapi/nexus/{nexus-2026061000.0.0-9779e2.json => nexus-2026062500.0.0-dccd9c.json} (99%) diff --git a/Cargo.lock b/Cargo.lock index db55fc5717a..87b78539932 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -167,7 +167,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -178,7 +178,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -1630,7 +1630,7 @@ dependencies = [ [[package]] name = "client-common" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/maghemite?rev=dc84a6ce494a9d004568b67e22add2285a04d887#dc84a6ce494a9d004568b67e22add2285a04d887" +source = "git+https://github.com/oxidecomputer/maghemite?rev=0d06cd1533cb8c376ac4ac30a278815c02e2f0e1#0d06cd1533cb8c376ac4ac30a278815c02e2f0e1" [[package]] name = "clipboard-win" @@ -2665,7 +2665,7 @@ dependencies = [ [[package]] name = "ddm-admin-client" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/maghemite?rev=dc84a6ce494a9d004568b67e22add2285a04d887#dc84a6ce494a9d004568b67e22add2285a04d887" +source = "git+https://github.com/oxidecomputer/maghemite?rev=0d06cd1533cb8c376ac4ac30a278815c02e2f0e1#0d06cd1533cb8c376ac4ac30a278815c02e2f0e1" dependencies = [ "ddm-api-types-versions", "oxnet", @@ -2679,7 +2679,7 @@ dependencies = [ [[package]] name = "ddm-api-types-versions" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/maghemite?rev=dc84a6ce494a9d004568b67e22add2285a04d887#dc84a6ce494a9d004568b67e22add2285a04d887" +source = "git+https://github.com/oxidecomputer/maghemite?rev=0d06cd1533cb8c376ac4ac30a278815c02e2f0e1#0d06cd1533cb8c376ac4ac30a278815c02e2f0e1" dependencies = [ "ddm-protocol", "oxnet", @@ -2692,7 +2692,7 @@ dependencies = [ [[package]] name = "ddm-protocol" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/maghemite?rev=dc84a6ce494a9d004568b67e22add2285a04d887#dc84a6ce494a9d004568b67e22add2285a04d887" +source = "git+https://github.com/oxidecomputer/maghemite?rev=0d06cd1533cb8c376ac4ac30a278815c02e2f0e1#0d06cd1533cb8c376ac4ac30a278815c02e2f0e1" dependencies = [ "oxnet", "schemars 0.8.22", @@ -6855,7 +6855,7 @@ dependencies = [ [[package]] name = "mg-admin-client" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/maghemite?rev=dc84a6ce494a9d004568b67e22add2285a04d887#dc84a6ce494a9d004568b67e22add2285a04d887" +source = "git+https://github.com/oxidecomputer/maghemite?rev=0d06cd1533cb8c376ac4ac30a278815c02e2f0e1#0d06cd1533cb8c376ac4ac30a278815c02e2f0e1" dependencies = [ "chrono", "client-common", @@ -6875,7 +6875,7 @@ dependencies = [ [[package]] name = "mg-api-types" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/maghemite?rev=dc84a6ce494a9d004568b67e22add2285a04d887#dc84a6ce494a9d004568b67e22add2285a04d887" +source = "git+https://github.com/oxidecomputer/maghemite?rev=0d06cd1533cb8c376ac4ac30a278815c02e2f0e1#0d06cd1533cb8c376ac4ac30a278815c02e2f0e1" dependencies = [ "mg-api-types-versions", ] @@ -6883,7 +6883,7 @@ dependencies = [ [[package]] name = "mg-api-types-versions" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/maghemite?rev=dc84a6ce494a9d004568b67e22add2285a04d887#dc84a6ce494a9d004568b67e22add2285a04d887" +source = "git+https://github.com/oxidecomputer/maghemite?rev=0d06cd1533cb8c376ac4ac30a278815c02e2f0e1#0d06cd1533cb8c376ac4ac30a278815c02e2f0e1" dependencies = [ "chrono", "nom 8.0.0", @@ -8586,7 +8586,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", @@ -14512,7 +14512,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 8405e5e7a66..7b8693c0acb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -623,8 +623,8 @@ ntp-admin-client = { path = "clients/ntp-admin-client" } ntp-admin-v1-client = { path = "clients/ntp-admin-v1-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 = "dc84a6ce494a9d004568b67e22add2285a04d887" } -ddm-admin-client = { git = "https://github.com/oxidecomputer/maghemite", rev = "dc84a6ce494a9d004568b67e22add2285a04d887" } +mg-admin-client = { git = "https://github.com/oxidecomputer/maghemite", rev = "0d06cd1533cb8c376ac4ac30a278815c02e2f0e1" } +ddm-admin-client = { git = "https://github.com/oxidecomputer/maghemite", rev = "0d06cd1533cb8c376ac4ac30a278815c02e2f0e1" } multimap = "0.10.1" nexus-auth = { path = "nexus/auth" } nexus-background-task-interface = { path = "nexus/background-task-interface" } @@ -763,7 +763,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 = "dc84a6ce494a9d004568b67e22add2285a04d887" } +mg-api-types = { git = "https://github.com/oxidecomputer/maghemite", rev = "0d06cd1533cb8c376ac4ac30a278815c02e2f0e1" } reconfigurator-cli = { path = "dev-tools/reconfigurator-cli" } reedline = "0.40.0" ref-cast = "1.0" diff --git a/nexus/external-api/output/nexus_tags.txt b/nexus/external-api/output/nexus_tags.txt index 0f7057078fc..898263cb1cc 100644 --- a/nexus/external-api/output/nexus_tags.txt +++ b/nexus/external-api/output/nexus_tags.txt @@ -284,6 +284,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 c3641f71d87..c4811430c8b 100644 --- a/nexus/external-api/src/lib.rs +++ b/nexus/external-api/src/lib.rs @@ -86,6 +86,7 @@ api_versions!([ // | date-based version should be at the top of the list. // v // (next_yyyy_mm_dd_nn, IDENT), + (2026_06_25_00, BGP_UNNUMBERED_STATUS), (2026_06_10_00, BGP_CONFIGURATION_UPDATE), (2026_06_08_00, INSTANCE_CPU_TYPE_TURIN_V2), (2026_06_05_00, EXTERNAL_JUMBO_FRAMES), @@ -6004,6 +6005,49 @@ pub trait NexusExternalApi { }) } + /// 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>, + 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>, + 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/mod.rs b/nexus/src/app/mod.rs index 0f40c372f08..650b3d3e3e0 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..ed4351529e7 --- /dev/null +++ b/nexus/src/app/unnumbered.rs @@ -0,0 +1,124 @@ +// 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::{ + SwitchUnnumberedInterface, SwitchUnnumberedManagerState, +}; +use omicron_common::api::external::Error; +use sled_agent_types::early_networking::SwitchSlot; + +fn maghemite_interface_name(interface_name: &str) -> String { + format!("tfport{interface_name}_0") +} + +impl super::Nexus { + pub async fn bgp_unnumbered_manager_status( + &self, + _optctx: &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 mut result = Vec::new(); + for switch_slot in [SwitchSlot::Switch0, SwitchSlot::Switch1] { + // 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, + ); + continue; + }; + let status = mg_client + .get_bgp_unnumbered_manager_state() + .await + .map_err(|e| { + Error::internal_error(&format!( + "maghemite get BGP unnumbered manager state: {e}" + )) + })? + .into_inner(); + + result.push(SwitchUnnumberedManagerState { + switch_slot, + state: status.into(), + }); + } + Ok(result) + } + + pub async fn bgp_unnumbered_interfaces( + &self, + _optctx: &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 mut result = Vec::new(); + for switch_slot in [SwitchSlot::Switch0, SwitchSlot::Switch1] { + // 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, + ); + continue; + }; + let interfaces = mg_client + .get_bgp_unnumbered_interfaces() + .await + .map_err(|e| { + Error::internal_error(&format!( + "maghemite get BGP unnumbered interfaces: {e}" + )) + })? + .into_inner(); + + for interface in interfaces { + result.push(SwitchUnnumberedInterface { + switch_slot, + interface: interface.into(), + }); + } + } + Ok(result) + } + + pub async fn bgp_unnumbered_interface( + &self, + _optctx: &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 = maghemite_interface_name(&interface_name); + let interface = mg_client + .get_bgp_unnumbered_interface_detail(&interface_name) + .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 3214e1c459c..3be0d77187f 100644 --- a/nexus/src/external_api/http_entrypoints.rs +++ b/nexus/src/external_api/http_entrypoints.rs @@ -57,6 +57,10 @@ use nexus_types::external_api::identity_provider::IdentityProvider; use nexus_types::external_api::image::Image; use nexus_types::external_api::ip_pool::{IpPool, IpPoolRange}; use nexus_types::external_api::metrics::SystemMetricsPathParam; +use nexus_types::external_api::networking::{ + SwitchUnnumberedInterface, SwitchUnnumberedManagerState, + UnnumberedInterfacePath, +}; use nexus_types::external_api::physical_disk::{ PhysicalDisk, PhysicalDiskAdoptionRequest, PhysicalDiskAdoptionRequestPath, PhysicalDiskManufacturerIdentity, UnadoptedPhysicalDisk, @@ -4684,6 +4688,72 @@ impl NexusExternalApi for NexusExternalApiImpl { .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?; + Ok(HttpResponseOk(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?; + Ok(HttpResponseOk(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 + .context + .external_latencies + .instrument_dropshot_handler(&rqctx, handler) + .await + } + async fn networking_allow_list_view( rqctx: RequestContext, ) -> Result, HttpError> { diff --git a/nexus/types/versions/src/bgp_unnumbered_status/mod.rs b/nexus/types/versions/src/bgp_unnumbered_status/mod.rs new file mode 100644 index 00000000000..c11cbd02dbe --- /dev/null +++ b/nexus/types/versions/src/bgp_unnumbered_status/mod.rs @@ -0,0 +1,5 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +pub mod networking; diff --git a/nexus/types/versions/src/bgp_unnumbered_status/networking.rs b/nexus/types/versions/src/bgp_unnumbered_status/networking.rs new file mode 100644 index 00000000000..0fe7cbbac92 --- /dev/null +++ b/nexus/types/versions/src/bgp_unnumbered_status/networking.rs @@ -0,0 +1,187 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +use std::net::Ipv6Addr; + +use mg_admin_client::types::{ + DiscoveredRouter as MgDiscoveredRouter, + PendingUnnumberedInterface as MgPendingUnnumberedInterface, + RouterDiscoveryRuntimeState as MgRouterDiscoveryRuntimeState, + UnnumberedInterface as MgUnnumberedInterface, + UnnumberedManagerState as MgUnnumberedManagerState, +}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use sled_agent_types_versions::v1::early_networking::SwitchSlot; + +fn nexus_interface_name(interface: String) -> String { + if let Some(interface) = interface + .strip_prefix("tfport") + .and_then(|interface| interface.strip_suffix("_0")) + { + interface.to_string() + } else { + interface + } +} + +#[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)] +pub struct UnnumberedInterfacePath { + pub switch_slot: SwitchSlot, + pub interface_name: String, +} + +#[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)] +pub struct UnnumberedManagerState { + pub monitor_running: bool, + pub pending_interfaces: Vec, + pub active_interfaces: Vec, +} + +#[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)] +pub struct SwitchUnnumberedManagerState { + pub switch_slot: SwitchSlot, + pub state: UnnumberedManagerState, +} + +impl From for UnnumberedManagerState { + fn from(value: MgUnnumberedManagerState) -> Self { + let MgUnnumberedManagerState { + monitor_running, + pending_interfaces, + active_interfaces, + } = value; + + Self { + monitor_running, + pending_interfaces: pending_interfaces + .into_iter() + .map(Into::into) + .collect(), + active_interfaces: active_interfaces + .into_iter() + .map(nexus_interface_name) + .collect(), + } + } +} + +#[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)] +pub struct PendingUnnumberedInterface { + /// Interface name + pub interface: String, + /// Configured router lifetime (seconds) + pub router_lifetime: u16, +} + +impl From for PendingUnnumberedInterface { + fn from(value: MgPendingUnnumberedInterface) -> Self { + let MgPendingUnnumberedInterface { interface, router_lifetime } = value; + Self { interface: nexus_interface_name(interface), router_lifetime } + } +} + +#[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 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(), + } + } +} + +#[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)] +pub struct DiscoveredRouter { + /// Peer IPv6 address + pub address: Ipv6Addr, + /// When the peer was first discovered (ISO 8601 timestamp) + pub discovered_at: String, + /// When the most recent Router Advertisement was received (ISO 8601 + /// timestamp) + pub last_advertisement: String, + /// Router lifetime from RA (seconds) + pub router_lifetime: u16, + /// Reachable time from RA (milliseconds) + pub reachable_time: u32, + /// Retransmit timer from RA (milliseconds) + pub retrans_timer: u32, + /// Whether the peer entry has expired + pub expired: bool, + /// Time until expiry (human-readable), or None if already expired + pub time_until_expiry: Option, +} + +impl From for DiscoveredRouter { + fn from(value: MgDiscoveredRouter) -> Self { + let MgDiscoveredRouter { + address, + discovered_at, + last_advertisement, + router_lifetime, + reachable_time, + retrans_timer, + expired, + time_until_expiry, + } = value; + + Self { + address, + discovered_at, + last_advertisement, + router_lifetime, + reachable_time, + retrans_timer, + expired, + time_until_expiry, + } + } +} diff --git a/nexus/types/versions/src/latest.rs b/nexus/types/versions/src/latest.rs index cf4becc0fa5..ef0737d90a2 100644 --- a/nexus/types/versions/src/latest.rs +++ b/nexus/types/versions/src/latest.rs @@ -330,6 +330,15 @@ 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_06_25_00::networking::DiscoveredRouter; + pub use crate::v2026_06_25_00::networking::PendingUnnumberedInterface; + pub use crate::v2026_06_25_00::networking::RouterDiscoveryRuntimeState; + pub use crate::v2026_06_25_00::networking::SwitchUnnumberedInterface; + pub use crate::v2026_06_25_00::networking::SwitchUnnumberedManagerState; + pub use crate::v2026_06_25_00::networking::UnnumberedInterface; + pub use crate::v2026_06_25_00::networking::UnnumberedInterfacePath; + pub use crate::v2026_06_25_00::networking::UnnumberedManagerState; } pub mod oxql { diff --git a/nexus/types/versions/src/lib.rs b/nexus/types/versions/src/lib.rs index 0998a1a4d18..84536167545 100644 --- a/nexus/types/versions/src/lib.rs +++ b/nexus/types/versions/src/lib.rs @@ -95,3 +95,5 @@ pub mod v2026_06_05_00; pub mod v2026_06_08_00; #[path = "bgp_configuration_update/mod.rs"] pub mod v2026_06_10_00; +#[path = "bgp_unnumbered_status/mod.rs"] +pub mod v2026_06_25_00; diff --git a/openapi/nexus/nexus-2026061000.0.0-9779e2.json.gitstub b/openapi/nexus/nexus-2026061000.0.0-9779e2.json.gitstub new file mode 100644 index 00000000000..924c037e31b --- /dev/null +++ b/openapi/nexus/nexus-2026061000.0.0-9779e2.json.gitstub @@ -0,0 +1 @@ +3a8796bc025128e4df2fe38ebc4404755823fa1d:openapi/nexus/nexus-2026061000.0.0-9779e2.json diff --git a/openapi/nexus/nexus-2026061000.0.0-9779e2.json b/openapi/nexus/nexus-2026062500.0.0-dccd9c.json similarity index 99% rename from openapi/nexus/nexus-2026061000.0.0-9779e2.json rename to openapi/nexus/nexus-2026062500.0.0-dccd9c.json index a71809018ae..ce4fd59cb8e 100644 --- a/openapi/nexus/nexus-2026061000.0.0-9779e2.json +++ b/openapi/nexus/nexus-2026062500.0.0-dccd9c.json @@ -7,7 +7,7 @@ "url": "https://oxide.computer", "email": "api@oxide.computer" }, - "version": "2026061000.0.0" + "version": "2026062500.0.0" }, "paths": { "/device/auth": { @@ -10787,6 +10787,113 @@ } } }, + "/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": { + "title": "Array_of_SwitchUnnumberedInterface", + "type": "array", + "items": { + "$ref": "#/components/schemas/SwitchUnnumberedInterface" + } + } + } + } + }, + "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": { + "title": "Array_of_SwitchUnnumberedManagerState", + "type": "array", + "items": { + "$ref": "#/components/schemas/SwitchUnnumberedManagerState" + } + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, "/v1/system/networking/bgp-announce-set": { "get": { "tags": [ @@ -19914,6 +20021,60 @@ } ] }, + "DiscoveredRouter": { + "type": "object", + "properties": { + "address": { + "description": "Peer IPv6 address", + "type": "string", + "format": "ipv6" + }, + "discovered_at": { + "description": "When the peer was first discovered (ISO 8601 timestamp)", + "type": "string" + }, + "expired": { + "description": "Whether the peer entry has expired", + "type": "boolean" + }, + "last_advertisement": { + "description": "When the most recent Router Advertisement was received (ISO 8601 timestamp)", + "type": "string" + }, + "reachable_time": { + "description": "Reachable time from RA (milliseconds)", + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "retrans_timer": { + "description": "Retransmit timer from RA (milliseconds)", + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "router_lifetime": { + "description": "Router lifetime from RA (seconds)", + "type": "integer", + "format": "uint16", + "minimum": 0 + }, + "time_until_expiry": { + "nullable": true, + "description": "Time until expiry (human-readable), or None if already expired", + "type": "string" + } + }, + "required": [ + "address", + "discovered_at", + "expired", + "last_advertisement", + "reachable_time", + "retrans_timer", + "router_lifetime" + ] + }, "Disk": { "description": "View of a Disk", "type": "object", @@ -25452,6 +25613,25 @@ "type": "string", "maxLength": 512 }, + "PendingUnnumberedInterface": { + "type": "object", + "properties": { + "interface": { + "description": "Interface name", + "type": "string" + }, + "router_lifetime": { + "description": "Configured router lifetime (seconds)", + "type": "integer", + "format": "uint16", + "minimum": 0 + } + }, + "required": [ + "interface", + "router_lifetime" + ] + }, "PhysicalDisk": { "description": "View of a Physical Disk\n\nPhysical disks reside in a particular sled and are used to store both Instance Disk data as well as internal metadata.", "type": "object", @@ -26918,6 +27098,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", @@ -29750,6 +29947,36 @@ } ] }, + "SwitchUnnumberedInterface": { + "type": "object", + "properties": { + "interface": { + "$ref": "#/components/schemas/UnnumberedInterface" + }, + "switch_slot": { + "$ref": "#/components/schemas/SwitchSlot" + } + }, + "required": [ + "interface", + "switch_slot" + ] + }, + "SwitchUnnumberedManagerState": { + "type": "object", + "properties": { + "state": { + "$ref": "#/components/schemas/UnnumberedManagerState" + }, + "switch_slot": { + "$ref": "#/components/schemas/SwitchSlot" + } + }, + "required": [ + "state", + "switch_slot" + ] + }, "SystemNetworkingSettings": { "description": "Fleet-wide networking settings. Only fleet viewers may view these settings. Only fleet admins can modify them.", "type": "object", @@ -30161,6 +30388,74 @@ } ] }, + "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" + ] + }, + "UnnumberedManagerState": { + "type": "object", + "properties": { + "active_interfaces": { + "type": "array", + "items": { + "type": "string" + } + }, + "monitor_running": { + "type": "boolean" + }, + "pending_interfaces": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PendingUnnumberedInterface" + } + } + }, + "required": [ + "active_interfaces", + "monitor_running", + "pending_interfaces" + ] + }, "UpdateStatus": { "type": "object", "properties": { diff --git a/openapi/nexus/nexus-latest.json b/openapi/nexus/nexus-latest.json index d5753ab6c18..df0c935fc9a 120000 --- a/openapi/nexus/nexus-latest.json +++ b/openapi/nexus/nexus-latest.json @@ -1 +1 @@ -nexus-2026061000.0.0-9779e2.json \ No newline at end of file +nexus-2026062500.0.0-dccd9c.json \ No newline at end of file diff --git a/package-manifest.toml b/package-manifest.toml index a20d54c5a12..0b6d4699156 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 = "dc84a6ce494a9d004568b67e22add2285a04d887" +source.commit = "0d06cd1533cb8c376ac4ac30a278815c02e2f0e1" # The SHA256 digest is automatically posted to: # https://buildomat.eng.oxide.computer/public/file/oxidecomputer/maghemite/image//mg-ddm-gz.sha256.txt -source.sha256 = "0b9abe2d1bf06cbf50ad40e0c5aef2c884e14e0ae5c516054de243eabd507f58" +source.sha256 = "85071cc3c1edfe4d76151a7f9b0e72262bc5388cc0d949719406163e12ad0253" 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 = "dc84a6ce494a9d004568b67e22add2285a04d887" +source.commit = "0d06cd1533cb8c376ac4ac30a278815c02e2f0e1" # The SHA256 digest is automatically posted to: # https://buildomat.eng.oxide.computer/public/file/oxidecomputer/maghemite/image//mg-ddm.sha256.txt -source.sha256 = "27333f4d73db9e3e678245e9baa874ec5c0985e8aeec2ebc9d96090e311b81eb" +source.sha256 = "46aa65671029ddcd963b76e22676629124936fecef327ba64c711deb72becfcc" 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 = "dc84a6ce494a9d004568b67e22add2285a04d887" +source.commit = "0d06cd1533cb8c376ac4ac30a278815c02e2f0e1" # The SHA256 digest is automatically posted to: # https://buildomat.eng.oxide.computer/public/file/oxidecomputer/maghemite/image//mgd.sha256.txt -source.sha256 = "79339c92c52185a15d867305209e5aedfc0c15e251c27e29cf356c6da113531e" +source.sha256 = "56dabbebad21bc9c6dacecda5e698cf35eeb4e51513dd84e3fcea0228bc77f5c" output.type = "zone" output.intermediate_only = true diff --git a/tools/maghemite_ddm_openapi_version b/tools/maghemite_ddm_openapi_version index 08c940ae598..bba5583f4de 100644 --- a/tools/maghemite_ddm_openapi_version +++ b/tools/maghemite_ddm_openapi_version @@ -1 +1 @@ -COMMIT="dc84a6ce494a9d004568b67e22add2285a04d887" +COMMIT="0d06cd1533cb8c376ac4ac30a278815c02e2f0e1" diff --git a/tools/maghemite_mg_openapi_version b/tools/maghemite_mg_openapi_version index 08c940ae598..bba5583f4de 100644 --- a/tools/maghemite_mg_openapi_version +++ b/tools/maghemite_mg_openapi_version @@ -1 +1 @@ -COMMIT="dc84a6ce494a9d004568b67e22add2285a04d887" +COMMIT="0d06cd1533cb8c376ac4ac30a278815c02e2f0e1" diff --git a/tools/maghemite_mgd_checksums b/tools/maghemite_mgd_checksums index 799fa32bdfe..13250781af0 100644 --- a/tools/maghemite_mgd_checksums +++ b/tools/maghemite_mgd_checksums @@ -1,4 +1,4 @@ -CIDL_SHA256="79339c92c52185a15d867305209e5aedfc0c15e251c27e29cf356c6da113531e" -MGD_LINUX_SHA256="eb039818f21d81ea216eacdf3ea3af93d81a4380d57ebdf8df5758912602ec2d" -MG_DDM_SHA256="27333f4d73db9e3e678245e9baa874ec5c0985e8aeec2ebc9d96090e311b81eb" -DDMD_LINUX_SHA256="1b04298fc63281b3e8ef6166b6a12d211f35604ead5093c7d0071cc526163bf2" \ No newline at end of file +CIDL_SHA256="56dabbebad21bc9c6dacecda5e698cf35eeb4e51513dd84e3fcea0228bc77f5c" +MGD_LINUX_SHA256="d5423820e4fefdaed2e0fda7bdde70139cf5847588ea3c6cd4f83c1fca4aa7e6" +MG_DDM_SHA256="46aa65671029ddcd963b76e22676629124936fecef327ba64c711deb72becfcc" +DDMD_LINUX_SHA256="f9aef2c0187137ccf7d6a0a0647065947b11fd637fefe8e6e6fc90757dd877cf" \ No newline at end of file diff --git a/workspace-hack/Cargo.toml b/workspace-hack/Cargo.toml index 634077d7307..c6363221f96 100644 --- a/workspace-hack/Cargo.toml +++ b/workspace-hack/Cargo.toml @@ -149,6 +149,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"] } @@ -300,6 +301,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"] } @@ -409,7 +411,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.3", 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"] } [target.x86_64-unknown-illumos.build-dependencies] chacha20 = { version = "0.10.0", default-features = false, features = ["legacy", "rng", "zeroize"] } @@ -427,6 +428,5 @@ 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.3", 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"] } ### END HAKARI SECTION From 96fe2a148556894c072709b45bb0171cd4f5a703 Mon Sep 17 00:00:00 2001 From: Trey Aspelund Date: Wed, 1 Jul 2026 10:13:39 -0600 Subject: [PATCH 03/16] Add new endpoints to authz tests Signed-off-by: Trey Aspelund --- nexus/tests/integration_tests/endpoints.rs | 24 ++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/nexus/tests/integration_tests/endpoints.rs b/nexus/tests/integration_tests/endpoints.rs index 67e7a0c8b9a..ee7a38f6082 100644 --- a/nexus/tests/integration_tests/endpoints.rs +++ b/nexus/tests/integration_tests/endpoints.rs @@ -1046,6 +1046,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"; @@ -3499,6 +3505,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, From bc4365e7861853ddd202ec2f28a4b0fd73c2c8a9 Mon Sep 17 00:00:00 2001 From: Trey Aspelund Date: Mon, 6 Jul 2026 14:51:57 -0600 Subject: [PATCH 04/16] initial review feedback Signed-off-by: Trey Aspelund --- nexus/src/app/unnumbered.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/nexus/src/app/unnumbered.rs b/nexus/src/app/unnumbered.rs index ed4351529e7..8dc0f49fd6f 100644 --- a/nexus/src/app/unnumbered.rs +++ b/nexus/src/app/unnumbered.rs @@ -8,6 +8,7 @@ use nexus_types::external_api::networking::{ }; use omicron_common::api::external::Error; use sled_agent_types::early_networking::SwitchSlot; +use strum::IntoEnumIterator; fn maghemite_interface_name(interface_name: &str) -> String { format!("tfport{interface_name}_0") @@ -16,14 +17,14 @@ fn maghemite_interface_name(interface_name: &str) -> String { impl super::Nexus { pub async fn bgp_unnumbered_manager_status( &self, - _optctx: &OpContext, + _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 mut result = Vec::new(); - for switch_slot in [SwitchSlot::Switch0, SwitchSlot::Switch1] { + for switch_slot in SwitchSlot::iter() { // 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 { @@ -53,14 +54,14 @@ impl super::Nexus { pub async fn bgp_unnumbered_interfaces( &self, - _optctx: &OpContext, + _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 mut result = Vec::new(); - for switch_slot in [SwitchSlot::Switch0, SwitchSlot::Switch1] { + for switch_slot in SwitchSlot::iter() { // 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 { @@ -92,7 +93,7 @@ impl super::Nexus { pub async fn bgp_unnumbered_interface( &self, - _optctx: &OpContext, + _opctx: &OpContext, switch_slot: SwitchSlot, interface_name: String, ) -> Result { From c69cc2c72a20f36a3a8c1fec466c991a8a46dff3 Mon Sep 17 00:00:00 2001 From: Trey Aspelund Date: Tue, 7 Jul 2026 18:48:08 -0600 Subject: [PATCH 05/16] New maghemite API shape... again Signed-off-by: Trey Aspelund --- Cargo.lock | 15 +- Cargo.toml | 6 +- .../src/bgp_unnumbered_status/networking.rs | 123 +++++++----- nexus/types/versions/src/latest.rs | 1 + ....json => nexus-2026062500.0.0-fe14a1.json} | 189 +++++++++++++----- openapi/nexus/nexus-latest.json | 2 +- package-manifest.toml | 12 +- tools/maghemite_ddm_openapi_version | 2 +- tools/maghemite_mg_openapi_version | 2 +- tools/maghemite_mgd_checksums | 8 +- 10 files changed, 239 insertions(+), 121 deletions(-) rename openapi/nexus/{nexus-2026062500.0.0-dccd9c.json => nexus-2026062500.0.0-fe14a1.json} (99%) diff --git a/Cargo.lock b/Cargo.lock index 87b78539932..d5d693d693d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1630,7 +1630,7 @@ dependencies = [ [[package]] name = "client-common" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/maghemite?rev=0d06cd1533cb8c376ac4ac30a278815c02e2f0e1#0d06cd1533cb8c376ac4ac30a278815c02e2f0e1" +source = "git+https://github.com/oxidecomputer/maghemite?rev=06bb62cc303fd86add35eee4a03291fd5932dbfb#06bb62cc303fd86add35eee4a03291fd5932dbfb" [[package]] name = "clipboard-win" @@ -2665,7 +2665,7 @@ dependencies = [ [[package]] name = "ddm-admin-client" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/maghemite?rev=0d06cd1533cb8c376ac4ac30a278815c02e2f0e1#0d06cd1533cb8c376ac4ac30a278815c02e2f0e1" +source = "git+https://github.com/oxidecomputer/maghemite?rev=06bb62cc303fd86add35eee4a03291fd5932dbfb#06bb62cc303fd86add35eee4a03291fd5932dbfb" dependencies = [ "ddm-api-types-versions", "oxnet", @@ -2679,7 +2679,7 @@ dependencies = [ [[package]] name = "ddm-api-types-versions" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/maghemite?rev=0d06cd1533cb8c376ac4ac30a278815c02e2f0e1#0d06cd1533cb8c376ac4ac30a278815c02e2f0e1" +source = "git+https://github.com/oxidecomputer/maghemite?rev=06bb62cc303fd86add35eee4a03291fd5932dbfb#06bb62cc303fd86add35eee4a03291fd5932dbfb" dependencies = [ "ddm-protocol", "oxnet", @@ -2692,7 +2692,7 @@ dependencies = [ [[package]] name = "ddm-protocol" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/maghemite?rev=0d06cd1533cb8c376ac4ac30a278815c02e2f0e1#0d06cd1533cb8c376ac4ac30a278815c02e2f0e1" +source = "git+https://github.com/oxidecomputer/maghemite?rev=06bb62cc303fd86add35eee4a03291fd5932dbfb#06bb62cc303fd86add35eee4a03291fd5932dbfb" dependencies = [ "oxnet", "schemars 0.8.22", @@ -6855,7 +6855,7 @@ dependencies = [ [[package]] name = "mg-admin-client" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/maghemite?rev=0d06cd1533cb8c376ac4ac30a278815c02e2f0e1#0d06cd1533cb8c376ac4ac30a278815c02e2f0e1" +source = "git+https://github.com/oxidecomputer/maghemite?rev=06bb62cc303fd86add35eee4a03291fd5932dbfb#06bb62cc303fd86add35eee4a03291fd5932dbfb" dependencies = [ "chrono", "client-common", @@ -6875,7 +6875,7 @@ dependencies = [ [[package]] name = "mg-api-types" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/maghemite?rev=0d06cd1533cb8c376ac4ac30a278815c02e2f0e1#0d06cd1533cb8c376ac4ac30a278815c02e2f0e1" +source = "git+https://github.com/oxidecomputer/maghemite?rev=06bb62cc303fd86add35eee4a03291fd5932dbfb#06bb62cc303fd86add35eee4a03291fd5932dbfb" dependencies = [ "mg-api-types-versions", ] @@ -6883,9 +6883,10 @@ dependencies = [ [[package]] name = "mg-api-types-versions" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/maghemite?rev=0d06cd1533cb8c376ac4ac30a278815c02e2f0e1#0d06cd1533cb8c376ac4ac30a278815c02e2f0e1" +source = "git+https://github.com/oxidecomputer/maghemite?rev=06bb62cc303fd86add35eee4a03291fd5932dbfb#06bb62cc303fd86add35eee4a03291fd5932dbfb" dependencies = [ "chrono", + "client-common", "nom 8.0.0", "num_enum 0.7.6", "oxnet", diff --git a/Cargo.toml b/Cargo.toml index 7b8693c0acb..7bb2d3d612d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -623,8 +623,8 @@ ntp-admin-client = { path = "clients/ntp-admin-client" } ntp-admin-v1-client = { path = "clients/ntp-admin-v1-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 = "0d06cd1533cb8c376ac4ac30a278815c02e2f0e1" } -ddm-admin-client = { git = "https://github.com/oxidecomputer/maghemite", rev = "0d06cd1533cb8c376ac4ac30a278815c02e2f0e1" } +mg-admin-client = { git = "https://github.com/oxidecomputer/maghemite", rev = "06bb62cc303fd86add35eee4a03291fd5932dbfb" } +ddm-admin-client = { git = "https://github.com/oxidecomputer/maghemite", rev = "06bb62cc303fd86add35eee4a03291fd5932dbfb" } multimap = "0.10.1" nexus-auth = { path = "nexus/auth" } nexus-background-task-interface = { path = "nexus/background-task-interface" } @@ -763,7 +763,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 = "0d06cd1533cb8c376ac4ac30a278815c02e2f0e1" } +mg-api-types = { git = "https://github.com/oxidecomputer/maghemite", rev = "06bb62cc303fd86add35eee4a03291fd5932dbfb" } reconfigurator-cli = { path = "dev-tools/reconfigurator-cli" } reedline = "0.40.0" ref-cast = "1.0" diff --git a/nexus/types/versions/src/bgp_unnumbered_status/networking.rs b/nexus/types/versions/src/bgp_unnumbered_status/networking.rs index 0fe7cbbac92..85d0f7e6584 100644 --- a/nexus/types/versions/src/bgp_unnumbered_status/networking.rs +++ b/nexus/types/versions/src/bgp_unnumbered_status/networking.rs @@ -2,15 +2,17 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. +use std::collections::BTreeMap; use std::net::Ipv6Addr; +use std::time::Duration; use mg_admin_client::types::{ - DiscoveredRouter as MgDiscoveredRouter, - PendingUnnumberedInterface as MgPendingUnnumberedInterface, RouterDiscoveryRuntimeState as MgRouterDiscoveryRuntimeState, UnnumberedInterface as MgUnnumberedInterface, + UnnumberedInterfaceStatus as MgUnnumberedInterfaceStatus, UnnumberedManagerState as MgUnnumberedManagerState, }; +use mg_api_types::unnumbered::DiscoveredRouter as MgDiscoveredRouter; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use sled_agent_types_versions::v1::early_networking::SwitchSlot; @@ -32,41 +34,82 @@ pub struct UnnumberedInterfacePath { pub interface_name: String, } -#[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)] -pub struct UnnumberedManagerState { - pub monitor_running: bool, - pub pending_interfaces: Vec, - pub active_interfaces: Vec, -} - #[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)] pub struct SwitchUnnumberedManagerState { pub switch_slot: SwitchSlot, pub state: UnnumberedManagerState, } +#[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, - pending_interfaces, - active_interfaces, - } = value; + let MgUnnumberedManagerState { monitor_running, interfaces } = value; Self { monitor_running, - pending_interfaces: pending_interfaces + interfaces: interfaces .into_iter() - .map(Into::into) - .collect(), - active_interfaces: active_interfaces - .into_iter() - .map(nexus_interface_name) + .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 @@ -75,13 +118,6 @@ pub struct PendingUnnumberedInterface { pub router_lifetime: u16, } -impl From for PendingUnnumberedInterface { - fn from(value: MgPendingUnnumberedInterface) -> Self { - let MgPendingUnnumberedInterface { interface, router_lifetime } = value; - Self { interface: nexus_interface_name(interface), router_lifetime } - } -} - #[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)] pub struct RouterDiscoveryRuntimeState { /// ICMPv6 Router Advertisement transmit loop is running @@ -139,49 +175,44 @@ impl From for UnnumberedInterface { } } +/// Information about a router discovered through router advertisements. #[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)] pub struct DiscoveredRouter { - /// Peer IPv6 address + /// Router IPv6 address pub address: Ipv6Addr, - /// When the peer was first discovered (ISO 8601 timestamp) - pub discovered_at: String, - /// When the most recent Router Advertisement was received (ISO 8601 - /// timestamp) - pub last_advertisement: String, + /// 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 (seconds) pub router_lifetime: u16, /// Reachable time from RA (milliseconds) pub reachable_time: u32, /// Retransmit timer from RA (milliseconds) pub retrans_timer: u32, - /// Whether the peer entry has expired - pub expired: bool, - /// Time until expiry (human-readable), or None if already expired - pub time_until_expiry: Option, } impl From for DiscoveredRouter { fn from(value: MgDiscoveredRouter) -> Self { let MgDiscoveredRouter { address, - discovered_at, - last_advertisement, + time_since_discovered, + time_since_last_rx, + effective_reachable_time, router_lifetime, reachable_time, retrans_timer, - expired, - time_until_expiry, } = value; - Self { address, - discovered_at, - last_advertisement, + time_since_discovered, + time_since_last_rx, + effective_reachable_time, router_lifetime, reachable_time, retrans_timer, - expired, - time_until_expiry, } } } diff --git a/nexus/types/versions/src/latest.rs b/nexus/types/versions/src/latest.rs index ef0737d90a2..2631d1b39dd 100644 --- a/nexus/types/versions/src/latest.rs +++ b/nexus/types/versions/src/latest.rs @@ -338,6 +338,7 @@ pub mod networking { pub use crate::v2026_06_25_00::networking::SwitchUnnumberedManagerState; pub use crate::v2026_06_25_00::networking::UnnumberedInterface; pub use crate::v2026_06_25_00::networking::UnnumberedInterfacePath; + pub use crate::v2026_06_25_00::networking::UnnumberedInterfaceStatus; pub use crate::v2026_06_25_00::networking::UnnumberedManagerState; } diff --git a/openapi/nexus/nexus-2026062500.0.0-dccd9c.json b/openapi/nexus/nexus-2026062500.0.0-fe14a1.json similarity index 99% rename from openapi/nexus/nexus-2026062500.0.0-dccd9c.json rename to openapi/nexus/nexus-2026062500.0.0-fe14a1.json index ce4fd59cb8e..2b41f6c66c7 100644 --- a/openapi/nexus/nexus-2026062500.0.0-dccd9c.json +++ b/openapi/nexus/nexus-2026062500.0.0-fe14a1.json @@ -20022,24 +20022,21 @@ ] }, "DiscoveredRouter": { + "description": "Information about a router discovered through router advertisements.", "type": "object", "properties": { "address": { - "description": "Peer IPv6 address", + "description": "Router IPv6 address", "type": "string", "format": "ipv6" }, - "discovered_at": { - "description": "When the peer was first discovered (ISO 8601 timestamp)", - "type": "string" - }, - "expired": { - "description": "Whether the peer entry has expired", - "type": "boolean" - }, - "last_advertisement": { - "description": "When the most recent Router Advertisement was received (ISO 8601 timestamp)", - "type": "string" + "effective_reachable_time": { + "description": "Effective reachable time governing expiry of this entry", + "allOf": [ + { + "$ref": "#/components/schemas/Duration" + } + ] }, "reachable_time": { "description": "Reachable time from RA (milliseconds)", @@ -20059,20 +20056,31 @@ "format": "uint16", "minimum": 0 }, - "time_until_expiry": { - "nullable": true, - "description": "Time until expiry (human-readable), or None if already expired", - "type": "string" + "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", - "discovered_at", - "expired", - "last_advertisement", + "effective_reachable_time", "reachable_time", "retrans_timer", - "router_lifetime" + "router_lifetime", + "time_since_discovered", + "time_since_last_rx" ] }, "Disk": { @@ -20695,6 +20703,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", @@ -25613,25 +25640,6 @@ "type": "string", "maxLength": 512 }, - "PendingUnnumberedInterface": { - "type": "object", - "properties": { - "interface": { - "description": "Interface name", - "type": "string" - }, - "router_lifetime": { - "description": "Configured router lifetime (seconds)", - "type": "integer", - "format": "uint16", - "minimum": 0 - } - }, - "required": [ - "interface", - "router_lifetime" - ] - }, "PhysicalDisk": { "description": "View of a Physical Disk\n\nPhysical disks reside in a particular sled and are used to store both Instance Disk data as well as internal metadata.", "type": "object", @@ -30431,29 +30439,106 @@ "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 + } + ] + }, "UnnumberedManagerState": { "type": "object", "properties": { - "active_interfaces": { - "type": "array", - "items": { - "type": "string" + "interfaces": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/UnnumberedInterfaceStatus" } }, "monitor_running": { "type": "boolean" - }, - "pending_interfaces": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PendingUnnumberedInterface" - } } }, "required": [ - "active_interfaces", - "monitor_running", - "pending_interfaces" + "interfaces", + "monitor_running" ] }, "UpdateStatus": { diff --git a/openapi/nexus/nexus-latest.json b/openapi/nexus/nexus-latest.json index df0c935fc9a..dc3b1be3b18 120000 --- a/openapi/nexus/nexus-latest.json +++ b/openapi/nexus/nexus-latest.json @@ -1 +1 @@ -nexus-2026062500.0.0-dccd9c.json \ No newline at end of file +nexus-2026062500.0.0-fe14a1.json \ No newline at end of file diff --git a/package-manifest.toml b/package-manifest.toml index 0b6d4699156..b3fe21b0769 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 = "0d06cd1533cb8c376ac4ac30a278815c02e2f0e1" +source.commit = "06bb62cc303fd86add35eee4a03291fd5932dbfb" # The SHA256 digest is automatically posted to: # https://buildomat.eng.oxide.computer/public/file/oxidecomputer/maghemite/image//mg-ddm-gz.sha256.txt -source.sha256 = "85071cc3c1edfe4d76151a7f9b0e72262bc5388cc0d949719406163e12ad0253" +source.sha256 = "8a34870f1184b1ef17985de10d9b515a3ec589502a5836f677c0defe888a7001" 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 = "0d06cd1533cb8c376ac4ac30a278815c02e2f0e1" +source.commit = "06bb62cc303fd86add35eee4a03291fd5932dbfb" # The SHA256 digest is automatically posted to: # https://buildomat.eng.oxide.computer/public/file/oxidecomputer/maghemite/image//mg-ddm.sha256.txt -source.sha256 = "46aa65671029ddcd963b76e22676629124936fecef327ba64c711deb72becfcc" +source.sha256 = "876c313eb9952c2a9ea09ff1d6fde9ab6a5103e62ce7463d357aeaf2c64057f0" 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 = "0d06cd1533cb8c376ac4ac30a278815c02e2f0e1" +source.commit = "06bb62cc303fd86add35eee4a03291fd5932dbfb" # The SHA256 digest is automatically posted to: # https://buildomat.eng.oxide.computer/public/file/oxidecomputer/maghemite/image//mgd.sha256.txt -source.sha256 = "56dabbebad21bc9c6dacecda5e698cf35eeb4e51513dd84e3fcea0228bc77f5c" +source.sha256 = "75473c73dd9e3841fd4a3106571967e5c958d00558ef26eac8b1563505aea187" output.type = "zone" output.intermediate_only = true diff --git a/tools/maghemite_ddm_openapi_version b/tools/maghemite_ddm_openapi_version index bba5583f4de..b2be977649c 100644 --- a/tools/maghemite_ddm_openapi_version +++ b/tools/maghemite_ddm_openapi_version @@ -1 +1 @@ -COMMIT="0d06cd1533cb8c376ac4ac30a278815c02e2f0e1" +COMMIT="06bb62cc303fd86add35eee4a03291fd5932dbfb" diff --git a/tools/maghemite_mg_openapi_version b/tools/maghemite_mg_openapi_version index bba5583f4de..b2be977649c 100644 --- a/tools/maghemite_mg_openapi_version +++ b/tools/maghemite_mg_openapi_version @@ -1 +1 @@ -COMMIT="0d06cd1533cb8c376ac4ac30a278815c02e2f0e1" +COMMIT="06bb62cc303fd86add35eee4a03291fd5932dbfb" diff --git a/tools/maghemite_mgd_checksums b/tools/maghemite_mgd_checksums index 13250781af0..34538add612 100644 --- a/tools/maghemite_mgd_checksums +++ b/tools/maghemite_mgd_checksums @@ -1,4 +1,4 @@ -CIDL_SHA256="56dabbebad21bc9c6dacecda5e698cf35eeb4e51513dd84e3fcea0228bc77f5c" -MGD_LINUX_SHA256="d5423820e4fefdaed2e0fda7bdde70139cf5847588ea3c6cd4f83c1fca4aa7e6" -MG_DDM_SHA256="46aa65671029ddcd963b76e22676629124936fecef327ba64c711deb72becfcc" -DDMD_LINUX_SHA256="f9aef2c0187137ccf7d6a0a0647065947b11fd637fefe8e6e6fc90757dd877cf" \ No newline at end of file +CIDL_SHA256="75473c73dd9e3841fd4a3106571967e5c958d00558ef26eac8b1563505aea187" +MGD_LINUX_SHA256="9417c1d4623a2eda63c37c0a5c4553399d15c83208ee14dd67dcc1dd7af4fa76" +MG_DDM_SHA256="876c313eb9952c2a9ea09ff1d6fde9ab6a5103e62ce7463d357aeaf2c64057f0" +DDMD_LINUX_SHA256="779d0bb1b56795831abb16c582a05cd4bb5c1081ad6e5cdc2f55397f3a725b9f" \ No newline at end of file From 2e0abe5f70caff7e85f675a68a18904a31930485 Mon Sep 17 00:00:00 2001 From: Trey Aspelund Date: Tue, 28 Jul 2026 17:45:17 -0600 Subject: [PATCH 06/16] Update Maghemite to PR #800 tip Signed-off-by: Trey Aspelund --- Cargo.lock | 50 +++++++++++++-------------- Cargo.toml | 6 ++-- nexus/src/app/background/tasks/bfd.rs | 5 ++- nexus/src/app/bfd.rs | 2 +- package-manifest.toml | 12 +++---- tools/maghemite_ddm_openapi_version | 2 +- tools/maghemite_mg_openapi_version | 2 +- tools/maghemite_mgd_checksums | 8 ++--- 8 files changed, 45 insertions(+), 42 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 697d65763c1..7ac533b8b0a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -167,7 +167,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -178,7 +178,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1640,7 +1640,7 @@ dependencies = [ [[package]] name = "client-common" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/maghemite?rev=06bb62cc303fd86add35eee4a03291fd5932dbfb#06bb62cc303fd86add35eee4a03291fd5932dbfb" +source = "git+https://github.com/oxidecomputer/maghemite?rev=5bb316ac73b6c0922ea4707c0f0aa9dae84c0e87#5bb316ac73b6c0922ea4707c0f0aa9dae84c0e87" [[package]] name = "clipboard-win" @@ -1749,7 +1749,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.48.0", ] [[package]] @@ -2675,7 +2675,7 @@ dependencies = [ [[package]] name = "ddm-admin-client" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/maghemite?rev=06bb62cc303fd86add35eee4a03291fd5932dbfb#06bb62cc303fd86add35eee4a03291fd5932dbfb" +source = "git+https://github.com/oxidecomputer/maghemite?rev=5bb316ac73b6c0922ea4707c0f0aa9dae84c0e87#5bb316ac73b6c0922ea4707c0f0aa9dae84c0e87" dependencies = [ "ddm-api-types-versions", "oxnet", @@ -2689,7 +2689,7 @@ dependencies = [ [[package]] name = "ddm-api-types-versions" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/maghemite?rev=06bb62cc303fd86add35eee4a03291fd5932dbfb#06bb62cc303fd86add35eee4a03291fd5932dbfb" +source = "git+https://github.com/oxidecomputer/maghemite?rev=5bb316ac73b6c0922ea4707c0f0aa9dae84c0e87#5bb316ac73b6c0922ea4707c0f0aa9dae84c0e87" dependencies = [ "ddm-protocol", "oxnet", @@ -2702,7 +2702,7 @@ dependencies = [ [[package]] name = "ddm-protocol" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/maghemite?rev=06bb62cc303fd86add35eee4a03291fd5932dbfb#06bb62cc303fd86add35eee4a03291fd5932dbfb" +source = "git+https://github.com/oxidecomputer/maghemite?rev=5bb316ac73b6c0922ea4707c0f0aa9dae84c0e87#5bb316ac73b6c0922ea4707c0f0aa9dae84c0e87" dependencies = [ "oxnet", "schemars 0.8.22", @@ -3836,7 +3836,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -5392,7 +5392,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.3", + "socket2 0.5.10", "system-configuration", "tokio", "tower-layer", @@ -6093,7 +6093,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi 0.5.2", "libc", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -6170,7 +6170,7 @@ dependencies = [ "portable-atomic", "portable-atomic-util", "serde_core", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -6875,7 +6875,7 @@ dependencies = [ [[package]] name = "mg-admin-client" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/maghemite?rev=06bb62cc303fd86add35eee4a03291fd5932dbfb#06bb62cc303fd86add35eee4a03291fd5932dbfb" +source = "git+https://github.com/oxidecomputer/maghemite?rev=5bb316ac73b6c0922ea4707c0f0aa9dae84c0e87#5bb316ac73b6c0922ea4707c0f0aa9dae84c0e87" dependencies = [ "chrono", "client-common", @@ -6895,7 +6895,7 @@ dependencies = [ [[package]] name = "mg-api-types" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/maghemite?rev=06bb62cc303fd86add35eee4a03291fd5932dbfb#06bb62cc303fd86add35eee4a03291fd5932dbfb" +source = "git+https://github.com/oxidecomputer/maghemite?rev=5bb316ac73b6c0922ea4707c0f0aa9dae84c0e87#5bb316ac73b6c0922ea4707c0f0aa9dae84c0e87" dependencies = [ "mg-api-types-versions", ] @@ -6903,7 +6903,7 @@ dependencies = [ [[package]] name = "mg-api-types-versions" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/maghemite?rev=06bb62cc303fd86add35eee4a03291fd5932dbfb#06bb62cc303fd86add35eee4a03291fd5932dbfb" +source = "git+https://github.com/oxidecomputer/maghemite?rev=5bb316ac73b6c0922ea4707c0f0aa9dae84c0e87#5bb316ac73b6c0922ea4707c0f0aa9dae84c0e87" dependencies = [ "chrono", "client-common", @@ -8439,7 +8439,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -8596,7 +8596,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", @@ -11924,7 +11924,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2 0.6.3", + "socket2 0.5.10", "thiserror 2.0.18", "tokio", "tracing", @@ -11962,9 +11962,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]] @@ -12890,7 +12890,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.11.0", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -12957,7 +12957,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -14548,7 +14548,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -15228,7 +15228,7 @@ dependencies = [ "getrandom 0.4.1", "once_cell", "rustix 1.1.3", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -15248,7 +15248,7 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8c27177b12a6399ffc08b98f76f7c9a1f4fe9fc967c784c5a071fa8d93cf7e1" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -17607,7 +17607,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.48.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index fd3d0dc289f..0b6d9105ac1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -624,8 +624,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 = "06bb62cc303fd86add35eee4a03291fd5932dbfb" } -ddm-admin-client = { git = "https://github.com/oxidecomputer/maghemite", rev = "06bb62cc303fd86add35eee4a03291fd5932dbfb" } +mg-admin-client = { git = "https://github.com/oxidecomputer/maghemite", rev = "5bb316ac73b6c0922ea4707c0f0aa9dae84c0e87" } +ddm-admin-client = { git = "https://github.com/oxidecomputer/maghemite", rev = "5bb316ac73b6c0922ea4707c0f0aa9dae84c0e87" } multimap = "0.10.1" nexus-auth = { path = "nexus/auth" } nexus-background-task-interface = { path = "nexus/background-task-interface" } @@ -765,7 +765,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 = "06bb62cc303fd86add35eee4a03291fd5932dbfb" } +mg-api-types = { git = "https://github.com/oxidecomputer/maghemite", rev = "5bb316ac73b6c0922ea4707c0f0aa9dae84c0e87" } reconfigurator-cli = { path = "dev-tools/reconfigurator-cli" } reedline = "0.40.0" ref-cast = "1.0" diff --git a/nexus/src/app/background/tasks/bfd.rs b/nexus/src/app/background/tasks/bfd.rs index f42a41f0bd8..8e1d1fb27ae 100644 --- a/nexus/src/app/background/tasks/bfd.rs +++ b/nexus/src/app/background/tasks/bfd.rs @@ -22,6 +22,7 @@ use std::{ collections::HashSet, hash::Hash, net::{IpAddr, Ipv4Addr}, + num::NonZeroU8, sync::Arc, }; @@ -40,7 +41,7 @@ struct BfdSessionKey { switch: SwitchSlot, local: Option, remote: IpAddr, - detection_threshold: u8, + detection_threshold: NonZeroU8, required_rx: u64, mode: BfdMode, } @@ -78,6 +79,8 @@ impl From for BfdSessionKey { .detection_threshold .0 .try_into() + .ok() + .and_then(NonZeroU8::new) .unwrap(), //TODO unwrap required_rx: value.required_rx.0.into(), mode: value.mode, diff --git a/nexus/src/app/bfd.rs b/nexus/src/app/bfd.rs index 43bb7b3cbf7..1d2534c61a8 100644 --- a/nexus/src/app/bfd.rs +++ b/nexus/src/app/bfd.rs @@ -84,7 +84,7 @@ impl super::Nexus { }, switch_slot, local: Some(info.config.listen), - detection_threshold: info.config.detection_threshold, + detection_threshold: info.config.detection_threshold.get(), required_rx: info.config.required_rx, mode: match info.config.mode { mg_admin_client::types::SessionMode::SingleHop => { diff --git a/package-manifest.toml b/package-manifest.toml index b3fe21b0769..996d174ad38 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 = "06bb62cc303fd86add35eee4a03291fd5932dbfb" +source.commit = "5bb316ac73b6c0922ea4707c0f0aa9dae84c0e87" # The SHA256 digest is automatically posted to: # https://buildomat.eng.oxide.computer/public/file/oxidecomputer/maghemite/image//mg-ddm-gz.sha256.txt -source.sha256 = "8a34870f1184b1ef17985de10d9b515a3ec589502a5836f677c0defe888a7001" +source.sha256 = "5f20e9bb6b0ec8ce14e4aecd91e5e7950d22f59d4bc5e90a120a9dbdc1501d6f" 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 = "06bb62cc303fd86add35eee4a03291fd5932dbfb" +source.commit = "5bb316ac73b6c0922ea4707c0f0aa9dae84c0e87" # The SHA256 digest is automatically posted to: # https://buildomat.eng.oxide.computer/public/file/oxidecomputer/maghemite/image//mg-ddm.sha256.txt -source.sha256 = "876c313eb9952c2a9ea09ff1d6fde9ab6a5103e62ce7463d357aeaf2c64057f0" +source.sha256 = "0628e8509cda7f66cbbb8448d40392c7c168ebbb30ca3598e174f5db822be9bd" 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 = "06bb62cc303fd86add35eee4a03291fd5932dbfb" +source.commit = "5bb316ac73b6c0922ea4707c0f0aa9dae84c0e87" # The SHA256 digest is automatically posted to: # https://buildomat.eng.oxide.computer/public/file/oxidecomputer/maghemite/image//mgd.sha256.txt -source.sha256 = "75473c73dd9e3841fd4a3106571967e5c958d00558ef26eac8b1563505aea187" +source.sha256 = "9e35f0ca01a23ee7b46d0ef77d549144b62405e564f3ffd967b2bec8781340fa" output.type = "zone" output.intermediate_only = true diff --git a/tools/maghemite_ddm_openapi_version b/tools/maghemite_ddm_openapi_version index b2be977649c..627a55c1729 100644 --- a/tools/maghemite_ddm_openapi_version +++ b/tools/maghemite_ddm_openapi_version @@ -1 +1 @@ -COMMIT="06bb62cc303fd86add35eee4a03291fd5932dbfb" +COMMIT="5bb316ac73b6c0922ea4707c0f0aa9dae84c0e87" diff --git a/tools/maghemite_mg_openapi_version b/tools/maghemite_mg_openapi_version index b2be977649c..627a55c1729 100644 --- a/tools/maghemite_mg_openapi_version +++ b/tools/maghemite_mg_openapi_version @@ -1 +1 @@ -COMMIT="06bb62cc303fd86add35eee4a03291fd5932dbfb" +COMMIT="5bb316ac73b6c0922ea4707c0f0aa9dae84c0e87" diff --git a/tools/maghemite_mgd_checksums b/tools/maghemite_mgd_checksums index 5386e407319..7b742d18cc3 100644 --- a/tools/maghemite_mgd_checksums +++ b/tools/maghemite_mgd_checksums @@ -1,4 +1,4 @@ -CIDL_SHA256="75473c73dd9e3841fd4a3106571967e5c958d00558ef26eac8b1563505aea187" -MGD_LINUX_SHA256="9417c1d4623a2eda63c37c0a5c4553399d15c83208ee14dd67dcc1dd7af4fa76" -MG_DDM_SHA256="876c313eb9952c2a9ea09ff1d6fde9ab6a5103e62ce7463d357aeaf2c64057f0" -DDMD_LINUX_SHA256="779d0bb1b56795831abb16c582a05cd4bb5c1081ad6e5cdc2f55397f3a725b9f" +CIDL_SHA256="9e35f0ca01a23ee7b46d0ef77d549144b62405e564f3ffd967b2bec8781340fa" +MGD_LINUX_SHA256="85147a24406d63f138a50b4dc9e8c338372d2274d0dda5e24b93e2fc75b7ae20" +MG_DDM_SHA256="0628e8509cda7f66cbbb8448d40392c7c168ebbb30ca3598e174f5db822be9bd" +DDMD_LINUX_SHA256="b8943e3ef9dd7c427f7b681c4e1889d8f2aa74c9c847ef039e3e63d78b2163f5" \ No newline at end of file From 6705343597b6ec44dab062bcb5a776e96bd4e6ab Mon Sep 17 00:00:00 2001 From: Trey Aspelund Date: Tue, 28 Jul 2026 18:26:43 -0600 Subject: [PATCH 07/16] Use flat paths for BGP unnumbered status endpoints Signed-off-by: Trey Aspelund --- nexus/external-api/output/nexus_tags.txt | 6 +- nexus/external-api/src/lib.rs | 6 +- ....json => nexus-2026072900.0.0-b1c66a.json} | 214 +++++++++--------- openapi/nexus/nexus-latest.json | 2 +- 4 files changed, 114 insertions(+), 114 deletions(-) rename openapi/nexus/{nexus-2026072900.0.0-f2ad91.json => nexus-2026072900.0.0-b1c66a.json} (99%) diff --git a/nexus/external-api/output/nexus_tags.txt b/nexus/external-api/output/nexus_tags.txt index 5a03e5a7e51..3bf5e5cf49f 100644 --- a/nexus/external-api/output/nexus_tags.txt +++ b/nexus/external-api/output/nexus_tags.txt @@ -281,9 +281,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_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 55c625475b7..0db8634866a 100644 --- a/nexus/external-api/src/lib.rs +++ b/nexus/external-api/src/lib.rs @@ -6118,7 +6118,7 @@ pub trait NexusExternalApi { /// Get BGP Unnumbered manager state #[endpoint { method = GET, - path = "/v1/system/networking/bgp/unnumbered/manager", + path = "/v1/system/networking/bgp-unnumbered-manager", tags = ["system/networking"], versions = VERSION_BGP_UNNUMBERED_STATUS.., }] @@ -6132,7 +6132,7 @@ pub trait NexusExternalApi { /// List BGP Unnumbered interfaces #[endpoint { method = GET, - path = "/v1/system/networking/bgp/unnumbered/interfaces", + path = "/v1/system/networking/bgp-unnumbered-interfaces", tags = ["system/networking"], versions = VERSION_BGP_UNNUMBERED_STATUS.., }] @@ -6146,7 +6146,7 @@ pub trait NexusExternalApi { /// Get BGP Unnumbered interface state #[endpoint { method = GET, - path = "/v1/system/networking/bgp/unnumbered/interfaces/{switch_slot}/{interface_name}", + path = "/v1/system/networking/bgp-unnumbered-interfaces/{switch_slot}/{interface_name}", tags = ["system/networking"], versions = VERSION_BGP_UNNUMBERED_STATUS.., }] diff --git a/openapi/nexus/nexus-2026072900.0.0-f2ad91.json b/openapi/nexus/nexus-2026072900.0.0-b1c66a.json similarity index 99% rename from openapi/nexus/nexus-2026072900.0.0-f2ad91.json rename to openapi/nexus/nexus-2026072900.0.0-b1c66a.json index 2774bbb3ec2..58eb9954cd4 100644 --- a/openapi/nexus/nexus-2026072900.0.0-f2ad91.json +++ b/openapi/nexus/nexus-2026072900.0.0-b1c66a.json @@ -10722,113 +10722,6 @@ } } }, - "/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": { - "title": "Array_of_SwitchUnnumberedInterface", - "type": "array", - "items": { - "$ref": "#/components/schemas/SwitchUnnumberedInterface" - } - } - } - } - }, - "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": { - "title": "Array_of_SwitchUnnumberedManagerState", - "type": "array", - "items": { - "$ref": "#/components/schemas/SwitchUnnumberedManagerState" - } - } - } - } - }, - "4XX": { - "$ref": "#/components/responses/Error" - }, - "5XX": { - "$ref": "#/components/responses/Error" - } - } - } - }, "/v1/system/networking/bgp-announce-set": { "get": { "tags": [ @@ -11147,6 +11040,113 @@ } } }, + "/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": { + "title": "Array_of_SwitchUnnumberedInterface", + "type": "array", + "items": { + "$ref": "#/components/schemas/SwitchUnnumberedInterface" + } + } + } + } + }, + "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": { + "title": "Array_of_SwitchUnnumberedManagerState", + "type": "array", + "items": { + "$ref": "#/components/schemas/SwitchUnnumberedManagerState" + } + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, "/v1/system/networking/inbound-icmp": { "get": { "tags": [ diff --git a/openapi/nexus/nexus-latest.json b/openapi/nexus/nexus-latest.json index 6a1ce2db78b..8bbe8e9fd5b 120000 --- a/openapi/nexus/nexus-latest.json +++ b/openapi/nexus/nexus-latest.json @@ -1 +1 @@ -nexus-2026072900.0.0-f2ad91.json \ No newline at end of file +nexus-2026072900.0.0-b1c66a.json \ No newline at end of file From 036d852764583685ff62c96671ed9b4eedfdf5b4 Mon Sep 17 00:00:00 2001 From: Trey Aspelund Date: Tue, 28 Jul 2026 18:28:21 -0600 Subject: [PATCH 08/16] Add a type for tfport interface names Signed-off-by: Trey Aspelund --- common/src/lib.rs | 1 + common/src/tfport.rs | 161 ++++++++++++++++++ illumos-utils/src/dladm.rs | 13 +- .../tasks/sync_switch_configuration.rs | 17 +- nexus/src/app/unnumbered.rs | 11 +- .../networking.rs | 32 +++- sled-agent/early-networking/src/lib.rs | 11 +- sled-agent/src/services.rs | 11 +- 8 files changed, 231 insertions(+), 26 deletions(-) create mode 100644 common/src/tfport.rs 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/src/app/background/tasks/sync_switch_configuration.rs b/nexus/src/app/background/tasks/sync_switch_configuration.rs index 5006efee458..adafbc1cca3 100644 --- a/nexus/src/app/background/tasks/sync_switch_configuration.rs +++ b/nexus/src/app/background/tasks/sync_switch_configuration.rs @@ -56,6 +56,7 @@ use nexus_types::identity::{Asset, Resource}; use nexus_types::internal_api::background::IncompleteBootstoreConfigReport; use nexus_types::internal_api::background::SwitchPortSettingsManagerStatus; use omicron_common::OMICRON_DPD_TAG; +use omicron_common::tfport::TfportInterfaceName; use omicron_common::{ address::{Ipv6Subnet, get_sled_address}, api::external::DataPageParams, @@ -821,9 +822,23 @@ impl BackgroundTask for SwitchPortSettingsManager { } // Unnumbered peer - identified by interface RouterPeerType::Unnumbered { router_lifetime } => { + let interface = match TfportInterfaceName::from_port_name( + port.port_name.as_str(), + ) { + Ok(interface) => interface, + Err(err) => { + error!( + log, + "invalid port name for tfport interface"; + "port_name" => %port.port_name, + "error" => %err, + ); + continue; + } + }; let peer_config = MgUnnumberedBgpPeerConfig { name: format!("unnumbered-{}", port.port_name), - interface: format!("tfport{}_0", port.port_name), + interface: interface.to_string(), router_lifetime: router_lifetime.as_u16(), hold_time: peer.hold_time.into(), idle_hold_time: peer.idle_hold_time.into(), diff --git a/nexus/src/app/unnumbered.rs b/nexus/src/app/unnumbered.rs index 8dc0f49fd6f..97680ef1189 100644 --- a/nexus/src/app/unnumbered.rs +++ b/nexus/src/app/unnumbered.rs @@ -7,13 +7,10 @@ use nexus_types::external_api::networking::{ SwitchUnnumberedInterface, SwitchUnnumberedManagerState, }; use omicron_common::api::external::Error; +use omicron_common::tfport::TfportInterfaceName; use sled_agent_types::early_networking::SwitchSlot; use strum::IntoEnumIterator; -fn maghemite_interface_name(interface_name: &str) -> String { - format!("tfport{interface_name}_0") -} - impl super::Nexus { pub async fn bgp_unnumbered_manager_status( &self, @@ -106,9 +103,11 @@ impl super::Nexus { )) })?; - let interface_name = maghemite_interface_name(&interface_name); + 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) + .get_bgp_unnumbered_interface_detail(interface_name.as_str()) .await .map_err(|e| { Error::internal_error(&format!( 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 index 3f9f190c0b4..3630efa7e13 100644 --- 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 @@ -15,18 +15,17 @@ use mg_admin_client::types::{ 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 { - if let Some(interface) = interface - .strip_prefix("tfport") - .and_then(|interface| interface.strip_suffix("_0")) - { - interface.to_string() - } else { - interface + match interface.parse::() { + Ok(interface_name) if interface_name.link_id() == 0 => { + interface_name.port_name().to_owned() + } + Ok(_) | Err(_) => interface, } } @@ -218,3 +217,22 @@ impl From for DiscoveredRouter { } } } + +#[cfg(test)] +mod tests { + use super::nexus_interface_name; + + #[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"); + } +} diff --git a/sled-agent/early-networking/src/lib.rs b/sled-agent/early-networking/src/lib.rs index 06b9d3cc5e4..70ed5013477 100644 --- a/sled-agent/early-networking/src/lib.rs +++ b/sled-agent/early-networking/src/lib.rs @@ -39,6 +39,7 @@ use omicron_common::address::{MGD_PORT, MGS_PORT}; use omicron_common::backoff::{ BackoffError, ExponentialBackoff, ExponentialBackoffBuilder, retry_notify, }; +use omicron_common::tfport::TfportInterfaceName; use omicron_ddm_admin_client::DdmError; use oxnet::IpNet; use sled_agent_types::early_networking::{ @@ -611,9 +612,17 @@ impl<'a> EarlyNetworkSetup<'a> { // Unnumbered peer - identified by interface RouterPeerType::Unnumbered { router_lifetime } => { + let interface = TfportInterfaceName::from_port_name( + &port.port, + ) + .map_err(|err| { + EarlyNetworkSetupError::BgpConfigurationError( + err.to_string(), + ) + })?; let bpc = MgUnnumberedBgpPeerConfig { name: format!("unnumbered-{}", port.port), - interface: format!("tfport{}_0", port.port), + interface: interface.to_string(), hold_time: peer .hold_time .unwrap_or(BgpPeerConfig::DEFAULT_HOLD_TIME), diff --git a/sled-agent/src/services.rs b/sled-agent/src/services.rs index f3d38455420..fd0916623ab 100644 --- a/sled-agent/src/services.rs +++ b/sled-agent/src/services.rs @@ -84,6 +84,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; @@ -3113,8 +3114,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() @@ -3344,7 +3347,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 }, From 65b61e16fe24fd2fc32864917f585c6af3776d7d Mon Sep 17 00:00:00 2001 From: Trey Aspelund Date: Wed, 29 Jul 2026 12:52:54 -0600 Subject: [PATCH 09/16] Report BGP unnumbered status for both switches Signed-off-by: Trey Aspelund --- nexus/external-api/src/lib.rs | 12 +- nexus/src/app/unnumbered.rs | 139 +++++++++------- nexus/src/external_api/http_entrypoints.rs | 9 +- .../tests/integration_tests/bgp_unnumbered.rs | 76 +++++++++ nexus/tests/integration_tests/mod.rs | 1 + .../networking.rs | 25 ++- nexus/types/versions/src/latest.rs | 5 +- ....json => nexus-2026072900.0.0-0ef03f.json} | 150 +++++++++++++++--- openapi/nexus/nexus-latest.json | 2 +- 9 files changed, 324 insertions(+), 95 deletions(-) create mode 100644 nexus/tests/integration_tests/bgp_unnumbered.rs rename openapi/nexus/{nexus-2026072900.0.0-b1c66a.json => nexus-2026072900.0.0-0ef03f.json} (99%) diff --git a/nexus/external-api/src/lib.rs b/nexus/external-api/src/lib.rs index 0db8634866a..b47cb99aee1 100644 --- a/nexus/external-api/src/lib.rs +++ b/nexus/external-api/src/lib.rs @@ -6125,7 +6125,11 @@ pub trait NexusExternalApi { async fn networking_bgp_unnumbered_manager_status( rqctx: RequestContext, ) -> Result< - HttpResponseOk>, + HttpResponseOk< + latest::networking::SwitchResults< + latest::networking::UnnumberedManagerState, + >, + >, HttpError, >; @@ -6139,7 +6143,11 @@ pub trait NexusExternalApi { async fn networking_bgp_unnumbered_interface_list( rqctx: RequestContext, ) -> Result< - HttpResponseOk>, + HttpResponseOk< + latest::networking::SwitchResults< + latest::networking::UnnumberedInterfaces, + >, + >, HttpError, >; diff --git a/nexus/src/app/unnumbered.rs b/nexus/src/app/unnumbered.rs index 97680ef1189..7be6c7154bc 100644 --- a/nexus/src/app/unnumbered.rs +++ b/nexus/src/app/unnumbered.rs @@ -4,88 +4,109 @@ use nexus_db_queries::context::OpContext; use nexus_types::external_api::networking::{ - SwitchUnnumberedInterface, SwitchUnnumberedManagerState, + SwitchResult, SwitchResults, SwitchUnavailableReason, + SwitchUnnumberedInterface, UnnumberedInterfaces, UnnumberedManagerState, }; use omicron_common::api::external::Error; use omicron_common::tfport::TfportInterfaceName; use sled_agent_types::early_networking::SwitchSlot; -use strum::IntoEnumIterator; impl super::Nexus { pub async fn bgp_unnumbered_manager_status( &self, _opctx: &OpContext, - ) -> Result, Error> { + ) -> 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 mut result = Vec::new(); - for switch_slot in SwitchSlot::iter() { - // 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, - ); - continue; - }; - let status = mg_client - .get_bgp_unnumbered_manager_state() - .await - .map_err(|e| { - Error::internal_error(&format!( - "maghemite get BGP unnumbered manager state: {e}" - )) - })? - .into_inner(); - - result.push(SwitchUnnumberedManagerState { - switch_slot, - state: status.into(), - }); - } - Ok(result) + 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::Unavailable { + reason: SwitchUnavailableReason::MgdUnresolved, + }; + }; + match mg_client.get_bgp_unnumbered_manager_state().await { + Ok(status) => SwitchResult::Available { + value: status.into_inner().into(), + }, + Err(err) => { + error!( + self.log, + "failed to get BGP unnumbered manager state"; + "switch-slot" => ?switch_slot, + "error" => %err, + ); + SwitchResult::Unavailable { + reason: SwitchUnavailableReason::QueryFailed, + } + } + } + } + }; + Ok(SwitchResults { + switch0: query(SwitchSlot::Switch0).await, + switch1: query(SwitchSlot::Switch1).await, + }) } pub async fn bgp_unnumbered_interfaces( &self, _opctx: &OpContext, - ) -> Result, Error> { + ) -> 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 mut result = Vec::new(); - for switch_slot in SwitchSlot::iter() { - // 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, - ); - continue; - }; - let interfaces = mg_client - .get_bgp_unnumbered_interfaces() - .await - .map_err(|e| { - Error::internal_error(&format!( - "maghemite get BGP unnumbered interfaces: {e}" - )) - })? - .into_inner(); - - for interface in interfaces { - result.push(SwitchUnnumberedInterface { - switch_slot, - interface: interface.into(), - }); + 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::Unavailable { + reason: SwitchUnavailableReason::MgdUnresolved, + }; + }; + match mg_client.get_bgp_unnumbered_interfaces().await { + Ok(interfaces) => SwitchResult::Available { + 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::Unavailable { + reason: SwitchUnavailableReason::QueryFailed, + } + } + } } - } - Ok(result) + }; + Ok(SwitchResults { + switch0: query(SwitchSlot::Switch0).await, + switch1: query(SwitchSlot::Switch1).await, + }) } pub async fn bgp_unnumbered_interface( diff --git a/nexus/src/external_api/http_entrypoints.rs b/nexus/src/external_api/http_entrypoints.rs index d95c458b9f7..5092580346f 100644 --- a/nexus/src/external_api/http_entrypoints.rs +++ b/nexus/src/external_api/http_entrypoints.rs @@ -59,8 +59,8 @@ 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::{ - SwitchUnnumberedInterface, SwitchUnnumberedManagerState, - UnnumberedInterfacePath, + SwitchResults, SwitchUnnumberedInterface, UnnumberedInterfacePath, + UnnumberedInterfaces, UnnumberedManagerState, }; use nexus_types::external_api::physical_disk::{ PhysicalDisk, PhysicalDiskAdoptionRequest, PhysicalDiskAdoptionRequestPath, @@ -4792,7 +4792,7 @@ impl NexusExternalApi for NexusExternalApiImpl { async fn networking_bgp_unnumbered_manager_status( rqctx: RequestContext, - ) -> Result>, HttpError> + ) -> Result>, HttpError> { let apictx = rqctx.context(); let handler = async { @@ -4812,7 +4812,8 @@ impl NexusExternalApi for NexusExternalApiImpl { async fn networking_bgp_unnumbered_interface_list( rqctx: RequestContext, - ) -> Result>, HttpError> { + ) -> Result>, HttpError> + { let apictx = rqctx.context(); let handler = async { let nexus = &apictx.context.nexus; diff --git a/nexus/tests/integration_tests/bgp_unnumbered.rs b/nexus/tests/integration_tests/bgp_unnumbered.rs new file mode 100644 index 00000000000..220fd33a062 --- /dev/null +++ b/nexus/tests/integration_tests/bgp_unnumbered.rs @@ -0,0 +1,76 @@ +// 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 unnumbered status APIs. + +use nexus_test_utils::http_testing::{AuthnMode, NexusRequest}; +use nexus_test_utils_macros::nexus_test; +use nexus_types::external_api::networking::{ + SwitchResult, SwitchResults, SwitchUnavailableReason, UnnumberedInterfaces, + UnnumberedManagerState, +}; + +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::Available { value } => { + assert!(value.interfaces.is_empty()); + } + SwitchResult::Unavailable { + reason: SwitchUnavailableReason::MgdUnresolved, + } => panic!("switch0 MGD was unexpectedly unresolved"), + SwitchResult::Unavailable { + reason: SwitchUnavailableReason::QueryFailed, + } => panic!("switch0 manager-state query unexpectedly failed"), + } + 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::Available { value: UnnumberedInterfaces(interfaces) } => { + assert!(interfaces.is_empty()); + } + SwitchResult::Unavailable { + reason: SwitchUnavailableReason::MgdUnresolved, + } => panic!("switch0 MGD was unexpectedly unresolved"), + SwitchResult::Unavailable { + reason: SwitchUnavailableReason::QueryFailed, + } => panic!("switch0 interface query unexpectedly failed"), + } + assert_mgd_unresolved(interfaces.switch1); +} + +fn assert_mgd_unresolved(result: SwitchResult) { + match result { + SwitchResult::Unavailable { + reason: SwitchUnavailableReason::MgdUnresolved, + } => {} + SwitchResult::Available { .. } => { + panic!("switch1 was unexpectedly available"); + } + SwitchResult::Unavailable { + reason: SwitchUnavailableReason::QueryFailed, + } => panic!("switch1 query unexpectedly failed"), + } +} diff --git a/nexus/tests/integration_tests/mod.rs b/nexus/tests/integration_tests/mod.rs index dc1fc84c2b4..12154547b5c 100644 --- a/nexus/tests/integration_tests/mod.rs +++ b/nexus/tests/integration_tests/mod.rs @@ -15,6 +15,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/networking.rs b/nexus/types/versions/src/bgp_unnumbered_status_2026_07_29/networking.rs index 3630efa7e13..2de463cf1f0 100644 --- 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 @@ -36,9 +36,25 @@ pub struct UnnumberedInterfacePath { } #[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)] -pub struct SwitchUnnumberedManagerState { - pub switch_slot: SwitchSlot, - pub state: UnnumberedManagerState, +#[schemars(rename = "{T}SwitchResults")] +pub struct SwitchResults { + pub switch0: SwitchResult, + pub switch1: SwitchResult, +} + +#[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)] +#[serde(tag = "status", rename_all = "snake_case")] +#[schemars(rename = "{T}SwitchResult")] +pub enum SwitchResult { + Available { value: T }, + Unavailable { reason: SwitchUnavailableReason }, +} + +#[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum SwitchUnavailableReason { + MgdUnresolved, + QueryFailed, } #[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)] @@ -149,6 +165,9 @@ pub struct UnnumberedInterface { 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, diff --git a/nexus/types/versions/src/latest.rs b/nexus/types/versions/src/latest.rs index 0ae74da6f3e..29774b17ba4 100644 --- a/nexus/types/versions/src/latest.rs +++ b/nexus/types/versions/src/latest.rs @@ -339,11 +339,14 @@ pub mod networking { 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::SwitchResult; + pub use crate::v2026_07_29_00::networking::SwitchResults; + pub use crate::v2026_07_29_00::networking::SwitchUnavailableReason; pub use crate::v2026_07_29_00::networking::SwitchUnnumberedInterface; - pub use crate::v2026_07_29_00::networking::SwitchUnnumberedManagerState; 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; } diff --git a/openapi/nexus/nexus-2026072900.0.0-b1c66a.json b/openapi/nexus/nexus-2026072900.0.0-0ef03f.json similarity index 99% rename from openapi/nexus/nexus-2026072900.0.0-b1c66a.json rename to openapi/nexus/nexus-2026072900.0.0-0ef03f.json index 58eb9954cd4..b9aa1316548 100644 --- a/openapi/nexus/nexus-2026072900.0.0-b1c66a.json +++ b/openapi/nexus/nexus-2026072900.0.0-0ef03f.json @@ -11053,11 +11053,7 @@ "content": { "application/json": { "schema": { - "title": "Array_of_SwitchUnnumberedInterface", - "type": "array", - "items": { - "$ref": "#/components/schemas/SwitchUnnumberedInterface" - } + "$ref": "#/components/schemas/UnnumberedInterfacesSwitchResults" } } } @@ -11129,11 +11125,7 @@ "content": { "application/json": { "schema": { - "title": "Array_of_SwitchUnnumberedManagerState", - "type": "array", - "items": { - "$ref": "#/components/schemas/SwitchUnnumberedManagerState" - } + "$ref": "#/components/schemas/UnnumberedManagerStateSwitchResults" } } } @@ -29939,6 +29931,13 @@ } ] }, + "SwitchUnavailableReason": { + "type": "string", + "enum": [ + "mgd_unresolved", + "query_failed" + ] + }, "SwitchUnnumberedInterface": { "type": "object", "properties": { @@ -29954,21 +29953,6 @@ "switch_slot" ] }, - "SwitchUnnumberedManagerState": { - "type": "object", - "properties": { - "state": { - "$ref": "#/components/schemas/UnnumberedManagerState" - }, - "switch_slot": { - "$ref": "#/components/schemas/SwitchSlot" - } - }, - "required": [ - "state", - "switch_slot" - ] - }, "SystemNetworkingSettings": { "description": "Fleet-wide networking settings. Only fleet viewers may view these settings. Only fleet admins can modify them.", "type": "object", @@ -30507,6 +30491,67 @@ } ] }, + "UnnumberedInterfaces": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UnnumberedInterface" + } + }, + "UnnumberedInterfacesSwitchResult": { + "oneOf": [ + { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "available" + ] + }, + "value": { + "$ref": "#/components/schemas/UnnumberedInterfaces" + } + }, + "required": [ + "status", + "value" + ] + }, + { + "type": "object", + "properties": { + "reason": { + "$ref": "#/components/schemas/SwitchUnavailableReason" + }, + "status": { + "type": "string", + "enum": [ + "unavailable" + ] + } + }, + "required": [ + "reason", + "status" + ] + } + ] + }, + "UnnumberedInterfacesSwitchResults": { + "type": "object", + "properties": { + "switch0": { + "$ref": "#/components/schemas/UnnumberedInterfacesSwitchResult" + }, + "switch1": { + "$ref": "#/components/schemas/UnnumberedInterfacesSwitchResult" + } + }, + "required": [ + "switch0", + "switch1" + ] + }, "UnnumberedManagerState": { "type": "object", "properties": { @@ -30525,6 +30570,61 @@ "monitor_running" ] }, + "UnnumberedManagerStateSwitchResult": { + "oneOf": [ + { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "available" + ] + }, + "value": { + "$ref": "#/components/schemas/UnnumberedManagerState" + } + }, + "required": [ + "status", + "value" + ] + }, + { + "type": "object", + "properties": { + "reason": { + "$ref": "#/components/schemas/SwitchUnavailableReason" + }, + "status": { + "type": "string", + "enum": [ + "unavailable" + ] + } + }, + "required": [ + "reason", + "status" + ] + } + ] + }, + "UnnumberedManagerStateSwitchResults": { + "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 8bbe8e9fd5b..ddb69c0820a 120000 --- a/openapi/nexus/nexus-latest.json +++ b/openapi/nexus/nexus-latest.json @@ -1 +1 @@ -nexus-2026072900.0.0-b1c66a.json \ No newline at end of file +nexus-2026072900.0.0-0ef03f.json \ No newline at end of file From 493883868856ce3a4e4b608e8c5530da4a8f5248 Mon Sep 17 00:00:00 2001 From: Trey Aspelund Date: Wed, 29 Jul 2026 13:00:03 -0600 Subject: [PATCH 10/16] Return per-switch results for BFD status Signed-off-by: Trey Aspelund --- nexus/external-api/src/lib.rs | 42 ++++- nexus/src/app/bfd.rs | 78 ++++++---- nexus/src/external_api/http_entrypoints.rs | 4 +- nexus/tests/integration_tests/bfd.rs | 28 +++- .../bgp_unnumbered_status_2026_07_29/bfd.rs | 117 ++++++++++++++ .../bgp_unnumbered_status_2026_07_29/mod.rs | 3 +- nexus/types/versions/src/impls/networking.rs | 85 ++++++++++ nexus/types/versions/src/latest.rs | 4 +- ....json => nexus-2026072900.0.0-5cce92.json} | 145 ++++++++++++------ openapi/nexus/nexus-latest.json | 2 +- 10 files changed, 418 insertions(+), 90 deletions(-) create mode 100644 nexus/types/versions/src/bgp_unnumbered_status_2026_07_29/bfd.rs rename openapi/nexus/{nexus-2026072900.0.0-0ef03f.json => nexus-2026072900.0.0-5cce92.json} (99%) diff --git a/nexus/external-api/src/lib.rs b/nexus/external-api/src/lib.rs index b47cb99aee1..b40de81be9f 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; @@ -6091,11 +6092,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 { @@ -6109,10 +6136,13 @@ 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 diff --git a/nexus/src/app/bfd.rs b/nexus/src/app/bfd.rs index 9a5d1a45899..44127d5094e 100644 --- a/nexus/src/app/bfd.rs +++ b/nexus/src/app/bfd.rs @@ -44,37 +44,37 @@ 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::Unavailable { + reason: + networking::SwitchUnavailableReason::MgdUnresolved, + }; + }; + match mg_client.get_bfd_peers().await { + Ok(status) => networking::SwitchResult::Available { + value: bfd::BfdPeerStatuses( + status + .into_inner() + .iter() + .map(|info| { + bfd::BfdPeerStatus { peer: info.config.peer, state: match info.state { BfdPeerState::Up => bfd::BfdState::Up, @@ -82,7 +82,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, @@ -94,9 +93,28 @@ impl super::Nexus { BfdMode::MultiHop } }, - }) + } + }) + .collect(), + ), + }, + Err(err) => { + error!( + self.log, "failed to get BFD peers"; + "switch-slot" => ?switch_slot, + "error" => %err, + ); + networking::SwitchResult::Unavailable { + reason: + networking::SwitchUnavailableReason::QueryFailed, + } + } + } } - } - Ok(result) + }; + Ok(networking::SwitchResults { + switch0: query(SwitchSlot::Switch0).await, + switch1: query(SwitchSlot::Switch1).await, + }) } } diff --git a/nexus/src/external_api/http_entrypoints.rs b/nexus/src/external_api/http_entrypoints.rs index 5092580346f..c53ac0edc86 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; @@ -4773,7 +4773,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; diff --git a/nexus/tests/integration_tests/bfd.rs b/nexus/tests/integration_tests/bfd.rs index c66d634ed69..d3b94a9371c 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::{ + SwitchResult, SwitchResults, SwitchUnavailableReason, +}; type ControlPlaneTestContext = nexus_test_utils::ControlPlaneTestContext; @@ -20,11 +23,30 @@ 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::Available { value: BfdPeerStatuses(statuses) } => { + assert!(statuses.is_empty()); + available += 1; + } + SwitchResult::Unavailable { + reason: SwitchUnavailableReason::MgdUnresolved, + } => unavailable += 1, + SwitchResult::Unavailable { + reason: SwitchUnavailableReason::QueryFailed, + } => { + panic!("BFD query unexpectedly failed"); + } + } + } + assert_eq!(available, 1); + assert_eq!(unavailable, 1); } 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..c4f9740174b --- /dev/null +++ b/nexus/types/versions/src/bgp_unnumbered_status_2026_07_29/bfd.rs @@ -0,0 +1,117 @@ +// 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::{SwitchResults, SwitchUnavailableReason}; + +#[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 = SwitchUnavailableReason; + + 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(SwitchUnavailableReason::MgdUnresolved) => continue, + Err(reason) => return Err(reason), + }; + 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::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::Available { + value: BfdPeerStatuses(vec![BfdPeerStatus { + peer, + state: BfdState::Up, + local: None, + detection_threshold: 3, + required_rx: 1000, + mode: BfdMode::SingleHop, + }]), + }, + switch1: SwitchResult::Unavailable { + reason: SwitchUnavailableReason::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::Unavailable { + reason: SwitchUnavailableReason::QueryFailed, + }, + switch1: SwitchResult::Available { + value: BfdPeerStatuses(Vec::new()), + }, + }; + + assert!( + Vec::::try_from(results).is_err() + ); + } +} 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 index 4e6a63b1927..38ebeb1b1fb 100644 --- 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 @@ -2,6 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. -//! BGP unnumbered status types. +//! BGP unnumbered and BFD status types. +pub mod bfd; pub mod networking; diff --git a/nexus/types/versions/src/impls/networking.rs b/nexus/types/versions/src/impls/networking.rs index 7c1d03d6933..6b91d68edd9 100644 --- a/nexus/types/versions/src/impls/networking.rs +++ b/nexus/types/versions/src/impls/networking.rs @@ -6,8 +6,57 @@ 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::Available { value } => Ok(value), + Self::Unavailable { reason } => Err(reason), + } + } +} + +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() + } +} + +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 { @@ -77,3 +126,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::Available { value: 0 }, + switch1: SwitchResult::Available { value: 1 }, + }; + + let borrowed: Vec<_> = results + .iter() + .map(|(slot, result)| { + let SwitchResult::Available { 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 29774b17ba4..ae7532e8acc 100644 --- a/nexus/types/versions/src/latest.rs +++ b/nexus/types/versions/src/latest.rs @@ -70,7 +70,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 { diff --git a/openapi/nexus/nexus-2026072900.0.0-0ef03f.json b/openapi/nexus/nexus-2026072900.0.0-5cce92.json similarity index 99% rename from openapi/nexus/nexus-2026072900.0.0-0ef03f.json rename to openapi/nexus/nexus-2026072900.0.0-5cce92.json index b9aa1316548..be715bab362 100644 --- a/openapi/nexus/nexus-2026072900.0.0-0ef03f.json +++ b/openapi/nexus/nexus-2026072900.0.0-5cce92.json @@ -10534,11 +10534,7 @@ "content": { "application/json": { "schema": { - "title": "Array_of_BfdStatus", - "type": "array", - "items": { - "$ref": "#/components/schemas/BfdStatus" - } + "$ref": "#/components/schemas/BfdPeerStatusesSwitchResults" } } } @@ -17220,6 +17216,104 @@ "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": { + "oneOf": [ + { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "available" + ] + }, + "value": { + "$ref": "#/components/schemas/BfdPeerStatuses" + } + }, + "required": [ + "status", + "value" + ] + }, + { + "type": "object", + "properties": { + "reason": { + "$ref": "#/components/schemas/SwitchUnavailableReason" + }, + "status": { + "type": "string", + "enum": [ + "unavailable" + ] + } + }, + "required": [ + "reason", + "status" + ] + } + ] + }, + "BfdPeerStatusesSwitchResults": { + "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", @@ -17327,47 +17421,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", diff --git a/openapi/nexus/nexus-latest.json b/openapi/nexus/nexus-latest.json index ddb69c0820a..bb6613001aa 120000 --- a/openapi/nexus/nexus-latest.json +++ b/openapi/nexus/nexus-latest.json @@ -1 +1 @@ -nexus-2026072900.0.0-0ef03f.json \ No newline at end of file +nexus-2026072900.0.0-5cce92.json \ No newline at end of file From 1f34fe89b62b45740cfebdc0a1b65dc4e1130a13 Mon Sep 17 00:00:00 2001 From: Trey Aspelund Date: Wed, 29 Jul 2026 14:36:14 -0600 Subject: [PATCH 11/16] Return per-switch results for BGP status Signed-off-by: Trey Aspelund --- nexus/external-api/src/lib.rs | 124 +++++- nexus/src/app/bgp.rs | 381 +++++++++++------- nexus/src/external_api/http_entrypoints.rs | 34 +- .../tests/integration_tests/bgp_unnumbered.rs | 104 ++++- .../networking.rs | 325 ++++++++++++++- nexus/types/versions/src/impls/networking.rs | 8 - nexus/types/versions/src/latest.rs | 13 +- ....json => nexus-2026072900.0.0-f0d494.json} | 337 ++++++++++++---- openapi/nexus/nexus-latest.json | 2 +- 9 files changed, 1062 insertions(+), 266 deletions(-) rename openapi/nexus/{nexus-2026072900.0.0-5cce92.json => nexus-2026072900.0.0-f0d494.json} (99%) diff --git a/nexus/external-api/src/lib.rs b/nexus/external-api/src/lib.rs index b40de81be9f..d9dc482337b 100644 --- a/nexus/external-api/src/lib.rs +++ b/nexus/external-api/src/lib.rs @@ -5823,11 +5823,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 @@ -5845,11 +5869,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(), )) } @@ -5882,11 +5906,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 @@ -5903,7 +5951,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())) } @@ -5912,15 +5961,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 { @@ -5942,12 +6019,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 { diff --git a/nexus/src/app/bgp.rs b/nexus/src/app/bgp.rs index b0034cbc5ab..cda086dbd93 100644 --- a/nexus/src/app/bgp.rs +++ b/nexus/src/app/bgp.rs @@ -17,6 +17,7 @@ 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; impl super::Nexus { @@ -196,215 +197,311 @@ impl super::Nexus { pub async fn bgp_peer_status( &self, opctx: &OpContext, - ) -> 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 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::Unavailable { + reason: + networking::SwitchUnavailableReason::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::Unavailable { + reason: + networking::SwitchUnavailableReason::QueryFailed, + }; } }; - 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), + ); + 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::Available { + 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, + > { 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::Unavailable { + reason: + networking::SwitchUnavailableReason::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::Unavailable { + reason: + networking::SwitchUnavailableReason::QueryFailed, + }; } }; - 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), + ); + 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::Available { + 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::Unavailable { + reason: + networking::SwitchUnavailableReason::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::Unavailable { + reason: + networking::SwitchUnavailableReason::QueryFailed, + }; + } + }; - Ok(result) + networking::SwitchResult::Available { + 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::Unavailable { + reason: + networking::SwitchUnavailableReason::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::Unavailable { + reason: + networking::SwitchUnavailableReason::QueryFailed, + }; + } + }; + networking::SwitchResult::Available { + 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/external_api/http_entrypoints.rs b/nexus/src/external_api/http_entrypoints.rs index c53ac0edc86..85abc736dd7 100644 --- a/nexus/src/external_api/http_entrypoints.rs +++ b/nexus/src/external_api/http_entrypoints.rs @@ -79,6 +79,7 @@ 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 omicron_common::address::IpRange; use omicron_common::api::external::DataPageParams; use omicron_common::api::external::Disk; @@ -4540,7 +4541,10 @@ 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 { @@ -4557,7 +4561,12 @@ 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 { @@ -4575,17 +4584,19 @@ 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, - ))) + Ok(HttpResponseOk(result)) }; apictx .context @@ -4608,6 +4619,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)) @@ -4622,7 +4635,12 @@ impl NexusExternalApi for NexusExternalApiImpl { 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 { diff --git a/nexus/tests/integration_tests/bgp_unnumbered.rs b/nexus/tests/integration_tests/bgp_unnumbered.rs index 220fd33a062..58281319d37 100644 --- a/nexus/tests/integration_tests/bgp_unnumbered.rs +++ b/nexus/tests/integration_tests/bgp_unnumbered.rs @@ -2,14 +2,18 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. -//! Tests BGP unnumbered status APIs. +//! Tests BGP status APIs. -use nexus_test_utils::http_testing::{AuthnMode, NexusRequest}; +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::{ SwitchResult, SwitchResults, SwitchUnavailableReason, UnnumberedInterfaces, UnnumberedManagerState, }; +use serde_json::{Value, json}; type ControlPlaneTestContext = nexus_test_utils::ControlPlaneTestContext; @@ -61,6 +65,102 @@ async fn test_bgp_unnumbered_status_by_switch( 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": "available", "value": [] }, + "switch1": { + "status": "unavailable", + "reason": "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: Value = NexusRequest::object_get( + client, + "/v1/system/networking/bgp-message-history?asn=64512", + ) + .authn_as(AuthnMode::PrivilegedUser) + .execute_and_parse_unwrap() + .await; + assert_eq!( + response, + json!({ + "switch0": { + "status": "unavailable", + "reason": "query_failed", + }, + "switch1": { + "status": "unavailable", + "reason": "mgd_unresolved", + }, + }) + ); + + 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::Unavailable { 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 index 2de463cf1f0..f9e7afb38cd 100644 --- 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 @@ -4,8 +4,8 @@ //! BGP unnumbered status networking types. -use std::collections::BTreeMap; -use std::net::Ipv6Addr; +use std::collections::{BTreeMap, HashMap}; +use std::net::{IpAddr, Ipv6Addr}; use std::time::Duration; use mg_admin_client::types::{ @@ -57,6 +57,154 @@ pub enum SwitchUnavailableReason { QueryFailed, } +/// 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::Available { 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::Available { 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::Available { 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::Available { 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, @@ -239,7 +387,11 @@ impl From for DiscoveredRouter { #[cfg(test)] mod tests { - use super::nexus_interface_name; + use std::net::{IpAddr, Ipv4Addr}; + + use sled_agent_types_versions::v1::early_networking::SwitchSlot; + + use super::*; #[test] fn converts_link_zero_tfport_name() { @@ -254,4 +406,171 @@ mod tests { ); 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::Available { + 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::Unavailable { + reason: SwitchUnavailableReason::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::Unavailable { + reason: SwitchUnavailableReason::MgdUnresolved, + }, + switch1: SwitchResult::Available { + 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::Available { + value: BgpImportedRoutes(vec![route(0)]), + }, + switch1: SwitchResult::Available { + 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::Available { + value: BgpMessageHistories(HashMap::new()), + }, + switch1: SwitchResult::Unavailable { + reason: SwitchUnavailableReason::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 unavailable_reasons_have_distinct_wire_representations() { + let unresolved = SwitchResult::::Unavailable { + reason: SwitchUnavailableReason::MgdUnresolved, + }; + let failed = SwitchResult::::Unavailable { + reason: SwitchUnavailableReason::QueryFailed, + }; + + assert_eq!( + serde_json::to_value(unresolved).unwrap(), + serde_json::json!({ + "status": "unavailable", + "reason": "mgd_unresolved", + }) + ); + assert_eq!( + serde_json::to_value(failed).unwrap(), + serde_json::json!({ + "status": "unavailable", + "reason": "query_failed", + }) + ); + } } diff --git a/nexus/types/versions/src/impls/networking.rs b/nexus/types/versions/src/impls/networking.rs index 6b91d68edd9..2df67a5b78a 100644 --- a/nexus/types/versions/src/impls/networking.rs +++ b/nexus/types/versions/src/impls/networking.rs @@ -96,14 +96,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 { diff --git a/nexus/types/versions/src/latest.rs b/nexus/types/versions/src/latest.rs index ae7532e8acc..a6d1c7cdc5b 100644 --- a/nexus/types/versions/src/latest.rs +++ b/nexus/types/versions/src/latest.rs @@ -272,7 +272,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; @@ -289,7 +288,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; @@ -315,10 +313,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 +332,13 @@ pub mod networking { 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; diff --git a/openapi/nexus/nexus-2026072900.0.0-5cce92.json b/openapi/nexus/nexus-2026072900.0.0-f0d494.json similarity index 99% rename from openapi/nexus/nexus-2026072900.0.0-5cce92.json rename to openapi/nexus/nexus-2026072900.0.0-f0d494.json index be715bab362..f39d7aec1b7 100644 --- a/openapi/nexus/nexus-2026072900.0.0-5cce92.json +++ b/openapi/nexus/nexus-2026072900.0.0-f0d494.json @@ -10903,11 +10903,7 @@ "content": { "application/json": { "schema": { - "title": "Array_of_BgpExported", - "type": "array", - "items": { - "$ref": "#/components/schemas/BgpExported" - } + "$ref": "#/components/schemas/BgpExportedRoutesSwitchResults" } } } @@ -10947,11 +10943,7 @@ "content": { "application/json": { "schema": { - "title": "Array_of_BgpImported", - "type": "array", - "items": { - "$ref": "#/components/schemas/BgpImported" - } + "$ref": "#/components/schemas/BgpImportedRoutesSwitchResults" } } } @@ -10991,7 +10983,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AggregateBgpMessageHistory" + "$ref": "#/components/schemas/BgpMessageHistoriesSwitchResults" } } } @@ -11018,11 +11010,7 @@ "content": { "application/json": { "schema": { - "title": "Array_of_BgpPeerStatus", - "type": "array", - "items": { - "$ref": "#/components/schemas/BgpPeerStatus" - } + "$ref": "#/components/schemas/BgpPeerStatusesSwitchResults" } } } @@ -16191,22 +16179,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" - ] - }, "AlertClass": { "description": "An alert class.", "type": "object", @@ -17704,7 +17676,7 @@ } }, "BgpExported": { - "description": "Route exported to a peer.", + "description": "A route exported to a BGP peer.", "type": "object", "properties": { "peer_id": { @@ -17718,20 +17690,72 @@ "$ref": "#/components/schemas/IpNet" } ] + } + }, + "required": [ + "peer_id", + "prefix" + ] + }, + "BgpExportedRoutes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BgpExported" + } + }, + "BgpExportedRoutesSwitchResult": { + "oneOf": [ + { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "available" + ] + }, + "value": { + "$ref": "#/components/schemas/BgpExportedRoutes" + } + }, + "required": [ + "status", + "value" + ] }, - "switch": { - "description": "Switch the route is exported from.", - "allOf": [ - { - "$ref": "#/components/schemas/SwitchSlot" + { + "type": "object", + "properties": { + "reason": { + "$ref": "#/components/schemas/SwitchUnavailableReason" + }, + "status": { + "type": "string", + "enum": [ + "unavailable" + ] } + }, + "required": [ + "reason", + "status" ] } + ] + }, + "BgpExportedRoutesSwitchResults": { + "type": "object", + "properties": { + "switch0": { + "$ref": "#/components/schemas/BgpExportedRoutesSwitchResult" + }, + "switch1": { + "$ref": "#/components/schemas/BgpExportedRoutesSwitchResult" + } }, "required": [ - "peer_id", - "prefix", - "switch" + "switch0", + "switch1" ] }, "BgpImported": { @@ -17756,21 +17780,135 @@ "$ref": "#/components/schemas/IpNet" } ] + } + }, + "required": [ + "id", + "nexthop", + "prefix" + ] + }, + "BgpImportedRoutes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BgpImported" + } + }, + "BgpImportedRoutesSwitchResult": { + "oneOf": [ + { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "available" + ] + }, + "value": { + "$ref": "#/components/schemas/BgpImportedRoutes" + } + }, + "required": [ + "status", + "value" + ] }, - "switch": { - "description": "Switch the route is imported into.", - "allOf": [ - { - "$ref": "#/components/schemas/SwitchSlot" + { + "type": "object", + "properties": { + "reason": { + "$ref": "#/components/schemas/SwitchUnavailableReason" + }, + "status": { + "type": "string", + "enum": [ + "unavailable" + ] } + }, + "required": [ + "reason", + "status" ] } + ] + }, + "BgpImportedRoutesSwitchResults": { + "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": { + "oneOf": [ + { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "available" + ] + }, + "value": { + "$ref": "#/components/schemas/BgpMessageHistories" + } + }, + "required": [ + "status", + "value" + ] + }, + { + "type": "object", + "properties": { + "reason": { + "$ref": "#/components/schemas/SwitchUnavailableReason" + }, + "status": { + "type": "string", + "enum": [ + "unavailable" + ] + } + }, + "required": [ + "reason", + "status" + ] + } + ] + }, + "BgpMessageHistoriesSwitchResults": { + "type": "object", + "properties": { + "switch0": { + "$ref": "#/components/schemas/BgpMessageHistoriesSwitchResult" + }, + "switch1": { + "$ref": "#/components/schemas/BgpMessageHistoriesSwitchResult" + } + }, + "required": [ + "switch0", + "switch1" ] }, "BgpMessageHistory": {}, @@ -18008,7 +18146,7 @@ "minimum": 0 }, "peer_id": { - "description": "Interface name", + "description": "Interface name.", "type": "string" }, "remote_asn": { @@ -18030,14 +18168,6 @@ "type": "integer", "format": "uint64", "minimum": 0 - }, - "switch": { - "description": "Switch with the peer session.", - "allOf": [ - { - "$ref": "#/components/schemas/SwitchSlot" - } - ] } }, "required": [ @@ -18046,8 +18176,68 @@ "peer_id", "remote_asn", "state", - "state_duration_millis", - "switch" + "state_duration_millis" + ] + }, + "BgpPeerStatuses": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BgpPeerStatus" + } + }, + "BgpPeerStatusesSwitchResult": { + "oneOf": [ + { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "available" + ] + }, + "value": { + "$ref": "#/components/schemas/BgpPeerStatuses" + } + }, + "required": [ + "status", + "value" + ] + }, + { + "type": "object", + "properties": { + "reason": { + "$ref": "#/components/schemas/SwitchUnavailableReason" + }, + "status": { + "type": "string", + "enum": [ + "unavailable" + ] + } + }, + "required": [ + "reason", + "status" + ] + } + ] + }, + "BgpPeerStatusesSwitchResults": { + "type": "object", + "properties": { + "switch0": { + "$ref": "#/components/schemas/BgpPeerStatusesSwitchResult" + }, + "switch1": { + "$ref": "#/components/schemas/BgpPeerStatusesSwitchResult" + } + }, + "required": [ + "switch0", + "switch1" ] }, "BinRangedouble": { @@ -29211,31 +29401,6 @@ "time_modified" ] }, - "SwitchBgpHistory": { - "description": "BGP message history for a particular switch.", - "type": "object", - "properties": { - "history": { - "description": "Message history indexed by peer address.", - "type": "object", - "additionalProperties": { - "$ref": "#/components/schemas/BgpMessageHistory" - } - }, - "switch": { - "description": "Switch this message history is associated with.", - "allOf": [ - { - "$ref": "#/components/schemas/SwitchSlot" - } - ] - } - }, - "required": [ - "history", - "switch" - ] - }, "SwitchInterfaceConfig": { "description": "A switch port interface configuration for a port settings object.", "type": "object", diff --git a/openapi/nexus/nexus-latest.json b/openapi/nexus/nexus-latest.json index bb6613001aa..33e614c4aa8 120000 --- a/openapi/nexus/nexus-latest.json +++ b/openapi/nexus/nexus-latest.json @@ -1 +1 @@ -nexus-2026072900.0.0-5cce92.json \ No newline at end of file +nexus-2026072900.0.0-f0d494.json \ No newline at end of file From 227570667f9dfa1446dcd86a26af5699018e6a38 Mon Sep 17 00:00:00 2001 From: Trey Aspelund Date: Wed, 29 Jul 2026 16:07:50 -0600 Subject: [PATCH 12/16] Propagate per-switch query errors Signed-off-by: Trey Aspelund --- nexus/src/app/bfd.rs | 12 +- nexus/src/app/bgp.rs | 135 +++++++++--- nexus/src/app/unnumbered.rs | 28 +-- nexus/src/external_api/http_entrypoints.rs | 208 +++++++++++++++++- nexus/tests/integration_tests/bfd.rs | 16 +- .../tests/integration_tests/bgp_unnumbered.rs | 82 +++---- .../bgp_unnumbered_status_2026_07_29/bfd.rs | 31 ++- .../networking.rs | 205 ++++++++++++++--- nexus/types/versions/src/impls/networking.rs | 30 ++- nexus/types/versions/src/latest.rs | 2 +- ....json => nexus-2026072900.0.0-fb28c9.json} | 180 +++++++++++---- openapi/nexus/nexus-latest.json | 2 +- 12 files changed, 719 insertions(+), 212 deletions(-) rename openapi/nexus/{nexus-2026072900.0.0-f0d494.json => nexus-2026072900.0.0-fb28c9.json} (99%) diff --git a/nexus/src/app/bfd.rs b/nexus/src/app/bfd.rs index 44127d5094e..4127ae0a08f 100644 --- a/nexus/src/app/bfd.rs +++ b/nexus/src/app/bfd.rs @@ -62,13 +62,12 @@ impl super::Nexus { self.log, "no mgd client found for switch slot"; "switch-slot" => ?switch_slot, ); - return networking::SwitchResult::Unavailable { - reason: - networking::SwitchUnavailableReason::MgdUnresolved, + return networking::SwitchResult::Err { + error: networking::SwitchError::MgdUnresolved, }; }; match mg_client.get_bfd_peers().await { - Ok(status) => networking::SwitchResult::Available { + Ok(status) => networking::SwitchResult::Ok { value: bfd::BfdPeerStatuses( status .into_inner() @@ -104,10 +103,7 @@ impl super::Nexus { "switch-slot" => ?switch_slot, "error" => %err, ); - networking::SwitchResult::Unavailable { - reason: - networking::SwitchUnavailableReason::QueryFailed, - } + networking::SwitchResult::Err { error: err.into() } } } } diff --git a/nexus/src/app/bgp.rs b/nexus/src/app/bgp.rs index cda086dbd93..7574af7291b 100644 --- a/nexus/src/app/bgp.rs +++ b/nexus/src/app/bgp.rs @@ -20,6 +20,26 @@ 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, @@ -200,6 +220,29 @@ impl super::Nexus { ) -> 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 mg_clients = self.mg_clients().await.map_err(|e| { @@ -215,9 +258,8 @@ impl super::Nexus { self.log, "no mgd client found for switch slot"; "switch_slot" => ?switch_slot, ); - return networking::SwitchResult::Unavailable { - reason: - networking::SwitchUnavailableReason::MgdUnresolved, + return networking::SwitchResult::Err { + error: networking::SwitchError::MgdUnresolved, }; }; let router_info = match client.read_routers().await { @@ -228,9 +270,8 @@ impl super::Nexus { "switch_slot" => ?switch_slot, InlineErrorChain::new(&e), ); - return networking::SwitchResult::Unavailable { - reason: - networking::SwitchUnavailableReason::QueryFailed, + return networking::SwitchResult::Err { + error: e.into(), }; } }; @@ -247,7 +288,16 @@ impl super::Nexus { "switch_slot" => ?switch_slot, InlineErrorChain::new(&e), ); - continue; + match error_policy { + ConstituentErrorPolicy::FailSlot => { + return networking::SwitchResult::Err { + error: e.into(), + }; + } + ConstituentErrorPolicy::RetainPartial => { + continue; + } + } } }; for (peer_id, info) in peers { @@ -264,7 +314,7 @@ impl super::Nexus { }); } } - networking::SwitchResult::Available { + networking::SwitchResult::Ok { value: networking::BgpPeerStatuses(statuses), } } @@ -281,6 +331,28 @@ impl super::Nexus { ) -> 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 mg_clients = self.mg_clients().await.map_err(|e| { @@ -296,9 +368,8 @@ impl super::Nexus { self.log, "no mgd client found for switch slot"; "switch_slot" => ?switch_slot, ); - return networking::SwitchResult::Unavailable { - reason: - networking::SwitchUnavailableReason::MgdUnresolved, + return networking::SwitchResult::Err { + error: networking::SwitchError::MgdUnresolved, }; }; let router_info = match client.read_routers().await { @@ -309,9 +380,8 @@ impl super::Nexus { "switch_slot" => ?switch_slot, InlineErrorChain::new(&e), ); - return networking::SwitchResult::Unavailable { - reason: - networking::SwitchUnavailableReason::QueryFailed, + return networking::SwitchResult::Err { + error: e.into(), }; } }; @@ -335,7 +405,16 @@ impl super::Nexus { "switch_slot" => ?switch_slot, InlineErrorChain::new(&e), ); - continue; + match error_policy { + ConstituentErrorPolicy::FailSlot => { + return networking::SwitchResult::Err { + error: e.into(), + }; + } + ConstituentErrorPolicy::RetainPartial => { + continue; + } + } } }; @@ -349,7 +428,7 @@ impl super::Nexus { } } } - networking::SwitchResult::Available { + networking::SwitchResult::Ok { value: networking::BgpExportedRoutes(routes), } } @@ -382,9 +461,8 @@ impl super::Nexus { self.log, "no mgd client found for switch slot"; "switch_slot" => ?switch_slot, ); - return networking::SwitchResult::Unavailable { - reason: - networking::SwitchUnavailableReason::MgdUnresolved, + return networking::SwitchResult::Err { + error: networking::SwitchError::MgdUnresolved, }; }; let history = match client @@ -402,14 +480,13 @@ impl super::Nexus { "switch_slot" => ?switch_slot, InlineErrorChain::new(&e), ); - return networking::SwitchResult::Unavailable { - reason: - networking::SwitchUnavailableReason::QueryFailed, + return networking::SwitchResult::Err { + error: e.into(), }; } }; - networking::SwitchResult::Available { + networking::SwitchResult::Ok { value: networking::BgpMessageHistories( history .into_iter() @@ -449,9 +526,8 @@ impl super::Nexus { self.log, "no mgd client found for switch slot"; "switch_slot" => ?switch_slot, ); - return networking::SwitchResult::Unavailable { - reason: - networking::SwitchUnavailableReason::MgdUnresolved, + return networking::SwitchResult::Err { + error: networking::SwitchError::MgdUnresolved, }; }; let mut imported = Vec::new(); @@ -488,13 +564,12 @@ impl super::Nexus { "switch_slot" => ?switch_slot, InlineErrorChain::new(&e), ); - return networking::SwitchResult::Unavailable { - reason: - networking::SwitchUnavailableReason::QueryFailed, + return networking::SwitchResult::Err { + error: e.into(), }; } }; - networking::SwitchResult::Available { + networking::SwitchResult::Ok { value: networking::BgpImportedRoutes(imported), } } diff --git a/nexus/src/app/unnumbered.rs b/nexus/src/app/unnumbered.rs index 7be6c7154bc..af5f23e5c68 100644 --- a/nexus/src/app/unnumbered.rs +++ b/nexus/src/app/unnumbered.rs @@ -4,8 +4,8 @@ use nexus_db_queries::context::OpContext; use nexus_types::external_api::networking::{ - SwitchResult, SwitchResults, SwitchUnavailableReason, - SwitchUnnumberedInterface, UnnumberedInterfaces, UnnumberedManagerState, + SwitchError, SwitchResult, SwitchResults, SwitchUnnumberedInterface, + UnnumberedInterfaces, UnnumberedManagerState, }; use omicron_common::api::external::Error; use omicron_common::tfport::TfportInterfaceName; @@ -30,14 +30,14 @@ impl super::Nexus { self.log, "no mgd client found for switch slot"; "switch-slot" => ?switch_slot, ); - return SwitchResult::Unavailable { - reason: SwitchUnavailableReason::MgdUnresolved, + return SwitchResult::Err { + error: SwitchError::MgdUnresolved, }; }; match mg_client.get_bgp_unnumbered_manager_state().await { - Ok(status) => SwitchResult::Available { - value: status.into_inner().into(), - }, + Ok(status) => { + SwitchResult::Ok { value: status.into_inner().into() } + } Err(err) => { error!( self.log, @@ -45,9 +45,7 @@ impl super::Nexus { "switch-slot" => ?switch_slot, "error" => %err, ); - SwitchResult::Unavailable { - reason: SwitchUnavailableReason::QueryFailed, - } + SwitchResult::Err { error: err.into() } } } } @@ -76,12 +74,12 @@ impl super::Nexus { self.log, "no mgd client found for switch slot"; "switch-slot" => ?switch_slot, ); - return SwitchResult::Unavailable { - reason: SwitchUnavailableReason::MgdUnresolved, + return SwitchResult::Err { + error: SwitchError::MgdUnresolved, }; }; match mg_client.get_bgp_unnumbered_interfaces().await { - Ok(interfaces) => SwitchResult::Available { + Ok(interfaces) => SwitchResult::Ok { value: UnnumberedInterfaces( interfaces .into_inner() @@ -96,9 +94,7 @@ impl super::Nexus { "switch-slot" => ?switch_slot, "error" => %err, ); - SwitchResult::Unavailable { - reason: SwitchUnavailableReason::QueryFailed, - } + SwitchResult::Err { error: err.into() } } } } diff --git a/nexus/src/external_api/http_entrypoints.rs b/nexus/src/external_api/http_entrypoints.rs index 85abc736dd7..aa4ec9cbc88 100644 --- a/nexus/src/external_api/http_entrypoints.rs +++ b/nexus/src/external_api/http_entrypoints.rs @@ -80,6 +80,7 @@ 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; @@ -119,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; @@ -131,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; @@ -4550,7 +4567,30 @@ impl NexusExternalApi for NexusExternalApiImpl { 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 @@ -4572,7 +4612,30 @@ impl NexusExternalApi for NexusExternalApiImpl { 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 @@ -4596,7 +4659,29 @@ impl NexusExternalApi for NexusExternalApiImpl { let nexus = &apictx.context.nexus; let sel = query_params.into_inner(); let result = nexus.bgp_message_history(&opctx, &sel).await?; - Ok(HttpResponseOk(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 @@ -4632,6 +4717,28 @@ 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, @@ -4647,7 +4754,7 @@ impl NexusExternalApi for NexusExternalApiImpl { 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 @@ -4799,7 +4906,30 @@ 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?; - Ok(HttpResponseOk(status)) + 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 @@ -4819,7 +4949,7 @@ impl NexusExternalApi for NexusExternalApiImpl { 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?; - Ok(HttpResponseOk(status)) + switch_results_response(status) }; apictx .context @@ -4839,7 +4969,7 @@ impl NexusExternalApi for NexusExternalApiImpl { 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?; - Ok(HttpResponseOk(status)) + switch_results_response(status) }; apictx .context @@ -9310,3 +9440,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 d3b94a9371c..6e7d7e601df 100644 --- a/nexus/tests/integration_tests/bfd.rs +++ b/nexus/tests/integration_tests/bfd.rs @@ -9,7 +9,7 @@ use nexus_test_utils::http_testing::NexusRequest; use nexus_test_utils_macros::nexus_test; use nexus_types::external_api::bfd::BfdPeerStatuses; use nexus_types::external_api::networking::{ - SwitchResult, SwitchResults, SwitchUnavailableReason, + SwitchError, SwitchResult, SwitchResults, }; type ControlPlaneTestContext = @@ -33,17 +33,15 @@ async fn test_empty_bfd_status(cptestctx: &ControlPlaneTestContext) { let mut unavailable = 0; for result in [status.switch0, status.switch1] { match result { - SwitchResult::Available { value: BfdPeerStatuses(statuses) } => { + SwitchResult::Ok { value: BfdPeerStatuses(statuses) } => { assert!(statuses.is_empty()); available += 1; } - SwitchResult::Unavailable { - reason: SwitchUnavailableReason::MgdUnresolved, - } => unavailable += 1, - SwitchResult::Unavailable { - reason: SwitchUnavailableReason::QueryFailed, - } => { - panic!("BFD query unexpectedly failed"); + SwitchResult::Err { error: SwitchError::MgdUnresolved } => { + unavailable += 1; + } + SwitchResult::Err { error } => { + panic!("BFD query unexpectedly failed: {error:?}"); } } } diff --git a/nexus/tests/integration_tests/bgp_unnumbered.rs b/nexus/tests/integration_tests/bgp_unnumbered.rs index 58281319d37..21845aa5449 100644 --- a/nexus/tests/integration_tests/bgp_unnumbered.rs +++ b/nexus/tests/integration_tests/bgp_unnumbered.rs @@ -10,8 +10,8 @@ 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::{ - SwitchResult, SwitchResults, SwitchUnavailableReason, UnnumberedInterfaces, - UnnumberedManagerState, + BgpMessageHistories, SwitchError, SwitchResult, SwitchResults, + UnnumberedInterfaces, UnnumberedManagerState, }; use serde_json::{Value, json}; @@ -32,15 +32,12 @@ async fn test_bgp_unnumbered_status_by_switch( .execute_and_parse_unwrap::>() .await; match manager.switch0 { - SwitchResult::Available { value } => { + SwitchResult::Ok { value } => { assert!(value.interfaces.is_empty()); } - SwitchResult::Unavailable { - reason: SwitchUnavailableReason::MgdUnresolved, - } => panic!("switch0 MGD was unexpectedly unresolved"), - SwitchResult::Unavailable { - reason: SwitchUnavailableReason::QueryFailed, - } => panic!("switch0 manager-state query unexpectedly failed"), + SwitchResult::Err { error } => { + panic!("switch0 manager-state query failed: {error:?}") + } } assert_mgd_unresolved(manager.switch1); @@ -52,15 +49,12 @@ async fn test_bgp_unnumbered_status_by_switch( .execute_and_parse_unwrap::>() .await; match interfaces.switch0 { - SwitchResult::Available { value: UnnumberedInterfaces(interfaces) } => { + SwitchResult::Ok { value: UnnumberedInterfaces(interfaces) } => { assert!(interfaces.is_empty()); } - SwitchResult::Unavailable { - reason: SwitchUnavailableReason::MgdUnresolved, - } => panic!("switch0 MGD was unexpectedly unresolved"), - SwitchResult::Unavailable { - reason: SwitchUnavailableReason::QueryFailed, - } => panic!("switch0 interface query unexpectedly failed"), + SwitchResult::Err { error } => { + panic!("switch0 interface query failed: {error:?}") + } } assert_mgd_unresolved(interfaces.switch1); } @@ -71,10 +65,12 @@ async fn test_bgp_aggregate_status_api_versions( ) { let client = &cptestctx.external_client; let available_empty = json!({ - "switch0": { "status": "available", "value": [] }, + "switch0": { "status": "ok", "value": [] }, "switch1": { - "status": "unavailable", - "reason": "mgd_unresolved", + "status": "err", + "error": { + "type": "mgd_unresolved", + }, }, }); for url in [ @@ -88,26 +84,32 @@ async fn test_bgp_aggregate_status_api_versions( .await; assert_eq!(response, available_empty, "unexpected response from {url}"); } - let response: Value = NexusRequest::object_get( + let response = NexusRequest::object_get( client, "/v1/system/networking/bgp-message-history?asn=64512", ) .authn_as(AuthnMode::PrivilegedUser) - .execute_and_parse_unwrap() + .execute_and_parse_unwrap::>() .await; - assert_eq!( - response, - json!({ - "switch0": { - "status": "unavailable", - "reason": "query_failed", - }, - "switch1": { - "status": "unavailable", - "reason": "mgd_unresolved", - }, - }) - ); + match response.switch0 { + SwitchResult::Err { + error: + SwitchError::RequestRejected { + error_code, + message, + upstream_request_id, + }, + } => { + assert_eq!(error_code, None); + assert_eq!(message, "not found: no bgp router configured"); + 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 = @@ -163,14 +165,12 @@ async fn versioned_get( fn assert_mgd_unresolved(result: SwitchResult) { match result { - SwitchResult::Unavailable { - reason: SwitchUnavailableReason::MgdUnresolved, - } => {} - SwitchResult::Available { .. } => { + SwitchResult::Err { error: SwitchError::MgdUnresolved } => {} + SwitchResult::Ok { .. } => { panic!("switch1 was unexpectedly available"); } - SwitchResult::Unavailable { - reason: SwitchUnavailableReason::QueryFailed, - } => panic!("switch1 query unexpectedly failed"), + SwitchResult::Err { error } => { + panic!("switch1 failed unexpectedly: {error:?}") + } } } 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 index c4f9740174b..a9e904ae994 100644 --- 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 @@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize}; use sled_agent_types_versions::v1::early_networking::BfdMode; use std::net::IpAddr; -use super::networking::{SwitchResults, SwitchUnavailableReason}; +use super::networking::{SwitchError, SwitchResults}; #[derive( Clone, @@ -37,7 +37,7 @@ pub struct BfdPeerStatuses(pub Vec); impl TryFrom> for Vec { - type Error = SwitchUnavailableReason; + type Error = SwitchError; fn try_from( results: SwitchResults, @@ -46,8 +46,10 @@ impl TryFrom> for (switch_slot, result) in results { let value = match result.into_result() { Ok(value) => value, - Err(SwitchUnavailableReason::MgdUnresolved) => continue, - Err(reason) => return Err(reason), + Err(SwitchError::MgdUnresolved) => { + continue; + } + Err(error) => return Err(error), }; statuses.extend(value.0.into_iter().map(|status| { v2026_03_06_01::bfd::BfdStatus { @@ -67,7 +69,7 @@ impl TryFrom> #[cfg(test)] mod tests { - use super::super::networking::SwitchResult; + use super::super::networking::{SwitchError, SwitchResult}; use super::*; use sled_agent_types_versions::v1::early_networking::SwitchSlot; use std::net::{IpAddr, Ipv6Addr}; @@ -76,7 +78,7 @@ mod tests { fn conversion_restores_switch_slots_and_omits_unavailable_switches() { let peer = IpAddr::V6(Ipv6Addr::LOCALHOST); let results = SwitchResults { - switch0: SwitchResult::Available { + switch0: SwitchResult::Ok { value: BfdPeerStatuses(vec![BfdPeerStatus { peer, state: BfdState::Up, @@ -86,9 +88,7 @@ mod tests { mode: BfdMode::SingleHop, }]), }, - switch1: SwitchResult::Unavailable { - reason: SwitchUnavailableReason::MgdUnresolved, - }, + switch1: SwitchResult::Err { error: SwitchError::MgdUnresolved }, }; let statuses = @@ -102,16 +102,13 @@ mod tests { #[test] fn conversion_preserves_query_failure() { let results = SwitchResults { - switch0: SwitchResult::Unavailable { - reason: SwitchUnavailableReason::QueryFailed, - }, - switch1: SwitchResult::Available { - value: BfdPeerStatuses(Vec::new()), - }, + switch0: SwitchResult::Err { error: SwitchError::QueryFailed }, + switch1: SwitchResult::Ok { value: BfdPeerStatuses(Vec::new()) }, }; - assert!( - Vec::::try_from(results).is_err() + assert_eq!( + Vec::::try_from(results), + Err(SwitchError::QueryFailed), ); } } 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 index f9e7afb38cd..a3904a12aed 100644 --- 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 @@ -35,6 +35,14 @@ pub struct UnnumberedInterfacePath { 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 { @@ -42,21 +50,79 @@ pub struct SwitchResults { 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 { - Available { value: T }, - Unavailable { reason: SwitchUnavailableReason }, + Ok { value: T }, + Err { error: SwitchError }, } -#[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum SwitchUnavailableReason { +/// 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 { @@ -123,7 +189,7 @@ impl From> fn from(results: SwitchResults) -> Self { let mut statuses = Vec::new(); for (switch, result) in results { - let SwitchResult::Available { value } = result else { + let SwitchResult::Ok { value } = result else { continue; }; statuses.extend(value.0.into_iter().map(|status| { @@ -148,7 +214,7 @@ impl From> fn from(results: SwitchResults) -> Self { let mut routes = Vec::new(); for (switch, result) in results { - let SwitchResult::Available { value } = result else { + let SwitchResult::Ok { value } = result else { continue; }; routes.extend(value.0.into_iter().map(|route| { @@ -169,7 +235,7 @@ impl From> fn from(results: SwitchResults) -> Self { let mut switch_histories = Vec::new(); for (switch, result) in results { - let SwitchResult::Available { value } = result else { + let SwitchResult::Ok { value } = result else { continue; }; switch_histories.push( @@ -189,7 +255,7 @@ impl From> fn from(results: SwitchResults) -> Self { let mut routes = Vec::new(); for (switch, result) in results { - let SwitchResult::Available { value } = result else { + let SwitchResult::Ok { value } = result else { continue; }; routes.extend(value.0.into_iter().map(|route| { @@ -389,10 +455,78 @@ impl From for DiscoveredRouter { 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"); @@ -411,7 +545,7 @@ mod tests { fn peer_status_conversion_restores_switch_slot_and_omits_query_failure() { let addr = IpAddr::V4(Ipv4Addr::LOCALHOST); let results = SwitchResults { - switch0: SwitchResult::Available { + switch0: SwitchResult::Ok { value: BgpPeerStatuses(vec![BgpPeerStatus { addr, peer_id: "tfportqsfp0_0".to_owned(), @@ -421,8 +555,8 @@ mod tests { state_duration_millis: 100, }]), }, - switch1: SwitchResult::Unavailable { - reason: SwitchUnavailableReason::QueryFailed, + switch1: SwitchResult::Err { + error: SwitchError::QueryFailed, }, }; @@ -454,10 +588,8 @@ mod tests { fn exported_routes_conversion_restores_switch_slot_and_omits_unresolved() { let prefix = "192.0.2.0/24".parse().unwrap(); let results = SwitchResults { - switch0: SwitchResult::Unavailable { - reason: SwitchUnavailableReason::MgdUnresolved, - }, - switch1: SwitchResult::Available { + switch0: SwitchResult::Err { error: SwitchError::MgdUnresolved }, + switch1: SwitchResult::Ok { value: BgpExportedRoutes(vec![BgpExported { peer_id: "peer".to_owned(), prefix, @@ -492,10 +624,10 @@ mod tests { id, }; let results = SwitchResults { - switch0: SwitchResult::Available { + switch0: SwitchResult::Ok { value: BgpImportedRoutes(vec![route(0)]), }, - switch1: SwitchResult::Available { + switch1: SwitchResult::Ok { value: BgpImportedRoutes(vec![route(1)]), }, }; @@ -535,12 +667,10 @@ mod tests { #[test] fn message_history_conversion_omits_unavailable_switch() { let results = SwitchResults { - switch0: SwitchResult::Available { + switch0: SwitchResult::Ok { value: BgpMessageHistories(HashMap::new()), }, - switch1: SwitchResult::Unavailable { - reason: SwitchUnavailableReason::QueryFailed, - }, + switch1: SwitchResult::Err { error: SwitchError::QueryFailed }, }; let history: crate::v2025_11_20_00::networking::AggregateBgpMessageHistory = @@ -550,26 +680,37 @@ mod tests { } #[test] - fn unavailable_reasons_have_distinct_wire_representations() { - let unresolved = SwitchResult::::Unavailable { - reason: SwitchUnavailableReason::MgdUnresolved, + fn switch_errors_preserve_normalized_details_on_the_wire() { + let unresolved = SwitchResult::::Err { + error: SwitchError::MgdUnresolved, }; - let failed = SwitchResult::::Unavailable { - reason: SwitchUnavailableReason::QueryFailed, + 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": "unavailable", - "reason": "mgd_unresolved", + "status": "err", + "error": { + "type": "mgd_unresolved", + }, }) ); assert_eq!( - serde_json::to_value(failed).unwrap(), + serde_json::to_value(rejected).unwrap(), serde_json::json!({ - "status": "unavailable", - "reason": "query_failed", + "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 2df67a5b78a..f7a910e23d7 100644 --- a/nexus/types/versions/src/impls/networking.rs +++ b/nexus/types/versions/src/impls/networking.rs @@ -10,14 +10,23 @@ use sled_agent_types_versions::v1::early_networking::SwitchSlot; use uuid::Uuid; impl latest::networking::SwitchResult { - pub fn into_result( - self, - ) -> Result { + pub fn into_result(self) -> Result { match self { - Self::Available { value } => Ok(value), - Self::Unavailable { reason } => Err(reason), + 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 { @@ -33,6 +42,11 @@ impl latest::networking::SwitchResults { ] .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 { @@ -128,14 +142,14 @@ mod tests { use latest::networking::{SwitchResult, SwitchResults}; let results = SwitchResults { - switch0: SwitchResult::Available { value: 0 }, - switch1: SwitchResult::Available { value: 1 }, + switch0: SwitchResult::Ok { value: 0 }, + switch1: SwitchResult::Ok { value: 1 }, }; let borrowed: Vec<_> = results .iter() .map(|(slot, result)| { - let SwitchResult::Available { value } = result else { + let SwitchResult::Ok { value } = result else { panic!("expected an available result"); }; (slot, *value) diff --git a/nexus/types/versions/src/latest.rs b/nexus/types/versions/src/latest.rs index a6d1c7cdc5b..cacc50a7550 100644 --- a/nexus/types/versions/src/latest.rs +++ b/nexus/types/versions/src/latest.rs @@ -342,9 +342,9 @@ pub mod networking { 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::SwitchUnavailableReason; 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; diff --git a/openapi/nexus/nexus-2026072900.0.0-f0d494.json b/openapi/nexus/nexus-2026072900.0.0-fb28c9.json similarity index 99% rename from openapi/nexus/nexus-2026072900.0.0-f0d494.json rename to openapi/nexus/nexus-2026072900.0.0-fb28c9.json index f39d7aec1b7..27ed34bade9 100644 --- a/openapi/nexus/nexus-2026072900.0.0-f0d494.json +++ b/openapi/nexus/nexus-2026072900.0.0-fb28c9.json @@ -17232,6 +17232,7 @@ } }, "BfdPeerStatusesSwitchResult": { + "description": "The outcome of querying one switch.", "oneOf": [ { "type": "object", @@ -17239,7 +17240,7 @@ "status": { "type": "string", "enum": [ - "available" + "ok" ] }, "value": { @@ -17254,24 +17255,25 @@ { "type": "object", "properties": { - "reason": { - "$ref": "#/components/schemas/SwitchUnavailableReason" + "error": { + "$ref": "#/components/schemas/SwitchError" }, "status": { "type": "string", "enum": [ - "unavailable" + "err" ] } }, "required": [ - "reason", + "error", "status" ] } ] }, "BfdPeerStatusesSwitchResults": { + "description": "Results of querying both switches.\n\nA 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.", "type": "object", "properties": { "switch0": { @@ -17704,6 +17706,7 @@ } }, "BgpExportedRoutesSwitchResult": { + "description": "The outcome of querying one switch.", "oneOf": [ { "type": "object", @@ -17711,7 +17714,7 @@ "status": { "type": "string", "enum": [ - "available" + "ok" ] }, "value": { @@ -17726,24 +17729,25 @@ { "type": "object", "properties": { - "reason": { - "$ref": "#/components/schemas/SwitchUnavailableReason" + "error": { + "$ref": "#/components/schemas/SwitchError" }, "status": { "type": "string", "enum": [ - "unavailable" + "err" ] } }, "required": [ - "reason", + "error", "status" ] } ] }, "BgpExportedRoutesSwitchResults": { + "description": "Results of querying both switches.\n\nA 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.", "type": "object", "properties": { "switch0": { @@ -17795,6 +17799,7 @@ } }, "BgpImportedRoutesSwitchResult": { + "description": "The outcome of querying one switch.", "oneOf": [ { "type": "object", @@ -17802,7 +17807,7 @@ "status": { "type": "string", "enum": [ - "available" + "ok" ] }, "value": { @@ -17817,24 +17822,25 @@ { "type": "object", "properties": { - "reason": { - "$ref": "#/components/schemas/SwitchUnavailableReason" + "error": { + "$ref": "#/components/schemas/SwitchError" }, "status": { "type": "string", "enum": [ - "unavailable" + "err" ] } }, "required": [ - "reason", + "error", "status" ] } ] }, "BgpImportedRoutesSwitchResults": { + "description": "Results of querying both switches.\n\nA 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.", "type": "object", "properties": { "switch0": { @@ -17857,6 +17863,7 @@ } }, "BgpMessageHistoriesSwitchResult": { + "description": "The outcome of querying one switch.", "oneOf": [ { "type": "object", @@ -17864,7 +17871,7 @@ "status": { "type": "string", "enum": [ - "available" + "ok" ] }, "value": { @@ -17879,24 +17886,25 @@ { "type": "object", "properties": { - "reason": { - "$ref": "#/components/schemas/SwitchUnavailableReason" + "error": { + "$ref": "#/components/schemas/SwitchError" }, "status": { "type": "string", "enum": [ - "unavailable" + "err" ] } }, "required": [ - "reason", + "error", "status" ] } ] }, "BgpMessageHistoriesSwitchResults": { + "description": "Results of querying both switches.\n\nA 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.", "type": "object", "properties": { "switch0": { @@ -18186,6 +18194,7 @@ } }, "BgpPeerStatusesSwitchResult": { + "description": "The outcome of querying one switch.", "oneOf": [ { "type": "object", @@ -18193,7 +18202,7 @@ "status": { "type": "string", "enum": [ - "available" + "ok" ] }, "value": { @@ -18208,24 +18217,25 @@ { "type": "object", "properties": { - "reason": { - "$ref": "#/components/schemas/SwitchUnavailableReason" + "error": { + "$ref": "#/components/schemas/SwitchError" }, "status": { "type": "string", "enum": [ - "unavailable" + "err" ] } }, "required": [ - "reason", + "error", "status" ] } ] }, "BgpPeerStatusesSwitchResults": { + "description": "Results of querying both switches.\n\nA 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.", "type": "object", "properties": { "switch0": { @@ -29401,6 +29411,95 @@ "time_modified" ] }, + "SwitchError": { + "description": "A normalized failure from one switch query.", + "oneOf": [ + { + "description": "The switch authoritatively rejected the query.", + "type": "object", + "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" + ] + }, + { + "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" + ] + } + ] + }, "SwitchInterfaceConfig": { "description": "A switch port interface configuration for a port settings object.", "type": "object", @@ -30149,13 +30248,6 @@ } ] }, - "SwitchUnavailableReason": { - "type": "string", - "enum": [ - "mgd_unresolved", - "query_failed" - ] - }, "SwitchUnnumberedInterface": { "type": "object", "properties": { @@ -30716,6 +30808,7 @@ } }, "UnnumberedInterfacesSwitchResult": { + "description": "The outcome of querying one switch.", "oneOf": [ { "type": "object", @@ -30723,7 +30816,7 @@ "status": { "type": "string", "enum": [ - "available" + "ok" ] }, "value": { @@ -30738,24 +30831,25 @@ { "type": "object", "properties": { - "reason": { - "$ref": "#/components/schemas/SwitchUnavailableReason" + "error": { + "$ref": "#/components/schemas/SwitchError" }, "status": { "type": "string", "enum": [ - "unavailable" + "err" ] } }, "required": [ - "reason", + "error", "status" ] } ] }, "UnnumberedInterfacesSwitchResults": { + "description": "Results of querying both switches.\n\nA 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.", "type": "object", "properties": { "switch0": { @@ -30789,6 +30883,7 @@ ] }, "UnnumberedManagerStateSwitchResult": { + "description": "The outcome of querying one switch.", "oneOf": [ { "type": "object", @@ -30796,7 +30891,7 @@ "status": { "type": "string", "enum": [ - "available" + "ok" ] }, "value": { @@ -30811,24 +30906,25 @@ { "type": "object", "properties": { - "reason": { - "$ref": "#/components/schemas/SwitchUnavailableReason" + "error": { + "$ref": "#/components/schemas/SwitchError" }, "status": { "type": "string", "enum": [ - "unavailable" + "err" ] } }, "required": [ - "reason", + "error", "status" ] } ] }, "UnnumberedManagerStateSwitchResults": { + "description": "Results of querying both switches.\n\nA 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.", "type": "object", "properties": { "switch0": { diff --git a/openapi/nexus/nexus-latest.json b/openapi/nexus/nexus-latest.json index 33e614c4aa8..5151ba17a8c 120000 --- a/openapi/nexus/nexus-latest.json +++ b/openapi/nexus/nexus-latest.json @@ -1 +1 @@ -nexus-2026072900.0.0-f0d494.json \ No newline at end of file +nexus-2026072900.0.0-fb28c9.json \ No newline at end of file From e17d493b1f92f43879c7a2e0cba59771c5b5343e Mon Sep 17 00:00:00 2001 From: Trey Aspelund Date: Wed, 29 Jul 2026 21:50:16 -0600 Subject: [PATCH 13/16] make ci less unhappy Signed-off-by: Trey Aspelund --- nexus/tests/integration_tests/bgp_unnumbered.rs | 2 +- nexus/tests/integration_tests/endpoints.rs | 6 +++--- ...b28c9.json => nexus-2026072900.0.0-1014e2.json} | 14 +++++++------- openapi/nexus/nexus-latest.json | 2 +- 4 files changed, 12 insertions(+), 12 deletions(-) rename openapi/nexus/{nexus-2026072900.0.0-fb28c9.json => nexus-2026072900.0.0-1014e2.json} (99%) diff --git a/nexus/tests/integration_tests/bgp_unnumbered.rs b/nexus/tests/integration_tests/bgp_unnumbered.rs index 21845aa5449..4e0030854f2 100644 --- a/nexus/tests/integration_tests/bgp_unnumbered.rs +++ b/nexus/tests/integration_tests/bgp_unnumbered.rs @@ -101,7 +101,7 @@ async fn test_bgp_aggregate_status_api_versions( }, } => { assert_eq!(error_code, None); - assert_eq!(message, "not found: no bgp router configured"); + assert_eq!(message, "Not Found"); assert!(!upstream_request_id.is_empty()); } SwitchResult::Ok { .. } => panic!("unknown ASN unexpectedly succeeded"), diff --git a/nexus/tests/integration_tests/endpoints.rs b/nexus/tests/integration_tests/endpoints.rs index 9e73796a570..bbe421f950f 100644 --- a/nexus/tests/integration_tests/endpoints.rs +++ b/nexus/tests/integration_tests/endpoints.rs @@ -1047,11 +1047,11 @@ pub const DEMO_BGP_ROUTES_IPV4_URL: &'static str = 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"; + "/v1/system/networking/bgp-unnumbered-manager"; pub const DEMO_BGP_UNNUMBERED_INTERFACES_URL: &'static str = - "/v1/system/networking/bgp/unnumbered/interfaces"; + "/v1/system/networking/bgp-unnumbered-interfaces"; pub const DEMO_BGP_UNNUMBERED_INTERFACE_URL: &'static str = - "/v1/system/networking/bgp/unnumbered/interfaces/switch0/qsfp0"; + "/v1/system/networking/bgp-unnumbered-interfaces/switch0/qsfp0"; pub const DEMO_BFD_STATUS_URL: &'static str = "/v1/system/networking/bfd-status"; diff --git a/openapi/nexus/nexus-2026072900.0.0-fb28c9.json b/openapi/nexus/nexus-2026072900.0.0-1014e2.json similarity index 99% rename from openapi/nexus/nexus-2026072900.0.0-fb28c9.json rename to openapi/nexus/nexus-2026072900.0.0-1014e2.json index 27ed34bade9..331f1468dc8 100644 --- a/openapi/nexus/nexus-2026072900.0.0-fb28c9.json +++ b/openapi/nexus/nexus-2026072900.0.0-1014e2.json @@ -17273,7 +17273,7 @@ ] }, "BfdPeerStatusesSwitchResults": { - "description": "Results of querying both switches.\n\nA 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.", + "description": "Results of querying both switches.", "type": "object", "properties": { "switch0": { @@ -17747,7 +17747,7 @@ ] }, "BgpExportedRoutesSwitchResults": { - "description": "Results of querying both switches.\n\nA 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.", + "description": "Results of querying both switches.", "type": "object", "properties": { "switch0": { @@ -17840,7 +17840,7 @@ ] }, "BgpImportedRoutesSwitchResults": { - "description": "Results of querying both switches.\n\nA 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.", + "description": "Results of querying both switches.", "type": "object", "properties": { "switch0": { @@ -17904,7 +17904,7 @@ ] }, "BgpMessageHistoriesSwitchResults": { - "description": "Results of querying both switches.\n\nA 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.", + "description": "Results of querying both switches.", "type": "object", "properties": { "switch0": { @@ -18235,7 +18235,7 @@ ] }, "BgpPeerStatusesSwitchResults": { - "description": "Results of querying both switches.\n\nA 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.", + "description": "Results of querying both switches.", "type": "object", "properties": { "switch0": { @@ -30849,7 +30849,7 @@ ] }, "UnnumberedInterfacesSwitchResults": { - "description": "Results of querying both switches.\n\nA 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.", + "description": "Results of querying both switches.", "type": "object", "properties": { "switch0": { @@ -30924,7 +30924,7 @@ ] }, "UnnumberedManagerStateSwitchResults": { - "description": "Results of querying both switches.\n\nA 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.", + "description": "Results of querying both switches.", "type": "object", "properties": { "switch0": { diff --git a/openapi/nexus/nexus-latest.json b/openapi/nexus/nexus-latest.json index 5151ba17a8c..815039759e0 120000 --- a/openapi/nexus/nexus-latest.json +++ b/openapi/nexus/nexus-latest.json @@ -1 +1 @@ -nexus-2026072900.0.0-fb28c9.json \ No newline at end of file +nexus-2026072900.0.0-1014e2.json \ No newline at end of file From 369f7ac7cc7e1ed360b24e2db5991bc37e37bf06 Mon Sep 17 00:00:00 2001 From: Trey Aspelund Date: Thu, 13 Aug 2026 16:15:56 -0600 Subject: [PATCH 14/16] bump maghemite again Signed-off-by: Trey Aspelund --- Cargo.toml | 6 +++--- package-manifest.toml | 12 ++++++------ tools/maghemite_ddm_openapi_version | 2 +- tools/maghemite_mg_openapi_version | 2 +- tools/maghemite_mgd_checksums | 8 ++++---- 5 files changed, 15 insertions(+), 15 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 291ff8eff55..a3aecbe791a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -623,8 +623,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 = "5bb316ac73b6c0922ea4707c0f0aa9dae84c0e87" } -ddm-admin-client = { git = "https://github.com/oxidecomputer/maghemite", rev = "5bb316ac73b6c0922ea4707c0f0aa9dae84c0e87" } +mg-admin-client = { git = "https://github.com/oxidecomputer/maghemite", rev = "4aa97e7b47430c1631cfe12c0129a73ab73348f4" } +ddm-admin-client = { git = "https://github.com/oxidecomputer/maghemite", rev = "4aa97e7b47430c1631cfe12c0129a73ab73348f4" } multimap = "0.10.1" nexus-auth = { path = "nexus/auth" } nexus-background-task-interface = { path = "nexus/background-task-interface" } @@ -767,7 +767,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 = "5bb316ac73b6c0922ea4707c0f0aa9dae84c0e87" } +mg-api-types = { git = "https://github.com/oxidecomputer/maghemite", rev = "4aa97e7b47430c1631cfe12c0129a73ab73348f4" } reconfigurator-cli = { path = "dev-tools/reconfigurator-cli" } reedline = "0.40.0" ref-cast = "1.0" diff --git a/package-manifest.toml b/package-manifest.toml index b573f801743..00fcbf99871 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 = "5bb316ac73b6c0922ea4707c0f0aa9dae84c0e87" +source.commit = "4aa97e7b47430c1631cfe12c0129a73ab73348f4" # The SHA256 digest is automatically posted to: # https://buildomat.eng.oxide.computer/public/file/oxidecomputer/maghemite/image//mg-ddm-gz.sha256.txt -source.sha256 = "5f20e9bb6b0ec8ce14e4aecd91e5e7950d22f59d4bc5e90a120a9dbdc1501d6f" +source.sha256 = "2af4c8c908e4e3900ec67bc5305ee40812be4599caa9f7333b7535af45923166" 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 = "5bb316ac73b6c0922ea4707c0f0aa9dae84c0e87" +source.commit = "4aa97e7b47430c1631cfe12c0129a73ab73348f4" # The SHA256 digest is automatically posted to: # https://buildomat.eng.oxide.computer/public/file/oxidecomputer/maghemite/image//mg-ddm.sha256.txt -source.sha256 = "0628e8509cda7f66cbbb8448d40392c7c168ebbb30ca3598e174f5db822be9bd" +source.sha256 = "e3f404e0b45da3bc8e069d1b49aec9f53ebb7b51f62de3b8d271c23ac41ef9f8" 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 = "5bb316ac73b6c0922ea4707c0f0aa9dae84c0e87" +source.commit = "4aa97e7b47430c1631cfe12c0129a73ab73348f4" # The SHA256 digest is automatically posted to: # https://buildomat.eng.oxide.computer/public/file/oxidecomputer/maghemite/image//mgd.sha256.txt -source.sha256 = "9e35f0ca01a23ee7b46d0ef77d549144b62405e564f3ffd967b2bec8781340fa" +source.sha256 = "0b397f92d9276ba960021f70ff09597d8002759badd41a23a791c884dee8a178" output.type = "zone" output.intermediate_only = true diff --git a/tools/maghemite_ddm_openapi_version b/tools/maghemite_ddm_openapi_version index 627a55c1729..1a2e77b5b0c 100644 --- a/tools/maghemite_ddm_openapi_version +++ b/tools/maghemite_ddm_openapi_version @@ -1 +1 @@ -COMMIT="5bb316ac73b6c0922ea4707c0f0aa9dae84c0e87" +COMMIT="4aa97e7b47430c1631cfe12c0129a73ab73348f4" diff --git a/tools/maghemite_mg_openapi_version b/tools/maghemite_mg_openapi_version index 627a55c1729..1a2e77b5b0c 100644 --- a/tools/maghemite_mg_openapi_version +++ b/tools/maghemite_mg_openapi_version @@ -1 +1 @@ -COMMIT="5bb316ac73b6c0922ea4707c0f0aa9dae84c0e87" +COMMIT="4aa97e7b47430c1631cfe12c0129a73ab73348f4" diff --git a/tools/maghemite_mgd_checksums b/tools/maghemite_mgd_checksums index baf9e05a232..bc4add8ba0a 100644 --- a/tools/maghemite_mgd_checksums +++ b/tools/maghemite_mgd_checksums @@ -1,4 +1,4 @@ -CIDL_SHA256="9e35f0ca01a23ee7b46d0ef77d549144b62405e564f3ffd967b2bec8781340fa" -MGD_LINUX_SHA256="85147a24406d63f138a50b4dc9e8c338372d2274d0dda5e24b93e2fc75b7ae20" -MG_DDM_SHA256="0628e8509cda7f66cbbb8448d40392c7c168ebbb30ca3598e174f5db822be9bd" -DDMD_LINUX_SHA256="b8943e3ef9dd7c427f7b681c4e1889d8f2aa74c9c847ef039e3e63d78b2163f5" +CIDL_SHA256="0b397f92d9276ba960021f70ff09597d8002759badd41a23a791c884dee8a178" +MGD_LINUX_SHA256="a64a85ec27c8678263b9297c9e15040fa09b369ea6937490adb502b442b98dd8" +MG_DDM_SHA256="e3f404e0b45da3bc8e069d1b49aec9f53ebb7b51f62de3b8d271c23ac41ef9f8" +DDMD_LINUX_SHA256="81be1efd9c781e6a6fff97276c80006a880fb67d77e8861257817a0267656c85" \ No newline at end of file From 66ec4031ab532a906f647aab1d2a19d5b45cff84 Mon Sep 17 00:00:00 2001 From: Trey Aspelund Date: Thu, 13 Aug 2026 16:33:41 -0600 Subject: [PATCH 15/16] Update generated dependencies for maghemite bump Signed-off-by: Trey Aspelund --- Cargo.lock | 39 +++++++++++++++++++++++++-------------- workspace-hack/Cargo.toml | 8 ++++---- 2 files changed, 29 insertions(+), 18 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4edccd1e9e6..cc9afeb08d0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1608,7 +1608,7 @@ dependencies = [ [[package]] name = "client-common" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/maghemite?rev=5bb316ac73b6c0922ea4707c0f0aa9dae84c0e87#5bb316ac73b6c0922ea4707c0f0aa9dae84c0e87" +source = "git+https://github.com/oxidecomputer/maghemite?rev=4aa97e7b47430c1631cfe12c0129a73ab73348f4#4aa97e7b47430c1631cfe12c0129a73ab73348f4" [[package]] name = "clipboard-win" @@ -2665,7 +2665,7 @@ dependencies = [ [[package]] name = "ddm-admin-client" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/maghemite?rev=5bb316ac73b6c0922ea4707c0f0aa9dae84c0e87#5bb316ac73b6c0922ea4707c0f0aa9dae84c0e87" +source = "git+https://github.com/oxidecomputer/maghemite?rev=4aa97e7b47430c1631cfe12c0129a73ab73348f4#4aa97e7b47430c1631cfe12c0129a73ab73348f4" dependencies = [ "ddm-api-types-versions", "oxnet", @@ -2679,7 +2679,7 @@ dependencies = [ [[package]] name = "ddm-api-types-versions" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/maghemite?rev=5bb316ac73b6c0922ea4707c0f0aa9dae84c0e87#5bb316ac73b6c0922ea4707c0f0aa9dae84c0e87" +source = "git+https://github.com/oxidecomputer/maghemite?rev=4aa97e7b47430c1631cfe12c0129a73ab73348f4#4aa97e7b47430c1631cfe12c0129a73ab73348f4" dependencies = [ "ddm-protocol", "oxnet", @@ -2692,7 +2692,7 @@ dependencies = [ [[package]] name = "ddm-protocol" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/maghemite?rev=5bb316ac73b6c0922ea4707c0f0aa9dae84c0e87#5bb316ac73b6c0922ea4707c0f0aa9dae84c0e87" +source = "git+https://github.com/oxidecomputer/maghemite?rev=4aa97e7b47430c1631cfe12c0129a73ab73348f4#4aa97e7b47430c1631cfe12c0129a73ab73348f4" dependencies = [ "oxnet", "schemars 0.8.22", @@ -6909,7 +6909,7 @@ dependencies = [ [[package]] name = "mg-admin-client" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/maghemite?rev=5bb316ac73b6c0922ea4707c0f0aa9dae84c0e87#5bb316ac73b6c0922ea4707c0f0aa9dae84c0e87" +source = "git+https://github.com/oxidecomputer/maghemite?rev=4aa97e7b47430c1631cfe12c0129a73ab73348f4#4aa97e7b47430c1631cfe12c0129a73ab73348f4" dependencies = [ "chrono", "client-common", @@ -6929,7 +6929,7 @@ dependencies = [ [[package]] name = "mg-api-types" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/maghemite?rev=5bb316ac73b6c0922ea4707c0f0aa9dae84c0e87#5bb316ac73b6c0922ea4707c0f0aa9dae84c0e87" +source = "git+https://github.com/oxidecomputer/maghemite?rev=4aa97e7b47430c1631cfe12c0129a73ab73348f4#4aa97e7b47430c1631cfe12c0129a73ab73348f4" dependencies = [ "mg-api-types-versions", ] @@ -6937,7 +6937,7 @@ dependencies = [ [[package]] name = "mg-api-types-versions" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/maghemite?rev=5bb316ac73b6c0922ea4707c0f0aa9dae84c0e87#5bb316ac73b6c0922ea4707c0f0aa9dae84c0e87" +source = "git+https://github.com/oxidecomputer/maghemite?rev=4aa97e7b47430c1631cfe12c0129a73ab73348f4#4aa97e7b47430c1631cfe12c0129a73ab73348f4" dependencies = [ "chrono", "client-common", @@ -13518,9 +13518,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", @@ -13568,22 +13568,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]] @@ -15249,6 +15249,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" diff --git a/workspace-hack/Cargo.toml b/workspace-hack/Cargo.toml index 7d954a43cda..ad9c55ac8d7 100644 --- a/workspace-hack/Cargo.toml +++ b/workspace-hack/Cargo.toml @@ -122,8 +122,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"] } @@ -273,8 +273,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"] } From 12dda4eba70c3ad2f9e51c17b979798487ea7522 Mon Sep 17 00:00:00 2001 From: Trey Aspelund Date: Tue, 18 Aug 2026 17:09:42 -0600 Subject: [PATCH 16/16] Update Maghemite dependency and RA status fields Signed-off-by: Trey Aspelund --- Cargo.lock | 14 ++++----- Cargo.toml | 6 ++-- .../networking.rs | 12 ++++---- ....json => nexus-2026081800.0.0-6422a0.json} | 30 +++++++++++-------- openapi/nexus/nexus-latest.json | 2 +- package-manifest.toml | 12 ++++---- tools/maghemite_ddm_openapi_version | 2 +- tools/maghemite_mg_openapi_version | 2 +- tools/maghemite_mgd_checksums | 8 ++--- 9 files changed, 47 insertions(+), 41 deletions(-) rename openapi/nexus/{nexus-2026081800.0.0-0c971e.json => nexus-2026081800.0.0-6422a0.json} (99%) diff --git a/Cargo.lock b/Cargo.lock index ebf8d2b81fb..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=4aa97e7b47430c1631cfe12c0129a73ab73348f4#4aa97e7b47430c1631cfe12c0129a73ab73348f4" +source = "git+https://github.com/oxidecomputer/maghemite?rev=bf831acede9b125a854bfda5615ae9248d1e2fa8#bf831acede9b125a854bfda5615ae9248d1e2fa8" [[package]] name = "clipboard-win" @@ -2651,7 +2651,7 @@ dependencies = [ [[package]] name = "ddm-admin-client" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/maghemite?rev=4aa97e7b47430c1631cfe12c0129a73ab73348f4#4aa97e7b47430c1631cfe12c0129a73ab73348f4" +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=4aa97e7b47430c1631cfe12c0129a73ab73348f4#4aa97e7b47430c1631cfe12c0129a73ab73348f4" +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=4aa97e7b47430c1631cfe12c0129a73ab73348f4#4aa97e7b47430c1631cfe12c0129a73ab73348f4" +source = "git+https://github.com/oxidecomputer/maghemite?rev=bf831acede9b125a854bfda5615ae9248d1e2fa8#bf831acede9b125a854bfda5615ae9248d1e2fa8" dependencies = [ "oxnet", "schemars 0.8.22", @@ -6905,7 +6905,7 @@ dependencies = [ [[package]] name = "mg-admin-client" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/maghemite?rev=4aa97e7b47430c1631cfe12c0129a73ab73348f4#4aa97e7b47430c1631cfe12c0129a73ab73348f4" +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=4aa97e7b47430c1631cfe12c0129a73ab73348f4#4aa97e7b47430c1631cfe12c0129a73ab73348f4" +source = "git+https://github.com/oxidecomputer/maghemite?rev=bf831acede9b125a854bfda5615ae9248d1e2fa8#bf831acede9b125a854bfda5615ae9248d1e2fa8" dependencies = [ "mg-api-types-versions", ] @@ -6933,7 +6933,7 @@ dependencies = [ [[package]] name = "mg-api-types-versions" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/maghemite?rev=4aa97e7b47430c1631cfe12c0129a73ab73348f4#4aa97e7b47430c1631cfe12c0129a73ab73348f4" +source = "git+https://github.com/oxidecomputer/maghemite?rev=bf831acede9b125a854bfda5615ae9248d1e2fa8#bf831acede9b125a854bfda5615ae9248d1e2fa8" dependencies = [ "chrono", "client-common", diff --git a/Cargo.toml b/Cargo.toml index 33b60c5c5ce..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 = "4aa97e7b47430c1631cfe12c0129a73ab73348f4" } -ddm-admin-client = { git = "https://github.com/oxidecomputer/maghemite", rev = "4aa97e7b47430c1631cfe12c0129a73ab73348f4" } +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 = "4aa97e7b47430c1631cfe12c0129a73ab73348f4" } +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/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 index a3904a12aed..7a1a963e5ba 100644 --- 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 @@ -420,12 +420,12 @@ pub struct DiscoveredRouter { pub time_since_last_rx: Duration, /// Effective reachable time governing expiry of this entry pub effective_reachable_time: Duration, - /// Router lifetime from RA (seconds) - pub router_lifetime: u16, - /// Reachable time from RA (milliseconds) - pub reachable_time: u32, - /// Retransmit timer from RA (milliseconds) - pub retrans_timer: u32, + /// 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 { diff --git a/openapi/nexus/nexus-2026081800.0.0-0c971e.json b/openapi/nexus/nexus-2026081800.0.0-6422a0.json similarity index 99% rename from openapi/nexus/nexus-2026081800.0.0-0c971e.json rename to openapi/nexus/nexus-2026081800.0.0-6422a0.json index 2bf9e2de76d..c8886a8501e 100644 --- a/openapi/nexus/nexus-2026081800.0.0-0c971e.json +++ b/openapi/nexus/nexus-2026081800.0.0-6422a0.json @@ -20409,22 +20409,28 @@ ] }, "reachable_time": { - "description": "Reachable time from RA (milliseconds)", - "type": "integer", - "format": "uint32", - "minimum": 0 + "description": "Reachable time from RA", + "allOf": [ + { + "$ref": "#/components/schemas/Duration" + } + ] }, "retrans_timer": { - "description": "Retransmit timer from RA (milliseconds)", - "type": "integer", - "format": "uint32", - "minimum": 0 + "description": "Retransmit timer from RA", + "allOf": [ + { + "$ref": "#/components/schemas/Duration" + } + ] }, "router_lifetime": { - "description": "Router lifetime from RA (seconds)", - "type": "integer", - "format": "uint16", - "minimum": 0 + "description": "Router lifetime from RA", + "allOf": [ + { + "$ref": "#/components/schemas/Duration" + } + ] }, "time_since_discovered": { "description": "Time elapsed since the router was first discovered", diff --git a/openapi/nexus/nexus-latest.json b/openapi/nexus/nexus-latest.json index 93f985ee3bd..09ec1c2cd29 120000 --- a/openapi/nexus/nexus-latest.json +++ b/openapi/nexus/nexus-latest.json @@ -1 +1 @@ -nexus-2026081800.0.0-0c971e.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 00fcbf99871..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 = "4aa97e7b47430c1631cfe12c0129a73ab73348f4" +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 = "2af4c8c908e4e3900ec67bc5305ee40812be4599caa9f7333b7535af45923166" +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 = "4aa97e7b47430c1631cfe12c0129a73ab73348f4" +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 = "e3f404e0b45da3bc8e069d1b49aec9f53ebb7b51f62de3b8d271c23ac41ef9f8" +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 = "4aa97e7b47430c1631cfe12c0129a73ab73348f4" +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 = "0b397f92d9276ba960021f70ff09597d8002759badd41a23a791c884dee8a178" +source.sha256 = "ada0d1513ea9a3582e0c6562ae71b8e637bc6fbdf50f68f0631a9578b3d5ab9c" output.type = "zone" output.intermediate_only = true diff --git a/tools/maghemite_ddm_openapi_version b/tools/maghemite_ddm_openapi_version index 1a2e77b5b0c..7cbbbb5ea7e 100644 --- a/tools/maghemite_ddm_openapi_version +++ b/tools/maghemite_ddm_openapi_version @@ -1 +1 @@ -COMMIT="4aa97e7b47430c1631cfe12c0129a73ab73348f4" +COMMIT="bf831acede9b125a854bfda5615ae9248d1e2fa8" diff --git a/tools/maghemite_mg_openapi_version b/tools/maghemite_mg_openapi_version index 1a2e77b5b0c..7cbbbb5ea7e 100644 --- a/tools/maghemite_mg_openapi_version +++ b/tools/maghemite_mg_openapi_version @@ -1 +1 @@ -COMMIT="4aa97e7b47430c1631cfe12c0129a73ab73348f4" +COMMIT="bf831acede9b125a854bfda5615ae9248d1e2fa8" diff --git a/tools/maghemite_mgd_checksums b/tools/maghemite_mgd_checksums index bc4add8ba0a..da7ae10ab46 100644 --- a/tools/maghemite_mgd_checksums +++ b/tools/maghemite_mgd_checksums @@ -1,4 +1,4 @@ -CIDL_SHA256="0b397f92d9276ba960021f70ff09597d8002759badd41a23a791c884dee8a178" -MGD_LINUX_SHA256="a64a85ec27c8678263b9297c9e15040fa09b369ea6937490adb502b442b98dd8" -MG_DDM_SHA256="e3f404e0b45da3bc8e069d1b49aec9f53ebb7b51f62de3b8d271c23ac41ef9f8" -DDMD_LINUX_SHA256="81be1efd9c781e6a6fff97276c80006a880fb67d77e8861257817a0267656c85" \ 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