Skip to content

feat: allow skipping default project and vpc resources - #9750

Merged
sudomateo merged 4 commits into
mainfrom
sudomateo/lsswxqnvlnty
Sep 9, 2026
Merged

feat: allow skipping default project and vpc resources#9750
sudomateo merged 4 commits into
mainfrom
sudomateo/lsswxqnvlnty

Conversation

@sudomateo

@sudomateo sudomateo commented Jan 30, 2026

Copy link
Copy Markdown
Member

Allow callers of project_create and vpc_create to select which optional default resources are created. This enables automation to avoid deleting unwanted defaults after creation.

For project_create:

  • omit defaults to create the default VPC and subnet
  • "defaults": {} to create neither
  • "defaults": {"vpc": {"type": "all"}} to create both
  • "defaults": {"vpc": {"type": "explicit", "defaults": {}}} to create only the default VPC
  • "defaults": {"vpc": {"type": "explicit", "defaults": {"subnet": {}}}} to create both

For vpc_create:

  • omit defaults to create the default subnet
  • "defaults": {} to create no default subnet
  • "defaults": {"subnet": {}} to create the default subnet

Closes #10973.

@sudomateo sudomateo left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gave things a one pass after getting the API versioning stuff to work locally.

Comment thread nexus/external-api/src/lib.rs Outdated
Comment thread nexus/external-api/src/lib.rs Outdated
Comment thread nexus/external-api/src/v2026020100.rs Outdated
Comment thread nexus/src/app/sagas/project_create.rs Outdated
Comment thread nexus/src/app/sagas/project_create.rs Outdated
Comment thread nexus/types/src/external_api/params.rs Outdated
@karencfv

karencfv commented Feb 1, 2026

Copy link
Copy Markdown
Contributor

This is great to see! It goes in line with an initiative from a while ago https://rfd.shared.oxide.computer/rfd/0390 . I have to wonder though, how many users want/use the default VPC? If the majority don't use it (e.g. they manage their resources via terraform or similar), would it make more sense to have an optional field that triggers the creation of a default VPC instead of a field that skips the creation of said VPC?

@sudomateo
sudomateo force-pushed the sudomateo/lsswxqnvlnty branch from fd22224 to 299174d Compare February 1, 2026 21:53
@sudomateo

Copy link
Copy Markdown
Member Author

This is great to see! It goes in line with an initiative from a while ago https://rfd.shared.oxide.computer/rfd/0390.

Heck yeah! I haven't seen this previously but I have chatted with enough customers that want this feature and I feel their pain trying to use automation to manage Oxide completely without needing to add some out of band API call to delete the default VPC.

I have to wonder though, how many users want/use the default VPC? If the majority don't use it (e.g. they manage their resources via terraform or similar), would it make more sense to have an optional field that triggers the creation of a default VPC instead of a field that skips the creation of said VPC?

I don't have numbers on this personally, but with the upcoming dual-stack networking changes I have to wonder how useful it is for us to create any default networking resources in a project. It's not like we'd know whether the user will want a V4, V6, or dual-stack.

I went with the approach I did, which I'm not married to, just because it was backwards compatible in the sense that creating a project after this change will still create the default VPC as it did before. I'm open to different designs though especially if we decide to step further away from any default resources.

@karencfv

karencfv commented Feb 2, 2026

Copy link
Copy Markdown
Contributor

needing to add some out of band API call to delete the default VPC.

Ugh, yeah. That's not really great at all 😖

I don't have numbers on this personally, but with the upcoming dual-stack networking changes I have to wonder how useful it is for us to create any default networking resources in a project. It's not like we'd know whether the user will want a V4, V6, or dual-stack.

It could be useful to check in with the networking team and have a discussion on whether we want to have any default resources at all. Back when https://rfd.shared.oxide.computer/rfd/0390 was written, we didn't have customer feedback, but now we do! A simpler API is always easier to use, if users don't have to decide on default resource creation, that would be really nice.

@sudomateo

Copy link
Copy Markdown
Member Author

It could be useful to check in with the networking team and have a discussion on whether we want to have any default resources at all. Back when https://rfd.shared.oxide.computer/rfd/0390 was written, we didn't have customer feedback, but now we do! A simpler API is always easier to use, if users don't have to decide on default resource creation, that would be really nice.

