diff --git a/end-to-end-tests/src/bin/commtest.rs b/end-to-end-tests/src/bin/commtest.rs index 929fe9c3371..b8d4494b092 100644 --- a/end-to-end-tests/src/bin/commtest.rs +++ b/end-to-end-tests/src/bin/commtest.rs @@ -270,6 +270,7 @@ async fn rack_prepare( .body(ProjectCreate { description: "A project for probes".into(), name: "classone".parse().unwrap(), + defaults: None, }) .send() .await?; diff --git a/end-to-end-tests/src/helpers/ctx.rs b/end-to-end-tests/src/helpers/ctx.rs index f2f2f048e9c..be8d56290ce 100644 --- a/end-to-end-tests/src/helpers/ctx.rs +++ b/end-to-end-tests/src/helpers/ctx.rs @@ -47,6 +47,7 @@ impl Context { .body(ProjectCreate { name: generate_name("proj")?, description: String::new(), + defaults: None, }) .send() .await? diff --git a/nexus/db-fixed-data/src/project.rs b/nexus/db-fixed-data/src/project.rs index 150711bf4e4..4bc86a8eef0 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(), }, + defaults: None, }, ) }); diff --git a/nexus/db-fixed-data/src/vpc.rs b/nexus/db-fixed-data/src/vpc.rs index e7110c5cb1d..7e45d43f8df 100644 --- a/nexus/db-fixed-data/src/vpc.rs +++ b/nexus/db-fixed-data/src/vpc.rs @@ -60,6 +60,7 @@ pub static SERVICES_VPC: LazyLock = LazyLock::new(|| { }, ipv6_prefix: Some(*SERVICE_VPC_IPV6_SUBNET), dns_name: SERVICES_DB_NAME.parse().unwrap(), + defaults: None, }, ) // `IncompleteVpc::new` only fails if given an invalid `ipv6_prefix` diff --git a/nexus/db-queries/src/db/datastore/disk.rs b/nexus/db-queries/src/db/datastore/disk.rs index f155ac830bf..65d13aa0cc1 100644 --- a/nexus/db-queries/src/db/datastore/disk.rs +++ b/nexus/db-queries/src/db/datastore/disk.rs @@ -2121,6 +2121,7 @@ mod tests { name: "testpost".parse().unwrap(), description: "please ignore".to_string(), }, + defaults: None, }, ), ) diff --git a/nexus/db-queries/src/db/datastore/external_ip.rs b/nexus/db-queries/src/db/datastore/external_ip.rs index 01e81871738..581bcd19d05 100644 --- a/nexus/db-queries/src/db/datastore/external_ip.rs +++ b/nexus/db-queries/src/db/datastore/external_ip.rs @@ -1818,6 +1818,7 @@ mod tests { }, ipv6_prefix: Some("fd00::/48".parse().unwrap()), dns_name: "foo".parse().unwrap(), + defaults: None, }, ) .unwrap(), diff --git a/nexus/db-queries/src/db/datastore/external_subnet.rs b/nexus/db-queries/src/db/datastore/external_subnet.rs index 68af6973435..1718c6dd7df 100644 --- a/nexus/db-queries/src/db/datastore/external_subnet.rs +++ b/nexus/db-queries/src/db/datastore/external_subnet.rs @@ -2383,6 +2383,7 @@ mod tests { name: "my-project".parse().unwrap(), description: String::new(), }, + defaults: None, }, ), ) @@ -4172,6 +4173,7 @@ mod tests { name: "my-project".parse().unwrap(), description: String::new(), }, + defaults: None, }, ), ) @@ -4642,6 +4644,7 @@ mod tests { name: "my-project".parse().unwrap(), description: String::new(), }, + defaults: None, }, ), ) @@ -4767,6 +4770,7 @@ mod tests { name: "my-project".parse().unwrap(), description: String::new(), }, + defaults: None, }, ), ) diff --git a/nexus/db-queries/src/db/datastore/instance.rs b/nexus/db-queries/src/db/datastore/instance.rs index 40ba2bcaea4..94620829089 100644 --- a/nexus/db-queries/src/db/datastore/instance.rs +++ b/nexus/db-queries/src/db/datastore/instance.rs @@ -2286,6 +2286,7 @@ mod tests { name: "stuff".parse().unwrap(), description: "Where I keep my stuff".into(), }, + defaults: None, }, ), ) diff --git a/nexus/db-queries/src/db/datastore/ip_pool.rs b/nexus/db-queries/src/db/datastore/ip_pool.rs index 49678e71cf1..42b34951962 100644 --- a/nexus/db-queries/src/db/datastore/ip_pool.rs +++ b/nexus/db-queries/src/db/datastore/ip_pool.rs @@ -2885,6 +2885,7 @@ mod test { name: "my-project".parse().unwrap(), description: "".to_string(), }, + defaults: None, }, ); let (.., project) = @@ -2998,6 +2999,7 @@ mod test { name: "my-project".parse().unwrap(), description: "".to_string(), }, + defaults: None, }, ); let (.., project) = diff --git a/nexus/db-queries/src/db/datastore/migration.rs b/nexus/db-queries/src/db/datastore/migration.rs index b05a2afd272..14d043c3a41 100644 --- a/nexus/db-queries/src/db/datastore/migration.rs +++ b/nexus/db-queries/src/db/datastore/migration.rs @@ -210,6 +210,7 @@ mod tests { name: "stuff".parse().unwrap(), description: "Where I keep my stuff".into(), }, + defaults: None, }, ), ) diff --git a/nexus/db-queries/src/db/datastore/mod.rs b/nexus/db-queries/src/db/datastore/mod.rs index 7ec9d8b4036..63f28974c99 100644 --- a/nexus/db-queries/src/db/datastore/mod.rs +++ b/nexus/db-queries/src/db/datastore/mod.rs @@ -766,6 +766,7 @@ mod test { name: "project".parse().unwrap(), description: "desc".to_string(), }, + defaults: None, }, ); 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 9cba5b26186..0757fc0103e 100644 --- a/nexus/db-queries/src/db/datastore/virtual_provisioning_collection.rs +++ b/nexus/db-queries/src/db/datastore/virtual_provisioning_collection.rs @@ -395,6 +395,7 @@ mod test { name: "myproject".parse().unwrap(), description: "It's a project".into(), }, + defaults: None, }, ), ) diff --git a/nexus/db-queries/src/db/datastore/vpc.rs b/nexus/db-queries/src/db/datastore/vpc.rs index 7e1cf4a049b..daa8efbfdb4 100644 --- a/nexus/db-queries/src/db/datastore/vpc.rs +++ b/nexus/db-queries/src/db/datastore/vpc.rs @@ -3020,6 +3020,7 @@ mod tests { name: "project".parse().unwrap(), description: String::from("test project"), }, + defaults: None, }; let project = Project::new(Uuid::new_v4(), project_params); let (authz_project, _) = datastore @@ -3044,6 +3045,7 @@ mod tests { }, ipv6_prefix: None, dns_name: name.clone(), + defaults: None, }, ) .expect("failed to create incomplete VPC"); @@ -3085,6 +3087,7 @@ mod tests { }, ipv6_prefix: None, dns_name: name.clone(), + defaults: None, }, ) .expect("failed to create incomplete VPC"); @@ -3125,6 +3128,7 @@ mod tests { name: "project".parse().unwrap(), description: String::from("test project"), }, + defaults: None, }; let project = Project::new(Uuid::new_v4(), project_params); let (authz_project, _) = datastore @@ -3149,6 +3153,7 @@ mod tests { }, ipv6_prefix: None, dns_name: name.clone(), + defaults: None, }, ) .expect("failed to create incomplete VPC"); @@ -3191,6 +3196,7 @@ mod tests { }, ipv6_prefix: None, dns_name: name.clone(), + defaults: None, }, ) .expect("failed to create incomplete VPC"); @@ -3550,6 +3556,7 @@ mod tests { name: "project".parse().unwrap(), description: String::from("test project"), }, + defaults: None, }; let project = Project::new(DEFAULT_SILO.id(), project_params); let (authz_project, _) = datastore @@ -3570,6 +3577,7 @@ mod tests { }, ipv6_prefix: None, dns_name: vpc_name.clone(), + defaults: None, }, ) .expect("failed to create incomplete VPC"); 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 472b5ef86c3..b83859e5e14 100644 --- a/nexus/db-queries/src/db/pub_test_utils/helpers.rs +++ b/nexus/db-queries/src/db/pub_test_utils/helpers.rs @@ -73,6 +73,7 @@ pub async fn create_project( name: name.parse().unwrap(), description: "desc".to_string(), }, + defaults: None, }, ); datastore.project_create(&opctx, project).await.unwrap() diff --git a/nexus/db-queries/src/db/pub_test_utils/multicast.rs b/nexus/db-queries/src/db/pub_test_utils/multicast.rs index 2ed27fe1c32..3e0ef2e733c 100644 --- a/nexus/db-queries/src/db/pub_test_utils/multicast.rs +++ b/nexus/db-queries/src/db/pub_test_utils/multicast.rs @@ -80,6 +80,7 @@ pub async fn create_test_setup_with_range( }, ipv6_prefix: None, dns_name: format!("{}-vpc", project_name).parse().unwrap(), + defaults: None, }; let vpc = IncompleteVpc::new( diff --git a/nexus/db-queries/src/db/queries/network_interface.rs b/nexus/db-queries/src/db/queries/network_interface.rs index bd936266e0a..9ec9924a67d 100644 --- a/nexus/db-queries/src/db/queries/network_interface.rs +++ b/nexus/db-queries/src/db/queries/network_interface.rs @@ -2147,6 +2147,7 @@ mod tests { name: "project".parse().unwrap(), description: "desc".to_string(), }, + defaults: None, }, ); let (.., project) = diff --git a/nexus/external-api/src/lib.rs b/nexus/external-api/src/lib.rs index df004b51382..cbf99467549 100644 --- a/nexus/external-api/src/lib.rs +++ b/nexus/external-api/src/lib.rs @@ -87,6 +87,7 @@ api_versions!([ // | date-based version should be at the top of the list. // v // (next_yyyy_mm_dd_nn, IDENT), + (2026_08_19_02, PROJECT_AND_VPC_CREATE_DEFAULTS), (2026_08_19_01, BGP_PEER_SRC_ADDR), (2026_08_17_00, SUPPORT_BUNDLES_STABLE), (2026_08_14_00, ALERT_LIST), @@ -1158,12 +1159,29 @@ pub trait NexusExternalApi { method = POST, path = "/v1/projects", tags = ["projects"], + versions = VERSION_PROJECT_AND_VPC_CREATE_DEFAULTS.., }] async fn project_create( rqctx: RequestContext, new_project: TypedBody, ) -> Result, HttpError>; + /// Create project + #[endpoint { + operation_id = "project_create", + method = POST, + path = "/v1/projects", + tags = ["projects"], + versions = ..VERSION_PROJECT_AND_VPC_CREATE_DEFAULTS, + }] + async fn project_create_v2025_11_20_00( + rqctx: RequestContext, + new_project: TypedBody, + ) -> Result, HttpError> + { + Self::project_create(rqctx, new_project.map(Into::into)).await + } + /// Fetch project #[endpoint { method = GET, @@ -6992,6 +7010,7 @@ pub trait NexusExternalApi { method = POST, path = "/v1/vpcs", tags = ["vpcs"], + versions = VERSION_PROJECT_AND_VPC_CREATE_DEFAULTS.., }] async fn vpc_create( rqctx: RequestContext, @@ -6999,6 +7018,22 @@ pub trait NexusExternalApi { body: TypedBody, ) -> Result, HttpError>; + /// Create VPC + #[endpoint { + operation_id = "vpc_create", + method = POST, + path = "/v1/vpcs", + tags = ["vpcs"], + versions = ..VERSION_PROJECT_AND_VPC_CREATE_DEFAULTS, + }] + async fn vpc_create_v2025_11_20_00( + rqctx: RequestContext, + query_params: Query, + body: TypedBody, + ) -> Result, HttpError> { + Self::vpc_create(rqctx, query_params, body.map(Into::into)).await + } + /// Fetch VPC #[endpoint { method = GET, diff --git a/nexus/src/app/sagas/instance_create.rs b/nexus/src/app/sagas/instance_create.rs index a0030ebab7c..45683db750a 100644 --- a/nexus/src/app/sagas/instance_create.rs +++ b/nexus/src/app/sagas/instance_create.rs @@ -772,6 +772,14 @@ async fn create_default_primary_network_interface( .vpc_subnet_name(&internal_default_name) .fetch() .await + .map_err(|error| match error { + Error::ObjectNotFound { .. } => Error::non_resourcetype_not_found( + "this project has no VPC or subnet named \"default\", so a \ + default network interface cannot be created; pass explicit \ + network interface parameters or create the VPC/subnet first", + ), + error => error, + }) .map_err(saga_action_failed)?; let interface = db::model::IncompleteNetworkInterface::new_instance( *interface_id, diff --git a/nexus/src/app/sagas/project_create.rs b/nexus/src/app/sagas/project_create.rs index 88f0449e8e4..4294fbe6636 100644 --- a/nexus/src/app/sagas/project_create.rs +++ b/nexus/src/app/sagas/project_create.rs @@ -52,24 +52,49 @@ 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 let Some(vpc_defaults) = default_vpc_defaults(¶ms.project_create) + { + 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, + sagas::vpc_create::should_create_default_subnet( + vpc_defaults, + ), + )?, + "vpc_create_params", + )); + } Ok(builder.build()?) } } +fn default_vpc_defaults( + project_create: &project::ProjectCreate, +) -> Option> { + match &project_create.defaults { + None => Some(None), + Some(defaults) => { + defaults.vpc.as_ref().map(|selection| match selection { + vpc::VpcCreateDefaultsSelection::All => None, + vpc::VpcCreateDefaultsSelection::Explicit { defaults } => { + Some(defaults) + } + }) + } + } +} + // project create saga: action implementations async fn spc_create_record( @@ -141,6 +166,9 @@ async fn spc_create_vpc_params( // handle the logic around name and dns_name by making // dns_name optional dns_name: "default".parse().unwrap(), + defaults: default_vpc_defaults(¶ms.project_create) + .expect("default VPC parameters require a default VPC") + .cloned(), }; let saga_params = sagas::vpc_create::Params { serialized_authn: authn::saga::Serialized::for_opctx(&opctx), @@ -155,6 +183,7 @@ mod test { use crate::{ app::sagas::project_create::Params, app::sagas::project_create::SagaProjectCreate, + app::sagas::project_create::default_vpc_defaults, }; use async_bb8_diesel::{AsyncRunQueryDsl, AsyncSimpleConnection}; use diesel::{ @@ -165,12 +194,62 @@ mod test { db::datastore::DataStore, }; use nexus_test_utils_macros::nexus_test; - use nexus_types::external_api::project; + use nexus_types::external_api::{project, vpc}; use omicron_common::api::external::IdentityMetadataCreateParams; type ControlPlaneTestContext = nexus_test_utils::ControlPlaneTestContext; + #[test] + fn test_default_vpc_defaults() { + let mut project_create = project::ProjectCreate { + identity: IdentityMetadataCreateParams { + name: "my-project".parse().unwrap(), + description: String::new(), + }, + defaults: None, + }; + + assert_eq!(default_vpc_defaults(&project_create), Some(None)); + + project_create.defaults = + Some(project::ProjectCreateDefaults { vpc: None }); + assert_eq!(default_vpc_defaults(&project_create), None); + + project_create.defaults = Some(project::ProjectCreateDefaults { + vpc: Some(vpc::VpcCreateDefaultsSelection::All), + }); + assert_eq!(default_vpc_defaults(&project_create), Some(None)); + + project_create.defaults = Some(project::ProjectCreateDefaults { + vpc: Some(vpc::VpcCreateDefaultsSelection::Explicit { + defaults: vpc::VpcCreateDefaults { subnet: None }, + }), + }); + assert!( + default_vpc_defaults(&project_create) + .unwrap() + .unwrap() + .subnet + .is_none() + ); + + project_create.defaults = Some(project::ProjectCreateDefaults { + vpc: Some(vpc::VpcCreateDefaultsSelection::Explicit { + defaults: vpc::VpcCreateDefaults { + subnet: Some(vpc::SubnetCreateDefaults {}), + }, + }), + }); + assert!( + default_vpc_defaults(&project_create) + .unwrap() + .unwrap() + .subnet + .is_some() + ); + } + // Helper for creating project create parameters fn new_test_params(opctx: &OpContext, authz_silo: authz::Silo) -> Params { Params { @@ -180,6 +259,7 @@ mod test { name: "my-project".parse().unwrap(), description: "My Project".to_string(), }, + defaults: None, }, authz_silo, } diff --git a/nexus/src/app/sagas/vpc_create.rs b/nexus/src/app/sagas/vpc_create.rs index fe3bca49a2f..ae64a77361f 100644 --- a/nexus/src/app/sagas/vpc_create.rs +++ b/nexus/src/app/sagas/vpc_create.rs @@ -80,6 +80,7 @@ declare_saga_actions! { /// to identify that parameters do not need to be supplied as input. pub fn create_dag( mut builder: steno::DagBuilder, + create_default_subnet: bool, ) -> Result { builder.append(Node::action( "vpc_id", @@ -101,16 +102,18 @@ pub fn create_dag( "GenerateDefaultV6RouteId", ACTION_GENERATE_ID.as_ref(), )); - builder.append(Node::action( - "subnet_route_id", - "GenerateSubnetRouteId", - ACTION_GENERATE_ID.as_ref(), - )); - builder.append(Node::action( - "default_subnet_id", - "GenerateDefaultSubnetId", - ACTION_GENERATE_ID.as_ref(), - )); + if create_default_subnet { + builder.append(Node::action( + "subnet_route_id", + "GenerateSubnetRouteId", + ACTION_GENERATE_ID.as_ref(), + )); + builder.append(Node::action( + "default_subnet_id", + "GenerateDefaultSubnetId", + ACTION_GENERATE_ID.as_ref(), + )); + } builder.append(Node::action( "default_internet_gateway_id", "GenerateDefaultInternetGatewayId", @@ -120,8 +123,10 @@ pub fn create_dag( builder.append(vpc_create_router_action()); builder.append(vpc_create_v4_route_action()); builder.append(vpc_create_v6_route_action()); - builder.append(vpc_create_subnet_action()); - builder.append(vpc_create_subnet_route_action()); + if create_default_subnet { + builder.append(vpc_create_subnet_action()); + builder.append(vpc_create_subnet_route_action()); + } builder.append(vpc_update_firewall_action()); builder.append(vpc_create_gateway_action()); builder.append(vpc_notify_sleds_action()); @@ -140,13 +145,22 @@ impl NexusSaga for SagaVpcCreate { } fn make_saga_dag( - _params: &Self::Params, + params: &Self::Params, builder: steno::DagBuilder, ) -> Result { - create_dag(builder) + create_dag( + builder, + should_create_default_subnet(params.vpc_create.defaults.as_ref()), + ) } } +pub(crate) fn should_create_default_subnet( + defaults: Option<&vpc::VpcCreateDefaults>, +) -> bool { + defaults.is_none_or(|defaults| defaults.subnet.is_some()) +} + // vpc create saga: action implementations async fn svc_create_vpc( @@ -700,6 +714,19 @@ pub(crate) mod test { const PROJECT_NAME: &str = "springfield-squidport"; + #[test] + fn test_should_create_default_subnet() { + assert!(super::should_create_default_subnet(None)); + assert!(!super::should_create_default_subnet(Some( + &vpc_types::VpcCreateDefaults { subnet: None } + ))); + assert!(super::should_create_default_subnet(Some( + &vpc_types::VpcCreateDefaults { + subnet: Some(vpc_types::SubnetCreateDefaults {}), + } + ))); + } + async fn create_org_and_project(client: &ClientTestContext) -> Uuid { create_default_ip_pools(&client).await; let project = create_project(client, PROJECT_NAME).await; @@ -720,6 +747,7 @@ pub(crate) mod test { }, ipv6_prefix: None, dns_name: "abc".parse().unwrap(), + defaults: None, }, authz_project, } diff --git a/nexus/test-utils/src/resource_helpers.rs b/nexus/test-utils/src/resource_helpers.rs index 8d556dca08d..697c7df88a8 100644 --- a/nexus/test-utils/src/resource_helpers.rs +++ b/nexus/test-utils/src/resource_helpers.rs @@ -695,6 +695,7 @@ pub async fn create_project( name: project_name.parse().unwrap(), description: "a pier".to_string(), }, + defaults: None, }, ) .await @@ -1047,6 +1048,7 @@ pub async fn create_vpc( }, ipv6_prefix: None, dns_name: "abc".parse().unwrap(), + defaults: None, }, ) .await @@ -1073,6 +1075,7 @@ pub async fn create_vpc_with_error( }, ipv6_prefix: None, dns_name: "abc".parse().unwrap(), + defaults: None, })) .expect_status(Some(status)), ) diff --git a/nexus/tests/integration_tests/audit_log.rs b/nexus/tests/integration_tests/audit_log.rs index 330adbb1461..afaf1f2bb05 100644 --- a/nexus/tests/integration_tests/audit_log.rs +++ b/nexus/tests/integration_tests/audit_log.rs @@ -89,6 +89,7 @@ async fn test_audit_log_list(ctx: &ControlPlaneTestContext) { name: "test-proj2".parse().unwrap(), description: "a pier".to_string(), }, + defaults: None, }; let long_user_agent = "A".repeat(300); let long_query_value = "B".repeat(600); @@ -863,6 +864,7 @@ async fn test_audit_log_access_token_auth(ctx: &ControlPlaneTestContext) { name: "token-project".parse().unwrap(), description: "created with access token".to_string(), }, + defaults: None, }; 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 3cac5724ea3..d12b1d47f42 100644 --- a/nexus/tests/integration_tests/basic.rs +++ b/nexus/tests/integration_tests/basic.rs @@ -167,6 +167,7 @@ async fn test_projects_basic(cptestctx: &ControlPlaneTestContext) { "", ), }, + defaults: None, }, ) .authn_as(AuthnMode::PrivilegedUser) @@ -368,6 +369,7 @@ async fn test_projects_basic(cptestctx: &ControlPlaneTestContext) { name: "simproject1".parse().unwrap(), description: "a duplicate of simproject1".to_string(), }, + defaults: None, }; let error = NexusRequest::new( RequestBuilder::new(client, Method::POST, &projects_url) @@ -416,6 +418,7 @@ async fn test_projects_basic(cptestctx: &ControlPlaneTestContext) { name: "honor-roller".parse().unwrap(), description: "a soapbox racer".to_string(), }, + defaults: None, }; 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 c71d68c9e89..f6127e31095 100644 --- a/nexus/tests/integration_tests/console_api.rs +++ b/nexus/tests/integration_tests/console_api.rs @@ -58,6 +58,7 @@ async fn test_sessions(cptestctx: &ControlPlaneTestContext) { name: "my-proj".parse().unwrap(), description: "a project".to_string(), }, + defaults: None, }; // 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 20f6e856b4e..59d900bf554 100644 --- a/nexus/tests/integration_tests/endpoints.rs +++ b/nexus/tests/integration_tests/endpoints.rs @@ -316,6 +316,7 @@ pub static DEMO_PROJECT_CREATE: LazyLock = name: DEMO_PROJECT_NAME.clone(), description: String::from(""), }, + defaults: None, }); // VPC used for testing @@ -341,6 +342,7 @@ pub static DEMO_VPC_CREATE: LazyLock = }, ipv6_prefix: None, dns_name: DEMO_VPC_NAME.clone(), + defaults: None, }); // VPC Subnet used for testing diff --git a/nexus/tests/integration_tests/external_ips.rs b/nexus/tests/integration_tests/external_ips.rs index d2758574e59..46bcbe4a2c1 100644 --- a/nexus/tests/integration_tests/external_ips.rs +++ b/nexus/tests/integration_tests/external_ips.rs @@ -349,6 +349,7 @@ async fn test_floating_ip_create_non_admin( name: PROJECT_NAME.parse().unwrap(), description: "floating ip project".to_string(), }, + defaults: None, }, ) .authn_as(AuthnMode::SiloUser(user.id)) diff --git a/nexus/tests/integration_tests/instances.rs b/nexus/tests/integration_tests/instances.rs index e9a5e9cfc7e..5ef4e76475b 100644 --- a/nexus/tests/integration_tests/instances.rs +++ b/nexus/tests/integration_tests/instances.rs @@ -8817,6 +8817,7 @@ async fn test_instance_create_in_silo(cptestctx: &ControlPlaneTestContext) { name: PROJECT_NAME.parse().unwrap(), description: String::new(), }, + defaults: None, }, ) .authn_as(AuthnMode::SiloUser(user_id)) diff --git a/nexus/tests/integration_tests/internet_gateway.rs b/nexus/tests/integration_tests/internet_gateway.rs index 333dd74d70e..c661411ece7 100644 --- a/nexus/tests/integration_tests/internet_gateway.rs +++ b/nexus/tests/integration_tests/internet_gateway.rs @@ -434,6 +434,7 @@ async fn test_vpc_create_attaches_all_default_pools_to_igw( name: "test-project".parse().unwrap(), description: "".to_string(), }, + defaults: None, }, ) .authn_as(AuthnMode::SiloUser(user.id)) @@ -450,6 +451,7 @@ async fn test_vpc_create_attaches_all_default_pools_to_igw( }, ipv6_prefix: None, dns_name: "test-vpc".parse().unwrap(), + defaults: None, }, ) .authn_as(AuthnMode::SiloUser(user.id)) @@ -575,6 +577,7 @@ async fn test_vpc_create_attaches_only_ipv4_default_pool_to_igw( name: "test-project".parse().unwrap(), description: "".to_string(), }, + defaults: None, }, ) .authn_as(AuthnMode::SiloUser(user.id)) @@ -591,6 +594,7 @@ async fn test_vpc_create_attaches_only_ipv4_default_pool_to_igw( }, ipv6_prefix: None, dns_name: "test-vpc".parse().unwrap(), + defaults: None, }, ) .authn_as(AuthnMode::SiloUser(user.id)) diff --git a/nexus/tests/integration_tests/multicast/authorization.rs b/nexus/tests/integration_tests/multicast/authorization.rs index 88550abab40..b631f9c6816 100644 --- a/nexus/tests/integration_tests/multicast/authorization.rs +++ b/nexus/tests/integration_tests/multicast/authorization.rs @@ -62,6 +62,7 @@ async fn create_group_via_instance_join( name: project_name.parse().unwrap(), description: format!("Project for {group_name}"), }, + defaults: None, }; NexusRequest::new( @@ -231,6 +232,7 @@ async fn test_silo_user_multicast_permissions( name: "second-project".parse().unwrap(), description: "Second project for testing".to_string(), }, + defaults: None, }; NexusRequest::new( RequestBuilder::new(client, http::Method::POST, "/v1/projects") @@ -1067,6 +1069,7 @@ async fn test_cross_silo_multicast_isolation( name: "project-silo-a".parse().unwrap(), description: "Project in Silo A".to_string(), }, + defaults: None, }; NexusRequest::new( @@ -1085,6 +1088,7 @@ async fn test_cross_silo_multicast_isolation( name: "project-silo-b".parse().unwrap(), description: "Project in Silo B".to_string(), }, + defaults: None, }; NexusRequest::new( @@ -1324,6 +1328,7 @@ async fn test_cross_silo_multicast_isolation( name: "project-silo-c".parse().unwrap(), description: "Project in Silo C".to_string(), }, + defaults: None, }; NexusRequest::new( diff --git a/nexus/tests/integration_tests/projects.rs b/nexus/tests/integration_tests/projects.rs index 89d6f2758ed..29a63213ac9 100644 --- a/nexus/tests/integration_tests/projects.rs +++ b/nexus/tests/integration_tests/projects.rs @@ -21,6 +21,8 @@ use nexus_test_utils::resource_helpers::create_project_image; use nexus_test_utils::resource_helpers::create_vpc; use nexus_test_utils::resource_helpers::grant_iam; use nexus_test_utils::resource_helpers::object_create; +use nexus_test_utils::resource_helpers::object_create_error; +use nexus_test_utils::resource_helpers::objects_list_page_authz; use nexus_test_utils::resource_helpers::project_get; use nexus_test_utils::resource_helpers::projects_list; use nexus_test_utils::resource_helpers::test_params; @@ -34,6 +36,7 @@ use nexus_types::external_api::project; use nexus_types::external_api::project::Project; use nexus_types::external_api::silo::Silo; use nexus_types::external_api::snapshot; +use nexus_types::external_api::vpc; use nexus_types::identity::Resource; use nexus_types_versions::latest::instance::Instance; use omicron_common::api::external::ByteCount; @@ -73,6 +76,142 @@ async fn test_projects(cptestctx: &ControlPlaneTestContext) { // and when we list projects we only get the ones in each silo } +#[nexus_test] +async fn test_project_create_defaults(cptestctx: &ControlPlaneTestContext) { + let client = &cptestctx.external_client; + + async fn create( + client: &ClientTestContext, + name: &str, + defaults: Option, + ) { + let _: Project = object_create( + client, + "/v1/projects", + &project::ProjectCreate { + identity: IdentityMetadataCreateParams { + name: name.parse().unwrap(), + description: String::new(), + }, + defaults, + }, + ) + .await; + } + + async fn default_counts( + client: &ClientTestContext, + project: &str, + ) -> (usize, usize) { + let vpcs = objects_list_page_authz::( + client, + &format!("/v1/vpcs?project={project}"), + ) + .await + .items; + let subnets = if vpcs.is_empty() { + Vec::new() + } else { + objects_list_page_authz::( + client, + &format!("/v1/vpc-subnets?project={project}&vpc=default"), + ) + .await + .items + }; + (vpcs.len(), subnets.len()) + } + + async fn assert_default_nic_error( + client: &ClientTestContext, + project: &str, + ) { + let error = object_create_error( + client, + &format!("/v1/instances?project={project}"), + &instance::InstanceCreate { + identity: IdentityMetadataCreateParams { + name: "my-instance".parse().unwrap(), + description: String::new(), + }, + ncpus: InstanceCpuCount(4), + memory: ByteCount::from_gibibytes_u32(1), + hostname: "the-host".parse().unwrap(), + user_data: Vec::new(), + ssh_public_keys: Some(Vec::new()), + network_interfaces: Default::default(), + external_ips: Vec::new(), + disks: Vec::new(), + boot_disk: None, + cpu_platform: None, + start: false, + auto_restart_policy: Default::default(), + anti_affinity_groups: Vec::new(), + multicast_groups: Vec::new(), + enable_jumbo_frames: false, + }, + StatusCode::NOT_FOUND, + ) + .await; + assert_eq!(error.error_code.as_deref(), Some("Not Found")); + assert_eq!( + error.message, + "this project has no VPC or subnet named \"default\", so a \ + default network interface cannot be created; pass explicit \ + network interface parameters or create the VPC/subnet first", + ); + } + + create(client, "defaults-omitted", None).await; + assert_eq!(default_counts(client, "defaults-omitted").await, (1, 1)); + + create( + client, + "defaults-empty", + Some(project::ProjectCreateDefaults { vpc: None }), + ) + .await; + assert_eq!(default_counts(client, "defaults-empty").await, (0, 0)); + assert_default_nic_error(client, "defaults-empty").await; + + create( + client, + "defaults-vpc-all", + Some(project::ProjectCreateDefaults { + vpc: Some(vpc::VpcCreateDefaultsSelection::All), + }), + ) + .await; + assert_eq!(default_counts(client, "defaults-vpc-all").await, (1, 1)); + + create( + client, + "defaults-vpc-only", + Some(project::ProjectCreateDefaults { + vpc: Some(vpc::VpcCreateDefaultsSelection::Explicit { + defaults: vpc::VpcCreateDefaults { subnet: None }, + }), + }), + ) + .await; + assert_eq!(default_counts(client, "defaults-vpc-only").await, (1, 0)); + assert_default_nic_error(client, "defaults-vpc-only").await; + + create( + client, + "defaults-selected", + Some(project::ProjectCreateDefaults { + vpc: Some(vpc::VpcCreateDefaultsSelection::Explicit { + defaults: vpc::VpcCreateDefaults { + subnet: Some(vpc::SubnetCreateDefaults {}), + }, + }), + }), + ) + .await; + assert_eq!(default_counts(client, "defaults-selected").await, (1, 1)); +} + async fn delete_project_default_subnet( project: &str, client: &ClientTestContext, @@ -514,6 +653,7 @@ async fn test_limited_collaborator_cannot_create_project( name: "forbidden-project".parse().unwrap(), description: "should not be created".to_string(), }, + defaults: None, })) .expect_status(Some(StatusCode::FORBIDDEN)), ) diff --git a/nexus/tests/integration_tests/quotas.rs b/nexus/tests/integration_tests/quotas.rs index baa771a29bf..6442ba79018 100644 --- a/nexus/tests/integration_tests/quotas.rs +++ b/nexus/tests/integration_tests/quotas.rs @@ -262,6 +262,7 @@ async fn setup_silo_with_quota( name: "project".parse().unwrap(), description: "".into(), }, + defaults: None, }, ) .authn_as(auth_mode.clone()) diff --git a/nexus/tests/integration_tests/silos.rs b/nexus/tests/integration_tests/silos.rs index e1081577284..6c5afa86fab 100644 --- a/nexus/tests/integration_tests/silos.rs +++ b/nexus/tests/integration_tests/silos.rs @@ -202,6 +202,7 @@ async fn test_silos(cptestctx: &ControlPlaneTestContext) { name: project_name.parse().unwrap(), description: String::new(), }, + defaults: None, }, ) .authn_as(AuthnMode::SiloUser(new_silo_user_id)) @@ -363,6 +364,7 @@ async fn test_silo_admin_group(cptestctx: &ControlPlaneTestContext) { name: "myproj".parse().unwrap(), description: "some proj".into(), }, + defaults: None, }, ) .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 671cc49c35a..777138f4fc1 100644 --- a/nexus/tests/integration_tests/utilization.rs +++ b/nexus/tests/integration_tests/utilization.rs @@ -218,6 +218,7 @@ async fn create_resources_in_test_suite_silo( name: test_project_name.parse().unwrap(), description: String::new(), }, + defaults: None, }, ) .authn_as(AuthnMode::SiloUser(user1.id)) diff --git a/nexus/tests/integration_tests/vpcs.rs b/nexus/tests/integration_tests/vpcs.rs index 775b4d68159..ff4e465a902 100644 --- a/nexus/tests/integration_tests/vpcs.rs +++ b/nexus/tests/integration_tests/vpcs.rs @@ -102,6 +102,7 @@ async fn test_vpcs(cptestctx: &ControlPlaneTestContext) { }, ipv6_prefix: Some(bad_prefix), dns_name: "abc".parse().unwrap(), + defaults: None, })), ) .authn_as(AuthnMode::PrivilegedUser) @@ -327,6 +328,7 @@ async fn test_vpc_limited_collaborator_role( }, ipv6_prefix: None, dns_name: "test".parse().unwrap(), + defaults: None, }, ) .authn_as(AuthnMode::PrivilegedUser) @@ -348,6 +350,7 @@ async fn test_vpc_limited_collaborator_role( }, ipv6_prefix: None, dns_name: "test2".parse().unwrap(), + defaults: None, }, ) .authn_as(AuthnMode::UnprivilegedUser) @@ -386,6 +389,7 @@ async fn test_vpc_limited_collaborator_role( }, ipv6_prefix: None, dns_name: "forbidden".parse().unwrap(), + defaults: None, })) .expect_status(Some(StatusCode::FORBIDDEN)), ) @@ -544,6 +548,7 @@ async fn test_limited_collaborator_blocked_from_networking_resources( }, ipv6_prefix: None, dns_name: "forbidden".parse().unwrap(), + defaults: None, })) .expect_status(Some(StatusCode::FORBIDDEN)), ) diff --git a/nexus/types/versions/src/latest.rs b/nexus/types/versions/src/latest.rs index c77692ed2db..a6597631212 100644 --- a/nexus/types/versions/src/latest.rs +++ b/nexus/types/versions/src/latest.rs @@ -370,9 +370,11 @@ pub mod probe { pub mod project { pub use crate::v2025_11_20_00::project::OptionalProjectSelector; pub use crate::v2025_11_20_00::project::Project; - pub use crate::v2025_11_20_00::project::ProjectCreate; pub use crate::v2025_11_20_00::project::ProjectSelector; pub use crate::v2025_11_20_00::project::ProjectUpdate; + + pub use crate::v2026_08_19_02::project::ProjectCreate; + pub use crate::v2026_08_19_02::project::ProjectCreateDefaults; } pub mod saml { @@ -466,7 +468,6 @@ pub mod vpc { pub use crate::v2025_11_20_00::vpc::RouterSelector; pub use crate::v2025_11_20_00::vpc::SubnetSelector; pub use crate::v2025_11_20_00::vpc::Vpc; - pub use crate::v2025_11_20_00::vpc::VpcCreate; pub use crate::v2025_11_20_00::vpc::VpcRouter; pub use crate::v2025_11_20_00::vpc::VpcRouterCreate; pub use crate::v2025_11_20_00::vpc::VpcRouterKind; @@ -476,6 +477,11 @@ pub mod vpc { pub use crate::v2025_11_20_00::vpc::VpcSubnetCreate; pub use crate::v2025_11_20_00::vpc::VpcSubnetUpdate; pub use crate::v2025_11_20_00::vpc::VpcUpdate; + + pub use crate::v2026_08_19_02::vpc::SubnetCreateDefaults; + pub use crate::v2026_08_19_02::vpc::VpcCreate; + pub use crate::v2026_08_19_02::vpc::VpcCreateDefaults; + pub use crate::v2026_08_19_02::vpc::VpcCreateDefaultsSelection; } pub mod asset { diff --git a/nexus/types/versions/src/lib.rs b/nexus/types/versions/src/lib.rs index 1f9611e71d6..2ff19b3ab9e 100644 --- a/nexus/types/versions/src/lib.rs +++ b/nexus/types/versions/src/lib.rs @@ -103,3 +103,5 @@ pub mod v2026_08_12_00; pub mod v2026_08_14_00; #[path = "bgp_peer_src_addr/mod.rs"] pub mod v2026_08_14_01; +#[path = "project_and_vpc_create_defaults/mod.rs"] +pub mod v2026_08_19_02; diff --git a/nexus/types/versions/src/project_and_vpc_create_defaults/mod.rs b/nexus/types/versions/src/project_and_vpc_create_defaults/mod.rs new file mode 100644 index 00000000000..ab86c9cb743 --- /dev/null +++ b/nexus/types/versions/src/project_and_vpc_create_defaults/mod.rs @@ -0,0 +1,11 @@ +// 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/. + +//! Version `PROJECT_AND_VPC_CREATE_DEFAULTS` of the Nexus external API. +//! +//! This version allows callers to select the default resources created with +//! projects and VPCs. + +pub mod project; +pub mod vpc; diff --git a/nexus/types/versions/src/project_and_vpc_create_defaults/project.rs b/nexus/types/versions/src/project_and_vpc_create_defaults/project.rs new file mode 100644 index 00000000000..783c88a6a35 --- /dev/null +++ b/nexus/types/versions/src/project_and_vpc_create_defaults/project.rs @@ -0,0 +1,152 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +use super::vpc::VpcCreateDefaultsSelection; +use crate::v2025_11_20_00; +use omicron_common::api::external::IdentityMetadataCreateParams; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +/// Default resources to create with a project. +#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ProjectCreateDefaults { + /// Create a default VPC with all or explicitly selected VPC defaults. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub vpc: Option, +} + +/// Create-time parameters for a `Project`. +#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] +pub struct ProjectCreate { + #[serde(flatten)] + pub identity: IdentityMetadataCreateParams, + + /// Default resources to create. + /// + /// If omitted, all default resources are created. If provided, only the + /// selected default resources are created. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub defaults: Option, +} + +impl From for ProjectCreate { + fn from(old: v2025_11_20_00::project::ProjectCreate) -> Self { + Self { identity: old.identity, defaults: None } + } +} + +#[cfg(test)] +mod tests { + use super::{ProjectCreate, ProjectCreateDefaults}; + use crate::v2026_08_19_02::vpc::{ + SubnetCreateDefaults, VpcCreateDefaults, VpcCreateDefaultsSelection, + }; + use serde_json::json; + + #[test] + fn defaults_wire_format() { + let base = json!({ + "name": "my-project", + "description": "My project", + }); + + let omitted: ProjectCreate = + serde_json::from_value(base.clone()).unwrap(); + assert!(omitted.defaults.is_none()); + assert_eq!(serde_json::to_value(omitted).unwrap(), base); + + let empty: ProjectCreate = serde_json::from_value(json!({ + "name": "my-project", + "description": "My project", + "defaults": {}, + })) + .unwrap(); + assert_eq!( + empty.defaults.unwrap(), + ProjectCreateDefaults { vpc: None } + ); + + let vpc_all: ProjectCreate = serde_json::from_value(json!({ + "name": "my-project", + "description": "My project", + "defaults": { "vpc": { "type": "all" } }, + })) + .unwrap(); + assert_eq!( + vpc_all.defaults.unwrap(), + ProjectCreateDefaults { + vpc: Some(VpcCreateDefaultsSelection::All), + } + ); + + let vpc_only: ProjectCreate = serde_json::from_value(json!({ + "name": "my-project", + "description": "My project", + "defaults": { + "vpc": { "type": "explicit", "defaults": {} }, + }, + })) + .unwrap(); + assert_eq!( + vpc_only.defaults.unwrap(), + ProjectCreateDefaults { + vpc: Some(VpcCreateDefaultsSelection::Explicit { + defaults: VpcCreateDefaults { subnet: None }, + }), + } + ); + + let selected: ProjectCreate = serde_json::from_value(json!({ + "name": "my-project", + "description": "My project", + "defaults": { + "vpc": { + "type": "explicit", + "defaults": { "subnet": {} }, + }, + }, + })) + .unwrap(); + assert_eq!( + selected.defaults.unwrap(), + ProjectCreateDefaults { + vpc: Some(VpcCreateDefaultsSelection::Explicit { + defaults: VpcCreateDefaults { + subnet: Some(SubnetCreateDefaults {}), + }, + }), + } + ); + + let duplicate_vpc = r#"{ + "name": "my-project", + "description": "My project", + "defaults": { + "vpc": { "type": "all" }, + "vpc": { "type": "explicit", "defaults": {} } + } + }"#; + assert!(serde_json::from_str::(duplicate_vpc).is_err()); + + let missing_selection_type = json!({ + "name": "my-project", + "description": "My project", + "defaults": { "vpc": {} }, + }); + assert!( + serde_json::from_value::(missing_selection_type) + .is_err() + ); + + let unknown_default = json!({ + "name": "my-project", + "description": "My project", + "defaults": { "vpcc": {} }, + }); + assert!( + serde_json::from_value::(unknown_default).is_err() + ); + } +} diff --git a/nexus/types/versions/src/project_and_vpc_create_defaults/vpc.rs b/nexus/types/versions/src/project_and_vpc_create_defaults/vpc.rs new file mode 100644 index 00000000000..9771dc7cc3e --- /dev/null +++ b/nexus/types/versions/src/project_and_vpc_create_defaults/vpc.rs @@ -0,0 +1,120 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +use crate::v2025_11_20_00; +use omicron_common::api::external::{IdentityMetadataCreateParams, Name}; +use oxnet::Ipv6Net; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +/// Select default resources to create with a VPC. +#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum VpcCreateDefaultsSelection { + /// Create all current and future default resources. + All, + + /// Create only the explicitly selected default resources. + Explicit { defaults: VpcCreateDefaults }, +} + +/// Default resources to create with a VPC. +#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct VpcCreateDefaults { + /// Create a default subnet. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub subnet: Option, +} + +/// Configuration for a default subnet. +#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct SubnetCreateDefaults {} + +/// Create-time parameters for a `Vpc`. +#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] +pub struct VpcCreate { + #[serde(flatten)] + pub identity: IdentityMetadataCreateParams, + + /// The IPv6 prefix for this VPC + /// + /// All IPv6 subnets created from this VPC must be taken from this range, + /// which should be a Unique Local Address in the range `fd00::/48`. The + /// default VPC Subnet will have the first `/64` range from this prefix. + pub ipv6_prefix: Option, + + pub dns_name: Name, + + /// Default resources to create. + /// + /// If omitted, all default resources are created. If provided, only the + /// selected default resources are created. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub defaults: Option, +} + +impl From for VpcCreate { + fn from(old: v2025_11_20_00::vpc::VpcCreate) -> Self { + Self { + identity: old.identity, + ipv6_prefix: old.ipv6_prefix, + dns_name: old.dns_name, + defaults: None, + } + } +} + +#[cfg(test)] +mod tests { + use super::{SubnetCreateDefaults, VpcCreate, VpcCreateDefaults}; + use serde_json::json; + + #[test] + fn defaults_wire_format() { + let base = json!({ + "name": "my-vpc", + "description": "My VPC", + "ipv6_prefix": null, + "dns_name": "my-vpc", + }); + + let omitted: VpcCreate = serde_json::from_value(base.clone()).unwrap(); + assert!(omitted.defaults.is_none()); + assert_eq!(serde_json::to_value(omitted).unwrap(), base); + + let empty: VpcCreate = serde_json::from_value(json!({ + "name": "my-vpc", + "description": "My VPC", + "ipv6_prefix": null, + "dns_name": "my-vpc", + "defaults": {}, + })) + .unwrap(); + assert_eq!(empty.defaults.unwrap(), VpcCreateDefaults { subnet: None }); + + let subnet: VpcCreate = serde_json::from_value(json!({ + "name": "my-vpc", + "description": "My VPC", + "ipv6_prefix": null, + "dns_name": "my-vpc", + "defaults": { "subnet": {} }, + })) + .unwrap(); + assert_eq!( + subnet.defaults.unwrap(), + VpcCreateDefaults { subnet: Some(SubnetCreateDefaults {}) } + ); + + let unknown_default = json!({ + "name": "my-vpc", + "description": "My VPC", + "ipv6_prefix": null, + "dns_name": "my-vpc", + "defaults": { "subnett": {} }, + }); + assert!(serde_json::from_value::(unknown_default).is_err()); + } +} diff --git a/openapi/nexus/nexus-2026081901.0.0-e5e4f5.json.gitstub b/openapi/nexus/nexus-2026081901.0.0-e5e4f5.json.gitstub new file mode 100644 index 00000000000..65ba4574110 --- /dev/null +++ b/openapi/nexus/nexus-2026081901.0.0-e5e4f5.json.gitstub @@ -0,0 +1 @@ +6b1ca22b4066024f8ac2c04bd678d6e09bf50a6f:openapi/nexus/nexus-2026081901.0.0-e5e4f5.json diff --git a/openapi/nexus/nexus-2026081901.0.0-e5e4f5.json b/openapi/nexus/nexus-2026081902.0.0-e008a6.json similarity index 99% rename from openapi/nexus/nexus-2026081901.0.0-e5e4f5.json rename to openapi/nexus/nexus-2026081902.0.0-e008a6.json index 0e2c3b606df..33d2b92fa12 100644 --- a/openapi/nexus/nexus-2026081901.0.0-e5e4f5.json +++ b/openapi/nexus/nexus-2026081902.0.0-e008a6.json @@ -7,7 +7,7 @@ "url": "https://oxide.computer", "email": "api@oxide.computer" }, - "version": "2026081901.0.0" + "version": "2026081902.0.0" }, "paths": { "/device/auth": { @@ -26549,9 +26549,18 @@ ] }, "ProjectCreate": { - "description": "Create-time parameters for a `Project`", + "description": "Create-time parameters for a `Project`.", "type": "object", "properties": { + "defaults": { + "nullable": true, + "description": "Default resources to create.\n\nIf omitted, all default resources are created. If provided, only the selected default resources are created.", + "allOf": [ + { + "$ref": "#/components/schemas/ProjectCreateDefaults" + } + ] + }, "description": { "type": "string" }, @@ -26564,6 +26573,22 @@ "name" ] }, + "ProjectCreateDefaults": { + "description": "Default resources to create with a project.", + "type": "object", + "properties": { + "vpc": { + "nullable": true, + "description": "Create a default VPC with all or explicitly selected VPC defaults.", + "allOf": [ + { + "$ref": "#/components/schemas/VpcCreateDefaultsSelection" + } + ] + } + }, + "additionalProperties": false + }, "ProjectResultsPage": { "description": "A single page of results", "type": "object", @@ -28694,6 +28719,11 @@ "items" ] }, + "SubnetCreateDefaults": { + "description": "Configuration for a default subnet.", + "type": "object", + "additionalProperties": false + }, "SubnetPool": { "description": "A pool of subnets for external subnet allocation", "type": "object", @@ -30952,9 +30982,18 @@ ] }, "VpcCreate": { - "description": "Create-time parameters for a `Vpc`", + "description": "Create-time parameters for a `Vpc`.", "type": "object", "properties": { + "defaults": { + "nullable": true, + "description": "Default resources to create.\n\nIf omitted, all default resources are created. If provided, only the selected default resources are created.", + "allOf": [ + { + "$ref": "#/components/schemas/VpcCreateDefaults" + } + ] + }, "description": { "type": "string" }, @@ -30980,6 +31019,61 @@ "name" ] }, + "VpcCreateDefaults": { + "description": "Default resources to create with a VPC.", + "type": "object", + "properties": { + "subnet": { + "nullable": true, + "description": "Create a default subnet.", + "allOf": [ + { + "$ref": "#/components/schemas/SubnetCreateDefaults" + } + ] + } + }, + "additionalProperties": false + }, + "VpcCreateDefaultsSelection": { + "description": "Select default resources to create with a VPC.", + "oneOf": [ + { + "description": "Create all current and future default resources.", + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "all" + ] + } + }, + "required": [ + "type" + ] + }, + { + "description": "Create only the explicitly selected default resources.", + "type": "object", + "properties": { + "defaults": { + "$ref": "#/components/schemas/VpcCreateDefaults" + }, + "type": { + "type": "string", + "enum": [ + "explicit" + ] + } + }, + "required": [ + "defaults", + "type" + ] + } + ] + }, "VpcFirewallIcmpFilter": { "type": "object", "properties": { diff --git a/openapi/nexus/nexus-latest.json b/openapi/nexus/nexus-latest.json index 1ed456d7c97..f1c87e84e65 120000 --- a/openapi/nexus/nexus-latest.json +++ b/openapi/nexus/nexus-latest.json @@ -1 +1 @@ -nexus-2026081901.0.0-e5e4f5.json \ No newline at end of file +nexus-2026081902.0.0-e008a6.json \ No newline at end of file