diff --git a/nexus/db-model/src/ipv6net.rs b/nexus/db-model/src/ipv6net.rs index 2bbcb08a4bb..d5eaee1d433 100644 --- a/nexus/db-model/src/ipv6net.rs +++ b/nexus/db-model/src/ipv6net.rs @@ -11,9 +11,20 @@ use ipnetwork::IpNetwork; use omicron_common::api::external; use omicron_common::nexus_config::NUM_INITIAL_RESERVED_IP_ADDRESSES; use rand::{rngs::StdRng, SeedableRng}; +use serde::Deserialize; +use serde::Serialize; use std::net::Ipv6Addr; -#[derive(Clone, Copy, Debug, PartialEq, AsExpression, FromSqlRow)] +#[derive( + Clone, + Copy, + Debug, + PartialEq, + AsExpression, + FromSqlRow, + Serialize, + Deserialize, +)] #[diesel(sql_type = sql_types::Inet)] pub struct Ipv6Net(pub external::Ipv6Net); diff --git a/nexus/db-model/src/l4_port_range.rs b/nexus/db-model/src/l4_port_range.rs index d16d2f8ac5d..40165d7f59e 100644 --- a/nexus/db-model/src/l4_port_range.rs +++ b/nexus/db-model/src/l4_port_range.rs @@ -8,10 +8,14 @@ use diesel::pg::Pg; use diesel::serialize::{self, ToSql}; use diesel::sql_types; use omicron_common::api::external; +use serde::Deserialize; +use serde::Serialize; /// Newtype wrapper around [`external::L4PortRange`] so we can derive /// diesel traits for it -#[derive(Clone, Copy, Debug, AsExpression, FromSqlRow)] +#[derive( + Clone, Copy, Debug, AsExpression, FromSqlRow, Serialize, Deserialize, +)] #[diesel(sql_type = sql_types::Text)] #[repr(transparent)] pub struct L4PortRange(pub external::L4PortRange); diff --git a/nexus/db-model/src/project.rs b/nexus/db-model/src/project.rs index 568f96ddc95..35a3773ba8f 100644 --- a/nexus/db-model/src/project.rs +++ b/nexus/db-model/src/project.rs @@ -10,10 +10,14 @@ use db_macros::Resource; use nexus_types::external_api::params; use nexus_types::external_api::views; use nexus_types::identity::Resource; +use serde::Deserialize; +use serde::Serialize; use uuid::Uuid; /// Describes a project within the database. -#[derive(Selectable, Queryable, Insertable, Debug, Resource)] +#[derive( + Selectable, Queryable, Insertable, Debug, Resource, Serialize, Deserialize, +)] #[diesel(table_name = project)] pub struct Project { #[diesel(embed)] diff --git a/nexus/db-model/src/vni.rs b/nexus/db-model/src/vni.rs index 73750d9c31f..36fd42d3e62 100644 --- a/nexus/db-model/src/vni.rs +++ b/nexus/db-model/src/vni.rs @@ -11,8 +11,12 @@ use diesel::serialize; use diesel::serialize::ToSql; use diesel::sql_types; use omicron_common::api::external; +use serde::Deserialize; +use serde::Serialize; -#[derive(Clone, Debug, Copy, AsExpression, FromSqlRow)] +#[derive( + Clone, Debug, Copy, AsExpression, FromSqlRow, Serialize, Deserialize, +)] #[diesel(sql_type = sql_types::Int4)] pub struct Vni(pub external::Vni); diff --git a/nexus/db-model/src/vpc.rs b/nexus/db-model/src/vpc.rs index 0ea38a03d34..8a4dc0e3493 100644 --- a/nexus/db-model/src/vpc.rs +++ b/nexus/db-model/src/vpc.rs @@ -14,9 +14,20 @@ use nexus_types::external_api::params; use nexus_types::external_api::views; use nexus_types::identity::Resource; use omicron_common::api::external; +use serde::Deserialize; +use serde::Serialize; use uuid::Uuid; -#[derive(Queryable, Insertable, Clone, Debug, Selectable, Resource)] +#[derive( + Queryable, + Insertable, + Clone, + Debug, + Selectable, + Resource, + Serialize, + Deserialize, +)] #[diesel(table_name = vpc)] pub struct Vpc { #[diesel(embed)] diff --git a/nexus/db-model/src/vpc_firewall_rule.rs b/nexus/db-model/src/vpc_firewall_rule.rs index d4228e24e12..e3df683e4ab 100644 --- a/nexus/db-model/src/vpc_firewall_rule.rs +++ b/nexus/db-model/src/vpc_firewall_rule.rs @@ -12,6 +12,8 @@ use diesel::serialize::{self, ToSql}; use diesel::sql_types; use nexus_types::identity::Resource; use omicron_common::api::external; +use serde::Deserialize; +use serde::Serialize; use std::io::Write; use uuid::Uuid; @@ -20,7 +22,7 @@ impl_enum_wrapper!( #[diesel(postgres_type(name = "vpc_firewall_rule_status"))] pub struct VpcFirewallRuleStatusEnum; - #[derive(Clone, Debug, AsExpression, FromSqlRow)] + #[derive(Clone, Debug, AsExpression, FromSqlRow, Serialize, Deserialize)] #[diesel(sql_type = VpcFirewallRuleStatusEnum)] pub struct VpcFirewallRuleStatus(pub external::VpcFirewallRuleStatus); @@ -35,7 +37,7 @@ impl_enum_wrapper!( #[diesel(postgres_type(name = "vpc_firewall_rule_direction"))] pub struct VpcFirewallRuleDirectionEnum; - #[derive(Clone, Debug, AsExpression, FromSqlRow)] + #[derive(Clone, Debug, AsExpression, FromSqlRow, Serialize, Deserialize)] #[diesel(sql_type = VpcFirewallRuleDirectionEnum)] pub struct VpcFirewallRuleDirection(pub external::VpcFirewallRuleDirection); @@ -50,7 +52,7 @@ impl_enum_wrapper!( #[diesel(postgres_type(name = "vpc_firewall_rule_action"))] pub struct VpcFirewallRuleActionEnum; - #[derive(Clone, Debug, AsExpression, FromSqlRow)] + #[derive(Clone, Debug, AsExpression, FromSqlRow, Serialize, Deserialize)] #[diesel(sql_type = VpcFirewallRuleActionEnum)] pub struct VpcFirewallRuleAction(pub external::VpcFirewallRuleAction); @@ -65,7 +67,7 @@ impl_enum_wrapper!( #[diesel(postgres_type(name = "vpc_firewall_rule_protocol"))] pub struct VpcFirewallRuleProtocolEnum; - #[derive(Clone, Debug, AsExpression, FromSqlRow)] + #[derive(Clone, Debug, AsExpression, FromSqlRow, Serialize, Deserialize)] #[diesel(sql_type = VpcFirewallRuleProtocolEnum)] pub struct VpcFirewallRuleProtocol(pub external::VpcFirewallRuleProtocol); @@ -78,7 +80,7 @@ NewtypeDeref! { () pub struct VpcFirewallRuleProtocol(external::VpcFirewallRuleP /// Newtype wrapper around [`external::VpcFirewallRuleTarget`] so we can derive /// diesel traits for it -#[derive(Clone, Debug, AsExpression, FromSqlRow)] +#[derive(Clone, Debug, AsExpression, FromSqlRow, Serialize, Deserialize)] #[diesel(sql_type = sql_types::Text)] #[repr(transparent)] pub struct VpcFirewallRuleTarget(pub external::VpcFirewallRuleTarget); @@ -113,7 +115,7 @@ where /// Newtype wrapper around [`external::VpcFirewallRuleHostFilter`] so we can derive /// diesel traits for it -#[derive(Clone, Debug, AsExpression, FromSqlRow)] +#[derive(Clone, Debug, AsExpression, FromSqlRow, Serialize, Deserialize)] #[diesel(sql_type = sql_types::Text)] #[repr(transparent)] pub struct VpcFirewallRuleHostFilter(pub external::VpcFirewallRuleHostFilter); @@ -148,7 +150,9 @@ where /// Newtype wrapper around [`external::VpcFirewallRulePriority`] so we can derive /// diesel traits for it -#[derive(Clone, Copy, Debug, AsExpression, FromSqlRow)] +#[derive( + Clone, Copy, Debug, AsExpression, FromSqlRow, Serialize, Deserialize, +)] #[repr(transparent)] #[diesel(sql_type = sql_types::Int4)] pub struct VpcFirewallRulePriority(pub external::VpcFirewallRulePriority); @@ -180,7 +184,16 @@ where } } -#[derive(Queryable, Insertable, Clone, Debug, Selectable, Resource)] +#[derive( + Queryable, + Insertable, + Clone, + Debug, + Selectable, + Resource, + Serialize, + Deserialize, +)] #[diesel(table_name = vpc_firewall_rule)] pub struct VpcFirewallRule { #[diesel(embed)] diff --git a/nexus/src/app/project.rs b/nexus/src/app/project.rs index bfae9cdd612..745de4bd529 100644 --- a/nexus/src/app/project.rs +++ b/nexus/src/app/project.rs @@ -4,6 +4,8 @@ //! Project APIs, contained within organizations +use crate::app::sagas; +use crate::authn; use crate::authz; use crate::context::OpContext; use crate::db; @@ -13,18 +15,17 @@ use crate::db::model::Name; use crate::external_api::params; use crate::external_api::shared; use anyhow::Context; -use nexus_defaults as defaults; -use nexus_types::identity::Resource; use omicron_common::api::external::CreateResult; use omicron_common::api::external::DataPageParams; use omicron_common::api::external::DeleteResult; use omicron_common::api::external::Error; -use omicron_common::api::external::IdentityMetadataCreateParams; +use omicron_common::api::external::InternalContext; 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 ref_cast::RefCast; +use std::sync::Arc; use uuid::Uuid; impl super::Nexus { @@ -64,8 +65,9 @@ impl super::Nexus { )), } } + pub async fn project_create( - &self, + self: &Arc, opctx: &OpContext, organization_lookup: &lookup::Organization<'_>, new_project: ¶ms::ProjectCreate, @@ -73,40 +75,20 @@ impl super::Nexus { let (.., authz_org) = organization_lookup.lookup_for(authz::Action::CreateChild).await?; - // Create a project. - let db_project = - db::model::Project::new(authz_org.id(), new_project.clone()); - let db_project = self - .db_datastore - .project_create(opctx, &authz_org, db_project) - .await?; - let project_lookup = LookupPath::new(opctx, &self.db_datastore) - .project_id(db_project.id()); - - // TODO: We probably want to have "project creation" and "default VPC - // creation" co-located within a saga for atomicity. - // - // Until then, we just perform the operations sequentially. - - // Create a default VPC associated with the project. - let _ = self - .project_create_vpc( - opctx, - &project_lookup, - ¶ms::VpcCreate { - identity: IdentityMetadataCreateParams { - name: "default".parse().unwrap(), - description: "Default VPC".to_string(), - }, - ipv6_prefix: Some(defaults::random_vpc_ipv6_prefix()?), - // TODO-robustness this will need to be None if we decide to - // handle the logic around name and dns_name by making - // dns_name optional - dns_name: "default".parse().unwrap(), - }, + let saga_params = sagas::project_create::Params { + serialized_authn: authn::saga::Serialized::for_opctx(opctx), + project_create: new_project.clone(), + authz_org, + }; + let saga_outputs = self + .execute_saga::( + saga_params, ) .await?; - + let db_project = saga_outputs + .lookup_node_output::("project") + .map_err(|e| Error::internal_error(&format!("{:#}", &e))) + .internal_context("looking up output from project create saga")?; Ok(db_project) } diff --git a/nexus/src/app/sagas/mod.rs b/nexus/src/app/sagas/mod.rs index 46b13a713b9..4cf0b67bade 100644 --- a/nexus/src/app/sagas/mod.rs +++ b/nexus/src/app/sagas/mod.rs @@ -24,9 +24,11 @@ pub mod disk_delete; pub mod instance_create; pub mod instance_delete; pub mod instance_migrate; +pub mod project_create; pub mod snapshot_create; pub mod volume_delete; pub mod volume_remove_rop; +pub mod vpc_create; pub mod common_storage; @@ -101,6 +103,9 @@ fn make_action_registry() -> ActionRegistry { ::register_actions( &mut registry, ); + ::register_actions( + &mut registry, + ); ::register_actions( &mut registry, ); @@ -110,6 +115,7 @@ fn make_action_registry() -> ActionRegistry { ::register_actions( &mut registry, ); + ::register_actions(&mut registry); registry } diff --git a/nexus/src/app/sagas/project_create.rs b/nexus/src/app/sagas/project_create.rs new file mode 100644 index 00000000000..85383b761d0 --- /dev/null +++ b/nexus/src/app/sagas/project_create.rs @@ -0,0 +1,127 @@ +// 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/. + +use super::ActionRegistry; +use super::NexusActionContext; +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; +use nexus_types::identity::Resource; +use omicron_common::api::external::IdentityMetadataCreateParams; +use serde::Deserialize; +use serde::Serialize; +use steno::ActionError; + +// project create saga: input parameters + +#[derive(Debug, Deserialize, Serialize)] +pub struct Params { + pub serialized_authn: authn::saga::Serialized, + pub project_create: params::ProjectCreate, + pub authz_org: authz::Organization, +} + +// project create saga: actions + +declare_saga_actions! { + project_create; + PROJECT_CREATE_RECORD -> "project" { + + spc_create_record + } + PROJECT_CREATE_VPC_PARAMS -> "vpc_create_params" { + + spc_create_vpc_params + } +} + +// project create saga: definition + +#[derive(Debug)] +pub struct SagaProjectCreate; +impl NexusSaga for SagaProjectCreate { + const NAME: &'static str = "project-create"; + type Params = Params; + + fn register_actions(registry: &mut ActionRegistry) { + project_create_register_actions(registry); + } + + fn make_saga_dag( + _params: &Self::Params, + mut builder: steno::DagBuilder, + ) -> Result { + builder.append(project_create_record_action()); + builder.append(project_create_vpc_params_action()); + + let subsaga_builder = steno::DagBuilder::new(steno::SagaName::new( + sagas::vpc_create::SagaVpcCreate::NAME, + )); + builder.append(steno::Node::subsaga( + "vpc", + sagas::vpc_create::create_dag(subsaga_builder)?, + "vpc_create_params", + )); + Ok(builder.build()?) + } +} + +// project create saga: action implementations + +async fn spc_create_record( + sagactx: NexusActionContext, +) -> Result { + let osagactx = sagactx.user_data(); + let params = sagactx.saga_params::()?; + let opctx = OpContext::for_saga_action(&sagactx, ¶ms.serialized_authn); + + let db_project = + db::model::Project::new(params.authz_org.id(), params.project_create); + osagactx + .datastore() + .project_create(&opctx, ¶ms.authz_org, db_project) + .await + .map_err(ActionError::action_failed) +} + +async fn spc_create_vpc_params( + sagactx: NexusActionContext, +) -> Result { + let osagactx = sagactx.user_data(); + let params = sagactx.saga_params::()?; + let opctx = OpContext::for_saga_action(&sagactx, ¶ms.serialized_authn); + + let project_id = sagactx.lookup::("project")?.id(); + 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(), + description: "Default VPC".to_string(), + }, + ipv6_prefix, + // TODO-robustness this will need to be None if we decide to + // handle the logic around name and dns_name by making + // dns_name optional + dns_name: "default".parse().unwrap(), + }; + let saga_params = sagas::vpc_create::Params { + serialized_authn: authn::saga::Serialized::for_opctx(&opctx), + vpc_create, + authz_project, + }; + Ok(saga_params) +} diff --git a/nexus/src/app/sagas/vpc_create.rs b/nexus/src/app/sagas/vpc_create.rs new file mode 100644 index 00000000000..854cc8cfb37 --- /dev/null +++ b/nexus/src/app/sagas/vpc_create.rs @@ -0,0 +1,335 @@ +// 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/. + +use super::ActionRegistry; +use super::NexusActionContext; +use super::NexusSaga; +use super::ACTION_GENERATE_ID; +use crate::app::sagas::declare_saga_actions; +use crate::context::OpContext; +use crate::db::model::VpcRouterKind; +use crate::db::queries::vpc_subnet::SubnetError; +use crate::external_api::params; +use crate::{authn, authz, db}; +use nexus_defaults as defaults; +use omicron_common::api::external; +use omicron_common::api::external::IdentityMetadataCreateParams; +use omicron_common::api::external::RouteDestination; +use omicron_common::api::external::RouteTarget; +use omicron_common::api::external::RouterRouteCreateParams; +use omicron_common::api::external::RouterRouteKind; +use serde::Deserialize; +use serde::Serialize; +use steno::ActionError; +use steno::Node; +use uuid::Uuid; + +// vpc create saga: input parameters + +#[derive(Debug, Deserialize, Serialize)] +pub struct Params { + pub serialized_authn: authn::saga::Serialized, + pub vpc_create: params::VpcCreate, + pub authz_project: authz::Project, +} + +// vpc create saga: actions + +declare_saga_actions! { + vpc_create; + VPC_CREATE_VPC -> "vpc" { + + svc_create_vpc + } + VPC_CREATE_ROUTER -> "router" { + + svc_create_router + } + VPC_CREATE_ROUTE -> "route" { + + svc_create_route + } + VPC_CREATE_SUBNET -> "subnet" { + + svc_create_subnet + } + VPC_UPDATE_FIREWALL -> "firewall" { + + svc_update_firewall + } + VPC_NOTIFY_SLEDS -> "no_result" { + + svc_notify_sleds + } +} + +// vpc create saga: definition + +/// Identical to [SagaVpcCreate::make_saga_dag], but using types +/// to identify that parameters do not need to be supplied as input. +pub fn create_dag( + mut builder: steno::DagBuilder, +) -> Result { + builder.append(Node::action( + "vpc_id", + "GenerateVpcId", + ACTION_GENERATE_ID.as_ref(), + )); + builder.append(Node::action( + "system_router_id", + "GenerateSystemRouterId", + ACTION_GENERATE_ID.as_ref(), + )); + builder.append(Node::action( + "default_route_id", + "GenerateDefaultRouteId", + ACTION_GENERATE_ID.as_ref(), + )); + builder.append(Node::action( + "default_subnet_id", + "GenerateDefaultSubnetId", + ACTION_GENERATE_ID.as_ref(), + )); + builder.append(vpc_create_vpc_action()); + builder.append(vpc_create_router_action()); + builder.append(vpc_create_route_action()); + builder.append(vpc_create_subnet_action()); + builder.append(vpc_update_firewall_action()); + builder.append(vpc_notify_sleds_action()); + + Ok(builder.build()?) +} + +#[derive(Debug)] +pub struct SagaVpcCreate; +impl NexusSaga for SagaVpcCreate { + const NAME: &'static str = "vpc-create"; + type Params = Params; + + fn register_actions(registry: &mut ActionRegistry) { + vpc_create_register_actions(registry); + } + + fn make_saga_dag( + _params: &Self::Params, + builder: steno::DagBuilder, + ) -> Result { + create_dag(builder) + } +} + +// vpc create saga: action implementations + +async fn svc_create_vpc( + sagactx: NexusActionContext, +) -> Result<(authz::Vpc, db::model::Vpc), ActionError> { + let osagactx = sagactx.user_data(); + let params = sagactx.saga_params::()?; + let opctx = OpContext::for_saga_action(&sagactx, ¶ms.serialized_authn); + let vpc_id = sagactx.lookup::("vpc_id")?; + let system_router_id = sagactx.lookup::("system_router_id")?; + + // TODO: This is both fake and utter nonsense. It should be eventually + // replaced with the proper behavior for creating the default route + // which may not even happen here. Creating the vpc, its system router, + // and that routers default route should all be a part of the same + // transaction. + let vpc = db::model::IncompleteVpc::new( + vpc_id, + params.authz_project.id(), + system_router_id, + params.vpc_create.clone(), + ) + .map_err(ActionError::action_failed)?; + let (authz_vpc, db_vpc) = osagactx + .datastore() + .project_create_vpc(&opctx, ¶ms.authz_project, vpc) + .await + .map_err(ActionError::action_failed)?; + Ok((authz_vpc, db_vpc)) +} + +async fn svc_create_router( + sagactx: NexusActionContext, +) -> Result { + let osagactx = sagactx.user_data(); + let params = sagactx.saga_params::()?; + let opctx = OpContext::for_saga_action(&sagactx, ¶ms.serialized_authn); + let vpc_id = sagactx.lookup::("vpc_id")?; + let system_router_id = sagactx.lookup::("system_router_id")?; + let (authz_vpc, _) = + sagactx.lookup::<(authz::Vpc, db::model::Vpc)>("vpc")?; + + // TODO: Ultimately when the VPC is created a system router w/ an + // appropriate setup should also be created. Given that the underlying + // systems aren't wired up yet this is a naive implementation to + // populate the database with a starting router. + let router = db::model::VpcRouter::new( + system_router_id, + vpc_id, + VpcRouterKind::System, + params::VpcRouterCreate { + identity: IdentityMetadataCreateParams { + name: "system".parse().unwrap(), + description: "Routes are automatically added to this \ + router as vpc subnets are created" + .into(), + }, + }, + ); + let (authz_router, _) = osagactx + .datastore() + .vpc_create_router(&opctx, &authz_vpc, router) + .await + .map_err(ActionError::action_failed)?; + Ok(authz_router) +} + +async fn svc_create_route( + sagactx: NexusActionContext, +) -> Result<(), ActionError> { + let osagactx = sagactx.user_data(); + let params = sagactx.saga_params::()?; + let opctx = OpContext::for_saga_action(&sagactx, ¶ms.serialized_authn); + let default_route_id = sagactx.lookup::("default_route_id")?; + let system_router_id = sagactx.lookup::("system_router_id")?; + let authz_router = sagactx.lookup::("router")?; + + let route = db::model::RouterRoute::new( + default_route_id, + system_router_id, + RouterRouteKind::Default, + RouterRouteCreateParams { + identity: IdentityMetadataCreateParams { + name: "default".parse().unwrap(), + description: "The default route of a vpc".to_string(), + }, + target: RouteTarget::InternetGateway("outbound".parse().unwrap()), + destination: RouteDestination::Vpc( + params.vpc_create.identity.name.clone(), + ), + }, + ); + + osagactx + .datastore() + .router_create_route(&opctx, &authz_router, route) + .await + .map_err(ActionError::action_failed)?; + Ok(()) +} + +async fn svc_create_subnet( + sagactx: NexusActionContext, +) -> Result<(), ActionError> { + let osagactx = sagactx.user_data(); + let params = sagactx.saga_params::()?; + let opctx = OpContext::for_saga_action(&sagactx, ¶ms.serialized_authn); + + let vpc_id = sagactx.lookup::("vpc_id")?; + let (authz_vpc, db_vpc) = + sagactx.lookup::<(authz::Vpc, db::model::Vpc)>("vpc")?; + let default_subnet_id = sagactx.lookup::("default_subnet_id")?; + + // Allocate the first /64 sub-range from the requested or created + // prefix. + let ipv6_block = external::Ipv6Net( + ipnetwork::Ipv6Network::new(db_vpc.ipv6_prefix.network(), 64) + .map_err(|_| { + external::Error::internal_error( + "Failed to allocate default IPv6 subnet", + ) + }) + .map_err(ActionError::action_failed)?, + ); + + let subnet = db::model::VpcSubnet::new( + default_subnet_id, + vpc_id, + IdentityMetadataCreateParams { + name: "default".parse().unwrap(), + description: format!( + "The default subnet for {}", + params.vpc_create.identity.name + ), + }, + *defaults::DEFAULT_VPC_SUBNET_IPV4_BLOCK, + ipv6_block, + ); + + // Create the subnet record in the database. Overlapping IP ranges + // should be translated into an internal error. That implies that + // there's already an existing VPC Subnet, but we're explicitly creating + // the _first_ VPC in the project. Something is wrong, and likely a bug + // in our code. + osagactx + .datastore() + .vpc_create_subnet(&opctx, &authz_vpc, subnet) + .await + .map_err(|err| match err { + SubnetError::OverlappingIpRange(ip) => { + let ipv4_block = &defaults::DEFAULT_VPC_SUBNET_IPV4_BLOCK; + let log = sagactx.user_data().log(); + error!( + log, + concat!( + "failed to create default VPC Subnet, IP address ", + "range '{}' overlaps with existing", + ), + ip; + "vpc_id" => ?vpc_id, + "subnet_id" => ?default_subnet_id, + "ipv4_block" => ?**ipv4_block, + "ipv6_block" => ?ipv6_block, + ); + external::Error::internal_error( + "Failed to create default VPC Subnet, \ + found overlapping IP address ranges", + ) + } + SubnetError::External(e) => e, + }) + .map_err(ActionError::action_failed)?; + + Ok(()) +} + +async fn svc_update_firewall( + sagactx: NexusActionContext, +) -> Result, ActionError> { + let osagactx = sagactx.user_data(); + let params = sagactx.saga_params::()?; + let opctx = OpContext::for_saga_action(&sagactx, ¶ms.serialized_authn); + + let (authz_vpc, _) = + sagactx.lookup::<(authz::Vpc, db::model::Vpc)>("vpc")?; + let rules = osagactx + .nexus() + .default_firewall_rules_for_vpc( + authz_vpc.id(), + params.vpc_create.identity.name.clone().into(), + ) + .await + .map_err(ActionError::action_failed)?; + osagactx + .datastore() + .vpc_update_firewall_rules(&opctx, &authz_vpc, rules.clone()) + .await + .map_err(ActionError::action_failed)?; + + Ok(rules) +} + +async fn svc_notify_sleds( + sagactx: NexusActionContext, +) -> Result<(), ActionError> { + let osagactx = sagactx.user_data(); + let params = sagactx.saga_params::()?; + let opctx = OpContext::for_saga_action(&sagactx, ¶ms.serialized_authn); + let (_, db_vpc) = sagactx.lookup::<(authz::Vpc, db::model::Vpc)>("vpc")?; + let rules = + sagactx.lookup::>("firewall")?; + + osagactx + .nexus() + .send_sled_agents_firewall_rules(&opctx, &db_vpc, &rules) + .await + .map_err(ActionError::action_failed)?; + + Ok(()) +} diff --git a/nexus/src/app/vpc.rs b/nexus/src/app/vpc.rs index c4544d323cb..a739a624e6e 100644 --- a/nexus/src/app/vpc.rs +++ b/nexus/src/app/vpc.rs @@ -4,6 +4,8 @@ //! VPCs and firewall rules +use crate::app::sagas; +use crate::authn; use crate::authz; use crate::context::OpContext; use crate::db; @@ -12,8 +14,6 @@ use crate::db::identity::Resource; use crate::db::lookup; use crate::db::lookup::LookupPath; use crate::db::model::Name; -use crate::db::model::VpcRouterKind; -use crate::db::queries::vpc_subnet::SubnetError; use crate::external_api::params; use nexus_defaults as defaults; use omicron_common::api::external; @@ -21,14 +21,10 @@ use omicron_common::api::external::CreateResult; use omicron_common::api::external::DataPageParams; use omicron_common::api::external::DeleteResult; use omicron_common::api::external::Error; -use omicron_common::api::external::IdentityMetadataCreateParams; +use omicron_common::api::external::InternalContext; use omicron_common::api::external::ListResultVec; use omicron_common::api::external::LookupResult; use omicron_common::api::external::LookupType; -use omicron_common::api::external::RouteDestination; -use omicron_common::api::external::RouteTarget; -use omicron_common::api::external::RouterRouteCreateParams; -use omicron_common::api::external::RouterRouteKind; use omicron_common::api::external::UpdateResult; use omicron_common::api::external::VpcFirewallRuleUpdateParams; use sled_agent_client::types::IpNet; @@ -38,153 +34,36 @@ use futures::future::join_all; use ipnetwork::IpNetwork; use std::collections::{HashMap, HashSet}; use std::net::IpAddr; +use std::sync::Arc; use uuid::Uuid; impl super::Nexus { // VPCs - pub async fn project_create_vpc( - &self, + self: &Arc, opctx: &OpContext, project_lookup: &lookup::Project<'_>, params: ¶ms::VpcCreate, ) -> CreateResult { let (.., authz_project) = project_lookup.lookup_for(authz::Action::CreateChild).await?; - let vpc_id = Uuid::new_v4(); - let system_router_id = Uuid::new_v4(); - let default_route_id = Uuid::new_v4(); - let default_subnet_id = Uuid::new_v4(); - - // TODO: This is both fake and utter nonsense. It should be eventually - // replaced with the proper behavior for creating the default route - // which may not even happen here. Creating the vpc, its system router, - // and that routers default route should all be a part of the same - // transaction. - let vpc = db::model::IncompleteVpc::new( - vpc_id, - authz_project.id(), - system_router_id, - params.clone(), - )?; - let (authz_vpc, db_vpc) = self - .db_datastore - .project_create_vpc(opctx, &authz_project, vpc) - .await?; - // TODO: Ultimately when the VPC is created a system router w/ an - // appropriate setup should also be created. Given that the underlying - // systems aren't wired up yet this is a naive implementation to - // populate the database with a starting router. Eventually this code - // should be replaced with a saga that'll handle creating the VPC and - // its underlying system - let router = db::model::VpcRouter::new( - system_router_id, - vpc_id, - VpcRouterKind::System, - params::VpcRouterCreate { - identity: IdentityMetadataCreateParams { - name: "system".parse().unwrap(), - description: "Routes are automatically added to this \ - router as vpc subnets are created" - .into(), - }, - }, - ); - let (authz_router, _) = self - .db_datastore - .vpc_create_router(&opctx, &authz_vpc, router) - .await?; - let route = db::model::RouterRoute::new( - default_route_id, - system_router_id, - RouterRouteKind::Default, - RouterRouteCreateParams { - identity: IdentityMetadataCreateParams { - name: "default".parse().unwrap(), - description: "The default route of a vpc".to_string(), - }, - target: RouteTarget::InternetGateway( - "outbound".parse().unwrap(), - ), - destination: RouteDestination::Vpc( - params.identity.name.clone(), - ), - }, - ); + opctx.authorize(authz::Action::CreateChild, &authz_project).await?; - self.db_datastore - .router_create_route(opctx, &authz_router, route) - .await?; + let saga_params = sagas::vpc_create::Params { + serialized_authn: authn::saga::Serialized::for_opctx(opctx), + vpc_create: params.clone(), + authz_project, + }; - // Allocate the first /64 sub-range from the requested or created - // prefix. - let ipv6_block = external::Ipv6Net( - ipnetwork::Ipv6Network::new(db_vpc.ipv6_prefix.network(), 64) - .map_err(|_| { - external::Error::internal_error( - "Failed to allocate default IPv6 subnet", - ) - })?, - ); - - // TODO: batch this up with everything above - let subnet = db::model::VpcSubnet::new( - default_subnet_id, - vpc_id, - IdentityMetadataCreateParams { - name: "default".parse().unwrap(), - description: format!( - "The default subnet for {}", - params.identity.name - ), - }, - *defaults::DEFAULT_VPC_SUBNET_IPV4_BLOCK, - ipv6_block, - ); - - // Create the subnet record in the database. Overlapping IP ranges - // should be translated into an internal error. That implies that - // there's already an existing VPC Subnet, but we're explicitly creating - // the _first_ VPC in the project. Something is wrong, and likely a bug - // in our code. - self.db_datastore - .vpc_create_subnet(opctx, &authz_vpc, subnet) - .await - .map_err(|err| match err { - SubnetError::OverlappingIpRange(ip) => { - let ipv4_block = &defaults::DEFAULT_VPC_SUBNET_IPV4_BLOCK; - error!( - self.log, - concat!( - "failed to create default VPC Subnet, IP address ", - "range '{}' overlaps with existing", - ), - ip; - "vpc_id" => ?vpc_id, - "subnet_id" => ?default_subnet_id, - "ipv4_block" => ?**ipv4_block, - "ipv6_block" => ?ipv6_block, - ); - external::Error::internal_error( - "Failed to create default VPC Subnet, \ - found overlapping IP address ranges", - ) - } - SubnetError::External(e) => e, - })?; - - // Save and send the default firewall rules for the new VPC. - let rules = self - .default_firewall_rules_for_vpc( - authz_vpc.id(), - params.identity.name.clone().into(), - ) - .await?; - self.db_datastore - .vpc_update_firewall_rules(opctx, &authz_vpc, rules.clone()) + let saga_outputs = self + .execute_saga::(saga_params) .await?; - self.send_sled_agents_firewall_rules(opctx, &db_vpc, &rules).await?; + + let (_, db_vpc) = saga_outputs + .lookup_node_output::<(authz::Vpc, db::model::Vpc)>("vpc") + .map_err(|e| Error::internal_error(&format!("{:#}", &e))) + .internal_context("looking up output from VPC create saga")?; Ok(db_vpc) } @@ -349,7 +228,7 @@ impl super::Nexus { /// Customize the default firewall rules for a particular VPC /// by replacing the name `default` with the VPC's actual name. - async fn default_firewall_rules_for_vpc( + pub(crate) async fn default_firewall_rules_for_vpc( &self, vpc_id: Uuid, vpc_name: Name, @@ -392,7 +271,7 @@ impl super::Nexus { Ok(rules) } - async fn send_sled_agents_firewall_rules( + pub(crate) async fn send_sled_agents_firewall_rules( &self, opctx: &OpContext, vpc: &db::model::Vpc,