diff --git a/common/src/api/external/mod.rs b/common/src/api/external/mod.rs index 5c69940e621..54fe89f3da0 100644 --- a/common/src/api/external/mod.rs +++ b/common/src/api/external/mod.rs @@ -1339,10 +1339,16 @@ pub enum FailureDomain { /// /// Membership in a group is not exclusive - members may belong to multiple /// affinity / anti-affinity groups. +/// +/// Affinity Groups can contain up to 32 members. +// See: AFFINITY_GROUP_MAX_MEMBERS #[derive(Clone, Debug, Deserialize, Serialize, JsonSchema, PartialEq)] #[serde(tag = "type", content = "value", rename_all = "snake_case")] pub enum AffinityGroupMember { /// An instance belonging to this group + /// + /// Instances can belong to up to 16 affinity groups. + // See: INSTANCE_MAX_AFFINITY_GROUPS Instance { id: InstanceUuid, name: Name, run_state: InstanceState }, } @@ -1364,10 +1370,16 @@ impl SimpleIdentityOrName for AffinityGroupMember { /// /// Membership in a group is not exclusive - members may belong to multiple /// affinity / anti-affinity groups. +/// +/// Anti-Affinity Groups can contain up to 32 members. +// See: ANTI_AFFINITY_GROUP_MAX_MEMBERS #[derive(Clone, Debug, Deserialize, Serialize, JsonSchema, PartialEq)] #[serde(tag = "type", content = "value", rename_all = "snake_case")] pub enum AntiAffinityGroupMember { /// An instance belonging to this group + /// + /// Instances can belong to up to 16 anti-affinity groups. + // See: INSTANCE_MAX_ANTI_AFFINITY_GROUPS Instance { id: InstanceUuid, name: Name, run_state: InstanceState }, } diff --git a/nexus/db-queries/src/db/datastore/affinity.rs b/nexus/db-queries/src/db/datastore/affinity.rs index 7cad11c1e24..ddbe2593b40 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,107 @@ 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; +// Affinity Group Constraints +// --- +// +// The following capacities prevent unbounded growth for affinity/anti-affinity +// groups, but they're also pretty arbitrary. If needed, these could become +// configuration options, and expand to larger values. + +// The maximum number of members which an affinity group may contain +const AFFINITY_GROUP_MAX_MEMBERS: usize = 32; + +// The maximum number of members which an anti-affinity group may contain +const ANTI_AFFINITY_GROUP_MAX_MEMBERS: usize = 32; + +// The maximum number of affinity groups to which an instance may belong +const INSTANCE_MAX_AFFINITY_GROUPS: usize = 16; + +// The maximum number of anti-affinity groups to which an instance may belong +const INSTANCE_MAX_ANTI_AFFINITY_GROUPS: usize = 16; + 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, @@ -622,6 +721,48 @@ impl DataStore { }) })?; + // Check that this group has space for another member + let member_count: i64 = membership_dsl::affinity_group_instance_membership + .filter(membership_dsl::group_id.eq(authz_affinity_group.id())) + .count() + .get_result_async(&conn) + .await + .map_err(|e| { + err.bail_retryable_or_else(e, |e| { + public_error_from_diesel( + e, + ErrorHandler::Server, + ) + }) + })?; + if member_count >= AFFINITY_GROUP_MAX_MEMBERS as i64 { + return Err(err.bail(Error::invalid_request( + format!("Affinity group already has maximum allowed members ({})", + AFFINITY_GROUP_MAX_MEMBERS) + ))); + } + + // Check that the instance isn't in too many affinity groups already + let group_membership_count: i64 = membership_dsl::affinity_group_instance_membership + .filter(membership_dsl::instance_id.eq(instance_id.into_untyped_uuid())) + .count() + .get_result_async(&conn) + .await + .map_err(|e| { + err.bail_retryable_or_else(e, |e| { + public_error_from_diesel( + e, + ErrorHandler::Server, + ) + }) + })?; + if group_membership_count >= INSTANCE_MAX_AFFINITY_GROUPS as i64 { + return Err(err.bail(Error::invalid_request( + format!("Instance already belongs to maximum allowed affinity groups ({})", + INSTANCE_MAX_AFFINITY_GROUPS) + ))); + } + // Check that the instance exists, and has no sled // reservation. // @@ -747,6 +888,48 @@ impl DataStore { }) })?; + // Check that this group has space for another member + let member_count: i64 = membership_dsl::anti_affinity_group_instance_membership + .filter(membership_dsl::group_id.eq(authz_anti_affinity_group.id())) + .count() + .get_result_async(&conn) + .await + .map_err(|e| { + err.bail_retryable_or_else(e, |e| { + public_error_from_diesel( + e, + ErrorHandler::Server, + ) + }) + })?; + if member_count >= ANTI_AFFINITY_GROUP_MAX_MEMBERS as i64 { + return Err(err.bail(Error::invalid_request( + format!("Anti-affinity group already has maximum allowed members ({})", + ANTI_AFFINITY_GROUP_MAX_MEMBERS) + ))); + } + + // Check that the instance isn't in too many anti-affinity groups already + let group_membership_count: i64 = membership_dsl::anti_affinity_group_instance_membership + .filter(membership_dsl::instance_id.eq(instance_id.into_untyped_uuid())) + .count() + .get_result_async(&conn) + .await + .map_err(|e| { + err.bail_retryable_or_else(e, |e| { + public_error_from_diesel( + e, + ErrorHandler::Server, + ) + }) + })?; + if group_membership_count >= INSTANCE_MAX_ANTI_AFFINITY_GROUPS as i64 { + return Err(err.bail(Error::invalid_request( + format!("Instance already belongs to maximum allowed anti-affinity groups ({})", + INSTANCE_MAX_ANTI_AFFINITY_GROUPS) + ))); + } + // Check that the instance exists, and has no sled // reservation. let _check_instance_exists = instance_dsl::instance @@ -3103,4 +3286,362 @@ mod tests { db.terminate().await; logctx.cleanup_successful(); } + + // Test the limit on how many instances can be in a single affinity group + #[tokio::test] + async fn affinity_group_max_members() { + // Setup + let logctx = dev::test_setup_log("affinity_group_max_members"); + let db = TestDatabase::new_with_datastore(&logctx.log).await; + let (opctx, datastore) = (db.opctx(), db.datastore()); + + let (authz_project, _) = + create_project(&opctx, &datastore, "my-project").await; + + // Create an affinity group + let group = create_affinity_group( + &opctx, + &datastore, + &authz_project, + "my-group", + ) + .await + .unwrap(); + + let (.., authz_affinity_group) = LookupPath::new(opctx, datastore) + .affinity_group_id(group.id()) + .lookup_for(authz::Action::Modify) + .await + .unwrap(); + + // Create instances up to the limit (AFFINITY_GROUP_MAX_MEMBERS) + let mut instances = Vec::new(); + for i in 0..AFFINITY_GROUP_MAX_MEMBERS { + let instance_name = format!("instance-{}", i); + let instance = create_stopped_instance_record( + &opctx, + &datastore, + &authz_project, + &instance_name, + ) + .await; + + // Add the instance to the group + datastore + .affinity_group_member_instance_add( + &opctx, + &authz_affinity_group, + instance, + ) + .await + .unwrap(); + + instances.push(instance); + } + + // Create one more instance - this should exceed the limit + let excess_instance = create_stopped_instance_record( + &opctx, + &datastore, + &authz_project, + "excess-instance", + ) + .await; + + // Adding this instance should fail + let err = datastore + .affinity_group_member_instance_add( + &opctx, + &authz_affinity_group, + excess_instance, + ) + .await + .unwrap_err(); + + // Assert that the error message mentions the limit + assert!( + err.to_string().contains(&format!( + "maximum allowed members ({})", + AFFINITY_GROUP_MAX_MEMBERS + )), + "Error message should mention the member limit: {}", + err + ); + + // Clean up + db.terminate().await; + logctx.cleanup_successful(); + } + + // Test the limit on how many affinity groups an instance can join + #[tokio::test] + async fn instance_max_affinity_groups() { + // Setup + let logctx = dev::test_setup_log("instance_max_affinity_groups"); + let db = TestDatabase::new_with_datastore(&logctx.log).await; + let (opctx, datastore) = (db.opctx(), db.datastore()); + + let (authz_project, _) = + create_project(&opctx, &datastore, "my-project").await; + + // Create a new instance + let multi_group_instance = create_stopped_instance_record( + &opctx, + &datastore, + &authz_project, + "multi-group-instance", + ) + .await; + + // Create groups up to the limit (INSTANCE_MAX_AFFINITY_GROUPS) + let mut groups = vec![]; + for i in 0..INSTANCE_MAX_AFFINITY_GROUPS { + let group_name = format!("group-{}", i); + let new_group = create_affinity_group( + &opctx, + &datastore, + &authz_project, + &group_name, + ) + .await + .unwrap(); + + let (.., authz_new_group) = LookupPath::new(opctx, datastore) + .affinity_group_id(new_group.id()) + .lookup_for(authz::Action::Modify) + .await + .unwrap(); + + // Add the instance to each group + datastore + .affinity_group_member_instance_add( + &opctx, + &authz_new_group, + multi_group_instance, + ) + .await + .unwrap(); + + groups.push(new_group); + } + + // Create one more group - this should exceed the limit when we try to add the instance + let excess_group = create_affinity_group( + &opctx, + &datastore, + &authz_project, + "excess-group", + ) + .await + .unwrap(); + + let (.., authz_excess_group) = LookupPath::new(opctx, datastore) + .affinity_group_id(excess_group.id()) + .lookup_for(authz::Action::Modify) + .await + .unwrap(); + + // Adding the instance to this group should fail + let err = datastore + .affinity_group_member_instance_add( + &opctx, + &authz_excess_group, + multi_group_instance, + ) + .await + .unwrap_err(); + + // Assert that the error message mentions the limit + assert!( + err.to_string().contains(&format!( + "maximum allowed affinity groups ({})", + INSTANCE_MAX_AFFINITY_GROUPS + )), + "Error message should mention the group limit: {}", + err + ); + + // Clean up + db.terminate().await; + logctx.cleanup_successful(); + } + + // Test the limit on how many instances can be in a single anti-affinity group + #[tokio::test] + async fn anti_affinity_group_max_members() { + // Setup + let logctx = dev::test_setup_log("anti_affinity_group_max_members"); + let db = TestDatabase::new_with_datastore(&logctx.log).await; + let (opctx, datastore) = (db.opctx(), db.datastore()); + + let (authz_project, _) = + create_project(&opctx, &datastore, "my-project").await; + + // Create an anti-affinity group + let group = create_anti_affinity_group( + &opctx, + &datastore, + &authz_project, + "my-group", + ) + .await + .unwrap(); + + let (.., authz_anti_affinity_group) = LookupPath::new(opctx, datastore) + .anti_affinity_group_id(group.id()) + .lookup_for(authz::Action::Modify) + .await + .unwrap(); + + // Create instances up to the limit (ANTI_AFFINITY_GROUP_MAX_MEMBERS) + let mut instances = Vec::new(); + for i in 0..ANTI_AFFINITY_GROUP_MAX_MEMBERS { + let instance_name = format!("instance-{}", i); + let instance = create_stopped_instance_record( + &opctx, + &datastore, + &authz_project, + &instance_name, + ) + .await; + + // Add the instance to the group + datastore + .anti_affinity_group_member_instance_add( + &opctx, + &authz_anti_affinity_group, + instance, + ) + .await + .unwrap(); + + instances.push(instance); + } + + // Create one more instance - this should exceed the limit + let excess_instance = create_stopped_instance_record( + &opctx, + &datastore, + &authz_project, + "excess-instance", + ) + .await; + + // Adding this instance should fail + let err = datastore + .anti_affinity_group_member_instance_add( + &opctx, + &authz_anti_affinity_group, + excess_instance, + ) + .await + .unwrap_err(); + + // Assert that the error message mentions the limit + assert!( + err.to_string().contains(&format!( + "maximum allowed members ({})", + ANTI_AFFINITY_GROUP_MAX_MEMBERS + )), + "Error message should mention the member limit: {}", + err + ); + + // Clean up. + db.terminate().await; + logctx.cleanup_successful(); + } + + // Test the limit on how many anti-affinity groups an instance can join + #[tokio::test] + async fn instance_max_anti_affinity_groups() { + // Setup + let logctx = dev::test_setup_log("instance_max_anti_affinity_groups"); + let db = TestDatabase::new_with_datastore(&logctx.log).await; + let (opctx, datastore) = (db.opctx(), db.datastore()); + + let (authz_project, _) = + create_project(&opctx, &datastore, "my-project").await; + + // Create a new instance + let multi_group_instance = create_stopped_instance_record( + &opctx, + &datastore, + &authz_project, + "multi-group-instance", + ) + .await; + + // Create groups up to the limit (INSTANCE_MAX_ANTI_AFFINITY_GROUPS) + let mut groups = vec![]; + for i in 0..INSTANCE_MAX_ANTI_AFFINITY_GROUPS { + let group_name = format!("group-{}", i); + let new_group = create_anti_affinity_group( + &opctx, + &datastore, + &authz_project, + &group_name, + ) + .await + .unwrap(); + + let (.., authz_new_group) = LookupPath::new(opctx, datastore) + .anti_affinity_group_id(new_group.id()) + .lookup_for(authz::Action::Modify) + .await + .unwrap(); + + // Add the instance to each group + datastore + .anti_affinity_group_member_instance_add( + &opctx, + &authz_new_group, + multi_group_instance, + ) + .await + .unwrap(); + + groups.push(new_group); + } + + // Create one more group - this should exceed the limit when we try to add the instance + let excess_group = create_anti_affinity_group( + &opctx, + &datastore, + &authz_project, + "excess-group", + ) + .await + .unwrap(); + + let (.., authz_excess_group) = LookupPath::new(opctx, datastore) + .anti_affinity_group_id(excess_group.id()) + .lookup_for(authz::Action::Modify) + .await + .unwrap(); + + // Adding the instance to this group should fail + let err = datastore + .anti_affinity_group_member_instance_add( + &opctx, + &authz_excess_group, + multi_group_instance, + ) + .await + .unwrap_err(); + + // Assert that the error message mentions the limit + assert!( + err.to_string().contains(&format!( + "maximum allowed anti-affinity groups ({})", + INSTANCE_MAX_ANTI_AFFINITY_GROUPS + )), + "Error message should mention the group limit: {}", + err + ); + + // Clean up + db.terminate().await; + logctx.cleanup_successful(); + } } 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..0463ee19522 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": [ @@ -12274,10 +12426,10 @@ ] }, "AffinityGroupMember": { - "description": "A member of an Affinity Group\n\nMembership in a group is not exclusive - members may belong to multiple affinity / anti-affinity groups.", + "description": "A member of an Affinity Group\n\nMembership in a group is not exclusive - members may belong to multiple affinity / anti-affinity groups.\n\nAffinity Groups can contain up to 32 members.", "oneOf": [ { - "description": "An instance belonging to this group", + "description": "An instance belonging to this group\n\nInstances can belong to up to 16 affinity groups.", "type": "object", "properties": { "type": { @@ -12574,10 +12726,10 @@ ] }, "AntiAffinityGroupMember": { - "description": "A member of an Anti-Affinity Group\n\nMembership in a group is not exclusive - members may belong to multiple affinity / anti-affinity groups.", + "description": "A member of an Anti-Affinity Group\n\nMembership in a group is not exclusive - members may belong to multiple affinity / anti-affinity groups.\n\nAnti-Affinity Groups can contain up to 32 members.", "oneOf": [ { - "description": "An instance belonging to this group", + "description": "An instance belonging to this group\n\nInstances can belong to up to 16 anti-affinity groups.", "type": "object", "properties": { "type": {