I'll bring up on the sync!

@askfongjojo

Copy link
Copy Markdown

See also #1015.

@sudomateo

Copy link
Copy Markdown
Member Author

We discussed this on today's control plane sync. The consensus was to maintain default VPC/subnet creation to support the happy path for web console users and allow some opt-out option for users that wish to have more control.

There was a bit of discussion whether this opt-out option would apply just to VPC/subnet creation or if it would mean "opt-out of all defaults in perpetuity". I don't have a strong opinion here. What do others think?

I'll have to update this pull request to get a cleaner implementation. I'm not exactly sure when I'll do that given my upcoming leave and other priorities, but it's on the list!

@sudomateo
sudomateo force-pushed the sudomateo/lsswxqnvlnty branch 4 times, most recently from 596d1ae to 2bc6421 Compare July 7, 2026 19:15
@rmustacc

rmustacc commented Jul 8, 2026

Copy link
Copy Markdown

There was a bit of discussion whether this opt-out option would apply just to VPC/subnet creation or if it would mean "opt-out of all defaults in perpetuity". I don't have a strong opinion here. What do others think?

I think we're going to have this problem in perpetuity and as we add other classes of things and evolve them, there are going to be more defaults here around what's allowed and what's set up. As an example here, today we only have a fixed set of roles in the RBAC. You could imagine a world where when that becomes more flexible and dynamically controllable having defaults there. From a use case perspective, customers who are managing this via terraform or similar are going to always want to skip all defaults, probably. So my assumption is that we want to have something that:

  1. When creating a project allows one to specify which defaults to create or not to have.
  2. This is going to want to be true recursively through things with defaults.
  3. We should have a uniform way of expressing this on creation of X.

Here's a strawman:

  • On project create add a new optional defaults: [ String Array].
  • Not specifying this means to create with everything.
  • Specifying an empty array is a way for saying none
  • We can list basically a set of defaults to be created like vpc or just have an alias all perhaps.
    • I would start with only the minimal subset

Ultimately this would add this to both projects and vpcs. We can then figure out how large or small to scope things and basically say defaults: [ all ] or defaults: [] is all we support today and allow more fine-grained aspects. The need for fine-grained defaults is more obvious at the project level, though the VPC could eventually have them with things that vary such as firewall and router though I think we'd want feedback that that's actually valuable.

@david-crespo

Copy link
Copy Markdown
Contributor

At first glance, I like this proposal!

@sudomateo

Copy link
Copy Markdown
Member Author

I meant to comment on this after Robert and I chatted out of band but I also like the proposal. I want to think a bit more about how nested defaults would work here. For example, project_create creates a default VPC which in turn creates a default subnet within the VPC.

  • Do we want to allow calls to project_create to create the default VPC but not the default subnet?
  • How would that look from the API?

Robert briefly mentioned something like this where the nested structure informs the shape.

# Create the default VPC and subnet.
"defaults": [
  "vpc": [
    "subnet"
  ]
]

# Create the default VPC only.
"defaults": [
  "vpc": []
]

# Create no defaults.
"defaults": []

# Omitting "defaults" creates all the defaults.

@david-crespo any opinion here?

@sudomateo
sudomateo force-pushed the sudomateo/lsswxqnvlnty branch from 2bc6421 to dc6710d Compare August 1, 2026 05:05
@sudomateo
sudomateo force-pushed the sudomateo/lsswxqnvlnty branch from dc6710d to 8ec58ce Compare August 20, 2026 01:42
@sudomateo sudomateo changed the title feat: skip default vpc feat: allow skipping default project and vpc resources Aug 20, 2026
@sudomateo
sudomateo marked this pull request as ready for review August 20, 2026 02:54
@david-crespo

david-crespo commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

I did a bit of work on what a good defaults DSL would look like last time I was tagged, but didn't feel strongly enough about it to post. Now that you have it built, it's easier to compare to what I landed on at the time, which is a tree of objects instead of lists of enum variants. The semantics are the same as yours: omitting defaults means all, and any explicit value is a closed allow-list — but my version uses nested structs/objects instead of the set of enum values to avoid some problems yours has.

/// Default resources to create with a project.
pub struct ProjectCreateDefaults {
    /// Create a default VPC with the selected VPC defaults.
    pub vpc: Option<VpcCreateDefaults>,
}

