diff --git a/common/src/api/external/mod.rs b/common/src/api/external/mod.rs index 80e284a0199..91170f2621b 100644 --- a/common/src/api/external/mod.rs +++ b/common/src/api/external/mod.rs @@ -953,6 +953,7 @@ pub enum ResourceType { DeviceAccessToken, DeviceAuthRequest, Disk, + ExternalSubnet, Fleet, FloatingIp, IdentityProvider, diff --git a/nexus/external-api/output/nexus_tags.txt b/nexus/external-api/output/nexus_tags.txt index 793f9ac39e1..abddf76b724 100644 --- a/nexus/external-api/output/nexus_tags.txt +++ b/nexus/external-api/output/nexus_tags.txt @@ -71,6 +71,16 @@ support_bundle_update PUT /experimental/v1/system/suppor support_bundle_view GET /experimental/v1/system/support-bundles/{bundle_id} timeseries_query POST /v1/timeseries/query +API operations found with tag "external-subnets" +OPERATION ID METHOD URL PATH +external_subnet_attach POST /v1/external-subnets/{external_subnet}/attach +external_subnet_create POST /v1/external-subnets +external_subnet_delete DELETE /v1/external-subnets/{external_subnet} +external_subnet_detach POST /v1/external-subnets/{external_subnet}/detach +external_subnet_list GET /v1/external-subnets +external_subnet_update PUT /v1/external-subnets/{external_subnet} +external_subnet_view GET /v1/external-subnets/{external_subnet} + API operations found with tag "floating-ips" OPERATION ID METHOD URL PATH floating_ip_attach POST /v1/floating-ips/{floating_ip}/attach diff --git a/nexus/external-api/src/lib.rs b/nexus/external-api/src/lib.rs index 1b8bb0d0946..198b5e84a90 100644 --- a/nexus/external-api/src/lib.rs +++ b/nexus/external-api/src/lib.rs @@ -188,6 +188,12 @@ const PUT_UPDATE_REPOSITORY_MAX_BYTES: usize = 4 * GIB; url = "http://docs.oxide.computer/api/floating-ips" } }, + "external-subnets" = { + description = "External subnets that can be attached to instances.", + external_docs = { + url = "http://docs.oxide.computer/api/external-subnets" + } + }, "images" = { description = "Images are read-only virtual disks that may be used to boot virtual machines.", external_docs = { @@ -1452,6 +1458,107 @@ pub trait NexusExternalApi { path_params: Path, ) -> Result, HttpError>; + // External Subnets + + /// List external subnets in a project + #[endpoint { + method = GET, + path = "/v1/external-subnets", + tags = ["external-subnets"], + versions = VERSION_EXTERNAL_SUBNET_ATTACHMENT.., + }] + async fn external_subnet_list( + rqctx: RequestContext, + query_params: Query>, + ) -> Result>, HttpError>; + + /// Create an external subnet + #[endpoint { + method = POST, + path = "/v1/external-subnets", + tags = ["external-subnets"], + versions = VERSION_EXTERNAL_SUBNET_ATTACHMENT.., + }] + async fn external_subnet_create( + rqctx: RequestContext, + query_params: Query, + subnet_params: TypedBody, + ) -> Result, HttpError>; + + /// Fetch an external subnet + #[endpoint { + method = GET, + path = "/v1/external-subnets/{external_subnet}", + tags = ["external-subnets"], + versions = VERSION_EXTERNAL_SUBNET_ATTACHMENT.., + }] + async fn external_subnet_view( + rqctx: RequestContext, + path_params: Path, + query_params: Query, + ) -> Result, HttpError>; + + /// Update an external subnet + #[endpoint { + method = PUT, + path = "/v1/external-subnets/{external_subnet}", + tags = ["external-subnets"], + versions = VERSION_EXTERNAL_SUBNET_ATTACHMENT.., + }] + async fn external_subnet_update( + rqctx: RequestContext, + path_params: Path, + query_params: Query, + subnet_params: TypedBody, + ) -> Result, HttpError>; + + /// Delete an external subnet + #[endpoint { + method = DELETE, + path = "/v1/external-subnets/{external_subnet}", + tags = ["external-subnets"], + versions = VERSION_EXTERNAL_SUBNET_ATTACHMENT.., + }] + async fn external_subnet_delete( + rqctx: RequestContext, + path_params: Path, + query_params: Query, + ) -> Result; + + /// Attach an external subnet to an instance + /// + /// Begins an asynchronous attach operation. Returns the subnet with + /// `instance_id` set to the target instance. The attach completes + /// asynchronously; poll the subnet to check completion. + #[endpoint { + method = POST, + path = "/v1/external-subnets/{external_subnet}/attach", + tags = ["external-subnets"], + versions = VERSION_EXTERNAL_SUBNET_ATTACHMENT.., + }] + async fn external_subnet_attach( + rqctx: RequestContext, + path_params: Path, + query_params: Query, + attach_params: TypedBody, + ) -> Result, HttpError>; + + /// Detach an external subnet from an instance + /// + /// Begins an asynchronous detach operation. Returns the subnet with + /// `instance_id` cleared. The detach completes asynchronously. + #[endpoint { + method = POST, + path = "/v1/external-subnets/{external_subnet}/detach", + tags = ["external-subnets"], + versions = VERSION_EXTERNAL_SUBNET_ATTACHMENT.., + }] + async fn external_subnet_detach( + rqctx: RequestContext, + path_params: Path, + query_params: Query, + ) -> Result, HttpError>; + // Floating IP Addresses /// List floating IPs diff --git a/nexus/src/app/external_subnet.rs b/nexus/src/app/external_subnet.rs new file mode 100644 index 00000000000..32fe67aef4e --- /dev/null +++ b/nexus/src/app/external_subnet.rs @@ -0,0 +1,153 @@ +// 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/. + +//! External Subnets, project-scoped resources allocated from subnet pools +//! +//! External subnets are similar to floating IPs but allocate entire subnets +//! rather than individual IP addresses. They can be attached to instances +//! to provide external connectivity. +//! +//! TODO(#9453): This module contains stub implementations that return +//! "not implemented" errors. Full implementation requires: +//! - Database schema and models (see nexus/db-model/) +//! - Datastore methods (see nexus/db-queries/src/db/datastore/) +//! - Authorization resources (see nexus/auth/src/authz/) +//! - Sagas for attach/detach operations +//! - Replacing these stubs with real implementations + +use crate::app::Unimpl; +use nexus_db_lookup::lookup; +use nexus_db_queries::authz; +use nexus_db_queries::context::OpContext; +use nexus_types::external_api::{params, views}; +use omicron_common::api::external::DeleteResult; +use omicron_common::api::external::Error; +use omicron_common::api::external::ListResultVec; +use omicron_common::api::external::LookupResult; +use omicron_common::api::external::LookupType; +use omicron_common::api::external::NameOrId; +use omicron_common::api::external::ResourceType; +use omicron_common::api::external::UpdateResult; +use omicron_common::api::external::http_pagination::PaginatedBy; + +impl super::Nexus { + /// Look up an external subnet by selector. + /// + /// TODO: This is a stub that always returns a not-found error. The real + /// implementation should match other selector lookup functions (see + /// `disk_lookup`, `vpc_lookup`, `floating_ip_lookup`, etc.): + /// + /// - Add lifetime parameter `'a` to the function + /// - Add `opctx: &'a OpContext` parameter + /// - Return `LookupResult>` + /// - Return `Ok(lookup_handle)` on success instead of `Err` + fn external_subnet_lookup( + &self, + selector: params::ExternalSubnetSelector, + ) -> LookupResult<()> { + let lookup_type = match selector { + params::ExternalSubnetSelector { + external_subnet: NameOrId::Id(id), + project: None, + } => LookupType::ById(id), + params::ExternalSubnetSelector { + external_subnet: NameOrId::Name(name), + project: Some(_), + } => LookupType::ByName(name.to_string()), + params::ExternalSubnetSelector { + external_subnet: NameOrId::Id(_), + .. + } => { + return Err(Error::invalid_request( + "when providing external subnet as an ID \ + project should not be specified", + )); + } + _ => { + return Err(Error::invalid_request( + "external subnet should either be a UUID or \ + project should be specified", + )); + } + }; + Err(lookup_type.into_not_found(ResourceType::ExternalSubnet)) + } + + pub(crate) async fn external_subnet_list( + &self, + opctx: &OpContext, + project_lookup: &lookup::Project<'_>, + _pagparams: &PaginatedBy<'_>, + ) -> ListResultVec { + let _ = project_lookup.lookup_for(authz::Action::ListChildren).await?; + Err(self.unimplemented_todo(opctx, Unimpl::Public).await) + } + + pub(crate) async fn external_subnet_create( + &self, + opctx: &OpContext, + project_lookup: &lookup::Project<'_>, + _params: params::ExternalSubnetCreate, + ) -> Result { + let _ = project_lookup.lookup_for(authz::Action::CreateChild).await?; + Err(self.unimplemented_todo(opctx, Unimpl::Public).await) + } + + pub(crate) async fn external_subnet_view( + &self, + opctx: &OpContext, + selector: params::ExternalSubnetSelector, + ) -> LookupResult { + let not_found = self.external_subnet_lookup(selector).unwrap_err(); + Err(self + .unimplemented_todo(opctx, Unimpl::ProtectedLookup(not_found)) + .await) + } + + pub(crate) async fn external_subnet_update( + &self, + opctx: &OpContext, + selector: params::ExternalSubnetSelector, + _params: params::ExternalSubnetUpdate, + ) -> UpdateResult { + let not_found = self.external_subnet_lookup(selector).unwrap_err(); + Err(self + .unimplemented_todo(opctx, Unimpl::ProtectedLookup(not_found)) + .await) + } + + pub(crate) async fn external_subnet_delete( + &self, + opctx: &OpContext, + selector: params::ExternalSubnetSelector, + ) -> DeleteResult { + let not_found = self.external_subnet_lookup(selector).unwrap_err(); + Err(self + .unimplemented_todo(opctx, Unimpl::ProtectedLookup(not_found)) + .await) + } + + pub(crate) async fn external_subnet_attach( + &self, + opctx: &OpContext, + selector: params::ExternalSubnetSelector, + _attach: params::ExternalSubnetAttach, + ) -> UpdateResult { + let not_found = self.external_subnet_lookup(selector).unwrap_err(); + Err(self + .unimplemented_todo(opctx, Unimpl::ProtectedLookup(not_found)) + .await) + } + + pub(crate) async fn external_subnet_detach( + &self, + opctx: &OpContext, + selector: params::ExternalSubnetSelector, + ) -> UpdateResult { + let not_found = self.external_subnet_lookup(selector).unwrap_err(); + Err(self + .unimplemented_todo(opctx, Unimpl::ProtectedLookup(not_found)) + .await) + } +} diff --git a/nexus/src/app/mod.rs b/nexus/src/app/mod.rs index 1844dcaa8b6..27811d1a6e3 100644 --- a/nexus/src/app/mod.rs +++ b/nexus/src/app/mod.rs @@ -71,6 +71,7 @@ mod disk; mod external_dns; pub(crate) mod external_endpoints; mod external_ip; +mod external_subnet; mod iam; mod image; mod instance; diff --git a/nexus/src/external_api/http_entrypoints.rs b/nexus/src/external_api/http_entrypoints.rs index efe8e02088f..a4c54c02360 100644 --- a/nexus/src/external_api/http_entrypoints.rs +++ b/nexus/src/external_api/http_entrypoints.rs @@ -1920,6 +1920,201 @@ impl NexusExternalApi for NexusExternalApiImpl { .await } + // External Subnets + + async fn external_subnet_list( + rqctx: RequestContext, + query_params: Query>, + ) -> 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?; + let query = query_params.into_inner(); + let pag_params = data_page_params_for(&rqctx, &query)?; + let scan_params = ScanByNameOrId::from_query(&query)?; + let paginated_by = name_or_id_pagination(&pag_params, scan_params)?; + let project_lookup = + nexus.project_lookup(&opctx, scan_params.selector.clone())?; + let subnets = nexus + .external_subnet_list(&opctx, &project_lookup, &paginated_by) + .await?; + Ok(HttpResponseOk(ScanByNameOrId::results_page( + &query, + subnets, + &marker_for_name_or_id, + )?)) + }; + apictx + .context + .external_latencies + .instrument_dropshot_handler(&rqctx, handler) + .await + } + + async fn external_subnet_create( + rqctx: RequestContext, + query_params: Query, + subnet_params: TypedBody, + ) -> 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?; + let query = query_params.into_inner(); + let params = subnet_params.into_inner(); + let project_lookup = nexus.project_lookup(&opctx, query)?; + let subnet = nexus + .external_subnet_create(&opctx, &project_lookup, params) + .await?; + Ok(HttpResponseCreated(subnet)) + }; + apictx + .context + .external_latencies + .instrument_dropshot_handler(&rqctx, handler) + .await + } + + async fn external_subnet_view( + rqctx: RequestContext, + path_params: Path, + query_params: Query, + ) -> 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?; + let path = path_params.into_inner(); + let query = query_params.into_inner(); + let selector = params::ExternalSubnetSelector { + external_subnet: path.external_subnet, + project: query.project, + }; + let subnet = nexus.external_subnet_view(&opctx, selector).await?; + Ok(HttpResponseOk(subnet)) + }; + apictx + .context + .external_latencies + .instrument_dropshot_handler(&rqctx, handler) + .await + } + + async fn external_subnet_update( + rqctx: RequestContext, + path_params: Path, + query_params: Query, + subnet_params: TypedBody, + ) -> 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?; + let path = path_params.into_inner(); + let query = query_params.into_inner(); + let params = subnet_params.into_inner(); + let selector = params::ExternalSubnetSelector { + external_subnet: path.external_subnet, + project: query.project, + }; + let subnet = + nexus.external_subnet_update(&opctx, selector, params).await?; + Ok(HttpResponseOk(subnet)) + }; + apictx + .context + .external_latencies + .instrument_dropshot_handler(&rqctx, handler) + .await + } + + async fn external_subnet_delete( + rqctx: RequestContext, + path_params: Path, + query_params: Query, + ) -> Result { + let apictx = rqctx.context(); + let handler = async { + let nexus = &apictx.context.nexus; + let opctx = + crate::context::op_context_for_external_api(&rqctx).await?; + let path = path_params.into_inner(); + let query = query_params.into_inner(); + let selector = params::ExternalSubnetSelector { + external_subnet: path.external_subnet, + project: query.project, + }; + nexus.external_subnet_delete(&opctx, selector).await?; + Ok(HttpResponseDeleted()) + }; + apictx + .context + .external_latencies + .instrument_dropshot_handler(&rqctx, handler) + .await + } + + async fn external_subnet_attach( + rqctx: RequestContext, + path_params: Path, + query_params: Query, + attach_params: TypedBody, + ) -> 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?; + let path = path_params.into_inner(); + let query = query_params.into_inner(); + let attach = attach_params.into_inner(); + let selector = params::ExternalSubnetSelector { + external_subnet: path.external_subnet, + project: query.project, + }; + let subnet = + nexus.external_subnet_attach(&opctx, selector, attach).await?; + Ok(HttpResponseAccepted(subnet)) + }; + apictx + .context + .external_latencies + .instrument_dropshot_handler(&rqctx, handler) + .await + } + + async fn external_subnet_detach( + rqctx: RequestContext, + path_params: Path, + query_params: Query, + ) -> 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?; + let path = path_params.into_inner(); + let query = query_params.into_inner(); + let selector = params::ExternalSubnetSelector { + external_subnet: path.external_subnet, + project: query.project, + }; + let subnet = nexus.external_subnet_detach(&opctx, selector).await?; + Ok(HttpResponseAccepted(subnet)) + }; + apictx + .context + .external_latencies + .instrument_dropshot_handler(&rqctx, handler) + .await + } + // Floating IP Addresses async fn floating_ip_list( diff --git a/nexus/tests/integration_tests/endpoints.rs b/nexus/tests/integration_tests/endpoints.rs index 8d18b64e22f..ac72f04c440 100644 --- a/nexus/tests/integration_tests/endpoints.rs +++ b/nexus/tests/integration_tests/endpoints.rs @@ -1142,6 +1142,55 @@ pub static DEMO_SUBNET_POOL_SILO_UPDATE: LazyLock< pub static DEMO_SUBNET_POOL_UTILIZATION_URL: LazyLock = LazyLock::new(|| format!("{}/utilization", *DEMO_SUBNET_POOL_URL)); +// External Subnets (project-scoped) +pub static DEMO_EXTERNAL_SUBNETS_URL: LazyLock = LazyLock::new(|| { + format!("/v1/external-subnets?project={}", *DEMO_PROJECT_NAME) +}); +pub static DEMO_EXTERNAL_SUBNET_NAME: LazyLock = + LazyLock::new(|| "demo-external-subnet".parse().unwrap()); +pub static DEMO_EXTERNAL_SUBNET_CREATE: LazyLock = + LazyLock::new(|| params::ExternalSubnetCreate { + identity: IdentityMetadataCreateParams { + name: DEMO_EXTERNAL_SUBNET_NAME.clone(), + description: String::from("an external subnet"), + }, + allocator: params::ExternalSubnetAllocator::Auto { + prefix_len: 24, + pool_selector: params::PoolSelector::default(), + }, + }); +pub static DEMO_EXTERNAL_SUBNET_URL: LazyLock = LazyLock::new(|| { + format!( + "/v1/external-subnets/{}?project={}", + *DEMO_EXTERNAL_SUBNET_NAME, *DEMO_PROJECT_NAME + ) +}); +pub static DEMO_EXTERNAL_SUBNET_UPDATE: LazyLock = + LazyLock::new(|| params::ExternalSubnetUpdate { + identity: IdentityMetadataUpdateParams { + name: None, + description: Some(String::from("an updated external subnet")), + }, + }); +pub static DEMO_EXTERNAL_SUBNET_ATTACH_URL: LazyLock = + LazyLock::new(|| { + format!( + "/v1/external-subnets/{}/attach?project={}", + *DEMO_EXTERNAL_SUBNET_NAME, *DEMO_PROJECT_NAME + ) + }); +pub static DEMO_EXTERNAL_SUBNET_ATTACH: LazyLock = + LazyLock::new(|| params::ExternalSubnetAttach { + instance: DEMO_INSTANCE_NAME.clone().into(), + }); +pub static DEMO_EXTERNAL_SUBNET_DETACH_URL: LazyLock = + LazyLock::new(|| { + format!( + "/v1/external-subnets/{}/detach?project={}", + *DEMO_EXTERNAL_SUBNET_NAME, *DEMO_PROJECT_NAME + ) + }); + // Snapshots pub static DEMO_SNAPSHOT_NAME: LazyLock = LazyLock::new(|| "demo-snapshot".parse().unwrap()); @@ -1820,6 +1869,50 @@ pub static VERIFY_ENDPOINTS: LazyLock> = LazyLock::new( unprivileged_access: UnprivilegedAccess::None, allowed_methods: vec![AllowedMethod::GetUnimplemented], }, + /* External Subnets (project-scoped) */ + // TODO(#9453): These are stub endpoints. Use GetUnimplemented + // since privileged GET requests will return 500 not 200. + VerifyEndpoint { + url: &DEMO_EXTERNAL_SUBNETS_URL, + visibility: Visibility::Protected, + unprivileged_access: UnprivilegedAccess::None, + allowed_methods: vec![ + AllowedMethod::GetUnimplemented, + AllowedMethod::Post( + serde_json::to_value(&*DEMO_EXTERNAL_SUBNET_CREATE) + .unwrap(), + ), + ], + }, + VerifyEndpoint { + url: &DEMO_EXTERNAL_SUBNET_URL, + visibility: Visibility::Protected, + unprivileged_access: UnprivilegedAccess::None, + allowed_methods: vec![ + AllowedMethod::GetUnimplemented, + AllowedMethod::Put( + serde_json::to_value(&*DEMO_EXTERNAL_SUBNET_UPDATE) + .unwrap(), + ), + AllowedMethod::Delete, + ], + }, + VerifyEndpoint { + url: &DEMO_EXTERNAL_SUBNET_ATTACH_URL, + visibility: Visibility::Protected, + unprivileged_access: UnprivilegedAccess::None, + allowed_methods: vec![AllowedMethod::Post( + serde_json::to_value(&*DEMO_EXTERNAL_SUBNET_ATTACH).unwrap(), + )], + }, + VerifyEndpoint { + url: &DEMO_EXTERNAL_SUBNET_DETACH_URL, + visibility: Visibility::Protected, + unprivileged_access: UnprivilegedAccess::None, + allowed_methods: vec![AllowedMethod::Post( + serde_json::to_value(&()).unwrap(), + )], + }, /* Silos */ VerifyEndpoint { url: "/v1/system/silos", diff --git a/nexus/tests/integration_tests/external_subnets.rs b/nexus/tests/integration_tests/external_subnets.rs new file mode 100644 index 00000000000..98dcccd2ad0 --- /dev/null +++ b/nexus/tests/integration_tests/external_subnets.rs @@ -0,0 +1,217 @@ +// 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/. + +//! Integration tests for External Subnets API stubs +//! +//! These tests verify that the stub endpoints return appropriate +//! "not implemented" errors. Once the full implementation is complete, +//! these tests should be replaced with proper CRUD tests. +//! +//! TODO(#9453): Replace stub tests with full implementation tests. + +use http::Method; +use http::StatusCode; +use nexus_test_utils::http_testing::AuthnMode; +use nexus_test_utils::http_testing::NexusRequest; +use nexus_test_utils::resource_helpers::create_project; +use nexus_test_utils_macros::nexus_test; +use nexus_types::external_api::params; +use omicron_common::api::external::IdentityMetadataCreateParams; + +type ControlPlaneTestContext = + nexus_test_utils::ControlPlaneTestContext; + +const PROJECT_NAME: &str = "test-project"; + +// Note: These tests verify that stub endpoints return 500 Internal Server Error. +// The detailed "endpoint is not implemented" message is intentionally not exposed +// to clients for security reasons (internal messages are logged server-side only). + +fn external_subnets_url(project: &str) -> String { + format!("/v1/external-subnets?project={}", project) +} + +fn external_subnet_url(name: &str, project: &str) -> String { + format!("/v1/external-subnets/{}?project={}", name, project) +} + +fn external_subnet_attach_url(name: &str, project: &str) -> String { + format!("/v1/external-subnets/{}/attach?project={}", name, project) +} + +fn external_subnet_detach_url(name: &str, project: &str) -> String { + format!("/v1/external-subnets/{}/detach?project={}", name, project) +} + +#[nexus_test] +async fn test_external_subnet_list_unimplemented( + cptestctx: &ControlPlaneTestContext, +) { + let client = &cptestctx.external_client; + + // Create a project first (external subnets are project-scoped) + let _ = create_project(client, PROJECT_NAME).await; + + NexusRequest::expect_failure( + client, + StatusCode::INTERNAL_SERVER_ERROR, + Method::GET, + &external_subnets_url(PROJECT_NAME), + ) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .expect("failed to make request"); +} + +#[nexus_test] +async fn test_external_subnet_create_unimplemented( + cptestctx: &ControlPlaneTestContext, +) { + let client = &cptestctx.external_client; + + // Create a project first + let _ = create_project(client, PROJECT_NAME).await; + + let create_params = params::ExternalSubnetCreate { + identity: IdentityMetadataCreateParams { + name: "test-subnet".parse().unwrap(), + description: String::from("A test external subnet"), + }, + allocator: params::ExternalSubnetAllocator::Auto { + prefix_len: 24, + pool_selector: params::PoolSelector::default(), + }, + }; + + NexusRequest::expect_failure_with_body( + client, + StatusCode::INTERNAL_SERVER_ERROR, + Method::POST, + &external_subnets_url(PROJECT_NAME), + &create_params, + ) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .expect("failed to make request"); +} + +#[nexus_test] +async fn test_external_subnet_view_unimplemented( + cptestctx: &ControlPlaneTestContext, +) { + let client = &cptestctx.external_client; + + // Create a project first + let _ = create_project(client, PROJECT_NAME).await; + + NexusRequest::expect_failure( + client, + StatusCode::INTERNAL_SERVER_ERROR, + Method::GET, + &external_subnet_url("test-subnet", PROJECT_NAME), + ) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .expect("failed to make request"); +} + +#[nexus_test] +async fn test_external_subnet_update_unimplemented( + cptestctx: &ControlPlaneTestContext, +) { + let client = &cptestctx.external_client; + + // Create a project first + let _ = create_project(client, PROJECT_NAME).await; + + let update_params = params::ExternalSubnetUpdate { + identity: omicron_common::api::external::IdentityMetadataUpdateParams { + name: None, + description: Some(String::from("Updated description")), + }, + }; + + NexusRequest::expect_failure_with_body( + client, + StatusCode::INTERNAL_SERVER_ERROR, + Method::PUT, + &external_subnet_url("test-subnet", PROJECT_NAME), + &update_params, + ) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .expect("failed to make request"); +} + +#[nexus_test] +async fn test_external_subnet_delete_unimplemented( + cptestctx: &ControlPlaneTestContext, +) { + let client = &cptestctx.external_client; + + // Create a project first + let _ = create_project(client, PROJECT_NAME).await; + + NexusRequest::expect_failure( + client, + StatusCode::INTERNAL_SERVER_ERROR, + Method::DELETE, + &external_subnet_url("test-subnet", PROJECT_NAME), + ) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .expect("failed to make request"); +} + +#[nexus_test] +async fn test_external_subnet_attach_unimplemented( + cptestctx: &ControlPlaneTestContext, +) { + let client = &cptestctx.external_client; + + // Create a project first + let _ = create_project(client, PROJECT_NAME).await; + + let attach_params = params::ExternalSubnetAttach { + instance: "test-instance".parse().unwrap(), + }; + + NexusRequest::expect_failure_with_body( + client, + StatusCode::INTERNAL_SERVER_ERROR, + Method::POST, + &external_subnet_attach_url("test-subnet", PROJECT_NAME), + &attach_params, + ) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .expect("failed to make request"); +} + +#[nexus_test] +async fn test_external_subnet_detach_unimplemented( + cptestctx: &ControlPlaneTestContext, +) { + let client = &cptestctx.external_client; + + // Create a project first + let _ = create_project(client, PROJECT_NAME).await; + + NexusRequest::expect_failure( + client, + StatusCode::INTERNAL_SERVER_ERROR, + Method::POST, + &external_subnet_detach_url("test-subnet", PROJECT_NAME), + ) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .expect("failed to make request"); +} diff --git a/nexus/tests/integration_tests/mod.rs b/nexus/tests/integration_tests/mod.rs index 70d62a8c431..ba0240abc3b 100644 --- a/nexus/tests/integration_tests/mod.rs +++ b/nexus/tests/integration_tests/mod.rs @@ -23,6 +23,7 @@ mod demo_saga; mod device_auth; mod disks; mod external_ips; +mod external_subnets; mod images; mod initialization; mod instances; diff --git a/nexus/types/src/external_api/params.rs b/nexus/types/src/external_api/params.rs index ed071ef5e4d..13feb585346 100644 --- a/nexus/types/src/external_api/params.rs +++ b/nexus/types/src/external_api/params.rs @@ -1570,6 +1570,69 @@ pub struct SubnetPoolSiloUpdate { pub is_default: bool, } +// External Subnets + +path_param!(ExternalSubnetPath, external_subnet, "external subnet"); + +/// Selector for looking up an external subnet +#[derive(Deserialize, JsonSchema, Clone)] +pub struct ExternalSubnetSelector { + /// Name or ID of the project (required if `external_subnet` is a Name) + pub project: Option, + /// Name or ID of the external subnet + pub external_subnet: NameOrId, +} + +/// Specify how to allocate an external subnet. +#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ExternalSubnetAllocator { + /// Reserve a specific subnet. + Explicit { + /// The subnet CIDR to reserve. Must be available in the pool. + subnet: IpNet, + /// The pool containing this subnet. If not specified, the default + /// subnet pool for the subnet's IP version is used. + pool: Option, + }, + /// Automatically allocate a subnet with the specified prefix length. + Auto { + /// The prefix length for the allocated subnet (e.g., 24 for a /24). + prefix_len: u8, + /// Pool selection. + /// + /// If omitted, this field uses the silo's default pool. If the + /// silo has default pools for both IPv4 and IPv6, the request will + /// fail unless `ip_version` is specified in the pool selector. + #[serde(default)] + pool_selector: PoolSelector, + }, +} + +/// Create an external subnet +#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] +pub struct ExternalSubnetCreate { + #[serde(flatten)] + pub identity: IdentityMetadataCreateParams, + + /// Subnet allocation method. + pub allocator: ExternalSubnetAllocator, +} + +/// Update an external subnet +#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] +pub struct ExternalSubnetUpdate { + #[serde(flatten)] + pub identity: IdentityMetadataUpdateParams, +} + +/// Attach an external subnet to an instance +#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] +pub struct ExternalSubnetAttach { + /// Name or ID of the instance to attach to + pub instance: NameOrId, +} + // Floating IPs /// Specify how to allocate a floating IP address. @@ -1714,7 +1777,7 @@ pub struct InstanceDiskAttach { pub name: Name, } -/// Specify which IP pool to allocate from. +/// Specify which IP or external subnet pool to allocate from. #[derive(Clone, Debug, Deserialize, PartialEq, Serialize, JsonSchema)] #[serde(tag = "type", rename_all = "snake_case")] pub enum PoolSelector { diff --git a/nexus/types/src/external_api/views.rs b/nexus/types/src/external_api/views.rs index 060c8ef81da..bfc6fa22bd5 100644 --- a/nexus/types/src/external_api/views.rs +++ b/nexus/types/src/external_api/views.rs @@ -509,6 +509,25 @@ pub struct SubnetPoolUtilization { pub capacity: f64, } +// EXTERNAL SUBNETS + +/// An external subnet allocated from a subnet pool +#[derive(ObjectIdentity, Clone, Debug, Deserialize, Serialize, JsonSchema)] +pub struct ExternalSubnet { + #[serde(flatten)] + pub identity: IdentityMetadata, + /// The allocated subnet CIDR + pub subnet: IpNet, + /// The project this subnet belongs to + pub project_id: Uuid, + /// The subnet pool this was allocated from + pub subnet_pool_id: Uuid, + /// The subnet pool member this subnet corresponds to + pub subnet_pool_member_id: Uuid, + /// The instance this subnet is attached to, if any + pub instance_id: Option, +} + // INSTANCE EXTERNAL IP ADDRESSES #[derive(Debug, Clone, Deserialize, PartialEq, Serialize, JsonSchema)] diff --git a/openapi/nexus/nexus-2026011601.0.0-db738e.json b/openapi/nexus/nexus-2026011601.0.0-613942.json similarity index 98% rename from openapi/nexus/nexus-2026011601.0.0-db738e.json rename to openapi/nexus/nexus-2026011601.0.0-613942.json index 43141f75344..dd0453d41a3 100644 --- a/openapi/nexus/nexus-2026011601.0.0-db738e.json +++ b/openapi/nexus/nexus-2026011601.0.0-613942.json @@ -2800,6 +2800,362 @@ } } }, + "/v1/external-subnets": { + "get": { + "tags": [ + "external-subnets" + ], + "summary": "List external subnets in a project", + "operationId": "external_subnet_list", + "parameters": [ + { + "in": "query", + "name": "limit", + "description": "Maximum number of items returned by a single call", + "schema": { + "nullable": true, + "type": "integer", + "format": "uint32", + "minimum": 1 + } + }, + { + "in": "query", + "name": "page_token", + "description": "Token returned by previous call to retrieve the subsequent page", + "schema": { + "nullable": true, + "type": "string" + } + }, + { + "in": "query", + "name": "project", + "description": "Name or ID of the project", + "schema": { + "$ref": "#/components/schemas/NameOrId" + } + }, + { + "in": "query", + "name": "sort_by", + "schema": { + "$ref": "#/components/schemas/NameOrIdSortMode" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExternalSubnetResultsPage" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + }, + "x-dropshot-pagination": { + "required": [ + "project" + ] + } + }, + "post": { + "tags": [ + "external-subnets" + ], + "summary": "Create an external subnet", + "operationId": "external_subnet_create", + "parameters": [ + { + "in": "query", + "name": "project", + "description": "Name or ID of the project", + "required": true, + "schema": { + "$ref": "#/components/schemas/NameOrId" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExternalSubnetCreate" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "successful creation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExternalSubnet" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/v1/external-subnets/{external_subnet}": { + "get": { + "tags": [ + "external-subnets" + ], + "summary": "Fetch an external subnet", + "operationId": "external_subnet_view", + "parameters": [ + { + "in": "path", + "name": "external_subnet", + "description": "Name or ID of the external subnet", + "required": true, + "schema": { + "$ref": "#/components/schemas/NameOrId" + } + }, + { + "in": "query", + "name": "project", + "description": "Name or ID of the project", + "schema": { + "$ref": "#/components/schemas/NameOrId" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExternalSubnet" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "put": { + "tags": [ + "external-subnets" + ], + "summary": "Update an external subnet", + "operationId": "external_subnet_update", + "parameters": [ + { + "in": "path", + "name": "external_subnet", + "description": "Name or ID of the external subnet", + "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/ExternalSubnetUpdate" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExternalSubnet" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "delete": { + "tags": [ + "external-subnets" + ], + "summary": "Delete an external subnet", + "operationId": "external_subnet_delete", + "parameters": [ + { + "in": "path", + "name": "external_subnet", + "description": "Name or ID of the external subnet", + "required": true, + "schema": { + "$ref": "#/components/schemas/NameOrId" + } + }, + { + "in": "query", + "name": "project", + "description": "Name or ID of the project", + "schema": { + "$ref": "#/components/schemas/NameOrId" + } + } + ], + "responses": { + "204": { + "description": "successful deletion" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/v1/external-subnets/{external_subnet}/attach": { + "post": { + "tags": [ + "external-subnets" + ], + "summary": "Attach an external subnet to an instance", + "description": "Begins an asynchronous attach operation. Returns the subnet with `instance_id` set to the target instance. The attach completes asynchronously; poll the subnet to check completion.", + "operationId": "external_subnet_attach", + "parameters": [ + { + "in": "path", + "name": "external_subnet", + "description": "Name or ID of the external subnet", + "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/ExternalSubnetAttach" + } + } + }, + "required": true + }, + "responses": { + "202": { + "description": "successfully enqueued operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExternalSubnet" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/v1/external-subnets/{external_subnet}/detach": { + "post": { + "tags": [ + "external-subnets" + ], + "summary": "Detach an external subnet from an instance", + "description": "Begins an asynchronous detach operation. Returns the subnet with `instance_id` cleared. The detach completes asynchronously.", + "operationId": "external_subnet_detach", + "parameters": [ + { + "in": "path", + "name": "external_subnet", + "description": "Name or ID of the external subnet", + "required": true, + "schema": { + "$ref": "#/components/schemas/NameOrId" + } + }, + { + "in": "query", + "name": "project", + "description": "Name or ID of the project", + "schema": { + "$ref": "#/components/schemas/NameOrId" + } + } + ], + "responses": { + "202": { + "description": "successfully enqueued operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExternalSubnet" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, "/v1/floating-ips": { "get": { "tags": [ @@ -19688,6 +20044,232 @@ "items" ] }, + "ExternalSubnet": { + "description": "An external subnet allocated from a subnet pool", + "type": "object", + "properties": { + "description": { + "description": "human-readable free-form text about a resource", + "type": "string" + }, + "id": { + "description": "unique, immutable, system-controlled identifier for each resource", + "type": "string", + "format": "uuid" + }, + "instance_id": { + "nullable": true, + "description": "The instance this subnet is attached to, if any", + "type": "string", + "format": "uuid" + }, + "name": { + "description": "unique, mutable, user-controlled identifier for each resource", + "allOf": [ + { + "$ref": "#/components/schemas/Name" + } + ] + }, + "project_id": { + "description": "The project this subnet belongs to", + "type": "string", + "format": "uuid" + }, + "subnet": { + "description": "The allocated subnet CIDR", + "allOf": [ + { + "$ref": "#/components/schemas/IpNet" + } + ] + }, + "subnet_pool_id": { + "description": "The subnet pool this was allocated from", + "type": "string", + "format": "uuid" + }, + "subnet_pool_member_id": { + "description": "The subnet pool member this subnet corresponds to", + "type": "string", + "format": "uuid" + }, + "time_created": { + "description": "timestamp when this resource was created", + "type": "string", + "format": "date-time" + }, + "time_modified": { + "description": "timestamp when this resource was last modified", + "type": "string", + "format": "date-time" + } + }, + "required": [ + "description", + "id", + "name", + "project_id", + "subnet", + "subnet_pool_id", + "subnet_pool_member_id", + "time_created", + "time_modified" + ] + }, + "ExternalSubnetAllocator": { + "description": "Specify how to allocate an external subnet.", + "oneOf": [ + { + "description": "Reserve a specific subnet.", + "type": "object", + "properties": { + "pool": { + "nullable": true, + "description": "The pool containing this subnet. If not specified, the default subnet pool for the subnet's IP version is used.", + "allOf": [ + { + "$ref": "#/components/schemas/NameOrId" + } + ] + }, + "subnet": { + "description": "The subnet CIDR to reserve. Must be available in the pool.", + "allOf": [ + { + "$ref": "#/components/schemas/IpNet" + } + ] + }, + "type": { + "type": "string", + "enum": [ + "explicit" + ] + } + }, + "required": [ + "subnet", + "type" + ] + }, + { + "description": "Automatically allocate a subnet with the specified prefix length.", + "type": "object", + "properties": { + "pool_selector": { + "description": "Pool selection.\n\nIf omitted, this field uses the silo's default pool. If the silo has default pools for both IPv4 and IPv6, the request will fail unless `ip_version` is specified in the pool selector.", + "default": { + "ip_version": null, + "type": "auto" + }, + "allOf": [ + { + "$ref": "#/components/schemas/PoolSelector" + } + ] + }, + "prefix_len": { + "description": "The prefix length for the allocated subnet (e.g., 24 for a /24).", + "type": "integer", + "format": "uint8", + "minimum": 0 + }, + "type": { + "type": "string", + "enum": [ + "auto" + ] + } + }, + "required": [ + "prefix_len", + "type" + ] + } + ] + }, + "ExternalSubnetAttach": { + "description": "Attach an external subnet to an instance", + "type": "object", + "properties": { + "instance": { + "description": "Name or ID of the instance to attach to", + "allOf": [ + { + "$ref": "#/components/schemas/NameOrId" + } + ] + } + }, + "required": [ + "instance" + ] + }, + "ExternalSubnetCreate": { + "description": "Create an external subnet", + "type": "object", + "properties": { + "allocator": { + "description": "Subnet allocation method.", + "allOf": [ + { + "$ref": "#/components/schemas/ExternalSubnetAllocator" + } + ] + }, + "description": { + "type": "string" + }, + "name": { + "$ref": "#/components/schemas/Name" + } + }, + "required": [ + "allocator", + "description", + "name" + ] + }, + "ExternalSubnetResultsPage": { + "description": "A single page of results", + "type": "object", + "properties": { + "items": { + "description": "list of items on this page of results", + "type": "array", + "items": { + "$ref": "#/components/schemas/ExternalSubnet" + } + }, + "next_page": { + "nullable": true, + "description": "token used to fetch the next page of results (if any)", + "type": "string" + } + }, + "required": [ + "items" + ] + }, + "ExternalSubnetUpdate": { + "description": "Update an external subnet", + "type": "object", + "properties": { + "description": { + "nullable": true, + "type": "string" + }, + "name": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/Name" + } + ] + } + } + }, "FailureDomain": { "description": "Describes the scope of affinity for the purposes of co-location.", "oneOf": [ @@ -24301,7 +24883,7 @@ ] }, "PoolSelector": { - "description": "Specify which IP pool to allocate from.", + "description": "Specify which IP or external subnet pool to allocate from.", "oneOf": [ { "description": "Use the specified pool by name or ID.", @@ -30510,6 +31092,13 @@ "url": "http://docs.oxide.computer/api/experimental" } }, + { + "name": "external-subnets", + "description": "External subnets that can be attached to instances.", + "externalDocs": { + "url": "http://docs.oxide.computer/api/external-subnets" + } + }, { "name": "floating-ips", "description": "Floating IPs allow a project to allocate well-known IPs to instances.", diff --git a/openapi/nexus/nexus-latest.json b/openapi/nexus/nexus-latest.json index 96f771224e7..f787cfddf53 120000 --- a/openapi/nexus/nexus-latest.json +++ b/openapi/nexus/nexus-latest.json @@ -1 +1 @@ -nexus-2026011601.0.0-db738e.json \ No newline at end of file +nexus-2026011601.0.0-613942.json \ No newline at end of file