diff --git a/nexus/db-queries/src/db/datastore/bgp.rs b/nexus/db-queries/src/db/datastore/bgp.rs index 2f09214b349..c714ccc8074 100644 --- a/nexus/db-queries/src/db/datastore/bgp.rs +++ b/nexus/db-queries/src/db/datastore/bgp.rs @@ -25,7 +25,7 @@ use omicron_common::api::external; use omicron_common::api::external::http_pagination::PaginatedBy; use omicron_common::api::external::{ CreateResult, DeleteResult, Error, ListResultVec, LookupResult, NameOrId, - ResourceType, + ResourceType, UpdateResult, }; use ref_cast::RefCast; use sled_agent_types::early_networking::RouterPeerType; @@ -225,6 +225,143 @@ impl DataStore { }) } + pub async fn bgp_config_update( + &self, + opctx: &OpContext, + sel: &networking::BgpConfigSelector, + update: &networking::BgpConfigUpdate, + ) -> UpdateResult { + use nexus_db_schema::schema::bgp_config; + use nexus_db_schema::schema::bgp_config::dsl as bgp_config_dsl; + use nexus_db_schema::schema::{ + bgp_announce_set, bgp_announce_set::dsl as announce_set_dsl, + }; + + let err = OptionalError::new(); + let transaction = async |conn| { + // Look up the existing config + let (query, not_found_err, msg) = match &sel.name_or_id { + NameOrId::Id(id) => ( + bgp_config_dsl::bgp_config + .filter(bgp_config::id.eq(*id)) + .into_boxed(), + Error::not_found_by_id(ResourceType::BgpConfig, id), + "failed to lookup bgp config by id", + ), + NameOrId::Name(name) => ( + bgp_config_dsl::bgp_config + .filter(bgp_config::name.eq(name.to_string())) + .into_boxed(), + Error::not_found_by_name(ResourceType::BgpConfig, name), + "failed to lookup bgp config by name", + ), + }; + + let lookup_err = |e, not_found_err, msg| { + error!(opctx.log, "{msg}"; "error" => ?e); + match e { + diesel::result::Error::NotFound => err.bail(not_found_err), + _ => err.bail(Error::internal_error(msg)), + } + }; + + let existing: BgpConfig = query + .filter(bgp_config::time_deleted.is_null()) + .select(BgpConfig::as_select()) + .limit(1) + .first_async::(&conn) + .await + .map_err(|e| lookup_err(e, not_found_err, msg))?; + + // Resolve bgp_announce_set_id if an update was requested + let new_bgp_announce_set_id = match &update.bgp_announce_set_id { + None => existing.bgp_announce_set_id, + Some(name_or_id) => { + let (query, not_found_err, msg) = match name_or_id { + NameOrId::Id(id) => ( + announce_set_dsl::bgp_announce_set + .filter(bgp_announce_set::id.eq(*id)) + .into_boxed(), + Error::not_found_by_id( + ResourceType::BgpAnnounceSet, + id, + ), + "failed to lookup announce set by id", + ), + NameOrId::Name(name) => ( + announce_set_dsl::bgp_announce_set + .filter( + bgp_announce_set::name.eq(name.to_string()), + ) + .into_boxed(), + Error::not_found_by_name( + ResourceType::BgpAnnounceSet, + name, + ), + "failed to lookup announce set by name", + ), + }; + query + .filter(bgp_announce_set::time_deleted.is_null()) + .select(bgp_announce_set::id) + .limit(1) + .first_async::(&conn) + .await + .map_err(|e| lookup_err(e, not_found_err, msg))? + } + }; + + let new_name = update + .identity + .name + .as_ref() + .unwrap_or(existing.name()) + .to_string(); + let new_description = update + .identity + .description + .as_deref() + .unwrap_or(existing.description()) + .to_string(); + let new_max_paths = + update.max_paths.map_or(*existing.max_paths, |m| m.as_u8()); + + diesel::update(bgp_config_dsl::bgp_config) + .filter(bgp_config_dsl::id.eq(existing.id())) + .set(( + bgp_config_dsl::time_modified.eq(Utc::now()), + bgp_config_dsl::name.eq(new_name), + bgp_config_dsl::description.eq(new_description), + bgp_config_dsl::bgp_announce_set_id + .eq(new_bgp_announce_set_id), + bgp_config_dsl::max_paths.eq(i16::from(new_max_paths)), + )) + .returning(BgpConfig::as_returning()) + .get_result_async(&conn) + .await + .map_err(|e| { + let msg = "bgp_config_update failed"; + error!(opctx.log, "{msg}"; "error" => ?e); + err.bail(public_error_from_diesel(e, ErrorHandler::Server)) + }) + }; + + let conn = self.pool_connection_authorized(opctx).await?; + self.transaction_retry_wrapper("bgp_config_update") + .transaction(&conn, transaction) + .await + .map_err(|e| { + let msg = "bgp_config_update failed"; + if let Some(err) = err.take() { + error!(opctx.log, "{msg}"; "error" => ?err); + err + } else { + error!(opctx.log, "{msg}"; "error" => ?e); + public_error_from_diesel(e, ErrorHandler::Server) + } + }) + } + pub async fn bgp_config_delete( &self, opctx: &OpContext, @@ -1049,13 +1186,135 @@ mod tests { use nexus_db_model::SwitchPortBgpPeerConfig; use nexus_types::external_api::networking::BgpPeer; use omicron_common::api::external::IdentityMetadataCreateParams; + use omicron_common::api::external::IdentityMetadataUpdateParams; use omicron_common::api::external::Name; use omicron_test_utils::dev; use oxnet::IpNet; use sled_agent_types::early_networking::ImportExportPolicy; + use sled_agent_types::early_networking::MaxPathConfig; use sled_agent_types::early_networking::RouterLifetimeConfig; use std::net::IpAddr; + #[tokio::test] + async fn test_update_bgp_config() { + let logctx = dev::test_setup_log("test_update_bgp_config"); + let db = TestDatabase::new_with_datastore(&logctx.log).await; + let (opctx, datastore) = (db.opctx(), db.datastore()); + + let config_name: Name = "config-name".parse().unwrap(); + let announce_name: Name = "announce-name".parse().unwrap(); + + let config = networking::BgpConfigCreate { + identity: IdentityMetadataCreateParams { + name: config_name.clone(), + description: String::from("a test config"), + }, + asn: 47, + bgp_announce_set_id: NameOrId::Name(announce_name.clone()), + vrf: None, + shaper: None, + checker: None, + max_paths: MaxPathConfig::new(1).unwrap(), + }; + + let new_announce_name: Name = "new-announce-name".parse().unwrap(); + let new_max_paths = MaxPathConfig::new(2).unwrap(); + let new_config_name: Name = "new-config-name".parse().unwrap(); + let new_description = String::from("updated description"); + + let update = networking::BgpConfigUpdate { + identity: IdentityMetadataUpdateParams { + name: Some(new_config_name.clone()), + description: Some(new_description.clone()), + }, + bgp_announce_set_id: Some(NameOrId::Name( + new_announce_name.clone(), + )), + max_paths: Some(new_max_paths), + }; + + // Make sure the announces exist + datastore + .bgp_create_announce_set( + &opctx, + &networking::BgpAnnounceSetCreate { + identity: IdentityMetadataCreateParams { + name: announce_name.clone(), + description: String::from("the first announce set"), + }, + announcement: Vec::default(), + }, + ) + .await + .expect("create bgp announce set"); + + let new_announce_id = datastore + .bgp_create_announce_set( + &opctx, + &networking::BgpAnnounceSetCreate { + identity: IdentityMetadataCreateParams { + name: new_announce_name.clone(), + description: String::from("the second announce set"), + }, + announcement: Vec::default(), + }, + ) + .await + .expect("create bgp announce set") + .0 + .identity + .id; + + // Try to update an inexistent BGP config + let res = datastore + .bgp_config_update( + &opctx, + &networking::BgpConfigSelector { + name_or_id: NameOrId::Name(config_name.clone()), + }, + &update, + ) + .await; + assert!(res.is_err()); + + // Create the BGP config + let config_id = datastore + .bgp_config_create(&opctx, &config) + .await + .expect("create bgp config") + .identity + .id; + + // Update the BGP config + datastore + .bgp_config_update( + &opctx, + &networking::BgpConfigSelector { + name_or_id: NameOrId::Name(config_name.clone()), + }, + &update, + ) + .await + .expect("update bgp config"); + + // Verify the BGP config was updated + let bgp_config = datastore + .bgp_config_get(&opctx, &NameOrId::Id(config_id)) + .await + .expect("get bgp config"); + + assert_eq!( + bgp_config.identity.name.to_string(), + new_config_name.to_string() + ); + assert_eq!(bgp_config.identity.description, new_description); + assert_eq!(bgp_config.bgp_announce_set_id, new_announce_id); + assert_eq!(bgp_config.max_paths.0, new_max_paths.as_u8()); + + db.terminate().await; + logctx.cleanup_successful(); + } + #[tokio::test] async fn test_delete_bgp_config_and_announce_set_by_name() { let logctx = dev::test_setup_log( diff --git a/nexus/external-api/output/nexus_tags.txt b/nexus/external-api/output/nexus_tags.txt index 20f7c265064..0f7057078fc 100644 --- a/nexus/external-api/output/nexus_tags.txt +++ b/nexus/external-api/output/nexus_tags.txt @@ -279,6 +279,7 @@ networking_bgp_announcement_list GET /v1/system/networking/bgp-anno networking_bgp_config_create POST /v1/system/networking/bgp networking_bgp_config_delete DELETE /v1/system/networking/bgp networking_bgp_config_list GET /v1/system/networking/bgp +networking_bgp_config_update PUT /v1/system/networking/bgp networking_bgp_exported GET /v1/system/networking/bgp-exported networking_bgp_imported GET /v1/system/networking/bgp-imported networking_bgp_message_history GET /v1/system/networking/bgp-message-history diff --git a/nexus/external-api/src/lib.rs b/nexus/external-api/src/lib.rs index 2c5f1952ab9..47ac25c15bf 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_10_00, BGP_CONFIGURATION_UPDATE), (2026_06_08_00, INSTANCE_CPU_TYPE_TURIN_V2), (2026_06_05_00, EXTERNAL_JUMBO_FRAMES), (2026_06_04_00, IMAGE_BLOCK_SIZE_TYPE), @@ -5814,6 +5815,24 @@ pub trait NexusExternalApi { sel: Query, ) -> Result; + /// Update BGP configuration + /// + /// Update the mutable fields of an existing BGP configuration. The `asn` + /// field is intentionally not updatable; changing the autonomous system + /// number requires creating a new BGP configuration object, since many + /// things are keyed off the ASN. + #[endpoint { + method = PUT, + path = "/v1/system/networking/bgp", + tags = ["system/networking"], + versions = VERSION_BGP_CONFIGURATION_UPDATE.., + }] + async fn networking_bgp_config_update( + rqctx: RequestContext, + sel: Query, + update: TypedBody, + ) -> Result, HttpError>; + /// Update BGP announce set /// /// If the announce set exists, this endpoint replaces the existing announce diff --git a/nexus/src/app/bgp.rs b/nexus/src/app/bgp.rs index 0ca95b2f294..0ab5a7fc8d1 100644 --- a/nexus/src/app/bgp.rs +++ b/nexus/src/app/bgp.rs @@ -10,6 +10,7 @@ use nexus_types::external_api::networking; use omicron_common::api::external::http_pagination::PaginatedBy; use omicron_common::api::external::{ self, CreateResult, DeleteResult, ListResultVec, LookupResult, NameOrId, + UpdateResult, }; use slog_error_chain::InlineErrorChain; @@ -42,6 +43,21 @@ impl super::Nexus { self.db_datastore.bgp_config_list(opctx, pagparams).await } + pub async fn bgp_config_update( + &self, + opctx: &OpContext, + sel: &networking::BgpConfigSelector, + update: &networking::BgpConfigUpdate, + ) -> UpdateResult { + opctx.authorize(authz::Action::Modify, &authz::FLEET).await?; + let result = + self.db_datastore.bgp_config_update(opctx, sel, update).await?; + // Eagerly propagate changes via background task + self.background_tasks + .activate(&self.background_tasks.task_switch_port_settings_manager); + Ok(result) + } + pub async fn bgp_config_delete( &self, opctx: &OpContext, diff --git a/nexus/src/external_api/http_entrypoints.rs b/nexus/src/external_api/http_entrypoints.rs index e7287a53cb0..776325161f3 100644 --- a/nexus/src/external_api/http_entrypoints.rs +++ b/nexus/src/external_api/http_entrypoints.rs @@ -4532,6 +4532,20 @@ impl NexusExternalApi for NexusExternalApiImpl { .await } + async fn networking_bgp_config_update( + rqctx: RequestContext, + sel: Query, + update: TypedBody, + ) -> Result, HttpError> { + audit_and_time(&rqctx, |opctx, nexus| async move { + let sel = sel.into_inner(); + let update = update.into_inner(); + let result = nexus.bgp_config_update(&opctx, &sel, &update).await?; + Ok(HttpResponseOk::(result.try_into()?)) + }) + .await + } + async fn networking_bgp_announce_set_update( rqctx: RequestContext, config: TypedBody, diff --git a/nexus/tests/integration_tests/endpoints.rs b/nexus/tests/integration_tests/endpoints.rs index e73ac1cb1b4..7524380a505 100644 --- a/nexus/tests/integration_tests/endpoints.rs +++ b/nexus/tests/integration_tests/endpoints.rs @@ -70,6 +70,7 @@ use omicron_common::api::external::VpcFirewallRuleUpdateParams; use omicron_test_utils::certificates::CertificateChain; use semver::Version; use sled_agent_types::early_networking::BfdMode; +use sled_agent_types::early_networking::MaxPathConfig; use sled_agent_types::early_networking::SwitchSlot; use std::collections::BTreeSet; use std::net::IpAddr; @@ -1014,6 +1015,16 @@ pub static DEMO_BGP_CONFIG: LazyLock = shaper: None, max_paths: Default::default(), }); +pub static DEMO_BGP_CONFIG_UPDATE: LazyLock = + LazyLock::new(|| networking::BgpConfigUpdate { + identity: IdentityMetadataUpdateParams { + name: Some("as47".parse().unwrap()), + description: Some("BGP config for AS47".into()), + }, + bgp_announce_set_id: Some(NameOrId::Name("instances".parse().unwrap())), + max_paths: Some(MaxPathConfig::new(1).unwrap()), + }); + pub const DEMO_BGP_ANNOUNCE_SET_URL: &'static str = "/v1/system/networking/bgp-announce-set"; pub static DEMO_BGP_ANNOUNCE: LazyLock = @@ -3439,6 +3450,9 @@ pub static VERIFY_ENDPOINTS: LazyLock> = LazyLock::new( serde_json::to_value(&*DEMO_BGP_CONFIG).unwrap(), ), AllowedMethod::Get, + AllowedMethod::Put( + serde_json::to_value(&*DEMO_BGP_CONFIG_UPDATE).unwrap(), + ), AllowedMethod::Delete, ], }, diff --git a/nexus/types/versions/src/bgp_configuration_update/mod.rs b/nexus/types/versions/src/bgp_configuration_update/mod.rs new file mode 100644 index 00000000000..c11cbd02dbe --- /dev/null +++ b/nexus/types/versions/src/bgp_configuration_update/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_configuration_update/networking.rs b/nexus/types/versions/src/bgp_configuration_update/networking.rs new file mode 100644 index 00000000000..374f9164a78 --- /dev/null +++ b/nexus/types/versions/src/bgp_configuration_update/networking.rs @@ -0,0 +1,33 @@ +// 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/. + +//! Networking types for the `BGP_CONFIGURATION_UPDATE` version. +//! +//! Changes in this version: +//! +//! * New [`BgpConfigUpdate`] type to allow updating a BGP configuration's +//! `name`, `description`, `max_paths` and `bgp_announce_set_id` fields +//! without deleting and recreating the object. + +use omicron_common::api::external::{IdentityMetadataUpdateParams, NameOrId}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use sled_agent_types_versions::v20::early_networking::MaxPathConfig; + +/// Parameters for updating a BGP configuration. +/// +/// The `asn` field is intentionally not updatable; changing the autonomous +/// system number requires creating a new BGP configuration object, since many +/// things are keyed off the ASN. +#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] +pub struct BgpConfigUpdate { + #[serde(flatten)] + pub identity: IdentityMetadataUpdateParams, + + /// Update the BGP announce set associated with this configuration. + pub bgp_announce_set_id: Option, + + /// Update the maximum number of equal-cost paths. + pub max_paths: Option, +} diff --git a/nexus/types/versions/src/latest.rs b/nexus/types/versions/src/latest.rs index c17e4d5b53e..92a1bac95c0 100644 --- a/nexus/types/versions/src/latest.rs +++ b/nexus/types/versions/src/latest.rs @@ -310,6 +310,8 @@ pub mod networking { pub use crate::v2026_05_07_00::networking::SwitchInterfaceConfig; pub use crate::v2026_05_07_00::networking::SwitchPortSettings; + + pub use crate::v2026_06_10_00::networking::BgpConfigUpdate; } pub mod oxql { diff --git a/nexus/types/versions/src/lib.rs b/nexus/types/versions/src/lib.rs index c0a3b4efe8a..0998a1a4d18 100644 --- a/nexus/types/versions/src/lib.rs +++ b/nexus/types/versions/src/lib.rs @@ -93,3 +93,5 @@ pub mod v2026_06_04_00; pub mod v2026_06_05_00; #[path = "instance_cpu_type_turin_v2/mod.rs"] pub mod v2026_06_08_00; +#[path = "bgp_configuration_update/mod.rs"] +pub mod v2026_06_10_00; diff --git a/openapi/nexus/nexus-2026060800.0.0-f1db6e.json.gitstub b/openapi/nexus/nexus-2026060800.0.0-f1db6e.json.gitstub new file mode 100644 index 00000000000..49ca319148e --- /dev/null +++ b/openapi/nexus/nexus-2026060800.0.0-f1db6e.json.gitstub @@ -0,0 +1 @@ +456b293ebc28e8419ce4829e90318a7ab500754e:openapi/nexus/nexus-2026060800.0.0-f1db6e.json diff --git a/openapi/nexus/nexus-2026060800.0.0-f1db6e.json b/openapi/nexus/nexus-2026061000.0.0-ef9fca.json similarity index 99% rename from openapi/nexus/nexus-2026060800.0.0-f1db6e.json rename to openapi/nexus/nexus-2026061000.0.0-ef9fca.json index c7680d95ce0..843c1c70c03 100644 --- a/openapi/nexus/nexus-2026060800.0.0-f1db6e.json +++ b/openapi/nexus/nexus-2026061000.0.0-ef9fca.json @@ -7,7 +7,7 @@ "url": "https://oxide.computer", "email": "api@oxide.computer" }, - "version": "2026060800.0.0" + "version": "2026061000.0.0" }, "paths": { "/device/auth": { @@ -10675,6 +10675,53 @@ "required": [] } }, + "put": { + "tags": [ + "system/networking" + ], + "summary": "Update BGP configuration", + "description": "Update the mutable fields of an existing BGP configuration. The `asn` field is intentionally not updatable; changing the autonomous system number requires creating a new BGP configuration object, since many things are keyed off the ASN.", + "operationId": "networking_bgp_config_update", + "parameters": [ + { + "in": "query", + "name": "name_or_id", + "description": "A name or id to use when selecting BGP config.", + "required": true, + "schema": { + "$ref": "#/components/schemas/NameOrId" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BgpConfigUpdate" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BgpConfig" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, "post": { "tags": [ "system/networking" @@ -17533,6 +17580,42 @@ "items" ] }, + "BgpConfigUpdate": { + "description": "Parameters for updating a BGP configuration.\n\nThe `asn` field is intentionally not updatable; changing the autonomous system number requires creating a new BGP configuration object, since many things are keyed off the ASN.", + "type": "object", + "properties": { + "bgp_announce_set_id": { + "nullable": true, + "description": "Update the BGP announce set associated with this configuration.", + "allOf": [ + { + "$ref": "#/components/schemas/NameOrId" + } + ] + }, + "description": { + "nullable": true, + "type": "string" + }, + "max_paths": { + "nullable": true, + "description": "Update the maximum number of equal-cost paths.", + "allOf": [ + { + "$ref": "#/components/schemas/MaxPathConfig" + } + ] + }, + "name": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/Name" + } + ] + } + } + }, "BgpExported": { "description": "Route exported to a peer.", "type": "object", diff --git a/openapi/nexus/nexus-latest.json b/openapi/nexus/nexus-latest.json index 3fa01e66b0b..166b3c9c097 120000 --- a/openapi/nexus/nexus-latest.json +++ b/openapi/nexus/nexus-latest.json @@ -1 +1 @@ -nexus-2026060800.0.0-f1db6e.json \ No newline at end of file +nexus-2026061000.0.0-ef9fca.json \ No newline at end of file