/// Default resources to create with a VPC.
pub struct VpcCreateDefaults {
    /// Create a default subnet.
    pub subnet: Option<SubnetCreateDefaults>,
}

/// Empty for now; leaves room for per-resource config later.
pub struct SubnetCreateDefaults {}

They look almost the same for stuff that you can do today:

Intent This PR Struct tree
All defaults, incl. future ones omit defaults omit defaults
No defaults "defaults": [] "defaults": {}
VPC, no subnet "defaults": [{"vpc": []}] "defaults": {"vpc": {}}
VPC + subnet "defaults": [{"vpc": ["subnet"]}] "defaults": {"vpc": {"subnet": {}}}

Problems with the current design

This section was initially written by 🤖 but I edited it quite a bit and I endorse it.

1. Conflicting selections. The set shape makes it possible to specify vpc twice with different contents:

"defaults": [{"vpc": []}, {"vpc": ["subnet"]}]

Nothing currently rejects this. uniqueItems in the schema (and BTreeSet dedup on the server) only catch identical entries, and these two are different values, so the request parses into a two-element set. The saga then has to decide which one to honor, and the current code does it by accident: default_vpc_defaults calls .iter().next(), which returns the first element in BTreeSet order. Derived Ord sorts the empty set before the non-empty one, so {"vpc": []} always wins — regardless of the order in the JSON. Net result: a request that says "create the default subnet" in one of its entries returns 201 and creates no subnet. (Verified against the PR's exact types.)

We could avoid it with validation rejecting multiple vpc entries, but the struct shape just makes the problem impossible. The only way to include vpc twice is with a duplicate key in one JSON object:

"defaults": {"vpc": {}, "vpc": {"subnet": {}}}

The SDKs can't even produce that (actually I think TS can) and if someone does it in raw JSON, serde 400s at parse time with duplicate field `vpc` .

2. Extending a leaf later. Suppose the default subnet becomes configurable (name, IP block). The struct shape extends neatly:

"defaults": {"subnet": {}}                          // today
"defaults": {"subnet": {"ipv4_block": "..."}}       // later, non-breaking

In this PR's current shape, "subnet" is a bare string (serde unit variant). Turning Subnet into Subnet(SubnetCreateDefaults) changes the shape from "subnet" to {"subnet": {...}}, which breaks existing requests. And the moment any VPC-level variant carries a payload, VpcCreateDefault becomes a string | object oneOf. That asymmetry already shows up here: project-level entries are objects ({"vpc": [...]}) while VPC-level entries are strings.

3. Generated clients. The oneOf this generates is different from the tagged enums we have elsewhere in the API. Most omicron enums serialize with an explicit "type" field ({"type": "ip", "value": "..."}), and oxide.go knows how to turn those into proper Go unions. ProjectCreateDefault instead serializes as an object with a single key named after the variant — {"vpc": [...]}, no "type" field. The generator has no handling for that form. Instead of generating a union type, it generates a single ordinary struct where each variant's content becomes an optional field:

type ProjectCreateDefault struct {
    Vpc []VpcCreateDefault `json:"vpc,omitempty"`
}

That type is actually broken for this API: omitempty drops an empty slice, so "default VPC, no subnet" marshals to "defaults": [{}], which the server rejects with a 400. I verified this by running the generator against this PR's spec and sending the output to the new deserializer.

To be fair, I also ran the generator against the nested object version, and that output has a bug too: optional object fields come out as value types (Defaults ProjectCreateDefaults with omitempty, which never omits structs in Go), so a user who sets nothing sends "defaults": {"vpc": {"subnet": {}}} — a frozen explicit selection instead of an omitted field, silently opting them out of future defaults. But the fix is simpler than for the enum sets version. The struct shape needs optional object fields emitted as pointers — a small generic change, and the generator already has a mechanism for forcing this (the nullable() exception list). The set shape needs the generator to grow a whole new union form: an interface type, one concrete struct per variant, custom marshal/unmarshal. And even fixed, it's clunky to use. Writing a value means wrapping it in the variant's struct inside a slice:

Defaults: []oxide.ProjectCreateDefault{
    &oxide.ProjectCreateDefaultVpc{Vpc: []oxide.VpcCreateDefault{oxide.VpcCreateDefaultSubnet}},
}

and since Go interfaces don't tell you which variant you're holding, reading a value back means a type switch over the concrete variant types (switch v := d.(type) { case *ProjectCreateDefaultVpc: ... }), with a new case for every default we add. The fixed struct shape is plain nested objects — values written by setting fields, read by nil-checking them:

Defaults: &oxide.ProjectCreateDefaults{
    Vpc: &oxide.VpcCreateDefaults{Subnet: &oxide.SubnetCreateDefaults{}},
}

and nil vs &struct{} is exactly the absent-vs-empty distinction this API requires.

Smart-sounding 🤖 paragraph about Terraform that I cannot verify

Terraform also has a hard time with the current design: the plugin framework has no union type, so our provider already flattens the API's unions into type/value string pairs with hand-written validators and docs explaining which value goes with which type (see vpc_router_route). The set shape would need that treatment plus set-level validation to reject {} elements and conflicting duplicates like [{vpc = []}, {vpc = ["subnet"]}]. The struct shape is just nested optional attributes — defaults = { vpc = { subnet = {} } } — the framework's native idiom, with omit-vs-{} mapping directly onto the absent-vs-empty semantics.

@sudomateo

Copy link
Copy Markdown
Member Author

Thank you @david-crespo and 🤖. I addressed the pointer issue in the Go SDK generator in oxidecomputer/oxide.go#444, so this tree of objects approach will work smoothly there. For Terraform, we'll have to design a schema to account for this, though I think with the current design it would basically mirror the API structure.

@david-crespo

Copy link
Copy Markdown
Contributor

Looks good at a glance, will look closer in a bit. I think this is correct from the TypeScript client generator, though I'll have to see if there is a better way to represent empty object than Record<string, unknown>. We could do Record<string, never>, but I'll have to think about whether that's better. In the meantime this will work fine.

Screenshot 2026-08-21 at 10 49 55 AM

}
builder.append(vpc_update_firewall_action());
builder.append(vpc_create_gateway_action());
builder.append(vpc_notify_sleds_action());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure what we can do about this besides adding documentation (both here and in the guides), but it's a big possible area of confusion that only the subnet is subject to the defaults config — there are plenty of other defaults that are unavoidable.

)?,
"vpc_create_params",
));
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like VPC is the only automatically created child for projects, so it's a lot simpler to explain and reason about than the VPC defaults. So that's nice I guess :)

