diff --git a/nexus/db-model/src/external_ip.rs b/nexus/db-model/src/external_ip.rs index b30f91c7c09..0f484f76106 100644 --- a/nexus/db-model/src/external_ip.rs +++ b/nexus/db-model/src/external_ip.rs @@ -16,6 +16,7 @@ use db_macros::Resource; use diesel::Queryable; use diesel::Selectable; use ipnetwork::IpNetwork; +use nexus_types::external_api::params; use nexus_types::external_api::shared; use nexus_types::external_api::views; use omicron_common::address::NUM_SOURCE_NAT_PORTS; @@ -538,6 +539,24 @@ impl From for views::FloatingIp { } } +#[derive(AsChangeset)] +#[diesel(table_name = external_ip)] +pub struct FloatingIpUpdate { + pub name: Option, + pub description: Option, + pub time_modified: DateTime, +} + +impl From for FloatingIpUpdate { + fn from(params: params::FloatingIpUpdate) -> Self { + Self { + name: params.identity.name.map(Name), + description: params.identity.description, + time_modified: Utc::now(), + } + } +} + impl TryFrom for InstanceExternalIpBody { type Error = Error; diff --git a/nexus/db-queries/src/db/datastore/external_ip.rs b/nexus/db-queries/src/db/datastore/external_ip.rs index d15e1b7ca86..09df1742140 100644 --- a/nexus/db-queries/src/db/datastore/external_ip.rs +++ b/nexus/db-queries/src/db/datastore/external_ip.rs @@ -35,6 +35,7 @@ use crate::db::update_and_check::UpdateStatus; use async_bb8_diesel::AsyncRunQueryDsl; use chrono::Utc; use diesel::prelude::*; +use nexus_db_model::FloatingIpUpdate; use nexus_db_model::Instance; use nexus_db_model::IpAttachState; use nexus_types::external_api::params; @@ -823,6 +824,32 @@ impl DataStore { .map_err(|e| public_error_from_diesel(e, ErrorHandler::Server)) } + /// Update a Floating IP + pub async fn floating_ip_update( + &self, + opctx: &OpContext, + authz_fip: &authz::FloatingIp, + update: FloatingIpUpdate, + ) -> UpdateResult { + use db::schema::external_ip::dsl; + + opctx.authorize(authz::Action::Modify, authz_fip).await?; + + diesel::update(dsl::external_ip) + .filter(dsl::id.eq(authz_fip.id())) + .filter(dsl::time_deleted.is_null()) + .set(update) + .returning(ExternalIp::as_returning()) + .get_result_async(&*self.pool_connection_authorized(opctx).await?) + .await + .map_err(|e| { + public_error_from_diesel( + e, + ErrorHandler::NotFoundByResource(authz_fip), + ) + }) + } + /// Delete a Floating IP, verifying first that it is not in use. pub async fn floating_ip_delete( &self, diff --git a/nexus/src/app/external_ip.rs b/nexus/src/app/external_ip.rs index 22907d4ea90..14c0c20ce6a 100644 --- a/nexus/src/app/external_ip.rs +++ b/nexus/src/app/external_ip.rs @@ -127,6 +127,22 @@ impl super::Nexus { .unwrap()) } + pub(crate) async fn floating_ip_update( + &self, + opctx: &OpContext, + ip_lookup: lookup::FloatingIp<'_>, + params: params::FloatingIpUpdate, + ) -> UpdateResult { + let (.., authz_fip) = + ip_lookup.lookup_for(authz::Action::Modify).await?; + Ok(self + .db_datastore + .floating_ip_update(opctx, &authz_fip, params.clone().into()) + .await? + .try_into() + .unwrap()) + } + pub(crate) async fn floating_ip_delete( &self, opctx: &OpContext, diff --git a/nexus/src/external_api/http_entrypoints.rs b/nexus/src/external_api/http_entrypoints.rs index b007cc6217a..405aacbead8 100644 --- a/nexus/src/external_api/http_entrypoints.rs +++ b/nexus/src/external_api/http_entrypoints.rs @@ -7,10 +7,10 @@ use super::{ console_api, device_auth, params, views::{ - self, Certificate, Group, IdentityProvider, Image, IpPool, IpPoolRange, - PhysicalDisk, Project, Rack, Role, Silo, SiloQuotas, SiloUtilization, - Sled, Snapshot, SshKey, User, UserBuiltin, Utilization, Vpc, VpcRouter, - VpcSubnet, + self, Certificate, FloatingIp, Group, IdentityProvider, Image, IpPool, + IpPoolRange, PhysicalDisk, Project, Rack, Role, Silo, SiloQuotas, + SiloUtilization, Sled, Snapshot, SshKey, User, UserBuiltin, + Utilization, Vpc, VpcRouter, VpcSubnet, }, }; use crate::external_api::shared; @@ -141,6 +141,7 @@ pub(crate) fn external_api() -> NexusApiDescription { api.register(floating_ip_list)?; api.register(floating_ip_create)?; api.register(floating_ip_view)?; + api.register(floating_ip_update)?; api.register(floating_ip_delete)?; api.register(floating_ip_attach)?; api.register(floating_ip_detach)?; @@ -1921,6 +1922,43 @@ async fn floating_ip_create( apictx.external_latencies.instrument_dropshot_handler(&rqctx, handler).await } +/// Update floating IP +#[endpoint { + method = PUT, + path = "/v1/floating-ips/{floating_ip}", + tags = ["floating-ips"], +}] +async fn floating_ip_update( + rqctx: RequestContext>, + path_params: Path, + query_params: Query, + updated_floating_ip: TypedBody, +) -> Result, HttpError> { + let apictx = rqctx.context(); + let handler = async { + let nexus = &apictx.nexus; + let path = path_params.into_inner(); + let query = query_params.into_inner(); + let updated_floating_ip_params = updated_floating_ip.into_inner(); + let opctx = crate::context::op_context_for_external_api(&rqctx).await?; + let floating_ip_selector = params::FloatingIpSelector { + project: query.project, + floating_ip: path.floating_ip, + }; + let floating_ip_lookup = + nexus.floating_ip_lookup(&opctx, floating_ip_selector)?; + let floating_ip = nexus + .floating_ip_update( + &opctx, + floating_ip_lookup, + updated_floating_ip_params, + ) + .await?; + Ok(HttpResponseOk(floating_ip)) + }; + apictx.external_latencies.instrument_dropshot_handler(&rqctx, handler).await +} + /// Delete floating IP #[endpoint { method = DELETE, diff --git a/nexus/tests/integration_tests/endpoints.rs b/nexus/tests/integration_tests/endpoints.rs index c5c69df232f..f18b2d961d2 100644 --- a/nexus/tests/integration_tests/endpoints.rs +++ b/nexus/tests/integration_tests/endpoints.rs @@ -772,6 +772,14 @@ pub static DEMO_FLOAT_IP_CREATE: Lazy = pool: None, }); +pub static DEMO_FLOAT_IP_UPDATE: Lazy = + Lazy::new(|| params::FloatingIpUpdate { + identity: IdentityMetadataUpdateParams { + name: None, + description: Some(String::from("an updated Floating IP")), + }, + }); + pub static DEMO_FLOAT_IP_ATTACH: Lazy = Lazy::new(|| params::FloatingIpAttach { kind: params::FloatingIpParentKind::Instance, @@ -2277,6 +2285,9 @@ pub static VERIFY_ENDPOINTS: Lazy> = Lazy::new(|| { unprivileged_access: UnprivilegedAccess::None, allowed_methods: vec![ AllowedMethod::Get, + AllowedMethod::Put( + serde_json::to_value(&*DEMO_FLOAT_IP_UPDATE).unwrap() + ), AllowedMethod::Delete, ], }, diff --git a/nexus/tests/integration_tests/external_ips.rs b/nexus/tests/integration_tests/external_ips.rs index 39c27174a14..b0950f52f6d 100644 --- a/nexus/tests/integration_tests/external_ips.rs +++ b/nexus/tests/integration_tests/external_ips.rs @@ -28,6 +28,8 @@ use nexus_test_utils::resource_helpers::object_create; use nexus_test_utils::resource_helpers::object_create_error; use nexus_test_utils::resource_helpers::object_delete; use nexus_test_utils::resource_helpers::object_delete_error; +use nexus_test_utils::resource_helpers::object_get; +use nexus_test_utils::resource_helpers::object_put; use nexus_test_utils_macros::nexus_test; use nexus_types::external_api::params; use nexus_types::external_api::shared; @@ -37,6 +39,7 @@ use nexus_types::identity::Resource; use omicron_common::address::IpRange; use omicron_common::address::Ipv4Range; use omicron_common::api::external::IdentityMetadataCreateParams; +use omicron_common::api::external::IdentityMetadataUpdateParams; use omicron_common::api::external::Instance; use omicron_common::api::external::Name; use omicron_common::api::external::NameOrId; @@ -381,6 +384,58 @@ async fn test_floating_ip_create_name_in_use( ); } +#[nexus_test] +async fn test_floating_ip_update(cptestctx: &ControlPlaneTestContext) { + let client = &cptestctx.external_client; + + create_default_ip_pool(&client).await; + let project = create_project(client, PROJECT_NAME).await; + + // Create the Floating IP + let fip = create_floating_ip( + client, + FIP_NAMES[0], + project.identity.name.as_str(), + None, + None, + ) + .await; + + let floating_ip_url = get_floating_ip_by_id_url(&fip.identity.id); + + // Verify that the Floating IP was created correctly + let fetched_floating_ip: FloatingIp = + object_get(client, &floating_ip_url).await; + + assert_eq!(fip.identity, fetched_floating_ip.identity); + + // Set up the updated values + let new_fip_name: &str = "updated"; + let new_fip_desc: &str = "updated description"; + let updates: params::FloatingIpUpdate = params::FloatingIpUpdate { + identity: IdentityMetadataUpdateParams { + name: Some(String::from(new_fip_name).parse().unwrap()), + description: Some(String::from(new_fip_desc).parse().unwrap()), + }, + }; + + // Update the Floating IP + let new_fip: FloatingIp = + object_put(client, &floating_ip_url, &updates).await; + + assert_eq!(new_fip.identity.name.as_str(), new_fip_name); + assert_eq!(new_fip.identity.description, new_fip_desc); + assert_eq!(new_fip.project_id, project.identity.id); + assert_eq!(new_fip.identity.time_created, fip.identity.time_created); + assert_ne!(new_fip.identity.time_modified, fip.identity.time_modified); + + // Verify that the Floating IP was updated correctly + let fetched_modified_floating_ip: FloatingIp = + object_get(client, &floating_ip_url).await; + + assert_eq!(new_fip.identity, fetched_modified_floating_ip.identity); +} + #[nexus_test] async fn test_floating_ip_delete(cptestctx: &ControlPlaneTestContext) { let client = &cptestctx.external_client; diff --git a/nexus/tests/output/nexus_tags.txt b/nexus/tests/output/nexus_tags.txt index adb36a24af9..ecffadcb4d6 100644 --- a/nexus/tests/output/nexus_tags.txt +++ b/nexus/tests/output/nexus_tags.txt @@ -17,6 +17,7 @@ floating_ip_create POST /v1/floating-ips floating_ip_delete DELETE /v1/floating-ips/{floating_ip} floating_ip_detach POST /v1/floating-ips/{floating_ip}/detach floating_ip_list GET /v1/floating-ips +floating_ip_update PUT /v1/floating-ips/{floating_ip} floating_ip_view GET /v1/floating-ips/{floating_ip} API operations found with tag "hidden" diff --git a/nexus/types/src/external_api/params.rs b/nexus/types/src/external_api/params.rs index 46260d20d07..31cb1d3e5c5 100644 --- a/nexus/types/src/external_api/params.rs +++ b/nexus/types/src/external_api/params.rs @@ -890,6 +890,12 @@ pub struct FloatingIpCreate { pub pool: Option, } +#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] +pub struct FloatingIpUpdate { + #[serde(flatten)] + pub identity: IdentityMetadataUpdateParams, +} + /// The type of resource that a floating IP is attached to #[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] #[serde(rename_all = "snake_case")] diff --git a/openapi/nexus.json b/openapi/nexus.json index b0aa84d67a1..3d31331a903 100644 --- a/openapi/nexus.json +++ b/openapi/nexus.json @@ -964,6 +964,60 @@ } } }, + "put": { + "tags": [ + "floating-ips" + ], + "summary": "Update floating IP", + "operationId": "floating_ip_update", + "parameters": [ + { + "in": "path", + "name": "floating_ip", + "description": "Name or ID of the floating IP", + "required": true, + "schema": { + "$ref": "#/components/schemas/NameOrId" + } + }, + { + "in": "query", + "name": "project", + "description": "Name or ID of the project", + "schema": { + "$ref": "#/components/schemas/NameOrId" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FloatingIpUpdate" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FloatingIp" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, "delete": { "tags": [ "floating-ips" @@ -11919,6 +11973,24 @@ "items" ] }, + "FloatingIpUpdate": { + "description": "Updateable identity-related parameters", + "type": "object", + "properties": { + "description": { + "nullable": true, + "type": "string" + }, + "name": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/Name" + } + ] + } + } + }, "Group": { "description": "View of a Group", "type": "object",