diff --git a/Cargo.lock b/Cargo.lock index 27da48f576b..b54bb161fdb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3816,8 +3816,10 @@ dependencies = [ "omicron-common", "omicron-uuid-kinds", "omicron-workspace-hack", + "proptest", "schemars", "serde", + "test-strategy", "thiserror 2.0.16", "tufaceous-artifact", "uuid", @@ -4835,6 +4837,7 @@ dependencies = [ "equivalent", "foldhash 0.2.0", "hashbrown 0.16.0", + "proptest", "ref-cast", "rustc-hash 2.1.1", "schemars", @@ -7076,11 +7079,13 @@ dependencies = [ "omicron-passwords", "omicron-uuid-kinds", "omicron-workspace-hack", + "proptest", "schemars", "serde", "serde_json", "sled-hardware-types", "strum 0.27.2", + "test-strategy", "thiserror 2.0.16", "tufaceous-artifact", "uuid", @@ -8632,6 +8637,7 @@ dependencies = [ "hyper", "hyper-rustls", "hyper-util", + "iddqd", "idna", "indexmap 2.11.0", "inout", @@ -8667,6 +8673,7 @@ dependencies = [ "ppv-lite86", "predicates", "proc-macro2", + "proptest", "rand 0.8.5", "rand 0.9.2", "rand_chacha 0.3.1", diff --git a/common/src/api/external/mod.rs b/common/src/api/external/mod.rs index 64b5d310b84..35ff366f11c 100644 --- a/common/src/api/external/mod.rs +++ b/common/src/api/external/mod.rs @@ -656,7 +656,12 @@ impl From for i64 { Diffable, )] #[daft(leaf)] -pub struct Generation(u64); +#[cfg_attr(any(test, feature = "testing"), derive(test_strategy::Arbitrary))] +pub struct Generation( + // Generations are restricted to 2**63 - 1 as documented above. + #[cfg_attr(any(test, feature = "testing"), strategy(0..=i64::MAX as u64))] + u64, +); impl Generation { // `as` is a little distasteful because it allows lossy conversion, but we diff --git a/common/src/disk.rs b/common/src/disk.rs index cd6c9e56105..22259104f8d 100644 --- a/common/src/disk.rs +++ b/common/src/disk.rs @@ -622,6 +622,7 @@ impl DiskManagementError { Diffable, strum::EnumIter, )] +#[cfg_attr(any(test, feature = "testing"), derive(test_strategy::Arbitrary))] pub enum M2Slot { A, B, diff --git a/gateway-types/Cargo.toml b/gateway-types/Cargo.toml index 28808732c5f..85d69de7459 100644 --- a/gateway-types/Cargo.toml +++ b/gateway-types/Cargo.toml @@ -18,8 +18,13 @@ hex.workspace = true omicron-common.workspace = true omicron-uuid-kinds.workspace = true omicron-workspace-hack.workspace = true +proptest = { workspace = true, optional = true } schemars.workspace = true serde.workspace = true +test-strategy = { workspace = true, optional = true } thiserror.workspace = true tufaceous-artifact.workspace = true uuid.workspace = true + +[features] +testing = ["dep:proptest", "dep:test-strategy"] diff --git a/gateway-types/src/component.rs b/gateway-types/src/component.rs index 473bde5eb1d..6e3a3e31301 100644 --- a/gateway-types/src/component.rs +++ b/gateway-types/src/component.rs @@ -28,6 +28,7 @@ use crate::rot::RotState; Diffable, )] #[serde(rename_all = "lowercase")] +#[cfg_attr(any(test, feature = "testing"), derive(test_strategy::Arbitrary))] pub enum SpType { Sled, Power, diff --git a/gateway-types/src/rot.rs b/gateway-types/src/rot.rs index 77b200b29ca..faa2b8b1e5d 100644 --- a/gateway-types/src/rot.rs +++ b/gateway-types/src/rot.rs @@ -180,6 +180,7 @@ impl From for RotState { JsonSchema, )] #[serde(tag = "slot", rename_all = "snake_case")] +#[cfg_attr(any(test, feature = "testing"), derive(test_strategy::Arbitrary))] pub enum RotSlot { A, B, diff --git a/nexus-sled-agent-shared/Cargo.toml b/nexus-sled-agent-shared/Cargo.toml index 92d488382be..666143546a6 100644 --- a/nexus-sled-agent-shared/Cargo.toml +++ b/nexus-sled-agent-shared/Cargo.toml @@ -19,11 +19,20 @@ omicron-passwords.workspace = true omicron-uuid-kinds.workspace = true omicron-workspace-hack.workspace = true # TODO: replace uses of propolis_client with local types +proptest = { workspace = true, optional = true } schemars.workspace = true serde.workspace = true serde_json.workspace = true sled-hardware-types.workspace = true strum.workspace = true +test-strategy = { workspace = true, optional = true } thiserror.workspace = true tufaceous-artifact.workspace = true uuid.workspace = true + +[dev-dependencies] +proptest.workspace = true +test-strategy.workspace = true + +[features] +testing = ["dep:proptest", "dep:test-strategy"] diff --git a/nexus-sled-agent-shared/src/inventory.rs b/nexus-sled-agent-shared/src/inventory.rs index ab6ff1ee139..b517d93b43c 100644 --- a/nexus-sled-agent-shared/src/inventory.rs +++ b/nexus-sled-agent-shared/src/inventory.rs @@ -1500,8 +1500,22 @@ fn default_nexus_lockstep_port() -> u16 { /// please add it here rather than doing something ad-hoc in the calling code /// so it's more legible. #[derive( - Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, EnumIter, + Debug, + Clone, + Copy, + PartialEq, + Eq, + Hash, + PartialOrd, + Ord, + Diffable, + EnumIter, + Deserialize, + Serialize, + JsonSchema, )] +#[serde(rename_all = "snake_case")] +#[cfg_attr(any(test, feature = "testing"), derive(test_strategy::Arbitrary))] pub enum ZoneKind { BoundaryNtp, Clickhouse, diff --git a/nexus/types/Cargo.toml b/nexus/types/Cargo.toml index 94c91c95c39..5eca41eddca 100644 --- a/nexus/types/Cargo.toml +++ b/nexus/types/Cargo.toml @@ -46,6 +46,7 @@ slog-error-chain.workspace = true steno.workspace = true strum.workspace = true tabled.workspace = true +test-strategy.workspace = true textwrap.workspace = true thiserror.workspace = true tokio.workspace = true @@ -71,6 +72,11 @@ tough.workspace = true # common to both, put them in `omicron-common` or `nexus-sled-agent-shared`. [dev-dependencies] +gateway-types = { workspace = true, features = ["testing"] } +iddqd = { workspace = true, features = ["proptest"] } +newtype-uuid = { workspace = true, features = ["proptest1"] } +nexus-sled-agent-shared = { workspace = true, features = ["testing"] } +omicron-common = { workspace = true, features = ["testing"] } omicron-test-utils.workspace = true proptest.workspace = true test-strategy.workspace = true diff --git a/nexus/types/src/deployment.rs b/nexus/types/src/deployment.rs index ef78e3fe9db..f435955bf3a 100644 --- a/nexus/types/src/deployment.rs +++ b/nexus/types/src/deployment.rs @@ -1267,6 +1267,7 @@ impl fmt::Display for BlueprintZoneDisposition { Diffable, )] #[serde(tag = "type", rename_all = "snake_case")] +#[cfg_attr(test, derive(test_strategy::Arbitrary))] pub enum BlueprintZoneImageSource { /// This zone's image source is whatever happens to be on the sled's /// "install" dataset. @@ -1343,6 +1344,7 @@ impl fmt::Display for BlueprintZoneImageSource { Diffable, )] #[serde(tag = "artifact_version", rename_all = "snake_case")] +#[cfg_attr(test, derive(test_strategy::Arbitrary))] pub enum BlueprintArtifactVersion { /// A specific version of the image is available. Available { version: ArtifactVersion }, @@ -1466,6 +1468,7 @@ impl fmt::Display for BlueprintHostPhase2DesiredContents { Copy, )] #[serde(rename_all = "snake_case")] +#[cfg_attr(test, derive(test_strategy::Arbitrary))] pub enum MgsUpdateComponent { Sp, Rot, @@ -1488,12 +1491,17 @@ impl Display for MgsUpdateComponent { #[derive( Clone, Debug, Eq, PartialEq, Deserialize, Serialize, JsonSchema, Diffable, )] +#[cfg_attr(test, derive(test_strategy::Arbitrary))] pub struct PendingMgsUpdates { // The IdOrdMap key is the baseboard_id. Only one outstanding MGS-managed // update is allowed for a given baseboard. // // Note that keys aren't strings so this can't be serialized as a JSON map, // but IdOrdMap serializes as an array. + // + // For proptest, make this map small to avoid bloating the size of generated + // test data. + #[cfg_attr(test, any(((0, 16).into(), Default::default())))] by_baseboard: IdOrdMap, } @@ -1562,6 +1570,7 @@ impl<'a> IntoIterator for &'a PendingMgsUpdates { #[derive( Clone, Debug, Eq, PartialEq, JsonSchema, Deserialize, Serialize, Diffable, )] +#[cfg_attr(test, derive(test_strategy::Arbitrary))] pub struct PendingMgsUpdate { // identity of the baseboard /// id of the baseboard that we're going to update @@ -1658,6 +1667,7 @@ impl PendingMgsUpdate { Clone, Debug, Eq, PartialEq, JsonSchema, Deserialize, Serialize, Diffable, )] #[serde(tag = "component", rename_all = "snake_case")] +#[cfg_attr(test, derive(test_strategy::Arbitrary))] pub enum PendingMgsUpdateDetails { /// the SP itself is being updated Sp(PendingMgsUpdateSpDetails), @@ -1720,6 +1730,7 @@ impl slog::KV for PendingMgsUpdateDetails { Clone, Debug, Eq, PartialEq, JsonSchema, Deserialize, Serialize, Diffable, )] #[serde(rename_all = "snake_case")] +#[cfg_attr(test, derive(test_strategy::Arbitrary))] pub struct PendingMgsUpdateSpDetails { // implicit: component = SP_ITSELF // implicit: firmware slot id = 0 (always 0 for SP itself) @@ -1752,6 +1763,7 @@ impl slog::KV for PendingMgsUpdateSpDetails { Clone, Debug, Eq, PartialEq, JsonSchema, Deserialize, Serialize, Diffable, )] #[serde(rename_all = "snake_case")] +#[cfg_attr(test, derive(test_strategy::Arbitrary))] pub struct PendingMgsUpdateRotDetails { // implicit: component = ROT // implicit: firmware slot id will be the inactive slot @@ -1823,6 +1835,7 @@ impl slog::KV for PendingMgsUpdateRotDetails { Clone, Debug, Eq, PartialEq, JsonSchema, Deserialize, Serialize, Diffable, )] #[serde(rename_all = "snake_case")] +#[cfg_attr(test, derive(test_strategy::Arbitrary))] pub struct PendingMgsUpdateRotBootloaderDetails { // implicit: component = STAGE0 // implicit: firmware slot id = 1 (always 1 (Stage0Next) for RoT bootloader) @@ -1859,6 +1872,7 @@ impl slog::KV for PendingMgsUpdateRotBootloaderDetails { Clone, Debug, Eq, PartialEq, JsonSchema, Deserialize, Serialize, Diffable, )] #[serde(rename_all = "snake_case")] +#[cfg_attr(test, derive(test_strategy::Arbitrary))] pub struct PendingMgsUpdateHostPhase1Details { /// Which slot is currently active according to the SP. /// @@ -1899,9 +1913,22 @@ pub struct PendingMgsUpdateHostPhase1Details { /// don't need to be able to represent an invalid inactive slot. pub expected_inactive_phase_2_hash: ArtifactHash, /// Address for contacting sled-agent to check phase 2 contents. + #[cfg_attr(test, strategy(socket_addr_v6_without_flowinfo()))] pub sled_agent_address: SocketAddrV6, } +// For proptest, we pass in flowinfo = 0, because flowinfo doesn't roundtrip +// through JSON. +#[cfg(test)] +fn socket_addr_v6_without_flowinfo() +-> impl proptest::strategy::Strategy { + use proptest::strategy::Strategy; + + proptest::arbitrary::any::<(Ipv6Addr, u16, u32)>().prop_map( + |(addr, port, scope_id)| SocketAddrV6::new(addr, port, 0, scope_id), + ) +} + impl slog::KV for PendingMgsUpdateHostPhase1Details { fn serialize( &self, @@ -1954,6 +1981,7 @@ impl slog::KV for PendingMgsUpdateHostPhase1Details { Clone, Debug, Eq, PartialEq, JsonSchema, Deserialize, Serialize, Diffable, )] #[serde(tag = "kind", content = "version", rename_all = "snake_case")] +#[cfg_attr(test, derive(test_strategy::Arbitrary))] pub enum ExpectedVersion { /// We expect to find _no_ valid caboose in this slot NoValidVersion, @@ -1986,6 +2014,7 @@ impl fmt::Display for ExpectedVersion { #[derive( Clone, Debug, Eq, PartialEq, JsonSchema, Deserialize, Serialize, Diffable, )] +#[cfg_attr(test, derive(test_strategy::Arbitrary))] pub struct ExpectedActiveRotSlot { pub slot: RotSlot, pub version: ArtifactVersion, diff --git a/nexus/types/src/deployment/planning_input.rs b/nexus/types/src/deployment/planning_input.rs index 6ed44e206e6..e216b60cf61 100644 --- a/nexus/types/src/deployment/planning_input.rs +++ b/nexus/types/src/deployment/planning_input.rs @@ -480,6 +480,7 @@ impl CockroachDbSettings { JsonSchema, Diffable, )] +#[cfg_attr(test, derive(test_strategy::Arbitrary))] pub enum CockroachDbClusterVersion { #[display("22.1")] V22_1, @@ -522,6 +523,7 @@ impl CockroachDbClusterVersion { Diffable, )] #[serde(tag = "action", content = "data", rename_all = "snake_case")] +#[cfg_attr(test, derive(test_strategy::Arbitrary))] pub enum CockroachDbPreserveDowngrade { /// Do not modify the setting. DoNotModify, diff --git a/nexus/types/src/deployment/planning_report.rs b/nexus/types/src/deployment/planning_report.rs index da55ad5934b..6cee384b3b1 100644 --- a/nexus/types/src/deployment/planning_report.rs +++ b/nexus/types/src/deployment/planning_report.rs @@ -18,6 +18,7 @@ use daft::Diffable; use iddqd::IdOrdItem; use iddqd::id_upcast; use indent_write::fmt::IndentWriter; +use nexus_sled_agent_shared::inventory::ZoneKind; use omicron_common::api::external::Generation; use omicron_common::disk::M2Slot; use omicron_common::policy::COCKROACHDB_REDUNDANCY; @@ -55,6 +56,7 @@ use thiserror::Error; #[derive( Clone, Debug, Deserialize, Serialize, PartialEq, Eq, Diffable, JsonSchema, )] +#[cfg_attr(test, derive(test_strategy::Arbitrary))] #[must_use = "an unread report is not actionable"] pub struct PlanningReport { /// The configuration in effect for this planning run. @@ -134,6 +136,7 @@ impl fmt::Display for PlanningReport { #[derive( Clone, Debug, Deserialize, Serialize, PartialEq, Eq, Diffable, JsonSchema, )] +#[cfg_attr(test, derive(test_strategy::Arbitrary))] pub struct PlanningExpungeStepReport { /// Expunged disks not present in the parent blueprint. pub orphan_disks: BTreeMap, @@ -169,6 +172,7 @@ impl fmt::Display for PlanningExpungeStepReport { #[derive( Clone, Debug, Deserialize, Serialize, PartialEq, Eq, Diffable, JsonSchema, )] +#[cfg_attr(test, derive(test_strategy::Arbitrary))] pub struct PlanningDecommissionStepReport { /// Decommissioned sleds that unexpectedly appeared as commissioned. pub zombie_sleds: Vec, @@ -208,6 +212,7 @@ impl fmt::Display for PlanningDecommissionStepReport { #[derive( Clone, Debug, Deserialize, Serialize, PartialEq, Eq, Diffable, JsonSchema, )] +#[cfg_attr(test, derive(test_strategy::Arbitrary))] pub struct PlanningNoopImageSourceConverted { pub num_eligible: usize, pub num_dataset: usize, @@ -218,14 +223,20 @@ pub struct PlanningNoopImageSourceConverted { #[derive( Clone, Debug, Deserialize, Serialize, PartialEq, Eq, Diffable, JsonSchema, )] +#[cfg_attr(test, derive(test_strategy::Arbitrary))] pub struct PlanningNoopImageSourceStepReport { pub no_target_release: bool, + // Make these maps small to avoid bloating the size of generated test data. + #[cfg_attr(test, any(((0, 16).into(), Default::default(), Default::default())))] pub skipped_sled_zones: BTreeMap, + #[cfg_attr(test, any(((0, 16).into(), Default::default(), Default::default())))] pub skipped_sled_host_phase_2: BTreeMap, + #[cfg_attr(test, any(((0, 16).into(), Default::default(), Default::default())))] pub skipped_zones: BTreeMap, + #[cfg_attr(test, any(((0, 16).into(), Default::default(), Default::default())))] pub converted: BTreeMap, } @@ -360,6 +371,7 @@ impl fmt::Display for PlanningNoopImageSourceStepReport { #[derive( Clone, Debug, Deserialize, Serialize, PartialEq, Eq, Diffable, JsonSchema, )] +#[cfg_attr(test, derive(test_strategy::Arbitrary))] #[serde(rename_all = "snake_case", tag = "type")] pub enum PlanningNoopImageSourceSkipSledZonesReason { AllZonesAlreadyArtifact { num_total: usize }, @@ -398,6 +410,7 @@ impl fmt::Display for PlanningNoopImageSourceSkipSledZonesReason { Clone, Debug, Deserialize, Serialize, PartialEq, Eq, Diffable, JsonSchema, )] #[serde(rename_all = "snake_case", tag = "type")] +#[cfg_attr(test, derive(test_strategy::Arbitrary))] pub enum PlanningNoopImageSourceSkipSledHostPhase2Reason { BothSlotsAlreadyArtifact, SledNotInInventory, @@ -420,6 +433,7 @@ impl fmt::Display for PlanningNoopImageSourceSkipSledHostPhase2Reason { Clone, Debug, Deserialize, Serialize, PartialEq, Eq, Diffable, JsonSchema, )] #[serde(rename_all = "snake_case", tag = "type")] +#[cfg_attr(test, derive(test_strategy::Arbitrary))] pub enum PlanningNoopImageSourceSkipZoneReason { ZoneNotInManifest { zone_kind: String, @@ -486,6 +500,7 @@ impl PlanningMupdateOverrideStepReport { )] #[serde(rename_all = "snake_case")] #[serde(tag = "type", content = "value")] +#[cfg_attr(test, derive(test_strategy::Arbitrary))] // TODO-K: Separate into enums for each component as suggested in // https://github.com/oxidecomputer/omicron/pull/9001#discussion_r2372863166 // and including more detailed information as suggested in @@ -543,6 +558,7 @@ pub enum FailedMgsUpdateReason { #[derive( Clone, Debug, Deserialize, Serialize, PartialEq, Eq, Diffable, JsonSchema, )] +#[cfg_attr(test, derive(test_strategy::Arbitrary))] pub struct BlockedMgsUpdate { /// id of the baseboard that we attempted to update pub baseboard_id: Arc, @@ -563,6 +579,7 @@ impl IdOrdItem for BlockedMgsUpdate { #[derive( Clone, Debug, Deserialize, Serialize, PartialEq, Eq, Diffable, JsonSchema, )] +#[cfg_attr(test, derive(test_strategy::Arbitrary))] pub struct PlanningMgsUpdatesStepReport { pub pending_mgs_updates: PendingMgsUpdates, pub blocked_mgs_updates: Vec, @@ -625,6 +642,7 @@ impl fmt::Display for PlanningMgsUpdatesStepReport { #[derive( Clone, Debug, Deserialize, Serialize, PartialEq, Eq, Diffable, JsonSchema, )] +#[cfg_attr(test, derive(test_strategy::Arbitrary))] pub struct PlanningAddOutOfEligibleSleds { pub placed: usize, pub wanted_to_place: usize, @@ -634,6 +652,7 @@ pub struct PlanningAddOutOfEligibleSleds { #[derive( Clone, Debug, Deserialize, Serialize, PartialEq, Eq, Diffable, JsonSchema, )] +#[cfg_attr(test, derive(test_strategy::Arbitrary))] pub struct PlanningAddSufficientZonesExist { pub target_count: usize, pub num_existing: usize, @@ -642,6 +661,7 @@ pub struct PlanningAddSufficientZonesExist { #[derive( Clone, Debug, Deserialize, Serialize, PartialEq, Eq, Diffable, JsonSchema, )] +#[cfg_attr(test, derive(test_strategy::Arbitrary))] pub struct DiscretionaryZonePlacement { kind: String, source: String, @@ -651,6 +671,7 @@ pub struct DiscretionaryZonePlacement { Clone, Debug, Deserialize, Serialize, PartialEq, Eq, Diffable, JsonSchema, )] #[serde(rename_all = "snake_case", tag = "type")] +#[cfg_attr(test, derive(test_strategy::Arbitrary))] pub enum ZoneAddWaitingOn { /// Waiting on one or more blockers (typically MUPdate-related reasons) to /// clear. @@ -668,6 +689,7 @@ impl ZoneAddWaitingOn { #[derive( Clone, Debug, Deserialize, Serialize, PartialEq, Eq, Diffable, JsonSchema, )] +#[cfg_attr(test, derive(test_strategy::Arbitrary))] pub struct PlanningAddStepReport { /// What are we waiting on to start zone additions? pub waiting_on: Option, @@ -675,6 +697,7 @@ pub struct PlanningAddStepReport { /// Reasons why zone adds and any updates are blocked. /// /// This is typically a list of MUPdate-related reasons. + #[cfg_attr(test, any(((0, 16).into(), Default::default())))] pub add_update_blocked_reasons: Vec, /// The value of the homonymous planner config. (What this really means is @@ -686,23 +709,48 @@ pub struct PlanningAddStepReport { /// zones to be added. pub target_release_generation_is_one: bool, + // Make these sets and maps small to avoid bloating the size of generated + // test data. + #[cfg_attr(test, any(((0, 16).into(), Default::default())))] pub sleds_without_ntp_zones_in_inventory: BTreeSet, + #[cfg_attr(test, any(((0, 16).into(), Default::default())))] pub sleds_without_zpools_for_ntp_zones: BTreeSet, + #[cfg_attr(test, any(((0, 16).into(), Default::default())))] pub sleds_waiting_for_ntp_zone: BTreeSet, + #[cfg_attr(test, any(((0, 16).into(), Default::default())))] pub sleds_getting_ntp_and_discretionary_zones: BTreeSet, + #[cfg_attr(test, any(((0, 16).into(), Default::default())))] pub sleds_missing_ntp_zone: BTreeSet, + #[cfg_attr( + test, + any(( + (0, 16).into(), + Default::default(), + ((0, 16).into(), Default::default()) + )) + )] pub sleds_missing_crucible_zone: BTreeMap>, /// Discretionary zone kind → (placed, wanted to place) + #[cfg_attr(test, any(((0, 16).into(), Default::default(), Default::default())))] pub out_of_eligible_sleds: BTreeMap, /// Discretionary zone kind → (wanted to place, num existing) + #[cfg_attr(test, any(((0, 16).into(), Default::default(), Default::default())))] pub sufficient_zones_exist: BTreeMap, /// Sled ID → kinds of discretionary zones placed there // TODO: make `sled_add_zone_*` methods return the added zone config // so that we can report it here. + #[cfg_attr( + test, + any(( + (0, 16).into(), + Default::default(), + ((0, 16).into(), Default::default()) + )) + )] pub discretionary_zones_placed: BTreeMap>, } @@ -952,22 +1000,51 @@ impl fmt::Display for PlanningAddStepReport { #[derive( Clone, Debug, Deserialize, Serialize, PartialEq, Eq, Diffable, JsonSchema, )] +#[cfg_attr(test, derive(test_strategy::Arbitrary))] pub struct PlanningOutOfDateZone { - pub zone_config: BlueprintZoneConfig, + pub zone: PlanningReportBlueprintZone, pub desired_image_source: BlueprintZoneImageSource, } #[derive( Clone, Debug, Deserialize, Serialize, PartialEq, Eq, Diffable, JsonSchema, )] +#[cfg_attr(test, derive(test_strategy::Arbitrary))] pub struct PlanningZoneUpdatesStepReport { /// What are we waiting on to start zone updates? pub waiting_on: Option, + // Make these maps small to avoid bloating the size of generated test data. + #[cfg_attr( + test, + any(( + (0, 16).into(), + Default::default(), + ((0, 16).into(), Default::default()) + )) + )] pub out_of_date_zones: BTreeMap>, - pub expunged_zones: BTreeMap>, - pub updated_zones: BTreeMap>, + #[cfg_attr( + test, + any(( + (0, 16).into(), + Default::default(), + ((0, 16).into(), Default::default()) + )) + )] + pub expunged_zones: BTreeMap>, + #[cfg_attr( + test, + any(( + (0, 16).into(), + Default::default(), + ((0, 16).into(), Default::default()) + )) + )] + pub updated_zones: BTreeMap>, + #[cfg_attr(test, any(((0, 16).into(), Default::default(), Default::default())))] pub unsafe_zones: BTreeMap, + #[cfg_attr(test, any(((0, 16).into(), Default::default(), Default::default())))] pub waiting_zones: BTreeMap, } @@ -1005,7 +1082,7 @@ impl PlanningZoneUpdatesStepReport { desired_image_source: BlueprintZoneImageSource, ) { let out_of_date = PlanningOutOfDateZone { - zone_config: zone_config.to_owned(), + zone: PlanningReportBlueprintZone::new(zone_config), desired_image_source, }; self.out_of_date_zones @@ -1021,13 +1098,17 @@ impl PlanningZoneUpdatesStepReport { ) { self.expunged_zones .entry(sled_id) - .and_modify(|zones| zones.push(zone_config.to_owned())) - .or_insert_with(|| vec![zone_config.to_owned()]); + .and_modify(|zones| { + zones.push(PlanningReportBlueprintZone::new(zone_config)) + }) + .or_insert_with(|| { + vec![PlanningReportBlueprintZone::new(zone_config)] + }); // We check for out-of-date zones before expunging zones. If we just // expunged this zone, it's no longer out of date. if let Some(out_of_date) = self.out_of_date_zones.get_mut(&sled_id) { - out_of_date.retain(|z| z.zone_config.id != zone_config.id); + out_of_date.retain(|z| z.zone.id != zone_config.id); } } @@ -1038,13 +1119,17 @@ impl PlanningZoneUpdatesStepReport { ) { self.updated_zones .entry(sled_id) - .and_modify(|zones| zones.push(zone_config.to_owned())) - .or_insert_with(|| vec![zone_config.to_owned()]); + .and_modify(|zones| { + zones.push(PlanningReportBlueprintZone::new(zone_config)) + }) + .or_insert_with(|| { + vec![PlanningReportBlueprintZone::new(zone_config)] + }); // We check for out-of-date zones before updating zones. If we just // updated this zone, it's no longer out of date. if let Some(out_of_date) = self.out_of_date_zones.get_mut(&sled_id) { - out_of_date.retain(|z| z.zone_config.id != zone_config.id); + out_of_date.retain(|z| z.zone.id != zone_config.id); } } @@ -1090,7 +1175,7 @@ impl fmt::Display for PlanningZoneUpdatesStepReport { " * sled {}, zone {} ({})", sled_id, zone.id, - zone.zone_type.kind().report_str(), + zone.kind.report_str(), )?; } } @@ -1106,7 +1191,7 @@ impl fmt::Display for PlanningZoneUpdatesStepReport { " * sled {}, zone {} ({})", sled_id, zone.id, - zone.zone_type.kind().report_str(), + zone.kind.report_str(), )?; } } @@ -1137,10 +1222,27 @@ impl fmt::Display for PlanningZoneUpdatesStepReport { } } +/// Reduced form of a `BlueprintZoneConfig` stored in a [`PlanningReport`]. +#[derive( + Clone, Debug, Deserialize, Serialize, PartialEq, Eq, Diffable, JsonSchema, +)] +#[cfg_attr(test, derive(test_strategy::Arbitrary))] +pub struct PlanningReportBlueprintZone { + pub id: OmicronZoneUuid, + pub kind: ZoneKind, +} + +impl PlanningReportBlueprintZone { + pub fn new(zone: &BlueprintZoneConfig) -> Self { + Self { id: zone.id, kind: zone.zone_type.kind() } + } +} + #[derive( Clone, Debug, Deserialize, Serialize, PartialEq, Eq, Diffable, JsonSchema, )] #[serde(rename_all = "snake_case", tag = "type")] +#[cfg_attr(test, derive(test_strategy::Arbitrary))] pub enum ZoneUpdatesWaitingOn { /// Waiting on blocked updates to RoT bootloader / RoT / SP / Host OS. BlockedMgsUpdates, @@ -1180,6 +1282,7 @@ impl ZoneUpdatesWaitingOn { Clone, Debug, Deserialize, Serialize, PartialEq, Eq, Diffable, JsonSchema, )] #[serde(rename_all = "snake_case", tag = "type")] +#[cfg_attr(test, derive(test_strategy::Arbitrary))] pub enum ZoneUnsafeToShutdown { Cockroachdb { reason: CockroachdbUnsafeToShutdown }, BoundaryNtp { total_boundary_ntp_zones: usize, synchronized_count: usize }, @@ -1212,6 +1315,7 @@ impl fmt::Display for ZoneUnsafeToShutdown { Clone, Debug, Deserialize, Serialize, PartialEq, Eq, Diffable, JsonSchema, )] #[serde(rename_all = "snake_case", tag = "type")] +#[cfg_attr(test, derive(test_strategy::Arbitrary))] pub enum ZoneWaitingToExpunge { Nexus { zone_generation: Generation }, } @@ -1234,6 +1338,7 @@ impl fmt::Display for ZoneWaitingToExpunge { Clone, Debug, Deserialize, Serialize, PartialEq, Eq, Diffable, JsonSchema, )] #[serde(tag = "component", rename_all = "snake_case", content = "value")] +#[cfg_attr(test, derive(test_strategy::Arbitrary))] pub enum PlanningNexusGenerationBumpReport { /// We have no reason to bump the Nexus generation number. NothingToReport, @@ -1288,6 +1393,7 @@ impl fmt::Display for PlanningNexusGenerationBumpReport { Clone, Debug, Deserialize, Serialize, PartialEq, Eq, Diffable, JsonSchema, )] #[serde(rename_all = "snake_case", tag = "type")] +#[cfg_attr(test, derive(test_strategy::Arbitrary))] pub enum NexusGenerationBumpWaitingOn { /// Waiting for the planner to finish updating all non-Nexus zones FoundOldNonNexusZones, @@ -1326,6 +1432,7 @@ impl NexusGenerationBumpWaitingOn { Clone, Debug, Deserialize, Serialize, PartialEq, Eq, Diffable, JsonSchema, )] #[serde(rename_all = "snake_case", tag = "type")] +#[cfg_attr(test, derive(test_strategy::Arbitrary))] pub enum CockroachdbUnsafeToShutdown { MissingLiveNodesStat, MissingUnderreplicatedStat, @@ -1365,6 +1472,7 @@ impl fmt::Display for CockroachdbUnsafeToShutdown { #[derive( Clone, Debug, Deserialize, Serialize, PartialEq, Eq, Diffable, JsonSchema, )] +#[cfg_attr(test, derive(test_strategy::Arbitrary))] pub struct PlanningCockroachdbSettingsStepReport { pub preserve_downgrade: CockroachDbPreserveDowngrade, } @@ -1411,3 +1519,23 @@ fn plural_map_of_vec(map: &BTreeMap>) -> (usize, &'static str) { let n = map.values().map(|v| v.len()).sum(); (n, plural(n)) } + +#[cfg(test)] +mod tests { + use super::*; + + use proptest::prelude::*; + use test_strategy::proptest; + + // Test that planning reports can be serialized and deserialized. + #[proptest] + fn planning_report_json_roundtrip(planning_report: PlanningReport) { + let json = serde_json::to_string(&planning_report).unwrap(); + let deserialized: PlanningReport = serde_json::from_str(&json).unwrap(); + prop_assert_eq!( + planning_report, + deserialized, + "input and output are equal" + ); + } +} diff --git a/nexus/types/src/deployment/reconfigurator_config.rs b/nexus/types/src/deployment/reconfigurator_config.rs index 8a8feadb4d1..1e1441b9587 100644 --- a/nexus/types/src/deployment/reconfigurator_config.rs +++ b/nexus/types/src/deployment/reconfigurator_config.rs @@ -193,6 +193,7 @@ impl fmt::Display for ReconfiguratorConfigDiffDisplay<'_, '_> { Deserialize, JsonSchema, )] +#[cfg_attr(test, derive(test_strategy::Arbitrary))] pub struct PlannerConfig { /// Whether to add zones even if a mupdate override is present. /// diff --git a/nexus/types/src/inventory.rs b/nexus/types/src/inventory.rs index 93273de5af8..a6122a2ee55 100644 --- a/nexus/types/src/inventory.rs +++ b/nexus/types/src/inventory.rs @@ -318,6 +318,7 @@ impl Collection { Serialize, JsonSchema, )] +#[cfg_attr(test, derive(test_strategy::Arbitrary))] pub struct BaseboardId { /// Oxide Part Number pub part_number: String, @@ -472,6 +473,7 @@ pub struct HostPhase1FlashHash { JsonSchema, )] #[serde(rename_all = "snake_case")] +#[cfg_attr(test, derive(test_strategy::Arbitrary))] pub enum CabooseWhich { SpSlot0, SpSlot1, diff --git a/openapi/nexus-internal.json b/openapi/nexus-internal.json index f7135cc66c8..94274e26b9e 100644 --- a/openapi/nexus-internal.json +++ b/openapi/nexus-internal.json @@ -5042,13 +5042,29 @@ "desired_image_source": { "$ref": "#/components/schemas/BlueprintZoneImageSource" }, - "zone_config": { - "$ref": "#/components/schemas/BlueprintZoneConfig" + "zone": { + "$ref": "#/components/schemas/PlanningReportBlueprintZone" } }, "required": [ "desired_image_source", - "zone_config" + "zone" + ] + }, + "PlanningReportBlueprintZone": { + "description": "Reduced form of a `BlueprintZoneConfig` stored in a [`PlanningReport`].", + "type": "object", + "properties": { + "id": { + "$ref": "#/components/schemas/TypedUuidForOmicronZoneKind" + }, + "kind": { + "$ref": "#/components/schemas/ZoneKind" + } + }, + "required": [ + "id", + "kind" ] }, "PlanningZoneUpdatesStepReport": { @@ -5059,7 +5075,7 @@ "additionalProperties": { "type": "array", "items": { - "$ref": "#/components/schemas/BlueprintZoneConfig" + "$ref": "#/components/schemas/PlanningReportBlueprintZone" } } }, @@ -5083,7 +5099,7 @@ "additionalProperties": { "type": "array", "items": { - "$ref": "#/components/schemas/BlueprintZoneConfig" + "$ref": "#/components/schemas/PlanningReportBlueprintZone" } } }, @@ -6261,6 +6277,24 @@ } ] }, + "ZoneKind": { + "description": "Like [`OmicronZoneType`], but without any associated data.\n\nThis enum is meant to correspond exactly 1:1 with `OmicronZoneType`.\n\n# String representations of this type\n\nThere are no fewer than six string representations for this type, all slightly different from each other.\n\n1. [`Self::zone_prefix`]: Used to construct zone names. 2. [`Self::service_prefix`]: Used to construct SMF service names. 3. [`Self::name_prefix`]: Used to construct `Name` instances. 4. [`Self::report_str`]: Used for reporting and testing. 5. [`Self::artifact_id_name`]: Used to match TUF artifact IDs. 6. [`Self::artifact_in_install_dataset`]: Used to match zone image tarballs in the install dataset. (This method is equivalent to appending `.tar.gz` to the result of [`Self::zone_prefix`].)\n\nThere is no `Display` impl to ensure that users explicitly choose the representation they want. (Please play close attention to this! The functions are all similar but different, and we don't currently have great type safety around the choice.)\n\n## Adding new representations\n\nIf you have a new use case for a string representation, please reuse one of the six representations if at all possible. If you must add a new one, please add it here rather than doing something ad-hoc in the calling code so it's more legible.", + "type": "string", + "enum": [ + "boundary_ntp", + "clickhouse", + "clickhouse_keeper", + "clickhouse_server", + "cockroach_db", + "crucible", + "crucible_pantry", + "external_dns", + "internal_dns", + "internal_ntp", + "nexus", + "oximeter" + ] + }, "ZoneUnsafeToShutdown": { "description": "Zones which should not be shut down, because their lack of availability could be problematic for the successful functioning of the deployed system.", "oneOf": [ diff --git a/openapi/nexus-lockstep.json b/openapi/nexus-lockstep.json index 2ed58cbf268..77b5c3d62c1 100644 --- a/openapi/nexus-lockstep.json +++ b/openapi/nexus-lockstep.json @@ -5975,13 +5975,29 @@ "desired_image_source": { "$ref": "#/components/schemas/BlueprintZoneImageSource" }, - "zone_config": { - "$ref": "#/components/schemas/BlueprintZoneConfig" + "zone": { + "$ref": "#/components/schemas/PlanningReportBlueprintZone" } }, "required": [ "desired_image_source", - "zone_config" + "zone" + ] + }, + "PlanningReportBlueprintZone": { + "description": "Reduced form of a `BlueprintZoneConfig` stored in a [`PlanningReport`].", + "type": "object", + "properties": { + "id": { + "$ref": "#/components/schemas/TypedUuidForOmicronZoneKind" + }, + "kind": { + "$ref": "#/components/schemas/ZoneKind" + } + }, + "required": [ + "id", + "kind" ] }, "PlanningZoneUpdatesStepReport": { @@ -5992,7 +6008,7 @@ "additionalProperties": { "type": "array", "items": { - "$ref": "#/components/schemas/BlueprintZoneConfig" + "$ref": "#/components/schemas/PlanningReportBlueprintZone" } } }, @@ -6016,7 +6032,7 @@ "additionalProperties": { "type": "array", "items": { - "$ref": "#/components/schemas/BlueprintZoneConfig" + "$ref": "#/components/schemas/PlanningReportBlueprintZone" } } }, @@ -7316,6 +7332,24 @@ } ] }, + "ZoneKind": { + "description": "Like [`OmicronZoneType`], but without any associated data.\n\nThis enum is meant to correspond exactly 1:1 with `OmicronZoneType`.\n\n# String representations of this type\n\nThere are no fewer than six string representations for this type, all slightly different from each other.\n\n1. [`Self::zone_prefix`]: Used to construct zone names. 2. [`Self::service_prefix`]: Used to construct SMF service names. 3. [`Self::name_prefix`]: Used to construct `Name` instances. 4. [`Self::report_str`]: Used for reporting and testing. 5. [`Self::artifact_id_name`]: Used to match TUF artifact IDs. 6. [`Self::artifact_in_install_dataset`]: Used to match zone image tarballs in the install dataset. (This method is equivalent to appending `.tar.gz` to the result of [`Self::zone_prefix`].)\n\nThere is no `Display` impl to ensure that users explicitly choose the representation they want. (Please play close attention to this! The functions are all similar but different, and we don't currently have great type safety around the choice.)\n\n## Adding new representations\n\nIf you have a new use case for a string representation, please reuse one of the six representations if at all possible. If you must add a new one, please add it here rather than doing something ad-hoc in the calling code so it's more legible.", + "type": "string", + "enum": [ + "boundary_ntp", + "clickhouse", + "clickhouse_keeper", + "clickhouse_server", + "cockroach_db", + "crucible", + "crucible_pantry", + "external_dns", + "internal_dns", + "internal_ntp", + "nexus", + "oximeter" + ] + }, "ZoneStatus": { "type": "object", "properties": { diff --git a/workspace-hack/Cargo.toml b/workspace-hack/Cargo.toml index d7a9ea41251..208be9b0c10 100644 --- a/workspace-hack/Cargo.toml +++ b/workspace-hack/Cargo.toml @@ -67,6 +67,7 @@ hashbrown = { version = "0.15.4" } hickory-proto = { version = "0.25.2", features = ["serde", "text-parsing"] } hmac = { version = "0.12.1", default-features = false, features = ["reset"] } hyper = { version = "1.7.0", features = ["full"] } +iddqd = { version = "0.3.13", features = ["daft", "proptest", "schemars08"] } idna = { version = "1.0.3" } indexmap = { version = "2.11.0", features = ["serde"] } inout = { version = "0.1.3", default-features = false, features = ["std"] } @@ -79,7 +80,7 @@ libc = { version = "0.2.174", features = ["extra_traits"] } log = { version = "0.4.27", default-features = false, features = ["kv_unstable", "std"] } managed = { version = "0.8.0", default-features = false, features = ["alloc", "map"] } memchr = { version = "2.7.4" } -newtype-uuid = { version = "1.2.4" } +newtype-uuid = { version = "1.2.4", features = ["proptest1"] } nix = { version = "0.29.0", features = ["feature", "net", "uio"] } num-bigint-dig = { version = "0.8.4", default-features = false, features = ["i128", "prime", "serde", "u64_digit", "zeroize"] } num-integer = { version = "0.1.46", features = ["i128"] } @@ -98,6 +99,7 @@ postgres-types = { version = "0.2.9", default-features = false, features = ["wit ppv-lite86 = { version = "0.2.20", default-features = false, features = ["simd", "std"] } predicates = { version = "3.1.3" } proc-macro2 = { version = "1.0.101" } +proptest = { version = "1.7.0" } rand-274715c4dabd11b0 = { package = "rand", version = "0.9.2" } rand-c38e5c1d305a1b54 = { package = "rand", version = "0.8.5" } rand_chacha-274715c4dabd11b0 = { package = "rand_chacha", version = "0.9.0", default-features = false, features = ["std"] } @@ -202,6 +204,7 @@ hashbrown = { version = "0.15.4" } hickory-proto = { version = "0.25.2", features = ["serde", "text-parsing"] } hmac = { version = "0.12.1", default-features = false, features = ["reset"] } hyper = { version = "1.7.0", features = ["full"] } +iddqd = { version = "0.3.13", features = ["daft", "proptest", "schemars08"] } idna = { version = "1.0.3" } indexmap = { version = "2.11.0", features = ["serde"] } inout = { version = "0.1.3", default-features = false, features = ["std"] } @@ -214,7 +217,7 @@ libc = { version = "0.2.174", features = ["extra_traits"] } log = { version = "0.4.27", default-features = false, features = ["kv_unstable", "std"] } managed = { version = "0.8.0", default-features = false, features = ["alloc", "map"] } memchr = { version = "2.7.4" } -newtype-uuid = { version = "1.2.4" } +newtype-uuid = { version = "1.2.4", features = ["proptest1"] } nix = { version = "0.29.0", features = ["feature", "net", "uio"] } num-bigint-dig = { version = "0.8.4", default-features = false, features = ["i128", "prime", "serde", "u64_digit", "zeroize"] } num-integer = { version = "0.1.46", features = ["i128"] } @@ -233,6 +236,7 @@ postgres-types = { version = "0.2.9", default-features = false, features = ["wit ppv-lite86 = { version = "0.2.20", default-features = false, features = ["simd", "std"] } predicates = { version = "3.1.3" } proc-macro2 = { version = "1.0.101" } +proptest = { version = "1.7.0" } rand-274715c4dabd11b0 = { package = "rand", version = "0.9.2" } rand-c38e5c1d305a1b54 = { package = "rand", version = "0.8.5" } rand_chacha-274715c4dabd11b0 = { package = "rand_chacha", version = "0.9.0", default-features = false, features = ["std"] }