@david-crespo

Copy link
Copy Markdown
Contributor

Might be worth giving this 404 on instance create with no default subnet

let (.., authz_subnet, db_subnet) = LookupPath::new(&opctx, datastore)
.project_id(*project_id)
.vpc_name(&internal_default_name)
.vpc_subnet_name(&internal_default_name)
.fetch()
.await
.map_err(saga_action_failed)?;

a better message along the lines of:

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

Looks like you can use the oddly named Error::non_resourcetype_not_found("...") to get a 404 with a custom message.

@rmustacc

rmustacc commented Aug 22, 2026

Copy link
Copy Markdown

FWIW, when I originally had followed up with @sudomateo I was using : delineated strings as a way to represent a tree-like structure I guess. e.g. I had suggested defaults: [ "vpc:subnet" ] to get a VPC that enables the default subnet.

@david-crespo regarding the intent of "defaults": {"vpc": {}}, I think this is better phrased as create a default VPC, but get no other contents. Today it is just the subnet, but this would opt out of all other defaults such as a future world where we enable say rbac there, would not come.

I guess the string or object is too much of a pain so we have to have something that's a pain to write out? It feels like with your subnet case the actual thing you should be writing is "defaults": {"vpc": {"subnet": { "all" }}}. I dunno, I think we probably need to tease out the forwards / backwards compat story with this unlimited nesting. It's not clear to me what happens for these nested things and what does it mean. I think it may help to clarify that my original proposal was trying to say a given simple top-level string implies all of its children.

What I don't see in your comparison is I want a default vpc and all of its things but not the rest.

@david-crespo

Copy link
Copy Markdown
Contributor

Yeah, I was thinking discriminated union would probably be better than string | object for the OpenAPI schema and the clients. TypeScript can handle weird unions but I think it would be messy in Rust and Go.

@sudomateo

Copy link
Copy Markdown
Member Author

