diff --git a/crates/api-core/src/cfg/file.rs b/crates/api-core/src/cfg/file.rs index 11e3c07beb..ee7fea3301 100644 --- a/crates/api-core/src/cfg/file.rs +++ b/crates/api-core/src/cfg/file.rs @@ -198,6 +198,11 @@ pub struct CarbideConfig { /// instead. pub networks: Option>, + /// VPCs to create at startup. Use the + /// `CreateVpc` gRPC to create them later + /// instead. + pub vpcs: Option>, + /// IPMI tool implementation for DPU power control /// (e.g., "prod" or "fake"). pub dpu_ipmi_tool_impl: Option, @@ -1557,6 +1562,8 @@ pub struct InitialObjectsConfig { pub pools: Option>, /// Network Segment definitions pub networks: Option>, + /// VPC definitions + pub vpcs: Option>, } /// TLS certificate and key configuration for securing @@ -2124,6 +2131,7 @@ fn default_mqtt_broker_port() -> u16 { } pub use carbide_dpa_manager::config::{DpaConfig, MqttAuthConfig, MqttAuthMode}; +use model::vpc::VpcDefinition; /// DSX Exchange Event Bus configuration for publishing state change events via MQTT 3.1.1. /// @@ -2323,6 +2331,7 @@ mod tests { use std::sync::atomic::Ordering as AtomicOrdering; use carbide_authn::config::CertComponent; + use carbide_network::virtualization::VpcVirtualizationType; use carbide_site_explorer::config::SiteExplorerExploreMode; use chrono::Datelike; use figment::Figment; @@ -3726,6 +3735,7 @@ firmware_url = "https://firmware.example.com/fw-b.bin" let config: InitialObjectsConfig = Toml::from_path(f.as_path()).unwrap(); let pools = config.pools.as_ref().unwrap(); let networks = config.networks.as_ref().unwrap(); + let vpcs = config.vpcs.as_ref().unwrap(); assert_eq!( networks.get("admin").unwrap(), @@ -3736,6 +3746,7 @@ firmware_url = "https://firmware.example.com/fw-b.bin" mtu: 9000, reserve_first: 5, allocation_strategy: Default::default(), + vpc_name: None, } ); @@ -3748,6 +3759,30 @@ firmware_url = "https://firmware.example.com/fw-b.bin" mtu: 1500, reserve_first: 5, allocation_strategy: Default::default(), + vpc_name: None, + } + ); + + assert_eq!( + networks.get("ZERO-DPU-HOST-01-SWP7").unwrap(), + &NetworkDefinition { + segment_type: NetworkDefinitionSegmentType::HostInband, + prefix: "10.217.18.192/30".parse().unwrap(), + gateway: "10.217.18.193".parse().unwrap(), + mtu: 1500, + reserve_first: 1, + allocation_strategy: Default::default(), + vpc_name: Some("zero-dpu-vpc".to_string()), + } + ); + + assert_eq!( + vpcs.get("zero-dpu-vpc").unwrap(), + &VpcDefinition { + organization_id: Some("2829bbe3-c169-4cd9-8b2a-19a8b1618a93".to_string()), + network_virtualization_type: VpcVirtualizationType::Flat, + routing_profile_type: None, + vni: None, } ); diff --git a/crates/api-core/src/cfg/test_data/initial_objects.toml b/crates/api-core/src/cfg/test_data/initial_objects.toml index 6d73e95543..43e3f3a29f 100644 --- a/crates/api-core/src/cfg/test_data/initial_objects.toml +++ b/crates/api-core/src/cfg/test_data/initial_objects.toml @@ -31,3 +31,15 @@ prefix = "172.99.0.0/26" gateway = "172.99.0.1" mtu = 1500 reserve_first = 5 + +[networks.ZERO-DPU-HOST-01-SWP7] +type = "hostinband" +prefix = "10.217.18.192/30" +gateway = "10.217.18.193" +mtu = 1500 +reserve_first = 1 +vpc_name = "zero-dpu-vpc" + +[vpcs.zero-dpu-vpc] +organization_id = "2829bbe3-c169-4cd9-8b2a-19a8b1618a93" +network_virtualization_type = "flat" diff --git a/crates/api-core/src/db_init.rs b/crates/api-core/src/db_init.rs index bb3d846331..157a74b3c7 100644 --- a/crates/api-core/src/db_init.rs +++ b/crates/api-core/src/db_init.rs @@ -18,6 +18,7 @@ use std::collections::HashMap; use carbide_network::virtualization::VpcVirtualizationType; +use carbide_uuid::vpc::VpcId; use db::dns::domain; use db::network_segment::reconcile_network_defs; use db::vpc::{self}; @@ -29,7 +30,9 @@ use model::machine::upgrade_policy::AgentUpgradePolicy; use model::metadata::Metadata; use model::network_prefix::NewNetworkPrefix; use model::network_segment::{NetworkDefinition, NetworkSegmentType, NewNetworkSegment}; -use model::vpc::{NewVpc, VpcStatus}; +use model::resource_pool; +use model::resource_pool::ResourcePool; +use model::vpc::{NewVpc, VpcDefinition, VpcStatus, VpcVirtualizationTypeCapabilities}; use sqlx::{Pool, Postgres}; use crate::CarbideError; @@ -96,12 +99,36 @@ pub async fn create_initial_networks( tracing::debug!("Network segment {name} exists"); continue; } + let mut ns = NewNetworkSegment::build_from(name, domain_id, def)?; ns.can_stretch = Some(true); + ns.vpc_id = if let Some(vpc_name) = &def.vpc_name { + match db::vpc::find_by_name(&mut txn, vpc_name).await?.as_slice() { + [vpc] => { + vpc.network_virtualization_type + .ensure_supports_segment(&ns)?; + Some(vpc.id) + } + [] => { + return Err(CarbideError::InvalidArgument(format!( + "Network segment {name} references VPC {vpc_name}, but no VPC with that name exists" + ))); + } + _ => { + return Err(CarbideError::InvalidArgument(format!( + "Network segment {name} references VPC {vpc_name}, but multiple VPCs with that name exist" + ))); + } + } + } else { + None + }; + // Capture before `save` moves `ns`. `insert_network_def` needs // the id because `network_def.segment_id` is FK-bound to it. let segment_id = ns.id; // update_network_segments_svi_ip will take care of allocating svi ip. + tracing::info!("Creating network segment {name} from config: {ns:?}"); crate::handlers::network_segment::save(api, &mut txn, ns, true, false).await?; // Snapshot the network definition in the same transaction as the network_segment row, // so the two stay consistent across restarts. @@ -115,6 +142,64 @@ pub async fn create_initial_networks( Ok(()) } +pub async fn create_initial_vpcs( + db_pool: &Pool, + vpcs: &HashMap, + vni_pool: &ResourcePool, +) -> Result<(), CarbideError> { + let mut txn = Transaction::begin(db_pool).await?; + for (name, def) in vpcs { + if db::vpc::find_by_name(&mut txn, name) + .await + .is_ok_and(|v| !v.is_empty()) + { + tracing::debug!("VPC {name} exists"); + continue; + } + + let vpc_id = VpcId::new(); + let tenant_organization_id = def + .organization_id + .clone() + .unwrap_or(uuid::Uuid::new_v4().into()); + + let vni = db::resource_pool::allocate( + vni_pool, + &mut txn, + resource_pool::OwnerType::Vpc, + vpc_id.to_string().as_ref(), + def.vni, + ) + .await?; + + let vpc = NewVpc { + id: vpc_id, + tenant_organization_id, + network_virtualization_type: def.network_virtualization_type, + metadata: Metadata { + name: name.to_owned(), + ..Default::default() + }, + network_security_group_id: None, + routing_profile_type: def.routing_profile_type.clone(), + vni: Some(vni), + }; + + // Validation + if def.routing_profile_type.is_some() { + def.network_virtualization_type + .ensure_supports_routing_profiles() + .map_err(CarbideError::from)?; + } + + db::vpc::persist(vpc, VpcStatus { vni: Some(vni) }, &mut txn).await?; + tracing::info!("Created VPC {name}"); + } + + txn.commit().await?; + Ok(()) +} + /// Create the static-assignments anchor segment if it doesn't exist. /// This segment holds external static IP assignments that don't fall /// within any managed network prefix. The 169.254.254.254/32 prefix is diff --git a/crates/api-core/src/setup.rs b/crates/api-core/src/setup.rs index c0189197f9..d48734a08a 100644 --- a/crates/api-core/src/setup.rs +++ b/crates/api-core/src/setup.rs @@ -78,6 +78,7 @@ use model::machine::HostHealthConfig; use model::network_segment::NetworkDefinition; use model::resource_pool::{self, ResourcePoolDef}; use model::route_server::RouteServerSourceType; +use model::vpc::VpcDefinition; use opentelemetry::metrics::Meter; use sqlx::postgres::PgSslMode; use sqlx::{ConnectOptions, PgPool}; @@ -107,10 +108,6 @@ use crate::mqtt_state_change_hook::hook::MqttStateChangeHook; use crate::scout_stream::ConnectionRegistry; use crate::{attestation, db_init, ethernet_virtualization, listener}; -/// The resolved set of network declarations passed from `start_api` into -/// `initialize_and_start_controllers`. -pub(crate) type NetworkDefinitionSources<'a> = Cow<'a, HashMap>; - /// Parse an `InitialObjectsConfig` file (the file pointed at by pub fn parse_initial_objects_config(path: &Path) -> eyre::Result { Figment::new() @@ -133,198 +130,6 @@ fn all_configuration_files(carbide_config: &CarbideConfig) -> Vec<&Path> { .collect::>() } -/// Given a figment and the name of a resource pool, return a human-readable -/// string describing where the resource pool definition came from -/// (for logging purposes). This is used to provide more helpful log messages -/// when there are conflicting resource pool definitions. -fn pool_source(figment: Option<&Figment>, name: &str) -> String { - figment - .and_then(|f| f.find_metadata(&format!("pools.{name}"))) - .and_then(|m| m.source.as_ref()) - .map(|source| source.to_string()) - .unwrap_or_else(|| "carbide-api config".to_string()) -} - -/// Given a figment and the name of a network definition, return the human-readable -/// string describing where the network definition came from. -fn network_source(figment: Option<&Figment>, name: &str) -> String { - figment - .and_then(|f| f.find_metadata(&format!("networks.{name}"))) - .and_then(|m| m.source.as_ref()) - .map(|source| source.to_string()) - .unwrap_or_else(|| "carbide-api config".to_string()) -} - -/// Determines the authoritative set of resource pool definitions to reconcile -/// against the database at startup, merging `InitialObjectsConfig.pools` -/// with the legacy `CarbideConfig.pools` source. -/// #[allow(clippy::result_large_err)] is used instead of Box -/// because this function is called once on startup of carbide-api and never again -#[allow(clippy::result_large_err)] -fn resolve_initial_pools( - carbide_config: &CarbideConfig, - initial_objects: Option<&InitialObjectsConfig>, -) -> Result, DefineResourcePoolError> { - let from_initial_objects = initial_objects.and_then(|io| io.pools.as_ref()); - let from_carbide_config = carbide_config.pools.as_ref(); - - match (from_initial_objects, from_carbide_config) { - // No pools are defined anywhere - (None, None) => Err(DefineResourcePoolError::InvalidArgument(format!( - "No resource pools are defined in loaded configuration files: {:?}", - all_configuration_files(carbide_config) - ))), - // Pools are defined in InitialObjectsConfig.pools - (Some(io), None) => Ok(io.clone()), - // Pools are defined in CarbideConfig.pools - (None, Some(cc)) => { - for name in cc.keys() { - let source = pool_source(carbide_config.config_ctx.as_ref(), name); - tracing::warn!( - pool = %name, - source = %source, - "Resource pool `{name} is defined in {source}. Defining Resource Pools \ - in {source}` is deprecated move the definitions into `initial_objects_file`. ") - } - Ok(cc.clone()) - } - // Pools are defined in both CarbideConfig.pools and InitialObjects.pools - (Some(io), Some(cc)) => { - let mut merged = io.clone(); - let mut conflicts: Vec = vec![]; - let mut legacy_names: Vec = vec![]; - - for (name, legacy_pool_def) in cc { - match merged.get(name) { - // `ResourcePoolDef`'s exist in both CarbideConfig.pools and InitialObjectsConfig.pools but are not the same `ResourcePoolDef` - // This is a conflict and must be resolved by the operator - Some(new_def) if new_def != legacy_pool_def => conflicts.push(name.clone()), - // `ResourcePoolDef`'s exist in both CarbideConfig.pools and InitialObjectsConfig.pools and have identical - // `ResourcePoolDef`. `legacy_names` is the name of the pools defined in CarbideConfig.pools - Some(_) => legacy_names.push(name.clone()), - None => { - // `ResourcePoolDef` only exists in `CarbideConfig.pools`. We still return the ResourcePoolDef, - // but we also want to alert operator that defining pools in `CarbideConfig.pool` is deprecated. - legacy_names.push(name.clone()); - merged.insert(name.clone(), legacy_pool_def.clone()); - } - } - } - - if !conflicts.is_empty() { - let conflict_details: Vec = conflicts - .iter() - .map(|name| { - format!( - "`{name}` (in {})", - pool_source(carbide_config.config_ctx.as_ref(), name) - ) - }) - .collect(); - return Err(DefineResourcePoolError::InvalidArgument(format!( - "resource pools have conflicting definitions \ - {conflict_details:?}. Reconcile each pool by \ - removing it from one source.", - ))); - } - for name in &legacy_names { - let source = pool_source(carbide_config.config_ctx.as_ref(), name); - tracing::warn!( - pool = %name, - source = %source, - "Resource pool `{name}` is still defined in both {source}. \ - Move it into initial_objects_file to silence this warning.", - ); - } - Ok(merged) - } - } -} - -/// Determines the authoritative set of network definitions to reconcile -/// against the database at startup, merging `InitialObjectsConfig.networks` -/// with the legacy `CarbideConfig.networks` source. -fn resolve_initial_networks<'a>( - carbide_config: &'a CarbideConfig, - initial_objects: Option<&'a InitialObjectsConfig>, -) -> eyre::Result> { - let from_initial_objects = initial_objects.and_then(|io| io.networks.as_ref()); - let from_carbide_config = carbide_config.networks.as_ref(); - - match (from_initial_objects, from_carbide_config) { - // No networks are defined anywhere — initial network creation is skipped. - (None, None) => Ok(Cow::Owned(HashMap::new())), - // Networks are defined in InitialObjectsConfig.networks - (Some(io), None) => Ok(Cow::Borrowed(io)), - // Networks are defined only in the legacy CarbideConfig.networks - (None, Some(cc)) => { - for name in cc.keys() { - let source = network_source(carbide_config.config_ctx.as_ref(), name); - tracing::warn!( - network = %name, - source = %source, - "Network `{name}` is defined in {source}. Defining networks in {source} \ - is deprecated; move the definitions into `initial_objects_file`.", - ); - } - Ok(Cow::Borrowed(cc)) - } - // Networks are defined in both sources. - (Some(io), Some(cc)) => { - // detect conflicts. - let conflicts: Vec<&str> = cc - .iter() - .filter(|(name, legacy_def)| { - io.get(name.as_str()) - .is_some_and(|new_def| new_def != *legacy_def) - }) - .map(|(name, _)| name.as_str()) - .collect(); - - if !conflicts.is_empty() { - // Each conflicting name is declared in both sources. - // Name them both so the operator knows which two files - // to compare. - let conflict_details: Vec = conflicts - .iter() - .map(|name| { - format!( - "`{name}` (in initial_objects_file vs {})", - network_source(carbide_config.config_ctx.as_ref(), name), - ) - }) - .collect(); - return Err(eyre::eyre!( - "networks have conflicting definitions {conflict_details:?}. \ - Reconcile each network by removing it from one source.", - )); - } - - // merge legacy-only entries into the result. - let mut merged = Cow::Borrowed(io); - for (name, legacy_def) in cc { - if !io.contains_key(name) { - merged.to_mut().insert(name.clone(), legacy_def.clone()); - } - } - - // Every name in `cc` is still in the deprecated source — - // emit one warning per name regardless of whether it was a - // legacy-only entry or an identical overlap. - for name in cc.keys() { - let source = network_source(carbide_config.config_ctx.as_ref(), name); - tracing::warn!( - network = %name, - source = %source, - "Network `{name}` is still defined in {source}. \ - Move it into initial_objects_file to silence this warning.", - ); - } - Ok(merged) - } - } -} - pub fn parse_carbide_config( config_str: &Path, site_config_str: Option<&Path>, @@ -527,6 +332,19 @@ pub async fn start_api( true => carbide_config.ib_fabrics.keys().cloned().collect(), }; + // Resolve initial seed data up-front so any configuration conflicts surface + // before we touch the database. The actual reconcile/creation runs inside + // `initialize_and_start_controllers`. + let seed_data = if carbide_config.listen_only { + tracing::info!("Not populating initial seed data in database, as listen_only=true"); + None + } else { + Some(SeedData::resolve( + &carbide_config, + initial_objects.as_ref(), + )?) + }; + // Note: Normally we want initialize_and_start_controllers to be responsible for populating // information into the database, but resource pools and route servers need to be defined first, // since the controllers rely on a fully-hydrated Api object, which relies on route_servers and @@ -535,21 +353,10 @@ pub async fn start_api( // // Pool reconciliation specifically must happen before `create_common_pools` runs below, because // that call queries `resource_pool` and bails if any mandatory pool is missing or empty. - // - // Resolve initial networks up-front so any configuration conflicts surface - // before we touch the database. The actual reconcile/creation runs inside - // `initialize_and_start_controllers`. - let resolved_networks = resolve_initial_networks(&carbide_config, initial_objects.as_ref())?; - - if carbide_config.listen_only { - tracing::info!( - "Not populating resource pools or route_servers in database, as listen_only=true" - ); - } else { + if let Some(seed_data) = seed_data.as_ref() { // Determine the authoritative list of resource_pools to seed into the database - let resolved_pools = resolve_initial_pools(&carbide_config, initial_objects.as_ref())?; let mut txn = Transaction::begin(&db_pool).await?; - db::resource_pool::reconcile_pool_defs(&mut txn, &resolved_pools).await?; + db::resource_pool::reconcile_pool_defs(&mut txn, &seed_data.initial_pools).await?; // We'll always update whatever route servers are in the config // to the database, and then leverage the enable_route_servers @@ -569,6 +376,7 @@ pub async fn start_api( txn.commit().await?; }; + let common_pools = db::resource_pool::create_common_pools(db_pool.clone(), ib_fabric_ids).await?; @@ -778,7 +586,7 @@ pub async fn start_api( api_service.clone(), meter.clone(), ipmi_tool.clone(), - resolved_networks, + seed_data, cancel_token.clone(), ) .await?; @@ -821,16 +629,198 @@ pub async fn start_api( Ok(()) } +#[derive(Debug)] +struct SeedData<'a> { + initial_networks: Cow<'a, HashMap>, + initial_vpcs: Cow<'a, HashMap>, + initial_pools: Cow<'a, HashMap>, +} + +trait SeedKind: Clone + PartialEq { + fn name() -> &'static str; + fn source_description(cfg: &CarbideConfig, name: &str) -> String; +} + +impl SeedKind for NetworkDefinition { + fn name() -> &'static str { + "Network" + } + + fn source_description(cfg: &CarbideConfig, name: &str) -> String { + cfg.config_ctx + .as_ref() + .and_then(|f| f.find_metadata(&format!("networks.{name}"))) + .and_then(|m| m.source.as_ref()) + .map(|source| source.to_string()) + .unwrap_or_else(|| "carbide-api config".to_string()) + } +} + +impl SeedKind for VpcDefinition { + fn name() -> &'static str { + "VPC" + } + + fn source_description(cfg: &CarbideConfig, name: &str) -> String { + cfg.config_ctx + .as_ref() + .and_then(|f| f.find_metadata(&format!("vpcs.{name}"))) + .and_then(|m| m.source.as_ref()) + .map(|source| source.to_string()) + .unwrap_or_else(|| "carbide-api config".to_string()) + } +} + +impl SeedKind for ResourcePoolDef { + fn name() -> &'static str { + "Resource pool" + } + + fn source_description(cfg: &CarbideConfig, name: &str) -> String { + cfg.config_ctx + .as_ref() + .and_then(|f| f.find_metadata(&format!("pools.{name}"))) + .and_then(|m| m.source.as_ref()) + .map(|source| source.to_string()) + .unwrap_or_else(|| "carbide-api config".to_string()) + } +} + +impl<'a> SeedData<'a> { + /// Determines the authoritative set of seed data definitions to reconcile + /// against the database at startup, merging e.g. `InitialObjectsConfig.networks` + /// with the legacy `CarbideConfig.networks` source. + fn resolve( + carbide_config: &'a CarbideConfig, + initial_objects: Option<&'a InitialObjectsConfig>, + ) -> eyre::Result { + let initial_networks = Self::merge_objects( + initial_objects.and_then(|io| io.networks.as_ref()), + carbide_config.networks.as_ref(), + carbide_config, + false, + )?; + let initial_vpcs = Self::merge_objects( + initial_objects.and_then(|io| io.vpcs.as_ref()), + carbide_config.vpcs.as_ref(), + carbide_config, + false, + )?; + let initial_pools = Self::merge_objects( + initial_objects.and_then(|io| io.pools.as_ref()), + carbide_config.pools.as_ref(), + carbide_config, + true, + )?; + + Ok(Self { + initial_networks, + initial_vpcs, + initial_pools, + }) + } + + fn merge_objects( + from_initial_objects: Option<&'a HashMap>, + from_carbide_config: Option<&'a HashMap>, + carbide_config: &CarbideConfig, + required: bool, + ) -> eyre::Result>> { + let kind = T::name(); + + match (from_initial_objects, from_carbide_config) { + // No objects are defined anywhere — raise an error + (None, None) if required => Err(DefineResourcePoolError::InvalidArgument(format!( + "No {kind}s are defined in loaded configuration files: {:?}", + all_configuration_files(carbide_config) + )) + .into()), + // No objects are defined anywhere — initial creation is skipped. + (None, None) => Ok(Cow::Owned(HashMap::new())), + // Objects are defined in InitialObjectsConfig.networks + (Some(io), None) => Ok(Cow::Borrowed(io)), + // Objects are defined only in the legacy CarbideConfig.networks + (None, Some(cc)) => { + for name in cc.keys() { + let source = T::source_description(carbide_config, name); + tracing::warn!( + object_kind = %kind, + object_name = %name, + source = %source, + "{kind} `{name}` is defined in {source}. Defining initial objects in {source} \ + is deprecated; move the definitions into `initial_objects_file`.", + ); + } + Ok(Cow::Borrowed(cc)) + } + // Objects are defined in both sources. + (Some(io), Some(cc)) => { + // detect conflicts. + let conflicts: Vec<&str> = cc + .iter() + .filter(|(name, legacy_def)| { + io.get(name.as_str()) + .is_some_and(|new_def| new_def != *legacy_def) + }) + .map(|(name, _)| name.as_str()) + .collect(); + + if !conflicts.is_empty() { + // Each conflicting name is declared in both sources. + // Name them both so the operator knows which two files + // to compare. + let conflict_details: Vec = conflicts + .iter() + .map(|name| { + format!( + "`{name}` (in initial_objects_file vs {})", + T::source_description(carbide_config, name), + ) + }) + .collect(); + return Err(eyre::eyre!( + "{kind} has conflicting definitions {conflict_details:?}. \ + Reconcile each object by removing it from one source.", + )); + } + + // merge legacy-only entries into the result. + let mut merged = Cow::Borrowed(io); + for (name, legacy_def) in cc { + if !io.contains_key(name) { + merged.to_mut().insert(name.clone(), legacy_def.clone()); + } + } + + // Every name in `cc` is still in the deprecated source — + // emit one warning per name regardless of whether it was a + // legacy-only entry or an identical overlap. + for name in cc.keys() { + let source = T::source_description(carbide_config, name); + tracing::warn!( + object_kind = %kind, + object_name = %name, + source = %source, + "{kind} `{name}` is still defined in {source}. \ + Move it into initial_objects_file to silence this warning.", + ); + } + Ok(merged) + } + } + } +} + /// Initialize and spawn all controllers and background tasks. /// /// All background tasks will be spawned into `join_set`, which can be awaited with /// [`JoinSet::join_all`] to wait for them to complete. -pub async fn initialize_and_start_controllers<'a>( +async fn initialize_and_start_controllers<'a>( join_set: &mut JoinSet<()>, api_service: Arc, meter: Meter, ipmi_tool: Arc, - initial_networks: NetworkDefinitionSources<'a>, + seed_data: Option>, cancel_token: CancellationToken, ) -> eyre::Result<()> { let Api { @@ -947,8 +937,20 @@ pub async fn initialize_and_start_controllers<'a>( resource_pool_stats: common_pools.pool_stats.clone(), }); - if !initial_networks.is_empty() { - db_init::create_initial_networks(&api_service, db_pool, &initial_networks).await?; + if let Some(seed_data) = seed_data { + if !seed_data.initial_vpcs.is_empty() { + db_init::create_initial_vpcs( + db_pool, + &seed_data.initial_vpcs, + common_pools.ethernet.pool_vpc_vni.as_ref(), + ) + .await?; + } + + if !seed_data.initial_networks.is_empty() { + db_init::create_initial_networks(&api_service, db_pool, &seed_data.initial_networks) + .await?; + } } if let Some(fnn_config) = carbide_config.fnn.as_ref() @@ -1444,13 +1446,14 @@ fn nmxc_tls_config_from_nvlink( mod tests { use std::collections::HashMap; + use carbide_network::virtualization::VpcVirtualizationType; use figment::Figment; use figment::providers::{Format, Toml}; use model::network_segment::{NetworkDefinition, NetworkDefinitionSegmentType}; use model::resource_pool::ResourcePoolType; use model::resource_pool::define::ResourcePoolDef; - use super::{resolve_initial_networks, resolve_initial_pools}; + use super::*; use crate::cfg::file::{CarbideConfig, InitialObjectsConfig}; fn carbide_with_networks( @@ -1467,10 +1470,28 @@ mod tests { .extract() .expect("Unable to extract config"); cfg.networks = networks; + cfg.pools = Some(Default::default()); cfg } + + fn carbide_with_vpcs(vpcs: Option>) -> CarbideConfig { + let mut cfg: CarbideConfig = Figment::new() + .merge(Toml::string( + r#" + database_url = "postgres://test" + listen = "[::]:1081" + asn = 1 + "#, + )) + .extract() + .expect("Unable to extract config"); + cfg.vpcs = vpcs; + cfg.pools = Some(Default::default()); + cfg + } + // Builds a `CarbideConfig` from the smallest valid TOML and overrides - // the `pools` field. `resolve_initial_pools` only reads `.pools`, so + // the `pools` field. `SeedData::resolve` only reads `.pools`, so // the rest of the config can be defaulted. fn carbide_with_pools(pools: Option>) -> CarbideConfig { let mut cfg: CarbideConfig = Figment::new() @@ -1500,6 +1521,7 @@ mod tests { mtu: 0, reserve_first: 0, allocation_strategy: Default::default(), + vpc_name: None, } } @@ -1512,12 +1534,33 @@ mod tests { } } + fn vpc_definition( + organization_id: Option<&str>, + network_virtualization_type: VpcVirtualizationType, + routing_profile_type: Option<&str>, + ) -> VpcDefinition { + VpcDefinition { + organization_id: organization_id.map(str::to_string), + network_virtualization_type, + routing_profile_type: routing_profile_type.map(str::to_string), + vni: None, + } + } + fn network_map(entries: &[(&str, NetworkDefinition)]) -> HashMap { entries .iter() .map(|(k, v)| (k.to_string(), v.clone())) .collect() } + + fn vpc_map(entries: &[(&str, VpcDefinition)]) -> HashMap { + entries + .iter() + .map(|(k, v)| (k.to_string(), v.clone())) + .collect() + } + fn pool_map(entries: &[(&str, ResourcePoolDef)]) -> HashMap { entries .iter() @@ -1529,6 +1572,15 @@ mod tests { InitialObjectsConfig { pools: None, networks: Some(network_map(entries)), + vpcs: None, + } + } + + fn initial_objects_vpcs(entries: &[(&str, VpcDefinition)]) -> InitialObjectsConfig { + InitialObjectsConfig { + pools: None, + networks: None, + vpcs: Some(vpc_map(entries)), } } @@ -1536,6 +1588,7 @@ mod tests { InitialObjectsConfig { pools: Some(pool_map(entries)), networks: None, + vpcs: None, } } @@ -1544,7 +1597,7 @@ mod tests { fn no_pool_sources_errors() { let cfg = carbide_with_pools(None); let err = - resolve_initial_pools(&cfg, None).expect_err("missing pools must surface as an error"); + SeedData::resolve(&cfg, None).expect_err("missing pools must surface as an error"); assert!( err.to_string().to_lowercase().contains("no resource pools"), "error message should name the missing input: {err}" @@ -1557,8 +1610,9 @@ mod tests { let cfg = carbide_with_pools(None); let io = initial_objects_pools(&[("lo-ip", ipv4_pool("10.0.0.0/24"))]); - let resolved = - resolve_initial_pools(&cfg, Some(&io)).expect("InitialObjectsConfig-only must succeed"); + let resolved = SeedData::resolve(&cfg, Some(&io)) + .expect("InitialObjectsConfig-only must succeed") + .initial_pools; assert_eq!(resolved.len(), 1); assert_eq!(resolved.get("lo-ip"), Some(&ipv4_pool("10.0.0.0/24"))); @@ -1570,7 +1624,9 @@ mod tests { fn legacy_only_returns_legacy_pools() { let cfg = carbide_with_pools(Some(pool_map(&[("lo-ip", ipv4_pool("10.0.0.0/24"))]))); - let resolved = resolve_initial_pools(&cfg, None).expect("legacy-only must succeed"); + let resolved = SeedData::resolve(&cfg, None) + .expect("legacy-only must succeed") + .initial_pools; assert_eq!(resolved.len(), 1); assert_eq!(resolved.get("lo-ip"), Some(&ipv4_pool("10.0.0.0/24"))); @@ -1583,7 +1639,9 @@ mod tests { let cfg = carbide_with_pools(Some(pool_map(&[("legacy-only", ipv4_pool("10.0.1.0/24"))]))); let io = initial_objects_pools(&[("new-only", ipv4_pool("10.0.2.0/24"))]); - let resolved = resolve_initial_pools(&cfg, Some(&io)).expect("disjoint union must succeed"); + let resolved = SeedData::resolve(&cfg, Some(&io)) + .expect("disjoint union must succeed") + .initial_pools; assert_eq!(resolved.len(), 2); assert!(resolved.contains_key("legacy-only")); @@ -1598,7 +1656,9 @@ mod tests { let cfg = carbide_with_pools(Some(pool_map(&[("lo-ip", pool.clone())]))); let io = initial_objects_pools(&[("lo-ip", pool.clone())]); - let resolved = resolve_initial_pools(&cfg, Some(&io)).expect("identical defs must succeed"); + let resolved = SeedData::resolve(&cfg, Some(&io)) + .expect("identical defs must succeed") + .initial_pools; assert_eq!(resolved.len(), 1); assert_eq!(resolved.get("lo-ip"), Some(&pool)); @@ -1611,7 +1671,7 @@ mod tests { let cfg = carbide_with_pools(Some(pool_map(&[("lo-ip", ipv4_pool("10.0.0.0/24"))]))); let io = initial_objects_pools(&[("lo-ip", ipv4_pool("10.0.0.0/16"))]); - let err = resolve_initial_pools(&cfg, Some(&io)).expect_err("conflicting defs must error"); + let err = SeedData::resolve(&cfg, Some(&io)).expect_err("conflicting defs must error"); assert!( err.to_string().contains("lo-ip"), @@ -1632,7 +1692,7 @@ mod tests { ("beta", ipv4_pool("10.0.1.0/16")), ]); - let err = resolve_initial_pools(&cfg, Some(&io)).expect_err("any conflict must error"); + let err = SeedData::resolve(&cfg, Some(&io)).expect_err("any conflict must error"); let msg = err.to_string(); assert!(msg.contains("alpha"), "expected `alpha` in {msg}"); @@ -1643,8 +1703,9 @@ mod tests { #[test] fn no_network_sources_returns_empty() { let cfg = carbide_with_networks(None); - let resolved = - resolve_initial_networks(&cfg, None).expect("missing networks must not be an error"); + let resolved = SeedData::resolve(&cfg, None) + .expect("missing networks must not be an error") + .initial_networks; assert!( resolved.is_empty(), "no declared networks should produce an empty map" @@ -1660,8 +1721,9 @@ mod tests { network_definition("10.0.0.0/24", NetworkDefinitionSegmentType::Admin), )]); - let resolved = resolve_initial_networks(&cfg, Some(&io)) - .expect("InitialObjectsConfig-only must succeed"); + let resolved = SeedData::resolve(&cfg, Some(&io)) + .expect("InitialObjectsConfig-only must succeed") + .initial_networks; assert_eq!(resolved.len(), 1); assert_eq!( @@ -1681,7 +1743,9 @@ mod tests { network_definition("10.0.0.0/24", NetworkDefinitionSegmentType::Admin), )]))); - let resolved = resolve_initial_networks(&cfg, None).expect("legacy-only must succeed"); + let resolved = SeedData::resolve(&cfg, None) + .expect("legacy-only must succeed") + .initial_networks; assert_eq!(resolved.len(), 1); assert_eq!( @@ -1706,8 +1770,9 @@ mod tests { network_definition("10.0.2.0/24", NetworkDefinitionSegmentType::Admin), )]); - let resolved = - resolve_initial_networks(&cfg, Some(&io)).expect("disjoint union must succeed"); + let resolved = SeedData::resolve(&cfg, Some(&io)) + .expect("disjoint union must succeed") + .initial_networks; assert_eq!(resolved.len(), 2); assert!(resolved.contains_key("legacy-only")); @@ -1722,8 +1787,9 @@ mod tests { let cfg = carbide_with_networks(Some(network_map(&[("network1", pool.clone())]))); let io = initial_objects_networks(&[("network1", pool.clone())]); - let resolved = - resolve_initial_networks(&cfg, Some(&io)).expect("identical defs must succeed"); + let resolved = SeedData::resolve(&cfg, Some(&io)) + .expect("identical defs must succeed") + .initial_networks; assert_eq!(resolved.len(), 1); assert_eq!(resolved.get("network1"), Some(&pool)); @@ -1742,8 +1808,7 @@ mod tests { network_definition("10.0.0.0/16", NetworkDefinitionSegmentType::Admin), )]); - let err = - resolve_initial_networks(&cfg, Some(&io)).expect_err("conflicting defs must error"); + let err = SeedData::resolve(&cfg, Some(&io)).expect_err("conflicting defs must error"); assert!( err.to_string().contains("network1"), @@ -1776,7 +1841,130 @@ mod tests { ), ]); - let err = resolve_initial_networks(&cfg, Some(&io)).expect_err("any conflict must error"); + let err = SeedData::resolve(&cfg, Some(&io)).expect_err("any conflict must error"); + let msg = err.to_string(); + + assert!(msg.contains("alpha"), "expected `alpha` in {msg}"); + assert!(msg.contains("beta"), "expected `beta` in {msg}"); + } + + #[test] + fn no_vpc_sources_returns_empty() { + let cfg = carbide_with_vpcs(None); + let resolved = SeedData::resolve(&cfg, None) + .expect("missing VPCs must not be an error") + .initial_vpcs; + assert!(resolved.is_empty()); + } + + #[test] + fn initial_objects_vpcs_only_succeeds() { + let cfg = carbide_with_vpcs(None); + let def = vpc_definition( + Some("2829bbe3-c169-4cd9-8b2a-19a8b1618a93"), + VpcVirtualizationType::Flat, + None, + ); + let io = initial_objects_vpcs(&[("host-inband-vpc", def.clone())]); + + let resolved = SeedData::resolve(&cfg, Some(&io)) + .expect("InitialObjectsConfig-only must succeed") + .initial_vpcs; + + assert_eq!(resolved.len(), 1); + assert_eq!(resolved.get("host-inband-vpc"), Some(&def)); + } + + #[test] + fn legacy_only_returns_legacy_vpcs() { + let def = vpc_definition(None, VpcVirtualizationType::EthernetVirtualizer, None); + let cfg = carbide_with_vpcs(Some(vpc_map(&[("legacy-vpc", def.clone())]))); + + let resolved = SeedData::resolve(&cfg, None) + .expect("legacy-only must succeed") + .initial_vpcs; + + assert_eq!(resolved.len(), 1); + assert_eq!(resolved.get("legacy-vpc"), Some(&def)); + } + + #[test] + fn disjoint_union_returns_all_vpcs() { + let legacy_def = vpc_definition(None, VpcVirtualizationType::EthernetVirtualizer, None); + let initial_def = vpc_definition( + Some("2829bbe3-c169-4cd9-8b2a-19a8b1618a93"), + VpcVirtualizationType::Flat, + None, + ); + let cfg = carbide_with_vpcs(Some(vpc_map(&[("legacy-vpc", legacy_def)]))); + let io = initial_objects_vpcs(&[("initial-vpc", initial_def)]); + + let resolved = SeedData::resolve(&cfg, Some(&io)) + .expect("disjoint union must succeed") + .initial_vpcs; + + assert_eq!(resolved.len(), 2); + assert!(resolved.contains_key("legacy-vpc")); + assert!(resolved.contains_key("initial-vpc")); + } + + #[test] + fn overlap_vpcs_identical_succeeds() { + let def = vpc_definition(None, VpcVirtualizationType::Flat, None); + let cfg = carbide_with_vpcs(Some(vpc_map(&[("host-inband-vpc", def.clone())]))); + let io = initial_objects_vpcs(&[("host-inband-vpc", def.clone())]); + + let resolved = SeedData::resolve(&cfg, Some(&io)) + .expect("identical defs must succeed") + .initial_vpcs; + + assert_eq!(resolved.len(), 1); + assert_eq!(resolved.get("host-inband-vpc"), Some(&def)); + } + + #[test] + fn overlap_vpcs_conflict_errors() { + let cfg = carbide_with_vpcs(Some(vpc_map(&[( + "host-inband-vpc", + vpc_definition(None, VpcVirtualizationType::EthernetVirtualizer, None), + )]))); + let io = initial_objects_vpcs(&[( + "host-inband-vpc", + vpc_definition(None, VpcVirtualizationType::Flat, None), + )]); + + let err = SeedData::resolve(&cfg, Some(&io)).expect_err("conflicting defs must error"); + + assert!( + err.to_string().contains("host-inband-vpc"), + "error message should name the conflicting VPC: {err}" + ); + } + + #[test] + fn collects_all_conflict_vpc_names() { + let cfg = carbide_with_vpcs(Some(vpc_map(&[ + ( + "alpha", + vpc_definition(None, VpcVirtualizationType::EthernetVirtualizer, None), + ), + ( + "beta", + vpc_definition(None, VpcVirtualizationType::EthernetVirtualizer, None), + ), + ]))); + let io = initial_objects_vpcs(&[ + ( + "alpha", + vpc_definition(None, VpcVirtualizationType::Flat, None), + ), + ( + "beta", + vpc_definition(None, VpcVirtualizationType::Flat, None), + ), + ]); + + let err = SeedData::resolve(&cfg, Some(&io)).expect_err("any conflict must error"); let msg = err.to_string(); assert!(msg.contains("alpha"), "expected `alpha` in {msg}"); diff --git a/crates/api-core/src/test_support/default_config.rs b/crates/api-core/src/test_support/default_config.rs index 54c3e801c2..d71679d7c4 100644 --- a/crates/api-core/src/test_support/default_config.rs +++ b/crates/api-core/src/test_support/default_config.rs @@ -85,6 +85,7 @@ pub fn get() -> CarbideConfig { auth: None, pools: None, networks: None, + vpcs: None, dpu_ipmi_tool_impl: None, dpu_ipmi_reboot_attempts: Some(0), bmc_session_lockout_threshold: default_bmc_session_lockout_threshold(), diff --git a/crates/api-core/src/tests/network_segment.rs b/crates/api-core/src/tests/network_segment.rs index 9705185677..920945165f 100644 --- a/crates/api-core/src/tests/network_segment.rs +++ b/crates/api-core/src/tests/network_segment.rs @@ -41,7 +41,7 @@ use model::network_segment::{ }; use model::resource_pool::common::VLANID; use model::resource_pool::{ResourcePool, ResourcePoolStats, ValueType}; -use model::vpc::UpdateVpcVirtualization; +use model::vpc::{UpdateVpcVirtualization, VpcDefinition}; use prometheus_text_parser::ParsedPrometheusMetrics; use rpc::Metadata; use rpc::forge::forge_server::Forge; @@ -501,6 +501,7 @@ pub async fn test_create_initial_networks(db_pool: sqlx::PgPool) -> Result<(), e mtu: 9000, reserve_first: 5, allocation_strategy: Default::default(), + vpc_name: None, }, ), ( @@ -512,6 +513,7 @@ pub async fn test_create_initial_networks(db_pool: sqlx::PgPool) -> Result<(), e mtu: 1500, reserve_first: 5, allocation_strategy: Default::default(), + vpc_name: None, }, ), ( @@ -523,6 +525,7 @@ pub async fn test_create_initial_networks(db_pool: sqlx::PgPool) -> Result<(), e mtu: 1500, reserve_first: 1, allocation_strategy: Default::default(), + vpc_name: None, }, ), ]); @@ -577,6 +580,121 @@ pub async fn test_create_initial_networks(db_pool: sqlx::PgPool) -> Result<(), e Ok(()) } +#[crate::sqlx_test] +pub async fn test_create_initial_vpc_and_attached_network( + db_pool: sqlx::PgPool, +) -> Result<(), eyre::Report> { + let env = + create_test_env_with_overrides(db_pool.clone(), TestEnvOverrides::no_network_segments()) + .await; + let vpcs = HashMap::from([( + "zero-dpu-vpc".to_string(), + VpcDefinition { + organization_id: Some("2829bbe3-c169-4cd9-8b2a-19a8b1618a93".to_string()), + network_virtualization_type: VpcVirtualizationType::Flat, + routing_profile_type: None, + vni: None, + }, + )]); + let networks = HashMap::from([( + "ZERO-DPU-HOST-01-SWP7".to_string(), + NetworkDefinition { + segment_type: NetworkDefinitionSegmentType::HostInband, + prefix: "10.217.18.192/30".parse().unwrap(), + gateway: "10.217.18.193".parse().unwrap(), + mtu: 1500, + reserve_first: 1, + allocation_strategy: Default::default(), + vpc_name: Some("zero-dpu-vpc".to_string()), + }, + )]); + + crate::db_init::create_initial_vpcs( + &env.pool, + &vpcs, + env.common_pools.ethernet.pool_vpc_vni.as_ref(), + ) + .await?; + crate::db_init::create_initial_networks(&env.api, &env.pool, &networks).await?; + + let mut txn = db_pool.begin().await?; + let seeded_vpcs = db::vpc::find_by_name(txn.as_mut(), "zero-dpu-vpc").await?; + assert_eq!(seeded_vpcs.len(), 1); + let seeded_vpc = &seeded_vpcs[0]; + assert_eq!( + seeded_vpc.tenant_organization_id, + "2829bbe3-c169-4cd9-8b2a-19a8b1618a93" + ); + assert_eq!( + seeded_vpc.network_virtualization_type, + VpcVirtualizationType::Flat + ); + + let host_inband = db::network_segment::find_by_name(&mut txn, "ZERO-DPU-HOST-01-SWP7").await?; + assert_eq!( + host_inband.config.segment_type, + NetworkSegmentType::HostInband + ); + assert_eq!(host_inband.config.vpc_id, Some(seeded_vpc.id)); + txn.commit().await?; + + crate::db_init::create_initial_vpcs( + &env.pool, + &vpcs, + env.common_pools.ethernet.pool_vpc_vni.as_ref(), + ) + .await?; + let seeded_vpcs = db::vpc::find_by_name(&env.pool, "zero-dpu-vpc").await?; + assert_eq!( + seeded_vpcs.len(), + 1, + "second create_initial_vpcs should not create duplicate VPCs" + ); + + Ok(()) +} + +#[crate::sqlx_test] +pub async fn test_create_initial_network_fails_for_missing_vpc_name( + db_pool: sqlx::PgPool, +) -> Result<(), eyre::Report> { + let env = + create_test_env_with_overrides(db_pool.clone(), TestEnvOverrides::no_network_segments()) + .await; + let networks = HashMap::from([( + "ZERO-DPU-HOST-01-SWP7".to_string(), + NetworkDefinition { + segment_type: NetworkDefinitionSegmentType::HostInband, + prefix: "10.217.18.192/30".parse().unwrap(), + gateway: "10.217.18.193".parse().unwrap(), + mtu: 1500, + reserve_first: 1, + allocation_strategy: Default::default(), + vpc_name: Some("missing-vpc".to_string()), + }, + )]); + + let err = crate::db_init::create_initial_networks(&env.api, &env.pool, &networks) + .await + .expect_err("missing VPC references must fail before creating a network segment"); + + assert!( + err.to_string().contains("missing-vpc"), + "error should name the missing VPC: {err}" + ); + + let mut txn = db_pool.begin().await?; + assert!( + db::network_segment::find_by_name(&mut txn, "ZERO-DPU-HOST-01-SWP7") + .await + .is_err(), + "network segment should not be created when its VPC reference is invalid" + ); + txn.commit().await?; + + Ok(()) +} + #[crate::sqlx_test] async fn test_find_segment_ids(pool: sqlx::PgPool) -> Result<(), eyre::Report> { let env = create_test_env_with_overrides(pool, TestEnvOverrides::no_network_segments()).await; diff --git a/crates/api-db/src/network_segment.rs b/crates/api-db/src/network_segment.rs index 11beeef21b..7a78eced69 100644 --- a/crates/api-db/src/network_segment.rs +++ b/crates/api-db/src/network_segment.rs @@ -936,6 +936,7 @@ mod tests { mtu: 1500, reserve_first: 5, allocation_strategy: Default::default(), + vpc_name: None, }; let mut txn = pool.begin().await?; @@ -972,6 +973,7 @@ mod tests { mtu: 1500, reserve_first: 3, allocation_strategy: Default::default(), + vpc_name: None, } } diff --git a/crates/api-model/src/network_segment/mod.rs b/crates/api-model/src/network_segment/mod.rs index b9a3290a0e..d892c7a2c4 100644 --- a/crates/api-model/src/network_segment/mod.rs +++ b/crates/api-model/src/network_segment/mod.rs @@ -93,9 +93,13 @@ pub struct NetworkDefinition { /// behavior of Carbide + carbide-dhcp. #[serde(default)] pub allocation_strategy: AllocationStrategy, + /// Set to the name of a VPC to attach this network segment to a VPC on creation. Will fail if + /// the VPC is not defined. You probably want to add a vpc with a corresponding name to the + /// config via `[vpcs.]` for this to work when data is initially being seeded. + pub vpc_name: Option, } -#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)] +#[derive(Debug, Copy, Deserialize, Serialize, Clone, PartialEq, Eq)] #[serde(rename_all = "lowercase")] pub enum NetworkDefinitionSegmentType { Admin, @@ -104,6 +108,22 @@ pub enum NetworkDefinitionSegmentType { // Tenant networks are created via the API, not the config file } +impl From for crate::network_segment::NetworkSegmentType { + fn from(value: NetworkDefinitionSegmentType) -> Self { + match value { + NetworkDefinitionSegmentType::Admin => { + crate::network_segment::NetworkSegmentType::Admin + } + NetworkDefinitionSegmentType::Underlay => { + crate::network_segment::NetworkSegmentType::Underlay + } + NetworkDefinitionSegmentType::HostInband => { + crate::network_segment::NetworkSegmentType::HostInband + } + } + } +} + /// Returns the SLA for the current state pub fn state_sla(state: &NetworkSegmentControllerState, state_version: &ConfigVersion) -> StateSla { let time_in_state = chrono::Utc::now() @@ -335,11 +355,7 @@ impl NewNetworkSegment { prefixes: vec![prefix], vlan_id: None, vni: None, - segment_type: match value.segment_type { - NetworkDefinitionSegmentType::Admin => NetworkSegmentType::Admin, - NetworkDefinitionSegmentType::Underlay => NetworkSegmentType::Underlay, - NetworkDefinitionSegmentType::HostInband => NetworkSegmentType::HostInband, - }, + segment_type: value.segment_type.into(), can_stretch: None, allocation_strategy: value.allocation_strategy, }) diff --git a/crates/api-model/src/vpc/mod.rs b/crates/api-model/src/vpc/mod.rs index ae45a2eab8..ae90262117 100644 --- a/crates/api-model/src/vpc/mod.rs +++ b/crates/api-model/src/vpc/mod.rs @@ -61,6 +61,14 @@ pub struct Vpc { pub status: Option, } +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)] +pub struct VpcDefinition { + pub organization_id: Option, + pub network_virtualization_type: VpcVirtualizationType, + pub routing_profile_type: Option, + pub vni: Option, +} + #[derive(Clone, Debug, Default)] pub struct VpcSearchFilter { pub name: Option, diff --git a/crates/network/Cargo.toml b/crates/network/Cargo.toml index d2fa01f3c6..38e7697195 100644 --- a/crates/network/Cargo.toml +++ b/crates/network/Cargo.toml @@ -37,15 +37,15 @@ ipnetwork = { workspace = true, features = ["serde"], optional = true } mac_address = { workspace = true } serde = { features = ["derive"], workspace = true } sqlx = { workspace = true, features = [ - "tls-rustls", - "mac_address", - "ipnetwork", - "uuid", - "migrate", - "postgres", - "chrono", - "macros", - "json", + "tls-rustls", + "mac_address", + "ipnetwork", + "uuid", + "migrate", + "postgres", + "chrono", + "macros", + "json", ], optional = true } thiserror = { workspace = true } diff --git a/crates/network/src/virtualization.rs b/crates/network/src/virtualization.rs index e09959e355..3cdaf6ff76 100644 --- a/crates/network/src/virtualization.rs +++ b/crates/network/src/virtualization.rs @@ -20,6 +20,7 @@ use std::str::FromStr; #[cfg(feature = "ipnetwork")] use ipnetwork::IpNetwork; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; /// DEFAULT_NETWORK_VIRTUALIZATION_TYPE is what to default to if the Cloud API /// doesn't send it to NICo (which it never does), or if the NICo API @@ -56,6 +57,38 @@ pub enum VpcVirtualizationType { Flat, } +impl VpcVirtualizationType { + pub fn as_str(&self) -> &'static str { + match self { + Self::EthernetVirtualizer | Self::EthernetVirtualizerWithNvue => "etv", + Self::Fnn => "fnn", + Self::Flat => "flat", + } + } +} + +/// Custom Serialize implementation to use our custom string representation +impl Serialize for VpcVirtualizationType { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +/// Custom Deserialize implementation to use our custom string representation +impl<'de> Deserialize<'de> for VpcVirtualizationType { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + ::deserialize(deserializer)? + .parse() + .map_err(serde::de::Error::custom) + } +} + // Per-variant policy ("how does this type behave with respect to segments, // peering, routing profiles, IPv6, host fabric interfaces") is declared // as data in `carbide_api_model::vpc::capability` and consulted via the @@ -80,12 +113,7 @@ impl sqlx::Encode<'_, sqlx::Postgres> for VpcVirtualizationType { &self, buf: &mut sqlx::postgres::PgArgumentBuffer, ) -> Result { - let s = match self { - Self::EthernetVirtualizer | Self::EthernetVirtualizerWithNvue => "etv", - Self::Fnn => "fnn", - Self::Flat => "flat", - }; - <&str as sqlx::Encode>::encode(s, buf) + <&str as sqlx::Encode>::encode(self.as_str(), buf) } } @@ -100,14 +128,7 @@ impl sqlx::postgres::PgHasArrayType for VpcVirtualizationType { impl sqlx::Decode<'_, sqlx::Postgres> for VpcVirtualizationType { fn decode(value: sqlx::postgres::PgValueRef<'_>) -> Result { let s = <&str as sqlx::Decode>::decode(value)?; - match s { - "etv" | "etv_nvue" => Ok(Self::EthernetVirtualizer), - "fnn" => Ok(Self::Fnn), - "flat" => Ok(Self::Flat), - other => { - Err(format!("invalid value {:?} for enum VpcVirtualizationType", other).into()) - } - } + s.parse().map_err(sqlx::error::BoxDynError::from) } }