Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
b2a2ba1
[sagas] Make a macro to simplify declaring saga actions
smklein Dec 16, 2022
f399ff7
ignore rustdoc
smklein Dec 16, 2022
66cf0c0
[nexus] Instance Deletion is now a saga
smklein Dec 16, 2022
828ed21
[nexus] Project and VPC creation are now sagas
smklein Dec 19, 2022
2e0a199
Merge branch 'main' into saga-macro
smklein Dec 19, 2022
533ee76
Merge branch 'main' into saga-macro
smklein Dec 19, 2022
6abce08
Move some lazy_static to once_cell
smklein Dec 20, 2022
169ed10
Merge branch 'main' into saga-macro
smklein Dec 20, 2022
29a3ab5
Extend docs
smklein Dec 20, 2022
3b62a6a
Merge branch 'main' into saga-macro
smklein Dec 20, 2022
05cbd9d
Merge branch 'saga-macro' into more-sagas
smklein Dec 20, 2022
f7f5fbe
Merge branch 'more-sagas' into project-creation-saga
smklein Dec 20, 2022
2f89973
[vpc_create] Add undo actions
smklein Dec 21, 2022
b9fef96
[vpc_create] Add tests for idempotency, fix things
smklein Dec 21, 2022
d68c763
[nexus] Make project creation unwind safe, add tests
smklein Dec 21, 2022
9a8504b
fix project lookup
smklein Dec 21, 2022
79271d2
Lookup project when deleting to avoid rcgen issue during unwind
smklein Dec 21, 2022
bfc6048
Merge branch 'main' into project-creation-saga
smklein Dec 23, 2022
f9c635a
Merge branch 'project-creation-saga' into vpc-creation-saga-idempotent
smklein Dec 23, 2022
8c01fcd
Merge branch 'vpc-creation-saga-idempotent' into project-creation-sag…
smklein Dec 23, 2022
666bf64
Merge branch 'main' into project-creation-saga-idempotent
smklein Dec 27, 2022
7b66888
Update nexus/src/app/sagas/project_create.rs
smklein Dec 28, 2022
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
6 changes: 4 additions & 2 deletions nexus/src/app/project.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,10 @@ impl super::Nexus {
saga_params,
)
.await?;
let db_project = saga_outputs
.lookup_node_output::<db::model::Project>("project")
let (_authz_project, db_project) = saga_outputs
.lookup_node_output::<(authz::Project, db::model::Project)>(
"project",
)
.map_err(|e| Error::internal_error(&format!("{:#}", &e)))
.internal_context("looking up output from project create saga")?;
Ok(db_project)
Expand Down
201 changes: 191 additions & 10 deletions nexus/src/app/sagas/project_create.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ use super::NexusSaga;
use crate::app::sagas;
use crate::app::sagas::declare_saga_actions;
use crate::context::OpContext;
use crate::db::lookup::LookupPath;
use crate::external_api::params;
use crate::{authn, authz, db};
use nexus_defaults as defaults;
Expand All @@ -33,6 +32,7 @@ declare_saga_actions! {
project_create;
PROJECT_CREATE_RECORD -> "project" {
+ spc_create_record
- spc_create_record_undo
}
PROJECT_CREATE_VPC_PARAMS -> "vpc_create_params" {
+ spc_create_vpc_params
Expand Down Expand Up @@ -74,7 +74,7 @@ impl NexusSaga for SagaProjectCreate {

async fn spc_create_record(
sagactx: NexusActionContext,
) -> Result<db::model::Project, ActionError> {
) -> Result<(authz::Project, db::model::Project), ActionError> {
let osagactx = sagactx.user_data();
let params = sagactx.saga_params::<Params>()?;
let opctx = OpContext::for_saga_action(&sagactx, &params.serialized_authn);
Expand All @@ -88,25 +88,42 @@ async fn spc_create_record(
.map_err(ActionError::action_failed)
}

async fn spc_create_record_undo(
sagactx: NexusActionContext,
) -> Result<(), anyhow::Error> {
let osagactx = sagactx.user_data();
let params = sagactx.saga_params::<Params>()?;
let opctx = OpContext::for_saga_action(&sagactx, &params.serialized_authn);

let (_authz_project, project) =
sagactx.lookup::<(authz::Project, db::model::Project)>("project")?;
Comment on lines +98 to +99
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
let (_authz_project, project) =
sagactx.lookup::<(authz::Project, db::model::Project)>("project")?;
let (.., project) =
sagactx.lookup::<(authz::Project, db::model::Project)>("project")?;

Would this work?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

They're functionally the same; I was just being explicit about "what is the unused value" in this case since deserialization is particularly important on the lookup function, which is generic based on the type parameters.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Also, to be pedantic in the context of let binding:

  1. let (.., foo) = ... means "destructure and bind one or more values to the anonymous "...", but only bind the last value to foo"
  2. let (_, foo) = ... means "destructure and bind exactly one value to _, and bind the second value to foo"
  3. let (_unused, foo) = ... means "destructure and bind exactly one value to _unused, and bind the second value to foo"

Technically in cases (1) and (2), since the bind is anonymous, it also goes out of scope, and drop is called immediately on the unnamed values. In contrast, in (3), drop is only called on _unused when it actually goes out of scope.

However, I don't think authz::Project has a drop implementation, hence why this is the same.

So it's not exactly the same, but it's basically the same - I just used this for readability, since the types of values being pulled out of the sagactx.lookup are important to get right, and caller-specified.


let (.., authz_project, project) =
db::lookup::LookupPath::new(&opctx, osagactx.datastore())
.project_id(project.id())
.fetch_for(authz::Action::Delete)
.await?;

osagactx
.datastore()
.project_delete(&opctx, &authz_project, &project)
.await?;
Ok(())
}

async fn spc_create_vpc_params(
sagactx: NexusActionContext,
) -> Result<sagas::vpc_create::Params, ActionError> {
let osagactx = sagactx.user_data();
let params = sagactx.saga_params::<Params>()?;
let opctx = OpContext::for_saga_action(&sagactx, &params.serialized_authn);

let project_id = sagactx.lookup::<db::model::Project>("project")?.id();
let (authz_project, _project) =
sagactx.lookup::<(authz::Project, db::model::Project)>("project")?;
let ipv6_prefix = Some(
defaults::random_vpc_ipv6_prefix()
.map_err(ActionError::action_failed)?,
);

let (.., authz_project) = LookupPath::new(&opctx, osagactx.datastore())
.project_id(project_id)
.lookup_for(authz::Action::CreateChild)
.await
.map_err(ActionError::action_failed)?;

let vpc_create = params::VpcCreate {
identity: IdentityMetadataCreateParams {
name: "default".parse().unwrap(),
Expand All @@ -125,3 +142,167 @@ async fn spc_create_vpc_params(
};
Ok(saga_params)
}

#[cfg(test)]
mod test {
use crate::{
app::saga::create_saga_dag, app::sagas::project_create::Params,
app::sagas::project_create::SagaProjectCreate, authn::saga::Serialized,
authz, context::OpContext, db::datastore::DataStore,
external_api::params,
};
use async_bb8_diesel::{AsyncRunQueryDsl, OptionalExtension};
use diesel::{ExpressionMethods, QueryDsl, SelectableHelper};
use dropshot::test_util::ClientTestContext;
use nexus_test_utils::resource_helpers::create_organization;
use nexus_test_utils::resource_helpers::populate_ip_pool;
use nexus_test_utils_macros::nexus_test;
use omicron_common::api::external::IdentityMetadataCreateParams;
use omicron_common::api::external::NameOrId;
use uuid::Uuid;

type ControlPlaneTestContext =
nexus_test_utils::ControlPlaneTestContext<crate::Server>;

const ORG_NAME: &str = "test-org";

async fn create_org(client: &ClientTestContext) -> Uuid {
populate_ip_pool(&client, "default", None).await;
let org = create_organization(&client, ORG_NAME).await;
org.identity.id
}

// Helper for creating project create parameters
fn new_test_params(
opctx: &OpContext,
authz_org: authz::Organization,
) -> Params {
Params {
serialized_authn: Serialized::for_opctx(opctx),
project_create: params::ProjectCreate {
identity: IdentityMetadataCreateParams {
name: "my-project".parse().unwrap(),
description: "My Project".to_string(),
},
},
authz_org,
}
}

fn test_opctx(cptestctx: &ControlPlaneTestContext) -> OpContext {
OpContext::for_tests(
cptestctx.logctx.log.new(o!()),
cptestctx.server.apictx.nexus.datastore().clone(),
)
}

async fn get_authz_org(
cptestctx: &ControlPlaneTestContext,
org_id: Uuid,
action: authz::Action,
) -> authz::Organization {
let nexus = &cptestctx.server.apictx.nexus;
let org_selector =
params::OrganizationSelector { organization: NameOrId::Id(org_id) };
let opctx = test_opctx(&cptestctx);
let (.., authz_org) = nexus
.organization_lookup(&opctx, &org_selector)
.expect("Invalid parameters constructing organization lookup")
.lookup_for(action)
.await
.expect("Organization does not exist");
authz_org
}

async fn verify_clean_slate(datastore: &DataStore) {
assert!(no_projects_exist(datastore).await);
crate::app::sagas::vpc_create::test::verify_clean_slate(datastore)
.await;
}

async fn no_projects_exist(datastore: &DataStore) -> bool {
use crate::db::model::Project;
use crate::db::schema::project::dsl;

dsl::project
.filter(dsl::time_deleted.is_null())
.select(Project::as_select())
.first_async::<Project>(datastore.pool_for_tests().await.unwrap())
.await
.optional()
.unwrap()
.map(|project| {
eprintln!("Project exists: {project:?}");
})
.is_none()
}

#[nexus_test(server = crate::Server)]
async fn test_saga_basic_usage_succeeds(
cptestctx: &ControlPlaneTestContext,
) {
let client = &cptestctx.external_client;
let nexus = &cptestctx.server.apictx.nexus;
let org_id = create_org(&client).await;

// Before running the test, confirm we have no records of any projects.
verify_clean_slate(nexus.datastore()).await;

// Build the saga DAG with the provided test parameters
let opctx = test_opctx(&cptestctx);
let authz_org =
get_authz_org(&cptestctx, org_id, authz::Action::CreateChild).await;
let params = new_test_params(&opctx, authz_org);
let dag = create_saga_dag::<SagaProjectCreate>(params).unwrap();
let runnable_saga = nexus.create_runnable_saga(dag).await.unwrap();

// Actually run the saga
nexus.run_saga(runnable_saga).await.unwrap();
}

#[nexus_test(server = crate::Server)]
async fn test_action_failure_can_unwind(
cptestctx: &ControlPlaneTestContext,
) {
let log = &cptestctx.logctx.log;

let client = &cptestctx.external_client;
let nexus = &cptestctx.server.apictx.nexus;
let org_id = create_org(&client).await;

// Build the saga DAG with the provided test parameters
let opctx = test_opctx(&cptestctx);
let authz_org =
get_authz_org(&cptestctx, org_id, authz::Action::CreateChild).await;
let params = new_test_params(&opctx, authz_org);
let dag = create_saga_dag::<SagaProjectCreate>(params).unwrap();

for node in dag.get_nodes() {
// Create a new saga for this node.
info!(
log,
"Creating new saga which will fail at index {:?}", node.index();
"node_name" => node.name().as_ref(),
"label" => node.label(),
);

let runnable_saga =
nexus.create_runnable_saga(dag.clone()).await.unwrap();

// Inject an error instead of running the node.
//
// This should cause the saga to unwind.
nexus
.sec()
.saga_inject_error(runnable_saga.id(), node.index())
.await
.unwrap();
nexus
.run_saga(runnable_saga)
.await
.expect_err("Saga should have failed");

verify_clean_slate(nexus.datastore()).await;
}
}
}
4 changes: 2 additions & 2 deletions nexus/src/app/sagas/vpc_create.rs
Original file line number Diff line number Diff line change
Expand Up @@ -409,7 +409,7 @@ async fn svc_notify_sleds(
}

#[cfg(test)]
mod test {
pub(crate) mod test {
use crate::{
app::saga::create_saga_dag, app::sagas::vpc_create::Params,
app::sagas::vpc_create::SagaVpcCreate, authn::saga::Serialized, authz,
Expand Down Expand Up @@ -551,7 +551,7 @@ mod test {
.expect("Failed to delete VPC");
}

async fn verify_clean_slate(datastore: &DataStore) {
pub(crate) async fn verify_clean_slate(datastore: &DataStore) {
assert!(no_vpcs_exist(datastore).await);
assert!(no_routers_exist(datastore).await);
assert!(no_routes_exist(datastore).await);
Expand Down
15 changes: 12 additions & 3 deletions nexus/src/db/datastore/project.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,14 +94,14 @@ impl DataStore {
opctx: &OpContext,
org: &authz::Organization,
project: Project,
) -> CreateResult<Project> {
) -> CreateResult<(authz::Project, Project)> {
use db::schema::project::dsl;

opctx.authorize(authz::Action::CreateChild, org).await?;

let name = project.name().as_str().to_string();
let organization_id = project.organization_id;
Organization::insert_resource(
let db_project = Organization::insert_resource(
organization_id,
diesel::insert_into(dsl::project).values(project),
)
Expand All @@ -118,7 +118,16 @@ impl DataStore {
ErrorHandler::Conflict(ResourceType::Project, &name),
)
}
})
})?;

Ok((
authz::Project::new(
org.clone(),
db_project.id(),
LookupType::ByName(db_project.name().to_string()),
),
db_project,
))
}

generate_fn_to_ensure_none_in_project!(instance, name, String);
Expand Down
2 changes: 1 addition & 1 deletion nexus/src/db/queries/network_interface.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1728,7 +1728,7 @@ mod tests {
.lookup_for(authz::Action::CreateChild)
.await
.unwrap();
let project = db_datastore
let (.., project) = db_datastore
.project_create(&opctx, &authz_org, project)
.await
.unwrap();
Expand Down