I think 434e09d balances both @rmustacc's and @david-crespo's concerns by making it possible to describe explicit or implicit defaults. I'll give this a fresh look next week though my local testing showed the desired outcome.

@sudomateo
sudomateo force-pushed the sudomateo/lsswxqnvlnty branch from 434e09d to d132008 Compare August 25, 2026 15:04
@sudomateo
sudomateo force-pushed the sudomateo/lsswxqnvlnty branch from b35222c to 1798d3c Compare September 8, 2026 01:37
@david-crespo

Copy link
Copy Markdown
Contributor

Robot noticed VpcCreateDefaultsSelection was missing deny_unknown_fields, while the other default structs have it. This also requires changing All to All {} in the definition of the enum (and updating all the matches to go with that), otherwise serde's deserialize impl won't enforce deny_unknown_fields, as demonstrated by the added test failing without the All {} (serde source).

diff to add
diff --git a/nexus/src/app/sagas/project_create.rs b/nexus/src/app/sagas/project_create.rs
index 4294fbe663..6f511bbbb2 100644
--- a/nexus/src/app/sagas/project_create.rs
+++ b/nexus/src/app/sagas/project_create.rs
@@ -86,7 +86,7 @@
         None => Some(None),
         Some(defaults) => {
             defaults.vpc.as_ref().map(|selection| match selection {
-                vpc::VpcCreateDefaultsSelection::All => None,
+                vpc::VpcCreateDefaultsSelection::All {} => None,
                 vpc::VpcCreateDefaultsSelection::Explicit { defaults } => {
                     Some(defaults)
                 }
@@ -217,7 +217,7 @@
         assert_eq!(default_vpc_defaults(&project_create), None);
 
         project_create.defaults = Some(project::ProjectCreateDefaults {
-            vpc: Some(vpc::VpcCreateDefaultsSelection::All),
+            vpc: Some(vpc::VpcCreateDefaultsSelection::All {}),
         });
         assert_eq!(default_vpc_defaults(&project_create), Some(None));
 
diff --git a/nexus/tests/integration_tests/projects.rs b/nexus/tests/integration_tests/projects.rs
index 29a63213ac..30877f8004 100644
--- a/nexus/tests/integration_tests/projects.rs
+++ b/nexus/tests/integration_tests/projects.rs
@@ -178,7 +178,7 @@
         client,
         "defaults-vpc-all",
         Some(project::ProjectCreateDefaults {
-            vpc: Some(vpc::VpcCreateDefaultsSelection::All),
+            vpc: Some(vpc::VpcCreateDefaultsSelection::All {}),
         }),
     )
     .await;
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
index a2a1083a96..e34da429da 100644
--- a/nexus/types/versions/src/project_and_vpc_create_defaults/project.rs
+++ b/nexus/types/versions/src/project_and_vpc_create_defaults/project.rs
@@ -46,6 +46,23 @@
     use serde_json::json;
 
     #[test]
+    fn defaults_selection_rejects_unknown_fields() {
+        for selection in [
+            json!({ "type": "all", "defaults": {} }),
+            json!({ "type": "explicit", "defaults": {}, "subnet": {} }),
+        ] {
+            let request = json!({
+                "name": "my-project",
+                "description": "My project",
+                "defaults": { "vpc": selection },
+            });
+            let error = serde_json::from_value::<ProjectCreate>(request)
+                .expect_err("unknown selection fields must be rejected");
+            assert!(error.to_string().contains("unknown field"), "{error}");
+        }
+    }
+
+    #[test]
     fn defaults_wire_format() {
         let base = json!({
             "name": "my-project",
@@ -77,7 +94,7 @@
         assert_eq!(
             vpc_all.defaults.unwrap(),
             ProjectCreateDefaults {
-                vpc: Some(VpcCreateDefaultsSelection::All),
+                vpc: Some(VpcCreateDefaultsSelection::All {}),
             }
         );
 
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
index 114862bf19..94aed67b62 100644
--- a/nexus/types/versions/src/project_and_vpc_create_defaults/vpc.rs
+++ b/nexus/types/versions/src/project_and_vpc_create_defaults/vpc.rs
@@ -10,10 +10,11 @@
 
 /// Select default resources to create with a VPC.
 #[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
-#[serde(tag = "type", rename_all = "snake_case")]
+#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
 pub enum VpcCreateDefaultsSelection {
     /// Create all current and future default resources.
-    All,
+    // Use a struct variant so deny_unknown_fields is enforced here too.
+    All {},
 
     /// Create only the explicitly selected default resources.
     Explicit { defaults: VpcCreateDefaults },
diff --git a/openapi/nexus/nexus-2026090800.0.0-434b53.json b/openapi/nexus/nexus-2026090800.0.0-8eb11c.json
rename from openapi/nexus/nexus-2026090800.0.0-434b53.json
rename to openapi/nexus/nexus-2026090800.0.0-8eb11c.json
index 35bad05c43..a813bdef3a 100644
--- a/openapi/nexus/nexus-2026090800.0.0-434b53.json
+++ b/openapi/nexus/nexus-2026090800.0.0-8eb11c.json
@@ -31052,7 +31052,8 @@
             },
             "required": [
               "type"
-            ]
+            ],
+            "additionalProperties": false
           },
           {
             "description": "Create only the explicitly selected default resources.",
@@ -31071,7 +31072,8 @@
             "required": [
               "defaults",
               "type"
-            ]
+            ],
+            "additionalProperties": false
           }
         ]
       },
