diff --git a/nexus/db-queries/src/db/datastore/affinity.rs b/nexus/db-queries/src/db/datastore/affinity.rs index 7cad11c1e24..b97ace07a0d 100644 --- a/nexus/db-queries/src/db/datastore/affinity.rs +++ b/nexus/db-queries/src/db/datastore/affinity.rs @@ -29,6 +29,7 @@ use crate::db::model::Project; use crate::db::model::VmmState; use crate::db::model::VmmStateEnum; use crate::db::pagination::RawPaginator; +use crate::db::pagination::paginated; use crate::transaction_retry::OptionalError; use async_bb8_diesel::AsyncRunQueryDsl; use chrono::Utc; @@ -48,9 +49,88 @@ use omicron_uuid_kinds::AffinityGroupUuid; use omicron_uuid_kinds::AntiAffinityGroupUuid; use omicron_uuid_kinds::GenericUuid; use omicron_uuid_kinds::InstanceUuid; +use ref_cast::RefCast; use uuid::Uuid; impl DataStore { + /// List affinity groups associated with a given instance + pub async fn instance_list_affinity_groups( + &self, + opctx: &OpContext, + authz_instance: &authz::Instance, + pagparams: &PaginatedBy<'_>, + ) -> ListResultVec { + use db::schema::affinity_group::dsl as group_dsl; + use db::schema::affinity_group_instance_membership::dsl as membership_dsl; + + opctx.authorize(authz::Action::ListChildren, authz_instance).await?; + + match pagparams { + PaginatedBy::Id(pagparams) => { + paginated(group_dsl::affinity_group, group_dsl::id, &pagparams) + } + PaginatedBy::Name(pagparams) => paginated( + group_dsl::affinity_group, + group_dsl::name, + &pagparams.map_name(|n| Name::ref_cast(n)), + ), + } + .filter(group_dsl::time_deleted.is_null()) + .inner_join( + membership_dsl::affinity_group_instance_membership.on( + membership_dsl::instance_id + .eq(authz_instance.id()) + .and(membership_dsl::group_id.eq(group_dsl::id)), + ), + ) + .select(AffinityGroup::as_select()) + .load_async::( + &*self.pool_connection_authorized(opctx).await?, + ) + .await + .map_err(|e| public_error_from_diesel(e, ErrorHandler::Server)) + } + + /// List anti-affinity groups associated with a given instance + pub async fn instance_list_anti_affinity_groups( + &self, + opctx: &OpContext, + authz_instance: &authz::Instance, + pagparams: &PaginatedBy<'_>, + ) -> ListResultVec { + use db::schema::anti_affinity_group::dsl as group_dsl; + use db::schema::anti_affinity_group_instance_membership::dsl as membership_dsl; + + opctx.authorize(authz::Action::ListChildren, authz_instance).await?; + + match pagparams { + PaginatedBy::Id(pagparams) => paginated( + group_dsl::anti_affinity_group, + group_dsl::id, + &pagparams, + ), + PaginatedBy::Name(pagparams) => paginated( + group_dsl::anti_affinity_group, + group_dsl::name, + &pagparams.map_name(|n| Name::ref_cast(n)), + ), + } + .filter(group_dsl::time_deleted.is_null()) + .inner_join( + membership_dsl::anti_affinity_group_instance_membership.on( + membership_dsl::instance_id + .eq(authz_instance.id()) + .and(membership_dsl::group_id.eq(group_dsl::id)), + ), + ) + .select(AntiAffinityGroup::as_select()) + .load_async::( + &*self.pool_connection_authorized(opctx).await?, + ) + .await + .map_err(|e| public_error_from_diesel(e, ErrorHandler::Server)) + } + pub async fn affinity_group_list( &self, opctx: &OpContext, diff --git a/nexus/external-api/output/nexus_tags.txt b/nexus/external-api/output/nexus_tags.txt index a94d31314d3..6e1e119b5de 100644 --- a/nexus/external-api/output/nexus_tags.txt +++ b/nexus/external-api/output/nexus_tags.txt @@ -75,6 +75,8 @@ image_view GET /v1/images/{image} API operations found with tag "instances" OPERATION ID METHOD URL PATH +instance_affinity_group_list GET /v1/instances/{instance}/affinity-groups +instance_anti_affinity_group_list GET /v1/instances/{instance}/anti-affinity-groups instance_create POST /v1/instances instance_delete DELETE /v1/instances/{instance} instance_disk_attach POST /v1/instances/{instance}/disks/attach diff --git a/nexus/external-api/src/lib.rs b/nexus/external-api/src/lib.rs index e29dedf04a0..a5e8654e30e 100644 --- a/nexus/external-api/src/lib.rs +++ b/nexus/external-api/src/lib.rs @@ -1264,6 +1264,34 @@ pub trait NexusExternalApi { disk_to_detach: TypedBody, ) -> Result, HttpError>; + /// List affinity groups containing instance + #[endpoint { + method = GET, + path = "/v1/instances/{instance}/affinity-groups", + tags = ["instances"], + }] + async fn instance_affinity_group_list( + rqctx: RequestContext, + query_params: Query< + PaginatedByNameOrId, + >, + path_params: Path, + ) -> Result>, HttpError>; + + /// List anti-affinity groups containing instance + #[endpoint { + method = GET, + path = "/v1/instances/{instance}/anti-affinity-groups", + tags = ["instances"], + }] + async fn instance_anti_affinity_group_list( + rqctx: RequestContext, + query_params: Query< + PaginatedByNameOrId, + >, + path_params: Path, + ) -> Result>, HttpError>; + // Affinity Groups /// List affinity groups diff --git a/nexus/src/app/instance.rs b/nexus/src/app/instance.rs index b0577aaf6e2..cf04c28f870 100644 --- a/nexus/src/app/instance.rs +++ b/nexus/src/app/instance.rs @@ -1523,6 +1523,38 @@ impl super::Nexus { Ok(disk) } + /// Lists affinity groups to which this instance belongs + pub(crate) async fn instance_list_affinity_groups( + &self, + opctx: &OpContext, + instance_lookup: &lookup::Instance<'_>, + pagparams: &PaginatedBy<'_>, + ) -> ListResultVec { + let (.., authz_instance) = + instance_lookup.lookup_for(authz::Action::ListChildren).await?; + self.db_datastore + .instance_list_affinity_groups(opctx, &authz_instance, pagparams) + .await + } + + /// Lists anti-affinity groups to which this instance belongs + pub(crate) async fn instance_list_anti_affinity_groups( + &self, + opctx: &OpContext, + instance_lookup: &lookup::Instance<'_>, + pagparams: &PaginatedBy<'_>, + ) -> ListResultVec { + let (.., authz_instance) = + instance_lookup.lookup_for(authz::Action::ListChildren).await?; + self.db_datastore + .instance_list_anti_affinity_groups( + opctx, + &authz_instance, + pagparams, + ) + .await + } + /// Invoked by a sled agent to publish an updated runtime state for an /// Instance. pub(crate) async fn notify_vmm_updated( diff --git a/nexus/src/external_api/http_entrypoints.rs b/nexus/src/external_api/http_entrypoints.rs index 1508ff51ec2..2eb053f46e5 100644 --- a/nexus/src/external_api/http_entrypoints.rs +++ b/nexus/src/external_api/http_entrypoints.rs @@ -2508,6 +2508,100 @@ impl NexusExternalApi for NexusExternalApiImpl { .await } + async fn instance_affinity_group_list( + rqctx: RequestContext, + query_params: Query< + PaginatedByNameOrId, + >, + path_params: Path, + ) -> Result>, HttpError> + { + let apictx = rqctx.context(); + let handler = async { + let nexus = &apictx.context.nexus; + let path = path_params.into_inner(); + 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 instance_selector = params::InstanceSelector { + project: scan_params.selector.project.clone(), + instance: path.instance, + }; + let instance_lookup = + nexus.instance_lookup(&opctx, instance_selector)?; + let groups = nexus + .instance_list_affinity_groups( + &opctx, + &instance_lookup, + &paginated_by, + ) + .await? + .into_iter() + .map(|g| g.into()) + .collect(); + Ok(HttpResponseOk(ScanByNameOrId::results_page( + &query, + groups, + &marker_for_name_or_id, + )?)) + }; + apictx + .context + .external_latencies + .instrument_dropshot_handler(&rqctx, handler) + .await + } + + async fn instance_anti_affinity_group_list( + rqctx: RequestContext, + query_params: Query< + PaginatedByNameOrId, + >, + path_params: Path, + ) -> Result>, HttpError> + { + let apictx = rqctx.context(); + let handler = async { + let nexus = &apictx.context.nexus; + let path = path_params.into_inner(); + 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 instance_selector = params::InstanceSelector { + project: scan_params.selector.project.clone(), + instance: path.instance, + }; + let instance_lookup = + nexus.instance_lookup(&opctx, instance_selector)?; + let groups = nexus + .instance_list_anti_affinity_groups( + &opctx, + &instance_lookup, + &paginated_by, + ) + .await? + .into_iter() + .map(|g| g.into()) + .collect(); + Ok(HttpResponseOk(ScanByNameOrId::results_page( + &query, + groups, + &marker_for_name_or_id, + )?)) + }; + apictx + .context + .external_latencies + .instrument_dropshot_handler(&rqctx, handler) + .await + } + // Affinity Groups async fn affinity_group_list( diff --git a/nexus/tests/integration_tests/affinity.rs b/nexus/tests/integration_tests/affinity.rs index debac93c0e1..a2299606a64 100644 --- a/nexus/tests/integration_tests/affinity.rs +++ b/nexus/tests/integration_tests/affinity.rs @@ -75,6 +75,11 @@ impl ProjectScopedApiHelper<'_, T> { .await } + async fn instance_groups_list(&self, instance: &str) -> Vec { + let url = instance_groups_url(T::URL_COMPONENT, instance, self.project); + objects_list_page_authz(&self.client, &url).await.items + } + async fn groups_list(&self) -> Vec { let url = groups_url(T::URL_COMPONENT, self.project); objects_list_page_authz(&self.client, &url).await.items @@ -401,6 +406,15 @@ impl AffinityGroupish for AntiAffinityType { } } +fn instance_groups_url( + ty: &str, + instance: &str, + project: Option<&str>, +) -> String { + let query_params = project_query_param_suffix(project); + format!("/v1/instances/{instance}/{ty}{query_params}") +} + fn groups_url(ty: &str, project: Option<&str>) -> String { let query_params = project_query_param_suffix(project); format!("/v1/{ty}{query_params}") @@ -755,6 +769,49 @@ async fn test_group_crud(client: &ClientTestContext) { assert!(groups.is_empty()); } +#[nexus_test] +async fn test_affinity_instance_group_list( + cptestctx: &ControlPlaneTestContext, +) { + let external_client = &cptestctx.external_client; + test_instance_group_list::(external_client).await; +} + +#[nexus_test] +async fn test_anti_affinity_instance_group_list( + cptestctx: &ControlPlaneTestContext, +) { + let external_client = &cptestctx.external_client; + test_instance_group_list::(external_client).await; +} + +async fn test_instance_group_list( + client: &ClientTestContext, +) { + const PROJECT_NAME: &'static str = "test-project"; + + let api = ApiHelper::new(client); + + // Create an IP pool and project that we'll use for testing. + create_default_ip_pool(&client).await; + api.create_project(PROJECT_NAME).await; + + let project_api = api.use_project::(PROJECT_NAME); + + project_api.create_stopped_instance("test-instance").await; + let groups = project_api.instance_groups_list("test-instance").await; + assert!(groups.is_empty(), "New instance should not belong to any groups"); + + project_api.group_create("yes-group").await; + project_api.group_create("no-group").await; + + project_api.group_member_add("yes-group", "test-instance").await; + + let groups = project_api.instance_groups_list("test-instance").await; + assert_eq!(groups.len(), 1); + assert_eq!(groups[0].identity().name, "yes-group"); +} + #[nexus_test] async fn test_affinity_group_project_selector( cptestctx: &ControlPlaneTestContext, diff --git a/nexus/tests/integration_tests/endpoints.rs b/nexus/tests/integration_tests/endpoints.rs index a606a0c94c2..1cb28dac8dc 100644 --- a/nexus/tests/integration_tests/endpoints.rs +++ b/nexus/tests/integration_tests/endpoints.rs @@ -596,6 +596,20 @@ pub static DEMO_INSTANCE_DISKS_DETACH_URL: LazyLock = *DEMO_INSTANCE_NAME, *DEMO_PROJECT_SELECTOR ) }); +pub static DEMO_INSTANCE_AFFINITY_GROUPS_URL: LazyLock = + LazyLock::new(|| { + format!( + "/v1/instances/{}/affinity-groups?{}", + *DEMO_INSTANCE_NAME, *DEMO_PROJECT_SELECTOR + ) + }); +pub static DEMO_INSTANCE_ANTI_AFFINITY_GROUPS_URL: LazyLock = + LazyLock::new(|| { + format!( + "/v1/instances/{}/anti-affinity-groups?{}", + *DEMO_INSTANCE_NAME, *DEMO_PROJECT_SELECTOR + ) + }); pub static DEMO_INSTANCE_EPHEMERAL_IP_URL: LazyLock = LazyLock::new(|| { format!( @@ -1910,6 +1924,18 @@ pub static VERIFY_ENDPOINTS: LazyLock> = .unwrap(), )], }, + VerifyEndpoint { + url: &DEMO_INSTANCE_AFFINITY_GROUPS_URL, + visibility: Visibility::Protected, + unprivileged_access: UnprivilegedAccess::None, + allowed_methods: vec![AllowedMethod::Get], + }, + VerifyEndpoint { + url: &DEMO_INSTANCE_ANTI_AFFINITY_GROUPS_URL, + visibility: Visibility::Protected, + unprivileged_access: UnprivilegedAccess::None, + allowed_methods: vec![AllowedMethod::Get], + }, /* Affinity Groups */ VerifyEndpoint { url: &DEMO_PROJECT_URL_AFFINITY_GROUPS, diff --git a/openapi/nexus.json b/openapi/nexus.json index f442c455f19..25ae52300da 100644 --- a/openapi/nexus.json +++ b/openapi/nexus.json @@ -3280,6 +3280,158 @@ } } }, + "/v1/instances/{instance}/affinity-groups": { + "get": { + "tags": [ + "instances" + ], + "summary": "List affinity groups containing instance", + "operationId": "instance_affinity_group_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" + } + }, + { + "in": "path", + "name": "instance", + "description": "Name or ID of the instance", + "required": true, + "schema": { + "$ref": "#/components/schemas/NameOrId" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AffinityGroupResultsPage" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + }, + "x-dropshot-pagination": { + "required": [] + } + } + }, + "/v1/instances/{instance}/anti-affinity-groups": { + "get": { + "tags": [ + "instances" + ], + "summary": "List anti-affinity groups containing instance", + "operationId": "instance_anti_affinity_group_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" + } + }, + { + "in": "path", + "name": "instance", + "description": "Name or ID of the instance", + "required": true, + "schema": { + "$ref": "#/components/schemas/NameOrId" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AntiAffinityGroupResultsPage" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + }, + "x-dropshot-pagination": { + "required": [] + } + } + }, "/v1/instances/{instance}/disks": { "get": { "tags": [