Skip to content

Commit 6f7f55c

Browse files
[api] Add endpoint to list external subnets attached to an instance (#9755)
## Summary Adds `GET /v1/instances/{instance}/external-subnets` which returns all external subnets currently attached to the specified instance. ## Changes **Datastore layer** (`nexus/db-queries/src/db/datastore/external_subnet.rs`): - Add `instance_lookup_external_subnets()` function - Authz check (`Action::Read` on instance) performed in datastore, close to the SQL query - Results ordered by ID for deterministic output - Filters to only return attached, non-deleted subnets **App layer** (`nexus/src/app/external_subnet.rs`): - Add `instance_list_external_subnets()` function - Additional authz check for `Action::ListChildren` on project (external subnets are project-scoped) **HTTP layer** (`nexus/src/external_api/http_entrypoints.rs`): - Add `instance_external_subnet_list` handler - No pagination (expect small number of attached subnets per instance; TODO to add max limit) **Tests**: - Add `test_instance_external_subnet_list_empty` integration test - Add endpoint to `endpoints.rs` for `test_unauthorized` coverage ## Test plan - [x] `cargo nextest run -p omicron-nexus -E 'test(=integration_tests::unauthorized::test_unauthorized)'` - [x] `cargo nextest run -p omicron-nexus -E 'test(=integration_tests::external_subnets::test_instance_external_subnet_list_empty)'`
1 parent 947b3af commit 6f7f55c

File tree

9 files changed

+31589
-1
lines changed

9 files changed

+31589
-1
lines changed

nexus/db-queries/src/db/datastore/external_subnet.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ use nexus_db_lookup::lookup;
3131
use nexus_db_model::ExternalSubnet;
3232
use nexus_db_model::ExternalSubnetIdentity;
3333
use nexus_db_model::ExternalSubnetUpdate;
34+
use nexus_db_model::IpAttachState;
3435
use nexus_db_model::IpNet;
3536
use nexus_db_model::IpVersion;
3637
use nexus_db_model::Name;
@@ -509,6 +510,25 @@ impl DataStore {
509510
}
510511
})
511512
}
513+
514+
/// Fetch all external subnets attached to the provided instance.
515+
pub async fn instance_lookup_external_subnets(
516+
&self,
517+
opctx: &OpContext,
518+
authz_instance: &authz::Instance,
519+
) -> ListResultVec<ExternalSubnet> {
520+
opctx.authorize(authz::Action::Read, authz_instance).await?;
521+
use nexus_db_schema::schema::external_subnet::dsl;
522+
dsl::external_subnet
523+
.filter(dsl::instance_id.eq(authz_instance.id()))
524+
.filter(dsl::time_deleted.is_null())
525+
.filter(dsl::attach_state.eq(IpAttachState::Attached))
526+
.order_by(dsl::id)
527+
.select(ExternalSubnet::as_select())
528+
.get_results_async(&*self.pool_connection_authorized(opctx).await?)
529+
.await
530+
.map_err(|e| public_error_from_diesel(e, ErrorHandler::Server))
531+
}
512532
}
513533

514534
#[cfg(test)]

nexus/external-api/output/nexus_tags.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ instance_disk_list GET /v1/instances/{instance}/disks
113113
instance_ephemeral_ip_attach POST /v1/instances/{instance}/external-ips/ephemeral
114114
instance_ephemeral_ip_detach DELETE /v1/instances/{instance}/external-ips/ephemeral
115115
instance_external_ip_list GET /v1/instances/{instance}/external-ips
116+
instance_external_subnet_list GET /v1/instances/{instance}/external-subnets
116117
instance_list GET /v1/instances
117118
instance_network_interface_create POST /v1/network-interfaces
118119
instance_network_interface_delete DELETE /v1/network-interfaces/{interface}