diff --git a/openapi/nexus/nexus-latest.json b/openapi/nexus/nexus-latest.json
index ea0f270081..68f13ecdeb 120000
--- a/openapi/nexus/nexus-latest.json
+++ b/openapi/nexus/nexus-latest.json
@@ -1,1 +1,1 @@
-nexus-2026090800.0.0-434b53.json
\ No newline at end of file
+nexus-2026090800.0.0-8eb11c.json
\ No newline at end of file

@david-crespo

Copy link
Copy Markdown
Contributor

I also suffered worked quite a bit on the doc comments — adding detail, making sure they were consistent with each other, and removing a few words I found confusing, like "optional".

diff of doc comment suggestions
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
index e34da429da..d6cb94ff32 100644
--- a/nexus/types/versions/src/project_and_vpc_create_defaults/project.rs
+++ b/nexus/types/versions/src/project_and_vpc_create_defaults/project.rs
@@ -8,11 +8,18 @@
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
 
-/// Default resources to create with a project.
+/// Default resources to create in a project
+///
+/// Each field corresponds to one resource. Set a field to an object to create
+/// that resource. Omit it or pass `null` to skip it.
 #[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.
+    /// Create the default VPC. Omit this field (or pass `null`) to skip it.
+    ///
+    /// When present, the value also determines which of the VPC's own defaults
+    /// to create: `{"type": "all"}` creates all of them, and `{"type":
+    /// "explicit", "defaults": {...}}` creates only those specified.
     #[serde(default, skip_serializing_if = "Option::is_none")]
     pub vpc: Option<VpcCreateDefaultsSelection>,
 }
@@ -23,10 +30,14 @@
     #[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.
+    /// Default resources to create in the project
+    ///
+    /// Omit this field (or pass `null`) to create all defaults: currently, a
+    /// default VPC with its own defaults. Pass an object to specify which
+    /// resources to create. `{}` creates none.
+    ///
+    /// For example, to create the default VPC but not its default subnet, pass
+    /// `{"vpc": {"type": "explicit", "defaults": {}}}`.
     #[serde(default, skip_serializing_if = "Option::is_none")]
     pub defaults: Option<ProjectCreateDefaults>,
 }
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
index 94aed67b62..84c9186101 100644
--- a/nexus/types/versions/src/project_and_vpc_create_defaults/vpc.rs
+++ b/nexus/types/versions/src/project_and_vpc_create_defaults/vpc.rs
@@ -8,28 +8,39 @@
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
 
-/// Select default resources to create with a VPC.
+/// Default resources to create in a VPC
 #[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
 #[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
 pub enum VpcCreateDefaultsSelection {
-    /// Create all current and future default resources.
+    /// Create all default resources
     // Use a struct variant so deny_unknown_fields is enforced here too.
     All {},
 
-    /// Create only the explicitly selected default resources.
+    /// Create only the default resources listed in `defaults`. Pass `{}` as
+    /// `defaults` to skip them all.
     Explicit { defaults: VpcCreateDefaults },
 }
 
