diff --git a/nexus/db-fixed-data/src/project.rs b/nexus/db-fixed-data/src/project.rs index 50e0e43e86d..3e4a2410804 100644 --- a/nexus/db-fixed-data/src/project.rs +++ b/nexus/db-fixed-data/src/project.rs @@ -27,6 +27,7 @@ pub static SERVICES_PROJECT: LazyLock = LazyLock::new(|| { name: SERVICES_DB_NAME.parse().unwrap(), description: "Built-in project for Oxide Services".to_string(), }, + skip_default_vpc: false, }, ) }); diff --git a/nexus/db-queries/src/db/datastore/disk.rs b/nexus/db-queries/src/db/datastore/disk.rs index 8ef72b3a8c8..7ff3748ebf3 100644 --- a/nexus/db-queries/src/db/datastore/disk.rs +++ b/nexus/db-queries/src/db/datastore/disk.rs @@ -2093,6 +2093,7 @@ mod tests { name: "testpost".parse().unwrap(), description: "please ignore".to_string(), }, + skip_default_vpc: false, }, ), ) diff --git a/nexus/db-queries/src/db/datastore/external_subnet.rs b/nexus/db-queries/src/db/datastore/external_subnet.rs index f3e37855596..d3d742b3a70 100644 --- a/nexus/db-queries/src/db/datastore/external_subnet.rs +++ b/nexus/db-queries/src/db/datastore/external_subnet.rs @@ -1216,6 +1216,7 @@ mod tests { name: "my-project".parse().unwrap(), description: String::new(), }, + skip_default_vpc: false, }, ), ) @@ -2842,6 +2843,7 @@ mod tests { name: "my-project".parse().unwrap(), description: String::new(), }, + skip_default_vpc: false, }, ), ) diff --git a/nexus/db-queries/src/db/datastore/instance.rs b/nexus/db-queries/src/db/datastore/instance.rs index 0a96c00b2c2..18de49ea06d 100644 --- a/nexus/db-queries/src/db/datastore/instance.rs +++ b/nexus/db-queries/src/db/datastore/instance.rs @@ -2337,6 +2337,7 @@ mod tests { name: "stuff".parse().unwrap(), description: "Where I keep my stuff".into(), }, + skip_default_vpc: false, }, ), ) diff --git a/nexus/db-queries/src/db/datastore/ip_pool.rs b/nexus/db-queries/src/db/datastore/ip_pool.rs index 0ddacd8c6c8..6d75813ff3a 100644 --- a/nexus/db-queries/src/db/datastore/ip_pool.rs +++ b/nexus/db-queries/src/db/datastore/ip_pool.rs @@ -2829,6 +2829,7 @@ mod test { name: "my-project".parse().unwrap(), description: "".to_string(), }, + skip_default_vpc: false, }, ); let (.., project) = @@ -2946,6 +2947,7 @@ mod test { name: "my-project".parse().unwrap(), description: "".to_string(), }, + skip_default_vpc: false, }, ); let (.., project) = diff --git a/nexus/db-queries/src/db/datastore/migration.rs b/nexus/db-queries/src/db/datastore/migration.rs index 230340e2696..ca9492ed874 100644 --- a/nexus/db-queries/src/db/datastore/migration.rs +++ b/nexus/db-queries/src/db/datastore/migration.rs @@ -209,6 +209,7 @@ mod tests { name: "stuff".parse().unwrap(), description: "Where I keep my stuff".into(), }, + skip_default_vpc: false, }, ), ) diff --git a/nexus/db-queries/src/db/datastore/mod.rs b/nexus/db-queries/src/db/datastore/mod.rs index 6a4d1a44b10..1017ae1f9af 100644 --- a/nexus/db-queries/src/db/datastore/mod.rs +++ b/nexus/db-queries/src/db/datastore/mod.rs @@ -697,6 +697,7 @@ mod test { name: "project".parse().unwrap(), description: "desc".to_string(), }, + skip_default_vpc: false, }, ); datastore.project_create(&opctx, project).await.unwrap(); diff --git a/nexus/db-queries/src/db/datastore/virtual_provisioning_collection.rs b/nexus/db-queries/src/db/datastore/virtual_provisioning_collection.rs index 5cf7f9a9f73..18bf385ae9f 100644 --- a/nexus/db-queries/src/db/datastore/virtual_provisioning_collection.rs +++ b/nexus/db-queries/src/db/datastore/virtual_provisioning_collection.rs @@ -394,6 +394,7 @@ mod test { name: "myproject".parse().unwrap(), description: "It's a project".into(), }, + skip_default_vpc: false, }, ), ) diff --git a/nexus/db-queries/src/db/datastore/vpc.rs b/nexus/db-queries/src/db/datastore/vpc.rs index 40e570c9dde..94ec6cec6db 100644 --- a/nexus/db-queries/src/db/datastore/vpc.rs +++ b/nexus/db-queries/src/db/datastore/vpc.rs @@ -3026,6 +3026,7 @@ mod tests { name: "project".parse().unwrap(), description: String::from("test project"), }, + skip_default_vpc: false, }; let project = Project::new(Uuid::new_v4(), project_params); let (authz_project, _) = datastore @@ -3131,6 +3132,7 @@ mod tests { name: "project".parse().unwrap(), description: String::from("test project"), }, + skip_default_vpc: false, }; let project = Project::new(Uuid::new_v4(), project_params); let (authz_project, _) = datastore @@ -3546,6 +3548,7 @@ mod tests { name: "project".parse().unwrap(), description: String::from("test project"), }, + skip_default_vpc: false, }; let project = Project::new(DEFAULT_SILO.id(), project_params); let (authz_project, _) = datastore diff --git a/nexus/db-queries/src/db/pub_test_utils/helpers.rs b/nexus/db-queries/src/db/pub_test_utils/helpers.rs index c30c3306083..892fdcff24f 100644 --- a/nexus/db-queries/src/db/pub_test_utils/helpers.rs +++ b/nexus/db-queries/src/db/pub_test_utils/helpers.rs @@ -67,6 +67,7 @@ pub async fn create_project( name: name.parse().unwrap(), description: "desc".to_string(), }, + skip_default_vpc: false, }, ); datastore.project_create(&opctx, project).await.unwrap() diff --git a/nexus/db-queries/src/db/queries/network_interface.rs b/nexus/db-queries/src/db/queries/network_interface.rs index 229eccaec6b..2bc9f0a2a90 100644 --- a/nexus/db-queries/src/db/queries/network_interface.rs +++ b/nexus/db-queries/src/db/queries/network_interface.rs @@ -2143,6 +2143,7 @@ mod tests { name: "project".parse().unwrap(), description: "desc".to_string(), }, + skip_default_vpc: false, }, ); let (.., project) = diff --git a/nexus/external-api/src/lib.rs b/nexus/external-api/src/lib.rs index 1ed372062af..40fe079d3bd 100644 --- a/nexus/external-api/src/lib.rs +++ b/nexus/external-api/src/lib.rs @@ -47,6 +47,7 @@ mod v2026012200; mod v2026012300; mod v2026013000; mod v2026013001; +mod v2026020100; #[cfg(test)] mod test_utils; @@ -79,6 +80,7 @@ api_versions!([ // | date-based version should be at the top of the list. // v // (next_yyyymmddnn, IDENT), + (2026020100, SKIP_DEFAULT_VPC), (2026013100, READ_ONLY_DISKS_NULLABLE), (2026013001, READ_ONLY_DISKS), (2026013000, INSTANCES_EXTERNAL_SUBNETS), @@ -942,12 +944,28 @@ pub trait NexusExternalApi { method = POST, path = "/v1/projects", tags = ["projects"], + versions = VERSION_SKIP_DEFAULT_VPC.., }] async fn project_create( rqctx: RequestContext, new_project: TypedBody, ) -> Result, HttpError>; + /// Create project + #[endpoint { + method = POST, + path = "/v1/projects", + tags = ["projects"], + operation_id = "project_create", + versions = ..VERSION_SKIP_DEFAULT_VPC, + }] + async fn v2026020100_project_create( + rqctx: RequestContext, + new_project: TypedBody, + ) -> Result, HttpError> { + Self::project_create(rqctx, new_project.map(Into::into)).await + } + /// Fetch project #[endpoint { method = GET, diff --git a/nexus/external-api/src/v2026020100.rs b/nexus/external-api/src/v2026020100.rs new file mode 100644 index 00000000000..daee70c8705 --- /dev/null +++ b/nexus/external-api/src/v2026020100.rs @@ -0,0 +1,24 @@ +// 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/. + +//! Types that changed in v2026020100. + +use nexus_types::external_api::params; +use omicron_common::api::external::IdentityMetadataCreateParams; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; + +/// Create-time parameters for a `Project` +#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] +pub struct ProjectCreate { + #[serde(flatten)] + pub identity: IdentityMetadataCreateParams, +} + +impl From for params::ProjectCreate { + fn from(ProjectCreate { identity }: ProjectCreate) -> Self { + Self { identity, skip_default_vpc: false } + } +} diff --git a/nexus/src/app/sagas/project_create.rs b/nexus/src/app/sagas/project_create.rs index 37434275c02..f09a481a81c 100644 --- a/nexus/src/app/sagas/project_create.rs +++ b/nexus/src/app/sagas/project_create.rs @@ -51,20 +51,24 @@ impl NexusSaga for SagaProjectCreate { } fn make_saga_dag( - _params: &Self::Params, + params: &Self::Params, mut builder: steno::DagBuilder, ) -> Result { builder.append(project_create_record_action()); - builder.append(project_create_vpc_params_action()); - - let subsaga_builder = steno::DagBuilder::new(steno::SagaName::new( - sagas::vpc_create::SagaVpcCreate::NAME, - )); - builder.append(steno::Node::subsaga( - "vpc", - sagas::vpc_create::create_dag(subsaga_builder)?, - "vpc_create_params", - )); + + if !params.project_create.skip_default_vpc { + builder.append(project_create_vpc_params_action()); + + let subsaga_builder = steno::DagBuilder::new(steno::SagaName::new( + sagas::vpc_create::SagaVpcCreate::NAME, + )); + builder.append(steno::Node::subsaga( + "vpc", + sagas::vpc_create::create_dag(subsaga_builder)?, + "vpc_create_params", + )); + } + Ok(builder.build()?) } } @@ -162,10 +166,11 @@ mod test { ExpressionMethods, OptionalExtension, QueryDsl, SelectableHelper, }; use nexus_db_queries::{ - authn::saga::Serialized, authz, context::OpContext, + authn::saga::Serialized, authz, context::OpContext, db, db::datastore::DataStore, }; use nexus_test_utils_macros::nexus_test; + use nexus_types::identity::Resource; use omicron_common::api::external::IdentityMetadataCreateParams; type ControlPlaneTestContext = @@ -173,6 +178,14 @@ mod test { // Helper for creating project create parameters fn new_test_params(opctx: &OpContext, authz_silo: authz::Silo) -> Params { + new_test_params_with_options(opctx, authz_silo, false) + } + + fn new_test_params_with_options( + opctx: &OpContext, + authz_silo: authz::Silo, + skip_default_vpc: bool, + ) -> Params { Params { serialized_authn: Serialized::for_opctx(opctx), project_create: params::ProjectCreate { @@ -180,6 +193,7 @@ mod test { name: "my-project".parse().unwrap(), description: "My Project".to_string(), }, + skip_default_vpc, }, authz_silo, } @@ -304,4 +318,57 @@ mod test { ) .await; } + + #[nexus_test(server = crate::Server)] + async fn test_skip_default_vpc_creates_no_vpc( + cptestctx: &ControlPlaneTestContext, + ) { + let nexus = &cptestctx.server.server_context().nexus; + let datastore = nexus.datastore(); + + // Before running the test, confirm we have no records of any projects. + verify_clean_slate(datastore).await; + + // Build the saga DAG with skip_default_vpc = true. + let opctx = test_opctx(&cptestctx); + let authz_silo = opctx.authn.silo_required().unwrap(); + let params = + new_test_params_with_options(&opctx, authz_silo.clone(), true); + let saga_output = nexus + .sagas + .saga_execute::(params) + .await + .unwrap(); + + // Verify that a project was created. + let (authz_project, db_project) = saga_output + .lookup_node_output::<(authz::Project, db::model::Project)>( + "project", + ) + .unwrap(); + assert_eq!(db_project.name().as_str(), "my-project"); + + // Verify that no VPCs were created for this project. + use async_bb8_diesel::AsyncRunQueryDsl; + use diesel::{ExpressionMethods, QueryDsl, SelectableHelper}; + use nexus_db_queries::db::model::Vpc; + use nexus_db_schema::schema::vpc::dsl; + + let vpcs = dsl::vpc + .filter(dsl::project_id.eq(authz_project.id())) + .filter(dsl::time_deleted.is_null()) + .select(Vpc::as_select()) + .load_async::( + &*datastore.pool_connection_for_tests().await.unwrap(), + ) + .await + .unwrap(); + + assert!( + vpcs.is_empty(), + "expected no VPCs for project with skip_default_vpc=true, \ + found: {:?}", + vpcs.iter().map(|v| v.name()).collect::>() + ); + } } diff --git a/nexus/test-utils/src/resource_helpers.rs b/nexus/test-utils/src/resource_helpers.rs index 2f490aaeef3..49b059e8888 100644 --- a/nexus/test-utils/src/resource_helpers.rs +++ b/nexus/test-utils/src/resource_helpers.rs @@ -616,6 +616,7 @@ pub async fn create_project( name: project_name.parse().unwrap(), description: "a pier".to_string(), }, + skip_default_vpc: false, }, ) .await diff --git a/nexus/tests/integration_tests/audit_log.rs b/nexus/tests/integration_tests/audit_log.rs index 7c84a0d49a4..2b5ca20ce57 100644 --- a/nexus/tests/integration_tests/audit_log.rs +++ b/nexus/tests/integration_tests/audit_log.rs @@ -77,6 +77,7 @@ async fn test_audit_log_list(ctx: &ControlPlaneTestContext) { name: "test-proj2".parse().unwrap(), description: "a pier".to_string(), }, + skip_default_vpc: false, }; let long_user_agent = "A".repeat(300); let long_query_value = "B".repeat(600); @@ -677,6 +678,7 @@ async fn test_audit_log_access_token_auth(ctx: &ControlPlaneTestContext) { name: "token-project".parse().unwrap(), description: "created with access token".to_string(), }, + skip_default_vpc: false, }; RequestBuilder::new(client, Method::POST, "/v1/projects") .body(Some(&body)) diff --git a/nexus/tests/integration_tests/basic.rs b/nexus/tests/integration_tests/basic.rs index 1345bbe07ee..8ea2aa41b5d 100644 --- a/nexus/tests/integration_tests/basic.rs +++ b/nexus/tests/integration_tests/basic.rs @@ -166,6 +166,7 @@ async fn test_projects_basic(cptestctx: &ControlPlaneTestContext) { "", ), }, + skip_default_vpc: false, }, ) .authn_as(AuthnMode::PrivilegedUser) @@ -367,6 +368,7 @@ async fn test_projects_basic(cptestctx: &ControlPlaneTestContext) { name: "simproject1".parse().unwrap(), description: "a duplicate of simproject1".to_string(), }, + skip_default_vpc: false, }; let error = NexusRequest::new( RequestBuilder::new(client, Method::POST, &projects_url) @@ -415,6 +417,7 @@ async fn test_projects_basic(cptestctx: &ControlPlaneTestContext) { name: "honor-roller".parse().unwrap(), description: "a soapbox racer".to_string(), }, + skip_default_vpc: false, }; let project: Project = NexusRequest::objects_post(client, projects_url, &project_create) diff --git a/nexus/tests/integration_tests/console_api.rs b/nexus/tests/integration_tests/console_api.rs index d67c5b27cc3..73f77a5b0c3 100644 --- a/nexus/tests/integration_tests/console_api.rs +++ b/nexus/tests/integration_tests/console_api.rs @@ -57,6 +57,7 @@ async fn test_sessions(cptestctx: &ControlPlaneTestContext) { name: "my-proj".parse().unwrap(), description: "a project".to_string(), }, + skip_default_vpc: false, }; // hitting auth-gated API endpoint without session cookie 401s diff --git a/nexus/tests/integration_tests/endpoints.rs b/nexus/tests/integration_tests/endpoints.rs index 0725f6e2714..dbe4d43f8cc 100644 --- a/nexus/tests/integration_tests/endpoints.rs +++ b/nexus/tests/integration_tests/endpoints.rs @@ -262,6 +262,7 @@ pub static DEMO_PROJECT_CREATE: LazyLock = name: DEMO_PROJECT_NAME.clone(), description: String::from(""), }, + skip_default_vpc: false, }); // VPC used for testing diff --git a/nexus/tests/integration_tests/external_ips.rs b/nexus/tests/integration_tests/external_ips.rs index d57f2b46a01..f2488676bd6 100644 --- a/nexus/tests/integration_tests/external_ips.rs +++ b/nexus/tests/integration_tests/external_ips.rs @@ -343,6 +343,7 @@ async fn test_floating_ip_create_non_admin( name: PROJECT_NAME.parse().unwrap(), description: "floating ip project".to_string(), }, + skip_default_vpc: false, }, ) .authn_as(AuthnMode::SiloUser(user.id)) diff --git a/nexus/tests/integration_tests/instances.rs b/nexus/tests/integration_tests/instances.rs index 44070fe3c7a..2c1a544bf7a 100644 --- a/nexus/tests/integration_tests/instances.rs +++ b/nexus/tests/integration_tests/instances.rs @@ -8078,6 +8078,7 @@ async fn test_instance_create_in_silo(cptestctx: &ControlPlaneTestContext) { name: PROJECT_NAME.parse().unwrap(), description: String::new(), }, + skip_default_vpc: false, }, ) .authn_as(AuthnMode::SiloUser(user_id)) diff --git a/nexus/tests/integration_tests/projects.rs b/nexus/tests/integration_tests/projects.rs index 85c459e467b..66bd1dd82df 100644 --- a/nexus/tests/integration_tests/projects.rs +++ b/nexus/tests/integration_tests/projects.rs @@ -524,6 +524,7 @@ async fn test_limited_collaborator_cannot_create_project( name: "forbidden-project".parse().unwrap(), description: "should not be created".to_string(), }, + skip_default_vpc: false, })) .expect_status(Some(StatusCode::FORBIDDEN)), ) diff --git a/nexus/tests/integration_tests/quotas.rs b/nexus/tests/integration_tests/quotas.rs index f05adce854c..f1816eb5563 100644 --- a/nexus/tests/integration_tests/quotas.rs +++ b/nexus/tests/integration_tests/quotas.rs @@ -253,6 +253,7 @@ async fn setup_silo_with_quota( name: "project".parse().unwrap(), description: "".into(), }, + skip_default_vpc: false, }, ) .authn_as(auth_mode.clone()) diff --git a/nexus/tests/integration_tests/silos.rs b/nexus/tests/integration_tests/silos.rs index a2866c5f506..66023620fe9 100644 --- a/nexus/tests/integration_tests/silos.rs +++ b/nexus/tests/integration_tests/silos.rs @@ -197,6 +197,7 @@ async fn test_silos(cptestctx: &ControlPlaneTestContext) { name: project_name.parse().unwrap(), description: String::new(), }, + skip_default_vpc: false, }, ) .authn_as(AuthnMode::SiloUser(new_silo_user_id)) @@ -358,6 +359,7 @@ async fn test_silo_admin_group(cptestctx: &ControlPlaneTestContext) { name: "myproj".parse().unwrap(), description: "some proj".into(), }, + skip_default_vpc: false, }, ) .authn_as(AuthnMode::SiloUser(admin_group_user.id())) diff --git a/nexus/tests/integration_tests/utilization.rs b/nexus/tests/integration_tests/utilization.rs index 92133a8961d..4226dbe6823 100644 --- a/nexus/tests/integration_tests/utilization.rs +++ b/nexus/tests/integration_tests/utilization.rs @@ -215,6 +215,7 @@ async fn create_resources_in_test_suite_silo( name: test_project_name.parse().unwrap(), description: String::new(), }, + skip_default_vpc: false, }, ) .authn_as(AuthnMode::SiloUser(user1.id)) diff --git a/nexus/types/src/external_api/params.rs b/nexus/types/src/external_api/params.rs index 175ebcc1e26..7f4f4e563f8 100644 --- a/nexus/types/src/external_api/params.rs +++ b/nexus/types/src/external_api/params.rs @@ -1079,6 +1079,10 @@ pub struct AntiAffinityGroupSelector { pub struct ProjectCreate { #[serde(flatten)] pub identity: IdentityMetadataCreateParams, + + /// Whether to skip creating the "default" VPC when the project is created. + #[serde(default)] + pub skip_default_vpc: bool, } /// Updateable properties of a `Project`