diff --git a/nexus/src/external_api/http_entrypoints.rs b/nexus/src/external_api/http_entrypoints.rs index a841e3246fe..c52f883f4aa 100644 --- a/nexus/src/external_api/http_entrypoints.rs +++ b/nexus/src/external_api/http_entrypoints.rs @@ -93,6 +93,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)?; @@ -1041,6 +1043,78 @@ 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 pools = nexus + .ip_pools_list(&opctx, &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 (.., pool) = + nexus.ip_pool_lookup(&opctx, &pool_selector)?.fetch().await?; + if pool.internal { + return Err(HttpError::for_bad_request( + None, + format!( + "Requested pool {} is reserved for internal use.", + pool_selector + ), + )); + } + // TODO: check that the given project has access if one is provided + 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 dc836de5ac7..f49eb724d5f 100644 --- a/nexus/test-utils/src/resource_helpers.rs +++ b/nexus/test-utils/src/resource_helpers.rs @@ -347,7 +347,9 @@ pub async fn create_instance( instance_name, ¶ms::InstanceNetworkInterfaceAttachment::Default, // Disks= - vec![], + Vec::::new(), + // External IPs= + Vec::::new(), ) .await } @@ -359,6 +361,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( @@ -376,7 +379,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 6259ae6af2e..5ceef7d3905 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 72ccc6c516e..f837fbe7edc 100644 --- a/nexus/tests/integration_tests/endpoints.rs +++ b/nexus/tests/integration_tests/endpoints.rs @@ -365,6 +365,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 = @@ -374,6 +376,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 { @@ -645,6 +649,14 @@ lazy_static! { ), ], }, + VerifyEndpoint { + url: &DEMO_IP_POOLS_PROJ_URL, + visibility: Visibility::Public, + unprivileged_access: UnprivilegedAccess::None, + allowed_methods: vec![ + AllowedMethod::Get + ], + }, // Single IP Pool endpoint VerifyEndpoint { @@ -659,6 +671,14 @@ lazy_static! { AllowedMethod::Delete, ], }, + VerifyEndpoint { + url: &DEMO_IP_POOL_PROJ_URL, + visibility: Visibility::Protected, + unprivileged_access: UnprivilegedAccess::None, + 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 091f1ae7080..4220d45fa1e 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; diff --git a/nexus/tests/integration_tests/ip_pools.rs b/nexus/tests/integration_tests/ip_pools.rs index 5c239bfc9a9..c48abd10306 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 afb41c163a3..046e5598fe6 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 57606a51e47..bec3d68e85d 100644 --- a/nexus/tests/output/nexus_tags.txt +++ b/nexus/tests/output/nexus_tags.txt @@ -63,6 +63,8 @@ system_policy_view GET /v1/system/policy API operations found with tag "projects" OPERATION ID METHOD URL PATH +project_ip_pool_list GET /v1/ip-pools +project_ip_pool_view GET /v1/ip-pools/{pool} project_create POST /v1/projects project_delete DELETE /v1/projects/{project} project_list GET /v1/projects diff --git a/openapi/nexus.json b/openapi/nexus.json index 986980e12ff..50a76e7fc9a 100644 --- a/openapi/nexus.json +++ b/openapi/nexus.json @@ -2168,6 +2168,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/me": { "get": { "tags": [