diff --git a/end-to-end-tests/src/bin/bootstrap.rs b/end-to-end-tests/src/bin/bootstrap.rs index 5aa9cf22f7f..fe84c98c70b 100644 --- a/end-to-end-tests/src/bin/bootstrap.rs +++ b/end-to-end-tests/src/bin/bootstrap.rs @@ -6,8 +6,8 @@ use end_to_end_tests::helpers::{ use omicron_test_utils::dev::poll::{CondCheckError, wait_for_condition}; use oxide_client::types::{ ByteCount, DeviceAccessTokenRequest, DeviceAuthRequest, DeviceAuthVerify, - DiskCreate, DiskSource, IpPoolCreate, IpPoolLinkSilo, IpPoolType, - IpVersion, NameOrId, SiloQuotasUpdate, + DiskCreate, DiskSource, IpPoolCreate, IpPoolLinkSilo, + IpPoolReservationType, IpPoolType, IpVersion, NameOrId, SiloQuotasUpdate, }; use oxide_client::{ ClientConsoleAuthExt, ClientDisksExt, ClientProjectsExt, @@ -48,17 +48,18 @@ async fn run_test() -> Result<()> { eprintln!("creating IP{} IP pool... {:?} - {:?}", ip_version, first, last); let pool_name = "default"; client - .ip_pool_create() + .system_ip_pool_create() .body(IpPoolCreate { name: pool_name.parse().unwrap(), description: "Default IP pool".to_string(), ip_version, pool_type: IpPoolType::Unicast, + reservation_type: IpPoolReservationType::ExternalSilos, }) .send() .await?; client - .ip_pool_silo_link() + .system_ip_pool_silo_link() .pool(pool_name) .body(IpPoolLinkSilo { silo: NameOrId::Name(params.silo_name().parse().unwrap()), @@ -67,7 +68,7 @@ async fn run_test() -> Result<()> { .send() .await?; client - .ip_pool_range_add() + .system_ip_pool_range_add() .pool(pool_name) .body(try_create_ip_range(first, last)?) .send() diff --git a/end-to-end-tests/src/bin/commtest.rs b/end-to-end-tests/src/bin/commtest.rs index 2fae239db59..7c8edd02802 100644 --- a/end-to-end-tests/src/bin/commtest.rs +++ b/end-to-end-tests/src/bin/commtest.rs @@ -7,9 +7,9 @@ use oxide_client::{ ClientSystemHardwareExt, ClientSystemIpPoolsExt, ClientSystemStatusExt, ClientVpcsExt, types::{ - IpPoolCreate, IpPoolLinkSilo, IpPoolType, IpRange, IpVersion, Name, - NameOrId, PingStatus, ProbeCreate, ProbeInfo, ProjectCreate, - UsernamePasswordCredentials, + IpPoolCreate, IpPoolLinkSilo, IpPoolReservationType, IpPoolType, + IpRange, IpVersion, Name, NameOrId, PingStatus, ProbeCreate, ProbeInfo, + ProjectCreate, UsernamePasswordCredentials, }, }; use std::{ @@ -280,48 +280,49 @@ async fn rack_prepare( })?; let pool_name = "default"; - api_retry!( - if let Err(e) = oxide.ip_pool_view().pool("default").send().await { - if let Some(reqwest::StatusCode::NOT_FOUND) = e.status() { - print!("default ip pool does not exist, creating ..."); - let ip_version = if args.ip_pool_begin.is_ipv4() { - IpVersion::V4 - } else { - IpVersion::V6 - }; - oxide - .ip_pool_create() - .body(IpPoolCreate { - name: pool_name.parse().unwrap(), - description: "Default IP pool".to_string(), - ip_version, - pool_type: IpPoolType::Unicast, - }) - .send() - .await?; - oxide - .ip_pool_silo_link() - .pool(pool_name) - .body(IpPoolLinkSilo { - silo: NameOrId::Name("recovery".parse().unwrap()), - is_default: true, - }) - .send() - .await?; - println!("done"); - Ok(()) + api_retry!(if let Err(e) = + oxide.system_ip_pool_view().pool("default").send().await + { + if let Some(reqwest::StatusCode::NOT_FOUND) = e.status() { + print!("default ip pool does not exist, creating ..."); + let ip_version = if args.ip_pool_begin.is_ipv4() { + IpVersion::V4 } else { - Err(e) - } - } else { - println!("default ip pool already exists"); + IpVersion::V6 + }; + oxide + .system_ip_pool_create() + .body(IpPoolCreate { + name: pool_name.parse().unwrap(), + description: "Default IP pool".to_string(), + ip_version, + pool_type: IpPoolType::Unicast, + reservation_type: IpPoolReservationType::ExternalSilos, + }) + .send() + .await?; + oxide + .system_ip_pool_silo_link() + .pool(pool_name) + .body(IpPoolLinkSilo { + silo: NameOrId::Name("recovery".parse().unwrap()), + is_default: true, + }) + .send() + .await?; + println!("done"); Ok(()) + } else { + Err(e) } - )?; + } else { + println!("default ip pool already exists"); + Ok(()) + })?; let pool = api_retry!( oxide - .ip_pool_range_list() + .system_ip_pool_range_list() .limit(u32::MAX) .pool(Name::try_from("default").unwrap()) .send() @@ -346,7 +347,7 @@ async fn rack_prepare( print!("ip range does not exist, creating ... "); api_retry!( oxide - .ip_pool_range_add() + .system_ip_pool_range_add() .pool(Name::try_from("default").unwrap()) .body(range.clone()) .send() diff --git a/nexus/db-model/src/ip_pool.rs b/nexus/db-model/src/ip_pool.rs index b0d331b4cd3..c4c0eb41fb8 100644 --- a/nexus/db-model/src/ip_pool.rs +++ b/nexus/db-model/src/ip_pool.rs @@ -80,6 +80,24 @@ impl ::std::fmt::Display for IpPoolReservationType { } } +impl From for IpPoolReservationType { + fn from(value: shared::IpPoolReservationType) -> Self { + match value { + shared::IpPoolReservationType::ExternalSilos => Self::ExternalSilos, + shared::IpPoolReservationType::OxideInternal => Self::OxideInternal, + } + } +} + +impl From for shared::IpPoolReservationType { + fn from(value: IpPoolReservationType) -> Self { + match value { + IpPoolReservationType::ExternalSilos => Self::ExternalSilos, + IpPoolReservationType::OxideInternal => Self::OxideInternal, + } + } +} + impl_enum_type!( IpVersionEnum: @@ -226,15 +244,13 @@ impl IpPool { } } -impl From for views::IpPool { +impl From for views::SystemIpPool { fn from(pool: IpPool) -> Self { - let identity = pool.identity(); - let pool_type = pool.pool_type; - Self { - identity, - pool_type: pool_type.into(), + identity: pool.identity(), + pool_type: pool.pool_type.into(), ip_version: pool.ip_version.into(), + reservation_type: pool.reservation_type.into(), } } } diff --git a/nexus/db-queries/src/db/datastore/deployment.rs b/nexus/db-queries/src/db/datastore/deployment.rs index 82d1d59f984..e235377c825 100644 --- a/nexus/db-queries/src/db/datastore/deployment.rs +++ b/nexus/db-queries/src/db/datastore/deployment.rs @@ -4310,9 +4310,13 @@ mod tests { )) .unwrap(); let (service_authz_ip_pool, service_ip_pool) = datastore - .ip_pools_service_lookup(&opctx, IpVersion::V4) + .fetch_first_oxide_internal_ip_pool( + &opctx, + authz::Action::CreateChild, + Some(IpVersion::V4), + ) .await - .expect("lookup service ip pool"); + .expect("Failed authz check on delegated IP Pool"); datastore .ip_pool_add_range( &opctx, @@ -4449,9 +4453,13 @@ mod tests { }) .expect("found external IP"); let (service_authz_ip_pool, service_ip_pool) = datastore - .ip_pools_service_lookup(&opctx, IpVersion::V4) + .fetch_first_oxide_internal_ip_pool( + &opctx, + authz::Action::CreateChild, + Some(IpVersion::V4), + ) .await - .expect("lookup service ip pool"); + .expect("Failed authz check for delegated IP Pool"); datastore .ip_pool_add_range( &opctx, 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 b21ce19ad99..ba1984982ae 100644 --- a/nexus/db-queries/src/db/datastore/deployment/external_networking.rs +++ b/nexus/db-queries/src/db/datastore/deployment/external_networking.rs @@ -40,6 +40,15 @@ impl DataStore { // Looking up the service pool IDs requires an opctx; we'll do this at // most once inside the loop below, when we first encounter an address // of the same IP version. + // + // TODO-correctness: We really need to know which delegated IP Pool to + // select an address from. It's not clear how we do that today, but we + // could make the IpPoolReservationType more fine-grained. + // + // In the meantime, select the first one for a specific IP version, + // which is no worse than today. + // + // See: https://github.com/oxidecomputer/omicron/issues/8949. let mut v4_pool = None; let mut v6_pool = None; @@ -66,10 +75,13 @@ impl DataStore { let pool = match pool_ref { Some(p) => p, None => { - let new = self - .ip_pools_service_lookup(opctx, version.into()) - .await? - .1; + let (_, new) = self + .fetch_first_oxide_internal_ip_pool( + opctx, + nexus_auth::authz::Action::CreateChild, + Some(version.into()), + ) + .await?; *pool_ref = Some(new); pool_ref.as_ref().unwrap() } @@ -615,9 +627,13 @@ mod tests { datastore: &DataStore, ) { let (ip_pool, db_pool) = datastore - .ip_pools_service_lookup(&opctx, IpVersion::V4.into()) + .fetch_first_oxide_internal_ip_pool( + &opctx, + nexus_auth::authz::Action::CreateChild, + Some(IpVersion::V4.into()), + ) .await - .expect("failed to find service IP pool"); + .expect("Failed authz check on delegated IP Pool"); datastore .ip_pool_add_range( &opctx, diff --git a/nexus/db-queries/src/db/datastore/external_ip.rs b/nexus/db-queries/src/db/datastore/external_ip.rs index 4ca47aa6df7..c6d42dd3b31 100644 --- a/nexus/db-queries/src/db/datastore/external_ip.rs +++ b/nexus/db-queries/src/db/datastore/external_ip.rs @@ -299,9 +299,19 @@ impl DataStore { external_ip: OmicronZoneExternalIp, ) -> CreateResult { let version = IpVersion::from(external_ip.ip_version()); - let (authz_pool, pool) = - self.ip_pools_service_lookup(opctx, version).await?; - opctx.authorize(authz::Action::CreateChild, &authz_pool).await?; + + // TODO-correctness: We need to figure out which IP Pool this address + // actually belongs in, if there are more than one of the same address + // version. + // + // See: https://github.com/oxidecomputer/omicron/issues/8949. + let (.., pool) = self + .fetch_first_oxide_internal_ip_pool( + opctx, + authz::Action::CreateChild, + Some(version), + ) + .await?; let data = IncompleteExternalIp::for_omicron_zone( pool.id(), external_ip, @@ -341,11 +351,14 @@ impl DataStore { // Note the IP version used here isn't important. It's just for the // authz check to list children, and not used for the actual database // query below, which filters on is_service to get external IPs from - // either pool. - let (authz_pool, _pool) = - self.ip_pools_service_lookup(opctx, IpVersion::V4).await?; - opctx.authorize(authz::Action::ListChildren, &authz_pool).await?; - + // any pool. + let _ = self + .fetch_first_oxide_internal_ip_pool( + opctx, + authz::Action::ListChildren, + None, + ) + .await?; paginated(dsl::external_ip, dsl::id, pagparams) .filter(dsl::is_service) .filter(dsl::time_deleted.is_null()) @@ -1171,9 +1184,13 @@ mod tests { )) .unwrap(); let (service_ip_pool, db_pool) = datastore - .ip_pools_service_lookup(opctx, IpVersion::V4) + .fetch_first_oxide_internal_ip_pool( + opctx, + authz::Action::CreateChild, + Some(IpVersion::V4), + ) .await - .expect("lookup service ip pool"); + .expect("Failed authz check on delegated IP Pool"); datastore .ip_pool_add_range(opctx, &service_ip_pool, &db_pool, &ip_range) .await diff --git a/nexus/db-queries/src/db/datastore/ip_pool.rs b/nexus/db-queries/src/db/datastore/ip_pool.rs index b2849277603..58110afc869 100644 --- a/nexus/db-queries/src/db/datastore/ip_pool.rs +++ b/nexus/db-queries/src/db/datastore/ip_pool.rs @@ -11,8 +11,6 @@ use crate::authz::ApiResource; use crate::context::OpContext; use crate::db::collection_insert::AsyncInsertError; use crate::db::collection_insert::DatastoreCollection; -use crate::db::datastore::SERVICE_IPV4_POOL_NAME; -use crate::db::datastore::SERVICE_IPV6_POOL_NAME; use crate::db::identity::Resource; use crate::db::model::IpKind; use crate::db::model::IpPool; @@ -43,6 +41,7 @@ use nexus_db_errors::public_error_from_diesel; use nexus_db_errors::public_error_from_diesel_lookup; use nexus_db_lookup::DbConnection; use nexus_db_lookup::LookupPath; +use nexus_db_lookup::lookup; use nexus_db_model::InternetGateway; use nexus_db_model::InternetGatewayIpPool; use nexus_db_model::IpVersion; @@ -62,41 +61,13 @@ use omicron_common::api::external::InternalContext; use omicron_common::api::external::ListResultVec; use omicron_common::api::external::LookupResult; use omicron_common::api::external::LookupType; +use omicron_common::api::external::NameOrId; use omicron_common::api::external::ResourceType; use omicron_common::api::external::UpdateResult; use omicron_common::api::external::http_pagination::PaginatedBy; use ref_cast::RefCast; use uuid::Uuid; -/// Helper type with both an authz IP Pool and the actual DB record. -#[derive(Debug, Clone)] -pub struct ServiceIpPool { - pub authz_pool: authz::IpPool, - pub db_pool: IpPool, -} - -/// Helper type with service IP Pool information for both IP versions. -#[derive(Debug, Clone)] -pub struct ServiceIpPools { - pub ipv4: ServiceIpPool, - pub ipv6: ServiceIpPool, -} - -impl ServiceIpPools { - /// Return the IP Pool appropriate for a range, based on its version. - pub fn pool_for_range(&self, range: &IpRange) -> &ServiceIpPool { - if range.first_address().is_ipv4() { &self.ipv4 } else { &self.ipv6 } - } - - /// Return the IP Pool appropriate for an IP version. - pub fn pool_for_version(&self, version: IpVersion) -> &IpPool { - match version { - IpVersion::V4 => &self.ipv4.db_pool, - IpVersion::V6 => &self.ipv6.db_pool, - } - } -} - // Error message emitted when a user attempts to link an IP Pool and internal // Silo, but the pool is already reserved for internal use, or vice versa. const BAD_SILO_LINK_ERROR: &str = "IP Pools cannot be both linked to external \ @@ -114,6 +85,20 @@ const LAST_POOL_ERROR: &str = "Cannot delete the last IP Pool reserved for \ before deleting this one."; impl DataStore { + /// Lookup an IP Pool directly. + pub fn ip_pool_lookup<'a>( + &'a self, + opctx: &'a OpContext, + pool: &'a NameOrId, + ) -> lookup::IpPool<'a> { + match pool { + NameOrId::Name(name) => { + LookupPath::new(opctx, self).ip_pool_name(Name::ref_cast(name)) + } + NameOrId::Id(id) => LookupPath::new(opctx, self).ip_pool_id(*id), + } + } + /// List IP Pools by their reservation type, IP version, and pool type. pub async fn ip_pools_list_paginated( &self, @@ -204,6 +189,39 @@ impl DataStore { .await } + /// List all IP Pools, making as many queries as needed to get them all + /// + /// This should generally not be used in API handlers or other + /// latency-sensitive contexts, but it can make sense in saga actions or + /// background tasks. + pub async fn ip_pools_list_batched( + &self, + opctx: &OpContext, + reservation_type: IpPoolReservationType, + version: Option, + ) -> ListResultVec { + opctx.check_complex_operations_allowed()?; + let mut pools = Vec::new(); + let mut paginator = Paginator::new( + SQL_BATCH_SIZE, + dropshot::PaginationOrder::Ascending, + ); + while let Some(p) = paginator.next() { + let batch = self + .ip_pools_list_paginated( + opctx, + reservation_type, + version, + None, + &PaginatedBy::Id(p.current_pagparams()), + ) + .await?; + paginator = p.found_batch(&batch, &|r| r.id()); + pools.extend(batch); + } + Ok(pools) + } + /// Look up whether the given pool is available to users in the current /// silo, i.e., whether there is an entry in the association table linking /// the pool with that silo @@ -310,24 +328,36 @@ impl DataStore { }) } - /// Look up internal service IP Pools for both IP versions. - /// - /// This is useful when you need to handle resources like external IPs where - /// the actual address might be from either IP version. - // - // TODO-remove: Use list_ip_pools_for_internal instead. - // - // See https://github.com/oxidecomputer/omicron/issues/8947. - pub async fn ip_pools_service_lookup_both_versions( + /// Fetch the first IP Pool reserved for Oxide internal use. + pub(crate) async fn fetch_first_oxide_internal_ip_pool( &self, opctx: &OpContext, - ) -> LookupResult { - let ipv4 = self.ip_pools_service_lookup(opctx, IpVersion::V4).await?; - let ipv6 = self.ip_pools_service_lookup(opctx, IpVersion::V6).await?; - Ok(ServiceIpPools { - ipv4: ServiceIpPool { authz_pool: ipv4.0, db_pool: ipv4.1 }, - ipv6: ServiceIpPool { authz_pool: ipv6.0, db_pool: ipv6.1 }, - }) + action: authz::Action, + version: Option, + ) -> LookupResult<(authz::IpPool, IpPool)> { + let pools = self + .ip_pools_list_paginated( + opctx, + IpPoolReservationType::OxideInternal, + version, + None, + &PaginatedBy::Id(DataPageParams { + marker: None, + direction: dropshot::PaginationOrder::Ascending, + limit: 1.try_into().unwrap(), + }), + ) + .await?; + let Some(pool) = pools.get(0) else { + let ver = version + .map(|ver| format!("IP{ver}")) + .unwrap_or_else(|| String::from("any IP version")); + let message = format!("No delegated IP Pool for {ver}"); + return Err(Error::internal_error(message.as_str())); + }; + self.ip_pool_lookup(opctx, &NameOrId::Id(pool.id())) + .fetch_for(action) + .await } /// Look up the default IP pool for the current silo. If there is no default @@ -400,28 +430,6 @@ impl DataStore { Ok(authz_pool) } - /// Look up IP pool intended for internal services by its well-known name. - /// - /// This method may require an index by Availability Zone in the future. - // - // TODO-remove: Use ip_pools_list_paginated with the right enum type - // instead. - // - // See https://github.com/oxidecomputer/omicron/issues/8947. - pub async fn ip_pools_service_lookup( - &self, - opctx: &OpContext, - ip_version: IpVersion, - ) -> LookupResult<(authz::IpPool, IpPool)> { - let name = match ip_version { - IpVersion::V4 => SERVICE_IPV4_POOL_NAME, - IpVersion::V6 => SERVICE_IPV6_POOL_NAME, - }; - let name = - Name(name.parse().expect("should be able to parse builtin names")); - LookupPath::new(&opctx, self).ip_pool_name(&name).fetch().await - } - /// Creates a new IP pool. pub async fn ip_pool_create( &self, @@ -539,36 +547,6 @@ impl DataStore { Ok(()) } - /// Check whether the pool is internal by checking that it exists and is - /// associated with the internal silo - // - // TODO-remove: This should go away when we let operators reserve any IP - // Pools for internal Oxide usage. The pool belongs to them even in that - // case, and so we should show it to them. - // - // See https://github.com/oxidecomputer/omicron/issues/8947. - pub async fn ip_pool_is_internal( - &self, - opctx: &OpContext, - authz_pool: &authz::IpPool, - ) -> LookupResult { - use nexus_db_schema::schema::ip_pool; - ip_pool::table - .find(authz_pool.id()) - .filter(ip_pool::time_deleted.is_null()) - .select( - ip_pool::reservation_type - .eq(IpPoolReservationType::OxideInternal), - ) - .first_async::( - &*self.pool_connection_authorized(opctx).await?, - ) - .await - .optional() - .map(|result| result.unwrap_or(false)) - .map_err(|e| public_error_from_diesel(e, ErrorHandler::Server)) - } - pub async fn ip_pool_update( &self, opctx: &OpContext, @@ -1968,11 +1946,13 @@ mod test { link_ip_pool_to_external_silo_query, reserve_ip_pool_query, unlink_ip_pool_from_external_silo_query, }; + use crate::db::datastore::{ + SERVICE_IPV4_POOL_NAME, SERVICE_IPV6_POOL_NAME, + }; use crate::db::explain::ExplainableAsync as _; use crate::db::model::{ IpPool, IpPoolResource, IpPoolResourceType, Project, }; - use crate::db::pagination::Paginator; use crate::db::pub_test_utils::TestDatabase; use crate::db::raw_query_builder::expectorate_query_contents; use assert_matches::assert_matches; @@ -1995,6 +1975,7 @@ mod test { use omicron_common::api::external::http_pagination::PaginatedBy; use omicron_common::api::external::{ DataPageParams, Error, IdentityMetadataCreateParams, LookupType, + NameOrId, }; use omicron_test_utils::dev; use omicron_uuid_kinds::{ @@ -2214,74 +2195,6 @@ mod test { logctx.cleanup_successful(); } - #[tokio::test] - async fn test_internal_ip_pools() { - let logctx = dev::test_setup_log("test_internal_ip_pools"); - let db = TestDatabase::new_with_datastore(&logctx.log).await; - let (opctx, datastore) = (db.opctx(), db.datastore()); - - for version in [IpVersion::V4, IpVersion::V6] { - // confirm internal pools appear as internal - let (authz_pool, pool) = datastore - .ip_pools_service_lookup(&opctx, version) - .await - .unwrap(); - assert_eq!(pool.ip_version, version); - - let is_internal = - datastore.ip_pool_is_internal(&opctx, &authz_pool).await; - assert_eq!(is_internal, Ok(true)); - - // another random pool should not be considered internal - let identity = IdentityMetadataCreateParams { - name: format!("other-{version}-pool").parse().unwrap(), - description: "".to_string(), - }; - let other_pool = datastore - .ip_pool_create( - &opctx, - IpPool::new( - &identity, - version, - IpPoolReservationType::ExternalSilos, - ), - ) - .await - .expect("Failed to create IP pool"); - assert_eq!(other_pool.ip_version, version); - - let authz_other_pool = authz::IpPool::new( - authz::FLEET, - other_pool.id(), - LookupType::ById(other_pool.id()), - ); - let is_internal = - datastore.ip_pool_is_internal(&opctx, &authz_other_pool).await; - assert_eq!(is_internal, Ok(false)); - - // now link it to the current silo, and it is still not internal. - let silo_id = opctx.authn.silo_required().unwrap().id(); - let is_default = matches!(version, IpVersion::V4); - let link = IpPoolResource { - ip_pool_id: other_pool.id(), - resource_type: IpPoolResourceType::Silo, - resource_id: silo_id, - is_default, - }; - datastore - .ip_pool_link_silo(&opctx, link) - .await - .expect("Failed to link IP pool to silo"); - - let is_internal = - datastore.ip_pool_is_internal(&opctx, &authz_other_pool).await; - assert_eq!(is_internal, Ok(false)); - } - - db.terminate().await; - logctx.cleanup_successful(); - } - // We're breaking out the utilization tests for IPv4 and IPv6 pools, since // pools only contain one version now. // @@ -2881,32 +2794,15 @@ mod test { } assert_eq!(oxide_pools.len(), N_POOLS); - let fetch_paginated = |reservation_type| async move { - let mut found = Vec::with_capacity(N_POOLS); - let mut paginator = Paginator::new( - NonZeroU32::new(5).unwrap(), - dropshot::PaginationOrder::Ascending, - ); - while let Some(page) = paginator.next() { - let batch = datastore - .ip_pools_list_paginated( - opctx, - reservation_type, - None, - None, - &PaginatedBy::Id(page.current_pagparams()), - ) - .await - .expect("Should be able to list pools with pagination"); - paginator = page.found_batch(&batch, &|pool| pool.id()); - found.extend(batch.into_iter()); - } - found - }; - // Paginate all the customer-reserved. - let customer_pools_found = - fetch_paginated(IpPoolReservationType::ExternalSilos).await; + let customer_pools_found = datastore + .ip_pools_list_batched( + opctx, + IpPoolReservationType::ExternalSilos, + None, + ) + .await + .unwrap(); assert_eq!(customer_pools.len(), customer_pools_found.len()); assert_eq!(customer_pools, customer_pools_found); @@ -2916,14 +2812,24 @@ mod test { // pools. These will go away in the future, so we'll unfortunately need // to update this test at that time. Until then, fetch those service // pools explicitly and add them. - let oxide_reserved_found = - fetch_paginated(IpPoolReservationType::OxideInternal).await; - let pools = datastore - .ip_pools_service_lookup_both_versions(opctx) + // + // See https://github.com/oxidecomputer/omicron/issues/8946. + let oxide_reserved_found = datastore + .ip_pools_list_batched( + opctx, + IpPoolReservationType::OxideInternal, + None, + ) .await .unwrap(); - oxide_pools.push(pools.ipv4.db_pool); - oxide_pools.push(pools.ipv6.db_pool); + for name in [SERVICE_IPV4_POOL_NAME, SERVICE_IPV6_POOL_NAME] { + let (.., pool) = datastore + .ip_pool_lookup(opctx, &(name.parse().unwrap())) + .fetch() + .await + .expect("able to lookup builtin service pool"); + oxide_pools.push(pool); + } oxide_pools.sort_by_key(|pool| pool.id()); assert_eq!(oxide_pools.len(), oxide_reserved_found.len()); assert_eq!(oxide_pools, oxide_reserved_found); @@ -3221,15 +3127,29 @@ mod test { let db = TestDatabase::new_with_datastore(&logctx.log).await; let (opctx, datastore) = (db.opctx(), db.datastore()); - // Fetch the pools. + // Fetch all the internal pools. let pools = datastore - .ip_pools_service_lookup_both_versions(opctx) + .ip_pools_list_batched( + opctx, + IpPoolReservationType::OxideInternal, + None, + ) .await .unwrap(); + assert_eq!(pools.len(), 2); + let mut pools = pools.into_iter(); + + // Fetch the first pool and lookup and authz object. + let first_pool = pools.next().expect("Checked above"); + let (authz_pool, ..) = datastore + .ip_pool_lookup(opctx, &NameOrId::Id(first_pool.id())) + .fetch() + .await + .expect("able to lookup internal IP Pool"); // We should be able to delete one of these. let _ = datastore - .ip_pool_delete(opctx, &pools.ipv4.authz_pool, &pools.ipv4.db_pool) + .ip_pool_delete(opctx, &authz_pool, &first_pool) .await .expect( "Should be able to delete internally-reserved \ @@ -3237,18 +3157,11 @@ mod test { ); // Check there's only one left. - let pagparams = &PaginatedBy::Id(DataPageParams { - marker: None, - direction: dropshot::PaginationOrder::Ascending, - limit: 100.try_into().unwrap(), - }); let l = datastore - .ip_pools_list_paginated( + .ip_pools_list_batched( opctx, IpPoolReservationType::OxideInternal, None, - None, - &pagparams, ) .await .unwrap(); @@ -3256,9 +3169,14 @@ mod test { // We should _not_ be able to delete the other now, because there's only // one left. - let res = datastore - .ip_pool_delete(opctx, &pools.ipv6.authz_pool, &pools.ipv6.db_pool) - .await; + let second_pool = pools.next().expect("Checked above"); + let (authz_pool, ..) = datastore + .ip_pool_lookup(opctx, &NameOrId::Id(second_pool.id())) + .fetch() + .await + .expect("able to lookup internal IP Pool"); + let res = + datastore.ip_pool_delete(opctx, &authz_pool, &second_pool).await; let Err(Error::InvalidRequest { message }) = &res else { panic!( @@ -3269,12 +3187,10 @@ mod test { assert_eq!(message.external_message(), LAST_POOL_ERROR); let l = datastore - .ip_pools_list_paginated( + .ip_pools_list_batched( opctx, IpPoolReservationType::OxideInternal, None, - None, - &pagparams, ) .await .unwrap(); @@ -3294,16 +3210,28 @@ mod test { // Fetch the pools. let pools = datastore - .ip_pools_service_lookup_both_versions(opctx) + .ip_pools_list_batched( + opctx, + IpPoolReservationType::OxideInternal, + None, + ) .await - .unwrap(); + .expect("able to list all IP Pools"); + assert_eq!(pools.len(), 2); + let mut pools = pools.into_iter(); + let first_pool = pools.next().expect("Checked above"); + let (authz_pool, ..) = datastore + .ip_pool_lookup(opctx, &NameOrId::Id(first_pool.id())) + .fetch() + .await + .expect("able to lookup IP Pool"); // We should be able to reserve one of these for external use. let _ = datastore .ip_pool_reserve( opctx, - &pools.ipv4.authz_pool, - &pools.ipv4.db_pool, + &authz_pool, + &first_pool, IpPoolReservationType::ExternalSilos, ) .await @@ -3313,18 +3241,11 @@ mod test { ); // Check there's only one left. - let pagparams = &PaginatedBy::Id(DataPageParams { - marker: None, - direction: dropshot::PaginationOrder::Ascending, - limit: 100.try_into().unwrap(), - }); let l = datastore - .ip_pools_list_paginated( + .ip_pools_list_batched( opctx, IpPoolReservationType::OxideInternal, None, - None, - &pagparams, ) .await .unwrap(); @@ -3332,11 +3253,17 @@ mod test { // We should _not_ be able to reserve the other for external use now, // because there's only one left for internal use. + let next_pool = pools.next().expect("Checked above"); + let (authz_pool, ..) = datastore + .ip_pool_lookup(opctx, &NameOrId::Id(next_pool.id())) + .fetch() + .await + .expect("able to lookup IP Pool"); let res = datastore .ip_pool_reserve( opctx, - &pools.ipv6.authz_pool, - &pools.ipv6.db_pool, + &authz_pool, + &next_pool, IpPoolReservationType::ExternalSilos, ) .await; @@ -3350,12 +3277,10 @@ mod test { assert_eq!(message.external_message(), LAST_POOL_ERROR); let l = datastore - .ip_pools_list_paginated( + .ip_pools_list_batched( opctx, IpPoolReservationType::OxideInternal, None, - None, - &pagparams, ) .await .unwrap(); @@ -3374,21 +3299,28 @@ mod test { let (opctx, datastore) = (db.opctx(), db.datastore()); // Get pool, add a range, allocate an external IP. - let pools = datastore - .ip_pools_service_lookup_both_versions(opctx) + let pool = datastore + .ip_pools_list_batched( + opctx, + IpPoolReservationType::OxideInternal, + Some(IpVersion::V4), + ) .await - .unwrap(); + .expect("able to list IP Pools") + .into_iter() + .next() + .expect("At least 1 IP Pools"); + let (authz_pool, ..) = datastore + .ip_pool_lookup(opctx, &NameOrId::Id(pool.id())) + .fetch() + .await + .expect("able to lookup IP Pool"); let ip_range = IpRange::V4(Ipv4Range { first: Ipv4Addr::new(1, 1, 1, 1), last: Ipv4Addr::new(1, 1, 1, 10), }); datastore - .ip_pool_add_range( - opctx, - &pools.ipv4.authz_pool, - &pools.ipv4.db_pool, - &ip_range, - ) + .ip_pool_add_range(opctx, &authz_pool, &pool, &ip_range) .await .unwrap(); @@ -3417,8 +3349,8 @@ mod test { let res = datastore .ip_pool_reserve( opctx, - &pools.ipv4.authz_pool, - &pools.ipv4.db_pool, + &authz_pool, + &pool, IpPoolReservationType::ExternalSilos, ) .await; @@ -3437,8 +3369,8 @@ mod test { .expect("Should be able to delete external IP"); let _ = datastore.ip_pool_reserve( opctx, - &pools.ipv4.authz_pool, - &pools.ipv4.db_pool, + &authz_pool, + &pool, IpPoolReservationType::ExternalSilos, ).await .expect( diff --git a/nexus/db-queries/src/db/datastore/mod.rs b/nexus/db-queries/src/db/datastore/mod.rs index ce0576cc65f..5b99f588ee0 100644 --- a/nexus/db-queries/src/db/datastore/mod.rs +++ b/nexus/db-queries/src/db/datastore/mod.rs @@ -166,9 +166,11 @@ pub use volume::*; // TODO: This should likely turn into a configuration option. pub const REGION_REDUNDANCY_THRESHOLD: usize = 3; +// TODO-remove: https://github.com/oxidecomputer/omicron/issues/8950 /// The name of the built-in IPv4 IP pool for Oxide services. pub const SERVICE_IPV4_POOL_NAME: &str = "oxide-service-pool-v4"; +// TODO-remove: https://github.com/oxidecomputer/omicron/issues/8950 /// The name of the built-in IPv6 IP pool for Oxide services. pub const SERVICE_IPV6_POOL_NAME: &str = "oxide-service-pool-v6"; diff --git a/nexus/db-queries/src/db/datastore/network_interface.rs b/nexus/db-queries/src/db/datastore/network_interface.rs index 6c46fd4074f..d0c4981c8f9 100644 --- a/nexus/db-queries/src/db/datastore/network_interface.rs +++ b/nexus/db-queries/src/db/datastore/network_interface.rs @@ -31,7 +31,6 @@ use nexus_db_errors::ErrorHandler; use nexus_db_errors::OptionalError; use nexus_db_errors::public_error_from_diesel; use nexus_db_lookup::DbConnection; -use nexus_db_model::IpVersion; use nexus_db_model::ServiceNetworkInterface; use nexus_types::identity::Resource; use omicron_common::api::external::DataPageParams; @@ -185,19 +184,13 @@ impl DataStore { pagparams: &DataPageParams<'_, Uuid>, ) -> ListResultVec { use nexus_db_schema::schema::service_network_interface::dsl; - - // See the comment in `service_create_network_interface`. There's no - // obvious parent for a service network interface (as opposed to - // instance network interfaces, which require ListChildren on the - // instance to list). As a logical proxy, we check for listing children - // of the service IP pool. - // - // Note that the IP version doesn't matter here, both pools have the - // same permissions. - let (authz_pool, _pool) = - self.ip_pools_service_lookup(opctx, IpVersion::V4).await?; - opctx.authorize(authz::Action::ListChildren, &authz_pool).await?; - + let _ = self + .fetch_first_oxide_internal_ip_pool( + opctx, + authz::Action::ListChildren, + None, + ) + .await?; paginated(dsl::service_network_interface, dsl::id, pagparams) .filter(dsl::time_deleted.is_null()) .select(ServiceNetworkInterface::as_select()) @@ -258,12 +251,12 @@ impl DataStore { // // Note that the IP version here doesn't matter, both IPv4 and IPv6 // service pools have the same permissions. - let (authz_service_ip_pool, _) = self - .ip_pools_service_lookup(opctx, IpVersion::V4) - .await - .map_err(network_interface::InsertError::External)?; - opctx - .authorize(authz::Action::CreateChild, &authz_service_ip_pool) + let _ = self + .fetch_first_oxide_internal_ip_pool( + opctx, + authz::Action::CreateChild, + None, + ) .await .map_err(network_interface::InsertError::External)?; self.service_create_network_interface_raw(opctx, interface).await @@ -442,12 +435,12 @@ impl DataStore { // // Note that the IP version here doesn't matter, both pools have the // same permissions. - let (authz_service_ip_pool, _) = self - .ip_pools_service_lookup(opctx, IpVersion::V4) - .await - .map_err(network_interface::DeleteError::External)?; - opctx - .authorize(authz::Action::Delete, &authz_service_ip_pool) + let _ = self + .fetch_first_oxide_internal_ip_pool( + opctx, + authz::Action::Delete, + None, + ) .await .map_err(network_interface::DeleteError::External)?; @@ -907,11 +900,15 @@ impl DataStore { // listing _instance_ NICs. That seems to be authorizing against the // wrong resource. // - // But assuming this check is correct, both service pools have the same + // But assuming this check is correct, all service pools have the same // permissions, so the actual IP version here doesn't matter. - let (authz_pool, _pool) = - self.ip_pools_service_lookup(opctx, IpVersion::V4).await?; - opctx.authorize(authz::Action::ListChildren, &authz_pool).await?; + let _ = self + .fetch_first_oxide_internal_ip_pool( + opctx, + authz::Action::ListChildren, + None, + ) + .await?; paginated(dsl::instance_network_interface, dsl::id, pagparams) .filter(dsl::time_deleted.is_null()) diff --git a/nexus/db-queries/src/db/datastore/rack.rs b/nexus/db-queries/src/db/datastore/rack.rs index ab4ce8ddb44..6698f22806c 100644 --- a/nexus/db-queries/src/db/datastore/rack.rs +++ b/nexus/db-queries/src/db/datastore/rack.rs @@ -8,7 +8,6 @@ use super::DataStore; use super::SERVICE_IPV4_POOL_NAME; use super::SERVICE_IPV6_POOL_NAME; use super::dns::DnsVersionUpdateBuilder; -use super::ip_pool::ServiceIpPools; use crate::authz; use crate::context::OpContext; use crate::db; @@ -40,6 +39,8 @@ use nexus_db_lookup::DbConnection; use nexus_db_lookup::LookupPath; use nexus_db_model::IncompleteNetworkInterface; use nexus_db_model::InitialDnsGroup; +use nexus_db_model::IpPool; +use nexus_db_model::IpPoolReservationType; use nexus_db_model::IpVersion; use nexus_db_model::PasswordHashString; use nexus_db_model::SiloUser; @@ -73,6 +74,7 @@ use omicron_uuid_kinds::SiloUserUuid; use omicron_uuid_kinds::SledUuid; use omicron_uuid_kinds::ZpoolUuid; use slog_error_chain::InlineErrorChain; +use std::collections::BTreeMap; use std::sync::{Arc, OnceLock}; use uuid::Uuid; @@ -535,11 +537,30 @@ impl DataStore { Ok(()) } + fn find_first_ip_pool_with_version<'a>( + log: &'a slog::Logger, + ip_pools: &'a [nexus_db_model::IpPool], + version: IpVersion, + ) -> Result<&'a nexus_db_model::IpPool, Error> { + ip_pools.iter().find(|p| p.ip_version == version).ok_or_else(|| { + error!( + log, + "Initializing Rack: No IP Pool with \ + version IP{}", + version, + ); + Error::internal_error(&format!( + "Could not find IP{version} Pool reserved \ + for Oxide internal use", + )) + }) + } + async fn rack_populate_service_networking_records( &self, conn: &async_bb8_diesel::Connection, log: &slog::Logger, - service_pools: &ServiceIpPools, + service_pools: &BTreeMap, zone_config: &BlueprintZoneConfig, ) -> Result<(), RackInitError> { // For services with external connectivity, we record their @@ -633,8 +654,17 @@ impl DataStore { ); return Ok(()); }; - let service_pool = - service_pools.pool_for_version(external_ip.ip_version().into()); + let service_pool = service_pools + .iter() + .find(|(range, (_pool, _authz_pool))| { + range.version() == external_ip.ip_version() + }) + .ok_or_else(|| { + RackInitError::AddingIp(Error::invalid_request( + "Requested external IP address not available", + )) + }) + .map(|(_range, (pool, _authz_pool))| pool)?; let db_ip = IncompleteExternalIp::for_omicron_zone( service_pool.id(), external_ip, @@ -682,6 +712,52 @@ impl DataStore { Ok(()) } + // List Oxide reserved IP Pools and corresponding authz objects. + async fn list_all_service_ip_pools( + &self, + opctx: &OpContext, + rack_init_ranges: &[IpRange], + ) -> Result, Error> { + // List all IP Pools first, then find the ones we need to cover the + // ranges we're provided at rack-init time. + // + // This should go away entirely with #8946, since the pools will be + // provided at rack-init time too. + let service_ip_pools = self + .ip_pools_list_batched( + &opctx, + IpPoolReservationType::OxideInternal, + None, + ) + .await?; + info!( + self.log, + "Fetched all Oxide-internal IP Pools"; + "n_pools" => service_ip_pools.len(), + ); + let mut pools = BTreeMap::new(); + for range in rack_init_ranges { + // Find pool for this version, then lookup authz object. + let this_pool = Self::find_first_ip_pool_with_version( + &self.log, + &service_ip_pools, + range.version().into(), + )?; + let (authz_pool, ..) = self + .ip_pool_lookup(opctx, &(this_pool.id().into())) + .fetch_for(authz::Action::CreateChild) + .await?; + debug!( + self.log, + "Matched IP Pool for rack-initialization IP Range"; + "range" => ?range, + "pool_id" => %this_pool.id(), + ); + pools.insert(*range, (this_pool.clone(), authz_pool)); + } + Ok(pools) + } + /// Update a rack to mark that it has been initialized pub async fn rack_set_initialized( &self, @@ -694,12 +770,15 @@ impl DataStore { // The `RackInit` request will eventually be modified to include the // full details of the IP Pool(s) delegated to Oxide at RSS time. For - // now, we still rely on the pre-populated IP Pools. There's one for - // IPv4 and one for IPv6. + // now, we still rely on builtin or operator-defined IP Pools reserved + // for Oxide internal use, of either version. Look them all up now, + // including the authz objects needed, prior to the transaction context + // below. // // See https://github.com/oxidecomputer/omicron/issues/8946. - let service_ip_pools = - self.ip_pools_service_lookup_both_versions(&opctx).await?; + let service_ip_pools = self + .list_all_service_ip_pools(opctx, &rack_init.service_ip_pool_ranges) + .await?; // NOTE: This operation could likely be optimized with a CTE, but given // the low-frequency of calls, this optimization has been deferred. @@ -761,14 +840,26 @@ impl DataStore { // // Which RSS has already allocated during bootstrapping. - // Set up the IP pool for internal services. + // Set up the IP pools for internal services. for range in service_ip_pool_ranges { - let service_pool = service_ip_pools.pool_for_range(&range); + + // Fetch IP Pool for this range and authz object. + let (service_pool, authz_pool) = service_ip_pools + .get(&range) + .ok_or_else(|| { + let message = format!( + "Failed to find previously-fetched IP{} IP Pool!", + range.version(), + ); + error!(log, "Initializing Rack: {}", message); + err.set(RackInitError::AddingIp(Error::internal_error(&message))).unwrap(); + DieselError::RollbackTransaction + })?; Self::ip_pool_add_range_on_connection( &conn, opctx, - &service_pool.authz_pool, - &service_pool.db_pool, + &authz_pool, + &service_pool, &range, ) .await @@ -782,6 +873,12 @@ impl DataStore { err.set(RackInitError::AddingIp(e)).unwrap(); DieselError::RollbackTransaction })?; + debug!( + log, + "Added IP Range to Oxide-internal IP Pool"; + "range" => ?range, + "pool_id" => %service_pool.id(), + ); } // Insert the RSS-generated blueprint. @@ -1004,8 +1101,13 @@ impl DataStore { self.rack_insert(opctx, &db::model::Rack::new(rack_id)).await?; - // Insert an IP Pool for both IP versions, reserved for Oxide internal - // use. + // Insert a delegated IP Pool for both IP versions. + // + // TODO: We need to remove these when the full IP Pool definition comes + // from RSS, not just the names. After that, the operator has control + // over these pools, and they should not be loaded by default. + // + // See: https://github.com/oxidecomputer/omicron/issues/8946 for (version, name) in [ (IpVersion::V4, SERVICE_IPV4_POOL_NAME), (IpVersion::V6, SERVICE_IPV6_POOL_NAME), @@ -1041,9 +1143,11 @@ mod test { use crate::db::pub_test_utils::TestDatabase; use crate::db::pub_test_utils::helpers::SledUpdateBuilder; use async_bb8_diesel::AsyncSimpleConnection; + use dropshot::PaginationOrder; use id_map::IdMap; use internal_dns_types::names::DNS_ZONE; use nexus_config::NUM_INITIAL_RESERVED_IP_ADDRESSES; + use nexus_db_model::IpPoolReservationType; use nexus_db_model::{DnsGroup, Generation, InitialDnsGroup}; use nexus_inventory::now_db_precision; use nexus_reconfigurator_planning::system::{ @@ -1684,11 +1788,28 @@ mod test { assert_eq!(ntp2_external_ip.last_port.0, 16383); // Furthermore, we should be able to see that these IP addresses have - // been allocated as a part of a service IP pool. - let (.., svc_pool) = datastore - .ip_pools_service_lookup(&opctx, IpVersion::V4) + // been allocated as a part of a delegated IP pool. + let pagparams = DataPageParams { + marker: None, + direction: PaginationOrder::Ascending, + limit: 10.try_into().unwrap(), + }; + let svc_pools = datastore + .ip_pools_list_paginated( + opctx, + IpPoolReservationType::OxideInternal, + Some(IpVersion::V4), + None, + &PaginatedBy::Id(pagparams), + ) .await .unwrap(); + assert_eq!( + svc_pools.len(), + 1, + "Expected exactly 1 delegated IPv4 IP Pool" + ); + let svc_pool = &svc_pools[0]; assert_eq!(svc_pool.name().as_str(), SERVICE_IPV4_POOL_NAME); let observed_ip_pool_ranges = get_all_ip_pool_ranges(&datastore).await; @@ -1977,10 +2098,27 @@ mod test { // Furthermore, we should be able to see that this IP addresses have been // allocated as a part of a service IP pool. - let (.., svc_pool) = datastore - .ip_pools_service_lookup(&opctx, IpVersion::V4) + let pagparams = DataPageParams { + marker: None, + direction: PaginationOrder::Ascending, + limit: 10.try_into().unwrap(), + }; + let svc_pools = datastore + .ip_pools_list_paginated( + opctx, + IpPoolReservationType::OxideInternal, + Some(IpVersion::V4), + None, + &PaginatedBy::Id(pagparams), + ) .await .unwrap(); + assert_eq!( + svc_pools.len(), + 1, + "Expected exactly 1 delegated IPv4 IP Pool" + ); + let svc_pool = &svc_pools[0]; assert_eq!(svc_pool.name().as_str(), SERVICE_IPV4_POOL_NAME); let observed_ip_pool_ranges = get_all_ip_pool_ranges(&datastore).await; @@ -2212,10 +2350,27 @@ mod test { // Furthermore, we should be able to see that this IP address has been // allocated as a part of a service IPv6 IP pool. - let (.., svc_pool) = datastore - .ip_pools_service_lookup(&opctx, IpVersion::V6) + let pagparams = DataPageParams { + marker: None, + direction: PaginationOrder::Ascending, + limit: 10.try_into().unwrap(), + }; + let svc_pools = datastore + .ip_pools_list_paginated( + opctx, + IpPoolReservationType::OxideInternal, + Some(IpVersion::V6), + None, + &PaginatedBy::Id(pagparams), + ) .await .unwrap(); + assert_eq!( + svc_pools.len(), + 1, + "Expected exactly 1 delegated IPv6 IP Pool" + ); + let svc_pool = &svc_pools[0]; assert_eq!(svc_pool.name().as_str(), SERVICE_IPV6_POOL_NAME); let observed_ip_pool_ranges = get_all_ip_pool_ranges(&datastore).await; diff --git a/nexus/external-api/output/nexus_tags.txt b/nexus/external-api/output/nexus_tags.txt index 9f84bca8d18..1fcffd706ec 100644 --- a/nexus/external-api/output/nexus_tags.txt +++ b/nexus/external-api/output/nexus_tags.txt @@ -126,10 +126,10 @@ system_policy_view GET /v1/system/policy API operations found with tag "projects" OPERATION ID METHOD URL PATH +ip_pool_list GET /v1/ip-pools +ip_pool_view GET /v1/ip-pools/{pool} project_create POST /v1/projects project_delete DELETE /v1/projects/{project} -project_ip_pool_list GET /v1/ip-pools -project_ip_pool_view GET /v1/ip-pools/{pool} project_list GET /v1/projects project_policy_update PUT /v1/projects/{project}/policy project_policy_view GET /v1/projects/{project}/policy @@ -205,23 +205,20 @@ switch_view GET /v1/system/hardware/switches/{ API operations found with tag "system/ip-pools" OPERATION ID METHOD URL PATH -ip_pool_create POST /v1/system/ip-pools -ip_pool_delete DELETE /v1/system/ip-pools/{pool} -ip_pool_list GET /v1/system/ip-pools -ip_pool_range_add POST /v1/system/ip-pools/{pool}/ranges/add -ip_pool_range_list GET /v1/system/ip-pools/{pool}/ranges -ip_pool_range_remove POST /v1/system/ip-pools/{pool}/ranges/remove -ip_pool_service_range_add POST /v1/system/ip-pools-service/ranges/add -ip_pool_service_range_list GET /v1/system/ip-pools-service/ranges -ip_pool_service_range_remove POST /v1/system/ip-pools-service/ranges/remove -ip_pool_service_view GET /v1/system/ip-pools-service -ip_pool_silo_link POST /v1/system/ip-pools/{pool}/silos -ip_pool_silo_list GET /v1/system/ip-pools/{pool}/silos -ip_pool_silo_unlink DELETE /v1/system/ip-pools/{pool}/silos/{silo} -ip_pool_silo_update PUT /v1/system/ip-pools/{pool}/silos/{silo} -ip_pool_update PUT /v1/system/ip-pools/{pool} -ip_pool_utilization_view GET /v1/system/ip-pools/{pool}/utilization -ip_pool_view GET /v1/system/ip-pools/{pool} +system_ip_pool_create POST /v1/system/ip-pools +system_ip_pool_delete DELETE /v1/system/ip-pools/{pool} +system_ip_pool_list GET /v1/system/ip-pools +system_ip_pool_range_add POST /v1/system/ip-pools/{pool}/ranges/add +system_ip_pool_range_list GET /v1/system/ip-pools/{pool}/ranges +system_ip_pool_range_remove POST /v1/system/ip-pools/{pool}/ranges/remove +system_ip_pool_reserve POST /v1/system/ip-pools/{pool}/reserve +system_ip_pool_silo_link POST /v1/system/ip-pools/{pool}/silos +system_ip_pool_silo_list GET /v1/system/ip-pools/{pool}/silos +system_ip_pool_silo_unlink DELETE /v1/system/ip-pools/{pool}/silos/{silo} +system_ip_pool_silo_update PUT /v1/system/ip-pools/{pool}/silos/{silo} +system_ip_pool_update PUT /v1/system/ip-pools/{pool} +system_ip_pool_utilization_view GET /v1/system/ip-pools/{pool}/utilization +system_ip_pool_view GET /v1/system/ip-pools/{pool} API operations found with tag "system/metrics" OPERATION ID METHOD URL PATH diff --git a/nexus/external-api/src/lib.rs b/nexus/external-api/src/lib.rs index 510dbe4295e..af658e0f7fb 100644 --- a/nexus/external-api/src/lib.rs +++ b/nexus/external-api/src/lib.rs @@ -440,7 +440,7 @@ pub trait NexusExternalApi { rqctx: RequestContext, path_params: Path, query_params: Query, - ) -> Result>, HttpError>; + ) -> Result>, HttpError>; /// Delete a silo /// @@ -900,10 +900,10 @@ pub trait NexusExternalApi { path = "/v1/ip-pools", tags = ["projects"], }] - async fn project_ip_pool_list( + async fn ip_pool_list( rqctx: RequestContext, query_params: Query, - ) -> Result>, HttpError>; + ) -> Result>, HttpError>; /// Fetch IP pool #[endpoint { @@ -911,10 +911,10 @@ pub trait NexusExternalApi { path = "/v1/ip-pools/{pool}", tags = ["projects"], }] - async fn project_ip_pool_view( + async fn ip_pool_view( rqctx: RequestContext, path_params: Path, - ) -> Result, HttpError>; + ) -> Result, HttpError>; /// List IP pools #[endpoint { @@ -922,10 +922,13 @@ pub trait NexusExternalApi { path = "/v1/system/ip-pools", tags = ["system/ip-pools"], }] - async fn ip_pool_list( + async fn system_ip_pool_list( rqctx: RequestContext, query_params: Query, - ) -> Result>, HttpError>; + // TODO-completeness: We should add filters for IP version and + // delegation state here. + // See https://github.com/oxidecomputer/omicron/issues/9147. + ) -> Result>, HttpError>; /// Create IP pool /// @@ -935,10 +938,10 @@ pub trait NexusExternalApi { path = "/v1/system/ip-pools", tags = ["system/ip-pools"], }] - async fn ip_pool_create( + async fn system_ip_pool_create( rqctx: RequestContext, pool_params: TypedBody, - ) -> Result, HttpError>; + ) -> Result, HttpError>; /// Fetch IP pool #[endpoint { @@ -946,10 +949,10 @@ pub trait NexusExternalApi { path = "/v1/system/ip-pools/{pool}", tags = ["system/ip-pools"], }] - async fn ip_pool_view( + async fn system_ip_pool_view( rqctx: RequestContext, path_params: Path, - ) -> Result, HttpError>; + ) -> Result, HttpError>; /// Delete IP pool #[endpoint { @@ -957,7 +960,7 @@ pub trait NexusExternalApi { path = "/v1/system/ip-pools/{pool}", tags = ["system/ip-pools"], }] - async fn ip_pool_delete( + async fn system_ip_pool_delete( rqctx: RequestContext, path_params: Path, ) -> Result; @@ -968,11 +971,11 @@ pub trait NexusExternalApi { path = "/v1/system/ip-pools/{pool}", tags = ["system/ip-pools"], }] - async fn ip_pool_update( + async fn system_ip_pool_update( rqctx: RequestContext, path_params: Path, updates: TypedBody, - ) -> Result, HttpError>; + ) -> Result, HttpError>; /// Fetch IP pool utilization #[endpoint { @@ -980,7 +983,7 @@ pub trait NexusExternalApi { path = "/v1/system/ip-pools/{pool}/utilization", tags = ["system/ip-pools"], }] - async fn ip_pool_utilization_view( + async fn system_ip_pool_utilization_view( rqctx: RequestContext, path_params: Path, ) -> Result, HttpError>; @@ -991,20 +994,13 @@ pub trait NexusExternalApi { path = "/v1/system/ip-pools/{pool}/silos", tags = ["system/ip-pools"], }] - async fn ip_pool_silo_list( + async fn system_ip_pool_silo_list( rqctx: RequestContext, path_params: Path, // paginating by resource_id because they're unique per pool. most robust // option would be to paginate by a composite key representing the (pool, // resource_type, resource) query_params: Query, - // TODO: this could just list views::Silo -- it's not like knowing silo_id - // and nothing else is particularly useful -- except we also want to say - // whether the pool is marked default on each silo. So one option would - // be to do the same as we did with SiloIpPool -- include is_default on - // whatever the thing is. Still... all we'd have to do to make this usable - // in both places would be to make it { ...IpPool, silo_id, silo_name, - // is_default } ) -> Result>, HttpError>; /// Link IP pool to silo @@ -1017,7 +1013,7 @@ pub trait NexusExternalApi { path = "/v1/system/ip-pools/{pool}/silos", tags = ["system/ip-pools"], }] - async fn ip_pool_silo_link( + async fn system_ip_pool_silo_link( rqctx: RequestContext, path_params: Path, resource_assoc: TypedBody, @@ -1031,7 +1027,7 @@ pub trait NexusExternalApi { path = "/v1/system/ip-pools/{pool}/silos/{silo}", tags = ["system/ip-pools"], }] - async fn ip_pool_silo_unlink( + async fn system_ip_pool_silo_unlink( rqctx: RequestContext, path_params: Path, ) -> Result; @@ -1048,21 +1044,23 @@ pub trait NexusExternalApi { path = "/v1/system/ip-pools/{pool}/silos/{silo}", tags = ["system/ip-pools"], }] - async fn ip_pool_silo_update( + async fn system_ip_pool_silo_update( rqctx: RequestContext, path_params: Path, update: TypedBody, ) -> Result, HttpError>; - /// Fetch Oxide service IP pool + /// Reserve an IP Pool for use by specific resources. #[endpoint { - method = GET, - path = "/v1/system/ip-pools-service", + method = POST, + path = "/v1/system/ip-pools/{pool}/reserve", tags = ["system/ip-pools"], }] - async fn ip_pool_service_view( + async fn system_ip_pool_reserve( rqctx: RequestContext, - ) -> Result, HttpError>; + path_params: Path, + body: TypedBody, + ) -> Result; /// List ranges for IP pool /// @@ -1072,7 +1070,7 @@ pub trait NexusExternalApi { path = "/v1/system/ip-pools/{pool}/ranges", tags = ["system/ip-pools"], }] - async fn ip_pool_range_list( + async fn system_ip_pool_range_list( rqctx: RequestContext, path_params: Path, query_params: Query, @@ -1093,7 +1091,7 @@ pub trait NexusExternalApi { path = "/v1/system/ip-pools/{pool}/ranges/add", tags = ["system/ip-pools"], }] - async fn ip_pool_range_add( + async fn system_ip_pool_range_add( rqctx: RequestContext, path_params: Path, range_params: TypedBody, @@ -1105,49 +1103,12 @@ pub trait NexusExternalApi { path = "/v1/system/ip-pools/{pool}/ranges/remove", tags = ["system/ip-pools"], }] - async fn ip_pool_range_remove( + async fn system_ip_pool_range_remove( rqctx: RequestContext, path_params: Path, range_params: TypedBody, ) -> Result; - /// List IP ranges for the Oxide service pool - /// - /// Ranges are ordered by their first address. - #[endpoint { - method = GET, - path = "/v1/system/ip-pools-service/ranges", - tags = ["system/ip-pools"], - }] - async fn ip_pool_service_range_list( - rqctx: RequestContext, - query_params: Query, - ) -> Result>, HttpError>; - - /// Add IP range to Oxide service pool - /// - /// IPv6 ranges are not allowed yet. - #[endpoint { - method = POST, - path = "/v1/system/ip-pools-service/ranges/add", - tags = ["system/ip-pools"], - }] - async fn ip_pool_service_range_add( - rqctx: RequestContext, - range_params: TypedBody, - ) -> Result, HttpError>; - - /// Remove IP range from Oxide service pool - #[endpoint { - method = POST, - path = "/v1/system/ip-pools-service/ranges/remove", - tags = ["system/ip-pools"], - }] - async fn ip_pool_service_range_remove( - rqctx: RequestContext, - range_params: TypedBody, - ) -> Result; - // Floating IP Addresses /// List floating IPs diff --git a/nexus/reconfigurator/execution/src/dns.rs b/nexus/reconfigurator/execution/src/dns.rs index 69bb198ff3d..78f525754ee 100644 --- a/nexus/reconfigurator/execution/src/dns.rs +++ b/nexus/reconfigurator/execution/src/dns.rs @@ -325,6 +325,7 @@ mod test { use internal_dns_types::names::ServiceName; use nexus_db_model::DbMetadataNexusState; use nexus_db_model::DnsGroup; + use nexus_db_model::IpPoolReservationType; use nexus_db_model::Silo; use nexus_db_queries::authn; use nexus_db_queries::authz; @@ -1401,23 +1402,27 @@ mod test { opctx: &OpContext, ) -> Vec { let service_pools = datastore - .ip_pools_service_lookup_both_versions(&opctx) - .await - .expect("success looking up both versions of the service IP Pools"); - let mut ranges = datastore - .ip_pool_list_ranges_batched(&opctx, &service_pools.ipv4.authz_pool) + .ip_pools_list_batched( + opctx, + IpPoolReservationType::OxideInternal, + None, + ) .await - .expect("success listing IPv4 pool ranges"); - ranges.append( - &mut datastore - .ip_pool_list_ranges_batched( - &opctx, - &service_pools.ipv6.authz_pool, - ) + .expect("success looking up all service IP Pools"); + let mut all_ranges = Vec::new(); + for pool in service_pools { + let (authz_pool, ..) = datastore + .ip_pool_lookup(opctx, &(pool.id().into())) + .fetch_for(authz::Action::ListChildren) .await - .expect("success listing IPv6 pool ranges"), - ); - ranges + .expect("success looking up authz object for IP Pool"); + let mut ranges = datastore + .ip_pool_list_ranges_batched(opctx, &authz_pool) + .await + .expect("success listing IP Pool ranges"); + all_ranges.append(&mut ranges); + } + all_ranges } // Tests end-to-end DNS behavior: diff --git a/nexus/reconfigurator/preparation/src/lib.rs b/nexus/reconfigurator/preparation/src/lib.rs index 81b96e9c952..3c9c3a834ed 100644 --- a/nexus/reconfigurator/preparation/src/lib.rs +++ b/nexus/reconfigurator/preparation/src/lib.rs @@ -9,6 +9,8 @@ use futures::StreamExt; use nexus_db_model::DbMetadataNexusState; use nexus_db_model::DnsGroup; use nexus_db_model::Generation; +use nexus_db_model::IpPoolReservationType; +use nexus_db_queries::authz; use nexus_db_queries::context::OpContext; use nexus_db_queries::db::DataStore; use nexus_db_queries::db::datastore::DataStoreDnsTest; @@ -400,19 +402,27 @@ async fn fetch_all_service_ip_pool_ranges( datastore: &DataStore, ) -> Result, Error> { let service_pools = datastore - .ip_pools_service_lookup_both_versions(opctx) + .ip_pools_list_batched( + opctx, + IpPoolReservationType::OxideInternal, + None, + ) .await .internal_context("fetching IP services pools")?; - let mut ranges = datastore - .ip_pool_list_ranges_batched(opctx, &service_pools.ipv4.authz_pool) - .await - .internal_context("listing services IPv4 pool ranges")?; - let mut v6_ranges = datastore - .ip_pool_list_ranges_batched(opctx, &service_pools.ipv6.authz_pool) - .await - .internal_context("listing services IPv6 pool ranges")?; - ranges.append(&mut v6_ranges); - Ok(ranges) + let mut all_ranges = Vec::new(); + for pool in service_pools { + let (authz_pool, ..) = datastore + .ip_pool_lookup(opctx, &(pool.id().into())) + .fetch_for(authz::Action::ListChildren) + .await + .internal_context("fetching authz object for service IP Pool")?; + let mut ranges = datastore + .ip_pool_list_ranges_batched(opctx, &authz_pool) + .await + .internal_context("listing services pool ranges")?; + all_ranges.append(&mut ranges); + } + Ok(all_ranges) } /// Loads state for debugging or import into `reconfigurator-cli` diff --git a/nexus/src/app/external_ip.rs b/nexus/src/app/external_ip.rs index 1ec6ff2d8dc..6be68aac269 100644 --- a/nexus/src/app/external_ip.rs +++ b/nexus/src/app/external_ip.rs @@ -118,7 +118,7 @@ impl super::Nexus { // resolve NameOrId into authz::IpPool let pool = match pool { Some(pool) => Some( - self.ip_pool_lookup(opctx, &pool)? + self.ip_pool_lookup(opctx, &pool) .lookup_for(authz::Action::CreateChild) .await? .0, diff --git a/nexus/src/app/ip_pool.rs b/nexus/src/app/ip_pool.rs index 1ef941cb735..279861f5870 100644 --- a/nexus/src/app/ip_pool.rs +++ b/nexus/src/app/ip_pool.rs @@ -7,18 +7,15 @@ use crate::external_api::params; use crate::external_api::shared; use ipnetwork::IpNetwork; -use nexus_db_lookup::LookupPath; use nexus_db_lookup::lookup; use nexus_db_model::IpPool; use nexus_db_model::IpPoolReservationType; use nexus_db_model::IpPoolType; -use nexus_db_model::IpPoolUpdate; use nexus_db_model::IpVersion; use nexus_db_queries::authz; use nexus_db_queries::authz::ApiResource; use nexus_db_queries::context::OpContext; use nexus_db_queries::db; -use nexus_db_queries::db::model::Name; use nexus_types::identity::Resource; use omicron_common::address::{IPV4_SSM_SUBNET, IPV6_SSM_SUBNET}; use omicron_common::api::external::CreateResult; @@ -29,47 +26,18 @@ use omicron_common::api::external::InternalContext; use omicron_common::api::external::ListResultVec; use omicron_common::api::external::LookupResult; use omicron_common::api::external::NameOrId; -use omicron_common::api::external::ResourceType; use omicron_common::api::external::UpdateResult; use omicron_common::api::external::http_pagination::PaginatedBy; -use ref_cast::RefCast; use std::matches; use uuid::Uuid; -/// Helper to make it easier to 404 on attempts to manipulate internal pools -fn not_found_from_lookup(pool_lookup: &lookup::IpPool<'_>) -> Error { - match pool_lookup { - lookup::IpPool::Name(_, name) => { - Error::not_found_by_name(ResourceType::IpPool, &name) - } - lookup::IpPool::OwnedName(_, name) => { - Error::not_found_by_name(ResourceType::IpPool, &name) - } - lookup::IpPool::PrimaryKey(_, id) => { - Error::not_found_by_id(ResourceType::IpPool, &id) - } - lookup::IpPool::Error(_, error) => error.to_owned(), - } -} - impl super::Nexus { pub fn ip_pool_lookup<'a>( &'a self, opctx: &'a OpContext, pool: &'a NameOrId, - ) -> LookupResult> { - match pool { - NameOrId::Name(name) => { - let pool = LookupPath::new(opctx, &self.db_datastore) - .ip_pool_name(Name::ref_cast(name)); - Ok(pool) - } - NameOrId::Id(id) => { - let pool = - LookupPath::new(opctx, &self.db_datastore).ip_pool_id(*id); - Ok(pool) - } - } + ) -> lookup::IpPool<'a> { + self.db_datastore.ip_pool_lookup(opctx, pool) } pub(crate) async fn ip_pool_create( @@ -135,7 +103,7 @@ impl super::Nexus { db::model::IpPoolResource, )> { let (authz_pool, pool) = self - .ip_pool_lookup(opctx, pool)? + .ip_pool_lookup(opctx, pool) // TODO-robustness: https://github.com/oxidecomputer/omicron/issues/3995 // Checking CreateChild works because it is the permission for // allocating IPs from a pool, which any authenticated user has. @@ -173,7 +141,7 @@ impl super::Nexus { self.db_datastore.ip_pool_silo_list(opctx, &authz_pool, pagparams).await } - // List pools for a given silo + /// List pools for a given silo pub(crate) async fn silo_ip_pool_list( &self, opctx: &OpContext, @@ -189,6 +157,7 @@ impl super::Nexus { self.db_datastore.silo_ip_pool_list(opctx, &authz_silo, pagparams).await } + /// Link a customer Silo to an IP Pool. pub(crate) async fn ip_pool_link_silo( &self, opctx: &OpContext, @@ -198,10 +167,6 @@ impl super::Nexus { let (authz_pool,) = pool_lookup.lookup_for(authz::Action::Modify).await?; - if self.db_datastore.ip_pool_is_internal(opctx, &authz_pool).await? { - return Err(not_found_from_lookup(pool_lookup)); - } - let (authz_silo,) = self .silo_lookup(&opctx, silo_link.silo.clone())? .lookup_for(authz::Action::Modify) @@ -219,6 +184,7 @@ impl super::Nexus { .await } + /// Unlink a customer Silo from an IP Pool. pub(crate) async fn ip_pool_unlink_silo( &self, opctx: &OpContext, @@ -228,10 +194,6 @@ impl super::Nexus { let (.., authz_pool) = pool_lookup.lookup_for(authz::Action::Modify).await?; - if self.db_datastore.ip_pool_is_internal(opctx, &authz_pool).await? { - return Err(not_found_from_lookup(pool_lookup)); - } - let (.., authz_silo) = silo_lookup.lookup_for(authz::Action::Modify).await?; @@ -240,6 +202,7 @@ impl super::Nexus { .await } + /// Update whether an IP Pool is the default for a Silo. pub(crate) async fn ip_pool_silo_update( &self, opctx: &OpContext, @@ -250,10 +213,6 @@ impl super::Nexus { let (.., authz_pool) = pool_lookup.lookup_for(authz::Action::Modify).await?; - if self.db_datastore.ip_pool_is_internal(opctx, &authz_pool).await? { - return Err(not_found_from_lookup(pool_lookup)); - } - let (.., authz_silo) = silo_lookup.lookup_for(authz::Action::Modify).await?; @@ -283,10 +242,6 @@ impl super::Nexus { let (.., authz_pool, db_pool) = pool_lookup.fetch_for(authz::Action::Delete).await?; - if self.db_datastore.ip_pool_is_internal(opctx, &authz_pool).await? { - return Err(not_found_from_lookup(pool_lookup)); - } - self.db_datastore.ip_pool_delete(opctx, &authz_pool, &db_pool).await } @@ -298,14 +253,9 @@ impl super::Nexus { ) -> UpdateResult { let (.., authz_pool) = pool_lookup.lookup_for(authz::Action::Modify).await?; - - if self.db_datastore.ip_pool_is_internal(opctx, &authz_pool).await? { - return Err(not_found_from_lookup(pool_lookup)); - } - - let updates_db = IpPoolUpdate::from(updates.clone()); - - self.db_datastore.ip_pool_update(opctx, &authz_pool, updates_db).await + self.db_datastore + .ip_pool_update(opctx, &authz_pool, updates.clone().into()) + .await } pub(crate) async fn ip_pool_list_ranges( @@ -317,10 +267,6 @@ impl super::Nexus { let (.., authz_pool) = pool_lookup.lookup_for(authz::Action::ListChildren).await?; - if self.db_datastore.ip_pool_is_internal(opctx, &authz_pool).await? { - return Err(not_found_from_lookup(pool_lookup)); - } - self.db_datastore .ip_pool_list_ranges(opctx, &authz_pool, pagparams) .await @@ -335,10 +281,6 @@ impl super::Nexus { let (.., authz_pool, db_pool) = pool_lookup.fetch_for(authz::Action::Modify).await?; - if self.db_datastore.ip_pool_is_internal(opctx, &authz_pool).await? { - return Err(not_found_from_lookup(pool_lookup)); - } - // Disallow V6 ranges until IPv6 is fully supported by the networking // subsystem. Instead of changing the API to reflect that (making this // endpoint inconsistent with the rest) and changing it back when we @@ -444,136 +386,29 @@ impl super::Nexus { let (.., authz_pool, _db_pool) = pool_lookup.fetch_for(authz::Action::Modify).await?; - if self.db_datastore.ip_pool_is_internal(opctx, &authz_pool).await? { - return Err(not_found_from_lookup(pool_lookup)); - } - self.db_datastore.ip_pool_delete_range(opctx, &authz_pool, range).await } - // The "ip_pool_service_..." functions look up IP pools for Oxide service usage, - // rather than for VMs. + // We no longer have explicit, hidden IP Pools for Oxide use, but this + // comment still applies. We might want to have different IP Pools for AZs, + // especially for pools delegated for Oxide services. // // TODO(https://github.com/oxidecomputer/omicron/issues/1276): Should be // accessed via AZ UUID, probably. - pub(crate) async fn ip_pool_service_fetch( + /// Reserve an IP Pool for a specific use. + pub(crate) async fn ip_pool_reserve( &self, opctx: &OpContext, - ) -> LookupResult { - // TODO: https://github.com/oxidecomputer/omicron/issues/8881 + ip_pool: &NameOrId, + reservation_type: IpPoolReservationType, + ) -> UpdateResult<()> { let (authz_pool, db_pool) = self - .db_datastore - .ip_pools_service_lookup(opctx, IpVersion::V4) + .ip_pool_lookup(opctx, ip_pool) + .fetch_for(authz::Action::Modify) .await?; - opctx.authorize(authz::Action::Read, &authz_pool).await?; - Ok(db_pool) - } - - pub(crate) async fn ip_pool_service_list_ranges( - &self, - opctx: &OpContext, - pagparams: &DataPageParams<'_, IpNetwork>, - ) -> ListResultVec { - // TODO: https://github.com/oxidecomputer/omicron/issues/8881 - let (authz_pool, ..) = self - .db_datastore - .ip_pools_service_lookup(opctx, IpVersion::V4) - .await?; - opctx.authorize(authz::Action::Read, &authz_pool).await?; - self.db_datastore - .ip_pool_list_ranges(opctx, &authz_pool, pagparams) - .await - } - - pub(crate) async fn ip_pool_service_add_range( - &self, - opctx: &OpContext, - range: &shared::IpRange, - ) -> UpdateResult { - let (authz_pool, db_pool) = self - .db_datastore - .ip_pools_service_lookup(opctx, range.version().into()) - .await?; - opctx.authorize(authz::Action::Modify, &authz_pool).await?; - - // Disallow V6 ranges until IPv6 is fully supported by the networking - // subsystem. Instead of changing the API to reflect that (making this - // endpoint inconsistent with the rest) and changing it back when we - // add support, we accept them at the API layer and error here. It - // would be nice if we could do it in the datastore layer, but we'd - // have no way of creating IPv6 ranges for the purpose of testing IP - // pool utilization. - // - // See https://github.com/oxidecomputer/omicron/issues/8761. - if matches!(range, shared::IpRange::V6(_)) { - return Err(Error::invalid_request( - "IPv6 ranges are not allowed yet", - )); - } - - // Validate that the range matches the pool type and that they match uniformity - let range_is_multicast = match range { - shared::IpRange::V4(v4_range) => { - let first = v4_range.first_address(); - let last = v4_range.last_address(); - let first_is_multicast = first.is_multicast(); - let last_is_multicast = last.is_multicast(); - - if first_is_multicast != last_is_multicast { - return Err(Error::invalid_request( - "IP range cannot span multicast and unicast address spaces", - )); - } - first_is_multicast - } - shared::IpRange::V6(v6_range) => { - let first = v6_range.first_address(); - let last = v6_range.last_address(); - let first_is_multicast = first.is_multicast(); - let last_is_multicast = last.is_multicast(); - - if first_is_multicast != last_is_multicast { - return Err(Error::invalid_request( - "IP range cannot span multicast and unicast address spaces", - )); - } - first_is_multicast - } - }; - - match db_pool.pool_type { - IpPoolType::Multicast => { - if !range_is_multicast { - return Err(Error::invalid_request( - "Cannot add unicast address range to multicast IP pool", - )); - } - } - IpPoolType::Unicast => { - if range_is_multicast { - return Err(Error::invalid_request( - "Cannot add multicast address range to unicast IP pool", - )); - } - } - } - self.db_datastore - .ip_pool_add_range(opctx, &authz_pool, &db_pool, range) + .ip_pool_reserve(opctx, &authz_pool, &db_pool, reservation_type) .await } - - pub(crate) async fn ip_pool_service_delete_range( - &self, - opctx: &OpContext, - range: &shared::IpRange, - ) -> DeleteResult { - let (authz_pool, ..) = self - .db_datastore - .ip_pools_service_lookup(opctx, range.version().into()) - .await?; - opctx.authorize(authz::Action::Modify, &authz_pool).await?; - self.db_datastore.ip_pool_delete_range(opctx, &authz_pool, range).await - } } diff --git a/nexus/src/app/probe.rs b/nexus/src/app/probe.rs index 6522f921689..e32072a2955 100644 --- a/nexus/src/app/probe.rs +++ b/nexus/src/app/probe.rs @@ -68,7 +68,7 @@ impl super::Nexus { // resolve NameOrId into authz::IpPool let pool = match &new_probe_params.ip_pool { Some(pool) => Some( - self.ip_pool_lookup(opctx, &pool)? + self.ip_pool_lookup(opctx, &pool) .lookup_for(authz::Action::CreateChild) .await? .0, diff --git a/nexus/src/app/sagas/instance_create.rs b/nexus/src/app/sagas/instance_create.rs index 89f8ccaf887..130359e88e8 100644 --- a/nexus/src/app/sagas/instance_create.rs +++ b/nexus/src/app/sagas/instance_create.rs @@ -813,7 +813,6 @@ async fn sic_allocate_instance_external_ip( osagactx .nexus() .ip_pool_lookup(&opctx, name_or_id) - .map_err(ActionError::action_failed)? .lookup_for(authz::Action::CreateChild) .await .map_err(ActionError::action_failed)? diff --git a/nexus/src/app/sagas/instance_ip_attach.rs b/nexus/src/app/sagas/instance_ip_attach.rs index 11a0a18f19a..9932ecf1957 100644 --- a/nexus/src/app/sagas/instance_ip_attach.rs +++ b/nexus/src/app/sagas/instance_ip_attach.rs @@ -100,7 +100,6 @@ async fn siia_begin_attach_ip( osagactx .nexus() .ip_pool_lookup(&opctx, name_or_id) - .map_err(ActionError::action_failed)? .lookup_for(authz::Action::CreateChild) .await .map_err(ActionError::action_failed)? diff --git a/nexus/src/app/silo.rs b/nexus/src/app/silo.rs index 1a9dbccdfde..2b1e620be2b 100644 --- a/nexus/src/app/silo.rs +++ b/nexus/src/app/silo.rs @@ -57,6 +57,7 @@ impl super::Nexus { Ok(silo) } + // TODO-cleanup: This is infallible, it doesn't need to return a Result. pub fn silo_lookup<'a>( &'a self, opctx: &'a OpContext, diff --git a/nexus/src/external_api/http_entrypoints.rs b/nexus/src/external_api/http_entrypoints.rs index 4e96db2d3eb..8e0d954f9b9 100644 --- a/nexus/src/external_api/http_entrypoints.rs +++ b/nexus/src/external_api/http_entrypoints.rs @@ -7,10 +7,10 @@ use super::{ console_api, params, views::{ - self, Certificate, FloatingIp, Group, IdentityProvider, Image, IpPool, + self, Certificate, FloatingIp, Group, IdentityProvider, Image, IpPoolRange, PhysicalDisk, Project, Rack, Silo, SiloQuotas, - SiloUtilization, Sled, Snapshot, SshKey, User, UserBuiltin, - Utilization, Vpc, VpcRouter, VpcSubnet, + SiloUtilization, Sled, Snapshot, SshKey, SystemIpPool, User, + UserBuiltin, Utilization, Vpc, VpcRouter, VpcSubnet, }, }; use crate::app::external_endpoints::authority_for_request; @@ -526,7 +526,7 @@ impl NexusExternalApi for NexusExternalApiImpl { rqctx: RequestContext, path_params: Path, query_params: Query, - ) -> Result>, HttpError> { + ) -> Result>, HttpError> { let apictx = rqctx.context(); let handler = async { let opctx = @@ -544,9 +544,10 @@ impl NexusExternalApi for NexusExternalApiImpl { .silo_ip_pool_list(&opctx, &silo_lookup, &paginated_by) .await? .iter() - .map(|(pool, silo_link)| views::SiloIpPool { + .map(|(pool, silo_link)| views::IpPool { identity: pool.identity(), is_default: silo_link.is_default, + ip_version: pool.ip_version.into(), }) .collect(); @@ -1673,10 +1674,10 @@ impl NexusExternalApi for NexusExternalApiImpl { // IP Pools - async fn project_ip_pool_list( + async fn ip_pool_list( rqctx: RequestContext, query_params: Query, - ) -> Result>, HttpError> { + ) -> Result>, HttpError> { let apictx = rqctx.context(); let handler = async { let nexus = &apictx.context.nexus; @@ -1690,9 +1691,10 @@ impl NexusExternalApi for NexusExternalApiImpl { .current_silo_ip_pool_list(&opctx, &paginated_by) .await? .into_iter() - .map(|(pool, silo_link)| views::SiloIpPool { + .map(|(pool, silo_link)| views::IpPool { identity: pool.identity(), is_default: silo_link.is_default, + ip_version: pool.ip_version.into(), }) .collect(); Ok(HttpResponseOk(ScanByNameOrId::results_page( @@ -1708,10 +1710,10 @@ impl NexusExternalApi for NexusExternalApiImpl { .await } - async fn project_ip_pool_view( + async fn ip_pool_view( rqctx: RequestContext, path_params: Path, - ) -> Result, HttpError> { + ) -> Result, HttpError> { let apictx = rqctx.context(); let handler = async { let opctx = @@ -1720,9 +1722,10 @@ impl NexusExternalApi for NexusExternalApiImpl { let pool_selector = path_params.into_inner().pool; let (.., pool, silo_link) = nexus.silo_ip_pool_fetch(&opctx, &pool_selector).await?; - Ok(HttpResponseOk(views::SiloIpPool { + Ok(HttpResponseOk(views::IpPool { identity: pool.identity(), is_default: silo_link.is_default, + ip_version: pool.ip_version.into(), })) }; apictx @@ -1732,10 +1735,10 @@ impl NexusExternalApi for NexusExternalApiImpl { .await } - async fn ip_pool_list( + async fn system_ip_pool_list( rqctx: RequestContext, query_params: Query, - ) -> Result>, HttpError> { + ) -> Result>, HttpError> { let apictx = rqctx.context(); let handler = async { let nexus = &apictx.context.nexus; @@ -1749,7 +1752,7 @@ impl NexusExternalApi for NexusExternalApiImpl { .ip_pools_list(&opctx, &paginated_by) .await? .into_iter() - .map(IpPool::from) + .map(SystemIpPool::from) .collect(); Ok(HttpResponseOk(ScanByNameOrId::results_page( &query, @@ -1764,10 +1767,10 @@ impl NexusExternalApi for NexusExternalApiImpl { .await } - async fn ip_pool_create( + async fn system_ip_pool_create( rqctx: RequestContext, pool_params: TypedBody, - ) -> Result, HttpError> { + ) -> Result, HttpError> { let apictx = rqctx.context(); let nexus = &apictx.context.nexus; let pool_params = pool_params.into_inner(); @@ -1775,7 +1778,7 @@ impl NexusExternalApi for NexusExternalApiImpl { let opctx = crate::context::op_context_for_external_api(&rqctx).await?; let pool = nexus.ip_pool_create(&opctx, &pool_params).await?; - Ok(HttpResponseCreated(pool.into())) + Ok(HttpResponseCreated(SystemIpPool::from(pool))) }; apictx .context @@ -1784,21 +1787,19 @@ impl NexusExternalApi for NexusExternalApiImpl { .await } - async fn ip_pool_view( + async fn system_ip_pool_view( rqctx: RequestContext, path_params: Path, - ) -> Result, HttpError> { + ) -> Result, HttpError> { let apictx = rqctx.context(); let handler = async { let opctx = crate::context::op_context_for_external_api(&rqctx).await?; let nexus = &apictx.context.nexus; let pool_selector = path_params.into_inner().pool; - // We do not prevent the service pool from being fetched by name or ID - // like we do for update, delete, associate. let (.., pool) = - nexus.ip_pool_lookup(&opctx, &pool_selector)?.fetch().await?; - Ok(HttpResponseOk(IpPool::from(pool))) + nexus.ip_pool_lookup(&opctx, &pool_selector).fetch().await?; + Ok(HttpResponseOk(SystemIpPool::from(pool))) }; apictx .context @@ -1807,7 +1808,7 @@ impl NexusExternalApi for NexusExternalApiImpl { .await } - async fn ip_pool_delete( + async fn system_ip_pool_delete( rqctx: RequestContext, path_params: Path, ) -> Result { @@ -1817,7 +1818,7 @@ impl NexusExternalApi for NexusExternalApiImpl { crate::context::op_context_for_external_api(&rqctx).await?; let nexus = &apictx.context.nexus; let path = path_params.into_inner(); - let pool_lookup = nexus.ip_pool_lookup(&opctx, &path.pool)?; + let pool_lookup = nexus.ip_pool_lookup(&opctx, &path.pool); nexus.ip_pool_delete(&opctx, &pool_lookup).await?; Ok(HttpResponseDeleted()) }; @@ -1828,11 +1829,11 @@ impl NexusExternalApi for NexusExternalApiImpl { .await } - async fn ip_pool_update( + async fn system_ip_pool_update( rqctx: RequestContext, path_params: Path, updates: TypedBody, - ) -> Result, HttpError> { + ) -> Result, HttpError> { let apictx = rqctx.context(); let handler = async { let opctx = @@ -1840,7 +1841,7 @@ impl NexusExternalApi for NexusExternalApiImpl { let nexus = &apictx.context.nexus; let path = path_params.into_inner(); let updates = updates.into_inner(); - let pool_lookup = nexus.ip_pool_lookup(&opctx, &path.pool)?; + let pool_lookup = nexus.ip_pool_lookup(&opctx, &path.pool); let pool = nexus.ip_pool_update(&opctx, &pool_lookup, &updates).await?; Ok(HttpResponseOk(pool.into())) @@ -1852,7 +1853,7 @@ impl NexusExternalApi for NexusExternalApiImpl { .await } - async fn ip_pool_utilization_view( + async fn system_ip_pool_utilization_view( rqctx: RequestContext, path_params: Path, ) -> Result, HttpError> { @@ -1864,7 +1865,7 @@ impl NexusExternalApi for NexusExternalApiImpl { let pool_selector = path_params.into_inner().pool; // We do not prevent the service pool from being fetched by name or ID // like we do for update, delete, associate. - let pool_lookup = nexus.ip_pool_lookup(&opctx, &pool_selector)?; + let pool_lookup = nexus.ip_pool_lookup(&opctx, &pool_selector); let utilization = nexus.ip_pool_utilization_view(&opctx, &pool_lookup).await?; Ok(HttpResponseOk(utilization.into())) @@ -1876,7 +1877,7 @@ impl NexusExternalApi for NexusExternalApiImpl { .await } - async fn ip_pool_silo_list( + async fn system_ip_pool_silo_list( rqctx: RequestContext, path_params: Path, // paginating by resource_id because they're unique per pool. most robust @@ -1902,7 +1903,7 @@ impl NexusExternalApi for NexusExternalApiImpl { let pag_params = data_page_params_for(&rqctx, &query)?; let path = path_params.into_inner(); - let pool_lookup = nexus.ip_pool_lookup(&opctx, &path.pool)?; + let pool_lookup = nexus.ip_pool_lookup(&opctx, &path.pool); let assocs = nexus .ip_pool_silo_list(&opctx, &pool_lookup, &pag_params) @@ -1924,7 +1925,7 @@ impl NexusExternalApi for NexusExternalApiImpl { .await } - async fn ip_pool_silo_link( + async fn system_ip_pool_silo_link( rqctx: RequestContext, path_params: Path, resource_assoc: TypedBody, @@ -1936,7 +1937,7 @@ impl NexusExternalApi for NexusExternalApiImpl { let nexus = &apictx.context.nexus; let path = path_params.into_inner(); let resource_assoc = resource_assoc.into_inner(); - let pool_lookup = nexus.ip_pool_lookup(&opctx, &path.pool)?; + let pool_lookup = nexus.ip_pool_lookup(&opctx, &path.pool); let assoc = nexus .ip_pool_link_silo(&opctx, &pool_lookup, &resource_assoc) .await?; @@ -1949,7 +1950,7 @@ impl NexusExternalApi for NexusExternalApiImpl { .await } - async fn ip_pool_silo_unlink( + async fn system_ip_pool_silo_unlink( rqctx: RequestContext, path_params: Path, ) -> Result { @@ -1959,7 +1960,7 @@ impl NexusExternalApi for NexusExternalApiImpl { crate::context::op_context_for_external_api(&rqctx).await?; let nexus = &apictx.context.nexus; let path = path_params.into_inner(); - let pool_lookup = nexus.ip_pool_lookup(&opctx, &path.pool)?; + let pool_lookup = nexus.ip_pool_lookup(&opctx, &path.pool); let silo_lookup = nexus.silo_lookup(&opctx, path.silo)?; nexus .ip_pool_unlink_silo(&opctx, &pool_lookup, &silo_lookup) @@ -1973,7 +1974,7 @@ impl NexusExternalApi for NexusExternalApiImpl { .await } - async fn ip_pool_silo_update( + async fn system_ip_pool_silo_update( rqctx: RequestContext, path_params: Path, update: TypedBody, @@ -1985,7 +1986,7 @@ impl NexusExternalApi for NexusExternalApiImpl { let nexus = &apictx.context.nexus; let path = path_params.into_inner(); let update = update.into_inner(); - let pool_lookup = nexus.ip_pool_lookup(&opctx, &path.pool)?; + let pool_lookup = nexus.ip_pool_lookup(&opctx, &path.pool); let silo_lookup = nexus.silo_lookup(&opctx, path.silo)?; let assoc = nexus .ip_pool_silo_update( @@ -2004,16 +2005,23 @@ impl NexusExternalApi for NexusExternalApiImpl { .await } - async fn ip_pool_service_view( - rqctx: RequestContext, - ) -> Result, HttpError> { + async fn system_ip_pool_reserve( + rqctx: RequestContext, + path_params: Path, + body: TypedBody, + ) -> Result { let apictx = rqctx.context(); let nexus = &apictx.context.nexus; let handler = async { let opctx = crate::context::op_context_for_external_api(&rqctx).await?; - let pool = nexus.ip_pool_service_fetch(&opctx).await?; - Ok(HttpResponseOk(IpPool::from(pool))) + let pool = path_params.into_inner().pool; + let reservation_type = body.into_inner().reservation_type; + nexus + .ip_pool_reserve(&opctx, &pool, reservation_type.into()) + .await + .map(|_| HttpResponseUpdatedNoContent()) + .map_err(HttpError::from) }; apictx .context @@ -2022,7 +2030,7 @@ impl NexusExternalApi for NexusExternalApiImpl { .await } - async fn ip_pool_range_list( + async fn system_ip_pool_range_list( rqctx: RequestContext, path_params: Path, query_params: Query, @@ -2043,7 +2051,7 @@ impl NexusExternalApi for NexusExternalApiImpl { direction: PaginationOrder::Ascending, marker, }; - let pool_lookup = nexus.ip_pool_lookup(&opctx, &path.pool)?; + let pool_lookup = nexus.ip_pool_lookup(&opctx, &path.pool); let ranges = nexus .ip_pool_list_ranges(&opctx, &pool_lookup, &pag_params) .await? @@ -2065,7 +2073,7 @@ impl NexusExternalApi for NexusExternalApiImpl { .await } - async fn ip_pool_range_add( + async fn system_ip_pool_range_add( rqctx: RequestContext, path_params: Path, range_params: TypedBody, @@ -2077,7 +2085,7 @@ impl NexusExternalApi for NexusExternalApiImpl { let nexus = &apictx.context.nexus; let path = path_params.into_inner(); let range = range_params.into_inner(); - let pool_lookup = nexus.ip_pool_lookup(&opctx, &path.pool)?; + let pool_lookup = nexus.ip_pool_lookup(&opctx, &path.pool); let out = nexus.ip_pool_add_range(&opctx, &pool_lookup, &range).await?; Ok(HttpResponseCreated(out.try_into()?)) @@ -2089,7 +2097,7 @@ impl NexusExternalApi for NexusExternalApiImpl { .await } - async fn ip_pool_range_remove( + async fn system_ip_pool_range_remove( rqctx: RequestContext, path_params: Path, range_params: TypedBody, @@ -2101,7 +2109,7 @@ impl NexusExternalApi for NexusExternalApiImpl { let nexus = &apictx.context.nexus; let path = path_params.into_inner(); let range = range_params.into_inner(); - let pool_lookup = nexus.ip_pool_lookup(&opctx, &path.pool)?; + let pool_lookup = nexus.ip_pool_lookup(&opctx, &path.pool); nexus.ip_pool_delete_range(&opctx, &pool_lookup, &range).await?; Ok(HttpResponseUpdatedNoContent()) }; @@ -2112,86 +2120,6 @@ impl NexusExternalApi for NexusExternalApiImpl { .await } - async fn ip_pool_service_range_list( - rqctx: RequestContext, - query_params: Query, - ) -> Result>, HttpError> { - let apictx = rqctx.context(); - let handler = async { - let opctx = - crate::context::op_context_for_external_api(&rqctx).await?; - let nexus = &apictx.context.nexus; - let query = query_params.into_inner(); - let marker = match query.page { - WhichPage::First(_) => None, - WhichPage::Next(ref addr) => Some(addr), - }; - let pag_params = DataPageParams { - limit: rqctx.page_limit(&query)?, - direction: PaginationOrder::Ascending, - marker, - }; - let ranges = nexus - .ip_pool_service_list_ranges(&opctx, &pag_params) - .await? - .into_iter() - .map(|range| range.try_into()) - .collect::, _>>()?; - Ok(HttpResponseOk(ResultsPage::new( - ranges, - &EmptyScanParams {}, - |range: &IpPoolRange, _| { - IpNetwork::from(range.range.first_address()) - }, - )?)) - }; - apictx - .context - .external_latencies - .instrument_dropshot_handler(&rqctx, handler) - .await - } - - async fn ip_pool_service_range_add( - rqctx: RequestContext, - range_params: TypedBody, - ) -> Result, HttpError> { - let apictx = &rqctx.context(); - let handler = async { - let opctx = - crate::context::op_context_for_external_api(&rqctx).await?; - let nexus = &apictx.context.nexus; - let range = range_params.into_inner(); - let out = nexus.ip_pool_service_add_range(&opctx, &range).await?; - Ok(HttpResponseCreated(out.try_into()?)) - }; - apictx - .context - .external_latencies - .instrument_dropshot_handler(&rqctx, handler) - .await - } - - async fn ip_pool_service_range_remove( - rqctx: RequestContext, - range_params: TypedBody, - ) -> Result { - let apictx = &rqctx.context(); - let nexus = &apictx.context.nexus; - let range = range_params.into_inner(); - let handler = async { - let opctx = - crate::context::op_context_for_external_api(&rqctx).await?; - nexus.ip_pool_service_delete_range(&opctx, &range).await?; - Ok(HttpResponseUpdatedNoContent()) - }; - apictx - .context - .external_latencies - .instrument_dropshot_handler(&rqctx, handler) - .await - } - // Floating IP Addresses async fn floating_ip_list( diff --git a/nexus/test-utils/src/resource_helpers.rs b/nexus/test-utils/src/resource_helpers.rs index 5f9f59b039f..9303821c6a9 100644 --- a/nexus/test-utils/src/resource_helpers.rs +++ b/nexus/test-utils/src/resource_helpers.rs @@ -29,8 +29,8 @@ use nexus_types::external_api::views::FloatingIp; use nexus_types::external_api::views::InternetGateway; use nexus_types::external_api::views::InternetGatewayIpAddress; use nexus_types::external_api::views::InternetGatewayIpPool; -use nexus_types::external_api::views::IpPool; use nexus_types::external_api::views::IpPoolRange; +use nexus_types::external_api::views::SystemIpPool; use nexus_types::external_api::views::User; use nexus_types::external_api::views::VpcSubnet; use nexus_types::external_api::views::{Project, Silo, Vpc, VpcRouter}; @@ -249,18 +249,23 @@ pub async fn create_ip_pool( client: &ClientTestContext, pool_name: &str, ip_range: Option, -) -> (IpPool, IpPoolRange) { - let pool_params = params::IpPoolCreate::new( - IdentityMetadataCreateParams { - name: pool_name.parse().unwrap(), - description: String::from("an ip pool"), +) -> (SystemIpPool, IpPoolRange) { + let pool = object_create( + client, + "/v1/system/ip-pools", + ¶ms::IpPoolCreate { + identity: IdentityMetadataCreateParams { + name: pool_name.parse().unwrap(), + description: String::from("an ip pool"), + }, + ip_version: ip_range + .map(|r| r.version()) + .unwrap_or_else(views::IpVersion::v4), + pool_type: shared::IpPoolType::Unicast, + reservation_type: shared::IpPoolReservationType::ExternalSilos, }, - ip_range - .as_ref() - .map(|r| r.version()) - .unwrap_or_else(views::IpVersion::v4), - ); - let pool = object_create(client, "/v1/system/ip-pools", &pool_params).await; + ) + .await; let ip_range = ip_range.unwrap_or_else(|| { use std::net::Ipv4Addr; @@ -293,7 +298,7 @@ pub async fn link_ip_pool( /// What you want for any test that is not testing IP logic specifically pub async fn create_default_ip_pool( client: &ClientTestContext, -) -> views::IpPool { +) -> views::SystemIpPool { let (pool, ..) = create_ip_pool(&client, "default", None).await; link_ip_pool(&client, "default", &DEFAULT_SILO.id(), true).await; pool diff --git a/nexus/tests/integration_tests/endpoints.rs b/nexus/tests/integration_tests/endpoints.rs index fddba783a20..f9f8924e43a 100644 --- a/nexus/tests/integration_tests/endpoints.rs +++ b/nexus/tests/integration_tests/endpoints.rs @@ -21,6 +21,8 @@ use nexus_test_utils::SWITCH_UUID; use nexus_test_utils::resource_helpers::test_params; use nexus_types::external_api::params; use nexus_types::external_api::shared; +use nexus_types::external_api::shared::IpPoolReservationType; +use nexus_types::external_api::shared::IpPoolType; use nexus_types::external_api::shared::IpRange; use nexus_types::external_api::shared::IpVersion; use nexus_types::external_api::shared::Ipv4Range; @@ -922,20 +924,20 @@ pub static DEMO_IMAGE_CREATE: LazyLock = }); // IP Pools -pub static DEMO_IP_POOLS_PROJ_URL: LazyLock = +pub static DEMO_SILOED_IP_POOLS_URL: LazyLock = LazyLock::new(|| "/v1/ip-pools".to_string()); -pub const DEMO_IP_POOLS_URL: &'static str = "/v1/system/ip-pools"; +pub const DEMO_SYSTEM_IP_POOLS_URL: &'static str = "/v1/system/ip-pools"; pub static DEMO_IP_POOL_NAME: LazyLock = LazyLock::new(|| "default".parse().unwrap()); pub static DEMO_IP_POOL_CREATE: LazyLock = - LazyLock::new(|| { - params::IpPoolCreate::new( - IdentityMetadataCreateParams { - name: DEMO_IP_POOL_NAME.clone(), - description: String::from("an IP pool"), - }, - IpVersion::V4, - ) + LazyLock::new(|| params::IpPoolCreate { + identity: IdentityMetadataCreateParams { + name: DEMO_IP_POOL_NAME.clone(), + description: String::from("an IP pool"), + }, + ip_version: IpVersion::V4, + pool_type: IpPoolType::Unicast, + reservation_type: IpPoolReservationType::ExternalSilos, }); pub static DEMO_IP_POOL_PROJ_URL: LazyLock = LazyLock::new(|| { format!( @@ -945,6 +947,12 @@ pub static DEMO_IP_POOL_PROJ_URL: LazyLock = LazyLock::new(|| { }); pub static DEMO_IP_POOL_URL: LazyLock = LazyLock::new(|| format!("/v1/system/ip-pools/{}", *DEMO_IP_POOL_NAME)); +pub static DEMO_IP_POOL_RESERVE_URL: LazyLock = + LazyLock::new(|| format!("{}/reserve", *DEMO_IP_POOL_URL)); +pub static DEMO_IP_POOL_RESERVE: LazyLock = + LazyLock::new(|| params::IpPoolReservationUpdate { + reservation_type: shared::IpPoolReservationType::OxideInternal, + }); pub static DEMO_IP_POOL_UTILIZATION_URL: LazyLock = LazyLock::new(|| format!("{}/utilization", *DEMO_IP_POOL_URL)); pub static DEMO_IP_POOL_UPDATE: LazyLock = @@ -984,16 +992,6 @@ pub static DEMO_IP_POOL_RANGES_ADD_URL: LazyLock = pub static DEMO_IP_POOL_RANGES_DEL_URL: LazyLock = LazyLock::new(|| format!("{}/remove", *DEMO_IP_POOL_RANGES_URL)); -// IP Pools (Services) -pub const DEMO_IP_POOL_SERVICE_URL: &'static str = - "/v1/system/ip-pools-service"; -pub static DEMO_IP_POOL_SERVICE_RANGES_URL: LazyLock = - LazyLock::new(|| format!("{}/ranges", DEMO_IP_POOL_SERVICE_URL)); -pub static DEMO_IP_POOL_SERVICE_RANGES_ADD_URL: LazyLock = - LazyLock::new(|| format!("{}/add", *DEMO_IP_POOL_SERVICE_RANGES_URL)); -pub static DEMO_IP_POOL_SERVICE_RANGES_DEL_URL: LazyLock = - LazyLock::new(|| format!("{}/remove", *DEMO_IP_POOL_SERVICE_RANGES_URL)); - // Snapshots pub static DEMO_SNAPSHOT_NAME: LazyLock = LazyLock::new(|| "demo-snapshot".parse().unwrap()); @@ -1447,7 +1445,7 @@ pub static VERIFY_ENDPOINTS: LazyLock> = LazyLock::new( }, // IP Pools top-level endpoint VerifyEndpoint { - url: &DEMO_IP_POOLS_URL, + url: &DEMO_SYSTEM_IP_POOLS_URL, visibility: Visibility::Public, unprivileged_access: UnprivilegedAccess::None, allowed_methods: vec![ @@ -1457,8 +1455,19 @@ pub static VERIFY_ENDPOINTS: LazyLock> = LazyLock::new( ), ], }, + // IP Pool reservation endpoint + VerifyEndpoint { + url: &DEMO_IP_POOL_RESERVE_URL, + visibility: Visibility::Protected, + unprivileged_access: UnprivilegedAccess::None, + allowed_methods: vec![ + AllowedMethod::Post( + serde_json::to_value(&*DEMO_IP_POOL_RESERVE).unwrap(), + ), + ], + }, VerifyEndpoint { - url: &DEMO_IP_POOLS_PROJ_URL, + url: &DEMO_SILOED_IP_POOLS_URL, visibility: Visibility::Public, unprivileged_access: UnprivilegedAccess::ReadOnly, allowed_methods: vec![AllowedMethod::Get], @@ -1539,38 +1548,6 @@ pub static VERIFY_ENDPOINTS: LazyLock> = LazyLock::new( unprivileged_access: UnprivilegedAccess::None, allowed_methods: vec![AllowedMethod::Get], }, - // IP Pool endpoint (Oxide services) - VerifyEndpoint { - url: &DEMO_IP_POOL_SERVICE_URL, - visibility: Visibility::Protected, - unprivileged_access: UnprivilegedAccess::None, - allowed_methods: vec![AllowedMethod::Get], - }, - // IP Pool ranges endpoint (Oxide services) - VerifyEndpoint { - url: &DEMO_IP_POOL_SERVICE_RANGES_URL, - visibility: Visibility::Protected, - unprivileged_access: UnprivilegedAccess::None, - allowed_methods: vec![AllowedMethod::Get], - }, - // IP Pool ranges/add endpoint (Oxide services) - VerifyEndpoint { - url: &DEMO_IP_POOL_SERVICE_RANGES_ADD_URL, - visibility: Visibility::Protected, - unprivileged_access: UnprivilegedAccess::None, - allowed_methods: vec![AllowedMethod::Post( - serde_json::to_value(&*DEMO_IP_POOL_RANGE).unwrap(), - )], - }, - // IP Pool ranges/delete endpoint (Oxide services) - VerifyEndpoint { - url: &DEMO_IP_POOL_SERVICE_RANGES_DEL_URL, - visibility: Visibility::Protected, - unprivileged_access: UnprivilegedAccess::None, - allowed_methods: vec![AllowedMethod::Post( - serde_json::to_value(&*DEMO_IP_POOL_RANGE).unwrap(), - )], - }, /* Silos */ VerifyEndpoint { url: "/v1/system/silos", diff --git a/nexus/tests/integration_tests/ip_pools.rs b/nexus/tests/integration_tests/ip_pools.rs index e8eec6b9fdf..14884586a52 100644 --- a/nexus/tests/integration_tests/ip_pools.rs +++ b/nexus/tests/integration_tests/ip_pools.rs @@ -15,7 +15,6 @@ use http::StatusCode; use http::method::Method; use nexus_db_queries::authz; use nexus_db_queries::context::OpContext; -use nexus_db_queries::db::datastore::SERVICE_IPV4_POOL_NAME; use nexus_db_queries::db::fixed_data::silo::DEFAULT_SILO; use nexus_test_utils::http_testing::AuthnMode; use nexus_test_utils::http_testing::NexusRequest; @@ -45,6 +44,7 @@ use nexus_types::external_api::params::IpPoolCreate; use nexus_types::external_api::params::IpPoolLinkSilo; use nexus_types::external_api::params::IpPoolSiloUpdate; use nexus_types::external_api::params::IpPoolUpdate; +use nexus_types::external_api::shared::IpPoolReservationType; use nexus_types::external_api::shared::IpPoolType; use nexus_types::external_api::shared::IpRange; use nexus_types::external_api::shared::Ipv4Range; @@ -55,9 +55,8 @@ use nexus_types::external_api::views::IpPoolRange; use nexus_types::external_api::views::IpPoolSiloLink; use nexus_types::external_api::views::IpVersion; use nexus_types::external_api::views::Silo; -use nexus_types::external_api::views::SiloIpPool; +use nexus_types::external_api::views::SystemIpPool; use nexus_types::identity::Resource; -use nexus_types::silo::INTERNAL_SILO_ID; use omicron_common::address::Ipv6Range; use omicron_common::api::external::IdentityMetadataUpdateParams; use omicron_common::api::external::InstanceState; @@ -104,37 +103,45 @@ async fn test_ip_pool_basic_crud(cptestctx: &ControlPlaneTestContext) { // Create the pool, verify we can get it back by either listing or fetching // directly - let params = IpPoolCreate::new( - IdentityMetadataCreateParams { + let params = IpPoolCreate { + identity: IdentityMetadataCreateParams { name: String::from(pool_name).parse().unwrap(), description: String::from(description), }, - IpVersion::V4, - ); - let created_pool: IpPool = + ip_version: IpVersion::V4, + pool_type: IpPoolType::Unicast, + reservation_type: IpPoolReservationType::ExternalSilos, + }; + let created_pool: SystemIpPool = object_create(client, ip_pools_url, ¶ms).await; assert_eq!(created_pool.identity.name, pool_name); assert_eq!(created_pool.identity.description, description); assert_eq!(created_pool.ip_version, IpVersion::V4); + assert_eq!( + created_pool.reservation_type, + IpPoolReservationType::ExternalSilos + ); let list = get_ip_pools(client).await; assert_eq!(list.len(), 1, "Expected exactly 1 IP pool"); assert_pools_eq(&created_pool, &list[0]); - let fetched_pool: IpPool = object_get(client, &ip_pool_url).await; + let fetched_pool: SystemIpPool = object_get(client, &ip_pool_url).await; assert_pools_eq(&created_pool, &fetched_pool); // Verify we get a conflict error if we insert it again let error = object_create_error( client, ip_pools_url, - ¶ms::IpPoolCreate::new( - IdentityMetadataCreateParams { + ¶ms::IpPoolCreate { + identity: IdentityMetadataCreateParams { name: pool_name.parse().unwrap(), description: String::new(), }, - IpVersion::V4, - ), + ip_version: IpVersion::V4, + pool_type: IpPoolType::Unicast, + reservation_type: IpPoolReservationType::ExternalSilos, + }, StatusCode::BAD_REQUEST, ) .await; @@ -179,7 +186,7 @@ async fn test_ip_pool_basic_crud(cptestctx: &ControlPlaneTestContext) { description: None, }, }; - let modified_pool: IpPool = + let modified_pool: SystemIpPool = object_put(client, &ip_pool_url, &updates).await; assert_eq!(modified_pool.identity.name, new_pool_name); assert_eq!(modified_pool.identity.id, created_pool.identity.id); @@ -196,7 +203,7 @@ async fn test_ip_pool_basic_crud(cptestctx: &ControlPlaneTestContext) { > created_pool.identity.time_modified ); - let fetched_modified_pool: IpPool = + let fetched_modified_pool: SystemIpPool = object_get(client, &new_ip_pool_url).await; assert_pools_eq(&modified_pool, &fetched_modified_pool); @@ -225,8 +232,8 @@ async fn test_ip_pool_basic_crud(cptestctx: &ControlPlaneTestContext) { .expect("Expected to be able to delete an empty IP Pool"); } -async fn get_ip_pools(client: &ClientTestContext) -> Vec { - NexusRequest::iter_collection_authn::( +async fn get_ip_pools(client: &ClientTestContext) -> Vec { + NexusRequest::iter_collection_authn::( client, "/v1/system/ip-pools", "", @@ -329,130 +336,6 @@ async fn test_ip_pool_list_dedupe(cptestctx: &ControlPlaneTestContext) { assert_eq!(silo3_pools.len(), 0); } -/// The internal IP pool, defined by its association with the internal silo, -/// cannot be interacted with through the operator API. CRUD operations should -/// all 404 except fetch by name or ID. -#[nexus_test] -async fn test_ip_pool_service_no_cud(cptestctx: &ControlPlaneTestContext) { - let client = &cptestctx.external_client; - - let internal_pool_name_url = - format!("/v1/system/ip-pools/{}", SERVICE_IPV4_POOL_NAME); - - // we can fetch the service pool by name or ID - let pool = NexusRequest::object_get(client, &internal_pool_name_url) - .authn_as(AuthnMode::PrivilegedUser) - .execute_and_parse_unwrap::() - .await; - - let internal_pool_id_url = - format!("/v1/system/ip-pools/{}", pool.identity.id); - let pool = NexusRequest::object_get(client, &internal_pool_id_url) - .authn_as(AuthnMode::PrivilegedUser) - .execute_and_parse_unwrap::() - .await; - - // but it does not come back in the list. there are none in the list - let pools = - objects_list_page_authz::(client, "/v1/system/ip-pools").await; - assert_eq!(pools.items.len(), 0); - - // deletes fail - - let error = object_delete_error( - client, - &internal_pool_name_url, - StatusCode::NOT_FOUND, - ) - .await; - let not_found_name = - "not found: ip-pool with name \"oxide-service-pool-v4\""; - assert_eq!(error.message, not_found_name); - - let not_found_id = - format!("not found: ip-pool with id \"{}\"", pool.identity.id); - let error = object_delete_error( - client, - &internal_pool_id_url, - StatusCode::NOT_FOUND, - ) - .await; - assert_eq!(error.message, not_found_id); - - // Update not allowed - let put_body = params::IpPoolUpdate { - identity: IdentityMetadataUpdateParams { - name: Some("test".parse().unwrap()), - description: Some("test".to_string()), - }, - }; - let error = object_put_error( - client, - &internal_pool_id_url, - &put_body, - StatusCode::NOT_FOUND, - ) - .await; - assert_eq!(error.message, not_found_id); - - let error = object_put_error( - client, - &internal_pool_name_url, - &put_body, - StatusCode::NOT_FOUND, - ) - .await; - assert_eq!(error.message, not_found_name); - - // add range not allowed by name or ID - let range = IpRange::V4( - Ipv4Range::new( - std::net::Ipv4Addr::new(10, 0, 0, 2), - std::net::Ipv4Addr::new(10, 0, 0, 5), - ) - .unwrap(), - ); - let url = format!("{}/ranges/add", internal_pool_id_url); - let error = - object_create_error(client, &url, &range, StatusCode::NOT_FOUND).await; - assert_eq!(error.message, not_found_id); - - let url = format!("{}/ranges/add", internal_pool_name_url); - let error = - object_create_error(client, &url, &range, StatusCode::NOT_FOUND).await; - assert_eq!(error.message, not_found_name); - - // remove range not allowed by name or ID - let url = format!("{}/ranges/add", internal_pool_id_url); - let error = - object_create_error(client, &url, &range, StatusCode::NOT_FOUND).await; - assert_eq!(error.message, not_found_id); - - let url = format!("{}/ranges/remove", internal_pool_name_url); - let error = - object_create_error(client, &url, &range, StatusCode::NOT_FOUND).await; - assert_eq!(error.message, not_found_name); - - // linking not allowed by name or ID - let body = params::IpPoolLinkSilo { - silo: NameOrId::Name(cptestctx.silo_name.clone()), - is_default: false, - }; - let url = format!("{}/silos", internal_pool_id_url); - let error = - object_create_error(client, &url, &body, StatusCode::NOT_FOUND).await; - assert_eq!(error.message, not_found_id); - - // unlink not allowed by name or ID - let url = format!("{}/silos/{}", internal_pool_id_url, INTERNAL_SILO_ID); - let error = object_delete_error(client, &url, StatusCode::NOT_FOUND).await; - assert_eq!(error.message, not_found_id); - - let url = format!("{}/silos/{}", internal_pool_name_url, INTERNAL_SILO_ID); - let error = object_delete_error(client, &url, StatusCode::NOT_FOUND).await; - assert_eq!(error.message, not_found_name); -} - #[nexus_test] async fn test_ip_pool_silo_link(cptestctx: &ControlPlaneTestContext) { let client = &cptestctx.external_client; @@ -809,7 +692,8 @@ async fn test_ip_pool_update_default(cptestctx: &ControlPlaneTestContext) { async fn test_ip_pool_pagination(cptestctx: &ControlPlaneTestContext) { let client = &cptestctx.external_client; let base_url = "/v1/system/ip-pools"; - let first_page = objects_list_page_authz::(client, &base_url).await; + let first_page = + objects_list_page_authz::(client, &base_url).await; // we start out with no pools assert_eq!(first_page.items.len(), 0); @@ -826,7 +710,7 @@ async fn test_ip_pool_pagination(cptestctx: &ControlPlaneTestContext) { let first_five_url = format!("{}?limit=5", base_url); let first_five = - objects_list_page_authz::(client, &first_five_url).await; + objects_list_page_authz::(client, &first_five_url).await; assert!(first_five.next_page.is_some()); assert_eq!(get_names(first_five.items), &pool_names[0..5]); @@ -836,7 +720,7 @@ async fn test_ip_pool_pagination(cptestctx: &ControlPlaneTestContext) { first_five.next_page.unwrap() ); let next_page = - objects_list_page_authz::(client, &next_page_url).await; + objects_list_page_authz::(client, &next_page_url).await; assert_eq!(get_names(next_page.items), &pool_names[5..8]); } @@ -889,7 +773,7 @@ async fn test_ip_pool_silos_pagination(cptestctx: &ControlPlaneTestContext) { } /// helper to make tests less ugly -fn get_names(pools: Vec) -> Vec { +fn get_names(pools: Vec) -> Vec { pools.iter().map(|p| p.identity.name.to_string()).collect() } @@ -901,15 +785,15 @@ async fn silos_for_pool( objects_list_page_authz::(client, &url).await } -async fn pools_for_silo( - client: &ClientTestContext, - silo: &str, -) -> Vec { +async fn pools_for_silo(client: &ClientTestContext, silo: &str) -> Vec { let url = format!("/v1/system/silos/{}/ip-pools", silo); - objects_list_page_authz::(client, &url).await.items + objects_list_page_authz::(client, &url).await.items } -async fn create_ipv4_pool(client: &ClientTestContext, name: &str) -> IpPool { +async fn create_ipv4_pool( + client: &ClientTestContext, + name: &str, +) -> SystemIpPool { create_pool(client, name, IpVersion::V4).await } @@ -917,14 +801,16 @@ async fn create_pool( client: &ClientTestContext, name: &str, ip_version: IpVersion, -) -> IpPool { - let params = IpPoolCreate::new( - IdentityMetadataCreateParams { +) -> SystemIpPool { + let params = IpPoolCreate { + identity: IdentityMetadataCreateParams { name: Name::try_from(name.to_string()).unwrap(), description: "".to_string(), }, ip_version, - ); + pool_type: IpPoolType::Unicast, + reservation_type: IpPoolReservationType::ExternalSilos, + }; NexusRequest::objects_post(client, "/v1/system/ip-pools", ¶ms) .authn_as(AuthnMode::PrivilegedUser) .execute() @@ -1002,7 +888,6 @@ async fn test_ipv6_ip_pool_utilization_total( let by_id = NameOrId::Id(pool.id()); let (authz_pool, db_pool) = nexus .ip_pool_lookup(&opctx, &by_id) - .expect("should be able to lookup pool we just created") .fetch_for(authz::Action::CreateChild) .await .expect("should be able to fetch pool we just created"); @@ -1046,19 +931,24 @@ async fn test_ip_pool_range_overlapping_ranges_fails( let ip_pool_add_range_url = format!("{}/add", ip_pool_ranges_url); // Create the pool, verify basic properties - let params = IpPoolCreate::new( - IdentityMetadataCreateParams { + let params = IpPoolCreate { + identity: IdentityMetadataCreateParams { name: String::from(pool_name).parse().unwrap(), description: String::from(description), }, - IpVersion::V4, - ); - - let created_pool: IpPool = + ip_version: IpVersion::V4, + pool_type: IpPoolType::Unicast, + reservation_type: IpPoolReservationType::ExternalSilos, + }; + let created_pool: SystemIpPool = object_create(client, ip_pools_url, ¶ms).await; assert_eq!(created_pool.identity.name, pool_name); assert_eq!(created_pool.identity.description, description); assert_eq!(created_pool.ip_version, IpVersion::V4); + assert_eq!( + created_pool.reservation_type, + IpPoolReservationType::ExternalSilos + ); // Test data for IPv4 ranges that should fail due to overlap let ipv4_range = TestRange { @@ -1186,13 +1076,6 @@ async fn test_ip_pool_range_rejects_v6(cptestctx: &ControlPlaneTestContext) { .await; assert_eq!(error.message, "IPv6 ranges are not allowed yet"); - - // same deal with service pool - let add_url = "/v1/system/ip-pools-service/ranges/add"; - let error = - object_create_error(client, add_url, &range, StatusCode::BAD_REQUEST) - .await; - assert_eq!(error.message, "IPv6 ranges are not allowed yet"); } #[nexus_test] @@ -1206,18 +1089,24 @@ async fn test_ip_pool_range_pagination(cptestctx: &ControlPlaneTestContext) { let ip_pool_add_range_url = format!("{}/add", ip_pool_ranges_url); // Create the pool, verify basic properties - let params = IpPoolCreate::new( - IdentityMetadataCreateParams { + let params = IpPoolCreate { + identity: IdentityMetadataCreateParams { name: String::from(pool_name).parse().unwrap(), description: String::from(description), }, - IpVersion::V4, - ); - let created_pool: IpPool = + ip_version: IpVersion::V4, + pool_type: IpPoolType::Unicast, + reservation_type: IpPoolReservationType::ExternalSilos, + }; + let created_pool: SystemIpPool = object_create(client, ip_pools_url, ¶ms).await; assert_eq!(created_pool.identity.name, pool_name); assert_eq!(created_pool.identity.description, description); assert_eq!(created_pool.ip_version, IpVersion::V4); + assert_eq!( + created_pool.reservation_type, + IpPoolReservationType::ExternalSilos + ); // Add some ranges, out of order. These will be paginated by their first // address, which sorts all IPv4 before IPv6, then within protocol versions @@ -1338,7 +1227,7 @@ async fn test_ip_pool_list_in_silo(cptestctx: &ControlPlaneTestContext) { let list = NexusRequest::object_get(client, "/v1/ip-pools") .authn_as(AuthnMode::SiloUser(user.id)) - .execute_and_parse_unwrap::>() + .execute_and_parse_unwrap::>() .await .items; @@ -1352,7 +1241,7 @@ async fn test_ip_pool_list_in_silo(cptestctx: &ControlPlaneTestContext) { let url = format!("/v1/ip-pools/{}", default_name); let pool = NexusRequest::object_get(client, &url) .authn_as(AuthnMode::SiloUser(user.id)) - .execute_and_parse_unwrap::() + .execute_and_parse_unwrap::() .await; assert_eq!(pool.identity.name.as_str(), default_name); assert!(pool.is_default); @@ -1360,7 +1249,7 @@ async fn test_ip_pool_list_in_silo(cptestctx: &ControlPlaneTestContext) { let url = format!("/v1/ip-pools/{}", other_name); let pool = NexusRequest::object_get(client, &url) .authn_as(AuthnMode::SiloUser(user.id)) - .execute_and_parse_unwrap::() + .execute_and_parse_unwrap::() .await; assert_eq!(pool.identity.name.as_str(), other_name); assert!(!pool.is_default); @@ -1503,115 +1392,7 @@ async fn test_ip_range_delete_with_allocated_external_ip_fails( ); } -#[nexus_test] -async fn test_ip_pool_service(cptestctx: &ControlPlaneTestContext) { - let client = &cptestctx.external_client; - let ip_pool_url = "/v1/system/ip-pools-service".to_string(); - let ip_pool_ranges_url = format!("{}/ranges", ip_pool_url); - let ip_pool_add_range_url = format!("{}/add", ip_pool_ranges_url); - let ip_pool_remove_range_url = format!("{}/remove", ip_pool_ranges_url); - - // View the pool, which should exist without explicit creation. - let fetched_pool: IpPool = NexusRequest::object_get(client, &ip_pool_url) - .authn_as(AuthnMode::PrivilegedUser) - .execute() - .await - .unwrap() - .parsed_body() - .unwrap(); - assert_eq!( - fetched_pool.identity.name, - nexus_db_queries::db::datastore::SERVICE_IPV4_POOL_NAME - ); - assert_eq!( - fetched_pool.identity.description, - "IPv4 IP Pool for Oxide Services" - ); - - // Fetch any ranges already present. - let existing_ranges = - objects_list_page_authz::(client, &ip_pool_ranges_url) - .await - .items; - - // Add some ranges. Pagination is tested more explicitly in the IP pool - // implementation, but we just check that these endpoints work here. - let ranges = [ - IpRange::V4( - Ipv4Range::new( - std::net::Ipv4Addr::new(10, 0, 0, 3), - std::net::Ipv4Addr::new(10, 0, 0, 4), - ) - .unwrap(), - ), - IpRange::V4( - Ipv4Range::new( - std::net::Ipv4Addr::new(10, 0, 0, 1), - std::net::Ipv4Addr::new(10, 0, 0, 2), - ) - .unwrap(), - ), - ]; - - let mut expected_ranges = existing_ranges.clone(); - for range in ranges.iter() { - let created_range: IpPoolRange = - NexusRequest::objects_post(client, &ip_pool_add_range_url, &range) - .authn_as(AuthnMode::PrivilegedUser) - .execute() - .await - .unwrap() - .parsed_body() - .unwrap(); - assert_eq!(range.first_address(), created_range.range.first_address()); - assert_eq!(range.last_address(), created_range.range.last_address()); - expected_ranges.push(created_range); - } - expected_ranges - .sort_by(|a, b| a.range.first_address().cmp(&b.range.first_address())); - - // List the ranges. - let first_page = - objects_list_page_authz::(client, &ip_pool_ranges_url) - .await; - assert_eq!(first_page.items.len(), expected_ranges.len()); - - let actual_ranges = first_page.items.iter(); - for (expected_range, actual_range) in - expected_ranges.iter().zip(actual_ranges) - { - assert_ranges_eq(expected_range, actual_range); - } - - // Remove both ranges, observe that the IP Pool is empty. - for range in ranges.iter() { - NexusRequest::new( - RequestBuilder::new( - client, - Method::POST, - &ip_pool_remove_range_url, - ) - .body(Some(&range)) - .expect_status(Some(StatusCode::NO_CONTENT)), - ) - .authn_as(AuthnMode::PrivilegedUser) - .execute() - .await - .expect("Failed to delete IP range from a pool"); - } - - let first_page = - objects_list_page_authz::(client, &ip_pool_ranges_url) - .await; - assert_eq!(first_page.items.len(), existing_ranges.len()); - for (expected_range, actual_range) in - existing_ranges.iter().zip(first_page.items.iter()) - { - assert_ranges_eq(expected_range, actual_range); - } -} - -fn assert_pools_eq(first: &IpPool, second: &IpPool) { +fn assert_pools_eq(first: &SystemIpPool, second: &SystemIpPool) { assert_eq!(first.identity, second.identity); assert_eq!(first.ip_version, second.ip_version); } @@ -1638,8 +1419,9 @@ async fn test_ip_pool_unicast_defaults(cptestctx: &ControlPlaneTestContext) { description: "Explicit unicast pool".to_string(), }, IpVersion::V4, + IpPoolReservationType::ExternalSilos, ); - let pool: IpPool = + let pool: SystemIpPool = object_create(client, "/v1/system/ip-pools", ¶ms).await; assert_eq!(pool.pool_type, IpPoolType::Unicast); } diff --git a/nexus/tests/integration_tests/silos.rs b/nexus/tests/integration_tests/silos.rs index a2866c5f506..82c7da0a5fd 100644 --- a/nexus/tests/integration_tests/silos.rs +++ b/nexus/tests/integration_tests/silos.rs @@ -2705,7 +2705,8 @@ async fn test_silo_delete_cleans_up_ip_pool_links( // but the pools are of course still there let url = "/v1/system/ip-pools"; - let pools = objects_list_page_authz::(client, &url).await; + let pools = + objects_list_page_authz::(client, &url).await; assert_eq!(pools.items.len(), 2); assert_eq!(pools.items[0].identity.name, "pool1"); assert_eq!(pools.items[1].identity.name, "pool2"); diff --git a/nexus/tests/integration_tests/unauthorized.rs b/nexus/tests/integration_tests/unauthorized.rs index 0969874fe7d..aaae2facbba 100644 --- a/nexus/tests/integration_tests/unauthorized.rs +++ b/nexus/tests/integration_tests/unauthorized.rs @@ -258,9 +258,9 @@ static SETUP_REQUESTS: LazyLock> = LazyLock::new(|| { }, // Create the default IP pool SetupReq::Post { - url: &DEMO_IP_POOLS_URL, + url: &DEMO_SYSTEM_IP_POOLS_URL, body: serde_json::to_value(&*DEMO_IP_POOL_CREATE).unwrap(), - id_routes: vec!["/v1/ip-pools/{id}"], + id_routes: vec!["/v1/system/ip-pools/{id}"], }, // Create an IP pool range SetupReq::Post { diff --git a/nexus/types/src/external_api/params.rs b/nexus/types/src/external_api/params.rs index a285d863d05..908475caf46 100644 --- a/nexus/types/src/external_api/params.rs +++ b/nexus/types/src/external_api/params.rs @@ -6,6 +6,7 @@ //! resources. use crate::external_api::shared; +use crate::external_api::shared::IpPoolReservationType; use base64::Engine; use chrono::{DateTime, Utc}; use http::Uri; @@ -1017,6 +1018,11 @@ pub struct IpPoolCreate { /// Type of IP pool (defaults to Unicast) #[serde(default)] pub pool_type: shared::IpPoolType, + /// Which resources the IP Pool is reserved for. + /// + /// The default is reserved for external silos. + #[serde(default = "IpPoolReservationType::external_silos")] + pub reservation_type: IpPoolReservationType, } impl IpPoolCreate { @@ -1024,16 +1030,28 @@ impl IpPoolCreate { pub fn new( identity: IdentityMetadataCreateParams, ip_version: IpVersion, + reservation_type: IpPoolReservationType, ) -> Self { - Self { identity, ip_version, pool_type: shared::IpPoolType::Unicast } + Self { + identity, + ip_version, + pool_type: shared::IpPoolType::Unicast, + reservation_type, + } } /// Create parameters for a multicast IP pool pub fn new_multicast( identity: IdentityMetadataCreateParams, ip_version: IpVersion, + reservation_type: IpPoolReservationType, ) -> Self { - Self { identity, ip_version, pool_type: shared::IpPoolType::Multicast } + Self { + identity, + ip_version, + pool_type: shared::IpPoolType::Multicast, + reservation_type, + } } } @@ -1044,6 +1062,13 @@ pub struct IpPoolUpdate { pub identity: IdentityMetadataUpdateParams, } +/// Parameters for modifying the reservation type of an IP Pool. +#[derive(Clone, Copy, Debug, Deserialize, JsonSchema, Serialize)] +pub struct IpPoolReservationUpdate { + /// What resources an IP Pool is reserved for. + pub reservation_type: IpPoolReservationType, +} + #[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] pub struct IpPoolSiloPath { pub pool: NameOrId, diff --git a/nexus/types/src/external_api/shared.rs b/nexus/types/src/external_api/shared.rs index 051bd3fe7dc..199d6f45504 100644 --- a/nexus/types/src/external_api/shared.rs +++ b/nexus/types/src/external_api/shared.rs @@ -743,6 +743,22 @@ impl RelayState { } } +/// Indicates what resources an IP Pool is reserved for. +#[derive(Clone, Copy, Debug, Deserialize, JsonSchema, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum IpPoolReservationType { + /// The pool is reserved for use by external customer Silos. + ExternalSilos, + /// The pool is reserved for Oxide internal use. + OxideInternal, +} + +impl IpPoolReservationType { + pub const fn external_silos() -> Self { + Self::ExternalSilos + } +} + /// Type of IP pool. #[derive(Clone, Debug, Deserialize, Serialize, JsonSchema, PartialEq)] #[serde(rename_all = "snake_case")] diff --git a/nexus/types/src/external_api/views.rs b/nexus/types/src/external_api/views.rs index 397265204da..bc000fcc2c2 100644 --- a/nexus/types/src/external_api/views.rs +++ b/nexus/types/src/external_api/views.rs @@ -388,15 +388,18 @@ pub struct InternetGatewayIpAddress { // IP POOLS /// A collection of IP ranges. If a pool is linked to a silo, IP addresses from -/// the pool can be allocated within that silo +/// the pool can be allocated within that silo. IP Pools may also be delegated +/// for internal use by Oxide, such as running API servers or NTP daemons. #[derive(ObjectIdentity, Clone, Debug, Deserialize, Serialize, JsonSchema)] -pub struct IpPool { +pub struct SystemIpPool { #[serde(flatten)] pub identity: IdentityMetadata, /// The IP version for the pool. pub ip_version: IpVersion, /// Type of IP pool (unicast or multicast) pub pool_type: shared::IpPoolType, + /// Resources the IP Pool is reserved for. + pub reservation_type: shared::IpPoolReservationType, } /// The utilization of IP addresses in a pool. @@ -417,7 +420,7 @@ pub struct IpPoolUtilization { /// An IP pool in the context of a silo #[derive(ObjectIdentity, Clone, Debug, Deserialize, Serialize, JsonSchema)] -pub struct SiloIpPool { +pub struct IpPool { #[serde(flatten)] pub identity: IdentityMetadata, @@ -425,6 +428,9 @@ pub struct SiloIpPool { /// ephemeral IPs will come from that pool when no other pool is specified. /// There can be at most one default for a given silo. pub is_default: bool, + + /// The IP version of the pool. + pub ip_version: IpVersion, } /// A link between an IP pool and a silo that allows one to allocate IPs from diff --git a/openapi/nexus.json b/openapi/nexus.json index 6a028c417c3..1a3f5134667 100644 --- a/openapi/nexus.json +++ b/openapi/nexus.json @@ -5286,7 +5286,7 @@ "projects" ], "summary": "List IP pools", - "operationId": "project_ip_pool_list", + "operationId": "ip_pool_list", "parameters": [ { "in": "query", @@ -5322,7 +5322,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SiloIpPoolResultsPage" + "$ref": "#/components/schemas/IpPoolResultsPage" } } } @@ -5345,7 +5345,7 @@ "projects" ], "summary": "Fetch IP pool", - "operationId": "project_ip_pool_view", + "operationId": "ip_pool_view", "parameters": [ { "in": "path", @@ -5363,7 +5363,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SiloIpPool" + "$ref": "#/components/schemas/IpPool" } } } @@ -8247,7 +8247,7 @@ "system/ip-pools" ], "summary": "List IP pools", - "operationId": "ip_pool_list", + "operationId": "system_ip_pool_list", "parameters": [ { "in": "query", @@ -8283,7 +8283,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/IpPoolResultsPage" + "$ref": "#/components/schemas/SystemIpPoolResultsPage" } } } @@ -8305,7 +8305,7 @@ ], "summary": "Create IP pool", "description": "IPv6 is not yet supported for unicast pools.", - "operationId": "ip_pool_create", + "operationId": "system_ip_pool_create", "requestBody": { "content": { "application/json": { @@ -8322,7 +8322,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/IpPool" + "$ref": "#/components/schemas/SystemIpPool" } } } @@ -8342,7 +8342,7 @@ "system/ip-pools" ], "summary": "Fetch IP pool", - "operationId": "ip_pool_view", + "operationId": "system_ip_pool_view", "parameters": [ { "in": "path", @@ -8360,7 +8360,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/IpPool" + "$ref": "#/components/schemas/SystemIpPool" } } } @@ -8378,7 +8378,7 @@ "system/ip-pools" ], "summary": "Update IP pool", - "operationId": "ip_pool_update", + "operationId": "system_ip_pool_update", "parameters": [ { "in": "path", @@ -8406,7 +8406,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/IpPool" + "$ref": "#/components/schemas/SystemIpPool" } } } @@ -8424,7 +8424,7 @@ "system/ip-pools" ], "summary": "Delete IP pool", - "operationId": "ip_pool_delete", + "operationId": "system_ip_pool_delete", "parameters": [ { "in": "path", @@ -8456,7 +8456,7 @@ ], "summary": "List ranges for IP pool", "description": "Ranges are ordered by their first address.", - "operationId": "ip_pool_range_list", + "operationId": "system_ip_pool_range_list", "parameters": [ { "in": "path", @@ -8518,7 +8518,7 @@ ], "summary": "Add range to IP pool.", "description": "IPv6 ranges are not allowed yet for unicast pools.\n\nFor multicast pools, all ranges must be either Any-Source Multicast (ASM) or Source-Specific Multicast (SSM), but not both. Mixing ASM and SSM ranges in the same pool is not allowed.\n\nASM: IPv4 addresses outside 232.0.0.0/8, IPv6 addresses with flag field != 3 SSM: IPv4 addresses in 232.0.0.0/8, IPv6 addresses with flag field = 3", - "operationId": "ip_pool_range_add", + "operationId": "system_ip_pool_range_add", "parameters": [ { "in": "path", @@ -8566,7 +8566,7 @@ "system/ip-pools" ], "summary": "Remove range from IP pool", - "operationId": "ip_pool_range_remove", + "operationId": "system_ip_pool_range_remove", "parameters": [ { "in": "path", @@ -8601,13 +8601,54 @@ } } }, + "/v1/system/ip-pools/{pool}/reserve": { + "post": { + "tags": [ + "system/ip-pools" + ], + "summary": "Reserve an IP Pool for use by specific resources.", + "operationId": "system_ip_pool_reserve", + "parameters": [ + { + "in": "path", + "name": "pool", + "description": "Name or ID of the IP pool", + "required": true, + "schema": { + "$ref": "#/components/schemas/NameOrId" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IpPoolReservationUpdate" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, "/v1/system/ip-pools/{pool}/silos": { "get": { "tags": [ "system/ip-pools" ], "summary": "List IP pool's linked silos", - "operationId": "ip_pool_silo_list", + "operationId": "system_ip_pool_silo_list", "parameters": [ { "in": "path", @@ -8674,7 +8715,7 @@ ], "summary": "Link IP pool to silo", "description": "Users in linked silos can allocate external IPs from this pool for their instances. A silo can have at most one default pool. IPs are allocated from the default pool when users ask for one without specifying a pool.", - "operationId": "ip_pool_silo_link", + "operationId": "system_ip_pool_silo_link", "parameters": [ { "in": "path", @@ -8723,7 +8764,7 @@ ], "summary": "Make IP pool default for silo", "description": "When a user asks for an IP (e.g., at instance create time) without specifying a pool, the IP comes from the default pool if a default is configured. When a pool is made the default for a silo, any existing default will remain linked to the silo, but will no longer be the default.", - "operationId": "ip_pool_silo_update", + "operationId": "system_ip_pool_silo_update", "parameters": [ { "in": "path", @@ -8777,7 +8818,7 @@ ], "summary": "Unlink IP pool from silo", "description": "Will fail if there are any outstanding IPs allocated in the silo.", - "operationId": "ip_pool_silo_unlink", + "operationId": "system_ip_pool_silo_unlink", "parameters": [ { "in": "path", @@ -8815,7 +8856,7 @@ "system/ip-pools" ], "summary": "Fetch IP pool utilization", - "operationId": "ip_pool_utilization_view", + "operationId": "system_ip_pool_utilization_view", "parameters": [ { "in": "path", @@ -8847,154 +8888,6 @@ } } }, - "/v1/system/ip-pools-service": { - "get": { - "tags": [ - "system/ip-pools" - ], - "summary": "Fetch Oxide service IP pool", - "operationId": "ip_pool_service_view", - "responses": { - "200": { - "description": "successful operation", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/IpPool" - } - } - } - }, - "4XX": { - "$ref": "#/components/responses/Error" - }, - "5XX": { - "$ref": "#/components/responses/Error" - } - } - } - }, - "/v1/system/ip-pools-service/ranges": { - "get": { - "tags": [ - "system/ip-pools" - ], - "summary": "List IP ranges for the Oxide service pool", - "description": "Ranges are ordered by their first address.", - "operationId": "ip_pool_service_range_list", - "parameters": [ - { - "in": "query", - "name": "limit", - "description": "Maximum number of items returned by a single call", - "schema": { - "nullable": true, - "type": "integer", - "format": "uint32", - "minimum": 1 - } - }, - { - "in": "query", - "name": "page_token", - "description": "Token returned by previous call to retrieve the subsequent page", - "schema": { - "nullable": true, - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "successful operation", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/IpPoolRangeResultsPage" - } - } - } - }, - "4XX": { - "$ref": "#/components/responses/Error" - }, - "5XX": { - "$ref": "#/components/responses/Error" - } - }, - "x-dropshot-pagination": { - "required": [] - } - } - }, - "/v1/system/ip-pools-service/ranges/add": { - "post": { - "tags": [ - "system/ip-pools" - ], - "summary": "Add IP range to Oxide service pool", - "description": "IPv6 ranges are not allowed yet.", - "operationId": "ip_pool_service_range_add", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/IpRange" - } - } - }, - "required": true - }, - "responses": { - "201": { - "description": "successful creation", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/IpPoolRange" - } - } - } - }, - "4XX": { - "$ref": "#/components/responses/Error" - }, - "5XX": { - "$ref": "#/components/responses/Error" - } - } - } - }, - "/v1/system/ip-pools-service/ranges/remove": { - "post": { - "tags": [ - "system/ip-pools" - ], - "summary": "Remove IP range from Oxide service pool", - "operationId": "ip_pool_service_range_remove", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/IpRange" - } - } - }, - "required": true - }, - "responses": { - "204": { - "description": "resource updated" - }, - "4XX": { - "$ref": "#/components/responses/Error" - }, - "5XX": { - "$ref": "#/components/responses/Error" - } - } - } - }, "/v1/system/metrics/{metric_name}": { "get": { "tags": [ @@ -10825,7 +10718,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SiloIpPoolResultsPage" + "$ref": "#/components/schemas/IpPoolResultsPage" } } } @@ -21453,7 +21346,7 @@ ] }, "IpPool": { - "description": "A collection of IP ranges. If a pool is linked to a silo, IP addresses from the pool can be allocated within that silo", + "description": "An IP pool in the context of a silo", "type": "object", "properties": { "description": { @@ -21466,13 +21359,17 @@ "format": "uuid" }, "ip_version": { - "description": "The IP version for the pool.", + "description": "The IP version of the pool.", "allOf": [ { "$ref": "#/components/schemas/IpVersion" } ] }, + "is_default": { + "description": "When a pool is the default for a silo, floating IPs and instance ephemeral IPs will come from that pool when no other pool is specified. There can be at most one default for a given silo.", + "type": "boolean" + }, "name": { "description": "unique, mutable, user-controlled identifier for each resource", "allOf": [ @@ -21481,14 +21378,6 @@ } ] }, - "pool_type": { - "description": "Type of IP pool (unicast or multicast)", - "allOf": [ - { - "$ref": "#/components/schemas/IpPoolType" - } - ] - }, "time_created": { "description": "timestamp when this resource was created", "type": "string", @@ -21504,8 +21393,8 @@ "description", "id", "ip_version", + "is_default", "name", - "pool_type", "time_created", "time_modified" ] @@ -21537,6 +21426,15 @@ "$ref": "#/components/schemas/IpPoolType" } ] + }, + "reservation_type": { + "description": "Which resources the IP Pool is reserved for.\n\nThe default is reserved for external silos.", + "default": "external_silos", + "allOf": [ + { + "$ref": "#/components/schemas/IpPoolReservationType" + } + ] } }, "required": [ @@ -21607,6 +21505,42 @@ "items" ] }, + "IpPoolReservationType": { + "description": "Indicates what resources an IP Pool is reserved for.", + "oneOf": [ + { + "description": "The pool is reserved for use by external customer Silos.", + "type": "string", + "enum": [ + "external_silos" + ] + }, + { + "description": "The pool is reserved for Oxide internal use.", + "type": "string", + "enum": [ + "oxide_internal" + ] + } + ] + }, + "IpPoolReservationUpdate": { + "description": "Parameters for modifying the reservation type of an IP Pool.", + "type": "object", + "properties": { + "reservation_type": { + "description": "What resources an IP Pool is reserved for.", + "allOf": [ + { + "$ref": "#/components/schemas/IpPoolReservationType" + } + ] + } + }, + "required": [ + "reservation_type" + ] + }, "IpPoolResultsPage": { "description": "A single page of results", "type": "object", @@ -24098,72 +24032,6 @@ } ] }, - "SiloIpPool": { - "description": "An IP pool in the context of a silo", - "type": "object", - "properties": { - "description": { - "description": "human-readable free-form text about a resource", - "type": "string" - }, - "id": { - "description": "unique, immutable, system-controlled identifier for each resource", - "type": "string", - "format": "uuid" - }, - "is_default": { - "description": "When a pool is the default for a silo, floating IPs and instance ephemeral IPs will come from that pool when no other pool is specified. There can be at most one default for a given silo.", - "type": "boolean" - }, - "name": { - "description": "unique, mutable, user-controlled identifier for each resource", - "allOf": [ - { - "$ref": "#/components/schemas/Name" - } - ] - }, - "time_created": { - "description": "timestamp when this resource was created", - "type": "string", - "format": "date-time" - }, - "time_modified": { - "description": "timestamp when this resource was last modified", - "type": "string", - "format": "date-time" - } - }, - "required": [ - "description", - "id", - "is_default", - "name", - "time_created", - "time_modified" - ] - }, - "SiloIpPoolResultsPage": { - "description": "A single page of results", - "type": "object", - "properties": { - "items": { - "description": "list of items on this page of results", - "type": "array", - "items": { - "$ref": "#/components/schemas/SiloIpPool" - } - }, - "next_page": { - "nullable": true, - "description": "token used to fetch the next page of results (if any)", - "type": "string" - } - }, - "required": [ - "items" - ] - }, "SiloQuotas": { "description": "A collection of resource counts used to set the virtual capacity of a silo", "type": "object", @@ -25916,6 +25784,94 @@ "vlan_id" ] }, + "SystemIpPool": { + "description": "A collection of IP ranges. If a pool is linked to a silo, IP addresses from the pool can be allocated within that silo. IP Pools may also be delegated for internal use by Oxide, such as running API servers or NTP daemons.", + "type": "object", + "properties": { + "description": { + "description": "human-readable free-form text about a resource", + "type": "string" + }, + "id": { + "description": "unique, immutable, system-controlled identifier for each resource", + "type": "string", + "format": "uuid" + }, + "ip_version": { + "description": "The IP version for the pool.", + "allOf": [ + { + "$ref": "#/components/schemas/IpVersion" + } + ] + }, + "name": { + "description": "unique, mutable, user-controlled identifier for each resource", + "allOf": [ + { + "$ref": "#/components/schemas/Name" + } + ] + }, + "pool_type": { + "description": "Type of IP pool (unicast or multicast)", + "allOf": [ + { + "$ref": "#/components/schemas/IpPoolType" + } + ] + }, + "reservation_type": { + "description": "Resources the IP Pool is reserved for.", + "allOf": [ + { + "$ref": "#/components/schemas/IpPoolReservationType" + } + ] + }, + "time_created": { + "description": "timestamp when this resource was created", + "type": "string", + "format": "date-time" + }, + "time_modified": { + "description": "timestamp when this resource was last modified", + "type": "string", + "format": "date-time" + } + }, + "required": [ + "description", + "id", + "ip_version", + "name", + "pool_type", + "reservation_type", + "time_created", + "time_modified" + ] + }, + "SystemIpPoolResultsPage": { + "description": "A single page of results", + "type": "object", + "properties": { + "items": { + "description": "list of items on this page of results", + "type": "array", + "items": { + "$ref": "#/components/schemas/SystemIpPool" + } + }, + "next_page": { + "nullable": true, + "description": "token used to fetch the next page of results (if any)", + "type": "string" + } + }, + "required": [ + "items" + ] + }, "TargetRelease": { "description": "View of a system software target release", "type": "object",