From b4e58c033e91ee111a0a867e7fda6dab7bc07a6d Mon Sep 17 00:00:00 2001 From: iximeow Date: Thu, 9 Jul 2026 02:19:28 +0000 Subject: [PATCH 01/24] nvme: write cache presence is not a given --- bin/propolis-server/src/lib/initializer.rs | 1 + bin/propolis-standalone/src/main.rs | 8 +++-- lib/propolis/src/hw/nvme/mod.rs | 38 ++++++++++++++++++---- 3 files changed, 39 insertions(+), 8 deletions(-) diff --git a/bin/propolis-server/src/lib/initializer.rs b/bin/propolis-server/src/lib/initializer.rs index cada3a60e..a3bf2f770 100644 --- a/bin/propolis-server/src/lib/initializer.rs +++ b/bin/propolis-server/src/lib/initializer.rs @@ -909,6 +909,7 @@ impl MachineInitializer<'_> { let nvme = nvme::PciNvme::create( &nvme_spec.serial_number, mdts, + true, self.log.new(slog::o!("component" => component)), ); self.devices.insert(device_id.clone(), nvme.clone()); diff --git a/bin/propolis-standalone/src/main.rs b/bin/propolis-standalone/src/main.rs index f520380dc..5bffc7f21 100644 --- a/bin/propolis-standalone/src/main.rs +++ b/bin/propolis-standalone/src/main.rs @@ -1363,8 +1363,12 @@ fn setup_instance( serial_number[..sz] .clone_from_slice(&dev_serial.as_bytes()[..sz]); - let nvme = - hw::nvme::PciNvme::create(&serial_number, mdts, log); + let nvme = hw::nvme::PciNvme::create( + &serial_number, + mdts, + true, + log, + ); guard.inventory.register_instance(&nvme, &bdf.to_string()); guard.inventory.register_block(&backend, name); diff --git a/lib/propolis/src/hw/nvme/mod.rs b/lib/propolis/src/hw/nvme/mod.rs index 7f8f639b1..a9360383f 100644 --- a/lib/propolis/src/hw/nvme/mod.rs +++ b/lib/propolis/src/hw/nvme/mod.rs @@ -147,6 +147,13 @@ struct CtrlState { /// Controller Capabilities cap: Capabilities, + /// Version + /// + /// A bare u32 as we expect to support specific NVMe major/minor versions + /// if/when we move beyond simply 1.0. The field is constant after the + /// controller is instantiated and returned to the guest in one read. + vs: u32, + /// Controller Configuration cc: Configuration, @@ -811,6 +818,7 @@ impl PciNvme { pub fn create( serial_number: &[u8; 20], mdts: Option, + has_write_cache: bool, log: slog::Logger, ) -> Arc { let builder = pci::Builder::new(pci::Ident { @@ -831,6 +839,24 @@ impl PciNvme { let cqes = size_of::().trailing_zeros() as u8; let sqes = size_of::().trailing_zeros() as u8; + // The controller's supported NVMe version is not (yet) configurable, + // but making it available to ourselves is useful to ensure we handle + // new versions correctly. + let vs = NVME_VER_1_0; + + // In NVMe 1.0e, VWC defines only bit 0, the presence of a volatile + // write cache. + let vwc = if has_write_cache { + // XXX: Later NVMe revisions define upper bits and only allow bits + // 1-2 to be zero in controllers implementing older versions of + // NVMe. This will need more precise handling when the supported + // NVMe version is configurable. + assert!(vs == NVME_VER_1_0); + 1 + } else { + 0 + }; + // Initialize the Identify structure returned when the host issues // an Identify Controller command. let ctrl_ident = bits::IdentifyController { @@ -839,8 +865,8 @@ impl PciNvme { sn: *serial_number, ieee: OXIDE_OUI, mdts: mdts.unwrap_or(0), - // We use standard Completion/Submission Queue Entry structures with no extra - // data, so required (minimum) == maximum + // We use standard Completion/Submission Queue Entry structures with + // no extra data, so required (minimum) == maximum sqes: NvmQueueEntrySize(0).with_maximum(sqes).with_required(sqes), cqes: NvmQueueEntrySize(0).with_maximum(cqes).with_required(cqes), // Supporting multiple namespaces complicates I/O dispatching, @@ -848,8 +874,7 @@ impl PciNvme { nn: 1, // bit 2 indicates support for the Dataset Management command oncs: (1 << 2), - // bit 0 indicates volatile write cache is present - vwc: 1, + vwc, // bit 8 indicates Doorbell Buffer support oacs: (1 << 8), ..Default::default() @@ -898,7 +923,7 @@ impl PciNvme { let state = NvmeCtrl { device_id: DeviceId::new(), - ctrl: CtrlState { cap, cc, csts, ..Default::default() }, + ctrl: CtrlState { cap, vs, cc, csts, ..Default::default() }, doorbell_buf: None, msix_hdl: None, cqs: Default::default(), @@ -1001,7 +1026,8 @@ impl PciNvme { ro.write_u64(state.ctrl.cap.0); } CtrlrReg::Version => { - ro.write_u32(NVME_VER_1_0); + let state = self.state.lock().unwrap(); + ro.write_u32(state.ctrl.vs); } CtrlrReg::IntrMaskSet | CtrlrReg::IntrMaskClear => { From b5242f52a5470580f702f0a2fb7400ef535ffa9a Mon Sep 17 00:00:00 2001 From: iximeow Date: Thu, 9 Jul 2026 07:06:02 +0000 Subject: [PATCH 02/24] new propolis-server API version with configurable VWC presence --- bin/propolis-cli/src/main.rs | 1 + .../propolis-api-types-versions/src/latest.rs | 15 +- crates/propolis-api-types-versions/src/lib.rs | 2 + .../src/nvme_write_cache/api.rs | 62 + .../nvme_write_cache/components/devices.rs | 61 + .../src/nvme_write_cache/components/mod.rs | 5 + .../src/nvme_write_cache/instance_spec.rs | 204 ++ .../src/nvme_write_cache/mod.rs | 12 + crates/propolis-config-toml/src/spec.rs | 2 + crates/propolis-server-api/src/lib.rs | 48 +- .../propolis-server-1.0.0-833484.json.gitstub | 1 - .../propolis-server-2.0.0-d68a9f.json.gitstub | 1 - .../propolis-server-3.0.0-10da2b.json.gitstub | 1 - .../propolis-server-6.0.0-b5b984.json | 2333 +++++++++++++++++ .../propolis-server-latest.json | 2 +- 15 files changed, 2731 insertions(+), 19 deletions(-) create mode 100644 crates/propolis-api-types-versions/src/nvme_write_cache/api.rs create mode 100644 crates/propolis-api-types-versions/src/nvme_write_cache/components/devices.rs create mode 100644 crates/propolis-api-types-versions/src/nvme_write_cache/components/mod.rs create mode 100644 crates/propolis-api-types-versions/src/nvme_write_cache/instance_spec.rs create mode 100644 crates/propolis-api-types-versions/src/nvme_write_cache/mod.rs delete mode 100644 openapi/propolis-server/propolis-server-1.0.0-833484.json.gitstub delete mode 100644 openapi/propolis-server/propolis-server-2.0.0-d68a9f.json.gitstub delete mode 100644 openapi/propolis-server/propolis-server-3.0.0-10da2b.json.gitstub create mode 100644 openapi/propolis-server/propolis-server-6.0.0-b5b984.json diff --git a/bin/propolis-cli/src/main.rs b/bin/propolis-cli/src/main.rs index d4b22f9a8..6df8f6930 100644 --- a/bin/propolis-cli/src/main.rs +++ b/bin/propolis-cli/src/main.rs @@ -263,6 +263,7 @@ impl DiskRequest { backend_id: backend_id.clone(), pci_path, serial_number: nvme_serial_from_str(&self.name, b' '), + has_write_cache: true, }), _ => anyhow::bail!( "invalid device type in disk request: {:?}", diff --git a/crates/propolis-api-types-versions/src/latest.rs b/crates/propolis-api-types-versions/src/latest.rs index 0d9675aeb..5e64474b0 100644 --- a/crates/propolis-api-types-versions/src/latest.rs +++ b/crates/propolis-api-types-versions/src/latest.rs @@ -30,7 +30,6 @@ pub mod components { pub use crate::v1::components::devices::BootOrderEntry; pub use crate::v1::components::devices::BootSettings; pub use crate::v1::components::devices::MigrationFailureInjector; - pub use crate::v1::components::devices::NvmeDisk; pub use crate::v1::components::devices::P9fs; pub use crate::v1::components::devices::PciPciBridge; pub use crate::v1::components::devices::QemuPvpanic; @@ -43,6 +42,8 @@ pub mod components { pub use crate::v1::components::devices::VirtioNic; pub use crate::v3::components::devices::VirtioSocket; + + pub use crate::v6::components::devices::NvmeDisk; } } @@ -70,8 +71,8 @@ pub mod instance { pub use crate::v1::instance::InstanceStateRequested; pub use crate::v1::instance::ReplacementComponent; - pub use crate::v3::api::InstanceEnsureRequest; - pub use crate::v3::api::InstanceInitializationMethod; + pub use crate::v6::api::InstanceEnsureRequest; + pub use crate::v6::api::InstanceInitializationMethod; } pub mod instance_spec { @@ -84,10 +85,10 @@ pub mod instance_spec { pub use crate::v2::instance_spec::SmbiosType1Input; - pub use crate::v3::instance_spec::Component; - pub use crate::v3::instance_spec::InstanceSpec; - pub use crate::v3::instance_spec::InstanceSpecGetResponse; - pub use crate::v3::instance_spec::InstanceSpecStatus; + 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 mod migration { diff --git a/crates/propolis-api-types-versions/src/lib.rs b/crates/propolis-api-types-versions/src/lib.rs index 77dd5e31c..aecc1a645 100644 --- a/crates/propolis-api-types-versions/src/lib.rs +++ b/crates/propolis-api-types-versions/src/lib.rs @@ -39,3 +39,5 @@ pub mod v2; pub mod v3; #[path = "crucible_volume_info/mod.rs"] pub mod v5; +#[path = "nvme_write_cache/mod.rs"] +pub mod v6; diff --git a/crates/propolis-api-types-versions/src/nvme_write_cache/api.rs b/crates/propolis-api-types-versions/src/nvme_write_cache/api.rs new file mode 100644 index 000000000..651896b26 --- /dev/null +++ b/crates/propolis-api-types-versions/src/nvme_write_cache/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 ADD_VSOCK 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::v3; + +#[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: v3::api::InstanceInitializationMethod) -> Self { + match old { + v3::api::InstanceInitializationMethod::Spec { spec } => { + Self::Spec { spec: spec.into() } + } + v3::api::InstanceInitializationMethod::MigrationTarget { + migration_id, + src_addr, + replace_components, + } => Self::MigrationTarget { + migration_id, + src_addr, + replace_components, + }, + } + } +} + +impl From for InstanceEnsureRequest { + fn from(old: v3::api::InstanceEnsureRequest) -> Self { + Self { properties: old.properties, init: old.init.into() } + } +} diff --git a/crates/propolis-api-types-versions/src/nvme_write_cache/components/devices.rs b/crates/propolis-api-types-versions/src/nvme_write_cache/components/devices.rs new file mode 100644 index 000000000..766cf6727 --- /dev/null +++ b/crates/propolis-api-types-versions/src/nvme_write_cache/components/devices.rs @@ -0,0 +1,61 @@ +use super::super::instance_spec; +use crate::v1::components::devices::NvmeDisk as V1NvmeDisk; +use crate::v1::instance_spec::{PciPath, SpecKey}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +/// A disk that presents an NVMe interface to the guest. +#[derive(Clone, Deserialize, Serialize, Debug, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct NvmeDisk { + /// The name of the disk's backend component. + pub backend_id: SpecKey, + + /// The PCI bus/device/function at which this disk should be attached. + pub pci_path: PciPath, + + /// The serial number to return in response to an NVMe Identify Controller + /// command. + pub serial_number: [u8; 20], + + /// Control if the NVMe disk reports the presence of a volatile write cache. + /// + /// This generally should be configured in consideration of the storage + /// backend for the NVMe device. "true" is a safe default, and was + /// historically the only configurable value. If the storage backend will + /// not lose data once writes are accepted, even in the face of unplanned + /// crashes or power loss (or, if you really want to lie to guests), setting + /// this to "false" can advise guests they may skip issuing flushes to the + /// device. + pub has_write_cache: bool, +} + +impl TryFrom for V1NvmeDisk { + type Error = instance_spec::InvalidV3Component; + + fn try_from(disk: NvmeDisk) -> Result { + let NvmeDisk { backend_id, pci_path, serial_number, has_write_cache } = + disk; + + if !has_write_cache { + return Err(instance_spec::InvalidV3Component { + reason: + "NvmeDisk with has_write_cache=false cannot be downgraded", + }); + } + + Ok(V1NvmeDisk { backend_id, pci_path, serial_number }) + } +} + +impl From for NvmeDisk { + fn from(v1_disk: V1NvmeDisk) -> Self { + let V1NvmeDisk { backend_id, pci_path, serial_number } = v1_disk; + + // API version `nvme_write_cache` pairs with a Propolis change to make + // the former-default of NVMe devices offering `VWC=1` into a + // configurable option. So the historical default and effect of any + // previous APIs was to `has_write_cache: true`. + Self { backend_id, pci_path, serial_number, has_write_cache: true } + } +} diff --git a/crates/propolis-api-types-versions/src/nvme_write_cache/components/mod.rs b/crates/propolis-api-types-versions/src/nvme_write_cache/components/mod.rs new file mode 100644 index 000000000..0b45a47b1 --- /dev/null +++ b/crates/propolis-api-types-versions/src/nvme_write_cache/components/mod.rs @@ -0,0 +1,5 @@ +// 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/. + +pub mod devices; diff --git a/crates/propolis-api-types-versions/src/nvme_write_cache/instance_spec.rs b/crates/propolis-api-types-versions/src/nvme_write_cache/instance_spec.rs new file mode 100644 index 000000000..cfe8d491e --- /dev/null +++ b/crates/propolis-api-types-versions/src/nvme_write_cache/instance_spec.rs @@ -0,0 +1,204 @@ +// 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/. + +use std::collections::BTreeMap; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::v1::components::backends; +use crate::v1::components::board; +use crate::v1::components::devices as v1_devices; +use crate::v1::instance::{InstanceProperties, InstanceState}; +use crate::v1::instance_spec::SpecKey; +use crate::v2::instance_spec::SmbiosType1Input; +use crate::v3; +use crate::v3::components::devices as v3_devices; +use crate::v3::instance_spec::Component as V3Component; + +pub use super::components::devices::NvmeDisk; + +#[derive(Clone, Deserialize, Serialize, Debug, JsonSchema)] +#[serde( + deny_unknown_fields, + tag = "type", + content = "component", + rename_all = "snake_case" +)] +pub enum Component { + VirtioDisk(v1_devices::VirtioDisk), + NvmeDisk(NvmeDisk), + VirtioNic(v1_devices::VirtioNic), + SerialPort(v1_devices::SerialPort), + PciPciBridge(v1_devices::PciPciBridge), + QemuPvpanic(v1_devices::QemuPvpanic), + BootSettings(v1_devices::BootSettings), + VirtioSocket(v3_devices::VirtioSocket), + SoftNpuPciPort(v1_devices::SoftNpuPciPort), + SoftNpuPort(v1_devices::SoftNpuPort), + SoftNpuP9(v1_devices::SoftNpuP9), + P9fs(v1_devices::P9fs), + MigrationFailureInjector(v1_devices::MigrationFailureInjector), + CrucibleStorageBackend(backends::CrucibleStorageBackend), + FileStorageBackend(backends::FileStorageBackend), + BlobStorageBackend(backends::BlobStorageBackend), + VirtioNetworkBackend(backends::VirtioNetworkBackend), + DlpiNetworkBackend(backends::DlpiNetworkBackend), +} + +#[derive(Clone, Deserialize, Serialize, Debug, JsonSchema)] +pub struct InstanceSpec { + pub board: board::Board, + pub components: BTreeMap, + pub smbios: Option, +} + +#[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("cannot convert component to v3: {reason}")] +pub struct InvalidV3Component { + pub(crate) reason: &'static str, +} + +impl TryFrom for V3Component { + type Error = InvalidV3Component; + + fn try_from(value: Component) -> Result { + Ok(match value { + Component::VirtioDisk(c) => V3Component::VirtioDisk(c), + Component::NvmeDisk(c) => V3Component::NvmeDisk(c.try_into()?), + Component::VirtioNic(c) => V3Component::VirtioNic(c), + Component::SerialPort(c) => V3Component::SerialPort(c), + Component::PciPciBridge(c) => V3Component::PciPciBridge(c), + Component::QemuPvpanic(c) => V3Component::QemuPvpanic(c), + Component::BootSettings(c) => V3Component::BootSettings(c), + Component::VirtioSocket(c) => V3Component::VirtioSocket(c), + Component::SoftNpuPciPort(c) => V3Component::SoftNpuPciPort(c), + Component::SoftNpuPort(c) => V3Component::SoftNpuPort(c), + Component::SoftNpuP9(c) => V3Component::SoftNpuP9(c), + Component::P9fs(c) => V3Component::P9fs(c), + Component::MigrationFailureInjector(c) => { + V3Component::MigrationFailureInjector(c) + } + Component::CrucibleStorageBackend(c) => { + V3Component::CrucibleStorageBackend(c) + } + Component::FileStorageBackend(c) => { + V3Component::FileStorageBackend(c) + } + Component::BlobStorageBackend(c) => { + V3Component::BlobStorageBackend(c) + } + Component::VirtioNetworkBackend(c) => { + V3Component::VirtioNetworkBackend(c) + } + Component::DlpiNetworkBackend(c) => { + V3Component::DlpiNetworkBackend(c) + } + }) + } +} + +impl From for v3::instance_spec::InstanceSpec { + fn from(new: InstanceSpec) -> Self { + Self { + board: new.board, + components: new + .components + .into_iter() + .filter_map(|(k, v)| { + V3Component::try_from(v).ok().map(|c| (k, c)) + }) + .collect(), + smbios: new.smbios, + } + } +} + +impl From for Component { + fn from(old: V3Component) -> Self { + match old { + V3Component::VirtioDisk(c) => Component::VirtioDisk(c), + V3Component::VirtioSocket(c) => Component::VirtioSocket(c), + V3Component::NvmeDisk(c) => Component::NvmeDisk(c.into()), + V3Component::VirtioNic(c) => Component::VirtioNic(c), + V3Component::SerialPort(c) => Component::SerialPort(c), + V3Component::PciPciBridge(c) => Component::PciPciBridge(c), + V3Component::QemuPvpanic(c) => Component::QemuPvpanic(c), + V3Component::BootSettings(c) => Component::BootSettings(c), + V3Component::SoftNpuPciPort(c) => Component::SoftNpuPciPort(c), + V3Component::SoftNpuPort(c) => Component::SoftNpuPort(c), + V3Component::SoftNpuP9(c) => Component::SoftNpuP9(c), + V3Component::P9fs(c) => Component::P9fs(c), + V3Component::MigrationFailureInjector(c) => { + Component::MigrationFailureInjector(c) + } + V3Component::CrucibleStorageBackend(c) => { + Component::CrucibleStorageBackend(c) + } + V3Component::FileStorageBackend(c) => { + Component::FileStorageBackend(c) + } + V3Component::BlobStorageBackend(c) => { + Component::BlobStorageBackend(c) + } + V3Component::VirtioNetworkBackend(c) => { + Component::VirtioNetworkBackend(c) + } + V3Component::DlpiNetworkBackend(c) => { + Component::DlpiNetworkBackend(c) + } + } + } +} + +impl From for v3::instance_spec::InstanceSpecStatus { + fn from(new: InstanceSpecStatus) -> Self { + match new { + InstanceSpecStatus::WaitingForMigrationSource => { + Self::WaitingForMigrationSource + } + InstanceSpecStatus::Present(spec) => Self::Present(spec.into()), + } + } +} + +impl From + for v3::instance_spec::InstanceSpecGetResponse +{ + fn from(new: InstanceSpecGetResponse) -> Self { + Self { + properties: new.properties, + state: new.state, + spec: new.spec.into(), + } + } +} + +impl From for InstanceSpec { + fn from(old: v3::instance_spec::InstanceSpec) -> Self { + Self { + board: old.board, + components: old + .components + .into_iter() + .map(|(k, v)| (k, Component::from(v))) + .collect(), + smbios: old.smbios, + } + } +} diff --git a/crates/propolis-api-types-versions/src/nvme_write_cache/mod.rs b/crates/propolis-api-types-versions/src/nvme_write_cache/mod.rs new file mode 100644 index 000000000..29cf1e870 --- /dev/null +++ b/crates/propolis-api-types-versions/src/nvme_write_cache/mod.rs @@ -0,0 +1,12 @@ +// 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 `NVME_WRITE_CACHE` of the Propolis Server API. +//! +//! This version adds an API field to control if an NVMe device reports having a +//! volatile write cache. + +pub mod api; +pub mod components; +pub mod instance_spec; diff --git a/crates/propolis-config-toml/src/spec.rs b/crates/propolis-config-toml/src/spec.rs index 2b6371892..78bdec4b5 100644 --- a/crates/propolis-config-toml/src/spec.rs +++ b/crates/propolis-config-toml/src/spec.rs @@ -339,6 +339,8 @@ fn parse_storage_device_from_config( backend_id, pci_path, serial_number: nvme_serial_from_str(name, b' '), + // XXX(ixi): this should actually be read from tomls and defaulted normally + has_write_cache: true, }), }, id_to_return, diff --git a/crates/propolis-server-api/src/lib.rs b/crates/propolis-server-api/src/lib.rs index 8670ab02d..fc2ea8b17 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}; +use propolis_api_types_versions::{latest, v1, v2, v3}; 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), + (6, NVME_WRITE_CACHE), (5, CRUCIBLE_VOLUME_INFO), (4, DROPSHOT_BUMP_WEBSOCKET), (3, ADD_VSOCK), @@ -48,7 +49,7 @@ pub trait PropolisServerApi { #[endpoint { method = PUT, path = "/instance", - versions = VERSION_ADD_VSOCK.. + versions = VERSION_NVME_WRITE_CACHE.. }] async fn instance_ensure( rqctx: RequestContext, @@ -58,6 +59,20 @@ pub trait PropolisServerApi { HttpError, >; + #[endpoint { + operation_id = "instance_ensure", + method = PUT, + path = "/instance", + versions = VERSION_ADD_VSOCK..VERSION_NVME_WRITE_CACHE + }] + async fn instance_ensure_v3( + rqctx: RequestContext, + request: TypedBody, + ) -> Result< + HttpResponseCreated, + HttpError, + >; + #[endpoint { operation_id = "instance_ensure", method = PUT, @@ -68,12 +83,12 @@ pub trait PropolisServerApi { rqctx: RequestContext, request: TypedBody, ) -> Result< - HttpResponseCreated, + HttpResponseCreated, HttpError, > { - Self::instance_ensure( + Self::instance_ensure_v3( rqctx, - request.map(latest::instance::InstanceEnsureRequest::from), + request.map(v3::api::InstanceEnsureRequest::from), ) .await } @@ -88,7 +103,7 @@ pub trait PropolisServerApi { rqctx: RequestContext, request: TypedBody, ) -> Result< - HttpResponseCreated, + HttpResponseCreated, HttpError, > { Self::instance_ensure_v2( @@ -101,7 +116,7 @@ pub trait PropolisServerApi { #[endpoint { method = GET, path = "/instance/spec", - versions = VERSION_ADD_VSOCK.. + versions = VERSION_NVME_WRITE_CACHE.. }] async fn instance_spec_get( rqctx: RequestContext, @@ -110,6 +125,23 @@ pub trait PropolisServerApi { HttpError, >; + #[endpoint { + operation_id = "instance_spec_get", + method = GET, + path = "/instance/spec", + versions = VERSION_ADD_VSOCK..VERSION_NVME_WRITE_CACHE + }] + async fn instance_spec_get_v3( + rqctx: RequestContext, + ) -> Result< + HttpResponseOk, + HttpError, + > { + Ok(Self::instance_spec_get(rqctx) + .await? + .map(v3::instance_spec::InstanceSpecGetResponse::from)) + } + #[endpoint { operation_id = "instance_spec_get", method = GET, @@ -122,7 +154,7 @@ pub trait PropolisServerApi { HttpResponseOk, HttpError, > { - Ok(Self::instance_spec_get(rqctx) + Ok(Self::instance_spec_get_v3(rqctx) .await? .map(v2::instance_spec::InstanceSpecGetResponse::from)) } diff --git a/openapi/propolis-server/propolis-server-1.0.0-833484.json.gitstub b/openapi/propolis-server/propolis-server-1.0.0-833484.json.gitstub deleted file mode 100644 index fa39bf092..000000000 --- a/openapi/propolis-server/propolis-server-1.0.0-833484.json.gitstub +++ /dev/null @@ -1 +0,0 @@ -8e9252917993e36d43dce96b4409ef151b7d4442:openapi/propolis-server/propolis-server-1.0.0-833484.json diff --git a/openapi/propolis-server/propolis-server-2.0.0-d68a9f.json.gitstub b/openapi/propolis-server/propolis-server-2.0.0-d68a9f.json.gitstub deleted file mode 100644 index faa9b4d85..000000000 --- a/openapi/propolis-server/propolis-server-2.0.0-d68a9f.json.gitstub +++ /dev/null @@ -1 +0,0 @@ -fd3636877061da7e951cb1fbce365f7cbf40933c:openapi/propolis-server/propolis-server-2.0.0-d68a9f.json diff --git a/openapi/propolis-server/propolis-server-3.0.0-10da2b.json.gitstub b/openapi/propolis-server/propolis-server-3.0.0-10da2b.json.gitstub deleted file mode 100644 index 90ed1d68f..000000000 --- a/openapi/propolis-server/propolis-server-3.0.0-10da2b.json.gitstub +++ /dev/null @@ -1 +0,0 @@ -368a2225b79328514ce0ea9181d8f874019edaa2:openapi/propolis-server/propolis-server-3.0.0-10da2b.json diff --git a/openapi/propolis-server/propolis-server-6.0.0-b5b984.json b/openapi/propolis-server/propolis-server-6.0.0-b5b984.json new file mode 100644 index 000000000..1bfc621c4 --- /dev/null +++ b/openapi/propolis-server/propolis-server-6.0.0-b5b984.json @@ -0,0 +1,2333 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "Oxide Propolis Server API", + "description": "API for interacting with the Propolis hypervisor frontend.", + "contact": { + "url": "https://oxide.computer", + "email": "api@oxide.computer" + }, + "version": "6.0.0" + }, + "paths": { + "/instance": { + "get": { + "operationId": "instance_get", + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InstanceGetResponse" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "put": { + "operationId": "instance_ensure", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InstanceEnsureRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "successful creation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InstanceEnsureResponse" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/instance/disk/{id}/snapshot/{snapshot_id}": { + "post": { + "summary": "Issues a snapshot request to a crucible backend.", + "operationId": "instance_issue_crucible_snapshot_request", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "snapshot_id", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "title": "Null", + "type": "string", + "enum": [ + null + ] + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/instance/disk/{id}/status": { + "get": { + "summary": "Gets the status of a Crucible volume backing a disk", + "operationId": "disk_volume_status", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VolumeStatus" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/instance/disk/{id}/vcr": { + "put": { + "summary": "Issues a volume_construction_request replace to a crucible backend.", + "operationId": "instance_issue_crucible_vcr_request", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InstanceVCRReplace" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReplaceResult" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/instance/migrate/{migration_id}/start": { + "get": { + "summary": "DO NOT USE THIS IF YOU'RE NOT PROPOLIS-SERVER.", + "description": "Internal API called during a migration from a destination instance to the source instance as part of the HTTP connection upgrade used to establish the migration link. This API is exported via OpenAPI purely to verify that its shape hasn't changed.", + "operationId": "instance_migrate_start", + "parameters": [ + { + "in": "path", + "name": "migration_id", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "101": { + "description": "Negotiating protocol upgrade from HTTP/1.1 to WebSocket" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + }, + "x-dropshot-websocket": {} + } + }, + "/instance/migration-status": { + "get": { + "operationId": "instance_migrate_status", + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InstanceMigrateStatusResponse" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/instance/nmi": { + "post": { + "summary": "Issues an NMI to the instance.", + "operationId": "instance_issue_nmi", + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "title": "Null", + "type": "string", + "enum": [ + null + ] + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/instance/serial": { + "get": { + "operationId": "instance_serial", + "parameters": [ + { + "in": "query", + "name": "from_start", + "description": "Character index in the serial buffer from which to read, counting the bytes output since instance start. If this is provided, `most_recent` must *not* be provided.", + "schema": { + "nullable": true, + "type": "integer", + "format": "uint64", + "minimum": 0 + } + }, + { + "in": "query", + "name": "most_recent", + "description": "Character index in the serial buffer from which to read, counting *backward* from the most recently buffered data retrieved from the instance. (See note on `from_start` about mutual exclusivity)", + "schema": { + "nullable": true, + "type": "integer", + "format": "uint64", + "minimum": 0 + } + } + ], + "responses": { + "101": { + "description": "Negotiating protocol upgrade from HTTP/1.1 to WebSocket" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + }, + "x-dropshot-websocket": {} + } + }, + "/instance/serial/history": { + "get": { + "operationId": "instance_serial_history_get", + "parameters": [ + { + "in": "query", + "name": "from_start", + "description": "Character index in the serial buffer from which to read, counting the bytes output since instance start. If this is not provided, `most_recent` must be provided, and if this *is* provided, `most_recent` must *not* be provided.", + "schema": { + "nullable": true, + "type": "integer", + "format": "uint64", + "minimum": 0 + } + }, + { + "in": "query", + "name": "max_bytes", + "description": "Maximum number of bytes of buffered serial console contents to return. If the requested range runs to the end of the available buffer, the data returned will be shorter than `max_bytes`.", + "schema": { + "nullable": true, + "type": "integer", + "format": "uint64", + "minimum": 0 + } + }, + { + "in": "query", + "name": "most_recent", + "description": "Character index in the serial buffer from which to read, counting *backward* from the most recently buffered data retrieved from the instance. (See note on `from_start` about mutual exclusivity)", + "schema": { + "nullable": true, + "type": "integer", + "format": "uint64", + "minimum": 0 + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InstanceSerialConsoleHistoryResponse" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/instance/spec": { + "get": { + "operationId": "instance_spec_get", + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InstanceSpecGetResponse" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/instance/state": { + "put": { + "operationId": "instance_state_put", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InstanceStateRequested" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/instance/state-monitor": { + "get": { + "operationId": "instance_state_monitor", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InstanceStateMonitorRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InstanceStateMonitorResponse" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + } + }, + "components": { + "schemas": { + "BlobStorageBackend": { + "description": "A storage backend for a disk whose initial contents are given explicitly by the specification.", + "type": "object", + "properties": { + "base64": { + "description": "The disk's initial contents, encoded as a base64 string.", + "type": "string" + }, + "readonly": { + "description": "Indicates whether the storage is read-only.", + "type": "boolean" + } + }, + "required": [ + "base64", + "readonly" + ], + "additionalProperties": false + }, + "Board": { + "description": "A VM's mainboard.", + "type": "object", + "properties": { + "chipset": { + "description": "The chipset to expose to guest software.", + "allOf": [ + { + "$ref": "#/components/schemas/Chipset" + } + ] + }, + "cpuid": { + "nullable": true, + "description": "The CPUID values to expose to the guest. If `None`, bhyve will derive default values from the host's CPUID values.", + "allOf": [ + { + "$ref": "#/components/schemas/Cpuid" + } + ] + }, + "cpus": { + "description": "The number of virtual logical processors attached to this VM.", + "type": "integer", + "format": "uint8", + "minimum": 0 + }, + "guest_hv_interface": { + "description": "The hypervisor platform to expose to the guest. The default is a bhyve-compatible interface with no additional features.\n\nFor compatibility with older versions of Propolis, this field is only serialized if it specifies a non-default interface.", + "allOf": [ + { + "$ref": "#/components/schemas/GuestHypervisorInterface" + } + ] + }, + "memory_mb": { + "description": "The amount of guest RAM attached to this VM.", + "type": "integer", + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "chipset", + "cpus", + "memory_mb" + ], + "additionalProperties": false + }, + "BootOrderEntry": { + "description": "An entry in the boot order stored in a [`BootSettings`] component.", + "type": "object", + "properties": { + "id": { + "description": "The ID of another component in the spec that Propolis should try to boot from.\n\nCurrently, only disk device components are supported.", + "allOf": [ + { + "$ref": "#/components/schemas/SpecKey" + } + ] + } + }, + "required": [ + "id" + ] + }, + "BootSettings": { + "description": "Settings supplied to the guest's firmware image that specify the order in which it should consider its options when selecting a device to try to boot from.", + "type": "object", + "properties": { + "order": { + "description": "An ordered list of components to attempt to boot from.", + "type": "array", + "items": { + "$ref": "#/components/schemas/BootOrderEntry" + } + } + }, + "required": [ + "order" + ], + "additionalProperties": false + }, + "Chipset": { + "description": "A kind of virtual chipset.", + "oneOf": [ + { + "description": "An Intel 440FX-compatible chipset.", + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "i440_fx" + ] + }, + "value": { + "$ref": "#/components/schemas/I440Fx" + } + }, + "required": [ + "type", + "value" + ], + "additionalProperties": false + } + ] + }, + "Component": { + "oneOf": [ + { + "type": "object", + "properties": { + "component": { + "$ref": "#/components/schemas/VirtioDisk" + }, + "type": { + "type": "string", + "enum": [ + "virtio_disk" + ] + } + }, + "required": [ + "component", + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "component": { + "$ref": "#/components/schemas/NvmeDisk" + }, + "type": { + "type": "string", + "enum": [ + "nvme_disk" + ] + } + }, + "required": [ + "component", + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "component": { + "$ref": "#/components/schemas/VirtioNic" + }, + "type": { + "type": "string", + "enum": [ + "virtio_nic" + ] + } + }, + "required": [ + "component", + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "component": { + "$ref": "#/components/schemas/SerialPort" + }, + "type": { + "type": "string", + "enum": [ + "serial_port" + ] + } + }, + "required": [ + "component", + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "component": { + "$ref": "#/components/schemas/PciPciBridge" + }, + "type": { + "type": "string", + "enum": [ + "pci_pci_bridge" + ] + } + }, + "required": [ + "component", + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "component": { + "$ref": "#/components/schemas/QemuPvpanic" + }, + "type": { + "type": "string", + "enum": [ + "qemu_pvpanic" + ] + } + }, + "required": [ + "component", + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "component": { + "$ref": "#/components/schemas/BootSettings" + }, + "type": { + "type": "string", + "enum": [ + "boot_settings" + ] + } + }, + "required": [ + "component", + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "component": { + "$ref": "#/components/schemas/VirtioSocket" + }, + "type": { + "type": "string", + "enum": [ + "virtio_socket" + ] + } + }, + "required": [ + "component", + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "component": { + "$ref": "#/components/schemas/SoftNpuPciPort" + }, + "type": { + "type": "string", + "enum": [ + "soft_npu_pci_port" + ] + } + }, + "required": [ + "component", + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "component": { + "$ref": "#/components/schemas/SoftNpuPort" + }, + "type": { + "type": "string", + "enum": [ + "soft_npu_port" + ] + } + }, + "required": [ + "component", + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "component": { + "$ref": "#/components/schemas/SoftNpuP9" + }, + "type": { + "type": "string", + "enum": [ + "soft_npu_p9" + ] + } + }, + "required": [ + "component", + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "component": { + "$ref": "#/components/schemas/P9fs" + }, + "type": { + "type": "string", + "enum": [ + "p9fs" + ] + } + }, + "required": [ + "component", + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "component": { + "$ref": "#/components/schemas/MigrationFailureInjector" + }, + "type": { + "type": "string", + "enum": [ + "migration_failure_injector" + ] + } + }, + "required": [ + "component", + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "component": { + "$ref": "#/components/schemas/CrucibleStorageBackend" + }, + "type": { + "type": "string", + "enum": [ + "crucible_storage_backend" + ] + } + }, + "required": [ + "component", + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "component": { + "$ref": "#/components/schemas/FileStorageBackend" + }, + "type": { + "type": "string", + "enum": [ + "file_storage_backend" + ] + } + }, + "required": [ + "component", + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "component": { + "$ref": "#/components/schemas/BlobStorageBackend" + }, + "type": { + "type": "string", + "enum": [ + "blob_storage_backend" + ] + } + }, + "required": [ + "component", + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "component": { + "$ref": "#/components/schemas/VirtioNetworkBackend" + }, + "type": { + "type": "string", + "enum": [ + "virtio_network_backend" + ] + } + }, + "required": [ + "component", + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "component": { + "$ref": "#/components/schemas/DlpiNetworkBackend" + }, + "type": { + "type": "string", + "enum": [ + "dlpi_network_backend" + ] + } + }, + "required": [ + "component", + "type" + ], + "additionalProperties": false + } + ] + }, + "Cpuid": { + "description": "A set of CPUID values to expose to a guest.", + "type": "object", + "properties": { + "entries": { + "description": "A list of CPUID leaves/subleaves and their associated values.\n\nPropolis servers require that each entry's `leaf` be unique and that it falls in either the \"standard\" (0 to 0xFFFF) or \"extended\" (0x8000_0000 to 0x8000_FFFF) function ranges, since these are the only valid input ranges currently defined by Intel and AMD. See the Intel 64 and IA-32 Architectures Software Developer's Manual (June 2024) Table 3-17 and the AMD64 Architecture Programmer's Manual (March 2024) Volume 3's documentation of the CPUID instruction.", + "type": "array", + "items": { + "$ref": "#/components/schemas/CpuidEntry" + } + }, + "vendor": { + "description": "The CPU vendor to emulate.\n\nCPUID leaves in the extended range (0x8000_0000 to 0x8000_FFFF) have vendor-defined semantics. Propolis uses this value to determine these semantics when deciding whether it needs to specialize the supplied template values for these leaves.", + "allOf": [ + { + "$ref": "#/components/schemas/CpuidVendor" + } + ] + } + }, + "required": [ + "entries", + "vendor" + ], + "additionalProperties": false + }, + "CpuidEntry": { + "description": "A full description of a CPUID leaf/subleaf and the values it produces.", + "type": "object", + "properties": { + "eax": { + "description": "The value to return in eax.", + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "ebx": { + "description": "The value to return in ebx.", + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "ecx": { + "description": "The value to return in ecx.", + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "edx": { + "description": "The value to return in edx.", + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "leaf": { + "description": "The leaf (function) number for this entry.", + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "subleaf": { + "nullable": true, + "description": "The subleaf (index) number for this entry, if it uses subleaves.", + "type": "integer", + "format": "uint32", + "minimum": 0 + } + }, + "required": [ + "eax", + "ebx", + "ecx", + "edx", + "leaf" + ], + "additionalProperties": false + }, + "CpuidVendor": { + "description": "A CPU vendor to use when interpreting the meanings of CPUID leaves in the extended ID range (0x80000000 to 0x8000FFFF).", + "type": "string", + "enum": [ + "amd", + "intel" + ] + }, + "CrucibleStorageBackend": { + "description": "A Crucible storage backend.", + "type": "object", + "properties": { + "readonly": { + "description": "Indicates whether the storage is read-only.", + "type": "boolean" + }, + "request_json": { + "description": "A serialized `[crucible_client_types::VolumeConstructionRequest]`. This is stored in serialized form so that breaking changes to the definition of a `VolumeConstructionRequest` do not inadvertently break instance spec deserialization.\n\nWhen using a spec to initialize a new instance, the spec author must ensure this request is well-formed and can be deserialized by the version of `crucible_client_types` used by the target Propolis.", + "type": "string" + } + }, + "required": [ + "readonly", + "request_json" + ], + "additionalProperties": false + }, + "DlpiNetworkBackend": { + "description": "A network backend associated with a DLPI VNIC on the host.", + "type": "object", + "properties": { + "vnic_name": { + "description": "The name of the VNIC to use as a backend.", + "type": "string" + } + }, + "required": [ + "vnic_name" + ], + "additionalProperties": false + }, + "DownstairsInfo": { + "type": "object", + "properties": { + "region_id": { + "nullable": true, + "type": "string", + "format": "uuid" + }, + "repair_addr": { + "nullable": true, + "type": "string" + }, + "state": { + "$ref": "#/components/schemas/DownstairsInfoStatus" + }, + "target_addr": { + "nullable": true, + "type": "string" + } + }, + "required": [ + "state" + ] + }, + "DownstairsInfoConnectionMode": { + "type": "string", + "enum": [ + "new", + "offline", + "faulted", + "replaced" + ] + }, + "DownstairsInfoNegotiationStatus": { + "type": "string", + "enum": [ + "wait_connect", + "negotiating", + "wait_quorum", + "reconcile", + "live_repair_ready" + ] + }, + "DownstairsInfoStatus": { + "oneOf": [ + { + "type": "object", + "properties": { + "mode": { + "$ref": "#/components/schemas/DownstairsInfoConnectionMode" + }, + "state": { + "$ref": "#/components/schemas/DownstairsInfoNegotiationStatus" + }, + "type": { + "type": "string", + "enum": [ + "connecting" + ] + } + }, + "required": [ + "mode", + "state", + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "active" + ] + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "live_repair" + ] + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "stopping" + ] + } + }, + "required": [ + "type" + ] + } + ] + }, + "Error": { + "description": "Error information from a response.", + "type": "object", + "properties": { + "error_code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "request_id": { + "type": "string" + } + }, + "required": [ + "message", + "request_id" + ] + }, + "FileStorageBackend": { + "description": "A storage backend backed by a file in the host system's file system.", + "type": "object", + "properties": { + "block_size": { + "description": "Block size of the backend", + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "path": { + "description": "A path to a file that backs a disk.", + "type": "string" + }, + "readonly": { + "description": "Indicates whether the storage is read-only.", + "type": "boolean" + }, + "workers": { + "nullable": true, + "description": "Optional worker threads for the file backend, exposed for testing only.", + "type": "integer", + "format": "uint", + "minimum": 1 + } + }, + "required": [ + "block_size", + "path", + "readonly" + ], + "additionalProperties": false + }, + "GuestHypervisorInterface": { + "description": "A hypervisor interface to expose to the guest.", + "oneOf": [ + { + "description": "Expose a bhyve-like interface (\"bhyve bhyve \" as the hypervisor ID in leaf 0x4000_0000 and no additional leaves or features).", + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "bhyve" + ] + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "description": "Expose a Hyper-V-compatible hypervisor interface with the supplied features enabled.", + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "hyper_v" + ] + }, + "value": { + "type": "object", + "properties": { + "features": { + "type": "array", + "items": { + "$ref": "#/components/schemas/HyperVFeatureFlag" + }, + "uniqueItems": true + } + }, + "required": [ + "features" + ], + "additionalProperties": false + } + }, + "required": [ + "type", + "value" + ], + "additionalProperties": false + } + ] + }, + "HyperVFeatureFlag": { + "description": "Flags that enable \"simple\" Hyper-V enlightenments that require no feature-specific configuration.", + "type": "string", + "enum": [ + "reference_tsc" + ] + }, + "I440Fx": { + "description": "An Intel 440FX-compatible chipset.", + "type": "object", + "properties": { + "enable_pcie": { + "description": "Specifies whether the chipset should allow PCI configuration space to be accessed through the PCIe extended configuration mechanism.", + "type": "boolean" + } + }, + "required": [ + "enable_pcie" + ], + "additionalProperties": false + }, + "Instance": { + "type": "object", + "properties": { + "properties": { + "$ref": "#/components/schemas/InstanceProperties" + }, + "state": { + "$ref": "#/components/schemas/InstanceState" + } + }, + "required": [ + "properties", + "state" + ] + }, + "InstanceEnsureRequest": { + "type": "object", + "properties": { + "init": { + "$ref": "#/components/schemas/InstanceInitializationMethod" + }, + "properties": { + "$ref": "#/components/schemas/InstanceProperties" + } + }, + "required": [ + "init", + "properties" + ] + }, + "InstanceEnsureResponse": { + "type": "object", + "properties": { + "migrate": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/InstanceMigrateInitiateResponse" + } + ] + } + } + }, + "InstanceGetResponse": { + "type": "object", + "properties": { + "instance": { + "$ref": "#/components/schemas/Instance" + } + }, + "required": [ + "instance" + ] + }, + "InstanceInitializationMethod": { + "oneOf": [ + { + "type": "object", + "properties": { + "method": { + "type": "string", + "enum": [ + "Spec" + ] + }, + "value": { + "type": "object", + "properties": { + "spec": { + "$ref": "#/components/schemas/InstanceSpec" + } + }, + "required": [ + "spec" + ] + } + }, + "required": [ + "method", + "value" + ] + }, + { + "type": "object", + "properties": { + "method": { + "type": "string", + "enum": [ + "MigrationTarget" + ] + }, + "value": { + "type": "object", + "properties": { + "migration_id": { + "type": "string", + "format": "uuid" + }, + "replace_components": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/ReplacementComponent" + } + }, + "src_addr": { + "type": "string" + } + }, + "required": [ + "migration_id", + "replace_components", + "src_addr" + ] + } + }, + "required": [ + "method", + "value" + ] + } + ] + }, + "InstanceMetadata": { + "type": "object", + "properties": { + "project_id": { + "type": "string", + "format": "uuid" + }, + "silo_id": { + "type": "string", + "format": "uuid" + }, + "sled_id": { + "type": "string", + "format": "uuid" + }, + "sled_model": { + "type": "string" + }, + "sled_revision": { + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "sled_serial": { + "type": "string" + } + }, + "required": [ + "project_id", + "silo_id", + "sled_id", + "sled_model", + "sled_revision", + "sled_serial" + ] + }, + "InstanceMigrateInitiateResponse": { + "type": "object", + "properties": { + "migration_id": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "migration_id" + ] + }, + "InstanceMigrateStatusResponse": { + "description": "The statuses of the most recent attempts to live migrate into and out of this Propolis.\n\nIf a VM is initialized by migration in and then begins to migrate out, this structure will contain statuses for both migrations. This ensures that clients can always obtain the status of a successful migration in even after a migration out begins.\n\nThis structure only reports the status of the most recent migration in a single direction. That is, if a migration in or out fails, and a new migration attempt begins, the new migration's status replaces the old's.", + "type": "object", + "properties": { + "migration_in": { + "nullable": true, + "description": "The status of the most recent attempt to initialize the current instance via migration in, or `None` if the instance has never been a migration target.", + "allOf": [ + { + "$ref": "#/components/schemas/InstanceMigrationStatus" + } + ] + }, + "migration_out": { + "nullable": true, + "description": "The status of the most recent attempt to migrate out of the current instance, or `None` if the instance has never been a migration source.", + "allOf": [ + { + "$ref": "#/components/schemas/InstanceMigrationStatus" + } + ] + } + } + }, + "InstanceMigrationStatus": { + "description": "The status of an individual live migration.", + "type": "object", + "properties": { + "id": { + "description": "The ID of this migration, supplied either by the external migration requester (for targets) or the other side of the migration (for sources).", + "type": "string", + "format": "uuid" + }, + "state": { + "description": "The current phase the migration is in.", + "allOf": [ + { + "$ref": "#/components/schemas/MigrationState" + } + ] + } + }, + "required": [ + "id", + "state" + ] + }, + "InstanceProperties": { + "type": "object", + "properties": { + "description": { + "description": "Free-form text description of an Instance.", + "type": "string" + }, + "id": { + "description": "Unique identifier for this Instance.", + "type": "string", + "format": "uuid" + }, + "metadata": { + "description": "Metadata used to track statistics for this Instance.", + "allOf": [ + { + "$ref": "#/components/schemas/InstanceMetadata" + } + ] + }, + "name": { + "description": "Human-readable name of the Instance.", + "type": "string" + } + }, + "required": [ + "description", + "id", + "metadata", + "name" + ] + }, + "InstanceSerialConsoleHistoryResponse": { + "description": "Contents of an Instance's serial console buffer.", + "type": "object", + "properties": { + "data": { + "description": "The bytes starting from the requested offset up to either the end of the buffer or the request's `max_bytes`. Provided as a u8 array rather than a string, as it may not be UTF-8.", + "type": "array", + "items": { + "type": "integer", + "format": "uint8", + "minimum": 0 + } + }, + "last_byte_offset": { + "description": "The absolute offset since boot (suitable for use as `byte_offset` in a subsequent request) of the last byte returned in `data`.", + "type": "integer", + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "data", + "last_byte_offset" + ] + }, + "InstanceSpec": { + "type": "object", + "properties": { + "board": { + "$ref": "#/components/schemas/Board" + }, + "components": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/Component" + } + }, + "smbios": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/SmbiosType1Input" + } + ] + } + }, + "required": [ + "board", + "components" + ] + }, + "InstanceSpecGetResponse": { + "type": "object", + "properties": { + "properties": { + "$ref": "#/components/schemas/InstanceProperties" + }, + "spec": { + "$ref": "#/components/schemas/InstanceSpecStatus" + }, + "state": { + "$ref": "#/components/schemas/InstanceState" + } + }, + "required": [ + "properties", + "spec", + "state" + ] + }, + "InstanceSpecStatus": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "WaitingForMigrationSource" + ] + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "Present" + ] + }, + "value": { + "$ref": "#/components/schemas/InstanceSpec" + } + }, + "required": [ + "type", + "value" + ] + } + ] + }, + "InstanceState": { + "description": "Current state of an Instance.", + "type": "string", + "enum": [ + "Creating", + "Starting", + "Running", + "Stopping", + "Stopped", + "Rebooting", + "Migrating", + "Repairing", + "Failed", + "Destroyed" + ] + }, + "InstanceStateMonitorRequest": { + "type": "object", + "properties": { + "gen": { + "type": "integer", + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "gen" + ] + }, + "InstanceStateMonitorResponse": { + "type": "object", + "properties": { + "gen": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "migration": { + "$ref": "#/components/schemas/InstanceMigrateStatusResponse" + }, + "state": { + "$ref": "#/components/schemas/InstanceState" + } + }, + "required": [ + "gen", + "migration", + "state" + ] + }, + "InstanceStateRequested": { + "type": "string", + "enum": [ + "Run", + "Stop", + "Reboot" + ] + }, + "InstanceVCRReplace": { + "type": "object", + "properties": { + "vcr_json": { + "type": "string" + } + }, + "required": [ + "vcr_json" + ] + }, + "MigrationFailureInjector": { + "description": "Describes a synthetic device that registers for VM lifecycle notifications and returns errors during attempts to migrate.\n\nThis is only supported by Propolis servers compiled with the `failure-injection` feature.", + "type": "object", + "properties": { + "fail_exports": { + "description": "The number of times this device should fail requests to export state.", + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "fail_imports": { + "description": "The number of times this device should fail requests to import state.", + "type": "integer", + "format": "uint32", + "minimum": 0 + } + }, + "required": [ + "fail_exports", + "fail_imports" + ], + "additionalProperties": false + }, + "MigrationState": { + "type": "string", + "enum": [ + "Sync", + "RamPush", + "Pause", + "RamPushDirty", + "Device", + "Resume", + "RamPull", + "Server", + "Finish", + "Error" + ] + }, + "NvmeDisk": { + "description": "A disk that presents an NVMe interface to the guest.", + "type": "object", + "properties": { + "backend_id": { + "description": "The name of the disk's backend component.", + "allOf": [ + { + "$ref": "#/components/schemas/SpecKey" + } + ] + }, + "has_write_cache": { + "description": "Control if the NVMe disk reports the presence of a volatile write cache.\n\nThis generally should be configured in consideration of the storage backend for the NVMe device. \"true\" is a safe default, and was historically the only configurable value. If the storage backend will not lose data once writes are accepted, even in the face of unplanned crashes or power loss (or, if you really want to lie to guests), setting this to \"false\" can advise guests they may skip issuing flushes to the device.", + "type": "boolean" + }, + "pci_path": { + "description": "The PCI bus/device/function at which this disk should be attached.", + "allOf": [ + { + "$ref": "#/components/schemas/PciPath" + } + ] + }, + "serial_number": { + "description": "The serial number to return in response to an NVMe Identify Controller command.", + "type": "array", + "items": { + "type": "integer", + "format": "uint8", + "minimum": 0 + }, + "minItems": 20, + "maxItems": 20 + } + }, + "required": [ + "backend_id", + "has_write_cache", + "pci_path", + "serial_number" + ], + "additionalProperties": false + }, + "P9fs": { + "description": "Describes a filesystem to expose through a P9 device.\n\nThis is only supported by Propolis servers compiled with the `falcon` feature.", + "type": "object", + "properties": { + "chunk_size": { + "description": "The chunk size to use in the 9P protocol. Vanilla Helios images should use 8192. Falcon Helios base images and Linux can use up to 65536.", + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "pci_path": { + "description": "The PCI path at which to attach the guest to this P9 filesystem.", + "allOf": [ + { + "$ref": "#/components/schemas/PciPath" + } + ] + }, + "source": { + "description": "The host source path to mount into the guest.", + "type": "string" + }, + "target": { + "description": "The 9P target filesystem tag.", + "type": "string" + } + }, + "required": [ + "chunk_size", + "pci_path", + "source", + "target" + ], + "additionalProperties": false + }, + "PciPath": { + "description": "A PCI bus/device/function tuple.", + "type": "object", + "properties": { + "bus": { + "type": "integer", + "format": "uint8", + "minimum": 0 + }, + "device": { + "type": "integer", + "format": "uint8", + "minimum": 0 + }, + "function": { + "type": "integer", + "format": "uint8", + "minimum": 0 + } + }, + "required": [ + "bus", + "device", + "function" + ] + }, + "PciPciBridge": { + "description": "A PCI-PCI bridge.", + "type": "object", + "properties": { + "downstream_bus": { + "description": "The logical bus number of this bridge's downstream bus. Other devices may use this bus number in their PCI paths to indicate they should be attached to this bridge's bus.", + "type": "integer", + "format": "uint8", + "minimum": 0 + }, + "pci_path": { + "description": "The PCI path at which to attach this bridge.", + "allOf": [ + { + "$ref": "#/components/schemas/PciPath" + } + ] + } + }, + "required": [ + "downstream_bus", + "pci_path" + ], + "additionalProperties": false + }, + "QemuPvpanic": { + "type": "object", + "properties": { + "enable_isa": { + "description": "Enable the QEMU PVPANIC ISA bus device (I/O port 0x505).", + "type": "boolean" + } + }, + "required": [ + "enable_isa" + ], + "additionalProperties": false + }, + "ReplaceResult": { + "type": "string", + "enum": [ + "started", + "started_already", + "completed_already", + "missing", + "vcr_matches" + ] + }, + "ReplacementComponent": { + "description": "An instance spec component that should be replaced during a live migration.", + "oneOf": [ + { + "type": "object", + "properties": { + "component": { + "type": "string", + "enum": [ + "MigrationFailureInjector" + ] + }, + "spec": { + "$ref": "#/components/schemas/MigrationFailureInjector" + } + }, + "required": [ + "component", + "spec" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "component": { + "type": "string", + "enum": [ + "CrucibleStorageBackend" + ] + }, + "spec": { + "$ref": "#/components/schemas/CrucibleStorageBackend" + } + }, + "required": [ + "component", + "spec" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "component": { + "type": "string", + "enum": [ + "VirtioNetworkBackend" + ] + }, + "spec": { + "$ref": "#/components/schemas/VirtioNetworkBackend" + } + }, + "required": [ + "component", + "spec" + ], + "additionalProperties": false + } + ] + }, + "SerialPort": { + "description": "A serial port device.", + "type": "object", + "properties": { + "num": { + "description": "The serial port number for this port.", + "allOf": [ + { + "$ref": "#/components/schemas/SerialPortNumber" + } + ] + } + }, + "required": [ + "num" + ], + "additionalProperties": false + }, + "SerialPortNumber": { + "description": "A serial port identifier, which determines what I/O ports a guest can use to access a port.", + "type": "string", + "enum": [ + "com1", + "com2", + "com3", + "com4" + ] + }, + "SmbiosType1Input": { + "type": "object", + "properties": { + "manufacturer": { + "type": "string" + }, + "product_name": { + "type": "string" + }, + "serial_number": { + "type": "string" + }, + "version": { + "type": "integer", + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "manufacturer", + "product_name", + "serial_number", + "version" + ], + "additionalProperties": false + }, + "SoftNpuP9": { + "description": "Describes a PCI device that shares host files with the guest using the P9 protocol.\n\nThis is only supported by Propolis servers compiled with the `falcon` feature.", + "type": "object", + "properties": { + "pci_path": { + "description": "The PCI path at which to attach the guest to this port.", + "allOf": [ + { + "$ref": "#/components/schemas/PciPath" + } + ] + } + }, + "required": [ + "pci_path" + ], + "additionalProperties": false + }, + "SoftNpuPciPort": { + "description": "Describes a SoftNPU PCI device.\n\nThis is only supported by Propolis servers compiled with the `falcon` feature.", + "type": "object", + "properties": { + "pci_path": { + "description": "The PCI path at which to attach the guest to this port.", + "allOf": [ + { + "$ref": "#/components/schemas/PciPath" + } + ] + } + }, + "required": [ + "pci_path" + ], + "additionalProperties": false + }, + "SoftNpuPort": { + "description": "Describes a port in a SoftNPU emulated ASIC.\n\nThis is only supported by Propolis servers compiled with the `falcon` feature.", + "type": "object", + "properties": { + "backend_id": { + "description": "The name of the port's associated DLPI backend.", + "allOf": [ + { + "$ref": "#/components/schemas/SpecKey" + } + ] + }, + "link_name": { + "description": "The data link name for this port.", + "type": "string" + } + }, + "required": [ + "backend_id", + "link_name" + ], + "additionalProperties": false + }, + "SpecKey": { + "description": "A key identifying a component in an instance spec.", + "oneOf": [ + { + "title": "uuid", + "allOf": [ + { + "type": "string", + "format": "uuid" + } + ] + }, + { + "title": "name", + "allOf": [ + { + "type": "string" + } + ] + } + ] + }, + "UpstairsInfoStatus": { + "type": "string", + "enum": [ + "initializing", + "go_active", + "active", + "deactivating", + "disabled" + ] + }, + "VirtioDisk": { + "description": "A disk that presents a virtio-block interface to the guest.", + "type": "object", + "properties": { + "backend_id": { + "description": "The name of the disk's backend component.", + "allOf": [ + { + "$ref": "#/components/schemas/SpecKey" + } + ] + }, + "pci_path": { + "description": "The PCI bus/device/function at which this disk should be attached.", + "allOf": [ + { + "$ref": "#/components/schemas/PciPath" + } + ] + } + }, + "required": [ + "backend_id", + "pci_path" + ], + "additionalProperties": false + }, + "VirtioNetworkBackend": { + "description": "A network backend associated with a virtio-net (viona) VNIC on the host.", + "type": "object", + "properties": { + "vnic_name": { + "description": "The name of the viona VNIC to use as a backend.", + "type": "string" + } + }, + "required": [ + "vnic_name" + ], + "additionalProperties": false + }, + "VirtioNic": { + "description": "A network card that presents a virtio-net interface to the guest.", + "type": "object", + "properties": { + "backend_id": { + "description": "The name of the device's backend.", + "allOf": [ + { + "$ref": "#/components/schemas/SpecKey" + } + ] + }, + "interface_id": { + "description": "A caller-defined correlation identifier for this interface. If Propolis is configured to collect network interface kstats in its Oximeter metrics, the metric series for this interface will be associated with this identifier.", + "type": "string", + "format": "uuid" + }, + "pci_path": { + "description": "The PCI path at which to attach this device.", + "allOf": [ + { + "$ref": "#/components/schemas/PciPath" + } + ] + } + }, + "required": [ + "backend_id", + "interface_id", + "pci_path" + ], + "additionalProperties": false + }, + "VirtioSocket": { + "description": "A socket device that presents a virtio-socket interface to the guest.", + "type": "object", + "properties": { + "guest_cid": { + "description": "The guest's Context ID.", + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "pci_path": { + "description": "The PCI path at which to attach this device.", + "allOf": [ + { + "$ref": "#/components/schemas/PciPath" + } + ] + } + }, + "required": [ + "guest_cid", + "pci_path" + ], + "additionalProperties": false + }, + "VolumeInfo": { + "description": "A tree representation of the info and status of all parts of a Volume.", + "oneOf": [ + { + "type": "object", + "properties": { + "volume": { + "type": "object", + "properties": { + "read_only_parent": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/VolumeInfo" + } + ] + }, + "sub_volumes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/VolumeInfo" + } + } + }, + "required": [ + "sub_volumes" + ] + } + }, + "required": [ + "volume" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "upstairs": { + "type": "object", + "properties": { + "block_size": { + "nullable": true, + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "encrypted": { + "type": "boolean" + }, + "generation": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "live_repair_in_progress": { + "type": "boolean" + }, + "read_only": { + "type": "boolean" + }, + "reconcile_in_progress": { + "type": "boolean" + }, + "session_id": { + "type": "string", + "format": "uuid" + }, + "state": { + "$ref": "#/components/schemas/UpstairsInfoStatus" + }, + "targets": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DownstairsInfo" + } + }, + "upstairs_id": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "encrypted", + "generation", + "live_repair_in_progress", + "read_only", + "reconcile_in_progress", + "session_id", + "state", + "targets", + "upstairs_id" + ] + } + }, + "required": [ + "upstairs" + ], + "additionalProperties": false + } + ] + }, + "VolumeStatus": { + "type": "object", + "properties": { + "volume_info": { + "$ref": "#/components/schemas/VolumeInfo" + } + }, + "required": [ + "volume_info" + ] + } + }, + "responses": { + "Error": { + "description": "Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } +} diff --git a/openapi/propolis-server/propolis-server-latest.json b/openapi/propolis-server/propolis-server-latest.json index 452ab4db5..114ec385b 120000 --- a/openapi/propolis-server/propolis-server-latest.json +++ b/openapi/propolis-server/propolis-server-latest.json @@ -1 +1 @@ -propolis-server-5.0.0-0c6dd9.json \ No newline at end of file +propolis-server-6.0.0-b5b984.json \ No newline at end of file From 68b138ad564d00ca231126410dc8eb313b9cef2e Mon Sep 17 00:00:00 2001 From: iximeow Date: Thu, 9 Jul 2026 17:47:44 +0000 Subject: [PATCH 03/24] parse has_write_cache in propolis-cli --- crates/propolis-config-toml/src/spec.rs | 71 +++++++++++++++++++------ 1 file changed, 54 insertions(+), 17 deletions(-) diff --git a/crates/propolis-config-toml/src/spec.rs b/crates/propolis-config-toml/src/spec.rs index 78bdec4b5..69316c10d 100644 --- a/crates/propolis-config-toml/src/spec.rs +++ b/crates/propolis-config-toml/src/spec.rs @@ -4,10 +4,7 @@ //! Functions for converting a [`super::Config`] into instance spec elements. -use std::{ - collections::BTreeMap, - str::{FromStr, ParseBoolError}, -}; +use std::{collections::BTreeMap, str::FromStr}; use propolis_client::{ instance_spec::{ @@ -54,8 +51,14 @@ pub enum TomlToSpecError { #[error("couldn't get path for file backend {0:?}")] InvalidFileBackendPath(String), - #[error("failed to parse read-only option for file backend {0:?}")] - FileBackendReadonlyParseFailed(String, #[source] ParseBoolError), + #[error("failed to parse option \"{field}\" for {name}: {error}")] + FieldParseError { + field: &'static str, + name: String, + // "String" is just a lowest common denominator for the different kinds + // of parse errors we might see. + error: String, + }, #[error("failed to get VNIC name for device {0:?}")] NoVnicName(String), @@ -335,13 +338,46 @@ fn parse_storage_device_from_config( Interface::Virtio => { Component::VirtioDisk(VirtioDisk { backend_id, pci_path }) } - Interface::Nvme => Component::NvmeDisk(NvmeDisk { - backend_id, - pci_path, - serial_number: nvme_serial_from_str(name, b' '), - // XXX(ixi): this should actually be read from tomls and defaulted normally - has_write_cache: true, - }), + Interface::Nvme => { + let write_cache_opt = device + .get("has_write_cache") + .map(|v: toml::Value| { + let s = v.as_str().ok_or_else(|| { + TomlToSpecError::FieldParseError { + field: "has_write_cache", + name: name.to_owned(), + error: format!( + "field must be a boolean, was {:?}", + v + ), + } + }); + + s.and_then(|s| { + s.parse::().map_err(|e| { + TomlToSpecError::FieldParseError { + field: "has_write_cache", + name: name.to_owned(), + error: e.to_string(), + } + }) + }) + }) + .transpose()?; + + // Reporting a write cache when the underlying medium does not + // causes unnecessary guest work, but is not a correctness + // issue. The converse can be. Default to reporting write caches + // if we're not instructed otherwise. + let has_write_cache = write_cache_opt.unwrap_or(true); + + Component::NvmeDisk(NvmeDisk { + backend_id, + pci_path, + serial_number: nvme_serial_from_str(name, b' '), + has_write_cache, + }) + } }, id_to_return, )) @@ -368,10 +404,11 @@ fn parse_storage_backend_from_config( Some(toml::Value::Boolean(ro)) => Some(*ro), Some(toml::Value::String(v)) => { Some(v.parse::().map_err(|e| { - TomlToSpecError::FileBackendReadonlyParseFailed( - name.to_owned(), - e, - ) + TomlToSpecError::FieldParseError { + field: "readonly", + name: name.to_owned(), + error: e.to_string(), + } })?) } _ => None, From d803c01941c8397d99e3a5f4e34c3b12853f3abc Mon Sep 17 00:00:00 2001 From: iximeow Date: Thu, 9 Jul 2026 17:57:40 +0000 Subject: [PATCH 04/24] the gitstubs should remain! --- .../propolis-server/propolis-server-1.0.0-833484.json.gitstub | 1 + .../propolis-server/propolis-server-2.0.0-d68a9f.json.gitstub | 1 + .../propolis-server/propolis-server-3.0.0-10da2b.json.gitstub | 1 + 3 files changed, 3 insertions(+) create mode 100644 openapi/propolis-server/propolis-server-1.0.0-833484.json.gitstub create mode 100644 openapi/propolis-server/propolis-server-2.0.0-d68a9f.json.gitstub create mode 100644 openapi/propolis-server/propolis-server-3.0.0-10da2b.json.gitstub diff --git a/openapi/propolis-server/propolis-server-1.0.0-833484.json.gitstub b/openapi/propolis-server/propolis-server-1.0.0-833484.json.gitstub new file mode 100644 index 000000000..fa39bf092 --- /dev/null +++ b/openapi/propolis-server/propolis-server-1.0.0-833484.json.gitstub @@ -0,0 +1 @@ +8e9252917993e36d43dce96b4409ef151b7d4442:openapi/propolis-server/propolis-server-1.0.0-833484.json diff --git a/openapi/propolis-server/propolis-server-2.0.0-d68a9f.json.gitstub b/openapi/propolis-server/propolis-server-2.0.0-d68a9f.json.gitstub new file mode 100644 index 000000000..faa9b4d85 --- /dev/null +++ b/openapi/propolis-server/propolis-server-2.0.0-d68a9f.json.gitstub @@ -0,0 +1 @@ +fd3636877061da7e951cb1fbce365f7cbf40933c:openapi/propolis-server/propolis-server-2.0.0-d68a9f.json diff --git a/openapi/propolis-server/propolis-server-3.0.0-10da2b.json.gitstub b/openapi/propolis-server/propolis-server-3.0.0-10da2b.json.gitstub new file mode 100644 index 000000000..90ed1d68f --- /dev/null +++ b/openapi/propolis-server/propolis-server-3.0.0-10da2b.json.gitstub @@ -0,0 +1 @@ +368a2225b79328514ce0ea9181d8f874019edaa2:openapi/propolis-server/propolis-server-3.0.0-10da2b.json From 03c90e677a25b6866057d1129e60815be8a3a2b4 Mon Sep 17 00:00:00 2001 From: iximeow Date: Thu, 9 Jul 2026 20:29:34 +0000 Subject: [PATCH 05/24] wip: configurable vwc in tests --- bin/propolis-server/src/lib/spec/mod.rs | 30 +++++++++++---------- crates/propolis-server-api/src/lib.rs | 10 +++++-- phd-tests/framework/src/test_vm/config.rs | 21 ++++++++++++--- phd-tests/tests/src/boot_order.rs | 12 ++++----- phd-tests/tests/src/crucible/mod.rs | 2 +- phd-tests/tests/src/crucible/smoke.rs | 2 +- phd-tests/tests/src/server_state_machine.rs | 2 +- 7 files changed, 51 insertions(+), 28 deletions(-) diff --git a/bin/propolis-server/src/lib/spec/mod.rs b/bin/propolis-server/src/lib/spec/mod.rs index b6110e16a..f32a8df3e 100644 --- a/bin/propolis-server/src/lib/spec/mod.rs +++ b/bin/propolis-server/src/lib/spec/mod.rs @@ -36,7 +36,7 @@ use propolis_api_types::instance_spec::{ use propolis_api_types::instance_spec::{ Component, InstanceSpec, SmbiosType1Input, }; -use propolis_api_types_versions::{v1, v2}; +use propolis_api_types_versions::{v1, v2, v3}; use thiserror::Error; #[cfg(feature = "failure-injection")] @@ -62,7 +62,8 @@ impl From for InstanceSpec { let v1_spec: v1::instance_spec::InstanceSpec = val.into(); let v2_spec = v2::instance_spec::InstanceSpec { smbios, ..v1_spec.into() }; - let mut spec: InstanceSpec = v2_spec.into(); + let v3_spec: v3::instance_spec::InstanceSpec = v2_spec.into(); + let mut spec: InstanceSpec = v3_spec.into(); if let Some(vsock) = vsock { spec.components @@ -87,7 +88,8 @@ impl TryFrom for Spec { } } - let v2_spec: v2::instance_spec::InstanceSpec = value.into(); + let v3_spec: v3::instance_spec::InstanceSpec = value.into(); + let v2_spec: v2::instance_spec::InstanceSpec = v3_spec.into(); let smbios = v2_spec.smbios.clone(); let v1_spec: v1::instance_spec::InstanceSpec = v2_spec.into(); @@ -238,7 +240,7 @@ impl StorageDevice { } } -impl From for v1::instance_spec::Component { +impl TryFrom for v1::instance_spec::Component { fn from(value: StorageDevice) -> Self { match value { StorageDevice::Virtio(d) => Self::VirtioDisk(d), @@ -247,15 +249,15 @@ impl From for v1::instance_spec::Component { } } -impl TryFrom for StorageDevice { +impl TryFrom for StorageDevice { type Error = ComponentTypeMismatch; fn try_from( - value: v1::instance_spec::Component, + value: Component, ) -> Result { match value { - v1::instance_spec::Component::VirtioDisk(d) => Ok(Self::Virtio(d)), - v1::instance_spec::Component::NvmeDisk(d) => Ok(Self::Nvme(d)), + Component::VirtioDisk(d) => Ok(Self::Virtio(d)), + Component::NvmeDisk(d) => Ok(Self::Nvme(d)), _ => Err(ComponentTypeMismatch), } } @@ -287,7 +289,7 @@ impl StorageBackend { } } -impl From for v1::instance_spec::Component { +impl From for Component { fn from(value: StorageBackend) -> Self { match value { StorageBackend::Crucible(be) => Self::CrucibleStorageBackend(be), @@ -297,20 +299,20 @@ impl From for v1::instance_spec::Component { } } -impl TryFrom for StorageBackend { +impl TryFrom for StorageBackend { type Error = ComponentTypeMismatch; fn try_from( - value: v1::instance_spec::Component, + value: Component, ) -> Result { match value { - v1::instance_spec::Component::CrucibleStorageBackend(be) => { + Component::CrucibleStorageBackend(be) => { Ok(Self::Crucible(be)) } - v1::instance_spec::Component::FileStorageBackend(be) => { + Component::FileStorageBackend(be) => { Ok(Self::File(be)) } - v1::instance_spec::Component::BlobStorageBackend(be) => { + Component::BlobStorageBackend(be) => { Ok(Self::Blob(be)) } _ => Err(ComponentTypeMismatch), diff --git a/crates/propolis-server-api/src/lib.rs b/crates/propolis-server-api/src/lib.rs index fc2ea8b17..dbc1e1ac7 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}; +use propolis_api_types_versions::{latest, v1, v2, v3, v6}; api_versions!([ // WHEN CHANGING THE API (part 1 of 2): @@ -71,7 +71,13 @@ pub trait PropolisServerApi { ) -> Result< HttpResponseCreated, HttpError, - >; + > { + Self::instance_ensure( + rqctx, + request.map(v6::api::InstanceEnsureRequest::from), + ) + .await + } #[endpoint { operation_id = "instance_ensure", diff --git a/phd-tests/framework/src/test_vm/config.rs b/phd-tests/framework/src/test_vm/config.rs index f65643c80..7e0cb47d3 100644 --- a/phd-tests/framework/src/test_vm/config.rs +++ b/phd-tests/framework/src/test_vm/config.rs @@ -27,7 +27,21 @@ use crate::{ #[derive(Clone, Copy, Debug)] pub enum DiskInterface { Virtio, - Nvme, + Nvme { has_write_cache: bool }, +} + +impl DiskInterface { + pub fn virtio() -> Self { + DiskInterface::Virtio + } + + pub fn nvme() -> Self { + // Default to reporting a write cache for the same reason as + // propolis-cli. Some tests want to see that we can actually tell a + // guest that there's no write cache, though, so it's configurable and + // may lie with respect to backend's actual cachefulness. + DiskInterface::Nvme { has_write_cache: true } + } } #[derive(Clone, Copy, Debug)] @@ -82,7 +96,7 @@ impl<'dr> VmConfig<'dr> { config.boot_disk( guest_artifact, - DiskInterface::Nvme, + DiskInterface::nvme(), DiskBackend::File, 4, ); @@ -330,7 +344,7 @@ impl<'dr> VmConfig<'dr> { ), pci_path, }), - DiskInterface::Nvme => Component::NvmeDisk(NvmeDisk { + DiskInterface::Nvme { has_write_cache } => Component::NvmeDisk(NvmeDisk { backend_id: SpecKey::Name( backend_name.clone().into_string(), ), @@ -344,6 +358,7 @@ impl<'dr> VmConfig<'dr> { // possible. 0, ), + has_write_cache, }), }; diff --git a/phd-tests/tests/src/boot_order.rs b/phd-tests/tests/src/boot_order.rs index 6ae79ff0a..e2e1f1c3c 100644 --- a/phd-tests/tests/src/boot_order.rs +++ b/phd-tests/tests/src/boot_order.rs @@ -59,7 +59,7 @@ async fn configurable_boot_order(ctx: &TestCtx) { cfg.data_disk( "alt-boot", DiskSource::Artifact(ctx.default_guest_os_artifact()), - DiskInterface::Virtio, + DiskInterface::virtio(), DiskBackend::File, 24, ); @@ -121,7 +121,7 @@ async fn unbootable_disk_skipped(ctx: &TestCtx) { cfg.data_disk( "unbootable", DiskSource::FatFilesystem(FatFilesystem::new()), - DiskInterface::Virtio, + DiskInterface::virtio(), DiskBackend::InMemory { readonly: true }, 16, ); @@ -240,7 +240,7 @@ async fn guest_can_adjust_boot_order(ctx: &TestCtx) { cfg.data_disk( "unbootable", DiskSource::FatFilesystem(FatFilesystem::new()), - DiskInterface::Virtio, + DiskInterface::virtio(), DiskBackend::InMemory { readonly: true }, 16, ); @@ -407,7 +407,7 @@ async fn boot_order_source_priority(ctx: &TestCtx) { cfg.data_disk( "unbootable", DiskSource::FatFilesystem(FatFilesystem::new()), - DiskInterface::Virtio, + DiskInterface::virtio(), DiskBackend::InMemory { readonly: true }, 16, ); @@ -415,7 +415,7 @@ async fn boot_order_source_priority(ctx: &TestCtx) { cfg.data_disk( "unbootable-2", DiskSource::FatFilesystem(FatFilesystem::new()), - DiskInterface::Virtio, + DiskInterface::virtio(), DiskBackend::InMemory { readonly: true }, 20, ); @@ -514,7 +514,7 @@ async fn nvme_boot_option_description(ctx: &TestCtx) { cfg.data_disk( "nvme-test-disk", DiskSource::Artifact(ctx.default_guest_os_artifact()), - DiskInterface::Nvme, + DiskInterface::nvme(), DiskBackend::File, 8, ); diff --git a/phd-tests/tests/src/crucible/mod.rs b/phd-tests/tests/src/crucible/mod.rs index ce6ba856e..9e84b0940 100644 --- a/phd-tests/tests/src/crucible/mod.rs +++ b/phd-tests/tests/src/crucible/mod.rs @@ -44,7 +44,7 @@ fn add_default_boot_disk<'a>( ctx, config, ctx.default_guest_os_artifact(), - DiskInterface::Nvme, + DiskInterface::nvme(), 4, 10, BlockSize::Bytes512, diff --git a/phd-tests/tests/src/crucible/smoke.rs b/phd-tests/tests/src/crucible/smoke.rs index 815e2782c..fc535b460 100644 --- a/phd-tests/tests/src/crucible/smoke.rs +++ b/phd-tests/tests/src/crucible/smoke.rs @@ -105,7 +105,7 @@ async fn vcr_replace_during_start_test(ctx: &TestCtx) { config.data_disk( DATA_DISK_NAME, DiskSource::Blank(1024 * 1024 * 1024), - DiskInterface::Nvme, + DiskInterface::nvme(), DiskBackend::Crucible { min_disk_size_gib: 1, block_size: BlockSize::Bytes512, diff --git a/phd-tests/tests/src/server_state_machine.rs b/phd-tests/tests/src/server_state_machine.rs index 9b9421610..dba7006ca 100644 --- a/phd-tests/tests/src/server_state_machine.rs +++ b/phd-tests/tests/src/server_state_machine.rs @@ -127,7 +127,7 @@ async fn stop_while_blocked_on_start_test(ctx: &TestCtx) { config.data_disk( DATA_DISK_NAME, DiskSource::Blank(1024 * 1024 * 1024), - DiskInterface::Nvme, + DiskInterface::nvme(), DiskBackend::Crucible { min_disk_size_gib: 1, block_size: BlockSize::Bytes512, From 023aa1f2af379940c8db85b240e6dde5194dad7b Mon Sep 17 00:00:00 2001 From: iximeow Date: Fri, 17 Jul 2026 18:01:49 +0000 Subject: [PATCH 06/24] wip (migration!) --- bin/propolis-server/src/lib/migrate/mod.rs | 1 + .../src/lib/migrate/preamble.rs | 306 +++++++++++++++--- bin/propolis-server/src/lib/migrate/source.rs | 7 +- .../src/lib/spec/api_spec_v1.rs | 273 ++++++++++++++++ .../src/lib/spec/api_spec_v3.rs | 299 +++++++++++++++++ .../spec/{api_spec_v0.rs => api_spec_v6.rs} | 139 ++++---- bin/propolis-server/src/lib/spec/builder.rs | 7 + bin/propolis-server/src/lib/spec/mod.rs | 98 ++++-- bin/propolis-server/src/lib/vm/mod.rs | 18 +- .../src/add_vsock/instance_spec.rs | 29 +- crates/propolis-config-toml/src/spec.rs | 2 +- 11 files changed, 1021 insertions(+), 158 deletions(-) create mode 100644 bin/propolis-server/src/lib/spec/api_spec_v1.rs create mode 100644 bin/propolis-server/src/lib/spec/api_spec_v3.rs rename bin/propolis-server/src/lib/spec/{api_spec_v0.rs => api_spec_v6.rs} (72%) diff --git a/bin/propolis-server/src/lib/migrate/mod.rs b/bin/propolis-server/src/lib/migrate/mod.rs index 2145dd3c3..b8467961d 100644 --- a/bin/propolis-server/src/lib/migrate/mod.rs +++ b/bin/propolis-server/src/lib/migrate/mod.rs @@ -17,6 +17,7 @@ mod memx; mod preamble; pub mod protocol; pub mod source; +mod types; /// Trait bounds for connection objects used in live migrations. pub(crate) trait MigrateConn: diff --git a/bin/propolis-server/src/lib/migrate/preamble.rs b/bin/propolis-server/src/lib/migrate/preamble.rs index 3c205f8ef..151d6c073 100644 --- a/bin/propolis-server/src/lib/migrate/preamble.rs +++ b/bin/propolis-server/src/lib/migrate/preamble.rs @@ -5,22 +5,29 @@ use std::collections::BTreeMap; use propolis_api_types::instance::ReplacementComponent; -use propolis_api_types_versions::v1; +use propolis_api_types_versions::{v1, v3, v6}; use serde::{Deserialize, Serialize}; -use crate::spec::{api_spec_v0::ApiSpecError, Spec}; +use crate::migrate; +use crate::spec::{ + api_spec_v1::ApiSpecError as V1SpecError, +// api_spec_v2::ApiSpecError as V2SpecError, + api_spec_v3::ApiSpecError as V3SpecError, + api_spec_v6::ApiSpecError as V6SpecError, + Spec +}; use super::MigrateError; #[derive(Deserialize, Serialize, Debug)] pub(crate) struct Preamble { - pub instance_spec: v1::instance_spec::VersionedInstanceSpec, + pub instance_spec: migrate::types::VersionedInstanceSpec, pub blobs: Vec>, } impl Preamble { pub fn new( - instance_spec: v1::instance_spec::VersionedInstanceSpec, + instance_spec: migrate::types::VersionedInstanceSpec, ) -> Preamble { Preamble { instance_spec, blobs: Vec::new() } } @@ -49,66 +56,261 @@ impl Preamble { MigrateError::InstanceSpecsIncompatible(msg) } - let v1::instance_spec::VersionedInstanceSpec::V0(mut source_spec) = - self.instance_spec; - for (id, comp) in replacements { - let Some(to_amend) = source_spec.components.get_mut(id) else { - return Err(MigrateError::InstanceSpecsIncompatible(format!( - "replacement component {id} not in source spec", - ))); - }; - - match comp { - #[cfg(not(feature = "failure-injection"))] - ReplacementComponent::MigrationFailureInjector(_) => { - return Err(MigrateError::InstanceSpecsIncompatible( - format!( - "replacing migration failure injector {id} is \ - impossible because the feature is compiled out" - ), - )); + let amended_spec = match self.instance_spec { + migrate::types::VersionedInstanceSpec::V1(mut source_spec) => { + for (id, comp) in replacements { + let Some(to_amend) = source_spec.components.get_mut(id) else { + return Err(MigrateError::InstanceSpecsIncompatible(format!( + "replacement component {id} not in source spec", + ))); + }; + + match comp { + #[cfg(not(feature = "failure-injection"))] + ReplacementComponent::MigrationFailureInjector(_) => { + return Err(MigrateError::InstanceSpecsIncompatible( + format!( + "replacing migration failure injector {id} is \ + impossible because the feature is compiled out" + ), + )); + } + + #[cfg(feature = "failure-injection")] + ReplacementComponent::MigrationFailureInjector(comp) => { + let v1::instance_spec::Component::MigrationFailureInjector( + src, + ) = to_amend + else { + return Err(wrong_type_error( + id, + "migration failure injector", + )); + }; + + *src = comp.clone(); + } + ReplacementComponent::CrucibleStorageBackend(comp) => { + let v1::instance_spec::Component::CrucibleStorageBackend( + src, + ) = to_amend + else { + return Err(wrong_type_error(id, "crucible backend")); + }; + + *src = comp.clone(); + } + ReplacementComponent::VirtioNetworkBackend(comp) => { + let v1::instance_spec::Component::VirtioNetworkBackend(src) = + to_amend + else { + return Err(wrong_type_error(id, "viona backend")); + }; + + *src = comp.clone(); + } + } } - #[cfg(feature = "failure-injection")] - ReplacementComponent::MigrationFailureInjector(comp) => { - let v1::instance_spec::Component::MigrationFailureInjector( - src, - ) = to_amend - else { - return Err(wrong_type_error( - id, - "migration failure injector", - )); + let amended_spec: Spec = + source_spec.try_into().map_err(|e: V1SpecError| { + MigrateError::PreambleParse(e.to_string()) + })?; + + amended_spec + } + migrate::types::VersionedInstanceSpec::V2(mut source_spec) => { + panic!("source spec: {:?}", source_spec); + /* + for (id, comp) in replacements { + let Some(to_amend) = source_spec.components.get_mut(id) else { + return Err(MigrateError::InstanceSpecsIncompatible(format!( + "replacement component {id} not in source spec", + ))); }; - *src = comp.clone(); + match comp { + #[cfg(not(feature = "failure-injection"))] + ReplacementComponent::MigrationFailureInjector(_) => { + return Err(MigrateError::InstanceSpecsIncompatible( + format!( + "replacing migration failure injector {id} is \ + impossible because the feature is compiled out" + ), + )); + } + + #[cfg(feature = "failure-injection")] + ReplacementComponent::MigrationFailureInjector(comp) => { + let v1::instance_spec::Component::MigrationFailureInjector( + src, + ) = to_amend + else { + return Err(wrong_type_error( + id, + "migration failure injector", + )); + }; + + *src = comp.clone(); + } + ReplacementComponent::CrucibleStorageBackend(comp) => { + let v1::instance_spec::Component::CrucibleStorageBackend( + src, + ) = to_amend + else { + return Err(wrong_type_error(id, "crucible backend")); + }; + + *src = comp.clone(); + } + ReplacementComponent::VirtioNetworkBackend(comp) => { + let v1::instance_spec::Component::VirtioNetworkBackend(src) = + to_amend + else { + return Err(wrong_type_error(id, "viona backend")); + }; + + *src = comp.clone(); + } + } } - ReplacementComponent::CrucibleStorageBackend(comp) => { - let v1::instance_spec::Component::CrucibleStorageBackend( - src, - ) = to_amend - else { - return Err(wrong_type_error(id, "crucible backend")); + + let amended_spec: Spec = + source_spec.try_into().map_err(|e: V1SpecError| { + MigrateError::PreambleParse(e.to_string()) + })?; + + amended_spec + */ + } + migrate::types::VersionedInstanceSpec::V3(mut source_spec) => { + for (id, comp) in replacements { + let Some(to_amend) = source_spec.components.get_mut(id) else { + return Err(MigrateError::InstanceSpecsIncompatible(format!( + "replacement component {id} not in source spec", + ))); }; - *src = comp.clone(); + match comp { + #[cfg(not(feature = "failure-injection"))] + ReplacementComponent::MigrationFailureInjector(_) => { + return Err(MigrateError::InstanceSpecsIncompatible( + format!( + "replacing migration failure injector {id} is \ + impossible because the feature is compiled out" + ), + )); + } + + #[cfg(feature = "failure-injection")] + ReplacementComponent::MigrationFailureInjector(comp) => { + let v3::instance_spec::Component::MigrationFailureInjector( + src, + ) = to_amend + else { + return Err(wrong_type_error( + id, + "migration failure injector", + )); + }; + + *src = comp.clone(); + } + ReplacementComponent::CrucibleStorageBackend(comp) => { + let v3::instance_spec::Component::CrucibleStorageBackend( + src, + ) = to_amend + else { + return Err(wrong_type_error(id, "crucible backend")); + }; + + *src = comp.clone(); + } + ReplacementComponent::VirtioNetworkBackend(comp) => { + let v3::instance_spec::Component::VirtioNetworkBackend(src) = + to_amend + else { + return Err(wrong_type_error(id, "viona backend")); + }; + + *src = comp.clone(); + } + } } - ReplacementComponent::VirtioNetworkBackend(comp) => { - let v1::instance_spec::Component::VirtioNetworkBackend(src) = - to_amend - else { - return Err(wrong_type_error(id, "viona backend")); + + let v6_spec: v6::instance_spec::InstanceSpec = source_spec.into(); + let amended_spec: Spec = + v6_spec.try_into().map_err(|e: V6SpecError| { + let v3_error: V3SpecError = e.into(); + MigrateError::PreambleParse(v3_error.to_string()) + })?; + + amended_spec + } + migrate::types::VersionedInstanceSpec::V6(mut source_spec) => { + for (id, comp) in replacements { + let Some(to_amend) = source_spec.components.get_mut(id) else { + return Err(MigrateError::InstanceSpecsIncompatible(format!( + "replacement component {id} not in source spec", + ))); }; - *src = comp.clone(); + match comp { + #[cfg(not(feature = "failure-injection"))] + ReplacementComponent::MigrationFailureInjector(_) => { + return Err(MigrateError::InstanceSpecsIncompatible( + format!( + "replacing migration failure injector {id} is \ + impossible because the feature is compiled out" + ), + )); + } + + #[cfg(feature = "failure-injection")] + ReplacementComponent::MigrationFailureInjector(comp) => { + let v6::instance_spec::Component::MigrationFailureInjector( + src, + ) = to_amend + else { + return Err(wrong_type_error( + id, + "migration failure injector", + )); + }; + + *src = comp.clone(); + } + ReplacementComponent::CrucibleStorageBackend(comp) => { + let v6::instance_spec::Component::CrucibleStorageBackend( + src, + ) = to_amend + else { + return Err(wrong_type_error(id, "crucible backend")); + }; + + *src = comp.clone(); + } + ReplacementComponent::VirtioNetworkBackend(comp) => { + let v6::instance_spec::Component::VirtioNetworkBackend(src) = + to_amend + else { + return Err(wrong_type_error(id, "viona backend")); + }; + + *src = comp.clone(); + } + } } - } - } - let amended_spec = - source_spec.try_into().map_err(|e: ApiSpecError| { - MigrateError::PreambleParse(e.to_string()) - })?; + let amended_spec: Spec = + source_spec.try_into().map_err(|e: V6SpecError| { + MigrateError::PreambleParse(e.to_string()) + })?; + + amended_spec + } + }; // TODO: Compare opaque blobs. diff --git a/bin/propolis-server/src/lib/migrate/source.rs b/bin/propolis-server/src/lib/migrate/source.rs index df9545753..948330870 100644 --- a/bin/propolis-server/src/lib/migrate/source.rs +++ b/bin/propolis-server/src/lib/migrate/source.rs @@ -467,9 +467,12 @@ impl RonV0Runner<'_, T> { async fn sync(&mut self) -> Result<(), MigrateError> { self.update_state(MigrationState::Sync); + let spec = self.vm.lock_shared().await.instance_spec().clone(); + let v1_spec: v1::instance_spec::InstanceSpec = spec.try_into() + .expect("TODO: handle being unable to turn Spec into a v1 InstanceSpec"); let preamble = - Preamble::new(v1::instance_spec::VersionedInstanceSpec::V0( - self.vm.lock_shared().await.instance_spec().clone().into(), + Preamble::new(crate::migrate::types::VersionedInstanceSpec::V1( + v1_spec )); let s = ron::ser::to_string(&preamble) .map_err(codec::ProtocolError::from)?; diff --git a/bin/propolis-server/src/lib/spec/api_spec_v1.rs b/bin/propolis-server/src/lib/spec/api_spec_v1.rs new file mode 100644 index 000000000..43c50486e --- /dev/null +++ b/bin/propolis-server/src/lib/spec/api_spec_v1.rs @@ -0,0 +1,273 @@ +// 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 from the initial API version ([`propolis_api_types::v1`], aka +//! "V0" in some parts of propolis-server) instance specs in the +//! [`propolis_api_types`] crate to the internal [`super::Spec`] representation. + +use propolis_api_types::instance_spec::{ + components::{ + board::Board as InstanceSpecBoard, + devices::{BootSettings, SerialPort as SerialPortDesc}, + }, + SpecKey, +}; +use propolis_api_types_versions::{v1, v2, v3}; +use thiserror::Error; + +#[cfg(feature = "falcon")] +use propolis_api_types::instance_spec::components::devices::SoftNpuPort as SoftNpuPortSpec; + +use super::{ + builder::{SpecBuilder, SpecBuilderError}, + SerialPortDevice, Spec, +}; + +#[cfg(feature = "failure-injection")] +use super::MigrationFailure; + +#[cfg(feature = "falcon")] +use super::SoftNpuPort; + +#[derive(Debug, Error)] +pub(crate) enum ApiSpecError { + #[error(transparent)] + Builder(#[from] SpecBuilderError), + + #[error("storage backend {backend} not found for device {device}")] + StorageBackendNotFound { backend: SpecKey, device: SpecKey }, + + #[error("network backend {backend} not found for device {device}")] + NetworkBackendNotFound { backend: SpecKey, device: SpecKey }, + + #[allow(dead_code)] + #[error("support for component {component} compiled out via {feature}")] + FeatureCompiledOut { component: SpecKey, feature: &'static str }, + + #[error("backend {0} not used by any device")] + BackendNotUsed(SpecKey), +} + +impl TryFrom for v1::instance_spec::InstanceSpec { + type Error = String; + + fn try_from(val: Spec) -> Result { + // Exhaustively destructure the input spec so that adding a new field + // without considering it here will break the build. + let Spec { + board, + cpuid, + disks, + nics, + boot_settings, + serial, + pci_pci_bridges, + pvpanic, + #[cfg(feature = "failure-injection")] + migration_failure, + #[cfg(feature = "falcon")] + softnpu, + + // Not part of `v1::instance_spec::InstanceSpec`. Added in + // `InstanceSpec` in API Version 2.0.0. + smbios_type1_input, + + // Not part of `v1::instance_spec::InstanceSpec`. Added in + // `InstanceSpec` in API Version 3.0.0. + vsock, + } = val; + + if smbios_type1_input.is_some() { + return Err("TODO: hahaha".to_string()); + } + + if vsock.is_some() { + return Err("TODO: hahaha".to_string()); + } + + // Inserts a component entry into the supplied map, asserting first that + // the supplied key is not present in that map. + // + // This assertion is valid because internal instance specs should assign + // a unique name to each component they describe. The spec builder + // upholds this invariant at spec creation time. + #[track_caller] + fn insert_component( + spec: &mut v1::instance_spec::InstanceSpec, + key: SpecKey, + val: v1::instance_spec::Component, + ) { + assert!( + !spec.components.contains_key(&key), + "component name {} already exists in output spec", + &key + ); + spec.components.insert(key, val); + } + + let board = InstanceSpecBoard { + cpus: board.cpus, + memory_mb: board.memory_mb, + chipset: board.chipset, + guest_hv_interface: board.guest_hv_interface, + cpuid: Some(cpuid.into_instance_spec_cpuid()), + }; + let mut spec = v1::instance_spec::InstanceSpec { + board, + components: Default::default(), + }; + + for (disk_id, disk) in disks { + let backend_id = disk.device_spec.backend_id().to_owned(); + let device_component: v1::instance_spec::Component = disk.device_spec.try_into().expect("TODO: StorageDevice into v1::Component"); + let backend_component: v1::instance_spec::Component = disk.backend_spec.into(); + insert_component(&mut spec, disk_id, device_component); + insert_component(&mut spec, backend_id, backend_component); + } + + for (nic_id, nic) in nics { + let backend_id = nic.device_spec.backend_id.clone(); + insert_component( + &mut spec, + nic_id, + v1::instance_spec::Component::VirtioNic(nic.device_spec), + ); + + insert_component( + &mut spec, + backend_id, + v1::instance_spec::Component::VirtioNetworkBackend( + nic.backend_spec, + ), + ); + } + + for (name, desc) in serial { + if desc.device == SerialPortDevice::Uart { + insert_component( + &mut spec, + name, + v1::instance_spec::Component::SerialPort(SerialPortDesc { + num: desc.num, + }), + ); + } + } + + for (bridge_name, bridge) in pci_pci_bridges { + insert_component( + &mut spec, + bridge_name, + v1::instance_spec::Component::PciPciBridge(bridge), + ); + } + + if let Some(pvpanic) = pvpanic { + insert_component( + &mut spec, + pvpanic.id, + v1::instance_spec::Component::QemuPvpanic(pvpanic.spec), + ); + } + + if let Some(settings) = boot_settings { + insert_component( + &mut spec, + settings.name, + v1::instance_spec::Component::BootSettings(BootSettings { + order: settings.order.into_iter().map(Into::into).collect(), + }), + ); + } + + #[cfg(feature = "failure-injection")] + if let Some(mig) = migration_failure { + insert_component( + &mut spec, + mig.id, + v1::instance_spec::Component::MigrationFailureInjector( + mig.spec, + ), + ); + } + + #[cfg(feature = "falcon")] + { + if let Some(softnpu_pci) = softnpu.pci_port { + insert_component( + &mut spec, + SpecKey::Name(format!( + "softnpu-pci-{}", + softnpu_pci.pci_path + )), + v1::instance_spec::Component::SoftNpuPciPort(softnpu_pci), + ); + } + + if let Some(p9) = softnpu.p9_device { + insert_component( + &mut spec, + SpecKey::Name(format!("softnpu-p9-{}", p9.pci_path)), + v1::instance_spec::Component::SoftNpuP9(p9), + ); + } + + if let Some(p9fs) = softnpu.p9fs { + insert_component( + &mut spec, + SpecKey::Name(format!("p9fs-{}", p9fs.pci_path)), + v1::instance_spec::Component::P9fs(p9fs), + ); + } + + for (port_name, port) in softnpu.ports { + insert_component( + &mut spec, + port_name.clone(), + v1::instance_spec::Component::SoftNpuPort( + SoftNpuPortSpec { + link_name: port.link_name, + backend_id: port.backend_name.clone(), + }, + ), + ); + + insert_component( + &mut spec, + port.backend_name, + v1::instance_spec::Component::DlpiNetworkBackend( + port.backend_spec, + ), + ); + } + } + + Ok(spec) + } +} + +/* +impl TryFrom for Spec { + type Error = ApiSpecError; + + fn try_from( + value: v1::instance_spec::InstanceSpec, + ) -> Result { + Ok(v1_to_spec_builder(value)?.finish()) + } +} +*/ + +/// Parses a v1 instance spec into a [`SpecBuilder`], validating component +/// names, PCI paths, and backend references along the way. Callers can add +/// additional (non-v1) components to the builder before calling `finish()`. +pub(crate) fn v1_to_spec_builder( + value: v1::instance_spec::InstanceSpec, +) -> Result { + let v2_spec: v2::instance_spec::InstanceSpec = value.into(); + let v3_spec: v3::instance_spec::InstanceSpec = v2_spec.into(); + + crate::spec::api_spec_v3::v3_to_spec_builder(v3_spec) + .map_err(|e| e.into()) +} diff --git a/bin/propolis-server/src/lib/spec/api_spec_v3.rs b/bin/propolis-server/src/lib/spec/api_spec_v3.rs new file mode 100644 index 000000000..6d5cdb3f9 --- /dev/null +++ b/bin/propolis-server/src/lib/spec/api_spec_v3.rs @@ -0,0 +1,299 @@ +// 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 from [`propolis_api_types::v3`]) instance specs in the +//! [`propolis_api_types`] crate to the internal [`super::Spec`] representation. + +use std::collections::BTreeMap; + +use propolis_api_types::instance_spec::{ + components::{ + backends::{DlpiNetworkBackend, VirtioNetworkBackend}, + board::Board as InstanceSpecBoard, + devices::{BootSettings, SerialPort as SerialPortDesc}, + }, + SpecKey, +}; +use propolis_api_types_versions::{v3, latest}; +use thiserror::Error; + +#[cfg(feature = "falcon")] +use propolis_api_types::instance_spec::components::devices::SoftNpuPort as SoftNpuPortSpec; + +use super::{ + builder::{SpecBuilder, SpecBuilderError}, + Disk, Nic, QemuPvpanic, SerialPortDevice, Spec, StorageBackend, + StorageDevice, +}; + +#[cfg(feature = "failure-injection")] +use super::MigrationFailure; + +#[cfg(feature = "falcon")] +use super::SoftNpuPort; + +#[derive(Debug, Error)] +pub(crate) enum ApiSpecError { + #[error(transparent)] + Builder(#[from] SpecBuilderError), + + #[error("storage backend {backend} not found for device {device}")] + StorageBackendNotFound { backend: SpecKey, device: SpecKey }, + + #[error("network backend {backend} not found for device {device}")] + NetworkBackendNotFound { backend: SpecKey, device: SpecKey }, + + #[allow(dead_code)] + #[error("support for component {component} compiled out via {feature}")] + FeatureCompiledOut { component: SpecKey, feature: &'static str }, + + #[error("backend {0} not used by any device")] + BackendNotUsed(SpecKey), +} + +use crate::spec::api_spec_v1; +impl From for api_spec_v1::ApiSpecError { + fn from(value: ApiSpecError) -> Self { + match value { + ApiSpecError::Builder(b) => api_spec_v1::ApiSpecError::Builder(b), + ApiSpecError::StorageBackendNotFound { backend, device } => api_spec_v1::ApiSpecError::StorageBackendNotFound { backend, device }, + ApiSpecError::NetworkBackendNotFound { backend, device } => api_spec_v1::ApiSpecError::NetworkBackendNotFound { backend, device }, + ApiSpecError::FeatureCompiledOut { component, feature } => api_spec_v1::ApiSpecError::FeatureCompiledOut { component, feature }, + ApiSpecError::BackendNotUsed(key) => api_spec_v1::ApiSpecError::BackendNotUsed(key), + } + } +} + +// TODO: docs. conversion back down from v6 to v3 because we defer InstanceSpec->Spec to +// `v6_to_spec_builder()`. +use crate::spec::api_spec_v6; +impl From for ApiSpecError { + fn from(value: api_spec_v6::ApiSpecError) -> Self { + match value { + api_spec_v6::ApiSpecError::Builder(b) => ApiSpecError::Builder(b), + api_spec_v6::ApiSpecError::StorageBackendNotFound { backend, device } => ApiSpecError::StorageBackendNotFound { backend, device }, + api_spec_v6::ApiSpecError::NetworkBackendNotFound { backend, device } => ApiSpecError::NetworkBackendNotFound { backend, device }, + api_spec_v6::ApiSpecError::FeatureCompiledOut { component, feature } => ApiSpecError::FeatureCompiledOut { component, feature }, + api_spec_v6::ApiSpecError::BackendNotUsed(key) => ApiSpecError::BackendNotUsed(key), + } + } +} + +impl TryFrom for v3::instance_spec::InstanceSpec { + type Error = String; + + fn try_from(val: Spec) -> Result { + // Exhaustively destructure the input spec so that adding a new field + // without considering it here will break the build. + let Spec { + board, + cpuid, + disks, + nics, + boot_settings, + serial, + pci_pci_bridges, + pvpanic, + smbios_type1_input, + vsock, + #[cfg(feature = "failure-injection")] + migration_failure, + #[cfg(feature = "falcon")] + softnpu, + } = val; + + // Inserts a component entry into the supplied map, asserting first that + // the supplied key is not present in that map. + // + // This assertion is valid because internal instance specs should assign + // a unique name to each component they describe. The spec builder + // upholds this invariant at spec creation time. + #[track_caller] + fn insert_component( + spec: &mut v3::instance_spec::InstanceSpec, + key: SpecKey, + val: v3::instance_spec::Component, + ) { + assert!( + !spec.components.contains_key(&key), + "component name {} already exists in output spec", + &key + ); + spec.components.insert(key, val); + } + + let board = InstanceSpecBoard { + cpus: board.cpus, + memory_mb: board.memory_mb, + chipset: board.chipset, + guest_hv_interface: board.guest_hv_interface, + cpuid: Some(cpuid.into_instance_spec_cpuid()), + }; + let mut spec = v3::instance_spec::InstanceSpec { + board, + smbios: smbios_type1_input, + components: Default::default(), + }; + + for (disk_id, disk) in disks { + let backend_id = disk.device_spec.backend_id().to_owned(); + let device_component: v3::instance_spec::Component = disk.device_spec.try_into().expect("TODO: StorageDevice into v3::Component"); + let backend_component: v3::instance_spec::Component = disk.backend_spec.into(); + insert_component(&mut spec, disk_id, device_component); + insert_component(&mut spec, backend_id, backend_component); + } + + for (nic_id, nic) in nics { + let backend_id = nic.device_spec.backend_id.clone(); + insert_component( + &mut spec, + nic_id, + v3::instance_spec::Component::VirtioNic(nic.device_spec), + ); + + insert_component( + &mut spec, + backend_id, + v3::instance_spec::Component::VirtioNetworkBackend( + nic.backend_spec, + ), + ); + } + + for (name, desc) in serial { + if desc.device == SerialPortDevice::Uart { + insert_component( + &mut spec, + name, + v3::instance_spec::Component::SerialPort(SerialPortDesc { + num: desc.num, + }), + ); + } + } + + for (bridge_name, bridge) in pci_pci_bridges { + insert_component( + &mut spec, + bridge_name, + v3::instance_spec::Component::PciPciBridge(bridge), + ); + } + + if let Some(pvpanic) = pvpanic { + insert_component( + &mut spec, + pvpanic.id, + v3::instance_spec::Component::QemuPvpanic(pvpanic.spec), + ); + } + + if let Some(vsock) = vsock { + insert_component( + &mut spec, + vsock.id, + v3::instance_spec::Component::VirtioSocket(vsock.spec), + ); + } + + if let Some(settings) = boot_settings { + insert_component( + &mut spec, + settings.name, + v3::instance_spec::Component::BootSettings(BootSettings { + order: settings.order.into_iter().map(Into::into).collect(), + }), + ); + } + + #[cfg(feature = "failure-injection")] + if let Some(mig) = migration_failure { + insert_component( + &mut spec, + mig.id, + v3::instance_spec::Component::MigrationFailureInjector( + mig.spec, + ), + ); + } + + #[cfg(feature = "falcon")] + { + if let Some(softnpu_pci) = softnpu.pci_port { + insert_component( + &mut spec, + SpecKey::Name(format!( + "softnpu-pci-{}", + softnpu_pci.pci_path + )), + v3::instance_spec::Component::SoftNpuPciPort(softnpu_pci), + ); + } + + if let Some(p9) = softnpu.p9_device { + insert_component( + &mut spec, + SpecKey::Name(format!("softnpu-p9-{}", p9.pci_path)), + v3::instance_spec::Component::SoftNpuP9(p9), + ); + } + + if let Some(p9fs) = softnpu.p9fs { + insert_component( + &mut spec, + SpecKey::Name(format!("p9fs-{}", p9fs.pci_path)), + v3::instance_spec::Component::P9fs(p9fs), + ); + } + + for (port_name, port) in softnpu.ports { + insert_component( + &mut spec, + port_name.clone(), + v3::instance_spec::Component::SoftNpuPort( + SoftNpuPortSpec { + link_name: port.link_name, + backend_id: port.backend_name.clone(), + }, + ), + ); + + insert_component( + &mut spec, + port.backend_name, + v3::instance_spec::Component::DlpiNetworkBackend( + port.backend_spec, + ), + ); + } + } + + Ok(spec) + } +} + +/* +impl TryFrom for Spec { + type Error = ApiSpecError; + + fn try_from( + value: v3::instance_spec::InstanceSpec, + ) -> Result { + Ok(v3_to_spec_builder(value)?.finish()) + } +} +*/ + +/// Parses a v3 instance spec into a [`SpecBuilder`], validating component +/// names, PCI paths, and backend references along the way. Callers can add +/// additional (non-v3) components to the builder before calling `finish()`. +pub(crate) fn v3_to_spec_builder( + value: v3::instance_spec::InstanceSpec, +) -> Result { + let latest_spec: latest::instance_spec::InstanceSpec = value.into(); + + // TODO: talk about this more + crate::spec::api_spec_v6::latest_api_spec_to_spec_builder(latest_spec) + .map_err(|e| e.into()) +} diff --git a/bin/propolis-server/src/lib/spec/api_spec_v0.rs b/bin/propolis-server/src/lib/spec/api_spec_v6.rs similarity index 72% rename from bin/propolis-server/src/lib/spec/api_spec_v0.rs rename to bin/propolis-server/src/lib/spec/api_spec_v6.rs index 4aa0d3e21..5da294cf6 100644 --- a/bin/propolis-server/src/lib/spec/api_spec_v0.rs +++ b/bin/propolis-server/src/lib/spec/api_spec_v6.rs @@ -2,8 +2,8 @@ // 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 from version-0 instance specs in the [`propolis_api_types`] -//! crate to the internal [`super::Spec`] representation. +//! Conversions from [`propolis_api_types::v6`] instance specs in the +//! [`propolis_api_types`] crate to the internal [`super::Spec`] representation. use std::collections::BTreeMap; @@ -15,7 +15,7 @@ use propolis_api_types::instance_spec::{ }, SpecKey, }; -use propolis_api_types_versions::v1; +use propolis_api_types_versions::{v6, latest}; use thiserror::Error; #[cfg(feature = "falcon")] @@ -52,7 +52,7 @@ pub(crate) enum ApiSpecError { BackendNotUsed(SpecKey), } -impl From for v1::instance_spec::InstanceSpec { +impl From for v6::instance_spec::InstanceSpec { fn from(val: Spec) -> Self { // Exhaustively destructure the input spec so that adding a new field // without considering it here will break the build. @@ -65,18 +65,12 @@ impl From for v1::instance_spec::InstanceSpec { serial, pci_pci_bridges, pvpanic, + smbios_type1_input, + vsock, #[cfg(feature = "failure-injection")] migration_failure, #[cfg(feature = "falcon")] softnpu, - - // Not part of `v1::instance_spec::InstanceSpec`. Added in - // `InstanceSpec` in API Version 2.0.0. - smbios_type1_input: _, - - // Not part of `v1::instance_spec::InstanceSpec`. Added in - // `InstanceSpec` in API Version 3.0.0. - vsock: _, } = val; // Inserts a component entry into the supplied map, asserting first that @@ -87,9 +81,9 @@ impl From for v1::instance_spec::InstanceSpec { // upholds this invariant at spec creation time. #[track_caller] fn insert_component( - spec: &mut v1::instance_spec::InstanceSpec, + spec: &mut v6::instance_spec::InstanceSpec, key: SpecKey, - val: v1::instance_spec::Component, + val: v6::instance_spec::Component, ) { assert!( !spec.components.contains_key(&key), @@ -106,15 +100,18 @@ impl From for v1::instance_spec::InstanceSpec { guest_hv_interface: board.guest_hv_interface, cpuid: Some(cpuid.into_instance_spec_cpuid()), }; - let mut spec = v1::instance_spec::InstanceSpec { + let mut spec = v6::instance_spec::InstanceSpec { board, + smbios: smbios_type1_input, components: Default::default(), }; for (disk_id, disk) in disks { let backend_id = disk.device_spec.backend_id().to_owned(); - insert_component(&mut spec, disk_id, disk.device_spec.into()); - insert_component(&mut spec, backend_id, disk.backend_spec.into()); + let device_component: v6::instance_spec::Component = disk.device_spec.into(); + let backend_component: v6::instance_spec::Component = disk.backend_spec.into(); + insert_component(&mut spec, disk_id, device_component); + insert_component(&mut spec, backend_id, backend_component); } for (nic_id, nic) in nics { @@ -122,13 +119,13 @@ impl From for v1::instance_spec::InstanceSpec { insert_component( &mut spec, nic_id, - v1::instance_spec::Component::VirtioNic(nic.device_spec), + v6::instance_spec::Component::VirtioNic(nic.device_spec), ); insert_component( &mut spec, backend_id, - v1::instance_spec::Component::VirtioNetworkBackend( + v6::instance_spec::Component::VirtioNetworkBackend( nic.backend_spec, ), ); @@ -139,7 +136,7 @@ impl From for v1::instance_spec::InstanceSpec { insert_component( &mut spec, name, - v1::instance_spec::Component::SerialPort(SerialPortDesc { + v6::instance_spec::Component::SerialPort(SerialPortDesc { num: desc.num, }), ); @@ -150,7 +147,7 @@ impl From for v1::instance_spec::InstanceSpec { insert_component( &mut spec, bridge_name, - v1::instance_spec::Component::PciPciBridge(bridge), + v6::instance_spec::Component::PciPciBridge(bridge), ); } @@ -158,7 +155,7 @@ impl From for v1::instance_spec::InstanceSpec { insert_component( &mut spec, pvpanic.id, - v1::instance_spec::Component::QemuPvpanic(pvpanic.spec), + v6::instance_spec::Component::QemuPvpanic(pvpanic.spec), ); } @@ -166,7 +163,7 @@ impl From for v1::instance_spec::InstanceSpec { insert_component( &mut spec, settings.name, - v1::instance_spec::Component::BootSettings(BootSettings { + v6::instance_spec::Component::BootSettings(BootSettings { order: settings.order.into_iter().map(Into::into).collect(), }), ); @@ -177,7 +174,7 @@ impl From for v1::instance_spec::InstanceSpec { insert_component( &mut spec, mig.id, - v1::instance_spec::Component::MigrationFailureInjector( + v6::instance_spec::Component::MigrationFailureInjector( mig.spec, ), ); @@ -192,7 +189,7 @@ impl From for v1::instance_spec::InstanceSpec { "softnpu-pci-{}", softnpu_pci.pci_path )), - v1::instance_spec::Component::SoftNpuPciPort(softnpu_pci), + v6::instance_spec::Component::SoftNpuPciPort(softnpu_pci), ); } @@ -200,7 +197,7 @@ impl From for v1::instance_spec::InstanceSpec { insert_component( &mut spec, SpecKey::Name(format!("softnpu-p9-{}", p9.pci_path)), - v1::instance_spec::Component::SoftNpuP9(p9), + v6::instance_spec::Component::SoftNpuP9(p9), ); } @@ -208,7 +205,7 @@ impl From for v1::instance_spec::InstanceSpec { insert_component( &mut spec, SpecKey::Name(format!("p9fs-{}", p9fs.pci_path)), - v1::instance_spec::Component::P9fs(p9fs), + v6::instance_spec::Component::P9fs(p9fs), ); } @@ -216,7 +213,7 @@ impl From for v1::instance_spec::InstanceSpec { insert_component( &mut spec, port_name.clone(), - v1::instance_spec::Component::SoftNpuPort( + v6::instance_spec::Component::SoftNpuPort( SoftNpuPortSpec { link_name: port.link_name, backend_id: port.backend_name.clone(), @@ -227,7 +224,7 @@ impl From for v1::instance_spec::InstanceSpec { insert_component( &mut spec, port.backend_name, - v1::instance_spec::Component::DlpiNetworkBackend( + v6::instance_spec::Component::DlpiNetworkBackend( port.backend_spec, ), ); @@ -238,24 +235,34 @@ impl From for v1::instance_spec::InstanceSpec { } } -impl TryFrom for Spec { +/* +impl TryFrom for Spec { type Error = ApiSpecError; fn try_from( - value: v1::instance_spec::InstanceSpec, + value: v6::instance_spec::InstanceSpec, ) -> Result { - Ok(v1_to_spec_builder(value)?.finish()) + Ok(v6_to_spec_builder(value)?.finish()) } } +*/ -/// Parses a v1 instance spec into a [`SpecBuilder`], validating component +/// Parses a v6 instance spec into a [`SpecBuilder`], validating component /// names, PCI paths, and backend references along the way. Callers can add -/// additional (non-v1) components to the builder before calling `finish()`. -pub(crate) fn v1_to_spec_builder( - value: v1::instance_spec::InstanceSpec, +/// additional (non-v6) components to the builder before calling `finish()`. +pub(crate) fn v6_to_spec_builder( + value: v6::instance_spec::InstanceSpec, +) -> Result { + let latest_spec: latest::instance_spec::InstanceSpec = value.into(); + + latest_api_spec_to_spec_builder(latest_spec) +} + +pub(crate) fn latest_api_spec_to_spec_builder( + value: latest::instance_spec::InstanceSpec, ) -> Result { let mut builder = SpecBuilder::with_instance_spec_board(value.board)?; - let mut devices: Vec<(SpecKey, v1::instance_spec::Component)> = vec![]; + let mut devices: Vec<(SpecKey, latest::instance_spec::Component)> = vec![]; let mut boot_settings = None; let mut storage_backends: BTreeMap = BTreeMap::new(); @@ -266,9 +273,9 @@ pub(crate) fn v1_to_spec_builder( for (id, component) in value.components.into_iter() { match component { - v1::instance_spec::Component::CrucibleStorageBackend(_) - | v1::instance_spec::Component::FileStorageBackend(_) - | v1::instance_spec::Component::BlobStorageBackend(_) => { + latest::instance_spec::Component::CrucibleStorageBackend(_) + | latest::instance_spec::Component::FileStorageBackend(_) + | latest::instance_spec::Component::BlobStorageBackend(_) => { storage_backends.insert( id, component @@ -276,10 +283,10 @@ pub(crate) fn v1_to_spec_builder( .expect("component is known to be a storage backend"), ); } - v1::instance_spec::Component::VirtioNetworkBackend(viona) => { + latest::instance_spec::Component::VirtioNetworkBackend(viona) => { viona_backends.insert(id, viona); } - v1::instance_spec::Component::DlpiNetworkBackend(dlpi) => { + latest::instance_spec::Component::DlpiNetworkBackend(dlpi) => { dlpi_backends.insert(id, dlpi); } device => { @@ -290,8 +297,8 @@ pub(crate) fn v1_to_spec_builder( for (device_id, device_spec) in devices { match device_spec { - v1::instance_spec::Component::VirtioDisk(_) - | v1::instance_spec::Component::NvmeDisk(_) => { + latest::instance_spec::Component::VirtioDisk(_) + | latest::instance_spec::Component::NvmeDisk(_) => { let device_spec = StorageDevice::try_from(device_spec) .expect("component is known to be a disk"); @@ -307,7 +314,7 @@ pub(crate) fn v1_to_spec_builder( Disk { device_spec, backend_spec }, )?; } - v1::instance_spec::Component::VirtioNic(nic) => { + latest::instance_spec::Component::VirtioNic(nic) => { let (_, backend_spec) = viona_backends .remove_entry(&nic.backend_id) .ok_or_else(|| ApiSpecError::NetworkBackendNotFound { @@ -320,19 +327,19 @@ pub(crate) fn v1_to_spec_builder( Nic { device_spec: nic, backend_spec }, )?; } - v1::instance_spec::Component::SerialPort(port) => { + latest::instance_spec::Component::SerialPort(port) => { builder.add_serial_port(device_id, port.num)?; } - v1::instance_spec::Component::PciPciBridge(bridge) => { + latest::instance_spec::Component::PciPciBridge(bridge) => { builder.add_pci_bridge(device_id, bridge)?; } - v1::instance_spec::Component::QemuPvpanic(pvpanic) => { + latest::instance_spec::Component::QemuPvpanic(pvpanic) => { builder.add_pvpanic_device(QemuPvpanic { id: device_id, spec: pvpanic, })?; } - v1::instance_spec::Component::BootSettings(settings) => { + latest::instance_spec::Component::BootSettings(settings) => { // The builder returns an error if its caller tries to add // a boot option that isn't in the set of attached disks. // Since there may be more disk devices left in the @@ -340,36 +347,40 @@ pub(crate) fn v1_to_spec_builder( // apply it to the builder later. boot_settings = Some((device_id, settings)); } + latest::instance_spec::Component::VirtioSocket(vsock) => { + let vsock_device = crate::spec::VirtioSocket { id: device_id.clone(), spec: vsock }; + builder.add_vsock_device(vsock_device)?; + } #[cfg(not(feature = "failure-injection"))] - v1::instance_spec::Component::MigrationFailureInjector(_) => { + latest::instance_spec::Component::MigrationFailureInjector(_) => { return Err(ApiSpecError::FeatureCompiledOut { component: device_id, feature: "failure-injection", }); } #[cfg(feature = "failure-injection")] - v1::instance_spec::Component::MigrationFailureInjector(mig) => { + latest::instance_spec::Component::MigrationFailureInjector(mig) => { builder.add_migration_failure_device(MigrationFailure { id: device_id, spec: mig, })?; } #[cfg(not(feature = "falcon"))] - v1::instance_spec::Component::SoftNpuPciPort(_) - | v1::instance_spec::Component::SoftNpuPort(_) - | v1::instance_spec::Component::SoftNpuP9(_) - | v1::instance_spec::Component::P9fs(_) => { + latest::instance_spec::Component::SoftNpuPciPort(_) + | latest::instance_spec::Component::SoftNpuPort(_) + | latest::instance_spec::Component::SoftNpuP9(_) + | latest::instance_spec::Component::P9fs(_) => { return Err(ApiSpecError::FeatureCompiledOut { component: device_id, feature: "falcon", }); } #[cfg(feature = "falcon")] - v1::instance_spec::Component::SoftNpuPciPort(port) => { + latest::instance_spec::Component::SoftNpuPciPort(port) => { builder.set_softnpu_pci_port(port)?; } #[cfg(feature = "falcon")] - v1::instance_spec::Component::SoftNpuPort(port) => { + latest::instance_spec::Component::SoftNpuPort(port) => { let (_, backend_spec) = dlpi_backends .remove_entry(&port.backend_id) .ok_or_else(|| ApiSpecError::NetworkBackendNotFound { @@ -386,18 +397,18 @@ pub(crate) fn v1_to_spec_builder( builder.add_softnpu_port(device_id, port)?; } #[cfg(feature = "falcon")] - v1::instance_spec::Component::SoftNpuP9(p9) => { + latest::instance_spec::Component::SoftNpuP9(p9) => { builder.set_softnpu_p9(p9)?; } #[cfg(feature = "falcon")] - v1::instance_spec::Component::P9fs(p9fs) => { + latest::instance_spec::Component::P9fs(p9fs) => { builder.set_p9fs(p9fs)?; } - v1::instance_spec::Component::CrucibleStorageBackend(_) - | v1::instance_spec::Component::FileStorageBackend(_) - | v1::instance_spec::Component::BlobStorageBackend(_) - | v1::instance_spec::Component::VirtioNetworkBackend(_) - | v1::instance_spec::Component::DlpiNetworkBackend(_) => { + latest::instance_spec::Component::CrucibleStorageBackend(_) + | latest::instance_spec::Component::FileStorageBackend(_) + | latest::instance_spec::Component::BlobStorageBackend(_) + | latest::instance_spec::Component::VirtioNetworkBackend(_) + | latest::instance_spec::Component::DlpiNetworkBackend(_) => { unreachable!("already filtered out backends") } } diff --git a/bin/propolis-server/src/lib/spec/builder.rs b/bin/propolis-server/src/lib/spec/builder.rs index 993697259..6c1ad42ef 100644 --- a/bin/propolis-server/src/lib/spec/builder.rs +++ b/bin/propolis-server/src/lib/spec/builder.rs @@ -79,6 +79,13 @@ pub(crate) struct SpecBuilder { component_names: BTreeSet, } +/// hokay. SpecBuilder is where we're stuffing the large ball of glue and twine that connects +/// between versioned HTTP API types like InstanceSpec and all its descendants, and the +/// non-versioned "This Version Of Propolis" types like Spec and specific device configuration +/// structs. +/// +/// this means that we have conversions from all external API InstanceSpec to `Spec` (via +/// SpecBuilder) here. elsewhere, conversions from Spec to all InstanceSpec. impl SpecBuilder { pub(super) fn with_instance_spec_board( board: InstanceSpecBoard, diff --git a/bin/propolis-server/src/lib/spec/mod.rs b/bin/propolis-server/src/lib/spec/mod.rs index f32a8df3e..661737cb1 100644 --- a/bin/propolis-server/src/lib/spec/mod.rs +++ b/bin/propolis-server/src/lib/spec/mod.rs @@ -16,7 +16,7 @@ use std::collections::BTreeMap; -use crate::spec::api_spec_v0::ApiSpecError; +use crate::spec::api_spec_v6::ApiSpecError; use cpuid_utils::CpuidSet; use propolis_api_types::instance_spec::{ components::{ @@ -36,7 +36,7 @@ use propolis_api_types::instance_spec::{ use propolis_api_types::instance_spec::{ Component, InstanceSpec, SmbiosType1Input, }; -use propolis_api_types_versions::{v1, v2, v3}; +use propolis_api_types_versions::{v1, v3, v6, latest}; use thiserror::Error; #[cfg(feature = "failure-injection")] @@ -50,34 +50,36 @@ use propolis_api_types::instance_spec::components::{ }; // mod api_request; -pub(crate) mod api_spec_v0; +pub(crate) mod api_spec_v1; +pub(crate) mod api_spec_v3; +pub(crate) mod api_spec_v6; pub(crate) mod builder; -/// The code related to latest types does not go into a versioned module -impl From for InstanceSpec { +/* +/// TODO: it happens to be true that we can write this today. it is not true in general that `Spec` +/// must be convertible into `latest::instance_spec::InstanceSpec` (we can deprecate or remove an +/// item in a new InstanceSpec, and the conversion can become impossible. this just hasn't happened +/// yet.) +impl From for latest::instance_spec::InstanceSpec { fn from(val: Spec) -> Self { - let smbios = val.smbios_type1_input.clone(); - let vsock = val.vsock.clone(); - - let v1_spec: v1::instance_spec::InstanceSpec = val.into(); - let v2_spec = - v2::instance_spec::InstanceSpec { smbios, ..v1_spec.into() }; - let v3_spec: v3::instance_spec::InstanceSpec = v2_spec.into(); - let mut spec: InstanceSpec = v3_spec.into(); - - if let Some(vsock) = vsock { - spec.components - .insert(vsock.id, Component::VirtioSocket(vsock.spec)); - } - spec + api_spec_v6:: + // TODO: + panic!("convert the spec into a latest::InstanceSpec or die trying"); } } +*/ /// The code related to latest types does not go into a versioned module +/// +/// TODO: impls here can probably be copied wholesale when new versions are added +/// it may not be correct for a new `TryFrom for Spec` to be derived from this +/// function. or it may be reasonable. this depends exclusively on the changes in InstanceSpec. impl TryFrom for Spec { type Error = ApiSpecError; fn try_from(value: InstanceSpec) -> Result { + Ok(api_spec_v6::latest_api_spec_to_spec_builder(value)?.finish()) + /* // Extract vsock before conversion since it's v3-only and will be // filtered out during the v3→v2→v1 chain. let mut vsock_entry = None; @@ -93,13 +95,23 @@ impl TryFrom for Spec { let smbios = v2_spec.smbios.clone(); let v1_spec: v1::instance_spec::InstanceSpec = v2_spec.into(); - let mut builder = api_spec_v0::v1_to_spec_builder(v1_spec)?; + let mut builder = api_spec_v1::v1_to_spec_builder(v1_spec)?; if let Some(vsock) = vsock_entry { builder.add_vsock_device(vsock)?; } let mut spec = builder.finish(); spec.smbios_type1_input = smbios; Ok(spec) + */ + } +} + +// TODO: now.. what about the older versions of InstanceSpec! +impl TryFrom for Spec { + type Error = api_spec_v1::ApiSpecError; + + fn try_from(value: v1::instance_spec::InstanceSpec) -> Result { + Ok(api_spec_v1::v1_to_spec_builder(value)?.finish()) } } @@ -240,7 +252,7 @@ impl StorageDevice { } } -impl TryFrom for v1::instance_spec::Component { +impl From for latest::instance_spec::Component { fn from(value: StorageDevice) -> Self { match value { StorageDevice::Virtio(d) => Self::VirtioDisk(d), @@ -249,6 +261,30 @@ impl TryFrom for v1::instance_spec::Component { } } +// TODO: this needs to move or die +impl TryFrom for v3::instance_spec::Component { + type Error = v6::instance_spec::InvalidV3Component; + + fn try_from(value: StorageDevice) -> Result { + match value { + StorageDevice::Virtio(d) => Ok(Self::VirtioDisk(d)), + StorageDevice::Nvme(d) => Ok(Self::NvmeDisk(d.try_into()?)), + } + } +} + +// TODO: this needs to move or die +impl TryFrom for v1::instance_spec::Component { + type Error = v6::instance_spec::InvalidV3Component; + + fn try_from(value: StorageDevice) -> Result { + match value { + StorageDevice::Virtio(d) => Ok(Self::VirtioDisk(d)), + StorageDevice::Nvme(d) => Ok(Self::NvmeDisk(d.try_into()?)), + } + } +} + impl TryFrom for StorageDevice { type Error = ComponentTypeMismatch; @@ -299,6 +335,26 @@ impl From for Component { } } +impl From for v3::instance_spec::Component { + fn from(value: StorageBackend) -> Self { + match value { + StorageBackend::Crucible(be) => Self::CrucibleStorageBackend(be), + StorageBackend::File(be) => Self::FileStorageBackend(be), + StorageBackend::Blob(be) => Self::BlobStorageBackend(be), + } + } +} + +impl From for v1::instance_spec::Component { + fn from(value: StorageBackend) -> Self { + match value { + StorageBackend::Crucible(be) => Self::CrucibleStorageBackend(be), + StorageBackend::File(be) => Self::FileStorageBackend(be), + StorageBackend::Blob(be) => Self::BlobStorageBackend(be), + } + } +} + impl TryFrom for StorageBackend { type Error = ComponentTypeMismatch; diff --git a/bin/propolis-server/src/lib/vm/mod.rs b/bin/propolis-server/src/lib/vm/mod.rs index f83bf8d29..fde568e54 100644 --- a/bin/propolis-server/src/lib/vm/mod.rs +++ b/bin/propolis-server/src/lib/vm/mod.rs @@ -355,9 +355,10 @@ impl Vm { let spec = vm.objects().lock_shared().await.instance_spec().clone(); let state = vm.external_state_rx.borrow().clone(); + let external_spec: propolis_api_types_versions::latest::instance_spec::InstanceSpec = spec.into(); Some(InstanceSpecGetResponse { properties: vm.properties.clone(), - spec: InstanceSpecStatus::Present(spec.into()), + spec: InstanceSpecStatus::Present(external_spec), state: state.state, }) } @@ -369,13 +370,14 @@ impl Vm { spec: spec.clone().into(), }) } - VmState::Rundown { vm, spec } => Some(InstanceSpecGetResponse { - properties: vm.properties.clone(), - state: vm.external_state_rx.borrow().state, - spec: InstanceSpecStatus::Present( - spec.as_ref().to_owned().into(), - ), - }), + VmState::Rundown { vm, spec } => { + let external_spec: propolis_api_types_versions::latest::instance_spec::InstanceSpec = (*spec.to_owned()).into(); + Some(InstanceSpecGetResponse { + properties: vm.properties.clone(), + state: vm.external_state_rx.borrow().state, + spec: InstanceSpecStatus::Present(external_spec), + }) + }, } } diff --git a/crates/propolis-api-types-versions/src/add_vsock/instance_spec.rs b/crates/propolis-api-types-versions/src/add_vsock/instance_spec.rs index 08caf469d..750b52017 100644 --- a/crates/propolis-api-types-versions/src/add_vsock/instance_spec.rs +++ b/crates/propolis-api-types-versions/src/add_vsock/instance_spec.rs @@ -112,19 +112,24 @@ impl TryFrom for V1Component { } } -impl From for v2::instance_spec::InstanceSpec { - fn from(new: InstanceSpec) -> Self { - Self { - board: new.board, - components: new +impl TryFrom for v2::instance_spec::InstanceSpec { + type Error = InvalidV1Component; + + fn try_from(new: InstanceSpec) -> Result { + let components: Result, _> = new .components .into_iter() - .filter_map(|(k, v)| { - V1Component::try_from(v).ok().map(|c| (k, c)) + .map(|(k, v)| { + V1Component::try_from(v).map(|c| (k, c)) }) - .collect(), + .collect::, _>>(); + let components = components.expect("TODO: hueagghggh"); + + Ok(Self { + board: new.board, + components, smbios: new.smbios, - } + }) } } @@ -170,7 +175,11 @@ impl From for v2::instance_spec::InstanceSpecStatus { InstanceSpecStatus::WaitingForMigrationSource => { Self::WaitingForMigrationSource } - InstanceSpecStatus::Present(spec) => Self::Present(spec.into()), + InstanceSpecStatus::Present(spec) => { + let v2_spec: v2::instance_spec::InstanceSpec = spec.try_into() + .expect("TODO: v3 instance spec into v2"); + Self::Present(v2_spec) + }, } } } diff --git a/crates/propolis-config-toml/src/spec.rs b/crates/propolis-config-toml/src/spec.rs index 69316c10d..6166de4ce 100644 --- a/crates/propolis-config-toml/src/spec.rs +++ b/crates/propolis-config-toml/src/spec.rs @@ -79,7 +79,7 @@ pub struct SpecConfig { pub components: BTreeMap, } -// Inspired by `api_spec_v0.rs`'s `insert_component` and +// Inspired by `api_spec_v1.rs`'s `insert_component` and // `propolis-cli/src/main.rs`'s `add_component_to_spec`. Same purpose as both of // them. // From 1b3dc3bc99d627ec37df1df996e49acc86dc7494 Mon Sep 17 00:00:00 2001 From: iximeow Date: Sat, 18 Jul 2026 00:29:02 +0000 Subject: [PATCH 07/24] migration works right with versioning now lol --- bin/propolis-cli/src/main.rs | 2 +- bin/propolis-server/src/lib/initializer.rs | 2 +- bin/propolis-server/src/lib/migrate/mod.rs | 4 + .../src/lib/migrate/preamble.rs | 271 +------------- bin/propolis-server/src/lib/migrate/source.rs | 11 +- bin/propolis-server/src/lib/migrate/types.rs | 347 ++++++++++++++++++ .../src/lib/spec/api_spec_v1.rs | 31 +- .../src/lib/spec/api_spec_v3.rs | 15 +- .../src/lib/spec/api_spec_v6.rs | 80 ++-- bin/propolis-server/src/lib/spec/mod.rs | 66 +--- crates/propolis-config-toml/src/spec.rs | 2 +- 11 files changed, 446 insertions(+), 385 deletions(-) create mode 100644 bin/propolis-server/src/lib/migrate/types.rs diff --git a/bin/propolis-cli/src/main.rs b/bin/propolis-cli/src/main.rs index 6df8f6930..7c1cab3ea 100644 --- a/bin/propolis-cli/src/main.rs +++ b/bin/propolis-cli/src/main.rs @@ -263,7 +263,7 @@ impl DiskRequest { backend_id: backend_id.clone(), pci_path, serial_number: nvme_serial_from_str(&self.name, b' '), - has_write_cache: true, + has_write_cache: false, }), _ => anyhow::bail!( "invalid device type in disk request: {:?}", diff --git a/bin/propolis-server/src/lib/initializer.rs b/bin/propolis-server/src/lib/initializer.rs index a3bf2f770..97274f629 100644 --- a/bin/propolis-server/src/lib/initializer.rs +++ b/bin/propolis-server/src/lib/initializer.rs @@ -909,7 +909,7 @@ impl MachineInitializer<'_> { let nvme = nvme::PciNvme::create( &nvme_spec.serial_number, mdts, - true, + false, self.log.new(slog::o!("component" => component)), ); self.devices.insert(device_id.clone(), nvme.clone()); diff --git a/bin/propolis-server/src/lib/migrate/mod.rs b/bin/propolis-server/src/lib/migrate/mod.rs index b8467961d..e4277edf5 100644 --- a/bin/propolis-server/src/lib/migrate/mod.rs +++ b/bin/propolis-server/src/lib/migrate/mod.rs @@ -2,6 +2,10 @@ // 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/. +//! This module and its children define the migration protocol for +//! `propolis-server`. Generally the structures and state machine here are +//! consistent with the discussion in RFD 71. + use bit_field::BitField; use dropshot::HttpError; use propolis::migrate::MigrateStateError; diff --git a/bin/propolis-server/src/lib/migrate/preamble.rs b/bin/propolis-server/src/lib/migrate/preamble.rs index 151d6c073..90924b662 100644 --- a/bin/propolis-server/src/lib/migrate/preamble.rs +++ b/bin/propolis-server/src/lib/migrate/preamble.rs @@ -5,15 +5,11 @@ use std::collections::BTreeMap; use propolis_api_types::instance::ReplacementComponent; -use propolis_api_types_versions::{v1, v3, v6}; +use propolis_api_types_versions::v1; use serde::{Deserialize, Serialize}; use crate::migrate; use crate::spec::{ - api_spec_v1::ApiSpecError as V1SpecError, -// api_spec_v2::ApiSpecError as V2SpecError, - api_spec_v3::ApiSpecError as V3SpecError, - api_spec_v6::ApiSpecError as V6SpecError, Spec }; @@ -47,270 +43,7 @@ impl Preamble { ReplacementComponent, >, ) -> Result { - fn wrong_type_error( - id: &v1::instance_spec::SpecKey, - kind: &str, - ) -> MigrateError { - let msg = - format!("component {id} is not a {kind} in the source spec"); - MigrateError::InstanceSpecsIncompatible(msg) - } - - let amended_spec = match self.instance_spec { - migrate::types::VersionedInstanceSpec::V1(mut source_spec) => { - for (id, comp) in replacements { - let Some(to_amend) = source_spec.components.get_mut(id) else { - return Err(MigrateError::InstanceSpecsIncompatible(format!( - "replacement component {id} not in source spec", - ))); - }; - - match comp { - #[cfg(not(feature = "failure-injection"))] - ReplacementComponent::MigrationFailureInjector(_) => { - return Err(MigrateError::InstanceSpecsIncompatible( - format!( - "replacing migration failure injector {id} is \ - impossible because the feature is compiled out" - ), - )); - } - - #[cfg(feature = "failure-injection")] - ReplacementComponent::MigrationFailureInjector(comp) => { - let v1::instance_spec::Component::MigrationFailureInjector( - src, - ) = to_amend - else { - return Err(wrong_type_error( - id, - "migration failure injector", - )); - }; - - *src = comp.clone(); - } - ReplacementComponent::CrucibleStorageBackend(comp) => { - let v1::instance_spec::Component::CrucibleStorageBackend( - src, - ) = to_amend - else { - return Err(wrong_type_error(id, "crucible backend")); - }; - - *src = comp.clone(); - } - ReplacementComponent::VirtioNetworkBackend(comp) => { - let v1::instance_spec::Component::VirtioNetworkBackend(src) = - to_amend - else { - return Err(wrong_type_error(id, "viona backend")); - }; - - *src = comp.clone(); - } - } - } - - let amended_spec: Spec = - source_spec.try_into().map_err(|e: V1SpecError| { - MigrateError::PreambleParse(e.to_string()) - })?; - - amended_spec - } - migrate::types::VersionedInstanceSpec::V2(mut source_spec) => { - panic!("source spec: {:?}", source_spec); - /* - for (id, comp) in replacements { - let Some(to_amend) = source_spec.components.get_mut(id) else { - return Err(MigrateError::InstanceSpecsIncompatible(format!( - "replacement component {id} not in source spec", - ))); - }; - - match comp { - #[cfg(not(feature = "failure-injection"))] - ReplacementComponent::MigrationFailureInjector(_) => { - return Err(MigrateError::InstanceSpecsIncompatible( - format!( - "replacing migration failure injector {id} is \ - impossible because the feature is compiled out" - ), - )); - } - - #[cfg(feature = "failure-injection")] - ReplacementComponent::MigrationFailureInjector(comp) => { - let v1::instance_spec::Component::MigrationFailureInjector( - src, - ) = to_amend - else { - return Err(wrong_type_error( - id, - "migration failure injector", - )); - }; - - *src = comp.clone(); - } - ReplacementComponent::CrucibleStorageBackend(comp) => { - let v1::instance_spec::Component::CrucibleStorageBackend( - src, - ) = to_amend - else { - return Err(wrong_type_error(id, "crucible backend")); - }; - - *src = comp.clone(); - } - ReplacementComponent::VirtioNetworkBackend(comp) => { - let v1::instance_spec::Component::VirtioNetworkBackend(src) = - to_amend - else { - return Err(wrong_type_error(id, "viona backend")); - }; - - *src = comp.clone(); - } - } - } - - let amended_spec: Spec = - source_spec.try_into().map_err(|e: V1SpecError| { - MigrateError::PreambleParse(e.to_string()) - })?; - - amended_spec - */ - } - migrate::types::VersionedInstanceSpec::V3(mut source_spec) => { - for (id, comp) in replacements { - let Some(to_amend) = source_spec.components.get_mut(id) else { - return Err(MigrateError::InstanceSpecsIncompatible(format!( - "replacement component {id} not in source spec", - ))); - }; - - match comp { - #[cfg(not(feature = "failure-injection"))] - ReplacementComponent::MigrationFailureInjector(_) => { - return Err(MigrateError::InstanceSpecsIncompatible( - format!( - "replacing migration failure injector {id} is \ - impossible because the feature is compiled out" - ), - )); - } - - #[cfg(feature = "failure-injection")] - ReplacementComponent::MigrationFailureInjector(comp) => { - let v3::instance_spec::Component::MigrationFailureInjector( - src, - ) = to_amend - else { - return Err(wrong_type_error( - id, - "migration failure injector", - )); - }; - - *src = comp.clone(); - } - ReplacementComponent::CrucibleStorageBackend(comp) => { - let v3::instance_spec::Component::CrucibleStorageBackend( - src, - ) = to_amend - else { - return Err(wrong_type_error(id, "crucible backend")); - }; - - *src = comp.clone(); - } - ReplacementComponent::VirtioNetworkBackend(comp) => { - let v3::instance_spec::Component::VirtioNetworkBackend(src) = - to_amend - else { - return Err(wrong_type_error(id, "viona backend")); - }; - - *src = comp.clone(); - } - } - } - - let v6_spec: v6::instance_spec::InstanceSpec = source_spec.into(); - let amended_spec: Spec = - v6_spec.try_into().map_err(|e: V6SpecError| { - let v3_error: V3SpecError = e.into(); - MigrateError::PreambleParse(v3_error.to_string()) - })?; - - amended_spec - } - migrate::types::VersionedInstanceSpec::V6(mut source_spec) => { - for (id, comp) in replacements { - let Some(to_amend) = source_spec.components.get_mut(id) else { - return Err(MigrateError::InstanceSpecsIncompatible(format!( - "replacement component {id} not in source spec", - ))); - }; - - match comp { - #[cfg(not(feature = "failure-injection"))] - ReplacementComponent::MigrationFailureInjector(_) => { - return Err(MigrateError::InstanceSpecsIncompatible( - format!( - "replacing migration failure injector {id} is \ - impossible because the feature is compiled out" - ), - )); - } - - #[cfg(feature = "failure-injection")] - ReplacementComponent::MigrationFailureInjector(comp) => { - let v6::instance_spec::Component::MigrationFailureInjector( - src, - ) = to_amend - else { - return Err(wrong_type_error( - id, - "migration failure injector", - )); - }; - - *src = comp.clone(); - } - ReplacementComponent::CrucibleStorageBackend(comp) => { - let v6::instance_spec::Component::CrucibleStorageBackend( - src, - ) = to_amend - else { - return Err(wrong_type_error(id, "crucible backend")); - }; - - *src = comp.clone(); - } - ReplacementComponent::VirtioNetworkBackend(comp) => { - let v6::instance_spec::Component::VirtioNetworkBackend(src) = - to_amend - else { - return Err(wrong_type_error(id, "viona backend")); - }; - - *src = comp.clone(); - } - } - } - - let amended_spec: Spec = - source_spec.try_into().map_err(|e: V6SpecError| { - MigrateError::PreambleParse(e.to_string()) - })?; - - amended_spec - } - }; + let amended_spec = self.instance_spec.into_amended_spec(replacements)?; // TODO: Compare opaque blobs. diff --git a/bin/propolis-server/src/lib/migrate/source.rs b/bin/propolis-server/src/lib/migrate/source.rs index 948330870..0b163dd36 100644 --- a/bin/propolis-server/src/lib/migrate/source.rs +++ b/bin/propolis-server/src/lib/migrate/source.rs @@ -9,7 +9,6 @@ use propolis::migrate::{ MigrateCtx, MigrateStateError, Migrator, PayloadOutputs, }; use propolis::vmm; -use propolis_api_types_versions::v1; use slog::{debug, error, info, trace, warn}; use std::collections::HashMap; use std::convert::TryInto; @@ -30,6 +29,7 @@ use crate::migrate::{ Device, DevicePayload, MigrateError, MigratePhase, MigrateRole, MigrationState, PageIter, }; +use crate::migrate::types::VersionedInstanceSpec; use crate::vm::objects::VmObjects; use crate::vm::state_publisher::{ @@ -467,13 +467,8 @@ impl RonV0Runner<'_, T> { async fn sync(&mut self) -> Result<(), MigrateError> { self.update_state(MigrationState::Sync); - let spec = self.vm.lock_shared().await.instance_spec().clone(); - let v1_spec: v1::instance_spec::InstanceSpec = spec.try_into() - .expect("TODO: handle being unable to turn Spec into a v1 InstanceSpec"); - let preamble = - Preamble::new(crate::migrate::types::VersionedInstanceSpec::V1( - v1_spec - )); + let versioned = VersionedInstanceSpec::from_spec(self.vm.lock_shared().await.instance_spec())?; + let preamble = Preamble::new(versioned); let s = ron::ser::to_string(&preamble) .map_err(codec::ProtocolError::from)?; self.send_msg(codec::Message::Serialized(s)).await?; diff --git a/bin/propolis-server/src/lib/migrate/types.rs b/bin/propolis-server/src/lib/migrate/types.rs new file mode 100644 index 000000000..cd98e92bf --- /dev/null +++ b/bin/propolis-server/src/lib/migrate/types.rs @@ -0,0 +1,347 @@ +// 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 to describe a VM which is being migrated. +//! +//! The API types that describe a VM are converted by `propolis-server` into a +//! [`struct Spec`][crate::lib::spec::Spec] as a "how this version happens to +//! describe VMs" internal structure. Early in migration we must convert this to +//! some format that a `propolis-server` of a different version can instantiate +//! an equivalent VM from, for device state everything else to be imported into. +//! We *kind of* use API types here, and the rest of this section gets into why +//! and what one should consider in adding future versions. +//! +//! Even for VMs that have been migrated many times, `propolis-server` must +//! incarnate a VM that can be described by *some* HTTP API `InstanceSpec` +//! version at some point in the past. We'll call this "oldest possible VM spec" +//! the "import horizon" that `propolis-server` supports. Further, the tooling +//! for Dropshot (/OpenAPI) version management is quite good, and provides +//! guardrails against old versions' API types having structural changes. +//! +//! So, the strategy we use for communicating `struct Spec` is to convert it +//! back into *some* `InstanceSpec` that faithfully describes the Spec from +//! whence it came, send that on the wire to a destination `propolis-server`, +//! and expect it'll produce an equivalent VM to import state into. +//! +//! What complicates this is that it may not in general be possible to translate +//! a `struct Spec` into one of the most recent HTTP API `InstanceSpec` types. +//! We may have decided to split a setting into multiple fields, add a device +//! setting, or may have even added a field which cannot be simply "updated +//! into" from an old `InstanceSpec` in isolation (see: SMBIOS Type 1 tables!) +//! This points us away from the naive "`propolis-server` simply produces an +//! InstanceSpec from this version or last version" strategy one may imagine, +//! and towards keeping HTTP API types around well after those HTTP API +//! *endpoints* have been retired! +//! +//! So, we're finally at why the existing mechanics of getting a `Spec` out of +//! `propolis-server` and transmitted are what they are: go through a list of +//! `TryInto for v*::instance_spec::InstanceSpec`, one of them will +//! succeed, and send that over. This is the implementation you'll find in +//! [`RonV0Runner::sync`][crate::lib::migrate::source::RonV0Runner::sync]. +//! +//! ### [`VersionedInstanceSpec`] +//! +//! `fn sync` mentions `VersionedInstanceSpec`, which is where things get weirder +//! than simple HTTP API types. +//! +//! `propolis-server` supports more flexibility than the control plane is +//! expected to need in the immediate future. Given the current control plane +//! (update, live migration) plans, the source and destination `propolis-server` +//! may either be the same version, or the destination may be one release newer. +//! +//! Since we have to support HTTP API types as far back as `propolis-server`'s +//! import horizon, it's not much additional work to at least try supporting +//! migration across downgrades of `propolis-server`. If try converting to all +//! `v1, v2, v3 ..` forms of `InstanceSpec` in *ascending* order, the only time +//! conversion will fail to be downgradeable is if a VM has been created using +//! only-in-newest API language. This means that some VMs created using a +//! `latest::instance_spec::InstanceSpec` could end up with even `v1` types on +//! the wire for migration, but as long as `From/TryFrom` use is correct and +//! *not lossy*, that's fine! +//! +//! So, `VersionedInstanceSpec` is a container that is outside the HTTP API but +//! only contains OpenAPI-described API types. A destination `propolis-server` +//! is expected to gracefully reject new variants, and a source +//! `propolis-server` is expected to emit oldest-supported forms of instances. +//! +//! ### What if we didn't do all that? +//! +//! Another option to negotiate one `propolis-server`'s `struct Spec` into +//! another process would be to have some set of structs and functions to move +//! to and from totally-unrelated-to-HTTP-API wire format with more +//! forward-compatible device types. This would probably work! It would also +//! require testing work to check we don't inadvertently change the current +//! canonical definition of an "old version" which should never change. +//! +//! In either case we need testing that old device descriptions don't +//! *semantically* change, so it doesn't save effort there either. + +use serde::{Deserialize, Serialize}; + +use propolis_api_types_versions::v1::instance::ReplacementComponent; +use propolis_api_types_versions::{v1, v2, v3, v6}; + +use std::collections::BTreeMap; + +use crate::migrate::MigrateError; +use crate::spec::{ + api_spec_v1::ApiSpecError as V1SpecError, + api_spec_v3::ApiSpecError as V3SpecError, + api_spec_v6::ApiSpecError as V6SpecError, + Spec, +}; + +/// A wrapper for one of any supported `InstanceSpec` that describe a +/// to-be-migrated VM. +/// +/// Architecturally, this bridges the very fixed HTTP API types and the +/// possibility of having to migrate an arbitrarily old VM. See the doc comments +/// on [`migrate`][crate::lib::migrate] for more about how this all fits +/// together. +// +// If you're adding (or removing!?) API versions, you'll just want to adjust the +// variants here, plus uses in `VersionedInstanceSpec::from_spec` and +// `VersionedInstanceSpec::into_amended_spec`. shrimple as that. +#[derive(Deserialize, Serialize, Debug)] +pub(crate) enum VersionedInstanceSpec { + V1(v1::instance_spec::InstanceSpec), + V2(v2::instance_spec::InstanceSpec), + V3(v3::instance_spec::InstanceSpec), + V6(v6::instance_spec::InstanceSpec), +} + +impl VersionedInstanceSpec { + pub(crate) fn from_spec(spec: &Spec) -> Result { + // Try conversions in oldest-to-newest order in support of + // migration-to-older-version. As long as the VM doesn't use a new + // feature or setting, we'll pick a version the older Propolis should + // know about, and everything else will "just work". + // + // When adding a new API version, the previous latest version will + // probably have gone from having an `Into` to instead having + // `TryInto`, which fails for a `Spec` describing whatever new + // features have been added. The new latest version, hopefully, will + // have an `Into`. Those two versions should be the only ones that + // need attention. + let versioned = if let Ok(v1_spec) = TryInto::::try_into(spec.clone()) { + VersionedInstanceSpec::V1(v1_spec) + } else if let Ok(v3_spec) = TryInto::::try_into(spec.clone()) { + VersionedInstanceSpec::V3(v3_spec) + } else { + VersionedInstanceSpec::V6(Into::::into(spec.clone())) + }; + Ok(versioned) + } + + pub(crate) fn into_amended_spec(self, replacements: &BTreeMap< + v1::instance_spec::SpecKey, + ReplacementComponent, + > + ) -> Result { + fn wrong_type_error( + id: &v1::instance_spec::SpecKey, + kind: &str, + ) -> MigrateError { + let msg = + format!("component {id} is not a {kind} in the source spec"); + MigrateError::InstanceSpecsIncompatible(msg) + } + + let amended_spec = match self { + VersionedInstanceSpec::V1(mut source_spec) => { + for (id, comp) in replacements { + let Some(to_amend) = source_spec.components.get_mut(id) else { + return Err(MigrateError::InstanceSpecsIncompatible(format!( + "replacement component {id} not in source spec", + ))); + }; + + match comp { + #[cfg(not(feature = "failure-injection"))] + ReplacementComponent::MigrationFailureInjector(_) => { + return Err(MigrateError::InstanceSpecsIncompatible( + format!( + "replacing migration failure injector {id} is \ + impossible because the feature is compiled out" + ), + )); + } + + #[cfg(feature = "failure-injection")] + ReplacementComponent::MigrationFailureInjector(comp) => { + let v1::instance_spec::Component::MigrationFailureInjector( + src, + ) = to_amend + else { + return Err(wrong_type_error( + id, + "migration failure injector", + )); + }; + + *src = comp.clone(); + } + ReplacementComponent::CrucibleStorageBackend(comp) => { + let v1::instance_spec::Component::CrucibleStorageBackend( + src, + ) = to_amend + else { + return Err(wrong_type_error(id, "crucible backend")); + }; + + *src = comp.clone(); + } + ReplacementComponent::VirtioNetworkBackend(comp) => { + let v1::instance_spec::Component::VirtioNetworkBackend(src) = + to_amend + else { + return Err(wrong_type_error(id, "viona backend")); + }; + + *src = comp.clone(); + } + } + } + + let amended_spec: Spec = + source_spec.try_into().map_err(|e: V1SpecError| { + MigrateError::PreambleParse(e.to_string()) + })?; + + amended_spec + } + VersionedInstanceSpec::V2(_source_spec) => { + panic!("should v2 really be here?"); + } + VersionedInstanceSpec::V3(mut source_spec) => { + for (id, comp) in replacements { + let Some(to_amend) = source_spec.components.get_mut(id) else { + return Err(MigrateError::InstanceSpecsIncompatible(format!( + "replacement component {id} not in source spec", + ))); + }; + + match comp { + #[cfg(not(feature = "failure-injection"))] + ReplacementComponent::MigrationFailureInjector(_) => { + return Err(MigrateError::InstanceSpecsIncompatible( + format!( + "replacing migration failure injector {id} is \ + impossible because the feature is compiled out" + ), + )); + } + + #[cfg(feature = "failure-injection")] + ReplacementComponent::MigrationFailureInjector(comp) => { + let v3::instance_spec::Component::MigrationFailureInjector( + src, + ) = to_amend + else { + return Err(wrong_type_error( + id, + "migration failure injector", + )); + }; + + *src = comp.clone(); + } + ReplacementComponent::CrucibleStorageBackend(comp) => { + let v3::instance_spec::Component::CrucibleStorageBackend( + src, + ) = to_amend + else { + return Err(wrong_type_error(id, "crucible backend")); + }; + + *src = comp.clone(); + } + ReplacementComponent::VirtioNetworkBackend(comp) => { + let v3::instance_spec::Component::VirtioNetworkBackend(src) = + to_amend + else { + return Err(wrong_type_error(id, "viona backend")); + }; + + *src = comp.clone(); + } + } + } + + let v6_spec: v6::instance_spec::InstanceSpec = source_spec.into(); + let amended_spec: Spec = + v6_spec.try_into().map_err(|e: V6SpecError| { + let v3_error: V3SpecError = e.into(); + MigrateError::PreambleParse(v3_error.to_string()) + })?; + + amended_spec + } + VersionedInstanceSpec::V6(mut source_spec) => { + for (id, comp) in replacements { + let Some(to_amend) = source_spec.components.get_mut(id) else { + return Err(MigrateError::InstanceSpecsIncompatible(format!( + "replacement component {id} not in source spec", + ))); + }; + + match comp { + #[cfg(not(feature = "failure-injection"))] + ReplacementComponent::MigrationFailureInjector(_) => { + return Err(MigrateError::InstanceSpecsIncompatible( + format!( + "replacing migration failure injector {id} is \ + impossible because the feature is compiled out" + ), + )); + } + + #[cfg(feature = "failure-injection")] + ReplacementComponent::MigrationFailureInjector(comp) => { + let v6::instance_spec::Component::MigrationFailureInjector( + src, + ) = to_amend + else { + return Err(wrong_type_error( + id, + "migration failure injector", + )); + }; + + *src = comp.clone(); + } + ReplacementComponent::CrucibleStorageBackend(comp) => { + let v6::instance_spec::Component::CrucibleStorageBackend( + src, + ) = to_amend + else { + return Err(wrong_type_error(id, "crucible backend")); + }; + + *src = comp.clone(); + } + ReplacementComponent::VirtioNetworkBackend(comp) => { + let v6::instance_spec::Component::VirtioNetworkBackend(src) = + to_amend + else { + return Err(wrong_type_error(id, "viona backend")); + }; + + *src = comp.clone(); + } + } + } + + let amended_spec: Spec = + source_spec.try_into().map_err(|e: V6SpecError| { + MigrateError::PreambleParse(e.to_string()) + })?; + + amended_spec + } + }; + + Ok(amended_spec) + } +} diff --git a/bin/propolis-server/src/lib/spec/api_spec_v1.rs b/bin/propolis-server/src/lib/spec/api_spec_v1.rs index 43c50486e..da070e0c0 100644 --- a/bin/propolis-server/src/lib/spec/api_spec_v1.rs +++ b/bin/propolis-server/src/lib/spec/api_spec_v1.rs @@ -47,10 +47,13 @@ pub(crate) enum ApiSpecError { #[error("backend {0} not used by any device")] BackendNotUsed(SpecKey), + + #[error("spec contains v1-incompatible component: {0}")] + IncompatibleComponent(String), } impl TryFrom for v1::instance_spec::InstanceSpec { - type Error = String; + type Error = ApiSpecError; fn try_from(val: Spec) -> Result { // Exhaustively destructure the input spec so that adding a new field @@ -79,11 +82,24 @@ impl TryFrom for v1::instance_spec::InstanceSpec { } = val; if smbios_type1_input.is_some() { - return Err("TODO: hahaha".to_string()); + // NOTE: This is overly strict. There is one specific SMBIOS Type 1 + // table that could be expressed previously, and that is the one + // where the instance serial is set to the instance UUID. + // + // This is the Type 1 table provided by Nexus as of specs later than + // V1, so by bailing here we're effectively blocking migration from + // new Propolises to old Propolises. This is acceptable for a few + // reasons: + // * the control plane is not expected to migrate VMs to down-rev + // Propolises + // * V1 specs are from before live migration was done outside + // ad-hoc/CI environments - such an old Propolis will never exist + // as a migration target in the field. + return Err(ApiSpecError::IncompatibleComponent("cannot express explicit SMBIOS tables in v1 instance spec".to_string())); } if vsock.is_some() { - return Err("TODO: hahaha".to_string()); + return Err(ApiSpecError::IncompatibleComponent("cannot convert virtio-socket to v1 instance spec".to_string())); } // Inserts a component entry into the supplied map, asserting first that @@ -120,7 +136,8 @@ impl TryFrom for v1::instance_spec::InstanceSpec { for (disk_id, disk) in disks { let backend_id = disk.device_spec.backend_id().to_owned(); - let device_component: v1::instance_spec::Component = disk.device_spec.try_into().expect("TODO: StorageDevice into v1::Component"); + let device_component: v1::instance_spec::Component = disk.device_spec.try_into() + .map_err(|e: propolis_api_types_versions::v6::instance_spec::InvalidV3Component| ApiSpecError::IncompatibleComponent(e.to_string()))?; let backend_component: v1::instance_spec::Component = disk.backend_spec.into(); insert_component(&mut spec, disk_id, device_component); insert_component(&mut spec, backend_id, backend_component); @@ -247,17 +264,13 @@ impl TryFrom for v1::instance_spec::InstanceSpec { } } -/* impl TryFrom for Spec { type Error = ApiSpecError; - fn try_from( - value: v1::instance_spec::InstanceSpec, - ) -> Result { + fn try_from(value: v1::instance_spec::InstanceSpec) -> Result { Ok(v1_to_spec_builder(value)?.finish()) } } -*/ /// Parses a v1 instance spec into a [`SpecBuilder`], validating component /// names, PCI paths, and backend references along the way. Callers can add diff --git a/bin/propolis-server/src/lib/spec/api_spec_v3.rs b/bin/propolis-server/src/lib/spec/api_spec_v3.rs index 6d5cdb3f9..d4620d758 100644 --- a/bin/propolis-server/src/lib/spec/api_spec_v3.rs +++ b/bin/propolis-server/src/lib/spec/api_spec_v3.rs @@ -5,11 +5,8 @@ //! Conversions from [`propolis_api_types::v3`]) instance specs in the //! [`propolis_api_types`] crate to the internal [`super::Spec`] representation. -use std::collections::BTreeMap; - use propolis_api_types::instance_spec::{ components::{ - backends::{DlpiNetworkBackend, VirtioNetworkBackend}, board::Board as InstanceSpecBoard, devices::{BootSettings, SerialPort as SerialPortDesc}, }, @@ -23,8 +20,7 @@ use propolis_api_types::instance_spec::components::devices::SoftNpuPort as SoftN use super::{ builder::{SpecBuilder, SpecBuilderError}, - Disk, Nic, QemuPvpanic, SerialPortDevice, Spec, StorageBackend, - StorageDevice, + SerialPortDevice, Spec, }; #[cfg(feature = "failure-injection")] @@ -50,6 +46,9 @@ pub(crate) enum ApiSpecError { #[error("backend {0} not used by any device")] BackendNotUsed(SpecKey), + + #[error("spec contains v3-incompatible component: {0}")] + IncompatibleComponent(String), } use crate::spec::api_spec_v1; @@ -61,6 +60,7 @@ impl From for api_spec_v1::ApiSpecError { ApiSpecError::NetworkBackendNotFound { backend, device } => api_spec_v1::ApiSpecError::NetworkBackendNotFound { backend, device }, ApiSpecError::FeatureCompiledOut { component, feature } => api_spec_v1::ApiSpecError::FeatureCompiledOut { component, feature }, ApiSpecError::BackendNotUsed(key) => api_spec_v1::ApiSpecError::BackendNotUsed(key), + ApiSpecError::IncompatibleComponent(key) => api_spec_v1::ApiSpecError::IncompatibleComponent(key), } } } @@ -81,7 +81,7 @@ impl From for ApiSpecError { } impl TryFrom for v3::instance_spec::InstanceSpec { - type Error = String; + type Error = ApiSpecError; fn try_from(val: Spec) -> Result { // Exhaustively destructure the input spec so that adding a new field @@ -138,7 +138,8 @@ impl TryFrom for v3::instance_spec::InstanceSpec { for (disk_id, disk) in disks { let backend_id = disk.device_spec.backend_id().to_owned(); - let device_component: v3::instance_spec::Component = disk.device_spec.try_into().expect("TODO: StorageDevice into v3::Component"); + let device_component: v3::instance_spec::Component = disk.device_spec.try_into() + .map_err(|e: propolis_api_types_versions::v6::instance_spec::InvalidV3Component| ApiSpecError::IncompatibleComponent(e.to_string()))?; let backend_component: v3::instance_spec::Component = disk.backend_spec.into(); insert_component(&mut spec, disk_id, device_component); insert_component(&mut spec, backend_id, backend_component); 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 5da294cf6..52ce2f9cf 100644 --- a/bin/propolis-server/src/lib/spec/api_spec_v6.rs +++ b/bin/propolis-server/src/lib/spec/api_spec_v6.rs @@ -159,6 +159,14 @@ impl From for v6::instance_spec::InstanceSpec { ); } + if let Some(vsock) = vsock { + insert_component( + &mut spec, + vsock.id, + v6::instance_spec::Component::VirtioSocket(vsock.spec), + ); + } + if let Some(settings) = boot_settings { insert_component( &mut spec, @@ -252,17 +260,9 @@ impl TryFrom for Spec { /// additional (non-v6) components to the builder before calling `finish()`. pub(crate) fn v6_to_spec_builder( value: v6::instance_spec::InstanceSpec, -) -> Result { - let latest_spec: latest::instance_spec::InstanceSpec = value.into(); - - latest_api_spec_to_spec_builder(latest_spec) -} - -pub(crate) fn latest_api_spec_to_spec_builder( - value: latest::instance_spec::InstanceSpec, ) -> Result { let mut builder = SpecBuilder::with_instance_spec_board(value.board)?; - let mut devices: Vec<(SpecKey, latest::instance_spec::Component)> = vec![]; + let mut devices: Vec<(SpecKey, v6::instance_spec::Component)> = vec![]; let mut boot_settings = None; let mut storage_backends: BTreeMap = BTreeMap::new(); @@ -273,9 +273,9 @@ pub(crate) fn latest_api_spec_to_spec_builder( for (id, component) in value.components.into_iter() { match component { - latest::instance_spec::Component::CrucibleStorageBackend(_) - | latest::instance_spec::Component::FileStorageBackend(_) - | latest::instance_spec::Component::BlobStorageBackend(_) => { + v6::instance_spec::Component::CrucibleStorageBackend(_) + | v6::instance_spec::Component::FileStorageBackend(_) + | v6::instance_spec::Component::BlobStorageBackend(_) => { storage_backends.insert( id, component @@ -283,10 +283,10 @@ pub(crate) fn latest_api_spec_to_spec_builder( .expect("component is known to be a storage backend"), ); } - latest::instance_spec::Component::VirtioNetworkBackend(viona) => { + v6::instance_spec::Component::VirtioNetworkBackend(viona) => { viona_backends.insert(id, viona); } - latest::instance_spec::Component::DlpiNetworkBackend(dlpi) => { + v6::instance_spec::Component::DlpiNetworkBackend(dlpi) => { dlpi_backends.insert(id, dlpi); } device => { @@ -297,8 +297,8 @@ pub(crate) fn latest_api_spec_to_spec_builder( for (device_id, device_spec) in devices { match device_spec { - latest::instance_spec::Component::VirtioDisk(_) - | latest::instance_spec::Component::NvmeDisk(_) => { + v6::instance_spec::Component::VirtioDisk(_) + | v6::instance_spec::Component::NvmeDisk(_) => { let device_spec = StorageDevice::try_from(device_spec) .expect("component is known to be a disk"); @@ -314,7 +314,7 @@ pub(crate) fn latest_api_spec_to_spec_builder( Disk { device_spec, backend_spec }, )?; } - latest::instance_spec::Component::VirtioNic(nic) => { + v6::instance_spec::Component::VirtioNic(nic) => { let (_, backend_spec) = viona_backends .remove_entry(&nic.backend_id) .ok_or_else(|| ApiSpecError::NetworkBackendNotFound { @@ -327,19 +327,19 @@ pub(crate) fn latest_api_spec_to_spec_builder( Nic { device_spec: nic, backend_spec }, )?; } - latest::instance_spec::Component::SerialPort(port) => { + v6::instance_spec::Component::SerialPort(port) => { builder.add_serial_port(device_id, port.num)?; } - latest::instance_spec::Component::PciPciBridge(bridge) => { + v6::instance_spec::Component::PciPciBridge(bridge) => { builder.add_pci_bridge(device_id, bridge)?; } - latest::instance_spec::Component::QemuPvpanic(pvpanic) => { + v6::instance_spec::Component::QemuPvpanic(pvpanic) => { builder.add_pvpanic_device(QemuPvpanic { id: device_id, spec: pvpanic, })?; } - latest::instance_spec::Component::BootSettings(settings) => { + v6::instance_spec::Component::BootSettings(settings) => { // The builder returns an error if its caller tries to add // a boot option that isn't in the set of attached disks. // Since there may be more disk devices left in the @@ -347,40 +347,40 @@ pub(crate) fn latest_api_spec_to_spec_builder( // apply it to the builder later. boot_settings = Some((device_id, settings)); } - latest::instance_spec::Component::VirtioSocket(vsock) => { + v6::instance_spec::Component::VirtioSocket(vsock) => { let vsock_device = crate::spec::VirtioSocket { id: device_id.clone(), spec: vsock }; builder.add_vsock_device(vsock_device)?; } #[cfg(not(feature = "failure-injection"))] - latest::instance_spec::Component::MigrationFailureInjector(_) => { + v6::instance_spec::Component::MigrationFailureInjector(_) => { return Err(ApiSpecError::FeatureCompiledOut { component: device_id, feature: "failure-injection", }); } #[cfg(feature = "failure-injection")] - latest::instance_spec::Component::MigrationFailureInjector(mig) => { + v6::instance_spec::Component::MigrationFailureInjector(mig) => { builder.add_migration_failure_device(MigrationFailure { id: device_id, spec: mig, })?; } #[cfg(not(feature = "falcon"))] - latest::instance_spec::Component::SoftNpuPciPort(_) - | latest::instance_spec::Component::SoftNpuPort(_) - | latest::instance_spec::Component::SoftNpuP9(_) - | latest::instance_spec::Component::P9fs(_) => { + v6::instance_spec::Component::SoftNpuPciPort(_) + | v6::instance_spec::Component::SoftNpuPort(_) + | v6::instance_spec::Component::SoftNpuP9(_) + | v6::instance_spec::Component::P9fs(_) => { return Err(ApiSpecError::FeatureCompiledOut { component: device_id, feature: "falcon", }); } #[cfg(feature = "falcon")] - latest::instance_spec::Component::SoftNpuPciPort(port) => { + v6::instance_spec::Component::SoftNpuPciPort(port) => { builder.set_softnpu_pci_port(port)?; } #[cfg(feature = "falcon")] - latest::instance_spec::Component::SoftNpuPort(port) => { + v6::instance_spec::Component::SoftNpuPort(port) => { let (_, backend_spec) = dlpi_backends .remove_entry(&port.backend_id) .ok_or_else(|| ApiSpecError::NetworkBackendNotFound { @@ -397,18 +397,18 @@ pub(crate) fn latest_api_spec_to_spec_builder( builder.add_softnpu_port(device_id, port)?; } #[cfg(feature = "falcon")] - latest::instance_spec::Component::SoftNpuP9(p9) => { + v6::instance_spec::Component::SoftNpuP9(p9) => { builder.set_softnpu_p9(p9)?; } #[cfg(feature = "falcon")] - latest::instance_spec::Component::P9fs(p9fs) => { + v6::instance_spec::Component::P9fs(p9fs) => { builder.set_p9fs(p9fs)?; } - latest::instance_spec::Component::CrucibleStorageBackend(_) - | latest::instance_spec::Component::FileStorageBackend(_) - | latest::instance_spec::Component::BlobStorageBackend(_) - | latest::instance_spec::Component::VirtioNetworkBackend(_) - | latest::instance_spec::Component::DlpiNetworkBackend(_) => { + v6::instance_spec::Component::CrucibleStorageBackend(_) + | v6::instance_spec::Component::FileStorageBackend(_) + | v6::instance_spec::Component::BlobStorageBackend(_) + | v6::instance_spec::Component::VirtioNetworkBackend(_) + | v6::instance_spec::Component::DlpiNetworkBackend(_) => { unreachable!("already filtered out backends") } } @@ -437,3 +437,9 @@ pub(crate) fn latest_api_spec_to_spec_builder( Ok(builder) } + +pub(crate) fn latest_api_spec_to_spec_builder( + value: latest::instance_spec::InstanceSpec, +) -> Result { + v6_to_spec_builder(value) +} diff --git a/bin/propolis-server/src/lib/spec/mod.rs b/bin/propolis-server/src/lib/spec/mod.rs index 661737cb1..d42e004fd 100644 --- a/bin/propolis-server/src/lib/spec/mod.rs +++ b/bin/propolis-server/src/lib/spec/mod.rs @@ -55,63 +55,17 @@ pub(crate) mod api_spec_v3; pub(crate) mod api_spec_v6; pub(crate) mod builder; -/* -/// TODO: it happens to be true that we can write this today. it is not true in general that `Spec` -/// must be convertible into `latest::instance_spec::InstanceSpec` (we can deprecate or remove an -/// item in a new InstanceSpec, and the conversion can become impossible. this just hasn't happened -/// yet.) -impl From for latest::instance_spec::InstanceSpec { - fn from(val: Spec) -> Self { - api_spec_v6:: - // TODO: - panic!("convert the spec into a latest::InstanceSpec or die trying"); - } -} -*/ - /// The code related to latest types does not go into a versioned module -/// -/// TODO: impls here can probably be copied wholesale when new versions are added -/// it may not be correct for a new `TryFrom for Spec` to be derived from this -/// function. or it may be reasonable. this depends exclusively on the changes in InstanceSpec. + +/// `propolis-server` relies on `TryInto` to convert the API-provided +/// `InstanceSpec` to an internal `Spec`. When adding a new API version to +/// `propolis-server` you will probably want to take this implementation and +/// copy it into the no-longer-latest `api_spec_v*` module. impl TryFrom for Spec { type Error = ApiSpecError; fn try_from(value: InstanceSpec) -> Result { - Ok(api_spec_v6::latest_api_spec_to_spec_builder(value)?.finish()) - /* - // Extract vsock before conversion since it's v3-only and will be - // filtered out during the v3→v2→v1 chain. - let mut vsock_entry = None; - for (id, component) in &value.components { - if let Component::VirtioSocket(v) = component { - vsock_entry = Some(VirtioSocket { id: id.clone(), spec: *v }); - break; - } - } - - let v3_spec: v3::instance_spec::InstanceSpec = value.into(); - let v2_spec: v2::instance_spec::InstanceSpec = v3_spec.into(); - let smbios = v2_spec.smbios.clone(); - let v1_spec: v1::instance_spec::InstanceSpec = v2_spec.into(); - - let mut builder = api_spec_v1::v1_to_spec_builder(v1_spec)?; - if let Some(vsock) = vsock_entry { - builder.add_vsock_device(vsock)?; - } - let mut spec = builder.finish(); - spec.smbios_type1_input = smbios; - Ok(spec) - */ - } -} - -// TODO: now.. what about the older versions of InstanceSpec! -impl TryFrom for Spec { - type Error = api_spec_v1::ApiSpecError; - - fn try_from(value: v1::instance_spec::InstanceSpec) -> Result { - Ok(api_spec_v1::v1_to_spec_builder(value)?.finish()) + Ok(api_spec_v6::v6_to_spec_builder(value)?.finish()) } } @@ -128,6 +82,14 @@ pub struct ComponentTypeMismatch; /// device paths, etc.). When constructing a new spec, use the /// [`builder::SpecBuilder`] struct to catch requests that violate these /// invariants. +/// +/// ### Relationship to migration +/// +/// As Propolis' internal representation of a VM, conversion to/from `Spec` is a +/// front-and-center concern for migrating from a current Propolis to some other +/// newer or older `propolis-server`. See the module comment in +/// [`lib/migrate/mod.rs`](crate::lib::migrate) for more about the relationships +/// between these types. #[derive(Clone, Debug, Default)] pub(crate) struct Spec { pub board: Board, diff --git a/crates/propolis-config-toml/src/spec.rs b/crates/propolis-config-toml/src/spec.rs index 6166de4ce..0109cb58c 100644 --- a/crates/propolis-config-toml/src/spec.rs +++ b/crates/propolis-config-toml/src/spec.rs @@ -369,7 +369,7 @@ fn parse_storage_device_from_config( // causes unnecessary guest work, but is not a correctness // issue. The converse can be. Default to reporting write caches // if we're not instructed otherwise. - let has_write_cache = write_cache_opt.unwrap_or(true); + let has_write_cache = write_cache_opt.unwrap_or(false); Component::NvmeDisk(NvmeDisk { backend_id, From d7d7f026ddc9a8d512e61265fd4adc8453aa2705 Mon Sep 17 00:00:00 2001 From: iximeow Date: Sat, 18 Jul 2026 00:30:54 +0000 Subject: [PATCH 08/24] rustfmt --- .../src/lib/migrate/preamble.rs | 7 +- bin/propolis-server/src/lib/migrate/source.rs | 6 +- bin/propolis-server/src/lib/migrate/types.rs | 77 ++++++++++++------- .../src/lib/spec/api_spec_v1.rs | 19 +++-- .../src/lib/spec/api_spec_v3.rs | 53 ++++++++++--- .../src/lib/spec/api_spec_v6.rs | 13 +++- bin/propolis-server/src/lib/spec/mod.rs | 22 ++---- bin/propolis-server/src/lib/vm/mod.rs | 2 +- .../src/add_vsock/instance_spec.rs | 22 ++---- phd-tests/framework/src/test_vm/config.rs | 34 ++++---- 10 files changed, 155 insertions(+), 100 deletions(-) diff --git a/bin/propolis-server/src/lib/migrate/preamble.rs b/bin/propolis-server/src/lib/migrate/preamble.rs index 90924b662..03b4ecde8 100644 --- a/bin/propolis-server/src/lib/migrate/preamble.rs +++ b/bin/propolis-server/src/lib/migrate/preamble.rs @@ -9,9 +9,7 @@ use propolis_api_types_versions::v1; use serde::{Deserialize, Serialize}; use crate::migrate; -use crate::spec::{ - Spec -}; +use crate::spec::Spec; use super::MigrateError; @@ -43,7 +41,8 @@ impl Preamble { ReplacementComponent, >, ) -> Result { - let amended_spec = self.instance_spec.into_amended_spec(replacements)?; + let amended_spec = + self.instance_spec.into_amended_spec(replacements)?; // TODO: Compare opaque blobs. diff --git a/bin/propolis-server/src/lib/migrate/source.rs b/bin/propolis-server/src/lib/migrate/source.rs index 0b163dd36..0d34ca192 100644 --- a/bin/propolis-server/src/lib/migrate/source.rs +++ b/bin/propolis-server/src/lib/migrate/source.rs @@ -24,12 +24,12 @@ use crate::migrate::memx; use crate::migrate::preamble::Preamble; use crate::migrate::probes; use crate::migrate::protocol::Protocol; +use crate::migrate::types::VersionedInstanceSpec; use crate::migrate::{codec, protocol}; use crate::migrate::{ Device, DevicePayload, MigrateError, MigratePhase, MigrateRole, MigrationState, PageIter, }; -use crate::migrate::types::VersionedInstanceSpec; use crate::vm::objects::VmObjects; use crate::vm::state_publisher::{ @@ -467,7 +467,9 @@ impl RonV0Runner<'_, T> { async fn sync(&mut self) -> Result<(), MigrateError> { self.update_state(MigrationState::Sync); - let versioned = VersionedInstanceSpec::from_spec(self.vm.lock_shared().await.instance_spec())?; + let versioned = VersionedInstanceSpec::from_spec( + self.vm.lock_shared().await.instance_spec(), + )?; let preamble = Preamble::new(versioned); let s = ron::ser::to_string(&preamble) .map_err(codec::ProtocolError::from)?; diff --git a/bin/propolis-server/src/lib/migrate/types.rs b/bin/propolis-server/src/lib/migrate/types.rs index cd98e92bf..3411a1dd2 100644 --- a/bin/propolis-server/src/lib/migrate/types.rs +++ b/bin/propolis-server/src/lib/migrate/types.rs @@ -88,8 +88,7 @@ use crate::migrate::MigrateError; use crate::spec::{ api_spec_v1::ApiSpecError as V1SpecError, api_spec_v3::ApiSpecError as V3SpecError, - api_spec_v6::ApiSpecError as V6SpecError, - Spec, + api_spec_v6::ApiSpecError as V6SpecError, Spec, }; /// A wrapper for one of any supported `InstanceSpec` that describe a @@ -112,7 +111,9 @@ pub(crate) enum VersionedInstanceSpec { } impl VersionedInstanceSpec { - pub(crate) fn from_spec(spec: &Spec) -> Result { + pub(crate) fn from_spec( + spec: &Spec, + ) -> Result { // Try conversions in oldest-to-newest order in support of // migration-to-older-version. As long as the VM doesn't use a new // feature or setting, we'll pick a version the older Propolis should @@ -124,20 +125,28 @@ impl VersionedInstanceSpec { // features have been added. The new latest version, hopefully, will // have an `Into`. Those two versions should be the only ones that // need attention. - let versioned = if let Ok(v1_spec) = TryInto::::try_into(spec.clone()) { + let versioned = if let Ok(v1_spec) = + TryInto::::try_into(spec.clone()) + { VersionedInstanceSpec::V1(v1_spec) - } else if let Ok(v3_spec) = TryInto::::try_into(spec.clone()) { + } else if let Ok(v3_spec) = + TryInto::::try_into(spec.clone()) + { VersionedInstanceSpec::V3(v3_spec) } else { - VersionedInstanceSpec::V6(Into::::into(spec.clone())) + VersionedInstanceSpec::V6( + Into::::into(spec.clone()), + ) }; Ok(versioned) } - pub(crate) fn into_amended_spec(self, replacements: &BTreeMap< - v1::instance_spec::SpecKey, - ReplacementComponent, - > + pub(crate) fn into_amended_spec( + self, + replacements: &BTreeMap< + v1::instance_spec::SpecKey, + ReplacementComponent, + >, ) -> Result { fn wrong_type_error( id: &v1::instance_spec::SpecKey, @@ -151,10 +160,13 @@ impl VersionedInstanceSpec { let amended_spec = match self { VersionedInstanceSpec::V1(mut source_spec) => { for (id, comp) in replacements { - let Some(to_amend) = source_spec.components.get_mut(id) else { - return Err(MigrateError::InstanceSpecsIncompatible(format!( - "replacement component {id} not in source spec", - ))); + let Some(to_amend) = source_spec.components.get_mut(id) + else { + return Err(MigrateError::InstanceSpecsIncompatible( + format!( + "replacement component {id} not in source spec", + ), + )); }; match comp { @@ -169,7 +181,9 @@ impl VersionedInstanceSpec { } #[cfg(feature = "failure-injection")] - ReplacementComponent::MigrationFailureInjector(comp) => { + ReplacementComponent::MigrationFailureInjector( + comp, + ) => { let v1::instance_spec::Component::MigrationFailureInjector( src, ) = to_amend @@ -216,10 +230,13 @@ impl VersionedInstanceSpec { } VersionedInstanceSpec::V3(mut source_spec) => { for (id, comp) in replacements { - let Some(to_amend) = source_spec.components.get_mut(id) else { - return Err(MigrateError::InstanceSpecsIncompatible(format!( - "replacement component {id} not in source spec", - ))); + let Some(to_amend) = source_spec.components.get_mut(id) + else { + return Err(MigrateError::InstanceSpecsIncompatible( + format!( + "replacement component {id} not in source spec", + ), + )); }; match comp { @@ -234,7 +251,9 @@ impl VersionedInstanceSpec { } #[cfg(feature = "failure-injection")] - ReplacementComponent::MigrationFailureInjector(comp) => { + ReplacementComponent::MigrationFailureInjector( + comp, + ) => { let v3::instance_spec::Component::MigrationFailureInjector( src, ) = to_amend @@ -269,7 +288,8 @@ impl VersionedInstanceSpec { } } - let v6_spec: v6::instance_spec::InstanceSpec = source_spec.into(); + let v6_spec: v6::instance_spec::InstanceSpec = + source_spec.into(); let amended_spec: Spec = v6_spec.try_into().map_err(|e: V6SpecError| { let v3_error: V3SpecError = e.into(); @@ -280,10 +300,13 @@ impl VersionedInstanceSpec { } VersionedInstanceSpec::V6(mut source_spec) => { for (id, comp) in replacements { - let Some(to_amend) = source_spec.components.get_mut(id) else { - return Err(MigrateError::InstanceSpecsIncompatible(format!( - "replacement component {id} not in source spec", - ))); + let Some(to_amend) = source_spec.components.get_mut(id) + else { + return Err(MigrateError::InstanceSpecsIncompatible( + format!( + "replacement component {id} not in source spec", + ), + )); }; match comp { @@ -298,7 +321,9 @@ impl VersionedInstanceSpec { } #[cfg(feature = "failure-injection")] - ReplacementComponent::MigrationFailureInjector(comp) => { + ReplacementComponent::MigrationFailureInjector( + comp, + ) => { let v6::instance_spec::Component::MigrationFailureInjector( src, ) = to_amend diff --git a/bin/propolis-server/src/lib/spec/api_spec_v1.rs b/bin/propolis-server/src/lib/spec/api_spec_v1.rs index da070e0c0..2c5844fc5 100644 --- a/bin/propolis-server/src/lib/spec/api_spec_v1.rs +++ b/bin/propolis-server/src/lib/spec/api_spec_v1.rs @@ -95,11 +95,16 @@ impl TryFrom for v1::instance_spec::InstanceSpec { // * V1 specs are from before live migration was done outside // ad-hoc/CI environments - such an old Propolis will never exist // as a migration target in the field. - return Err(ApiSpecError::IncompatibleComponent("cannot express explicit SMBIOS tables in v1 instance spec".to_string())); + return Err(ApiSpecError::IncompatibleComponent( + "cannot express explicit SMBIOS tables in v1 instance spec" + .to_string(), + )); } if vsock.is_some() { - return Err(ApiSpecError::IncompatibleComponent("cannot convert virtio-socket to v1 instance spec".to_string())); + return Err(ApiSpecError::IncompatibleComponent( + "cannot convert virtio-socket to v1 instance spec".to_string(), + )); } // Inserts a component entry into the supplied map, asserting first that @@ -138,7 +143,8 @@ impl TryFrom for v1::instance_spec::InstanceSpec { let backend_id = disk.device_spec.backend_id().to_owned(); let device_component: v1::instance_spec::Component = disk.device_spec.try_into() .map_err(|e: propolis_api_types_versions::v6::instance_spec::InvalidV3Component| ApiSpecError::IncompatibleComponent(e.to_string()))?; - let backend_component: v1::instance_spec::Component = disk.backend_spec.into(); + let backend_component: v1::instance_spec::Component = + disk.backend_spec.into(); insert_component(&mut spec, disk_id, device_component); insert_component(&mut spec, backend_id, backend_component); } @@ -267,7 +273,9 @@ impl TryFrom for v1::instance_spec::InstanceSpec { impl TryFrom for Spec { type Error = ApiSpecError; - fn try_from(value: v1::instance_spec::InstanceSpec) -> Result { + fn try_from( + value: v1::instance_spec::InstanceSpec, + ) -> Result { Ok(v1_to_spec_builder(value)?.finish()) } } @@ -281,6 +289,5 @@ pub(crate) fn v1_to_spec_builder( let v2_spec: v2::instance_spec::InstanceSpec = value.into(); let v3_spec: v3::instance_spec::InstanceSpec = v2_spec.into(); - crate::spec::api_spec_v3::v3_to_spec_builder(v3_spec) - .map_err(|e| e.into()) + crate::spec::api_spec_v3::v3_to_spec_builder(v3_spec).map_err(|e| e.into()) } diff --git a/bin/propolis-server/src/lib/spec/api_spec_v3.rs b/bin/propolis-server/src/lib/spec/api_spec_v3.rs index d4620d758..10e3f20ba 100644 --- a/bin/propolis-server/src/lib/spec/api_spec_v3.rs +++ b/bin/propolis-server/src/lib/spec/api_spec_v3.rs @@ -12,7 +12,7 @@ use propolis_api_types::instance_spec::{ }, SpecKey, }; -use propolis_api_types_versions::{v3, latest}; +use propolis_api_types_versions::{latest, v3}; use thiserror::Error; #[cfg(feature = "falcon")] @@ -56,11 +56,30 @@ impl From for api_spec_v1::ApiSpecError { fn from(value: ApiSpecError) -> Self { match value { ApiSpecError::Builder(b) => api_spec_v1::ApiSpecError::Builder(b), - ApiSpecError::StorageBackendNotFound { backend, device } => api_spec_v1::ApiSpecError::StorageBackendNotFound { backend, device }, - ApiSpecError::NetworkBackendNotFound { backend, device } => api_spec_v1::ApiSpecError::NetworkBackendNotFound { backend, device }, - ApiSpecError::FeatureCompiledOut { component, feature } => api_spec_v1::ApiSpecError::FeatureCompiledOut { component, feature }, - ApiSpecError::BackendNotUsed(key) => api_spec_v1::ApiSpecError::BackendNotUsed(key), - ApiSpecError::IncompatibleComponent(key) => api_spec_v1::ApiSpecError::IncompatibleComponent(key), + ApiSpecError::StorageBackendNotFound { backend, device } => { + api_spec_v1::ApiSpecError::StorageBackendNotFound { + backend, + device, + } + } + ApiSpecError::NetworkBackendNotFound { backend, device } => { + api_spec_v1::ApiSpecError::NetworkBackendNotFound { + backend, + device, + } + } + ApiSpecError::FeatureCompiledOut { component, feature } => { + api_spec_v1::ApiSpecError::FeatureCompiledOut { + component, + feature, + } + } + ApiSpecError::BackendNotUsed(key) => { + api_spec_v1::ApiSpecError::BackendNotUsed(key) + } + ApiSpecError::IncompatibleComponent(key) => { + api_spec_v1::ApiSpecError::IncompatibleComponent(key) + } } } } @@ -72,10 +91,21 @@ impl From for ApiSpecError { fn from(value: api_spec_v6::ApiSpecError) -> Self { match value { api_spec_v6::ApiSpecError::Builder(b) => ApiSpecError::Builder(b), - api_spec_v6::ApiSpecError::StorageBackendNotFound { backend, device } => ApiSpecError::StorageBackendNotFound { backend, device }, - api_spec_v6::ApiSpecError::NetworkBackendNotFound { backend, device } => ApiSpecError::NetworkBackendNotFound { backend, device }, - api_spec_v6::ApiSpecError::FeatureCompiledOut { component, feature } => ApiSpecError::FeatureCompiledOut { component, feature }, - api_spec_v6::ApiSpecError::BackendNotUsed(key) => ApiSpecError::BackendNotUsed(key), + api_spec_v6::ApiSpecError::StorageBackendNotFound { + backend, + device, + } => ApiSpecError::StorageBackendNotFound { backend, device }, + api_spec_v6::ApiSpecError::NetworkBackendNotFound { + backend, + device, + } => ApiSpecError::NetworkBackendNotFound { backend, device }, + api_spec_v6::ApiSpecError::FeatureCompiledOut { + component, + feature, + } => ApiSpecError::FeatureCompiledOut { component, feature }, + api_spec_v6::ApiSpecError::BackendNotUsed(key) => { + ApiSpecError::BackendNotUsed(key) + } } } } @@ -140,7 +170,8 @@ impl TryFrom for v3::instance_spec::InstanceSpec { let backend_id = disk.device_spec.backend_id().to_owned(); let device_component: v3::instance_spec::Component = disk.device_spec.try_into() .map_err(|e: propolis_api_types_versions::v6::instance_spec::InvalidV3Component| ApiSpecError::IncompatibleComponent(e.to_string()))?; - let backend_component: v3::instance_spec::Component = disk.backend_spec.into(); + let backend_component: v3::instance_spec::Component = + disk.backend_spec.into(); insert_component(&mut spec, disk_id, device_component); insert_component(&mut spec, backend_id, backend_component); } 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 52ce2f9cf..7f73d320b 100644 --- a/bin/propolis-server/src/lib/spec/api_spec_v6.rs +++ b/bin/propolis-server/src/lib/spec/api_spec_v6.rs @@ -15,7 +15,7 @@ use propolis_api_types::instance_spec::{ }, SpecKey, }; -use propolis_api_types_versions::{v6, latest}; +use propolis_api_types_versions::{latest, v6}; use thiserror::Error; #[cfg(feature = "falcon")] @@ -108,8 +108,10 @@ impl From for v6::instance_spec::InstanceSpec { for (disk_id, disk) in disks { let backend_id = disk.device_spec.backend_id().to_owned(); - let device_component: v6::instance_spec::Component = disk.device_spec.into(); - let backend_component: v6::instance_spec::Component = disk.backend_spec.into(); + let device_component: v6::instance_spec::Component = + disk.device_spec.into(); + let backend_component: v6::instance_spec::Component = + disk.backend_spec.into(); insert_component(&mut spec, disk_id, device_component); insert_component(&mut spec, backend_id, backend_component); } @@ -348,7 +350,10 @@ pub(crate) fn v6_to_spec_builder( boot_settings = Some((device_id, settings)); } v6::instance_spec::Component::VirtioSocket(vsock) => { - let vsock_device = crate::spec::VirtioSocket { id: device_id.clone(), spec: vsock }; + let vsock_device = crate::spec::VirtioSocket { + id: device_id.clone(), + spec: vsock, + }; builder.add_vsock_device(vsock_device)?; } #[cfg(not(feature = "failure-injection"))] diff --git a/bin/propolis-server/src/lib/spec/mod.rs b/bin/propolis-server/src/lib/spec/mod.rs index d42e004fd..57cfc98e6 100644 --- a/bin/propolis-server/src/lib/spec/mod.rs +++ b/bin/propolis-server/src/lib/spec/mod.rs @@ -36,7 +36,7 @@ use propolis_api_types::instance_spec::{ use propolis_api_types::instance_spec::{ Component, InstanceSpec, SmbiosType1Input, }; -use propolis_api_types_versions::{v1, v3, v6, latest}; +use propolis_api_types_versions::{latest, v1, v3, v6}; use thiserror::Error; #[cfg(feature = "failure-injection")] @@ -250,9 +250,7 @@ impl TryFrom for v1::instance_spec::Component { impl TryFrom for StorageDevice { type Error = ComponentTypeMismatch; - fn try_from( - value: Component, - ) -> Result { + fn try_from(value: Component) -> Result { match value { Component::VirtioDisk(d) => Ok(Self::Virtio(d)), Component::NvmeDisk(d) => Ok(Self::Nvme(d)), @@ -320,19 +318,11 @@ impl From for v1::instance_spec::Component { impl TryFrom for StorageBackend { type Error = ComponentTypeMismatch; - fn try_from( - value: Component, - ) -> Result { + fn try_from(value: Component) -> Result { match value { - Component::CrucibleStorageBackend(be) => { - Ok(Self::Crucible(be)) - } - Component::FileStorageBackend(be) => { - Ok(Self::File(be)) - } - Component::BlobStorageBackend(be) => { - Ok(Self::Blob(be)) - } + Component::CrucibleStorageBackend(be) => Ok(Self::Crucible(be)), + Component::FileStorageBackend(be) => Ok(Self::File(be)), + Component::BlobStorageBackend(be) => Ok(Self::Blob(be)), _ => Err(ComponentTypeMismatch), } } diff --git a/bin/propolis-server/src/lib/vm/mod.rs b/bin/propolis-server/src/lib/vm/mod.rs index fde568e54..fee18e5cb 100644 --- a/bin/propolis-server/src/lib/vm/mod.rs +++ b/bin/propolis-server/src/lib/vm/mod.rs @@ -377,7 +377,7 @@ impl Vm { state: vm.external_state_rx.borrow().state, spec: InstanceSpecStatus::Present(external_spec), }) - }, + } } } diff --git a/crates/propolis-api-types-versions/src/add_vsock/instance_spec.rs b/crates/propolis-api-types-versions/src/add_vsock/instance_spec.rs index 750b52017..02c6d10f3 100644 --- a/crates/propolis-api-types-versions/src/add_vsock/instance_spec.rs +++ b/crates/propolis-api-types-versions/src/add_vsock/instance_spec.rs @@ -117,19 +117,13 @@ impl TryFrom for v2::instance_spec::InstanceSpec { fn try_from(new: InstanceSpec) -> Result { let components: Result, _> = new - .components - .into_iter() - .map(|(k, v)| { - V1Component::try_from(v).map(|c| (k, c)) - }) - .collect::, _>>(); + .components + .into_iter() + .map(|(k, v)| V1Component::try_from(v).map(|c| (k, c))) + .collect::, _>>(); let components = components.expect("TODO: hueagghggh"); - Ok(Self { - board: new.board, - components, - smbios: new.smbios, - }) + Ok(Self { board: new.board, components, smbios: new.smbios }) } } @@ -176,10 +170,10 @@ impl From for v2::instance_spec::InstanceSpecStatus { Self::WaitingForMigrationSource } InstanceSpecStatus::Present(spec) => { - let v2_spec: v2::instance_spec::InstanceSpec = spec.try_into() - .expect("TODO: v3 instance spec into v2"); + let v2_spec: v2::instance_spec::InstanceSpec = + spec.try_into().expect("TODO: v3 instance spec into v2"); Self::Present(v2_spec) - }, + } } } } diff --git a/phd-tests/framework/src/test_vm/config.rs b/phd-tests/framework/src/test_vm/config.rs index 7e0cb47d3..887f17244 100644 --- a/phd-tests/framework/src/test_vm/config.rs +++ b/phd-tests/framework/src/test_vm/config.rs @@ -344,22 +344,24 @@ impl<'dr> VmConfig<'dr> { ), pci_path, }), - DiskInterface::Nvme { has_write_cache } => Component::NvmeDisk(NvmeDisk { - backend_id: SpecKey::Name( - backend_name.clone().into_string(), - ), - pci_path, - serial_number: nvme_serial_from_str( - device_name.as_str(), - // Omicron supplies (or will supply, as of this writing) - // 0 as the padding byte to maintain compatibility for - // existing disks. Match that behavior here so that PHD - // and Omicron VM configurations are as similar as - // possible. - 0, - ), - has_write_cache, - }), + DiskInterface::Nvme { has_write_cache } => { + Component::NvmeDisk(NvmeDisk { + backend_id: SpecKey::Name( + backend_name.clone().into_string(), + ), + pci_path, + serial_number: nvme_serial_from_str( + device_name.as_str(), + // Omicron supplies (or will supply, as of this writing) + // 0 as the padding byte to maintain compatibility for + // existing disks. Match that behavior here so that PHD + // and Omicron VM configurations are as similar as + // possible. + 0, + ), + has_write_cache, + }) + } }; let _old = spec From 439bdb2e31b5a426b968f30390005efe634caf9b Mon Sep 17 00:00:00 2001 From: iximeow Date: Sat, 18 Jul 2026 00:58:58 +0000 Subject: [PATCH 09/24] self-review --- .../src/lib/spec/api_spec_v1.rs | 35 +++++++- .../src/lib/spec/api_spec_v3.rs | 81 +++++++++++-------- .../src/lib/spec/api_spec_v6.rs | 8 +- bin/propolis-server/src/lib/spec/builder.rs | 19 +++-- bin/propolis-server/src/lib/spec/mod.rs | 68 +++------------- 5 files changed, 105 insertions(+), 106 deletions(-) diff --git a/bin/propolis-server/src/lib/spec/api_spec_v1.rs b/bin/propolis-server/src/lib/spec/api_spec_v1.rs index 2c5844fc5..1fdd01fb3 100644 --- a/bin/propolis-server/src/lib/spec/api_spec_v1.rs +++ b/bin/propolis-server/src/lib/spec/api_spec_v1.rs @@ -13,7 +13,7 @@ use propolis_api_types::instance_spec::{ }, SpecKey, }; -use propolis_api_types_versions::{v1, v2, v3}; +use propolis_api_types_versions::{v1, v2, v3, v6}; use thiserror::Error; #[cfg(feature = "falcon")] @@ -21,7 +21,7 @@ use propolis_api_types::instance_spec::components::devices::SoftNpuPort as SoftN use super::{ builder::{SpecBuilder, SpecBuilderError}, - SerialPortDevice, Spec, + SerialPortDevice, Spec, StorageBackend, StorageDevice, }; #[cfg(feature = "failure-injection")] @@ -52,6 +52,37 @@ pub(crate) enum ApiSpecError { IncompatibleComponent(String), } +// Woah! It's strange to have a conversion to a *v1* type which has an error +// from *v6* about *v3*. Not as bad as it seems though: v6 is when this +// component changed, and v3 is next-most-recent version of +// `instance_spec::Component`. So in v6, the error is "I can't convert this to +// v3::instance_spec::Component". +// +// We're converting to v1, though, which has a further-different `Component` +// type. If we had a fallible operation converting a storage device all the way +// down, that might produce an error about failing to convert a V3 type to V1 - +// it turns out that's infallible here. +impl TryFrom for v1::instance_spec::Component { + type Error = v6::instance_spec::InvalidV3Component; + + fn try_from(value: StorageDevice) -> Result { + match value { + StorageDevice::Virtio(d) => Ok(Self::VirtioDisk(d)), + StorageDevice::Nvme(d) => Ok(Self::NvmeDisk(d.try_into()?)), + } + } +} + +impl From for v1::instance_spec::Component { + fn from(value: StorageBackend) -> Self { + match value { + StorageBackend::Crucible(be) => Self::CrucibleStorageBackend(be), + StorageBackend::File(be) => Self::FileStorageBackend(be), + StorageBackend::Blob(be) => Self::BlobStorageBackend(be), + } + } +} + impl TryFrom for v1::instance_spec::InstanceSpec { type Error = ApiSpecError; diff --git a/bin/propolis-server/src/lib/spec/api_spec_v3.rs b/bin/propolis-server/src/lib/spec/api_spec_v3.rs index 10e3f20ba..7f7904590 100644 --- a/bin/propolis-server/src/lib/spec/api_spec_v3.rs +++ b/bin/propolis-server/src/lib/spec/api_spec_v3.rs @@ -12,15 +12,16 @@ use propolis_api_types::instance_spec::{ }, SpecKey, }; -use propolis_api_types_versions::{latest, v3}; +use propolis_api_types_versions::{latest, v3, v6}; use thiserror::Error; #[cfg(feature = "falcon")] use propolis_api_types::instance_spec::components::devices::SoftNpuPort as SoftNpuPortSpec; use super::{ + api_spec_v6, builder::{SpecBuilder, SpecBuilderError}, - SerialPortDevice, Spec, + SerialPortDevice, Spec, StorageBackend, StorageDevice, }; #[cfg(feature = "failure-injection")] @@ -84,28 +85,23 @@ impl From for api_spec_v1::ApiSpecError { } } -// TODO: docs. conversion back down from v6 to v3 because we defer InstanceSpec->Spec to -// `v6_to_spec_builder()`. -use crate::spec::api_spec_v6; -impl From for ApiSpecError { - fn from(value: api_spec_v6::ApiSpecError) -> Self { +impl TryFrom for v3::instance_spec::Component { + type Error = v6::instance_spec::InvalidV3Component; + + fn try_from(value: StorageDevice) -> Result { match value { - api_spec_v6::ApiSpecError::Builder(b) => ApiSpecError::Builder(b), - api_spec_v6::ApiSpecError::StorageBackendNotFound { - backend, - device, - } => ApiSpecError::StorageBackendNotFound { backend, device }, - api_spec_v6::ApiSpecError::NetworkBackendNotFound { - backend, - device, - } => ApiSpecError::NetworkBackendNotFound { backend, device }, - api_spec_v6::ApiSpecError::FeatureCompiledOut { - component, - feature, - } => ApiSpecError::FeatureCompiledOut { component, feature }, - api_spec_v6::ApiSpecError::BackendNotUsed(key) => { - ApiSpecError::BackendNotUsed(key) - } + StorageDevice::Virtio(d) => Ok(Self::VirtioDisk(d)), + StorageDevice::Nvme(d) => Ok(Self::NvmeDisk(d.try_into()?)), + } + } +} + +impl From for v3::instance_spec::Component { + fn from(value: StorageBackend) -> Self { + match value { + StorageBackend::Crucible(be) => Self::CrucibleStorageBackend(be), + StorageBackend::File(be) => Self::FileStorageBackend(be), + StorageBackend::Blob(be) => Self::BlobStorageBackend(be), } } } @@ -305,17 +301,32 @@ impl TryFrom for v3::instance_spec::InstanceSpec { } } -/* -impl TryFrom for Spec { - type Error = ApiSpecError; - - fn try_from( - value: v3::instance_spec::InstanceSpec, - ) -> Result { - Ok(v3_to_spec_builder(value)?.finish()) +// Converting the API error back down is lossless, so define that here too. +// +// This notionally should be scoped to `v3_to_spec_builder`; there's not much +// reason to do this conversion anywhere else.. +impl From for ApiSpecError { + fn from(value: api_spec_v6::ApiSpecError) -> Self { + match value { + api_spec_v6::ApiSpecError::Builder(b) => ApiSpecError::Builder(b), + api_spec_v6::ApiSpecError::StorageBackendNotFound { + backend, + device, + } => ApiSpecError::StorageBackendNotFound { backend, device }, + api_spec_v6::ApiSpecError::NetworkBackendNotFound { + backend, + device, + } => ApiSpecError::NetworkBackendNotFound { backend, device }, + api_spec_v6::ApiSpecError::FeatureCompiledOut { + component, + feature, + } => ApiSpecError::FeatureCompiledOut { component, feature }, + api_spec_v6::ApiSpecError::BackendNotUsed(key) => { + ApiSpecError::BackendNotUsed(key) + } + } } } -*/ /// Parses a v3 instance spec into a [`SpecBuilder`], validating component /// names, PCI paths, and backend references along the way. Callers can add @@ -323,9 +334,9 @@ impl TryFrom for Spec { pub(crate) fn v3_to_spec_builder( value: v3::instance_spec::InstanceSpec, ) -> Result { + // Converting v3 to v6 is lossless so just do that and piggyback on the + // latest `InstanceSpec->SpecBuilder`. let latest_spec: latest::instance_spec::InstanceSpec = value.into(); - // TODO: talk about this more - crate::spec::api_spec_v6::latest_api_spec_to_spec_builder(latest_spec) - .map_err(|e| e.into()) + api_spec_v6::v6_to_spec_builder(latest_spec).map_err(|e| e.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 7f73d320b..3715060e5 100644 --- a/bin/propolis-server/src/lib/spec/api_spec_v6.rs +++ b/bin/propolis-server/src/lib/spec/api_spec_v6.rs @@ -15,7 +15,7 @@ use propolis_api_types::instance_spec::{ }, SpecKey, }; -use propolis_api_types_versions::{latest, v6}; +use propolis_api_types_versions::v6; use thiserror::Error; #[cfg(feature = "falcon")] @@ -442,9 +442,3 @@ pub(crate) fn v6_to_spec_builder( Ok(builder) } - -pub(crate) fn latest_api_spec_to_spec_builder( - value: latest::instance_spec::InstanceSpec, -) -> Result { - v6_to_spec_builder(value) -} diff --git a/bin/propolis-server/src/lib/spec/builder.rs b/bin/propolis-server/src/lib/spec/builder.rs index 6c1ad42ef..98ca40761 100644 --- a/bin/propolis-server/src/lib/spec/builder.rs +++ b/bin/propolis-server/src/lib/spec/builder.rs @@ -71,6 +71,18 @@ pub(crate) enum SpecBuilderError { DefaultCpuidReadFailed(#[from] cpuid_utils::host::GetHostCpuidError), } +/// A builder onto which devices and other components are added as an +/// `InstanceSpec` is interpreted. Among other things, this services as a +/// forcing function to canonicalize VM descriptions, where we can enforce +/// invariants about components (such as "disk devices must reference backends +/// that exist"). +/// +/// Note that the API type `Component` itself does not appear here: the +/// expectation is that individual components' definitions change relatively +/// rarely, so callers do the work of mapping components to the +/// closer-to-internal definitions that `SpecBuilder` accepts. In theory, +/// hopefully, this means `SpecBuilder` itself changes rarely and can be more +/// reasily audited for semantic drift. #[derive(Debug, Default)] pub(crate) struct SpecBuilder { spec: super::Spec, @@ -79,13 +91,6 @@ pub(crate) struct SpecBuilder { component_names: BTreeSet, } -/// hokay. SpecBuilder is where we're stuffing the large ball of glue and twine that connects -/// between versioned HTTP API types like InstanceSpec and all its descendants, and the -/// non-versioned "This Version Of Propolis" types like Spec and specific device configuration -/// structs. -/// -/// this means that we have conversions from all external API InstanceSpec to `Spec` (via -/// SpecBuilder) here. elsewhere, conversions from Spec to all InstanceSpec. impl SpecBuilder { pub(super) fn with_instance_spec_board( board: InstanceSpecBoard, diff --git a/bin/propolis-server/src/lib/spec/mod.rs b/bin/propolis-server/src/lib/spec/mod.rs index 57cfc98e6..6c049a7d6 100644 --- a/bin/propolis-server/src/lib/spec/mod.rs +++ b/bin/propolis-server/src/lib/spec/mod.rs @@ -36,7 +36,7 @@ use propolis_api_types::instance_spec::{ use propolis_api_types::instance_spec::{ Component, InstanceSpec, SmbiosType1Input, }; -use propolis_api_types_versions::{latest, v1, v3, v6}; +use propolis_api_types_versions::latest; use thiserror::Error; #[cfg(feature = "failure-injection")] @@ -49,7 +49,6 @@ use propolis_api_types::instance_spec::components::{ devices::{P9fs, SoftNpuP9, SoftNpuPciPort}, }; -// mod api_request; pub(crate) mod api_spec_v1; pub(crate) mod api_spec_v3; pub(crate) mod api_spec_v6; @@ -111,16 +110,19 @@ pub(crate) struct Spec { #[cfg(feature = "falcon")] pub softnpu: SoftNpu, - // TODO: This is an option because there is no good way to generate a - // default implementation of `SmbiosType1Input`. The default `serial_number` - // field of `SmbiosType1Input` should be equivalent to the VM UUID for - // backwards compatibility, but that isn't currently possible. + // This is an option because in v1::instance_spec::InstanceSpec the defaults + // `None` would imply came from the data outside the `InstanceSpec` itself; + // instance properties, for the instance's UUID, in particular. Two + // options here are to have `Builder` take the instance's UUID at all times + // and only sometimes synthesize `SmbiosType1Input` if nothing else is + // provided, or allow this to be `None` and interpret that in "the old way" + // when instantiating the SMBIOS tables. We've gone the latter. + // Alternatively, we could scratch `Builder` entirely and have open-coded + // functions to translate `InstanceSpec` to a `Spec`, and have v1 of *those* + // take the requisite ancillary data. // - // One way to fix this would be to remove the `Builder` and directly - // construct `Spec` from a function that takes an `v1::instance_spec::InstanceSpec` and the - // VM UUID. This would replace `impl TryFrom for Spec`, and - // would allow removing the `Default` derive on `Spec`, and the `Option` - // from the `smbios_type1_input` field. + // If (when!) we remove `v1` types - they are wholly from before any kind of + // live migration was supported - this can be de-Option'd. pub smbios_type1_input: Option, } @@ -223,30 +225,6 @@ impl From for latest::instance_spec::Component { } } -// TODO: this needs to move or die -impl TryFrom for v3::instance_spec::Component { - type Error = v6::instance_spec::InvalidV3Component; - - fn try_from(value: StorageDevice) -> Result { - match value { - StorageDevice::Virtio(d) => Ok(Self::VirtioDisk(d)), - StorageDevice::Nvme(d) => Ok(Self::NvmeDisk(d.try_into()?)), - } - } -} - -// TODO: this needs to move or die -impl TryFrom for v1::instance_spec::Component { - type Error = v6::instance_spec::InvalidV3Component; - - fn try_from(value: StorageDevice) -> Result { - match value { - StorageDevice::Virtio(d) => Ok(Self::VirtioDisk(d)), - StorageDevice::Nvme(d) => Ok(Self::NvmeDisk(d.try_into()?)), - } - } -} - impl TryFrom for StorageDevice { type Error = ComponentTypeMismatch; @@ -295,26 +273,6 @@ impl From for Component { } } -impl From for v3::instance_spec::Component { - fn from(value: StorageBackend) -> Self { - match value { - StorageBackend::Crucible(be) => Self::CrucibleStorageBackend(be), - StorageBackend::File(be) => Self::FileStorageBackend(be), - StorageBackend::Blob(be) => Self::BlobStorageBackend(be), - } - } -} - -impl From for v1::instance_spec::Component { - fn from(value: StorageBackend) -> Self { - match value { - StorageBackend::Crucible(be) => Self::CrucibleStorageBackend(be), - StorageBackend::File(be) => Self::FileStorageBackend(be), - StorageBackend::Blob(be) => Self::BlobStorageBackend(be), - } - } -} - impl TryFrom for StorageBackend { type Error = ComponentTypeMismatch; From 7241116da9e379ba7edb58d4cb9b3787559216ee Mon Sep 17 00:00:00 2001 From: iximeow Date: Sat, 18 Jul 2026 01:18:06 +0000 Subject: [PATCH 10/24] agh --- .../src/nvme_write_cache/components/devices.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/propolis-api-types-versions/src/nvme_write_cache/components/devices.rs b/crates/propolis-api-types-versions/src/nvme_write_cache/components/devices.rs index 766cf6727..cfe04ed2a 100644 --- a/crates/propolis-api-types-versions/src/nvme_write_cache/components/devices.rs +++ b/crates/propolis-api-types-versions/src/nvme_write_cache/components/devices.rs @@ -1,3 +1,7 @@ +// 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/. + use super::super::instance_spec; use crate::v1::components::devices::NvmeDisk as V1NvmeDisk; use crate::v1::instance_spec::{PciPath, SpecKey}; From 051a5b173530d24f736fe15d8187a8a1b9f3e631 Mon Sep 17 00:00:00 2001 From: iximeow Date: Mon, 20 Jul 2026 21:51:11 +0000 Subject: [PATCH 11/24] plumb up has_write_cache --- bin/propolis-cli/src/main.rs | 6 +++++- bin/propolis-server/src/lib/initializer.rs | 2 +- bin/propolis-standalone/src/main.rs | 8 +++++++- crates/propolis-config-toml/src/lib.rs | 6 +++++- crates/propolis-config-toml/src/spec.rs | 18 ++++-------------- 5 files changed, 22 insertions(+), 18 deletions(-) diff --git a/bin/propolis-cli/src/main.rs b/bin/propolis-cli/src/main.rs index 7c1cab3ea..10a649986 100644 --- a/bin/propolis-cli/src/main.rs +++ b/bin/propolis-cli/src/main.rs @@ -263,7 +263,11 @@ impl DiskRequest { backend_id: backend_id.clone(), pci_path, serial_number: nvme_serial_from_str(&self.name, b' '), - has_write_cache: false, + // TODO: `DiskRequest` implies this is the disk-side interface + // of a Crucible storage backend, so we report a write cache. + // but this probably should be configurable more directly in the + // limit + has_write_cache: true, }), _ => anyhow::bail!( "invalid device type in disk request: {:?}", diff --git a/bin/propolis-server/src/lib/initializer.rs b/bin/propolis-server/src/lib/initializer.rs index 97274f629..0da580375 100644 --- a/bin/propolis-server/src/lib/initializer.rs +++ b/bin/propolis-server/src/lib/initializer.rs @@ -909,7 +909,7 @@ impl MachineInitializer<'_> { let nvme = nvme::PciNvme::create( &nvme_spec.serial_number, mdts, - false, + nvme_spec.has_write_cache, self.log.new(slog::o!("component" => component)), ); self.devices.insert(device_id.clone(), nvme.clone()); diff --git a/bin/propolis-standalone/src/main.rs b/bin/propolis-standalone/src/main.rs index 5bffc7f21..a9c0c1f04 100644 --- a/bin/propolis-standalone/src/main.rs +++ b/bin/propolis-standalone/src/main.rs @@ -1355,6 +1355,12 @@ fn setup_instance( .to_string(); let log = log.new(slog::o!("dev" => format!("nvme-{}", name))); + let has_write_cache = dev + .options + .get("has_write_cache") + .unwrap() + .as_bool() + .unwrap(); // Limit data transfers to 1MiB (2^8 * 4k) in size let mdts = Some(8); @@ -1366,7 +1372,7 @@ fn setup_instance( let nvme = hw::nvme::PciNvme::create( &serial_number, mdts, - true, + has_write_cache, log, ); diff --git a/crates/propolis-config-toml/src/lib.rs b/crates/propolis-config-toml/src/lib.rs index 583fb6591..c2c1774e6 100644 --- a/crates/propolis-config-toml/src/lib.rs +++ b/crates/propolis-config-toml/src/lib.rs @@ -93,8 +93,12 @@ pub struct Device { } impl Device { + pub fn get_toml_value>(&self, key: S) -> Option<&toml::Value> { + self.options.get(key.as_ref()) + } + pub fn get_string>(&self, key: S) -> Option<&str> { - self.options.get(key.as_ref())?.as_str() + self.get_toml_value(key)?.as_str() } pub fn get>(&self, key: S) -> Option { diff --git a/crates/propolis-config-toml/src/spec.rs b/crates/propolis-config-toml/src/spec.rs index 0109cb58c..96b98b490 100644 --- a/crates/propolis-config-toml/src/spec.rs +++ b/crates/propolis-config-toml/src/spec.rs @@ -340,9 +340,9 @@ fn parse_storage_device_from_config( } Interface::Nvme => { let write_cache_opt = device - .get("has_write_cache") - .map(|v: toml::Value| { - let s = v.as_str().ok_or_else(|| { + .get_toml_value("has_write_cache") + .map(|v: &toml::Value| { + v.as_bool().ok_or_else(|| { TomlToSpecError::FieldParseError { field: "has_write_cache", name: name.to_owned(), @@ -351,16 +351,6 @@ fn parse_storage_device_from_config( v ), } - }); - - s.and_then(|s| { - s.parse::().map_err(|e| { - TomlToSpecError::FieldParseError { - field: "has_write_cache", - name: name.to_owned(), - error: e.to_string(), - } - }) }) }) .transpose()?; @@ -369,7 +359,7 @@ fn parse_storage_device_from_config( // causes unnecessary guest work, but is not a correctness // issue. The converse can be. Default to reporting write caches // if we're not instructed otherwise. - let has_write_cache = write_cache_opt.unwrap_or(false); + let has_write_cache = write_cache_opt.unwrap_or(true); Component::NvmeDisk(NvmeDisk { backend_id, From a0dc3e5586d50e8b862894058220b0b8fd9b206b Mon Sep 17 00:00:00 2001 From: iximeow Date: Tue, 21 Jul 2026 20:54:17 +0000 Subject: [PATCH 12/24] clean up the gnarly bits a .. bit. also clippy. --- bin/propolis-server/src/lib/migrate/mod.rs | 8 + bin/propolis-server/src/lib/migrate/types.rs | 190 +----------------- .../src/lib/spec/api_spec_v1.rs | 77 ++++++- .../src/lib/spec/api_spec_v3.rs | 81 +++++++- .../src/lib/spec/api_spec_v6.rs | 70 ++++++- bin/propolis-server/src/lib/spec/mod.rs | 8 +- crates/propolis-config-toml/src/lib.rs | 5 +- 7 files changed, 238 insertions(+), 201 deletions(-) diff --git a/bin/propolis-server/src/lib/migrate/mod.rs b/bin/propolis-server/src/lib/migrate/mod.rs index e4277edf5..8979f99e6 100644 --- a/bin/propolis-server/src/lib/migrate/mod.rs +++ b/bin/propolis-server/src/lib/migrate/mod.rs @@ -9,6 +9,7 @@ use bit_field::BitField; use dropshot::HttpError; use propolis::migrate::MigrateStateError; +use propolis_api_types::instance_spec::SpecKey; use propolis_api_types::migration::MigrationState; use serde::{Deserialize, Serialize}; use slog::error; @@ -158,6 +159,13 @@ pub enum MigrateError { RemoteError(MigrateRole, String), } +impl MigrateError { + pub(crate) fn wrong_type(id: &SpecKey, kind: &str) -> MigrateError { + let msg = format!("component {id} is not a {kind} in the source spec"); + MigrateError::InstanceSpecsIncompatible(msg) + } +} + impl From for MigrateError { fn from(err: tokio_tungstenite::tungstenite::Error) -> MigrateError { MigrateError::Websocket(err.to_string()) diff --git a/bin/propolis-server/src/lib/migrate/types.rs b/bin/propolis-server/src/lib/migrate/types.rs index 3411a1dd2..b5906c68a 100644 --- a/bin/propolis-server/src/lib/migrate/types.rs +++ b/bin/propolis-server/src/lib/migrate/types.rs @@ -86,8 +86,8 @@ use std::collections::BTreeMap; use crate::migrate::MigrateError; use crate::spec::{ - api_spec_v1::ApiSpecError as V1SpecError, - api_spec_v3::ApiSpecError as V3SpecError, + api_spec_v1, api_spec_v1::ApiSpecError as V1SpecError, api_spec_v3, + api_spec_v3::ApiSpecError as V3SpecError, api_spec_v6, api_spec_v6::ApiSpecError as V6SpecError, Spec, }; @@ -148,75 +148,9 @@ impl VersionedInstanceSpec { ReplacementComponent, >, ) -> Result { - fn wrong_type_error( - id: &v1::instance_spec::SpecKey, - kind: &str, - ) -> MigrateError { - let msg = - format!("component {id} is not a {kind} in the source spec"); - MigrateError::InstanceSpecsIncompatible(msg) - } - let amended_spec = match self { VersionedInstanceSpec::V1(mut source_spec) => { - for (id, comp) in replacements { - let Some(to_amend) = source_spec.components.get_mut(id) - else { - return Err(MigrateError::InstanceSpecsIncompatible( - format!( - "replacement component {id} not in source spec", - ), - )); - }; - - match comp { - #[cfg(not(feature = "failure-injection"))] - ReplacementComponent::MigrationFailureInjector(_) => { - return Err(MigrateError::InstanceSpecsIncompatible( - format!( - "replacing migration failure injector {id} is \ - impossible because the feature is compiled out" - ), - )); - } - - #[cfg(feature = "failure-injection")] - ReplacementComponent::MigrationFailureInjector( - comp, - ) => { - let v1::instance_spec::Component::MigrationFailureInjector( - src, - ) = to_amend - else { - return Err(wrong_type_error( - id, - "migration failure injector", - )); - }; - - *src = comp.clone(); - } - ReplacementComponent::CrucibleStorageBackend(comp) => { - let v1::instance_spec::Component::CrucibleStorageBackend( - src, - ) = to_amend - else { - return Err(wrong_type_error(id, "crucible backend")); - }; - - *src = comp.clone(); - } - ReplacementComponent::VirtioNetworkBackend(comp) => { - let v1::instance_spec::Component::VirtioNetworkBackend(src) = - to_amend - else { - return Err(wrong_type_error(id, "viona backend")); - }; - - *src = comp.clone(); - } - } - } + api_spec_v1::amend(&mut source_spec, replacements)?; let amended_spec: Spec = source_spec.try_into().map_err(|e: V1SpecError| { @@ -229,64 +163,7 @@ impl VersionedInstanceSpec { panic!("should v2 really be here?"); } VersionedInstanceSpec::V3(mut source_spec) => { - for (id, comp) in replacements { - let Some(to_amend) = source_spec.components.get_mut(id) - else { - return Err(MigrateError::InstanceSpecsIncompatible( - format!( - "replacement component {id} not in source spec", - ), - )); - }; - - match comp { - #[cfg(not(feature = "failure-injection"))] - ReplacementComponent::MigrationFailureInjector(_) => { - return Err(MigrateError::InstanceSpecsIncompatible( - format!( - "replacing migration failure injector {id} is \ - impossible because the feature is compiled out" - ), - )); - } - - #[cfg(feature = "failure-injection")] - ReplacementComponent::MigrationFailureInjector( - comp, - ) => { - let v3::instance_spec::Component::MigrationFailureInjector( - src, - ) = to_amend - else { - return Err(wrong_type_error( - id, - "migration failure injector", - )); - }; - - *src = comp.clone(); - } - ReplacementComponent::CrucibleStorageBackend(comp) => { - let v3::instance_spec::Component::CrucibleStorageBackend( - src, - ) = to_amend - else { - return Err(wrong_type_error(id, "crucible backend")); - }; - - *src = comp.clone(); - } - ReplacementComponent::VirtioNetworkBackend(comp) => { - let v3::instance_spec::Component::VirtioNetworkBackend(src) = - to_amend - else { - return Err(wrong_type_error(id, "viona backend")); - }; - - *src = comp.clone(); - } - } - } + api_spec_v3::amend(&mut source_spec, replacements)?; let v6_spec: v6::instance_spec::InstanceSpec = source_spec.into(); @@ -299,64 +176,7 @@ impl VersionedInstanceSpec { amended_spec } VersionedInstanceSpec::V6(mut source_spec) => { - for (id, comp) in replacements { - let Some(to_amend) = source_spec.components.get_mut(id) - else { - return Err(MigrateError::InstanceSpecsIncompatible( - format!( - "replacement component {id} not in source spec", - ), - )); - }; - - match comp { - #[cfg(not(feature = "failure-injection"))] - ReplacementComponent::MigrationFailureInjector(_) => { - return Err(MigrateError::InstanceSpecsIncompatible( - format!( - "replacing migration failure injector {id} is \ - impossible because the feature is compiled out" - ), - )); - } - - #[cfg(feature = "failure-injection")] - ReplacementComponent::MigrationFailureInjector( - comp, - ) => { - let v6::instance_spec::Component::MigrationFailureInjector( - src, - ) = to_amend - else { - return Err(wrong_type_error( - id, - "migration failure injector", - )); - }; - - *src = comp.clone(); - } - ReplacementComponent::CrucibleStorageBackend(comp) => { - let v6::instance_spec::Component::CrucibleStorageBackend( - src, - ) = to_amend - else { - return Err(wrong_type_error(id, "crucible backend")); - }; - - *src = comp.clone(); - } - ReplacementComponent::VirtioNetworkBackend(comp) => { - let v6::instance_spec::Component::VirtioNetworkBackend(src) = - to_amend - else { - return Err(wrong_type_error(id, "viona backend")); - }; - - *src = comp.clone(); - } - } - } + api_spec_v6::amend(&mut source_spec, replacements)?; let amended_spec: Spec = source_spec.try_into().map_err(|e: V6SpecError| { diff --git a/bin/propolis-server/src/lib/spec/api_spec_v1.rs b/bin/propolis-server/src/lib/spec/api_spec_v1.rs index 1fdd01fb3..2dd7ed8ef 100644 --- a/bin/propolis-server/src/lib/spec/api_spec_v1.rs +++ b/bin/propolis-server/src/lib/spec/api_spec_v1.rs @@ -6,6 +6,8 @@ //! "V0" in some parts of propolis-server) instance specs in the //! [`propolis_api_types`] crate to the internal [`super::Spec`] representation. +use std::collections::BTreeMap; + use propolis_api_types::instance_spec::{ components::{ board::Board as InstanceSpecBoard, @@ -13,7 +15,9 @@ use propolis_api_types::instance_spec::{ }, SpecKey, }; -use propolis_api_types_versions::{v1, v2, v3, v6}; +use propolis_api_types_versions::{ + v1, v1::instance::ReplacementComponent, v2, v3, v6, +}; use thiserror::Error; #[cfg(feature = "falcon")] @@ -23,13 +27,11 @@ use super::{ builder::{SpecBuilder, SpecBuilderError}, SerialPortDevice, Spec, StorageBackend, StorageDevice, }; +use crate::migrate::MigrateError; #[cfg(feature = "failure-injection")] use super::MigrationFailure; -#[cfg(feature = "falcon")] -use super::SoftNpuPort; - #[derive(Debug, Error)] pub(crate) enum ApiSpecError { #[error(transparent)] @@ -322,3 +324,70 @@ pub(crate) fn v1_to_spec_builder( crate::spec::api_spec_v3::v3_to_spec_builder(v3_spec).map_err(|e| e.into()) } + +fn amend_component( + id: &SpecKey, + to_amend: &mut v1::instance_spec::Component, + replacement: &ReplacementComponent, +) -> Result<(), MigrateError> { + match replacement { + #[cfg(not(feature = "failure-injection"))] + ReplacementComponent::MigrationFailureInjector(_) => { + return Err(MigrateError::InstanceSpecsIncompatible(format!( + "replacing migration failure injector {id} is \ + impossible because the feature is compiled out" + ))); + } + + #[cfg(feature = "failure-injection")] + ReplacementComponent::MigrationFailureInjector(comp) => { + let v1::instance_spec::Component::MigrationFailureInjector(src) = + to_amend + else { + return Err(MigrateError::wrong_type( + id, + "migration failure injector", + )); + }; + + *src = comp.clone(); + } + ReplacementComponent::CrucibleStorageBackend(comp) => { + let v1::instance_spec::Component::CrucibleStorageBackend(src) = + to_amend + else { + return Err(MigrateError::wrong_type(id, "crucible backend")); + }; + + *src = comp.clone(); + } + ReplacementComponent::VirtioNetworkBackend(comp) => { + let v1::instance_spec::Component::VirtioNetworkBackend(src) = + to_amend + else { + return Err(MigrateError::wrong_type(id, "viona backend")); + }; + + *src = comp.clone(); + } + } + + Ok(()) +} + +pub(crate) fn amend( + spec: &mut v1::instance_spec::InstanceSpec, + replacements: &BTreeMap, +) -> Result<(), MigrateError> { + for (id, replacement) in replacements { + let Some(to_amend) = spec.components.get_mut(id) else { + return Err(MigrateError::InstanceSpecsIncompatible(format!( + "replacement component {id} not in source spec", + ))); + }; + + amend_component(id, to_amend, replacement)?; + } + + Ok(()) +} diff --git a/bin/propolis-server/src/lib/spec/api_spec_v3.rs b/bin/propolis-server/src/lib/spec/api_spec_v3.rs index 7f7904590..ac76d16b1 100644 --- a/bin/propolis-server/src/lib/spec/api_spec_v3.rs +++ b/bin/propolis-server/src/lib/spec/api_spec_v3.rs @@ -5,6 +5,8 @@ //! Conversions from [`propolis_api_types::v3`]) instance specs in the //! [`propolis_api_types`] crate to the internal [`super::Spec`] representation. +use std::collections::BTreeMap; + use propolis_api_types::instance_spec::{ components::{ board::Board as InstanceSpecBoard, @@ -12,7 +14,7 @@ use propolis_api_types::instance_spec::{ }, SpecKey, }; -use propolis_api_types_versions::{latest, v3, v6}; +use propolis_api_types_versions::{v1::instance::ReplacementComponent, v3, v6}; use thiserror::Error; #[cfg(feature = "falcon")] @@ -23,13 +25,11 @@ use super::{ builder::{SpecBuilder, SpecBuilderError}, SerialPortDevice, Spec, StorageBackend, StorageDevice, }; +use crate::migrate::MigrateError; #[cfg(feature = "failure-injection")] use super::MigrationFailure; -#[cfg(feature = "falcon")] -use super::SoftNpuPort; - #[derive(Debug, Error)] pub(crate) enum ApiSpecError { #[error(transparent)] @@ -335,8 +335,75 @@ pub(crate) fn v3_to_spec_builder( value: v3::instance_spec::InstanceSpec, ) -> Result { // Converting v3 to v6 is lossless so just do that and piggyback on the - // latest `InstanceSpec->SpecBuilder`. - let latest_spec: latest::instance_spec::InstanceSpec = value.into(); + // v6 `InstanceSpec->SpecBuilder`. + let v6_spec: v6::instance_spec::InstanceSpec = value.into(); + + api_spec_v6::v6_to_spec_builder(v6_spec).map_err(|e| e.into()) +} + +fn amend_component( + id: &SpecKey, + to_amend: &mut v3::instance_spec::Component, + replacement: &ReplacementComponent, +) -> Result<(), MigrateError> { + match replacement { + #[cfg(not(feature = "failure-injection"))] + ReplacementComponent::MigrationFailureInjector(_) => { + return Err(MigrateError::InstanceSpecsIncompatible(format!( + "replacing migration failure injector {id} is \ + impossible because the feature is compiled out" + ))); + } + + #[cfg(feature = "failure-injection")] + ReplacementComponent::MigrationFailureInjector(comp) => { + let v3::instance_spec::Component::MigrationFailureInjector(src) = + to_amend + else { + return Err(MigrateError::wrong_type( + id, + "migration failure injector", + )); + }; + + *src = comp.clone(); + } + ReplacementComponent::CrucibleStorageBackend(comp) => { + let v3::instance_spec::Component::CrucibleStorageBackend(src) = + to_amend + else { + return Err(MigrateError::wrong_type(id, "crucible backend")); + }; + + *src = comp.clone(); + } + ReplacementComponent::VirtioNetworkBackend(comp) => { + let v3::instance_spec::Component::VirtioNetworkBackend(src) = + to_amend + else { + return Err(MigrateError::wrong_type(id, "viona backend")); + }; + + *src = comp.clone(); + } + } + + Ok(()) +} + +pub(crate) fn amend( + spec: &mut v3::instance_spec::InstanceSpec, + replacements: &BTreeMap, +) -> Result<(), MigrateError> { + for (id, replacement) in replacements { + let Some(to_amend) = spec.components.get_mut(id) else { + return Err(MigrateError::InstanceSpecsIncompatible(format!( + "replacement component {id} not in source spec", + ))); + }; + + amend_component(id, to_amend, replacement)?; + } - api_spec_v6::v6_to_spec_builder(latest_spec).map_err(|e| e.into()) + Ok(()) } 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 3715060e5..809549fc3 100644 --- a/bin/propolis-server/src/lib/spec/api_spec_v6.rs +++ b/bin/propolis-server/src/lib/spec/api_spec_v6.rs @@ -15,7 +15,7 @@ use propolis_api_types::instance_spec::{ }, SpecKey, }; -use propolis_api_types_versions::v6; +use propolis_api_types_versions::{v1::instance::ReplacementComponent, v6}; use thiserror::Error; #[cfg(feature = "falcon")] @@ -26,6 +26,7 @@ use super::{ Disk, Nic, QemuPvpanic, SerialPortDevice, Spec, StorageBackend, StorageDevice, }; +use crate::migrate::MigrateError; #[cfg(feature = "failure-injection")] use super::MigrationFailure; @@ -442,3 +443,70 @@ pub(crate) fn v6_to_spec_builder( Ok(builder) } + +fn amend_component( + id: &SpecKey, + to_amend: &mut v6::instance_spec::Component, + replacement: &ReplacementComponent, +) -> Result<(), MigrateError> { + match replacement { + #[cfg(not(feature = "failure-injection"))] + ReplacementComponent::MigrationFailureInjector(_) => { + return Err(MigrateError::InstanceSpecsIncompatible(format!( + "replacing migration failure injector {id} is \ + impossible because the feature is compiled out" + ))); + } + + #[cfg(feature = "failure-injection")] + ReplacementComponent::MigrationFailureInjector(comp) => { + let v6::instance_spec::Component::MigrationFailureInjector(src) = + to_amend + else { + return Err(MigrateError::wrong_type( + id, + "migration failure injector", + )); + }; + + *src = comp.clone(); + } + ReplacementComponent::CrucibleStorageBackend(comp) => { + let v6::instance_spec::Component::CrucibleStorageBackend(src) = + to_amend + else { + return Err(MigrateError::wrong_type(id, "crucible backend")); + }; + + *src = comp.clone(); + } + ReplacementComponent::VirtioNetworkBackend(comp) => { + let v6::instance_spec::Component::VirtioNetworkBackend(src) = + to_amend + else { + return Err(MigrateError::wrong_type(id, "viona backend")); + }; + + *src = comp.clone(); + } + } + + Ok(()) +} + +pub(crate) fn amend( + spec: &mut v6::instance_spec::InstanceSpec, + replacements: &BTreeMap, +) -> Result<(), MigrateError> { + for (id, replacement) in replacements { + let Some(to_amend) = spec.components.get_mut(id) else { + return Err(MigrateError::InstanceSpecsIncompatible(format!( + "replacement component {id} not in source spec", + ))); + }; + + amend_component(id, to_amend, replacement)?; + } + + Ok(()) +} diff --git a/bin/propolis-server/src/lib/spec/mod.rs b/bin/propolis-server/src/lib/spec/mod.rs index 6c049a7d6..69dcc4dba 100644 --- a/bin/propolis-server/src/lib/spec/mod.rs +++ b/bin/propolis-server/src/lib/spec/mod.rs @@ -13,6 +13,10 @@ //! wire-format types in the [`propolis_api_types`] crate. This, in turn, allows //! [`Spec`] and its component types to take forms that might otherwise be hard //! to change in a backward-compatible way. +//! +//! Types and operations here are copied as-needed into new verson-specific +//! modules as needed, as new versions of the `propolis-server` HTTP API are +//! added. use std::collections::BTreeMap; @@ -54,8 +58,6 @@ pub(crate) mod api_spec_v3; pub(crate) mod api_spec_v6; pub(crate) mod builder; -/// The code related to latest types does not go into a versioned module - /// `propolis-server` relies on `TryInto` to convert the API-provided /// `InstanceSpec` to an internal `Spec`. When adding a new API version to /// `propolis-server` you will probably want to take this implementation and @@ -116,7 +118,7 @@ pub(crate) struct Spec { // options here are to have `Builder` take the instance's UUID at all times // and only sometimes synthesize `SmbiosType1Input` if nothing else is // provided, or allow this to be `None` and interpret that in "the old way" - // when instantiating the SMBIOS tables. We've gone the latter. + // when instantiating the SMBIOS tables. We've gone with the latter. // Alternatively, we could scratch `Builder` entirely and have open-coded // functions to translate `InstanceSpec` to a `Spec`, and have v1 of *those* // take the requisite ancillary data. diff --git a/crates/propolis-config-toml/src/lib.rs b/crates/propolis-config-toml/src/lib.rs index c2c1774e6..b9ed33995 100644 --- a/crates/propolis-config-toml/src/lib.rs +++ b/crates/propolis-config-toml/src/lib.rs @@ -93,7 +93,10 @@ pub struct Device { } impl Device { - pub fn get_toml_value>(&self, key: S) -> Option<&toml::Value> { + pub fn get_toml_value>( + &self, + key: S, + ) -> Option<&toml::Value> { self.options.get(key.as_ref()) } From 1ffc5fbc6cdb41dc84baa9973e84a108fe812f75 Mon Sep 17 00:00:00 2001 From: iximeow Date: Tue, 21 Jul 2026 21:10:15 +0000 Subject: [PATCH 13/24] xtask-openapi gets a cookie --- .../propolis-server-5.0.0-0c6dd9.json | 2328 ----------------- .../propolis-server-5.0.0-0c6dd9.json.gitstub | 1 + 2 files changed, 1 insertion(+), 2328 deletions(-) delete mode 100644 openapi/propolis-server/propolis-server-5.0.0-0c6dd9.json create mode 100644 openapi/propolis-server/propolis-server-5.0.0-0c6dd9.json.gitstub diff --git a/openapi/propolis-server/propolis-server-5.0.0-0c6dd9.json b/openapi/propolis-server/propolis-server-5.0.0-0c6dd9.json deleted file mode 100644 index 94d0a20d8..000000000 --- a/openapi/propolis-server/propolis-server-5.0.0-0c6dd9.json +++ /dev/null @@ -1,2328 +0,0 @@ -{ - "openapi": "3.0.3", - "info": { - "title": "Oxide Propolis Server API", - "description": "API for interacting with the Propolis hypervisor frontend.", - "contact": { - "url": "https://oxide.computer", - "email": "api@oxide.computer" - }, - "version": "5.0.0" - }, - "paths": { - "/instance": { - "get": { - "operationId": "instance_get", - "responses": { - "200": { - "description": "successful operation", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/InstanceGetResponse" - } - } - } - }, - "4XX": { - "$ref": "#/components/responses/Error" - }, - "5XX": { - "$ref": "#/components/responses/Error" - } - } - }, - "put": { - "operationId": "instance_ensure", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/InstanceEnsureRequest" - } - } - }, - "required": true - }, - "responses": { - "201": { - "description": "successful creation", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/InstanceEnsureResponse" - } - } - } - }, - "4XX": { - "$ref": "#/components/responses/Error" - }, - "5XX": { - "$ref": "#/components/responses/Error" - } - } - } - }, - "/instance/disk/{id}/snapshot/{snapshot_id}": { - "post": { - "summary": "Issues a snapshot request to a crucible backend.", - "operationId": "instance_issue_crucible_snapshot_request", - "parameters": [ - { - "in": "path", - "name": "id", - "required": true, - "schema": { - "type": "string" - } - }, - { - "in": "path", - "name": "snapshot_id", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "responses": { - "200": { - "description": "successful operation", - "content": { - "application/json": { - "schema": { - "title": "Null", - "type": "string", - "enum": [ - null - ] - } - } - } - }, - "4XX": { - "$ref": "#/components/responses/Error" - }, - "5XX": { - "$ref": "#/components/responses/Error" - } - } - } - }, - "/instance/disk/{id}/status": { - "get": { - "summary": "Gets the status of a Crucible volume backing a disk", - "operationId": "disk_volume_status", - "parameters": [ - { - "in": "path", - "name": "id", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "successful operation", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/VolumeStatus" - } - } - } - }, - "4XX": { - "$ref": "#/components/responses/Error" - }, - "5XX": { - "$ref": "#/components/responses/Error" - } - } - } - }, - "/instance/disk/{id}/vcr": { - "put": { - "summary": "Issues a volume_construction_request replace to a crucible backend.", - "operationId": "instance_issue_crucible_vcr_request", - "parameters": [ - { - "in": "path", - "name": "id", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/InstanceVCRReplace" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "successful operation", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReplaceResult" - } - } - } - }, - "4XX": { - "$ref": "#/components/responses/Error" - }, - "5XX": { - "$ref": "#/components/responses/Error" - } - } - } - }, - "/instance/migrate/{migration_id}/start": { - "get": { - "summary": "DO NOT USE THIS IF YOU'RE NOT PROPOLIS-SERVER.", - "description": "Internal API called during a migration from a destination instance to the source instance as part of the HTTP connection upgrade used to establish the migration link. This API is exported via OpenAPI purely to verify that its shape hasn't changed.", - "operationId": "instance_migrate_start", - "parameters": [ - { - "in": "path", - "name": "migration_id", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "responses": { - "101": { - "description": "Negotiating protocol upgrade from HTTP/1.1 to WebSocket" - }, - "4XX": { - "$ref": "#/components/responses/Error" - }, - "5XX": { - "$ref": "#/components/responses/Error" - } - }, - "x-dropshot-websocket": {} - } - }, - "/instance/migration-status": { - "get": { - "operationId": "instance_migrate_status", - "responses": { - "200": { - "description": "successful operation", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/InstanceMigrateStatusResponse" - } - } - } - }, - "4XX": { - "$ref": "#/components/responses/Error" - }, - "5XX": { - "$ref": "#/components/responses/Error" - } - } - } - }, - "/instance/nmi": { - "post": { - "summary": "Issues an NMI to the instance.", - "operationId": "instance_issue_nmi", - "responses": { - "200": { - "description": "successful operation", - "content": { - "application/json": { - "schema": { - "title": "Null", - "type": "string", - "enum": [ - null - ] - } - } - } - }, - "4XX": { - "$ref": "#/components/responses/Error" - }, - "5XX": { - "$ref": "#/components/responses/Error" - } - } - } - }, - "/instance/serial": { - "get": { - "operationId": "instance_serial", - "parameters": [ - { - "in": "query", - "name": "from_start", - "description": "Character index in the serial buffer from which to read, counting the bytes output since instance start. If this is provided, `most_recent` must *not* be provided.", - "schema": { - "nullable": true, - "type": "integer", - "format": "uint64", - "minimum": 0 - } - }, - { - "in": "query", - "name": "most_recent", - "description": "Character index in the serial buffer from which to read, counting *backward* from the most recently buffered data retrieved from the instance. (See note on `from_start` about mutual exclusivity)", - "schema": { - "nullable": true, - "type": "integer", - "format": "uint64", - "minimum": 0 - } - } - ], - "responses": { - "101": { - "description": "Negotiating protocol upgrade from HTTP/1.1 to WebSocket" - }, - "4XX": { - "$ref": "#/components/responses/Error" - }, - "5XX": { - "$ref": "#/components/responses/Error" - } - }, - "x-dropshot-websocket": {} - } - }, - "/instance/serial/history": { - "get": { - "operationId": "instance_serial_history_get", - "parameters": [ - { - "in": "query", - "name": "from_start", - "description": "Character index in the serial buffer from which to read, counting the bytes output since instance start. If this is not provided, `most_recent` must be provided, and if this *is* provided, `most_recent` must *not* be provided.", - "schema": { - "nullable": true, - "type": "integer", - "format": "uint64", - "minimum": 0 - } - }, - { - "in": "query", - "name": "max_bytes", - "description": "Maximum number of bytes of buffered serial console contents to return. If the requested range runs to the end of the available buffer, the data returned will be shorter than `max_bytes`.", - "schema": { - "nullable": true, - "type": "integer", - "format": "uint64", - "minimum": 0 - } - }, - { - "in": "query", - "name": "most_recent", - "description": "Character index in the serial buffer from which to read, counting *backward* from the most recently buffered data retrieved from the instance. (See note on `from_start` about mutual exclusivity)", - "schema": { - "nullable": true, - "type": "integer", - "format": "uint64", - "minimum": 0 - } - } - ], - "responses": { - "200": { - "description": "successful operation", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/InstanceSerialConsoleHistoryResponse" - } - } - } - }, - "4XX": { - "$ref": "#/components/responses/Error" - }, - "5XX": { - "$ref": "#/components/responses/Error" - } - } - } - }, - "/instance/spec": { - "get": { - "operationId": "instance_spec_get", - "responses": { - "200": { - "description": "successful operation", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/InstanceSpecGetResponse" - } - } - } - }, - "4XX": { - "$ref": "#/components/responses/Error" - }, - "5XX": { - "$ref": "#/components/responses/Error" - } - } - } - }, - "/instance/state": { - "put": { - "operationId": "instance_state_put", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/InstanceStateRequested" - } - } - }, - "required": true - }, - "responses": { - "204": { - "description": "resource updated" - }, - "4XX": { - "$ref": "#/components/responses/Error" - }, - "5XX": { - "$ref": "#/components/responses/Error" - } - } - } - }, - "/instance/state-monitor": { - "get": { - "operationId": "instance_state_monitor", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/InstanceStateMonitorRequest" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "successful operation", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/InstanceStateMonitorResponse" - } - } - } - }, - "4XX": { - "$ref": "#/components/responses/Error" - }, - "5XX": { - "$ref": "#/components/responses/Error" - } - } - } - } - }, - "components": { - "schemas": { - "BlobStorageBackend": { - "description": "A storage backend for a disk whose initial contents are given explicitly by the specification.", - "type": "object", - "properties": { - "base64": { - "description": "The disk's initial contents, encoded as a base64 string.", - "type": "string" - }, - "readonly": { - "description": "Indicates whether the storage is read-only.", - "type": "boolean" - } - }, - "required": [ - "base64", - "readonly" - ], - "additionalProperties": false - }, - "Board": { - "description": "A VM's mainboard.", - "type": "object", - "properties": { - "chipset": { - "description": "The chipset to expose to guest software.", - "allOf": [ - { - "$ref": "#/components/schemas/Chipset" - } - ] - }, - "cpuid": { - "nullable": true, - "description": "The CPUID values to expose to the guest. If `None`, bhyve will derive default values from the host's CPUID values.", - "allOf": [ - { - "$ref": "#/components/schemas/Cpuid" - } - ] - }, - "cpus": { - "description": "The number of virtual logical processors attached to this VM.", - "type": "integer", - "format": "uint8", - "minimum": 0 - }, - "guest_hv_interface": { - "description": "The hypervisor platform to expose to the guest. The default is a bhyve-compatible interface with no additional features.\n\nFor compatibility with older versions of Propolis, this field is only serialized if it specifies a non-default interface.", - "allOf": [ - { - "$ref": "#/components/schemas/GuestHypervisorInterface" - } - ] - }, - "memory_mb": { - "description": "The amount of guest RAM attached to this VM.", - "type": "integer", - "format": "uint64", - "minimum": 0 - } - }, - "required": [ - "chipset", - "cpus", - "memory_mb" - ], - "additionalProperties": false - }, - "BootOrderEntry": { - "description": "An entry in the boot order stored in a [`BootSettings`] component.", - "type": "object", - "properties": { - "id": { - "description": "The ID of another component in the spec that Propolis should try to boot from.\n\nCurrently, only disk device components are supported.", - "allOf": [ - { - "$ref": "#/components/schemas/SpecKey" - } - ] - } - }, - "required": [ - "id" - ] - }, - "BootSettings": { - "description": "Settings supplied to the guest's firmware image that specify the order in which it should consider its options when selecting a device to try to boot from.", - "type": "object", - "properties": { - "order": { - "description": "An ordered list of components to attempt to boot from.", - "type": "array", - "items": { - "$ref": "#/components/schemas/BootOrderEntry" - } - } - }, - "required": [ - "order" - ], - "additionalProperties": false - }, - "Chipset": { - "description": "A kind of virtual chipset.", - "oneOf": [ - { - "description": "An Intel 440FX-compatible chipset.", - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "i440_fx" - ] - }, - "value": { - "$ref": "#/components/schemas/I440Fx" - } - }, - "required": [ - "type", - "value" - ], - "additionalProperties": false - } - ] - }, - "Component": { - "oneOf": [ - { - "type": "object", - "properties": { - "component": { - "$ref": "#/components/schemas/VirtioDisk" - }, - "type": { - "type": "string", - "enum": [ - "virtio_disk" - ] - } - }, - "required": [ - "component", - "type" - ], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "component": { - "$ref": "#/components/schemas/NvmeDisk" - }, - "type": { - "type": "string", - "enum": [ - "nvme_disk" - ] - } - }, - "required": [ - "component", - "type" - ], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "component": { - "$ref": "#/components/schemas/VirtioNic" - }, - "type": { - "type": "string", - "enum": [ - "virtio_nic" - ] - } - }, - "required": [ - "component", - "type" - ], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "component": { - "$ref": "#/components/schemas/SerialPort" - }, - "type": { - "type": "string", - "enum": [ - "serial_port" - ] - } - }, - "required": [ - "component", - "type" - ], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "component": { - "$ref": "#/components/schemas/PciPciBridge" - }, - "type": { - "type": "string", - "enum": [ - "pci_pci_bridge" - ] - } - }, - "required": [ - "component", - "type" - ], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "component": { - "$ref": "#/components/schemas/QemuPvpanic" - }, - "type": { - "type": "string", - "enum": [ - "qemu_pvpanic" - ] - } - }, - "required": [ - "component", - "type" - ], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "component": { - "$ref": "#/components/schemas/BootSettings" - }, - "type": { - "type": "string", - "enum": [ - "boot_settings" - ] - } - }, - "required": [ - "component", - "type" - ], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "component": { - "$ref": "#/components/schemas/VirtioSocket" - }, - "type": { - "type": "string", - "enum": [ - "virtio_socket" - ] - } - }, - "required": [ - "component", - "type" - ], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "component": { - "$ref": "#/components/schemas/SoftNpuPciPort" - }, - "type": { - "type": "string", - "enum": [ - "soft_npu_pci_port" - ] - } - }, - "required": [ - "component", - "type" - ], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "component": { - "$ref": "#/components/schemas/SoftNpuPort" - }, - "type": { - "type": "string", - "enum": [ - "soft_npu_port" - ] - } - }, - "required": [ - "component", - "type" - ], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "component": { - "$ref": "#/components/schemas/SoftNpuP9" - }, - "type": { - "type": "string", - "enum": [ - "soft_npu_p9" - ] - } - }, - "required": [ - "component", - "type" - ], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "component": { - "$ref": "#/components/schemas/P9fs" - }, - "type": { - "type": "string", - "enum": [ - "p9fs" - ] - } - }, - "required": [ - "component", - "type" - ], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "component": { - "$ref": "#/components/schemas/MigrationFailureInjector" - }, - "type": { - "type": "string", - "enum": [ - "migration_failure_injector" - ] - } - }, - "required": [ - "component", - "type" - ], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "component": { - "$ref": "#/components/schemas/CrucibleStorageBackend" - }, - "type": { - "type": "string", - "enum": [ - "crucible_storage_backend" - ] - } - }, - "required": [ - "component", - "type" - ], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "component": { - "$ref": "#/components/schemas/FileStorageBackend" - }, - "type": { - "type": "string", - "enum": [ - "file_storage_backend" - ] - } - }, - "required": [ - "component", - "type" - ], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "component": { - "$ref": "#/components/schemas/BlobStorageBackend" - }, - "type": { - "type": "string", - "enum": [ - "blob_storage_backend" - ] - } - }, - "required": [ - "component", - "type" - ], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "component": { - "$ref": "#/components/schemas/VirtioNetworkBackend" - }, - "type": { - "type": "string", - "enum": [ - "virtio_network_backend" - ] - } - }, - "required": [ - "component", - "type" - ], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "component": { - "$ref": "#/components/schemas/DlpiNetworkBackend" - }, - "type": { - "type": "string", - "enum": [ - "dlpi_network_backend" - ] - } - }, - "required": [ - "component", - "type" - ], - "additionalProperties": false - } - ] - }, - "Cpuid": { - "description": "A set of CPUID values to expose to a guest.", - "type": "object", - "properties": { - "entries": { - "description": "A list of CPUID leaves/subleaves and their associated values.\n\nPropolis servers require that each entry's `leaf` be unique and that it falls in either the \"standard\" (0 to 0xFFFF) or \"extended\" (0x8000_0000 to 0x8000_FFFF) function ranges, since these are the only valid input ranges currently defined by Intel and AMD. See the Intel 64 and IA-32 Architectures Software Developer's Manual (June 2024) Table 3-17 and the AMD64 Architecture Programmer's Manual (March 2024) Volume 3's documentation of the CPUID instruction.", - "type": "array", - "items": { - "$ref": "#/components/schemas/CpuidEntry" - } - }, - "vendor": { - "description": "The CPU vendor to emulate.\n\nCPUID leaves in the extended range (0x8000_0000 to 0x8000_FFFF) have vendor-defined semantics. Propolis uses this value to determine these semantics when deciding whether it needs to specialize the supplied template values for these leaves.", - "allOf": [ - { - "$ref": "#/components/schemas/CpuidVendor" - } - ] - } - }, - "required": [ - "entries", - "vendor" - ], - "additionalProperties": false - }, - "CpuidEntry": { - "description": "A full description of a CPUID leaf/subleaf and the values it produces.", - "type": "object", - "properties": { - "eax": { - "description": "The value to return in eax.", - "type": "integer", - "format": "uint32", - "minimum": 0 - }, - "ebx": { - "description": "The value to return in ebx.", - "type": "integer", - "format": "uint32", - "minimum": 0 - }, - "ecx": { - "description": "The value to return in ecx.", - "type": "integer", - "format": "uint32", - "minimum": 0 - }, - "edx": { - "description": "The value to return in edx.", - "type": "integer", - "format": "uint32", - "minimum": 0 - }, - "leaf": { - "description": "The leaf (function) number for this entry.", - "type": "integer", - "format": "uint32", - "minimum": 0 - }, - "subleaf": { - "nullable": true, - "description": "The subleaf (index) number for this entry, if it uses subleaves.", - "type": "integer", - "format": "uint32", - "minimum": 0 - } - }, - "required": [ - "eax", - "ebx", - "ecx", - "edx", - "leaf" - ], - "additionalProperties": false - }, - "CpuidVendor": { - "description": "A CPU vendor to use when interpreting the meanings of CPUID leaves in the extended ID range (0x80000000 to 0x8000FFFF).", - "type": "string", - "enum": [ - "amd", - "intel" - ] - }, - "CrucibleStorageBackend": { - "description": "A Crucible storage backend.", - "type": "object", - "properties": { - "readonly": { - "description": "Indicates whether the storage is read-only.", - "type": "boolean" - }, - "request_json": { - "description": "A serialized `[crucible_client_types::VolumeConstructionRequest]`. This is stored in serialized form so that breaking changes to the definition of a `VolumeConstructionRequest` do not inadvertently break instance spec deserialization.\n\nWhen using a spec to initialize a new instance, the spec author must ensure this request is well-formed and can be deserialized by the version of `crucible_client_types` used by the target Propolis.", - "type": "string" - } - }, - "required": [ - "readonly", - "request_json" - ], - "additionalProperties": false - }, - "DlpiNetworkBackend": { - "description": "A network backend associated with a DLPI VNIC on the host.", - "type": "object", - "properties": { - "vnic_name": { - "description": "The name of the VNIC to use as a backend.", - "type": "string" - } - }, - "required": [ - "vnic_name" - ], - "additionalProperties": false - }, - "DownstairsInfo": { - "type": "object", - "properties": { - "region_id": { - "nullable": true, - "type": "string", - "format": "uuid" - }, - "repair_addr": { - "nullable": true, - "type": "string" - }, - "state": { - "$ref": "#/components/schemas/DownstairsInfoStatus" - }, - "target_addr": { - "nullable": true, - "type": "string" - } - }, - "required": [ - "state" - ] - }, - "DownstairsInfoConnectionMode": { - "type": "string", - "enum": [ - "new", - "offline", - "faulted", - "replaced" - ] - }, - "DownstairsInfoNegotiationStatus": { - "type": "string", - "enum": [ - "wait_connect", - "negotiating", - "wait_quorum", - "reconcile", - "live_repair_ready" - ] - }, - "DownstairsInfoStatus": { - "oneOf": [ - { - "type": "object", - "properties": { - "mode": { - "$ref": "#/components/schemas/DownstairsInfoConnectionMode" - }, - "state": { - "$ref": "#/components/schemas/DownstairsInfoNegotiationStatus" - }, - "type": { - "type": "string", - "enum": [ - "connecting" - ] - } - }, - "required": [ - "mode", - "state", - "type" - ] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "active" - ] - } - }, - "required": [ - "type" - ] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "live_repair" - ] - } - }, - "required": [ - "type" - ] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "stopping" - ] - } - }, - "required": [ - "type" - ] - } - ] - }, - "Error": { - "description": "Error information from a response.", - "type": "object", - "properties": { - "error_code": { - "type": "string" - }, - "message": { - "type": "string" - }, - "request_id": { - "type": "string" - } - }, - "required": [ - "message", - "request_id" - ] - }, - "FileStorageBackend": { - "description": "A storage backend backed by a file in the host system's file system.", - "type": "object", - "properties": { - "block_size": { - "description": "Block size of the backend", - "type": "integer", - "format": "uint32", - "minimum": 0 - }, - "path": { - "description": "A path to a file that backs a disk.", - "type": "string" - }, - "readonly": { - "description": "Indicates whether the storage is read-only.", - "type": "boolean" - }, - "workers": { - "nullable": true, - "description": "Optional worker threads for the file backend, exposed for testing only.", - "type": "integer", - "format": "uint", - "minimum": 1 - } - }, - "required": [ - "block_size", - "path", - "readonly" - ], - "additionalProperties": false - }, - "GuestHypervisorInterface": { - "description": "A hypervisor interface to expose to the guest.", - "oneOf": [ - { - "description": "Expose a bhyve-like interface (\"bhyve bhyve \" as the hypervisor ID in leaf 0x4000_0000 and no additional leaves or features).", - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "bhyve" - ] - } - }, - "required": [ - "type" - ], - "additionalProperties": false - }, - { - "description": "Expose a Hyper-V-compatible hypervisor interface with the supplied features enabled.", - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "hyper_v" - ] - }, - "value": { - "type": "object", - "properties": { - "features": { - "type": "array", - "items": { - "$ref": "#/components/schemas/HyperVFeatureFlag" - }, - "uniqueItems": true - } - }, - "required": [ - "features" - ], - "additionalProperties": false - } - }, - "required": [ - "type", - "value" - ], - "additionalProperties": false - } - ] - }, - "HyperVFeatureFlag": { - "description": "Flags that enable \"simple\" Hyper-V enlightenments that require no feature-specific configuration.", - "type": "string", - "enum": [ - "reference_tsc" - ] - }, - "I440Fx": { - "description": "An Intel 440FX-compatible chipset.", - "type": "object", - "properties": { - "enable_pcie": { - "description": "Specifies whether the chipset should allow PCI configuration space to be accessed through the PCIe extended configuration mechanism.", - "type": "boolean" - } - }, - "required": [ - "enable_pcie" - ], - "additionalProperties": false - }, - "Instance": { - "type": "object", - "properties": { - "properties": { - "$ref": "#/components/schemas/InstanceProperties" - }, - "state": { - "$ref": "#/components/schemas/InstanceState" - } - }, - "required": [ - "properties", - "state" - ] - }, - "InstanceEnsureRequest": { - "type": "object", - "properties": { - "init": { - "$ref": "#/components/schemas/InstanceInitializationMethod" - }, - "properties": { - "$ref": "#/components/schemas/InstanceProperties" - } - }, - "required": [ - "init", - "properties" - ] - }, - "InstanceEnsureResponse": { - "type": "object", - "properties": { - "migrate": { - "nullable": true, - "allOf": [ - { - "$ref": "#/components/schemas/InstanceMigrateInitiateResponse" - } - ] - } - } - }, - "InstanceGetResponse": { - "type": "object", - "properties": { - "instance": { - "$ref": "#/components/schemas/Instance" - } - }, - "required": [ - "instance" - ] - }, - "InstanceInitializationMethod": { - "oneOf": [ - { - "type": "object", - "properties": { - "method": { - "type": "string", - "enum": [ - "Spec" - ] - }, - "value": { - "type": "object", - "properties": { - "spec": { - "$ref": "#/components/schemas/InstanceSpec" - } - }, - "required": [ - "spec" - ] - } - }, - "required": [ - "method", - "value" - ] - }, - { - "type": "object", - "properties": { - "method": { - "type": "string", - "enum": [ - "MigrationTarget" - ] - }, - "value": { - "type": "object", - "properties": { - "migration_id": { - "type": "string", - "format": "uuid" - }, - "replace_components": { - "type": "object", - "additionalProperties": { - "$ref": "#/components/schemas/ReplacementComponent" - } - }, - "src_addr": { - "type": "string" - } - }, - "required": [ - "migration_id", - "replace_components", - "src_addr" - ] - } - }, - "required": [ - "method", - "value" - ] - } - ] - }, - "InstanceMetadata": { - "type": "object", - "properties": { - "project_id": { - "type": "string", - "format": "uuid" - }, - "silo_id": { - "type": "string", - "format": "uuid" - }, - "sled_id": { - "type": "string", - "format": "uuid" - }, - "sled_model": { - "type": "string" - }, - "sled_revision": { - "type": "integer", - "format": "uint32", - "minimum": 0 - }, - "sled_serial": { - "type": "string" - } - }, - "required": [ - "project_id", - "silo_id", - "sled_id", - "sled_model", - "sled_revision", - "sled_serial" - ] - }, - "InstanceMigrateInitiateResponse": { - "type": "object", - "properties": { - "migration_id": { - "type": "string", - "format": "uuid" - } - }, - "required": [ - "migration_id" - ] - }, - "InstanceMigrateStatusResponse": { - "description": "The statuses of the most recent attempts to live migrate into and out of this Propolis.\n\nIf a VM is initialized by migration in and then begins to migrate out, this structure will contain statuses for both migrations. This ensures that clients can always obtain the status of a successful migration in even after a migration out begins.\n\nThis structure only reports the status of the most recent migration in a single direction. That is, if a migration in or out fails, and a new migration attempt begins, the new migration's status replaces the old's.", - "type": "object", - "properties": { - "migration_in": { - "nullable": true, - "description": "The status of the most recent attempt to initialize the current instance via migration in, or `None` if the instance has never been a migration target.", - "allOf": [ - { - "$ref": "#/components/schemas/InstanceMigrationStatus" - } - ] - }, - "migration_out": { - "nullable": true, - "description": "The status of the most recent attempt to migrate out of the current instance, or `None` if the instance has never been a migration source.", - "allOf": [ - { - "$ref": "#/components/schemas/InstanceMigrationStatus" - } - ] - } - } - }, - "InstanceMigrationStatus": { - "description": "The status of an individual live migration.", - "type": "object", - "properties": { - "id": { - "description": "The ID of this migration, supplied either by the external migration requester (for targets) or the other side of the migration (for sources).", - "type": "string", - "format": "uuid" - }, - "state": { - "description": "The current phase the migration is in.", - "allOf": [ - { - "$ref": "#/components/schemas/MigrationState" - } - ] - } - }, - "required": [ - "id", - "state" - ] - }, - "InstanceProperties": { - "type": "object", - "properties": { - "description": { - "description": "Free-form text description of an Instance.", - "type": "string" - }, - "id": { - "description": "Unique identifier for this Instance.", - "type": "string", - "format": "uuid" - }, - "metadata": { - "description": "Metadata used to track statistics for this Instance.", - "allOf": [ - { - "$ref": "#/components/schemas/InstanceMetadata" - } - ] - }, - "name": { - "description": "Human-readable name of the Instance.", - "type": "string" - } - }, - "required": [ - "description", - "id", - "metadata", - "name" - ] - }, - "InstanceSerialConsoleHistoryResponse": { - "description": "Contents of an Instance's serial console buffer.", - "type": "object", - "properties": { - "data": { - "description": "The bytes starting from the requested offset up to either the end of the buffer or the request's `max_bytes`. Provided as a u8 array rather than a string, as it may not be UTF-8.", - "type": "array", - "items": { - "type": "integer", - "format": "uint8", - "minimum": 0 - } - }, - "last_byte_offset": { - "description": "The absolute offset since boot (suitable for use as `byte_offset` in a subsequent request) of the last byte returned in `data`.", - "type": "integer", - "format": "uint64", - "minimum": 0 - } - }, - "required": [ - "data", - "last_byte_offset" - ] - }, - "InstanceSpec": { - "type": "object", - "properties": { - "board": { - "$ref": "#/components/schemas/Board" - }, - "components": { - "type": "object", - "additionalProperties": { - "$ref": "#/components/schemas/Component" - } - }, - "smbios": { - "nullable": true, - "allOf": [ - { - "$ref": "#/components/schemas/SmbiosType1Input" - } - ] - } - }, - "required": [ - "board", - "components" - ] - }, - "InstanceSpecGetResponse": { - "type": "object", - "properties": { - "properties": { - "$ref": "#/components/schemas/InstanceProperties" - }, - "spec": { - "$ref": "#/components/schemas/InstanceSpecStatus" - }, - "state": { - "$ref": "#/components/schemas/InstanceState" - } - }, - "required": [ - "properties", - "spec", - "state" - ] - }, - "InstanceSpecStatus": { - "oneOf": [ - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "WaitingForMigrationSource" - ] - } - }, - "required": [ - "type" - ] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "Present" - ] - }, - "value": { - "$ref": "#/components/schemas/InstanceSpec" - } - }, - "required": [ - "type", - "value" - ] - } - ] - }, - "InstanceState": { - "description": "Current state of an Instance.", - "type": "string", - "enum": [ - "Creating", - "Starting", - "Running", - "Stopping", - "Stopped", - "Rebooting", - "Migrating", - "Repairing", - "Failed", - "Destroyed" - ] - }, - "InstanceStateMonitorRequest": { - "type": "object", - "properties": { - "gen": { - "type": "integer", - "format": "uint64", - "minimum": 0 - } - }, - "required": [ - "gen" - ] - }, - "InstanceStateMonitorResponse": { - "type": "object", - "properties": { - "gen": { - "type": "integer", - "format": "uint64", - "minimum": 0 - }, - "migration": { - "$ref": "#/components/schemas/InstanceMigrateStatusResponse" - }, - "state": { - "$ref": "#/components/schemas/InstanceState" - } - }, - "required": [ - "gen", - "migration", - "state" - ] - }, - "InstanceStateRequested": { - "type": "string", - "enum": [ - "Run", - "Stop", - "Reboot" - ] - }, - "InstanceVCRReplace": { - "type": "object", - "properties": { - "vcr_json": { - "type": "string" - } - }, - "required": [ - "vcr_json" - ] - }, - "MigrationFailureInjector": { - "description": "Describes a synthetic device that registers for VM lifecycle notifications and returns errors during attempts to migrate.\n\nThis is only supported by Propolis servers compiled with the `failure-injection` feature.", - "type": "object", - "properties": { - "fail_exports": { - "description": "The number of times this device should fail requests to export state.", - "type": "integer", - "format": "uint32", - "minimum": 0 - }, - "fail_imports": { - "description": "The number of times this device should fail requests to import state.", - "type": "integer", - "format": "uint32", - "minimum": 0 - } - }, - "required": [ - "fail_exports", - "fail_imports" - ], - "additionalProperties": false - }, - "MigrationState": { - "type": "string", - "enum": [ - "Sync", - "RamPush", - "Pause", - "RamPushDirty", - "Device", - "Resume", - "RamPull", - "Server", - "Finish", - "Error" - ] - }, - "NvmeDisk": { - "description": "A disk that presents an NVMe interface to the guest.", - "type": "object", - "properties": { - "backend_id": { - "description": "The name of the disk's backend component.", - "allOf": [ - { - "$ref": "#/components/schemas/SpecKey" - } - ] - }, - "pci_path": { - "description": "The PCI bus/device/function at which this disk should be attached.", - "allOf": [ - { - "$ref": "#/components/schemas/PciPath" - } - ] - }, - "serial_number": { - "description": "The serial number to return in response to an NVMe Identify Controller command.", - "type": "array", - "items": { - "type": "integer", - "format": "uint8", - "minimum": 0 - }, - "minItems": 20, - "maxItems": 20 - } - }, - "required": [ - "backend_id", - "pci_path", - "serial_number" - ], - "additionalProperties": false - }, - "P9fs": { - "description": "Describes a filesystem to expose through a P9 device.\n\nThis is only supported by Propolis servers compiled with the `falcon` feature.", - "type": "object", - "properties": { - "chunk_size": { - "description": "The chunk size to use in the 9P protocol. Vanilla Helios images should use 8192. Falcon Helios base images and Linux can use up to 65536.", - "type": "integer", - "format": "uint32", - "minimum": 0 - }, - "pci_path": { - "description": "The PCI path at which to attach the guest to this P9 filesystem.", - "allOf": [ - { - "$ref": "#/components/schemas/PciPath" - } - ] - }, - "source": { - "description": "The host source path to mount into the guest.", - "type": "string" - }, - "target": { - "description": "The 9P target filesystem tag.", - "type": "string" - } - }, - "required": [ - "chunk_size", - "pci_path", - "source", - "target" - ], - "additionalProperties": false - }, - "PciPath": { - "description": "A PCI bus/device/function tuple.", - "type": "object", - "properties": { - "bus": { - "type": "integer", - "format": "uint8", - "minimum": 0 - }, - "device": { - "type": "integer", - "format": "uint8", - "minimum": 0 - }, - "function": { - "type": "integer", - "format": "uint8", - "minimum": 0 - } - }, - "required": [ - "bus", - "device", - "function" - ] - }, - "PciPciBridge": { - "description": "A PCI-PCI bridge.", - "type": "object", - "properties": { - "downstream_bus": { - "description": "The logical bus number of this bridge's downstream bus. Other devices may use this bus number in their PCI paths to indicate they should be attached to this bridge's bus.", - "type": "integer", - "format": "uint8", - "minimum": 0 - }, - "pci_path": { - "description": "The PCI path at which to attach this bridge.", - "allOf": [ - { - "$ref": "#/components/schemas/PciPath" - } - ] - } - }, - "required": [ - "downstream_bus", - "pci_path" - ], - "additionalProperties": false - }, - "QemuPvpanic": { - "type": "object", - "properties": { - "enable_isa": { - "description": "Enable the QEMU PVPANIC ISA bus device (I/O port 0x505).", - "type": "boolean" - } - }, - "required": [ - "enable_isa" - ], - "additionalProperties": false - }, - "ReplaceResult": { - "type": "string", - "enum": [ - "started", - "started_already", - "completed_already", - "missing", - "vcr_matches" - ] - }, - "ReplacementComponent": { - "description": "An instance spec component that should be replaced during a live migration.", - "oneOf": [ - { - "type": "object", - "properties": { - "component": { - "type": "string", - "enum": [ - "MigrationFailureInjector" - ] - }, - "spec": { - "$ref": "#/components/schemas/MigrationFailureInjector" - } - }, - "required": [ - "component", - "spec" - ], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "component": { - "type": "string", - "enum": [ - "CrucibleStorageBackend" - ] - }, - "spec": { - "$ref": "#/components/schemas/CrucibleStorageBackend" - } - }, - "required": [ - "component", - "spec" - ], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "component": { - "type": "string", - "enum": [ - "VirtioNetworkBackend" - ] - }, - "spec": { - "$ref": "#/components/schemas/VirtioNetworkBackend" - } - }, - "required": [ - "component", - "spec" - ], - "additionalProperties": false - } - ] - }, - "SerialPort": { - "description": "A serial port device.", - "type": "object", - "properties": { - "num": { - "description": "The serial port number for this port.", - "allOf": [ - { - "$ref": "#/components/schemas/SerialPortNumber" - } - ] - } - }, - "required": [ - "num" - ], - "additionalProperties": false - }, - "SerialPortNumber": { - "description": "A serial port identifier, which determines what I/O ports a guest can use to access a port.", - "type": "string", - "enum": [ - "com1", - "com2", - "com3", - "com4" - ] - }, - "SmbiosType1Input": { - "type": "object", - "properties": { - "manufacturer": { - "type": "string" - }, - "product_name": { - "type": "string" - }, - "serial_number": { - "type": "string" - }, - "version": { - "type": "integer", - "format": "uint64", - "minimum": 0 - } - }, - "required": [ - "manufacturer", - "product_name", - "serial_number", - "version" - ], - "additionalProperties": false - }, - "SoftNpuP9": { - "description": "Describes a PCI device that shares host files with the guest using the P9 protocol.\n\nThis is only supported by Propolis servers compiled with the `falcon` feature.", - "type": "object", - "properties": { - "pci_path": { - "description": "The PCI path at which to attach the guest to this port.", - "allOf": [ - { - "$ref": "#/components/schemas/PciPath" - } - ] - } - }, - "required": [ - "pci_path" - ], - "additionalProperties": false - }, - "SoftNpuPciPort": { - "description": "Describes a SoftNPU PCI device.\n\nThis is only supported by Propolis servers compiled with the `falcon` feature.", - "type": "object", - "properties": { - "pci_path": { - "description": "The PCI path at which to attach the guest to this port.", - "allOf": [ - { - "$ref": "#/components/schemas/PciPath" - } - ] - } - }, - "required": [ - "pci_path" - ], - "additionalProperties": false - }, - "SoftNpuPort": { - "description": "Describes a port in a SoftNPU emulated ASIC.\n\nThis is only supported by Propolis servers compiled with the `falcon` feature.", - "type": "object", - "properties": { - "backend_id": { - "description": "The name of the port's associated DLPI backend.", - "allOf": [ - { - "$ref": "#/components/schemas/SpecKey" - } - ] - }, - "link_name": { - "description": "The data link name for this port.", - "type": "string" - } - }, - "required": [ - "backend_id", - "link_name" - ], - "additionalProperties": false - }, - "SpecKey": { - "description": "A key identifying a component in an instance spec.", - "oneOf": [ - { - "title": "uuid", - "allOf": [ - { - "type": "string", - "format": "uuid" - } - ] - }, - { - "title": "name", - "allOf": [ - { - "type": "string" - } - ] - } - ] - }, - "UpstairsInfoStatus": { - "type": "string", - "enum": [ - "initializing", - "go_active", - "active", - "deactivating", - "disabled" - ] - }, - "VirtioDisk": { - "description": "A disk that presents a virtio-block interface to the guest.", - "type": "object", - "properties": { - "backend_id": { - "description": "The name of the disk's backend component.", - "allOf": [ - { - "$ref": "#/components/schemas/SpecKey" - } - ] - }, - "pci_path": { - "description": "The PCI bus/device/function at which this disk should be attached.", - "allOf": [ - { - "$ref": "#/components/schemas/PciPath" - } - ] - } - }, - "required": [ - "backend_id", - "pci_path" - ], - "additionalProperties": false - }, - "VirtioNetworkBackend": { - "description": "A network backend associated with a virtio-net (viona) VNIC on the host.", - "type": "object", - "properties": { - "vnic_name": { - "description": "The name of the viona VNIC to use as a backend.", - "type": "string" - } - }, - "required": [ - "vnic_name" - ], - "additionalProperties": false - }, - "VirtioNic": { - "description": "A network card that presents a virtio-net interface to the guest.", - "type": "object", - "properties": { - "backend_id": { - "description": "The name of the device's backend.", - "allOf": [ - { - "$ref": "#/components/schemas/SpecKey" - } - ] - }, - "interface_id": { - "description": "A caller-defined correlation identifier for this interface. If Propolis is configured to collect network interface kstats in its Oximeter metrics, the metric series for this interface will be associated with this identifier.", - "type": "string", - "format": "uuid" - }, - "pci_path": { - "description": "The PCI path at which to attach this device.", - "allOf": [ - { - "$ref": "#/components/schemas/PciPath" - } - ] - } - }, - "required": [ - "backend_id", - "interface_id", - "pci_path" - ], - "additionalProperties": false - }, - "VirtioSocket": { - "description": "A socket device that presents a virtio-socket interface to the guest.", - "type": "object", - "properties": { - "guest_cid": { - "description": "The guest's Context ID.", - "type": "integer", - "format": "uint64", - "minimum": 0 - }, - "pci_path": { - "description": "The PCI path at which to attach this device.", - "allOf": [ - { - "$ref": "#/components/schemas/PciPath" - } - ] - } - }, - "required": [ - "guest_cid", - "pci_path" - ], - "additionalProperties": false - }, - "VolumeInfo": { - "description": "A tree representation of the info and status of all parts of a Volume.", - "oneOf": [ - { - "type": "object", - "properties": { - "volume": { - "type": "object", - "properties": { - "read_only_parent": { - "nullable": true, - "allOf": [ - { - "$ref": "#/components/schemas/VolumeInfo" - } - ] - }, - "sub_volumes": { - "type": "array", - "items": { - "$ref": "#/components/schemas/VolumeInfo" - } - } - }, - "required": [ - "sub_volumes" - ] - } - }, - "required": [ - "volume" - ], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "upstairs": { - "type": "object", - "properties": { - "block_size": { - "nullable": true, - "type": "integer", - "format": "uint64", - "minimum": 0 - }, - "encrypted": { - "type": "boolean" - }, - "generation": { - "type": "integer", - "format": "uint64", - "minimum": 0 - }, - "live_repair_in_progress": { - "type": "boolean" - }, - "read_only": { - "type": "boolean" - }, - "reconcile_in_progress": { - "type": "boolean" - }, - "session_id": { - "type": "string", - "format": "uuid" - }, - "state": { - "$ref": "#/components/schemas/UpstairsInfoStatus" - }, - "targets": { - "type": "array", - "items": { - "$ref": "#/components/schemas/DownstairsInfo" - } - }, - "upstairs_id": { - "type": "string", - "format": "uuid" - } - }, - "required": [ - "encrypted", - "generation", - "live_repair_in_progress", - "read_only", - "reconcile_in_progress", - "session_id", - "state", - "targets", - "upstairs_id" - ] - } - }, - "required": [ - "upstairs" - ], - "additionalProperties": false - } - ] - }, - "VolumeStatus": { - "type": "object", - "properties": { - "volume_info": { - "$ref": "#/components/schemas/VolumeInfo" - } - }, - "required": [ - "volume_info" - ] - } - }, - "responses": { - "Error": { - "description": "Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - } - } - } -} diff --git a/openapi/propolis-server/propolis-server-5.0.0-0c6dd9.json.gitstub b/openapi/propolis-server/propolis-server-5.0.0-0c6dd9.json.gitstub new file mode 100644 index 000000000..2c4d4cfc8 --- /dev/null +++ b/openapi/propolis-server/propolis-server-5.0.0-0c6dd9.json.gitstub @@ -0,0 +1 @@ +9343361f922d10c5825906a69c7bb05d139a3662:openapi/propolis-server/propolis-server-5.0.0-0c6dd9.json From 62a076b372250dad6e4145ea744b151d5f8205be Mon Sep 17 00:00:00 2001 From: iximeow Date: Wed, 22 Jul 2026 02:59:22 +0000 Subject: [PATCH 14/24] there really isnt much to carry for v2 instance spec conversion --- bin/propolis-server/src/lib/migrate/types.rs | 15 +++- .../src/lib/spec/api_spec_v1.rs | 4 +- .../src/lib/spec/api_spec_v2.rs | 86 +++++++++++++++++++ bin/propolis-server/src/lib/spec/mod.rs | 1 + 4 files changed, 101 insertions(+), 5 deletions(-) create mode 100644 bin/propolis-server/src/lib/spec/api_spec_v2.rs diff --git a/bin/propolis-server/src/lib/migrate/types.rs b/bin/propolis-server/src/lib/migrate/types.rs index b5906c68a..e46ca801d 100644 --- a/bin/propolis-server/src/lib/migrate/types.rs +++ b/bin/propolis-server/src/lib/migrate/types.rs @@ -86,8 +86,8 @@ use std::collections::BTreeMap; use crate::migrate::MigrateError; use crate::spec::{ - api_spec_v1, api_spec_v1::ApiSpecError as V1SpecError, api_spec_v3, - api_spec_v3::ApiSpecError as V3SpecError, api_spec_v6, + api_spec_v1, api_spec_v1::ApiSpecError as V1SpecError, api_spec_v2, + api_spec_v3, api_spec_v3::ApiSpecError as V3SpecError, api_spec_v6, api_spec_v6::ApiSpecError as V6SpecError, Spec, }; @@ -159,8 +159,15 @@ impl VersionedInstanceSpec { amended_spec } - VersionedInstanceSpec::V2(_source_spec) => { - panic!("should v2 really be here?"); + VersionedInstanceSpec::V2(mut source_spec) => { + api_spec_v2::amend(&mut source_spec, replacements)?; + + let amended_spec: Spec = + source_spec.try_into().map_err(|e: V1SpecError| { + MigrateError::PreambleParse(e.to_string()) + })?; + + amended_spec } VersionedInstanceSpec::V3(mut source_spec) => { api_spec_v3::amend(&mut source_spec, replacements)?; diff --git a/bin/propolis-server/src/lib/spec/api_spec_v1.rs b/bin/propolis-server/src/lib/spec/api_spec_v1.rs index 2dd7ed8ef..6cb1f528f 100644 --- a/bin/propolis-server/src/lib/spec/api_spec_v1.rs +++ b/bin/propolis-server/src/lib/spec/api_spec_v1.rs @@ -325,7 +325,9 @@ pub(crate) fn v1_to_spec_builder( crate::spec::api_spec_v3::v3_to_spec_builder(v3_spec).map_err(|e| e.into()) } -fn amend_component( +// `amend_component` is suitable for (and used in) amending a v2 InstanceSpec, +// so this one is pub(crate) unlike other `api_spec_v*`. +pub(crate) fn amend_component( id: &SpecKey, to_amend: &mut v1::instance_spec::Component, replacement: &ReplacementComponent, diff --git a/bin/propolis-server/src/lib/spec/api_spec_v2.rs b/bin/propolis-server/src/lib/spec/api_spec_v2.rs new file mode 100644 index 000000000..4108c6645 --- /dev/null +++ b/bin/propolis-server/src/lib/spec/api_spec_v2.rs @@ -0,0 +1,86 @@ +// 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 from the initial API version ([`propolis_api_types::v1`], aka +//! "V0" in some parts of propolis-server) instance specs in the +//! [`propolis_api_types`] crate to the internal [`super::Spec`] representation. + +use std::collections::BTreeMap; + +use propolis_api_types::instance_spec::SpecKey; +use propolis_api_types_versions::{ + v1, v1::instance::ReplacementComponent, v2, v3, +}; + +#[cfg(feature = "falcon")] +use propolis_api_types::instance_spec::components::devices::SoftNpuPort as SoftNpuPortSpec; + +use super::{builder::SpecBuilder, Spec}; +use crate::migrate::MigrateError; + +#[cfg(feature = "failure-injection")] +use super::MigrationFailure; + +// v2 does not introduce new opportunities for Spec->InstanceSpec conversion +// to fail, so we can reuse the v1 error type directly. +use super::api_spec_v1::ApiSpecError; + +impl TryFrom for v2::instance_spec::InstanceSpec { + type Error = ApiSpecError; + + fn try_from(mut val: Spec) -> Result { + // A V2 InstanceSpec is just a V1 InstanceSpec with an optional `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(); + + let v1::instance_spec::InstanceSpec { board, components } = + val.try_into()?; + + Ok(v2::instance_spec::InstanceSpec { + board, + smbios: smbios, + components, + }) + } +} + +impl TryFrom for Spec { + type Error = ApiSpecError; + + fn try_from( + value: v2::instance_spec::InstanceSpec, + ) -> Result { + Ok(v2_to_spec_builder(value)?.finish()) + } +} + +/// Parses a v1 instance spec into a [`SpecBuilder`], validating component +/// names, PCI paths, and backend references along the way. Callers can add +/// additional (non-v1) components to the builder before calling `finish()`. +pub(crate) fn v2_to_spec_builder( + value: v2::instance_spec::InstanceSpec, +) -> Result { + let v3_spec: v3::instance_spec::InstanceSpec = value.into(); + + crate::spec::api_spec_v3::v3_to_spec_builder(v3_spec).map_err(|e| e.into()) +} + +pub(crate) fn amend( + spec: &mut v2::instance_spec::InstanceSpec, + replacements: &BTreeMap, +) -> Result<(), MigrateError> { + for (id, replacement) in replacements { + let Some(to_amend) = spec.components.get_mut(id) else { + return Err(MigrateError::InstanceSpecsIncompatible(format!( + "replacement component {id} not in source spec", + ))); + }; + + super::api_spec_v1::amend_component(id, to_amend, replacement)?; + } + + Ok(()) +} diff --git a/bin/propolis-server/src/lib/spec/mod.rs b/bin/propolis-server/src/lib/spec/mod.rs index 69dcc4dba..18cd39ca3 100644 --- a/bin/propolis-server/src/lib/spec/mod.rs +++ b/bin/propolis-server/src/lib/spec/mod.rs @@ -54,6 +54,7 @@ use propolis_api_types::instance_spec::components::{ }; 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 builder; From a673f1f1f01d361cfee83750f42192ffefe521b3 Mon Sep 17 00:00:00 2001 From: iximeow Date: Wed, 22 Jul 2026 10:20:32 +0000 Subject: [PATCH 15/24] those todo's shouldn't have made it out of draft.. --- .../src/lib/spec/api_spec_v1.rs | 8 +++-- .../src/lib/spec/api_spec_v3.rs | 8 +++-- bin/propolis-server/src/lib/vm/mod.rs | 6 ++-- bin/propolis-standalone/src/main.rs | 5 ++-- .../src/add_vsock/instance_spec.rs | 29 ++++++++++--------- crates/propolis-server-api/src/lib.rs | 16 +++++++--- 6 files changed, 45 insertions(+), 27 deletions(-) diff --git a/bin/propolis-server/src/lib/spec/api_spec_v1.rs b/bin/propolis-server/src/lib/spec/api_spec_v1.rs index 6cb1f528f..1dd58c30c 100644 --- a/bin/propolis-server/src/lib/spec/api_spec_v1.rs +++ b/bin/propolis-server/src/lib/spec/api_spec_v1.rs @@ -174,8 +174,12 @@ impl TryFrom for v1::instance_spec::InstanceSpec { for (disk_id, disk) in disks { let backend_id = disk.device_spec.backend_id().to_owned(); - let device_component: v1::instance_spec::Component = disk.device_spec.try_into() - .map_err(|e: propolis_api_types_versions::v6::instance_spec::InvalidV3Component| ApiSpecError::IncompatibleComponent(e.to_string()))?; + let device_component: v1::instance_spec::Component = disk + .device_spec + .try_into() + .map_err(|e: v6::instance_spec::InvalidV3Component| { + ApiSpecError::IncompatibleComponent(e.to_string()) + })?; let backend_component: v1::instance_spec::Component = disk.backend_spec.into(); insert_component(&mut spec, disk_id, device_component); diff --git a/bin/propolis-server/src/lib/spec/api_spec_v3.rs b/bin/propolis-server/src/lib/spec/api_spec_v3.rs index ac76d16b1..8b3163262 100644 --- a/bin/propolis-server/src/lib/spec/api_spec_v3.rs +++ b/bin/propolis-server/src/lib/spec/api_spec_v3.rs @@ -164,8 +164,12 @@ impl TryFrom for v3::instance_spec::InstanceSpec { for (disk_id, disk) in disks { let backend_id = disk.device_spec.backend_id().to_owned(); - let device_component: v3::instance_spec::Component = disk.device_spec.try_into() - .map_err(|e: propolis_api_types_versions::v6::instance_spec::InvalidV3Component| ApiSpecError::IncompatibleComponent(e.to_string()))?; + let device_component: v3::instance_spec::Component = disk + .device_spec + .try_into() + .map_err(|e: v6::instance_spec::InvalidV3Component| { + ApiSpecError::IncompatibleComponent(e.to_string()) + })?; let backend_component: v3::instance_spec::Component = disk.backend_spec.into(); insert_component(&mut spec, disk_id, device_component); diff --git a/bin/propolis-server/src/lib/vm/mod.rs b/bin/propolis-server/src/lib/vm/mod.rs index fee18e5cb..9f3c98e7f 100644 --- a/bin/propolis-server/src/lib/vm/mod.rs +++ b/bin/propolis-server/src/lib/vm/mod.rs @@ -89,7 +89,7 @@ use propolis_api_types::instance::{ InstanceStateMonitorResponse, }; use propolis_api_types::instance_spec::{ - InstanceSpecGetResponse, InstanceSpecStatus, SpecKey, + InstanceSpec, InstanceSpecGetResponse, InstanceSpecStatus, SpecKey, }; use propolis_api_types::migration::{ InstanceMigrateStatusResponse, InstanceMigrationStatus, MigrationState, @@ -355,7 +355,7 @@ impl Vm { let spec = vm.objects().lock_shared().await.instance_spec().clone(); let state = vm.external_state_rx.borrow().clone(); - let external_spec: propolis_api_types_versions::latest::instance_spec::InstanceSpec = spec.into(); + let external_spec: InstanceSpec = spec.into(); Some(InstanceSpecGetResponse { properties: vm.properties.clone(), spec: InstanceSpecStatus::Present(external_spec), @@ -371,7 +371,7 @@ impl Vm { }) } VmState::Rundown { vm, spec } => { - let external_spec: propolis_api_types_versions::latest::instance_spec::InstanceSpec = (*spec.to_owned()).into(); + let external_spec: InstanceSpec = (*spec.to_owned()).into(); Some(InstanceSpecGetResponse { properties: vm.properties.clone(), state: vm.external_state_rx.borrow().state, diff --git a/bin/propolis-standalone/src/main.rs b/bin/propolis-standalone/src/main.rs index a9c0c1f04..8ce40f064 100644 --- a/bin/propolis-standalone/src/main.rs +++ b/bin/propolis-standalone/src/main.rs @@ -1358,9 +1358,8 @@ fn setup_instance( let has_write_cache = dev .options .get("has_write_cache") - .unwrap() - .as_bool() - .unwrap(); + .map(|v| v.as_bool().unwrap()) + .unwrap_or(false); // Limit data transfers to 1MiB (2^8 * 4k) in size let mdts = Some(8); diff --git a/crates/propolis-api-types-versions/src/add_vsock/instance_spec.rs b/crates/propolis-api-types-versions/src/add_vsock/instance_spec.rs index 02c6d10f3..5072232bf 100644 --- a/crates/propolis-api-types-versions/src/add_vsock/instance_spec.rs +++ b/crates/propolis-api-types-versions/src/add_vsock/instance_spec.rs @@ -116,12 +116,11 @@ impl TryFrom for v2::instance_spec::InstanceSpec { type Error = InvalidV1Component; fn try_from(new: InstanceSpec) -> Result { - let components: Result, _> = new + let components: BTreeMap<_, _> = new .components .into_iter() .map(|(k, v)| V1Component::try_from(v).map(|c| (k, c))) - .collect::, _>>(); - let components = components.expect("TODO: hueagghggh"); + .collect::, _>>()?; Ok(Self { board: new.board, components, smbios: new.smbios }) } @@ -163,30 +162,34 @@ impl From for Component { } } -impl From for v2::instance_spec::InstanceSpecStatus { - fn from(new: InstanceSpecStatus) -> Self { +impl TryFrom for v2::instance_spec::InstanceSpecStatus { + type Error = InvalidV1Component; + + fn try_from(new: InstanceSpecStatus) -> Result { match new { InstanceSpecStatus::WaitingForMigrationSource => { - Self::WaitingForMigrationSource + Ok(Self::WaitingForMigrationSource) } InstanceSpecStatus::Present(spec) => { let v2_spec: v2::instance_spec::InstanceSpec = - spec.try_into().expect("TODO: v3 instance spec into v2"); - Self::Present(v2_spec) + spec.try_into()?; + Ok(Self::Present(v2_spec)) } } } } -impl From +impl TryFrom for v2::instance_spec::InstanceSpecGetResponse { - fn from(new: InstanceSpecGetResponse) -> Self { - Self { + type Error = InvalidV1Component; + + fn try_from(new: InstanceSpecGetResponse) -> Result { + Ok(Self { properties: new.properties, state: new.state, - spec: new.spec.into(), - } + spec: new.spec.try_into()?, + }) } } diff --git a/crates/propolis-server-api/src/lib.rs b/crates/propolis-server-api/src/lib.rs index dbc1e1ac7..298d0d33e 100644 --- a/crates/propolis-server-api/src/lib.rs +++ b/crates/propolis-server-api/src/lib.rs @@ -3,7 +3,7 @@ // file, You can obtain one at https://mozilla.org/MPL/2.0/. use dropshot::{ - HttpError, HttpResponseCreated, HttpResponseOk, + ClientErrorStatusCode, HttpError, HttpResponseCreated, HttpResponseOk, HttpResponseUpdatedNoContent, Path, Query, RequestContext, TypedBody, WebsocketChannelResult, WebsocketConnection, }; @@ -160,9 +160,17 @@ pub trait PropolisServerApi { HttpResponseOk, HttpError, > { - Ok(Self::instance_spec_get_v3(rqctx) - .await? - .map(v2::instance_spec::InstanceSpecGetResponse::from)) + let v3_response = Self::instance_spec_get_v3(rqctx).await?.0; + let v2_response: v2::instance_spec::InstanceSpecGetResponse = + v3_response.try_into().map_err(|_e| { + HttpError::for_client_error( + None, + ClientErrorStatusCode::BAD_REQUEST, + "instance spec cannot be expressed to v2 clients" + .to_owned(), + ) + })?; + Ok(HttpResponseOk(v2_response)) } #[endpoint { From b1300a6c0f743d0dadcf0c0c247f1f0b4d3d5c8c Mon Sep 17 00:00:00 2001 From: iximeow Date: Wed, 22 Jul 2026 22:51:50 +0000 Subject: [PATCH 16/24] build on previous conversions for Spec->InstanceSpec too --- bin/propolis-server/src/lib/migrate/types.rs | 7 +- .../src/lib/spec/api_spec_v2.rs | 9 +- .../src/lib/spec/api_spec_v3.rs | 303 ++---------------- .../src/lib/spec/api_spec_v6.rs | 221 +++---------- 4 files changed, 68 insertions(+), 472 deletions(-) diff --git a/bin/propolis-server/src/lib/migrate/types.rs b/bin/propolis-server/src/lib/migrate/types.rs index e46ca801d..be4f1b81e 100644 --- a/bin/propolis-server/src/lib/migrate/types.rs +++ b/bin/propolis-server/src/lib/migrate/types.rs @@ -87,8 +87,7 @@ use std::collections::BTreeMap; use crate::migrate::MigrateError; use crate::spec::{ api_spec_v1, api_spec_v1::ApiSpecError as V1SpecError, api_spec_v2, - api_spec_v3, api_spec_v3::ApiSpecError as V3SpecError, api_spec_v6, - api_spec_v6::ApiSpecError as V6SpecError, Spec, + api_spec_v3, api_spec_v6, api_spec_v6::ApiSpecError as V6SpecError, Spec, }; /// A wrapper for one of any supported `InstanceSpec` that describe a @@ -176,8 +175,8 @@ impl VersionedInstanceSpec { source_spec.into(); let amended_spec: Spec = v6_spec.try_into().map_err(|e: V6SpecError| { - let v3_error: V3SpecError = e.into(); - MigrateError::PreambleParse(v3_error.to_string()) + let v1_error: V1SpecError = e.into(); + MigrateError::PreambleParse(v1_error.to_string()) })?; amended_spec 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 4108c6645..7b568b966 100644 --- a/bin/propolis-server/src/lib/spec/api_spec_v2.rs +++ b/bin/propolis-server/src/lib/spec/api_spec_v2.rs @@ -13,9 +13,6 @@ use propolis_api_types_versions::{ v1, v1::instance::ReplacementComponent, v2, v3, }; -#[cfg(feature = "falcon")] -use propolis_api_types::instance_spec::components::devices::SoftNpuPort as SoftNpuPortSpec; - use super::{builder::SpecBuilder, Spec}; use crate::migrate::MigrateError; @@ -39,11 +36,7 @@ impl TryFrom for v2::instance_spec::InstanceSpec { let v1::instance_spec::InstanceSpec { board, components } = val.try_into()?; - Ok(v2::instance_spec::InstanceSpec { - board, - smbios: smbios, - components, - }) + Ok(v2::instance_spec::InstanceSpec { board, smbios, components }) } } diff --git a/bin/propolis-server/src/lib/spec/api_spec_v3.rs b/bin/propolis-server/src/lib/spec/api_spec_v3.rs index 8b3163262..2e1c403ae 100644 --- a/bin/propolis-server/src/lib/spec/api_spec_v3.rs +++ b/bin/propolis-server/src/lib/spec/api_spec_v3.rs @@ -7,301 +7,46 @@ use std::collections::BTreeMap; -use propolis_api_types::instance_spec::{ - components::{ - board::Board as InstanceSpecBoard, - devices::{BootSettings, SerialPort as SerialPortDesc}, - }, - SpecKey, +use propolis_api_types::instance_spec::SpecKey; +use propolis_api_types_versions::{ + v1::instance::ReplacementComponent, v2, v3, v6, }; -use propolis_api_types_versions::{v1::instance::ReplacementComponent, v3, v6}; -use thiserror::Error; -#[cfg(feature = "falcon")] -use propolis_api_types::instance_spec::components::devices::SoftNpuPort as SoftNpuPortSpec; - -use super::{ - api_spec_v6, - builder::{SpecBuilder, SpecBuilderError}, - SerialPortDevice, Spec, StorageBackend, StorageDevice, -}; +use super::{api_spec_v6, builder::SpecBuilder, Spec}; use crate::migrate::MigrateError; -#[cfg(feature = "failure-injection")] -use super::MigrationFailure; - -#[derive(Debug, Error)] -pub(crate) enum ApiSpecError { - #[error(transparent)] - Builder(#[from] SpecBuilderError), - - #[error("storage backend {backend} not found for device {device}")] - StorageBackendNotFound { backend: SpecKey, device: SpecKey }, - - #[error("network backend {backend} not found for device {device}")] - NetworkBackendNotFound { backend: SpecKey, device: SpecKey }, - - #[allow(dead_code)] - #[error("support for component {component} compiled out via {feature}")] - FeatureCompiledOut { component: SpecKey, feature: &'static str }, - - #[error("backend {0} not used by any device")] - BackendNotUsed(SpecKey), - - #[error("spec contains v3-incompatible component: {0}")] - IncompatibleComponent(String), -} - -use crate::spec::api_spec_v1; -impl From for api_spec_v1::ApiSpecError { - fn from(value: ApiSpecError) -> Self { - match value { - ApiSpecError::Builder(b) => api_spec_v1::ApiSpecError::Builder(b), - ApiSpecError::StorageBackendNotFound { backend, device } => { - api_spec_v1::ApiSpecError::StorageBackendNotFound { - backend, - device, - } - } - ApiSpecError::NetworkBackendNotFound { backend, device } => { - api_spec_v1::ApiSpecError::NetworkBackendNotFound { - backend, - device, - } - } - ApiSpecError::FeatureCompiledOut { component, feature } => { - api_spec_v1::ApiSpecError::FeatureCompiledOut { - component, - feature, - } - } - ApiSpecError::BackendNotUsed(key) => { - api_spec_v1::ApiSpecError::BackendNotUsed(key) - } - ApiSpecError::IncompatibleComponent(key) => { - api_spec_v1::ApiSpecError::IncompatibleComponent(key) - } - } - } -} - -impl TryFrom for v3::instance_spec::Component { - type Error = v6::instance_spec::InvalidV3Component; - - fn try_from(value: StorageDevice) -> Result { - match value { - StorageDevice::Virtio(d) => Ok(Self::VirtioDisk(d)), - StorageDevice::Nvme(d) => Ok(Self::NvmeDisk(d.try_into()?)), - } - } -} - -impl From for v3::instance_spec::Component { - fn from(value: StorageBackend) -> Self { - match value { - StorageBackend::Crucible(be) => Self::CrucibleStorageBackend(be), - StorageBackend::File(be) => Self::FileStorageBackend(be), - StorageBackend::Blob(be) => Self::BlobStorageBackend(be), - } - } -} +// once again, v3 Spec<->InstanceSpec conversion failures are unchanged from +// previous, so reuse the error type. +use super::api_spec_v1::ApiSpecError; impl TryFrom for v3::instance_spec::InstanceSpec { type Error = ApiSpecError; - fn try_from(val: Spec) -> Result { - // Exhaustively destructure the input spec so that adding a new field - // without considering it here will break the build. - let Spec { - board, - cpuid, - disks, - nics, - boot_settings, - serial, - pci_pci_bridges, - pvpanic, - smbios_type1_input, - vsock, - #[cfg(feature = "failure-injection")] - migration_failure, - #[cfg(feature = "falcon")] - softnpu, - } = val; + fn try_from(mut val: Spec) -> Result { + // v3 added only the `vsock` component, which is expressed only as the + // `vsock` field on `Spec` here. Either we can remove it and this is a + // Spec that can be interpreted as v2, or this spec is not valid as + // either. + let vsock = val.vsock.take(); - // Inserts a component entry into the supplied map, asserting first that - // the supplied key is not present in that map. - // - // This assertion is valid because internal instance specs should assign - // a unique name to each component they describe. The spec builder - // upholds this invariant at spec creation time. - #[track_caller] - fn insert_component( - spec: &mut v3::instance_spec::InstanceSpec, - key: SpecKey, - val: v3::instance_spec::Component, - ) { - assert!( - !spec.components.contains_key(&key), - "component name {} already exists in output spec", - &key - ); - spec.components.insert(key, val); - } - - let board = InstanceSpecBoard { - cpus: board.cpus, - memory_mb: board.memory_mb, - chipset: board.chipset, - guest_hv_interface: board.guest_hv_interface, - cpuid: Some(cpuid.into_instance_spec_cpuid()), - }; - let mut spec = v3::instance_spec::InstanceSpec { - board, - smbios: smbios_type1_input, - components: Default::default(), - }; - - for (disk_id, disk) in disks { - let backend_id = disk.device_spec.backend_id().to_owned(); - let device_component: v3::instance_spec::Component = disk - .device_spec - .try_into() - .map_err(|e: v6::instance_spec::InvalidV3Component| { - ApiSpecError::IncompatibleComponent(e.to_string()) - })?; - let backend_component: v3::instance_spec::Component = - disk.backend_spec.into(); - insert_component(&mut spec, disk_id, device_component); - insert_component(&mut spec, backend_id, backend_component); - } - - for (nic_id, nic) in nics { - let backend_id = nic.device_spec.backend_id.clone(); - insert_component( - &mut spec, - nic_id, - v3::instance_spec::Component::VirtioNic(nic.device_spec), - ); - - insert_component( - &mut spec, - backend_id, - v3::instance_spec::Component::VirtioNetworkBackend( - nic.backend_spec, - ), - ); - } - - for (name, desc) in serial { - if desc.device == SerialPortDevice::Uart { - insert_component( - &mut spec, - name, - v3::instance_spec::Component::SerialPort(SerialPortDesc { - num: desc.num, - }), - ); - } - } - - for (bridge_name, bridge) in pci_pci_bridges { - insert_component( - &mut spec, - bridge_name, - v3::instance_spec::Component::PciPciBridge(bridge), - ); - } - - if let Some(pvpanic) = pvpanic { - insert_component( - &mut spec, - pvpanic.id, - v3::instance_spec::Component::QemuPvpanic(pvpanic.spec), - ); - } + let v2_instance_spec: v2::instance_spec::InstanceSpec = + val.try_into()?; + let mut instance_spec: v3::instance_spec::InstanceSpec = + v2_instance_spec.into(); if let Some(vsock) = vsock { - insert_component( - &mut spec, - vsock.id, + let existing = instance_spec.components.insert( + vsock.id.clone(), v3::instance_spec::Component::VirtioSocket(vsock.spec), ); - } - - if let Some(settings) = boot_settings { - insert_component( - &mut spec, - settings.name, - v3::instance_spec::Component::BootSettings(BootSettings { - order: settings.order.into_iter().map(Into::into).collect(), - }), - ); - } - - #[cfg(feature = "failure-injection")] - if let Some(mig) = migration_failure { - insert_component( - &mut spec, - mig.id, - v3::instance_spec::Component::MigrationFailureInjector( - mig.spec, - ), + assert!( + existing.is_none(), + "there was already a component named {} in the spec?!", + vsock.id ); } - #[cfg(feature = "falcon")] - { - if let Some(softnpu_pci) = softnpu.pci_port { - insert_component( - &mut spec, - SpecKey::Name(format!( - "softnpu-pci-{}", - softnpu_pci.pci_path - )), - v3::instance_spec::Component::SoftNpuPciPort(softnpu_pci), - ); - } - - if let Some(p9) = softnpu.p9_device { - insert_component( - &mut spec, - SpecKey::Name(format!("softnpu-p9-{}", p9.pci_path)), - v3::instance_spec::Component::SoftNpuP9(p9), - ); - } - - if let Some(p9fs) = softnpu.p9fs { - insert_component( - &mut spec, - SpecKey::Name(format!("p9fs-{}", p9fs.pci_path)), - v3::instance_spec::Component::P9fs(p9fs), - ); - } - - for (port_name, port) in softnpu.ports { - insert_component( - &mut spec, - port_name.clone(), - v3::instance_spec::Component::SoftNpuPort( - SoftNpuPortSpec { - link_name: port.link_name, - backend_id: port.backend_name.clone(), - }, - ), - ); - - insert_component( - &mut spec, - port.backend_name, - v3::instance_spec::Component::DlpiNetworkBackend( - port.backend_spec, - ), - ); - } - } - - Ok(spec) + Ok(instance_spec) } } 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 809549fc3..b11ef579b 100644 --- a/bin/propolis-server/src/lib/spec/api_spec_v6.rs +++ b/bin/propolis-server/src/lib/spec/api_spec_v6.rs @@ -8,23 +8,15 @@ use std::collections::BTreeMap; use propolis_api_types::instance_spec::{ - components::{ - backends::{DlpiNetworkBackend, VirtioNetworkBackend}, - board::Board as InstanceSpecBoard, - devices::{BootSettings, SerialPort as SerialPortDesc}, - }, + components::backends::{DlpiNetworkBackend, VirtioNetworkBackend}, SpecKey, }; -use propolis_api_types_versions::{v1::instance::ReplacementComponent, v6}; +use propolis_api_types_versions::{v1::instance::ReplacementComponent, v3, v6}; use thiserror::Error; -#[cfg(feature = "falcon")] -use propolis_api_types::instance_spec::components::devices::SoftNpuPort as SoftNpuPortSpec; - use super::{ builder::{SpecBuilder, SpecBuilderError}, - Disk, Nic, QemuPvpanic, SerialPortDevice, Spec, StorageBackend, - StorageDevice, + Disk, Nic, QemuPvpanic, Spec, StorageBackend, StorageDevice, }; use crate::migrate::MigrateError; @@ -54,25 +46,42 @@ pub(crate) enum ApiSpecError { } impl From for v6::instance_spec::InstanceSpec { - fn from(val: Spec) -> Self { - // Exhaustively destructure the input spec so that adding a new field - // without considering it here will break the build. - let Spec { - board, - cpuid, - disks, - nics, - boot_settings, - serial, - pci_pci_bridges, - pvpanic, - smbios_type1_input, - vsock, - #[cfg(feature = "failure-injection")] - migration_failure, - #[cfg(feature = "falcon")] - softnpu, - } = val; + fn from(mut val: Spec) -> Self { + // 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. + // + // TODO: could be extract_if once we're on a Rust >= 1.91.0. + let mut nvme_disks = Vec::new(); + for (key, disk) in val.disks.iter() { + let should_remove = match disk.device_spec { + StorageDevice::Nvme(_) => true, + _ => false, + }; + if should_remove { + nvme_disks.push((key.clone(), disk.clone())); + } + } + val.disks.retain(|_, disk| match disk.device_spec { + StorageDevice::Nvme(_) => false, + _ => true, + }); + + 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 mut spec: v6::instance_spec::InstanceSpec = v3_spec.into(); // Inserts a component entry into the supplied map, asserting first that // the supplied key is not present in that map. @@ -94,20 +103,7 @@ impl From for v6::instance_spec::InstanceSpec { spec.components.insert(key, val); } - let board = InstanceSpecBoard { - cpus: board.cpus, - memory_mb: board.memory_mb, - chipset: board.chipset, - guest_hv_interface: board.guest_hv_interface, - cpuid: Some(cpuid.into_instance_spec_cpuid()), - }; - let mut spec = v6::instance_spec::InstanceSpec { - board, - smbios: smbios_type1_input, - components: Default::default(), - }; - - for (disk_id, disk) in disks { + for (disk_id, disk) in nvme_disks { let backend_id = disk.device_spec.backend_id().to_owned(); let device_component: v6::instance_spec::Component = disk.device_spec.into(); @@ -117,147 +113,10 @@ impl From for v6::instance_spec::InstanceSpec { insert_component(&mut spec, backend_id, backend_component); } - for (nic_id, nic) in nics { - let backend_id = nic.device_spec.backend_id.clone(); - insert_component( - &mut spec, - nic_id, - v6::instance_spec::Component::VirtioNic(nic.device_spec), - ); - - insert_component( - &mut spec, - backend_id, - v6::instance_spec::Component::VirtioNetworkBackend( - nic.backend_spec, - ), - ); - } - - for (name, desc) in serial { - if desc.device == SerialPortDevice::Uart { - insert_component( - &mut spec, - name, - v6::instance_spec::Component::SerialPort(SerialPortDesc { - num: desc.num, - }), - ); - } - } - - for (bridge_name, bridge) in pci_pci_bridges { - insert_component( - &mut spec, - bridge_name, - v6::instance_spec::Component::PciPciBridge(bridge), - ); - } - - if let Some(pvpanic) = pvpanic { - insert_component( - &mut spec, - pvpanic.id, - v6::instance_spec::Component::QemuPvpanic(pvpanic.spec), - ); - } - - if let Some(vsock) = vsock { - insert_component( - &mut spec, - vsock.id, - v6::instance_spec::Component::VirtioSocket(vsock.spec), - ); - } - - if let Some(settings) = boot_settings { - insert_component( - &mut spec, - settings.name, - v6::instance_spec::Component::BootSettings(BootSettings { - order: settings.order.into_iter().map(Into::into).collect(), - }), - ); - } - - #[cfg(feature = "failure-injection")] - if let Some(mig) = migration_failure { - insert_component( - &mut spec, - mig.id, - v6::instance_spec::Component::MigrationFailureInjector( - mig.spec, - ), - ); - } - - #[cfg(feature = "falcon")] - { - if let Some(softnpu_pci) = softnpu.pci_port { - insert_component( - &mut spec, - SpecKey::Name(format!( - "softnpu-pci-{}", - softnpu_pci.pci_path - )), - v6::instance_spec::Component::SoftNpuPciPort(softnpu_pci), - ); - } - - if let Some(p9) = softnpu.p9_device { - insert_component( - &mut spec, - SpecKey::Name(format!("softnpu-p9-{}", p9.pci_path)), - v6::instance_spec::Component::SoftNpuP9(p9), - ); - } - - if let Some(p9fs) = softnpu.p9fs { - insert_component( - &mut spec, - SpecKey::Name(format!("p9fs-{}", p9fs.pci_path)), - v6::instance_spec::Component::P9fs(p9fs), - ); - } - - for (port_name, port) in softnpu.ports { - insert_component( - &mut spec, - port_name.clone(), - v6::instance_spec::Component::SoftNpuPort( - SoftNpuPortSpec { - link_name: port.link_name, - backend_id: port.backend_name.clone(), - }, - ), - ); - - insert_component( - &mut spec, - port.backend_name, - v6::instance_spec::Component::DlpiNetworkBackend( - port.backend_spec, - ), - ); - } - } - spec } } -/* -impl TryFrom for Spec { - type Error = ApiSpecError; - - fn try_from( - value: v6::instance_spec::InstanceSpec, - ) -> Result { - Ok(v6_to_spec_builder(value)?.finish()) - } -} -*/ - /// Parses a v6 instance spec into a [`SpecBuilder`], validating component /// names, PCI paths, and backend references along the way. Callers can add /// additional (non-v6) components to the builder before calling `finish()`. From 8fa58d73b9dab136acdb3788e72b0473f3fd2563 Mon Sep 17 00:00:00 2001 From: iximeow Date: Wed, 22 Jul 2026 23:05:48 +0000 Subject: [PATCH 17/24] ok clippy --- bin/propolis-server/src/lib/spec/api_spec_v1.rs | 2 +- bin/propolis-server/src/lib/spec/api_spec_v2.rs | 2 +- bin/propolis-server/src/lib/spec/api_spec_v6.rs | 13 ++++--------- 3 files changed, 6 insertions(+), 11 deletions(-) diff --git a/bin/propolis-server/src/lib/spec/api_spec_v1.rs b/bin/propolis-server/src/lib/spec/api_spec_v1.rs index 1dd58c30c..c672d9f58 100644 --- a/bin/propolis-server/src/lib/spec/api_spec_v1.rs +++ b/bin/propolis-server/src/lib/spec/api_spec_v1.rs @@ -326,7 +326,7 @@ pub(crate) fn v1_to_spec_builder( let v2_spec: v2::instance_spec::InstanceSpec = value.into(); let v3_spec: v3::instance_spec::InstanceSpec = v2_spec.into(); - crate::spec::api_spec_v3::v3_to_spec_builder(v3_spec).map_err(|e| e.into()) + crate::spec::api_spec_v3::v3_to_spec_builder(v3_spec) } // `amend_component` is suitable for (and used in) amending a v2 InstanceSpec, 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 7b568b966..341c2fe15 100644 --- a/bin/propolis-server/src/lib/spec/api_spec_v2.rs +++ b/bin/propolis-server/src/lib/spec/api_spec_v2.rs @@ -58,7 +58,7 @@ pub(crate) fn v2_to_spec_builder( ) -> Result { let v3_spec: v3::instance_spec::InstanceSpec = value.into(); - crate::spec::api_spec_v3::v3_to_spec_builder(v3_spec).map_err(|e| e.into()) + crate::spec::api_spec_v3::v3_to_spec_builder(v3_spec) } pub(crate) fn amend( 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 b11ef579b..0b122ae5c 100644 --- a/bin/propolis-server/src/lib/spec/api_spec_v6.rs +++ b/bin/propolis-server/src/lib/spec/api_spec_v6.rs @@ -57,19 +57,14 @@ impl From for v6::instance_spec::InstanceSpec { // // TODO: could be extract_if once we're on a Rust >= 1.91.0. let mut nvme_disks = Vec::new(); + let v6_only_disk = + |disk: &Disk| matches!(disk.device_spec, StorageDevice::Nvme(_)); for (key, disk) in val.disks.iter() { - let should_remove = match disk.device_spec { - StorageDevice::Nvme(_) => true, - _ => false, - }; - if should_remove { + if v6_only_disk(disk) { nvme_disks.push((key.clone(), disk.clone())); } } - val.disks.retain(|_, disk| match disk.device_spec { - StorageDevice::Nvme(_) => false, - _ => true, - }); + val.disks.retain(|_, disk| v6_only_disk(disk)); let v3_spec: v3::instance_spec::InstanceSpec = val.try_into().unwrap_or_else(|e| { From 286946848137016807c01cbb4da529b16194b4c5 Mon Sep 17 00:00:00 2001 From: iximeow Date: Sat, 25 Jul 2026 01:55:13 +0000 Subject: [PATCH 18/24] so that negation is important --- bin/propolis-server/src/lib/spec/api_spec_v6.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 0b122ae5c..1fa251481 100644 --- a/bin/propolis-server/src/lib/spec/api_spec_v6.rs +++ b/bin/propolis-server/src/lib/spec/api_spec_v6.rs @@ -64,7 +64,7 @@ impl From for v6::instance_spec::InstanceSpec { nvme_disks.push((key.clone(), disk.clone())); } } - val.disks.retain(|_, disk| v6_only_disk(disk)); + val.disks.retain(|_, disk| !v6_only_disk(disk)); let v3_spec: v3::instance_spec::InstanceSpec = val.try_into().unwrap_or_else(|e| { From cfdcc6baeb39c394bff0c38ada2a5fa77c88bc33 Mon Sep 17 00:00:00 2001 From: iximeow Date: Sat, 25 Jul 2026 01:55:39 +0000 Subject: [PATCH 19/24] luiz, eliza reviews --- bin/propolis-server/src/lib/migrate/types.rs | 72 +++++++++---------- .../src/lib/spec/api_spec_v2.rs | 7 +- bin/propolis-server/src/lib/spec/builder.rs | 2 +- 3 files changed, 37 insertions(+), 44 deletions(-) diff --git a/bin/propolis-server/src/lib/migrate/types.rs b/bin/propolis-server/src/lib/migrate/types.rs index be4f1b81e..cf83be94e 100644 --- a/bin/propolis-server/src/lib/migrate/types.rs +++ b/bin/propolis-server/src/lib/migrate/types.rs @@ -8,14 +8,14 @@ //! [`struct Spec`][crate::lib::spec::Spec] as a "how this version happens to //! describe VMs" internal structure. Early in migration we must convert this to //! some format that a `propolis-server` of a different version can instantiate -//! an equivalent VM from, for device state everything else to be imported into. -//! We *kind of* use API types here, and the rest of this section gets into why -//! and what one should consider in adding future versions. +//! an equivalent VM from, which CPUs, memory, and device state will be imported +//! into. We *kind of* use API types here, and the rest of this section gets +//! into why and what one should consider in adding future versions. //! //! Even for VMs that have been migrated many times, `propolis-server` must -//! incarnate a VM that can be described by *some* HTTP API `InstanceSpec` +//! incarnate a VM that has been[1] described by *some* HTTP API `InstanceSpec` //! version at some point in the past. We'll call this "oldest possible VM spec" -//! the "import horizon" that `propolis-server` supports. Further, the tooling +//! the _import horizon_ that `propolis-server` supports. Further, the tooling //! for Dropshot (/OpenAPI) version management is quite good, and provides //! guardrails against old versions' API types having structural changes. //! @@ -38,7 +38,7 @@ //! `propolis-server` and transmitted are what they are: go through a list of //! `TryInto for v*::instance_spec::InstanceSpec`, one of them will //! succeed, and send that over. This is the implementation you'll find in -//! [`RonV0Runner::sync`][crate::lib::migrate::source::RonV0Runner::sync]. +//! [`RonV0Runner::sync`](crate::lib::migrate::source::RonV0Runner::sync). //! //! ### [`VersionedInstanceSpec`] //! @@ -52,10 +52,10 @@ //! //! Since we have to support HTTP API types as far back as `propolis-server`'s //! import horizon, it's not much additional work to at least try supporting -//! migration across downgrades of `propolis-server`. If try converting to all -//! `v1, v2, v3 ..` forms of `InstanceSpec` in *ascending* order, the only time -//! conversion will fail to be downgradeable is if a VM has been created using -//! only-in-newest API language. This means that some VMs created using a +//! migration across downgrades of `propolis-server`. If we try converting to +//! all `v1, v2, v3 ..` forms of `InstanceSpec` in *ascending* order, the only +//! time conversion will fail to be downgradeable is if a VM has been created +//! using only-in-newest API language. This means that some VMs created using a //! `latest::instance_spec::InstanceSpec` could end up with even `v1` types on //! the wire for migration, but as long as `From/TryFrom` use is correct and //! *not lossy*, that's fine! @@ -76,6 +76,15 @@ //! //! In either case we need testing that old device descriptions don't //! *semantically* change, so it doesn't save effort there either. +//! +//! [1]: Technically, being able to describe a VM faithfully using the language +//! of an old API version does not tell you if the VM actually *was* created +//! using that API description. It's possible a VM was provided to Propolis in +//! the form of a `v6::instance_spec::InstanceSpec` which _happens_ to be +//! expressible as a `v1::instance_spec::InstanceSpec`. For the purposes of the +//! discussion above this a boring nitpick; a V1-compatible instance spec that +//! happened to come to us in V6-form can be imagined as anything that it can +//! convert back to (be that V3, V2, V1, ...). use serde::{Deserialize, Serialize}; @@ -86,8 +95,8 @@ use std::collections::BTreeMap; use crate::migrate::MigrateError; use crate::spec::{ - api_spec_v1, api_spec_v1::ApiSpecError as V1SpecError, api_spec_v2, - api_spec_v3, api_spec_v6, api_spec_v6::ApiSpecError as V6SpecError, Spec, + api_spec_v1, api_spec_v2, + api_spec_v3, api_spec_v6, Spec, }; /// A wrapper for one of any supported `InstanceSpec` that describe a @@ -151,45 +160,30 @@ impl VersionedInstanceSpec { VersionedInstanceSpec::V1(mut source_spec) => { api_spec_v1::amend(&mut source_spec, replacements)?; - let amended_spec: Spec = - source_spec.try_into().map_err(|e: V1SpecError| { - MigrateError::PreambleParse(e.to_string()) - })?; - - amended_spec + api_spec_v1::v1_to_spec_builder(source_spec).map_err(|e| { + MigrateError::PreambleParse(e.to_string()) + })?.finish() } VersionedInstanceSpec::V2(mut source_spec) => { api_spec_v2::amend(&mut source_spec, replacements)?; - let amended_spec: Spec = - source_spec.try_into().map_err(|e: V1SpecError| { - MigrateError::PreambleParse(e.to_string()) - })?; - - amended_spec + api_spec_v2::v2_to_spec_builder(source_spec).map_err(|e| { + MigrateError::PreambleParse(e.to_string()) + })?.finish() } VersionedInstanceSpec::V3(mut source_spec) => { api_spec_v3::amend(&mut source_spec, replacements)?; - let v6_spec: v6::instance_spec::InstanceSpec = - source_spec.into(); - let amended_spec: Spec = - v6_spec.try_into().map_err(|e: V6SpecError| { - let v1_error: V1SpecError = e.into(); - MigrateError::PreambleParse(v1_error.to_string()) - })?; - - amended_spec + api_spec_v3::v3_to_spec_builder(source_spec).map_err(|e| { + MigrateError::PreambleParse(e.to_string()) + })?.finish() } VersionedInstanceSpec::V6(mut source_spec) => { api_spec_v6::amend(&mut source_spec, replacements)?; - let amended_spec: Spec = - source_spec.try_into().map_err(|e: V6SpecError| { - MigrateError::PreambleParse(e.to_string()) - })?; - - amended_spec + api_spec_v6::v6_to_spec_builder(source_spec).map_err(|e| { + MigrateError::PreambleParse(e.to_string()) + })?.finish() } }; 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 341c2fe15..88f1f95f3 100644 --- a/bin/propolis-server/src/lib/spec/api_spec_v2.rs +++ b/bin/propolis-server/src/lib/spec/api_spec_v2.rs @@ -2,8 +2,7 @@ // 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 from the initial API version ([`propolis_api_types::v1`], aka -//! "V0" in some parts of propolis-server) instance specs in the +//! Conversions from [`propolis_api_types::v2`]) instance specs in the //! [`propolis_api_types`] crate to the internal [`super::Spec`] representation. use std::collections::BTreeMap; @@ -50,9 +49,9 @@ impl TryFrom for Spec { } } -/// Parses a v1 instance spec into a [`SpecBuilder`], validating component +/// Parses a v2 instance spec into a [`SpecBuilder`], validating component /// names, PCI paths, and backend references along the way. Callers can add -/// additional (non-v1) components to the builder before calling `finish()`. +/// additional (non-v2) components to the builder before calling `finish()`. pub(crate) fn v2_to_spec_builder( value: v2::instance_spec::InstanceSpec, ) -> Result { diff --git a/bin/propolis-server/src/lib/spec/builder.rs b/bin/propolis-server/src/lib/spec/builder.rs index 98ca40761..78560da8e 100644 --- a/bin/propolis-server/src/lib/spec/builder.rs +++ b/bin/propolis-server/src/lib/spec/builder.rs @@ -82,7 +82,7 @@ pub(crate) enum SpecBuilderError { /// rarely, so callers do the work of mapping components to the /// closer-to-internal definitions that `SpecBuilder` accepts. In theory, /// hopefully, this means `SpecBuilder` itself changes rarely and can be more -/// reasily audited for semantic drift. +/// readily audited for semantic drift. #[derive(Debug, Default)] pub(crate) struct SpecBuilder { spec: super::Spec, From 65417b83f31b63ec1f7a981aeec78d304cec3bd0 Mon Sep 17 00:00:00 2001 From: iximeow Date: Sat, 25 Jul 2026 01:58:25 +0000 Subject: [PATCH 20/24] rustfmt aughugh --- bin/propolis-server/src/lib/migrate/types.rs | 29 +++++++++----------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/bin/propolis-server/src/lib/migrate/types.rs b/bin/propolis-server/src/lib/migrate/types.rs index cf83be94e..62fa39f9a 100644 --- a/bin/propolis-server/src/lib/migrate/types.rs +++ b/bin/propolis-server/src/lib/migrate/types.rs @@ -94,10 +94,7 @@ use propolis_api_types_versions::{v1, v2, v3, v6}; 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, Spec}; /// A wrapper for one of any supported `InstanceSpec` that describe a /// to-be-migrated VM. @@ -160,30 +157,30 @@ impl VersionedInstanceSpec { VersionedInstanceSpec::V1(mut source_spec) => { api_spec_v1::amend(&mut source_spec, replacements)?; - api_spec_v1::v1_to_spec_builder(source_spec).map_err(|e| { - MigrateError::PreambleParse(e.to_string()) - })?.finish() + api_spec_v1::v1_to_spec_builder(source_spec) + .map_err(|e| MigrateError::PreambleParse(e.to_string()))? + .finish() } VersionedInstanceSpec::V2(mut source_spec) => { api_spec_v2::amend(&mut source_spec, replacements)?; - api_spec_v2::v2_to_spec_builder(source_spec).map_err(|e| { - MigrateError::PreambleParse(e.to_string()) - })?.finish() + api_spec_v2::v2_to_spec_builder(source_spec) + .map_err(|e| MigrateError::PreambleParse(e.to_string()))? + .finish() } VersionedInstanceSpec::V3(mut source_spec) => { api_spec_v3::amend(&mut source_spec, replacements)?; - api_spec_v3::v3_to_spec_builder(source_spec).map_err(|e| { - MigrateError::PreambleParse(e.to_string()) - })?.finish() + api_spec_v3::v3_to_spec_builder(source_spec) + .map_err(|e| MigrateError::PreambleParse(e.to_string()))? + .finish() } VersionedInstanceSpec::V6(mut source_spec) => { api_spec_v6::amend(&mut source_spec, replacements)?; - api_spec_v6::v6_to_spec_builder(source_spec).map_err(|e| { - MigrateError::PreambleParse(e.to_string()) - })?.finish() + api_spec_v6::v6_to_spec_builder(source_spec) + .map_err(|e| MigrateError::PreambleParse(e.to_string()))? + .finish() } }; From 64c032f46a67a7532602b38fa23990cff6de3c1e Mon Sep 17 00:00:00 2001 From: iximeow Date: Tue, 28 Jul 2026 02:04:25 +0000 Subject: [PATCH 21/24] split out what is v6 in perpetuity vs what is latest this is largely Luiz' suggestions in review (or me answering questions by making code more obvious. hopefully.) --- .../src/lib/spec/api_spec_latest.rs | 211 ++++++++++++++++++ .../src/lib/spec/api_spec_v1.rs | 39 +--- .../src/lib/spec/api_spec_v2.rs | 21 +- .../src/lib/spec/api_spec_v3.rs | 37 +-- .../src/lib/spec/api_spec_v6.rs | 211 +----------------- bin/propolis-server/src/lib/spec/mod.rs | 63 +++++- 6 files changed, 297 insertions(+), 285 deletions(-) create mode 100644 bin/propolis-server/src/lib/spec/api_spec_latest.rs diff --git a/bin/propolis-server/src/lib/spec/api_spec_latest.rs b/bin/propolis-server/src/lib/spec/api_spec_latest.rs new file mode 100644 index 000000000..35ca277c5 --- /dev/null +++ b/bin/propolis-server/src/lib/spec/api_spec_latest.rs @@ -0,0 +1,211 @@ +// 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 from [`propolis_api_types::v6`] instance specs in the +//! [`propolis_api_types`] crate to the internal [`super::Spec`] representation. + +use std::collections::BTreeMap; + +use propolis_api_types::instance_spec::{ + components::backends::{DlpiNetworkBackend, VirtioNetworkBackend}, + SpecKey, +}; +use propolis_api_types_versions::latest; + +use super::{ + builder::SpecBuilder, ApiSpecError, Disk, Nic, QemuPvpanic, + StorageBackend, StorageDevice, +}; + +#[cfg(feature = "failure-injection")] +use super::MigrationFailure; + +#[cfg(feature = "falcon")] +use super::SoftNpuPort; + +/// Parses the latest form of `InstanceSpec` into a [`SpecBuilder`], validating +/// component names, PCI paths, and backend references along the way. Callers +/// can add additional components to the builder before calling `finish()`. +pub(crate) fn latest_to_spec_builder( + value: latest::instance_spec::InstanceSpec, +) -> Result { + let mut builder = SpecBuilder::with_instance_spec_board(value.board)?; + let mut devices: Vec<(SpecKey, latest::instance_spec::Component)> = vec![]; + let mut boot_settings = None; + let mut storage_backends: BTreeMap = + BTreeMap::new(); + let mut viona_backends: BTreeMap = + BTreeMap::new(); + let mut dlpi_backends: BTreeMap = + BTreeMap::new(); + + for (id, component) in value.components.into_iter() { + match component { + latest::instance_spec::Component::CrucibleStorageBackend(_) + | latest::instance_spec::Component::FileStorageBackend(_) + | latest::instance_spec::Component::BlobStorageBackend(_) => { + storage_backends.insert( + id, + component + .try_into() + .expect("component is known to be a storage backend"), + ); + } + latest::instance_spec::Component::VirtioNetworkBackend(viona) => { + viona_backends.insert(id, viona); + } + latest::instance_spec::Component::DlpiNetworkBackend(dlpi) => { + dlpi_backends.insert(id, dlpi); + } + device => { + devices.push((id, device)); + } + } + } + + for (device_id, device_spec) in devices { + match device_spec { + latest::instance_spec::Component::VirtioDisk(_) + | latest::instance_spec::Component::NvmeDisk(_) => { + let device_spec = StorageDevice::try_from(device_spec) + .expect("component is known to be a disk"); + + let (_, backend_spec) = storage_backends + .remove_entry(device_spec.backend_id()) + .ok_or_else(|| ApiSpecError::StorageBackendNotFound { + backend: device_spec.backend_id().to_owned(), + device: device_id.clone(), + })?; + + builder.add_storage_device( + device_id, + Disk { device_spec, backend_spec }, + )?; + } + latest::instance_spec::Component::VirtioNic(nic) => { + let (_, backend_spec) = viona_backends + .remove_entry(&nic.backend_id) + .ok_or_else(|| ApiSpecError::NetworkBackendNotFound { + backend: nic.backend_id.clone(), + device: device_id.clone(), + })?; + + builder.add_network_device( + device_id, + Nic { device_spec: nic, backend_spec }, + )?; + } + latest::instance_spec::Component::SerialPort(port) => { + builder.add_serial_port(device_id, port.num)?; + } + latest::instance_spec::Component::PciPciBridge(bridge) => { + builder.add_pci_bridge(device_id, bridge)?; + } + latest::instance_spec::Component::QemuPvpanic(pvpanic) => { + builder.add_pvpanic_device(QemuPvpanic { + id: device_id, + spec: pvpanic, + })?; + } + latest::instance_spec::Component::BootSettings(settings) => { + // The builder returns an error if its caller tries to add + // a boot option that isn't in the set of attached disks. + // Since there may be more disk devices left in the + // component map, just capture the boot order for now and + // apply it to the builder later. + boot_settings = Some((device_id, settings)); + } + latest::instance_spec::Component::VirtioSocket(vsock) => { + let vsock_device = crate::spec::VirtioSocket { + id: device_id.clone(), + spec: vsock, + }; + builder.add_vsock_device(vsock_device)?; + } + #[cfg(not(feature = "failure-injection"))] + latest::instance_spec::Component::MigrationFailureInjector(_) => { + return Err(ApiSpecError::FeatureCompiledOut { + component: device_id, + feature: "failure-injection", + }); + } + #[cfg(feature = "failure-injection")] + latest::instance_spec::Component::MigrationFailureInjector(mig) => { + builder.add_migration_failure_device(MigrationFailure { + id: device_id, + spec: mig, + })?; + } + #[cfg(not(feature = "falcon"))] + latest::instance_spec::Component::SoftNpuPciPort(_) + | latest::instance_spec::Component::SoftNpuPort(_) + | latest::instance_spec::Component::SoftNpuP9(_) + | latest::instance_spec::Component::P9fs(_) => { + return Err(ApiSpecError::FeatureCompiledOut { + component: device_id, + feature: "falcon", + }); + } + #[cfg(feature = "falcon")] + latest::instance_spec::Component::SoftNpuPciPort(port) => { + builder.set_softnpu_pci_port(port)?; + } + #[cfg(feature = "falcon")] + latest::instance_spec::Component::SoftNpuPort(port) => { + let (_, backend_spec) = dlpi_backends + .remove_entry(&port.backend_id) + .ok_or_else(|| ApiSpecError::NetworkBackendNotFound { + backend: port.backend_id.clone(), + device: device_id.clone(), + })?; + + let port = SoftNpuPort { + link_name: port.link_name, + backend_name: port.backend_id, + backend_spec, + }; + + builder.add_softnpu_port(device_id, port)?; + } + #[cfg(feature = "falcon")] + latest::instance_spec::Component::SoftNpuP9(p9) => { + builder.set_softnpu_p9(p9)?; + } + #[cfg(feature = "falcon")] + latest::instance_spec::Component::P9fs(p9fs) => { + builder.set_p9fs(p9fs)?; + } + latest::instance_spec::Component::CrucibleStorageBackend(_) + | latest::instance_spec::Component::FileStorageBackend(_) + | latest::instance_spec::Component::BlobStorageBackend(_) + | latest::instance_spec::Component::VirtioNetworkBackend(_) + | latest::instance_spec::Component::DlpiNetworkBackend(_) => { + unreachable!("already filtered out backends") + } + } + } + + // Now that all disks have been attached, try to establish the boot + // order if one was supplied. + if let Some(settings) = boot_settings { + builder.add_boot_order( + settings.0, + settings.1.order.into_iter().map(Into::into), + )?; + } + + if let Some(backend) = storage_backends.into_keys().next() { + return Err(ApiSpecError::BackendNotUsed(backend)); + } + + if let Some(backend) = viona_backends.into_keys().next() { + return Err(ApiSpecError::BackendNotUsed(backend)); + } + + if let Some(backend) = dlpi_backends.into_keys().next() { + return Err(ApiSpecError::BackendNotUsed(backend)); + } + + Ok(builder) +} diff --git a/bin/propolis-server/src/lib/spec/api_spec_v1.rs b/bin/propolis-server/src/lib/spec/api_spec_v1.rs index c672d9f58..917b7216f 100644 --- a/bin/propolis-server/src/lib/spec/api_spec_v1.rs +++ b/bin/propolis-server/src/lib/spec/api_spec_v1.rs @@ -18,42 +18,19 @@ use propolis_api_types::instance_spec::{ use propolis_api_types_versions::{ v1, v1::instance::ReplacementComponent, v2, v3, v6, }; -use thiserror::Error; #[cfg(feature = "falcon")] use propolis_api_types::instance_spec::components::devices::SoftNpuPort as SoftNpuPortSpec; use super::{ - builder::{SpecBuilder, SpecBuilderError}, - SerialPortDevice, Spec, StorageBackend, StorageDevice, + builder::SpecBuilder, LegacyApiSpecError, SerialPortDevice, Spec, + StorageBackend, StorageDevice, }; use crate::migrate::MigrateError; #[cfg(feature = "failure-injection")] use super::MigrationFailure; -#[derive(Debug, Error)] -pub(crate) enum ApiSpecError { - #[error(transparent)] - Builder(#[from] SpecBuilderError), - - #[error("storage backend {backend} not found for device {device}")] - StorageBackendNotFound { backend: SpecKey, device: SpecKey }, - - #[error("network backend {backend} not found for device {device}")] - NetworkBackendNotFound { backend: SpecKey, device: SpecKey }, - - #[allow(dead_code)] - #[error("support for component {component} compiled out via {feature}")] - FeatureCompiledOut { component: SpecKey, feature: &'static str }, - - #[error("backend {0} not used by any device")] - BackendNotUsed(SpecKey), - - #[error("spec contains v1-incompatible component: {0}")] - IncompatibleComponent(String), -} - // Woah! It's strange to have a conversion to a *v1* type which has an error // from *v6* about *v3*. Not as bad as it seems though: v6 is when this // component changed, and v3 is next-most-recent version of @@ -86,7 +63,7 @@ impl From for v1::instance_spec::Component { } impl TryFrom for v1::instance_spec::InstanceSpec { - type Error = ApiSpecError; + type Error = LegacyApiSpecError; fn try_from(val: Spec) -> Result { // Exhaustively destructure the input spec so that adding a new field @@ -128,14 +105,14 @@ impl TryFrom for v1::instance_spec::InstanceSpec { // * V1 specs are from before live migration was done outside // ad-hoc/CI environments - such an old Propolis will never exist // as a migration target in the field. - return Err(ApiSpecError::IncompatibleComponent( + return Err(LegacyApiSpecError::IncompatibleComponent( "cannot express explicit SMBIOS tables in v1 instance spec" .to_string(), )); } if vsock.is_some() { - return Err(ApiSpecError::IncompatibleComponent( + return Err(LegacyApiSpecError::IncompatibleComponent( "cannot convert virtio-socket to v1 instance spec".to_string(), )); } @@ -178,7 +155,7 @@ impl TryFrom for v1::instance_spec::InstanceSpec { .device_spec .try_into() .map_err(|e: v6::instance_spec::InvalidV3Component| { - ApiSpecError::IncompatibleComponent(e.to_string()) + LegacyApiSpecError::IncompatibleComponent(e.to_string()) })?; let backend_component: v1::instance_spec::Component = disk.backend_spec.into(); @@ -308,7 +285,7 @@ impl TryFrom for v1::instance_spec::InstanceSpec { } impl TryFrom for Spec { - type Error = ApiSpecError; + type Error = LegacyApiSpecError; fn try_from( value: v1::instance_spec::InstanceSpec, @@ -322,7 +299,7 @@ impl TryFrom for Spec { /// additional (non-v1) components to the builder before calling `finish()`. pub(crate) fn v1_to_spec_builder( value: v1::instance_spec::InstanceSpec, -) -> Result { +) -> Result { let v2_spec: v2::instance_spec::InstanceSpec = value.into(); let v3_spec: v3::instance_spec::InstanceSpec = v2_spec.into(); 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 88f1f95f3..8dbbc46de 100644 --- a/bin/propolis-server/src/lib/spec/api_spec_v2.rs +++ b/bin/propolis-server/src/lib/spec/api_spec_v2.rs @@ -12,24 +12,21 @@ use propolis_api_types_versions::{ v1, v1::instance::ReplacementComponent, v2, v3, }; -use super::{builder::SpecBuilder, Spec}; +use super::{builder::SpecBuilder, LegacyApiSpecError, Spec}; use crate::migrate::MigrateError; #[cfg(feature = "failure-injection")] use super::MigrationFailure; -// v2 does not introduce new opportunities for Spec->InstanceSpec conversion -// to fail, so we can reuse the v1 error type directly. -use super::api_spec_v1::ApiSpecError; - impl TryFrom for v2::instance_spec::InstanceSpec { - type Error = ApiSpecError; + type Error = LegacyApiSpecError; fn try_from(mut val: Spec) -> Result { - // A V2 InstanceSpec is just a V1 InstanceSpec with an optional `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. + // A V2 InstanceSpec is just a V1 InstanceSpec with an optional + // `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(); let v1::instance_spec::InstanceSpec { board, components } = @@ -40,7 +37,7 @@ impl TryFrom for v2::instance_spec::InstanceSpec { } impl TryFrom for Spec { - type Error = ApiSpecError; + type Error = LegacyApiSpecError; fn try_from( value: v2::instance_spec::InstanceSpec, @@ -54,7 +51,7 @@ impl TryFrom for Spec { /// additional (non-v2) components to the builder before calling `finish()`. pub(crate) fn v2_to_spec_builder( value: v2::instance_spec::InstanceSpec, -) -> Result { +) -> Result { let v3_spec: v3::instance_spec::InstanceSpec = value.into(); crate::spec::api_spec_v3::v3_to_spec_builder(v3_spec) diff --git a/bin/propolis-server/src/lib/spec/api_spec_v3.rs b/bin/propolis-server/src/lib/spec/api_spec_v3.rs index 2e1c403ae..9ac144e86 100644 --- a/bin/propolis-server/src/lib/spec/api_spec_v3.rs +++ b/bin/propolis-server/src/lib/spec/api_spec_v3.rs @@ -12,15 +12,11 @@ use propolis_api_types_versions::{ v1::instance::ReplacementComponent, v2, v3, v6, }; -use super::{api_spec_v6, builder::SpecBuilder, Spec}; +use super::{api_spec_v6, builder::SpecBuilder, LegacyApiSpecError, Spec}; use crate::migrate::MigrateError; -// once again, v3 Spec<->InstanceSpec conversion failures are unchanged from -// previous, so reuse the error type. -use super::api_spec_v1::ApiSpecError; - impl TryFrom for v3::instance_spec::InstanceSpec { - type Error = ApiSpecError; + type Error = LegacyApiSpecError; fn try_from(mut val: Spec) -> Result { // v3 added only the `vsock` component, which is expressed only as the @@ -50,39 +46,12 @@ impl TryFrom for v3::instance_spec::InstanceSpec { } } -// Converting the API error back down is lossless, so define that here too. -// -// This notionally should be scoped to `v3_to_spec_builder`; there's not much -// reason to do this conversion anywhere else.. -impl From for ApiSpecError { - fn from(value: api_spec_v6::ApiSpecError) -> Self { - match value { - api_spec_v6::ApiSpecError::Builder(b) => ApiSpecError::Builder(b), - api_spec_v6::ApiSpecError::StorageBackendNotFound { - backend, - device, - } => ApiSpecError::StorageBackendNotFound { backend, device }, - api_spec_v6::ApiSpecError::NetworkBackendNotFound { - backend, - device, - } => ApiSpecError::NetworkBackendNotFound { backend, device }, - api_spec_v6::ApiSpecError::FeatureCompiledOut { - component, - feature, - } => ApiSpecError::FeatureCompiledOut { component, feature }, - api_spec_v6::ApiSpecError::BackendNotUsed(key) => { - ApiSpecError::BackendNotUsed(key) - } - } - } -} - /// Parses a v3 instance spec into a [`SpecBuilder`], validating component /// names, PCI paths, and backend references along the way. Callers can add /// additional (non-v3) components to the builder before calling `finish()`. pub(crate) fn v3_to_spec_builder( value: v3::instance_spec::InstanceSpec, -) -> Result { +) -> Result { // Converting v3 to v6 is lossless so just do that and piggyback on the // v6 `InstanceSpec->SpecBuilder`. let v6_spec: v6::instance_spec::InstanceSpec = value.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 1fa251481..59448da33 100644 --- a/bin/propolis-server/src/lib/spec/api_spec_v6.rs +++ b/bin/propolis-server/src/lib/spec/api_spec_v6.rs @@ -8,42 +8,16 @@ use std::collections::BTreeMap; use propolis_api_types::instance_spec::{ - components::backends::{DlpiNetworkBackend, VirtioNetworkBackend}, SpecKey, }; use propolis_api_types_versions::{v1::instance::ReplacementComponent, v3, v6}; -use thiserror::Error; use super::{ - builder::{SpecBuilder, SpecBuilderError}, - Disk, Nic, QemuPvpanic, Spec, StorageBackend, StorageDevice, + builder::SpecBuilder, ApiSpecError, Disk, Spec, + StorageDevice, }; use crate::migrate::MigrateError; - -#[cfg(feature = "failure-injection")] -use super::MigrationFailure; - -#[cfg(feature = "falcon")] -use super::SoftNpuPort; - -#[derive(Debug, Error)] -pub(crate) enum ApiSpecError { - #[error(transparent)] - Builder(#[from] SpecBuilderError), - - #[error("storage backend {backend} not found for device {device}")] - StorageBackendNotFound { backend: SpecKey, device: SpecKey }, - - #[error("network backend {backend} not found for device {device}")] - NetworkBackendNotFound { backend: SpecKey, device: SpecKey }, - - #[allow(dead_code)] - #[error("support for component {component} compiled out via {feature}")] - FeatureCompiledOut { component: SpecKey, feature: &'static str }, - - #[error("backend {0} not used by any device")] - BackendNotUsed(SpecKey), -} +use crate::spec::api_spec_latest; impl From for v6::instance_spec::InstanceSpec { fn from(mut val: Spec) -> Self { @@ -118,184 +92,7 @@ impl From for v6::instance_spec::InstanceSpec { pub(crate) fn v6_to_spec_builder( value: v6::instance_spec::InstanceSpec, ) -> Result { - let mut builder = SpecBuilder::with_instance_spec_board(value.board)?; - let mut devices: Vec<(SpecKey, v6::instance_spec::Component)> = vec![]; - let mut boot_settings = None; - let mut storage_backends: BTreeMap = - BTreeMap::new(); - let mut viona_backends: BTreeMap = - BTreeMap::new(); - let mut dlpi_backends: BTreeMap = - BTreeMap::new(); - - for (id, component) in value.components.into_iter() { - match component { - v6::instance_spec::Component::CrucibleStorageBackend(_) - | v6::instance_spec::Component::FileStorageBackend(_) - | v6::instance_spec::Component::BlobStorageBackend(_) => { - storage_backends.insert( - id, - component - .try_into() - .expect("component is known to be a storage backend"), - ); - } - v6::instance_spec::Component::VirtioNetworkBackend(viona) => { - viona_backends.insert(id, viona); - } - v6::instance_spec::Component::DlpiNetworkBackend(dlpi) => { - dlpi_backends.insert(id, dlpi); - } - device => { - devices.push((id, device)); - } - } - } - - for (device_id, device_spec) in devices { - match device_spec { - v6::instance_spec::Component::VirtioDisk(_) - | v6::instance_spec::Component::NvmeDisk(_) => { - let device_spec = StorageDevice::try_from(device_spec) - .expect("component is known to be a disk"); - - let (_, backend_spec) = storage_backends - .remove_entry(device_spec.backend_id()) - .ok_or_else(|| ApiSpecError::StorageBackendNotFound { - backend: device_spec.backend_id().to_owned(), - device: device_id.clone(), - })?; - - builder.add_storage_device( - device_id, - Disk { device_spec, backend_spec }, - )?; - } - v6::instance_spec::Component::VirtioNic(nic) => { - let (_, backend_spec) = viona_backends - .remove_entry(&nic.backend_id) - .ok_or_else(|| ApiSpecError::NetworkBackendNotFound { - backend: nic.backend_id.clone(), - device: device_id.clone(), - })?; - - builder.add_network_device( - device_id, - Nic { device_spec: nic, backend_spec }, - )?; - } - v6::instance_spec::Component::SerialPort(port) => { - builder.add_serial_port(device_id, port.num)?; - } - v6::instance_spec::Component::PciPciBridge(bridge) => { - builder.add_pci_bridge(device_id, bridge)?; - } - v6::instance_spec::Component::QemuPvpanic(pvpanic) => { - builder.add_pvpanic_device(QemuPvpanic { - id: device_id, - spec: pvpanic, - })?; - } - v6::instance_spec::Component::BootSettings(settings) => { - // The builder returns an error if its caller tries to add - // a boot option that isn't in the set of attached disks. - // Since there may be more disk devices left in the - // component map, just capture the boot order for now and - // apply it to the builder later. - boot_settings = Some((device_id, settings)); - } - v6::instance_spec::Component::VirtioSocket(vsock) => { - let vsock_device = crate::spec::VirtioSocket { - id: device_id.clone(), - spec: vsock, - }; - builder.add_vsock_device(vsock_device)?; - } - #[cfg(not(feature = "failure-injection"))] - v6::instance_spec::Component::MigrationFailureInjector(_) => { - return Err(ApiSpecError::FeatureCompiledOut { - component: device_id, - feature: "failure-injection", - }); - } - #[cfg(feature = "failure-injection")] - v6::instance_spec::Component::MigrationFailureInjector(mig) => { - builder.add_migration_failure_device(MigrationFailure { - id: device_id, - spec: mig, - })?; - } - #[cfg(not(feature = "falcon"))] - v6::instance_spec::Component::SoftNpuPciPort(_) - | v6::instance_spec::Component::SoftNpuPort(_) - | v6::instance_spec::Component::SoftNpuP9(_) - | v6::instance_spec::Component::P9fs(_) => { - return Err(ApiSpecError::FeatureCompiledOut { - component: device_id, - feature: "falcon", - }); - } - #[cfg(feature = "falcon")] - v6::instance_spec::Component::SoftNpuPciPort(port) => { - builder.set_softnpu_pci_port(port)?; - } - #[cfg(feature = "falcon")] - v6::instance_spec::Component::SoftNpuPort(port) => { - let (_, backend_spec) = dlpi_backends - .remove_entry(&port.backend_id) - .ok_or_else(|| ApiSpecError::NetworkBackendNotFound { - backend: port.backend_id.clone(), - device: device_id.clone(), - })?; - - let port = SoftNpuPort { - link_name: port.link_name, - backend_name: port.backend_id, - backend_spec, - }; - - builder.add_softnpu_port(device_id, port)?; - } - #[cfg(feature = "falcon")] - v6::instance_spec::Component::SoftNpuP9(p9) => { - builder.set_softnpu_p9(p9)?; - } - #[cfg(feature = "falcon")] - v6::instance_spec::Component::P9fs(p9fs) => { - builder.set_p9fs(p9fs)?; - } - v6::instance_spec::Component::CrucibleStorageBackend(_) - | v6::instance_spec::Component::FileStorageBackend(_) - | v6::instance_spec::Component::BlobStorageBackend(_) - | v6::instance_spec::Component::VirtioNetworkBackend(_) - | v6::instance_spec::Component::DlpiNetworkBackend(_) => { - unreachable!("already filtered out backends") - } - } - } - - // Now that all disks have been attached, try to establish the boot - // order if one was supplied. - if let Some(settings) = boot_settings { - builder.add_boot_order( - settings.0, - settings.1.order.into_iter().map(Into::into), - )?; - } - - if let Some(backend) = storage_backends.into_keys().next() { - return Err(ApiSpecError::BackendNotUsed(backend)); - } - - if let Some(backend) = viona_backends.into_keys().next() { - return Err(ApiSpecError::BackendNotUsed(backend)); - } - - if let Some(backend) = dlpi_backends.into_keys().next() { - return Err(ApiSpecError::BackendNotUsed(backend)); - } - - Ok(builder) + api_spec_latest::latest_to_spec_builder(value) } fn amend_component( diff --git a/bin/propolis-server/src/lib/spec/mod.rs b/bin/propolis-server/src/lib/spec/mod.rs index 18cd39ca3..27037058e 100644 --- a/bin/propolis-server/src/lib/spec/mod.rs +++ b/bin/propolis-server/src/lib/spec/mod.rs @@ -17,10 +17,23 @@ //! Types and operations here are copied as-needed into new verson-specific //! modules as needed, as new versions of the `propolis-server` HTTP API are //! added. +//! +//! ## New Versions +//! +//! When adding a new API version, particularly when those API versions inclue a +//! new defintion for `InstanceSpec`, you will probably want to: +//! +//! * add a new `api_spec_vN` module for the new version, with conversions from +//! `Spec` to the `vN::instance_spec::InstanceSpec` via the previous version +//! and from `vN::instance_spec::InstanceSpec` to `Spec` via +//! `latest_to_spec_builder()` +//! * adjust the formerly-latest module's `vN_to_spec_builder` to call +//! `vN_to_spec_builder` and (try) converting `InstanceSpec` upwards to match. +//! * adjust the formerly-latest module's `Spec` to `InstanceSpec` conversion to +//! be fallible, using `LegacyApiSpecError` instead of `ApiSpecError` use std::collections::BTreeMap; -use crate::spec::api_spec_v6::ApiSpecError; use cpuid_utils::CpuidSet; use propolis_api_types::instance_spec::{ components::{ @@ -57,8 +70,56 @@ 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_latest; pub(crate) mod builder; +/// An error that can arise in converting +/// `propolis_api_types::instance_spec::InstanceSpec` to a propolis-server +/// `Spec`. To date, this is a subset of the errors that can arise in converting +/// older versions of `InstanceSpec` as well. +/// +/// When adding a new `api_spec_v*`, it may be appropriate to either move this +/// error type into the formerly-latest module and add a nwe top-level type, or +/// to simply rename uses of this error in the formerly-latest module to +/// `LegacyApiSpecError`. Whichever is appropriate depends on how similar the +/// errors in the newly version are to the errors described here. +#[derive(Debug, Error)] +pub(crate) enum ApiSpecError { + #[error(transparent)] + Builder(#[from] builder::SpecBuilderError), + + #[error("storage backend {backend} not found for device {device}")] + StorageBackendNotFound { backend: SpecKey, device: SpecKey }, + + #[error("network backend {backend} not found for device {device}")] + NetworkBackendNotFound { backend: SpecKey, device: SpecKey }, + + #[allow(dead_code)] + #[error("support for component {component} compiled out via {feature}")] + FeatureCompiledOut { component: SpecKey, feature: &'static str }, + + #[error("backend {0} not used by any device")] + BackendNotUsed(SpecKey), +} + +/// An error that can arise in converting any older versions of `InstanceSpec` +/// to a propolis-server `Spec`. To date, this may be any of the errors that can +/// occur in converting the most recent version of the spec, plus errors in +/// converting components to older API forms. +/// +/// This type, as well as `ApiSpecError` are best-effort attempts to describe +/// the error space as we've seen it so far; if the "legacy" kinds of errors end +/// up variable it may make sense to revisit even having a "shared" error type +/// for these conversions. +#[derive(Debug, Error)] +pub(crate) enum LegacyApiSpecError { + #[error(transparent)] + SpecError(#[from] ApiSpecError), + + #[error("spec contains v1-incompatible component: {0}")] + IncompatibleComponent(String), +} + /// `propolis-server` relies on `TryInto` to convert the API-provided /// `InstanceSpec` to an internal `Spec`. When adding a new API version to /// `propolis-server` you will probably want to take this implementation and From 420f452e4f39e90798836305dd8af5672677c265 Mon Sep 17 00:00:00 2001 From: iximeow Date: Tue, 28 Jul 2026 02:07:49 +0000 Subject: [PATCH 22/24] rustfmt and a missing v2 conversion --- bin/propolis-server/src/lib/migrate/types.rs | 4 ++++ bin/propolis-server/src/lib/spec/api_spec_latest.rs | 4 ++-- bin/propolis-server/src/lib/spec/api_spec_v6.rs | 9 ++------- bin/propolis-server/src/lib/spec/mod.rs | 2 +- 4 files changed, 9 insertions(+), 10 deletions(-) diff --git a/bin/propolis-server/src/lib/migrate/types.rs b/bin/propolis-server/src/lib/migrate/types.rs index 62fa39f9a..d88e24b15 100644 --- a/bin/propolis-server/src/lib/migrate/types.rs +++ b/bin/propolis-server/src/lib/migrate/types.rs @@ -134,6 +134,10 @@ impl VersionedInstanceSpec { TryInto::::try_into(spec.clone()) { VersionedInstanceSpec::V1(v1_spec) + } else if let Ok(v2_spec) = + TryInto::::try_into(spec.clone()) + { + VersionedInstanceSpec::V2(v2_spec) } else if let Ok(v3_spec) = TryInto::::try_into(spec.clone()) { 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 35ca277c5..6d25b4402 100644 --- a/bin/propolis-server/src/lib/spec/api_spec_latest.rs +++ b/bin/propolis-server/src/lib/spec/api_spec_latest.rs @@ -14,8 +14,8 @@ use propolis_api_types::instance_spec::{ use propolis_api_types_versions::latest; use super::{ - builder::SpecBuilder, ApiSpecError, Disk, Nic, QemuPvpanic, - StorageBackend, StorageDevice, + builder::SpecBuilder, ApiSpecError, Disk, Nic, QemuPvpanic, StorageBackend, + StorageDevice, }; #[cfg(feature = "failure-injection")] 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 59448da33..221f97280 100644 --- a/bin/propolis-server/src/lib/spec/api_spec_v6.rs +++ b/bin/propolis-server/src/lib/spec/api_spec_v6.rs @@ -7,15 +7,10 @@ use std::collections::BTreeMap; -use propolis_api_types::instance_spec::{ - SpecKey, -}; +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 super::{builder::SpecBuilder, ApiSpecError, Disk, Spec, StorageDevice}; use crate::migrate::MigrateError; use crate::spec::api_spec_latest; diff --git a/bin/propolis-server/src/lib/spec/mod.rs b/bin/propolis-server/src/lib/spec/mod.rs index 27037058e..6540f65a1 100644 --- a/bin/propolis-server/src/lib/spec/mod.rs +++ b/bin/propolis-server/src/lib/spec/mod.rs @@ -66,11 +66,11 @@ use propolis_api_types::instance_spec::components::{ devices::{P9fs, SoftNpuP9, SoftNpuPciPort}, }; +pub(crate) mod api_spec_latest; 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_latest; pub(crate) mod builder; /// An error that can arise in converting From 78811b1ecc4913f2faaf0abd317b721756c8dcff Mon Sep 17 00:00:00 2001 From: iximeow Date: Wed, 29 Jul 2026 22:53:21 +0000 Subject: [PATCH 23/24] spelling, docs betterification --- bin/propolis-server/src/lib/spec/api_spec_latest.rs | 11 +++++++++-- bin/propolis-server/src/lib/spec/mod.rs | 7 +++---- 2 files changed, 12 insertions(+), 6 deletions(-) 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 6d25b4402..b3772e5ec 100644 --- a/bin/propolis-server/src/lib/spec/api_spec_latest.rs +++ b/bin/propolis-server/src/lib/spec/api_spec_latest.rs @@ -2,8 +2,15 @@ // 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 from [`propolis_api_types::v6`] instance specs in the -//! [`propolis_api_types`] crate to the internal [`super::Spec`] representation. +//! Conversions from types in [`propolis_api_types`] - that is, the latest +//! Propolis API version - to the internal [`super::Spec`] representation. +//! +//! `propolis_api_types` is a re-export of the latest versions of types out of +//! `propolis_api_types_versions`. The types that tend to change across files +//! are referred to here as `latest::` for similarity to other +//! version-specific code. Types that do not tend to change (say, `SpecKey`) are +//! just taken from the re-exported path because that's how they're used +//! everywhere (including other `api_spec_*` files.) use std::collections::BTreeMap; diff --git a/bin/propolis-server/src/lib/spec/mod.rs b/bin/propolis-server/src/lib/spec/mod.rs index 6540f65a1..79c613650 100644 --- a/bin/propolis-server/src/lib/spec/mod.rs +++ b/bin/propolis-server/src/lib/spec/mod.rs @@ -14,9 +14,8 @@ //! [`Spec`] and its component types to take forms that might otherwise be hard //! to change in a backward-compatible way. //! -//! Types and operations here are copied as-needed into new verson-specific -//! modules as needed, as new versions of the `propolis-server` HTTP API are -//! added. +//! Types and operations here are copied as-needed into new version-specific +//! modules, as new versions of the `propolis-server` HTTP API are added. //! //! ## New Versions //! @@ -79,7 +78,7 @@ pub(crate) mod builder; /// older versions of `InstanceSpec` as well. /// /// When adding a new `api_spec_v*`, it may be appropriate to either move this -/// error type into the formerly-latest module and add a nwe top-level type, or +/// error type into the formerly-latest module and add a new top-level type, or /// to simply rename uses of this error in the formerly-latest module to /// `LegacyApiSpecError`. Whichever is appropriate depends on how similar the /// errors in the newly version are to the errors described here. From 36076baaa14e2bc4d62947cd7c32f440a370fc97 Mon Sep 17 00:00:00 2001 From: iximeow Date: Wed, 29 Jul 2026 23:14:22 +0000 Subject: [PATCH 24/24] other doc links too.. hum --- bin/propolis-server/src/lib/spec/api_spec_v1.rs | 6 +++--- bin/propolis-server/src/lib/spec/api_spec_v2.rs | 4 ++-- bin/propolis-server/src/lib/spec/api_spec_v3.rs | 4 ++-- bin/propolis-server/src/lib/spec/api_spec_v6.rs | 4 ++-- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/bin/propolis-server/src/lib/spec/api_spec_v1.rs b/bin/propolis-server/src/lib/spec/api_spec_v1.rs index 917b7216f..0337b717a 100644 --- a/bin/propolis-server/src/lib/spec/api_spec_v1.rs +++ b/bin/propolis-server/src/lib/spec/api_spec_v1.rs @@ -2,9 +2,9 @@ // 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 from the initial API version ([`propolis_api_types::v1`], aka -//! "V0" in some parts of propolis-server) instance specs in the -//! [`propolis_api_types`] crate to the internal [`super::Spec`] representation. +//! Conversions between [`propolis_api_types_versions::v1`] (aka "V0" in some +//! parts of propolis-server) instance specs and the internal [`super::Spec`] +//! representation. use std::collections::BTreeMap; 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 8dbbc46de..d7f764177 100644 --- a/bin/propolis-server/src/lib/spec/api_spec_v2.rs +++ b/bin/propolis-server/src/lib/spec/api_spec_v2.rs @@ -2,8 +2,8 @@ // 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 from [`propolis_api_types::v2`]) instance specs in the -//! [`propolis_api_types`] crate to the internal [`super::Spec`] representation. +//! Conversions between [`propolis_api_types_versions::v2`] instance specs and +//! the internal [`super::Spec`] representation. use std::collections::BTreeMap; diff --git a/bin/propolis-server/src/lib/spec/api_spec_v3.rs b/bin/propolis-server/src/lib/spec/api_spec_v3.rs index 9ac144e86..d55cc609b 100644 --- a/bin/propolis-server/src/lib/spec/api_spec_v3.rs +++ b/bin/propolis-server/src/lib/spec/api_spec_v3.rs @@ -2,8 +2,8 @@ // 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 from [`propolis_api_types::v3`]) instance specs in the -//! [`propolis_api_types`] crate to the internal [`super::Spec`] representation. +//! Conversions between [`propolis_api_types_versions::v3`] instance specs and +//! the internal [`super::Spec`] representation. use std::collections::BTreeMap; 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 221f97280..3cdadcb10 100644 --- a/bin/propolis-server/src/lib/spec/api_spec_v6.rs +++ b/bin/propolis-server/src/lib/spec/api_spec_v6.rs @@ -2,8 +2,8 @@ // 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 from [`propolis_api_types::v6`] instance specs in the -//! [`propolis_api_types`] crate to the internal [`super::Spec`] representation. +//! Conversions between [`propolis_api_types_versions::v6`] instance specs and +//! the internal [`super::Spec`] representation. use std::collections::BTreeMap;