Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[affinity] Allow listing affinity/anti-affinity groups per-instance #7857

Open
wants to merge 6 commits into
base: affinity-query-commentary
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions nexus/db-queries/src/db/datastore/affinity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -48,9 +49,88 @@ use omicron_uuid_kinds::AffinityGroupUuid;
use omicron_uuid_kinds::AntiAffinityGroupUuid;
use omicron_uuid_kinds::GenericUuid;
use omicron_uuid_kinds::InstanceUuid;
use ref_cast::RefCast;
use uuid::Uuid;

impl DataStore {
/// List affinity groups associated with a given instance
pub async fn instance_list_affinity_groups(
&self,
opctx: &OpContext,
authz_instance: &authz::Instance,
pagparams: &PaginatedBy<'_>,
) -> ListResultVec<AffinityGroup> {
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::<AffinityGroup>(
&*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<AntiAffinityGroup> {
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)),
),
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I typed this up

Maybe this is just necessary to make the compiler happy, but what does it mean to paginate by either name or ID here? Is it possible to select one or the other?

but I answered my own question. Putting it here for posterity: the use of PaginatedByNameOrId in the query params on the endpoint means this takes a sort_by=NameOrIdSortMode param that lets you specify sort by ID, name asc, or name desc.

.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::<AntiAffinityGroup>(
&*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,
Expand Down
2 changes: 2 additions & 0 deletions nexus/external-api/output/nexus_tags.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 28 additions & 0 deletions nexus/external-api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1264,6 +1264,34 @@ pub trait NexusExternalApi {
disk_to_detach: TypedBody<params::DiskPath>,
) -> Result<HttpResponseAccepted<Disk>, 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<Self::Context>,
query_params: Query<
PaginatedByNameOrId<params::OptionalProjectSelector>,
>,
path_params: Path<params::InstancePath>,
) -> Result<HttpResponseOk<ResultsPage<views::AffinityGroup>>, 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<Self::Context>,
query_params: Query<
PaginatedByNameOrId<params::OptionalProjectSelector>,
>,
path_params: Path<params::InstancePath>,
) -> Result<HttpResponseOk<ResultsPage<views::AntiAffinityGroup>>, HttpError>;

// Affinity Groups

/// List affinity groups
Expand Down
32 changes: 32 additions & 0 deletions nexus/src/app/instance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<db::model::AffinityGroup> {
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<db::model::AntiAffinityGroup> {
let (.., authz_instance) =
instance_lookup.lookup_for(authz::Action::ListChildren).await?;
Copy link
Contributor

@david-crespo david-crespo Mar 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this (also or instead) check list children on projects since the groups are children of the project? I think that check is implied by this check because anyone with viewer+ on the project has list children on the project and the instance, but that's sort of incidental. It might be better if this check was more direct.

resource Project {
permissions = [
"list_children",
"modify",
"read",
"create_child",
];
roles = [ "admin", "collaborator", "viewer" ];
# Roles implied by other roles on this resource
"viewer" if "collaborator";
"collaborator" if "admin";
# Permissions granted directly by roles on this resource
"list_children" if "viewer";
"read" if "viewer";
"create_child" if "collaborator";
"modify" if "admin";

// If this resource is directly inside a Project, we only need to define
// permissions that are contingent on having roles on that Project.
(PolarSnippet::InProject, "Project") => format!(
r#"
resource {} {{
permissions = [
"list_children",
"modify",
"read",
"create_child",
];
relations = {{ containing_project: Project }};
"list_children" if "viewer" on "containing_project";
"read" if "viewer" on "containing_project";
"modify" if "collaborator" on "containing_project";
"create_child" if "collaborator" on "containing_project";
}}
has_relation(parent: Project, "containing_project", child: {})
if child.project = parent;
"#,
resource_name, resource_name,
),

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see we do the same for listing instance disks, which are directly analogous from an authz POV. Seems fine?

/// Lists disks attached to the instance.
pub(crate) async fn instance_list_disks(
&self,
opctx: &OpContext,
instance_lookup: &lookup::Instance<'_>,
pagparams: &PaginatedBy<'_>,
) -> ListResultVec<db::model::Disk> {
let (.., authz_instance) =
instance_lookup.lookup_for(authz::Action::ListChildren).await?;
self.db_datastore
.instance_list_disks(opctx, &authz_instance, pagparams)
.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(
Expand Down
94 changes: 94 additions & 0 deletions nexus/src/external_api/http_entrypoints.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2508,6 +2508,100 @@ impl NexusExternalApi for NexusExternalApiImpl {
.await
}

async fn instance_affinity_group_list(
rqctx: RequestContext<ApiContext>,
query_params: Query<
PaginatedByNameOrId<params::OptionalProjectSelector>,
>,
path_params: Path<params::InstancePath>,
) -> Result<HttpResponseOk<ResultsPage<views::AffinityGroup>>, 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<ApiContext>,
query_params: Query<
PaginatedByNameOrId<params::OptionalProjectSelector>,
>,
path_params: Path<params::InstancePath>,
) -> Result<HttpResponseOk<ResultsPage<views::AntiAffinityGroup>>, 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(
Expand Down
57 changes: 57 additions & 0 deletions nexus/tests/integration_tests/affinity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,11 @@ impl<T: AffinityGroupish> ProjectScopedApiHelper<'_, T> {
.await
}

async fn instance_groups_list(&self, instance: &str) -> Vec<T::Group> {
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<T::Group> {
let url = groups_url(T::URL_COMPONENT, self.project);
objects_list_page_authz(&self.client, &url).await.items
Expand Down Expand Up @@ -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}")
Expand Down Expand Up @@ -755,6 +769,49 @@ async fn test_group_crud<T: AffinityGroupish>(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::<AffinityType>(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::<AntiAffinityType>(external_client).await;
}

async fn test_instance_group_list<T: AffinityGroupish>(
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::<T>(PROJECT_NAME);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WHAT IS THIS BEAUTIFUL API

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a hackery of query parameter appending for the affinity tests... but feel free to re-use it, if you want!


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,
Expand Down
26 changes: 26 additions & 0 deletions nexus/tests/integration_tests/endpoints.rs
Original file line number Diff line number Diff line change
Expand Up @@ -596,6 +596,20 @@ pub static DEMO_INSTANCE_DISKS_DETACH_URL: LazyLock<String> =
*DEMO_INSTANCE_NAME, *DEMO_PROJECT_SELECTOR
)
});
pub static DEMO_INSTANCE_AFFINITY_GROUPS_URL: LazyLock<String> =
LazyLock::new(|| {
format!(
"/v1/instances/{}/affinity-groups?{}",
*DEMO_INSTANCE_NAME, *DEMO_PROJECT_SELECTOR
)
});
pub static DEMO_INSTANCE_ANTI_AFFINITY_GROUPS_URL: LazyLock<String> =
LazyLock::new(|| {
format!(
"/v1/instances/{}/anti-affinity-groups?{}",
*DEMO_INSTANCE_NAME, *DEMO_PROJECT_SELECTOR
)
});
pub static DEMO_INSTANCE_EPHEMERAL_IP_URL: LazyLock<String> =
LazyLock::new(|| {
format!(
Expand Down Expand Up @@ -1910,6 +1924,18 @@ pub static VERIFY_ENDPOINTS: LazyLock<Vec<VerifyEndpoint>> =
.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,
Expand Down
Loading
Loading