diff --git a/nexus/db-queries/src/db/datastore/ip_pool.rs b/nexus/db-queries/src/db/datastore/ip_pool.rs index b2849277603..b0d88db0f1d 100644 --- a/nexus/db-queries/src/db/datastore/ip_pool.rs +++ b/nexus/db-queries/src/db/datastore/ip_pool.rs @@ -113,6 +113,12 @@ const LAST_POOL_ERROR: &str = "Cannot delete the last IP Pool reserved for \ Oxide internal usage. Create and reserve at least one more IP Pool \ before deleting this one."; +#[derive(Clone, Copy, Debug, Default)] +pub struct IpPoolListFilters { + pub ip_version: Option, + pub delegated_for_internal_use: Option, +} + impl DataStore { /// List IP Pools by their reservation type, IP version, and pool type. pub async fn ip_pools_list_paginated( @@ -161,15 +167,50 @@ impl DataStore { &self, opctx: &OpContext, pagparams: &PaginatedBy<'_>, + filters: &IpPoolListFilters, ) -> ListResultVec { - self.ip_pools_list_paginated( - opctx, - IpPoolReservationType::ExternalSilos, - None, - None, - pagparams, - ) - .await + use nexus_db_schema::schema::ip_pool; + + opctx + .authorize(authz::Action::ListChildren, &authz::IP_POOL_LIST) + .await?; + let mut query = match pagparams { + PaginatedBy::Id(pagparams) => { + paginated(ip_pool::table, ip_pool::id, pagparams) + } + PaginatedBy::Name(pagparams) => paginated( + ip_pool::table, + ip_pool::name, + &pagparams.map_name(|n| Name::ref_cast(n)), + ), + }; + + query = query.filter(ip_pool::time_deleted.is_null()); + + if let Some(ip_version) = filters.ip_version { + query = query.filter(ip_pool::ip_version.eq(ip_version)); + } + + match filters.delegated_for_internal_use { + Some(true) => { + query = query.filter( + ip_pool::name + .eq(SERVICE_IPV4_POOL_NAME) + .or(ip_pool::name.eq(SERVICE_IPV6_POOL_NAME)), + ); + } + Some(false) | None => { + query = query + .filter(ip_pool::name.ne(SERVICE_IPV4_POOL_NAME)) + .filter(ip_pool::name.ne(SERVICE_IPV6_POOL_NAME)); + } + } + + query + .select(IpPool::as_select()) + .get_results_async(&*self.pool_connection_authorized(opctx).await?) + .await + .map_err(|e| public_error_from_diesel(e, ErrorHandler::Server)) } /// List Multicast IP Pools (external silos) @@ -1962,6 +2003,7 @@ mod test { use std::net::{Ipv4Addr, Ipv6Addr}; use std::num::NonZeroU32; + use super::IpPoolListFilters; use crate::authz; use crate::db::datastore::ip_pool::{ BAD_SILO_LINK_ERROR, LAST_POOL_ERROR, POOL_HAS_IPS_ERROR, @@ -2019,7 +2061,7 @@ mod test { let pagbyid = PaginatedBy::Id(pagparams_id); let all_pools = datastore - .ip_pools_list(&opctx, &pagbyid) + .ip_pools_list(&opctx, &pagbyid, &IpPoolListFilters::default()) .await .expect("Should list IP pools"); assert_eq!(all_pools.len(), 0); @@ -2054,7 +2096,7 @@ mod test { // shows up in full list but not silo list let all_pools = datastore - .ip_pools_list(&opctx, &pagbyid) + .ip_pools_list(&opctx, &pagbyid, &IpPoolListFilters::default()) .await .expect("Should list IP pools"); assert_eq!(all_pools.len(), 1); diff --git a/nexus/db-queries/src/db/datastore/mod.rs b/nexus/db-queries/src/db/datastore/mod.rs index 3667323d493..ddb799897bb 100644 --- a/nexus/db-queries/src/db/datastore/mod.rs +++ b/nexus/db-queries/src/db/datastore/mod.rs @@ -136,6 +136,7 @@ pub use instance::{ InstanceAndActiveVmm, InstanceGestalt, InstanceStateComputer, }; pub use inventory::DataStoreInventoryTest; +pub use ip_pool::IpPoolListFilters; use nexus_db_model::AllSchemaVersions; use nexus_types::internal_api::views::HeldDbClaimInfo; pub use oximeter::CollectorReassignment; diff --git a/nexus/external-api/src/lib.rs b/nexus/external-api/src/lib.rs index 4e8c144c2a6..4046c03d602 100644 --- a/nexus/external-api/src/lib.rs +++ b/nexus/external-api/src/lib.rs @@ -911,7 +911,7 @@ pub trait NexusExternalApi { }] async fn ip_pool_list( rqctx: RequestContext, - query_params: Query, + query_params: Query>, ) -> Result>, HttpError>; /// Create IP pool diff --git a/nexus/src/app/ip_pool.rs b/nexus/src/app/ip_pool.rs index 1ef941cb735..4447765f913 100644 --- a/nexus/src/app/ip_pool.rs +++ b/nexus/src/app/ip_pool.rs @@ -18,6 +18,7 @@ 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::datastore::IpPoolListFilters; use nexus_db_queries::db::model::Name; use nexus_types::identity::Resource; use omicron_common::address::{IPV4_SSM_SUBNET, IPV6_SSM_SUBNET}; @@ -271,8 +272,13 @@ impl super::Nexus { &self, opctx: &OpContext, pagparams: &PaginatedBy<'_>, + selector: ¶ms::IpPoolListSelector, ) -> ListResultVec { - self.db_datastore.ip_pools_list(opctx, pagparams).await + let filters = IpPoolListFilters { + ip_version: selector.ip_version.map(Into::into), + delegated_for_internal_use: selector.delegated_for_internal_use, + }; + self.db_datastore.ip_pools_list(opctx, pagparams, &filters).await } pub(crate) async fn ip_pool_delete( diff --git a/nexus/src/external_api/http_entrypoints.rs b/nexus/src/external_api/http_entrypoints.rs index c6aeabc60a5..ad69ae9e099 100644 --- a/nexus/src/external_api/http_entrypoints.rs +++ b/nexus/src/external_api/http_entrypoints.rs @@ -1684,7 +1684,7 @@ impl NexusExternalApi for NexusExternalApiImpl { async fn ip_pool_list( rqctx: RequestContext, - query_params: Query, + query_params: Query>, ) -> Result>, HttpError> { let apictx = rqctx.context(); let handler = async { @@ -1692,11 +1692,12 @@ impl NexusExternalApi for NexusExternalApiImpl { let query = query_params.into_inner(); let pag_params = data_page_params_for(&rqctx, &query)?; let scan_params = ScanByNameOrId::from_query(&query)?; + let filters = scan_params.selector.clone(); let paginated_by = name_or_id_pagination(&pag_params, scan_params)?; let opctx = crate::context::op_context_for_external_api(&rqctx).await?; let pools = nexus - .ip_pools_list(&opctx, &paginated_by) + .ip_pools_list(&opctx, &paginated_by, &filters) .await? .into_iter() .map(IpPool::from) diff --git a/nexus/tests/integration_tests/ip_pools.rs b/nexus/tests/integration_tests/ip_pools.rs index e8eec6b9fdf..f43543e9ab7 100644 --- a/nexus/tests/integration_tests/ip_pools.rs +++ b/nexus/tests/integration_tests/ip_pools.rs @@ -15,7 +15,9 @@ 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::datastore::{ + SERVICE_IPV4_POOL_NAME, SERVICE_IPV6_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; @@ -225,11 +227,14 @@ 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 { +async fn get_ip_pools_with_params( + client: &ClientTestContext, + params: &str, +) -> Vec { NexusRequest::iter_collection_authn::( client, "/v1/system/ip-pools", - "", + params, None, ) .await @@ -237,6 +242,10 @@ async fn get_ip_pools(client: &ClientTestContext) -> Vec { .all_items } +async fn get_ip_pools(client: &ClientTestContext) -> Vec { + get_ip_pools_with_params(client, "").await +} + // this test exists primarily because of a bug in the initial implementation // where we included a duplicate of each pool in the list response for every // associated silo @@ -803,6 +812,85 @@ async fn test_ip_pool_update_default(cptestctx: &ControlPlaneTestContext) { assert_eq!(silos_p1.items[0].is_default, false); } +#[nexus_test] +async fn test_ip_pool_list_filter_delegated( + cptestctx: &ControlPlaneTestContext, +) { + let client = &cptestctx.external_client; + + let (user_pool, ..) = create_ip_pool(client, "pool-filter", None).await; + + let default_pools = get_ip_pools(client).await; + assert!( + default_pools + .iter() + .any(|pool| pool.identity.id == user_pool.identity.id) + ); + + let delegated_only = + get_ip_pools_with_params(client, "delegated_for_internal_use=true") + .await; + assert!( + delegated_only + .iter() + .any(|pool| pool.identity.name == SERVICE_IPV4_POOL_NAME) + ); + assert!( + delegated_only + .iter() + .any(|pool| pool.identity.name == SERVICE_IPV6_POOL_NAME) + ); + assert!( + delegated_only + .iter() + .all(|pool| pool.identity.id != user_pool.identity.id) + ); + + let non_delegated = + get_ip_pools_with_params(client, "delegated_for_internal_use=false") + .await; + assert!( + non_delegated + .iter() + .any(|pool| pool.identity.id == user_pool.identity.id) + ); + assert!(non_delegated.iter().all(|pool| { + pool.identity.name != SERVICE_IPV4_POOL_NAME + && pool.identity.name != SERVICE_IPV6_POOL_NAME + })); +} + +#[nexus_test] +async fn test_ip_pool_list_filter_ip_version( + cptestctx: &ControlPlaneTestContext, +) { + let client = &cptestctx.external_client; + + let (user_pool, ..) = create_ip_pool(client, "pool-filter-v4", None).await; + + let v4_pools = get_ip_pools_with_params( + client, + "ip_version=v4&delegated_for_internal_use=false", + ) + .await; + assert!(v4_pools.iter().all(|pool| pool.ip_version == IpVersion::V4)); + assert!( + v4_pools.iter().any(|pool| pool.identity.id == user_pool.identity.id) + ); + + let delegated_v6 = get_ip_pools_with_params( + client, + "delegated_for_internal_use=true&ip_version=v6", + ) + .await; + assert!(delegated_v6.iter().all(|pool| pool.ip_version == IpVersion::V6)); + assert!( + delegated_v6 + .iter() + .any(|pool| pool.identity.name == SERVICE_IPV6_POOL_NAME) + ); +} + // IP pool list fetch logic includes a join to ip_pool_resource, which is // unusual, so we want to make sure pagination logic still works #[nexus_test] diff --git a/nexus/types/src/external_api/params.rs b/nexus/types/src/external_api/params.rs index a285d863d05..1e67036b8dc 100644 --- a/nexus/types/src/external_api/params.rs +++ b/nexus/types/src/external_api/params.rs @@ -997,6 +997,22 @@ impl std::fmt::Debug for CertificateCreate { // IP POOLS +/// Filters for listing IP pools. +#[derive( + Clone, Debug, Default, Deserialize, Serialize, JsonSchema, PartialEq, +)] +pub struct IpPoolListSelector { + /// Restrict pools to a specific IP version. + #[serde(default)] + pub ip_version: Option, + + /// Filter on pools delegated for internal Oxide use. + /// + /// Defaults to excluding internal pools when unset. + #[serde(default)] + pub delegated_for_internal_use: Option, +} + /// Create-time parameters for an `IpPool`. /// /// For multicast pools, all ranges must be either Any-Source Multicast (ASM) diff --git a/openapi/nexus.json b/openapi/nexus.json index 5ec949c06a3..d7b173b7e0e 100644 --- a/openapi/nexus.json +++ b/openapi/nexus.json @@ -8249,6 +8249,23 @@ "summary": "List IP pools", "operationId": "ip_pool_list", "parameters": [ + { + "in": "query", + "name": "delegated_for_internal_use", + "description": "Filter on pools delegated for internal Oxide use.\n\nDefaults to excluding internal pools when unset.", + "schema": { + "nullable": true, + "type": "boolean" + } + }, + { + "in": "query", + "name": "ip_version", + "description": "Restrict pools to a specific IP version.", + "schema": { + "$ref": "#/components/schemas/IpVersion" + } + }, { "in": "query", "name": "limit",