diff --git a/.github/buildomat/jobs/deploy.sh b/.github/buildomat/jobs/deploy.sh index 205f8cc1709..d8065e058c3 100755 --- a/.github/buildomat/jobs/deploy.sh +++ b/.github/buildomat/jobs/deploy.sh @@ -294,7 +294,7 @@ infra_ip_last = \"$UPLINK_IP\" /^routes/c\\ routes = \\[{nexthop = \"$GATEWAY_IP\", destination = \"0.0.0.0/0\"}\\] /^addresses/c\\ -addresses = \\[{address = \"$UPLINK_IP/24\"} \\] +addresses = \\[{address = {type = \"static\", ip_net = \"$UPLINK_IP/24\"} }\\] } " pkg/config-rss.toml diff -u pkg/config-rss.toml{~,} || true diff --git a/Cargo.lock b/Cargo.lock index 117893e9f6e..ed9ae3848c0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -869,6 +869,7 @@ dependencies = [ "schemars 0.8.22", "serde", "serde_json", + "sled-agent-types", "sled-hardware-types", "slog", "uuid", @@ -13553,6 +13554,7 @@ name = "sled-agent-types-versions" version = "0.1.0" dependencies = [ "anyhow", + "assert_matches", "async-trait", "bootstore", "camino", @@ -13583,6 +13585,7 @@ dependencies = [ "strum 0.27.2", "test-strategy", "thiserror 2.0.18", + "toml 0.8.23", "trust-quorum-types-versions", "tufaceous-artifact", "uuid", @@ -16699,6 +16702,7 @@ dependencies = [ "sled-agent-types", "sled-hardware-types", "slog", + "slog-error-chain", "thiserror 2.0.18", "tokio", "toml 0.8.23", diff --git a/clients/bootstrap-agent-client/Cargo.toml b/clients/bootstrap-agent-client/Cargo.toml index 3762c6747f3..b042bd1488f 100644 --- a/clients/bootstrap-agent-client/Cargo.toml +++ b/clients/bootstrap-agent-client/Cargo.toml @@ -15,6 +15,7 @@ reqwest = { workspace = true, features = [ "json", "rustls", "stream" ] } schemars.workspace = true serde.workspace = true serde_json.workspace = true +sled-agent-types.workspace = true sled-hardware-types.workspace = true slog.workspace = true uuid.workspace = true diff --git a/clients/bootstrap-agent-lockstep-client/src/lib.rs b/clients/bootstrap-agent-lockstep-client/src/lib.rs index 5363afcf598..ea733cfaf81 100644 --- a/clients/bootstrap-agent-lockstep-client/src/lib.rs +++ b/clients/bootstrap-agent-lockstep-client/src/lib.rs @@ -26,8 +26,18 @@ progenitor::generate_api!( replace = { AllowedSourceIps = omicron_common::api::external::AllowedSourceIps, Baseboard = sled_hardware_types::Baseboard, + BgpPeerConfig = sled_agent_types::early_networking::BgpPeerConfig, ImportExportPolicy = sled_agent_types::early_networking::ImportExportPolicy, + LldpAdminStatus = sled_agent_types::early_networking::LldpAdminStatus, + LldpPortConfig = sled_agent_types::early_networking::LldpPortConfig, + PortConfig = sled_agent_types::early_networking::PortConfig, + PortFec = sled_agent_types::early_networking::PortFec, + PortSpeed = sled_agent_types::early_networking::PortSpeed, + RouteConfig = sled_agent_types::early_networking::RouteConfig, + RouterLifetimeConfig = sled_agent_types::early_networking::RouterLifetimeConfig, SwitchSlot = sled_agent_types::early_networking::SwitchSlot, + TxEqConfig = sled_agent_types::early_networking::TxEqConfig, + UplinkAddressConfig = sled_agent_types::early_networking::UplinkAddressConfig, }, ); diff --git a/clients/nexus-lockstep-client/src/lib.rs b/clients/nexus-lockstep-client/src/lib.rs index 61e88d38355..f186893ce3c 100644 --- a/clients/nexus-lockstep-client/src/lib.rs +++ b/clients/nexus-lockstep-client/src/lib.rs @@ -69,10 +69,12 @@ progenitor::generate_api!( ReconfiguratorConfigParam = nexus_types::deployment::ReconfiguratorConfigParam, ReconfiguratorConfigView = nexus_types::deployment::ReconfiguratorConfigView, RecoverySiloConfig = sled_agent_types_versions::latest::rack_init::RecoverySiloConfig, + RouterPeerType = sled_agent_types::early_networking::RouterPeerType, SledAgentUpdateStatus = nexus_types::internal_api::views::SledAgentUpdateStatus, SwitchSlot = sled_agent_types::early_networking::SwitchSlot, TrustQuorumConfig = nexus_types::trust_quorum::TrustQuorumConfig, UpdateStatus = nexus_types::internal_api::views::UpdateStatus, + UplinkAddressConfig = sled_agent_types::early_networking::UplinkAddressConfig, ZoneStatus = nexus_types::internal_api::views::ZoneStatus, ZpoolName = omicron_common::zpool_name::ZpoolName, }, diff --git a/nexus/db-model/src/bgp.rs b/nexus/db-model/src/bgp.rs index bb74a13eb75..99c36d5e28c 100644 --- a/nexus/db-model/src/bgp.rs +++ b/nexus/db-model/src/bgp.rs @@ -16,12 +16,14 @@ use omicron_common::api::external::IdentityMetadataCreateParams; use serde::{Deserialize, Serialize}; use sled_agent_types::early_networking::BgpPeerConfig; use sled_agent_types::early_networking::ImportExportPolicy; +use sled_agent_types::early_networking::InvalidIpAddrError; use sled_agent_types::early_networking::MaxPathConfig; use sled_agent_types::early_networking::RouterLifetimeConfig; use sled_agent_types::early_networking::RouterLifetimeConfigError; +use sled_agent_types::early_networking::RouterPeerIpAddr; +use sled_agent_types::early_networking::RouterPeerIpAddrError; +use sled_agent_types::early_networking::RouterPeerType; use slog_error_chain::InlineErrorChain; -use std::net::IpAddr; -use std::net::Ipv6Addr; use uuid::Uuid; #[derive( @@ -172,24 +174,52 @@ pub struct BgpPeerView { pub enum BgpPeerConfigDataError { #[error("database contains illegal router lifetime value")] RouterLifetime(#[source] RouterLifetimeConfigError), + #[error("database contains illegal router peer address")] + Address(#[source] RouterPeerIpAddrError), } impl TryFrom for BgpPeerConfig { type Error = BgpPeerConfigDataError; fn try_from(value: BgpPeerView) -> Result { - // For unnumbered peers (addr is None), use UNSPECIFIED - let addr = match value.addr { - None => IpAddr::V6(Ipv6Addr::UNSPECIFIED), - Some(addr) => addr.ip(), - }; - - // TODO-correctness We should have db constraints to ensure these can't + // TODO-correctness We should have db constraints to ensure this can't // fail. let router_lifetime = RouterLifetimeConfig::new(value.router_lifetime.0) .map_err(BgpPeerConfigDataError::RouterLifetime)?; - let min_ttl = value.min_ttl.map(|val| val.0); + + // Convert weaker database representation IP address back to a + // strongly-typed `RouterPeerType`. + let addr = match value + .addr + .map(|addr| RouterPeerIpAddr::try_from(addr.ip())) + { + Some(Ok(ip)) => RouterPeerType::Numbered { ip }, + + // TODO-cleanup This allows any of three DB values (NULL, `0.0.0.0`, + // `::`) to be converted to `RouterPeerType::Unnumbered`. Should we + // add db constraints to squish that down to one (probably NULL)? + Some(Err(RouterPeerIpAddrError { + err: InvalidIpAddrError::UnspecifiedAddress, + .. + })) + | None => RouterPeerType::Unnumbered { router_lifetime }, + + // We should never see any other kind of invalid address as a peer - + // those will fail if we try to send them to maghemite anyway. Bail + // out as early as we can. + Some(Err( + err @ RouterPeerIpAddrError { + err: + InvalidIpAddrError::LoopbackAddress + | InvalidIpAddrError::MulticastAddress + | InvalidIpAddrError::Ipv4Broadcast + | InvalidIpAddrError::Ipv6UnicastLinkLocal + | InvalidIpAddrError::Ipv4MappedIpv6, + .. + }, + )) => return Err(BgpPeerConfigDataError::Address(err)), + }; Ok(Self { asn: *value.asn, @@ -203,7 +233,7 @@ impl TryFrom for BgpPeerConfig { enforce_first_as: value.enforce_first_as, local_pref: value.local_pref.map(|x| x.into()), md5_auth_key: value.md5_auth_key, - min_ttl, + min_ttl: value.min_ttl.map(|val| val.0), multi_exit_discriminator: value .multi_exit_discriminator .map(|x| x.into()), @@ -212,7 +242,6 @@ impl TryFrom for BgpPeerConfig { allowed_export: ImportExportPolicy::NoFiltering, allowed_import: ImportExportPolicy::NoFiltering, vlan_id: value.vlan_id.map(|x| x.0), - router_lifetime, }) } } diff --git a/nexus/mgs-updates/src/test_util/host_phase_2_test_state.rs b/nexus/mgs-updates/src/test_util/host_phase_2_test_state.rs index 8f448eb690a..aa513da8d4e 100644 --- a/nexus/mgs-updates/src/test_util/host_phase_2_test_state.rs +++ b/nexus/mgs-updates/src/test_util/host_phase_2_test_state.rs @@ -271,6 +271,7 @@ mod api_impl { use sled_agent_types_versions::v20; use sled_agent_types_versions::v25; use sled_agent_types_versions::v26; + use sled_agent_types_versions::v30; use sled_diagnostics::SledDiagnosticsQueryOutput; use std::collections::BTreeMap; use std::collections::BTreeSet; @@ -771,6 +772,13 @@ mod api_impl { unimplemented!() } + async fn write_network_bootstore_config_v30( + _rqctx: RequestContext, + _body: TypedBody, + ) -> Result { + unimplemented!() + } + async fn write_network_bootstore_config_v26( _rqctx: RequestContext, _body: TypedBody, diff --git a/nexus/src/app/background/tasks/sync_switch_configuration.rs b/nexus/src/app/background/tasks/sync_switch_configuration.rs index e93cc305a3a..71b6f97c837 100644 --- a/nexus/src/app/background/tasks/sync_switch_configuration.rs +++ b/nexus/src/app/background/tasks/sync_switch_configuration.rs @@ -56,14 +56,17 @@ use sled_agent_types::early_networking::BgpPeerConfig as SledBgpPeerConfig; use sled_agent_types::early_networking::EarlyNetworkConfigBody; use sled_agent_types::early_networking::EarlyNetworkConfigEnvelope; use sled_agent_types::early_networking::ImportExportPolicy; +use sled_agent_types::early_networking::InvalidIpAddrError; use sled_agent_types::early_networking::LldpAdminStatus; use sled_agent_types::early_networking::LldpPortConfig; use sled_agent_types::early_networking::MaxPathConfig; use sled_agent_types::early_networking::PortConfig; use sled_agent_types::early_networking::RackNetworkConfig; use sled_agent_types::early_networking::RouteConfig as SledRouteConfig; +use sled_agent_types::early_networking::RouterPeerType; use sled_agent_types::early_networking::SwitchSlot; use sled_agent_types::early_networking::TxEqConfig; +use sled_agent_types::early_networking::UplinkAddress; use sled_agent_types::early_networking::UplinkAddressConfig; use sled_agent_types::early_networking::WriteNetworkConfigRequest; use slog_error_chain::InlineErrorChain; @@ -468,7 +471,7 @@ impl BackgroundTask for SwitchPortSettingsManager { // // calculate and apply switch zone SMF changes // - let uplinks = uplinks(&changes); + let uplinks = uplinks(&changes, &log); // yeet the messages for (switch_slot, config) in &uplinks { @@ -1097,6 +1100,34 @@ impl BackgroundTask for SwitchPortSettingsManager { log, "failed to convert database peer configs to \ API peer configs"; + "switch_slot" => ?switch_slot, + "port" => &port.port_name.to_string(), + InlineErrorChain::new(&err), + ); + continue; + } + }; + + let addresses = match info + .addresses + .iter() + .map(|a| { + let address = UplinkAddress::try_from_ip_net_treating_unspecified_as_addrconf(a.address)?; + Ok(UplinkAddressConfig { + address, + vlan_id: a.vlan_id + }) + }) + .collect::>() + { + Ok(addresses) => addresses, + Err(err) => { + error!( + log, + "failed to convert database uplink addresses \ + to API uplink addresses"; + "switch_slot" => ?switch_slot, + "port" => &port.port_name.to_string(), InlineErrorChain::new(&err), ); continue; @@ -1104,15 +1135,7 @@ impl BackgroundTask for SwitchPortSettingsManager { }; let mut port_config = PortConfig { - addresses: info - .addresses - .iter() - .map(|a| - UplinkAddressConfig { - address: if a.address.addr().is_unspecified() {None} else {Some(a.address)}, - vlan_id: a.vlan_id - } - ).collect(), + addresses, autoneg: info .links .get(0) //TODO breakout support @@ -1162,11 +1185,15 @@ impl BackgroundTask for SwitchPortSettingsManager { ; for peer in port_config.bgp_peers.iter_mut() { - // For unnumbered peers (addr is UNSPECIFIED), pass None - let peer_addr_for_lookup = if peer.addr.is_unspecified() { - None - } else { - Some(peer.addr) + // For unnumbered peers, pass None + // + // TODO-cleanup Push `RouterPeerAddress` down to all the + // datastore methods below instead of an `Option`. + let peer_addr_for_lookup = match peer.addr { + RouterPeerType::Unnumbered { .. } => None, + RouterPeerType::Numbered { ip } => { + Some(IpAddr::from(ip)) + } }; peer.communities = match self @@ -1679,6 +1706,7 @@ async fn switch_loopback_addresses( fn uplinks( changes: &[(SwitchSlot, nexus_db_model::SwitchPort, PortSettingsChange)], + log: &slog::Logger, ) -> HashMap> { let mut uplinks: HashMap> = HashMap::new(); for (switch_slot, port, change) in changes { @@ -1720,20 +1748,35 @@ fn uplinks( None }; + let addrs = match config + .addresses + .iter() + .map(|a| { + let address = UplinkAddress::try_from_ip_net_treating_unspecified_as_addrconf(a.address)?; + Ok(UplinkAddressConfig { + address, + vlan_id: a.vlan_id + }) + }) + .collect::>() + { + Ok(addresses) => addresses, + Err(err) => { + error!( + log, + "failed to convert database uplink addresses to \ + API uplink addresses"; + "switch_slot" => ?switch_slot, + "port" => &port.port_name.to_string(), + InlineErrorChain::new(&err), + ); + continue; + } + }; + let config = HostPortConfig { port: port.port_name.to_string(), - addrs: config - .addresses - .iter() - .map(|a| UplinkAddressConfig { - address: if a.address.addr().is_unspecified() { - None - } else { - Some(a.address) - }, - vlan_id: a.vlan_id, - }) - .collect(), + addrs, lldp, tx_eq, }; diff --git a/nexus/src/app/rack.rs b/nexus/src/app/rack.rs index e7b6be32dd3..a1436989bb9 100644 --- a/nexus/src/app/rack.rs +++ b/nexus/src/app/rack.rs @@ -42,18 +42,18 @@ use omicron_common::api::external::NameOrId; use omicron_common::api::external::ResourceType; use omicron_uuid_kinds::SledUuid; use oxnet::IpNet; -use oxnet::Ipv6Net; use sled_agent_client::types::AddSledRequest; use sled_agent_client::types::StartSledAgentRequest; use sled_agent_client::types::StartSledAgentRequestBody; use sled_agent_types::early_networking::LldpAdminStatus; +use sled_agent_types::early_networking::RouterLifetimeConfig; +use sled_agent_types::early_networking::RouterPeerType; use sled_hardware_types::BaseboardId; use slog_error_chain::InlineErrorChain; use std::collections::BTreeMap; use std::collections::BTreeSet; use std::collections::HashMap; -use std::net::Ipv6Addr; use std::num::NonZeroU32; use std::str::FromStr; use uuid::Uuid; @@ -552,9 +552,11 @@ impl super::Nexus { .iter() .map(|a| networking::Address { address_lot: NameOrId::Name(address_lot_name.clone()), - address: a.address.unwrap_or_else(|| { - IpNet::V6(Ipv6Net::host_net(Ipv6Addr::UNSPECIFIED)) - }), + // TODO-cleanup Extend stronger types out to the external + // API (omicron#9832). + address: a + .address + .ip_net_squashing_addrconf_to_unspecified(), vlan_id: a.vlan_id, }) .collect(); @@ -586,32 +588,44 @@ impl super::Nexus { let peers: Vec = uplink_config .bgp_peers .iter() - .map(|r| networking::BgpPeer { - bgp_config: NameOrId::Name( - format!("as{}", r.asn).parse().unwrap(), - ), - interface_name: link_name.clone(), - addr: if r.addr.is_unspecified() { - None - } else { - Some(r.addr) - }, - hold_time: r.hold_time() as u32, - idle_hold_time: r.idle_hold_time() as u32, - delay_open: r.delay_open() as u32, - connect_retry: r.connect_retry() as u32, - keepalive: r.keepalive() as u32, - remote_asn: r.remote_asn, - min_ttl: r.min_ttl, - md5_auth_key: r.md5_auth_key.clone(), - multi_exit_discriminator: r.multi_exit_discriminator, - local_pref: r.local_pref, - enforce_first_as: r.enforce_first_as, - communities: r.communities.clone(), - allowed_import: r.allowed_import.clone(), - allowed_export: r.allowed_export.clone(), - vlan_id: r.vlan_id, - router_lifetime: r.router_lifetime.as_u16(), + .map(|r| { + // TODO-cleanup Extend stronger types out to the external + // API (omicron#9832). + // + // For now, squash unnumbered back to None, and fill in a + // default router_lifetime for numbered. + let (addr, router_lifetime) = match r.addr { + RouterPeerType::Unnumbered { router_lifetime } => { + (None, router_lifetime.as_u16()) + } + RouterPeerType::Numbered { ip } => ( + Some(ip.into()), + RouterLifetimeConfig::default().as_u16(), + ), + }; + networking::BgpPeer { + bgp_config: NameOrId::Name( + format!("as{}", r.asn).parse().unwrap(), + ), + interface_name: link_name.clone(), + addr, + hold_time: r.hold_time() as u32, + idle_hold_time: r.idle_hold_time() as u32, + delay_open: r.delay_open() as u32, + connect_retry: r.connect_retry() as u32, + keepalive: r.keepalive() as u32, + remote_asn: r.remote_asn, + min_ttl: r.min_ttl, + md5_auth_key: r.md5_auth_key.clone(), + multi_exit_discriminator: r.multi_exit_discriminator, + local_pref: r.local_pref, + enforce_first_as: r.enforce_first_as, + communities: r.communities.clone(), + allowed_import: r.allowed_import.clone(), + allowed_export: r.allowed_export.clone(), + vlan_id: r.vlan_id, + router_lifetime, + } }) .collect(); diff --git a/openapi/bootstrap-agent-lockstep.json b/openapi/bootstrap-agent-lockstep.json index aef19840c28..d24a0195ed4 100644 --- a/openapi/bootstrap-agent-lockstep.json +++ b/openapi/bootstrap-agent-lockstep.json @@ -294,9 +294,12 @@ "type": "object", "properties": { "addr": { - "description": "Address of the peer. Use `UNSPECIFIED` to indicate an unnumbered BGP session established over the interface specified by `port`.", - "type": "string", - "format": "ip" + "description": "Address of the peer.", + "allOf": [ + { + "$ref": "#/components/schemas/RouterPeerType" + } + ] }, "allowed_export": { "description": "Define export policy for a peer.", @@ -418,15 +421,6 @@ "format": "uint32", "minimum": 0 }, - "router_lifetime": { - "description": "Router lifetime in seconds for unnumbered BGP peers.", - "default": 0, - "allOf": [ - { - "$ref": "#/components/schemas/RouterLifetimeConfig" - } - ] - }, "vlan_id": { "nullable": true, "description": "Associate a VLAN ID with a BGP peer session.", @@ -777,7 +771,7 @@ ] }, "port": { - "description": "Nmae of the port this config applies to.", + "description": "Name of the port this config applies to.", "type": "string" }, "routes": { @@ -870,9 +864,6 @@ "properties": { "allowed_source_ips": { "description": "IPs or subnets allowed to make requests to user-facing services", - "default": { - "allow": "any" - }, "allOf": [ { "$ref": "#/components/schemas/AllowedSourceIps" @@ -954,6 +945,7 @@ } }, "required": [ + "allowed_source_ips", "bootstrap_discovery", "dns_servers", "external_certificates", @@ -1258,6 +1250,60 @@ "minimum": 0, "maximum": 9000 }, + "RouterPeerIpAddr": { + "type": "string", + "format": "ip" + }, + "RouterPeerType": { + "oneOf": [ + { + "type": "object", + "properties": { + "router_lifetime": { + "description": "Router lifetime in seconds for unnumbered BGP peers.", + "allOf": [ + { + "$ref": "#/components/schemas/RouterLifetimeConfig" + } + ] + }, + "type": { + "type": "string", + "enum": [ + "unnumbered" + ] + } + }, + "required": [ + "router_lifetime", + "type" + ] + }, + { + "type": "object", + "properties": { + "ip": { + "description": "IP address for numbered BGP peers.", + "allOf": [ + { + "$ref": "#/components/schemas/RouterPeerIpAddr" + } + ] + }, + "type": { + "type": "string", + "enum": [ + "numbered" + ] + } + }, + "required": [ + "ip", + "type" + ] + } + ] + }, "RssStep": { "description": "Steps we go through during initial rack setup. Keep this list in order that they happen.", "oneOf": [ @@ -1528,15 +1574,50 @@ } } }, + "UplinkAddress": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "addrconf" + ] + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "ip_net": { + "$ref": "#/components/schemas/UplinkIpNet" + }, + "type": { + "type": "string", + "enum": [ + "static" + ] + } + }, + "required": [ + "ip_net", + "type" + ] + } + ] + }, "UplinkAddressConfig": { "type": "object", "properties": { "address": { - "nullable": true, - "description": "The address to be used on the uplink. Set to `None` for an Ipv6 Link Local address.", + "description": "The address to be used on the uplink.", "allOf": [ { - "$ref": "#/components/schemas/IpNet" + "$ref": "#/components/schemas/UplinkAddress" } ] }, @@ -1548,7 +1629,13 @@ "format": "uint16", "minimum": 0 } - } + }, + "required": [ + "address" + ] + }, + "UplinkIpNet": { + "$ref": "#/components/schemas/IpNet" }, "UserId": { "title": "A username for a local-only user", diff --git a/openapi/nexus-lockstep.json b/openapi/nexus-lockstep.json index 71e17ac8696..c463a88564d 100644 --- a/openapi/nexus-lockstep.json +++ b/openapi/nexus-lockstep.json @@ -1745,9 +1745,12 @@ "type": "object", "properties": { "addr": { - "description": "Address of the peer. Use `UNSPECIFIED` to indicate an unnumbered BGP session established over the interface specified by `port`.", - "type": "string", - "format": "ip" + "description": "Address of the peer.", + "allOf": [ + { + "$ref": "#/components/schemas/RouterPeerType" + } + ] }, "allowed_export": { "description": "Define export policy for a peer.", @@ -1869,15 +1872,6 @@ "format": "uint32", "minimum": 0 }, - "router_lifetime": { - "description": "Router lifetime in seconds for unnumbered BGP peers.", - "default": 0, - "allOf": [ - { - "$ref": "#/components/schemas/RouterLifetimeConfig" - } - ] - }, "vlan_id": { "nullable": true, "description": "Associate a VLAN ID with a BGP peer session.", @@ -7730,7 +7724,7 @@ ] }, "port": { - "description": "Nmae of the port this config applies to.", + "description": "Name of the port this config applies to.", "type": "string" }, "routes": { @@ -8545,6 +8539,60 @@ "minimum": 0, "maximum": 9000 }, + "RouterPeerIpAddr": { + "type": "string", + "format": "ip" + }, + "RouterPeerType": { + "oneOf": [ + { + "type": "object", + "properties": { + "router_lifetime": { + "description": "Router lifetime in seconds for unnumbered BGP peers.", + "allOf": [ + { + "$ref": "#/components/schemas/RouterLifetimeConfig" + } + ] + }, + "type": { + "type": "string", + "enum": [ + "unnumbered" + ] + } + }, + "required": [ + "router_lifetime", + "type" + ] + }, + { + "type": "object", + "properties": { + "ip": { + "description": "IP address for numbered BGP peers.", + "allOf": [ + { + "$ref": "#/components/schemas/RouterPeerIpAddr" + } + ] + }, + "type": { + "type": "string", + "enum": [ + "numbered" + ] + } + }, + "required": [ + "ip", + "type" + ] + } + ] + }, "Saga": { "description": "Sagas\n\nThese are currently only intended for observability by developers. We will eventually want to flesh this out into something more observable for end users.", "type": "object", @@ -9576,15 +9624,50 @@ "sleds" ] }, + "UplinkAddress": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "addrconf" + ] + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "ip_net": { + "$ref": "#/components/schemas/UplinkIpNet" + }, + "type": { + "type": "string", + "enum": [ + "static" + ] + } + }, + "required": [ + "ip_net", + "type" + ] + } + ] + }, "UplinkAddressConfig": { "type": "object", "properties": { "address": { - "nullable": true, - "description": "The address to be used on the uplink. Set to `None` for an Ipv6 Link Local address.", + "description": "The address to be used on the uplink.", "allOf": [ { - "$ref": "#/components/schemas/IpNet" + "$ref": "#/components/schemas/UplinkAddress" } ] }, @@ -9596,7 +9679,13 @@ "format": "uint16", "minimum": 0 } - } + }, + "required": [ + "address" + ] + }, + "UplinkIpNet": { + "$ref": "#/components/schemas/IpNet" }, "UserId": { "title": "A username for a local-only user", diff --git a/openapi/sled-agent/sled-agent-29.0.0-5aab4f.json.gitstub b/openapi/sled-agent/sled-agent-29.0.0-5aab4f.json.gitstub new file mode 100644 index 00000000000..01df453161b --- /dev/null +++ b/openapi/sled-agent/sled-agent-29.0.0-5aab4f.json.gitstub @@ -0,0 +1 @@ +67b7b8bd27c43d93b94b169409425969249ee262:openapi/sled-agent/sled-agent-29.0.0-5aab4f.json diff --git a/openapi/sled-agent/sled-agent-29.0.0-5aab4f.json b/openapi/sled-agent/sled-agent-30.0.0-98370d.json similarity index 99% rename from openapi/sled-agent/sled-agent-29.0.0-5aab4f.json rename to openapi/sled-agent/sled-agent-30.0.0-98370d.json index 03d3b01b1fc..cc8ebbef83b 100644 --- a/openapi/sled-agent/sled-agent-29.0.0-5aab4f.json +++ b/openapi/sled-agent/sled-agent-30.0.0-98370d.json @@ -7,7 +7,7 @@ "url": "https://oxide.computer", "email": "api@oxide.computer" }, - "version": "29.0.0" + "version": "30.0.0" }, "paths": { "/artifacts": { @@ -3335,9 +3335,12 @@ "type": "object", "properties": { "addr": { - "description": "Address of the peer. Use `UNSPECIFIED` to indicate an unnumbered BGP session established over the interface specified by `port`.", - "type": "string", - "format": "ip" + "description": "Address of the peer.", + "allOf": [ + { + "$ref": "#/components/schemas/RouterPeerType" + } + ] }, "allowed_export": { "description": "Define export policy for a peer.", @@ -3459,15 +3462,6 @@ "format": "uint32", "minimum": 0 }, - "router_lifetime": { - "description": "Router lifetime in seconds for unnumbered BGP peers.", - "default": 0, - "allOf": [ - { - "$ref": "#/components/schemas/RouterLifetimeConfig" - } - ] - }, "vlan_id": { "nullable": true, "description": "Associate a VLAN ID with a BGP peer session.", @@ -8065,7 +8059,7 @@ ] }, "port": { - "description": "Nmae of the port this config applies to.", + "description": "Name of the port this config applies to.", "type": "string" }, "routes": { @@ -8879,6 +8873,60 @@ "minimum": 0, "maximum": 9000 }, + "RouterPeerIpAddr": { + "type": "string", + "format": "ip" + }, + "RouterPeerType": { + "oneOf": [ + { + "type": "object", + "properties": { + "router_lifetime": { + "description": "Router lifetime in seconds for unnumbered BGP peers.", + "allOf": [ + { + "$ref": "#/components/schemas/RouterLifetimeConfig" + } + ] + }, + "type": { + "type": "string", + "enum": [ + "unnumbered" + ] + } + }, + "required": [ + "router_lifetime", + "type" + ] + }, + { + "type": "object", + "properties": { + "ip": { + "description": "IP address for numbered BGP peers.", + "allOf": [ + { + "$ref": "#/components/schemas/RouterPeerIpAddr" + } + ] + }, + "type": { + "type": "string", + "enum": [ + "numbered" + ] + } + }, + "required": [ + "ip", + "type" + ] + } + ] + }, "RouterTarget": { "description": "The target for a given router entry.", "oneOf": [ @@ -9875,15 +9923,50 @@ } } }, + "UplinkAddress": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "addrconf" + ] + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "ip_net": { + "$ref": "#/components/schemas/UplinkIpNet" + }, + "type": { + "type": "string", + "enum": [ + "static" + ] + } + }, + "required": [ + "ip_net", + "type" + ] + } + ] + }, "UplinkAddressConfig": { "type": "object", "properties": { "address": { - "nullable": true, - "description": "The address to be used on the uplink. Set to `None` for an Ipv6 Link Local address.", + "description": "The address to be used on the uplink.", "allOf": [ { - "$ref": "#/components/schemas/IpNet" + "$ref": "#/components/schemas/UplinkAddress" } ] }, @@ -9895,7 +9978,13 @@ "format": "uint16", "minimum": 0 } - } + }, + "required": [ + "address" + ] + }, + "UplinkIpNet": { + "$ref": "#/components/schemas/IpNet" }, "VirtioDisk": { "description": "A disk that presents a virtio-block interface to the guest.", diff --git a/openapi/sled-agent/sled-agent-latest.json b/openapi/sled-agent/sled-agent-latest.json index 8533071a07a..c465dae7846 120000 --- a/openapi/sled-agent/sled-agent-latest.json +++ b/openapi/sled-agent/sled-agent-latest.json @@ -1 +1 @@ -sled-agent-29.0.0-5aab4f.json \ No newline at end of file +sled-agent-30.0.0-98370d.json \ No newline at end of file diff --git a/openapi/wicketd.json b/openapi/wicketd.json index e2c196b18ae..410a3acd205 100644 --- a/openapi/wicketd.json +++ b/openapi/wicketd.json @@ -3893,6 +3893,13 @@ "nexthop" ] }, + "RouterLifetimeConfig": { + "description": "Router lifetime in seconds for unnumbered BGP peers", + "type": "integer", + "format": "uint16", + "minimum": 0, + "maximum": 9000 + }, "RssStep": { "description": "Steps we go through during initial rack setup. Keep this list in order that they happen.\n\n
JSON schema\n\n```json { \"description\": \"Steps we go through during initial rack setup. Keep this list in order that they happen.\", \"oneOf\": [ { \"type\": \"object\", \"required\": [ \"status\" ], \"properties\": { \"status\": { \"type\": \"string\", \"enum\": [ \"requested\" ] } } }, { \"type\": \"object\", \"required\": [ \"status\" ], \"properties\": { \"status\": { \"type\": \"string\", \"enum\": [ \"starting\" ] } } }, { \"type\": \"object\", \"required\": [ \"status\" ], \"properties\": { \"status\": { \"type\": \"string\", \"enum\": [ \"load_existing_plan\" ] } } }, { \"type\": \"object\", \"required\": [ \"status\" ], \"properties\": { \"status\": { \"type\": \"string\", \"enum\": [ \"create_sled_plan\" ] } } }, { \"type\": \"object\", \"required\": [ \"status\" ], \"properties\": { \"status\": { \"type\": \"string\", \"enum\": [ \"init_trust_quorum\" ] } } }, { \"type\": \"object\", \"required\": [ \"status\" ], \"properties\": { \"status\": { \"type\": \"string\", \"enum\": [ \"network_config_update\" ] } } }, { \"type\": \"object\", \"required\": [ \"status\" ], \"properties\": { \"status\": { \"type\": \"string\", \"enum\": [ \"sled_init\" ] } } }, { \"type\": \"object\", \"required\": [ \"status\" ], \"properties\": { \"status\": { \"type\": \"string\", \"enum\": [ \"init_dns\" ] } } }, { \"type\": \"object\", \"required\": [ \"status\" ], \"properties\": { \"status\": { \"type\": \"string\", \"enum\": [ \"configure_dns\" ] } } }, { \"type\": \"object\", \"required\": [ \"status\" ], \"properties\": { \"status\": { \"type\": \"string\", \"enum\": [ \"init_ntp\" ] } } }, { \"type\": \"object\", \"required\": [ \"status\" ], \"properties\": { \"status\": { \"type\": \"string\", \"enum\": [ \"wait_for_time_sync\" ] } } }, { \"type\": \"object\", \"required\": [ \"status\" ], \"properties\": { \"status\": { \"type\": \"string\", \"enum\": [ \"wait_for_database\" ] } } }, { \"type\": \"object\", \"required\": [ \"status\" ], \"properties\": { \"status\": { \"type\": \"string\", \"enum\": [ \"cluster_init\" ] } } }, { \"type\": \"object\", \"required\": [ \"status\" ], \"properties\": { \"status\": { \"type\": \"string\", \"enum\": [ \"zones_init\" ] } } }, { \"type\": \"object\", \"required\": [ \"status\" ], \"properties\": { \"status\": { \"type\": \"string\", \"enum\": [ \"nexus_handoff\" ] } } } ] } ```
", "oneOf": [ @@ -7416,28 +7423,6 @@ } ] }, - "UplinkAddressConfig": { - "type": "object", - "properties": { - "address": { - "nullable": true, - "description": "The address to be used on the uplink. Set to `None` for an Ipv6 Link Local address.", - "allOf": [ - { - "$ref": "#/components/schemas/IpNet" - } - ] - }, - "vlan_id": { - "nullable": true, - "description": "The VLAN id (if any) associated with this address.", - "default": null, - "type": "integer", - "format": "uint16", - "minimum": 0 - } - } - }, "UplinkPreflightStepId": { "oneOf": [ { @@ -7573,10 +7558,12 @@ "type": "object", "properties": { "addr": { - "nullable": true, "description": "Address of the peer.", - "type": "string", - "format": "ip" + "allOf": [ + { + "$ref": "#/components/schemas/UserSpecifiedRouterPeerAddr" + } + ] }, "allowed_export": { "description": "Apply export policy to this peer with an allow list.", @@ -7701,9 +7688,11 @@ "router_lifetime": { "description": "Router lifetime in seconds for unnumbered BGP peers.", "default": 0, - "type": "integer", - "format": "uint16", - "minimum": 0 + "allOf": [ + { + "$ref": "#/components/schemas/RouterLifetimeConfig" + } + ] }, "vlan_id": { "nullable": true, @@ -7715,6 +7704,7 @@ } }, "required": [ + "addr", "asn", "port" ], @@ -7734,7 +7724,7 @@ "addresses": { "type": "array", "items": { - "$ref": "#/components/schemas/UplinkAddressConfig" + "$ref": "#/components/schemas/UserSpecifiedUplinkAddressConfig" } }, "autoneg": { @@ -7836,6 +7826,31 @@ ], "additionalProperties": false }, + "UserSpecifiedRouterPeerAddr": { + "type": "string" + }, + "UserSpecifiedUplinkAddressConfig": { + "description": "User-specified version of [`sled_agent_types::early_networking::UplinkAddressConfig`].\n\nThis allows us to have a nicer TOML representation of [`UplinkAddress`].", + "type": "object", + "properties": { + "address": { + "description": "The address to be used on the uplink.", + "type": "string" + }, + "vlan_id": { + "nullable": true, + "description": "The VLAN id (if any) associated with this address.", + "default": null, + "type": "integer", + "format": "uint16", + "minimum": 0 + } + }, + "required": [ + "address" + ], + "additionalProperties": false + }, "Vendor": { "description": "Vendor-specific information about a transceiver module.", "type": "object", diff --git a/sled-agent/api/src/lib.rs b/sled-agent/api/src/lib.rs index c8f6fc0c841..8a87ede76ff 100644 --- a/sled-agent/api/src/lib.rs +++ b/sled-agent/api/src/lib.rs @@ -21,9 +21,10 @@ use omicron_common::api::internal::{ }; use sled_agent_types_versions::{ latest, v1, v4, v6, v7, v9, v10, v11, v12, v14, v16, v17, v18, v20, v22, - v24, v25, v26, + v24, v25, v26, v30, }; use sled_diagnostics::SledDiagnosticsQueryOutput; +use slog_error_chain::InlineErrorChain; api_versions!([ // WHEN CHANGING THE API (part 1 of 2): @@ -37,6 +38,7 @@ api_versions!([ // | example for the next person. // v // (next_int, IDENT), + (30, STRONGER_BGP_UNNUMBERED_TYPES), (29, ADD_VSOCK_COMPONENT), (28, MODIFY_SERVICES_IN_INVENTORY), (27, RENAME_SWITCH_LOCATION_TO_SWITCH_SLOT), @@ -760,13 +762,34 @@ pub trait SledAgentApi { #[endpoint { method = POST, path = "/switch-ports", - versions = VERSION_BGP_V6.., + versions = VERSION_STRONGER_BGP_UNNUMBERED_TYPES.., }] async fn uplink_ensure( rqctx: RequestContext, body: TypedBody, ) -> Result; + #[endpoint { + method = POST, + path = "/switch-ports", + versions = VERSION_BGP_V6..VERSION_STRONGER_BGP_UNNUMBERED_TYPES, + }] + async fn uplink_ensure_v20( + rqctx: RequestContext, + body: TypedBody, + ) -> Result { + Self::uplink_ensure( + rqctx, + body.try_map(TryFrom::try_from).map_err(|err| { + HttpError::for_bad_request( + None, + InlineErrorChain::new(&err).to_string(), + ) + })?, + ) + .await + } + #[endpoint { method = POST, path = "/switch-ports", @@ -776,7 +799,7 @@ pub trait SledAgentApi { rqctx: RequestContext, body: TypedBody, ) -> Result { - Self::uplink_ensure(rqctx, body.map(From::from)).await + Self::uplink_ensure_v20(rqctx, body.map(From::from)).await } /// This API endpoint is only reading the local sled agent's view of the @@ -862,7 +885,20 @@ pub trait SledAgentApi { #[endpoint { method = PUT, path = "/network-bootstore-config", - versions = VERSION_RACK_NETWORK_CONFIG_NOT_OPTIONAL.., + versions = VERSION_STRONGER_BGP_UNNUMBERED_TYPES.., + operation_id = "write_network_bootstore_config", + }] + async fn write_network_bootstore_config_v30( + rqctx: RequestContext, + body: TypedBody, + ) -> Result; + + // As described above, this must not forward to newer versions; sled-agent + // must implement this by faithfully serializing the requested version. + #[endpoint { + method = PUT, + path = "/network-bootstore-config", + versions = VERSION_RACK_NETWORK_CONFIG_NOT_OPTIONAL..VERSION_STRONGER_BGP_UNNUMBERED_TYPES, operation_id = "write_network_bootstore_config", }] async fn write_network_bootstore_config_v26( diff --git a/sled-agent/src/bootstrap/early_networking.rs b/sled-agent/src/bootstrap/early_networking.rs index 02809fb7475..ade0edbe289 100644 --- a/sled-agent/src/bootstrap/early_networking.rs +++ b/sled-agent/src/bootstrap/early_networking.rs @@ -40,7 +40,7 @@ use oxnet::IpNet; use rdb_types::{Prefix, Prefix4, Prefix6}; use sled_agent_types::early_networking::{ BfdMode, BgpConfig, BgpPeerConfig, ImportExportPolicy, PortConfig, PortFec, - PortSpeed, RackNetworkConfig, SwitchSlot, + PortSpeed, RackNetworkConfig, RouterPeerType, SwitchSlot, UplinkAddress, }; use slog::Logger; use slog_error_chain::InlineErrorChain; @@ -554,104 +554,107 @@ impl<'a> EarlyNetworkSetup<'a> { } } - // Determine if this is a numbered or unnumbered peer based on - // whether an address is specified (unspecified = unnumbered) - if !peer.addr.is_unspecified() { - let addr = peer.addr; + match peer.addr { // Numbered peer - identified by address - let bpc = MgBgpPeerConfig { - name: format!("{}", addr), - host: format!("{}:179", addr), - hold_time: peer - .hold_time - .unwrap_or(BgpPeerConfig::DEFAULT_HOLD_TIME), - idle_hold_time: peer - .idle_hold_time - .unwrap_or(BgpPeerConfig::DEFAULT_IDLE_HOLD_TIME), - delay_open: peer - .delay_open - .unwrap_or(BgpPeerConfig::DEFAULT_DELAY_OPEN), - connect_retry: peer - .connect_retry - .unwrap_or(BgpPeerConfig::DEFAULT_CONNECT_RETRY), - keepalive: peer - .keepalive - .unwrap_or(BgpPeerConfig::DEFAULT_KEEPALIVE), - resolution: BGP_SESSION_RESOLUTION, - passive: false, - remote_asn: peer.remote_asn, - min_ttl: peer.min_ttl, - md5_auth_key: peer.md5_auth_key.clone(), - multi_exit_discriminator: peer.multi_exit_discriminator, - communities: peer.communities.clone(), - local_pref: peer.local_pref, - enforce_first_as: peer.enforce_first_as, - ipv4_unicast: Some(build_ipv4_unicast(peer)), - ipv6_unicast: Some(build_ipv6_unicast(peer)), - vlan_id: peer.vlan_id, - connect_retry_jitter: Some(JitterRange { - max: 1.0, - min: 0.75, - }), - deterministic_collision_resolution: false, - idle_hold_jitter: None, - }; - match bgp_peer_configs.get_mut(&port.port) { - Some(peers) => { - peers.push(bpc); - } - None => { - bgp_peer_configs - .insert(port.port.clone(), vec![bpc]); + RouterPeerType::Numbered { ip: addr } => { + let bpc = MgBgpPeerConfig { + name: format!("{}", addr), + host: format!("{}:179", addr), + hold_time: peer + .hold_time + .unwrap_or(BgpPeerConfig::DEFAULT_HOLD_TIME), + idle_hold_time: peer.idle_hold_time.unwrap_or( + BgpPeerConfig::DEFAULT_IDLE_HOLD_TIME, + ), + delay_open: peer + .delay_open + .unwrap_or(BgpPeerConfig::DEFAULT_DELAY_OPEN), + connect_retry: peer.connect_retry.unwrap_or( + BgpPeerConfig::DEFAULT_CONNECT_RETRY, + ), + keepalive: peer + .keepalive + .unwrap_or(BgpPeerConfig::DEFAULT_KEEPALIVE), + resolution: BGP_SESSION_RESOLUTION, + passive: false, + remote_asn: peer.remote_asn, + min_ttl: peer.min_ttl, + md5_auth_key: peer.md5_auth_key.clone(), + multi_exit_discriminator: peer + .multi_exit_discriminator, + communities: peer.communities.clone(), + local_pref: peer.local_pref, + enforce_first_as: peer.enforce_first_as, + ipv4_unicast: Some(build_ipv4_unicast(peer)), + ipv6_unicast: Some(build_ipv6_unicast(peer)), + vlan_id: peer.vlan_id, + connect_retry_jitter: Some(JitterRange { + max: 1.0, + min: 0.75, + }), + deterministic_collision_resolution: false, + idle_hold_jitter: None, + }; + match bgp_peer_configs.get_mut(&port.port) { + Some(peers) => { + peers.push(bpc); + } + None => { + bgp_peer_configs + .insert(port.port.clone(), vec![bpc]); + } } } - } else { + // Unnumbered peer - identified by interface - let bpc = MgUnnumberedBgpPeerConfig { - name: format!("unnumbered-{}", port.port), - interface: format!("tfport{}_0", port.port), - hold_time: peer - .hold_time - .unwrap_or(BgpPeerConfig::DEFAULT_HOLD_TIME), - idle_hold_time: peer - .idle_hold_time - .unwrap_or(BgpPeerConfig::DEFAULT_IDLE_HOLD_TIME), - delay_open: peer - .delay_open - .unwrap_or(BgpPeerConfig::DEFAULT_DELAY_OPEN), - connect_retry: peer - .connect_retry - .unwrap_or(BgpPeerConfig::DEFAULT_CONNECT_RETRY), - keepalive: peer - .keepalive - .unwrap_or(BgpPeerConfig::DEFAULT_KEEPALIVE), - resolution: BGP_SESSION_RESOLUTION, - passive: false, - remote_asn: peer.remote_asn, - min_ttl: peer.min_ttl, - md5_auth_key: peer.md5_auth_key.clone(), - multi_exit_discriminator: peer.multi_exit_discriminator, - communities: peer.communities.clone(), - local_pref: peer.local_pref, - enforce_first_as: peer.enforce_first_as, - ipv4_unicast: Some(build_ipv4_unicast(peer)), - ipv6_unicast: Some(build_ipv6_unicast(peer)), - vlan_id: peer.vlan_id, - connect_retry_jitter: Some(JitterRange { - max: 1.0, - min: 0.75, - }), - deterministic_collision_resolution: false, - idle_hold_jitter: None, - router_lifetime: peer.router_lifetime.as_u16(), - }; - match bgp_unnumbered_peer_configs.get_mut(&port.port) { - Some(peers) => { - peers.push(bpc); - } - None => { - bgp_unnumbered_peer_configs - .insert(port.port.clone(), vec![bpc]); + RouterPeerType::Unnumbered { router_lifetime } => { + let bpc = MgUnnumberedBgpPeerConfig { + name: format!("unnumbered-{}", port.port), + interface: format!("tfport{}_0", port.port), + hold_time: peer + .hold_time + .unwrap_or(BgpPeerConfig::DEFAULT_HOLD_TIME), + idle_hold_time: peer.idle_hold_time.unwrap_or( + BgpPeerConfig::DEFAULT_IDLE_HOLD_TIME, + ), + delay_open: peer + .delay_open + .unwrap_or(BgpPeerConfig::DEFAULT_DELAY_OPEN), + connect_retry: peer.connect_retry.unwrap_or( + BgpPeerConfig::DEFAULT_CONNECT_RETRY, + ), + keepalive: peer + .keepalive + .unwrap_or(BgpPeerConfig::DEFAULT_KEEPALIVE), + resolution: BGP_SESSION_RESOLUTION, + passive: false, + remote_asn: peer.remote_asn, + min_ttl: peer.min_ttl, + md5_auth_key: peer.md5_auth_key.clone(), + multi_exit_discriminator: peer + .multi_exit_discriminator, + communities: peer.communities.clone(), + local_pref: peer.local_pref, + enforce_first_as: peer.enforce_first_as, + ipv4_unicast: Some(build_ipv4_unicast(peer)), + ipv6_unicast: Some(build_ipv6_unicast(peer)), + vlan_id: peer.vlan_id, + connect_retry_jitter: Some(JitterRange { + max: 1.0, + min: 0.75, + }), + deterministic_collision_resolution: false, + idle_hold_jitter: None, + router_lifetime: router_lifetime.as_u16(), + }; + match bgp_unnumbered_peer_configs.get_mut(&port.port) { + Some(peers) => { + peers.push(bpc); + } + None => { + bgp_unnumbered_peer_configs + .insert(port.port.clone(), vec![bpc]); + } } } } @@ -830,13 +833,15 @@ impl<'a> EarlyNetworkSetup<'a> { let mut addrs = Vec::new(); for a in &port_config.addresses { - if a.addr().is_unspecified() { - continue; + match a.address { + UplinkAddress::AddrConf => continue, + UplinkAddress::Static { ip_net } => { + // TODO We're discarding the `uplink_cidr.prefix()` here and + // only using the IP address; at some point we probably need + // to give the full CIDR to dendrite? + addrs.push(ip_net.addr()); + } } - // TODO We're discarding the `uplink_cidr.prefix()` here and only using - // the IP address; at some point we probably need to give the full CIDR - // to dendrite? - addrs.push(a.addr()); } let link_settings = LinkSettings { diff --git a/sled-agent/src/http_entrypoints.rs b/sled-agent/src/http_entrypoints.rs index f7811a418e3..7524020f154 100644 --- a/sled-agent/src/http_entrypoints.rs +++ b/sled-agent/src/http_entrypoints.rs @@ -78,7 +78,7 @@ use trust_quorum_types::messages::{ use trust_quorum_types::status::{CommitStatus, CoordinatorStatus, NodeStatus}; // Fixed identifiers for prior versions only -use sled_agent_types_versions::{v1, v20, v25, v26}; +use sled_agent_types_versions::{v1, v20, v25, v26, v30}; use sled_diagnostics::{ SledDiagnosticsCommandHttpOutput, SledDiagnosticsQueryOutput, }; @@ -959,6 +959,7 @@ impl SledAgentApi for SledAgentImpl { // // Use shorter names so rustfmt doesn't give up on this function. use v20::early_networking::EarlyNetworkConfigBody as BodyV20; + use v26::early_networking::EarlyNetworkConfigBody as BodyV26; type LatestEnvelope = EarlyNetworkConfigEnvelope; let sa = rqctx.context(); @@ -975,7 +976,7 @@ impl SledAgentApi for SledAgentImpl { let config = match config { Some(config) => { - let body: BodyV20 = + let latest_version_body = LatestEnvelope::deserialize_from_bootstore(&config) .and_then(|envelope| { envelope.deserialize_body() @@ -986,8 +987,9 @@ impl SledAgentApi for SledAgentImpl { early network config: {}", InlineErrorChain::new(&err), )) - })? - .into(); + })?; + let body = + BodyV20::from(BodyV26::from(latest_version_body)); v20::early_networking::EarlyNetworkConfig { generation: config.generation, schema_version: BodyV20::SCHEMA_VERSION, @@ -1007,6 +1009,26 @@ impl SledAgentApi for SledAgentImpl { .await } + async fn write_network_bootstore_config_v30( + rqctx: RequestContext, + body: TypedBody, + ) -> Result { + let sa = rqctx.context(); + let bs = sa.bootstore(); + let body = body.into_inner(); + let config = EarlyNetworkConfigEnvelope::from(&body.body) + .serialize_to_bootstore_with_generation(body.generation); + + bs.update_network_config(config).await.map_err(|e| { + HttpError::for_internal_error(format!( + "failed to write updated config to boot store: {}", + InlineErrorChain::new(&e), + )) + })?; + + Ok(HttpResponseUpdatedNoContent()) + } + async fn write_network_bootstore_config_v26( rqctx: RequestContext, body: TypedBody, diff --git a/sled-agent/src/rack_setup/service.rs b/sled-agent/src/rack_setup/service.rs index 56355429627..386163d3015 100644 --- a/sled-agent/src/rack_setup/service.rs +++ b/sled-agent/src/rack_setup/service.rs @@ -888,14 +888,7 @@ impl ServiceInner { rib_priority: r.rib_priority, }) .collect(), - addresses: config - .addresses - .iter() - .map(|a| NexusTypes::UplinkAddressConfig { - address: a.address, - vlan_id: a.vlan_id, - }) - .collect(), + addresses: config.addresses.clone(), switch: config.switch, uplink_port_speed: config.uplink_port_speed, uplink_port_fec: config.uplink_port_fec, @@ -923,10 +916,6 @@ impl ServiceInner { allowed_export: b.allowed_export.clone(), allowed_import: b.allowed_import.clone(), vlan_id: b.vlan_id, - router_lifetime: - NexusTypes::RouterLifetimeConfig( - b.router_lifetime.as_u16(), - ), }) .collect(), lldp: config.lldp.as_ref().map(|lp| { diff --git a/sled-agent/src/sim/http_entrypoints.rs b/sled-agent/src/sim/http_entrypoints.rs index 2866f23d58b..e85c2d55e45 100644 --- a/sled-agent/src/sim/http_entrypoints.rs +++ b/sled-agent/src/sim/http_entrypoints.rs @@ -87,6 +87,7 @@ use sled_agent_types_versions::v1; use sled_agent_types_versions::v20; use sled_agent_types_versions::v25; use sled_agent_types_versions::v26; +use sled_agent_types_versions::v30; use sled_diagnostics::SledDiagnosticsQueryOutput; use slog_error_chain::InlineErrorChain; use std::collections::BTreeMap; @@ -403,7 +404,8 @@ impl SledAgentApi for SledAgentSimImpl { > { // Read the current envelope, then convert it back down to the version // we have to report for this (now-removed!) API endpoint. - use v20::early_networking::EarlyNetworkConfigBody; + use v20::early_networking::EarlyNetworkConfigBody as BodyV20; + use v26::early_networking::EarlyNetworkConfigBody as BodyV26; let config = rqctx.context().bootstore_network_config.lock().unwrap().clone(); @@ -416,23 +418,38 @@ impl SledAgentApi for SledAgentSimImpl { InlineErrorChain::new(&err) )) })?; - let body: EarlyNetworkConfigBody = envelope - .deserialize_body() - .map_err(|err| { + let latest_version_body = + envelope.deserialize_body().map_err(|err| { HttpError::for_internal_error(format!( "could not deserialize early network config body: {}", InlineErrorChain::new(&err) )) - })? - .into(); + })?; + + // Downconvert from the current version to the v20 version we have to + // return from this endpoint. + let body = BodyV20::from(BodyV26::from(latest_version_body)); Ok(HttpResponseOk(v20::early_networking::EarlyNetworkConfig { generation: config.generation, - schema_version: EarlyNetworkConfigBody::SCHEMA_VERSION, + schema_version: BodyV20::SCHEMA_VERSION, body, })) } + async fn write_network_bootstore_config_v30( + rqctx: RequestContext, + body: TypedBody, + ) -> Result { + let mut config = + rqctx.context().bootstore_network_config.lock().unwrap(); + let body = body.into_inner(); + + *config = EarlyNetworkConfigEnvelope::from(&body.body) + .serialize_to_bootstore_with_generation(body.generation); + Ok(HttpResponseUpdatedNoContent()) + } + async fn write_network_bootstore_config_v26( rqctx: RequestContext, body: TypedBody, diff --git a/sled-agent/tests/data/early_network_blobs.txt b/sled-agent/tests/data/early_network_blobs.txt index a99bb01ff6c..bdd1bda8deb 100644 --- a/sled-agent/tests/data/early_network_blobs.txt +++ b/sled-agent/tests/data/early_network_blobs.txt @@ -1,3 +1,4 @@ 2026-01-22 r17,{"generation":114,"schema_version":2,"body":{"ntp_servers":[],"rack_network_config":{"rack_subnet":"fd00:1122:3344:100::/56","infra_ip_first":"172.20.15.21","infra_ip_last":"172.20.15.22","ports":[{"routes":[],"addresses":[],"switch":"switch1","port":"qsfp0","uplink_port_speed":"speed100_g","uplink_port_fec":null,"bgp_peers":[],"autoneg":false,"lldp":null,"tx_eq":null},{"routes":[],"addresses":[],"switch":"switch1","port":"qsfp26","uplink_port_speed":"speed100_g","uplink_port_fec":"rs","bgp_peers":[],"autoneg":false,"lldp":{"status":"disabled","chassis_id":null,"port_id":null,"port_description":null,"system_name":null,"system_description":null,"management_addrs":null},"tx_eq":null},{"routes":[],"addresses":[{"address":"172.20.15.53/29","vlan_id":null}],"switch":"switch1","port":"qsfp18","uplink_port_speed":"speed100_g","uplink_port_fec":"rs","bgp_peers":[{"asn":65002,"port":"qsfp18","addr":"172.20.15.51","hold_time":6,"idle_hold_time":3,"delay_open":3,"connect_retry":3,"keepalive":2,"remote_asn":null,"min_ttl":null,"md5_auth_key":null,"multi_exit_discriminator":null,"communities":[],"local_pref":null,"enforce_first_as":false,"allowed_import":{"type":"no_filtering"},"allowed_export":{"type":"allow","value":["172.20.52.0/22","172.20.26.0/24"]},"vlan_id":null}],"autoneg":false,"lldp":{"status":"disabled","chassis_id":null,"port_id":null,"port_description":null,"system_name":null,"system_description":null,"management_addrs":null},"tx_eq":null},{"routes":[],"addresses":[{"address":"172.20.15.45/29","vlan_id":null}],"switch":"switch0","port":"qsfp18","uplink_port_speed":"speed100_g","uplink_port_fec":"rs","bgp_peers":[{"asn":65002,"port":"qsfp18","addr":"172.20.15.43","hold_time":6,"idle_hold_time":0,"delay_open":3,"connect_retry":3,"keepalive":2,"remote_asn":null,"min_ttl":null,"md5_auth_key":null,"multi_exit_discriminator":null,"communities":[],"local_pref":null,"enforce_first_as":false,"allowed_import":{"type":"no_filtering"},"allowed_export":{"type":"allow","value":["172.20.52.0/22","172.20.26.0/24"]},"vlan_id":null}],"autoneg":false,"lldp":{"status":"disabled","chassis_id":null,"port_id":null,"port_description":null,"system_name":null,"system_description":null,"management_addrs":null},"tx_eq":null},{"routes":[],"addresses":[],"switch":"switch0","port":"qsfp0","uplink_port_speed":"speed100_g","uplink_port_fec":null,"bgp_peers":[],"autoneg":false,"lldp":null,"tx_eq":null},{"routes":[],"addresses":[],"switch":"switch0","port":"qsfp26","uplink_port_speed":"speed100_g","uplink_port_fec":"rs","bgp_peers":[],"autoneg":false,"lldp":{"status":"disabled","chassis_id":null,"port_id":null,"port_description":null,"system_name":null,"system_description":null,"management_addrs":null},"tx_eq":null}],"bgp":[{"asn":65002,"originate":["172.20.52.0/22","172.20.26.0/24"],"shaper":null,"checker":null}],"bfd":[]}}} 2026-02-27 r18,{"schema_version":2,"body":{"ntp_servers":[],"rack_network_config":{"bfd":[],"bgp":[{"asn":65002,"checker":null,"max_paths":1,"originate":["172.20.52.0/22","172.20.26.0/24"],"shaper":null}],"infra_ip_first":"172.20.15.21","infra_ip_last":"172.20.15.22","ports":[{"addresses":[],"autoneg":false,"bgp_peers":[],"lldp":null,"port":"qsfp0","routes":[],"switch":"switch1","tx_eq":null,"uplink_port_fec":null,"uplink_port_speed":"speed100_g"},{"addresses":[],"autoneg":false,"bgp_peers":[],"lldp":{"chassis_id":null,"management_addrs":null,"port_description":null,"port_id":null,"status":"disabled","system_description":null,"system_name":null},"port":"qsfp26","routes":[],"switch":"switch1","tx_eq":null,"uplink_port_fec":"rs","uplink_port_speed":"speed100_g"},{"addresses":[{"address":"172.20.15.53/29","vlan_id":null}],"autoneg":false,"bgp_peers":[{"addr":"172.20.15.51","allowed_export":{"type":"allow","value":["172.20.52.0/22","172.20.26.0/24"]},"allowed_import":{"type":"no_filtering"},"asn":65002,"communities":[],"connect_retry":3,"delay_open":3,"enforce_first_as":false,"hold_time":6,"idle_hold_time":3,"keepalive":2,"local_pref":null,"md5_auth_key":null,"min_ttl":null,"multi_exit_discriminator":null,"port":"qsfp18","remote_asn":null,"router_lifetime":0,"vlan_id":null}],"lldp":{"chassis_id":null,"management_addrs":null,"port_description":null,"port_id":null,"status":"disabled","system_description":null,"system_name":null},"port":"qsfp18","routes":[],"switch":"switch1","tx_eq":null,"uplink_port_fec":"rs","uplink_port_speed":"speed100_g"},{"addresses":[{"address":"172.20.15.45/29","vlan_id":null}],"autoneg":false,"bgp_peers":[{"addr":"172.20.15.43","allowed_export":{"type":"allow","value":["172.20.52.0/22","172.20.26.0/24"]},"allowed_import":{"type":"no_filtering"},"asn":65002,"communities":[],"connect_retry":3,"delay_open":3,"enforce_first_as":false,"hold_time":6,"idle_hold_time":0,"keepalive":2,"local_pref":null,"md5_auth_key":null,"min_ttl":null,"multi_exit_discriminator":null,"port":"qsfp18","remote_asn":null,"router_lifetime":0,"vlan_id":null}],"lldp":{"chassis_id":null,"management_addrs":null,"port_description":null,"port_id":null,"status":"disabled","system_description":null,"system_name":null},"port":"qsfp18","routes":[],"switch":"switch0","tx_eq":null,"uplink_port_fec":"rs","uplink_port_speed":"speed100_g"},{"addresses":[],"autoneg":false,"bgp_peers":[],"lldp":null,"port":"qsfp0","routes":[],"switch":"switch0","tx_eq":null,"uplink_port_fec":null,"uplink_port_speed":"speed100_g"},{"addresses":[],"autoneg":false,"bgp_peers":[],"lldp":{"chassis_id":null,"management_addrs":null,"port_description":null,"port_id":null,"status":"disabled","system_description":null,"system_name":null},"port":"qsfp26","routes":[],"switch":"switch0","tx_eq":null,"uplink_port_fec":"rs","uplink_port_speed":"speed100_g"}],"rack_subnet":"fd00:1122:3344:100::/56"}}} 2026-02-27 pre-r19,{"schema_version":3,"body":{"rack_network_config":{"bfd":[],"bgp":[{"asn":65002,"checker":null,"max_paths":1,"originate":["172.20.52.0/22","172.20.26.0/24"],"shaper":null}],"infra_ip_first":"172.20.15.21","infra_ip_last":"172.20.15.22","ports":[{"addresses":[],"autoneg":false,"bgp_peers":[],"lldp":null,"port":"qsfp0","routes":[],"switch":"switch1","tx_eq":null,"uplink_port_fec":null,"uplink_port_speed":"speed100_g"},{"addresses":[],"autoneg":false,"bgp_peers":[],"lldp":{"chassis_id":null,"management_addrs":null,"port_description":null,"port_id":null,"status":"disabled","system_description":null,"system_name":null},"port":"qsfp26","routes":[],"switch":"switch1","tx_eq":null,"uplink_port_fec":"rs","uplink_port_speed":"speed100_g"},{"addresses":[{"address":"172.20.15.53/29","vlan_id":null}],"autoneg":false,"bgp_peers":[{"addr":"172.20.15.51","allowed_export":{"type":"allow","value":["172.20.52.0/22","172.20.26.0/24"]},"allowed_import":{"type":"no_filtering"},"asn":65002,"communities":[],"connect_retry":3,"delay_open":3,"enforce_first_as":false,"hold_time":6,"idle_hold_time":3,"keepalive":2,"local_pref":null,"md5_auth_key":null,"min_ttl":null,"multi_exit_discriminator":null,"port":"qsfp18","remote_asn":null,"router_lifetime":0,"vlan_id":null}],"lldp":{"chassis_id":null,"management_addrs":null,"port_description":null,"port_id":null,"status":"disabled","system_description":null,"system_name":null},"port":"qsfp18","routes":[],"switch":"switch1","tx_eq":null,"uplink_port_fec":"rs","uplink_port_speed":"speed100_g"},{"addresses":[{"address":"172.20.15.45/29","vlan_id":null}],"autoneg":false,"bgp_peers":[{"addr":"172.20.15.43","allowed_export":{"type":"allow","value":["172.20.52.0/22","172.20.26.0/24"]},"allowed_import":{"type":"no_filtering"},"asn":65002,"communities":[],"connect_retry":3,"delay_open":3,"enforce_first_as":false,"hold_time":6,"idle_hold_time":0,"keepalive":2,"local_pref":null,"md5_auth_key":null,"min_ttl":null,"multi_exit_discriminator":null,"port":"qsfp18","remote_asn":null,"router_lifetime":0,"vlan_id":null}],"lldp":{"chassis_id":null,"management_addrs":null,"port_description":null,"port_id":null,"status":"disabled","system_description":null,"system_name":null},"port":"qsfp18","routes":[],"switch":"switch0","tx_eq":null,"uplink_port_fec":"rs","uplink_port_speed":"speed100_g"},{"addresses":[],"autoneg":false,"bgp_peers":[],"lldp":null,"port":"qsfp0","routes":[],"switch":"switch0","tx_eq":null,"uplink_port_fec":null,"uplink_port_speed":"speed100_g"},{"addresses":[],"autoneg":false,"bgp_peers":[],"lldp":{"chassis_id":null,"management_addrs":null,"port_description":null,"port_id":null,"status":"disabled","system_description":null,"system_name":null},"port":"qsfp26","routes":[],"switch":"switch0","tx_eq":null,"uplink_port_fec":"rs","uplink_port_speed":"speed100_g"}],"rack_subnet":"fd00:1122:3344:100::/56"}}} +2026-03-17 pre-r19,{"schema_version":4,"body":{"rack_network_config":{"bfd":[],"bgp":[{"asn":65002,"checker":null,"max_paths":1,"originate":["172.20.52.0/22","172.20.26.0/24"],"shaper":null}],"infra_ip_first":"172.20.15.21","infra_ip_last":"172.20.15.22","ports":[{"addresses":[],"autoneg":false,"bgp_peers":[],"lldp":null,"port":"qsfp0","routes":[],"switch":"switch1","tx_eq":null,"uplink_port_fec":null,"uplink_port_speed":"speed100_g"},{"addresses":[],"autoneg":false,"bgp_peers":[],"lldp":{"chassis_id":null,"management_addrs":null,"port_description":null,"port_id":null,"status":"disabled","system_description":null,"system_name":null},"port":"qsfp26","routes":[],"switch":"switch1","tx_eq":null,"uplink_port_fec":"rs","uplink_port_speed":"speed100_g"},{"addresses":[{"address":{"type":"addrconf"},"vlan_id":1}],"autoneg":false,"bgp_peers":[{"addr":{"router_lifetime":1234,"type":"unnumbered"},"allowed_export":{"type":"allow","value":["172.20.52.0/22","172.20.26.0/24"]},"allowed_import":{"type":"no_filtering"},"asn":65002,"communities":[],"connect_retry":3,"delay_open":3,"enforce_first_as":false,"hold_time":6,"idle_hold_time":3,"keepalive":2,"local_pref":null,"md5_auth_key":null,"min_ttl":null,"multi_exit_discriminator":null,"port":"qsfp18","remote_asn":null,"vlan_id":1}],"lldp":{"chassis_id":null,"management_addrs":null,"port_description":null,"port_id":null,"status":"disabled","system_description":null,"system_name":null},"port":"qsfp18","routes":[],"switch":"switch1","tx_eq":null,"uplink_port_fec":"rs","uplink_port_speed":"speed100_g"},{"addresses":[{"address":{"ip_net":"172.20.15.45/29","type":"static"},"vlan_id":null}],"autoneg":false,"bgp_peers":[{"addr":{"ip":"172.20.15.43","type":"numbered"},"allowed_export":{"type":"allow","value":["172.20.52.0/22","172.20.26.0/24"]},"allowed_import":{"type":"no_filtering"},"asn":65002,"communities":[],"connect_retry":3,"delay_open":3,"enforce_first_as":false,"hold_time":6,"idle_hold_time":0,"keepalive":2,"local_pref":null,"md5_auth_key":null,"min_ttl":null,"multi_exit_discriminator":null,"port":"qsfp18","remote_asn":null,"vlan_id":null}],"lldp":{"chassis_id":null,"management_addrs":null,"port_description":null,"port_id":null,"status":"disabled","system_description":null,"system_name":null},"port":"qsfp18","routes":[],"switch":"switch0","tx_eq":null,"uplink_port_fec":"rs","uplink_port_speed":"speed100_g"},{"addresses":[],"autoneg":false,"bgp_peers":[],"lldp":null,"port":"qsfp0","routes":[],"switch":"switch0","tx_eq":null,"uplink_port_fec":null,"uplink_port_speed":"speed100_g"},{"addresses":[],"autoneg":false,"bgp_peers":[],"lldp":{"chassis_id":null,"management_addrs":null,"port_description":null,"port_id":null,"status":"disabled","system_description":null,"system_name":null},"port":"qsfp26","routes":[],"switch":"switch0","tx_eq":null,"uplink_port_fec":"rs","uplink_port_speed":"speed100_g"}],"rack_subnet":"fd00:1122:3344:100::/56"}}} diff --git a/sled-agent/tests/integration_tests/early_network.rs b/sled-agent/tests/integration_tests/early_network.rs index af0942874e5..a86278440ad 100644 --- a/sled-agent/tests/integration_tests/early_network.rs +++ b/sled-agent/tests/integration_tests/early_network.rs @@ -10,8 +10,10 @@ use sled_agent_types::early_networking::{ BgpConfig, BgpPeerConfig, EarlyNetworkConfigBody, EarlyNetworkConfigEnvelope, ImportExportPolicy, LldpAdminStatus, LldpPortConfig, MaxPathConfig, PortConfig, PortFec, PortSpeed, - RackNetworkConfig, SwitchSlot, UplinkAddressConfig, + RackNetworkConfig, RouterLifetimeConfig, RouterPeerType, SwitchSlot, + UplinkAddress, UplinkAddressConfig, }; +use slog_error_chain::InlineErrorChain; const BLOB_PATH: &str = "tests/data/early_network_blobs.txt"; @@ -58,13 +60,15 @@ fn early_network_blobs_deserialize() { .unwrap_or_else(|error| { panic!( "error deserializing early_network_blobs.txt envelope \ - \"{blob_desc}\" (line {blob_lineno}): {error}", + \"{blob_desc}\" (line {blob_lineno}): {}", + InlineErrorChain::new(&error), ); }); let config = envelope.deserialize_body().unwrap_or_else(|error| { panic!( "error deserializing early_network_blobs.txt body \ - \"{blob_desc}\" (line {blob_lineno}): {error}", + \"{blob_desc}\" (line {blob_lineno}): {}", + InlineErrorChain::new(&error), ); }); @@ -88,8 +92,8 @@ fn early_network_blobs_deserialize() { .unwrap_or_else(|error| { panic!( "error deserializing early_network_blobs.txt \ - \"{blob_desc}\" (line {blob_lineno}) as bootstore config: \ - {error}", + \"{blob_desc}\" (line {blob_lineno}) as bootstore config: {}", + InlineErrorChain::new(&error), ); }); @@ -123,7 +127,7 @@ fn early_network_blobs_deserialize() { /// future, older blobs can still be deserialized correctly. fn current_config_example() -> (&'static str, EarlyNetworkConfigEnvelope) { // NOTE: the description must not contain commas or newlines. - let description = "2026-02-27 pre-r19"; + let description = "2026-03-17 pre-r19"; let config = EarlyNetworkConfigEnvelope::from(&EarlyNetworkConfigBody { rack_network_config: RackNetworkConfig { rack_subnet: "fd00:1122:3344:100::/56".parse().unwrap(), @@ -164,9 +168,10 @@ fn current_config_example() -> (&'static str, EarlyNetworkConfigEnvelope) { }, PortConfig { routes: vec![], - addresses: vec![UplinkAddressConfig::without_vlan( - "172.20.15.53/29".parse().unwrap(), - )], + addresses: vec![UplinkAddressConfig { + address: UplinkAddress::AddrConf, + vlan_id: Some(1), + }], switch: SwitchSlot::Switch1, port: "qsfp18".to_owned(), uplink_port_speed: PortSpeed::Speed100G, @@ -174,7 +179,10 @@ fn current_config_example() -> (&'static str, EarlyNetworkConfigEnvelope) { bgp_peers: vec![BgpPeerConfig { asn: 65002, port: "qsfp18".to_owned(), - addr: "172.20.15.51".parse().unwrap(), + addr: RouterPeerType::Unnumbered { + router_lifetime: RouterLifetimeConfig::new(1234) + .unwrap(), + }, hold_time: Some(6), idle_hold_time: Some(3), delay_open: Some(3), @@ -192,8 +200,7 @@ fn current_config_example() -> (&'static str, EarlyNetworkConfigEnvelope) { "172.20.52.0/22".parse().unwrap(), "172.20.26.0/24".parse().unwrap(), ]), - vlan_id: None, - router_lifetime: Default::default(), + vlan_id: Some(1), }], autoneg: false, tx_eq: None, @@ -219,7 +226,9 @@ fn current_config_example() -> (&'static str, EarlyNetworkConfigEnvelope) { bgp_peers: vec![BgpPeerConfig { asn: 65002, port: "qsfp18".to_owned(), - addr: "172.20.15.43".parse().unwrap(), + addr: RouterPeerType::Numbered { + ip: "172.20.15.43".parse().unwrap(), + }, hold_time: Some(6), idle_hold_time: Some(0), delay_open: Some(3), @@ -238,7 +247,6 @@ fn current_config_example() -> (&'static str, EarlyNetworkConfigEnvelope) { "172.20.26.0/24".parse().unwrap(), ]), vlan_id: None, - router_lifetime: Default::default(), }], autoneg: false, tx_eq: None, diff --git a/sled-agent/types/src/early_networking/serialization.rs b/sled-agent/types/src/early_networking/serialization.rs index 24169ec6734..2d5bc267a59 100644 --- a/sled-agent/types/src/early_networking/serialization.rs +++ b/sled-agent/types/src/early_networking/serialization.rs @@ -50,7 +50,7 @@ use bootstore::schemes::v0 as bootstore; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; -use sled_agent_types_versions::{latest, v20, v26}; +use sled_agent_types_versions::{latest, v20, v26, v30}; use slog_error_chain::SlogInlineError; #[derive(Debug, thiserror::Error, SlogInlineError)] @@ -303,6 +303,7 @@ impl EarlyNetworkConfigEnvelope { let f = versioned_decode!( v20::early_networking::EarlyNetworkConfigBody, v26::early_networking::EarlyNetworkConfigBody, + v30::early_networking::EarlyNetworkConfigBody, ); f(self.schema_version, self.body.clone()) } @@ -320,39 +321,33 @@ fn deserialize_body( } // We need to be able to construct [`EarlyNetworkConfigEnvelope`]s for every -// version of `EarlyNetworkConfigBody` (starting from the current version of -// `EarlyNetworkConfigBody` when `EarlyNetworkConfigEnvelope` was introduced). +// version of `EarlyNetworkConfigBody` (starting from the version of +// `EarlyNetworkConfigBody` that was current when `EarlyNetworkConfigEnvelope` +// was introduced; i.e., v20). // -// Put those `From` impls here. -impl From<&'_ v20::early_networking::EarlyNetworkConfigBody> - for EarlyNetworkConfigEnvelope -{ - fn from(value: &'_ v20::early_networking::EarlyNetworkConfigBody) -> Self { - Self { - schema_version: - v20::early_networking::EarlyNetworkConfigBody::SCHEMA_VERSION, - // We're serializing in-memory; this can only fail if - // `EarlyNetworkConfigBody` contains types that can't be represented - // as JSON, which (a) should never happen and (b) we should catch - // immediately in tests. - body: serde_json::to_value(value) - .expect("EarlyNetworkConfigBody can be serialized as JSON"), - } - } -} -impl From<&'_ v26::early_networking::EarlyNetworkConfigBody> - for EarlyNetworkConfigEnvelope -{ - fn from(value: &'_ v26::early_networking::EarlyNetworkConfigBody) -> Self { - Self { - schema_version: - v26::early_networking::EarlyNetworkConfigBody::SCHEMA_VERSION, - // We're serializing in-memory; this can only fail if - // `EarlyNetworkConfigBody` contains types that can't be represented - // as JSON, which (a) should never happen and (b) we should catch - // immediately in tests. - body: serde_json::to_value(value) - .expect("EarlyNetworkConfigBody can be serialized as JSON"), +// Put those `From` impls here. They're all identical, so we use this macro to +// remain consistent; in particular, we _must_ ensure that we set +// `schema_version` to the `SCHEMA_VERSION` of the specific type we're +// converting from. +macro_rules! from_body_for_envelope { + ($body_type:path) => { + impl From<&'_ $body_type> for EarlyNetworkConfigEnvelope { + fn from(value: &'_ $body_type) -> Self { + Self { + schema_version: <$body_type>::SCHEMA_VERSION, + // We're serializing in-memory; this can only fail if + // the body type contains types that can't be represented + // as JSON, which (a) should never happen and (b) we + // should catch immediately in tests. + body: serde_json::to_value(value).expect(concat!( + stringify!($body_type), + " can be serialized as JSON" + )), + } + } } - } + }; } +from_body_for_envelope!(v20::early_networking::EarlyNetworkConfigBody); +from_body_for_envelope!(v26::early_networking::EarlyNetworkConfigBody); +from_body_for_envelope!(v30::early_networking::EarlyNetworkConfigBody); diff --git a/sled-agent/types/versions/Cargo.toml b/sled-agent/types/versions/Cargo.toml index 095b0d2037a..7ff8ed93514 100644 --- a/sled-agent/types/versions/Cargo.toml +++ b/sled-agent/types/versions/Cargo.toml @@ -43,9 +43,11 @@ tufaceous-artifact.workspace = true uuid.workspace = true [dev-dependencies] +assert_matches.workspace = true omicron-test-utils.workspace = true proptest.workspace = true test-strategy.workspace = true +toml.workspace = true [features] testing = ["dep:proptest", "dep:test-strategy"] diff --git a/sled-agent/types/versions/src/bgp_v6/early_networking.rs b/sled-agent/types/versions/src/bgp_v6/early_networking.rs index 87f2d005d08..2d423d77bc2 100644 --- a/sled-agent/types/versions/src/bgp_v6/early_networking.rs +++ b/sled-agent/types/versions/src/bgp_v6/early_networking.rs @@ -456,7 +456,9 @@ pub enum MaxPathConfigError { /// /// This value is used in IPv6 Router Advertisements to indicate how long /// the router should be considered valid by neighbors. -#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Serialize)] +#[derive( + Debug, Copy, Clone, Eq, PartialEq, Hash, PartialOrd, Ord, Serialize, +)] pub struct RouterLifetimeConfig(u16); impl RouterLifetimeConfig { diff --git a/sled-agent/types/versions/src/bgp_v6/rack_init.rs b/sled-agent/types/versions/src/bgp_v6/rack_init.rs index e4c9021caac..dc8d5cc561a 100644 --- a/sled-agent/types/versions/src/bgp_v6/rack_init.rs +++ b/sled-agent/types/versions/src/bgp_v6/rack_init.rs @@ -113,7 +113,7 @@ struct UnvalidatedRackInitializeRequest { allowed_source_ips: AllowedSourceIps, } -#[derive(Debug, Clone)] +#[derive(Clone)] pub struct RackInitializeRequestParams { pub rack_initialize_request: RackInitializeRequest, pub skip_timesync: bool, diff --git a/sled-agent/types/versions/src/impls/early_networking.rs b/sled-agent/types/versions/src/impls/early_networking.rs index 861a79be7df..02c258b6938 100644 --- a/sled-agent/types/versions/src/impls/early_networking.rs +++ b/sled-agent/types/versions/src/impls/early_networking.rs @@ -5,6 +5,7 @@ //! Implementations for early networking types. use crate::latest::early_networking::BgpPeerConfig; +use crate::latest::early_networking::InvalidIpAddrError; use crate::latest::early_networking::LldpAdminStatus; use crate::latest::early_networking::MaxPathConfig; use crate::latest::early_networking::MaxPathConfigError; @@ -12,12 +13,22 @@ use crate::latest::early_networking::PortFec; use crate::latest::early_networking::PortSpeed; use crate::latest::early_networking::RouterLifetimeConfig; use crate::latest::early_networking::RouterLifetimeConfigError; +use crate::latest::early_networking::RouterPeerIpAddr; +use crate::latest::early_networking::RouterPeerIpAddrError; +use crate::latest::early_networking::RouterPeerType; use crate::latest::early_networking::SwitchSlot; +use crate::latest::early_networking::UplinkAddress; use crate::latest::early_networking::UplinkAddressConfig; +use crate::latest::early_networking::UplinkIpNet; +use crate::latest::early_networking::UplinkIpNetError; use omicron_common::api::external; use oxnet::IpNet; +use oxnet::IpNetParseError; +use oxnet::Ipv6Net; use std::fmt; +use std::net::AddrParseError; use std::net::IpAddr; +use std::net::Ipv4Addr; use std::net::Ipv6Addr; use std::str::FromStr; @@ -114,42 +125,153 @@ impl std::fmt::Display for RouterLifetimeConfig { } } -impl UplinkAddressConfig { - /// Construct an `UplinkAddressConfig` with no VLAN ID. - pub fn without_vlan(address: IpNet) -> Self { - // TODO-cleanup Squash unspecified addresses down to `None`. We want - // better types here: - // . - let address = - if address.addr().is_unspecified() { None } else { Some(address) }; - Self { address, vlan_id: None } +impl std::fmt::Display for UplinkIpNet { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + IpNet::from(*self).fmt(f) + } +} + +impl std::fmt::Display for RouterPeerIpAddr { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(f) + } +} + +impl UplinkIpNet { + pub const fn addr(&self) -> IpAddr { + self.0.addr() + } +} + +#[derive(Debug, thiserror::Error)] +pub enum UplinkIpNetParseError { + #[error("invalid IP net")] + IpNetParseError(#[from] IpNetParseError), + #[error(transparent)] + InvalidIpError(#[from] UplinkIpNetError), +} + +impl FromStr for UplinkIpNet { + type Err = UplinkIpNetParseError; + + fn from_str(s: &str) -> Result { + let ip = IpNet::from_str(s)?; + let addr = Self::try_from(ip)?; + Ok(addr) } +} + +#[derive(Debug, thiserror::Error)] +pub enum RouterPeerIpAddrParseError { + #[error(transparent)] + AddrParseError(#[from] AddrParseError), + #[error(transparent)] + InvalidIpAddr(#[from] RouterPeerIpAddrError), +} + +impl FromStr for RouterPeerIpAddr { + type Err = RouterPeerIpAddrParseError; - pub fn addr(&self) -> IpAddr { - match self.address { - Some(ipaddr) => ipaddr.addr(), - None => IpAddr::V6(Ipv6Addr::UNSPECIFIED), + fn from_str(s: &str) -> Result { + let ip = IpAddr::from_str(s)?; + let addr = Self::try_from(ip)?; + Ok(addr) + } +} + +impl RouterPeerType { + /// In contexts where we cannot use this strong type to describe + /// "unnumbered" addresses, we have two or three possible representations: + /// + /// * In a context where we need a non-optional `IpAddr`, we could use + /// `Ipv4Addr::UNSPECIFIED` or `Ipv6Addr::UNSPECIFIED`. + /// * In a context where we need `Option`, we could use `None`, + /// Some(`Ipv4Addr::UNSPECIFIED`), or Some(`Ipv6Addr::UNSPECIFIED`). + /// + /// In the optional case, we always prefer `None`. In the non-optional case, + /// we choose this sentinel value. + pub const UNNUMBERED_SENTINEL: IpAddr = IpAddr::V4(Ipv4Addr::UNSPECIFIED); + + /// Squash this address down to an [`IpAddr`] by converting + /// [`RouterPeerType::Unnumbered`] to + /// [`RouterPeerType::UNNUMBERED_SENTINEL`]. + /// + /// Uses of this function probably indicate places where we could consider + /// using stronger types. + pub fn ip_squashing_unnumbered_to_sentinel(&self) -> IpAddr { + match *self { + Self::Unnumbered { .. } => Self::UNNUMBERED_SENTINEL, + Self::Numbered { ip } => ip.into(), } } +} - /// Format `self` appropriately for passing to `uplinkd`'s SMF properties. - pub fn to_uplinkd_smf_property(&self) -> String { - fn addr_string(addr: &oxnet::IpNet) -> String { - if addr.addr().is_unspecified() { - "link-local".into() - } else { - addr.to_string() +impl UplinkAddress { + /// Squash this address down to a flat IP address by converting + /// [`UplinkAddress::AddrConf`] to `::`. + /// + /// Uses of this function probably indicate places where we could consider + /// using stronger types. + pub fn ip_squashing_addrconf_to_unspecified(&self) -> IpAddr { + match self { + UplinkAddress::AddrConf => IpAddr::V6(Ipv6Addr::UNSPECIFIED), + UplinkAddress::Static { ip_net } => ip_net.addr(), + } + } + + /// Squash this address down to an [`IpNet`] address by converting + /// [`UplinkAddress::AddrConf`] to `::/128`. + /// + /// Uses of this function probably indicate places where we could consider + /// using stronger types. + pub fn ip_net_squashing_addrconf_to_unspecified(&self) -> IpNet { + match *self { + UplinkAddress::AddrConf => { + IpNet::V6(Ipv6Net::host_net(Ipv6Addr::UNSPECIFIED)) } + UplinkAddress::Static { ip_net } => ip_net.into(), } + } - // TODO-cleanup for now, squash address values of both `None` and - // `Some(UNSPECIFIED)` down to "link-local". We want better types here: - // . - match (&self.address, self.vlan_id) { - (Some(addr), None) => addr_string(addr), - (Some(addr), Some(v)) => format!("{};{v}", addr_string(addr)), - (None, None) => "link-local".to_string(), - (None, Some(v)) => format!("link-local;{v}"), + /// Convert an arbitrary [`IpNet`] into an [`UplinkAddress`] by converting + /// an unspecified IP to [`UplinkAddress::AddrConf`]. + /// + /// Uses of this function probably indicate places where we could consider + /// using stronger types. + pub fn try_from_ip_net_treating_unspecified_as_addrconf( + ip_net: IpNet, + ) -> Result { + match UplinkIpNet::try_from(ip_net) { + Ok(ip_net) => Ok(Self::Static { ip_net }), + Err(err) => match err.err { + InvalidIpAddrError::UnspecifiedAddress => Ok(Self::AddrConf), + InvalidIpAddrError::LoopbackAddress + | InvalidIpAddrError::MulticastAddress + | InvalidIpAddrError::Ipv4Broadcast + | InvalidIpAddrError::Ipv6UnicastLinkLocal + | InvalidIpAddrError::Ipv4MappedIpv6 => Err(err.err), + }, + } + } +} + +impl UplinkAddressConfig { + /// Helper to construct an `UplinkAddressConfig` with a specified IP net and + /// no VLAN ID. + pub fn without_vlan(ip_net: UplinkIpNet) -> Self { + Self { address: UplinkAddress::Static { ip_net }, vlan_id: None } + } + + /// Format `self` appropriately for passing to `uplinkd`'s SMF properties. + pub fn to_uplinkd_smf_property(&self) -> String { + let addr: &dyn fmt::Display = match &self.address { + UplinkAddress::AddrConf => &"link-local", + UplinkAddress::Static { ip_net } => ip_net, + }; + + match self.vlan_id { + Some(v) => format!("{addr};{v}"), + None => addr.to_string(), } } } @@ -214,3 +336,282 @@ impl fmt::Display for PortFec { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::latest::early_networking::InvalidIpAddrError; + use oxnet::Ipv4Net; + use proptest::prelude::*; + use serde::{Deserialize, Serialize}; + use test_strategy::proptest; + + #[test] + fn test_uplink_smf_property_formatting() { + for (address, expected_addr) in [ + ( + UplinkAddress::Static { + ip_net: UplinkIpNet::try_from(IpNet::V6( + Ipv6Net::new("fd80::123".parse().unwrap(), 16).unwrap(), + )) + .unwrap(), + }, + "fd80::123/16", + ), + ( + UplinkAddress::Static { + ip_net: UplinkIpNet::try_from(IpNet::V4( + Ipv4Net::new("10.0.0.1".parse().unwrap(), 8).unwrap(), + )) + .unwrap(), + }, + "10.0.0.1/8", + ), + (UplinkAddress::AddrConf, "link-local"), + ] { + for (vlan_id, expected_vlan) in + [(Some(1), ";1"), (Some(1234), ";1234"), (None, "")] + { + let config = UplinkAddressConfig { address, vlan_id }; + let expected = format!("{expected_addr}{expected_vlan}"); + assert_eq!( + config.to_uplinkd_smf_property(), + expected, + "unexpected SMF property for {config:?}" + ); + } + } + } + + // We want our proptests below to hit all the invalid categories of IPs, so + // define our own input mapping that chooses from all the particular + // categories we want to reject. + // + // Returns an IP and the expected kind of error we should get if we try to + // parse it, if any. + fn arb_ip_addr() + -> impl Strategy)> { + prop_oneof![ + // ipv4 unspecified + Just(( + IpAddr::V4(Ipv4Addr::UNSPECIFIED), + Some(InvalidIpAddrError::UnspecifiedAddress) + )), + // ipv6 unspecified + Just(( + IpAddr::V6(Ipv6Addr::UNSPECIFIED), + Some(InvalidIpAddrError::UnspecifiedAddress) + )), + // ipv4 loopback + Just(( + IpAddr::V4(Ipv4Addr::LOCALHOST), + Some(InvalidIpAddrError::LoopbackAddress) + )), + // ipv6 loopback + Just(( + IpAddr::V6(Ipv6Addr::LOCALHOST), + Some(InvalidIpAddrError::LoopbackAddress) + )), + // ipv4 multicast: 224.0.0.0 – 239.255.255.255 + (224u8..=239u8, any::<[u8; 3]>()).prop_map(|(hi, rest)| { + ( + IpAddr::V4(Ipv4Addr::new(hi, rest[0], rest[1], rest[2])), + Some(InvalidIpAddrError::MulticastAddress), + ) + }), + // ipv6 multicast: ff00::/8 + any::<[u8; 15]>().prop_map(|rest| { + let mut octets = [0u8; 16]; + octets[0] = 0xff; + octets[1..].copy_from_slice(&rest); + ( + IpAddr::V6(Ipv6Addr::from(octets)), + Some(InvalidIpAddrError::MulticastAddress), + ) + }), + // ipv4 broadcast + Just(( + IpAddr::V4(Ipv4Addr::BROADCAST), + Some(InvalidIpAddrError::Ipv4Broadcast) + )), + // ipv6 unicast link-local: fe80::/10 + any::<[u8; 15]>().prop_map(|rest| { + let mut octets = [0u8; 16]; + octets[0] = 0xfe; + octets[1] = 0x80 | (rest[0] & 0x3f); + octets[2..].copy_from_slice(&rest[1..]); + ( + IpAddr::V6(Ipv6Addr::from(octets)), + Some(InvalidIpAddrError::Ipv6UnicastLinkLocal), + ) + }), + // ipv4-mapped ipv6 + any::() + .prop_map(|ip| ip.to_ipv6_mapped()) + .prop_map(IpAddr::V6) + .prop_map(|ip| (ip, Some(InvalidIpAddrError::Ipv4MappedIpv6))), + // any other ipv4 (filtered) + any::() + .prop_filter( + "not unspecified, loopback, multicast, or broadcast", + |ip| { + !ip.is_unspecified() + && !ip.is_loopback() + && !ip.is_multicast() + && !ip.is_broadcast() + } + ) + .prop_map(IpAddr::V4) + .prop_map(|ip| (ip, None)), + // any other ipv6 (filtered) + any::<[u8; 16]>() + .prop_map(|b| Ipv6Addr::from(b)) + .prop_filter( + "not unspecified, loopback, multicast, or link-local", + |ip| { + !ip.is_unspecified() + && !ip.is_loopback() + && !ip.is_multicast() + && !ip.is_unicast_link_local() + && ip.to_ipv4_mapped().is_none() + } + ) + .prop_map(IpAddr::V6) + .prop_map(|ip| (ip, None)), + ] + } + + #[proptest] + fn test_ip_parsing( + #[strategy(arb_ip_addr())] input: (IpAddr, Option), + ) { + let (ip, expected_err) = input; + // Test both RouterPeerIpAddr and UplinkIpNet; we don't bother + // proptesting the network side of `IpNet` because that's not relevant + // to any of our specific parsing. + let ip_net = IpNet::new(ip, 24).unwrap(); + let ip_string = ip.to_string(); + let ip_net_string = ip_net.to_string(); + let ip_result = ip_string.parse::(); + let ip_net_result = ip_net_string.parse::(); + + if let Some(expected_err) = expected_err { + let ip_err = ip_result.expect_err("parsing failed"); + match ip_err { + RouterPeerIpAddrParseError::AddrParseError(_) => { + panic!("unexpected error {ip_err:?}") + } + RouterPeerIpAddrParseError::InvalidIpAddr(ip_err) => { + assert_eq!(ip_err.ip, ip); + assert_eq!(ip_err.err, expected_err); + } + } + let ip_net_err = ip_net_result.expect_err("parsing failed"); + match ip_net_err { + UplinkIpNetParseError::IpNetParseError(_) => { + panic!("unexpected error {ip_net_err:?}") + } + UplinkIpNetParseError::InvalidIpError(ip_net_err) => { + assert_eq!(ip_net_err.ip_net, ip_net); + assert_eq!(ip_net_err.err, expected_err); + } + } + } else { + let parsed_ip = ip_result.expect("parsing succeeded"); + assert_eq!(parsed_ip.0, ip); + let parsed_ip_net = ip_net_result.expect("parsing succeeded"); + assert_eq!(IpNet::from(parsed_ip_net), ip_net); + } + } + + #[proptest] + fn test_router_peer_ip_addr_serialization( + #[strategy(arb_ip_addr())] input: (IpAddr, Option), + ) { + let (ip, expected_err) = input; + + #[derive(Debug, Serialize, Deserialize)] + struct WrapIp { + ip: IpAddr, + } + #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] + struct WrapRouterIp { + ip: RouterPeerIpAddr, + } + + let wrapped = WrapIp { ip }; + + let jsonified = serde_json::to_string(&wrapped).unwrap(); + let tomlified = toml::to_string(&wrapped).unwrap(); + + let json_result = serde_json::from_str::(&jsonified); + let toml_result = toml::from_str::(&tomlified); + + if let Some(expected_err) = expected_err { + let expected_err = expected_err.to_string(); + let err = + json_result.expect_err("deserialization failed").to_string(); + assert!( + err.contains(&expected_err), + "got error {err:?}, but expected it to contain {expected_err:?}" + ); + let err = + toml_result.expect_err("deserialization failed").to_string(); + assert!( + err.contains(&expected_err), + "got error {err:?}, but expected it to contain {expected_err:?}" + ); + } else { + let json_result = json_result.expect("deserialization succeeded"); + assert_eq!(json_result.ip.0, ip); + let toml_result = toml_result.expect("deserialization succeeded"); + assert_eq!(toml_result.ip.0, ip); + } + } + + #[proptest] + fn test_uplink_ip_net_serialization( + #[strategy(arb_ip_addr())] input: (IpAddr, Option), + ) { + let (ip, expected_err) = input; + + #[derive(Debug, Serialize, Deserialize)] + struct WrapIpNet { + ip_net: IpNet, + } + #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] + struct WrapUplinkIpNet { + ip_net: UplinkIpNet, + } + + let ip_net = IpNet::new(ip, 24).unwrap(); + let wrapped = WrapIpNet { ip_net }; + + let jsonified = serde_json::to_string(&wrapped).unwrap(); + let tomlified = toml::to_string(&wrapped).unwrap(); + + let json_result = serde_json::from_str::(&jsonified); + let toml_result = toml::from_str::(&tomlified); + + if let Some(expected_err) = expected_err { + let expected_err = expected_err.to_string(); + let err = + json_result.expect_err("deserialization failed").to_string(); + assert!( + err.contains(&expected_err), + "got error {err:?}, but expected it to contain {expected_err:?}" + ); + let err = + toml_result.expect_err("deserialization failed").to_string(); + assert!( + err.contains(&expected_err), + "got error {err:?}, but expected it to contain {expected_err:?}" + ); + } else { + let json_result = json_result.expect("deserialization succeeded"); + assert_eq!(IpNet::from(json_result.ip_net), ip_net); + let toml_result = toml_result.expect("deserialization succeeded"); + assert_eq!(IpNet::from(toml_result.ip_net), ip_net); + } + } +} diff --git a/sled-agent/types/versions/src/latest.rs b/sled-agent/types/versions/src/latest.rs index 29777b10793..29fd25261b6 100644 --- a/sled-agent/types/versions/src/latest.rs +++ b/sled-agent/types/versions/src/latest.rs @@ -65,17 +65,24 @@ pub mod early_networking { pub use crate::v1::early_networking::TxEqConfig; pub use crate::v20::early_networking::BgpConfig; - pub use crate::v20::early_networking::BgpPeerConfig; pub use crate::v20::early_networking::MaxPathConfig; pub use crate::v20::early_networking::MaxPathConfigError; - pub use crate::v20::early_networking::PortConfig; - pub use crate::v20::early_networking::RackNetworkConfig; pub use crate::v20::early_networking::RouterLifetimeConfig; pub use crate::v20::early_networking::RouterLifetimeConfigError; - pub use crate::v20::early_networking::UplinkAddressConfig; - pub use crate::v26::early_networking::EarlyNetworkConfigBody; - pub use crate::v26::early_networking::WriteNetworkConfigRequest; + pub use crate::v30::early_networking::BgpPeerConfig; + pub use crate::v30::early_networking::EarlyNetworkConfigBody; + pub use crate::v30::early_networking::InvalidIpAddrError; + pub use crate::v30::early_networking::PortConfig; + pub use crate::v30::early_networking::RackNetworkConfig; + pub use crate::v30::early_networking::RouterPeerIpAddr; + pub use crate::v30::early_networking::RouterPeerIpAddrError; + pub use crate::v30::early_networking::RouterPeerType; + pub use crate::v30::early_networking::UplinkAddress; + pub use crate::v30::early_networking::UplinkAddressConfig; + pub use crate::v30::early_networking::UplinkIpNet; + pub use crate::v30::early_networking::UplinkIpNetError; + pub use crate::v30::early_networking::WriteNetworkConfigRequest; } pub mod firewall_rules { @@ -176,9 +183,10 @@ pub mod rack_init { pub use crate::bootstrap_v1::rack_init::RecoverySiloConfig; pub use crate::v20::rack_init::BootstrapAddressDiscovery; - pub use crate::v20::rack_init::RackInitializeRequest; - pub use crate::v20::rack_init::RackInitializeRequestParams; pub use crate::v20::rack_init::RackInitializeRequestParseError; + + pub use crate::v30::rack_init::RackInitializeRequest; + pub use crate::v30::rack_init::RackInitializeRequestParams; } pub mod rot { @@ -221,8 +229,8 @@ pub mod trust_quorum { } pub mod uplink { - pub use crate::v20::uplink::HostPortConfig; - pub use crate::v20::uplink::SwitchPorts; + pub use crate::v30::uplink::HostPortConfig; + pub use crate::v30::uplink::SwitchPorts; } pub mod zone_bundle { diff --git a/sled-agent/types/versions/src/lib.rs b/sled-agent/types/versions/src/lib.rs index 279a23ef181..ce9235347ec 100644 --- a/sled-agent/types/versions/src/lib.rs +++ b/sled-agent/types/versions/src/lib.rs @@ -71,6 +71,8 @@ pub mod v28; pub mod v29; #[path = "add_switch_zone_operator_policy/mod.rs"] pub mod v3; +#[path = "stronger_bgp_unnumbered_types/mod.rs"] +pub mod v30; #[path = "add_nexus_lockstep_port_to_inventory/mod.rs"] pub mod v4; #[path = "add_probe_put_endpoint/mod.rs"] diff --git a/sled-agent/types/versions/src/stronger_bgp_unnumbered_types/early_networking.rs b/sled-agent/types/versions/src/stronger_bgp_unnumbered_types/early_networking.rs new file mode 100644 index 00000000000..1c7c2bb347e --- /dev/null +++ b/sled-agent/types/versions/src/stronger_bgp_unnumbered_types/early_networking.rs @@ -0,0 +1,773 @@ +// 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/. + +//! Types for network setup required to bring up the control plane. +//! +//! Changes in this version: +//! +//! * Introduce [`UplinkIpNet`], a newtype wrapper around [`IpNet`] that does +//! not allow unspecified IP addresses. +//! * Introduce [`RouterPeerIpAddr`], a newtype wrapper around [`IpAddr`] that +//! enforces several requirements for valid peer addresses. +//! * Introduce [`UplinkAddress`], a stronger type for specifying +//! possibly-link-local IP nets. This is the new type of +//! [`UplinkAddressConfig::address`], which was previously an +//! [`Option`] where both `None` and `Some(UNSPECIFIED)` were treated +//! as link-local. +//! * Introduce [`RouterPeerType`], a stronger type for specifying +//! possibly-unnumbered BGP peers. This is the new type of +//! [`BgpPeerConfig::addr`], which was previously an [`IpAddr`] where an +//! unspecified address was treated as unnumbered. +//! * Move `router_lifetime` from the top-level of `BgpPeerConfig` to inside the +//! [`RouterPeerType::Unnumbered`] variant; it only applies to unnumbered +//! peers. +//! * Update types that transitively contain the newly-updated +//! [`UplinkAddressConfig`] or [`BgpPeerConfig`]: +//! * [`EarlyNetworkConfigBody`] +//! * [`PortConfig`] +//! * [`RackNetworkConfig`] +//! * [`WriteNetworkConfigRequest`] + +use crate::v1::early_networking as v1; +use crate::v20::early_networking as v20; +use crate::v26::early_networking as v26; +use oxnet::{IpNet, Ipv6Net}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use slog_error_chain::InlineErrorChain; +use std::net::IpAddr; + +#[derive(Clone, Copy, Debug, thiserror::Error, PartialEq, Eq)] +pub enum InvalidIpAddrError { + #[error("unspecified address is not allowed")] + UnspecifiedAddress, + #[error("loopback address is not allowed")] + LoopbackAddress, + #[error("multicast addresses are not allowed")] + MulticastAddress, + #[error("IPv4 broadcast address is not allowed")] + Ipv4Broadcast, + #[error("IPv6 unicast link-local addresses are not allowed")] + Ipv6UnicastLinkLocal, + #[error("IPv4-mapped IPv6 addresses are not allowed")] + Ipv4MappedIpv6, +} + +#[derive( + Clone, + Copy, + Debug, + Deserialize, + Serialize, + PartialEq, + Eq, + JsonSchema, + Hash, + PartialOrd, + Ord, +)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum UplinkAddress { + // `#[serde(rename)]` this to `addrconf` so when this shows up in + // config-rss.toml development files, it's not phrased as `addr_conf`. This + // also makes it consistent with our custom TOML parsing in customer-facing + // TOML via wicket. + #[serde(rename = "addrconf")] + AddrConf, + Static { + ip_net: UplinkIpNet, + }, +} + +#[derive( + Clone, + Copy, + Debug, + Deserialize, + Serialize, + PartialEq, + Eq, + JsonSchema, + Hash, + PartialOrd, + Ord, +)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum RouterPeerType { + Unnumbered { + /// Router lifetime in seconds for unnumbered BGP peers. + router_lifetime: v20::RouterLifetimeConfig, + }, + Numbered { + /// IP address for numbered BGP peers. + ip: RouterPeerIpAddr, + }, +} + +#[derive( + Clone, + Copy, + Debug, + Serialize, + PartialEq, + Eq, + JsonSchema, + Hash, + PartialOrd, + Ord, +)] +// We'd also like to have `#[serde(try_from = "IpNet")]`, but that loses the +// detailed error messages we produce. We manually implement Deserialize per +// https://github.com/serde-rs/serde/issues/2211#issuecomment-1627628399. +#[serde(into = "IpNet")] +#[schemars(with = "IpNet")] +pub struct UplinkIpNet(pub(crate) IpNet); + +impl<'de> Deserialize<'de> for UplinkIpNet { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + IpNet::deserialize(deserializer).and_then(|ip_net| { + Self::try_from(ip_net).map_err(|err| { + serde::de::Error::custom(InlineErrorChain::new(&err)) + }) + }) + } +} + +// These conversion implementations are defined here instead of in +// `crate::impls::*` because they're tied to how we derive `Deserialize` and +// `Serialize`. +impl From for IpNet { + fn from(value: UplinkIpNet) -> Self { + value.0 + } +} + +#[derive(Debug, thiserror::Error)] +#[error("invalid uplink ipnet `{ip_net}`")] +pub struct UplinkIpNetError { + pub ip_net: IpNet, + #[source] + pub err: InvalidIpAddrError, +} + +impl TryFrom for UplinkIpNet { + type Error = UplinkIpNetError; + + fn try_from(value: IpNet) -> Result { + // Apply the same validation rules we use for `RouterPeerIpAddr`. If the + // IP fails, steal the specific error out and wrap it in our error type + // instead. + match RouterPeerIpAddr::try_from(value.addr()) { + Ok(_) => Ok(Self(value)), + Err(RouterPeerIpAddrError { err, .. }) => { + Err(UplinkIpNetError { ip_net: value, err }) + } + } + } +} + +#[derive( + Clone, + Copy, + Debug, + Serialize, + PartialEq, + Eq, + JsonSchema, + Hash, + PartialOrd, + Ord, +)] +// We'd also like to have `#[serde(try_from = "IpAddr")]`, but that loses the +// detailed error messages we produce. We manually implement Deserialize per +// https://github.com/serde-rs/serde/issues/2211#issuecomment-1627628399. +#[serde(into = "IpAddr")] +#[schemars(with = "IpAddr")] +pub struct RouterPeerIpAddr(pub(crate) IpAddr); + +impl<'de> Deserialize<'de> for RouterPeerIpAddr { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + IpAddr::deserialize(deserializer).and_then(|ip| { + Self::try_from(ip).map_err(|err| { + serde::de::Error::custom(InlineErrorChain::new(&err)) + }) + }) + } +} + +// As with `UplinkIpNet`, these conversion implementations are defined here +// instead of in `crate::impls::*` because they're tied to how we derive +// `Deserialize` and `Serialize`. +impl From for IpAddr { + fn from(value: RouterPeerIpAddr) -> Self { + value.0 + } +} + +#[derive(Debug, thiserror::Error)] +#[error("invalid router peer address `{ip}`")] +pub struct RouterPeerIpAddrError { + pub ip: IpAddr, + #[source] + pub err: InvalidIpAddrError, +} + +impl TryFrom for RouterPeerIpAddr { + type Error = RouterPeerIpAddrError; + + fn try_from(ip: IpAddr) -> Result { + let err = match ip { + IpAddr::V4(ipv4) => { + // Perform the same validity checks we require in maghemite. + // We deliberately do not flag Class E (240.0.0.0/4) or + // Link-Local (169.254.0.0/16) ranges as invalid, as some + // networks have deployed these as if they were standard + // routable unicast addresses, which we need to handle. + if ipv4.is_loopback() { + InvalidIpAddrError::LoopbackAddress + } else if ipv4.is_multicast() { + InvalidIpAddrError::MulticastAddress + } else if ipv4.is_broadcast() { + InvalidIpAddrError::Ipv4Broadcast + } else if ipv4.is_unspecified() { + InvalidIpAddrError::UnspecifiedAddress + } else { + return Ok(Self(ip)); + } + } + IpAddr::V6(ipv6) => { + // As above, perform validity checks we require in maghemite. + if ipv6.is_loopback() { + InvalidIpAddrError::LoopbackAddress + } else if ipv6.is_multicast() { + InvalidIpAddrError::MulticastAddress + } else if ipv6.is_unspecified() { + InvalidIpAddrError::UnspecifiedAddress + } else if ipv6.is_unicast_link_local() { + InvalidIpAddrError::Ipv6UnicastLinkLocal + } else if ipv6.to_ipv4_mapped().is_some() { + // switch to ipv6.is_ipv4_mapped() once it's stabilized + InvalidIpAddrError::Ipv4MappedIpv6 + } else { + return Ok(Self(ip)); + } + } + }; + + Err(RouterPeerIpAddrError { ip, err }) + } +} + +#[derive( + Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq, JsonSchema, Hash, +)] +pub struct UplinkAddressConfig { + /// The address to be used on the uplink. + pub address: UplinkAddress, + + /// The VLAN id (if any) associated with this address. + #[serde(default)] + pub vlan_id: Option, +} + +impl TryFrom for UplinkAddressConfig { + type Error = UplinkIpNetError; + + fn try_from(value: v20::UplinkAddressConfig) -> Result { + let address = match value.address.map(UplinkIpNet::try_from) { + Some(Ok(ip_net)) => UplinkAddress::Static { ip_net }, + None => UplinkAddress::AddrConf, + Some(Err(err)) => match err.err { + // v20::UplinkAddressConfig should have represented addrconf IPs + // as `None` (handled above), but it's also possible we could + // have an unspecified IP as a sentinel value; peel that error + // out and convert to addrconf. Forward any other kind of + // invalid address out as a failure - we should not have any of + // these, and if we do, we're going to reject them somewhere + // down the line at runtime anyway. + InvalidIpAddrError::UnspecifiedAddress => { + UplinkAddress::AddrConf + } + InvalidIpAddrError::LoopbackAddress + | InvalidIpAddrError::MulticastAddress + | InvalidIpAddrError::Ipv4Broadcast + | InvalidIpAddrError::Ipv6UnicastLinkLocal + | InvalidIpAddrError::Ipv4MappedIpv6 => return Err(err), + }, + }; + Ok(Self { address, vlan_id: value.vlan_id }) + } +} + +impl From for v20::UplinkAddressConfig { + fn from(value: UplinkAddressConfig) -> Self { + let address = match value.address { + UplinkAddress::AddrConf => None, + UplinkAddress::Static { ip_net } => Some(ip_net.into()), + }; + Self { address, vlan_id: value.vlan_id } + } +} + +#[derive( + Clone, Debug, Deserialize, Serialize, PartialEq, Eq, Hash, JsonSchema, +)] +pub struct PortConfig { + /// The set of routes associated with this port. + pub routes: Vec, + /// This port's addresses and optional vlan IDs + pub addresses: Vec, + /// Switch the port belongs to. + pub switch: v1::SwitchSlot, + /// Name of the port this config applies to. + pub port: String, + /// Port speed. + pub uplink_port_speed: v1::PortSpeed, + /// Port forward error correction type. + pub uplink_port_fec: Option, + /// BGP peers on this port + pub bgp_peers: Vec, + /// Whether or not to set autonegotiation + #[serde(default)] + pub autoneg: bool, + /// LLDP configuration for this port + pub lldp: Option, + /// TX-EQ configuration for this port + pub tx_eq: Option, +} + +#[derive(Debug, thiserror::Error)] +pub enum PortConfigConversionError { + #[error(transparent)] + UplinkIpNet(#[from] UplinkIpNetError), + #[error(transparent)] + RouterPeerIpAddr(#[from] RouterPeerIpAddrError), +} + +impl TryFrom for PortConfig { + type Error = PortConfigConversionError; + + fn try_from(value: v20::PortConfig) -> Result { + let addresses = value + .addresses + .into_iter() + .map(TryFrom::try_from) + .collect::>()?; + let bgp_peers = value + .bgp_peers + .into_iter() + .map(TryFrom::try_from) + .collect::>()?; + Ok(Self { + routes: value.routes, + addresses, + switch: value.switch, + port: value.port, + uplink_port_speed: value.uplink_port_speed, + uplink_port_fec: value.uplink_port_fec, + bgp_peers, + autoneg: value.autoneg, + lldp: value.lldp, + tx_eq: value.tx_eq, + }) + } +} + +impl From for v20::PortConfig { + fn from(value: PortConfig) -> Self { + Self { + routes: value.routes, + addresses: value.addresses.into_iter().map(From::from).collect(), + switch: value.switch, + port: value.port, + uplink_port_speed: value.uplink_port_speed, + uplink_port_fec: value.uplink_port_fec, + bgp_peers: value.bgp_peers.into_iter().map(From::from).collect(), + autoneg: value.autoneg, + lldp: value.lldp, + tx_eq: value.tx_eq, + } + } +} + +#[derive( + Clone, Debug, Deserialize, Serialize, PartialEq, Eq, Hash, JsonSchema, +)] +pub struct BgpPeerConfig { + /// The autonomous system number of the router the peer belongs to. + pub asn: u32, + /// Switch port the peer is reachable on. + pub port: String, + /// Address of the peer. + pub addr: RouterPeerType, + /// How long to keep a session alive without a keepalive in seconds. + /// Defaults to 6. + pub hold_time: Option, + /// How long to keep a peer in idle after a state machine reset in seconds. + pub idle_hold_time: Option, + /// How long to delay sending open messages to a peer. In seconds. + pub delay_open: Option, + /// The interval in seconds between peer connection retry attempts. + pub connect_retry: Option, + /// The interval to send keepalive messages at. + pub keepalive: Option, + /// Require that a peer has a specified ASN. + #[serde(default)] + pub remote_asn: Option, + /// Require messages from a peer have a minimum IP time to live field. + #[serde(default)] + pub min_ttl: Option, + /// Use the given key for TCP-MD5 authentication with the peer. + #[serde(default)] + pub md5_auth_key: Option, + /// Apply the provided multi-exit discriminator (MED) updates sent to the peer. + #[serde(default)] + pub multi_exit_discriminator: Option, + /// Include the provided communities in updates sent to the peer. + #[serde(default)] + pub communities: Vec, + /// Apply a local preference to routes received from this peer. + #[serde(default)] + pub local_pref: Option, + /// Enforce that the first AS in paths received from this peer is the peer's AS. + #[serde(default)] + pub enforce_first_as: bool, + /// Define import policy for a peer. + #[serde(default)] + pub allowed_import: v1::ImportExportPolicy, + /// Define export policy for a peer. + #[serde(default)] + pub allowed_export: v1::ImportExportPolicy, + /// Associate a VLAN ID with a BGP peer session. + #[serde(default)] + pub vlan_id: Option, +} + +impl TryFrom for BgpPeerConfig { + type Error = RouterPeerIpAddrError; + + fn try_from(value: v20::BgpPeerConfig) -> Result { + let addr = match RouterPeerIpAddr::try_from(value.addr) { + Ok(ip) => RouterPeerType::Numbered { ip }, + Err(err) => match err.err { + // v20::BgpPeerConfig represented unnumbered peers as + // unspecified addresses; peel that error out and convert to an + // unnumbered address. Forward any other kind of invalid address + // out as a failure - we should not have any of these, and if we + // do, we're going to reject them somewhere down the line at + // runtime anyway. + InvalidIpAddrError::UnspecifiedAddress => { + RouterPeerType::Unnumbered { + router_lifetime: value.router_lifetime, + } + } + InvalidIpAddrError::LoopbackAddress + | InvalidIpAddrError::MulticastAddress + | InvalidIpAddrError::Ipv4Broadcast + | InvalidIpAddrError::Ipv6UnicastLinkLocal + | InvalidIpAddrError::Ipv4MappedIpv6 => return Err(err), + }, + }; + Ok(Self { + asn: value.asn, + port: value.port, + addr, + hold_time: value.hold_time, + idle_hold_time: value.idle_hold_time, + delay_open: value.delay_open, + connect_retry: value.connect_retry, + keepalive: value.keepalive, + remote_asn: value.remote_asn, + min_ttl: value.min_ttl, + md5_auth_key: value.md5_auth_key, + multi_exit_discriminator: value.multi_exit_discriminator, + communities: value.communities, + local_pref: value.local_pref, + enforce_first_as: value.enforce_first_as, + allowed_import: value.allowed_import, + allowed_export: value.allowed_export, + vlan_id: value.vlan_id, + }) + } +} + +impl From for v20::BgpPeerConfig { + fn from(value: BgpPeerConfig) -> Self { + let router_lifetime = match value.addr { + RouterPeerType::Unnumbered { router_lifetime } => router_lifetime, + // v20::BgpPeerConfig always has a `router_lifetime` field, but its + // value is only used with unnumbered peers. For numbered peers, + // just fill in a default value. + RouterPeerType::Numbered { .. } => { + v20::RouterLifetimeConfig::default() + } + }; + + Self { + asn: value.asn, + port: value.port, + addr: value.addr.ip_squashing_unnumbered_to_sentinel(), + hold_time: value.hold_time, + idle_hold_time: value.idle_hold_time, + delay_open: value.delay_open, + connect_retry: value.connect_retry, + keepalive: value.keepalive, + remote_asn: value.remote_asn, + min_ttl: value.min_ttl, + md5_auth_key: value.md5_auth_key, + multi_exit_discriminator: value.multi_exit_discriminator, + communities: value.communities, + local_pref: value.local_pref, + enforce_first_as: value.enforce_first_as, + allowed_import: value.allowed_import, + allowed_export: value.allowed_export, + vlan_id: value.vlan_id, + router_lifetime, + } + } +} + +/// Initial network configuration +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, JsonSchema)] +pub struct RackNetworkConfig { + pub rack_subnet: Ipv6Net, + // TODO: #3591 Consider making infra-ip ranges implicit for uplinks + /// First ip address to be used for configuring network infrastructure + pub infra_ip_first: IpAddr, + /// Last ip address to be used for configuring network infrastructure + pub infra_ip_last: IpAddr, + /// Uplinks for connecting the rack to external networks + pub ports: Vec, + /// BGP configurations for connecting the rack to external networks + pub bgp: Vec, + /// BFD configuration for connecting the rack to external networks + #[serde(default)] + pub bfd: Vec, +} + +/// This is the actual configuration of EarlyNetworking. +#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema, PartialEq)] +pub struct EarlyNetworkConfigBody { + // Rack network configuration as delivered from RSS or Nexus + pub rack_network_config: RackNetworkConfig, +} + +impl EarlyNetworkConfigBody { + pub const SCHEMA_VERSION: u32 = 4; +} + +impl TryFrom for EarlyNetworkConfigBody { + type Error = anyhow::Error; + + fn try_from(old: v26::EarlyNetworkConfigBody) -> Result { + let old = old.rack_network_config; + + let ports = old + .ports + .into_iter() + .map(TryFrom::try_from) + .collect::>()?; + + Ok(Self { + rack_network_config: RackNetworkConfig { + rack_subnet: old.rack_subnet, + infra_ip_first: old.infra_ip_first, + infra_ip_last: old.infra_ip_last, + ports, + bgp: old.bgp, + bfd: old.bfd, + }, + }) + } +} + +impl From for v26::EarlyNetworkConfigBody { + fn from(new: EarlyNetworkConfigBody) -> Self { + let new = new.rack_network_config; + + Self { + rack_network_config: v20::RackNetworkConfig { + rack_subnet: new.rack_subnet, + infra_ip_first: new.infra_ip_first, + infra_ip_last: new.infra_ip_last, + ports: new.ports.into_iter().map(From::from).collect(), + bgp: new.bgp, + bfd: new.bfd, + }, + } + } +} + +/// Structure for requests from Nexus to sled-agent to write a new +/// `EarlyNetworkConfigBody` into the replicated bootstore. +/// +/// [`WriteNetworkConfigRequest`] INTENTIONALLY does not have a `From` +/// implementation from prior API versions. It is critically important that +/// sled-agent not attempt to rewrite old `EarlyNetworkConfigBody` types to the +/// latest version. For more about this, see the comments on the relevant +/// endpoint in `sled-agent-api`. +#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema, PartialEq)] +pub struct WriteNetworkConfigRequest { + pub generation: u64, + pub body: EarlyNetworkConfigBody, +} + +#[cfg(test)] +mod tests { + use super::*; + use std::net::Ipv4Addr; + use std::net::Ipv6Addr; + + #[test] + fn test_uplink_address_conversions() { + // Confirm we can convert old -> new -> old. In some cases the `new -> + // old` produces an `old` that isn't quite the same; we fill in + // `expected_old` in those cases. Specifically: `old` could contain an + // address of `None`, `Some("0.0.0.0/x")`, or `Some("::/x")`, which will + // always come back as `None` after the round trip. + for (old, new, expected_old) in [ + ( + Some("10.0.0.0/8".parse::().unwrap()), + UplinkAddress::Static { ip_net: "10.0.0.0/8".parse().unwrap() }, + None, + ), + ( + Some("fd00:1234::/64".parse::().unwrap()), + UplinkAddress::Static { + ip_net: "fd00:1234::/64".parse().unwrap(), + }, + None, + ), + (None, UplinkAddress::AddrConf, None), + ( + Some("0.0.0.0/8".parse::().unwrap()), + UplinkAddress::AddrConf, + Some(None), + ), + ( + Some("::/128".parse::().unwrap()), + UplinkAddress::AddrConf, + Some(None), + ), + ] { + for vlan_id in [Some(1234), None] { + let expected_old = expected_old.unwrap_or(old); + let old = v20::UplinkAddressConfig { address: old, vlan_id }; + let new = UplinkAddressConfig { address: new, vlan_id }; + let expected_old = + v20::UplinkAddressConfig { address: expected_old, vlan_id }; + + assert_eq!(UplinkAddressConfig::try_from(old).unwrap(), new); + assert_eq!(v20::UplinkAddressConfig::from(new), expected_old); + } + } + } + + #[test] + fn test_router_peer_address_conversions() { + fn make_new_bgp_peer_config(addr: RouterPeerType) -> BgpPeerConfig { + BgpPeerConfig { + asn: 1, + port: "port".to_owned(), + addr, + hold_time: None, + idle_hold_time: None, + delay_open: None, + connect_retry: None, + keepalive: None, + remote_asn: None, + min_ttl: None, + md5_auth_key: None, + multi_exit_discriminator: None, + communities: Vec::new(), + local_pref: None, + enforce_first_as: false, + allowed_import: v1::ImportExportPolicy::NoFiltering, + allowed_export: v1::ImportExportPolicy::NoFiltering, + vlan_id: None, + } + } + fn make_old_bgp_peer_config( + addr: IpAddr, + router_lifetime: v20::RouterLifetimeConfig, + ) -> v20::BgpPeerConfig { + v20::BgpPeerConfig { + asn: 1, + port: "port".to_owned(), + addr, + hold_time: None, + idle_hold_time: None, + delay_open: None, + connect_retry: None, + keepalive: None, + remote_asn: None, + min_ttl: None, + md5_auth_key: None, + multi_exit_discriminator: None, + communities: Vec::new(), + local_pref: None, + enforce_first_as: false, + allowed_import: v1::ImportExportPolicy::NoFiltering, + allowed_export: v1::ImportExportPolicy::NoFiltering, + vlan_id: None, + router_lifetime, + } + } + + // Confirm we can convert old -> new -> old. In some cases the `new -> + // old` produces an `old` that isn't quite the same; we fill in + // `expected_old` in those cases. Specifically: `old` could contain an + // address of `0.0.0.0` or `::`; either will come back as + // `UNNUMBERED_SENTINEL` after the round trip. + for (old_ip, old_router_lifetime, new, expected_old_ip) in [ + ( + "10.0.0.1".parse::().unwrap(), + v20::RouterLifetimeConfig::default(), + RouterPeerType::Numbered { ip: "10.0.0.1".parse().unwrap() }, + None, + ), + ( + "fd00:1234::3".parse().unwrap(), + v20::RouterLifetimeConfig::default(), + RouterPeerType::Numbered { + ip: "fd00:1234::3".parse().unwrap(), + }, + None, + ), + ( + IpAddr::V4(Ipv4Addr::UNSPECIFIED), + v20::RouterLifetimeConfig::default(), + RouterPeerType::Unnumbered { + router_lifetime: v20::RouterLifetimeConfig::default(), + }, + Some(RouterPeerType::UNNUMBERED_SENTINEL), + ), + ( + IpAddr::V6(Ipv6Addr::UNSPECIFIED), + v20::RouterLifetimeConfig::new(1234).unwrap(), + RouterPeerType::Unnumbered { + router_lifetime: v20::RouterLifetimeConfig::new(1234) + .unwrap(), + }, + Some(RouterPeerType::UNNUMBERED_SENTINEL), + ), + ] { + let expected_old_ip = expected_old_ip.unwrap_or(old_ip); + + let old = make_old_bgp_peer_config(old_ip, old_router_lifetime); + let new = make_new_bgp_peer_config(new); + let expected_old = + make_old_bgp_peer_config(expected_old_ip, old_router_lifetime); + + assert_eq!(BgpPeerConfig::try_from(old).unwrap(), new); + assert_eq!(v20::BgpPeerConfig::from(new), expected_old); + } + } +} diff --git a/sled-agent/types/versions/src/stronger_bgp_unnumbered_types/mod.rs b/sled-agent/types/versions/src/stronger_bgp_unnumbered_types/mod.rs new file mode 100644 index 00000000000..f979e11f7bc --- /dev/null +++ b/sled-agent/types/versions/src/stronger_bgp_unnumbered_types/mod.rs @@ -0,0 +1,9 @@ +// 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/. + +//! Version `STRONGER_BGP_UNNUMBERED_TYPES` of the Sled Agent API. + +pub mod early_networking; +pub mod rack_init; +pub mod uplink; diff --git a/sled-agent/types/versions/src/stronger_bgp_unnumbered_types/rack_init.rs b/sled-agent/types/versions/src/stronger_bgp_unnumbered_types/rack_init.rs new file mode 100644 index 00000000000..bd188b3c925 --- /dev/null +++ b/sled-agent/types/versions/src/stronger_bgp_unnumbered_types/rack_init.rs @@ -0,0 +1,124 @@ +// 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/. + +//! Rack initialization types +//! +//! Changes in this version: +//! * New [`RackInitializeRequest`] to pick up new [`RackNetworkConfig`]. +//! * New [`RackInitializeRequestParams`] to pick up new +//! [`RackInitializeRequest`]. + +use super::early_networking::RackNetworkConfig; +use crate::bootstrap_v1::rack_init::RecoverySiloConfig; +use crate::impls::rack_init::default_allowed_source_ips; +use crate::impls::rack_init::validate_external_dns; +use crate::v20::rack_init::BootstrapAddressDiscovery; +use anyhow::Result; +use omicron_common::{ + address::IpRange, + api::{external::AllowedSourceIps, internal::nexus::Certificate}, +}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use sled_hardware_types::Baseboard; +use std::net::IpAddr; + +/// Configuration for the "rack setup service". +/// +/// The Rack Setup Service should be responsible for one-time setup actions, +/// such as CockroachDB placement and initialization. Without operator +/// intervention, however, these actions need a way to be automated in our +/// deployment. +#[derive(Clone, Deserialize, Serialize, PartialEq, JsonSchema)] +#[serde(try_from = "UnvalidatedRackInitializeRequest")] +pub struct RackInitializeRequest { + /// The set of peer_ids required to initialize trust quorum + /// + /// The value is `None` if we are not using trust quorum + pub trust_quorum_peers: Option>, + + /// Describes how bootstrap addresses should be collected during RSS. + pub bootstrap_discovery: BootstrapAddressDiscovery, + + /// The external NTP server addresses. + pub ntp_servers: Vec, + + /// The external DNS server addresses. + pub dns_servers: Vec, + + /// Ranges of the service IP pool which may be used for internal services. + // TODO(https://github.com/oxidecomputer/omicron/issues/1530): Eventually, + // we want to configure multiple pools. + pub internal_services_ip_pool_ranges: Vec, + + /// Service IP addresses on which we run external DNS servers. + /// + /// Each address must be present in `internal_services_ip_pool_ranges`. + pub external_dns_ips: Vec, + + /// DNS name for the DNS zone delegated to the rack for external DNS + pub external_dns_zone_name: String, + + /// initial TLS certificates for the external API + pub external_certificates: Vec, + + /// Configuration of the Recovery Silo (the initial Silo) + pub recovery_silo: RecoverySiloConfig, + + /// Initial rack network configuration + pub rack_network_config: RackNetworkConfig, + + /// IPs or subnets allowed to make requests to user-facing services + pub allowed_source_ips: AllowedSourceIps, +} + +// "Shadow" copy of `RackInitializeRequest` that does no validation on its +// fields. +#[derive(Clone, Deserialize)] +struct UnvalidatedRackInitializeRequest { + trust_quorum_peers: Option>, + bootstrap_discovery: BootstrapAddressDiscovery, + ntp_servers: Vec, + dns_servers: Vec, + internal_services_ip_pool_ranges: Vec, + external_dns_ips: Vec, + external_dns_zone_name: String, + external_certificates: Vec, + recovery_silo: RecoverySiloConfig, + rack_network_config: RackNetworkConfig, + #[serde(default = "default_allowed_source_ips")] + allowed_source_ips: AllowedSourceIps, +} + +#[derive(Debug, Clone)] +pub struct RackInitializeRequestParams { + pub rack_initialize_request: RackInitializeRequest, + pub skip_timesync: bool, +} + +impl TryFrom for RackInitializeRequest { + type Error = anyhow::Error; + + fn try_from(value: UnvalidatedRackInitializeRequest) -> Result { + validate_external_dns( + &value.external_dns_ips, + &value.internal_services_ip_pool_ranges, + )?; + + Ok(Self { + trust_quorum_peers: value.trust_quorum_peers, + bootstrap_discovery: value.bootstrap_discovery, + ntp_servers: value.ntp_servers, + dns_servers: value.dns_servers, + internal_services_ip_pool_ranges: value + .internal_services_ip_pool_ranges, + external_dns_ips: value.external_dns_ips, + external_dns_zone_name: value.external_dns_zone_name, + external_certificates: value.external_certificates, + recovery_silo: value.recovery_silo, + rack_network_config: value.rack_network_config, + allowed_source_ips: value.allowed_source_ips, + }) + } +} diff --git a/sled-agent/types/versions/src/stronger_bgp_unnumbered_types/uplink.rs b/sled-agent/types/versions/src/stronger_bgp_unnumbered_types/uplink.rs new file mode 100644 index 00000000000..ec18d334389 --- /dev/null +++ b/sled-agent/types/versions/src/stronger_bgp_unnumbered_types/uplink.rs @@ -0,0 +1,70 @@ +// 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/. + +//! Uplink-related types for the Sled Agent API. +//! +//! Changes in this version: +//! * New [`HostPortConfig`] to pick up new [`UplinkAddressConfig`]. +//! * New [`SwitchPorts`] to pick up new [`HostPortConfig`]. + +use super::early_networking::UplinkAddressConfig; +use super::early_networking::UplinkIpNetError; +use crate::v1::early_networking::LldpPortConfig; +use crate::v1::early_networking::TxEqConfig; +use crate::v20; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +/// A set of switch uplinks. +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] +pub struct SwitchPorts { + pub uplinks: Vec, +} + +impl TryFrom for SwitchPorts { + type Error = UplinkIpNetError; + + fn try_from(value: v20::uplink::SwitchPorts) -> Result { + let uplinks = value + .uplinks + .into_iter() + .map(TryFrom::try_from) + .collect::>()?; + Ok(Self { uplinks }) + } +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, JsonSchema)] +pub struct HostPortConfig { + /// Switchport to use for external connectivity + pub port: String, + + /// IP Address and prefix (e.g., `192.168.0.1/16`) to apply to switchport + /// (must be in infra_ip pool). May also include an optional VLAN ID. + pub addrs: Vec, + + pub lldp: Option, + + pub tx_eq: Option, +} + +impl TryFrom for HostPortConfig { + type Error = UplinkIpNetError; + + fn try_from( + value: v20::uplink::HostPortConfig, + ) -> Result { + let addrs = value + .addrs + .into_iter() + .map(TryFrom::try_from) + .collect::>()?; + Ok(Self { + port: value.port, + addrs, + lldp: value.lldp, + tx_eq: value.tx_eq, + }) + } +} diff --git a/smf/sled-agent/gimlet-standalone/config-rss.toml b/smf/sled-agent/gimlet-standalone/config-rss.toml index e1c8dc3e3b2..98a1fb4a17d 100644 --- a/smf/sled-agent/gimlet-standalone/config-rss.toml +++ b/smf/sled-agent/gimlet-standalone/config-rss.toml @@ -102,7 +102,7 @@ bgp = [] # Routes associated with this port. routes = [{nexthop = "192.168.1.199", destination = "0.0.0.0/0"}] # Addresses associated with this port. -addresses = [{address = "192.168.1.30/32"}] +addresses = [{address = {type = "static", ip_net = "192.168.1.30/32"}}] # Name of the uplink port. This should always be "qsfp0" when using softnpu. port = "qsfp0" # The speed of this port. diff --git a/smf/sled-agent/non-gimlet/config-rss.toml b/smf/sled-agent/non-gimlet/config-rss.toml index cd42d00635d..c121ed4fb09 100644 --- a/smf/sled-agent/non-gimlet/config-rss.toml +++ b/smf/sled-agent/non-gimlet/config-rss.toml @@ -102,7 +102,7 @@ bgp = [] # Routes associated with this port. routes = [{nexthop = "192.168.1.199", destination = "0.0.0.0/0"}] # Addresses associated with this port. -addresses = [{address = "192.168.1.30/24"}] +addresses = [{address = {type = "static", ip_net = "192.168.1.30/24"}}] # Name of the uplink port. This should always be "qsfp0" when using softnpu. port = "qsfp0" # The speed of this port. diff --git a/wicket-common/Cargo.toml b/wicket-common/Cargo.toml index 32ec24398e6..ac00187a5a3 100644 --- a/wicket-common/Cargo.toml +++ b/wicket-common/Cargo.toml @@ -25,6 +25,7 @@ sha2.workspace = true sled-agent-types.workspace = true sled-hardware-types.workspace = true slog.workspace = true +slog-error-chain.workspace = true thiserror.workspace = true tokio.workspace = true transceiver-controller.workspace = true diff --git a/wicket-common/src/example.rs b/wicket-common/src/example.rs index 9c2de608d22..da1da6ade2a 100644 --- a/wicket-common/src/example.rs +++ b/wicket-common/src/example.rs @@ -14,7 +14,8 @@ use omicron_common::{ }; use sled_agent_types::early_networking::{ BgpConfig, BgpPeerConfig, LldpAdminStatus, LldpPortConfig, MaxPathConfig, - PortFec, PortSpeed, RouteConfig, TxEqConfig, UplinkAddressConfig, + PortFec, PortSpeed, RouteConfig, RouterLifetimeConfig, TxEqConfig, + UplinkAddress, }; use sled_hardware_types::Baseboard; @@ -25,6 +26,7 @@ use crate::{ CurrentRssUserConfigInsensitive, PutRssUserConfigInsensitive, UserSpecifiedBgpPeerConfig, UserSpecifiedImportExportPolicy, UserSpecifiedPortConfig, UserSpecifiedRackNetworkConfig, + UserSpecifiedRouterPeerAddr, UserSpecifiedUplinkAddressConfig, }, }; @@ -98,7 +100,7 @@ impl ExampleRackSetupData { let switch0_port0_bgp_peers = vec![ UserSpecifiedBgpPeerConfig { asn: 47, - addr: Some("10.2.3.4".parse().unwrap()), + addr: UserSpecifiedRouterPeerAddr::Unnumbered, port: "port0".into(), hold_time: Some(BgpPeerConfig::DEFAULT_HOLD_TIME), idle_hold_time: Some(BgpPeerConfig::DEFAULT_IDLE_HOLD_TIME), @@ -117,11 +119,13 @@ impl ExampleRackSetupData { "127.0.0.1/8".parse().unwrap(), ]), vlan_id: None, - router_lifetime: 0, + router_lifetime: RouterLifetimeConfig::default(), }, UserSpecifiedBgpPeerConfig { asn: 28, - addr: Some("10.2.3.5".parse().unwrap()), + addr: UserSpecifiedRouterPeerAddr::Numbered( + "10.2.3.5".parse().unwrap(), + ), port: "port0".into(), remote_asn: Some(200), hold_time: Some(10), @@ -141,13 +145,15 @@ impl ExampleRackSetupData { ]), allowed_export: UserSpecifiedImportExportPolicy::Allow(vec![]), vlan_id: None, - router_lifetime: 0, + router_lifetime: RouterLifetimeConfig::default(), }, ]; let switch1_port0_bgp_peers = vec![UserSpecifiedBgpPeerConfig { asn: 47, - addr: Some("10.2.3.4".parse().unwrap()), + addr: UserSpecifiedRouterPeerAddr::Numbered( + "10.2.3.4".parse().unwrap(), + ), port: "port0".into(), hold_time: Some(BgpPeerConfig::DEFAULT_HOLD_TIME), idle_hold_time: Some(BgpPeerConfig::DEFAULT_IDLE_HOLD_TIME), @@ -166,7 +172,7 @@ impl ExampleRackSetupData { ]), allowed_export: UserSpecifiedImportExportPolicy::NoFiltering, vlan_id: None, - router_lifetime: 0, + router_lifetime: RouterLifetimeConfig::default(), }]; let switch0_port0_lldp = Some(LldpPortConfig { @@ -207,9 +213,10 @@ impl ExampleRackSetupData { #[rustfmt::skip] switch0: btreemap! { "port0".to_owned() => UserSpecifiedPortConfig { - addresses: vec![UplinkAddressConfig::without_vlan( - "172.30.0.1/24".parse().unwrap(), - )], + addresses: vec![UserSpecifiedUplinkAddressConfig { + address: UplinkAddress::AddrConf, + vlan_id: Some(1), + }], routes: vec![RouteConfig { destination: "0.0.0.0/0".parse().unwrap(), nexthop: "172.30.0.10".parse().unwrap(), @@ -229,8 +236,8 @@ impl ExampleRackSetupData { // Use the same port name as in switch0 to test that it doesn't // collide. "port0".to_owned() => UserSpecifiedPortConfig { - addresses: vec![UplinkAddressConfig::without_vlan( - "172.32.0.1/24".parse().unwrap(), + addresses: vec![UserSpecifiedUplinkAddressConfig::without_vlan( + "172.30.0.1/24".parse().unwrap(), )], routes: vec![RouteConfig { destination: "0.0.0.0/0".parse().unwrap(), diff --git a/wicket-common/src/rack_setup.rs b/wicket-common/src/rack_setup.rs index 798e437d29f..ee85223a3cb 100644 --- a/wicket-common/src/rack_setup.rs +++ b/wicket-common/src/rack_setup.rs @@ -23,10 +23,15 @@ use sled_agent_types::early_networking::LldpPortConfig; use sled_agent_types::early_networking::PortFec; use sled_agent_types::early_networking::PortSpeed; use sled_agent_types::early_networking::RouteConfig; +use sled_agent_types::early_networking::RouterLifetimeConfig; +use sled_agent_types::early_networking::RouterPeerIpAddr; use sled_agent_types::early_networking::SwitchSlot; use sled_agent_types::early_networking::TxEqConfig; +use sled_agent_types::early_networking::UplinkAddress; use sled_agent_types::early_networking::UplinkAddressConfig; +use sled_agent_types::early_networking::UplinkIpNet; use sled_hardware_types::Baseboard; +use slog_error_chain::InlineErrorChain; use std::collections::BTreeMap; use std::collections::BTreeSet; use std::fmt; @@ -179,7 +184,7 @@ impl UserSpecifiedRackNetworkConfig { #[serde(deny_unknown_fields)] pub struct UserSpecifiedPortConfig { pub routes: Vec, - pub addresses: Vec, + pub addresses: Vec, pub uplink_port_speed: PortSpeed, pub uplink_port_fec: Option, pub autoneg: bool, @@ -191,6 +196,92 @@ pub struct UserSpecifiedPortConfig { pub tx_eq: Option, } +/// User-specified version of +/// [`sled_agent_types::early_networking::UplinkAddressConfig`]. +/// +/// This allows us to have a nicer TOML representation of [`UplinkAddress`]. +#[derive( + Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize, JsonSchema, +)] +#[serde(deny_unknown_fields)] +pub struct UserSpecifiedUplinkAddressConfig { + /// The address to be used on the uplink. + // This type is used in both JSON (via OpenAPI) and TOML (for operator + // uploads to wicket). For the TOML case specifically, we want to use a more + // user-friendly representation, so we serialize/deserialize this field as a + // string. See the `uplink_address_serde` module below for the specific + // mapping. + #[serde(with = "uplink_address_serde")] + #[schemars(with = "String")] + pub address: UplinkAddress, + + /// The VLAN id (if any) associated with this address. + #[serde(default)] + pub vlan_id: Option, +} + +impl From for UplinkAddressConfig { + fn from(value: UserSpecifiedUplinkAddressConfig) -> Self { + Self { address: value.address, vlan_id: value.vlan_id } + } +} + +impl UserSpecifiedUplinkAddressConfig { + /// String representation for [`UplinkAddress::AddrConf`] when + /// serializing/deserializing [`UserSpecifiedUplinkAddressConfig`]. + pub const ADDR_CONF: &str = "addrconf"; + + /// Helper to construct a `UserSpecifiedUplinkAddressConfig` with a + /// specified IP net and no VLAN ID. + pub fn without_vlan(ip_net: UplinkIpNet) -> Self { + Self { address: UplinkAddress::Static { ip_net }, vlan_id: None } + } +} + +/// Special handling to serialize/deserialize [`UplinkAddress`] as a flat +/// string for a nicer TOML representation. +mod uplink_address_serde { + use super::{UplinkAddress, UserSpecifiedUplinkAddressConfig}; + use oxnet::IpNet; + use serde::{Deserialize, Deserializer, Serializer}; + use sled_agent_types::early_networking::UplinkIpNet; + use slog_error_chain::InlineErrorChain; + + pub fn serialize( + addr: &UplinkAddress, + s: S, + ) -> Result { + match addr { + UplinkAddress::AddrConf => { + s.serialize_str(UserSpecifiedUplinkAddressConfig::ADDR_CONF) + } + UplinkAddress::Static { ip_net } => { + s.serialize_str(&ip_net.to_string()) + } + } + } + + pub fn deserialize<'de, D: Deserializer<'de>>( + d: D, + ) -> Result { + let s = String::deserialize(d)?; + if s.eq_ignore_ascii_case(UserSpecifiedUplinkAddressConfig::ADDR_CONF) { + Ok(UplinkAddress::AddrConf) + } else { + let ip_net: IpNet = s.parse().map_err(|_| { + serde::de::Error::custom(format!( + "invalid uplink ipnet `{s}`: \ + expected `addrconf` or an IP network", + )) + })?; + let ip_net = UplinkIpNet::try_from(ip_net).map_err(|err| { + serde::de::Error::custom(InlineErrorChain::new(&err)) + })?; + Ok(UplinkAddress::Static { ip_net }) + } + } +} + /// User-specified version of [`BgpPeerConfig`]. /// /// This is similar to [`BgpPeerConfig`], except it doesn't have the sensitive @@ -204,7 +295,7 @@ pub struct UserSpecifiedBgpPeerConfig { /// Switch port the peer is reachable on. pub port: String, /// Address of the peer. - pub addr: Option, + pub addr: UserSpecifiedRouterPeerAddr, /// How long to keep a session alive without a keepalive in seconds. /// Defaults to 6 seconds. pub hold_time: Option, @@ -252,7 +343,69 @@ pub struct UserSpecifiedBgpPeerConfig { pub vlan_id: Option, /// Router lifetime in seconds for unnumbered BGP peers. #[serde(default)] - pub router_lifetime: u16, + pub router_lifetime: RouterLifetimeConfig, +} + +// Type that allows either the string "unnumbered" or an IP address. Has custom +// implementations of `JsonSchema`, `Serialize`, and `Deserialize` to support +// this. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum UserSpecifiedRouterPeerAddr { + Unnumbered, + Numbered(RouterPeerIpAddr), +} + +impl UserSpecifiedRouterPeerAddr { + /// String representation for [`UserSpecifiedRouterPeerAddr::Unnumbered`] in + /// serialization. + pub const UNNUMBERED_PEER: &str = "unnumbered"; +} + +impl JsonSchema for UserSpecifiedRouterPeerAddr { + fn schema_name() -> String { + "UserSpecifiedRouterPeerAddr".to_string() + } + + fn json_schema( + generator: &mut schemars::r#gen::SchemaGenerator, + ) -> schemars::schema::Schema { + String::json_schema(generator) + } +} + +impl Serialize for UserSpecifiedRouterPeerAddr { + fn serialize(&self, s: S) -> Result + where + S: Serializer, + { + match self { + Self::Unnumbered => s.serialize_str(Self::UNNUMBERED_PEER), + Self::Numbered(ip) => s.serialize_str(&ip.to_string()), + } + } +} + +impl<'de> Deserialize<'de> for UserSpecifiedRouterPeerAddr { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + if s.eq_ignore_ascii_case(Self::UNNUMBERED_PEER) { + Ok(Self::Unnumbered) + } else { + let ip: IpAddr = s.parse().map_err(|_| { + serde::de::Error::custom(format!( + "invalid router peer address `{s}`: \ + expected `unnumbered` or an IP address", + )) + })?; + let ip = RouterPeerIpAddr::try_from(ip).map_err(|err| { + serde::de::Error::custom(InlineErrorChain::new(&err)) + })?; + Ok(Self::Numbered(ip)) + } + } } impl UserSpecifiedBgpPeerConfig { @@ -587,13 +740,13 @@ mod tests { ]; for input in &inputs { - let input = Wrapper { policy: input.clone() }; + let input = ImportExportPolicyWrapper { policy: input.clone() }; eprintln!("** input: {:?}, testing JSON", input); // Check that serialization to JSON and back works. let serialized = serde_json::to_string(&input).unwrap(); eprintln!("serialized JSON: {serialized}"); - let deserialized: Wrapper = + let deserialized: ImportExportPolicyWrapper = serde_json::from_str(&serialized).unwrap(); assert_eq!(input, deserialized); @@ -601,14 +754,163 @@ mod tests { // Check that serialization to TOML and back works. let serialized = toml::to_string(&input).unwrap(); eprintln!("serialized TOML: {serialized}"); - let deserialized: Wrapper = toml::from_str(&serialized).unwrap(); + let deserialized: ImportExportPolicyWrapper = + toml::from_str(&serialized).unwrap(); assert_eq!(input, deserialized); } } #[derive(Debug, Deserialize, Serialize, PartialEq, Eq)] - struct Wrapper { + struct ImportExportPolicyWrapper { #[serde(default)] policy: UserSpecifiedImportExportPolicy, } + + #[test] + fn roundtrip_router_peer_address() { + let inputs = [ + (UserSpecifiedRouterPeerAddr::Unnumbered, "unnumbered"), + ( + UserSpecifiedRouterPeerAddr::Numbered( + "1.1.1.1".parse().unwrap(), + ), + "1.1.1.1", + ), + ( + UserSpecifiedRouterPeerAddr::Numbered( + "fd00::1".parse().unwrap(), + ), + "fd00::1", + ), + ]; + + for (input, expected_str) in inputs { + let input = RouterPeerAddressWrapper { addr: input }; + + eprintln!("** input: {:?}, testing JSON", input); + // Check that serialization to JSON and back works. + let serialized = serde_json::to_string(&input).unwrap(); + eprintln!("serialized JSON: {serialized}"); + let deserialized: RouterPeerAddressWrapper = + serde_json::from_str(&serialized).unwrap(); + assert_eq!(input, deserialized); + + eprintln!("** input: {:?}, testing TOML", input); + // Check that serialization to TOML and back works. + let serialized = toml::to_string(&input).unwrap(); + eprintln!("serialized TOML: {serialized}"); + let deserialized: RouterPeerAddressWrapper = + toml::from_str(&serialized).unwrap(); + assert_eq!(input, deserialized); + + assert_eq!(serialized, format!("addr = \"{expected_str}\"\n")); + } + } + + #[test] + fn invalid_router_peer_address() { + let invalid_inputs = [ + "0.0.0.0", + "::", + "foobar", + "127.0.0.1", + "255.255.255.255", + "ff02::1", + "fe80::1", + ]; + + for input in invalid_inputs { + let toml_input = format!("addr = \"{input}\"\n"); + match toml::from_str::(&toml_input) { + Ok(addr) => panic!("unexpected success: parsed {addr:?}"), + Err(err) => { + let err = err.to_string(); + assert!( + err.contains(&format!( + "invalid router peer address `{input}`" + )), + "unexpected error for input `{input}`: {err}" + ); + } + } + } + } + + #[derive(Debug, Deserialize, Serialize, PartialEq, Eq)] + struct RouterPeerAddressWrapper { + pub addr: UserSpecifiedRouterPeerAddr, + } + + #[test] + fn roundtrip_uplink_address() { + let inputs = [ + (UplinkAddress::AddrConf, "addrconf"), + ( + UplinkAddress::Static { ip_net: "1.1.1.0/24".parse().unwrap() }, + "1.1.1.0/24", + ), + ( + UplinkAddress::Static { ip_net: "fd00::/64".parse().unwrap() }, + "fd00::/64", + ), + ]; + + for (input, expected_str) in inputs { + let input = UplinkAddressWrapper { addr: input }; + + eprintln!("** input: {:?}, testing JSON", input); + // Check that serialization to JSON and back works. + let serialized = serde_json::to_string(&input).unwrap(); + eprintln!("serialized JSON: {serialized}"); + let deserialized: UplinkAddressWrapper = + serde_json::from_str(&serialized).unwrap(); + assert_eq!(input, deserialized); + + eprintln!("** input: {:?}, testing TOML", input); + // Check that serialization to TOML and back works. + let serialized = toml::to_string(&input).unwrap(); + eprintln!("serialized TOML: {serialized}"); + let deserialized: UplinkAddressWrapper = + toml::from_str(&serialized).unwrap(); + assert_eq!(input, deserialized); + + assert_eq!(serialized, format!("addr = \"{expected_str}\"\n")); + } + } + + #[test] + fn invalid_uplink_address() { + let invalid_inputs = [ + "0.0.0.0/0", + "::/128", + "255.255.255.255/16", + "ff80::1/64", + "fe80::1/64", + "foobar", + ]; + + for input in invalid_inputs { + let toml_input = format!("addr = \"{input}\"\n"); + match toml::from_str::(&toml_input) { + Ok(addr) => panic!("unexpected success: parsed {addr:?}"), + Err(err) => { + let err = err.to_string(); + assert!( + err.contains(&format!( + "invalid uplink ipnet `{input}`" + )), + "unexpected error for input `{input}`: {err}" + ); + } + } + } + } + + #[derive(Debug, Deserialize, Serialize, PartialEq, Eq)] + struct UplinkAddressWrapper { + // This attribute matches the one on + // `UserSpecifiedUplinkAddressConfig::address` above. + #[serde(with = "uplink_address_serde")] + pub addr: UplinkAddress, + } } diff --git a/wicket/src/cli/rack_setup/config_toml.rs b/wicket/src/cli/rack_setup/config_toml.rs index a45a7ea9ce5..97e3f65ca48 100644 --- a/wicket/src/cli/rack_setup/config_toml.rs +++ b/wicket/src/cli/rack_setup/config_toml.rs @@ -11,7 +11,7 @@ use serde::Serialize; use sled_agent_types::early_networking::BgpConfig; use sled_agent_types::early_networking::LldpPortConfig; use sled_agent_types::early_networking::RouteConfig; -use sled_agent_types::early_networking::UplinkAddressConfig; +use sled_agent_types::early_networking::UplinkAddress; use sled_hardware_types::Baseboard; use std::borrow::Cow; use std::collections::BTreeSet; @@ -31,6 +31,7 @@ use wicket_common::rack_setup::UserSpecifiedBgpPeerConfig; use wicket_common::rack_setup::UserSpecifiedImportExportPolicy; use wicket_common::rack_setup::UserSpecifiedPortConfig; use wicket_common::rack_setup::UserSpecifiedRackNetworkConfig; +use wicket_common::rack_setup::UserSpecifiedUplinkAddressConfig; static TEMPLATE: &str = include_str!("config_template.toml"); @@ -371,11 +372,17 @@ fn populate_uplink_table(cfg: &UserSpecifiedPortConfig) -> Table { // addresses = [] let mut addresses_out = Array::new(); for a in addresses { - let UplinkAddressConfig { address, vlan_id } = a; + let UserSpecifiedUplinkAddressConfig { address, vlan_id } = a; let mut x = InlineTable::new(); - if let Some(address) = address { - x.insert("address", string_value(address)); - } + x.insert( + "address", + string_value(match address { + UplinkAddress::AddrConf => { + UserSpecifiedUplinkAddressConfig::ADDR_CONF.to_owned() + } + UplinkAddress::Static { ip_net } => ip_net.to_string(), + }), + ); if let Some(vlan_id) = vlan_id { x.insert("vlan_id", i64_value(i64::from(*vlan_id))); } @@ -432,9 +439,7 @@ fn populate_uplink_table(cfg: &UserSpecifiedPortConfig) -> Table { peer.insert("port", string_item(port)); // addr = "" - if let Some(x) = addr { - peer.insert("addr", string_item(x)); - } + peer.insert("addr", string_item(enum_to_toml_string(addr))); // hold_time if let Some(x) = hold_time { @@ -514,10 +519,10 @@ fn populate_uplink_table(cfg: &UserSpecifiedPortConfig) -> Table { } // router_lifetime - if *router_lifetime != 0 { + if router_lifetime.as_u16() != 0 { peer.insert( "router_lifetime", - i64_item(i64::from(*router_lifetime)), + i64_item(i64::from(router_lifetime.as_u16())), ); } diff --git a/wicket/src/ui/panes/rack_setup.rs b/wicket/src/ui/panes/rack_setup.rs index a292cd85c0c..dff677ad958 100644 --- a/wicket/src/ui/panes/rack_setup.rs +++ b/wicket/src/ui/panes/rack_setup.rs @@ -37,6 +37,7 @@ use sled_agent_types::early_networking::LldpAdminStatus; use sled_agent_types::early_networking::LldpPortConfig; use sled_agent_types::early_networking::RouteConfig; use sled_agent_types::early_networking::SwitchSlot; +use sled_agent_types::early_networking::UplinkAddress; use std::borrow::Cow; use wicket_common::rack_setup::BgpAuthKeyInfo; use wicket_common::rack_setup::BgpAuthKeyStatus; @@ -45,6 +46,8 @@ use wicket_common::rack_setup::UserSpecifiedBgpPeerConfig; use wicket_common::rack_setup::UserSpecifiedImportExportPolicy; use wicket_common::rack_setup::UserSpecifiedPortConfig; use wicket_common::rack_setup::UserSpecifiedRackNetworkConfig; +use wicket_common::rack_setup::UserSpecifiedRouterPeerAddr; +use wicket_common::rack_setup::UserSpecifiedUplinkAddressConfig; use wicketd_client::types::CurrentRssUserConfig; use wicketd_client::types::CurrentRssUserConfigSensitive; use wicketd_client::types::RackOperationStatus; @@ -867,15 +870,19 @@ fn rss_config_text<'a>( }); let addresses = addresses.iter().map(|a| { + let UserSpecifiedUplinkAddressConfig { address, vlan_id } = a; + let addr_description = match address { + UplinkAddress::AddrConf => Cow::Borrowed( + UserSpecifiedUplinkAddressConfig::ADDR_CONF, + ), + UplinkAddress::Static { ip_net } => { + Cow::Owned(ip_net.to_string()) + } + }; let mut items = vec![Span::styled(" • Address : ", label_style)]; - if let Some(address) = a.address { - items.push(Span::styled(address.to_string(), ok_style)); - } else { - items - .push(Span::styled("link-local".to_string(), ok_style)); - } - if let Some(vlan_id) = a.vlan_id { + items.push(Span::styled(addr_description, ok_style)); + if let Some(vlan_id) = vlan_id { items.extend([ Span::styled(" (vlan_id=", label_style), Span::styled(vlan_id.to_string(), ok_style), @@ -914,8 +921,12 @@ fn rss_config_text<'a>( } = p; let addr_string = match addr { - Some(a) => a.to_string(), - None => "unnumbered".to_string(), + UserSpecifiedRouterPeerAddr::Unnumbered => Cow::Borrowed( + UserSpecifiedRouterPeerAddr::UNNUMBERED_PEER, + ), + UserSpecifiedRouterPeerAddr::Numbered(ip) => { + Cow::Owned(ip.to_string()) + } }; let mut lines = vec![ @@ -1002,7 +1013,7 @@ fn rss_config_text<'a>( Span::styled(vlan_id.to_string(), ok_style), ]); } - if *router_lifetime != 0 { + if router_lifetime.as_u16() != 0 { settings.extend([ Span::styled(" router_lifetime=", label_style), Span::styled( diff --git a/wicket/tests/output/example_non_empty.toml b/wicket/tests/output/example_non_empty.toml index 46879c07eb5..0537bfdeae4 100644 --- a/wicket/tests/output/example_non_empty.toml +++ b/wicket/tests/output/example_non_empty.toml @@ -75,7 +75,7 @@ rack_subnet_address = "fd00:1122:3344:100::" [rack_network_config.switch0.port0] routes = [{ nexthop = "172.30.0.10", destination = "0.0.0.0/0", vlan_id = 1 }] -addresses = [{ address = "172.30.0.1/24" }] +addresses = [{ address = "addrconf", vlan_id = 1 }] uplink_port_speed = "speed400_g" uplink_port_fec = "firecode" autoneg = true @@ -83,7 +83,7 @@ autoneg = true [[rack_network_config.switch0.port0.bgp_peers]] asn = 47 port = "port0" -addr = "10.2.3.4" +addr = "unnumbered" hold_time = 6 idle_hold_time = 3 delay_open = 0 @@ -129,7 +129,7 @@ post2 = 0 [rack_network_config.switch1.port0] routes = [{ nexthop = "172.33.0.10", destination = "0.0.0.0/0", vlan_id = 1 }] -addresses = [{ address = "172.32.0.1/24" }] +addresses = [{ address = "172.30.0.1/24" }] uplink_port_speed = "speed400_g" autoneg = true diff --git a/wicketd/src/preflight_check/uplink.rs b/wicketd/src/preflight_check/uplink.rs index 0eeecefe8bc..d38c5c15e0e 100644 --- a/wicketd/src/preflight_check/uplink.rs +++ b/wicketd/src/preflight_check/uplink.rs @@ -28,6 +28,8 @@ use oxnet::IpNet; use sled_agent_types::early_networking::PortFec as OmicronPortFec; use sled_agent_types::early_networking::PortSpeed as OmicronPortSpeed; use sled_agent_types::early_networking::SwitchSlot; +use sled_agent_types::early_networking::UplinkAddress; +use sled_agent_types::early_networking::UplinkAddressConfig; use slog::Logger; use slog::error; use slog::o; @@ -308,7 +310,9 @@ fn add_steps_for_single_local_uplink_preflight_check<'a>( let uplink_property = UplinkProperty(format!("uplinks/{}_0", port)); - for addr in &uplink.addresses { + for &addr in &uplink.addresses { + let addr = UplinkAddressConfig::from(addr); + // count current number of link-local addresses let addrconf_count = match execute_command(&[ IPADM, @@ -378,7 +382,7 @@ fn add_steps_for_single_local_uplink_preflight_check<'a>( 'waiting_for_addr: loop { match addr.address { // When we are using numbered uplinks - Some(uplink_cidr) => { + UplinkAddress::Static { ip_net: uplink_cidr } => { let ipadm_out = match execute_command(&[ IPADM, "show-addr", @@ -410,7 +414,7 @@ fn add_steps_for_single_local_uplink_preflight_check<'a>( } } // unnumbered uplinks - None => { + UplinkAddress::AddrConf => { // look for a new unnumbered uplink let new_count = match execute_command(&[ IPADM, @@ -838,7 +842,11 @@ fn build_port_settings( let mut port_settings = PortSettings { links: HashMap::new() }; - let addrs = uplink.addresses.iter().map(|a| a.addr()).collect(); + let addrs = uplink + .addresses + .iter() + .map(|a| a.address.ip_squashing_addrconf_to_unspecified()) + .collect(); port_settings.links.insert( link_id.to_string(), diff --git a/wicketd/src/rss_config.rs b/wicketd/src/rss_config.rs index eb272b2824f..22ce64baf4f 100644 --- a/wicketd/src/rss_config.rs +++ b/wicketd/src/rss_config.rs @@ -12,7 +12,6 @@ use anyhow::bail; use bootstrap_agent_lockstep_client::types::BootstrapAddressDiscovery; use bootstrap_agent_lockstep_client::types::Certificate; use bootstrap_agent_lockstep_client::types::Name; -use bootstrap_agent_lockstep_client::types::PortConfig as BaPortConfig; use bootstrap_agent_lockstep_client::types::RackInitializeRequest; use bootstrap_agent_lockstep_client::types::RecoverySiloConfig; use bootstrap_agent_lockstep_client::types::UserId; @@ -24,7 +23,11 @@ use omicron_common::address::Ipv4Range; use omicron_common::address::Ipv6Range; use omicron_common::api::external::AllowedSourceIps; use oxnet::Ipv6Net; +use sled_agent_types::early_networking::PortConfig; +use sled_agent_types::early_networking::RouterLifetimeConfig; +use sled_agent_types::early_networking::RouterPeerType; use sled_agent_types::early_networking::SwitchSlot; +use sled_agent_types::early_networking::UplinkAddress; use sled_hardware_types::Baseboard; use slog::debug; use slog::warn; @@ -47,6 +50,7 @@ use wicket_common::rack_setup::GetBgpAuthKeyInfoResponse; use wicket_common::rack_setup::PutRssUserConfigInsensitive; use wicket_common::rack_setup::UserSpecifiedPortConfig; use wicket_common::rack_setup::UserSpecifiedRackNetworkConfig; +use wicket_common::rack_setup::UserSpecifiedRouterPeerAddr; use wicketd_api::CertificateUploadResponse; use wicketd_api::CurrentRssUserConfig; use wicketd_api::CurrentRssUserConfigSensitive; @@ -620,7 +624,7 @@ fn validate_rack_network_config( return Err(anyhow!("Must have at least one port configured")); } - // Make sure `infra_ip_first`..`infra_ip_last` is a well-defined range... + // Make sure `infra_ip_first`..`infra_ip_last` is a well-defined range. let infra_ip_range = match (config.infra_ip_first, config.infra_ip_last) { (IpAddr::V4(first), IpAddr::V4(last)) => Ipv4Range::new(first, last) .map_err(|s: String| { @@ -637,24 +641,44 @@ fn validate_rack_network_config( )), }?; - // TODO this implies a single contiguous range for port IPs which is over - // constraining - // iterate through each port config for (_, _, port_config) in config.iter_uplinks() { + // Check that `infra_ip_{first...last}` contains every `uplink_ip`. + // + // TODO this implies a single contiguous range for port IPs which is + // over constraining for addr in &port_config.addresses { - if addr.addr().is_unspecified() { - continue; - } - // ... and check that it contains `uplink_ip`. - if addr.addr() < infra_ip_range.first_address() - || addr.addr() > infra_ip_range.last_address() + let addr: IpAddr = match addr.address { + UplinkAddress::AddrConf => continue, + UplinkAddress::Static { ip_net } => ip_net.addr(), + }; + if addr < infra_ip_range.first_address() + || addr > infra_ip_range.last_address() { bail!( - "`uplink_cidr`'s IP address must be in the range defined by \ - `infra_ip_first` and `infra_ip_last`" + "`uplink_cidr` IP address {addr} is not covered by the \ + range defined by `infra_ip_first` ({}) and \ + `infra_ip_last` ({})", + infra_ip_range.first_address(), + infra_ip_range.last_address(), ); } } + + // Check that router_lifetime is only specified for unnumbered peers + for peer in &port_config.bgp_peers { + match peer.addr { + UserSpecifiedRouterPeerAddr::Unnumbered => (), + UserSpecifiedRouterPeerAddr::Numbered(ip) => { + if peer.router_lifetime != RouterLifetimeConfig::default() { + bail!( + "numbered BGP peer {ip} specifies a \ + router_lifetime, but router_lifetime is only \ + supported for unnumbered BGP peers" + ); + } + } + } + } } // Check that all auth keys are present. @@ -740,7 +764,7 @@ pub fn validate_rack_subnet( Ipv6Net::new(rack_subnet_address, 56).map_err(|e| e.to_string()) } -/// Builds a `BaPortConfig` from a `UserSpecifiedPortConfig`. +/// Builds a [`PortConfig`] from a [`UserSpecifiedPortConfig`]. /// /// Assumes that all auth keys are present in `bgp_auth_keys`. fn build_port_config( @@ -748,40 +772,13 @@ fn build_port_config( port: &str, config: &UserSpecifiedPortConfig, bgp_auth_keys: &BTreeMap>, -) -> BaPortConfig { - use bootstrap_agent_lockstep_client::types::BgpPeerConfig as BaBgpPeerConfig; - use bootstrap_agent_lockstep_client::types::LldpAdminStatus as BaLldpAdminStatus; - use bootstrap_agent_lockstep_client::types::LldpPortConfig as BaLldpPortConfig; - use bootstrap_agent_lockstep_client::types::PortFec as BaPortFec; - use bootstrap_agent_lockstep_client::types::PortSpeed as BaPortSpeed; - use bootstrap_agent_lockstep_client::types::RouteConfig as BaRouteConfig; - use bootstrap_agent_lockstep_client::types::RouterLifetimeConfig as BaRouterLifetimeConfig; - use bootstrap_agent_lockstep_client::types::TxEqConfig as BaTxEqConfig; - use bootstrap_agent_lockstep_client::types::UplinkAddressConfig as BaUplinkAddressConfig; - use sled_agent_types::early_networking::LldpAdminStatus; - use sled_agent_types::early_networking::PortFec; - use sled_agent_types::early_networking::PortSpeed; - - BaPortConfig { +) -> PortConfig { + use sled_agent_types::early_networking::BgpPeerConfig; + + PortConfig { port: port.to_owned(), - routes: config - .routes - .iter() - .map(|r| BaRouteConfig { - destination: r.destination, - nexthop: r.nexthop, - vlan_id: r.vlan_id, - rib_priority: r.rib_priority, - }) - .collect(), - addresses: config - .addresses - .iter() - .map(|a| BaUplinkAddressConfig { - address: a.address, - vlan_id: a.vlan_id, - }) - .collect(), + routes: config.routes.clone(), + addresses: config.addresses.iter().copied().map(From::from).collect(), bgp_peers: config .bgp_peers .iter() @@ -805,10 +802,19 @@ fn build_port_config( key }); - BaBgpPeerConfig { - addr: p - .addr - .unwrap_or_else(|| IpAddr::V6(Ipv6Addr::UNSPECIFIED)), + let addr = match p.addr { + UserSpecifiedRouterPeerAddr::Unnumbered => { + RouterPeerType::Unnumbered { + router_lifetime: p.router_lifetime, + } + } + UserSpecifiedRouterPeerAddr::Numbered(ip) => { + RouterPeerType::Numbered { ip } + } + }; + + BgpPeerConfig { + addr, asn: p.asn, port: p.port.clone(), hold_time: p.hold_time, @@ -826,49 +832,15 @@ fn build_port_config( allowed_export: p.allowed_export.clone().into(), allowed_import: p.allowed_import.clone().into(), vlan_id: p.vlan_id, - router_lifetime: BaRouterLifetimeConfig(p.router_lifetime), } }) .collect(), switch, - uplink_port_speed: match config.uplink_port_speed { - PortSpeed::Speed0G => BaPortSpeed::Speed0G, - PortSpeed::Speed1G => BaPortSpeed::Speed1G, - PortSpeed::Speed10G => BaPortSpeed::Speed10G, - PortSpeed::Speed25G => BaPortSpeed::Speed25G, - PortSpeed::Speed40G => BaPortSpeed::Speed40G, - PortSpeed::Speed50G => BaPortSpeed::Speed50G, - PortSpeed::Speed100G => BaPortSpeed::Speed100G, - PortSpeed::Speed200G => BaPortSpeed::Speed200G, - PortSpeed::Speed400G => BaPortSpeed::Speed400G, - }, - uplink_port_fec: config.uplink_port_fec.map(|fec| match fec { - PortFec::Firecode => BaPortFec::Firecode, - PortFec::None => BaPortFec::None, - PortFec::Rs => BaPortFec::Rs, - }), + uplink_port_speed: config.uplink_port_speed, + uplink_port_fec: config.uplink_port_fec, autoneg: config.autoneg, - lldp: config.lldp.as_ref().map(|c| BaLldpPortConfig { - status: match c.status { - LldpAdminStatus::Enabled => BaLldpAdminStatus::Enabled, - LldpAdminStatus::Disabled => BaLldpAdminStatus::Disabled, - LldpAdminStatus::TxOnly => BaLldpAdminStatus::TxOnly, - LldpAdminStatus::RxOnly => BaLldpAdminStatus::RxOnly, - }, - chassis_id: c.chassis_id.clone(), - port_id: c.port_id.clone(), - system_name: c.system_name.clone(), - system_description: c.system_description.clone(), - port_description: c.port_description.clone(), - management_addrs: c.management_addrs.clone(), - }), - tx_eq: config.tx_eq.as_ref().map(|c| BaTxEqConfig { - pre1: c.pre1, - pre2: c.pre2, - main: c.main, - post2: c.post2, - post1: c.post1, - }), + lldp: config.lldp.clone(), + tx_eq: config.tx_eq, } } @@ -921,6 +893,94 @@ mod tests { use super::*; + #[test] + fn test_router_lifetime_unnumbered_only() { + // Default should be okay and have at least one BGP peer. + let example = ExampleRackSetupData::non_empty(); + let bgp_auth_keys = { + let mut m = BTreeMap::new(); + for id in example.bgp_auth_keys { + m.insert( + id, + Some(BgpAuthKey::TcpMd5 { key: "dummy".to_owned() }), + ); + } + m + }; + let rack_network_config = example.put_insensitive.rack_network_config; + validate_rack_network_config(&rack_network_config, &bgp_auth_keys) + .expect("base config is valid"); + assert!( + !rack_network_config + .switch0 + .first_key_value() + .expect("at least one switch0 port") + .1 + .bgp_peers + .is_empty() + ); + + // Combine unnumbered with a non-default router_lifetime - fine. + let mut valid_router_lifetime = rack_network_config.clone(); + { + let peer = valid_router_lifetime + .switch0 + .first_entry() + .unwrap() + .into_mut() + .bgp_peers + .get_mut(0) + .unwrap(); + peer.addr = UserSpecifiedRouterPeerAddr::Unnumbered; + peer.router_lifetime = RouterLifetimeConfig::new(1234).unwrap(); + } + validate_rack_network_config(&valid_router_lifetime, &bgp_auth_keys) + .expect("unnumbered with non-zero router_lifetime is ok"); + + // Keep non-default router_lifetime but change to a numbered peer - + // should fail with a reasonable error. + let mut invalid_router_lifetime = valid_router_lifetime.clone(); + { + let peer = invalid_router_lifetime + .switch0 + .first_entry() + .unwrap() + .into_mut() + .bgp_peers + .get_mut(0) + .unwrap(); + peer.addr = UserSpecifiedRouterPeerAddr::Numbered( + "1.2.3.4".parse().unwrap(), + ); + } + let err = validate_rack_network_config( + &invalid_router_lifetime, + &bgp_auth_keys, + ) + .expect_err("numbered with non-zero router_lifetime is not ok"); + assert_eq!( + format!("{err:#}"), + "numbered BGP peer 1.2.3.4 specifies a router_lifetime, but \ + router_lifetime is only supported for unnumbered BGP peers" + ); + + // Keep numbered peer but switch router_lifetime back to default - fine. + let mut valid_router_lifetime = invalid_router_lifetime.clone(); + { + let peer = valid_router_lifetime + .switch0 + .first_entry() + .unwrap() + .into_mut() + .bgp_peers + .get_mut(0) + .unwrap(); + peer.router_lifetime = RouterLifetimeConfig::default() + } + validate_rack_network_config(&valid_router_lifetime, &bgp_auth_keys) + .expect("numbered with zero router_lifetime is ok"); + } + #[test] fn test_bgp_auth_key_states() { let example = ExampleRackSetupData::non_empty();