diff --git a/nexus/db-queries/src/db/datastore/ip_pool.rs b/nexus/db-queries/src/db/datastore/ip_pool.rs index f31ae4181fa..dd4ce0fe6f1 100644 --- a/nexus/db-queries/src/db/datastore/ip_pool.rs +++ b/nexus/db-queries/src/db/datastore/ip_pool.rs @@ -97,19 +97,26 @@ const INTERNAL_SILO_DEFAULT_CONSTRAINT: &'static str = const INTERNAL_SILO_DEFAULT_ERROR: &'static str = "The internal Silo cannot have a default IP Pool"; +#[derive(Clone, Copy, Debug, Default)] +pub struct IpPoolListFilters { + pub ip_version: Option, + pub delegated_for_internal_use: Option, +} + impl DataStore { /// List IP Pools pub async fn ip_pools_list( &self, opctx: &OpContext, pagparams: &PaginatedBy<'_>, + filters: &IpPoolListFilters, ) -> ListResultVec { use nexus_db_schema::schema::ip_pool; opctx .authorize(authz::Action::ListChildren, &authz::IP_POOL_LIST) .await?; - match pagparams { + let mut query = match pagparams { PaginatedBy::Id(pagparams) => { paginated(ip_pool::table, ip_pool::id, pagparams) } @@ -118,14 +125,34 @@ impl DataStore { 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)); } - .filter(ip_pool::name.ne(SERVICE_IPV4_POOL_NAME)) - .filter(ip_pool::name.ne(SERVICE_IPV6_POOL_NAME)) - .filter(ip_pool::time_deleted.is_null()) - .select(IpPool::as_select()) - .get_results_async(&*self.pool_connection_authorized(opctx).await?) - .await - .map_err(|e| public_error_from_diesel(e, ErrorHandler::Server)) + + 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)), + ); + } + _ => { + 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)) } /// Look up whether the given pool is available to users in the current @@ -1317,6 +1344,7 @@ impl DataStore { mod test { use std::num::NonZeroU32; + use super::IpPoolListFilters; use crate::authz; use crate::db::datastore::ip_pool::INTERNAL_SILO_DEFAULT_ERROR; use crate::db::model::{ @@ -1353,7 +1381,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); @@ -1381,7 +1409,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 6180ee8fb0b..b382daadbfc 100644 --- a/nexus/db-queries/src/db/datastore/mod.rs +++ b/nexus/db-queries/src/db/datastore/mod.rs @@ -133,6 +133,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 b98bca4a449..4c01759e8a1 100644 --- a/nexus/external-api/src/lib.rs +++ b/nexus/external-api/src/lib.rs @@ -708,7 +708,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 f51bc8a5541..3866de71ed8 100644 --- a/nexus/src/app/ip_pool.rs +++ b/nexus/src/app/ip_pool.rs @@ -14,6 +14,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::api::external::CreateResult; @@ -249,8 +250,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 a569203d8c4..f755c822fd9 100644 --- a/nexus/src/external_api/http_entrypoints.rs +++ b/nexus/src/external_api/http_entrypoints.rs @@ -1152,7 +1152,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 { @@ -1160,11 +1160,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 fa6fa2839ec..8c55bbb9614 100644 --- a/nexus/tests/integration_tests/ip_pools.rs +++ b/nexus/tests/integration_tests/ip_pools.rs @@ -14,7 +14,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; @@ -222,11 +224,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 @@ -234,6 +239,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 @@ -706,6 +715,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 8a1cd6f6fa7..84a96a70efd 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` #[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] pub struct IpPoolCreate { diff --git a/openapi/nexus.json b/openapi/nexus.json index d88de5977b9..eed06037e1e 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",