-/// Default resources to create with a VPC.
+/// Default resources to create in a VPC
+///
+/// Each field corresponds to one resource. Set a field to an object to create
+/// that resource. Omit it or pass `null` to skip it.
+///
+/// This does not affect the system router, default firewall rules, or default
+/// internet gateway, which are always created.
 #[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
 #[serde(deny_unknown_fields)]
 pub struct VpcCreateDefaults {
-    /// Create a default subnet.
+    /// Create the default subnet. Pass `{}` to create it and omit this field
+    /// (or pass `null`) to skip it.
     #[serde(default, skip_serializing_if = "Option::is_none")]
     pub subnet: Option<SubnetCreateDefaults>,
 }
 
-/// Configuration for a default subnet.
+/// Default resources to create in the default subnet
+///
+/// Including this object in the request creates the default subnet. A subnet
+/// has no default resources yet, so the object is always empty.
 #[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
 #[serde(deny_unknown_fields)]
 pub struct SubnetCreateDefaults {}
@@ -44,15 +55,20 @@
     ///
     /// 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.
+    /// default subnet, if requested, will take the first `/64` range from this
+    /// prefix.
     pub ipv6_prefix: Option<Ipv6Net>,
 
     pub dns_name: Name,
 
-    /// Default resources to create.
-    ///
-    /// If omitted, all default resources are created. If provided, only the
-    /// selected default resources are created.
+    /// Default resources to create in the VPC
+    ///
+    /// Omit this field (or pass `null`) to create all defaults: currently, the
+    /// default subnet. Pass an object to specify which resources to create. `{}`
+    /// creates none.
+    ///
+    /// This does not affect the system router, default firewall rules, or
+    /// default internet gateway, which are always created.
     #[serde(default, skip_serializing_if = "Option::is_none")]
     pub defaults: Option<VpcCreateDefaults>,
 }
diff --git a/openapi/nexus/nexus-2026090800.0.0-8eb11c.json b/openapi/nexus/nexus-2026090800.0.0-145fbf.json
rename from openapi/nexus/nexus-2026090800.0.0-8eb11c.json
rename to openapi/nexus/nexus-2026090800.0.0-145fbf.json
index a813bdef3a..457875b535 100644
--- a/openapi/nexus/nexus-2026090800.0.0-8eb11c.json
+++ b/openapi/nexus/nexus-2026090800.0.0-145fbf.json
@@ -26554,7 +26554,7 @@
         "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.",
