From d4aab3087f48b5ca564531419d1fba12d6e47979 Mon Sep 17 00:00:00 2001 From: lif <> Date: Mon, 15 May 2023 22:28:49 +0000 Subject: [PATCH] Add API endpoints for retrieving IP pools available to projects (#2148) --- nexus/db-queries/src/db/datastore/project.rs | 29 +++ nexus/src/app/project.rs | 38 ++++ nexus/src/external_api/http_entrypoints.rs | 81 ++++++++ nexus/test-utils/src/resource_helpers.rs | 7 +- nexus/tests/integration_tests/disks.rs | 1 + nexus/tests/integration_tests/endpoints.rs | 20 ++ nexus/tests/integration_tests/instances.rs | 6 +- nexus/tests/integration_tests/ip_pools.rs | 179 +++++++++++++++++- .../integration_tests/subnet_allocation.rs | 4 +- nexus/tests/output/nexus_tags.txt | 2 + openapi/nexus.json | 115 +++++++++++ 11 files changed, 475 insertions(+), 7 deletions(-) diff --git a/nexus/db-queries/src/db/datastore/project.rs b/nexus/db-queries/src/db/datastore/project.rs index a5e8f0be6f..9ae520a97a 100644 --- a/nexus/db-queries/src/db/datastore/project.rs +++ b/nexus/db-queries/src/db/datastore/project.rs @@ -329,4 +329,33 @@ impl DataStore { ) }) } + + /// List IP Pools accessible to a project + pub async fn project_ip_pools_list( + &self, + opctx: &OpContext, + authz_project: &authz::Project, + pagparams: &PaginatedBy<'_>, + ) -> ListResultVec { + use db::schema::ip_pool::dsl; + opctx.authorize(authz::Action::ListChildren, authz_project).await?; + match pagparams { + PaginatedBy::Id(pagparams) => { + paginated(dsl::ip_pool, dsl::id, pagparams) + } + PaginatedBy::Name(pagparams) => paginated( + dsl::ip_pool, + dsl::name, + &pagparams.map_name(|n| Name::ref_cast(n)), + ), + } + // TODO(2148, 2056): filter only pools accessible by the given + // project, once specific projects for pools are implemented + .filter(dsl::internal.eq(false)) + .filter(dsl::time_deleted.is_null()) + .select(db::model::IpPool::as_select()) + .get_results_async(self.pool_authorized(opctx).await?) + .await + .map_err(|e| public_error_from_diesel_pool(e, ErrorHandler::Server)) + } } diff --git a/nexus/src/app/project.rs b/nexus/src/app/project.rs index 4f568ff516..4c2702407e 100644 --- a/nexus/src/app/project.rs +++ b/nexus/src/app/project.rs @@ -13,6 +13,7 @@ use crate::db::lookup::LookupPath; use crate::external_api::params; use crate::external_api::shared; use anyhow::Context; +use nexus_db_model::Name; use nexus_db_queries::context::OpContext; use omicron_common::api::external::http_pagination::PaginatedBy; use omicron_common::api::external::CreateResult; @@ -23,6 +24,7 @@ use omicron_common::api::external::ListResultVec; use omicron_common::api::external::LookupResult; use omicron_common::api::external::NameOrId; use omicron_common::api::external::UpdateResult; +use ref_cast::RefCast; use std::sync::Arc; impl super::Nexus { @@ -147,4 +149,40 @@ impl super::Nexus { .collect::, _>>()?; Ok(shared::Policy { role_assignments }) } + + pub async fn project_ip_pools_list( + &self, + opctx: &OpContext, + project_lookup: &lookup::Project<'_>, + pagparams: &PaginatedBy<'_>, + ) -> ListResultVec { + let (.., authz_project) = + project_lookup.lookup_for(authz::Action::ListChildren).await?; + + self.db_datastore + .project_ip_pools_list(opctx, &authz_project, pagparams) + .await + } + + pub fn project_ip_pool_lookup<'a>( + &'a self, + opctx: &'a OpContext, + pool: &'a NameOrId, + _project_lookup: &Option>, + ) -> LookupResult> { + // TODO(2148, 2056): check that the given project has access (if one + // is provided to the call) once that relation is implemented + 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) + } + } + } } diff --git a/nexus/src/external_api/http_entrypoints.rs b/nexus/src/external_api/http_entrypoints.rs index 48fe902414..2ca6c1682b 100644 --- a/nexus/src/external_api/http_entrypoints.rs +++ b/nexus/src/external_api/http_entrypoints.rs @@ -39,8 +39,10 @@ use dropshot::{ channel, endpoint, WebsocketChannelResult, WebsocketConnection, }; use ipnetwork::IpNetwork; +use nexus_db_queries::authz::ApiResource; use nexus_db_queries::db::lookup::ImageLookup; use nexus_db_queries::db::lookup::ImageParentLookup; +use nexus_types::external_api::params::ProjectSelector; use nexus_types::{ external_api::views::{SledInstance, Switch}, identity::AssetIdentityMetadata, @@ -107,6 +109,8 @@ pub fn external_api() -> NexusApiDescription { api.register(project_update)?; api.register(project_policy_view)?; api.register(project_policy_update)?; + api.register(project_ip_pool_list)?; + api.register(project_ip_pool_view)?; // Operator-Accessible IP Pools API api.register(ip_pool_list)?; @@ -1074,6 +1078,83 @@ async fn project_policy_update( // IP Pools +/// List all IP Pools that can be used by a given project. +#[endpoint { + method = GET, + path = "/v1/ip-pools", + tags = ["projects"], +}] +async fn project_ip_pool_list( + rqctx: RequestContext>, + query_params: Query>, +) -> Result>, HttpError> { + // Per https://github.com/oxidecomputer/omicron/issues/2148 + // This is currently the same list as /v1/system/ip-pools, that is to say, + // IP pools that are *available to* a given project, those being ones that + // are not the internal pools for Oxide service usage. This may change + // in the future as the scoping of pools is further developed, but for now, + // this is literally a near-duplicate of `ip_pool_list`: + let apictx = rqctx.context(); + let handler = async { + let nexus = &apictx.nexus; + let query = query_params.into_inner(); + let pag_params = data_page_params_for(&rqctx, &query)?; + let scan_params = ScanByNameOrId::from_query(&query)?; + let paginated_by = name_or_id_pagination(&pag_params, scan_params)?; + let opctx = crate::context::op_context_for_external_api(&rqctx).await?; + let project_lookup = + nexus.project_lookup(&opctx, scan_params.selector.clone())?; + let pools = nexus + .project_ip_pools_list(&opctx, &project_lookup, &paginated_by) + .await? + .into_iter() + .map(IpPool::from) + .collect(); + Ok(HttpResponseOk(ScanByNameOrId::results_page( + &query, + pools, + &marker_for_name_or_id, + )?)) + }; + apictx.external_latencies.instrument_dropshot_handler(&rqctx, handler).await +} + +/// Fetch an IP pool +#[endpoint { + method = GET, + path = "/v1/ip-pools/{pool}", + tags = ["projects"], +}] +async fn project_ip_pool_view( + rqctx: RequestContext>, + path_params: Path, + project: Query, +) -> Result, HttpError> { + let apictx = rqctx.context(); + let handler = async { + let opctx = crate::context::op_context_for_external_api(&rqctx).await?; + let nexus = &apictx.nexus; + let pool_selector = path_params.into_inner().pool; + let project_lookup = if let Some(project) = project.into_inner().project + { + Some(nexus.project_lookup(&opctx, ProjectSelector { project })?) + } else { + None + }; + let (authz_pool, pool) = nexus + .project_ip_pool_lookup(&opctx, &pool_selector, &project_lookup)? + .fetch() + .await?; + // TODO(2148): once we've actualy implemented filtering to pools belonging to + // the specified project, we can remove this internal check. + if pool.internal { + return Err(authz_pool.not_found().into()); + } + Ok(HttpResponseOk(IpPool::from(pool))) + }; + apictx.external_latencies.instrument_dropshot_handler(&rqctx, handler).await +} + /// List IP pools #[endpoint { method = GET, diff --git a/nexus/test-utils/src/resource_helpers.rs b/nexus/test-utils/src/resource_helpers.rs index 1a5c631805..382d579cf7 100644 --- a/nexus/test-utils/src/resource_helpers.rs +++ b/nexus/test-utils/src/resource_helpers.rs @@ -348,7 +348,9 @@ pub async fn create_instance( instance_name, ¶ms::InstanceNetworkInterfaceAttachment::Default, // Disks= - vec![], + Vec::::new(), + // External IPs= + Vec::::new(), ) .await } @@ -360,6 +362,7 @@ pub async fn create_instance_with( instance_name: &str, nics: ¶ms::InstanceNetworkInterfaceAttachment, disks: Vec, + external_ips: Vec, ) -> Instance { let url = format!("/v1/instances?project={}", project_name); object_create( @@ -377,7 +380,7 @@ pub async fn create_instance_with( b"#cloud-config\nsystem_info:\n default_user:\n name: oxide" .to_vec(), network_interfaces: nics.clone(), - external_ips: vec![], + external_ips, disks, start: true, }, diff --git a/nexus/tests/integration_tests/disks.rs b/nexus/tests/integration_tests/disks.rs index 4db5ceebaf..801334f4ac 100644 --- a/nexus/tests/integration_tests/disks.rs +++ b/nexus/tests/integration_tests/disks.rs @@ -1357,6 +1357,7 @@ async fn create_instance_with_disk(client: &ClientTestContext) { vec![params::InstanceDiskAttachment::Attach( params::InstanceDiskAttach { name: DISK_NAME.parse().unwrap() }, )], + Vec::::new(), ) .await; } diff --git a/nexus/tests/integration_tests/endpoints.rs b/nexus/tests/integration_tests/endpoints.rs index 72be5c192f..d35dd2290d 100644 --- a/nexus/tests/integration_tests/endpoints.rs +++ b/nexus/tests/integration_tests/endpoints.rs @@ -441,6 +441,8 @@ lazy_static! { }; // IP Pools + pub static ref DEMO_IP_POOLS_PROJ_URL: String = + format!("/v1/ip-pools?project={}", *DEMO_PROJECT_NAME); pub static ref DEMO_IP_POOLS_URL: &'static str = "/v1/system/ip-pools"; pub static ref DEMO_IP_POOL_NAME: Name = "default".parse().unwrap(); pub static ref DEMO_IP_POOL_CREATE: params::IpPoolCreate = @@ -450,6 +452,8 @@ lazy_static! { description: String::from("an IP pool"), }, }; + pub static ref DEMO_IP_POOL_PROJ_URL: String = + format!("/v1/ip-pools/{}?project={}", *DEMO_IP_POOL_NAME, *DEMO_PROJECT_NAME); pub static ref DEMO_IP_POOL_URL: String = format!("/v1/system/ip-pools/{}", *DEMO_IP_POOL_NAME); pub static ref DEMO_IP_POOL_UPDATE: params::IpPoolUpdate = params::IpPoolUpdate { @@ -721,6 +725,14 @@ lazy_static! { ), ], }, + VerifyEndpoint { + url: &DEMO_IP_POOLS_PROJ_URL, + visibility: Visibility::Public, + unprivileged_access: UnprivilegedAccess::ReadOnly, + allowed_methods: vec![ + AllowedMethod::Get + ], + }, // Single IP Pool endpoint VerifyEndpoint { @@ -735,6 +747,14 @@ lazy_static! { AllowedMethod::Delete, ], }, + VerifyEndpoint { + url: &DEMO_IP_POOL_PROJ_URL, + visibility: Visibility::Protected, + unprivileged_access: UnprivilegedAccess::ReadOnly, + allowed_methods: vec![ + AllowedMethod::Get + ], + }, // IP Pool ranges endpoint VerifyEndpoint { diff --git a/nexus/tests/integration_tests/instances.rs b/nexus/tests/integration_tests/instances.rs index 82f81f7613..ef8cc3c3f7 100644 --- a/nexus/tests/integration_tests/instances.rs +++ b/nexus/tests/integration_tests/instances.rs @@ -522,7 +522,8 @@ async fn test_instance_migrate(cptestctx: &ControlPlaneTestContext) { PROJECT_NAME, instance_name, ¶ms::InstanceNetworkInterfaceAttachment::Default, - vec![], + Vec::::new(), + Vec::::new(), ) .await; let instance_id = instance.identity.id; @@ -609,7 +610,8 @@ async fn test_instance_migrate_v2p(cptestctx: &ControlPlaneTestContext) { ¶ms::InstanceNetworkInterfaceAttachment::Default, // Omit disks: simulated sled agent assumes that disks are always co- // located with their instances. - vec![], + Vec::::new(), + Vec::::new(), ) .await; let instance_id = instance.identity.id; diff --git a/nexus/tests/integration_tests/ip_pools.rs b/nexus/tests/integration_tests/ip_pools.rs index 5c239bfc9a..c48abd1030 100644 --- a/nexus/tests/integration_tests/ip_pools.rs +++ b/nexus/tests/integration_tests/ip_pools.rs @@ -11,12 +11,17 @@ use http::StatusCode; use nexus_test_utils::http_testing::AuthnMode; use nexus_test_utils::http_testing::NexusRequest; use nexus_test_utils::http_testing::RequestBuilder; -use nexus_test_utils::resource_helpers::create_instance; use nexus_test_utils::resource_helpers::create_project; use nexus_test_utils::resource_helpers::objects_list_page_authz; +use nexus_test_utils::resource_helpers::{ + create_instance, create_instance_with, +}; use nexus_test_utils_macros::nexus_test; -use omicron_common::api::external::IdentityMetadataCreateParams; use omicron_common::api::external::IdentityMetadataUpdateParams; +use omicron_common::api::external::{IdentityMetadataCreateParams, Name}; +use omicron_nexus::external_api::params::ExternalIpCreate; +use omicron_nexus::external_api::params::InstanceDiskAttachment; +use omicron_nexus::external_api::params::InstanceNetworkInterfaceAttachment; use omicron_nexus::external_api::params::IpPoolCreate; use omicron_nexus::external_api::params::IpPoolUpdate; use omicron_nexus::external_api::shared::IpRange; @@ -26,6 +31,7 @@ use omicron_nexus::external_api::views::IpPool; use omicron_nexus::external_api::views::IpPoolRange; use omicron_nexus::TestInterfaces; use sled_agent_client::TestInterfaces as SledTestInterfaces; +use std::collections::HashSet; type ControlPlaneTestContext = nexus_test_utils::ControlPlaneTestContext; @@ -552,6 +558,175 @@ async fn test_ip_pool_range_pagination(cptestctx: &ControlPlaneTestContext) { } } +#[nexus_test] +async fn test_ip_pool_list_usable_by_project( + cptestctx: &ControlPlaneTestContext, +) { + let client = &cptestctx.external_client; + let scoped_ip_pools_url = "/v1/ip-pools"; + let ip_pools_url = "/v1/system/ip-pools"; + let mypool_name = "mypool"; + let default_ip_pool_add_range_url = + format!("{}/default/ranges/add", ip_pools_url); + let mypool_ip_pool_add_range_url = + format!("{}/{}/ranges/add", ip_pools_url, mypool_name); + let service_ip_pool_add_range_url = + "/v1/system/ip-pools-service/ranges/add".to_string(); + + // Add an IP range to the default pool + let default_range = IpRange::V4( + Ipv4Range::new( + std::net::Ipv4Addr::new(10, 0, 0, 1), + std::net::Ipv4Addr::new(10, 0, 0, 2), + ) + .unwrap(), + ); + let created_range: IpPoolRange = NexusRequest::objects_post( + client, + &default_ip_pool_add_range_url, + &default_range, + ) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .unwrap() + .parsed_body() + .unwrap(); + assert_eq!( + default_range.first_address(), + created_range.range.first_address() + ); + assert_eq!( + default_range.last_address(), + created_range.range.last_address() + ); + + // Create an org and project, and then try to make an instance with an IP from + // each range to which the project is expected have access. + + const PROJECT_NAME: &str = "myproj"; + const INSTANCE_NAME: &str = "myinst"; + create_project(client, PROJECT_NAME).await; + + // TODO: give this project explicit access when such functionality exists + let params = IpPoolCreate { + identity: IdentityMetadataCreateParams { + name: String::from(mypool_name).parse().unwrap(), + description: String::from("right on cue"), + }, + }; + NexusRequest::objects_post(client, ip_pools_url, ¶ms) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .unwrap(); + + // Add an IP range to mypool + let mypool_range = IpRange::V4( + Ipv4Range::new( + std::net::Ipv4Addr::new(10, 0, 0, 51), + std::net::Ipv4Addr::new(10, 0, 0, 52), + ) + .unwrap(), + ); + let created_range: IpPoolRange = NexusRequest::objects_post( + client, + &mypool_ip_pool_add_range_url, + &mypool_range, + ) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .unwrap() + .parsed_body() + .unwrap(); + assert_eq!( + mypool_range.first_address(), + created_range.range.first_address() + ); + assert_eq!(mypool_range.last_address(), created_range.range.last_address()); + + // add a service range we *don't* expect to see in the results + let service_range = IpRange::V4( + Ipv4Range::new( + std::net::Ipv4Addr::new(10, 0, 0, 101), + std::net::Ipv4Addr::new(10, 0, 0, 102), + ) + .unwrap(), + ); + + let created_range: IpPoolRange = NexusRequest::objects_post( + client, + &service_ip_pool_add_range_url, + &service_range, + ) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .unwrap() + .parsed_body() + .unwrap(); + assert_eq!( + service_range.first_address(), + created_range.range.first_address() + ); + assert_eq!( + service_range.last_address(), + created_range.range.last_address() + ); + + // TODO: add non-service, ip pools that the project *can't* use, when that + // functionality is implemented in the future (i.e. a "notmypool") + + let list_url = format!("{}?project={}", scoped_ip_pools_url, PROJECT_NAME); + let list = NexusRequest::iter_collection_authn::( + client, &list_url, "", None, + ) + .await + .expect("Failed to list IP Pools") + .all_items; + + // default and mypool + assert_eq!(list.len(), 2); + let pool_names: HashSet = + list.iter().map(|pool| pool.identity.name.to_string()).collect(); + let expected_names: HashSet = + ["default", "mypool"].into_iter().map(|s| s.to_string()).collect(); + assert_eq!(pool_names, expected_names); + + // ensure we can view each pool returned + for pool_name in &pool_names { + let view_pool_url = format!( + "{}/{}?project={}", + scoped_ip_pools_url, pool_name, PROJECT_NAME + ); + let pool: IpPool = NexusRequest::object_get(client, &view_pool_url) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .unwrap() + .parsed_body() + .unwrap(); + assert_eq!(pool.identity.name.as_str(), pool_name.as_str()); + } + + // ensure we can successfully create an instance with each of the pools we + // should be able to access + for pool_name in pool_names { + let instance_name = format!("{}-{}", INSTANCE_NAME, pool_name); + let pool_name = Some(Name::try_from(pool_name).unwrap()); + create_instance_with( + client, + PROJECT_NAME, + &instance_name, + &InstanceNetworkInterfaceAttachment::Default, + Vec::::new(), + vec![ExternalIpCreate::Ephemeral { pool_name }], + ) + .await; + } +} + #[nexus_test] async fn test_ip_range_delete_with_allocated_external_ip_fails( cptestctx: &ControlPlaneTestContext, diff --git a/nexus/tests/integration_tests/subnet_allocation.rs b/nexus/tests/integration_tests/subnet_allocation.rs index afb41c163a..046e5598fe 100644 --- a/nexus/tests/integration_tests/subnet_allocation.rs +++ b/nexus/tests/integration_tests/subnet_allocation.rs @@ -140,7 +140,9 @@ async fn test_subnet_allocation(cptestctx: &ControlPlaneTestContext) { &format!("i{}", i), &nic, // Disks= - vec![], + Vec::::new(), + // External IPs= + Vec::::new(), ) .await; } diff --git a/nexus/tests/output/nexus_tags.txt b/nexus/tests/output/nexus_tags.txt index a999a8d6d6..3c50181369 100644 --- a/nexus/tests/output/nexus_tags.txt +++ b/nexus/tests/output/nexus_tags.txt @@ -64,6 +64,8 @@ API operations found with tag "projects" OPERATION ID METHOD URL PATH 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 diff --git a/openapi/nexus.json b/openapi/nexus.json index 5c9f9ebe03..2ecc7d918b 100644 --- a/openapi/nexus.json +++ b/openapi/nexus.json @@ -2056,6 +2056,121 @@ } } }, + "/v1/ip-pools": { + "get": { + "tags": [ + "projects" + ], + "summary": "List all IP Pools that can be used by a given project.", + "operationId": "project_ip_pool_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" + } + }, + { + "in": "query", + "name": "project", + "description": "Name or ID of the project", + "schema": { + "$ref": "#/components/schemas/NameOrId" + } + }, + { + "in": "query", + "name": "sort_by", + "schema": { + "$ref": "#/components/schemas/NameOrIdSortMode" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IpPoolResultsPage" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + }, + "x-dropshot-pagination": { + "required": [ + "project" + ] + } + } + }, + "/v1/ip-pools/{pool}": { + "get": { + "tags": [ + "projects" + ], + "summary": "Fetch an IP pool", + "operationId": "project_ip_pool_view", + "parameters": [ + { + "in": "path", + "name": "pool", + "description": "Name or ID of the IP pool", + "required": true, + "schema": { + "$ref": "#/components/schemas/NameOrId" + } + }, + { + "in": "query", + "name": "project", + "description": "Name or ID of the project", + "schema": { + "$ref": "#/components/schemas/NameOrId" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IpPool" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, "/v1/login/{silo_name}/local": { "post": { "tags": [