diff --git a/nexus/db-model/src/network_interface.rs b/nexus/db-model/src/network_interface.rs index de315f5ac7..9ea9fe5917 100644 --- a/nexus/db-model/src/network_interface.rs +++ b/nexus/db-model/src/network_interface.rs @@ -19,12 +19,15 @@ use nexus_db_schema::schema::service_network_interface; use nexus_sled_agent_shared::inventory::ZoneKind; use nexus_types::external_api::params; use nexus_types::identity::Resource; -use omicron_common::api::external::Error; use omicron_common::api::{external, internal}; use omicron_uuid_kinds::GenericUuid; use omicron_uuid_kinds::InstanceUuid; use omicron_uuid_kinds::OmicronZoneUuid; use omicron_uuid_kinds::VnicUuid; +use oxnet::IpNet; +use oxnet::Ipv4Net; +use oxnet::Ipv6Net; +use std::net::IpAddr; use uuid::Uuid; /// The max number of interfaces that may be associated with a resource, @@ -346,6 +349,179 @@ impl From for NetworkInterface { } } +mod private { + pub trait IpSealed: Clone + Copy + std::fmt::Debug { + fn into_ipnet(self) -> ipnetwork::IpNetwork; + } + + impl IpSealed for std::net::Ipv4Addr { + fn into_ipnet(self) -> ipnetwork::IpNetwork { + ipnetwork::IpNetwork::V4(ipnetwork::Ipv4Network::from(self)) + } + } + impl IpSealed for std::net::Ipv6Addr { + fn into_ipnet(self) -> ipnetwork::IpNetwork { + ipnetwork::IpNetwork::V6(ipnetwork::Ipv6Network::from(self)) + } + } +} + +pub trait Ip: private::IpSealed {} +impl Ip for T where T: private::IpSealed {} + +/// How an IP address is assigned to an interface. +#[derive(Clone, Copy, Debug, Default)] +pub enum IpAssignment { + /// Automatically assign an IP address. + #[default] + Auto, + /// Explicitly assign a specific address, if available. + Explicit(T), +} + +/// How to assign an IPv4 address. +pub type Ipv4Assignment = IpAssignment; + +/// How to assign an IPv6 address. +pub type Ipv6Assignment = IpAssignment; + +/// Configuration for a network interface's IPv4 addressing. +#[derive(Clone, Debug, Default)] +pub struct Ipv4Config { + /// The VPC-private address to assign to the interface. + pub ip: Ipv4Assignment, + /// Additional IP networks the interface can send / receive on. + pub transit_ips: Vec, +} + +/// Configuration for a network interface's IPv6 addressing. +#[derive(Clone, Debug, Default)] +pub struct Ipv6Config { + /// The VPC-private address to assign to the interface. + pub ip: Ipv6Assignment, + /// Additional IP networks the interface can send / receive on. + pub transit_ips: Vec, +} + +/// Configuration for a network interface's IP addressing. +#[derive(Clone, Debug)] +pub enum IpConfig { + /// The interface has only an IPv4 stack. + V4(Ipv4Config), + /// The interface has only an IPv6 stack. + V6(Ipv6Config), + /// The interface has both an IPv4 and IPv6 stack. + DualStack { v4: Ipv4Config, v6: Ipv6Config }, +} + +impl IpConfig { + /// Construct an IPv4 configuration with no transit IPs. + pub fn from_ipv4(addr: std::net::Ipv4Addr) -> Self { + IpConfig::V4(Ipv4Config { + ip: Ipv4Assignment::Explicit(addr), + transit_ips: vec![], + }) + } + + /// Construct an IP configuration with only an automatic IPv4 address. + pub fn auto_ipv4() -> Self { + IpConfig::V4(Ipv4Config::default()) + } + + /// Return the IPv4 address assignment. + pub fn ipv4_assignment(&self) -> Option<&Ipv4Assignment> { + match self { + IpConfig::V4(Ipv4Config { ip, .. }) => Some(ip), + IpConfig::V6(_) => None, + IpConfig::DualStack { v4: Ipv4Config { ip, .. }, .. } => Some(ip), + } + } + + /// Return the IPv4 address explicitly requested, if one exists. + pub fn ipv4_addr(&self) -> Option<&std::net::Ipv4Addr> { + self.ipv4_assignment().and_then(|assignment| match assignment { + IpAssignment::Auto => None, + IpAssignment::Explicit(addr) => Some(addr), + }) + } + + /// Construct an IPv6 configuration with no transit IPs. + pub fn from_ipv6(addr: std::net::Ipv6Addr) -> Self { + IpConfig::V6(Ipv6Config { + ip: Ipv6Assignment::Explicit(addr), + transit_ips: vec![], + }) + } + + /// Construct an IP configuration with only an automatic IPv6 address. + pub fn auto_ipv6() -> Self { + IpConfig::V6(Ipv6Config::default()) + } + + /// Return the IPv6 address assignment. + pub fn ipv6_assignment(&self) -> Option<&Ipv6Assignment> { + match self { + IpConfig::V6(Ipv6Config { ip, .. }) => Some(ip), + IpConfig::V4(_) => None, + IpConfig::DualStack { v6: Ipv6Config { ip, .. }, .. } => Some(ip), + } + } + + /// Return the IPv6 address explicitly requested, if one exists. + pub fn ipv6_addr(&self) -> Option<&std::net::Ipv6Addr> { + self.ipv6_assignment().and_then(|assignment| match assignment { + IpAssignment::Auto => None, + IpAssignment::Explicit(addr) => Some(addr), + }) + } + + /// Return the transit IPs requested in this configuration. + pub fn transit_ips(&self) -> Vec { + match self { + IpConfig::V4(Ipv4Config { transit_ips, .. }) => { + transit_ips.iter().copied().map(Into::into).collect() + } + IpConfig::V6(Ipv6Config { transit_ips, .. }) => { + transit_ips.iter().copied().map(Into::into).collect() + } + IpConfig::DualStack { + v4: Ipv4Config { transit_ips: ipv4_addrs, .. }, + v6: Ipv6Config { transit_ips: ipv6_addrs, .. }, + } => ipv4_addrs + .iter() + .copied() + .map(Into::into) + .chain(ipv6_addrs.iter().copied().map(Into::into)) + .collect(), + } + } + + /// Construct an IP configuration with both IPv4 / IPv6 addresses and no + /// transit IPs. + pub fn auto_dual_stack() -> Self { + IpConfig::DualStack { + v4: Ipv4Config::default(), + v6: Ipv6Config::default(), + } + } + + /// Return true if this config has any transit IPs + fn has_transit_ips(&self) -> bool { + match self { + IpConfig::V4(Ipv4Config { transit_ips, .. }) => { + !transit_ips.is_empty() + } + IpConfig::V6(Ipv6Config { transit_ips, .. }) => { + !transit_ips.is_empty() + } + IpConfig::DualStack { + v4: Ipv4Config { transit_ips: ipv4_addrs, .. }, + v6: Ipv6Config { transit_ips: ipv6_addrs, .. }, + } => !ipv4_addrs.is_empty() || !ipv6_addrs.is_empty(), + } + } +} + /// A not fully constructed NetworkInterface. It may not yet have an IP /// address allocated. #[derive(Clone, Debug)] @@ -354,10 +530,9 @@ pub struct IncompleteNetworkInterface { pub kind: NetworkInterfaceKind, pub parent_id: Uuid, pub subnet: VpcSubnet, - pub ip: Option, + pub ip_config: IpConfig, pub mac: Option, pub slot: Option, - pub transit_ips: Vec, } impl IncompleteNetworkInterface { @@ -368,20 +543,15 @@ impl IncompleteNetworkInterface { parent_id: Uuid, subnet: VpcSubnet, identity: external::IdentityMetadataCreateParams, - ip: Option, + ip_config: IpConfig, mac: Option, slot: Option, - transit_ips: Vec, ) -> Result { - if let Some(ip) = ip { - // TODO-completeness: - // https://github.com/oxidecomputer/omicron/issues/9244. - if ip.is_ipv6() { - return Err(Error::invalid_request( - "IPv6 addresses are not yet supported", - )); - } - subnet.check_requestable_addr(ip)?; + if let Some(ip) = ip_config.ipv4_addr() { + subnet.check_requestable_addr(IpAddr::V4(*ip))?; + }; + if let Some(ip) = ip_config.ipv6_addr() { + subnet.check_requestable_addr(IpAddr::V6(*ip))?; }; if let Some(mac) = mac { match kind { @@ -422,10 +592,9 @@ impl IncompleteNetworkInterface { kind, parent_id, subnet, - ip, + ip_config, mac, slot, - transit_ips, }) } @@ -434,8 +603,7 @@ impl IncompleteNetworkInterface { instance_id: InstanceUuid, subnet: VpcSubnet, identity: external::IdentityMetadataCreateParams, - ip: Option, - transit_ips: Vec, + ip_config: IpConfig, ) -> Result { Self::new( interface_id, @@ -443,10 +611,9 @@ impl IncompleteNetworkInterface { instance_id.into_untyped_uuid(), subnet, identity, - ip, + ip_config, None, None, - transit_ips, ) } @@ -455,20 +622,24 @@ impl IncompleteNetworkInterface { service_id: Uuid, subnet: VpcSubnet, identity: external::IdentityMetadataCreateParams, - ip: std::net::IpAddr, + ip_config: IpConfig, mac: external::MacAddr, slot: u8, ) -> Result { + if ip_config.has_transit_ips() { + return Err(external::Error::invalid_request( + "Cannot specify transit IPs for service NICs", + )); + } Self::new( interface_id, NetworkInterfaceKind::Service, service_id, subnet, identity, - Some(ip), + ip_config, Some(mac), Some(slot), - vec![], // Service interfaces don't use transit_ips ) } @@ -477,7 +648,7 @@ impl IncompleteNetworkInterface { probe_id: Uuid, subnet: VpcSubnet, identity: external::IdentityMetadataCreateParams, - ip: Option, + ip_config: IpConfig, mac: Option, ) -> Result { Self::new( @@ -486,10 +657,9 @@ impl IncompleteNetworkInterface { probe_id, subnet, identity, - ip, + ip_config, mac, None, - vec![], // Probe interfaces don't use transit_ips ) } } diff --git a/nexus/db-queries/src/db/datastore/deployment/external_networking.rs b/nexus/db-queries/src/db/datastore/deployment/external_networking.rs index 5de8818ab4..2c979954a5 100644 --- a/nexus/db-queries/src/db/datastore/deployment/external_networking.rs +++ b/nexus/db-queries/src/db/datastore/deployment/external_networking.rs @@ -12,6 +12,7 @@ use crate::db::fixed_data::vpc_subnet::NEXUS_VPC_SUBNET; use crate::db::fixed_data::vpc_subnet::NTP_VPC_SUBNET; use nexus_db_lookup::DbConnection; use nexus_db_model::IncompleteNetworkInterface; +use nexus_db_model::IpConfig; use nexus_db_model::IpPool; use nexus_sled_agent_shared::inventory::ZoneKind; use nexus_types::deployment::BlueprintZoneConfig; @@ -384,6 +385,16 @@ impl DataStore { if self.is_nic_already_allocated(conn, service_id, nic, log).await? { return Ok(()); } + + // TODO-completeness: Handle dual-stack `shared::NetworkInterface`s. + // See https://github.com/oxidecomputer/omicron/issues/9246. + let std::net::IpAddr::V4(ip) = nic.ip else { + return Err(Error::internal_error(&format!( + "Unexpectedly found a service NIC without an IPv4 \ + address, nic_id=\"{}\"", + nic.id, + ))); + }; let nic_arg = IncompleteNetworkInterface::new_service( nic.id, service_id.into_untyped_uuid(), @@ -392,7 +403,7 @@ impl DataStore { name: nic.name.clone(), description: format!("{} service vNIC", zone_kind.report_str()), }, - nic.ip, + IpConfig::from_ipv4(ip), nic.mac, nic.slot, )?; diff --git a/nexus/db-queries/src/db/datastore/network_interface.rs b/nexus/db-queries/src/db/datastore/network_interface.rs index 29d6fff36b..0c6f2c9791 100644 --- a/nexus/db-queries/src/db/datastore/network_interface.rs +++ b/nexus/db-queries/src/db/datastore/network_interface.rs @@ -943,6 +943,7 @@ mod tests { use crate::db::pub_test_utils::TestDatabase; use nexus_config::NUM_INITIAL_RESERVED_IP_ADDRESSES; use nexus_db_fixed_data::vpc_subnet::NEXUS_VPC_SUBNET; + use nexus_db_model::IpConfig; use omicron_common::address::NEXUS_OPTE_IPV4_SUBNET; use omicron_test_utils::dev; use std::collections::BTreeSet; @@ -994,7 +995,7 @@ mod tests { name: name.parse().unwrap(), description: name, }, - ip.into(), + IpConfig::from_ipv4(ip), macs.next().unwrap(), 0, ) diff --git a/nexus/db-queries/src/db/datastore/probe.rs b/nexus/db-queries/src/db/datastore/probe.rs index 375faf7a27..7527f7c098 100644 --- a/nexus/db-queries/src/db/datastore/probe.rs +++ b/nexus/db-queries/src/db/datastore/probe.rs @@ -15,6 +15,7 @@ use nexus_db_errors::ErrorHandler; use nexus_db_errors::public_error_from_diesel; use nexus_db_lookup::LookupPath; use nexus_db_model::IncompleteNetworkInterface; +use nexus_db_model::IpConfig; use nexus_db_model::Probe; use nexus_db_model::VpcSubnet; use nexus_db_model::to_db_typed_uuid; @@ -294,7 +295,7 @@ impl super::DataStore { probe.name(), ), }, - None, //Request IP address assignment + IpConfig::auto_ipv4(), None, //Request MAC address assignment )?; diff --git a/nexus/db-queries/src/db/datastore/rack.rs b/nexus/db-queries/src/db/datastore/rack.rs index 68a0e33488..35cf9cd8e9 100644 --- a/nexus/db-queries/src/db/datastore/rack.rs +++ b/nexus/db-queries/src/db/datastore/rack.rs @@ -40,6 +40,7 @@ use nexus_db_lookup::DbConnection; use nexus_db_lookup::LookupPath; use nexus_db_model::IncompleteNetworkInterface; use nexus_db_model::InitialDnsGroup; +use nexus_db_model::IpConfig; use nexus_db_model::IpVersion; use nexus_db_model::PasswordHashString; use nexus_db_model::SiloUser; @@ -58,6 +59,7 @@ use nexus_types::external_api::shared; use nexus_types::external_api::shared::IpRange; use nexus_types::external_api::shared::SiloRole; use nexus_types::identity::Resource; +use nexus_types::inventory::NetworkInterface; use omicron_common::api::external::AllowedSourceIps; use omicron_common::api::external::DataPageParams; use omicron_common::api::external::Error; @@ -73,6 +75,8 @@ use omicron_uuid_kinds::SiloUserUuid; use omicron_uuid_kinds::SledUuid; use omicron_uuid_kinds::ZpoolUuid; use slog_error_chain::InlineErrorChain; +use std::net::IpAddr; +use std::net::Ipv4Addr; use std::sync::{Arc, OnceLock}; use uuid::Uuid; @@ -546,12 +550,27 @@ impl DataStore { // explicit IP allocation and create a service NIC as well. let zone_type = &zone_config.zone_type; let zone_report_str = zone_type.kind().report_str(); + + // Extract an IPv4 address from the `shared::NetworkInterface` object. + // + // TODO-completeness: Handle IPv6 interface addresses. See + // https://github.com/oxidecomputer/omicron/issues/9246. + let extract_ipv4 = |nic: &NetworkInterface| -> Result { + let IpAddr::V4(ipv4) = nic.ip else { + return Err(Error::invalid_request( + "IPv6 addresses are not yet supported", + )); + }; + Ok(ipv4) + }; + let service_ip_nic = match zone_type { BlueprintZoneType::ExternalDns( blueprint_zone_type::ExternalDns { nic, dns_address, .. }, ) => { let external_ip = OmicronZoneExternalIp::Floating(dns_address.into_ip()); + let ip = extract_ipv4(nic).map_err(RackInitError::AddingNic)?; let db_nic = IncompleteNetworkInterface::new_service( nic.id, zone_config.id.into_untyped_uuid(), @@ -563,7 +582,7 @@ impl DataStore { zone_report_str ), }, - nic.ip, + IpConfig::from_ipv4(ip), nic.mac, nic.slot, ) @@ -576,6 +595,7 @@ impl DataStore { .. }) => { let external_ip = OmicronZoneExternalIp::Floating(*external_ip); + let ip = extract_ipv4(nic).map_err(RackInitError::AddingNic)?; let db_nic = IncompleteNetworkInterface::new_service( nic.id, zone_config.id.into_untyped_uuid(), @@ -587,7 +607,7 @@ impl DataStore { zone_report_str ), }, - nic.ip, + IpConfig::from_ipv4(ip), nic.mac, nic.slot, ) @@ -598,6 +618,7 @@ impl DataStore { blueprint_zone_type::BoundaryNtp { external_ip, nic, .. }, ) => { let external_ip = OmicronZoneExternalIp::Snat(*external_ip); + let ip = extract_ipv4(nic).map_err(RackInitError::AddingNic)?; let db_nic = IncompleteNetworkInterface::new_service( nic.id, zone_config.id.into_untyped_uuid(), @@ -609,7 +630,7 @@ impl DataStore { zone_report_str ), }, - nic.ip, + IpConfig::from_ipv4(ip), nic.mac, nic.slot, ) diff --git a/nexus/db-queries/src/db/datastore/vpc.rs b/nexus/db-queries/src/db/datastore/vpc.rs index bb2ff53ccf..f2e89b028a 100644 --- a/nexus/db-queries/src/db/datastore/vpc.rs +++ b/nexus/db-queries/src/db/datastore/vpc.rs @@ -2976,6 +2976,7 @@ mod tests { use nexus_db_fixed_data::silo::DEFAULT_SILO; use nexus_db_fixed_data::vpc_subnet::NEXUS_VPC_SUBNET; use nexus_db_model::IncompleteNetworkInterface; + use nexus_db_model::IpConfig; use nexus_reconfigurator_planning::blueprint_builder::BlueprintBuilder; use nexus_reconfigurator_planning::planner::PlannerRng; use nexus_reconfigurator_planning::system::SledBuilder; @@ -3290,6 +3291,9 @@ mod tests { .zone_type .external_networking() .expect("external networking for zone type"); + let IpAddr::V4(ip) = nic.ip else { + panic!("Expected an IPv4 address for this NIC"); + }; IncompleteNetworkInterface::new_service( nic.id, zone_config.id.into_untyped_uuid(), @@ -3298,7 +3302,7 @@ mod tests { name: nic.name.clone(), description: nic.name.to_string(), }, - nic.ip, + IpConfig::from_ipv4(ip), nic.mac, nic.slot, ) @@ -4032,8 +4036,7 @@ mod tests { name: "nic".parse().unwrap(), description: "A NIC...".into(), }, - None, - vec![], + IpConfig::auto_ipv4(), ) .unwrap(), ) diff --git a/nexus/db-queries/src/db/queries/network_interface.rs b/nexus/db-queries/src/db/queries/network_interface.rs index ae8a059c0e..0881f5d056 100644 --- a/nexus/db-queries/src/db/queries/network_interface.rs +++ b/nexus/db-queries/src/db/queries/network_interface.rs @@ -21,19 +21,19 @@ use diesel::query_builder::QueryId; use diesel::query_builder::{AstPass, Query}; use diesel::result::Error as DieselError; use diesel::sql_types::{self, Nullable}; -use ipnetwork::IpNetwork; use ipnetwork::Ipv4Network; +use ipnetwork::{IpNetwork, Ipv6Network}; use nexus_config::NUM_INITIAL_RESERVED_IP_ADDRESSES; use nexus_db_errors::{ErrorHandler, public_error_from_diesel, retryable}; use nexus_db_lookup::DbConnection; -use nexus_db_model::SqlU8; +use nexus_db_model::{Ip, IpAssignment, Ipv4Addr, SqlU8}; use nexus_db_model::{MAX_NICS_PER_INSTANCE, NetworkInterfaceKind}; use nexus_db_schema::enums::NetworkInterfaceKindEnum; use nexus_db_schema::schema::network_interface::dsl; -use omicron_common::api::external; use omicron_common::api::external::MacAddr; +use omicron_common::api::external::{self, Error}; use slog_error_chain::SlogInlineError; -use std::net::{IpAddr, Ipv6Addr}; +use std::net::Ipv6Addr; use std::sync::LazyLock; use uuid::Uuid; @@ -401,10 +401,18 @@ fn decode_database_error( // Constraint violated if a user-requested IP address has // already been assigned within the same VPC Subnet. Some(constraint) if constraint == IPV4_NOT_AVAILABLE_CONSTRAINT => { - let ip = interface - .ip - .unwrap_or_else(|| std::net::Ipv4Addr::UNSPECIFIED.into()); - InsertError::IpAddressNotAvailable(ip) + let Some(ipv4) = interface.ip_config.ipv4_addr() else { + let err = Error::internal_error(&format!( + "Violated constraint that ensures unique \ + IPv4 addresses when inserting an explicitly-\ + requested IPv4 address, but there doesn't \ + appear to be any such address. Instead, \ + found {:?}", + interface.ip_config, + )); + return InsertError::External(err); + }; + InsertError::IpAddressNotAvailable((*ipv4).into()) } // Constraint violated if a user-requested MAC address has // already been assigned within the same VPC. @@ -469,57 +477,46 @@ fn decode_database_error( // Return the first available address in a subnet. This is not the network // address, since Oxide reserves the first few addresses. -fn first_available_address(subnet: &IpNetwork) -> IpAddr { - match subnet { - IpNetwork::V4(network) => network - .nth(NUM_INITIAL_RESERVED_IP_ADDRESSES as _) - .unwrap_or_else(|| { - panic!("Unexpectedly small IPv4 subnetwork: '{}'", network) - }) - .into(), - IpNetwork::V6(network) => { - // NOTE: This call to `nth()` will loop and call the `next()` - // implementation. That's inefficient, but the number of reserved - // addresses is very small, so it should not matter. - network - .iter() - .nth(NUM_INITIAL_RESERVED_IP_ADDRESSES as _) - .unwrap_or_else(|| { - panic!("Unexpectedly small IPv6 subnetwork: '{}'", network) - }) - .into() - } - } +fn first_available_ipv4_address(network: &Ipv4Network) -> std::net::Ipv4Addr { + network.nth(NUM_INITIAL_RESERVED_IP_ADDRESSES as _).unwrap_or_else(|| { + panic!("Unexpectedly small IPv4 subnetwork: '{}'", network) + }) +} + +fn first_available_ipv6_address(network: &Ipv6Network) -> std::net::Ipv6Addr { + // NOTE: This call to `nth()` will loop and call the `next()` + // implementation. That's inefficient, but the number of reserved + // addresses is very small, so it should not matter. + network.iter().nth(NUM_INITIAL_RESERVED_IP_ADDRESSES as _).unwrap_or_else( + || panic!("Unexpectedly small IPv6 subnetwork: '{}'", network), + ) } // Return the last available address in a subnet. This is not the broadcast // address, since that is reserved. -fn last_available_address(subnet: &IpNetwork) -> IpAddr { - // NOTE: In both cases below, we subtract 2 from the network size. That's - // because we first subtract 1 to go from a size to an index, and then - // another 1 because the broadcast address isn't valid for an interface. - match subnet { - IpNetwork::V4(network) => network - .size() - .checked_sub(2) - .and_then(|n| network.nth(n)) - .map(IpAddr::V4) - .unwrap_or_else(|| { - panic!("Unexpectedly small IPv4 subnetwork: '{}'", network); - }), - IpNetwork::V6(network) => { - // NOTE: The iterator implementation for `Ipv6Network` only - // implements the required `Iterator::next()` method. That means we - // get the default implementation of the `nth()` method, which will - // loop and call `next()`. That is ridiculously inefficient, so we - // manually compute the nth address through addition instead. - let base = u128::from(network.network()); - let n = network.size().checked_sub(2).unwrap_or_else(|| { - panic!("Unexpectedly small IPv6 subnetwork: '{}'", network); - }); - IpAddr::V6(Ipv6Addr::from(base + n)) - } - } +// +// NOTE: In both functions below, we subtract 2 from the network size. That's +// because we first subtract 1 to go from a size to an index, and then +// another 1 because the broadcast address isn't valid for an interface. +fn last_available_ipv4_address(network: &Ipv4Network) -> std::net::Ipv4Addr { + network.size().checked_sub(2).and_then(|n| network.nth(n)).unwrap_or_else( + || { + panic!("Unexpectedly small IPv4 subnetwork: '{}'", network); + }, + ) +} + +fn last_available_ipv6_address(network: &Ipv6Network) -> std::net::Ipv6Addr { + // NOTE: The iterator implementation for `Ipv6Network` only + // implements the required `Iterator::next()` method. That means we + // get the default implementation of the `nth()` method, which will + // loop and call `next()`. That is ridiculously inefficient, so we + // manually compute the nth address through addition instead. + let base = u128::from(network.network()); + let n = network.size().checked_sub(2).unwrap_or_else(|| { + panic!("Unexpectedly small IPv6 subnetwork: '{}'", network); + }); + Ipv6Addr::from(base + n) } /// The `NextIpv4Address` query is a `NextItem` query for choosing the next @@ -528,7 +525,7 @@ fn last_available_address(subnet: &IpNetwork) -> IpAddr { pub struct NextIpv4Address { inner: NextItemSelfJoined< nexus_db_schema::schema::network_interface::table, - IpNetwork, + Ipv4Addr, nexus_db_schema::schema::network_interface::dsl::ip, Uuid, nexus_db_schema::schema::network_interface::dsl::subnet_id, @@ -537,15 +534,49 @@ pub struct NextIpv4Address { impl NextIpv4Address { pub fn new(subnet: Ipv4Network, subnet_id: Uuid) -> Self { - let subnet = IpNetwork::from(subnet); - let min = IpNetwork::from(first_available_address(&subnet)); - let max = IpNetwork::from(last_available_address(&subnet)); - Self { inner: NextItemSelfJoined::new_scoped(subnet_id, min, max) } + let min = first_available_ipv4_address(&subnet); + let max = last_available_ipv4_address(&subnet); + Self { + inner: NextItemSelfJoined::new_scoped( + subnet_id, + min.into(), + max.into(), + ), + } } } delegate_query_fragment_impl!(NextIpv4Address); +/// The `NextIpv6Address` query is a `NextItem` query for choosing the next +/// available IPv6 address for an interface. +#[derive(Debug, Clone, Copy)] +pub struct NextIpv6Address { + inner: NextItemSelfJoined< + nexus_db_schema::schema::network_interface::table, + db::model::Ipv6Addr, + nexus_db_schema::schema::network_interface::dsl::ipv6, + Uuid, + nexus_db_schema::schema::network_interface::dsl::subnet_id, + >, +} + +impl NextIpv6Address { + pub fn new(subnet: Ipv6Network, subnet_id: Uuid) -> Self { + let min = first_available_ipv6_address(&subnet); + let max = last_available_ipv6_address(&subnet); + Self { + inner: NextItemSelfJoined::new_scoped( + subnet_id, + min.into(), + max.into(), + ), + } + } +} + +delegate_query_fragment_impl!(NextIpv6Address); + /// A `NextItem` subquery that selects the next empty slot for an interface. /// /// This pushes a subquery that looks like: @@ -1000,7 +1031,7 @@ pub struct InsertQuery { interface: IncompleteNetworkInterface, now: DateTime, - // The following fields are derived from the previous fields. This begs the + // The following fields are derived from the previous fields. This raises the // question: "Why bother storing them at all?" // // Diesel's [`diesel::query_builder::ast_pass::AstPass:push_bind_param`] method @@ -1010,44 +1041,94 @@ pub struct InsertQuery { vpc_id_str: String, subnet_id_str: String, parent_id_str: String, - ip_sql: Option, + ipv4_sql: AutoOrOptionalIp, + ipv6_sql: AutoOrOptionalIp, + transit_ips: Vec, mac_sql: Option, slot_sql: Option, next_mac_subquery: NextMacAddress, - next_ipv4_address_subquery: NextIpv4Address, next_slot_subquery: NextNicSlot, is_primary_subquery: IsPrimaryNic, } +/// Helper type to insert an interface's IP address. +/// +/// This inserts either a constant IP address, NULL, or the result of the +/// next-item subquery that selects the next available IP address. +#[derive(Debug, Clone)] +enum AutoOrOptionalIp { + /// Insert the result of the subquery that automatically selects an address. + Auto(Q), + /// Insert an IP address or NULL if one isn't requested for the interface. + Nullable(Option), +} + +impl AutoOrOptionalIp +where + Q: QueryFragment, +{ + fn new(assignment: Option<&IpAssignment>, auto: Q) -> Self + where + T: Ip, + { + match assignment { + None => AutoOrOptionalIp::Nullable(None), + Some(IpAssignment::Auto) => AutoOrOptionalIp::Auto(auto), + Some(IpAssignment::Explicit(ip)) => { + AutoOrOptionalIp::Nullable(Some(ip.into_ipnet())) + } + } + } +} + impl InsertQuery { pub fn new(interface: IncompleteNetworkInterface) -> Self { let vpc_id_str = interface.subnet.vpc_id.to_string(); let subnet_id_str = interface.subnet.identity.id.to_string(); let kind = interface.kind; let parent_id_str = interface.parent_id.to_string(); - let ip_sql = interface.ip.map(|ip| ip.into()); let mac_sql = interface.mac.map(|mac| mac.into()); let slot_sql = interface.slot.map(|slot| slot.into()); let next_mac_subquery = NextMacAddress::new(interface.subnet.vpc_id, interface.kind); + let next_slot_subquery = NextNicSlot::new(interface.parent_id); + let is_primary_subquery = + IsPrimaryNic { kind, parent_id: interface.parent_id }; let next_ipv4_address_subquery = NextIpv4Address::new( interface.subnet.ipv4_block.0.into(), interface.subnet.identity.id, ); - let next_slot_subquery = NextNicSlot::new(interface.parent_id); - let is_primary_subquery = - IsPrimaryNic { kind, parent_id: interface.parent_id }; + let ipv4_sql = AutoOrOptionalIp::new( + interface.ip_config.ipv4_assignment(), + next_ipv4_address_subquery, + ); + let next_ipv6_address_subquery = NextIpv6Address::new( + interface.subnet.ipv6_block.0.into(), + interface.subnet.identity.id, + ); + let ipv6_sql = AutoOrOptionalIp::new( + interface.ip_config.ipv6_assignment(), + next_ipv6_address_subquery, + ); + let transit_ips = interface + .ip_config + .transit_ips() + .into_iter() + .map(Into::into) + .collect(); + Self { interface, now: Utc::now(), vpc_id_str, subnet_id_str, parent_id_str, - ip_sql, + ipv4_sql, + ipv6_sql, + transit_ips, mac_sql, slot_sql, next_mac_subquery, - next_ipv4_address_subquery, next_slot_subquery, is_primary_subquery, } @@ -1191,17 +1272,37 @@ impl QueryFragment for InsertQuery { // If the user specified an IP address, then insert it by value. If they // did not, meaning we're allocating the next available one on their // behalf, then insert that subquery here. - if let Some(ref ip) = &self.ip_sql { - out.push_bind_param::(ip)?; - } else { - out.push_sql("("); - self.next_ipv4_address_subquery.walk_ast(out.reborrow())?; - out.push_sql(")"); + match &self.ipv4_sql { + AutoOrOptionalIp::Auto(subquery) => { + out.push_sql("("); + subquery.walk_ast(out.reborrow())?; + out.push_sql(")"); + } + AutoOrOptionalIp::Nullable(maybe_ip) => out + .push_bind_param::, _>( + maybe_ip, + )?, } out.push_sql(" AS "); out.push_identifier(dsl::ip::NAME)?; out.push_sql(", "); + // Same for IPv6 addresses. + match &self.ipv6_sql { + AutoOrOptionalIp::Auto(subquery) => { + out.push_sql("("); + subquery.walk_ast(out.reborrow())?; + out.push_sql(")"); + } + AutoOrOptionalIp::Nullable(maybe_ip) => out + .push_bind_param::, _>( + maybe_ip, + )?, + } + out.push_sql(" AS "); + out.push_identifier(dsl::ipv6::NAME)?; + out.push_sql(", "); + if let Some(slot) = &self.slot_sql { out.push_bind_param::(slot)?; } else { @@ -1217,7 +1318,7 @@ impl QueryFragment for InsertQuery { out.push_sql(", "); out.push_bind_param::, Vec>( - &self.interface.transit_ips, + &self.transit_ips, )?; out.push_sql(" AS "); out.push_identifier(dsl::transit_ips::NAME)?; @@ -1272,6 +1373,8 @@ impl QueryFragment for InsertQueryValues { out.push_sql(", "); out.push_identifier(dsl::ip::NAME)?; out.push_sql(", "); + out.push_identifier(dsl::ipv6::NAME)?; + out.push_sql(", "); out.push_identifier(dsl::slot::NAME)?; out.push_sql(", "); out.push_identifier(dsl::is_primary::NAME)?; @@ -1846,7 +1949,6 @@ mod tests { use super::InsertError; use super::MAX_NICS_PER_INSTANCE; use super::NUM_INITIAL_RESERVED_IP_ADDRESSES; - use super::first_available_address; use crate::authz; use crate::context::OpContext; use crate::db::datastore::DataStore; @@ -1859,11 +1961,18 @@ mod tests { use crate::db::model::Project; use crate::db::model::VpcSubnet; use crate::db::pub_test_utils::TestDatabase; - use crate::db::queries::network_interface::last_available_address; + use crate::db::queries::network_interface::first_available_ipv4_address; + use crate::db::queries::network_interface::first_available_ipv6_address; + use crate::db::queries::network_interface::last_available_ipv4_address; + use crate::db::queries::network_interface::last_available_ipv6_address; use async_bb8_diesel::AsyncRunQueryDsl; use dropshot::test_util::LogContext; use model::NetworkInterfaceKind; use nexus_db_lookup::LookupPath; + use nexus_db_model::IpConfig; + use nexus_db_model::IpVersion; + use nexus_db_model::Ipv4Assignment; + use nexus_db_model::Ipv4Config; use nexus_types::external_api::params; use nexus_types::external_api::params::InstanceCreate; use nexus_types::external_api::params::InstanceNetworkInterfaceAttachment; @@ -2136,7 +2245,7 @@ mod tests { name: "service-nic".parse().unwrap(), description: String::from("service nic"), }, - ip.into(), + IpConfig::from_ipv4(ip), MacAddr::random_system(), 0, ) @@ -2208,8 +2317,7 @@ mod tests { name: "interface-a".parse().unwrap(), description: String::from("description"), }, - Some(requested_ip), - vec![], + IpConfig::from_ipv4(requested_ip), ) .unwrap(); let err = context.datastore() @@ -2225,11 +2333,33 @@ mod tests { } #[tokio::test] - async fn test_insert_request_exact_ip() { - let context = TestContext::new("test_insert_request_exact_ip", 2).await; + async fn can_request_exact_ipv4() { + let context = TestContext::new("can_request_exact_ipv4", 2).await; + can_request_exact_ip_impl(context, IpVersion::V4).await + } + + #[tokio::test] + async fn can_request_exact_ipv6() { + let context = TestContext::new("can_request_exact_ipv6", 2).await; + can_request_exact_ip_impl(context, IpVersion::V6).await + } + + async fn can_request_exact_ip_impl( + context: TestContext, + ip_version: IpVersion, + ) { let instance = context.create_stopped_instance().await; let instance_id = InstanceUuid::from_untyped_uuid(instance.id()); - let requested_ip = "172.30.0.5".parse().unwrap(); + let (requested_ip, ip_config) = match ip_version { + IpVersion::V4 => { + let addr = context.net1.subnets[0].ipv4_block.nth(5).unwrap(); + (IpAddr::V4(addr), IpConfig::from_ipv4(addr)) + } + IpVersion::V6 => { + let addr = context.net1.subnets[0].ipv6_block.nth(5).unwrap(); + (IpAddr::V6(addr), IpConfig::from_ipv6(addr)) + } + }; let interface = IncompleteNetworkInterface::new_instance( Uuid::new_v4(), instance_id, @@ -2238,8 +2368,7 @@ mod tests { name: "interface-a".parse().unwrap(), description: String::from("description"), }, - Some(requested_ip), - vec![], + ip_config, ) .unwrap(); let inserted_interface = context @@ -2251,15 +2380,28 @@ mod tests { .await .expect("Failed to insert interface with known-good IP address"); assert_interfaces_eq(&interface, &inserted_interface.clone().into()); + let actual_addr: IpAddr = match ip_version { + IpVersion::V4 => { + inserted_interface.ipv4.expect("an IPv4 address").into() + } + IpVersion::V6 => { + inserted_interface.ipv6.expect("an IPv6 address").into() + } + }; assert_eq!( - IpAddr::from(inserted_interface.ipv4.expect("an IPv4 address")), - requested_ip, + actual_addr, requested_ip, "The requested IP address should be available when no interfaces exist in the table" ); - assert!( - inserted_interface.ipv6.is_none(), - "Should not have an IPv6 address" - ); + match ip_version { + IpVersion::V4 => assert!( + inserted_interface.ipv6.is_none(), + "Should not have an IPv6 address" + ), + IpVersion::V6 => assert!( + inserted_interface.ipv4.is_none(), + "Should not have an IPv4 address" + ), + } context.success().await; } @@ -2275,8 +2417,7 @@ mod tests { name: "interface-b".parse().unwrap(), description: String::from("description"), }, - None, - vec![], + IpConfig::auto_ipv4(), ) .unwrap(); let err = context.datastore() @@ -2314,8 +2455,7 @@ mod tests { name: format!("interface-{}", i).parse().unwrap(), description: String::from("description"), }, - None, - vec![], + IpConfig::auto_ipv4(), ) .unwrap(); let inserted_interface = context @@ -2365,8 +2505,7 @@ mod tests { name: "interface-c".parse().unwrap(), description: String::from("description"), }, - None, - vec![], + IpConfig::auto_ipv4(), ) .unwrap(); let inserted_interface = context @@ -2386,8 +2525,7 @@ mod tests { name: "interface-c".parse().unwrap(), description: String::from("description"), }, - Some(ip.into()), - vec![], + IpConfig::from_ipv4(ip.into()), ) .unwrap(); let result = context @@ -2424,7 +2562,7 @@ mod tests { name: "service-nic".parse().unwrap(), description: String::from("service nic"), }, - ip.into(), + IpConfig::from_ipv4(ip), mac, 0, ) @@ -2464,7 +2602,7 @@ mod tests { name: "service-nic".parse().unwrap(), description: String::from("service nic"), }, - ip.into(), + IpConfig::from_ipv4(ip), mac, slot, ) @@ -2504,7 +2642,7 @@ mod tests { name: "service-nic".parse().unwrap(), description: String::from("service nic"), }, - ips.next().expect("exhausted test subnet").into(), + IpConfig::from_ipv4(ips.next().expect("exhausted test subnet")), mac, 0, ) @@ -2527,7 +2665,7 @@ mod tests { name: "new-service-nic".parse().unwrap(), description: String::from("new-service nic"), }, - ips.next().expect("exhausted test subnet").into(), + IpConfig::from_ipv4(ips.next().expect("exhausted test subnet")), mac, 0, ) @@ -2583,7 +2721,7 @@ mod tests { name: "service-nic".parse().unwrap(), description: String::from("service nic"), }, - ip0.into(), + IpConfig::from_ipv4(ip0), next_mac(), 0, ) @@ -2604,7 +2742,7 @@ mod tests { name: "new-service-nic".parse().unwrap(), description: String::from("new-service nic"), }, - ip1.into(), + IpConfig::from_ipv4(ip1), next_mac(), 0, ) @@ -2637,8 +2775,7 @@ mod tests { name: "interface-c".parse().unwrap(), description: String::from("description"), }, - None, - vec![], + IpConfig::auto_ipv4(), ) .unwrap(); let _ = context @@ -2657,8 +2794,7 @@ mod tests { name: "interface-c".parse().unwrap(), description: String::from("description"), }, - None, - vec![], + IpConfig::auto_ipv4(), ) .unwrap(); let result = context @@ -2689,8 +2825,7 @@ mod tests { name: "interface-c".parse().unwrap(), description: String::from("description"), }, - None, - vec![], + IpConfig::auto_ipv4(), ) .unwrap(); let _ = context @@ -2706,8 +2841,7 @@ mod tests { name: "interface-d".parse().unwrap(), description: String::from("description"), }, - None, - vec![], + IpConfig::auto_ipv4(), ) .unwrap(); let result = context @@ -2735,8 +2869,7 @@ mod tests { name: "interface-c".parse().unwrap(), description: String::from("description"), }, - None, - vec![], + IpConfig::auto_ipv4(), ) .unwrap(); let _ = context @@ -2778,8 +2911,7 @@ mod tests { name: "interface-c".parse().unwrap(), description: String::from("description"), }, - None, - vec![], + IpConfig::auto_ipv4(), ) .unwrap(); let _ = context @@ -2788,7 +2920,9 @@ mod tests { .await .expect("Failed to insert interface"); let expected_address = "172.30.0.5".parse().unwrap(); - for addr in [Some(expected_address), None] { + for ip_config in + [IpConfig::from_ipv4(expected_address), IpConfig::auto_ipv4()] + { let interface = IncompleteNetworkInterface::new_instance( Uuid::new_v4(), instance_id, @@ -2797,8 +2931,7 @@ mod tests { name: "interface-a".parse().unwrap(), description: String::from("description"), }, - addr, - vec![], + ip_config, ) .unwrap(); let result = context @@ -2837,8 +2970,7 @@ mod tests { name: "interface-c".parse().unwrap(), description: String::from("description"), }, - None, - vec![], + IpConfig::auto_ipv4(), ) .unwrap(); let _ = context @@ -2867,8 +2999,7 @@ mod tests { name: "interface-d".parse().unwrap(), description: String::from("description"), }, - None, - vec![], + IpConfig::auto_ipv4(), ) .unwrap(); let result = context @@ -2901,8 +3032,7 @@ mod tests { name: format!("if{}", i).parse().unwrap(), description: String::from("description"), }, - None, - vec![], + IpConfig::auto_ipv4(), ) .unwrap(); let result = context @@ -2947,7 +3077,15 @@ mod tests { "The random MAC address {:?} is not a valid {} address", inserted.mac, kind, ); - assert_eq!(inserted.transit_ips, incomplete.transit_ips); + assert_eq!( + inserted.transit_ips, + incomplete + .ip_config + .transit_ips() + .into_iter() + .map(ipnetwork::IpNetwork::from) + .collect::>() + ); } // Test that we fail to insert an interface if there are no available slots @@ -2971,8 +3109,7 @@ mod tests { name: format!("interface-{}", slot).parse().unwrap(), description: String::from("description"), }, - None, - vec![], + IpConfig::auto_ipv4(), ) .unwrap(); let inserted_interface = context @@ -3007,8 +3144,7 @@ mod tests { name: "interface-8".parse().unwrap(), description: String::from("description"), }, - None, - vec![], + IpConfig::auto_ipv4(), ) .unwrap(); let result = context @@ -3026,16 +3162,44 @@ mod tests { // Regression for https://github.com/oxidecomputer/omicron/issues/8208 #[tokio::test] - async fn allocation_and_deallocation_takes_next_smallest_address() { + async fn allocation_and_deallocation_takes_next_smallest_ipv4_address() { let context = TestContext::new( - "allocation_and_deallocation_takes_next_smallest_address", + "allocation_and_deallocation_takes_next_smallest_ipv4_address", 1, ) .await; + allocation_and_deallocation_takes_next_smallest_address_impl( + context, + IpVersion::V4, + ) + .await + } + #[tokio::test] + async fn allocation_and_deallocation_takes_next_smallest_ipv6_address() { + let context = TestContext::new( + "allocation_and_deallocation_takes_next_smallest_ipv6_address", + 1, + ) + .await; + allocation_and_deallocation_takes_next_smallest_address_impl( + context, + IpVersion::V6, + ) + .await + } + + async fn allocation_and_deallocation_takes_next_smallest_address_impl( + context: TestContext, + ip_version: IpVersion, + ) { // Create three instances, each with an interface. const N_INSTANCES: usize = 3; let mut instances = Vec::with_capacity(N_INSTANCES); + let ip_config = match ip_version { + IpVersion::V4 => IpConfig::auto_ipv4(), + IpVersion::V6 => IpConfig::auto_ipv6(), + }; for _ in 0..N_INSTANCES { let instance = context.create_stopped_instance().await; let instance_id = InstanceUuid::from_untyped_uuid(instance.id()); @@ -3047,8 +3211,7 @@ mod tests { name: "interface-c".parse().unwrap(), description: String::from("description"), }, - None, - vec![], + ip_config.clone(), ) .unwrap(); let intf = context @@ -3063,7 +3226,14 @@ mod tests { } // Delete the NIC on the first instance. - let original_ip = instances[0].1.ipv4.expect("an IPv4 address"); + let original_ip = match ip_version { + IpVersion::V4 => { + IpAddr::V4(instances[0].1.ipv4.expect("an IPv4 address").into()) + } + IpVersion::V6 => { + IpAddr::V6(instances[0].1.ipv6.expect("an IPv6 address").into()) + } + }; context.delete_instance_nics(instances[0].0.id()).await; // And recreate it, ensuring we get the same IP address again. @@ -3075,8 +3245,7 @@ mod tests { name: "interface-c".parse().unwrap(), description: String::from("description"), }, - None, - vec![], + ip_config.clone(), ) .unwrap(); let intf = context @@ -3085,7 +3254,14 @@ mod tests { .await .expect("Failed to insert interface"); instances[0].1 = intf; - let new_ip = instances[0].1.ipv4.expect("an IPv4 address"); + let new_ip = match ip_version { + IpVersion::V4 => { + IpAddr::V4(instances[0].1.ipv4.expect("an IPv4 address").into()) + } + IpVersion::V6 => { + IpAddr::V6(instances[0].1.ipv6.expect("an IPv6 address").into()) + } + }; assert_eq!( new_ip, original_ip, "Should have recreated the first available IP address again" @@ -3107,8 +3283,7 @@ mod tests { name: "interface-c".parse().unwrap(), description: String::from("description"), }, - None, - vec![], + ip_config.clone(), ) .unwrap(); let intf = context @@ -3116,8 +3291,22 @@ mod tests { .instance_create_network_interface_raw(context.opctx(), interface) .await .expect("Failed to insert interface"); + let (actual_addr, expected_addr) = match ip_version { + IpVersion::V4 => ( + IpAddr::V4(intf.ipv4.expect("an IPv4 address").into()), + IpAddr::V4( + instances[1].1.ipv4.expect("an IPv4 address").into(), + ), + ), + IpVersion::V6 => ( + IpAddr::V6(intf.ipv6.expect("an IPv6 address").into()), + IpAddr::V6( + instances[1].1.ipv6.expect("an IPv6 address").into(), + ), + ), + }; assert_eq!( - intf.ipv4, instances[1].1.ipv4, + actual_addr, expected_addr, "Should have used the second address", ); @@ -3150,8 +3339,7 @@ mod tests { name: "interface-c".parse().unwrap(), description: String::from("description"), }, - Some(IpAddr::V4(addr)), - vec![], + IpConfig::from_ipv4(addr), ) .unwrap(); let _ = context @@ -3171,8 +3359,7 @@ mod tests { name: "interface-c".parse().unwrap(), description: String::from("description"), }, - None, - vec![], + IpConfig::auto_ipv4(), ) .unwrap(); @@ -3205,13 +3392,13 @@ mod tests { fn test_first_available_address() { let subnet = "172.30.0.0/28".parse().unwrap(); assert_eq!( - first_available_address(&subnet), - "172.30.0.5".parse::().unwrap(), + first_available_ipv4_address(&subnet), + "172.30.0.5".parse::().unwrap(), ); let subnet = "fd00::/64".parse().unwrap(); assert_eq!( - first_available_address(&subnet), - "fd00::5".parse::().unwrap(), + first_available_ipv6_address(&subnet), + "fd00::5".parse::().unwrap(), ); } @@ -3219,13 +3406,13 @@ mod tests { fn test_last_available_address() { let subnet = "172.30.0.0/28".parse().unwrap(); assert_eq!( - last_available_address(&subnet), - "172.30.0.14".parse::().unwrap(), + last_available_ipv4_address(&subnet), + "172.30.0.14".parse::().unwrap(), ); let subnet = "fd00::/64".parse().unwrap(); assert_eq!( - last_available_address(&subnet), - "fd00::ffff:ffff:ffff:fffe".parse::().unwrap(), + last_available_ipv6_address(&subnet), + "fd00::ffff:ffff:ffff:fffe".parse::().unwrap(), ); } @@ -3242,6 +3429,10 @@ mod tests { "172.16.0.0/16".parse().unwrap(), ]; + let ip_config = IpConfig::V4(Ipv4Config { + ip: Ipv4Assignment::Auto, + transit_ips: transit_ips.clone(), + }); let interface = IncompleteNetworkInterface::new_instance( Uuid::new_v4(), instance_id, @@ -3250,8 +3441,7 @@ mod tests { name: "interface-with-transit".parse().unwrap(), description: String::from("Test interface with transit IPs"), }, - None, // Auto-assign IP - transit_ips.clone(), + ip_config, ) .unwrap(); @@ -3269,4 +3459,51 @@ mod tests { context.success().await; } + + // We'll create a bunch of instances, in the same subnet, each with many + // interfaces assigned automatic IPv6 addresses. This test is pretty + // silly, in that it can't really _fail_, just that we don't hit any + // pathological behavior when trying to allocate more than a small + // number of IPv6 addresses. + #[tokio::test] + #[ignore] + async fn can_allocate_many_ipv6_interfaces() { + let context = + TestContext::new("can_allocate_many_ipv6_interfaces", 1).await; + const N_INSTANCES: usize = 256; + let mut interfaces = + Vec::with_capacity(N_INSTANCES * MAX_NICS_PER_INSTANCE); + for _ in 0..N_INSTANCES { + let instance = context.create_stopped_instance().await; + let instance_id = InstanceUuid::from_untyped_uuid(instance.id()); + let interface = IncompleteNetworkInterface::new_instance( + Uuid::new_v4(), + instance_id, + context.net1.subnets[0].clone(), + IdentityMetadataCreateParams { + name: "net0".parse().unwrap(), + description: String::from("description"), + }, + IpConfig::auto_ipv6(), + ) + .unwrap(); + let intf = context + .datastore() + .instance_create_network_interface_raw( + context.opctx(), + interface, + ) + .await + .expect("Failed to insert interface"); + interfaces.push(intf); + } + let start = context.net1.subnets[0].ipv6_block; + for (i, intf) in interfaces.iter().enumerate() { + assert!(intf.ipv4.is_none()); + let addr = intf.ipv6.expect("an IPv6 address"); + let expected_addr = start.nth((i + 5) as _).unwrap(); + assert_eq!(expected_addr, std::net::Ipv6Addr::from(addr)); + } + context.success().await; + } } diff --git a/nexus/src/app/network_interface.rs b/nexus/src/app/network_interface.rs index 42fc324406..a7e7bec174 100644 --- a/nexus/src/app/network_interface.rs +++ b/nexus/src/app/network_interface.rs @@ -1,4 +1,9 @@ +use std::net::IpAddr; + use nexus_db_lookup::lookup; +use nexus_db_model::IpConfig; +use nexus_db_model::Ipv4Assignment; +use nexus_db_model::Ipv4Config; use nexus_db_queries::authz::ApiResource; use nexus_db_queries::context::OpContext; use nexus_db_queries::db::queries::network_interface; @@ -72,6 +77,33 @@ impl super::Nexus { instance_lookup: &lookup::Instance<'_>, params: ¶ms::InstanceNetworkInterfaceCreate, ) -> CreateResult { + // TODO-completeness: Support creating dual-stack NICs in the public + // API. See https://github.com/oxidecomputer/omicron/issues/9248. + let ipv4_assignment = match params.ip { + Some(IpAddr::V4(ip)) => Ipv4Assignment::Explicit(ip), + Some(IpAddr::V6(_)) => { + return Err(Error::invalid_request( + "IPv6 addressing is not yet suported for network interfaces", + )); + } + None => Ipv4Assignment::Auto, + }; + let transit_ips = params + .transit_ips + .iter() + .map(|ipnet| { + let oxnet::IpNet::V4(net) = ipnet else { + return Err(Error::invalid_request( + "IPv6 transit IPs are not yet supported \ + for network interfaces", + )); + }; + Ok(*net) + }) + .collect::>()?; + let ip_config = + IpConfig::V4(Ipv4Config { ip: ipv4_assignment, transit_ips }); + let (.., authz_project, authz_instance) = instance_lookup.lookup_for(authz::Action::Modify).await?; @@ -92,8 +124,7 @@ impl super::Nexus { InstanceUuid::from_untyped_uuid(authz_instance.id()), db_subnet, params.identity.clone(), - params.ip, - params.transit_ips.iter().map(|ip| (*ip).into()).collect(), + ip_config, )?; self.db_datastore .instance_create_network_interface( diff --git a/nexus/src/app/sagas/instance_create.rs b/nexus/src/app/sagas/instance_create.rs index 89f8ccaf88..1058f9797d 100644 --- a/nexus/src/app/sagas/instance_create.rs +++ b/nexus/src/app/sagas/instance_create.rs @@ -11,7 +11,9 @@ use crate::app::{ }; use crate::external_api::params; use nexus_db_lookup::LookupPath; -use nexus_db_model::{ExternalIp, NetworkInterfaceKind}; +use nexus_db_model::{ + ExternalIp, IpConfig, Ipv4Assignment, Ipv4Config, NetworkInterfaceKind, +}; use nexus_db_queries::db::queries::network_interface::InsertError as InsertNicError; use nexus_db_queries::{authn, authz, db}; use nexus_defaults::DEFAULT_PRIMARY_NIC_NAME; @@ -31,6 +33,7 @@ use slog::warn; use std::collections::HashSet; use std::convert::TryFrom; use std::fmt::Debug; +use std::net::IpAddr; use steno::ActionError; use steno::Node; use steno::{DagBuilder, SagaName}; @@ -605,13 +608,41 @@ async fn create_custom_network_interface( .fetch() .await .map_err(ActionError::action_failed)?; + + // TODO-completeness: Support IPv6 addressing in the public API, see + // https://github.com/oxidecomputer/omicron/issues/9248. + let ipv4_assignment = match interface_params.ip { + Some(IpAddr::V4(ip)) => Ipv4Assignment::Explicit(ip), + Some(IpAddr::V6(_)) => { + return Err(ActionError::action_failed(Error::invalid_request( + "IPv6 addressing is not yet suported for network interfaces", + ))); + } + None => Ipv4Assignment::Auto, + }; + let transit_ips = interface_params + .transit_ips + .iter() + .map(|ipnet| { + let oxnet::IpNet::V4(net) = ipnet else { + return Err(ActionError::action_failed( + Error::invalid_request( + "IPv6 transit IPs are not yet supported \ + for network interfaces", + ), + )); + }; + Ok(*net) + }) + .collect::>()?; + let ip_config = + IpConfig::V4(Ipv4Config { ip: ipv4_assignment, transit_ips }); let interface = db::model::IncompleteNetworkInterface::new_instance( interface_id, instance_id, db_subnet.clone(), interface_params.identity.clone(), - interface_params.ip, - interface_params.transit_ips.iter().map(|ip| (*ip).into()).collect(), + ip_config, ) .map_err(ActionError::action_failed)?; datastore @@ -701,13 +732,40 @@ async fn create_default_primary_network_interface( .await .map_err(ActionError::action_failed)?; + // TODO-completeness: Support IPv6 addressing in the public API, see + // https://github.com/oxidecomputer/omicron/issues/9248. + let ipv4_assignment = match interface_params.ip { + Some(IpAddr::V4(ip)) => Ipv4Assignment::Explicit(ip), + Some(IpAddr::V6(_)) => { + return Err(ActionError::action_failed(Error::invalid_request( + "IPv6 addressing is not yet suported for network interfaces", + ))); + } + None => Ipv4Assignment::Auto, + }; + let transit_ips = interface_params + .transit_ips + .iter() + .map(|ipnet| { + let oxnet::IpNet::V4(net) = ipnet else { + return Err(ActionError::action_failed( + Error::invalid_request( + "IPv6 transit IPs are not yet supported \ + for network interfaces", + ), + )); + }; + Ok(*net) + }) + .collect::>()?; + let ip_config = + IpConfig::V4(Ipv4Config { ip: ipv4_assignment, transit_ips }); let interface = db::model::IncompleteNetworkInterface::new_instance( interface_id, instance_id, db_subnet.clone(), interface_params.identity.clone(), - interface_params.ip, - interface_params.transit_ips.iter().map(|ip| (*ip).into()).collect(), + ip_config, ) .map_err(ActionError::action_failed)?; datastore