+            "description": "Default resources to create in the project\n\nOmit this field (or pass `null`) to create all defaults: currently, a default VPC with its own defaults. Pass an object to specify which resources to create. `{}` creates none.\n\nFor example, to create the default VPC but not its default subnet, pass `{\"vpc\": {\"type\": \"explicit\", \"defaults\": {}}}`.",
             "allOf": [
               {
                 "$ref": "#/components/schemas/ProjectCreateDefaults"
@@ -26574,12 +26574,12 @@
         ]
       },
       "ProjectCreateDefaults": {
-        "description": "Default resources to create with a project.",
+        "description": "Default resources to create in a project\n\nEach field corresponds to one resource. Set a field to an object to create that resource. Omit it or pass `null` to skip it.",
         "type": "object",
         "properties": {
           "vpc": {
             "nullable": true,
-            "description": "Create a default VPC with all or explicitly selected VPC defaults.",
+            "description": "Create the default VPC. Omit this field (or pass `null`) to skip it.\n\nWhen present, the value also determines which of the VPC's own defaults to create: `{\"type\": \"all\"}` creates all of them, and `{\"type\": \"explicit\", \"defaults\": {...}}` creates only those specified.",
             "allOf": [
               {
                 "$ref": "#/components/schemas/VpcCreateDefaultsSelection"
@@ -28721,7 +28721,7 @@
         ]
       },
       "SubnetCreateDefaults": {
-        "description": "Configuration for a default subnet.",
+        "description": "Default resources to create in the default subnet\n\nIncluding this object in the request creates the default subnet. A subnet has no default resources yet, so the object is always empty.",
         "type": "object",
         "additionalProperties": false
       },
@@ -30988,7 +30988,7 @@
         "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.",
+            "description": "Default resources to create in the VPC\n\nOmit this field (or pass `null`) to create all defaults: currently, the default subnet. Pass an object to specify which resources to create. `{}` creates none.\n\nThis does not affect the system router, default firewall rules, or default internet gateway, which are always created.",
             "allOf": [
               {
                 "$ref": "#/components/schemas/VpcCreateDefaults"
@@ -31003,7 +31003,7 @@
           },
           "ipv6_prefix": {
             "nullable": true,
-            "description": "The IPv6 prefix for this VPC\n\nAll 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.",
+            "description": "The IPv6 prefix for this VPC\n\nAll 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 subnet, if requested, will take the first `/64` range from this prefix.",
             "allOf": [
               {
                 "$ref": "#/components/schemas/Ipv6Net"
@@ -31021,12 +31021,12 @@
         ]
       },
       "VpcCreateDefaults": {
-        "description": "Default resources to create with a VPC.",
+        "description": "Default resources to create in a VPC\n\nEach field corresponds to one resource. Set a field to an object to create that resource. Omit it or pass `null` to skip it.\n\nThis does not affect the system router, default firewall rules, or default internet gateway, which are always created.",
         "type": "object",
         "properties": {
           "subnet": {
             "nullable": true,
-            "description": "Create a default subnet.",
+            "description": "Create the default subnet. Pass `{}` to create it and omit this field (or pass `null`) to skip it.",
             "allOf": [
               {
                 "$ref": "#/components/schemas/SubnetCreateDefaults"
@@ -31037,10 +31037,10 @@
         "additionalProperties": false
       },
       "VpcCreateDefaultsSelection": {
-        "description": "Select default resources to create with a VPC.",
+        "description": "Default resources to create in a VPC",
         "oneOf": [
           {
-            "description": "Create all current and future default resources.",
+            "description": "Create all default resources",
             "type": "object",
             "properties": {
               "type": {
@@ -31056,7 +31056,7 @@
             "additionalProperties": false
           },
           {
-            "description": "Create only the explicitly selected default resources.",
+            "description": "Create only the default resources listed in `defaults`. Pass `{}` as `defaults` to skip them all.",
             "type": "object",
             "properties": {
               "defaults": {
diff --git a/openapi/nexus/nexus-latest.json b/openapi/nexus/nexus-latest.json
index 68f13ecdeb..13dc7fd6c3 120000
--- a/openapi/nexus/nexus-latest.json
+++ b/openapi/nexus/nexus-latest.json
@@ -1,1 +1,1 @@
-nexus-2026090800.0.0-8eb11c.json
\ No newline at end of file
+nexus-2026090800.0.0-145fbf.json
\ No newline at end of file

Allow callers of `project_create` and `vpc_create` to select which
default resources are created. This enables automation to control every
resource without deleting unwanted defaults after creation.

For `project_create`:

- omit `defaults` to create the default VPC and subnet
- `"defaults": {}` to create neither
- `"defaults": {"vpc": {}}` to create only the default VPC
- `"defaults": {"vpc": {"subnet": {}}}` to create both

For `vpc_create`:

- omit `defaults` to create the default subnet
- `"defaults": {}` to create no default subnet
- `"defaults": {"subnet": {}}` to create the default subnet

Closes SSE-420, #10973.
Updated the implementation to use tagged unions for resource defaults,
allowing both explicit and implicit defaults to be specified.

The `instance_create` API was updated with a more descriptive error
message when attempting to create an instance using the default network
interface when the corresponding default VPC and subnet do not exist.
Added a test to verify the created VPCs can omit the default subnet and be deleted immediately.

Amp-Thread-ID: https://ampcode.com/threads/T-01a04459-8653-72b8-8021-7b7de37c68cf
Applied feedback to use `deny_unknown_fields` and update the
documentation comments.
@sudomateo
sudomateo force-pushed the sudomateo/lsswxqnvlnty branch from 1798d3c to c39a663 Compare September 8, 2026 23:43
@sudomateo
sudomateo enabled auto-merge (squash) September 9, 2026 00:05
@sudomateo
sudomateo disabled auto-merge September 9, 2026 00:05
@sudomateo
sudomateo enabled auto-merge (squash) September 9, 2026 00:08
@sudomateo
sudomateo merged commit 7ff4a21 into main Sep 9, 2026
19 checks passed
@sudomateo
sudomateo deleted the sudomateo/lsswxqnvlnty branch September 9, 2026 00:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

project_create: do not create default VPC

5 participants