From 319db2cf5bc03e0d3ef6a88425c4bea69a284f0f Mon Sep 17 00:00:00 2001 From: lif <> Date: Sat, 19 Sep 2026 08:12:00 +0000 Subject: [PATCH 01/10] Support soft shutdown of guest via ACPI PWRBTN_STS Implements behavior specified in RFD 622, based on earlier work on platform support for ACPI fixed power button by @lgfa29. This adds an optional `acpi_timeout_secs` parameter to instance state PUT, which if specified will cause propolis-server to send the guest an [ACPI fixed power button](https://uefi.org/specs/ACPI/6.6/04_ACPI_Hardware_Specification.html#fixed-power-button) signal. After sending this to the guest, propolis-server waits for the guest to shut itself down for the specified number of seconds before enqueueing the explicit hard-stop or hard-reset the request would have otherwise been without the timeout parameter specified. If an instance state PUT request to stop or reset is received while shutting down, it is honored immediately, possibly interrupting the guest's safer shutdown process (unmounting disks, et al). If an instance state PUT with a timeout is received while still waiting for a previously-requested ACPI shutdown, the newer request is ignored. If a guest which is expected to be shutting down sends propolis *any* CPU-halting event, be it the expected halt, a chipset-driven reset, or a triple-fault, the action which was specified in the pending shutdown request is taken, overriding what the guest's action would normally imply absent the pending shutdown with with the authority of the user's intent.\* (\* In essence, we expect that if the control plane tells us the instance should be turned off gently, it could well be because the sled is about to be upgraded, and the customer is likely to want their VM guests' filesystems to be left in as coherent a state as possible. An unclean unmount from a triple-fault followed shortly by the system rebooting, starting a filesystem repair, and then being interrupted during *that* by a hard-cut of its power is unlikely to be a positive customer experience) Co-authored-by: Luiz Aoqui --- bin/propolis-cli/src/main.rs | 15 +- bin/propolis-server/src/lib/initializer.rs | 13 +- bin/propolis-server/src/lib/server.rs | 10 +- bin/propolis-server/src/lib/vm/active.rs | 32 +- bin/propolis-server/src/lib/vm/ensure.rs | 1 + bin/propolis-server/src/lib/vm/mod.rs | 7 + bin/propolis-server/src/lib/vm/objects.rs | 15 +- .../src/lib/vm/request_queue.rs | 322 +++++++++++++++++- .../src/lib/vm/state_driver.rs | 170 +++++++-- bin/propolis-standalone/src/main.rs | 1 + .../src/acpi_shutdown/instance.rs | 19 ++ .../src/acpi_shutdown/mod.rs | 5 + .../propolis-api-types-versions/src/latest.rs | 3 +- crates/propolis-api-types-versions/src/lib.rs | 2 + crates/propolis-server-api/src/lib.rs | 25 +- lib/propolis/src/hw/chipset/i440fx.rs | 20 +- .../propolis-server-6.0.0-b5b984.json.gitstub | 1 + ...json => propolis-server-7.0.0-a344ff.json} | 28 +- .../propolis-server-latest.json | 2 +- phd-tests/framework/src/test_vm/mod.rs | 46 ++- phd-tests/tests/src/acpi_shutdown.rs | 144 ++++++++ phd-tests/tests/src/lib.rs | 1 + 22 files changed, 809 insertions(+), 73 deletions(-) create mode 100644 crates/propolis-api-types-versions/src/acpi_shutdown/instance.rs create mode 100644 crates/propolis-api-types-versions/src/acpi_shutdown/mod.rs create mode 100644 openapi/propolis-server/propolis-server-6.0.0-b5b984.json.gitstub rename openapi/propolis-server/{propolis-server-6.0.0-b5b984.json => propolis-server-7.0.0-a344ff.json} (98%) create mode 100644 phd-tests/tests/src/acpi_shutdown.rs diff --git a/bin/propolis-cli/src/main.rs b/bin/propolis-cli/src/main.rs index 72ba175b4..0690a4bcf 100644 --- a/bin/propolis-cli/src/main.rs +++ b/bin/propolis-cli/src/main.rs @@ -39,7 +39,10 @@ use uuid::Uuid; use propolis_client::{ support::{InstanceSerialConsoleHelper, WSClientOffset}, - types::{InstanceStateRequested, InstanceVcrReplace, MigrationState}, + types::{ + InstanceStateChange, InstanceStateRequested, InstanceVcrReplace, + MigrationState, + }, Client, }; @@ -118,6 +121,9 @@ enum Command { /// The requested state #[clap(value_parser = parse_state)] state: InstanceStateRequested, + /// The number of seconds to wait after sending ACPI PWRBTN_STS before + /// forcing stop/reset. (If omitted, stop/reset are forced immediately) + acpi_timeout_secs: Option, }, /// Drop to a Serial console connected to the instance @@ -572,10 +578,11 @@ async fn get_instance(client: &Client) -> anyhow::Result<()> { async fn put_instance( client: &Client, state: InstanceStateRequested, + acpi_timeout_secs: Option, ) -> anyhow::Result<()> { client .instance_state_put() - .body(state) + .body(InstanceStateChange { state, acpi_timeout_secs }) .send() .await .with_context(|| anyhow!("failed to set instance state"))?; @@ -958,7 +965,9 @@ async fn main() -> anyhow::Result<()> { .await? } Command::Get => get_instance(&client).await?, - Command::State { state } => put_instance(&client, state).await?, + Command::State { state, acpi_timeout_secs } => { + put_instance(&client, state, acpi_timeout_secs).await? + } Command::Serial { byte_offset } => { serial(addr, byte_offset, log).await? } diff --git a/bin/propolis-server/src/lib/initializer.rs b/bin/propolis-server/src/lib/initializer.rs index 0da580375..bf55e072c 100644 --- a/bin/propolis-server/src/lib/initializer.rs +++ b/bin/propolis-server/src/lib/initializer.rs @@ -189,6 +189,7 @@ pub fn build_instance( pub struct RegisteredChipset { chipset: Arc, isa: Arc, + pm: Arc, } impl RegisteredChipset { pub fn pci_attach(&self, bdf: pci::Bdf, dev: Arc) { @@ -197,6 +198,9 @@ impl RegisteredChipset { pub fn irq_pin(&self, irq: u8) -> Option> { self.isa.irq_pin(irq) } + pub fn acpi_shutdown(&self) { + self.pm.acpi_shutdown(); + } fn reset_pin(&self) -> Arc { self.chipset.reset_pin() } @@ -356,6 +360,7 @@ impl MachineInitializer<'_> { let chipset_pm = i440fx::Piix3PM::create( self.machine.hdl.clone(), chipset_hb.power_pin(), + chipset_lpc.sci_pin(), self.log.new(slog::o!("device" => "piix3pm")), ); @@ -387,7 +392,7 @@ impl MachineInitializer<'_> { ); self.devices.insert( SpecKey::Name(chipset_pm.type_name().into()), - chipset_pm, + chipset_pm.clone(), ); // Record attachment for any bridges in PCI topology too @@ -404,7 +409,11 @@ impl MachineInitializer<'_> { self.devices.insert(spec_element.0.clone(), bridge); } - Ok(RegisteredChipset { chipset: chipset_hb, isa: chipset_lpc }) + Ok(RegisteredChipset { + chipset: chipset_hb, + isa: chipset_lpc, + pm: chipset_pm, + }) } } } diff --git a/bin/propolis-server/src/lib/server.rs b/bin/propolis-server/src/lib/server.rs index cba4f65cd..8501e51a4 100644 --- a/bin/propolis-server/src/lib/server.rs +++ b/bin/propolis-server/src/lib/server.rs @@ -43,7 +43,7 @@ use propolis_api_types::disk::{ }; use propolis_api_types::instance::{ ErrorCode, Instance, InstanceEnsureRequest, InstanceEnsureResponse, - InstanceGetResponse, InstanceInitializationMethod, + InstanceGetResponse, InstanceInitializationMethod, InstanceStateChange, InstanceStateMonitorRequest, InstanceStateMonitorResponse, InstanceStateRequested, }; @@ -364,13 +364,13 @@ impl PropolisServerApi for PropolisServerImpl { async fn instance_state_put( rqctx: RequestContext, - request: TypedBody, + request: TypedBody, ) -> Result { let ctx = rqctx.context(); - let requested_state = request.into_inner(); + let requested_change = request.into_inner(); let vm = ctx.vm.active_vm().await.ok_or_else(not_created_error)?; let result = vm - .put_state(requested_state) + .put_state(requested_change) .map(|_| HttpResponseUpdatedNoContent {}) .map_err(|e| match e { VmError::WaitingToInitialize => HttpError::for_unavail( @@ -391,7 +391,7 @@ impl PropolisServerApi for PropolisServerImpl { }); if result.is_ok() { - if let InstanceStateRequested::Reboot = requested_state { + if let InstanceStateRequested::Reboot = requested_change.state { let stats = MutexGuard::map( vm.services().oximeter.lock().await, |state| &mut state.stats, diff --git a/bin/propolis-server/src/lib/vm/active.rs b/bin/propolis-server/src/lib/vm/active.rs index 2abb93a0b..8f0cda22c 100644 --- a/bin/propolis-server/src/lib/vm/active.rs +++ b/bin/propolis-server/src/lib/vm/active.rs @@ -5,15 +5,17 @@ //! Implements a wrapper around an active VM. use std::sync::Arc; +use std::time::Duration; use propolis_api_types::instance::{ - InstanceProperties, InstanceStateRequested, + InstanceProperties, InstanceStateChange, InstanceStateRequested, }; use propolis_api_types::instance_spec::SpecKey; use slog::info; use uuid::Uuid; -use crate::vm::request_queue::ExternalRequest; +use crate::vm::request_queue::{ExternalRequest, RequestDeniedReason}; +use crate::vm::SoftShutdownFate; use super::{ objects::VmObjects, services::VmServices, CrucibleReplaceResultTx, @@ -57,17 +59,31 @@ impl ActiveVm { /// Pushes a state change request to the VM's state change queue. pub(crate) fn put_state( &self, - requested: InstanceStateRequested, + requested: InstanceStateChange, ) -> Result<(), VmError> { info!(self.log, "requested state via API"; - "state" => ?requested); - - self.state_driver_queue - .queue_external_request(match requested { + "state" => ?requested.state, + "timeout" => ?requested.acpi_timeout_secs); + + let ext_req = if let Some(secs) = requested.acpi_timeout_secs { + let timeout = Duration::from_secs(secs); + let fate = match requested.state { + InstanceStateRequested::Run => { + return Err(RequestDeniedReason::TimeoutOnRun.into()); + } + InstanceStateRequested::Stop => SoftShutdownFate::Stop, + InstanceStateRequested::Reboot => SoftShutdownFate::Reboot, + }; + ExternalRequest::acpi_shutdown(fate, timeout) + } else { + match requested.state { InstanceStateRequested::Run => ExternalRequest::start(), InstanceStateRequested::Stop => ExternalRequest::stop(), InstanceStateRequested::Reboot => ExternalRequest::reboot(), - }) + } + }; + self.state_driver_queue + .queue_external_request(ext_req) .map_err(Into::into) } diff --git a/bin/propolis-server/src/lib/vm/ensure.rs b/bin/propolis-server/src/lib/vm/ensure.rs index 3f7d50709..22b205084 100644 --- a/bin/propolis-server/src/lib/vm/ensure.rs +++ b/bin/propolis-server/src/lib/vm/ensure.rs @@ -656,6 +656,7 @@ async fn initialize_vm_objects( framebuffer: Some(ramfb), ps2ctrl, attest_handle, + chipset: chipset.into(), }; // Another really terrible hack. As we've found in Propolis#1008, brk() diff --git a/bin/propolis-server/src/lib/vm/mod.rs b/bin/propolis-server/src/lib/vm/mod.rs index 9f3c98e7f..ae95963fb 100644 --- a/bin/propolis-server/src/lib/vm/mod.rs +++ b/bin/propolis-server/src/lib/vm/mod.rs @@ -164,6 +164,13 @@ const VMM_MIN_RT_THREADS: usize = 8; /// is greater. const VMM_BASE_RT_THREADS: usize = 4; +/// The action to take after an ACPI shutdown either completes or times out. +#[derive(Clone, Copy, Debug)] +pub enum SoftShutdownFate { + Stop, + Reboot, +} + /// Errors generated by the VM controller and its subcomponents. #[derive(Debug, thiserror::Error)] pub(crate) enum VmError { diff --git a/bin/propolis-server/src/lib/vm/objects.rs b/bin/propolis-server/src/lib/vm/objects.rs index 49ba61c84..d0af0f6cf 100644 --- a/bin/propolis-server/src/lib/vm/objects.rs +++ b/bin/propolis-server/src/lib/vm/objects.rs @@ -22,7 +22,10 @@ use propolis_api_types::instance_spec::SpecKey; use slog::info; use tokio::sync::{RwLock, RwLockReadGuard, RwLockWriteGuard}; -use crate::{serial::Serial, spec::Spec, vcpu_tasks::VcpuTaskController}; +use crate::{ + initializer::RegisteredChipset, serial::Serial, spec::Spec, + vcpu_tasks::VcpuTaskController, +}; use super::{BlockBackendMap, CrucibleBackendMap, DeviceMap}; @@ -53,6 +56,7 @@ pub(super) struct InputVmObjects { pub framebuffer: Option>, pub ps2ctrl: Arc, pub attest_handle: Option, + pub chipset: Arc, } /// The collection of objects and state that make up a Propolis instance. @@ -91,6 +95,9 @@ pub(crate) struct VmObjectsLocked { /// A handle to the VM's attestation server. attest_handle: Option, + + /// A handle to the VM's mainboard chipset. + chipset: Arc, } impl VmObjects { @@ -132,6 +139,7 @@ impl VmObjectsLocked { framebuffer: input.framebuffer, ps2ctrl: input.ps2ctrl, attest_handle: input.attest_handle, + chipset: input.chipset, } } @@ -194,6 +202,11 @@ impl VmObjectsLocked { &self.ps2ctrl } + /// Yields a clonable reference to this VM's mainboard chipset. + pub(crate) fn chipset(&self) -> &Arc { + &self.chipset + } + pub(crate) fn device_map(&self) -> &DeviceMap { &self.devices } diff --git a/bin/propolis-server/src/lib/vm/request_queue.rs b/bin/propolis-server/src/lib/vm/request_queue.rs index 2608b4933..86ff8c47c 100644 --- a/bin/propolis-server/src/lib/vm/request_queue.rs +++ b/bin/propolis-server/src/lib/vm/request_queue.rs @@ -21,6 +21,8 @@ use slog::{info, Logger}; use thiserror::Error; use uuid::Uuid; +use crate::vm::SoftShutdownFate; + /// Wraps a [`dropshot::WebsocketConnection`] for inclusion in an /// [`ExternalRequest`]. // @@ -60,6 +62,12 @@ pub(crate) enum StateChangeRequest { /// graceful reboot and does not coordinate with guest software. Reboot, + /// Sends an ACPI shutdown signal to the guest, and then either stops + /// or reboots the instance (depending on `fate`) once the guest halts + /// its CPU. If this does not occur within the `timeout`, the guest is + /// ungracefully stopped/rebooted. + ACPIShutdown { fate: SoftShutdownFate, timeout: std::time::Duration }, + /// Halts the VM. Note that this is not a graceful shutdown and does not /// coordinate with guest software. Stop, @@ -74,6 +82,11 @@ impl std::fmt::Debug for StateChangeRequest { .field("migration_id", migration_id) .finish(), Self::Reboot => write!(f, "Reboot"), + Self::ACPIShutdown { fate, timeout } => f + .debug_struct("ACPIShutdown") + .field("fate", fate) + .field("timeout", timeout) + .finish(), Self::Stop => write!(f, "Stop"), } } @@ -129,6 +142,13 @@ impl ExternalRequest { Self::State(StateChangeRequest::Start) } + pub const fn acpi_shutdown( + fate: SoftShutdownFate, + timeout: std::time::Duration, + ) -> Self { + Self::State(StateChangeRequest::ACPIShutdown { fate, timeout }) + } + /// Constructs a VM stop request. pub const fn stop() -> Self { Self::State(StateChangeRequest::Stop) @@ -166,7 +186,16 @@ impl ExternalRequest { } fn is_stop(&self) -> bool { - matches!(self, Self::State(StateChangeRequest::Stop)) + matches!( + self, + Self::State( + StateChangeRequest::Stop + | StateChangeRequest::ACPIShutdown { + fate: SoftShutdownFate::Stop, + .. + } + ) + ) } } @@ -197,6 +226,9 @@ pub(crate) enum RequestDeniedReason { #[error("Instance failed to start or halted due to a failure")] InstanceFailed, + + #[error("Cannot supply shutdown timeout to a Run request")] + TimeoutOnRun, } /// A kind of request that can be popped from the queue and then completed. @@ -270,6 +302,10 @@ pub(super) struct ExternalRequestQueue { /// completed by the state driver. awaiting_stop: bool, + /// True if this queue has enqueued an ACPI shutdown request that has not + /// been completed by the state driver. + awaiting_shutdown: bool, + /// The queue's logger. log: Logger, } @@ -297,6 +333,7 @@ impl ExternalRequestQueue { awaiting_reboot: false, awaiting_migration_out: false, awaiting_stop: false, + awaiting_shutdown: false, log, } } @@ -367,12 +404,20 @@ impl ExternalRequestQueue { assert!(!self.awaiting_migration_out); self.awaiting_migration_out = true; } + ExternalRequest::State(StateChangeRequest::ACPIShutdown { + .. + }) => { + assert!(!self.awaiting_shutdown); + self.awaiting_shutdown = true; + } ExternalRequest::State(StateChangeRequest::Reboot) => { assert!(!self.awaiting_reboot); + self.awaiting_shutdown = false; self.awaiting_reboot = true; } ExternalRequest::State(StateChangeRequest::Stop) => { assert!(!self.awaiting_stop); + self.awaiting_shutdown = false; self.awaiting_stop = true; } ExternalRequest::Component(_) => {} @@ -416,7 +461,7 @@ impl ExternalRequestQueue { // Interpret start requests as requests to reach the Running // state. ExternalRequest::State(StateChangeRequest::Start) => { - if self.awaiting_stop { + if self.awaiting_stop || self.awaiting_shutdown { return Err(RequestDeniedReason::HaltPending); } else if self.state != QueueState::NotStarted { return Ok(false); @@ -433,7 +478,7 @@ impl ExternalRequestQueue { return Err( RequestDeniedReason::AlreadyMigrationSource, ); - } else if self.awaiting_stop { + } else if self.awaiting_stop || self.awaiting_shutdown { return Err(RequestDeniedReason::HaltPending); } else if self.state == QueueState::NotStarted { return Err(RequestDeniedReason::InstanceNotActive); @@ -461,6 +506,37 @@ impl ExternalRequestQueue { } } + // Reject ACPI shutdown requests if the instance is not in a + // state where it could reasonably be expected to respond to + // a power button press (that is to say, Running), and also + // ignore further shutdown requests if one is already happening. + ExternalRequest::State(StateChangeRequest::ACPIShutdown { + .. + }) => match self.state { + QueueState::StartPending => { + return Err(RequestDeniedReason::StartInProgress) + } + QueueState::NotStarted => { + return Err(RequestDeniedReason::InstanceNotActive) + } + QueueState::Stopped => { + return Err(RequestDeniedReason::Halted) + } + QueueState::Failed => { + return Err(RequestDeniedReason::InstanceFailed) + } + QueueState::MigratedOut => { + return Err(RequestDeniedReason::MigratedOut) + } + QueueState::Running => { + if self.awaiting_stop || self.awaiting_reboot { + return Err(RequestDeniedReason::HaltPending); + } else if self.awaiting_shutdown { + return Ok(false); + } + } + }, + // Always queue requests to stop a VM unless one is already // present. // @@ -513,7 +589,8 @@ impl ExternalRequestQueue { } CompletedRequest::Reboot => { assert_eq!(self.state, QueueState::Running); - assert!(self.awaiting_reboot); + // (awaiting_reboot not asserted; would risk data race when a + // just-in-timely halt coincides with ACPI reset timeout task) self.awaiting_reboot = false; } CompletedRequest::MigrationOut { succeeded } => { @@ -539,6 +616,21 @@ impl ExternalRequestQueue { info!(&self.log, "queue notified that VM has stopped"); self.state = QueueState::Stopped; } + + /// Update the queue's `awaiting_shutdown` flag when the guest has an ACPI + /// shutdown request outstanding and a CPU-halting guest event occurs + pub(super) fn notify_shutdown(&mut self) { + info!(&self.log, "queue notified that VM guest has shut down"); + self.awaiting_shutdown = false; + } + + /// Update the queue's `awaiting_reboot` flag when the guest has rebooted for reasons + /// other than an explicit external hard reboot request (i.e. when an ACPI + /// reboot was in progress as a CPU-halting guest event occurred) + pub(super) fn notify_rebooted(&mut self) { + info!(&self.log, "queue notified that VM has rebooted"); + self.awaiting_reboot = false; + } } // It's possible for an external request queue to be dropped with outstanding @@ -583,6 +675,8 @@ impl Drop for ExternalRequestQueue { #[cfg(test)] mod test { + use std::time::Duration; + use super::*; use proptest::prelude::*; @@ -632,6 +726,34 @@ mod test { ); } + #[track_caller] + fn assert_acpi_shutdown(&self) { + assert!( + matches!( + self, + Self::State(StateChangeRequest::ACPIShutdown { + fate: SoftShutdownFate::Stop, + .. + }) + ), + "expected ACPI shutdown request, got {self:?}" + ); + } + + #[track_caller] + fn assert_acpi_reboot(&self) { + assert!( + matches!( + self, + Self::State(StateChangeRequest::ACPIShutdown { + fate: SoftShutdownFate::Reboot, + .. + }) + ), + "expected ACPI reboot request, got {self:?}" + ); + } + #[track_caller] fn assert_migrate_as_source(&self) { assert!( @@ -787,6 +909,198 @@ mod test { assert!(queue.try_queue(ExternalRequest::reboot()).is_err()); } + #[test] + fn acpi_shutdowns_ignored_after_first() { + let mut queue = + ExternalRequestQueue::new(test_logger(), InstanceAutoStart::Yes); + queue.notify_request_completed(CompletedRequest::Start { + succeeded: true, + }); + + assert!(queue.is_empty()); + // enqueue an ACPI shutdown request with intent to stop + assert!(queue + .try_queue(ExternalRequest::acpi_shutdown( + SoftShutdownFate::Stop, + Duration::from_secs(600) + )) + .is_ok()); + + // all these further ACPI shutdown requests should be ignored + // (with intent sent to reboot, just so we can distinguish in this test + // that only the first request was enqueued) + for _ in 0..5 { + assert!(queue + .try_queue(ExternalRequest::acpi_shutdown( + SoftShutdownFate::Reboot, + Duration::from_secs(600) + )) + .is_ok()); + } + queue.pop_front().unwrap().assert_acpi_shutdown(); + assert!(queue.is_empty()); + } + + #[test] + fn acpi_shutdown_overridden_by_hard_stop_request() { + let mut queue = + ExternalRequestQueue::new(test_logger(), InstanceAutoStart::Yes); + queue.notify_request_completed(CompletedRequest::Start { + succeeded: true, + }); + + assert!(queue.is_empty()); + // enqueue an ACPI shutdown request with intent to stop + assert!(queue + .try_queue(ExternalRequest::acpi_shutdown( + SoftShutdownFate::Stop, + Duration::from_secs(600) + )) + .is_ok()); + // enqueue a hard stop + assert!(queue.try_queue(ExternalRequest::stop()).is_ok()); + + queue.pop_front().unwrap().assert_acpi_shutdown(); + queue.pop_front().unwrap().assert_stop(); + + assert!(queue.is_empty()); + } + + #[test] + fn acpi_reboot_overridden_by_hard_stop_request() { + let mut queue = + ExternalRequestQueue::new(test_logger(), InstanceAutoStart::Yes); + queue.notify_request_completed(CompletedRequest::Start { + succeeded: true, + }); + + assert!(queue.is_empty()); + // enqueue an ACPI shutdown request with intent to reboot + assert!(queue + .try_queue(ExternalRequest::acpi_shutdown( + SoftShutdownFate::Reboot, + Duration::from_secs(600) + )) + .is_ok()); + // enqueue a hard stop + assert!(queue.try_queue(ExternalRequest::stop()).is_ok()); + + queue.pop_front().unwrap().assert_acpi_reboot(); + queue.pop_front().unwrap().assert_stop(); + + assert!(queue.is_empty()); + } + + #[test] + fn acpi_shutdown_overridden_by_hard_reboot_request() { + let mut queue = + ExternalRequestQueue::new(test_logger(), InstanceAutoStart::Yes); + queue.notify_request_completed(CompletedRequest::Start { + succeeded: true, + }); + + assert!(queue.is_empty()); + // enqueue an ACPI shutdown request with intent to reboot + assert!(queue + .try_queue(ExternalRequest::acpi_shutdown( + SoftShutdownFate::Stop, + Duration::from_secs(600) + )) + .is_ok()); + // enqueue a hard stop + assert!(queue.try_queue(ExternalRequest::reboot()).is_ok()); + + queue.pop_front().unwrap().assert_acpi_shutdown(); + queue.pop_front().unwrap().assert_reboot(); + + assert!(queue.is_empty()); + } + + #[test] + fn acpi_shutdown_requests_ignored_after_vm_failure() { + let mut queue = + ExternalRequestQueue::new(test_logger(), InstanceAutoStart::Yes); + + queue.notify_request_completed(CompletedRequest::Start { + succeeded: false, + }); + + assert!(queue + .try_queue(ExternalRequest::acpi_shutdown( + SoftShutdownFate::Stop, + Duration::from_secs(600) + )) + .is_ok()); + assert!(queue.is_empty()); + } + + #[test] + fn acpi_shutdown_requests_disallowed_while_stopped() { + let mut queue = + ExternalRequestQueue::new(test_logger(), InstanceAutoStart::Yes); + + queue.notify_request_completed(CompletedRequest::Start { + succeeded: true, + }); + + assert!(queue.try_queue(ExternalRequest::stop()).is_ok()); + + // attempts to shut down while processing a hard stop are an error + assert!(queue + .try_queue(ExternalRequest::acpi_shutdown( + SoftShutdownFate::Stop, + Duration::from_secs(600) + )) + .is_err()); + + queue.pop_front().unwrap().assert_stop(); + queue.notify_request_completed(CompletedRequest::Stop); + assert!(queue.is_empty()); + + // further attempts to shut down while stopped are simply ignored + assert!(queue + .try_queue(ExternalRequest::acpi_shutdown( + SoftShutdownFate::Stop, + Duration::from_secs(600) + )) + .is_ok()); + + assert!(queue.is_empty()); + } + + #[test] + fn acpi_shutdown_requests_disallowed_while_rebooting() { + let mut queue = + ExternalRequestQueue::new(test_logger(), InstanceAutoStart::Yes); + + queue.notify_request_completed(CompletedRequest::Start { + succeeded: true, + }); + + assert!(queue.try_queue(ExternalRequest::reboot()).is_ok()); + assert!(queue + .try_queue(ExternalRequest::acpi_shutdown( + SoftShutdownFate::Stop, + Duration::from_secs(600) + )) + .is_err()); + + queue.pop_front().unwrap().assert_reboot(); + queue.notify_request_completed(CompletedRequest::Reboot); + + // allowed again after reboot is complete + assert!(queue + .try_queue(ExternalRequest::acpi_shutdown( + SoftShutdownFate::Stop, + Duration::from_secs(600) + )) + .is_ok()); + + queue.pop_front().unwrap().assert_acpi_shutdown(); + + assert!(queue.is_empty()); + } + #[test] fn mutation_disallowed_after_stopped() { let mut queue = diff --git a/bin/propolis-server/src/lib/vm/state_driver.rs b/bin/propolis-server/src/lib/vm/state_driver.rs index 613f4f492..ea3899b01 100644 --- a/bin/propolis-server/src/lib/vm/state_driver.rs +++ b/bin/propolis-server/src/lib/vm/state_driver.rs @@ -110,7 +110,9 @@ use crate::{ destination::DestinationProtocol, source::SourceProtocol, MigrateRole, }, spec::StorageBackend, - vm::{state_publisher::ExternalStateUpdate, BlockBackendMap}, + vm::{ + state_publisher::ExternalStateUpdate, BlockBackendMap, SoftShutdownFate, + }, }; use super::{ @@ -273,6 +275,18 @@ impl InputQueue { guard.external_requests.notify_stopped(); } + /// Notifies the external request queue that the instance guest has + /// completed its externally-requested shutdown process. + fn notify_shutdown(&self) { + let mut guard = self.inner.lock().unwrap(); + guard.external_requests.notify_shutdown(); + } + + fn notify_rebooted(&self) { + let mut guard = self.inner.lock().unwrap(); + guard.external_requests.notify_rebooted(); + } + /// Submits an external state change request to the queue. pub(super) fn queue_external_request( &self, @@ -364,6 +378,12 @@ struct StateDriver { /// True if the VM is paused. paused: bool, + /// `Some` if an ACPI shutdown has been requested. + /// Contains a tuple of the action to take after the guest halts its CPUs, + /// the timeout task that will forcibly halt the guest's CPUs if it is not + /// aborted. + soft_off: Option<(SoftShutdownFate, tokio::task::JoinHandle<()>)>, + /// State persisted from previous attempts to migrate out of this VM. migration_src_state: crate::migrate::source::PersistentState, } @@ -428,6 +448,7 @@ pub(super) async fn ensure_vm_and_launch_driver( external_state: state_publisher, paused: false, migration_src_state: Default::default(), + soft_off: None, }; // Run the VM until it exits, then set rundown on the parent VM so that no @@ -759,11 +780,12 @@ impl StateDriver { // reported that it's fully started. Similarly, requests to // start a VM that's already starting are expected to be ignored // for idempotency. - r @ ExternalRequest::State(StateChangeRequest::Start) - | r @ ExternalRequest::State( - StateChangeRequest::MigrateAsSource { .. }, - ) - | r @ ExternalRequest::State(StateChangeRequest::Reboot) => { + r @ ExternalRequest::State( + StateChangeRequest::Start + | StateChangeRequest::MigrateAsSource { .. } + | StateChangeRequest::Reboot + | StateChangeRequest::ACPIShutdown { .. }, + ) => { unreachable!( "external request {r:?} shouldn't be queued while \ starting" @@ -773,22 +795,56 @@ impl StateDriver { } } + async fn stop_and_notify( + &mut self, + direct_external_req: bool, + ) -> HandleEventOutcome { + self.do_halt().await; + + self.external_state + .update(ExternalStateUpdate::Instance(InstanceState::Stopped)); + + if direct_external_req { + self.input_queue.notify_request_completed(CompletedRequest::Stop); + } else { + self.input_queue.notify_stopped(); + } + + HandleEventOutcome::Exit { final_state: InstanceState::Destroyed } + } + async fn handle_guest_event( &mut self, event: GuestEvent, ) -> HandleEventOutcome { + // if an ACPI shutdown request was in progress when a guest event comes, + // we trust the control plane's authority on the ultimate fate + // of the guest's running state -- even if e.g. guest BSoD'd during + // its shutdown process and tried to reboot afterward, when operator + // asked for the guest to be *off*. + if let Some((fate, _)) = &self.soft_off { + self.input_queue.notify_shutdown(); + return match fate { + SoftShutdownFate::Stop => { + info!( + self.log, + "Halting due to {event:?} following power button event" + ); + self.stop_and_notify(false).await + } + SoftShutdownFate::Reboot => { + info!(self.log, "Resetting due to {event:?} following power button event"); + self.do_reboot().await; + self.input_queue.notify_rebooted(); + HandleEventOutcome::Continue + } + }; + } + match event { GuestEvent::VcpuSuspendHalt(_when) => { info!(self.log, "Halting due to VM suspend event",); - self.do_halt().await; - self.external_state.update(ExternalStateUpdate::Instance( - InstanceState::Stopped, - )); - - self.input_queue.notify_stopped(); - HandleEventOutcome::Exit { - final_state: InstanceState::Destroyed, - } + self.stop_and_notify(false).await } GuestEvent::VcpuSuspendReset(_when) => { info!(self.log, "Resetting due to VM suspend event"); @@ -805,15 +861,7 @@ impl StateDriver { } GuestEvent::ChipsetHalt => { info!(self.log, "Halting due to chipset-driven halt"); - self.do_halt().await; - self.external_state.update(ExternalStateUpdate::Instance( - InstanceState::Stopped, - )); - - self.input_queue.notify_stopped(); - HandleEventOutcome::Exit { - final_state: InstanceState::Destroyed, - } + self.stop_and_notify(false).await } GuestEvent::ChipsetReset => { info!(self.log, "Resetting due to chipset-driven reset"); @@ -828,6 +876,49 @@ impl StateDriver { request: ExternalRequest, ) -> HandleEventOutcome { match request { + ExternalRequest::State(StateChangeRequest::ACPIShutdown { + fate, + timeout, + }) => { + // if timeout already in progress, ignore further soft-off reqs + if self.soft_off.is_none() { + let guard = self.objects.lock_exclusive().await; + let chipset = guard.chipset(); + + chipset.acpi_shutdown(); + // TODO? might watch for OSPM clearing the status bit to + // determine if guest acknowledges the button press + + // timeout to hard-stop/reset if guest takes too long + let input_queue_weak = Arc::downgrade(&self.input_queue); + // (philosophically, timeout behavior is part of what was + // externally requested...) + let quasi_ext_req = match fate { + SoftShutdownFate::Stop => ExternalRequest::stop(), + SoftShutdownFate::Reboot => ExternalRequest::reboot(), + }; + let log = self.log.clone(); + self.soft_off = Some(( + fate, + tokio::spawn(async move { + tokio::time::sleep(timeout).await; + if let Some(input_queue) = + input_queue_weak.upgrade() + { + if let Err(e) = input_queue + .queue_external_request(quasi_ext_req) + { + slog::error!( + log, + "Failed to enqueue {fate:?}: {e}" + ); + } + } + }), + )); + } + HandleEventOutcome::Continue + } ExternalRequest::State(StateChangeRequest::Start) => { // If this start attempt produces a terminal VM state, return it // to the driver and indicate that the driver should exit. @@ -846,7 +937,13 @@ impl StateDriver { migration_id, websock, }) => { - if self + if let Some((fate, _)) = &self.soft_off { + slog::warn!( + self.log, + "Ignored MigrateAsSource while ACPI {fate:?} in progress" + ); + HandleEventOutcome::Continue + } else if self .migrate_as_source(migration_id, websock.into_inner()) .await .is_ok() @@ -867,17 +964,7 @@ impl StateDriver { HandleEventOutcome::Continue } ExternalRequest::State(StateChangeRequest::Stop) => { - self.do_halt().await; - self.external_state.update(ExternalStateUpdate::Instance( - InstanceState::Stopped, - )); - - self.input_queue - .notify_request_completed(CompletedRequest::Stop); - - HandleEventOutcome::Exit { - final_state: InstanceState::Destroyed, - } + self.stop_and_notify(true).await } ExternalRequest::Component( ComponentChangeRequest::ReconfigureCrucibleVolume { @@ -898,6 +985,11 @@ impl StateDriver { async fn do_reboot(&mut self) { info!(self.log, "resetting instance"); + // first abandon any timeout task that may send a redundant request + if let Some((_, timeout_task)) = self.soft_off.take() { + timeout_task.abort(); + } + self.external_state .update(ExternalStateUpdate::Instance(InstanceState::Rebooting)); @@ -911,6 +1003,12 @@ impl StateDriver { async fn do_halt(&mut self) { info!(self.log, "stopping instance"); + + // first abandon any timeout task that may send a redundant request + if let Some((_, timeout_task)) = self.soft_off.take() { + timeout_task.abort(); + } + self.external_state .update(ExternalStateUpdate::Instance(InstanceState::Stopping)); diff --git a/bin/propolis-standalone/src/main.rs b/bin/propolis-standalone/src/main.rs index 57c290331..aa7f5b93b 100644 --- a/bin/propolis-standalone/src/main.rs +++ b/bin/propolis-standalone/src/main.rs @@ -1230,6 +1230,7 @@ fn setup_instance( let chipset_pm = i440fx::Piix3PM::create( machine.hdl.clone(), chipset_hb.power_pin(), + chipset_lpc.sci_pin(), log.new(slog::o!("device" => "piix3pm")), ); diff --git a/crates/propolis-api-types-versions/src/acpi_shutdown/instance.rs b/crates/propolis-api-types-versions/src/acpi_shutdown/instance.rs new file mode 100644 index 000000000..ed655c9c0 --- /dev/null +++ b/crates/propolis-api-types-versions/src/acpi_shutdown/instance.rs @@ -0,0 +1,19 @@ +// 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 schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::v1::instance::InstanceStateRequested; + +/// Requested state change of an Instance. +#[derive(Clone, Copy, Deserialize, Serialize, JsonSchema)] +pub struct InstanceStateChange { + /// The desired state for the Instance. + pub state: InstanceStateRequested, + /// The number of seconds to wait after sending ACPI `PWRBTN_STS` before + /// forcing stop/reset. (If omitted, stop/reset are forced immediately, + /// and the ACPI signal is not sent.) + pub acpi_timeout_secs: Option, +} diff --git a/crates/propolis-api-types-versions/src/acpi_shutdown/mod.rs b/crates/propolis-api-types-versions/src/acpi_shutdown/mod.rs new file mode 100644 index 000000000..fed465ed4 --- /dev/null +++ b/crates/propolis-api-types-versions/src/acpi_shutdown/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 instance; diff --git a/crates/propolis-api-types-versions/src/latest.rs b/crates/propolis-api-types-versions/src/latest.rs index 5e64474b0..b440742c5 100644 --- a/crates/propolis-api-types-versions/src/latest.rs +++ b/crates/propolis-api-types-versions/src/latest.rs @@ -65,7 +65,6 @@ pub mod instance { pub use crate::v1::instance::InstancePathParams; pub use crate::v1::instance::InstanceProperties; pub use crate::v1::instance::InstanceState; - pub use crate::v1::instance::InstanceStateChange; pub use crate::v1::instance::InstanceStateMonitorRequest; pub use crate::v1::instance::InstanceStateMonitorResponse; pub use crate::v1::instance::InstanceStateRequested; @@ -73,6 +72,8 @@ pub mod instance { pub use crate::v6::api::InstanceEnsureRequest; pub use crate::v6::api::InstanceInitializationMethod; + + pub use crate::v7::instance::InstanceStateChange; } pub mod instance_spec { diff --git a/crates/propolis-api-types-versions/src/lib.rs b/crates/propolis-api-types-versions/src/lib.rs index aecc1a645..eb2829df2 100644 --- a/crates/propolis-api-types-versions/src/lib.rs +++ b/crates/propolis-api-types-versions/src/lib.rs @@ -41,3 +41,5 @@ pub mod v3; pub mod v5; #[path = "nvme_write_cache/mod.rs"] pub mod v6; +#[path = "acpi_shutdown/mod.rs"] +pub mod v7; diff --git a/crates/propolis-server-api/src/lib.rs b/crates/propolis-server-api/src/lib.rs index 298d0d33e..684d9df26 100644 --- a/crates/propolis-server-api/src/lib.rs +++ b/crates/propolis-server-api/src/lib.rs @@ -22,6 +22,7 @@ api_versions!([ // | example for the next person. // v // (next_int, IDENT), + (7, ACPI_SHUTDOWN), (6, NVME_WRITE_CACHE), (5, CRUCIBLE_VOLUME_INFO), (4, DROPSHOT_BUMP_WEBSOCKET), @@ -213,12 +214,34 @@ pub trait PropolisServerApi { #[endpoint { method = PUT, path = "/instance/state", + versions = VERSION_ACPI_SHUTDOWN.. }] async fn instance_state_put( rqctx: RequestContext, - request: TypedBody, + request: TypedBody, ) -> Result; + #[endpoint { + operation_id = "instance_state_put", + method = PUT, + path = "/instance/state", + versions = ..VERSION_ACPI_SHUTDOWN + }] + async fn instance_state_put_v6( + rqctx: RequestContext, + request: TypedBody, + ) -> Result { + Self::instance_state_put( + rqctx, + latest::instance::InstanceStateChange { + state: request.into_inner(), + acpi_timeout_secs: None, + } + .into(), + ) + .await + } + #[endpoint { method = GET, path = "/instance/serial/history", diff --git a/lib/propolis/src/hw/chipset/i440fx.rs b/lib/propolis/src/hw/chipset/i440fx.rs index beafb08a1..2a1523ed6 100644 --- a/lib/propolis/src/hw/chipset/i440fx.rs +++ b/lib/propolis/src/hw/chipset/i440fx.rs @@ -103,7 +103,6 @@ struct IrqConfig { lnk_pins: [Arc; 4], - #[allow(unused)] // XXX: wire up SCI notifications sci_pin: Arc, } @@ -466,6 +465,10 @@ impl Piix3Lpc { .pin_handle(irq) .map(|pin| Box::new(pin) as Box) } + + pub fn sci_pin(&self) -> Arc { + Arc::clone(&self.irq_config.sci_pin) as Arc + } } impl pci::Device for Piix3Lpc { fn device_state(&self) -> &pci::DeviceState { @@ -803,12 +806,14 @@ pub struct Piix3PM { regs: Mutex, power_pin: Arc, + sci_pin: Arc, log: slog::Logger, } impl Piix3PM { pub fn create( hdl: Arc, power_pin: Arc, + sci_pin: Arc, log: slog::Logger, ) -> Arc { let pci_state = pci::Builder::new(pci::Ident { @@ -836,6 +841,7 @@ impl Piix3PM { regs: Mutex::new(regs), power_pin, + sci_pin, log, }) } @@ -848,6 +854,12 @@ impl Piix3PM { pio.register(PMBASE_DEFAULT, PMBASE_LEN, piofn).unwrap(); } + pub fn acpi_shutdown(&self) { + let mut regs = self.regs.lock().unwrap(); + regs.pm_status.insert(PmSts::PWRBTN_STS); + self.sci_pin.pulse(); + } + fn pio_rw(&self, _port: u16, mut rwo: RWOp) { PM_REGS.process(&mut rwo, |id, rwo| match rwo { RWOp::Read(ro) => self.pmreg_read(id, ro), @@ -1185,8 +1197,9 @@ mod test { let scaffold = Scaffold::new(); let log = Logger::root(Discard, slog::o!()); let power_pin = Arc::new(NoOpPin {}); + let sci_pin = Arc::new(NoOpPin {}); - let pm = Piix3PM::create(hdl, power_pin, log); + let pm = Piix3PM::create(hdl, power_pin, sci_pin, log); let _bus = setup_attach(&scaffold, pm.clone()); cfg_read(pm.as_ref() as &dyn Endpoint); @@ -1198,8 +1211,9 @@ mod test { let scaffold = Scaffold::new(); let log = Logger::root(Discard, slog::o!()); let power_pin = Arc::new(NoOpPin {}); + let sci_pin = Arc::new(NoOpPin {}); - let pm = Piix3PM::create(hdl, power_pin, log); + let pm = Piix3PM::create(hdl, power_pin, sci_pin, log); let _bus = setup_attach(&scaffold, pm.clone()); cfg_write(pm.as_ref() as &dyn Endpoint); diff --git a/openapi/propolis-server/propolis-server-6.0.0-b5b984.json.gitstub b/openapi/propolis-server/propolis-server-6.0.0-b5b984.json.gitstub new file mode 100644 index 000000000..a2c8f9747 --- /dev/null +++ b/openapi/propolis-server/propolis-server-6.0.0-b5b984.json.gitstub @@ -0,0 +1 @@ +046f74302e2ea09a75b0a6810645d42c7df6644a:openapi/propolis-server/propolis-server-6.0.0-b5b984.json diff --git a/openapi/propolis-server/propolis-server-6.0.0-b5b984.json b/openapi/propolis-server/propolis-server-7.0.0-a344ff.json similarity index 98% rename from openapi/propolis-server/propolis-server-6.0.0-b5b984.json rename to openapi/propolis-server/propolis-server-7.0.0-a344ff.json index 1bfc621c4..5718d81fe 100644 --- a/openapi/propolis-server/propolis-server-6.0.0-b5b984.json +++ b/openapi/propolis-server/propolis-server-7.0.0-a344ff.json @@ -7,7 +7,7 @@ "url": "https://oxide.computer", "email": "api@oxide.computer" }, - "version": "6.0.0" + "version": "7.0.0" }, "paths": { "/instance": { @@ -399,7 +399,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/InstanceStateRequested" + "$ref": "#/components/schemas/InstanceStateChange" } } }, @@ -1654,6 +1654,30 @@ "Destroyed" ] }, + "InstanceStateChange": { + "description": "Requested state change of an Instance.", + "type": "object", + "properties": { + "acpi_timeout_secs": { + "nullable": true, + "description": "The number of seconds to wait after sending ACPI `PWRBTN_STS` before forcing stop/reset. (If omitted, stop/reset are forced immediately, and the ACPI signal is not sent.)", + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "state": { + "description": "The desired state for the Instance.", + "allOf": [ + { + "$ref": "#/components/schemas/InstanceStateRequested" + } + ] + } + }, + "required": [ + "state" + ] + }, "InstanceStateMonitorRequest": { "type": "object", "properties": { diff --git a/openapi/propolis-server/propolis-server-latest.json b/openapi/propolis-server/propolis-server-latest.json index 114ec385b..b1cd71342 120000 --- a/openapi/propolis-server/propolis-server-latest.json +++ b/openapi/propolis-server/propolis-server-latest.json @@ -1 +1 @@ -propolis-server-6.0.0-b5b984.json \ No newline at end of file +propolis-server-7.0.0-a344ff.json \ No newline at end of file diff --git a/phd-tests/framework/src/test_vm/mod.rs b/phd-tests/framework/src/test_vm/mod.rs index 6514d46c7..b2cec894d 100644 --- a/phd-tests/framework/src/test_vm/mod.rs +++ b/phd-tests/framework/src/test_vm/mod.rs @@ -37,7 +37,7 @@ use propolis_client::{ InstanceEnsureRequest, InstanceGetResponse, InstanceInitializationMethod, InstanceMigrateStatusResponse, InstanceSerialConsoleHistoryResponse, InstanceState, - InstanceStateRequested, MigrationState, + InstanceStateChange, InstanceStateRequested, MigrationState, }, }; use propolis_client::{Client, ResponseValue}; @@ -558,27 +558,58 @@ impl TestVm { /// Sets the VM to the running state without first sending an instance /// ensure request. pub async fn run(&self) -> PropolisClientResult<()> { - self.put_instance_state(InstanceStateRequested::Run).await + self.put_instance_state(InstanceStateRequested::Run, None).await } /// Stops the VM. pub async fn stop(&self) -> PropolisClientResult<()> { - self.put_instance_state(InstanceStateRequested::Stop).await + self.put_instance_state(InstanceStateRequested::Stop, None).await } /// Resets the VM by requesting the `Reboot` state from the server (as /// distinct from requesting a reboot from within the guest). pub async fn reset(&self) -> PropolisClientResult<()> { - self.put_instance_state(InstanceStateRequested::Reboot).await + self.put_instance_state(InstanceStateRequested::Reboot, None).await + } + + /// Sends a power button press, and forcefully stops the VM after the given + /// timeout if the guest hasn't shut itself down by then. + pub async fn acpi_shutdown( + &self, + timeout_secs: u64, + ) -> PropolisClientResult<()> { + self.put_instance_state( + InstanceStateRequested::Stop, + Some(timeout_secs), + ) + .await + } + + /// Reboots the VM after a guest shutdown triggered by a power button press, + /// or forcefully after the given timeout if the guest does not shut down. + pub async fn acpi_reset( + &self, + timeout_secs: u64, + ) -> PropolisClientResult<()> { + self.put_instance_state( + InstanceStateRequested::Reboot, + Some(timeout_secs), + ) + .await } #[instrument(skip_all, fields(vm = self.spec.vm_name, vm_id = %self.id))] async fn put_instance_state( &self, state: InstanceStateRequested, + acpi_timeout_secs: Option, ) -> PropolisClientResult<()> { info!(?state, "Requesting instance state change"); - self.client.instance_state_put().body(state).send().await + self.client + .instance_state_put() + .body(InstanceStateChange { state, acpi_timeout_secs }) + .send() + .await } /// Issues a Propolis client `instance_get` request. @@ -1195,7 +1226,10 @@ async fn try_ensure_vm_destroyed(client: &Client) { debug!("trying to ensure Propolis server VM is destroyed"); if let Err(error) = client .instance_state_put() - .body(InstanceStateRequested::Stop) + .body(InstanceStateChange { + state: InstanceStateRequested::Stop, + acpi_timeout_secs: None, + }) .send() .await { diff --git a/phd-tests/tests/src/acpi_shutdown.rs b/phd-tests/tests/src/acpi_shutdown.rs new file mode 100644 index 000000000..d31eb2600 --- /dev/null +++ b/phd-tests/tests/src/acpi_shutdown.rs @@ -0,0 +1,144 @@ +// 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::time::Duration; + +use phd_framework::guest_os::GuestOsKind; +use phd_testcase::{phd_framework::test_vm::MigrationTimeout, *}; + +use propolis_client::types::InstanceState; +use uuid::Uuid; + +/// start an alpine TestVm that will *not* respond to pwrbtn events. +async fn non_acpid_vm( + ctx: &TestCtx, + vm_name: &str, +) -> phd_testcase::Result { + if ctx.default_guest_os_kind().await? != GuestOsKind::Alpine { + // other more heavyweight distros probably listen for pwrbtn events + phd_skip!("acpi_shutdown tests expect alpine default behaviors"); + } + + let mut vm = ctx.spawn_default_vm(vm_name).await?; + vm.launch().await?; + vm.wait_to_boot().await?; + Ok(vm) +} + +/// start an alpine TestVm that *will* respond to pwrbtn events. +async fn acpid_vm( + ctx: &TestCtx, + vm_name: &str, +) -> phd_testcase::Result { + let vm = non_acpid_vm(ctx, vm_name).await?; + + // alpine won't respond to acpi pwrbtn events without acpid running. + vm.run_shell_command("acpid").await?; + + Ok(vm) +} + +#[phd_testcase] +async fn acpi_shutdown_stop(ctx: &TestCtx) { + let vm = acpid_vm(ctx, "acpi_shutdown_stop").await?; + + // pwrbtn with intent to stop + vm.acpi_shutdown(60).await?; + + // note: timeout *before* the above elapsed, we specifically want to see + // that the guest reacted to the power button press by shutting itself down + vm.wait_for_state(InstanceState::Destroyed, Duration::from_secs(30)) + .await?; +} + +#[phd_testcase] +async fn acpi_shutdown_reboot(ctx: &TestCtx) { + let vm = acpid_vm(ctx, "acpi_shutdown_reboot").await?; + + for i in 0..5 { + // pwrbtn with intent to reboot, alternatingly being + // likely to be hard cut-off by the timeout + vm.acpi_reset(if i & 1 == 0 { 30 } else { 1 }).await?; + + // wait for the login prompt again + vm.wait_to_boot().await?; + } +} + +#[phd_testcase] +async fn acpi_shutdown_interject_with_hard_stop(ctx: &TestCtx) { + let vm = + non_acpid_vm(ctx, "acpi_shutdown_interject_with_hard_stop").await?; + + // note: *not* running acpid, so alpine will ignore pwrbtn. + // sending a 'reset' request so we can be sure it was ignored when the + // instance ends up destroyed + vm.acpi_reset(60).await?; + + // give up waiting for timeout to elapse + tokio::time::sleep(Duration::from_secs(3)).await; + vm.stop().await?; + + vm.wait_for_state(InstanceState::Destroyed, Duration::from_secs(30)) + .await?; +} + +#[phd_testcase] +async fn acpi_shutdown_interject_with_hard_reboot(ctx: &TestCtx) { + let vm = + non_acpid_vm(ctx, "acpi_shutdown_interject_with_hard_reboot").await?; + + // note: *not* running acpid, so alpine will ignore pwrbtn. + // sending a 'shutdown' request so we can be sure it was ignored when the + // instance ends up at login prompt again + vm.acpi_shutdown(60).await?; + + // give up waiting for timeout to elapse + tokio::time::sleep(Duration::from_secs(3)).await; + vm.reset().await?; + + vm.wait_to_boot().await?; +} + +#[phd_testcase] +async fn acpi_shutdown_timeout_stop(ctx: &TestCtx) { + let vm = non_acpid_vm(ctx, "acpi_shutdown_timeout_stop").await?; + + // note: *not* running acpid, so alpine should ignore pwrbtn + // and be hard-stopped in 1 second + vm.acpi_shutdown(1).await?; + + vm.wait_for_state(InstanceState::Destroyed, Duration::from_secs(5)).await?; +} + +#[phd_testcase] +async fn acpi_shutdown_timeout_reboot(ctx: &TestCtx) { + let vm = non_acpid_vm(ctx, "acpi_shutdown_timeout_reboot").await?; + + // note: *not* running acpid, so alpine should ignore pwrbtn + // and be hard-reset in 1 second + vm.acpi_reset(1).await?; + + // wait for the login prompt again + vm.wait_to_boot().await?; +} + +#[phd_testcase] +async fn acpi_shutdown_reboot_then_migrate(ctx: &TestCtx) { + let vm0 = acpid_vm(ctx, "acpi_shutdown_reboot_then_migrate_vm0").await?; + let mut vm1 = ctx + .spawn_successor_vm("acpi_shutdown_reboot_then_migrate_vm1", &vm0, None) + .await?; + + vm0.acpi_reset(30).await?; + // wait for the login prompt again + vm0.wait_to_boot().await?; + + vm1.migrate_from(&vm0, Uuid::new_v4(), MigrationTimeout::default()).await?; + + // and let's try that again on the successor + vm1.acpi_reset(30).await?; + // wait for the login prompt yet again + vm1.wait_to_boot().await?; +} diff --git a/phd-tests/tests/src/lib.rs b/phd-tests/tests/src/lib.rs index 2e061051a..1cdec013c 100644 --- a/phd-tests/tests/src/lib.rs +++ b/phd-tests/tests/src/lib.rs @@ -4,6 +4,7 @@ pub use phd_testcase; +mod acpi_shutdown; mod boot_order; mod cpuid; mod crucible; From 93d41154a7b32ead8aa6e9759b06054dc517a0f3 Mon Sep 17 00:00:00 2001 From: lif <> Date: Fri, 2 Oct 2026 07:46:43 +0000 Subject: [PATCH 02/10] update mock-server instance_state_put to match real server --- bin/mock-server/src/lib/lib.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/bin/mock-server/src/lib/lib.rs b/bin/mock-server/src/lib/lib.rs index 345738a43..f2aea8955 100644 --- a/bin/mock-server/src/lib/lib.rs +++ b/bin/mock-server/src/lib/lib.rs @@ -344,7 +344,7 @@ async fn instance_state_monitor( }] async fn instance_state_put( rqctx: RequestContext>, - request: TypedBody, + request: TypedBody, ) -> Result { let mut instance = rqctx.context().instance.lock().await; let instance = instance.as_mut().ok_or_else(|| { @@ -353,13 +353,14 @@ async fn instance_state_put( ) })?; let requested_state = request.into_inner(); - instance.set_target_state(&rqctx.log, requested_state).await.map_err( - |err| { + instance + .set_target_state(&rqctx.log, requested_state.state) + .await + .map_err(|err| { HttpError::for_internal_error(format!( "Failed to transition: {err}" )) - }, - )?; + })?; Ok(HttpResponseUpdatedNoContent {}) } From 0d3e18fb7993405a2347647553ac08838d08d617 Mon Sep 17 00:00:00 2001 From: lif <> Date: Wed, 14 Oct 2026 07:09:02 +0000 Subject: [PATCH 03/10] pr nits --- .../src/lib/vm/state_driver.rs | 83 ++++++++++--------- lib/propolis/src/hw/chipset/i440fx.rs | 2 +- 2 files changed, 47 insertions(+), 38 deletions(-) diff --git a/bin/propolis-server/src/lib/vm/state_driver.rs b/bin/propolis-server/src/lib/vm/state_driver.rs index ea3899b01..18c23b6dd 100644 --- a/bin/propolis-server/src/lib/vm/state_driver.rs +++ b/bin/propolis-server/src/lib/vm/state_driver.rs @@ -880,43 +880,43 @@ impl StateDriver { fate, timeout, }) => { - // if timeout already in progress, ignore further soft-off reqs - if self.soft_off.is_none() { - let guard = self.objects.lock_exclusive().await; - let chipset = guard.chipset(); - - chipset.acpi_shutdown(); - // TODO? might watch for OSPM clearing the status bit to - // determine if guest acknowledges the button press - - // timeout to hard-stop/reset if guest takes too long - let input_queue_weak = Arc::downgrade(&self.input_queue); - // (philosophically, timeout behavior is part of what was - // externally requested...) - let quasi_ext_req = match fate { - SoftShutdownFate::Stop => ExternalRequest::stop(), - SoftShutdownFate::Reboot => ExternalRequest::reboot(), - }; - let log = self.log.clone(); - self.soft_off = Some(( - fate, - tokio::spawn(async move { - tokio::time::sleep(timeout).await; - if let Some(input_queue) = - input_queue_weak.upgrade() + if self.soft_off.is_some() { + // timeout already in progress, ignore further soft-off reqs + return HandleEventOutcome::Continue; + } + + let guard = self.objects.lock_exclusive().await; + let chipset = guard.chipset(); + + chipset.acpi_shutdown(); + // TODO? might watch for OSPM clearing the status bit to + // determine if guest acknowledges the button press + + // timeout to hard-stop/reset if guest takes too long + let input_queue_weak = Arc::downgrade(&self.input_queue); + // (philosophically, timeout behavior is part of what was + // externally requested...) + let quasi_ext_req = match fate { + SoftShutdownFate::Stop => ExternalRequest::stop(), + SoftShutdownFate::Reboot => ExternalRequest::reboot(), + }; + let log = self.log.clone(); + self.soft_off = Some(( + fate, + tokio::spawn(async move { + tokio::time::sleep(timeout).await; + if let Some(input_queue) = input_queue_weak.upgrade() { + if let Err(e) = input_queue + .queue_external_request(quasi_ext_req) { - if let Err(e) = input_queue - .queue_external_request(quasi_ext_req) - { - slog::error!( - log, - "Failed to enqueue {fate:?}: {e}" - ); - } + slog::error!( + log, + "Failed to enqueue {fate:?}: {e}" + ); } - }), - )); - } + } + }), + )); HandleEventOutcome::Continue } ExternalRequest::State(StateChangeRequest::Start) => { @@ -985,7 +985,13 @@ impl StateDriver { async fn do_reboot(&mut self) { info!(self.log, "resetting instance"); - // first abandon any timeout task that may send a redundant request + // first, abandon any timeout task that may send a redundant request. + // if the guest has, say, raced us with an independently-originating + // power signal, at worst we'd process a couple reboots in quick + // succession. (what we *don't* want is for the guest to successfully + // shut down while we're waiting for it to do so, have us reboot it + // here, and then have the timeout task hard-reboot minutes later + // once it's come back up) if let Some((_, timeout_task)) = self.soft_off.take() { timeout_task.abort(); } @@ -1004,7 +1010,10 @@ impl StateDriver { async fn do_halt(&mut self) { info!(self.log, "stopping instance"); - // first abandon any timeout task that may send a redundant request + // first, abandon any timeout task that may send a redundant request. + // (if the guest has raced us with a power signal, turning off what + // is already off is fine - but we certainly don't want someone turning + // the instance back *on* later, then our old timeout task offs it) if let Some((_, timeout_task)) = self.soft_off.take() { timeout_task.abort(); } diff --git a/lib/propolis/src/hw/chipset/i440fx.rs b/lib/propolis/src/hw/chipset/i440fx.rs index 2a1523ed6..58d89ffa0 100644 --- a/lib/propolis/src/hw/chipset/i440fx.rs +++ b/lib/propolis/src/hw/chipset/i440fx.rs @@ -103,7 +103,6 @@ struct IrqConfig { lnk_pins: [Arc; 4], - // XXX: wire up SCI notifications sci_pin: Arc, } impl IrqConfig { @@ -854,6 +853,7 @@ impl Piix3PM { pio.register(PMBASE_DEFAULT, PMBASE_LEN, piofn).unwrap(); } + // XXX: handle the logical OR of "SCI" inputs when we have many, see future hotplug work pub fn acpi_shutdown(&self) { let mut regs = self.regs.lock().unwrap(); regs.pm_status.insert(PmSts::PWRBTN_STS); From e735f1c925e8723de64a005de0fdef46632c718b Mon Sep 17 00:00:00 2001 From: lif <> Date: Thu, 15 Oct 2026 05:57:01 +0000 Subject: [PATCH 04/10] let the operator change their mind about an instance's post-shutdown destiny. the future is whatever you make it --- .../src/lib/vm/request_queue.rs | 32 +++++----- .../src/lib/vm/state_driver.rs | 60 ++++++++++++------- phd-tests/tests/src/acpi_shutdown.rs | 17 ++++++ 3 files changed, 71 insertions(+), 38 deletions(-) diff --git a/bin/propolis-server/src/lib/vm/request_queue.rs b/bin/propolis-server/src/lib/vm/request_queue.rs index 86ff8c47c..1390f2c08 100644 --- a/bin/propolis-server/src/lib/vm/request_queue.rs +++ b/bin/propolis-server/src/lib/vm/request_queue.rs @@ -407,7 +407,9 @@ impl ExternalRequestQueue { ExternalRequest::State(StateChangeRequest::ACPIShutdown { .. }) => { - assert!(!self.awaiting_shutdown); + // subsequent shutdown requests may be seeking to change + // the post-shutdown instance fate (stop vs. reboot), + // so don't assert !awaiting_shutdown self.awaiting_shutdown = true; } ExternalRequest::State(StateChangeRequest::Reboot) => { @@ -509,7 +511,8 @@ impl ExternalRequestQueue { // Reject ACPI shutdown requests if the instance is not in a // state where it could reasonably be expected to respond to // a power button press (that is to say, Running), and also - // ignore further shutdown requests if one is already happening. + // allow further shutdown requests if one is already happening + // to allow changing the post-shutdown action (stop/reboot). ExternalRequest::State(StateChangeRequest::ACPIShutdown { .. }) => match self.state { @@ -531,8 +534,6 @@ impl ExternalRequestQueue { QueueState::Running => { if self.awaiting_stop || self.awaiting_reboot { return Err(RequestDeniedReason::HaltPending); - } else if self.awaiting_shutdown { - return Ok(false); } } }, @@ -910,7 +911,7 @@ mod test { } #[test] - fn acpi_shutdowns_ignored_after_first() { + fn acpi_shutdowns_after_acpi_shutdown_allowed() { let mut queue = ExternalRequestQueue::new(test_logger(), InstanceAutoStart::Yes); queue.notify_request_completed(CompletedRequest::Start { @@ -926,18 +927,17 @@ mod test { )) .is_ok()); - // all these further ACPI shutdown requests should be ignored - // (with intent sent to reboot, just so we can distinguish in this test - // that only the first request was enqueued) - for _ in 0..5 { - assert!(queue - .try_queue(ExternalRequest::acpi_shutdown( - SoftShutdownFate::Reboot, - Duration::from_secs(600) - )) - .is_ok()); - } + // a further ACPI shutdown request with intent sent to reboot, + // just so we can distinguish from the previous one + assert!(queue + .try_queue(ExternalRequest::acpi_shutdown( + SoftShutdownFate::Reboot, + Duration::from_secs(600) + )) + .is_ok()); + queue.pop_front().unwrap().assert_acpi_shutdown(); + queue.pop_front().unwrap().assert_acpi_reboot(); assert!(queue.is_empty()); } diff --git a/bin/propolis-server/src/lib/vm/state_driver.rs b/bin/propolis-server/src/lib/vm/state_driver.rs index 18c23b6dd..c1bba6b05 100644 --- a/bin/propolis-server/src/lib/vm/state_driver.rs +++ b/bin/propolis-server/src/lib/vm/state_driver.rs @@ -382,7 +382,10 @@ struct StateDriver { /// Contains a tuple of the action to take after the guest halts its CPUs, /// the timeout task that will forcibly halt the guest's CPUs if it is not /// aborted. - soft_off: Option<(SoftShutdownFate, tokio::task::JoinHandle<()>)>, + soft_off: Option<( + Arc>, + tokio::task::JoinHandle<()>, + )>, /// State persisted from previous attempts to migrate out of this VM. migration_src_state: crate::migrate::source::PersistentState, @@ -824,7 +827,8 @@ impl StateDriver { // asked for the guest to be *off*. if let Some((fate, _)) = &self.soft_off { self.input_queue.notify_shutdown(); - return match fate { + let fate_val = *fate.lock().await; + return match fate_val { SoftShutdownFate::Stop => { info!( self.log, @@ -880,8 +884,11 @@ impl StateDriver { fate, timeout, }) => { - if self.soft_off.is_some() { - // timeout already in progress, ignore further soft-off reqs + if let Some((fate_mut, _)) = &mut self.soft_off { + // timeout already in progress, don't start timeout task + // anew or send further PWRBTN_STS, but do change the + // post-shutdown fate + *fate_mut.lock().await = fate; return HandleEventOutcome::Continue; } @@ -889,31 +896,40 @@ impl StateDriver { let chipset = guard.chipset(); chipset.acpi_shutdown(); - // TODO? might watch for OSPM clearing the status bit to - // determine if guest acknowledges the button press + + // TODO? in future we may watch for OSPM clearing the status bit + // (per ACPI 4.8.2.2.1.1) after above chipset.acpi_shutdown call + // call to determine whether the guest has acknowledged the push + // of the power button. mayhap consider adding a tighter timeout + // task to terminate/reboot sooner if the kernel is so wedged or + // absent that it didn't even respond to pulling PWRBTN_STS high // timeout to hard-stop/reset if guest takes too long - let input_queue_weak = Arc::downgrade(&self.input_queue); - // (philosophically, timeout behavior is part of what was - // externally requested...) - let quasi_ext_req = match fate { - SoftShutdownFate::Stop => ExternalRequest::stop(), - SoftShutdownFate::Reboot => ExternalRequest::reboot(), - }; + let queue_weak = Arc::downgrade(&self.input_queue); + let fate_strong = Arc::new(tokio::sync::Mutex::new(fate)); + let fate_weak = Arc::downgrade(&fate_strong); let log = self.log.clone(); self.soft_off = Some(( - fate, + fate_strong, tokio::spawn(async move { tokio::time::sleep(timeout).await; - if let Some(input_queue) = input_queue_weak.upgrade() { - if let Err(e) = input_queue - .queue_external_request(quasi_ext_req) - { - slog::error!( - log, - "Failed to enqueue {fate:?}: {e}" - ); + let Some(queue) = queue_weak.upgrade() else { return }; + let Some(fate) = fate_weak.upgrade() else { return }; + let quasi_ext_req = match *fate.lock().await { + SoftShutdownFate::Stop => ExternalRequest::stop(), + SoftShutdownFate::Reboot => { + ExternalRequest::reboot() } + }; + // (philosophically, timeout behavior is part of + // what was externally-requested...) + if let Err(e) = + queue.queue_external_request(quasi_ext_req) + { + slog::error!( + log, + "Failed to enqueue {fate:?}: {e}" + ); } }), )); diff --git a/phd-tests/tests/src/acpi_shutdown.rs b/phd-tests/tests/src/acpi_shutdown.rs index d31eb2600..d97fb774c 100644 --- a/phd-tests/tests/src/acpi_shutdown.rs +++ b/phd-tests/tests/src/acpi_shutdown.rs @@ -101,6 +101,23 @@ async fn acpi_shutdown_interject_with_hard_reboot(ctx: &TestCtx) { vm.wait_to_boot().await?; } +#[phd_testcase] +async fn acpi_shutdown_change_of_fate(ctx: &TestCtx) { + let vm = + non_acpid_vm(ctx, "acpi_shutdown_interject_with_soft_reboot").await?; + + // note: *not* running acpid, so alpine will ignore pwrbtn. + // sending a 'shutdown' request so we can be sure we don't race with a + // very fast guest shutdown, but rather our given timeout + vm.acpi_reset(10).await?; + // this shouldn't change the timeout, but it should make the instance stop + // after the *previous* timeout + vm.acpi_shutdown(60).await?; + + vm.wait_for_state(InstanceState::Destroyed, Duration::from_secs(20)) + .await?; +} + #[phd_testcase] async fn acpi_shutdown_timeout_stop(ctx: &TestCtx) { let vm = non_acpid_vm(ctx, "acpi_shutdown_timeout_stop").await?; From 5e6270dd4b936d860bdb1b6c8979174c29497f79 Mon Sep 17 00:00:00 2001 From: lif <> Date: Sat, 17 Oct 2026 04:30:37 +0000 Subject: [PATCH 05/10] add explicit InstanceRequestedState variants for AcpiStop and AcpiReboot instead of just implying it via the presence of timeout --- bin/propolis-cli/src/main.rs | 7 ++- bin/propolis-server/src/lib/vm/active.rs | 35 ++++++----- .../src/lib/vm/request_queue.rs | 31 +++++----- .../src/lib/vm/state_driver.rs | 59 +++++++++---------- .../src/acpi_shutdown/instance.rs | 24 +++++++- .../propolis-api-types-versions/src/latest.rs | 2 +- crates/propolis-server-api/src/lib.rs | 2 +- ...json => propolis-server-7.0.0-772a7f.json} | 6 +- .../propolis-server-latest.json | 2 +- phd-tests/framework/src/test_vm/mod.rs | 4 +- 10 files changed, 100 insertions(+), 72 deletions(-) rename openapi/propolis-server/{propolis-server-7.0.0-a344ff.json => propolis-server-7.0.0-772a7f.json} (99%) diff --git a/bin/propolis-cli/src/main.rs b/bin/propolis-cli/src/main.rs index 0690a4bcf..aae5c889f 100644 --- a/bin/propolis-cli/src/main.rs +++ b/bin/propolis-cli/src/main.rs @@ -122,7 +122,7 @@ enum Command { #[clap(value_parser = parse_state)] state: InstanceStateRequested, /// The number of seconds to wait after sending ACPI PWRBTN_STS before - /// forcing stop/reset. (If omitted, stop/reset are forced immediately) + /// forcing stop/reset. (Only valid on acpi-stop and acpi-reboot states) acpi_timeout_secs: Option, }, @@ -452,8 +452,11 @@ fn parse_state(state: &str) -> anyhow::Result { "run" => Ok(InstanceStateRequested::Run), "stop" => Ok(InstanceStateRequested::Stop), "reboot" => Ok(InstanceStateRequested::Reboot), + "acpi-stop" => Ok(InstanceStateRequested::AcpiStop), + "acpi-reboot" => Ok(InstanceStateRequested::AcpiReboot), _ => Err(anyhow!( - "invalid requested state, must be one of: 'run', 'stop', 'reboot" + "invalid requested state, must be one of: \ + 'run', 'stop', 'reboot', 'acpi-stop', 'acpi-reboot'" )), } } diff --git a/bin/propolis-server/src/lib/vm/active.rs b/bin/propolis-server/src/lib/vm/active.rs index 8f0cda22c..6816afa13 100644 --- a/bin/propolis-server/src/lib/vm/active.rs +++ b/bin/propolis-server/src/lib/vm/active.rs @@ -65,21 +65,26 @@ impl ActiveVm { "state" => ?requested.state, "timeout" => ?requested.acpi_timeout_secs); - let ext_req = if let Some(secs) = requested.acpi_timeout_secs { - let timeout = Duration::from_secs(secs); - let fate = match requested.state { - InstanceStateRequested::Run => { - return Err(RequestDeniedReason::TimeoutOnRun.into()); - } - InstanceStateRequested::Stop => SoftShutdownFate::Stop, - InstanceStateRequested::Reboot => SoftShutdownFate::Reboot, - }; - ExternalRequest::acpi_shutdown(fate, timeout) - } else { - match requested.state { - InstanceStateRequested::Run => ExternalRequest::start(), - InstanceStateRequested::Stop => ExternalRequest::stop(), - InstanceStateRequested::Reboot => ExternalRequest::reboot(), + let timeout = requested.acpi_timeout_secs.map(Duration::from_secs); + let ext_req = match requested.state { + InstanceStateRequested::Run + | InstanceStateRequested::Stop + | InstanceStateRequested::Reboot + if timeout.is_some() => + { + return Err(RequestDeniedReason::TimeoutOnNonAcpiState.into()); + } + InstanceStateRequested::Run => ExternalRequest::start(), + InstanceStateRequested::Stop => ExternalRequest::stop(), + InstanceStateRequested::Reboot => ExternalRequest::reboot(), + InstanceStateRequested::AcpiStop => { + ExternalRequest::acpi_shutdown(SoftShutdownFate::Stop, timeout) + } + InstanceStateRequested::AcpiReboot => { + ExternalRequest::acpi_shutdown( + SoftShutdownFate::Reboot, + timeout, + ) } }; self.state_driver_queue diff --git a/bin/propolis-server/src/lib/vm/request_queue.rs b/bin/propolis-server/src/lib/vm/request_queue.rs index 1390f2c08..a5e33c843 100644 --- a/bin/propolis-server/src/lib/vm/request_queue.rs +++ b/bin/propolis-server/src/lib/vm/request_queue.rs @@ -66,7 +66,10 @@ pub(crate) enum StateChangeRequest { /// or reboots the instance (depending on `fate`) once the guest halts /// its CPU. If this does not occur within the `timeout`, the guest is /// ungracefully stopped/rebooted. - ACPIShutdown { fate: SoftShutdownFate, timeout: std::time::Duration }, + ACPIShutdown { + fate: SoftShutdownFate, + timeout: Option, + }, /// Halts the VM. Note that this is not a graceful shutdown and does not /// coordinate with guest software. @@ -144,7 +147,7 @@ impl ExternalRequest { pub const fn acpi_shutdown( fate: SoftShutdownFate, - timeout: std::time::Duration, + timeout: Option, ) -> Self { Self::State(StateChangeRequest::ACPIShutdown { fate, timeout }) } @@ -227,8 +230,8 @@ pub(crate) enum RequestDeniedReason { #[error("Instance failed to start or halted due to a failure")] InstanceFailed, - #[error("Cannot supply shutdown timeout to a Run request")] - TimeoutOnRun, + #[error("Cannot supply timeout with non-ACPI-shutdown state requests")] + TimeoutOnNonAcpiState, } /// A kind of request that can be popped from the queue and then completed. @@ -923,7 +926,7 @@ mod test { assert!(queue .try_queue(ExternalRequest::acpi_shutdown( SoftShutdownFate::Stop, - Duration::from_secs(600) + Some(Duration::from_secs(600)) )) .is_ok()); @@ -932,7 +935,7 @@ mod test { assert!(queue .try_queue(ExternalRequest::acpi_shutdown( SoftShutdownFate::Reboot, - Duration::from_secs(600) + Some(Duration::from_secs(600)) )) .is_ok()); @@ -954,7 +957,7 @@ mod test { assert!(queue .try_queue(ExternalRequest::acpi_shutdown( SoftShutdownFate::Stop, - Duration::from_secs(600) + Some(Duration::from_secs(600)) )) .is_ok()); // enqueue a hard stop @@ -979,7 +982,7 @@ mod test { assert!(queue .try_queue(ExternalRequest::acpi_shutdown( SoftShutdownFate::Reboot, - Duration::from_secs(600) + Some(Duration::from_secs(600)) )) .is_ok()); // enqueue a hard stop @@ -1004,7 +1007,7 @@ mod test { assert!(queue .try_queue(ExternalRequest::acpi_shutdown( SoftShutdownFate::Stop, - Duration::from_secs(600) + Some(Duration::from_secs(600)) )) .is_ok()); // enqueue a hard stop @@ -1028,7 +1031,7 @@ mod test { assert!(queue .try_queue(ExternalRequest::acpi_shutdown( SoftShutdownFate::Stop, - Duration::from_secs(600) + Some(Duration::from_secs(600)) )) .is_ok()); assert!(queue.is_empty()); @@ -1049,7 +1052,7 @@ mod test { assert!(queue .try_queue(ExternalRequest::acpi_shutdown( SoftShutdownFate::Stop, - Duration::from_secs(600) + Some(Duration::from_secs(600)) )) .is_err()); @@ -1061,7 +1064,7 @@ mod test { assert!(queue .try_queue(ExternalRequest::acpi_shutdown( SoftShutdownFate::Stop, - Duration::from_secs(600) + Some(Duration::from_secs(600)) )) .is_ok()); @@ -1081,7 +1084,7 @@ mod test { assert!(queue .try_queue(ExternalRequest::acpi_shutdown( SoftShutdownFate::Stop, - Duration::from_secs(600) + Some(Duration::from_secs(600)) )) .is_err()); @@ -1092,7 +1095,7 @@ mod test { assert!(queue .try_queue(ExternalRequest::acpi_shutdown( SoftShutdownFate::Stop, - Duration::from_secs(600) + Some(Duration::from_secs(600)) )) .is_ok()); diff --git a/bin/propolis-server/src/lib/vm/state_driver.rs b/bin/propolis-server/src/lib/vm/state_driver.rs index c1bba6b05..ac4646b77 100644 --- a/bin/propolis-server/src/lib/vm/state_driver.rs +++ b/bin/propolis-server/src/lib/vm/state_driver.rs @@ -384,7 +384,7 @@ struct StateDriver { /// aborted. soft_off: Option<( Arc>, - tokio::task::JoinHandle<()>, + Option>, )>, /// State persisted from previous attempts to migrate out of this VM. @@ -884,7 +884,7 @@ impl StateDriver { fate, timeout, }) => { - if let Some((fate_mut, _)) = &mut self.soft_off { + if let Some((fate_mut, Some(_))) = &mut self.soft_off { // timeout already in progress, don't start timeout task // anew or send further PWRBTN_STS, but do change the // post-shutdown fate @@ -904,35 +904,32 @@ impl StateDriver { // task to terminate/reboot sooner if the kernel is so wedged or // absent that it didn't even respond to pulling PWRBTN_STS high - // timeout to hard-stop/reset if guest takes too long - let queue_weak = Arc::downgrade(&self.input_queue); let fate_strong = Arc::new(tokio::sync::Mutex::new(fate)); + // timeout task to hard-stop/reset if guest takes too long + let Some(timeout) = timeout else { + self.soft_off = Some((fate_strong, None)); + return HandleEventOutcome::Continue; + }; + + let queue_weak = Arc::downgrade(&self.input_queue); let fate_weak = Arc::downgrade(&fate_strong); let log = self.log.clone(); - self.soft_off = Some(( - fate_strong, - tokio::spawn(async move { - tokio::time::sleep(timeout).await; - let Some(queue) = queue_weak.upgrade() else { return }; - let Some(fate) = fate_weak.upgrade() else { return }; - let quasi_ext_req = match *fate.lock().await { - SoftShutdownFate::Stop => ExternalRequest::stop(), - SoftShutdownFate::Reboot => { - ExternalRequest::reboot() - } - }; - // (philosophically, timeout behavior is part of - // what was externally-requested...) - if let Err(e) = - queue.queue_external_request(quasi_ext_req) - { - slog::error!( - log, - "Failed to enqueue {fate:?}: {e}" - ); - } - }), - )); + let timeout_task = tokio::spawn(async move { + tokio::time::sleep(timeout).await; + let Some(queue) = queue_weak.upgrade() else { return }; + let Some(fate) = fate_weak.upgrade() else { return }; + let quasi_ext_req = match *fate.lock().await { + SoftShutdownFate::Stop => ExternalRequest::stop(), + SoftShutdownFate::Reboot => ExternalRequest::reboot(), + }; + // (philosophically, timeout behavior is part of + // what was externally-requested...) + if let Err(e) = queue.queue_external_request(quasi_ext_req) + { + slog::error!(log, "Failed to enqueue {fate:?}: {e}"); + } + }); + self.soft_off = Some((fate_strong, Some(timeout_task))); HandleEventOutcome::Continue } ExternalRequest::State(StateChangeRequest::Start) => { @@ -953,7 +950,7 @@ impl StateDriver { migration_id, websock, }) => { - if let Some((fate, _)) = &self.soft_off { + if let Some((fate, Some(_))) = &self.soft_off { slog::warn!( self.log, "Ignored MigrateAsSource while ACPI {fate:?} in progress" @@ -1008,7 +1005,7 @@ impl StateDriver { // shut down while we're waiting for it to do so, have us reboot it // here, and then have the timeout task hard-reboot minutes later // once it's come back up) - if let Some((_, timeout_task)) = self.soft_off.take() { + if let Some((_, Some(timeout_task))) = self.soft_off.take() { timeout_task.abort(); } @@ -1030,7 +1027,7 @@ impl StateDriver { // (if the guest has raced us with a power signal, turning off what // is already off is fine - but we certainly don't want someone turning // the instance back *on* later, then our old timeout task offs it) - if let Some((_, timeout_task)) = self.soft_off.take() { + if let Some((_, Some(timeout_task))) = self.soft_off.take() { timeout_task.abort(); } diff --git a/crates/propolis-api-types-versions/src/acpi_shutdown/instance.rs b/crates/propolis-api-types-versions/src/acpi_shutdown/instance.rs index ed655c9c0..92446beba 100644 --- a/crates/propolis-api-types-versions/src/acpi_shutdown/instance.rs +++ b/crates/propolis-api-types-versions/src/acpi_shutdown/instance.rs @@ -5,7 +5,26 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use crate::v1::instance::InstanceStateRequested; +use crate::v1::instance::InstanceStateRequested as InstanceStateRequestedV1; + +#[derive(Clone, Copy, Debug, Deserialize, Serialize, JsonSchema)] +pub enum InstanceStateRequested { + Run, + Stop, + Reboot, + AcpiStop, + AcpiReboot, +} + +impl From for InstanceStateRequested { + fn from(value: InstanceStateRequestedV1) -> Self { + match value { + InstanceStateRequestedV1::Run => Self::Run, + InstanceStateRequestedV1::Stop => Self::Stop, + InstanceStateRequestedV1::Reboot => Self::Reboot, + } + } +} /// Requested state change of an Instance. #[derive(Clone, Copy, Deserialize, Serialize, JsonSchema)] @@ -13,7 +32,6 @@ pub struct InstanceStateChange { /// The desired state for the Instance. pub state: InstanceStateRequested, /// The number of seconds to wait after sending ACPI `PWRBTN_STS` before - /// forcing stop/reset. (If omitted, stop/reset are forced immediately, - /// and the ACPI signal is not sent.) + /// forcing stop/reset. (Only valid for AcpiStop and AcpiReboot states.) pub acpi_timeout_secs: Option, } diff --git a/crates/propolis-api-types-versions/src/latest.rs b/crates/propolis-api-types-versions/src/latest.rs index b440742c5..08e666d2d 100644 --- a/crates/propolis-api-types-versions/src/latest.rs +++ b/crates/propolis-api-types-versions/src/latest.rs @@ -67,13 +67,13 @@ pub mod instance { pub use crate::v1::instance::InstanceState; pub use crate::v1::instance::InstanceStateMonitorRequest; pub use crate::v1::instance::InstanceStateMonitorResponse; - pub use crate::v1::instance::InstanceStateRequested; pub use crate::v1::instance::ReplacementComponent; pub use crate::v6::api::InstanceEnsureRequest; pub use crate::v6::api::InstanceInitializationMethod; pub use crate::v7::instance::InstanceStateChange; + pub use crate::v7::instance::InstanceStateRequested; } pub mod instance_spec { diff --git a/crates/propolis-server-api/src/lib.rs b/crates/propolis-server-api/src/lib.rs index 684d9df26..2e4804ca0 100644 --- a/crates/propolis-server-api/src/lib.rs +++ b/crates/propolis-server-api/src/lib.rs @@ -234,7 +234,7 @@ pub trait PropolisServerApi { Self::instance_state_put( rqctx, latest::instance::InstanceStateChange { - state: request.into_inner(), + state: request.into_inner().into(), acpi_timeout_secs: None, } .into(), diff --git a/openapi/propolis-server/propolis-server-7.0.0-a344ff.json b/openapi/propolis-server/propolis-server-7.0.0-772a7f.json similarity index 99% rename from openapi/propolis-server/propolis-server-7.0.0-a344ff.json rename to openapi/propolis-server/propolis-server-7.0.0-772a7f.json index 5718d81fe..ba19b1552 100644 --- a/openapi/propolis-server/propolis-server-7.0.0-a344ff.json +++ b/openapi/propolis-server/propolis-server-7.0.0-772a7f.json @@ -1660,7 +1660,7 @@ "properties": { "acpi_timeout_secs": { "nullable": true, - "description": "The number of seconds to wait after sending ACPI `PWRBTN_STS` before forcing stop/reset. (If omitted, stop/reset are forced immediately, and the ACPI signal is not sent.)", + "description": "The number of seconds to wait after sending ACPI `PWRBTN_STS` before forcing stop/reset. (Only valid for AcpiStop and AcpiReboot states.)", "type": "integer", "format": "uint64", "minimum": 0 @@ -1717,7 +1717,9 @@ "enum": [ "Run", "Stop", - "Reboot" + "Reboot", + "AcpiStop", + "AcpiReboot" ] }, "InstanceVCRReplace": { diff --git a/openapi/propolis-server/propolis-server-latest.json b/openapi/propolis-server/propolis-server-latest.json index b1cd71342..b842b5ebd 120000 --- a/openapi/propolis-server/propolis-server-latest.json +++ b/openapi/propolis-server/propolis-server-latest.json @@ -1 +1 @@ -propolis-server-7.0.0-a344ff.json \ No newline at end of file +propolis-server-7.0.0-772a7f.json \ No newline at end of file diff --git a/phd-tests/framework/src/test_vm/mod.rs b/phd-tests/framework/src/test_vm/mod.rs index b2cec894d..5c1fbcc97 100644 --- a/phd-tests/framework/src/test_vm/mod.rs +++ b/phd-tests/framework/src/test_vm/mod.rs @@ -579,7 +579,7 @@ impl TestVm { timeout_secs: u64, ) -> PropolisClientResult<()> { self.put_instance_state( - InstanceStateRequested::Stop, + InstanceStateRequested::AcpiStop, Some(timeout_secs), ) .await @@ -592,7 +592,7 @@ impl TestVm { timeout_secs: u64, ) -> PropolisClientResult<()> { self.put_instance_state( - InstanceStateRequested::Reboot, + InstanceStateRequested::AcpiReboot, Some(timeout_secs), ) .await From ea26347748a9dd8c13bac399c53525fafae9450c Mon Sep 17 00:00:00 2001 From: lif <> Date: Sat, 17 Oct 2026 05:12:45 +0000 Subject: [PATCH 06/10] update mock-server to allow inspecting most-recently-sent state change request (& clippy fix in statedriver) --- bin/mock-server/src/lib/lib.rs | 53 ++++++++++++++++--- .../src/lib/vm/state_driver.rs | 9 ++-- 2 files changed, 51 insertions(+), 11 deletions(-) diff --git a/bin/mock-server/src/lib/lib.rs b/bin/mock-server/src/lib/lib.rs index f2aea8955..667d1dc19 100644 --- a/bin/mock-server/src/lib/lib.rs +++ b/bin/mock-server/src/lib/lib.rs @@ -47,6 +47,7 @@ pub struct InstanceContext { serial_task: serial::SerialTask, state_watcher_rx: watch::Receiver, state_watcher_tx: watch::Sender, + last_state_change: Option, } struct MockState { @@ -92,6 +93,7 @@ impl InstanceContext { serial_task, state_watcher_rx, state_watcher_tx, + last_state_change: None, } } @@ -126,7 +128,8 @@ impl InstanceContext { } ( api::InstanceState::Running, - api::InstanceStateRequested::Reboot, + api::InstanceStateRequested::Reboot + | api::InstanceStateRequested::AcpiReboot, ) => { self.queue_states( log, @@ -138,12 +141,26 @@ impl InstanceContext { .await; Ok(()) } - (current, api::InstanceStateRequested::Reboot) => { - Err(Error::InvalidTransition { - current, - requested: api::InstanceStateRequested::Reboot, - }) + ( + api::InstanceState::Running, + api::InstanceStateRequested::AcpiStop, + ) => { + self.queue_states( + log, + &[ + api::InstanceState::Stopping, + api::InstanceState::Stopped, + ], + ) + .await; + Ok(()) } + ( + current, + api::InstanceStateRequested::Reboot + | api::InstanceStateRequested::AcpiReboot + | api::InstanceStateRequested::AcpiStop, + ) => Err(Error::InvalidTransition { current, requested: target }), (_, api::InstanceStateRequested::Run) => { self.queue_states(log, &[api::InstanceState::Running]).await; Ok(()) @@ -361,6 +378,7 @@ async fn instance_state_put( "Failed to transition: {err}" )) })?; + instance.last_state_change = Some(requested_state); Ok(HttpResponseUpdatedNoContent {}) } @@ -557,6 +575,28 @@ async fn mock_step( Ok(HttpResponseUpdatedNoContent()) } +// used for checking acpi shutdown timeout values in controlplane tests +#[endpoint { + method = GET, + path = "/mock/last_state_requested" +}] +async fn mock_last_state_requested_get( + rqctx: RequestContext>, +) -> Result, HttpError> { + let instance = rqctx.context().instance.lock().await; + let instance = instance.as_ref().ok_or_else(|| { + HttpError::for_internal_error( + "Server not initialized (no instance)".to_string(), + ) + })?; + match &instance.last_state_change { + Some(change) => Ok(HttpResponseOk(change.to_owned())), + None => Err(HttpError::for_internal_error( + "No instance_state_put yet received".to_string(), + )), + } +} + mod serial { use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; @@ -833,6 +873,7 @@ pub fn api() -> ApiDescription> { api.register(mock_mode_get).unwrap(); api.register(mock_mode_set).unwrap(); api.register(mock_step).unwrap(); + api.register(mock_last_state_requested_get).unwrap(); api } diff --git a/bin/propolis-server/src/lib/vm/state_driver.rs b/bin/propolis-server/src/lib/vm/state_driver.rs index ac4646b77..fee1b3380 100644 --- a/bin/propolis-server/src/lib/vm/state_driver.rs +++ b/bin/propolis-server/src/lib/vm/state_driver.rs @@ -102,7 +102,7 @@ use propolis_api_types::instance_spec::{ }; use propolis_api_types::migration::MigrationState; use slog::{error, info}; -use tokio::sync::Notify; +use tokio::{sync::Notify, task::JoinHandle}; use uuid::Uuid; use crate::{ @@ -360,6 +360,8 @@ impl guest_event::ChipsetEventHandler for InputQueue { } } +type ShutdownFateMutex = Arc>; + /// The context for a VM state driver task's main loop. struct StateDriver { /// The state driver's associated logger. @@ -382,10 +384,7 @@ struct StateDriver { /// Contains a tuple of the action to take after the guest halts its CPUs, /// the timeout task that will forcibly halt the guest's CPUs if it is not /// aborted. - soft_off: Option<( - Arc>, - Option>, - )>, + soft_off: Option<(ShutdownFateMutex, Option>)>, /// State persisted from previous attempts to migrate out of this VM. migration_src_state: crate::migrate::source::PersistentState, From 379ed800d832bb9449c01fae714272efb347a09b Mon Sep 17 00:00:00 2001 From: lif <> Date: Wed, 28 Oct 2026 23:53:09 +0000 Subject: [PATCH 07/10] remark on how acpi shutdown relates to state driver --- bin/propolis-server/src/lib/vm/state_driver.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/bin/propolis-server/src/lib/vm/state_driver.rs b/bin/propolis-server/src/lib/vm/state_driver.rs index fee1b3380..9f682fcae 100644 --- a/bin/propolis-server/src/lib/vm/state_driver.rs +++ b/bin/propolis-server/src/lib/vm/state_driver.rs @@ -87,6 +87,14 @@ //! responsible for running down the VM objects and structures and resetting the //! server so that it can start another VM. //! +//! When a [`StateChangeRequest::ACPIShutdown`] arrives in the [`InputQueue`], +//! the [`StateDriver`] sends an ACPI fixed power-button signal to the guest, +//! stores the intended post-shutdown fate (rebooting or stopping) to determine +//! the VM's state after receiving the next chipset request, and starts a +//! timeout task to force that state transition if that chipset request is not +//! received before the deadline given by the state change request. The guest's +//! state remains `Running` until either of these outcomes occur. +//! //! [`ensure`]: crate::vm::ensure use std::{ From 048f470dc54b27a4958c8f8906fca3c27d8f8f76 Mon Sep 17 00:00:00 2001 From: lif <> Date: Wed, 4 Nov 2026 09:11:43 +0000 Subject: [PATCH 08/10] in mock propolis, allow reboot requests when the most recent state change request was an ACPI shutdown --- bin/mock-server/src/lib/lib.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/bin/mock-server/src/lib/lib.rs b/bin/mock-server/src/lib/lib.rs index 667d1dc19..f956a8931 100644 --- a/bin/mock-server/src/lib/lib.rs +++ b/bin/mock-server/src/lib/lib.rs @@ -155,6 +155,19 @@ impl InstanceContext { .await; Ok(()) } + // allowed to change the fate of an ACPI shutdown to a reboot + ( + api::InstanceState::Stopping, + api::InstanceStateRequested::Reboot + | api::InstanceStateRequested::AcpiReboot, + ) if self + .last_state_change + .as_ref() + .map(|change| change.acpi_timeout_secs.is_some()) + .unwrap_or(false) => + { + Ok(()) + } ( current, api::InstanceStateRequested::Reboot From b806beda39bc6e000dd88add56746267b034348c Mon Sep 17 00:00:00 2001 From: lif <> Date: Fri, 6 Nov 2026 01:31:16 +0000 Subject: [PATCH 09/10] move timeout_secs to AcpiStop/AcpiReboot state request variants --- bin/mock-server/src/lib/lib.rs | 46 +- bin/propolis-cli/src/main.rs | 28 +- bin/propolis-server/src/lib/server.rs | 6 +- bin/propolis-server/src/lib/vm/active.rs | 32 +- .../src/lib/vm/request_queue.rs | 3 - .../src/acpi_shutdown/instance.rs | 22 +- .../propolis-api-types-versions/src/latest.rs | 1 - crates/propolis-server-api/src/lib.rs | 9 +- .../propolis-server-7.0.0-772a7f.json | 2359 ----------------- .../propolis-server-latest.json | 2 +- phd-tests/framework/src/test_vm/mod.rs | 34 +- 11 files changed, 86 insertions(+), 2456 deletions(-) delete mode 100644 openapi/propolis-server/propolis-server-7.0.0-772a7f.json diff --git a/bin/mock-server/src/lib/lib.rs b/bin/mock-server/src/lib/lib.rs index f956a8931..fc9f75b74 100644 --- a/bin/mock-server/src/lib/lib.rs +++ b/bin/mock-server/src/lib/lib.rs @@ -24,7 +24,7 @@ mod api_types; use api_types::types::{self as api, InstanceEnsureRequest}; pub use api_types::MockMode; -#[derive(Debug, Eq, PartialEq, Error)] +#[derive(Debug, Error)] pub enum Error { #[error("Failed to send simulated state change update through channel")] TransitionSendFail, @@ -47,7 +47,7 @@ pub struct InstanceContext { serial_task: serial::SerialTask, state_watcher_rx: watch::Receiver, state_watcher_tx: watch::Sender, - last_state_change: Option, + last_state_change: Option, } struct MockState { @@ -103,7 +103,7 @@ impl InstanceContext { pub async fn set_target_state( &mut self, log: &Logger, - target: api::InstanceStateRequested, + target: &api::InstanceStateRequested, ) -> Result<(), Error> { match (self.current_state(), target) { ( @@ -129,7 +129,7 @@ impl InstanceContext { ( api::InstanceState::Running, api::InstanceStateRequested::Reboot - | api::InstanceStateRequested::AcpiReboot, + | api::InstanceStateRequested::AcpiReboot { timeout_secs: _ }, ) => { self.queue_states( log, @@ -143,7 +143,7 @@ impl InstanceContext { } ( api::InstanceState::Running, - api::InstanceStateRequested::AcpiStop, + api::InstanceStateRequested::AcpiStop { timeout_secs: _ }, ) => { self.queue_states( log, @@ -159,21 +159,26 @@ impl InstanceContext { ( api::InstanceState::Stopping, api::InstanceStateRequested::Reboot - | api::InstanceStateRequested::AcpiReboot, - ) if self - .last_state_change - .as_ref() - .map(|change| change.acpi_timeout_secs.is_some()) - .unwrap_or(false) => + | api::InstanceStateRequested::AcpiReboot { timeout_secs: _ }, + ) if matches!( + self.last_state_change.as_ref(), + Some( + api::InstanceStateRequested::AcpiStop { .. } + | api::InstanceStateRequested::AcpiReboot { .. } + ) + ) => { Ok(()) } ( current, api::InstanceStateRequested::Reboot - | api::InstanceStateRequested::AcpiReboot - | api::InstanceStateRequested::AcpiStop, - ) => Err(Error::InvalidTransition { current, requested: target }), + | api::InstanceStateRequested::AcpiReboot { timeout_secs: _ } + | api::InstanceStateRequested::AcpiStop { timeout_secs: _ }, + ) => Err(Error::InvalidTransition { + current, + requested: target.to_owned(), + }), (_, api::InstanceStateRequested::Run) => { self.queue_states(log, &[api::InstanceState::Running]).await; Ok(()) @@ -374,7 +379,7 @@ async fn instance_state_monitor( }] async fn instance_state_put( rqctx: RequestContext>, - request: TypedBody, + request: TypedBody, ) -> Result { let mut instance = rqctx.context().instance.lock().await; let instance = instance.as_mut().ok_or_else(|| { @@ -383,14 +388,13 @@ async fn instance_state_put( ) })?; let requested_state = request.into_inner(); - instance - .set_target_state(&rqctx.log, requested_state.state) - .await - .map_err(|err| { + instance.set_target_state(&rqctx.log, &requested_state).await.map_err( + |err| { HttpError::for_internal_error(format!( "Failed to transition: {err}" )) - })?; + }, + )?; instance.last_state_change = Some(requested_state); Ok(HttpResponseUpdatedNoContent {}) } @@ -595,7 +599,7 @@ async fn mock_step( }] async fn mock_last_state_requested_get( rqctx: RequestContext>, -) -> Result, HttpError> { +) -> Result, HttpError> { let instance = rqctx.context().instance.lock().await; let instance = instance.as_ref().ok_or_else(|| { HttpError::for_internal_error( diff --git a/bin/propolis-cli/src/main.rs b/bin/propolis-cli/src/main.rs index aae5c889f..9cb9c9564 100644 --- a/bin/propolis-cli/src/main.rs +++ b/bin/propolis-cli/src/main.rs @@ -39,10 +39,7 @@ use uuid::Uuid; use propolis_client::{ support::{InstanceSerialConsoleHelper, WSClientOffset}, - types::{ - InstanceStateChange, InstanceStateRequested, InstanceVcrReplace, - MigrationState, - }, + types::{InstanceStateRequested, InstanceVcrReplace, MigrationState}, Client, }; @@ -452,8 +449,11 @@ fn parse_state(state: &str) -> anyhow::Result { "run" => Ok(InstanceStateRequested::Run), "stop" => Ok(InstanceStateRequested::Stop), "reboot" => Ok(InstanceStateRequested::Reboot), - "acpi-stop" => Ok(InstanceStateRequested::AcpiStop), - "acpi-reboot" => Ok(InstanceStateRequested::AcpiReboot), + // XXX: timeouts to be replaced after parse + "acpi-stop" => Ok(InstanceStateRequested::AcpiStop { timeout_secs: 0 }), + "acpi-reboot" => { + Ok(InstanceStateRequested::AcpiReboot { timeout_secs: 0 }) + } _ => Err(anyhow!( "invalid requested state, must be one of: \ 'run', 'stop', 'reboot', 'acpi-stop', 'acpi-reboot'" @@ -581,11 +581,10 @@ async fn get_instance(client: &Client) -> anyhow::Result<()> { async fn put_instance( client: &Client, state: InstanceStateRequested, - acpi_timeout_secs: Option, ) -> anyhow::Result<()> { client .instance_state_put() - .body(InstanceStateChange { state, acpi_timeout_secs }) + .body(state) .send() .await .with_context(|| anyhow!("failed to set instance state"))?; @@ -968,8 +967,17 @@ async fn main() -> anyhow::Result<()> { .await? } Command::Get => get_instance(&client).await?, - Command::State { state, acpi_timeout_secs } => { - put_instance(&client, state, acpi_timeout_secs).await? + Command::State { mut state, acpi_timeout_secs } => { + match &mut state { + InstanceStateRequested::AcpiStop { timeout_secs } + | InstanceStateRequested::AcpiReboot { timeout_secs } => { + *timeout_secs = acpi_timeout_secs.ok_or(anyhow!( + "acpi-stop and acpi-reboot states must provide timeout" + ))? + } + _ => {} + } + put_instance(&client, state).await? } Command::Serial { byte_offset } => { serial(addr, byte_offset, log).await? diff --git a/bin/propolis-server/src/lib/server.rs b/bin/propolis-server/src/lib/server.rs index 8501e51a4..5dbb4419e 100644 --- a/bin/propolis-server/src/lib/server.rs +++ b/bin/propolis-server/src/lib/server.rs @@ -43,7 +43,7 @@ use propolis_api_types::disk::{ }; use propolis_api_types::instance::{ ErrorCode, Instance, InstanceEnsureRequest, InstanceEnsureResponse, - InstanceGetResponse, InstanceInitializationMethod, InstanceStateChange, + InstanceGetResponse, InstanceInitializationMethod, InstanceStateMonitorRequest, InstanceStateMonitorResponse, InstanceStateRequested, }; @@ -364,7 +364,7 @@ impl PropolisServerApi for PropolisServerImpl { async fn instance_state_put( rqctx: RequestContext, - request: TypedBody, + request: TypedBody, ) -> Result { let ctx = rqctx.context(); let requested_change = request.into_inner(); @@ -391,7 +391,7 @@ impl PropolisServerApi for PropolisServerImpl { }); if result.is_ok() { - if let InstanceStateRequested::Reboot = requested_change.state { + if let InstanceStateRequested::Reboot = requested_change { let stats = MutexGuard::map( vm.services().oximeter.lock().await, |state| &mut state.stats, diff --git a/bin/propolis-server/src/lib/vm/active.rs b/bin/propolis-server/src/lib/vm/active.rs index 6816afa13..567d0e7db 100644 --- a/bin/propolis-server/src/lib/vm/active.rs +++ b/bin/propolis-server/src/lib/vm/active.rs @@ -8,13 +8,13 @@ use std::sync::Arc; use std::time::Duration; use propolis_api_types::instance::{ - InstanceProperties, InstanceStateChange, InstanceStateRequested, + InstanceProperties, InstanceStateRequested, }; use propolis_api_types::instance_spec::SpecKey; use slog::info; use uuid::Uuid; -use crate::vm::request_queue::{ExternalRequest, RequestDeniedReason}; +use crate::vm::request_queue::ExternalRequest; use crate::vm::SoftShutdownFate; use super::{ @@ -59,31 +59,25 @@ impl ActiveVm { /// Pushes a state change request to the VM's state change queue. pub(crate) fn put_state( &self, - requested: InstanceStateChange, + requested: InstanceStateRequested, ) -> Result<(), VmError> { info!(self.log, "requested state via API"; - "state" => ?requested.state, - "timeout" => ?requested.acpi_timeout_secs); - - let timeout = requested.acpi_timeout_secs.map(Duration::from_secs); - let ext_req = match requested.state { - InstanceStateRequested::Run - | InstanceStateRequested::Stop - | InstanceStateRequested::Reboot - if timeout.is_some() => - { - return Err(RequestDeniedReason::TimeoutOnNonAcpiState.into()); - } + "state" => ?requested); + + let ext_req = match requested { InstanceStateRequested::Run => ExternalRequest::start(), InstanceStateRequested::Stop => ExternalRequest::stop(), InstanceStateRequested::Reboot => ExternalRequest::reboot(), - InstanceStateRequested::AcpiStop => { - ExternalRequest::acpi_shutdown(SoftShutdownFate::Stop, timeout) + InstanceStateRequested::AcpiStop { timeout_secs } => { + ExternalRequest::acpi_shutdown( + SoftShutdownFate::Stop, + Some(Duration::from_secs(timeout_secs)), + ) } - InstanceStateRequested::AcpiReboot => { + InstanceStateRequested::AcpiReboot { timeout_secs } => { ExternalRequest::acpi_shutdown( SoftShutdownFate::Reboot, - timeout, + Some(Duration::from_secs(timeout_secs)), ) } }; diff --git a/bin/propolis-server/src/lib/vm/request_queue.rs b/bin/propolis-server/src/lib/vm/request_queue.rs index a5e33c843..81ed8ab82 100644 --- a/bin/propolis-server/src/lib/vm/request_queue.rs +++ b/bin/propolis-server/src/lib/vm/request_queue.rs @@ -229,9 +229,6 @@ pub(crate) enum RequestDeniedReason { #[error("Instance failed to start or halted due to a failure")] InstanceFailed, - - #[error("Cannot supply timeout with non-ACPI-shutdown state requests")] - TimeoutOnNonAcpiState, } /// A kind of request that can be popped from the queue and then completed. diff --git a/crates/propolis-api-types-versions/src/acpi_shutdown/instance.rs b/crates/propolis-api-types-versions/src/acpi_shutdown/instance.rs index 92446beba..489ef99f6 100644 --- a/crates/propolis-api-types-versions/src/acpi_shutdown/instance.rs +++ b/crates/propolis-api-types-versions/src/acpi_shutdown/instance.rs @@ -12,8 +12,16 @@ pub enum InstanceStateRequested { Run, Stop, Reboot, - AcpiStop, - AcpiReboot, + AcpiStop { + /// The number of seconds to wait after sending ACPI `PWRBTN_STS` before + /// forcing the guest to stop. + timeout_secs: u64, + }, + AcpiReboot { + /// The number of seconds to wait after sending ACPI `PWRBTN_STS` before + /// forcing the guest to reboot. + timeout_secs: u64, + }, } impl From for InstanceStateRequested { @@ -25,13 +33,3 @@ impl From for InstanceStateRequested { } } } - -/// Requested state change of an Instance. -#[derive(Clone, Copy, Deserialize, Serialize, JsonSchema)] -pub struct InstanceStateChange { - /// The desired state for the Instance. - pub state: InstanceStateRequested, - /// The number of seconds to wait after sending ACPI `PWRBTN_STS` before - /// forcing stop/reset. (Only valid for AcpiStop and AcpiReboot states.) - pub acpi_timeout_secs: Option, -} diff --git a/crates/propolis-api-types-versions/src/latest.rs b/crates/propolis-api-types-versions/src/latest.rs index 08e666d2d..88b6c8b24 100644 --- a/crates/propolis-api-types-versions/src/latest.rs +++ b/crates/propolis-api-types-versions/src/latest.rs @@ -72,7 +72,6 @@ pub mod instance { pub use crate::v6::api::InstanceEnsureRequest; pub use crate::v6::api::InstanceInitializationMethod; - pub use crate::v7::instance::InstanceStateChange; pub use crate::v7::instance::InstanceStateRequested; } diff --git a/crates/propolis-server-api/src/lib.rs b/crates/propolis-server-api/src/lib.rs index 2e4804ca0..d27a291a5 100644 --- a/crates/propolis-server-api/src/lib.rs +++ b/crates/propolis-server-api/src/lib.rs @@ -218,7 +218,7 @@ pub trait PropolisServerApi { }] async fn instance_state_put( rqctx: RequestContext, - request: TypedBody, + request: TypedBody, ) -> Result; #[endpoint { @@ -233,10 +233,9 @@ pub trait PropolisServerApi { ) -> Result { Self::instance_state_put( rqctx, - latest::instance::InstanceStateChange { - state: request.into_inner().into(), - acpi_timeout_secs: None, - } + latest::instance::InstanceStateRequested::from( + request.into_inner(), + ) .into(), ) .await diff --git a/openapi/propolis-server/propolis-server-7.0.0-772a7f.json b/openapi/propolis-server/propolis-server-7.0.0-772a7f.json deleted file mode 100644 index ba19b1552..000000000 --- a/openapi/propolis-server/propolis-server-7.0.0-772a7f.json +++ /dev/null @@ -1,2359 +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": "7.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/InstanceStateChange" - } - } - }, - "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" - ] - }, - "InstanceStateChange": { - "description": "Requested state change of an Instance.", - "type": "object", - "properties": { - "acpi_timeout_secs": { - "nullable": true, - "description": "The number of seconds to wait after sending ACPI `PWRBTN_STS` before forcing stop/reset. (Only valid for AcpiStop and AcpiReboot states.)", - "type": "integer", - "format": "uint64", - "minimum": 0 - }, - "state": { - "description": "The desired state for the Instance.", - "allOf": [ - { - "$ref": "#/components/schemas/InstanceStateRequested" - } - ] - } - }, - "required": [ - "state" - ] - }, - "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", - "AcpiStop", - "AcpiReboot" - ] - }, - "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 b842b5ebd..03244f915 120000 --- a/openapi/propolis-server/propolis-server-latest.json +++ b/openapi/propolis-server/propolis-server-latest.json @@ -1 +1 @@ -propolis-server-7.0.0-772a7f.json \ No newline at end of file +propolis-server-7.0.0-eb0d76.json \ No newline at end of file diff --git a/phd-tests/framework/src/test_vm/mod.rs b/phd-tests/framework/src/test_vm/mod.rs index 5c1fbcc97..b0aa6e0a4 100644 --- a/phd-tests/framework/src/test_vm/mod.rs +++ b/phd-tests/framework/src/test_vm/mod.rs @@ -37,7 +37,7 @@ use propolis_client::{ InstanceEnsureRequest, InstanceGetResponse, InstanceInitializationMethod, InstanceMigrateStatusResponse, InstanceSerialConsoleHistoryResponse, InstanceState, - InstanceStateChange, InstanceStateRequested, MigrationState, + InstanceStateRequested, MigrationState, }, }; use propolis_client::{Client, ResponseValue}; @@ -558,18 +558,18 @@ impl TestVm { /// Sets the VM to the running state without first sending an instance /// ensure request. pub async fn run(&self) -> PropolisClientResult<()> { - self.put_instance_state(InstanceStateRequested::Run, None).await + self.put_instance_state(InstanceStateRequested::Run).await } /// Stops the VM. pub async fn stop(&self) -> PropolisClientResult<()> { - self.put_instance_state(InstanceStateRequested::Stop, None).await + self.put_instance_state(InstanceStateRequested::Stop).await } /// Resets the VM by requesting the `Reboot` state from the server (as /// distinct from requesting a reboot from within the guest). pub async fn reset(&self) -> PropolisClientResult<()> { - self.put_instance_state(InstanceStateRequested::Reboot, None).await + self.put_instance_state(InstanceStateRequested::Reboot).await } /// Sends a power button press, and forcefully stops the VM after the given @@ -578,10 +578,9 @@ impl TestVm { &self, timeout_secs: u64, ) -> PropolisClientResult<()> { - self.put_instance_state( - InstanceStateRequested::AcpiStop, - Some(timeout_secs), - ) + self.put_instance_state(InstanceStateRequested::AcpiStop { + timeout_secs, + }) .await } @@ -591,10 +590,9 @@ impl TestVm { &self, timeout_secs: u64, ) -> PropolisClientResult<()> { - self.put_instance_state( - InstanceStateRequested::AcpiReboot, - Some(timeout_secs), - ) + self.put_instance_state(InstanceStateRequested::AcpiReboot { + timeout_secs, + }) .await } @@ -602,14 +600,9 @@ impl TestVm { async fn put_instance_state( &self, state: InstanceStateRequested, - acpi_timeout_secs: Option, ) -> PropolisClientResult<()> { info!(?state, "Requesting instance state change"); - self.client - .instance_state_put() - .body(InstanceStateChange { state, acpi_timeout_secs }) - .send() - .await + self.client.instance_state_put().body(state).send().await } /// Issues a Propolis client `instance_get` request. @@ -1226,10 +1219,7 @@ async fn try_ensure_vm_destroyed(client: &Client) { debug!("trying to ensure Propolis server VM is destroyed"); if let Err(error) = client .instance_state_put() - .body(InstanceStateChange { - state: InstanceStateRequested::Stop, - acpi_timeout_secs: None, - }) + .body(InstanceStateRequested::Stop) .send() .await { From 4a5484f3bb0262eee92851bc91c3155a36f49daf Mon Sep 17 00:00:00 2001 From: lif <> Date: Fri, 6 Nov 2026 03:47:25 +0000 Subject: [PATCH 10/10] git add.. --- .../propolis-server-7.0.0-eb0d76.json | 2383 +++++++++++++++++ 1 file changed, 2383 insertions(+) create mode 100644 openapi/propolis-server/propolis-server-7.0.0-eb0d76.json diff --git a/openapi/propolis-server/propolis-server-7.0.0-eb0d76.json b/openapi/propolis-server/propolis-server-7.0.0-eb0d76.json new file mode 100644 index 000000000..4afbf9fb2 --- /dev/null +++ b/openapi/propolis-server/propolis-server-7.0.0-eb0d76.json @@ -0,0 +1,2383 @@ +{ + "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": "7.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": { + "oneOf": [ + { + "type": "string", + "enum": [ + "Run", + "Stop", + "Reboot" + ] + }, + { + "type": "object", + "properties": { + "AcpiStop": { + "type": "object", + "properties": { + "timeout_secs": { + "description": "The number of seconds to wait after sending ACPI `PWRBTN_STS` before forcing the guest to stop.", + "type": "integer", + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "timeout_secs" + ] + } + }, + "required": [ + "AcpiStop" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "AcpiReboot": { + "type": "object", + "properties": { + "timeout_secs": { + "description": "The number of seconds to wait after sending ACPI `PWRBTN_STS` before forcing the guest to reboot.", + "type": "integer", + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "timeout_secs" + ] + } + }, + "required": [ + "AcpiReboot" + ], + "additionalProperties": false + } + ] + }, + "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" + } + } + } + } + } + } +}