nexus/external-api/src/lib.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ api_versions!([
7777
// | date-based version should be at the top of the list.
7878
// v
7979
// (next_yyyymmddnn, IDENT),
80+
(2026013000, INSTANCES_EXTERNAL_SUBNETS),
8081
(2026012800, REMOVE_SUBNET_POOL_POOL_TYPE),
8182
(2026012300, DUAL_STACK_EPHEMERAL_IP),
8283
(2026012201, EXTERNAL_SUBNET_ALLOCATOR_UPDATE),
@@ -3904,6 +3905,21 @@ pub trait NexusExternalApi {
39043905
query_params: Query<params::EphemeralIpDetachSelector>,
39053906
) -> Result<HttpResponseDeleted, HttpError>;
39063907

3908+
// Instance External Subnets
3909+
3910+
/// List external subnets attached to instance
3911+
#[endpoint {
3912+
method = GET,
3913+
path = "/v1/instances/{instance}/external-subnets",
3914+
tags = ["instances"],
3915+
versions = VERSION_INSTANCES_EXTERNAL_SUBNETS..,
3916+
}]
3917+
async fn instance_external_subnet_list(
3918+
rqctx: RequestContext<Self::Context>,
3919+
query_params: Query<params::OptionalProjectSelector>,
3920+
path_params: Path<params::InstancePath>,
3921+
) -> Result<HttpResponseOk<ResultsPage<views::ExternalSubnet>>, HttpError>;
3922+
39073923
// Instance Multicast Groups
39083924

39093925
/// List multicast groups for an instance

nexus/src/app/external_subnet.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,4 +150,24 @@ impl super::Nexus {
150150
.unimplemented_todo(opctx, Unimpl::ProtectedLookup(not_found))
151151
.await)
152152
}
153+
154+
pub(crate) async fn instance_list_external_subnets(
155+
&self,
156+
opctx: &OpContext,
157+
instance_lookup: &lookup::Instance<'_>,
158+
) -> ListResultVec<views::ExternalSubnet> {
159+
let (.., authz_project, authz_instance) =
160+
instance_lookup.lookup_for(authz::Action::Read).await?;
161+
162+
// External subnets are project-scoped, so check ListChildren on project
163+
opctx.authorize(authz::Action::ListChildren, &authz_project).await?;
164+
165+
Ok(self
166+
.db_datastore
167+
.instance_lookup_external_subnets(opctx, &authz_instance)
168+
.await?
169+
.into_iter()
170+
.map(|subnet| subnet.into())
171+
.collect())
172+
}
153173
}

nexus/src/external_api/http_entrypoints.rs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4918,6 +4918,43 @@ impl NexusExternalApi for NexusExternalApiImpl {
49184918
.await
49194919
}
49204920

4921+
// Instance External Subnets
4922+
4923+
async fn instance_external_subnet_list(
4924+
rqctx: RequestContext<ApiContext>,
4925+
query_params: Query<params::OptionalProjectSelector>,
4926+
path_params: Path<params::InstancePath>,
4927+
) -> Result<HttpResponseOk<ResultsPage<views::ExternalSubnet>>, HttpError>
4928+
{
4929+
let apictx = rqctx.context();
4930+
let handler = async {
4931+
let nexus = &apictx.context.nexus;
4932+
let path = path_params.into_inner();
4933+
let query = query_params.into_inner();
4934+
let opctx =
4935+
crate::context::op_context_for_external_api(&rqctx).await?;
4936+
let instance_selector = params::InstanceSelector {
4937+
project: query.project,
4938+
instance: path.instance,
4939+
};
4940+
let instance_lookup =
4941+
nexus.instance_lookup(&opctx, instance_selector)?;
4942+
let subnets = nexus
4943+
.instance_list_external_subnets(&opctx, &instance_lookup)
4944+
.await?;
4945+
// The number of external subnets attached to an instance is expected
4946+
// to be small, so pagination is not implemented.
4947+
// TODO: Add MAX_EXTERNAL_SUBNETS_PER_INSTANCE constant (similar to
4948+
// MAX_EXTERNAL_IPS_PER_INSTANCE) and enforce it in attach operations.
4949+
Ok(HttpResponseOk(ResultsPage { items: subnets, next_page: None }))
4950+
};
4951+
apictx
4952+
.context
4953+
.external_latencies
4954+
.instrument_dropshot_handler(&rqctx, handler)
4955+
.await
4956+
}
4957+
49214958
// Instance Multicast Groups
49224959

