diff --git a/sled-agent/src/bootstrap/early_networking.rs b/sled-agent/src/bootstrap/early_networking.rs index cb411a2546..4216a418c6 100644 --- a/sled-agent/src/bootstrap/early_networking.rs +++ b/sled-agent/src/bootstrap/early_networking.rs @@ -682,6 +682,65 @@ pub struct EarlyNetworkConfig { pub body: EarlyNetworkConfigBody, } +impl EarlyNetworkConfig { + // Note: This currently only converts between v0 and v1 or deserializes v1 of + // `EarlyNetworkConfig`. + pub fn deserialize_bootstore_config( + log: &Logger, + config: &bootstore::NetworkConfig, + ) -> Result { + // Try to deserialize the latest version of the data structure (v1). If + // that succeeds we are done. + let v1_error = + match serde_json::from_slice::(&config.blob) { + Ok(val) => return Ok(val), + Err(error) => { + // Log this error and continue trying to deserialize older + // versions. + warn!( + log, + "Failed to deserialize EarlyNetworkConfig \ + as v1, trying next as v0: {}", + error, + ); + error + } + }; + + match serde_json::from_slice::(&config.blob) { + Ok(val) => { + // Convert from v0 to v1 + return Ok(EarlyNetworkConfig { + generation: val.generation, + schema_version: 1, + body: EarlyNetworkConfigBody { + ntp_servers: val.ntp_servers, + rack_network_config: val.rack_network_config.map( + |v0_config| { + RackNetworkConfigV0::to_v1( + val.rack_subnet, + v0_config, + ) + }, + ), + }, + }); + } + Err(error) => { + // Log this error. + warn!( + log, + "Failed to deserialize EarlyNetworkConfig as v0: {}", error, + ); + } + }; + + // Return the v1 error preferentially over the v0 error as it's more + // likely to be useful. + Err(v1_error) + } +} + /// This is the actual configuration of EarlyNetworking. /// /// We nest it below the "header" of `generation` and `schema_version` so that @@ -711,39 +770,6 @@ impl From for bootstore::NetworkConfig { } } -// Note: This currently only converts between v0 and v1 or deserializes v1 of -// `EarlyNetworkConfig`. -impl TryFrom for EarlyNetworkConfig { - type Error = serde_json::Error; - - fn try_from( - value: bootstore::NetworkConfig, - ) -> std::result::Result { - // Try to deserialize the latest version of the data structure (v1). If - // that succeeds we are done. - if let Ok(val) = - serde_json::from_slice::(&value.blob) - { - return Ok(val); - } - - // We don't have the latest version. Try to deserialize v0 and then - // convert it to the latest version. - let v0 = serde_json::from_slice::(&value.blob)?; - - Ok(EarlyNetworkConfig { - generation: v0.generation, - schema_version: 1, - body: EarlyNetworkConfigBody { - ntp_servers: v0.ntp_servers, - rack_network_config: v0.rack_network_config.map(|v0_config| { - RackNetworkConfigV0::to_v1(v0.rack_subnet, v0_config) - }), - }, - }) - } -} - /// Deprecated, use `RackNetworkConfig` instead. Cannot actually deprecate due to /// /// @@ -815,9 +841,13 @@ fn convert_fec(fec: &PortFec) -> dpd_client::types::PortFec { mod tests { use super::*; use omicron_common::api::internal::shared::RouteConfig; + use omicron_test_utils::dev::test_setup_log; #[test] fn serialized_early_network_config_v0_to_v1_conversion() { + let logctx = test_setup_log( + "serialized_early_network_config_v0_to_v1_conversion", + ); let v0 = EarlyNetworkConfigV0 { generation: 1, rack_subnet: Ipv6Addr::UNSPECIFIED, @@ -841,7 +871,11 @@ mod tests { let bootstore_conf = bootstore::NetworkConfig { generation: 1, blob: v0_serialized }; - let v1 = EarlyNetworkConfig::try_from(bootstore_conf).unwrap(); + let v1 = EarlyNetworkConfig::deserialize_bootstore_config( + &logctx.log, + &bootstore_conf, + ) + .unwrap(); let v0_rack_network_config = v0.rack_network_config.unwrap(); let uplink = v0_rack_network_config.uplinks[0].clone(); let expected = EarlyNetworkConfig { @@ -872,5 +906,7 @@ mod tests { }; assert_eq!(expected, v1); + + logctx.cleanup_successful(); } } diff --git a/sled-agent/src/http_entrypoints.rs b/sled-agent/src/http_entrypoints.rs index 9c3a079dac..2dcb35b77e 100644 --- a/sled-agent/src/http_entrypoints.rs +++ b/sled-agent/src/http_entrypoints.rs @@ -659,7 +659,10 @@ async fn read_network_bootstore_config_cache( })?; let config = match config { - Some(config) => EarlyNetworkConfig::try_from(config).map_err(|e| { + Some(config) => EarlyNetworkConfig::deserialize_bootstore_config( + &rqctx.log, &config, + ) + .map_err(|e| { HttpError::for_internal_error(format!( "deserialize early network config: {e}" )) diff --git a/sled-agent/src/sled_agent.rs b/sled-agent/src/sled_agent.rs index 90e9706198..57aea61ae9 100644 --- a/sled-agent/src/sled_agent.rs +++ b/sled-agent/src/sled_agent.rs @@ -445,8 +445,11 @@ impl SledAgent { })?; let early_network_config = - EarlyNetworkConfig::try_from(serialized_config) - .map_err(|err| BackoffError::transient(err.to_string()))?; + EarlyNetworkConfig::deserialize_bootstore_config( + &log, + &serialized_config, + ) + .map_err(|err| BackoffError::transient(err.to_string()))?; Ok(early_network_config.body.rack_network_config) }; diff --git a/sled-agent/tests/data/early_network_blobs.txt b/sled-agent/tests/data/early_network_blobs.txt new file mode 100644 index 0000000000..c968d4010b --- /dev/null +++ b/sled-agent/tests/data/early_network_blobs.txt @@ -0,0 +1,2 @@ +2023-11-30 mupdate failing blob,{"generation":15,"schema_version":1,"body":{"ntp_servers":[],"rack_network_config":{"rack_subnet":"fd00:1122:3344:100::/56","infra_ip_first":"0.0.0.0","infra_ip_last":"0.0.0.0","ports":[{"routes":[],"addresses":[],"switch":"switch1","port":"qsfp0","uplink_port_speed":"speed100_g","uplink_port_fec":"none","bgp_peers":[]},{"routes":[],"addresses":["172.20.15.53/29"],"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":6,"delay_open":0,"connect_retry":3,"keepalive":2}]},{"routes":[],"addresses":["172.20.15.45/29"],"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":6,"delay_open":0,"connect_retry":3,"keepalive":2}]},{"routes":[],"addresses":[],"switch":"switch0","port":"qsfp0","uplink_port_speed":"speed100_g","uplink_port_fec":"none","bgp_peers":[]}],"bgp":[{"asn":65002,"originate":["172.20.26.0/24"]},{"asn":65002,"originate":["172.20.26.0/24"]}]}}} +2023-12-06 config,{"generation":20,"schema_version":1,"body":{"ntp_servers":["ntp.example.com"],"rack_network_config":{"rack_subnet":"ff01::/32","infra_ip_first":"127.0.0.1","infra_ip_last":"127.1.0.1","ports":[{"routes":[{"destination":"10.1.9.32/16","nexthop":"10.1.9.32"}],"addresses":["2001:db8::/96"],"switch":"switch0","port":"foo","uplink_port_speed":"speed200_g","uplink_port_fec":"firecode","bgp_peers":[{"asn":65000,"port":"bar","addr":"1.2.3.4","hold_time":20,"idle_hold_time":50,"delay_open":null,"connect_retry":30,"keepalive":10}],"autoneg":true}],"bgp":[{"asn":20000,"originate":["192.168.0.0/24"]}]}}} diff --git a/sled-agent/tests/integration_tests/early_network.rs b/sled-agent/tests/integration_tests/early_network.rs new file mode 100644 index 0000000000..719adb769a --- /dev/null +++ b/sled-agent/tests/integration_tests/early_network.rs @@ -0,0 +1,156 @@ +// 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/. + +//! Tests that EarlyNetworkConfig deserializes across versions. + +use std::net::Ipv4Addr; + +use bootstore::schemes::v0 as bootstore; +use omicron_common::api::{ + external::SwitchLocation, + internal::shared::{ + BgpConfig, BgpPeerConfig, PortConfigV1, PortFec, PortSpeed, + RackNetworkConfig, RouteConfig, + }, +}; +use omicron_sled_agent::bootstrap::early_networking::{ + EarlyNetworkConfig, EarlyNetworkConfigBody, +}; +use omicron_test_utils::dev::test_setup_log; + +/// Test that previous and current versions of `EarlyNetworkConfig` blobs +/// deserialize correctly. +#[test] +fn early_network_blobs_deserialize() { + let logctx = test_setup_log("early_network_blobs_deserialize"); + + let (current_desc, current_config) = current_config_example(); + assert!( + !current_desc.contains(',') && !current_desc.contains('\n'), + "current_desc must not contain commas or newlines" + ); + + // Read old blobs as newline-delimited JSON. + let mut known_blobs = + std::fs::read_to_string("tests/data/early_network_blobs.txt") + .expect("error reading early_network_blobs.txt"); + let mut current_blob_is_known = false; + for (blob_idx, line) in known_blobs.lines().enumerate() { + let blob_lineno = blob_idx + 1; + let (blob_desc, blob_json) = + line.split_once(',').unwrap_or_else(|| { + panic!( + "error parsing early_network_blobs.txt \ + line {blob_lineno}: missing comma", + ); + }); + + // Attempt to deserialize this blob. + let config = serde_json::from_str::(blob_json) + .unwrap_or_else(|error| { + panic!( + "error deserializing early_network_blobs.txt \ + \"{blob_desc}\" (line {blob_lineno}): {error}", + ); + }); + + // Does this config match the current config? + if blob_desc == current_desc { + assert_eq!( + config, current_config, + "early_network_blobs.txt line {}: {} does not match current config", + blob_lineno, blob_desc + ); + current_blob_is_known = true; + } + + // Now attempt to put this blob into a bootstore config, and deserialize that. + let network_config = bootstore::NetworkConfig { + generation: config.generation, + blob: blob_json.to_owned().into(), + }; + let config2 = EarlyNetworkConfig::deserialize_bootstore_config( + &logctx.log, + &network_config, + ).unwrap_or_else(|error| { + panic!( + "error deserializing early_network_blobs.txt \ + \"{blob_desc}\" (line {blob_lineno}) as bootstore config: {error}", + ); + }); + + assert_eq!( + config, config2, + "early_network_blobs.txt line {}: {} does not match deserialization \ + as bootstore config", + blob_lineno, blob_desc + ); + } + + // If the current blob was not covered, add it to the list of known blobs. + if !current_blob_is_known { + let current_blob_json = serde_json::to_string(¤t_config).unwrap(); + let current_blob = format!("{},{}", current_desc, current_blob_json); + known_blobs.push_str(¤t_blob); + known_blobs.push('\n'); + } + + expectorate::assert_contents( + "tests/data/early_network_blobs.txt", + &known_blobs, + ); + + logctx.cleanup_successful(); +} + +/// Returns a current version of the EarlyNetworkConfig blob, along with a +/// short description of the current version. The values can be arbitrary, but +/// this should be a nontrivial blob where no vectors are empty. +/// +/// The goal is that if the definition of `EarlyNetworkConfig` changes in the +/// future, older blobs can still be deserialized correctly. +fn current_config_example() -> (&'static str, EarlyNetworkConfig) { + // NOTE: the description must not contain commas or newlines. + let description = "2023-12-06 config"; + let config = EarlyNetworkConfig { + generation: 20, + schema_version: 1, + body: EarlyNetworkConfigBody { + ntp_servers: vec!["ntp.example.com".to_owned()], + rack_network_config: Some(RackNetworkConfig { + rack_subnet: "ff01::0/32".parse().unwrap(), + infra_ip_first: Ipv4Addr::new(127, 0, 0, 1), + infra_ip_last: Ipv4Addr::new(127, 1, 0, 1), + ports: vec![PortConfigV1 { + routes: vec![RouteConfig { + destination: "10.1.9.32/16".parse().unwrap(), + nexthop: "10.1.9.32".parse().unwrap(), + }], + addresses: vec!["2001:db8::/96".parse().unwrap()], + switch: SwitchLocation::Switch0, + port: "foo".to_owned(), + uplink_port_speed: PortSpeed::Speed200G, + uplink_port_fec: PortFec::Firecode, + bgp_peers: vec![BgpPeerConfig { + asn: 65000, + port: "bar".to_owned(), + addr: Ipv4Addr::new(1, 2, 3, 4), + hold_time: Some(20), + idle_hold_time: Some(50), + delay_open: None, + connect_retry: Some(30), + keepalive: Some(10), + }], + autoneg: true, + }], + bgp: vec![BgpConfig { + asn: 20000, + originate: vec!["192.168.0.0/24".parse().unwrap()], + }], + }), + }, + }; + + (description, config) +} diff --git a/sled-agent/tests/integration_tests/mod.rs b/sled-agent/tests/integration_tests/mod.rs index 1bf43dc00c..13e38077ea 100644 --- a/sled-agent/tests/integration_tests/mod.rs +++ b/sled-agent/tests/integration_tests/mod.rs @@ -3,3 +3,4 @@ // file, You can obtain one at https://mozilla.org/MPL/2.0/. mod commands; +mod early_network;