diff --git a/Cargo.lock b/Cargo.lock index b40ba30c392..c1759410f0e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6948,9 +6948,11 @@ dependencies = [ "oximeter-types 0.1.0", "oxnet", "oxql-types", + "proptest", "schemars 0.8.22", "scim2-rs", "serde", + "test-strategy", "tufaceous-artifact", "uuid", ] diff --git a/nexus/external-api/Cargo.toml b/nexus/external-api/Cargo.toml index 627ad3ccb0d..49556080ce0 100644 --- a/nexus/external-api/Cargo.toml +++ b/nexus/external-api/Cargo.toml @@ -7,6 +7,10 @@ license = "MPL-2.0" [lints] workspace = true +[dev-dependencies] +proptest.workspace = true +test-strategy.workspace = true + [dependencies] anyhow.workspace = true base64.workspace = true diff --git a/nexus/external-api/src/lib.rs b/nexus/external-api/src/lib.rs index 6ce83ee99ee..8e6a065fcd6 100644 --- a/nexus/external-api/src/lib.rs +++ b/nexus/external-api/src/lib.rs @@ -41,6 +41,7 @@ mod v2025122300; mod v2026010100; mod v2026010300; mod v2026010500; +mod v2026011300; api_versions!([ // API versions are in the format YYYYMMDDNN.0.0, defined below as @@ -2180,7 +2181,8 @@ pub trait NexusExternalApi { new_instance: TypedBody, ) -> Result, HttpError> { let new_instance = new_instance.try_map(TryInto::try_into)?; - Self::instance_create(rqctx, query_params, new_instance).await + Self::v2026010300_instance_create(rqctx, query_params, new_instance) + .await } /// Create instance @@ -2197,7 +2199,8 @@ pub trait NexusExternalApi { new_instance: TypedBody, ) -> Result, HttpError> { let new_instance = new_instance.try_map(TryInto::try_into)?; - Self::instance_create(rqctx, query_params, new_instance).await + Self::v2026010500_instance_create(rqctx, query_params, new_instance) + .await } /// Create instance @@ -2213,16 +2216,34 @@ pub trait NexusExternalApi { query_params: Query, new_instance: TypedBody, ) -> Result, HttpError> { - Self::instance_create(rqctx, query_params, new_instance.map(Into::into)) + let new_instance = new_instance.map(Into::into); + Self::v2026011300_instance_create(rqctx, query_params, new_instance) .await } /// Create instance #[endpoint { + operation_id = "instance_create", method = POST, path = "/v1/instances", tags = ["instances"], - versions = VERSION_MULTICAST_IMPLICIT_LIFECYCLE_UPDATES.., + versions = VERSION_MULTICAST_IMPLICIT_LIFECYCLE_UPDATES..VERSION_VPC_SUBNET_ATTACHMENT, + }] + async fn v2026011300_instance_create( + rqctx: RequestContext, + query_params: Query, + new_instance: TypedBody, + ) -> Result, HttpError> { + let new_instance = new_instance.map(Into::into); + Self::instance_create(rqctx, query_params, new_instance).await + } + + /// Create instance + #[endpoint { + method = POST, + path = "/v1/instances", + tags = ["instances"], + versions = VERSION_VPC_SUBNET_ATTACHMENT.., }] async fn instance_create( rqctx: RequestContext, @@ -3432,13 +3453,38 @@ pub trait NexusExternalApi { HttpError, > { let interface_params = interface_params.try_map(TryInto::try_into)?; - let HttpResponseCreated(nic) = Self::instance_network_interface_create( + let HttpResponseCreated(nic) = + Self::v2026011300_instance_network_interface_create( + rqctx, + query_params, + interface_params, + ) + .await?; + nic.try_into().map(HttpResponseCreated).map_err(HttpError::from) + } + + /// Create network interface + #[endpoint { + operation_id = "instance_network_interface_create", + method = POST, + path = "/v1/network-interfaces", + tags = ["instances"], + versions = VERSION_DUAL_STACK_NICS..VERSION_VPC_SUBNET_ATTACHMENT, + }] + async fn v2026011300_instance_network_interface_create( + rqctx: RequestContext, + query_params: Query, + interface_params: TypedBody< + v2026011300::InstanceNetworkInterfaceCreate, + >, + ) -> Result, HttpError> { + let interface_params = interface_params.map(Into::into); + Self::instance_network_interface_create( rqctx, query_params, interface_params, ) - .await?; - nic.try_into().map(HttpResponseCreated).map_err(HttpError::from) + .await } /// Create network interface @@ -3446,7 +3492,7 @@ pub trait NexusExternalApi { method = POST, path = "/v1/network-interfaces", tags = ["instances"], - versions = VERSION_DUAL_STACK_NICS.., + versions = VERSION_VPC_SUBNET_ATTACHMENT.., }] async fn instance_network_interface_create( rqctx: RequestContext, diff --git a/nexus/external-api/src/v2026010100.rs b/nexus/external-api/src/v2026010100.rs index dabef26017b..11714bff6e0 100644 --- a/nexus/external-api/src/v2026010100.rs +++ b/nexus/external-api/src/v2026010100.rs @@ -86,8 +86,16 @@ pub enum InstanceNetworkInterfaceAttachment { None, } +// v2026010100 (PRE_DUAL_STACK_NICS) uses v2026011300's network interface types +// (pre-VPC_SUBNET_ATTACHMENT, with `subnet_name: Name`). +// +// The difference is in the NIC create parameters: +// - v2026010100 uses `ip: Option` + `transit_ips: Vec` +// - v2026011300 uses `ip_config: PrivateIpStackCreate` +use crate::v2026011300; + impl TryFrom - for params::InstanceNetworkInterfaceAttachment + for v2026011300::InstanceNetworkInterfaceAttachment { type Error = external::Error; @@ -245,7 +253,7 @@ pub struct InstanceNetworkInterfaceCreate { } impl TryFrom - for params::InstanceNetworkInterfaceCreate + for v2026011300::InstanceNetworkInterfaceCreate { type Error = external::Error; @@ -426,7 +434,7 @@ pub struct InstanceCreate { pub cpu_platform: Option, } -impl TryFrom for params::InstanceCreate { +impl TryFrom for v2026010300::InstanceCreate { type Error = external::Error; fn try_from(value: InstanceCreate) -> Result { @@ -438,20 +446,8 @@ impl TryFrom for params::InstanceCreate { hostname: value.hostname, user_data: value.user_data, network_interfaces, - external_ips: value - .external_ips - .into_iter() - .map(TryInto::try_into) - .collect::, _>>()?, - multicast_groups: value - .multicast_groups - .into_iter() - .map(|g| params::MulticastGroupJoinSpec { - group: g.into(), - source_ips: None, - ip_version: None, - }) - .collect(), + external_ips: value.external_ips, + multicast_groups: value.multicast_groups, disks: value.disks, boot_disk: value.boot_disk, ssh_public_keys: value.ssh_public_keys, @@ -487,3 +483,244 @@ impl TryFrom for ProbeInfo { }) } } + +#[cfg(test)] +mod tests { + use super::*; + use nexus_types::external_api::params::IpAssignment; + use proptest::prelude::*; + + /// Helper to create a NIC with the given IP and transit IPs. + fn make_nic( + ip: Option, + transit_ips: Vec, + ) -> InstanceNetworkInterfaceCreate { + InstanceNetworkInterfaceCreate { + identity: IdentityMetadataCreateParams { + name: "nic0".parse().unwrap(), + description: "test".to_string(), + }, + vpc_name: "default".parse().unwrap(), + subnet_name: "default".parse().unwrap(), + ip, + transit_ips, + } + } + + /// Strategy for generating IPv4 networks (address + prefix 8-30). + fn arb_ipv4_net() -> impl Strategy { + (any::(), 8u8..=30).prop_map(|(addr, prefix)| { + oxnet::Ipv4Net::new(addr, prefix) + .unwrap_or_else(|_| oxnet::Ipv4Net::new(addr, 24).unwrap()) + }) + } + + /// Strategy for generating IPv6 networks (address + prefix 16-120). + fn arb_ipv6_net() -> impl Strategy { + (any::(), 16u8..=120).prop_map(|(addr, prefix)| { + oxnet::Ipv6Net::new(addr, prefix) + .unwrap_or_else(|_| oxnet::Ipv6Net::new(addr, 64).unwrap()) + }) + } + + // ========================================================================= + // Semantic choice: old `Default` maps to new `DefaultDualStack` + // ========================================================================= + + #[test] + fn attachment_default_converts_to_dual_stack() { + let result: v2026011300::InstanceNetworkInterfaceAttachment = + InstanceNetworkInterfaceAttachment::Default.try_into().unwrap(); + assert!(matches!( + result, + v2026011300::InstanceNetworkInterfaceAttachment::DefaultDualStack + )); + } + + // ========================================================================= + // Semantic choice: no IP + no transit IPs defaults to dual-stack + // ========================================================================= + + #[test] + fn no_ip_no_transit_defaults_to_dual_stack() { + let result: v2026011300::InstanceNetworkInterfaceCreate = + make_nic(None, vec![]).try_into().unwrap(); + assert!(matches!( + result.ip_config, + PrivateIpStackCreate::DualStack { .. } + )); + } + + proptest! { + // ===================================================================== + // Property: IPv4 address produces V4 stack with that address + // ===================================================================== + + #[test] + fn ipv4_address_produces_v4_stack(ip in any::()) { + let nic = make_nic(Some(ip.into()), vec![]); + let result: v2026011300::InstanceNetworkInterfaceCreate = + nic.try_into().unwrap(); + + prop_assert!(matches!( + result.ip_config, + PrivateIpStackCreate::V4(v4) + if matches!(v4.ip, IpAssignment::Explicit(x) if x == ip) + )); + } + + // ===================================================================== + // Property: IPv6 address produces V6 stack with that address + // ===================================================================== + + #[test] + fn ipv6_address_produces_v6_stack(ip in any::()) { + let nic = make_nic(Some(ip.into()), vec![]); + let result: v2026011300::InstanceNetworkInterfaceCreate = + nic.try_into().unwrap(); + + prop_assert!(matches!( + result.ip_config, + PrivateIpStackCreate::V6(v6) + if matches!(v6.ip, IpAssignment::Explicit(x) if x == ip) + )); + } + + // ===================================================================== + // Property: IPv4 transit IPs are preserved in output + // ===================================================================== + + #[test] + fn ipv4_transit_ips_preserved( + ip in any::(), + transit in proptest::collection::vec(arb_ipv4_net(), 0..4), + ) { + let transit_ipnet: Vec = + transit.iter().copied().map(Into::into).collect(); + let nic = make_nic(Some(ip.into()), transit_ipnet); + let result: v2026011300::InstanceNetworkInterfaceCreate = + nic.try_into().unwrap(); + + prop_assert!(matches!( + result.ip_config, + PrivateIpStackCreate::V4(v4) if v4.transit_ips == transit + )); + } + + // ===================================================================== + // Property: IPv6 transit IPs are preserved in output + // ===================================================================== + + #[test] + fn ipv6_transit_ips_preserved( + ip in any::(), + transit in proptest::collection::vec(arb_ipv6_net(), 0..4), + ) { + let transit_ipnet: Vec = + transit.iter().copied().map(Into::into).collect(); + let nic = make_nic(Some(ip.into()), transit_ipnet); + let result: v2026011300::InstanceNetworkInterfaceCreate = + nic.try_into().unwrap(); + + prop_assert!(matches!( + result.ip_config, + PrivateIpStackCreate::V6(v6) if v6.transit_ips == transit + )); + } + + // ===================================================================== + // Property: Mixed IPv4/IPv6 transit IPs always fail + // ===================================================================== + + #[test] + fn mixed_transit_ip_families_fail( + v4_transit in proptest::collection::vec(arb_ipv4_net(), 1..3), + v6_transit in proptest::collection::vec(arb_ipv6_net(), 1..3), + ) { + let mut transit: Vec = + v4_transit.into_iter().map(IpNet::from).collect(); + transit.extend(v6_transit.into_iter().map(IpNet::from)); + + let nic = make_nic(None, transit); + let result: Result = + nic.try_into(); + prop_assert!(result.is_err()); + } + + // ===================================================================== + // Property: IPv4 address with IPv6 transit IPs fails + // ===================================================================== + + #[test] + fn ipv4_with_ipv6_transit_fails( + ip in any::(), + v6_transit in proptest::collection::vec(arb_ipv6_net(), 1..3), + ) { + let transit: Vec = + v6_transit.into_iter().map(Into::into).collect(); + let nic = make_nic(Some(ip.into()), transit); + let result: Result = + nic.try_into(); + prop_assert!(result.is_err()); + } + + // ===================================================================== + // Property: IPv6 address with IPv4 transit IPs fails + // ===================================================================== + + #[test] + fn ipv6_with_ipv4_transit_fails( + ip in any::(), + v4_transit in proptest::collection::vec(arb_ipv4_net(), 1..3), + ) { + let transit: Vec = + v4_transit.into_iter().map(Into::into).collect(); + let nic = make_nic(Some(ip.into()), transit); + let result: Result = + nic.try_into(); + prop_assert!(result.is_err()); + } + + // ===================================================================== + // Property: No IP + IPv4-only transit produces V4 stack with Auto IP + // ===================================================================== + + #[test] + fn no_ip_with_ipv4_transit_produces_v4_auto( + v4_transit in proptest::collection::vec(arb_ipv4_net(), 1..4), + ) { + let transit: Vec = + v4_transit.iter().copied().map(Into::into).collect(); + let nic = make_nic(None, transit); + let result: v2026011300::InstanceNetworkInterfaceCreate = + nic.try_into().unwrap(); + + prop_assert!(matches!( + result.ip_config, + PrivateIpStackCreate::V4(v4) + if matches!(v4.ip, IpAssignment::Auto) && v4.transit_ips == v4_transit + )); + } + + // ===================================================================== + // Property: No IP + IPv6-only transit produces V6 stack with Auto IP + // ===================================================================== + + #[test] + fn no_ip_with_ipv6_transit_produces_v6_auto( + v6_transit in proptest::collection::vec(arb_ipv6_net(), 1..4), + ) { + let transit: Vec = + v6_transit.iter().copied().map(Into::into).collect(); + let nic = make_nic(None, transit); + let result: v2026011300::InstanceNetworkInterfaceCreate = + nic.try_into().unwrap(); + + prop_assert!(matches!( + result.ip_config, + PrivateIpStackCreate::V6(v6) + if matches!(v6.ip, IpAssignment::Auto) && v6.transit_ips == v6_transit + )); + } + } +} diff --git a/nexus/external-api/src/v2026010300.rs b/nexus/external-api/src/v2026010300.rs index 7386d6440a5..2a617de420a 100644 --- a/nexus/external-api/src/v2026010300.rs +++ b/nexus/external-api/src/v2026010300.rs @@ -196,9 +196,10 @@ impl TryFrom for params::FloatingIpCreate { } } -// v2026010300 (DUAL_STACK_NICS) uses the current `InstanceNetworkInterfaceAttachment` -// with `DefaultIpv4`, `DefaultIpv6`, `DefaultDualStack` variants. -pub use params::InstanceNetworkInterfaceAttachment; +// v2026010300 (DUAL_STACK_NICS) uses v2026011300's network interface types +// (pre-VPC_SUBNET_ATTACHMENT, with `subnet_name: Name`). +use crate::v2026010500; +use crate::v2026011300; /// Create-time parameters for an `Instance` /// @@ -219,7 +220,7 @@ pub struct InstanceCreate { pub user_data: Vec, /// The network interfaces to be created for this instance. #[serde(default)] - pub network_interfaces: InstanceNetworkInterfaceAttachment, + pub network_interfaces: v2026011300::InstanceNetworkInterfaceAttachment, /// The external IP addresses provided to this instance. // Uses local ExternalIpCreate (has ip_version field) → params::ExternalIpCreate #[serde(default)] @@ -253,19 +254,19 @@ pub struct InstanceCreate { pub cpu_platform: Option, } -impl TryFrom for params::InstanceCreate { +impl TryFrom for v2026010500::InstanceCreate { type Error = external::Error; fn try_from( old: InstanceCreate, - ) -> Result { + ) -> Result { let external_ips: Vec = old .external_ips .into_iter() .map(TryInto::try_into) .collect::, _>>()?; - Ok(params::InstanceCreate { + Ok(v2026010500::InstanceCreate { identity: old.identity, ncpus: old.ncpus, memory: old.memory, @@ -273,15 +274,7 @@ impl TryFrom for params::InstanceCreate { user_data: old.user_data, network_interfaces: old.network_interfaces, external_ips, - multicast_groups: old - .multicast_groups - .into_iter() - .map(|g| params::MulticastGroupJoinSpec { - group: g.into(), - source_ips: None, - ip_version: None, - }) - .collect(), + multicast_groups: old.multicast_groups, disks: old.disks, boot_disk: old.boot_disk, ssh_public_keys: old.ssh_public_keys, @@ -316,3 +309,145 @@ impl From for params::ProbeCreate { } } } + +#[cfg(test)] +mod tests { + use super::*; + use proptest::prelude::*; + + fn make_floating_ip( + ip: Option, + pool: Option, + ip_version: Option, + ) -> FloatingIpCreate { + FloatingIpCreate { + identity: IdentityMetadataCreateParams { + name: "fip".parse().unwrap(), + description: "test".to_string(), + }, + ip, + pool, + ip_version, + } + } + + // ========================================================================= + // Error cases: these test invalid input combinations that must be rejected + // ========================================================================= + + #[test] + fn ephemeral_ip_with_pool_and_ip_version_fails() { + let old = EphemeralIpCreate { + pool: Some("my-pool".parse::().unwrap().into()), + ip_version: Some(IpVersion::V4), + }; + assert!(TryInto::::try_into(old).is_err()); + } + + #[test] + fn floating_ip_with_ip_and_ip_version_fails() { + let old = make_floating_ip( + Some("10.0.0.1".parse().unwrap()), + None, + Some(IpVersion::V4), + ); + assert!(TryInto::::try_into(old).is_err()); + } + + #[test] + fn floating_ip_with_pool_and_ip_version_fails() { + let old = make_floating_ip( + None, + Some("my-pool".parse::().unwrap().into()), + Some(IpVersion::V4), + ); + assert!(TryInto::::try_into(old).is_err()); + } + + // ========================================================================= + // Property tests + // ========================================================================= + + proptest! { + /// EphemeralIpCreate: ip_version is preserved when using default pool + #[test] + fn ephemeral_ip_preserves_ip_version( + ip_version in prop::option::of(prop_oneof![ + Just(IpVersion::V4), + Just(IpVersion::V6), + ]) + ) { + let old = EphemeralIpCreate { pool: None, ip_version }; + let result: params::EphemeralIpCreate = old.try_into().unwrap(); + match result.pool_selector { + params::PoolSelector::Auto { ip_version: v } => { + prop_assert!(v == ip_version); + } + _ => panic!("expected Auto variant"), + } + } + + /// EphemeralIpCreate: explicit pool produces Explicit selector + #[test] + fn ephemeral_ip_with_pool_produces_explicit( + pool_name in "[a-z][a-z0-9]{0,8}" + ) { + let pool: NameOrId = pool_name.parse::().unwrap().into(); + let old = EphemeralIpCreate { pool: Some(pool), ip_version: None }; + let result: params::EphemeralIpCreate = old.try_into().unwrap(); + match result.pool_selector { + params::PoolSelector::Explicit { .. } => {} + _ => panic!("expected Explicit variant"), + } + } + + /// FloatingIpCreate: explicit IP is preserved in output + #[test] + fn floating_ip_preserves_explicit_ip(ip in any::()) { + let old = make_floating_ip(Some(ip), None, None); + let result: params::FloatingIpCreate = old.try_into().unwrap(); + match result.address_selector { + params::AddressSelector::Explicit { ip: addr, .. } => { + prop_assert!(addr == ip); + } + _ => panic!("expected Explicit variant"), + } + } + + /// FloatingIpCreate: explicit pool produces correct selector + #[test] + fn floating_ip_with_pool_produces_explicit( + pool_name in "[a-z][a-z0-9]{0,8}" + ) { + let pool: NameOrId = pool_name.parse::().unwrap().into(); + let old = make_floating_ip(None, Some(pool), None); + let result: params::FloatingIpCreate = old.try_into().unwrap(); + match result.address_selector { + params::AddressSelector::Auto { + pool_selector: params::PoolSelector::Explicit { .. } + } => {} + _ => panic!("expected Auto/Explicit variant"), + } + } + + /// FloatingIpCreate: ip_version is preserved when using default pool + #[test] + fn floating_ip_preserves_ip_version( + ip_version in prop::option::of(prop_oneof![ + Just(IpVersion::V4), + Just(IpVersion::V6), + ]) + ) { + let old = make_floating_ip(None, None, ip_version); + let result: params::FloatingIpCreate = old.try_into().unwrap(); + match result.address_selector { + params::AddressSelector::Auto { + pool_selector: params::PoolSelector::Auto { ip_version: v } + } => { + prop_assert!(v == ip_version); + } + _ => panic!("expected Auto/Auto variant"), + } + } + } +} diff --git a/nexus/external-api/src/v2026010500.rs b/nexus/external-api/src/v2026010500.rs index e75471586c3..baa40664e6f 100644 --- a/nexus/external-api/src/v2026010500.rs +++ b/nexus/external-api/src/v2026010500.rs @@ -24,11 +24,11 @@ use omicron_common::api::external::{ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -// v2026010500 (POOL_SELECTION_ENUMS) uses the current `InstanceNetworkInterfaceAttachment` -// with `DefaultIpv4`, `DefaultIpv6`, `DefaultDualStack` variants. +// v2026010500 (POOL_SELECTION_ENUMS) uses v2026011300's network interface types +// (pre-VPC_SUBNET_ATTACHMENT, with `subnet_name: Name`). // // Only the multicast_groups field differs (Vec vs Vec). -pub use params::InstanceNetworkInterfaceAttachment; +use crate::v2026011300; /// Create-time parameters for an `Instance` /// @@ -53,7 +53,7 @@ pub struct InstanceCreate { /// The network interfaces to be created for this instance. #[serde(default)] - pub network_interfaces: InstanceNetworkInterfaceAttachment, + pub network_interfaces: v2026011300::InstanceNetworkInterfaceAttachment, /// The external IP addresses provided to this instance. #[serde(default)] @@ -96,7 +96,7 @@ pub struct InstanceCreate { pub cpu_platform: Option, } -impl From for params::InstanceCreate { +impl From for v2026011300::InstanceCreate { fn from(value: InstanceCreate) -> Self { Self { identity: value.identity, @@ -125,3 +125,50 @@ impl From for params::InstanceCreate { } } } + +#[cfg(test)] +mod tests { + use super::*; + use proptest::prelude::*; + + fn make_instance_create(multicast_groups: Vec) -> InstanceCreate { + InstanceCreate { + identity: IdentityMetadataCreateParams { + name: "test-instance".parse().unwrap(), + description: "test".to_string(), + }, + ncpus: InstanceCpuCount(1), + memory: ByteCount::from_gibibytes_u32(1), + hostname: "test".parse().unwrap(), + user_data: vec![], + network_interfaces: Default::default(), + external_ips: vec![], + multicast_groups, + disks: vec![], + boot_disk: None, + ssh_public_keys: None, + start: true, + auto_restart_policy: None, + anti_affinity_groups: vec![], + cpu_platform: None, + } + } + + proptest! { + /// multicast_groups Vec → Vec with defaults + #[test] + fn multicast_groups_converted_with_defaults(count in 0usize..10) { + let groups: Vec = (0..count) + .map(|i| format!("group{i}").parse::().unwrap().into()) + .collect(); + let old = make_instance_create(groups); + let result: v2026011300::InstanceCreate = old.into(); + + prop_assert!(result.multicast_groups.len() == count); + for spec in &result.multicast_groups { + prop_assert!(spec.source_ips.is_none()); + prop_assert!(spec.ip_version.is_none()); + } + } + } +} diff --git a/nexus/external-api/src/v2026011300.rs b/nexus/external-api/src/v2026011300.rs new file mode 100644 index 00000000000..1e424d3be61 --- /dev/null +++ b/nexus/external-api/src/v2026011300.rs @@ -0,0 +1,259 @@ +// 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/. + +//! Nexus external types that changed from 2026011300 to 2026011500. +//! +//! ## Network Interface Changes +//! +//! [`InstanceNetworkInterfaceCreate`] uses `subnet_name: Name` for a single +//! subnet. Newer versions use `subnets: Vec` +//! to support multiple subnets with optional direct attachment. +//! +//! Affected endpoints: +//! - `POST /v1/instances` (instance_create) +//! - `POST /v1/network-interfaces` (instance_network_interface_create) +//! +//! [`InstanceNetworkInterfaceCreate`]: self::InstanceNetworkInterfaceCreate + +use nexus_types::external_api::params; +use omicron_common::api::external::{ + ByteCount, Hostname, IdentityMetadataCreateParams, + InstanceAutoRestartPolicy, InstanceCpuCount, InstanceCpuPlatform, Name, + NameOrId, +}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +/// Create-time parameters for an `InstanceNetworkInterface` +/// +/// This version uses a single `subnet_name` field. Newer versions use +/// `subnets: Vec` for multiple subnets. +#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] +pub struct InstanceNetworkInterfaceCreate { + #[serde(flatten)] + pub identity: IdentityMetadataCreateParams, + /// The VPC in which to create the interface. + pub vpc_name: Name, + /// The VPC Subnet in which to create the interface. + pub subnet_name: Name, + /// The IP stack configuration for this interface. + /// + /// If not provided, a default configuration will be used, which creates a + /// dual-stack IPv4 / IPv6 interface. + #[serde(default = "params::PrivateIpStackCreate::auto_dual_stack")] + pub ip_config: params::PrivateIpStackCreate, +} + +impl From + for params::InstanceNetworkInterfaceCreate +{ + fn from(value: InstanceNetworkInterfaceCreate) -> Self { + Self { + identity: value.identity, + vpc_name: value.vpc_name, + subnets: vec![params::NetworkInterfaceSubnetConfig { + subnet: value.subnet_name.into(), + attached: false, + }], + ip_config: value.ip_config, + } + } +} + +/// Describes an attachment of an `InstanceNetworkInterface` to an `Instance`, +/// at the time the instance is created. +#[derive(Clone, Debug, Default, Deserialize, Serialize, JsonSchema)] +#[serde(tag = "type", content = "params", rename_all = "snake_case")] +pub enum InstanceNetworkInterfaceAttachment { + /// Create one or more `InstanceNetworkInterface`s for the `Instance`. + /// + /// If more than one interface is provided, then the first will be + /// designated the primary interface for the instance. + Create(Vec), + + /// Create a single primary interface with an automatically-assigned IPv4 + /// address. + /// + /// The IP will be pulled from the Project's default VPC / VPC Subnet. + DefaultIpv4, + + /// Create a single primary interface with an automatically-assigned IPv6 + /// address. + /// + /// The IP will be pulled from the Project's default VPC / VPC Subnet. + DefaultIpv6, + + /// Create a single primary interface with automatically-assigned IPv4 and + /// IPv6 addresses. + /// + /// The IPs will be pulled from the Project's default VPC / VPC Subnet. + #[default] + DefaultDualStack, + + /// No network interfaces at all will be created for the instance. + None, +} + +impl From + for params::InstanceNetworkInterfaceAttachment +{ + fn from(value: InstanceNetworkInterfaceAttachment) -> Self { + match value { + InstanceNetworkInterfaceAttachment::Create(nics) => { + Self::Create(nics.into_iter().map(Into::into).collect()) + } + InstanceNetworkInterfaceAttachment::DefaultIpv4 => { + Self::DefaultIpv4 + } + InstanceNetworkInterfaceAttachment::DefaultIpv6 => { + Self::DefaultIpv6 + } + InstanceNetworkInterfaceAttachment::DefaultDualStack => { + Self::DefaultDualStack + } + InstanceNetworkInterfaceAttachment::None => Self::None, + } + } +} + +/// Create-time parameters for an `Instance` +/// +/// This version uses the old `InstanceNetworkInterfaceAttachment` with +/// `subnet_name: Name`. Newer versions use `subnets: Vec`. +#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] +pub struct InstanceCreate { + #[serde(flatten)] + pub identity: IdentityMetadataCreateParams, + /// The number of vCPUs to be allocated to the instance. + pub ncpus: InstanceCpuCount, + /// The amount of RAM (in bytes) to be allocated to the instance. + pub memory: ByteCount, + /// The hostname to be assigned to the instance. + pub hostname: Hostname, + + /// User data for instance initialization systems (such as cloud-init). + #[serde(default, with = "params::UserData")] + pub user_data: Vec, + + /// The network interfaces to be created for this instance. + #[serde(default)] + pub network_interfaces: InstanceNetworkInterfaceAttachment, + + /// The external IP addresses provided to this instance. + #[serde(default)] + pub external_ips: Vec, + + /// Multicast groups this instance should join at creation. + #[serde(default)] + pub multicast_groups: Vec, + + /// A list of disks to be attached to the instance. + #[serde(default)] + pub disks: Vec, + + /// The disk the instance is configured to boot from. + #[serde(default)] + pub boot_disk: Option, + + /// An allowlist of SSH public keys to be transferred to the instance. + pub ssh_public_keys: Option>, + + /// Should this instance be started upon creation; true by default. + #[serde(default = "params::bool_true")] + pub start: bool, + + /// The auto-restart policy for this instance. + #[serde(default)] + pub auto_restart_policy: Option, + + /// Anti-Affinity groups which this instance should be added. + #[serde(default)] + pub anti_affinity_groups: Vec, + + /// The CPU platform to be used for this instance. + #[serde(default)] + pub cpu_platform: Option, +} + +impl From for params::InstanceCreate { + fn from(value: InstanceCreate) -> Self { + Self { + identity: value.identity, + ncpus: value.ncpus, + memory: value.memory, + hostname: value.hostname, + user_data: value.user_data, + network_interfaces: value.network_interfaces.into(), + external_ips: value.external_ips, + multicast_groups: value.multicast_groups, + disks: value.disks, + boot_disk: value.boot_disk, + ssh_public_keys: value.ssh_public_keys, + start: value.start, + auto_restart_policy: value.auto_restart_policy, + anti_affinity_groups: value.anti_affinity_groups, + cpu_platform: value.cpu_platform, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use proptest::prelude::*; + + proptest! { + /// InstanceNetworkInterfaceCreate: subnet_name → single unattached subnet + #[test] + fn nic_create_converts_subnet_name_to_single_unattached( + // Names must start with a letter, can contain letters/numbers, + // but cannot end with a hyphen + subnet_name in "[a-z]([a-z0-9]*[a-z0-9])?" + ) { + let name: Name = subnet_name.parse().unwrap(); + let old = InstanceNetworkInterfaceCreate { + identity: IdentityMetadataCreateParams { + name: "nic0".parse().unwrap(), + description: "test".to_string(), + }, + vpc_name: "default".parse().unwrap(), + subnet_name: name.clone(), + ip_config: params::PrivateIpStackCreate::auto_dual_stack(), + }; + let result: params::InstanceNetworkInterfaceCreate = old.into(); + + prop_assert!(result.subnets.len() == 1); + prop_assert!(!result.subnets[0].attached); + prop_assert!(matches!( + &result.subnets[0].subnet, + NameOrId::Name(n) if n == &name + )); + } + + /// InstanceNetworkInterfaceAttachment::Create preserves NIC count + #[test] + fn attachment_create_preserves_nic_count(count in 1usize..5) { + let nics: Vec<_> = (0..count) + .map(|i| InstanceNetworkInterfaceCreate { + identity: IdentityMetadataCreateParams { + name: format!("nic{i}").parse().unwrap(), + description: "test".to_string(), + }, + vpc_name: "default".parse().unwrap(), + subnet_name: format!("subnet{i}").parse().unwrap(), + ip_config: params::PrivateIpStackCreate::auto_dual_stack(), + }) + .collect(); + let old = InstanceNetworkInterfaceAttachment::Create(nics); + let result: params::InstanceNetworkInterfaceAttachment = old.into(); + + match result { + params::InstanceNetworkInterfaceAttachment::Create(converted) => { + prop_assert!(converted.len() == count); + } + _ => panic!("expected Create variant"), + } + } + } +} diff --git a/nexus/src/app/network_interface.rs b/nexus/src/app/network_interface.rs index 4be22aa46be..c098aa1d191 100644 --- a/nexus/src/app/network_interface.rs +++ b/nexus/src/app/network_interface.rs @@ -9,6 +9,7 @@ use nexus_db_queries::authz::ApiResource; use nexus_db_queries::context::OpContext; use nexus_db_queries::db::queries::network_interface; use nexus_types::external_api::params; +use nexus_types::external_api::params::NetworkInterfaceSubnetConfig; use omicron_common::api::external::CreateResult; use omicron_common::api::external::DeleteResult; use omicron_common::api::external::Error; @@ -83,8 +84,23 @@ impl super::Nexus { // NOTE: We need to lookup the VPC and VPC Subnet, since we need both // IDs for creating the network interface. + // + // TODO(#9580): For now, we only support a single unattached subnet + // specified by name. Fix this to accept multiple subnets, by name or ID, + // which might be attached or detached. + let [ + NetworkInterfaceSubnetConfig { + subnet: NameOrId::Name(subnet_name), + attached: false, + }, + ] = params.subnets.as_slice() + else { + return Err(Error::invalid_request( + "exactly one subnet must be specified by name for the network interface", + )); + }; let vpc_name = db::model::Name(params.vpc_name.clone()); - let subnet_name = db::model::Name(params.subnet_name.clone()); + let subnet_name = db::model::Name(subnet_name.clone()); let (.., authz_subnet, db_subnet) = LookupPath::new(opctx, &self.db_datastore) .project_id(authz_project.id()) diff --git a/nexus/src/app/sagas/instance_create.rs b/nexus/src/app/sagas/instance_create.rs index 57a3acb3085..7c48144ccee 100644 --- a/nexus/src/app/sagas/instance_create.rs +++ b/nexus/src/app/sagas/instance_create.rs @@ -21,7 +21,7 @@ use nexus_db_queries::db::queries::network_interface::InsertError as InsertNicEr use nexus_db_queries::{authn, authz, db}; use nexus_defaults::DEFAULT_PRIMARY_NIC_NAME; use nexus_types::external_api::params::{ - InstanceDiskAttachment, PrivateIpStackCreate, + InstanceDiskAttachment, NetworkInterfaceSubnetConfig, PrivateIpStackCreate, }; use nexus_types::identity::Resource; use omicron_common::api::external::IdentityMetadataCreateParams; @@ -681,11 +681,24 @@ async fn create_custom_network_interface( // should probably either be in a transaction, or the // `instance_create_network_interface` function/query needs some JOIN // on the `vpc_subnet` table. + // + // TODO(#9580): For now, we only support a single unattached subnet + // specified by name. Fix this to accept multiple subnets, by name or ID, + // which might be attached or detached. + let [ + NetworkInterfaceSubnetConfig { + subnet: NameOrId::Name(subnet_name), + attached: false, + }, + ] = interface_params.subnets.as_slice() + else { + return Err(ActionError::action_failed(Error::invalid_request( + "exactly one subnet must be specified by name for the network interface", + ))); + }; let (.., authz_subnet, db_subnet) = LookupPath::new(&opctx, datastore) .vpc_id(authz_vpc.id()) - .vpc_subnet_name(&db::model::Name::from( - interface_params.subnet_name.clone(), - )) + .vpc_subnet_name(&db::model::Name::from(subnet_name.clone())) .fetch() .await .map_err(ActionError::action_failed)?; @@ -770,7 +783,10 @@ async fn create_default_primary_network_interface( ), }, vpc_name: default_name.clone(), - subnet_name: default_name.clone(), + subnets: vec![NetworkInterfaceSubnetConfig { + subnet: default_name.clone().into(), + attached: false, + }], ip_config, }; diff --git a/nexus/test-utils/src/resource_helpers.rs b/nexus/test-utils/src/resource_helpers.rs index 88f231b868f..e953b955ccf 100644 --- a/nexus/test-utils/src/resource_helpers.rs +++ b/nexus/test-utils/src/resource_helpers.rs @@ -78,6 +78,19 @@ use std::sync::Arc; use std::time::Duration; use uuid::Uuid; +/// Creates a single-element subnet configuration for network interface creation. +/// +/// This is a convenience helper for tests that need to specify a single subnet +/// without the `attached` flag (the common case). +pub fn single_unattached_subnet( + name: &str, +) -> Vec { + vec![params::NetworkInterfaceSubnetConfig { + subnet: name.parse::().expect("invalid subnet name").into(), + attached: false, + }] +} + pub async fn objects_list_page_authz( client: &ClientTestContext, path: &str, diff --git a/nexus/tests/integration_tests/endpoints.rs b/nexus/tests/integration_tests/endpoints.rs index 5ffe148cac2..a5c1d9b386b 100644 --- a/nexus/tests/integration_tests/endpoints.rs +++ b/nexus/tests/integration_tests/endpoints.rs @@ -18,6 +18,7 @@ use nexus_test_utils::PHYSICAL_DISK_UUID; use nexus_test_utils::RACK_UUID; use nexus_test_utils::SLED_AGENT_UUID; use nexus_test_utils::SWITCH_UUID; +use nexus_test_utils::resource_helpers::single_unattached_subnet; use nexus_test_utils::resource_helpers::test_params; use nexus_types::external_api::params; use nexus_types::external_api::params::PrivateIpStackCreate; @@ -725,7 +726,7 @@ pub static DEMO_INSTANCE_NIC_CREATE: LazyLock< description: String::from(""), }, vpc_name: DEMO_VPC_NAME.clone(), - subnet_name: DEMO_VPC_SUBNET_NAME.clone(), + subnets: single_unattached_subnet(DEMO_VPC_SUBNET_NAME.as_str()), ip_config: PrivateIpStackCreate::auto_ipv4(), }); pub static DEMO_INSTANCE_NIC_PUT: LazyLock< diff --git a/nexus/tests/integration_tests/instances.rs b/nexus/tests/integration_tests/instances.rs index 70503aa0c1a..304c09d6c0b 100644 --- a/nexus/tests/integration_tests/instances.rs +++ b/nexus/tests/integration_tests/instances.rs @@ -40,6 +40,7 @@ use nexus_test_utils::resource_helpers::object_get; use nexus_test_utils::resource_helpers::object_put; use nexus_test_utils::resource_helpers::object_put_error; use nexus_test_utils::resource_helpers::objects_list_page_authz; +use nexus_test_utils::resource_helpers::single_unattached_subnet; use nexus_test_utils::resource_helpers::test_params; use nexus_test_utils::start_sled_agent_with_config; use nexus_test_utils::wait_for_producer; @@ -2689,7 +2690,7 @@ async fn test_instance_create_saga_removes_instance_database_record( description: String::from("first custom interface"), }, vpc_name: default_name.clone(), - subnet_name: default_name.clone(), + subnets: single_unattached_subnet(default_name.as_str()), ip_config: PrivateIpStackCreate::from_ipv4(requested_address), }; let interface_params = @@ -2775,7 +2776,7 @@ async fn test_instance_create_saga_removes_instance_database_record( description: String::from("first custom interface"), }, vpc_name: default_name.clone(), - subnet_name: default_name.clone(), + subnets: single_unattached_subnet(default_name.as_str()), ip_config: PrivateIpStackCreate::from_ipv4(requested_address), }; let interface_params = @@ -2877,7 +2878,7 @@ async fn test_instance_with_single_explicit_ip_address_impl( description: String::from("first custom interface"), }, vpc_name: default_name.clone(), - subnet_name: default_name.clone(), + subnets: single_unattached_subnet(default_name.as_str()), ip_config: ip_config.clone(), }; let interface_params = @@ -3048,7 +3049,7 @@ async fn test_instance_with_new_custom_network_interfaces( description: String::from("first custom interface"), }, vpc_name: default_name.clone(), - subnet_name: default_name.clone(), + subnets: single_unattached_subnet(default_name.as_str()), ip_config: PrivateIpStackCreate::auto_ipv4(), }; let if1_params = params::InstanceNetworkInterfaceCreate { @@ -3057,7 +3058,7 @@ async fn test_instance_with_new_custom_network_interfaces( description: String::from("second custom interface"), }, vpc_name: default_name.clone(), - subnet_name: non_default_subnet_name.clone(), + subnets: single_unattached_subnet(non_default_subnet_name.as_str()), ip_config: PrivateIpStackCreate::auto_dual_stack(), }; let interface_params = @@ -3253,7 +3254,7 @@ async fn test_instance_create_delete_network_interface( description: String::from("a new nic"), }, vpc_name: "default".parse().unwrap(), - subnet_name: "default".parse().unwrap(), + subnets: single_unattached_subnet("default"), ip_config: PrivateIpStackCreate::V4(PrivateIpv4StackCreate { ip: IpAssignment::Explicit("172.30.0.10".parse().unwrap()), transit_ips: vec![ @@ -3268,7 +3269,9 @@ async fn test_instance_create_delete_network_interface( description: String::from("a new nic"), }, vpc_name: "default".parse().unwrap(), - subnet_name: secondary_subnet.identity.name.clone(), + subnets: single_unattached_subnet( + secondary_subnet.identity.name.as_str(), + ), ip_config: PrivateIpStackCreate::V4(PrivateIpv4StackCreate { ip: IpAssignment::Explicit("172.31.0.11".parse().unwrap()), transit_ips: vec!["192.168.1.0/24".parse().unwrap()], @@ -3520,7 +3523,7 @@ async fn test_instance_update_network_interfaces( description: String::from("a new nic"), }, vpc_name: "default".parse().unwrap(), - subnet_name: "default".parse().unwrap(), + subnets: single_unattached_subnet("default"), ip_config: PrivateIpStackCreate::from_ipv4( "172.30.0.10".parse().unwrap(), ), @@ -3531,7 +3534,9 @@ async fn test_instance_update_network_interfaces( description: String::from("a new nic"), }, vpc_name: "default".parse().unwrap(), - subnet_name: secondary_subnet.identity.name.clone(), + subnets: single_unattached_subnet( + secondary_subnet.identity.name.as_str(), + ), ip_config: PrivateIpStackCreate::from_ipv4( "172.31.0.11".parse().unwrap(), ), @@ -3943,7 +3948,9 @@ async fn cannot_make_new_primary_nic_lacking_ip_stack_for_external_addresses( description: String::from("a new nic"), }, vpc_name: "default".parse().unwrap(), - subnet_name: secondary_subnet.identity.name.clone(), + subnets: single_unattached_subnet( + secondary_subnet.identity.name.as_str(), + ), ip_config: PrivateIpStackCreate::auto_ipv6(), }; @@ -4259,7 +4266,7 @@ async fn test_instance_with_multiple_nics_unwinds_completely( description: String::from("first custom interface"), }, vpc_name: default_name.clone(), - subnet_name: default_name.clone(), + subnets: single_unattached_subnet(default_name.as_str()), ip_config: PrivateIpStackCreate::from_ipv4( "172.30.0.6".parse().unwrap(), ), @@ -4270,7 +4277,7 @@ async fn test_instance_with_multiple_nics_unwinds_completely( description: String::from("second custom interface"), }, vpc_name: default_name.clone(), - subnet_name: default_name.clone(), + subnets: single_unattached_subnet(default_name.as_str()), ip_config: PrivateIpStackCreate::from_ipv4( "172.30.0.7".parse().unwrap(), ), @@ -8094,7 +8101,7 @@ async fn test_instance_create_with_cross_project_subnet( ), }, vpc_name: vpc_b_name.parse().unwrap(), - subnet_name: subnet_b_name.parse().unwrap(), + subnets: single_unattached_subnet(subnet_b_name), ip_config: PrivateIpStackCreate::auto_ipv4(), }; @@ -8224,7 +8231,7 @@ async fn test_silo_limited_collaborator_cross_project_subnet( description: String::from("NIC using same project's subnet"), }, vpc_name: vpc_a_name.parse().unwrap(), - subnet_name: subnet_a_name.parse().unwrap(), + subnets: single_unattached_subnet(subnet_a_name), ip_config: PrivateIpStackCreate::auto_ipv4(), }; @@ -8288,7 +8295,7 @@ async fn test_silo_limited_collaborator_cross_project_subnet( ), }, vpc_name: vpc_b_name.parse().unwrap(), - subnet_name: subnet_b_name.parse().unwrap(), + subnets: single_unattached_subnet(subnet_b_name), ip_config: PrivateIpStackCreate::auto_ipv4(), }; @@ -9061,3 +9068,128 @@ async fn test_instance_with_max_disks(cptestctx: &ControlPlaneTestContext) { let disks = get_instance_disks(&client, name).await; assert_eq!(disks.len(), MAX_DISKS_PER_INSTANCE as usize); } + +// ============================================================================= +// API Versioning Tests +// +// These tests verify that older API versions are correctly handled, with +// requests in old formats being converted to current types. +// ============================================================================= + +/// Test instance creation using API version 2026011300 with the old +/// `subnet_name` format for network interfaces. +/// +/// Prior to version 2026011500 (VPC_SUBNET_ATTACHMENT), network interfaces +/// used `subnet_name: Name` instead of `subnets: Vec`. +/// This test verifies backward compatibility. +#[nexus_test] +async fn test_instance_create_with_old_subnet_name_api_version( + cptestctx: &ControlPlaneTestContext, +) { + let client = &cptestctx.external_client; + create_project_and_pool(&client).await; + + // Construct JSON body using the OLD format (subnet_name instead of subnets) + let body = serde_json::json!({ + "name": "old-api-instance", + "description": "instance created with old API version", + "ncpus": 4, + "memory": 1073741824_u64, // 1 GiB + "hostname": "old-api-host", + "network_interfaces": { + "type": "create", + "params": [{ + "name": "nic0", + "description": "primary NIC", + "vpc_name": "default", + "subnet_name": "default" + }] + } + }); + + // Send request with old API version header + let instance: Instance = NexusRequest::new( + RequestBuilder::new(client, Method::POST, &get_instances_url()) + .header(omicron_common::api::VERSION_HEADER, "2026011300.0.0") + .body(Some(&body)) + .expect_status(Some(StatusCode::CREATED)), + ) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .expect("failed to create instance with old API version") + .parsed_body() + .expect("failed to parse instance response"); + + assert_eq!(instance.identity.name.as_str(), "old-api-instance"); + + // Verify the NIC was created correctly + let url = format!( + "/v1/network-interfaces?project={}&instance=old-api-instance", + PROJECT_NAME + ); + let nics: ResultsPage = + NexusRequest::object_get(client, &url) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .expect("failed to list NICs") + .parsed_body() + .expect("failed to parse NICs response"); + + assert_eq!(nics.items.len(), 1); + assert_eq!(nics.items[0].identity.name.as_str(), "nic0"); +} + +/// Test network interface creation using API version 2026011300 with the old +/// `subnet_name` format. +#[nexus_test] +async fn test_network_interface_create_with_old_subnet_name_api_version( + cptestctx: &ControlPlaneTestContext, +) { + let client = &cptestctx.external_client; + create_project_and_pool(&client).await; + + // Create an instance with no NICs first + let instance = create_instance_with( + client, + PROJECT_NAME, + "test-instance", + ¶ms::InstanceNetworkInterfaceAttachment::None, + vec![], + vec![], + false, // don't start + Default::default(), + None, + vec![], + ) + .await; + + // Construct JSON body using the OLD format (subnet_name instead of subnets) + let body = serde_json::json!({ + "name": "old-api-nic", + "description": "NIC created with old API version", + "vpc_name": "default", + "subnet_name": "default" + }); + + // Send request with old API version header + let url = format!( + "/v1/network-interfaces?project={}&instance={}", + PROJECT_NAME, instance.identity.name + ); + let nic: InstanceNetworkInterface = NexusRequest::new( + RequestBuilder::new(client, Method::POST, &url) + .header(omicron_common::api::VERSION_HEADER, "2026011300.0.0") + .body(Some(&body)) + .expect_status(Some(StatusCode::CREATED)), + ) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .expect("failed to create NIC with old API version") + .parsed_body() + .expect("failed to parse NIC response"); + + assert_eq!(nic.identity.name.as_str(), "old-api-nic"); +} diff --git a/nexus/tests/integration_tests/internet_gateway.rs b/nexus/tests/integration_tests/internet_gateway.rs index 5acd4f8ca8f..ca017ce9ac3 100644 --- a/nexus/tests/integration_tests/internet_gateway.rs +++ b/nexus/tests/integration_tests/internet_gateway.rs @@ -13,7 +13,7 @@ use nexus_test_utils::{ create_local_user, create_project, create_route, create_router, create_vpc, delete_internet_gateway, detach_ip_address_from_igw, detach_ip_pool_from_igw, link_ip_pool, objects_list_page_authz, - test_params, + single_unattached_subnet, test_params, }, }; use nexus_test_utils_macros::nexus_test; @@ -371,7 +371,7 @@ async fn test_setup(c: &ClientTestContext) { name: "noname".parse().unwrap(), }, ip_config: PrivateIpStackCreate::auto_ipv4(), - subnet_name: "default".parse().unwrap(), + subnets: single_unattached_subnet("default"), vpc_name: VPC_NAME.parse().unwrap(), }, ]); diff --git a/nexus/tests/integration_tests/subnet_allocation.rs b/nexus/tests/integration_tests/subnet_allocation.rs index a255a3fd4e8..f19a154a2de 100644 --- a/nexus/tests/integration_tests/subnet_allocation.rs +++ b/nexus/tests/integration_tests/subnet_allocation.rs @@ -17,6 +17,7 @@ use nexus_test_utils::resource_helpers::create_default_ip_pools; use nexus_test_utils::resource_helpers::create_instance_with; use nexus_test_utils::resource_helpers::create_project; use nexus_test_utils::resource_helpers::objects_list_page_authz; +use nexus_test_utils::resource_helpers::single_unattached_subnet; use nexus_test_utils_macros::nexus_test; use nexus_types::external_api::params; use nexus_types::external_api::params::PrivateIpStackCreate; @@ -47,7 +48,7 @@ async fn create_instance_expect_failure( description: String::from("description"), }, vpc_name: "default".parse().unwrap(), - subnet_name: subnet_name.parse().unwrap(), + subnets: single_unattached_subnet(subnet_name), ip_config: PrivateIpStackCreate::auto_ipv4(), }, ]); @@ -136,7 +137,7 @@ async fn test_subnet_allocation(cptestctx: &ControlPlaneTestContext) { description: String::from("some iface"), }, vpc_name: "default".parse().unwrap(), - subnet_name: SUBNET_NAME.parse().unwrap(), + subnets: single_unattached_subnet(SUBNET_NAME), ip_config: PrivateIpStackCreate::auto_ipv4(), }, ]); diff --git a/nexus/tests/integration_tests/vpc_routers.rs b/nexus/tests/integration_tests/vpc_routers.rs index 9317d2e3e14..5b1b8232d4e 100644 --- a/nexus/tests/integration_tests/vpc_routers.rs +++ b/nexus/tests/integration_tests/vpc_routers.rs @@ -20,6 +20,7 @@ use nexus_test_utils::resource_helpers::create_router; use nexus_test_utils::resource_helpers::create_vpc_subnet; use nexus_test_utils::resource_helpers::object_delete; use nexus_test_utils::resource_helpers::objects_list_page_authz; +use nexus_test_utils::resource_helpers::single_unattached_subnet; use nexus_test_utils::resource_helpers::{create_project, create_vpc}; use nexus_test_utils::resource_helpers::{object_put, object_put_error}; use nexus_test_utils_macros::nexus_test; @@ -509,7 +510,7 @@ async fn test_vpc_routers_custom_delivered_to_instance( description: "".into(), }, vpc_name: vpc.name().clone(), - subnet_name: subnet_name.parse().unwrap(), + subnets: single_unattached_subnet(subnet_name), ip_config: PrivateIpStackCreate::from_ipv4( format!("192.168.{i}.10").parse().unwrap(), ), diff --git a/nexus/types/src/external_api/params.rs b/nexus/types/src/external_api/params.rs index f5c67523706..2b27fc23316 100644 --- a/nexus/types/src/external_api/params.rs +++ b/nexus/types/src/external_api/params.rs @@ -1063,6 +1063,25 @@ pub struct ProjectUpdate { // NETWORK INTERFACES +/// Configuration for a subnet on a network interface. +#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] +pub struct NetworkInterfaceSubnetConfig { + /// Name or ID of the VPC subnet. + pub subnet: NameOrId, + + /// If true, the entire subnet is attached to this interface, allowing + /// traffic from/to any IP in the subnet. + /// + /// This is useful for container orchestration platforms that need to manage + /// addressing within a subnet. When a subnet is attached, its CIDR is + /// automatically added to the interface's `transit_ips`. + /// + /// Defaults to `false` (standard behavior: interface gets a single IP from + /// the subnet). + #[serde(default)] + pub attached: bool, +} + /// Create-time parameters for an `InstanceNetworkInterface` #[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] pub struct InstanceNetworkInterfaceCreate { @@ -1070,8 +1089,11 @@ pub struct InstanceNetworkInterfaceCreate { pub identity: IdentityMetadataCreateParams, /// The VPC in which to create the interface. pub vpc_name: Name, - /// The VPC Subnet in which to create the interface. - pub subnet_name: Name, + /// Subnets for this interface. The first subnet is primary. + /// + /// Must contain at least one entry. Set `attached: true` to directly attach + /// the entire subnet, allowing traffic from/to any address in that subnet. + pub subnets: Vec, /// The IP stack configuration for this interface. /// /// If not provided, a default configuration will be used, which creates a diff --git a/openapi/nexus/nexus-2026011500.0.0-1fce3b.json b/openapi/nexus/nexus-2026011500.0.0-cadcc1.json similarity index 99% rename from openapi/nexus/nexus-2026011500.0.0-1fce3b.json rename to openapi/nexus/nexus-2026011500.0.0-cadcc1.json index ba0f895a9f2..ebc1595afa6 100644 --- a/openapi/nexus/nexus-2026011500.0.0-1fce3b.json +++ b/openapi/nexus/nexus-2026011500.0.0-cadcc1.json @@ -22576,13 +22576,12 @@ "name": { "$ref": "#/components/schemas/Name" }, - "subnet_name": { - "description": "The VPC Subnet in which to create the interface.", - "allOf": [ - { - "$ref": "#/components/schemas/Name" - } - ] + "subnets": { + "description": "Subnets for this interface. The first subnet is primary.\n\nMust contain at least one entry. Set `attached: true` to directly attach the entire subnet, allowing traffic from/to any address in that subnet.", + "type": "array", + "items": { + "$ref": "#/components/schemas/NetworkInterfaceSubnetConfig" + } }, "vpc_name": { "description": "The VPC in which to create the interface.", @@ -22596,7 +22595,7 @@ "required": [ "description", "name", - "subnet_name", + "subnets", "vpc_name" ] }, @@ -24601,6 +24600,28 @@ } ] }, + "NetworkInterfaceSubnetConfig": { + "description": "Configuration for a subnet on a network interface.", + "type": "object", + "properties": { + "attached": { + "description": "If true, the entire subnet is attached to this interface, allowing traffic from/to any IP in the subnet.\n\nThis is useful for container orchestration platforms that need to manage addressing within a subnet. When a subnet is attached, its CIDR is automatically added to the interface's `transit_ips`.\n\nDefaults to `false` (standard behavior: interface gets a single IP from the subnet).", + "default": false, + "type": "boolean" + }, + "subnet": { + "description": "Name or ID of the VPC subnet.", + "allOf": [ + { + "$ref": "#/components/schemas/NameOrId" + } + ] + } + }, + "required": [ + "subnet" + ] + }, "OxqlQueryResult": { "description": "The result of a successful OxQL query.", "type": "object", diff --git a/openapi/nexus/nexus-latest.json b/openapi/nexus/nexus-latest.json index 31e5ac17e0b..d7ea4a6f6e2 120000 --- a/openapi/nexus/nexus-latest.json +++ b/openapi/nexus/nexus-latest.json @@ -1 +1 @@ -nexus-2026011500.0.0-1fce3b.json \ No newline at end of file +nexus-2026011500.0.0-cadcc1.json \ No newline at end of file