// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 use std::collections::HashSet; use std::path::{Path, PathBuf}; use std::sync::Arc; use openshell_core::proto::compute::v1::DriverSandbox as Sandbox; use openshell_core::settings::parse_bool_like; use crate::runtime::VmBackend; /// Label-key prefix used to request a per-sandbox lifecycle extension. /// /// A sandbox opts into an [`ExtensionActivation::OnRequest`] extension when /// its template carries a `{SANDBOX_EXTENSION_LABEL_PREFIX}{key}` label set /// to an enabled value. The label is policy-controlled (stamped by the /// gateway), so a guest cannot self-activate an extension. This contract /// lives with the lifecycle framework that consumes it rather than in the /// shared settings registry. pub const SANDBOX_EXTENSION_LABEL_PREFIX: &str = "openshell.ai/extension."; #[derive(Debug, Clone, PartialEq, Eq)] pub enum LaunchAbortReason { LauncherSpawnFailed, BeforeLaunchHookFailed, GuestPrepareFailed, /// The VM helper process exited on its own *after* the launcher was /// spawned (e.g. a guest crash or the hypervisor dying), rather than the /// driver aborting during setup. The driver releases its own per-launch /// allocations when this happens, so extensions are given the same /// opportunity to release host resources they allocated in /// [`LifecycleExtension::before_launch`]. ProcessExited, /// The gateway intentionally stopped the sandbox while retaining disk state. Stopped, } #[derive(Debug, Clone)] pub struct LifecycleError { message: String, resource_exhausted: bool, } impl LifecycleError { pub fn new(message: impl Into) -> Self { Self { message: message.into(), resource_exhausted: false, } } pub fn resource_exhausted(message: impl Into) -> Self { Self { message: message.into(), resource_exhausted: true, } } #[must_use] pub fn message(&self) -> &str { &self.message } #[must_use] pub fn is_resource_exhausted(&self) -> bool { self.resource_exhausted } } impl std::fmt::Display for LifecycleError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(&self.message) } } impl std::error::Error for LifecycleError {} pub type LifecycleResult = Result; /// A capability an extension can require from the VM backend. /// /// Extensions declare features they need (e.g. PCI passthrough or an /// external kernel image) and the VM driver resolves a concrete /// [`VmBackend`] that can satisfy them. The mapping from feature to /// backend lives in [`BackendFeature::requires_qemu`] for now; once a /// third backend exists this should evolve into a per-backend capability /// table that the resolver intersects against feature requirements. /// /// # Current limitations /// /// Until the non-GPU QEMU launch path (PCI device transport / VFIO root /// port wiring) lands, the driver still rejects launches where the /// resolved backend is QEMU but the sandbox has no GPU. As a result, /// declaring [`Self::PciPassthrough`] or [`Self::ExternalKernelImage`] on /// a non-GPU sandbox is accepted by [`LifecycleExtensionRegistry::validate`] /// at registration time but will fail provisioning with a /// `FailedPrecondition` status. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum BackendFeature { /// Extension supplies its own kernel image via /// [`LaunchPlan::kernel_image`]. Currently QEMU-only. ExternalKernelImage, /// Extension contributes guest init drop-ins via /// [`LaunchPlan::guest_init_dropins`]. Supported by all backends. GuestInitDropins, /// Extension needs PCI device passthrough on the guest. Currently /// QEMU-only and currently rejected for non-GPU sandboxes pending the /// non-GPU QEMU launch path landing. PciPassthrough, } impl BackendFeature { #[must_use] pub fn as_str(self) -> &'static str { match self { Self::ExternalKernelImage => "external-kernel-image", Self::GuestInitDropins => "guest-init-dropins", Self::PciPassthrough => "pci-passthrough", } } /// Returns true when satisfying this feature requires the QEMU backend /// today. This is the simplest possible resolver and is expected to be /// replaced with a per-backend capability table once a third backend /// exists. #[must_use] pub fn requires_qemu(self) -> bool { matches!(self, Self::ExternalKernelImage | Self::PciPassthrough) } } #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct ExtensionCapabilities { pub kernel_profiles: Vec, pub guest_init_dropins: Vec, pub launch_features: Vec, pub host_resources: Vec, } /// A registration-time description of what a lifecycle extension provides /// and requires. /// /// `required_backends` and `required_backend_features` are merged into the /// launch plan unconditionally for every sandbox. An extension that wants /// conditional behavior (e.g. only contribute requirements when the /// sandbox spec asks for it) should leave the descriptor fields empty and /// call [`LaunchPlan::require_backend`] / /// [`LaunchPlan::require_backend_feature`] inside /// [`LifecycleExtension::configure_launch`] instead. /// /// Whether the descriptor requirements are merged for *every* sandbox or /// only for sandboxes that opted in is controlled by the extension's /// [`LifecycleExtension::activation`]: a [`ExtensionActivation::Global`] /// extension always participates, while an /// [`ExtensionActivation::OnRequest`] extension only participates when the /// sandbox carries the matching request label (see /// [`ExtensionActivation`]). Within an active extension, the /// "declare in the descriptor (merged) vs decide in the hook" knob still /// applies for finer-grained, spec-dependent behavior. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ExtensionDescriptor { pub name: String, pub provides: ExtensionCapabilities, pub required_backends: Vec, pub required_backend_features: Vec, } impl ExtensionDescriptor { #[must_use] pub fn new(name: impl Into) -> Self { Self { name: name.into(), provides: ExtensionCapabilities::default(), required_backends: Vec::new(), required_backend_features: Vec::new(), } } } /// A guest-side init drop-in injected into the sandbox's overlay disk. /// /// Drop-ins land at `/opt/openshell/init.d/{name}` inside the guest with /// mode `0o755`. The guest's init script *executes* drop-ins in a child /// shell in deterministic ASCII-sorted order; it does not source them. /// Authors should: /// /// - Begin the file with a `#!/bin/bash` (or equivalent) shebang. /// - Use the `00-`, `50-`, `99-` prefix convention to control ordering. /// - Treat the parent shell as immutable: env vars set in a drop-in do not /// propagate to the rest of init. /// /// `name` must consist of ASCII letters, digits, `.`, `-`, or `_` (no /// path separators, no `.`/`..`); duplicates across a single launch plan /// are rejected by the driver. #[derive(Debug, Clone, PartialEq, Eq)] pub struct GuestInitDropin { pub name: String, pub contents: Vec, } impl GuestInitDropin { #[must_use] pub fn new(name: impl Into, contents: impl Into>) -> Self { Self { name: name.into(), contents: contents.into(), } } } #[derive(Debug, Clone)] pub struct LaunchPlan { pub backend: VmBackend, pub vcpus: u8, pub mem_mib: u32, pub required_backends: Vec, pub required_backend_features: Vec, pub kernel_profile: Option, pub kernel_image: Option, pub gpu_bdf: Option, pub vsock_cid: Option, pub guest_init_dropins: Vec, pub env: Vec, } impl LaunchPlan { pub fn require_backend(&mut self, backend: VmBackend) { if !self.required_backends.contains(&backend) { self.required_backends.push(backend); } } pub fn require_backend_feature(&mut self, feature: BackendFeature) { if !self.required_backend_features.contains(&feature) { self.required_backend_features.push(feature); } } pub fn require_backend_features(&mut self, features: impl IntoIterator) { for feature in features { self.require_backend_feature(feature); } } } #[derive(Debug, Clone)] pub struct RestoreContext { pub sandbox: Sandbox, pub state_dir: PathBuf, } /// When a registered lifecycle extension participates in a sandbox's /// lifecycle. /// /// This gates *every* hook (configure/launch/cleanup/restore) for the /// extension, so an inactive extension is invisible to the sandbox: it /// contributes no descriptor requirements, runs no host-side side effects, /// and — crucially — its cleanup hooks are not invoked either (there is /// nothing to clean up because nothing ran). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ExtensionActivation { /// The extension participates in every sandbox on this driver. This is /// the default and matches the historical behavior where all registered /// extensions ran unconditionally. Global, /// The extension participates only for sandboxes that explicitly /// requested it via a `{SANDBOX_EXTENSION_LABEL_PREFIX}{key}` template /// label set to an enabled value. The label is policy-controlled /// (stamped by the gateway), so guests cannot self-activate an /// extension. `key` must be a valid extension identifier (validated by /// [`LifecycleExtensionRegistry::validate`]). OnRequest { key: &'static str }, } /// Lifecycle hooks an extension can implement to participate in VM sandbox /// provisioning, launch failure, deletion, and post-restart reconciliation. /// /// # Hook ordering during a successful launch /// /// 1. [`configure_launch`](Self::configure_launch) — contribute backend /// requirements (via [`LaunchPlan::require_backend`] / /// [`LaunchPlan::require_backend_feature`]) and provisioning inputs /// (kernel profile, guest init drop-ins, etc.). Called before the driver /// has resolved the final backend. /// 2. Driver resolves [`LaunchPlan::backend`] from declared requirements /// and allocates backend-specific host resources such as a vsock CID. /// 3. [`before_launch`](Self::before_launch) — perform host-side /// side effects with the resolved plan in hand, optionally append /// additional guest env via [`LaunchPlan::env`]. /// 4. The driver spawns the VM launcher process. /// /// On launch failure or sandbox deletion, the driver invokes /// [`after_launch_failed`](Self::after_launch_failed) or /// [`after_delete`](Self::after_delete) in **reverse /// registration order**, so cleanup mirrors setup. #[tonic::async_trait] pub trait LifecycleExtension: std::fmt::Debug + Send + Sync { fn name(&self) -> &str; /// Declare when this extension participates in a sandbox's lifecycle. /// /// Defaults to [`ExtensionActivation::Global`] (runs for every sandbox). /// Override and return [`ExtensionActivation::OnRequest`] for an opt-in /// extension that should only run when the sandbox carries the matching /// request label. fn activation(&self) -> ExtensionActivation { ExtensionActivation::Global } fn descriptor(&self) -> ExtensionDescriptor { ExtensionDescriptor::new(self.name()) } /// Contribute backend requirements and provisioning inputs to the plan /// before the driver picks a backend. /// /// Use this hook to: /// - Declare backend requirements with /// [`LaunchPlan::require_backend`] or /// [`LaunchPlan::require_backend_feature`]. /// - Set [`LaunchPlan::kernel_profile`] or /// [`LaunchPlan::kernel_image`]. /// - Append [`LaunchPlan::guest_init_dropins`] entries. /// /// At this point [`LaunchPlan::backend`] is the driver's tentative /// choice and may still change during backend resolution. Do not perform /// host-side side effects here — defer them to /// [`before_launch`](Self::before_launch). async fn configure_launch( &self, _sandbox: &Sandbox, _state_dir: &Path, _plan: &mut LaunchPlan, ) -> LifecycleResult<()> { Ok(()) } /// Perform host-side preparation with the resolved launch plan. /// /// At this point [`LaunchPlan::backend`], /// [`LaunchPlan::required_backends`], and /// [`LaunchPlan::required_backend_features`] are finalized and any /// backend-specific host resources have been /// allocated. This hook is the right place to bind PCI devices, set /// up filesystem state, or otherwise prepare the host. /// /// Implementations MAY append entries to [`LaunchPlan::env`] to /// inject additional guest environment variables, and MAY return an /// error to abort the launch. Implementations MUST NOT change /// [`LaunchPlan::backend`], [`LaunchPlan::required_backends`], or /// [`LaunchPlan::required_backend_features`]; those changes are /// ignored by the driver once `before_launch` is reached. /// /// If this hook performs allocations that must be released on failure /// or delete, implement /// [`after_launch_failed`](Self::after_launch_failed) and /// [`after_delete`](Self::after_delete) accordingly. async fn before_launch( &self, _sandbox: &Sandbox, _state_dir: &Path, _plan: &mut LaunchPlan, ) -> LifecycleResult<()> { Ok(()) } /// Release anything this extension allocated during /// [`configure_launch`](Self::configure_launch) or /// [`before_launch`](Self::before_launch). /// /// Invoked in reverse registration order with the reason in /// [`LaunchAbortReason`]. This fires both when the launcher could not be /// started / was aborted before it became healthy *and* when a /// previously-running VM helper process exits on its own /// ([`LaunchAbortReason::ProcessExited`]); in the latter case the driver /// simultaneously releases its own per-launch allocations. /// /// Because [`LaunchAbortReason::ProcessExited`] cleanup runs while the /// sandbox record still exists, this hook may later be followed by /// [`after_delete`](Self::after_delete) for the same sandbox. /// Implementations MUST therefore be idempotent: do best-effort cleanup, /// treat already-released resources as success, and return [`Ok`] when /// possible. Errors are logged but do not propagate. /// /// Invoked on every launcher failure, including failures that happen /// during a persisted-sandbox restore (in that case /// [`after_restore`](Self::after_restore) is *not* invoked). async fn after_launch_failed( &self, _sandbox: &Sandbox, _state_dir: &Path, _reason: LaunchAbortReason, ) -> LifecycleResult<()> { Ok(()) } /// Release per-sandbox resources after a sandbox has been deleted. /// /// Invoked in reverse registration order. Errors are logged but do not /// propagate. async fn after_delete(&self, _sandbox: &Sandbox, _state_dir: &Path) -> LifecycleResult<()> { Ok(()) } /// Inspect or reconcile persisted extension state before the driver /// attempts to restore a sandbox after a process restart. /// /// Returning an error causes the driver to skip restoring this /// sandbox; the persisted state is left on disk for operator /// inspection. async fn before_restore(&self, _sandbox: &RestoreContext) -> LifecycleResult<()> { Ok(()) } /// Notify the extension that a persisted sandbox has been /// successfully restored and its launcher is running again. /// /// Only invoked when restore succeeds. If the restore fails partway /// through, [`after_launch_failed`](Self::after_launch_failed) /// runs instead. async fn after_restore(&self, _sandbox: &RestoreContext) -> LifecycleResult<()> { Ok(()) } } #[derive(Clone, Default)] pub struct LifecycleExtensionRegistry { extensions: Vec>, } impl std::fmt::Debug for LifecycleExtensionRegistry { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("LifecycleExtensionRegistry") .field( "names", &self .extensions .iter() .map(|ext| ext.name()) .collect::>(), ) .finish() } } impl LifecycleExtensionRegistry { #[must_use] pub fn new() -> Self { Self::default() } #[must_use] pub fn with(extensions: Vec>) -> Self { Self { extensions } } pub fn push(&mut self, extension: Arc) { self.extensions.push(extension); } #[must_use] pub fn is_empty(&self) -> bool { self.extensions.is_empty() } #[must_use] pub fn len(&self) -> usize { self.extensions.len() } #[must_use] pub fn names(&self) -> Vec { self.extensions .iter() .map(|ext| ext.name().to_string()) .collect() } #[must_use] pub fn descriptors(&self) -> Vec { self.extensions.iter().map(|ext| ext.descriptor()).collect() } /// Names of the extensions that are active for `sandbox`, in /// registration order. #[must_use] pub fn active_names(&self, sandbox: &Sandbox) -> Vec { self.active_for(sandbox) .into_iter() .map(|ext| ext.name().to_string()) .collect() } pub fn validate(&self) -> LifecycleResult<()> { let mut names = HashSet::new(); for ext in &self.extensions { let descriptor = ext.descriptor(); validate_extension_name(ext.name())?; validate_extension_name(&descriptor.name)?; if let ExtensionActivation::OnRequest { key } = ext.activation() { validate_extension_identifier(key).map_err(|err| { LifecycleError::new(format!( "lifecycle extension '{}' has invalid activation key '{}': {err}", ext.name(), key )) })?; } if descriptor.name != ext.name() { return Err(LifecycleError::new(format!( "lifecycle extension '{}' descriptor name does not match '{}'", ext.name(), descriptor.name ))); } validate_descriptor_strings(&descriptor)?; if !names.insert(descriptor.name.clone()) { return Err(LifecycleError::new(format!( "duplicate lifecycle extension name: {}", descriptor.name ))); } } Ok(()) } /// Extensions active for `sandbox`, preserving registration order. /// /// [`ExtensionActivation::Global`] extensions are always included; /// [`ExtensionActivation::OnRequest`] extensions are included only when /// the sandbox carries the matching request label. fn active_for<'a>(&'a self, sandbox: &Sandbox) -> Vec<&'a Arc> { self.extensions .iter() .filter(|ext| match ext.activation() { ExtensionActivation::Global => true, ExtensionActivation::OnRequest { key } => sandbox_requested_extension(sandbox, key), }) .collect() } #[tracing::instrument( name = "vm.configure_launch", skip_all, fields( otel.name = "vm.configure_launch", otel.status_code = tracing::field::Empty, sandbox.id = %sandbox.id, ) )] pub async fn configure_launch( &self, sandbox: &Sandbox, state_dir: &Path, plan: &mut LaunchPlan, ) -> LifecycleResult<()> { let span_status = openshell_otel::ErrorStatusGuard::current(); for ext in self.active_for(sandbox) { let descriptor = ext.descriptor(); for backend in descriptor.required_backends { plan.require_backend(backend); } plan.require_backend_features(descriptor.required_backend_features); // Snapshot fields where "last writer wins" could mask an // extension conflict, so we can flag the conflict instead of // silently dropping the earlier value. let prev_kernel_profile = plan.kernel_profile.clone(); let prev_kernel_image = plan.kernel_image.clone(); ext.configure_launch(sandbox, state_dir, plan).await?; warn_on_singleton_overwrite( ext.name(), "kernel_profile", prev_kernel_profile.as_deref(), plan.kernel_profile.as_deref(), ); warn_on_singleton_overwrite( ext.name(), "kernel_image", prev_kernel_image .as_deref() .map(|p| p.display().to_string()), plan.kernel_image .as_deref() .map(|p| p.display().to_string()), ); } span_status.finish(Ok(())) } #[tracing::instrument( name = "vm.before_launch", skip_all, fields( otel.name = "vm.before_launch", otel.status_code = tracing::field::Empty, sandbox.id = %sandbox.id, ) )] pub async fn before_launch( &self, sandbox: &Sandbox, state_dir: &Path, plan: &mut LaunchPlan, ) -> LifecycleResult<()> { let span_status = openshell_otel::ErrorStatusGuard::current(); for ext in self.active_for(sandbox) { ext.before_launch(sandbox, state_dir, plan).await?; } span_status.finish(Ok(())) } pub async fn after_launch_failed( &self, sandbox: &Sandbox, state_dir: &Path, reason: LaunchAbortReason, ) { for ext in self.active_for(sandbox).into_iter().rev() { if let Err(err) = ext .after_launch_failed(sandbox, state_dir, reason.clone()) .await { tracing::warn!( extension = ext.name(), sandbox_id = %sandbox.id, error = %err, "vm driver: lifecycle extension after_launch_failed hook failed" ); } } } pub async fn after_delete(&self, sandbox: &Sandbox, state_dir: &Path) { for ext in self.active_for(sandbox).into_iter().rev() { if let Err(err) = ext.after_delete(sandbox, state_dir).await { tracing::warn!( extension = ext.name(), sandbox_id = %sandbox.id, error = %err, "vm driver: lifecycle extension after_delete hook failed" ); } } } pub async fn before_restore(&self, sandbox: &RestoreContext) -> LifecycleResult<()> { for ext in self.active_for(&sandbox.sandbox) { ext.before_restore(sandbox).await?; } Ok(()) } pub async fn after_restore(&self, sandbox: &RestoreContext) { for ext in self.active_for(&sandbox.sandbox) { if let Err(err) = ext.after_restore(sandbox).await { tracing::warn!( extension = ext.name(), sandbox_id = %sandbox.sandbox.id, error = %err, "vm driver: lifecycle extension after_restore hook failed" ); } } } } /// Returns true when `sandbox` opted into the extension identified by /// `key` via a policy-stamped `{SANDBOX_EXTENSION_LABEL_PREFIX}{key}` /// template label set to an enabled value. fn sandbox_requested_extension(sandbox: &Sandbox, key: &str) -> bool { let label_key = format!("{SANDBOX_EXTENSION_LABEL_PREFIX}{key}"); sandbox .spec .as_ref() .and_then(|spec| spec.template.as_ref()) .and_then(|template| template.labels.get(&label_key)) .is_some_and(|value| extension_label_enabled(value)) } /// Interpret an extension request-label value. Accepts the explicit /// `enabled`/`requested` spellings as well as the common bool-like values /// understood by [`parse_bool_like`]; anything unrecognized is treated as /// "not requested" so a malformed label fails closed. fn extension_label_enabled(value: &str) -> bool { let normalized = value.trim().to_ascii_lowercase(); if matches!( normalized.as_str(), "enabled" | "enable" | "requested" | "request" ) { return true; } if matches!(normalized.as_str(), "disabled" | "disable") { return false; } parse_bool_like(&normalized).unwrap_or(false) } fn warn_on_singleton_overwrite( extension_name: &str, field: &str, prev: Option, next: Option, ) where T: AsRef + std::fmt::Display + PartialEq, { let (Some(prev), Some(next)) = (prev, next) else { return; }; if prev == next { return; } tracing::warn!( extension = extension_name, field, previous = %prev, next = %next, "vm driver: lifecycle extension overwrote a singleton launch-plan field set by an earlier extension" ); } pub fn extension_state_dir( sandbox_state_dir: &Path, extension_name: &str, ) -> LifecycleResult { validate_extension_name(extension_name)?; Ok(sandbox_state_dir.join("extensions").join(extension_name)) } fn validate_extension_name(name: &str) -> LifecycleResult<()> { if name.is_empty() || name == "." || name == ".." { return Err(LifecycleError::new( "lifecycle extension name is empty or reserved", )); } if !name .chars() .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == '.') { return Err(LifecycleError::new(format!( "lifecycle extension name '{name}' must contain only ASCII letters, numbers, '.', '-', or '_'" ))); } Ok(()) } fn validate_descriptor_strings(descriptor: &ExtensionDescriptor) -> LifecycleResult<()> { for value in descriptor .provides .kernel_profiles .iter() .chain(descriptor.provides.guest_init_dropins.iter()) .chain(descriptor.provides.launch_features.iter()) .chain(descriptor.provides.host_resources.iter()) { validate_extension_identifier(value).map_err(|err| { LifecycleError::new(format!( "lifecycle extension '{}' has invalid provided capability '{}': {err}", descriptor.name, value )) })?; } Ok(()) } fn validate_extension_identifier(value: &str) -> Result<(), &'static str> { if value.is_empty() || value == "." || value == ".." { return Err("identifier is empty or reserved"); } if !value .chars() .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == '.') { return Err("identifier must contain only ASCII letters, numbers, '.', '-', or '_'"); } Ok(()) } #[cfg(test)] mod tests { use std::collections::HashMap; use std::path::PathBuf; use std::sync::Mutex; use openshell_core::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; use super::*; #[derive(Debug)] struct RecordingExtension { name: String, configure_should_fail: bool, before_should_fail: bool, activation: ExtensionActivation, calls: Mutex>, } impl RecordingExtension { fn new(name: &str) -> Arc { Arc::new(Self { name: name.to_string(), configure_should_fail: false, before_should_fail: false, activation: ExtensionActivation::Global, calls: Mutex::new(Vec::new()), }) } fn on_request(name: &str, key: &'static str) -> Arc { Arc::new(Self { name: name.to_string(), configure_should_fail: false, before_should_fail: false, activation: ExtensionActivation::OnRequest { key }, calls: Mutex::new(Vec::new()), }) } fn failing(name: &str) -> Arc { Arc::new(Self { name: name.to_string(), configure_should_fail: false, before_should_fail: true, activation: ExtensionActivation::Global, calls: Mutex::new(Vec::new()), }) } fn configure_failing(name: &str) -> Arc { Arc::new(Self { name: name.to_string(), configure_should_fail: true, before_should_fail: false, activation: ExtensionActivation::Global, calls: Mutex::new(Vec::new()), }) } fn calls(&self) -> Vec { self.calls.lock().unwrap().clone() } } #[tonic::async_trait] impl LifecycleExtension for RecordingExtension { fn name(&self) -> &str { &self.name } fn activation(&self) -> ExtensionActivation { self.activation } fn descriptor(&self) -> ExtensionDescriptor { ExtensionDescriptor { name: self.name.clone(), provides: ExtensionCapabilities { kernel_profiles: vec![format!("profile-{}", self.name)], guest_init_dropins: vec![format!("50-{}.sh", self.name)], launch_features: vec!["guest-init-dropins".to_string()], host_resources: Vec::new(), }, required_backends: Vec::new(), required_backend_features: vec![BackendFeature::GuestInitDropins], } } async fn configure_launch( &self, _sandbox: &Sandbox, _state_dir: &Path, plan: &mut LaunchPlan, ) -> LifecycleResult<()> { self.calls .lock() .unwrap() .push(format!("{}:configure_launch", self.name)); if self.configure_should_fail { return Err(LifecycleError::new(format!( "{}: scripted configure_launch failure", self.name ))); } plan.kernel_profile = Some(format!("profile-{}", self.name)); plan.guest_init_dropins.push(GuestInitDropin::new( format!("50-{}.sh", self.name), b"#!/bin/sh\n".to_vec(), )); Ok(()) } async fn before_launch( &self, _sandbox: &Sandbox, _state_dir: &Path, plan: &mut LaunchPlan, ) -> LifecycleResult<()> { self.calls .lock() .unwrap() .push(format!("{}:before_launch", self.name)); if self.before_should_fail { return Err(LifecycleError::new(format!( "{}: scripted before_launch failure", self.name ))); } plan.env.push(format!("RECORDING_{}=1", self.name)); Ok(()) } async fn after_launch_failed( &self, _sandbox: &Sandbox, _state_dir: &Path, reason: LaunchAbortReason, ) -> LifecycleResult<()> { self.calls .lock() .unwrap() .push(format!("{}:after_launch_failed:{:?}", self.name, reason)); Ok(()) } async fn after_delete(&self, _sandbox: &Sandbox, _state_dir: &Path) -> LifecycleResult<()> { self.calls .lock() .unwrap() .push(format!("{}:after_delete", self.name)); Ok(()) } } fn sample_plan(backend: VmBackend) -> LaunchPlan { LaunchPlan { backend, vcpus: 2, mem_mib: 2048, required_backends: Vec::new(), required_backend_features: Vec::new(), kernel_profile: None, kernel_image: None, gpu_bdf: None, vsock_cid: None, guest_init_dropins: Vec::new(), env: Vec::new(), } } fn sample_sandbox() -> Sandbox { Sandbox { id: "sandbox-123".to_string(), name: "sandbox-123".to_string(), ..Default::default() } } fn sample_sandbox_with_extension(key: &str) -> Sandbox { let label_key = format!("{SANDBOX_EXTENSION_LABEL_PREFIX}{key}"); Sandbox { id: "sandbox-123".to_string(), name: "sandbox-123".to_string(), spec: Some(DriverSandboxSpec { template: Some(DriverSandboxTemplate { labels: HashMap::from([(label_key, "enabled".to_string())]), ..Default::default() }), ..Default::default() }), ..Default::default() } } fn as_extension(extension: &Arc) -> Arc where T: LifecycleExtension + 'static, { extension.clone() } #[tokio::test] async fn configure_launch_runs_each_extension_in_order() { let ext_a = RecordingExtension::new("a"); let ext_b = RecordingExtension::new("b"); let registry = LifecycleExtensionRegistry::with(vec![as_extension(&ext_a), as_extension(&ext_b)]); let mut plan = sample_plan(VmBackend::Qemu); let sandbox = sample_sandbox(); registry .configure_launch(&sandbox, &PathBuf::from("/tmp/state"), &mut plan) .await .expect("configure_launch succeeds"); assert_eq!(plan.kernel_profile.as_deref(), Some("profile-b")); assert_eq!( plan.guest_init_dropins .iter() .map(|dropin| dropin.name.as_str()) .collect::>(), vec!["50-a.sh", "50-b.sh"] ); assert_eq!(ext_a.calls(), vec!["a:configure_launch"]); assert_eq!(ext_b.calls(), vec!["b:configure_launch"]); } #[tokio::test] async fn configure_launch_short_circuits_on_first_failure() { let ext_a = RecordingExtension::new("a"); let ext_fail = RecordingExtension::configure_failing("boom"); let ext_c = RecordingExtension::new("c"); let registry = LifecycleExtensionRegistry::with(vec![ as_extension(&ext_a), as_extension(&ext_fail), as_extension(&ext_c), ]); let mut plan = sample_plan(VmBackend::Libkrun); let sandbox = sample_sandbox(); let err = registry .configure_launch(&sandbox, &PathBuf::from("/tmp/state"), &mut plan) .await .expect_err("scripted failure should propagate"); assert!(err.message().contains("scripted configure_launch failure")); assert_eq!(ext_a.calls(), vec!["a:configure_launch"]); assert_eq!(ext_fail.calls(), vec!["boom:configure_launch"]); assert!( ext_c.calls().is_empty(), "extensions after the failure must not be invoked" ); } #[tokio::test] async fn on_request_extension_only_runs_when_label_is_enabled() { let ext_global = RecordingExtension::new("global"); let ext_requested = RecordingExtension::on_request("requested", "nemo-relay"); let registry = LifecycleExtensionRegistry::with(vec![ as_extension(&ext_global), as_extension(&ext_requested), ]); let mut plan = sample_plan(VmBackend::Libkrun); registry .configure_launch(&sample_sandbox(), &PathBuf::from("/tmp/state"), &mut plan) .await .expect("configure_launch succeeds"); assert_eq!(ext_global.calls(), vec!["global:configure_launch"]); assert!( ext_requested.calls().is_empty(), "on-request extension must stay inactive without the request label" ); let mut requested_plan = sample_plan(VmBackend::Libkrun); registry .configure_launch( &sample_sandbox_with_extension("nemo-relay"), &PathBuf::from("/tmp/state"), &mut requested_plan, ) .await .expect("configure_launch succeeds"); assert_eq!( ext_requested.calls(), vec!["requested:configure_launch"], "on-request extension should run when the label is enabled" ); } #[tokio::test] async fn active_names_reflects_request_label() { let registry = LifecycleExtensionRegistry::with(vec![ RecordingExtension::new("global"), RecordingExtension::on_request("requested", "nemo-relay"), ]); assert_eq!(registry.active_names(&sample_sandbox()), vec!["global"]); assert_eq!( registry.active_names(&sample_sandbox_with_extension("nemo-relay")), vec!["global", "requested"] ); } #[test] fn validate_rejects_invalid_on_request_key() { let registry = LifecycleExtensionRegistry::with(vec![RecordingExtension::on_request( "bad-key", "../escape", )]); let err = registry .validate() .expect_err("invalid activation key should fail validation"); assert!(err.message().contains("invalid activation key")); } #[tokio::test] async fn before_launch_runs_each_extension_in_order_and_collects_env() { let ext_a = RecordingExtension::new("a"); let ext_b = RecordingExtension::new("b"); let registry = LifecycleExtensionRegistry::with(vec![as_extension(&ext_a), as_extension(&ext_b)]); let mut plan = sample_plan(VmBackend::Qemu); let sandbox = sample_sandbox(); registry .before_launch(&sandbox, &PathBuf::from("/tmp/state"), &mut plan) .await .expect("before_launch succeeds"); assert_eq!(plan.env, vec!["RECORDING_a=1", "RECORDING_b=1"]); assert_eq!(ext_a.calls(), vec!["a:before_launch"]); assert_eq!(ext_b.calls(), vec!["b:before_launch"]); } #[tokio::test] async fn before_launch_short_circuits_on_first_failure() { let ext_a = RecordingExtension::new("a"); let ext_fail = RecordingExtension::failing("boom"); let ext_c = RecordingExtension::new("c"); let registry = LifecycleExtensionRegistry::with(vec![ as_extension(&ext_a), as_extension(&ext_fail), as_extension(&ext_c), ]); let mut plan = sample_plan(VmBackend::Libkrun); let sandbox = sample_sandbox(); let err = registry .before_launch(&sandbox, &PathBuf::from("/tmp/state"), &mut plan) .await .expect_err("scripted failure should propagate"); assert!(err.message().contains("scripted before_launch failure")); assert_eq!(ext_a.calls(), vec!["a:before_launch"]); assert_eq!(ext_fail.calls(), vec!["boom:before_launch"]); assert!( ext_c.calls().is_empty(), "extensions after the failure must not be invoked" ); } #[tokio::test] async fn after_launch_failed_runs_every_extension_in_reverse_order() { let ext_a = RecordingExtension::new("a"); let ext_b = RecordingExtension::new("b"); let registry = LifecycleExtensionRegistry::with(vec![as_extension(&ext_a), as_extension(&ext_b)]); let sandbox = sample_sandbox(); registry .after_launch_failed( &sandbox, &PathBuf::from("/tmp/state"), LaunchAbortReason::LauncherSpawnFailed, ) .await; assert_eq!( ext_a.calls(), vec!["a:after_launch_failed:LauncherSpawnFailed"] ); assert_eq!( ext_b.calls(), vec!["b:after_launch_failed:LauncherSpawnFailed"] ); } #[tokio::test] async fn after_delete_runs_every_extension() { let ext_a = RecordingExtension::new("a"); let ext_b = RecordingExtension::new("b"); let registry = LifecycleExtensionRegistry::with(vec![as_extension(&ext_a), as_extension(&ext_b)]); let sandbox = sample_sandbox(); registry .after_delete(&sandbox, &PathBuf::from("/tmp/state")) .await; assert_eq!(ext_a.calls(), vec!["a:after_delete"]); assert_eq!(ext_b.calls(), vec!["b:after_delete"]); } #[test] fn resource_exhausted_flag_round_trips() { let err = LifecycleError::resource_exhausted("pool empty"); assert!(err.is_resource_exhausted()); assert_eq!(err.message(), "pool empty"); let plain = LifecycleError::new("internal"); assert!(!plain.is_resource_exhausted()); } #[test] fn extension_state_dir_rejects_path_unsafe_names() { let base = PathBuf::from("/tmp/sandbox"); assert_eq!( extension_state_dir(&base, "vfio").unwrap(), base.join("extensions").join("vfio") ); assert!(extension_state_dir(&base, "../vfio").is_err()); assert!(extension_state_dir(&base, "").is_err()); } #[test] fn validate_rejects_duplicate_extension_names() { let registry = LifecycleExtensionRegistry::with(vec![ RecordingExtension::new("dup"), RecordingExtension::new("dup"), ]); let err = registry .validate() .expect_err("duplicate names should fail"); assert!(err.message().contains("duplicate")); } #[test] fn descriptor_tracks_provided_capabilities_and_requirements() { let ext = RecordingExtension::new("vfio"); let registry = LifecycleExtensionRegistry::with(vec![ext]); let descriptors = registry.descriptors(); assert_eq!(descriptors.len(), 1); assert_eq!(descriptors[0].name, "vfio"); assert!(descriptors[0].required_backends.is_empty()); assert_eq!( descriptors[0].required_backend_features, vec![BackendFeature::GuestInitDropins] ); assert_eq!( descriptors[0].provides.kernel_profiles, vec!["profile-vfio".to_string()] ); assert_eq!( descriptors[0].provides.guest_init_dropins, vec!["50-vfio.sh".to_string()] ); } }