49234960
async fn instance_multicast_group_list(

nexus/tests/integration_tests/endpoints.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -654,6 +654,13 @@ pub static DEMO_INSTANCE_EXTERNAL_IPS_URL: LazyLock<String> =
654654
*DEMO_INSTANCE_NAME, *DEMO_PROJECT_SELECTOR
655655
)
656656
});
657+
pub static DEMO_INSTANCE_EXTERNAL_SUBNETS_URL: LazyLock<String> =
658+
LazyLock::new(|| {
659+
format!(
660+
"/v1/instances/{}/external-subnets?{}",
661+
*DEMO_INSTANCE_NAME, *DEMO_PROJECT_SELECTOR
662+
)
663+
});
657664
pub static DEMO_INSTANCE_CREATE: LazyLock<params::InstanceCreate> =
658665
LazyLock::new(|| params::InstanceCreate {
659666
identity: IdentityMetadataCreateParams {
@@ -2730,6 +2737,13 @@ pub static VERIFY_ENDPOINTS: LazyLock<Vec<VerifyEndpoint>> = LazyLock::new(
27302737
unprivileged_access: UnprivilegedAccess::None,
27312738
allowed_methods: vec![AllowedMethod::Get],
27322739
},
2740+
/* Instance external subnets */
2741+
VerifyEndpoint {
2742+
url: &DEMO_INSTANCE_EXTERNAL_SUBNETS_URL,
2743+
visibility: Visibility::Protected,
2744+
unprivileged_access: UnprivilegedAccess::None,
2745+
allowed_methods: vec![AllowedMethod::Get],
2746+
},
27332747
VerifyEndpoint {
27342748
url: &DEMO_INSTANCE_EPHEMERAL_IP_URL,
27352749
visibility: Visibility::Protected,

nexus/tests/integration_tests/external_subnets.rs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,13 @@ use http::Method;
1414
use http::StatusCode;
1515
use nexus_test_utils::http_testing::AuthnMode;
1616
use nexus_test_utils::http_testing::NexusRequest;
17+
use nexus_test_utils::resource_helpers::create_default_ip_pools;
18+
use nexus_test_utils::resource_helpers::create_instance;
1719
use nexus_test_utils::resource_helpers::create_project;
20+
use nexus_test_utils::resource_helpers::objects_list_page_authz;
1821
use nexus_test_utils_macros::nexus_test;
1922
use nexus_types::external_api::params;
23+
use nexus_types::external_api::views;
2024
use omicron_common::api::external::IdentityMetadataCreateParams;
2125

2226
type ControlPlaneTestContext =
@@ -44,6 +48,10 @@ fn external_subnet_detach_url(name: &str, project: &str) -> String {
4448
format!("/v1/external-subnets/{}/detach?project={}", name, project)
4549
}
4650

51+
fn instance_external_subnets_url(instance: &str, project: &str) -> String {
52+
format!("/v1/instances/{}/external-subnets?project={}", instance, project)
53+
}
54+
4755
#[nexus_test]
4856
async fn test_external_subnet_list_unimplemented(
4957
cptestctx: &ControlPlaneTestContext,
@@ -215,3 +223,25 @@ async fn test_external_subnet_detach_unimplemented(
215223
.await
216224
.expect("failed to make request");
217225
}
226+
227+
const INSTANCE_NAME: &str = "test-instance";
228+
229+
#[nexus_test]
230+
async fn test_instance_external_subnet_list_empty(
231+
cptestctx: &ControlPlaneTestContext,
232+
) {
233+
let client = &cptestctx.external_client;
234+
235+
// Create default IP pools, project, and instance
236+
create_default_ip_pools(client).await;
237+
let _ = create_project(client, PROJECT_NAME).await;
238+
let _ = create_instance(client, PROJECT_NAME, INSTANCE_NAME).await;
239+
240+
// List external subnets for the instance - should return empty list
241+
let subnets = objects_list_page_authz::<views::ExternalSubnet>(
242+
client,
243+
&instance_external_subnets_url(INSTANCE_NAME, PROJECT_NAME),
244+
)
245+
.await;
246+
assert!(subnets.items.is_empty());
247+
}

0 commit comments

Comments
 (0)