diff --git a/bin/propolis-server/src/lib/initializer.rs b/bin/propolis-server/src/lib/initializer.rs index 0da580375..217592f8c 100644 --- a/bin/propolis-server/src/lib/initializer.rs +++ b/bin/propolis-server/src/lib/initializer.rs @@ -1256,6 +1256,15 @@ impl MachineInitializer<'_> { smbios.serial_number.try_into().unwrap_or_default(); smb_type1.version = smbios.version.to_string().try_into().unwrap_or_default(); + // Unset SKU number and family leave the table's default (empty) + // strings so the emitted bytes match pre-v7 behavior. + if let Some(sku_number) = smbios.sku_number { + smb_type1.sku_number = + sku_number.try_into().unwrap_or_default(); + } + if let Some(smb_family) = smbios.family { + smb_type1.family = smb_family.try_into().unwrap_or_default(); + } } else { smb_type1.manufacturer = "Oxide".try_into().unwrap(); smb_type1.product_name = "OxVM".try_into().unwrap(); diff --git a/bin/propolis-server/src/lib/migrate/types.rs b/bin/propolis-server/src/lib/migrate/types.rs index d88e24b15..316366e01 100644 --- a/bin/propolis-server/src/lib/migrate/types.rs +++ b/bin/propolis-server/src/lib/migrate/types.rs @@ -89,12 +89,14 @@ use serde::{Deserialize, Serialize}; use propolis_api_types_versions::v1::instance::ReplacementComponent; -use propolis_api_types_versions::{v1, v2, v3, v6}; +use propolis_api_types_versions::{v1, v2, v3, v6, v7}; use std::collections::BTreeMap; use crate::migrate::MigrateError; -use crate::spec::{api_spec_v1, api_spec_v2, api_spec_v3, api_spec_v6, Spec}; +use crate::spec::{ + api_spec_v1, api_spec_v2, api_spec_v3, api_spec_v6, api_spec_v7, Spec, +}; /// A wrapper for one of any supported `InstanceSpec` that describe a /// to-be-migrated VM. @@ -113,6 +115,7 @@ pub(crate) enum VersionedInstanceSpec { V2(v2::instance_spec::InstanceSpec), V3(v3::instance_spec::InstanceSpec), V6(v6::instance_spec::InstanceSpec), + V7(v7::instance_spec::InstanceSpec), } impl VersionedInstanceSpec { @@ -142,9 +145,13 @@ impl VersionedInstanceSpec { TryInto::::try_into(spec.clone()) { VersionedInstanceSpec::V3(v3_spec) + } else if let Ok(v6_spec) = + TryInto::::try_into(spec.clone()) + { + VersionedInstanceSpec::V6(v6_spec) } else { - VersionedInstanceSpec::V6( - Into::::into(spec.clone()), + VersionedInstanceSpec::V7( + Into::::into(spec.clone()), ) }; Ok(versioned) @@ -186,6 +193,13 @@ impl VersionedInstanceSpec { .map_err(|e| MigrateError::PreambleParse(e.to_string()))? .finish() } + VersionedInstanceSpec::V7(mut source_spec) => { + api_spec_v7::amend(&mut source_spec, replacements)?; + + api_spec_v7::v7_to_spec_builder(source_spec) + .map_err(|e| MigrateError::PreambleParse(e.to_string()))? + .finish() + } }; Ok(amended_spec) diff --git a/bin/propolis-server/src/lib/spec/api_spec_latest.rs b/bin/propolis-server/src/lib/spec/api_spec_latest.rs index 2af35d8a3..a9d4b1bdd 100644 --- a/bin/propolis-server/src/lib/spec/api_spec_latest.rs +++ b/bin/propolis-server/src/lib/spec/api_spec_latest.rs @@ -255,6 +255,8 @@ mod test { product_name: "913-0000019".to_string(), serial_number: "2FAKE000".to_string(), version: 2, + sku_number: Some("913-0000019".to_string()), + family: Some("Gimlet".to_string()), }), }; @@ -265,5 +267,43 @@ mod test { assert_eq!(smbios.product_name, "913-0000019"); assert_eq!(smbios.serial_number, "2FAKE000"); assert_eq!(smbios.version, 2); + assert_eq!(smbios.sku_number.as_deref(), Some("913-0000019")); + assert_eq!(smbios.family.as_deref(), Some("Gimlet")); + } + + // Empty SKU and family strings normalize to unset at ingress, so a + // pre-v7 view of such a spec stays expressible. + #[test] + fn empty_smbios_strings_normalize_to_unset() { + let api_spec = latest::instance_spec::InstanceSpec { + board: Board { + cpus: 4, + memory_mb: 512, + chipset: Chipset::I440Fx(I440Fx { enable_pcie: false }), + guest_hv_interface: GuestHypervisorInterface::Bhyve, + // Explicit values keep the builder from querying bhyve for + // its default guest CPUID set, which needs VMM device access + // the test runner may lack. + cpuid: Some(Cpuid { + entries: vec![], + vendor: CpuidVendor::Amd, + }), + }, + components: Default::default(), + smbios: Some(SmbiosType1Input { + manufacturer: "a4x2".to_string(), + product_name: "913-0000019".to_string(), + serial_number: "2FAKE000".to_string(), + version: 2, + sku_number: Some(String::new()), + family: Some(String::new()), + }), + }; + + let spec = latest_to_spec_builder(api_spec).unwrap().finish(); + let smbios = + spec.smbios_type1_input.expect("SMBIOS type 1 input preserved"); + assert_eq!(smbios.sku_number, None); + assert_eq!(smbios.family, None); } } diff --git a/bin/propolis-server/src/lib/spec/api_spec_v2.rs b/bin/propolis-server/src/lib/spec/api_spec_v2.rs index d7f764177..b0a16ee3c 100644 --- a/bin/propolis-server/src/lib/spec/api_spec_v2.rs +++ b/bin/propolis-server/src/lib/spec/api_spec_v2.rs @@ -26,8 +26,10 @@ impl TryFrom for v2::instance_spec::InstanceSpec { // `smbios_type1_input`. Emptying out the SMBIOS Type 1 input means // this either can be converted to a V1 spec which we can losslessly // make V2 by adding the SMBIOS table input back in, or we wouldn't be - // able to get to a V2 InstanceSpec either way. - let smbios = val.smbios_type1_input.take(); + // able to get to a V2 InstanceSpec either way. The input itself must + // also downgrade: v7 added fields to it that V2 cannot express. + let smbios = + val.smbios_type1_input.take().map(TryInto::try_into).transpose()?; let v1::instance_spec::InstanceSpec { board, components } = val.try_into()?; diff --git a/bin/propolis-server/src/lib/spec/api_spec_v6.rs b/bin/propolis-server/src/lib/spec/api_spec_v6.rs index ac32e1e60..91129b177 100644 --- a/bin/propolis-server/src/lib/spec/api_spec_v6.rs +++ b/bin/propolis-server/src/lib/spec/api_spec_v6.rs @@ -8,21 +8,28 @@ use std::collections::BTreeMap; use propolis_api_types::instance_spec::SpecKey; -use propolis_api_types_versions::{v1::instance::ReplacementComponent, v3, v6}; - -use super::{builder::SpecBuilder, ApiSpecError, Disk, Spec, StorageDevice}; +use propolis_api_types_versions::{ + v1::instance::ReplacementComponent, v3, v6, v7, +}; + +use super::{ + builder::SpecBuilder, ApiSpecError, Disk, LegacyApiSpecError, Spec, + StorageDevice, +}; use crate::migrate::MigrateError; use crate::spec::api_spec_latest; -impl From for v6::instance_spec::InstanceSpec { - fn from(mut val: Spec) -> Self { +impl TryFrom for v6::instance_spec::InstanceSpec { + type Error = LegacyApiSpecError; + + fn try_from(mut val: Spec) -> Result { // v6 adds a new field on NvmeDisk. Such disks probably can't be // converted to v3 components and would cause a conversion from // Spec->v3::instance_spec::InstanceSpec to fail. So, extract those // disks and convert the rest of the Spec to a - // v3::instance_spec::InstanceSpec. If this fails, we wouldn't have been - // able to get a v6 spec anyway. If it succeeds, we can add the disks - // back in here. + // v3::instance_spec::InstanceSpec. That conversion fails only if the + // Spec uses post-v6 features (a v7 SMBIOS type 1 input), in which case + // no v6 spec exists either. If it succeeds, add the disks back in. // // TODO: could be extract_if once we're on a Rust >= 1.91.0. let mut nvme_disks = Vec::new(); @@ -35,15 +42,7 @@ impl From for v6::instance_spec::InstanceSpec { } val.disks.retain(|_, disk| !v6_only_disk(disk)); - let v3_spec: v3::instance_spec::InstanceSpec = - val.try_into().unwrap_or_else(|e| { - unreachable!( - "Converting to Spec without v6 bits to v3 failed: {e}. \ - This is currently impossible. When Spec to \ - v6::instance_spec::InstanceSpec becomes fallible, \ - this should `?`." - ); - }); + let v3_spec: v3::instance_spec::InstanceSpec = val.try_into()?; let mut spec: v6::instance_spec::InstanceSpec = v3_spec.into(); @@ -77,7 +76,7 @@ impl From for v6::instance_spec::InstanceSpec { insert_component(&mut spec, backend_id, backend_component); } - spec + Ok(spec) } } @@ -87,7 +86,11 @@ impl From for v6::instance_spec::InstanceSpec { pub(crate) fn v6_to_spec_builder( value: v6::instance_spec::InstanceSpec, ) -> Result { - api_spec_latest::latest_to_spec_builder(value) + // Converting v6 to v7 is lossless so just do that and piggyback on the + // latest `InstanceSpec->SpecBuilder`. + let v7_spec: v7::instance_spec::InstanceSpec = value.into(); + + api_spec_latest::latest_to_spec_builder(v7_spec) } fn amend_component( @@ -140,12 +143,14 @@ fn amend_component( Ok(()) } -pub(crate) fn amend( - spec: &mut v6::instance_spec::InstanceSpec, +/// Applies `replacements` to `components`; shared by the v6 and v7 amend +/// paths, which use the same component type. +pub(crate) fn amend_components( + components: &mut BTreeMap, replacements: &BTreeMap, ) -> Result<(), MigrateError> { for (id, replacement) in replacements { - let Some(to_amend) = spec.components.get_mut(id) else { + let Some(to_amend) = components.get_mut(id) else { return Err(MigrateError::InstanceSpecsIncompatible(format!( "replacement component {id} not in source spec", ))); @@ -156,3 +161,10 @@ pub(crate) fn amend( Ok(()) } + +pub(crate) fn amend( + spec: &mut v6::instance_spec::InstanceSpec, + replacements: &BTreeMap, +) -> Result<(), MigrateError> { + amend_components(&mut spec.components, replacements) +} diff --git a/bin/propolis-server/src/lib/spec/api_spec_v7.rs b/bin/propolis-server/src/lib/spec/api_spec_v7.rs new file mode 100644 index 000000000..ec0d285ba --- /dev/null +++ b/bin/propolis-server/src/lib/spec/api_spec_v7.rs @@ -0,0 +1,55 @@ +// 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/. + +//! Conversions between [`propolis_api_types_versions::v7`] instance specs and +//! the internal [`super::Spec`] representation. + +use std::collections::BTreeMap; + +use propolis_api_types::instance_spec::SpecKey; +use propolis_api_types_versions::{v1::instance::ReplacementComponent, v6, v7}; + +use super::{builder::SpecBuilder, ApiSpecError, Spec}; +use crate::migrate::MigrateError; +use crate::spec::{api_spec_latest, api_spec_v6}; + +impl From for v7::instance_spec::InstanceSpec { + fn from(mut val: Spec) -> Self { + // v7 only widens the SMBIOS type 1 input, which would cause the + // conversion to a v6 spec to fail if the new fields are set. Set the + // input aside, convert the rest as v6, and put it back afterwards. + let smbios = val.smbios_type1_input.take(); + + let v6_spec: v6::instance_spec::InstanceSpec = + val.try_into().unwrap_or_else(|e| { + unreachable!( + "Converting a Spec without v7 bits to v6 failed: {e}. \ + This is currently impossible. When Spec to \ + v7::instance_spec::InstanceSpec becomes fallible, \ + this should `?`." + ); + }); + + let mut spec: v7::instance_spec::InstanceSpec = v6_spec.into(); + spec.smbios = smbios; + spec + } +} + +/// Parses a v7 instance spec into a [`SpecBuilder`], validating component +/// names, PCI paths, and backend references along the way. Callers can add +/// additional (non-v7) components to the builder before calling `finish()`. +pub(crate) fn v7_to_spec_builder( + value: v7::instance_spec::InstanceSpec, +) -> Result { + api_spec_latest::latest_to_spec_builder(value) +} + +pub(crate) fn amend( + spec: &mut v7::instance_spec::InstanceSpec, + replacements: &BTreeMap, +) -> Result<(), MigrateError> { + // v7 reuses the v6 component types, so the v6 amendment logic applies. + api_spec_v6::amend_components(&mut spec.components, replacements) +} diff --git a/bin/propolis-server/src/lib/spec/builder.rs b/bin/propolis-server/src/lib/spec/builder.rs index e3290369e..1af08e082 100644 --- a/bin/propolis-server/src/lib/spec/builder.rs +++ b/bin/propolis-server/src/lib/spec/builder.rs @@ -392,7 +392,11 @@ impl SpecBuilder { } /// Sets the SMBIOS type 1 table contents to expose to the guest. - pub fn set_smbios_type1_input(&mut self, input: SmbiosType1Input) { + pub fn set_smbios_type1_input(&mut self, mut input: SmbiosType1Input) { + // An empty SKU or family renders identically to unset (string index + // 0), so normalize at ingress and keep downgrades expressible. + input.sku_number = input.sku_number.filter(|s| !s.is_empty()); + input.family = input.family.filter(|s| !s.is_empty()); self.spec.smbios_type1_input = Some(input); } diff --git a/bin/propolis-server/src/lib/spec/mod.rs b/bin/propolis-server/src/lib/spec/mod.rs index 79c613650..e3a092bbb 100644 --- a/bin/propolis-server/src/lib/spec/mod.rs +++ b/bin/propolis-server/src/lib/spec/mod.rs @@ -70,6 +70,7 @@ pub(crate) mod api_spec_v1; pub(crate) mod api_spec_v2; pub(crate) mod api_spec_v3; pub(crate) mod api_spec_v6; +pub(crate) mod api_spec_v7; pub(crate) mod builder; /// An error that can arise in converting @@ -117,6 +118,12 @@ pub(crate) enum LegacyApiSpecError { #[error("spec contains v1-incompatible component: {0}")] IncompatibleComponent(String), + + #[error(transparent)] + SmbiosDowngrade( + #[from] + propolis_api_types_versions::v7::instance_spec::SmbiosDowngradeError, + ), } /// `propolis-server` relies on `TryInto` to convert the API-provided @@ -127,7 +134,7 @@ impl TryFrom for Spec { type Error = ApiSpecError; fn try_from(value: InstanceSpec) -> Result { - Ok(api_spec_v6::v6_to_spec_builder(value)?.finish()) + Ok(api_spec_v7::v7_to_spec_builder(value)?.finish()) } } diff --git a/crates/propolis-api-types-versions/src/latest.rs b/crates/propolis-api-types-versions/src/latest.rs index 5e64474b0..96baf5949 100644 --- a/crates/propolis-api-types-versions/src/latest.rs +++ b/crates/propolis-api-types-versions/src/latest.rs @@ -71,8 +71,8 @@ pub mod instance { pub use crate::v1::instance::InstanceStateRequested; pub use crate::v1::instance::ReplacementComponent; - pub use crate::v6::api::InstanceEnsureRequest; - pub use crate::v6::api::InstanceInitializationMethod; + pub use crate::v7::api::InstanceEnsureRequest; + pub use crate::v7::api::InstanceInitializationMethod; } pub mod instance_spec { @@ -83,12 +83,12 @@ pub mod instance_spec { pub use crate::v1::instance_spec::SpecKey; pub use crate::v1::instance_spec::VersionedInstanceSpec; - pub use crate::v2::instance_spec::SmbiosType1Input; - pub use crate::v6::instance_spec::Component; - pub use crate::v6::instance_spec::InstanceSpec; - pub use crate::v6::instance_spec::InstanceSpecGetResponse; - pub use crate::v6::instance_spec::InstanceSpecStatus; + + pub use crate::v7::instance_spec::InstanceSpec; + pub use crate::v7::instance_spec::InstanceSpecGetResponse; + pub use crate::v7::instance_spec::InstanceSpecStatus; + pub use crate::v7::instance_spec::SmbiosType1Input; } pub mod migration { diff --git a/crates/propolis-api-types-versions/src/lib.rs b/crates/propolis-api-types-versions/src/lib.rs index aecc1a645..697cf1348 100644 --- a/crates/propolis-api-types-versions/src/lib.rs +++ b/crates/propolis-api-types-versions/src/lib.rs @@ -41,3 +41,5 @@ pub mod v3; pub mod v5; #[path = "nvme_write_cache/mod.rs"] pub mod v6; +#[path = "smbios_sku_family/mod.rs"] +pub mod v7; diff --git a/crates/propolis-api-types-versions/src/smbios_sku_family/api.rs b/crates/propolis-api-types-versions/src/smbios_sku_family/api.rs new file mode 100644 index 000000000..bc591535d --- /dev/null +++ b/crates/propolis-api-types-versions/src/smbios_sku_family/api.rs @@ -0,0 +1,62 @@ +// 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/. + +//! API request and response types for the SMBIOS_SKU_FAMILY API version. + +use std::{collections::BTreeMap, net::SocketAddr}; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::instance_spec::InstanceSpec; +use crate::v1::instance::{InstanceProperties, ReplacementComponent}; +use crate::v1::instance_spec::SpecKey; +use crate::v6; + +#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] +#[serde(tag = "method", content = "value")] +pub enum InstanceInitializationMethod { + Spec { + spec: InstanceSpec, + }, + MigrationTarget { + migration_id: Uuid, + src_addr: SocketAddr, + replace_components: BTreeMap, + }, +} + +#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] +pub struct InstanceEnsureRequest { + pub properties: InstanceProperties, + pub init: InstanceInitializationMethod, +} + +impl From + for InstanceInitializationMethod +{ + fn from(old: v6::api::InstanceInitializationMethod) -> Self { + match old { + v6::api::InstanceInitializationMethod::Spec { spec } => { + Self::Spec { spec: spec.into() } + } + v6::api::InstanceInitializationMethod::MigrationTarget { + migration_id, + src_addr, + replace_components, + } => Self::MigrationTarget { + migration_id, + src_addr, + replace_components, + }, + } + } +} + +impl From for InstanceEnsureRequest { + fn from(old: v6::api::InstanceEnsureRequest) -> Self { + Self { properties: old.properties, init: old.init.into() } + } +} diff --git a/crates/propolis-api-types-versions/src/smbios_sku_family/instance_spec.rs b/crates/propolis-api-types-versions/src/smbios_sku_family/instance_spec.rs new file mode 100644 index 000000000..ab6f14528 --- /dev/null +++ b/crates/propolis-api-types-versions/src/smbios_sku_family/instance_spec.rs @@ -0,0 +1,238 @@ +// 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/. + +//! Instance specification types for the SMBIOS_SKU_FAMILY API version. + +use std::collections::BTreeMap; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::v1::components::board; +use crate::v1::instance::{InstanceProperties, InstanceState}; +use crate::v1::instance_spec::SpecKey; +use crate::v2; +use crate::v6; +use crate::v6::instance_spec::Component; + +/// Input for the guest SMBIOS type 1 (System Information) table, defined in +/// section 7.2 of the SMBIOS spec (DSP0134): +#[derive(Clone, Deserialize, Serialize, Debug, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SmbiosType1Input { + pub manufacturer: String, + pub product_name: String, + pub serial_number: String, + pub version: u64, + + /// The SKU Number string in the emitted table. Unset means empty. + // Unset must serialize exactly like v6: pre-v7 deserializers deny + // unknown field names even when the value is null. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sku_number: Option, + + /// The Family string in the emitted table. Unset means empty. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub family: Option, +} + +#[derive(Clone, Deserialize, Serialize, Debug, JsonSchema)] +pub struct InstanceSpec { + pub board: board::Board, + pub components: BTreeMap, + pub smbios: Option, +} + +// The widened SmbiosType1Input pushes InstanceSpec past clippy's variant +// size threshold; boxing would ripple through every constructor for no gain. +#[allow(clippy::large_enum_variant)] +#[derive(Clone, Deserialize, Serialize, JsonSchema)] +#[serde(tag = "type", content = "value")] +pub enum InstanceSpecStatus { + WaitingForMigrationSource, + Present(InstanceSpec), +} + +#[derive(Clone, Deserialize, Serialize, JsonSchema)] +pub struct InstanceSpecGetResponse { + pub properties: InstanceProperties, + pub state: InstanceState, + pub spec: InstanceSpecStatus, +} + +#[derive(thiserror::Error, Debug)] +#[error("SMBIOS type 1 input cannot be downgraded: {reason}")] +pub struct SmbiosDowngradeError { + pub(crate) reason: &'static str, +} + +impl From for SmbiosType1Input { + fn from(old: v2::instance_spec::SmbiosType1Input) -> Self { + let v2::instance_spec::SmbiosType1Input { + manufacturer, + product_name, + serial_number, + version, + } = old; + + Self { + manufacturer, + product_name, + serial_number, + version, + sku_number: None, + family: None, + } + } +} + +impl TryFrom for v2::instance_spec::SmbiosType1Input { + type Error = SmbiosDowngradeError; + + fn try_from(new: SmbiosType1Input) -> Result { + let SmbiosType1Input { + manufacturer, + product_name, + serial_number, + version, + sku_number, + family, + } = new; + + if sku_number.is_some() { + return Err(SmbiosDowngradeError { + reason: "SKU number cannot be downgraded", + }); + } + + if family.is_some() { + return Err(SmbiosDowngradeError { + reason: "family cannot be downgraded", + }); + } + + Ok(Self { manufacturer, product_name, serial_number, version }) + } +} + +impl From for InstanceSpec { + fn from(old: v6::instance_spec::InstanceSpec) -> Self { + Self { + board: old.board, + components: old.components, + smbios: old.smbios.map(Into::into), + } + } +} + +impl TryFrom for v6::instance_spec::InstanceSpec { + type Error = SmbiosDowngradeError; + + fn try_from(new: InstanceSpec) -> Result { + Ok(Self { + board: new.board, + components: new.components, + smbios: new.smbios.map(TryInto::try_into).transpose()?, + }) + } +} + +impl TryFrom for v6::instance_spec::InstanceSpecStatus { + type Error = SmbiosDowngradeError; + + fn try_from(new: InstanceSpecStatus) -> Result { + Ok(match new { + InstanceSpecStatus::WaitingForMigrationSource => { + Self::WaitingForMigrationSource + } + InstanceSpecStatus::Present(spec) => { + Self::Present(spec.try_into()?) + } + }) + } +} + +impl TryFrom + for v6::instance_spec::InstanceSpecGetResponse +{ + type Error = SmbiosDowngradeError; + + fn try_from(new: InstanceSpecGetResponse) -> Result { + Ok(Self { + properties: new.properties, + state: new.state, + spec: new.spec.try_into()?, + }) + } +} + +#[cfg(test)] +mod test { + use super::*; + + // Verifies that upgrading a pre-v7 SMBIOS type 1 input leaves the new + // fields unset. + #[test] + fn old_smbios_input_upgrades_with_new_fields_unset() { + let old = v2::instance_spec::SmbiosType1Input { + manufacturer: "Oxide".to_string(), + product_name: "OxVM".to_string(), + serial_number: "12345".to_string(), + version: 3, + }; + + let new = SmbiosType1Input::from(old); + assert_eq!(new.manufacturer, "Oxide"); + assert_eq!(new.product_name, "OxVM"); + assert_eq!(new.serial_number, "12345"); + assert_eq!(new.version, 3); + assert_eq!(new.sku_number, None); + assert_eq!(new.family, None); + } + + // Verifies that the new fields block downgrading the input to v2 form + // when set and downgrade cleanly when unset. + #[test] + fn new_smbios_fields_gate_downgrade() { + let mut new = SmbiosType1Input { + manufacturer: "Oxide".to_string(), + product_name: "OxVM".to_string(), + serial_number: "12345".to_string(), + version: 3, + sku_number: Some("913-0000019".to_string()), + family: None, + }; + + assert!( + v2::instance_spec::SmbiosType1Input::try_from(new.clone()).is_err() + ); + + new.sku_number = None; + new.family = Some("Gimlet".to_string()); + assert!( + v2::instance_spec::SmbiosType1Input::try_from(new.clone()).is_err() + ); + + new.family = None; + let old = v2::instance_spec::SmbiosType1Input::try_from(new).unwrap(); + assert_eq!(old.manufacturer, "Oxide"); + assert_eq!(old.product_name, "OxVM"); + assert_eq!(old.serial_number, "12345"); + assert_eq!(old.version, 3); + } + + // Verifies that a payload without the new fields still parses. + #[test] + fn smbios_input_serde_defaults() { + let json = r#"{"manufacturer":"Oxide","product_name":"OxVM", + "serial_number":"12345","version":3}"#; + let input: SmbiosType1Input = serde_json::from_str(json).unwrap(); + assert_eq!(input.sku_number, None); + assert_eq!(input.family, None); + + let out = serde_json::to_string(&input).unwrap(); + assert!(!out.contains("sku_number")); + assert!(!out.contains("family")); + } +} diff --git a/crates/propolis-api-types-versions/src/smbios_sku_family/mod.rs b/crates/propolis-api-types-versions/src/smbios_sku_family/mod.rs new file mode 100644 index 000000000..d3db24f50 --- /dev/null +++ b/crates/propolis-api-types-versions/src/smbios_sku_family/mod.rs @@ -0,0 +1,11 @@ +// 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 `SMBIOS_SKU_FAMILY` of the Propolis Server API. +//! +//! This version adds optional SKU number and family strings to the SMBIOS +//! Type 1 table input. + +pub mod api; +pub mod instance_spec; diff --git a/crates/propolis-server-api/src/lib.rs b/crates/propolis-server-api/src/lib.rs index 298d0d33e..ada614007 100644 --- a/crates/propolis-server-api/src/lib.rs +++ b/crates/propolis-server-api/src/lib.rs @@ -8,7 +8,7 @@ use dropshot::{ WebsocketChannelResult, WebsocketConnection, }; use dropshot_api_manager_types::api_versions; -use propolis_api_types_versions::{latest, v1, v2, v3, v6}; +use propolis_api_types_versions::{latest, v1, v2, v3, v6, v7}; api_versions!([ // WHEN CHANGING THE API (part 1 of 2): @@ -22,6 +22,7 @@ api_versions!([ // | example for the next person. // v // (next_int, IDENT), + (7, SMBIOS_SKU_FAMILY), (6, NVME_WRITE_CACHE), (5, CRUCIBLE_VOLUME_INFO), (4, DROPSHOT_BUMP_WEBSOCKET), @@ -49,7 +50,7 @@ pub trait PropolisServerApi { #[endpoint { method = PUT, path = "/instance", - versions = VERSION_NVME_WRITE_CACHE.. + versions = VERSION_SMBIOS_SKU_FAMILY.. }] async fn instance_ensure( rqctx: RequestContext, @@ -59,6 +60,26 @@ pub trait PropolisServerApi { HttpError, >; + #[endpoint { + operation_id = "instance_ensure", + method = PUT, + path = "/instance", + versions = VERSION_NVME_WRITE_CACHE..VERSION_SMBIOS_SKU_FAMILY + }] + async fn instance_ensure_v6( + rqctx: RequestContext, + request: TypedBody, + ) -> Result< + HttpResponseCreated, + HttpError, + > { + Self::instance_ensure( + rqctx, + request.map(v7::api::InstanceEnsureRequest::from), + ) + .await + } + #[endpoint { operation_id = "instance_ensure", method = PUT, @@ -72,7 +93,7 @@ pub trait PropolisServerApi { HttpResponseCreated, HttpError, > { - Self::instance_ensure( + Self::instance_ensure_v6( rqctx, request.map(v6::api::InstanceEnsureRequest::from), ) @@ -122,7 +143,7 @@ pub trait PropolisServerApi { #[endpoint { method = GET, path = "/instance/spec", - versions = VERSION_NVME_WRITE_CACHE.. + versions = VERSION_SMBIOS_SKU_FAMILY.. }] async fn instance_spec_get( rqctx: RequestContext, @@ -131,6 +152,35 @@ pub trait PropolisServerApi { HttpError, >; + #[endpoint { + operation_id = "instance_spec_get", + method = GET, + path = "/instance/spec", + versions = VERSION_NVME_WRITE_CACHE..VERSION_SMBIOS_SKU_FAMILY + }] + async fn instance_spec_get_v6( + rqctx: RequestContext, + ) -> Result< + HttpResponseOk, + HttpError, + > { + let latest_response = Self::instance_spec_get(rqctx).await?.0; + let v6_response: v6::instance_spec::InstanceSpecGetResponse = + latest_response.try_into().map_err( + |e: v7::instance_spec::SmbiosDowngradeError| { + HttpError::for_client_error( + None, + ClientErrorStatusCode::BAD_REQUEST, + format!( + "instance spec cannot be expressed to v6 \ + clients: {e}" + ), + ) + }, + )?; + Ok(HttpResponseOk(v6_response)) + } + #[endpoint { operation_id = "instance_spec_get", method = GET, @@ -143,7 +193,7 @@ pub trait PropolisServerApi { HttpResponseOk, HttpError, > { - Ok(Self::instance_spec_get(rqctx) + Ok(Self::instance_spec_get_v6(rqctx) .await? .map(v3::instance_spec::InstanceSpecGetResponse::from)) } diff --git a/openapi/propolis-server/propolis-server-6.0.0-b5b984.json.gitstub b/openapi/propolis-server/propolis-server-6.0.0-b5b984.json.gitstub new file mode 100644 index 000000000..a2c8f9747 --- /dev/null +++ b/openapi/propolis-server/propolis-server-6.0.0-b5b984.json.gitstub @@ -0,0 +1 @@ +046f74302e2ea09a75b0a6810645d42c7df6644a:openapi/propolis-server/propolis-server-6.0.0-b5b984.json diff --git a/openapi/propolis-server/propolis-server-6.0.0-b5b984.json b/openapi/propolis-server/propolis-server-7.0.0-2995e2.json similarity index 99% rename from openapi/propolis-server/propolis-server-6.0.0-b5b984.json rename to openapi/propolis-server/propolis-server-7.0.0-2995e2.json index 1bfc621c4..8a33c9b45 100644 --- a/openapi/propolis-server/propolis-server-6.0.0-b5b984.json +++ b/openapi/propolis-server/propolis-server-7.0.0-2995e2.json @@ -7,7 +7,7 @@ "url": "https://oxide.computer", "email": "api@oxide.computer" }, - "version": "6.0.0" + "version": "7.0.0" }, "paths": { "/instance": { @@ -1989,8 +1989,14 @@ ] }, "SmbiosType1Input": { + "description": "Input for the guest SMBIOS type 1 (System Information) table, defined in section 7.2 of the SMBIOS spec (DSP0134): ", "type": "object", "properties": { + "family": { + "nullable": true, + "description": "The Family string in the emitted table. Unset means empty.", + "type": "string" + }, "manufacturer": { "type": "string" }, @@ -2000,6 +2006,11 @@ "serial_number": { "type": "string" }, + "sku_number": { + "nullable": true, + "description": "The SKU Number string in the emitted table. Unset means empty.", + "type": "string" + }, "version": { "type": "integer", "format": "uint64", diff --git a/openapi/propolis-server/propolis-server-latest.json b/openapi/propolis-server/propolis-server-latest.json index 114ec385b..f13f3ed27 120000 --- a/openapi/propolis-server/propolis-server-latest.json +++ b/openapi/propolis-server/propolis-server-latest.json @@ -1 +1 @@ -propolis-server-6.0.0-b5b984.json \ No newline at end of file +propolis-server-7.0.0-2995e2.json \ No newline at end of file