Skip to content

Commit

Permalink
Add API endpoints for retrieving IP pools available to projects (oxid…
Browse files Browse the repository at this point in the history
  • Loading branch information
lif committed May 17, 2023
1 parent 891acc7 commit 1c553ce
Show file tree
Hide file tree
Showing 9 changed files with 399 additions and 6 deletions.
74 changes: 74 additions & 0 deletions nexus/src/external_api/http_entrypoints.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)?;
Expand Down Expand Up @@ -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<Arc<ServerContext>>,
query_params: Query<PaginatedByNameOrId<params::ProjectSelector>>,
) -> Result<HttpResponseOk<ResultsPage<IpPool>>, 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<Arc<ServerContext>>,
path_params: Path<params::IpPoolPath>,
_project: Query<params::OptionalProjectSelector>,
) -> Result<HttpResponseOk<views::IpPool>, 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,
Expand Down
7 changes: 5 additions & 2 deletions nexus/test-utils/src/resource_helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -347,7 +347,9 @@ pub async fn create_instance(
instance_name,
&params::InstanceNetworkInterfaceAttachment::Default,
// Disks=
vec![],
Vec::<params::InstanceDiskAttachment>::new(),
// External IPs=
Vec::<params::ExternalIpCreate>::new(),
)
.await
}
Expand All @@ -359,6 +361,7 @@ pub async fn create_instance_with(
instance_name: &str,
nics: &params::InstanceNetworkInterfaceAttachment,
disks: Vec<params::InstanceDiskAttachment>,
external_ips: Vec<params::ExternalIpCreate>,
) -> Instance {
let url = format!("/v1/instances?project={}", project_name);
object_create(
Expand All @@ -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,
},
Expand Down
1 change: 1 addition & 0 deletions nexus/tests/integration_tests/disks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1357,6 +1357,7 @@ async fn create_instance_with_disk(client: &ClientTestContext) {
vec![params::InstanceDiskAttachment::Attach(
params::InstanceDiskAttach { name: DISK_NAME.parse().unwrap() },
)],
Vec::<params::ExternalIpCreate>::new(),
)
.await;
}
Expand Down
20 changes: 20 additions & 0 deletions nexus/tests/integration_tests/endpoints.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
3 changes: 2 additions & 1 deletion nexus/tests/integration_tests/instances.rs
Original file line number Diff line number Diff line change
Expand Up @@ -522,7 +522,8 @@ async fn test_instance_migrate(cptestctx: &ControlPlaneTestContext) {
PROJECT_NAME,
instance_name,
&params::InstanceNetworkInterfaceAttachment::Default,
vec![],
Vec::<params::InstanceDiskAttachment>::new(),
Vec::<params::ExternalIpCreate>::new(),
)
.await;
let instance_id = instance.identity.id;
Expand Down
179 changes: 177 additions & 2 deletions nexus/tests/integration_tests/ip_pools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<omicron_nexus::Server>;
Expand Down Expand Up @@ -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, &params)
.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::<IpPool>(
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<String> =
list.iter().map(|pool| pool.identity.name.to_string()).collect();
let expected_names: HashSet<String> =
["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::<InstanceDiskAttachment>::new(),
vec![ExternalIpCreate::Ephemeral { pool_name }],
)
.await;
}
}

#[nexus_test]
async fn test_ip_range_delete_with_allocated_external_ip_fails(
cptestctx: &ControlPlaneTestContext,
Expand Down
4 changes: 3 additions & 1 deletion nexus/tests/integration_tests/subnet_allocation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,9 @@ async fn test_subnet_allocation(cptestctx: &ControlPlaneTestContext) {
&format!("i{}", i),
&nic,
// Disks=
vec![],
Vec::<params::InstanceDiskAttachment>::new(),
// External IPs=
Vec::<params::ExternalIpCreate>::new(),
)
.await;
}
Expand Down
Loading

0 comments on commit 1c553ce

Please sign in to comment.