diff --git a/common/src/api/external/http_pagination.rs b/common/src/api/external/http_pagination.rs index d4d237b2341..7cfef9a09c0 100644 --- a/common/src/api/external/http_pagination.rs +++ b/common/src/api/external/http_pagination.rs @@ -312,6 +312,19 @@ pub type PaginatedByNameOrId = PaginationParams< pub type PageSelectorByNameOrId = PageSelector, NameOrId>; +pub fn id_pagination<'a, Selector>( + pag_params: &'a DataPageParams, + scan_params: &'a ScanById, +) -> Result, HttpError> +where + Selector: + Clone + Debug + DeserializeOwned + JsonSchema + PartialEq + Serialize, +{ + match scan_params.sort_by { + IdSortMode::IdAscending => Ok(PaginatedBy::Id(pag_params.clone())), + } +} + pub fn name_or_id_pagination<'a, Selector>( pag_params: &'a DataPageParams, scan_params: &'a ScanByNameOrId, diff --git a/nexus/src/app/affinity.rs b/nexus/src/app/affinity.rs new file mode 100644 index 00000000000..5a1ebf7ab22 --- /dev/null +++ b/nexus/src/app/affinity.rs @@ -0,0 +1,407 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +//! Affinity groups + +use nexus_db_model::AffinityGroup; +use nexus_db_model::AntiAffinityGroup; +use nexus_db_queries::authz; +use nexus_db_queries::context::OpContext; +use nexus_db_queries::db::lookup; +use nexus_db_queries::db::lookup::LookupPath; +use nexus_types::external_api::params; +use nexus_types::external_api::views; +use omicron_common::api::external; +use omicron_common::api::external::http_pagination::PaginatedBy; +use omicron_common::api::external::CreateResult; +use omicron_common::api::external::DeleteResult; +use omicron_common::api::external::Error; +use omicron_common::api::external::ListResultVec; +use omicron_common::api::external::LookupResult; +use omicron_common::api::external::NameOrId; +use omicron_common::api::external::UpdateResult; +use omicron_uuid_kinds::GenericUuid; +use omicron_uuid_kinds::InstanceUuid; + +impl super::Nexus { + pub fn affinity_group_lookup<'a>( + &'a self, + opctx: &'a OpContext, + affinity_group_selector: params::AffinityGroupSelector, + ) -> LookupResult> { + match affinity_group_selector { + params::AffinityGroupSelector { + affinity_group: NameOrId::Id(id), + project: None + } => { + let affinity_group = + LookupPath::new(opctx, &self.db_datastore).affinity_group_id(id); + Ok(affinity_group) + } + params::AffinityGroupSelector { + affinity_group: NameOrId::Name(name), + project: Some(project) + } => { + let affinity_group = self + .project_lookup(opctx, params::ProjectSelector { project })? + .affinity_group_name_owned(name.into()); + Ok(affinity_group) + } + params::AffinityGroupSelector { + affinity_group: NameOrId::Id(_), + .. + } => { + Err(Error::invalid_request( + "when providing affinity_group as an ID, project should not be specified", + )) + } + _ => { + Err(Error::invalid_request( + "affinity_group should either be UUID or project should be specified", + )) + } + } + } + + pub fn anti_affinity_group_lookup<'a>( + &'a self, + opctx: &'a OpContext, + anti_affinity_group_selector: params::AntiAffinityGroupSelector, + ) -> LookupResult> { + match anti_affinity_group_selector { + params::AntiAffinityGroupSelector { + anti_affinity_group: NameOrId::Id(id), + project: None + } => { + let anti_affinity_group = + LookupPath::new(opctx, &self.db_datastore).anti_affinity_group_id(id); + Ok(anti_affinity_group) + } + params::AntiAffinityGroupSelector { + anti_affinity_group: NameOrId::Name(name), + project: Some(project) + } => { + let anti_affinity_group = self + .project_lookup(opctx, params::ProjectSelector { project })? + .anti_affinity_group_name_owned(name.into()); + Ok(anti_affinity_group) + } + params::AntiAffinityGroupSelector { + anti_affinity_group: NameOrId::Id(_), + .. + } => { + Err(Error::invalid_request( + "when providing anti_affinity_group as an ID, project should not be specified", + )) + } + _ => { + Err(Error::invalid_request( + "anti_affinity_group should either be UUID or project should be specified", + )) + } + } + } + + pub(crate) async fn affinity_group_list( + &self, + opctx: &OpContext, + project_lookup: &lookup::Project<'_>, + pagparams: &PaginatedBy<'_>, + ) -> ListResultVec { + let (.., authz_project) = + project_lookup.lookup_for(authz::Action::ListChildren).await?; + + Ok(self + .db_datastore + .affinity_group_list(opctx, &authz_project, pagparams) + .await? + .into_iter() + .map(Into::into) + .collect()) + } + + pub(crate) async fn anti_affinity_group_list( + &self, + opctx: &OpContext, + project_lookup: &lookup::Project<'_>, + pagparams: &PaginatedBy<'_>, + ) -> ListResultVec { + let (.., authz_project) = + project_lookup.lookup_for(authz::Action::ListChildren).await?; + + Ok(self + .db_datastore + .anti_affinity_group_list(opctx, &authz_project, pagparams) + .await? + .into_iter() + .map(Into::into) + .collect()) + } + + pub(crate) async fn affinity_group_create( + &self, + opctx: &OpContext, + project_lookup: &lookup::Project<'_>, + affinity_group_params: params::AffinityGroupCreate, + ) -> CreateResult { + let (.., authz_project) = + project_lookup.lookup_for(authz::Action::CreateChild).await?; + + let affinity_group = + AffinityGroup::new(authz_project.id(), affinity_group_params); + self.db_datastore + .affinity_group_create(opctx, &authz_project, affinity_group) + .await + .map(Into::into) + } + + pub(crate) async fn anti_affinity_group_create( + &self, + opctx: &OpContext, + project_lookup: &lookup::Project<'_>, + anti_affinity_group_params: params::AntiAffinityGroupCreate, + ) -> CreateResult { + let (.., authz_project) = + project_lookup.lookup_for(authz::Action::CreateChild).await?; + + let anti_affinity_group = AntiAffinityGroup::new( + authz_project.id(), + anti_affinity_group_params, + ); + self.db_datastore + .anti_affinity_group_create( + opctx, + &authz_project, + anti_affinity_group, + ) + .await + .map(Into::into) + } + + pub(crate) async fn affinity_group_update( + &self, + opctx: &OpContext, + group_lookup: &lookup::AffinityGroup<'_>, + updates: ¶ms::AffinityGroupUpdate, + ) -> UpdateResult { + let (.., authz_group) = + group_lookup.lookup_for(authz::Action::Modify).await?; + self.db_datastore + .affinity_group_update(opctx, &authz_group, updates.clone().into()) + .await + .map(Into::into) + } + + pub(crate) async fn anti_affinity_group_update( + &self, + opctx: &OpContext, + group_lookup: &lookup::AntiAffinityGroup<'_>, + updates: ¶ms::AntiAffinityGroupUpdate, + ) -> UpdateResult { + let (.., authz_group) = + group_lookup.lookup_for(authz::Action::Modify).await?; + self.db_datastore + .anti_affinity_group_update( + opctx, + &authz_group, + updates.clone().into(), + ) + .await + .map(Into::into) + } + + pub(crate) async fn affinity_group_delete( + &self, + opctx: &OpContext, + group_lookup: &lookup::AffinityGroup<'_>, + ) -> DeleteResult { + let (.., authz_group) = + group_lookup.lookup_for(authz::Action::Delete).await?; + self.db_datastore.affinity_group_delete(opctx, &authz_group).await + } + + pub(crate) async fn anti_affinity_group_delete( + &self, + opctx: &OpContext, + group_lookup: &lookup::AntiAffinityGroup<'_>, + ) -> DeleteResult { + let (.., authz_group) = + group_lookup.lookup_for(authz::Action::Delete).await?; + self.db_datastore.anti_affinity_group_delete(opctx, &authz_group).await + } + + pub(crate) async fn affinity_group_member_list( + &self, + opctx: &OpContext, + affinity_group_lookup: &lookup::AffinityGroup<'_>, + pagparams: &PaginatedBy<'_>, + ) -> ListResultVec { + let (.., authz_affinity_group) = affinity_group_lookup + .lookup_for(authz::Action::ListChildren) + .await?; + Ok(self + .db_datastore + .affinity_group_member_list(opctx, &authz_affinity_group, pagparams) + .await? + .into_iter() + .map(Into::into) + .collect()) + } + + pub(crate) async fn anti_affinity_group_member_list( + &self, + opctx: &OpContext, + anti_affinity_group_lookup: &lookup::AntiAffinityGroup<'_>, + pagparams: &PaginatedBy<'_>, + ) -> ListResultVec { + let (.., authz_anti_affinity_group) = anti_affinity_group_lookup + .lookup_for(authz::Action::ListChildren) + .await?; + Ok(self + .db_datastore + .anti_affinity_group_member_list( + opctx, + &authz_anti_affinity_group, + pagparams, + ) + .await? + .into_iter() + .map(Into::into) + .collect()) + } + + pub(crate) async fn affinity_group_member_view( + &self, + opctx: &OpContext, + affinity_group_lookup: &lookup::AffinityGroup<'_>, + instance_lookup: &lookup::Instance<'_>, + ) -> Result { + let (.., authz_affinity_group) = + affinity_group_lookup.lookup_for(authz::Action::Read).await?; + let (.., authz_instance) = + instance_lookup.lookup_for(authz::Action::Read).await?; + let member = external::AffinityGroupMember::Instance( + InstanceUuid::from_untyped_uuid(authz_instance.id()), + ); + + self.db_datastore + .affinity_group_member_view(opctx, &authz_affinity_group, member) + .await + } + + pub(crate) async fn anti_affinity_group_member_view( + &self, + opctx: &OpContext, + anti_affinity_group_lookup: &lookup::AntiAffinityGroup<'_>, + instance_lookup: &lookup::Instance<'_>, + ) -> Result { + let (.., authz_anti_affinity_group) = + anti_affinity_group_lookup.lookup_for(authz::Action::Read).await?; + let (.., authz_instance) = + instance_lookup.lookup_for(authz::Action::Read).await?; + let member = external::AntiAffinityGroupMember::Instance( + InstanceUuid::from_untyped_uuid(authz_instance.id()), + ); + + self.db_datastore + .anti_affinity_group_member_view( + opctx, + &authz_anti_affinity_group, + member, + ) + .await + } + + pub(crate) async fn affinity_group_member_add( + &self, + opctx: &OpContext, + affinity_group_lookup: &lookup::AffinityGroup<'_>, + instance_lookup: &lookup::Instance<'_>, + ) -> Result { + let (.., authz_affinity_group) = + affinity_group_lookup.lookup_for(authz::Action::Modify).await?; + let (.., authz_instance) = + instance_lookup.lookup_for(authz::Action::Read).await?; + let member = external::AffinityGroupMember::Instance( + InstanceUuid::from_untyped_uuid(authz_instance.id()), + ); + + self.db_datastore + .affinity_group_member_add( + opctx, + &authz_affinity_group, + member.clone(), + ) + .await?; + Ok(member) + } + + pub(crate) async fn anti_affinity_group_member_add( + &self, + opctx: &OpContext, + anti_affinity_group_lookup: &lookup::AntiAffinityGroup<'_>, + instance_lookup: &lookup::Instance<'_>, + ) -> Result { + let (.., authz_anti_affinity_group) = anti_affinity_group_lookup + .lookup_for(authz::Action::Modify) + .await?; + let (.., authz_instance) = + instance_lookup.lookup_for(authz::Action::Read).await?; + let member = external::AntiAffinityGroupMember::Instance( + InstanceUuid::from_untyped_uuid(authz_instance.id()), + ); + + self.db_datastore + .anti_affinity_group_member_add( + opctx, + &authz_anti_affinity_group, + member.clone(), + ) + .await?; + Ok(member) + } + + pub(crate) async fn affinity_group_member_delete( + &self, + opctx: &OpContext, + affinity_group_lookup: &lookup::AffinityGroup<'_>, + instance_lookup: &lookup::Instance<'_>, + ) -> Result<(), Error> { + let (.., authz_affinity_group) = + affinity_group_lookup.lookup_for(authz::Action::Modify).await?; + let (.., authz_instance) = + instance_lookup.lookup_for(authz::Action::Read).await?; + let member = external::AffinityGroupMember::Instance( + InstanceUuid::from_untyped_uuid(authz_instance.id()), + ); + + self.db_datastore + .affinity_group_member_delete(opctx, &authz_affinity_group, member) + .await + } + + pub(crate) async fn anti_affinity_group_member_delete( + &self, + opctx: &OpContext, + anti_affinity_group_lookup: &lookup::AntiAffinityGroup<'_>, + instance_lookup: &lookup::Instance<'_>, + ) -> Result<(), Error> { + let (.., authz_anti_affinity_group) = anti_affinity_group_lookup + .lookup_for(authz::Action::Modify) + .await?; + let (.., authz_instance) = + instance_lookup.lookup_for(authz::Action::Read).await?; + let member = external::AntiAffinityGroupMember::Instance( + InstanceUuid::from_untyped_uuid(authz_instance.id()), + ); + + self.db_datastore + .anti_affinity_group_member_delete( + opctx, + &authz_anti_affinity_group, + member, + ) + .await + } +} diff --git a/nexus/src/app/mod.rs b/nexus/src/app/mod.rs index db87d7db7d7..08d84244d83 100644 --- a/nexus/src/app/mod.rs +++ b/nexus/src/app/mod.rs @@ -46,6 +46,7 @@ use uuid::Uuid; // The implementation of Nexus is large, and split into a number of submodules // by resource. mod address_lot; +mod affinity; mod allow_list; pub(crate) mod background; mod bfd; diff --git a/nexus/src/external_api/http_entrypoints.rs b/nexus/src/external_api/http_entrypoints.rs index 887a6d35a65..b1cf5b59e5a 100644 --- a/nexus/src/external_api/http_entrypoints.rs +++ b/nexus/src/external_api/http_entrypoints.rs @@ -15,7 +15,6 @@ use super::{ }; use crate::app::external_endpoints::authority_for_request; use crate::app::support_bundles::SupportBundleQueryType; -use crate::app::Unimpl; use crate::context::ApiContext; use crate::external_api::shared; use dropshot::Body; @@ -55,6 +54,7 @@ use nexus_types::{ }, }; use omicron_common::api::external::http_pagination::data_page_params_for; +use omicron_common::api::external::http_pagination::id_pagination; use omicron_common::api::external::http_pagination::marker_for_id; use omicron_common::api::external::http_pagination::marker_for_name; use omicron_common::api::external::http_pagination::marker_for_name_or_id; @@ -2513,7 +2513,7 @@ impl NexusExternalApi for NexusExternalApiImpl { async fn affinity_group_list( rqctx: RequestContext, - _query_params: Query>, + query_params: Query>, ) -> Result>, HttpError> { let apictx = rqctx.context(); @@ -2521,7 +2521,20 @@ impl NexusExternalApi for NexusExternalApiImpl { let nexus = &apictx.context.nexus; let opctx = crate::context::op_context_for_external_api(&rqctx).await?; - Err(nexus.unimplemented_todo(&opctx, Unimpl::Public).await.into()) + 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 project_lookup = + nexus.project_lookup(&opctx, scan_params.selector.clone())?; + let groups = nexus + .affinity_group_list(&opctx, &project_lookup, &paginated_by) + .await?; + Ok(HttpResponseOk(ScanByNameOrId::results_page( + &query, + groups, + &marker_for_name_or_id, + )?)) }; apictx .context @@ -2532,16 +2545,28 @@ impl NexusExternalApi for NexusExternalApiImpl { async fn affinity_group_view( rqctx: RequestContext, - _query_params: Query, - _path_params: Path, + query_params: Query, + path_params: Path, ) -> Result, HttpError> { let apictx = rqctx.context(); let handler = async { let nexus = &apictx.context.nexus; + let path = path_params.into_inner(); let opctx = crate::context::op_context_for_external_api(&rqctx).await?; + let query = query_params.into_inner(); + + let group_selector = params::AffinityGroupSelector { + affinity_group: path.affinity_group, + project: query.project.clone(), + }; + + let (.., group) = nexus + .affinity_group_lookup(&opctx, group_selector)? + .fetch() + .await?; - Err(nexus.unimplemented_todo(&opctx, Unimpl::Public).await.into()) + Ok(HttpResponseOk(group.into())) }; apictx .context @@ -2552,8 +2577,8 @@ impl NexusExternalApi for NexusExternalApiImpl { async fn affinity_group_member_list( rqctx: RequestContext, - _query_params: Query>, - _path_params: Path, + query_params: Query>, + path_params: Path, ) -> Result>, HttpError> { let apictx = rqctx.context(); @@ -2561,7 +2586,30 @@ impl NexusExternalApi for NexusExternalApiImpl { let opctx = crate::context::op_context_for_external_api(&rqctx).await?; let nexus = &apictx.context.nexus; - Err(nexus.unimplemented_todo(&opctx, Unimpl::Public).await.into()) + let path = path_params.into_inner(); + let query = query_params.into_inner(); + let pag_params = data_page_params_for(&rqctx, &query)?; + let scan_params = ScanById::from_query(&query)?; + let paginated_by = id_pagination(&pag_params, scan_params)?; + + let group_selector = params::AffinityGroupSelector { + project: scan_params.selector.project.clone(), + affinity_group: path.affinity_group, + }; + let group_lookup = + nexus.affinity_group_lookup(&opctx, group_selector)?; + let affinity_group_member_instances = nexus + .affinity_group_member_list( + &opctx, + &group_lookup, + &paginated_by, + ) + .await?; + Ok(HttpResponseOk(ScanById::results_page( + &query, + affinity_group_member_instances, + &marker_for_id, + )?)) }; apictx .context @@ -2572,16 +2620,42 @@ impl NexusExternalApi for NexusExternalApiImpl { async fn affinity_group_member_instance_view( rqctx: RequestContext, - _query_params: Query, - _path_params: Path, + query_params: Query, + path_params: Path, ) -> Result, HttpError> { let apictx = rqctx.context(); let handler = async { let nexus = &apictx.context.nexus; + let path = path_params.into_inner(); let opctx = crate::context::op_context_for_external_api(&rqctx).await?; + let query = query_params.into_inner(); + + // Select group + let group_selector = params::AffinityGroupSelector { + affinity_group: path.affinity_group, + project: query.project.clone(), + }; + let group_lookup = + nexus.affinity_group_lookup(&opctx, group_selector)?; - Err(nexus.unimplemented_todo(&opctx, Unimpl::Public).await.into()) + // Select instance + let instance_selector = params::InstanceSelector { + project: query.project, + instance: path.instance, + }; + let instance_lookup = + nexus.instance_lookup(&opctx, instance_selector)?; + + let group = nexus + .affinity_group_member_view( + &opctx, + &group_lookup, + &instance_lookup, + ) + .await?; + + Ok(HttpResponseOk(group)) }; apictx .context @@ -2592,15 +2666,41 @@ impl NexusExternalApi for NexusExternalApiImpl { async fn affinity_group_member_instance_add( rqctx: RequestContext, - _query_params: Query, - _path_params: Path, + query_params: Query, + path_params: Path, ) -> Result, HttpError> { let apictx = rqctx.context(); let handler = async { let opctx = crate::context::op_context_for_external_api(&rqctx).await?; let nexus = &apictx.context.nexus; - Err(nexus.unimplemented_todo(&opctx, Unimpl::Public).await.into()) + let path = path_params.into_inner(); + let query = query_params.into_inner(); + + // Select group + let group_selector = params::AffinityGroupSelector { + affinity_group: path.affinity_group, + project: query.project.clone(), + }; + let group_lookup = + nexus.affinity_group_lookup(&opctx, group_selector)?; + + // Select instance + let instance_selector = params::InstanceSelector { + project: query.project, + instance: path.instance, + }; + let instance_lookup = + nexus.instance_lookup(&opctx, instance_selector)?; + + let member = nexus + .affinity_group_member_add( + &opctx, + &group_lookup, + &instance_lookup, + ) + .await?; + Ok(HttpResponseCreated(member)) }; apictx .context @@ -2611,15 +2711,40 @@ impl NexusExternalApi for NexusExternalApiImpl { async fn affinity_group_member_instance_delete( rqctx: RequestContext, - _query_params: Query, - _path_params: Path, + query_params: Query, + path_params: Path, ) -> Result { let apictx = rqctx.context(); let handler = async { let opctx = crate::context::op_context_for_external_api(&rqctx).await?; let nexus = &apictx.context.nexus; - Err(nexus.unimplemented_todo(&opctx, Unimpl::Public).await.into()) + let path = path_params.into_inner(); + let query = query_params.into_inner(); + + // Select group + let group_selector = params::AffinityGroupSelector { + affinity_group: path.affinity_group, + project: query.project.clone(), + }; + let group_lookup = + nexus.affinity_group_lookup(&opctx, group_selector)?; + + // Select instance + let instance_selector = params::InstanceSelector { + project: query.project, + instance: path.instance, + }; + let instance_lookup = + nexus.instance_lookup(&opctx, instance_selector)?; + nexus + .affinity_group_member_delete( + &opctx, + &group_lookup, + &instance_lookup, + ) + .await?; + Ok(HttpResponseDeleted()) }; apictx .context @@ -2630,15 +2755,25 @@ impl NexusExternalApi for NexusExternalApiImpl { async fn affinity_group_create( rqctx: RequestContext, - _query_params: Query, - _new_affinity_group_params: TypedBody, + query_params: Query, + new_affinity_group_params: TypedBody, ) -> Result, HttpError> { let apictx = rqctx.context(); let handler = async { let opctx = crate::context::op_context_for_external_api(&rqctx).await?; let nexus = &apictx.context.nexus; - Err(nexus.unimplemented_todo(&opctx, Unimpl::Public).await.into()) + let query = query_params.into_inner(); + let project_lookup = nexus.project_lookup(&opctx, query)?; + let new_affinity_group = new_affinity_group_params.into_inner(); + let affinity_group = nexus + .affinity_group_create( + &opctx, + &project_lookup, + new_affinity_group, + ) + .await?; + Ok(HttpResponseCreated(affinity_group)) }; apictx .context @@ -2649,16 +2784,28 @@ impl NexusExternalApi for NexusExternalApiImpl { async fn affinity_group_update( rqctx: RequestContext, - _query_params: Query, - _path_params: Path, - _updated_group: TypedBody, + query_params: Query, + path_params: Path, + updated_group: TypedBody, ) -> Result, HttpError> { let apictx = rqctx.context(); let handler = async { let opctx = crate::context::op_context_for_external_api(&rqctx).await?; let nexus = &apictx.context.nexus; - Err(nexus.unimplemented_todo(&opctx, Unimpl::Public).await.into()) + let path = path_params.into_inner(); + let updates = updated_group.into_inner(); + let query = query_params.into_inner(); + let group_selector = params::AffinityGroupSelector { + project: query.project, + affinity_group: path.affinity_group, + }; + let group_lookup = + nexus.affinity_group_lookup(&opctx, group_selector)?; + let affinity_group = nexus + .affinity_group_update(&opctx, &group_lookup, &updates) + .await?; + Ok(HttpResponseOk(affinity_group)) }; apictx .context @@ -2669,15 +2816,24 @@ impl NexusExternalApi for NexusExternalApiImpl { async fn affinity_group_delete( rqctx: RequestContext, - _query_params: Query, - _path_params: Path, + query_params: Query, + path_params: Path, ) -> Result { let apictx = rqctx.context(); let handler = async { let opctx = crate::context::op_context_for_external_api(&rqctx).await?; let nexus = &apictx.context.nexus; - Err(nexus.unimplemented_todo(&opctx, Unimpl::Public).await.into()) + let path = path_params.into_inner(); + let query = query_params.into_inner(); + let group_selector = params::AffinityGroupSelector { + project: query.project, + affinity_group: path.affinity_group, + }; + let group_lookup = + nexus.affinity_group_lookup(&opctx, group_selector)?; + nexus.affinity_group_delete(&opctx, &group_lookup).await?; + Ok(HttpResponseDeleted()) }; apictx .context @@ -2688,7 +2844,7 @@ impl NexusExternalApi for NexusExternalApiImpl { async fn anti_affinity_group_list( rqctx: RequestContext, - _query_params: Query>, + query_params: Query>, ) -> Result>, HttpError> { let apictx = rqctx.context(); @@ -2696,7 +2852,24 @@ impl NexusExternalApi for NexusExternalApiImpl { let nexus = &apictx.context.nexus; let opctx = crate::context::op_context_for_external_api(&rqctx).await?; - Err(nexus.unimplemented_todo(&opctx, Unimpl::Public).await.into()) + 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 project_lookup = + nexus.project_lookup(&opctx, scan_params.selector.clone())?; + let groups = nexus + .anti_affinity_group_list( + &opctx, + &project_lookup, + &paginated_by, + ) + .await?; + Ok(HttpResponseOk(ScanByNameOrId::results_page( + &query, + groups, + &marker_for_name_or_id, + )?)) }; apictx .context @@ -2707,15 +2880,28 @@ impl NexusExternalApi for NexusExternalApiImpl { async fn anti_affinity_group_view( rqctx: RequestContext, - _query_params: Query, - _path_params: Path, + query_params: Query, + path_params: Path, ) -> Result, HttpError> { let apictx = rqctx.context(); let handler = async { let nexus = &apictx.context.nexus; + let path = path_params.into_inner(); let opctx = crate::context::op_context_for_external_api(&rqctx).await?; - Err(nexus.unimplemented_todo(&opctx, Unimpl::Public).await.into()) + let query = query_params.into_inner(); + + let group_selector = params::AntiAffinityGroupSelector { + anti_affinity_group: path.anti_affinity_group, + project: query.project.clone(), + }; + + let (.., group) = nexus + .anti_affinity_group_lookup(&opctx, group_selector)? + .fetch() + .await?; + + Ok(HttpResponseOk(group.into())) }; apictx .context @@ -2726,8 +2912,8 @@ impl NexusExternalApi for NexusExternalApiImpl { async fn anti_affinity_group_member_list( rqctx: RequestContext, - _query_params: Query>, - _path_params: Path, + query_params: Query>, + path_params: Path, ) -> Result>, HttpError> { let apictx = rqctx.context(); @@ -2735,7 +2921,30 @@ impl NexusExternalApi for NexusExternalApiImpl { let opctx = crate::context::op_context_for_external_api(&rqctx).await?; let nexus = &apictx.context.nexus; - Err(nexus.unimplemented_todo(&opctx, Unimpl::Public).await.into()) + let path = path_params.into_inner(); + let query = query_params.into_inner(); + let pag_params = data_page_params_for(&rqctx, &query)?; + let scan_params = ScanById::from_query(&query)?; + let paginated_by = id_pagination(&pag_params, scan_params)?; + + let group_selector = params::AntiAffinityGroupSelector { + project: scan_params.selector.project.clone(), + anti_affinity_group: path.anti_affinity_group, + }; + let group_lookup = + nexus.anti_affinity_group_lookup(&opctx, group_selector)?; + let group_members = nexus + .anti_affinity_group_member_list( + &opctx, + &group_lookup, + &paginated_by, + ) + .await?; + Ok(HttpResponseOk(ScanById::results_page( + &query, + group_members, + &marker_for_id, + )?)) }; apictx .context @@ -2746,15 +2955,42 @@ impl NexusExternalApi for NexusExternalApiImpl { async fn anti_affinity_group_member_instance_view( rqctx: RequestContext, - _query_params: Query, - _path_params: Path, + query_params: Query, + path_params: Path, ) -> Result, HttpError> { let apictx = rqctx.context(); let handler = async { let nexus = &apictx.context.nexus; + let path = path_params.into_inner(); let opctx = crate::context::op_context_for_external_api(&rqctx).await?; - Err(nexus.unimplemented_todo(&opctx, Unimpl::Public).await.into()) + let query = query_params.into_inner(); + + // Select group + let group_selector = params::AntiAffinityGroupSelector { + anti_affinity_group: path.anti_affinity_group, + project: query.project.clone(), + }; + let group_lookup = + nexus.anti_affinity_group_lookup(&opctx, group_selector)?; + + // Select instance + let instance_selector = params::InstanceSelector { + project: query.project, + instance: path.instance, + }; + let instance_lookup = + nexus.instance_lookup(&opctx, instance_selector)?; + + let group = nexus + .anti_affinity_group_member_view( + &opctx, + &group_lookup, + &instance_lookup, + ) + .await?; + + Ok(HttpResponseOk(group)) }; apictx .context @@ -2765,15 +3001,41 @@ impl NexusExternalApi for NexusExternalApiImpl { async fn anti_affinity_group_member_instance_add( rqctx: RequestContext, - _query_params: Query, - _path_params: Path, + query_params: Query, + path_params: Path, ) -> Result, HttpError> { let apictx = rqctx.context(); let handler = async { let opctx = crate::context::op_context_for_external_api(&rqctx).await?; let nexus = &apictx.context.nexus; - Err(nexus.unimplemented_todo(&opctx, Unimpl::Public).await.into()) + let path = path_params.into_inner(); + let query = query_params.into_inner(); + + // Select group + let group_selector = params::AntiAffinityGroupSelector { + anti_affinity_group: path.anti_affinity_group, + project: query.project.clone(), + }; + let group_lookup = + nexus.anti_affinity_group_lookup(&opctx, group_selector)?; + + // Select instance + let instance_selector = params::InstanceSelector { + project: query.project, + instance: path.instance, + }; + let instance_lookup = + nexus.instance_lookup(&opctx, instance_selector)?; + + let member = nexus + .anti_affinity_group_member_add( + &opctx, + &group_lookup, + &instance_lookup, + ) + .await?; + Ok(HttpResponseCreated(member)) }; apictx .context @@ -2784,15 +3046,41 @@ impl NexusExternalApi for NexusExternalApiImpl { async fn anti_affinity_group_member_instance_delete( rqctx: RequestContext, - _query_params: Query, - _path_params: Path, + query_params: Query, + path_params: Path, ) -> Result { let apictx = rqctx.context(); let handler = async { let opctx = crate::context::op_context_for_external_api(&rqctx).await?; let nexus = &apictx.context.nexus; - Err(nexus.unimplemented_todo(&opctx, Unimpl::Public).await.into()) + let path = path_params.into_inner(); + let query = query_params.into_inner(); + + // Select group + let group_selector = params::AntiAffinityGroupSelector { + anti_affinity_group: path.anti_affinity_group, + project: query.project.clone(), + }; + let group_lookup = + nexus.anti_affinity_group_lookup(&opctx, group_selector)?; + + // Select instance + let instance_selector = params::InstanceSelector { + project: query.project, + instance: path.instance, + }; + let instance_lookup = + nexus.instance_lookup(&opctx, instance_selector)?; + + nexus + .anti_affinity_group_member_delete( + &opctx, + &group_lookup, + &instance_lookup, + ) + .await?; + Ok(HttpResponseDeleted()) }; apictx .context @@ -2803,8 +3091,8 @@ impl NexusExternalApi for NexusExternalApiImpl { async fn anti_affinity_group_create( rqctx: RequestContext, - _query_params: Query, - _new_anti_affinity_group_params: TypedBody< + query_params: Query, + new_anti_affinity_group_params: TypedBody< params::AntiAffinityGroupCreate, >, ) -> Result, HttpError> { @@ -2813,7 +3101,18 @@ impl NexusExternalApi for NexusExternalApiImpl { let opctx = crate::context::op_context_for_external_api(&rqctx).await?; let nexus = &apictx.context.nexus; - Err(nexus.unimplemented_todo(&opctx, Unimpl::Public).await.into()) + let query = query_params.into_inner(); + let project_lookup = nexus.project_lookup(&opctx, query)?; + let new_anti_affinity_group = + new_anti_affinity_group_params.into_inner(); + let anti_affinity_group = nexus + .anti_affinity_group_create( + &opctx, + &project_lookup, + new_anti_affinity_group, + ) + .await?; + Ok(HttpResponseCreated(anti_affinity_group)) }; apictx .context @@ -2824,16 +3123,28 @@ impl NexusExternalApi for NexusExternalApiImpl { async fn anti_affinity_group_update( rqctx: RequestContext, - _query_params: Query, - _path_params: Path, - _updated_group: TypedBody, + query_params: Query, + path_params: Path, + updated_group: TypedBody, ) -> Result, HttpError> { let apictx = rqctx.context(); let handler = async { let opctx = crate::context::op_context_for_external_api(&rqctx).await?; let nexus = &apictx.context.nexus; - Err(nexus.unimplemented_todo(&opctx, Unimpl::Public).await.into()) + let path = path_params.into_inner(); + let updates = updated_group.into_inner(); + let query = query_params.into_inner(); + let group_selector = params::AntiAffinityGroupSelector { + project: query.project, + anti_affinity_group: path.anti_affinity_group, + }; + let group_lookup = + nexus.anti_affinity_group_lookup(&opctx, group_selector)?; + let anti_affinity_group = nexus + .anti_affinity_group_update(&opctx, &group_lookup, &updates) + .await?; + Ok(HttpResponseOk(anti_affinity_group)) }; apictx .context @@ -2844,15 +3155,24 @@ impl NexusExternalApi for NexusExternalApiImpl { async fn anti_affinity_group_delete( rqctx: RequestContext, - _query_params: Query, - _path_params: Path, + query_params: Query, + path_params: Path, ) -> Result { let apictx = rqctx.context(); let handler = async { let opctx = crate::context::op_context_for_external_api(&rqctx).await?; let nexus = &apictx.context.nexus; - Err(nexus.unimplemented_todo(&opctx, Unimpl::Public).await.into()) + let path = path_params.into_inner(); + let query = query_params.into_inner(); + let group_selector = params::AntiAffinityGroupSelector { + project: query.project, + anti_affinity_group: path.anti_affinity_group, + }; + let group_lookup = + nexus.anti_affinity_group_lookup(&opctx, group_selector)?; + nexus.anti_affinity_group_delete(&opctx, &group_lookup).await?; + Ok(HttpResponseDeleted()) }; apictx .context diff --git a/nexus/test-utils/src/resource_helpers.rs b/nexus/test-utils/src/resource_helpers.rs index 9fe2b62a276..011c4a72773 100644 --- a/nexus/test-utils/src/resource_helpers.rs +++ b/nexus/test-utils/src/resource_helpers.rs @@ -21,6 +21,8 @@ use nexus_types::external_api::shared::Baseboard; use nexus_types::external_api::shared::IdentityType; use nexus_types::external_api::shared::IpRange; use nexus_types::external_api::views; +use nexus_types::external_api::views::AffinityGroup; +use nexus_types::external_api::views::AntiAffinityGroup; use nexus_types::external_api::views::Certificate; use nexus_types::external_api::views::FloatingIp; use nexus_types::external_api::views::InternetGateway; @@ -33,9 +35,11 @@ use nexus_types::external_api::views::VpcSubnet; use nexus_types::external_api::views::{Project, Silo, Vpc, VpcRouter}; use nexus_types::identity::Resource; use nexus_types::internal_api::params as internal_params; +use omicron_common::api::external::AffinityPolicy; use omicron_common::api::external::ByteCount; use omicron_common::api::external::Disk; use omicron_common::api::external::Error; +use omicron_common::api::external::FailureDomain; use omicron_common::api::external::Generation; use omicron_common::api::external::IdentityMetadataCreateParams; use omicron_common::api::external::Instance; @@ -581,6 +585,46 @@ where object_create_error(client, &url, body, status).await } +pub async fn create_affinity_group( + client: &ClientTestContext, + project_name: &str, + group_name: &str, +) -> AffinityGroup { + object_create( + &client, + format!("/v1/affinity-groups?project={}", &project_name).as_str(), + ¶ms::AffinityGroupCreate { + identity: IdentityMetadataCreateParams { + name: group_name.parse().unwrap(), + description: String::from("affinity group description"), + }, + policy: AffinityPolicy::Fail, + failure_domain: FailureDomain::Sled, + }, + ) + .await +} + +pub async fn create_anti_affinity_group( + client: &ClientTestContext, + project_name: &str, + group_name: &str, +) -> AntiAffinityGroup { + object_create( + &client, + format!("/v1/anti-affinity-groups?project={}", &project_name).as_str(), + ¶ms::AntiAffinityGroupCreate { + identity: IdentityMetadataCreateParams { + name: group_name.parse().unwrap(), + description: String::from("anti-affinity group description"), + }, + policy: AffinityPolicy::Fail, + failure_domain: FailureDomain::Sled, + }, + ) + .await +} + pub async fn create_vpc( client: &ClientTestContext, project_name: &str, diff --git a/nexus/tests/integration_tests/affinity.rs b/nexus/tests/integration_tests/affinity.rs new file mode 100644 index 00000000000..1549db51cba --- /dev/null +++ b/nexus/tests/integration_tests/affinity.rs @@ -0,0 +1,895 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +//! Tests Affinity (and Anti-Affinity) Groups + +use dropshot::test_util::ClientTestContext; +use dropshot::HttpErrorResponseBody; +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_default_ip_pool; +use nexus_test_utils::resource_helpers::create_instance_with; +use nexus_test_utils::resource_helpers::create_project; +use nexus_test_utils::resource_helpers::object_create; +use nexus_test_utils::resource_helpers::object_create_error; +use nexus_test_utils::resource_helpers::object_delete; +use nexus_test_utils::resource_helpers::object_delete_error; +use nexus_test_utils::resource_helpers::object_get; +use nexus_test_utils::resource_helpers::object_get_error; +use nexus_test_utils::resource_helpers::object_put; +use nexus_test_utils::resource_helpers::object_put_error; +use nexus_test_utils::resource_helpers::objects_list_page_authz; +use nexus_test_utils_macros::nexus_test; +use nexus_types::external_api::params; +use nexus_types::external_api::views::AffinityGroup; +use nexus_types::external_api::views::AntiAffinityGroup; +use nexus_types::external_api::views::Sled; +use nexus_types::external_api::views::SledInstance; +use omicron_common::api::external; +use omicron_common::api::external::AffinityGroupMember; +use omicron_common::api::external::AntiAffinityGroupMember; +use omicron_common::api::external::ObjectIdentity; +use std::collections::BTreeSet; +use std::marker::PhantomData; + +type ControlPlaneTestContext = + nexus_test_utils::ControlPlaneTestContext; + +// Simplifying mechanism for making calls to Nexus' external API +struct ApiHelper<'a> { + client: &'a ClientTestContext, +} + +// This is an extention of the "ApiHelper", with an opinion about: +// +// - What project (if any) is selected +// - Whether or not we're accessing affinity/anti-affinity groups +struct ProjectScopedApiHelper<'a, T> { + client: &'a ClientTestContext, + project: Option<&'a str>, + affinity_type: PhantomData, +} + +impl ProjectScopedApiHelper<'_, T> { + async fn create_stopped_instance( + &self, + instance_name: &str, + ) -> external::Instance { + create_instance_with( + &self.client, + &self.project.as_ref().expect("Need to specify project name"), + instance_name, + ¶ms::InstanceNetworkInterfaceAttachment::None, + // Disks= + Vec::::new(), + // External IPs= + Vec::::new(), + // Start= + false, + // Auto-restart policy= + None, + ) + .await + } + + async fn groups_list(&self) -> Vec { + let url = groups_url(T::URL_COMPONENT, self.project); + objects_list_page_authz(&self.client, &url).await.items + } + + async fn groups_list_expect_error( + &self, + status: StatusCode, + ) -> HttpErrorResponseBody { + let url = groups_url(T::URL_COMPONENT, self.project); + object_get_error(&self.client, &url, status).await + } + + async fn group_create(&self, group: &str) -> T::Group { + let url = groups_url(T::URL_COMPONENT, self.project); + let params = T::make_create_params(group); + object_create(&self.client, &url, ¶ms).await + } + + async fn group_create_expect_error( + &self, + group: &str, + status: StatusCode, + ) -> HttpErrorResponseBody { + let url = groups_url(T::URL_COMPONENT, self.project); + let params = T::make_create_params(group); + object_create_error(&self.client, &url, ¶ms, status).await + } + + async fn group_get(&self, group: &str) -> T::Group { + let url = group_url(T::URL_COMPONENT, self.project, group); + object_get(&self.client, &url).await + } + + async fn group_get_expect_error( + &self, + group: &str, + status: StatusCode, + ) -> HttpErrorResponseBody { + let url = group_url(T::URL_COMPONENT, self.project, group); + object_get_error(&self.client, &url, status).await + } + + async fn group_update(&self, group: &str) -> T::Group { + let url = group_url(T::URL_COMPONENT, self.project, group); + let params = T::make_update_params(); + object_put(&self.client, &url, ¶ms).await + } + + async fn group_update_expect_error( + &self, + group: &str, + status: StatusCode, + ) -> HttpErrorResponseBody { + let url = group_url(T::URL_COMPONENT, self.project, group); + let params = T::make_update_params(); + object_put_error(&self.client, &url, ¶ms, status).await + } + + async fn group_delete(&self, group: &str) { + let url = group_url(T::URL_COMPONENT, self.project, group); + object_delete(&self.client, &url).await + } + + async fn group_delete_expect_error( + &self, + group: &str, + status: StatusCode, + ) -> HttpErrorResponseBody { + let url = group_url(T::URL_COMPONENT, self.project, group); + object_delete_error(&self.client, &url, status).await + } + + async fn group_members_list(&self, group: &str) -> Vec { + let url = group_members_url(T::URL_COMPONENT, self.project, group); + objects_list_page_authz(&self.client, &url).await.items + } + + async fn group_members_list_expect_error( + &self, + group: &str, + status: StatusCode, + ) -> HttpErrorResponseBody { + let url = group_members_url(T::URL_COMPONENT, self.project, group); + object_get_error(&self.client, &url, status).await + } + + async fn group_member_add(&self, group: &str, instance: &str) -> T::Member { + let url = group_member_instance_url( + T::URL_COMPONENT, + self.project, + group, + instance, + ); + object_create(&self.client, &url, &()).await + } + + async fn group_member_add_expect_error( + &self, + group: &str, + instance: &str, + status: StatusCode, + ) -> HttpErrorResponseBody { + let url = group_member_instance_url( + T::URL_COMPONENT, + self.project, + group, + instance, + ); + object_create_error(&self.client, &url, &(), status).await + } + + async fn group_member_get(&self, group: &str, instance: &str) -> T::Member { + let url = group_member_instance_url( + T::URL_COMPONENT, + self.project, + group, + instance, + ); + object_get(&self.client, &url).await + } + + async fn group_member_get_expect_error( + &self, + group: &str, + instance: &str, + status: StatusCode, + ) -> HttpErrorResponseBody { + let url = group_member_instance_url( + T::URL_COMPONENT, + self.project, + group, + instance, + ); + object_get_error(&self.client, &url, status).await + } + + async fn group_member_delete(&self, group: &str, instance: &str) { + let url = group_member_instance_url( + T::URL_COMPONENT, + self.project, + group, + instance, + ); + object_delete(&self.client, &url).await + } + + async fn group_member_delete_expect_error( + &self, + group: &str, + instance: &str, + status: StatusCode, + ) -> HttpErrorResponseBody { + let url = group_member_instance_url( + T::URL_COMPONENT, + self.project, + group, + instance, + ); + object_delete_error(&self.client, &url, status).await + } +} + +impl<'a> ApiHelper<'a> { + fn new(client: &'a ClientTestContext) -> Self { + Self { client } + } + + fn use_project( + &'a self, + project: &'a str, + ) -> ProjectScopedApiHelper<'a, T> { + ProjectScopedApiHelper { + client: self.client, + project: Some(project), + affinity_type: PhantomData, + } + } + + fn no_project(&'a self) -> ProjectScopedApiHelper<'a, T> { + ProjectScopedApiHelper { + client: self.client, + project: None, + affinity_type: PhantomData, + } + } + + async fn create_project(&self, name: &str) { + create_project(&self.client, name).await; + } + + async fn sleds_list(&self) -> Vec { + let url = "/v1/system/hardware/sleds"; + objects_list_page_authz(&self.client, url).await.items + } + + async fn sled_instance_list(&self, sled: &str) -> Vec { + let url = format!("/v1/system/hardware/sleds/{sled}/instances"); + objects_list_page_authz(&self.client, &url).await.items + } + + async fn start_instance( + &self, + instance: &external::Instance, + ) -> external::Instance { + let uri = format!("/v1/instances/{}/start", instance.identity.id); + + NexusRequest::new( + RequestBuilder::new(&self.client, http::Method::POST, &uri) + .expect_status(Some(http::StatusCode::ACCEPTED)), + ) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .unwrap_or_else(|e| { + panic!("failed to make \"POST\" request to {uri}: {e}") + }) + .parsed_body() + .unwrap() + } +} + +fn project_query_param_suffix(project: Option<&str>) -> String { + if let Some(project) = project { + format!("?project={project}") + } else { + String::new() + } +} + +/// Types and traits used by both affinity and anti-affinity groups. +/// +/// Use this trait if you're trying to test something which appilies to both +/// group types. Conversely, if you're trying to test behavior specific to one +/// type or the other, I recommend that you avoid making your tests generic. +trait AffinityGroupish { + /// The struct used to represent this group. + /// + /// Should be the result of GET-ing this group. + type Group: serde::de::DeserializeOwned + ObjectIdentity; + + /// The struct representing a single member within this group. + type Member: serde::de::DeserializeOwned; + + /// Parameters that can be used to construct this group as a part of a POST + /// request. + type CreateParams: serde::Serialize; + + /// Parameters that can be used to update this group as a part of a PUT + /// request. + type UpdateParams: serde::Serialize; + + const URL_COMPONENT: &'static str; + const RESOURCE_NAME: &'static str; + + fn make_create_params(group_name: &str) -> Self::CreateParams; + fn make_update_params() -> Self::UpdateParams; +} + +// Arbitrary text used to validate PUT calls to groups +const NEW_DESCRIPTION: &'static str = "Updated description"; + +struct AffinityType; + +impl AffinityGroupish for AffinityType { + type Group = AffinityGroup; + type Member = AffinityGroupMember; + type CreateParams = params::AffinityGroupCreate; + type UpdateParams = params::AffinityGroupUpdate; + + const URL_COMPONENT: &'static str = "affinity-groups"; + const RESOURCE_NAME: &'static str = "affinity-group"; + + fn make_create_params(group_name: &str) -> Self::CreateParams { + params::AffinityGroupCreate { + identity: external::IdentityMetadataCreateParams { + name: group_name.parse().unwrap(), + description: String::from("This is a description"), + }, + policy: external::AffinityPolicy::Fail, + failure_domain: external::FailureDomain::Sled, + } + } + + fn make_update_params() -> Self::UpdateParams { + params::AffinityGroupUpdate { + identity: external::IdentityMetadataUpdateParams { + name: None, + description: Some(NEW_DESCRIPTION.to_string()), + }, + } + } +} + +struct AntiAffinityType; + +impl AffinityGroupish for AntiAffinityType { + type Group = AntiAffinityGroup; + type Member = AntiAffinityGroupMember; + type CreateParams = params::AntiAffinityGroupCreate; + type UpdateParams = params::AntiAffinityGroupUpdate; + + const URL_COMPONENT: &'static str = "anti-affinity-groups"; + const RESOURCE_NAME: &'static str = "anti-affinity-group"; + + fn make_create_params(group_name: &str) -> Self::CreateParams { + params::AntiAffinityGroupCreate { + identity: external::IdentityMetadataCreateParams { + name: group_name.parse().unwrap(), + description: String::from("This is a description"), + }, + policy: external::AffinityPolicy::Fail, + failure_domain: external::FailureDomain::Sled, + } + } + + fn make_update_params() -> Self::UpdateParams { + params::AntiAffinityGroupUpdate { + identity: external::IdentityMetadataUpdateParams { + name: None, + description: Some(NEW_DESCRIPTION.to_string()), + }, + } + } +} + +fn groups_url(ty: &str, project: Option<&str>) -> String { + let query_params = project_query_param_suffix(project); + format!("/v1/{ty}{query_params}") +} + +fn group_url(ty: &str, project: Option<&str>, group: &str) -> String { + let query_params = project_query_param_suffix(project); + format!("/v1/{ty}/{group}{query_params}") +} + +fn group_members_url(ty: &str, project: Option<&str>, group: &str) -> String { + let query_params = project_query_param_suffix(project); + format!("/v1/{ty}/{group}/members{query_params}") +} + +fn group_member_instance_url( + ty: &str, + project: Option<&str>, + group: &str, + instance: &str, +) -> String { + let query_params = project_query_param_suffix(project); + format!("/v1/{ty}/{group}/members/instance/{instance}{query_params}") +} + +#[nexus_test(extra_sled_agents = 2)] +async fn test_affinity_group_usage(cptestctx: &ControlPlaneTestContext) { + let external_client = &cptestctx.external_client; + + const PROJECT_NAME: &'static str = "test-project"; + const GROUP_NAME: &'static str = "group"; + const EXPECTED_SLEDS: usize = 3; + const INSTANCE_COUNT: usize = EXPECTED_SLEDS; + + let api = ApiHelper::new(external_client); + + // Verify the expected sleds to begin with. + let sleds = api.sleds_list().await; + assert_eq!(sleds.len(), EXPECTED_SLEDS); + + // Verify that there are no instances on the sleds. + for sled in &sleds { + let sled_id = sled.identity.id.to_string(); + assert!(api.sled_instance_list(&sled_id).await.is_empty()); + } + + // Create an IP pool and project that we'll use for testing. + create_default_ip_pool(&external_client).await; + api.create_project(PROJECT_NAME).await; + + let project_api = api.use_project::(PROJECT_NAME); + + let mut instances = Vec::new(); + for i in 0..INSTANCE_COUNT { + instances.push( + project_api + .create_stopped_instance(&format!("test-instance-{i}")) + .await, + ); + } + + // When we start, we observe no affinity groups + let groups = project_api.groups_list().await; + assert!(groups.is_empty()); + + // We can now create a group and observe it + let group = project_api.group_create(GROUP_NAME).await; + + // We can list it and also GET the group specifically + let groups = project_api.groups_list().await; + assert_eq!(groups.len(), 1); + assert_eq!(groups[0].identity.id, group.identity.id); + + let observed_group = project_api.group_get(GROUP_NAME).await; + assert_eq!(observed_group.identity.id, group.identity.id); + + // List all members of the affinity group (expect nothing) + let members = project_api.group_members_list(GROUP_NAME).await; + assert!(members.is_empty()); + + // Add these instances to an affinity group + for instance in &instances { + project_api + .group_member_add(GROUP_NAME, &instance.identity.name.to_string()) + .await; + } + + // List members again (expect all instances) + let members = project_api.group_members_list(GROUP_NAME).await; + assert_eq!(members.len(), instances.len()); + + // We can also list each member + for instance in &instances { + project_api + .group_member_get(GROUP_NAME, instance.identity.name.as_str()) + .await; + } + + // Start the instances we created earlier. + // + // We don't actually care that they're "running" from the perspective of the + // simulated sled agent, we just want placement to be triggered from Nexus. + for instance in &instances { + api.start_instance(&instance).await; + } + + // Use a BTreeSet so we can ignore ordering when comparing instance + // placement. + let expected_instances = instances + .iter() + .map(|instance| instance.identity.id) + .collect::>(); + + // We expect that all sleds will be empty, except for one, which will have + // all the instances in our affinity group. + let mut empty_sleds = 0; + let mut populated_sleds = 0; + for sled in &sleds { + let observed_instances = api + .sled_instance_list(&sled.identity.id.to_string()) + .await + .into_iter() + .map(|sled_instance| sled_instance.identity.id) + .collect::>(); + + if !observed_instances.is_empty() { + assert_eq!(observed_instances, expected_instances); + populated_sleds += 1; + } else { + empty_sleds += 1; + } + } + assert_eq!(populated_sleds, 1); + assert_eq!(empty_sleds, 2); +} + +#[nexus_test(extra_sled_agents = 2)] +async fn test_anti_affinity_group_usage(cptestctx: &ControlPlaneTestContext) { + let external_client = &cptestctx.external_client; + + const PROJECT_NAME: &'static str = "test-project"; + const GROUP_NAME: &'static str = "group"; + const EXPECTED_SLEDS: usize = 3; + const INSTANCE_COUNT: usize = EXPECTED_SLEDS; + + let api = ApiHelper::new(external_client); + + // Verify the expected sleds to begin with. + let sleds = api.sleds_list().await; + assert_eq!(sleds.len(), EXPECTED_SLEDS); + + // Verify that there are no instances on the sleds. + for sled in &sleds { + let sled_id = sled.identity.id.to_string(); + assert!(api.sled_instance_list(&sled_id).await.is_empty()); + } + + // Create an IP pool and project that we'll use for testing. + create_default_ip_pool(&external_client).await; + api.create_project(PROJECT_NAME).await; + + let project_api = api.use_project::(PROJECT_NAME); + + let mut instances = Vec::new(); + for i in 0..INSTANCE_COUNT { + instances.push( + project_api + .create_stopped_instance(&format!("test-instance-{i}")) + .await, + ); + } + + // When we start, we observe no anti-affinity groups + let groups = project_api.groups_list().await; + assert!(groups.is_empty()); + + // We can now create a group and observe it + let group = project_api.group_create(GROUP_NAME).await; + + // We can list it and also GET the group specifically + let groups = project_api.groups_list().await; + assert_eq!(groups.len(), 1); + assert_eq!(groups[0].identity.id, group.identity.id); + + let observed_group = project_api.group_get(GROUP_NAME).await; + assert_eq!(observed_group.identity.id, group.identity.id); + + // List all members of the anti-affinity group (expect nothing) + let members = project_api.group_members_list(GROUP_NAME).await; + assert!(members.is_empty()); + + // Add these instances to the anti-affinity group + for instance in &instances { + project_api + .group_member_add(GROUP_NAME, &instance.identity.name.to_string()) + .await; + } + + // List members again (expect all instances) + let members = project_api.group_members_list(GROUP_NAME).await; + assert_eq!(members.len(), instances.len()); + + // We can also list each member + for instance in &instances { + project_api + .group_member_get(GROUP_NAME, instance.identity.name.as_str()) + .await; + } + + // Start the instances we created earlier. + // + // We don't actually care that they're "running" from the perspective of the + // simulated sled agent, we just want placement to be triggered from Nexus. + for instance in &instances { + api.start_instance(&instance).await; + } + + let mut expected_instances = instances + .iter() + .map(|instance| instance.identity.id) + .collect::>(); + + // We expect that each sled will have a since instance, as all of the + // instances will want to be anti-located from each other. + for sled in &sleds { + let observed_instances = api + .sled_instance_list(&sled.identity.id.to_string()) + .await + .into_iter() + .map(|sled_instance| sled_instance.identity.id) + .collect::>(); + + assert_eq!( + observed_instances.len(), + 1, + "All instances should be placed on distinct sleds" + ); + + assert!( + expected_instances.remove(&observed_instances[0]), + "The instance {} was observed on multiple sleds", + observed_instances[0] + ); + } + + assert!( + expected_instances.is_empty(), + "Did not find allocations for some instances: {expected_instances:?}" + ); +} + +#[nexus_test] +async fn test_affinity_group_crud(cptestctx: &ControlPlaneTestContext) { + let external_client = &cptestctx.external_client; + test_group_crud::(external_client).await; +} + +#[nexus_test] +async fn test_anti_affinity_group_crud(cptestctx: &ControlPlaneTestContext) { + let external_client = &cptestctx.external_client; + test_group_crud::(external_client).await; +} + +async fn test_group_crud(client: &ClientTestContext) { + const PROJECT_NAME: &'static str = "test-project"; + const GROUP_NAME: &'static str = "group"; + + 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); + + let instance = project_api.create_stopped_instance("test-instance").await; + + // When we start, we observe no affinity groups + let groups = project_api.groups_list().await; + assert!(groups.is_empty()); + + // We can now create a group and observe it + project_api.group_create(GROUP_NAME).await; + let response = project_api + .group_create_expect_error(GROUP_NAME, StatusCode::BAD_REQUEST) + .await; + assert_eq!( + response.message, + format!("already exists: {} \"{GROUP_NAME}\"", T::RESOURCE_NAME), + ); + + // We can modify the group itself + let group = project_api.group_update(GROUP_NAME).await; + assert_eq!(group.identity().description, NEW_DESCRIPTION); + + // List all members of the affinity group (expect nothing) + let members = project_api.group_members_list(GROUP_NAME).await; + assert!(members.is_empty()); + + // Add the instance to the affinity group + let instance_name = &instance.identity.name.to_string(); + project_api.group_member_add(GROUP_NAME, &instance_name).await; + let response = project_api + .group_member_add_expect_error( + GROUP_NAME, + &instance_name, + StatusCode::BAD_REQUEST, + ) + .await; + assert_eq!( + response.message, + format!( + "already exists: {}-member \"{}\"", + T::RESOURCE_NAME, + instance.identity.id + ), + ); + + // List members again (expect the instance) + let members = project_api.group_members_list(GROUP_NAME).await; + assert_eq!(members.len(), 1); + project_api + .group_member_get(GROUP_NAME, instance.identity.name.as_str()) + .await; + + // Delete the member, observe that it is gone + project_api.group_member_delete(GROUP_NAME, &instance_name).await; + project_api + .group_member_delete_expect_error( + GROUP_NAME, + &instance_name, + StatusCode::NOT_FOUND, + ) + .await; + let members = project_api.group_members_list(GROUP_NAME).await; + assert_eq!(members.len(), 0); + project_api + .group_member_get_expect_error( + GROUP_NAME, + &instance_name, + StatusCode::NOT_FOUND, + ) + .await; + + // Delete the group, observe that it is gone + project_api.group_delete(GROUP_NAME).await; + project_api + .group_delete_expect_error(GROUP_NAME, StatusCode::NOT_FOUND) + .await; + project_api.group_get_expect_error(GROUP_NAME, StatusCode::NOT_FOUND).await; + let groups = project_api.groups_list().await; + assert!(groups.is_empty()); +} + +#[nexus_test] +async fn test_affinity_group_project_selector( + cptestctx: &ControlPlaneTestContext, +) { + let external_client = &cptestctx.external_client; + test_group_project_selector::(external_client).await; +} + +#[nexus_test] +async fn test_anti_affinity_group_project_selector( + cptestctx: &ControlPlaneTestContext, +) { + let external_client = &cptestctx.external_client; + test_group_project_selector::(external_client).await; +} + +async fn test_group_project_selector( + client: &ClientTestContext, +) { + const PROJECT_NAME: &'static str = "test-project"; + const GROUP_NAME: &'static str = "group"; + + 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; + + // All requests use the "?project={PROJECT_NAME}" query parameter + let project_api = api.use_project::(PROJECT_NAME); + // All requests omit the project query parameter + let no_project_api = api.no_project::(); + + let instance = project_api.create_stopped_instance("test-instance").await; + + // We can only list groups within a project + no_project_api.groups_list_expect_error(StatusCode::BAD_REQUEST).await; + let _groups = project_api.groups_list().await; + + // We can only create a group within a project + no_project_api + .group_create_expect_error(GROUP_NAME, StatusCode::BAD_REQUEST) + .await; + let group = project_api.group_create(GROUP_NAME).await; + + // Once we've created a group, we can access it by: + // + // - Project + Group Name, or + // - No Project + Group ID + // + // Other combinations are considered bad requests. + let group_id = group.identity().id.to_string(); + + project_api.group_get(GROUP_NAME).await; + no_project_api.group_get(&group_id).await; + project_api + .group_get_expect_error(&group_id, StatusCode::BAD_REQUEST) + .await; + no_project_api + .group_get_expect_error(GROUP_NAME, StatusCode::BAD_REQUEST) + .await; + + // Same for listing members + project_api.group_members_list(GROUP_NAME).await; + no_project_api.group_members_list(&group_id).await; + project_api + .group_members_list_expect_error(&group_id, StatusCode::BAD_REQUEST) + .await; + no_project_api + .group_members_list_expect_error(GROUP_NAME, StatusCode::BAD_REQUEST) + .await; + + // Same for updating the group + project_api.group_update(GROUP_NAME).await; + no_project_api.group_update(&group_id).await; + project_api + .group_update_expect_error(&group_id, StatusCode::BAD_REQUEST) + .await; + no_project_api + .group_update_expect_error(GROUP_NAME, StatusCode::BAD_REQUEST) + .await; + + // Group Members can be added by name or UUID + let instance_name = instance.identity.name.as_str(); + let instance_id = instance.identity.id.to_string(); + project_api.group_member_add(GROUP_NAME, instance_name).await; + project_api.group_member_delete(GROUP_NAME, instance_name).await; + no_project_api.group_member_add(&group_id, &instance_id).await; + no_project_api.group_member_delete(&group_id, &instance_id).await; + + // Trying to use UUIDs with the project selector is invalid + project_api + .group_member_add_expect_error( + GROUP_NAME, + &instance_id, + StatusCode::BAD_REQUEST, + ) + .await; + project_api + .group_member_add_expect_error( + &group_id, + instance_name, + StatusCode::BAD_REQUEST, + ) + .await; + + // Using any names without the project selector is invalid + no_project_api + .group_member_add_expect_error( + GROUP_NAME, + &instance_id, + StatusCode::BAD_REQUEST, + ) + .await; + no_project_api + .group_member_add_expect_error( + &group_id, + instance_name, + StatusCode::BAD_REQUEST, + ) + .await; + no_project_api + .group_member_add_expect_error( + GROUP_NAME, + instance_name, + StatusCode::BAD_REQUEST, + ) + .await; + + // Group deletion also prevents mixing {project, ID} and {no-project, name}. + project_api + .group_delete_expect_error(&group_id, StatusCode::BAD_REQUEST) + .await; + no_project_api + .group_delete_expect_error(GROUP_NAME, StatusCode::BAD_REQUEST) + .await; + no_project_api.group_delete(&group_id).await; +} diff --git a/nexus/tests/integration_tests/endpoints.rs b/nexus/tests/integration_tests/endpoints.rs index 70104cac900..618752227e6 100644 --- a/nexus/tests/integration_tests/endpoints.rs +++ b/nexus/tests/integration_tests/endpoints.rs @@ -24,8 +24,10 @@ use nexus_types::external_api::shared::IpRange; use nexus_types::external_api::shared::Ipv4Range; use nexus_types::external_api::views::SledProvisionPolicy; use omicron_common::api::external::AddressLotKind; +use omicron_common::api::external::AffinityPolicy; use omicron_common::api::external::AllowedSourceIps; use omicron_common::api::external::ByteCount; +use omicron_common::api::external::FailureDomain; use omicron_common::api::external::IdentityMetadataCreateParams; use omicron_common::api::external::IdentityMetadataUpdateParams; use omicron_common::api::external::InstanceCpuCount; @@ -160,6 +162,14 @@ pub static DEMO_PROJECT_URL_IMAGES: LazyLock = LazyLock::new(|| format!("/v1/images?project={}", *DEMO_PROJECT_NAME)); pub static DEMO_PROJECT_URL_INSTANCES: LazyLock = LazyLock::new(|| format!("/v1/instances?project={}", *DEMO_PROJECT_NAME)); +pub static DEMO_PROJECT_URL_AFFINITY_GROUPS: LazyLock = + LazyLock::new(|| { + format!("/v1/affinity-groups?project={}", *DEMO_PROJECT_NAME) + }); +pub static DEMO_PROJECT_URL_ANTI_AFFINITY_GROUPS: LazyLock = + LazyLock::new(|| { + format!("/v1/anti-affinity-groups?project={}", *DEMO_PROJECT_NAME) + }); pub static DEMO_PROJECT_URL_SNAPSHOTS: LazyLock = LazyLock::new(|| format!("/v1/snapshots?project={}", *DEMO_PROJECT_NAME)); pub static DEMO_PROJECT_URL_VPCS: LazyLock = @@ -434,9 +444,102 @@ pub static DEMO_IMPORT_DISK_FINALIZE_URL: LazyLock = ) }); +// Affinity/Anti- group used for testing + +pub static DEMO_AFFINITY_GROUP_NAME: LazyLock = + LazyLock::new(|| "demo-affinity-group".parse().unwrap()); +pub static DEMO_AFFINITY_GROUP_URL: LazyLock = LazyLock::new(|| { + format!( + "/v1/affinity-groups/{}?{}", + *DEMO_AFFINITY_GROUP_NAME, *DEMO_PROJECT_SELECTOR + ) +}); +pub static DEMO_AFFINITY_GROUP_MEMBERS_URL: LazyLock = + LazyLock::new(|| { + format!( + "/v1/affinity-groups/{}/members?{}", + *DEMO_AFFINITY_GROUP_NAME, *DEMO_PROJECT_SELECTOR + ) + }); +pub static DEMO_AFFINITY_GROUP_INSTANCE_MEMBER_URL: LazyLock = + LazyLock::new(|| { + format!( + "/v1/affinity-groups/{}/members/instance/{}?{}", + *DEMO_AFFINITY_GROUP_NAME, + *DEMO_STOPPED_INSTANCE_NAME, + *DEMO_PROJECT_SELECTOR + ) + }); +pub static DEMO_AFFINITY_GROUP_CREATE: LazyLock = + LazyLock::new(|| params::AffinityGroupCreate { + identity: IdentityMetadataCreateParams { + name: DEMO_AFFINITY_GROUP_NAME.clone(), + description: String::from(""), + }, + policy: AffinityPolicy::Allow, + failure_domain: FailureDomain::Sled, + }); +pub static DEMO_AFFINITY_GROUP_UPDATE: LazyLock = + LazyLock::new(|| params::AffinityGroupUpdate { + identity: IdentityMetadataUpdateParams { + name: None, + description: Some(String::from("an updated description")), + }, + }); + +pub static DEMO_ANTI_AFFINITY_GROUP_NAME: LazyLock = + LazyLock::new(|| "demo-anti-affinity-group".parse().unwrap()); +pub static DEMO_ANTI_AFFINITY_GROUPS_URL: LazyLock = + LazyLock::new(|| { + format!("/v1/anti-affinity-groups?{}", *DEMO_PROJECT_SELECTOR) + }); +pub static DEMO_ANTI_AFFINITY_GROUP_URL: LazyLock = + LazyLock::new(|| { + format!( + "/v1/anti-affinity-groups/{}?{}", + *DEMO_ANTI_AFFINITY_GROUP_NAME, *DEMO_PROJECT_SELECTOR + ) + }); +pub static DEMO_ANTI_AFFINITY_GROUP_MEMBERS_URL: LazyLock = + LazyLock::new(|| { + format!( + "/v1/anti-affinity-groups/{}/members?{}", + *DEMO_ANTI_AFFINITY_GROUP_NAME, *DEMO_PROJECT_SELECTOR + ) + }); +pub static DEMO_ANTI_AFFINITY_GROUP_INSTANCE_MEMBER_URL: LazyLock = + LazyLock::new(|| { + format!( + "/v1/anti-affinity-groups/{}/members/instance/{}?{}", + *DEMO_ANTI_AFFINITY_GROUP_NAME, + *DEMO_STOPPED_INSTANCE_NAME, + *DEMO_PROJECT_SELECTOR + ) + }); +pub static DEMO_ANTI_AFFINITY_GROUP_CREATE: LazyLock< + params::AntiAffinityGroupCreate, +> = LazyLock::new(|| params::AntiAffinityGroupCreate { + identity: IdentityMetadataCreateParams { + name: DEMO_ANTI_AFFINITY_GROUP_NAME.clone(), + description: String::from(""), + }, + policy: AffinityPolicy::Allow, + failure_domain: FailureDomain::Sled, +}); +pub static DEMO_ANTI_AFFINITY_GROUP_UPDATE: LazyLock< + params::AntiAffinityGroupUpdate, +> = LazyLock::new(|| params::AntiAffinityGroupUpdate { + identity: IdentityMetadataUpdateParams { + name: None, + description: Some(String::from("an updated description")), + }, +}); + // Instance used for testing pub static DEMO_INSTANCE_NAME: LazyLock = LazyLock::new(|| "demo-instance".parse().unwrap()); +pub static DEMO_STOPPED_INSTANCE_NAME: LazyLock = + LazyLock::new(|| "demo-stopped-instance".parse().unwrap()); pub static DEMO_INSTANCE_URL: LazyLock = LazyLock::new(|| { format!("/v1/instances/{}?{}", *DEMO_INSTANCE_NAME, *DEMO_PROJECT_SELECTOR) }); @@ -537,6 +640,26 @@ pub static DEMO_INSTANCE_CREATE: LazyLock = start: true, auto_restart_policy: Default::default(), }); +pub static DEMO_STOPPED_INSTANCE_CREATE: LazyLock = + LazyLock::new(|| params::InstanceCreate { + identity: IdentityMetadataCreateParams { + name: DEMO_STOPPED_INSTANCE_NAME.clone(), + description: String::from(""), + }, + ncpus: InstanceCpuCount(1), + memory: ByteCount::from_gibibytes_u32(16), + hostname: "demo-instance".parse().unwrap(), + user_data: vec![], + ssh_public_keys: Some(Vec::new()), + network_interfaces: params::InstanceNetworkInterfaceAttachment::Default, + external_ips: vec![params::ExternalIpCreate::Ephemeral { + pool: Some(DEMO_IP_POOL_NAME.clone().into()), + }], + disks: vec![], + boot_disk: None, + start: true, + auto_restart_policy: Default::default(), + }); pub static DEMO_INSTANCE_UPDATE: LazyLock = LazyLock::new(|| params::InstanceUpdate { boot_disk: None, @@ -1779,6 +1902,98 @@ pub static VERIFY_ENDPOINTS: LazyLock> = .unwrap(), )], }, + /* Affinity Groups */ + VerifyEndpoint { + url: &DEMO_PROJECT_URL_AFFINITY_GROUPS, + visibility: Visibility::Protected, + unprivileged_access: UnprivilegedAccess::None, + + allowed_methods: vec![ + AllowedMethod::Post( + serde_json::to_value(&*DEMO_AFFINITY_GROUP_CREATE) + .unwrap(), + ), + AllowedMethod::Get, + ], + }, + VerifyEndpoint { + url: &DEMO_AFFINITY_GROUP_URL, + visibility: Visibility::Protected, + unprivileged_access: UnprivilegedAccess::None, + + allowed_methods: vec![ + AllowedMethod::Get, + AllowedMethod::Delete, + AllowedMethod::Put( + serde_json::to_value(&*DEMO_AFFINITY_GROUP_UPDATE) + .unwrap(), + ), + ], + }, + VerifyEndpoint { + url: &DEMO_AFFINITY_GROUP_MEMBERS_URL, + visibility: Visibility::Protected, + unprivileged_access: UnprivilegedAccess::None, + + allowed_methods: vec![AllowedMethod::Get], + }, + VerifyEndpoint { + url: &DEMO_AFFINITY_GROUP_INSTANCE_MEMBER_URL, + visibility: Visibility::Protected, + unprivileged_access: UnprivilegedAccess::None, + + allowed_methods: vec![ + AllowedMethod::Get, + AllowedMethod::Delete, + AllowedMethod::Post(serde_json::Value::Null), + ], + }, + /* Anti-Affinity Groups */ + VerifyEndpoint { + url: &DEMO_ANTI_AFFINITY_GROUPS_URL, + visibility: Visibility::Protected, + unprivileged_access: UnprivilegedAccess::None, + + allowed_methods: vec![ + AllowedMethod::Post( + serde_json::to_value(&*DEMO_ANTI_AFFINITY_GROUP_CREATE) + .unwrap(), + ), + AllowedMethod::Get, + ], + }, + VerifyEndpoint { + url: &DEMO_ANTI_AFFINITY_GROUP_URL, + visibility: Visibility::Protected, + unprivileged_access: UnprivilegedAccess::None, + + allowed_methods: vec![ + AllowedMethod::Get, + AllowedMethod::Delete, + AllowedMethod::Put( + serde_json::to_value(&*DEMO_ANTI_AFFINITY_GROUP_UPDATE) + .unwrap(), + ), + ], + }, + VerifyEndpoint { + url: &DEMO_ANTI_AFFINITY_GROUP_MEMBERS_URL, + visibility: Visibility::Protected, + unprivileged_access: UnprivilegedAccess::None, + + allowed_methods: vec![AllowedMethod::Get], + }, + VerifyEndpoint { + url: &DEMO_ANTI_AFFINITY_GROUP_INSTANCE_MEMBER_URL, + visibility: Visibility::Protected, + unprivileged_access: UnprivilegedAccess::None, + + allowed_methods: vec![ + AllowedMethod::Get, + AllowedMethod::Delete, + AllowedMethod::Post(serde_json::Value::Null), + ], + }, VerifyEndpoint { url: &DEMO_IMPORT_DISK_BULK_WRITE_START_URL, visibility: Visibility::Protected, diff --git a/nexus/tests/integration_tests/mod.rs b/nexus/tests/integration_tests/mod.rs index dc404736cd1..6283b51f587 100644 --- a/nexus/tests/integration_tests/mod.rs +++ b/nexus/tests/integration_tests/mod.rs @@ -4,6 +4,7 @@ //! the way it is. mod address_lots; +mod affinity; mod allow_list; mod authn_http; mod authz; diff --git a/nexus/tests/integration_tests/projects.rs b/nexus/tests/integration_tests/projects.rs index d9752b1949f..dcd99de9042 100644 --- a/nexus/tests/integration_tests/projects.rs +++ b/nexus/tests/integration_tests/projects.rs @@ -9,11 +9,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_affinity_group; +use nexus_test_utils::resource_helpers::create_anti_affinity_group; +use nexus_test_utils::resource_helpers::create_default_ip_pool; +use nexus_test_utils::resource_helpers::create_disk; use nexus_test_utils::resource_helpers::create_floating_ip; -use nexus_test_utils::resource_helpers::{ - create_default_ip_pool, create_disk, create_project, create_vpc, - object_create, project_get, projects_list, DiskTest, -}; +use nexus_test_utils::resource_helpers::create_project; +use nexus_test_utils::resource_helpers::create_vpc; +use nexus_test_utils::resource_helpers::object_create; +use nexus_test_utils::resource_helpers::project_get; +use nexus_test_utils::resource_helpers::projects_list; +use nexus_test_utils::resource_helpers::DiskTest; use nexus_test_utils_macros::nexus_test; use nexus_types::external_api::params; use nexus_types::external_api::views; @@ -392,3 +398,67 @@ async fn test_project_deletion_with_vpc(cptestctx: &ControlPlaneTestContext) { .unwrap(); delete_project(&project_url, &client).await; } + +#[nexus_test] +async fn test_project_deletion_with_affinity_group( + cptestctx: &ControlPlaneTestContext, +) { + let client = &cptestctx.external_client; + + // Create a project that we'll use for testing. + let name = "springfield-squidport"; + let project_url = format!("/v1/projects/{}", name); + + create_project(&client, &name).await; + delete_project_default_subnet(&name, &client).await; + delete_project_default_vpc(&name, &client).await; + + let group_name = "just-rainsticks"; + create_affinity_group(&client, name, group_name).await; + + assert_eq!( + "project to be deleted contains an affinity group: just-rainsticks", + delete_project_expect_fail(&project_url, &client).await, + ); + + let group_url = + format!("/v1/affinity-groups/{group_name}?project={}", name); + NexusRequest::object_delete(client, &group_url) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .unwrap(); + delete_project(&project_url, &client).await; +} + +#[nexus_test] +async fn test_project_deletion_with_anti_affinity_group( + cptestctx: &ControlPlaneTestContext, +) { + let client = &cptestctx.external_client; + + // Create a project that we'll use for testing. + let name = "springfield-squidport"; + let project_url = format!("/v1/projects/{}", name); + + create_project(&client, &name).await; + delete_project_default_subnet(&name, &client).await; + delete_project_default_vpc(&name, &client).await; + + let group_name = "just-rainsticks"; + create_anti_affinity_group(&client, name, group_name).await; + + assert_eq!( + "project to be deleted contains an anti affinity group: just-rainsticks", + delete_project_expect_fail(&project_url, &client).await, + ); + + let group_url = + format!("/v1/anti-affinity-groups/{group_name}?project={}", name); + NexusRequest::object_delete(client, &group_url) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .unwrap(); + delete_project(&project_url, &client).await; +} diff --git a/nexus/tests/integration_tests/unauthorized.rs b/nexus/tests/integration_tests/unauthorized.rs index 7ec33d97f89..1f45597ce74 100644 --- a/nexus/tests/integration_tests/unauthorized.rs +++ b/nexus/tests/integration_tests/unauthorized.rs @@ -292,6 +292,37 @@ static SETUP_REQUESTS: LazyLock> = LazyLock::new(|| { body: serde_json::to_value(&*DEMO_INSTANCE_CREATE).unwrap(), id_routes: vec!["/v1/instances/{id}"], }, + // Create a stopped Instance in the Project + SetupReq::Post { + url: &DEMO_PROJECT_URL_INSTANCES, + body: serde_json::to_value(&*DEMO_STOPPED_INSTANCE_CREATE).unwrap(), + id_routes: vec!["/v1/instances/{id}"], + }, + // Create an affinity group in the Project + SetupReq::Post { + url: &DEMO_PROJECT_URL_AFFINITY_GROUPS, + body: serde_json::to_value(&*DEMO_AFFINITY_GROUP_CREATE).unwrap(), + id_routes: vec!["/v1/affinity-groups/{id}"], + }, + // Add a member to the affinity group + SetupReq::Post { + url: &DEMO_AFFINITY_GROUP_INSTANCE_MEMBER_URL, + body: serde_json::Value::Null, + id_routes: vec![], + }, + // Create an anti-affinity group in the Project + SetupReq::Post { + url: &DEMO_PROJECT_URL_ANTI_AFFINITY_GROUPS, + body: serde_json::to_value(&*DEMO_ANTI_AFFINITY_GROUP_CREATE) + .unwrap(), + id_routes: vec!["/v1/anti-affinity-groups/{id}"], + }, + // Add a member to the anti-affinity group + SetupReq::Post { + url: &DEMO_ANTI_AFFINITY_GROUP_INSTANCE_MEMBER_URL, + body: serde_json::Value::Null, + id_routes: vec![], + }, // Lookup the previously created NIC SetupReq::Get { url: &DEMO_INSTANCE_NIC_URL, diff --git a/nexus/tests/output/uncovered-authz-endpoints.txt b/nexus/tests/output/uncovered-authz-endpoints.txt index c943728beb4..8a639f1224c 100644 --- a/nexus/tests/output/uncovered-authz-endpoints.txt +++ b/nexus/tests/output/uncovered-authz-endpoints.txt @@ -1,22 +1,10 @@ API endpoints with no coverage in authz tests: probe_delete (delete "/experimental/v1/probes/{probe}") -affinity_group_delete (delete "/v1/affinity-groups/{affinity_group}") -affinity_group_member_instance_delete (delete "/v1/affinity-groups/{affinity_group}/members/instance/{instance}") -anti_affinity_group_delete (delete "/v1/anti-affinity-groups/{anti_affinity_group}") -anti_affinity_group_member_instance_delete (delete "/v1/anti-affinity-groups/{anti_affinity_group}/members/instance/{instance}") probe_list (get "/experimental/v1/probes") probe_view (get "/experimental/v1/probes/{probe}") support_bundle_download (get "/experimental/v1/system/support-bundles/{support_bundle}/download") support_bundle_download_file (get "/experimental/v1/system/support-bundles/{support_bundle}/download/{file}") support_bundle_index (get "/experimental/v1/system/support-bundles/{support_bundle}/index") -affinity_group_list (get "/v1/affinity-groups") -affinity_group_view (get "/v1/affinity-groups/{affinity_group}") -affinity_group_member_list (get "/v1/affinity-groups/{affinity_group}/members") -affinity_group_member_instance_view (get "/v1/affinity-groups/{affinity_group}/members/instance/{instance}") -anti_affinity_group_list (get "/v1/anti-affinity-groups") -anti_affinity_group_view (get "/v1/anti-affinity-groups/{anti_affinity_group}") -anti_affinity_group_member_list (get "/v1/anti-affinity-groups/{anti_affinity_group}/members") -anti_affinity_group_member_instance_view (get "/v1/anti-affinity-groups/{anti_affinity_group}/members/instance/{instance}") ping (get "/v1/ping") networking_switch_port_lldp_neighbors (get "/v1/system/hardware/rack-switch-port/{rack_id}/{switch_location}/{port}/lldp/neighbors") networking_switch_port_lldp_config_view (get "/v1/system/hardware/switch-port/{port}/lldp/config") @@ -28,12 +16,6 @@ device_auth_confirm (post "/device/confirm") device_access_token (post "/device/token") probe_create (post "/experimental/v1/probes") login_saml (post "/login/{silo_name}/saml/{provider_name}") -affinity_group_create (post "/v1/affinity-groups") -affinity_group_member_instance_add (post "/v1/affinity-groups/{affinity_group}/members/instance/{instance}") -anti_affinity_group_create (post "/v1/anti-affinity-groups") -anti_affinity_group_member_instance_add (post "/v1/anti-affinity-groups/{anti_affinity_group}/members/instance/{instance}") login_local (post "/v1/login/{silo_name}/local") logout (post "/v1/logout") networking_switch_port_lldp_config_update (post "/v1/system/hardware/switch-port/{port}/lldp/config") -affinity_group_update (put "/v1/affinity-groups/{affinity_group}") -anti_affinity_group_update (put "/v1/anti-affinity-groups/{anti_affinity_group}")