Files
OpenShell/crates/openshell-driver-vm/src/driver.rs
T
Seth Jennings 6461677c32 feat(policy): accept numeric UIDs for sandbox process identity (#1973)
* feat(policy): accept numeric UIDs in sandbox process identity validation

Allow run_as_user and run_as_group to be either the literal 'sandbox'
or a numeric UID/GID within [1000, 2_000_000_000]. This removes the
hard dependency on a baked-in 'sandbox' user in container images,
enabling compute drivers to inject resolved UIDs at sandbox creation.

Phase 1 of #1959.

Signed-off-by: Seth Jennings <sjenning@redhat.com>

* feat(supervisor): accept numeric UIDs for process identity dropping

Allow run_as_user and run_as_group to be numeric UIDs/GIDs, removing
the hard dependency on a baked-in 'sandbox' user in container images.

Changes:
- validate_sandbox_user(): accepts numeric UIDs without passwd lookup
  (logs OCSF event); keeps passwd check for "sandbox" name; rejects
  non-numeric non-sandbox strings that fail passwd lookup
- prepare_filesystem(): passes numeric UIDs/GIDs directly to chown()
  instead of requiring a passwd entry
- drop_privileges(): resolves numeric UIDs/GIDs directly via UID::from_raw
  / Gid::from_raw; skips initgroups when target uid matches current euid;
  uses guard conditions before setgid/setuid calls
- session_user_and_home(): falls back to ("{uid}", "/sandbox") for
  numeric UIDs, avoiding a passwd lookup that will fail

Re-exports MIN_SANDBOX_UID and MAX_SANDBOX_UID from openshell-policy
so callers have consistent range constants.

Phase 2 of #1959.

Signed-off-by: Seth Jennings <sjenning@redhat.com>

* feat(driver-kubernetes): resolve sandbox UID/GID from config or OpenShift SCC annotations

Phase 3 of the numeric-UID plan: allow operators to specify explicit
sandbox_uid/sandbox_gid in Kubernetes driver config, auto-detect from
OpenShift SCC namespace annotations, and propagate resolved values to
supervisor container env vars and PVC init container securityContext.

Changes:
- Add sandbox_uid/sandbox_gid fields to KubernetesComputeConfig
- Add SANDBOX_UID/SANDBOX_GID env var constants to openshell-core
- Implement resolve_sandbox_identity() to fetch namespace annotations
  and auto-detect OpenShift SCC UID ranges (sa.scc.uid-range)
- Pass resolved UID/GID through SandboxPodParams to pod spec builder
- Inject SANDBOX_UID/SANDBOX_GID env vars into supervisor container
- Update PVC init container securityContext with resolved UID/GID
  instead of hard-coded root
- Add comprehensive unit tests for resolution logic and annotation
  parsing (resolve_sandbox_uid, resolve_sandbox_gid, OpenShift SCC
  annotation parsing)

Signed-off-by: Seth Jennings <sjenning@redhat.com>

* feat(driver-vm): add configurable sandbox UID/GID and update docs/examples

Phase 4 of the numeric-UID plan: replace hardcoded SANDBOX_UID (10001)
in VM rootfs preparation with configurable sandbox_uid/sandbox_gid fields.

Changes:
- Add sandbox_uid/sandbox_gid to VmDriverConfig with serde derives
- Pass resolved UID/GID through prepare_sandbox_rootfs_from_image_root
  to ensure_sandbox_guest_user which writes /etc/passwd/group/gshadow
- Update BYOC Dockerfile: remove groupadd/useradd, document runtime UID
  injection and the ability to skip baked-in sandbox user
- Update gateway-config.mdx: document sandbox_uid/sandbox_gid for both
  Kubernetes (with OpenShift SCC autodetection) and VM drivers
- Update sandbox-compute-drivers.mdx: add Sandbox User Identity section
  explaining numeric UID support across all compute drivers
- Update rootfs tests to use non-default UIDs, verify config passthrough

Signed-off-by: Seth Jennings <sjenning@redhat.com>

* code review changes

* fix(supervisor): harden tests for restricted CI container environments

Guard tests against CI-specific constraints: root without CAP_SETPCAP,
UIDs with no /etc/passwd entry, and restricted /proc access.

Signed-off-by: Seth Jennings <sjennings@nvidia.com>
Signed-off-by: Seth Jennings <sjenning@redhat.com>

---------

Signed-off-by: Seth Jennings <sjenning@redhat.com>
Signed-off-by: Seth Jennings <sjennings@nvidia.com>
2026-07-02 13:31:06 -07:00

7157 lines
253 KiB
Rust

// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
use crate::gpu::{
GpuInventory, SubnetAllocator, allocate_vsock_cid, mac_from_sandbox_id, tap_device_name,
};
use crate::lifecycle::{
BackendFeature, GuestInitDropin, LaunchAbortReason, LaunchPlan, LifecycleExtensionRegistry,
RestoreContext, extension_state_dir,
};
use crate::rootfs::{
clone_or_copy_sparse_file, create_ext4_image_from_dir_with_size, create_rootfs_image_from_dir,
extract_rootfs_archive_to, prepare_sandbox_rootfs_from_image_root, sandbox_guest_init_path,
set_rootfs_image_file_mode, write_rootfs_image_file,
};
use crate::runtime::VmBackend;
use bollard::Docker;
use bollard::errors::Error as BollardError;
use bollard::models::ContainerCreateBody;
use bollard::query_parameters::{CreateContainerOptionsBuilder, RemoveContainerOptionsBuilder};
use flate2::read::GzDecoder;
use futures::{Stream, StreamExt, TryStreamExt};
use nix::errno::Errno;
use nix::sys::signal::{Signal, kill};
use nix::unistd::Pid;
use oci_client::client::{Client as OciClient, ClientConfig};
use oci_client::manifest::{
ImageIndexEntry, OCI_IMAGE_MEDIA_TYPE, OciDescriptor, OciImageManifest,
};
use oci_client::secrets::RegistryAuth;
use oci_client::{Reference, RegistryOperation};
use openshell_core::gpu::{
driver_gpu_requirements, effective_driver_gpu_count, validate_specific_gpu_device_request,
};
use openshell_core::progress::{
PROGRESS_STEP_PULLING_IMAGE, PROGRESS_STEP_REQUESTING_SANDBOX, PROGRESS_STEP_STARTING_SANDBOX,
format_bytes, mark_progress_active, mark_progress_complete, mark_progress_detail,
};
use openshell_core::proto::compute::v1::{
CreateSandboxRequest, CreateSandboxResponse, DeleteSandboxRequest, DeleteSandboxResponse,
DriverCondition as SandboxCondition, DriverPlatformEvent as PlatformEvent,
DriverSandbox as Sandbox, DriverSandboxStatus as SandboxStatus,
DriverSandboxTemplate as SandboxTemplate, GetCapabilitiesRequest, GetCapabilitiesResponse,
GetSandboxRequest, GetSandboxResponse, ListSandboxesRequest, ListSandboxesResponse,
StopSandboxRequest, StopSandboxResponse, ValidateSandboxCreateRequest,
ValidateSandboxCreateResponse, WatchSandboxesDeletedEvent, WatchSandboxesEvent,
WatchSandboxesPlatformEvent, WatchSandboxesRequest, WatchSandboxesSandboxEvent,
compute_driver_server::ComputeDriver, watch_sandboxes_event,
};
use openshell_core::proto_struct::{
deserialize_optional_non_empty_string_list, struct_to_json_value,
};
use openshell_vfio::SysfsRoot;
use prost::Message;
use sha2::{Digest, Sha256};
use std::collections::{HashMap, HashSet};
use std::fs;
use std::io::Read;
use std::net::Ipv4Addr;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use std::path::{Component, Path, PathBuf};
use std::pin::Pin;
use std::process::Stdio;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use tokio::io::AsyncWriteExt;
use tokio::process::{Child, Command};
use tokio::sync::{Mutex, broadcast, mpsc};
use tokio::task::JoinHandle;
use tokio_stream::wrappers::ReceiverStream;
use tonic::{Request, Response, Status};
use tracing::{info, warn};
use url::{Host, Url};
const DRIVER_NAME: &str = "openshell-driver-vm";
const WATCH_BUFFER: usize = 256;
const DEFAULT_VCPUS: u8 = 2;
const DEFAULT_MEM_MIB: u32 = 2048;
const DEFAULT_OVERLAY_DISK_MIB: u64 = 4096;
const DEFAULT_REGISTRY_LAYER_DOWNLOAD_CONCURRENCY: usize = 4;
const MAX_REGISTRY_LAYER_DOWNLOAD_CONCURRENCY: usize = 16;
#[derive(Debug, Clone, Default, serde::Deserialize)]
#[serde(default, deny_unknown_fields)]
struct VmSandboxDriverConfig {
#[serde(
default,
deserialize_with = "deserialize_optional_non_empty_string_list"
)]
gpu_device_ids: Option<Vec<String>>,
}
impl VmSandboxDriverConfig {
fn from_sandbox(sandbox: &Sandbox) -> Result<Self, String> {
let Some(template) = sandbox
.spec
.as_ref()
.and_then(|spec| spec.template.as_ref())
else {
return Ok(Self::default());
};
Self::from_template(template)
}
fn from_template(template: &SandboxTemplate) -> Result<Self, String> {
let Some(config) = template.driver_config.as_ref() else {
return Ok(Self::default());
};
serde_json::from_value(struct_to_json_value(config))
.map_err(|err| format!("invalid vm driver_config: {err}"))
}
}
/// gvproxy host-loopback IP — gvproxy's TCP/UDP/ICMP forwarder NAT-rewrites
/// this destination to the host's `127.0.0.1` and dials out from the host
/// process. This is the only address that transparently reaches host-bound
/// services without explicit `expose` rules.
///
/// See gvisor-tap-vsock `cmd/gvproxy/config.go` (default NAT entry
/// `HostIP -> 127.0.0.1`) and `pkg/services/forwarder/tcp.go` (NAT lookup
/// before `net.Dial`).
///
/// Code paths route via `GVPROXY_HOST_LOOPBACK_ALIAS` (DNS / /etc/hosts)
/// instead so logs stay readable; this constant is kept for documentation
/// and parity with the guest init script.
#[allow(dead_code)]
const GVPROXY_HOST_LOOPBACK_IP: &str = "192.168.127.254";
const OPENSHELL_HOST_GATEWAY_ALIAS: &str = "host.openshell.internal";
/// Hostname gvproxy resolves (via its embedded DNS) to the host-loopback IP.
///
/// We rewrite loopback URLs to this hostname rather than the bare IP because:
/// * the guest init script seeds /etc/hosts with the same mapping, so it
/// resolves even when gvproxy's DNS is not in resolv.conf;
/// * keeping a recognisable hostname makes log messages clearer than a bare
/// 192.168.127.254 reference;
/// * package-managed gateway certificates include this SAN for guest mTLS.
///
/// Both names ultimately route through the gvproxy NAT path on
/// `GVPROXY_HOST_LOOPBACK_IP` — they do **not** go through the gateway IP.
const GVPROXY_HOST_LOOPBACK_ALIAS: &str = OPENSHELL_HOST_GATEWAY_ALIAS;
const GUEST_SSH_SOCKET_PATH: &str = "/run/openshell/ssh.sock";
const GUEST_TLS_CA_PATH: &str = "/opt/openshell/tls/ca.crt";
const GUEST_TLS_CERT_PATH: &str = "/opt/openshell/tls/tls.crt";
const GUEST_TLS_KEY_PATH: &str = "/opt/openshell/tls/tls.key";
const GUEST_SANDBOX_TOKEN_PATH: &str = "/opt/openshell/auth/sandbox.jwt";
const GUEST_INIT_DROPIN_DIR: &str = "/opt/openshell/init.d";
/// Guest path of the driver-authored manifest enumerating which
/// `init.d` drop-ins the guest init script is allowed to execute.
///
/// The guest runs *only* the entries listed here (fail-closed): anything
/// else found under `init.d` — e.g. files baked into a user-controlled
/// guest image — is ignored. The driver writes this file into the overlay
/// upperdir on every launch, so the image cannot forge or shadow it.
const GUEST_INIT_DROPIN_MANIFEST: &str = "/opt/openshell/init.d.manifest";
const IMAGE_CACHE_ROOT_DIR: &str = "images";
const IMAGE_CACHE_ROOTFS_IMAGE: &str = "rootfs.ext4";
const OVERLAY_TEMPLATE_CACHE_DIR: &str = "overlay-templates";
const OVERLAY_TEMPLATE_CACHE_LAYOUT_VERSION: &str = "sandbox-overlay-ext4-v1";
const SANDBOX_OVERLAY_IMAGE: &str = "overlay.ext4";
const SANDBOX_REQUEST_FILE: &str = "sandbox.pb";
const GUEST_IMAGE_CONFIG_DIR: &str = "openshell-image";
const GUEST_IMAGE_OCI_LAYOUT_DIR: &str = "oci";
const GUEST_IMAGE_OCI_REF: &str = "openshell";
const IMAGE_EXPORT_ROOTFS_ARCHIVE: &str = "source-rootfs.tar";
const BOOTSTRAP_IMAGE_CACHE_LAYOUT_VERSION: &str = "sandbox-bootstrap-rootfs-ext4-v3";
const PREPARED_IMAGE_CACHE_LAYOUT_VERSION: &str = "sandbox-prepared-rootfs-ext4-umoci-v3";
const IMAGE_IDENTITY_FILE: &str = "image-identity";
const IMAGE_REFERENCE_FILE: &str = "image-reference";
const IMAGE_PREP_INIT_MODE: &str = "image-prep";
static IMAGE_CACHE_BUILD_COUNTER: AtomicU64 = AtomicU64::new(0);
#[derive(Debug, Clone)]
struct VmDriverTlsPaths {
ca: PathBuf,
cert: PathBuf,
key: PathBuf,
}
#[derive(Debug, Clone)]
struct RuntimeImagePlan {
root_disk: PathBuf,
image_disk: Option<PathBuf>,
image_identity: String,
bootstrap_image_identity: String,
}
#[derive(Debug, Clone)]
struct PreparedImageDisk {
image_identity: String,
disk_path: PathBuf,
}
#[derive(Debug, Clone)]
struct GuestImagePayload {
image_ref: String,
image_identity: String,
source: GuestImagePayloadSource,
}
#[derive(Debug, Clone)]
enum GuestImagePayloadSource {
RegistryOciLayout { layout_dir: PathBuf },
LocalDocker { rootfs_archive: PathBuf },
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct VmDriverConfig {
pub openshell_endpoint: String,
pub state_dir: PathBuf,
pub launcher_bin: Option<PathBuf>,
pub default_image: String,
pub bootstrap_image: String,
pub log_level: String,
pub krun_log_level: u32,
pub vcpus: u8,
pub mem_mib: u32,
pub overlay_disk_mib: u64,
pub guest_tls_ca: Option<PathBuf>,
pub guest_tls_cert: Option<PathBuf>,
pub guest_tls_key: Option<PathBuf>,
pub gpu_enabled: bool,
pub gpu_mem_mib: u32,
pub gpu_vcpus: u8,
/// Resolved sandbox UID for rootfs `/etc/passwd` entry.
/// When empty, defaults to 10001 (the legacy hardcoded value).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sandbox_uid: Option<u32>,
/// Resolved sandbox GID for rootfs `/etc/passwd` and `/etc/group` entries.
/// When empty, defaults to the resolved UID.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sandbox_gid: Option<u32>,
}
/// Default sandbox UID used by the VM driver when no config value is set.
pub const DEFAULT_SANDBOX_UID: u32 = 10001;
impl Default for VmDriverConfig {
fn default() -> Self {
Self {
openshell_endpoint: String::new(),
state_dir: PathBuf::from("target/openshell-vm-driver"),
launcher_bin: None,
default_image: String::new(),
bootstrap_image: String::new(),
log_level: "info".to_string(),
krun_log_level: 1,
vcpus: DEFAULT_VCPUS,
mem_mib: DEFAULT_MEM_MIB,
overlay_disk_mib: DEFAULT_OVERLAY_DISK_MIB,
guest_tls_ca: None,
guest_tls_cert: None,
guest_tls_key: None,
gpu_enabled: false,
gpu_mem_mib: 8192,
gpu_vcpus: 4,
sandbox_uid: None,
sandbox_gid: None,
}
}
}
impl VmDriverConfig {
/// Resolve the sandbox UID, falling back to `DEFAULT_SANDBOX_UID`.
pub fn resolve_sandbox_uid(&self) -> u32 {
self.sandbox_uid.unwrap_or(DEFAULT_SANDBOX_UID)
}
/// Resolve the sandbox GID, falling back to the resolved UID.
pub fn resolve_sandbox_gid(&self, resolved_uid: u32) -> u32 {
self.sandbox_gid.unwrap_or(resolved_uid)
}
pub fn validate_sandbox_identity(&self) -> Result<(), String> {
let range = openshell_policy::MIN_SANDBOX_UID..=openshell_policy::MAX_SANDBOX_UID;
if let Some(uid) = self.sandbox_uid
&& !range.contains(&uid)
{
return Err(format!(
"sandbox_uid {uid} is outside the allowed range [{}, {}]",
openshell_policy::MIN_SANDBOX_UID,
openshell_policy::MAX_SANDBOX_UID,
));
}
if let Some(gid) = self.sandbox_gid
&& !range.contains(&gid)
{
return Err(format!(
"sandbox_gid {gid} is outside the allowed range [{}, {}]",
openshell_policy::MIN_SANDBOX_UID,
openshell_policy::MAX_SANDBOX_UID,
));
}
Ok(())
}
fn requires_tls_materials(&self) -> bool {
self.openshell_endpoint.starts_with("https://")
}
fn tls_paths(&self) -> Result<Option<VmDriverTlsPaths>, String> {
let provided = [
self.guest_tls_ca.as_ref(),
self.guest_tls_cert.as_ref(),
self.guest_tls_key.as_ref(),
];
if provided.iter().all(Option::is_none) {
return if self.requires_tls_materials() {
Err(
"https:// openshell endpoint requires OPENSHELL_VM_TLS_CA, OPENSHELL_VM_TLS_CERT, and OPENSHELL_VM_TLS_KEY so sandbox VMs can authenticate to the gateway"
.to_string(),
)
} else {
Ok(None)
};
}
let Some(ca) = self.guest_tls_ca.clone() else {
return Err(
"OPENSHELL_VM_TLS_CA is required when TLS materials are configured".to_string(),
);
};
let Some(cert) = self.guest_tls_cert.clone() else {
return Err(
"OPENSHELL_VM_TLS_CERT is required when TLS materials are configured".to_string(),
);
};
let Some(key) = self.guest_tls_key.clone() else {
return Err(
"OPENSHELL_VM_TLS_KEY is required when TLS materials are configured".to_string(),
);
};
for path in [&ca, &cert, &key] {
if !path.is_file() {
return Err(format!(
"TLS material '{}' does not exist or is not a file",
path.display()
));
}
}
Ok(Some(VmDriverTlsPaths { ca, cert, key }))
}
}
fn validate_openshell_endpoint(endpoint: &str) -> Result<(), String> {
let url = Url::parse(endpoint)
.map_err(|err| format!("invalid openshell endpoint '{endpoint}': {err}"))?;
let Some(host) = url.host() else {
return Err(format!("openshell endpoint '{endpoint}' is missing a host"));
};
let invalid_from_vm = match host {
Host::Domain(_) => false,
Host::Ipv4(ip) => ip.is_unspecified(),
Host::Ipv6(ip) => ip.is_unspecified(),
};
if invalid_from_vm {
return Err(format!(
"openshell endpoint '{endpoint}' is not reachable from sandbox VMs; use a concrete host such as 127.0.0.1, {OPENSHELL_HOST_GATEWAY_ALIAS}, or another routable address"
));
}
Ok(())
}
#[derive(Debug)]
struct VmProcess {
child: Child,
deleting: bool,
}
struct SandboxRecord {
snapshot: Sandbox,
state_dir: PathBuf,
process: Option<Arc<Mutex<VmProcess>>>,
provisioning_task: Option<JoinHandle<()>>,
gpu_bdf: Option<String>,
qemu_network_allocated: bool,
deleting: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum OverlayPreparation {
Fresh,
PreserveExisting,
}
#[derive(Clone)]
pub struct VmDriver {
config: VmDriverConfig,
launcher_bin: PathBuf,
registry: Arc<Mutex<HashMap<String, SandboxRecord>>>,
image_cache_lock: Arc<Mutex<()>>,
events: broadcast::Sender<WatchSandboxesEvent>,
gpu_inventory: Option<Arc<std::sync::Mutex<GpuInventory>>>,
subnet_allocator: Arc<std::sync::Mutex<SubnetAllocator>>,
lifecycle_extensions: Arc<LifecycleExtensionRegistry>,
}
impl VmDriver {
pub async fn new(config: VmDriverConfig) -> Result<Self, String> {
Self::new_with_extensions(config, LifecycleExtensionRegistry::new()).await
}
pub async fn new_with_extensions(
config: VmDriverConfig,
lifecycle_extensions: LifecycleExtensionRegistry,
) -> Result<Self, String> {
lifecycle_extensions
.validate()
.map_err(|err| err.message().to_string())?;
config.validate_sandbox_identity()?;
if config.openshell_endpoint.trim().is_empty() {
return Err("openshell endpoint is required".to_string());
}
validate_openshell_endpoint(&config.openshell_endpoint)?;
let _ = config.tls_paths()?;
#[cfg(target_os = "linux")]
if config.gpu_enabled {
check_gpu_privileges()?;
tokio::task::spawn_blocking(crate::cleanup_stale_tap_interfaces)
.await
.map_err(|e| format!("cleanup stale TAP interfaces panicked: {e}"))?;
}
let state_root = sandboxes_root_dir(&config.state_dir);
create_private_dir_all(&state_root).await.map_err(|err| {
format!(
"failed to create state dir '{}': {err}",
state_root.display()
)
})?;
let image_cache_root = image_cache_root_dir(&config.state_dir);
tokio::fs::create_dir_all(&image_cache_root)
.await
.map_err(|err| {
format!(
"failed to create state dir '{}': {err}",
image_cache_root.display()
)
})?;
let launcher_bin = if let Some(path) = config.launcher_bin.clone() {
path
} else {
std::env::current_exe()
.map_err(|err| format!("failed to resolve vm driver executable: {err}"))?
};
let gpu_inventory = if config.gpu_enabled {
let sysfs = SysfsRoot::system();
let inventory = GpuInventory::new(sysfs, &config.state_dir);
tracing::info!(
gpu_count = inventory.gpu_count(),
"GPU inventory initialized"
);
Some(Arc::new(std::sync::Mutex::new(inventory)))
} else {
None
};
let subnet_allocator = Arc::new(std::sync::Mutex::new(SubnetAllocator::new(
Ipv4Addr::new(10, 0, 128, 0),
17,
)));
let (events, _) = broadcast::channel(WATCH_BUFFER);
let driver = Self {
config,
launcher_bin,
registry: Arc::new(Mutex::new(HashMap::new())),
image_cache_lock: Arc::new(Mutex::new(())),
events,
gpu_inventory,
subnet_allocator,
lifecycle_extensions: Arc::new(lifecycle_extensions),
};
driver.restore_persisted_sandboxes().await;
Ok(driver)
}
#[must_use]
pub fn capabilities(&self) -> GetCapabilitiesResponse {
GetCapabilitiesResponse {
driver_name: DRIVER_NAME.to_string(),
driver_version: openshell_core::VERSION.to_string(),
default_image: self.config.default_image.clone(),
}
}
// `tonic::Status` is large but is the standard error type across the
// gRPC API surface; boxing here would diverge from every other handler.
#[allow(clippy::result_large_err)]
pub fn validate_sandbox(&self, sandbox: &Sandbox) -> Result<(), Status> {
validate_vm_sandbox(sandbox, self.config.gpu_enabled)?;
if self.resolved_sandbox_image(sandbox).is_none() {
return Err(Status::failed_precondition(
"vm sandboxes require template.image or a configured default sandbox image",
));
}
Ok(())
}
// `tonic::Status` is large but is the standard error type across the
// gRPC API surface; boxing here would diverge from every other handler.
#[allow(clippy::result_large_err)]
pub async fn create_sandbox(&self, sandbox: &Sandbox) -> Result<CreateSandboxResponse, Status> {
info!(
sandbox_id = %sandbox.id,
sandbox_name = %sandbox.name,
"vm driver: create_sandbox received"
);
validate_vm_sandbox(sandbox, self.config.gpu_enabled)?;
let state_dir = sandbox_state_dir(&self.config.state_dir, &sandbox.id)?;
let image_ref = self.resolved_sandbox_image(sandbox).ok_or_else(|| {
Status::failed_precondition(
"vm sandboxes require template.image or a configured default sandbox image",
)
})?;
info!(
sandbox_id = %sandbox.id,
image_ref = %image_ref,
state_dir = %state_dir.display(),
"vm driver: resolved image ref, preparing disks"
);
let snapshot = sandbox_snapshot(sandbox, provisioning_condition(), false);
{
let mut registry = self.registry.lock().await;
if registry.contains_key(&sandbox.id) {
return Err(Status::already_exists("sandbox already exists"));
}
registry.insert(
sandbox.id.clone(),
SandboxRecord {
snapshot: snapshot.clone(),
state_dir: state_dir.clone(),
process: None,
provisioning_task: None,
gpu_bdf: None,
qemu_network_allocated: false,
deleting: false,
},
);
}
let tls_paths = match self.config.tls_paths() {
Ok(paths) => paths,
Err(err) => {
let mut registry = self.registry.lock().await;
registry.remove(&sandbox.id);
return Err(Status::failed_precondition(err));
}
};
if let Err(err) = create_private_dir_all(&state_dir).await {
let mut registry = self.registry.lock().await;
registry.remove(&sandbox.id);
return Err(Status::internal(format!("create state dir failed: {err}")));
}
if let Err(err) = self.ensure_extension_state_dirs(&state_dir).await {
let mut registry = self.registry.lock().await;
registry.remove(&sandbox.id);
let _ = tokio::fs::remove_dir_all(&state_dir).await;
return Err(err);
}
if let Err(err) = write_sandbox_request(&state_dir, sandbox).await {
let mut registry = self.registry.lock().await;
registry.remove(&sandbox.id);
let _ = tokio::fs::remove_dir_all(&state_dir).await;
return Err(Status::internal(format!(
"write sandbox resume metadata failed: {err}"
)));
}
self.publish_platform_event(
sandbox.id.clone(),
platform_event(
"vm",
"Normal",
"Scheduled",
format!("Sandbox accepted by vm driver to image \"{image_ref}\""),
),
);
self.publish_snapshot(snapshot);
let driver = self.clone();
let sandbox_for_task = sandbox.clone();
let sandbox_id = sandbox.id.clone();
let image_ref_for_task = image_ref.clone();
let state_dir_for_task = state_dir.clone();
let task = tokio::spawn(async move {
driver
.provision_sandbox(
sandbox_for_task,
image_ref_for_task,
state_dir_for_task,
tls_paths,
OverlayPreparation::Fresh,
)
.await;
});
let mut registry = self.registry.lock().await;
if let Some(record) = registry.get_mut(&sandbox_id) {
if record.deleting {
task.abort();
} else {
record.provisioning_task = Some(task);
}
} else {
task.abort();
}
Ok(CreateSandboxResponse {})
}
async fn provision_sandbox(
&self,
sandbox: Sandbox,
image_ref: String,
state_dir: PathBuf,
tls_paths: Option<VmDriverTlsPaths>,
overlay_preparation: OverlayPreparation,
) {
let sandbox_id = sandbox.id.clone();
if let Err(err) = self
.provision_sandbox_inner(
sandbox,
image_ref,
state_dir.clone(),
tls_paths,
overlay_preparation,
)
.await
{
if err.code() == tonic::Code::Cancelled {
if overlay_preparation == OverlayPreparation::Fresh {
let _ = tokio::fs::remove_dir_all(&state_dir).await;
}
return;
}
warn!(
sandbox_id = %sandbox_id,
error = %err.message(),
"vm driver: sandbox provisioning failed"
);
self.fail_provisioning(
&sandbox_id,
&state_dir,
"ProvisioningFailed",
err.message(),
overlay_preparation == OverlayPreparation::Fresh,
)
.await;
}
}
#[allow(clippy::result_large_err)]
async fn provision_sandbox_inner(
&self,
sandbox: Sandbox,
image_ref: String,
state_dir: PathBuf,
tls_paths: Option<VmDriverTlsPaths>,
overlay_preparation: OverlayPreparation,
) -> Result<(), Status> {
self.ensure_provisioning_active(&sandbox.id).await?;
let is_gpu = sandbox
.spec
.as_ref()
.and_then(|spec| spec.resource_requirements.as_ref())
.and_then(|requirements| driver_gpu_requirements(Some(requirements)))
.is_some();
self.publish_platform_event(
sandbox.id.clone(),
platform_event(
"vm",
"Normal",
"ResolvingImage",
format!("Resolving VM sandbox image \"{image_ref}\""),
),
);
let image_plan = self.prepare_runtime_images(&sandbox.id, &image_ref).await?;
let image_identity = image_plan.image_identity.clone();
self.ensure_provisioning_active(&sandbox.id).await?;
info!(
sandbox_id = %sandbox.id,
image_identity = %image_identity,
bootstrap_image_identity = %image_plan.bootstrap_image_identity,
image_disk = image_plan.image_disk.as_ref().map(|path| path.display().to_string()).unwrap_or_default(),
"vm driver: sandbox root disk plan resolved"
);
let disk_paths = sandbox_runtime_disk_paths(&state_dir);
let root_disk = image_plan.root_disk;
let image_disk = image_plan.image_disk;
let overlay_disk = disk_paths.overlay_disk;
self.publish_platform_event(
sandbox.id.clone(),
platform_event(
"vm",
"Normal",
"PreparingOverlay",
"Preparing writable VM overlay disk".to_string(),
),
);
if let Err(err) = self
.prepare_runtime_overlay(
&overlay_disk,
tls_paths.as_ref(),
sandbox
.spec
.as_ref()
.map(|spec| spec.sandbox_token.as_str())
.filter(|token| !token.is_empty()),
overlay_preparation,
)
.await
{
return Err(Status::internal(format!(
"prepare guest overlay disk failed: {err}"
)));
}
self.ensure_provisioning_active(&sandbox.id).await?;
if let Err(err) =
write_sandbox_image_metadata(&state_dir, &image_ref, &image_identity).await
{
return Err(Status::internal(format!(
"write sandbox image metadata failed: {err}"
)));
}
let gpu_device_id = vm_gpu_device_id(&sandbox)?;
let gpu_bdf = if let Some(gpu_device_id) = gpu_device_id.as_deref() {
Some(
self.assign_gpu_to_record(&sandbox.id, gpu_device_id)
.await?,
)
} else {
None
};
let needs_qemu = is_gpu;
let mut plan =
match self.build_vm_launch_plan(&sandbox.id, needs_qemu, is_gpu, gpu_bdf.clone()) {
Ok(plan) => plan,
Err(err) => {
self.release_gpu_and_subnet(&sandbox.id);
return Err(err);
}
};
// `build_vm_launch_plan` already allocated the QEMU subnet, so record
// it as allocated now — before the cancellable `configure_launch` /
// `before_launch` hooks run. If a delete aborts provisioning while
// one of those hooks is awaiting, the aborted future never runs its
// own release path, and the delete cleanup is gated on this flag; if
// the flag were still unset the subnet would leak.
if plan.backend == VmBackend::Qemu
&& let Err(err) = self.mark_qemu_network_allocated(&sandbox.id).await
{
self.release_gpu_and_subnet(&sandbox.id);
return Err(err);
}
if let Err(err) = self
.lifecycle_extensions
.configure_launch(&sandbox, &state_dir, &mut plan)
.await
{
self.lifecycle_extensions
.after_launch_failed(
&sandbox,
&state_dir,
LaunchAbortReason::BeforeLaunchHookFailed,
)
.await;
self.release_gpu_and_subnet(&sandbox.id);
let message = format!(
"vm lifecycle extension rejected sandbox launch plan: {}",
err.message()
);
return Err(if err.is_resource_exhausted() {
Status::resource_exhausted(message)
} else {
Status::failed_precondition(message)
});
}
// Resolve and validate the backend from the requirements that
// `configure_launch` extensions contributed. After this point the
// plan's backend, sizing, and host allocations (subnet, tap, vsock)
// are final; the `before_launch` hook below may still mutate
// `plan.env` and `plan.guest_init_dropins` and may abort the launch,
// but it MUST NOT change `plan.backend`, `plan.required_backends`,
// or `plan.required_backend_features` -- those are enforced as a
// documented trait contract, not a runtime check.
if let Err(err) =
self.resolve_launch_plan_backend(&sandbox.id, is_gpu, gpu_bdf.clone(), &mut plan)
{
self.lifecycle_extensions
.after_launch_failed(
&sandbox,
&state_dir,
LaunchAbortReason::BeforeLaunchHookFailed,
)
.await;
self.release_gpu_and_subnet(&sandbox.id);
return Err(err);
}
if let Err(err) = Self::validate_launch_plan_backend(is_gpu, &plan) {
self.lifecycle_extensions
.after_launch_failed(
&sandbox,
&state_dir,
LaunchAbortReason::BeforeLaunchHookFailed,
)
.await;
self.release_gpu_and_subnet(&sandbox.id);
return Err(err);
}
if let Err(err) = self
.lifecycle_extensions
.before_launch(&sandbox, &state_dir, &mut plan)
.await
{
self.lifecycle_extensions
.after_launch_failed(
&sandbox,
&state_dir,
LaunchAbortReason::BeforeLaunchHookFailed,
)
.await;
self.release_gpu_and_subnet(&sandbox.id);
let message = format!(
"vm lifecycle extension rejected sandbox launch: {}",
err.message()
);
return Err(if err.is_resource_exhausted() {
Status::resource_exhausted(message)
} else {
Status::failed_precondition(message)
});
}
if let Err(err) = inject_guest_init_dropins(&overlay_disk, &plan.guest_init_dropins) {
self.lifecycle_extensions
.after_launch_failed(&sandbox, &state_dir, LaunchAbortReason::GuestPrepareFailed)
.await;
self.release_gpu_and_subnet(&sandbox.id);
return Err(err);
}
let endpoint_override = if plan.backend == VmBackend::Qemu {
plan.host_ip.as_deref().map(|host_ip| {
guest_visible_openshell_endpoint_for_tap(&self.config.openshell_endpoint, host_ip)
})
} else {
None
};
let console_output = state_dir.join("rootfs-console.log");
let mut command = Command::new(&self.launcher_bin);
command.kill_on_drop(true);
command.stdin(Stdio::null());
command.stdout(Stdio::inherit());
command.stderr(Stdio::inherit());
command.arg("--internal-run-vm");
command.arg("--vm-root-disk").arg(&root_disk);
command.arg("--vm-overlay-disk").arg(&overlay_disk);
if let Some(image_disk) = &image_disk {
command.arg("--vm-image-disk").arg(image_disk);
}
command.arg("--vm-exec").arg(sandbox_guest_init_path());
command.arg("--vm-workdir").arg("/");
command.arg("--vm-console-output").arg(&console_output);
command.arg("--vm-vcpus").arg(plan.vcpus.to_string());
command.arg("--vm-mem-mib").arg(plan.mem_mib.to_string());
if let Some(kernel_image) = &plan.kernel_image {
command.arg("--vm-kernel-image").arg(kernel_image);
}
if plan.backend == VmBackend::Qemu {
command.arg("--vm-backend").arg("qemu");
if let Some(bdf) = plan.gpu_bdf.as_deref() {
command.arg("--vm-gpu-bdf").arg(bdf);
}
if let Some(tap) = plan.tap_device.as_deref() {
command.arg("--vm-tap-device").arg(tap);
}
if let Some(guest_ip) = plan.guest_ip.as_deref() {
command.arg("--vm-guest-ip").arg(guest_ip);
}
if let Some(host_ip) = plan.host_ip.as_deref() {
command.arg("--vm-host-ip").arg(host_ip);
}
if let Some(vsock_cid) = plan.vsock_cid {
command.arg("--vm-vsock-cid").arg(vsock_cid.to_string());
}
if let Some(guest_mac) = plan.guest_mac.as_deref() {
command.arg("--vm-guest-mac").arg(guest_mac);
}
if let Some(port) = plan.gateway_port {
command.arg("--vm-gateway-port").arg(port.to_string());
}
}
self.ensure_provisioning_active(&sandbox.id).await?;
command
.arg("--vm-krun-log-level")
.arg(self.config.krun_log_level.to_string());
for env in build_guest_environment(&sandbox, &self.config, endpoint_override.as_deref()) {
command.arg("--vm-env").arg(env);
}
for env in &plan.env {
command.arg("--vm-env").arg(env);
}
info!(
sandbox_id = %sandbox.id,
launcher = %self.launcher_bin.display(),
console_output = %console_output.display(),
"vm driver: spawning VM launcher"
);
let child = match command.spawn() {
Ok(child) => child,
Err(err) => {
warn!(
sandbox_id = %sandbox.id,
error = %err,
"vm driver: launcher spawn failed"
);
self.lifecycle_extensions
.after_launch_failed(
&sandbox,
&state_dir,
LaunchAbortReason::LauncherSpawnFailed,
)
.await;
self.release_gpu_and_subnet(&sandbox.id);
return Err(Status::internal(format!(
"failed to launch vm helper '{}': {err}",
self.launcher_bin.display()
)));
}
};
info!(
sandbox_id = %sandbox.id,
launcher_pid = child.id().unwrap_or(0),
"vm driver: launcher spawned"
);
let process = Arc::new(Mutex::new(VmProcess {
child,
deleting: false,
}));
let mut process_to_stop = None;
let mut snapshot_to_publish = None;
{
let mut registry = self.registry.lock().await;
match registry.get_mut(&sandbox.id) {
Some(record) if !record.deleting => {
record.process = Some(process.clone());
record.gpu_bdf.clone_from(&gpu_bdf);
record.qemu_network_allocated = plan.backend == VmBackend::Qemu;
record.provisioning_task = None;
snapshot_to_publish = Some(record.snapshot.clone());
}
_ => {
process_to_stop = Some(process.clone());
}
}
}
if let Some(process) = process_to_stop {
{
let mut process = process.lock().await;
process.deleting = true;
terminate_vm_process(&mut process.child)
.await
.map_err(|err| Status::internal(format!("failed to stop vm: {err}")))?;
}
self.release_gpu_and_subnet(&sandbox.id);
return Err(Status::cancelled("sandbox provisioning cancelled"));
}
self.publish_platform_event(
sandbox.id.clone(),
platform_event("vm", "Normal", "Started", "Started VM launcher".to_string()),
);
if let Some(snapshot) = snapshot_to_publish {
self.publish_snapshot(snapshot);
}
if overlay_preparation == OverlayPreparation::PreserveExisting {
let persisted = RestoreContext {
sandbox: sandbox.clone(),
state_dir: state_dir.clone(),
};
self.lifecycle_extensions.after_restore(&persisted).await;
}
tokio::spawn({
let driver = self.clone();
let sandbox_id = sandbox.id.clone();
async move {
driver.monitor_sandbox(sandbox_id).await;
}
});
Ok(())
}
pub async fn delete_sandbox(
&self,
sandbox_id: &str,
sandbox_name: &str,
) -> Result<DeleteSandboxResponse, Status> {
if !sandbox_id.is_empty() {
validate_sandbox_id(sandbox_id)?;
}
let record_id = {
let registry = self.registry.lock().await;
if let Some((id, _record)) = registry.get_key_value(sandbox_id) {
Some(id.clone())
} else {
registry
.iter()
.find(|(_, record)| record.snapshot.name == sandbox_name)
.map(|(id, _)| id.clone())
}
};
let Some(record_id) = record_id else {
return Ok(DeleteSandboxResponse { deleted: false });
};
let (
state_dir,
process,
gpu_bdf,
qemu_network_allocated,
provisioning_task,
sandbox_snapshot,
) = {
let mut registry = self.registry.lock().await;
let Some(record) = registry.get_mut(&record_id) else {
return Ok(DeleteSandboxResponse { deleted: false });
};
record.deleting = true;
(
record.state_dir.clone(),
record.process.clone(),
record.gpu_bdf.clone(),
record.qemu_network_allocated,
record.provisioning_task.take(),
record.snapshot.clone(),
)
};
if let Some(snapshot) = self
.set_snapshot_condition(&record_id, deleting_condition(), true)
.await
{
self.publish_snapshot(snapshot);
}
if let Some(task) = provisioning_task {
task.abort();
}
if let Some(process) = process {
let mut process = process.lock().await;
process.deleting = true;
terminate_vm_process(&mut process.child)
.await
.map_err(|err| Status::internal(format!("failed to stop vm: {err}")))?;
}
self.lifecycle_extensions
.after_delete(&sandbox_snapshot, &state_dir)
.await;
self.release_allocations(&record_id, gpu_bdf.is_some(), qemu_network_allocated);
remove_sandbox_state_dir(&self.config.state_dir, &state_dir).await?;
{
let mut registry = self.registry.lock().await;
registry.remove(&record_id);
}
self.publish_deleted(record_id);
Ok(DeleteSandboxResponse { deleted: true })
}
pub async fn get_sandbox(
&self,
sandbox_id: &str,
sandbox_name: &str,
) -> Result<Option<Sandbox>, Status> {
if !sandbox_id.is_empty() {
validate_sandbox_id(sandbox_id)?;
}
let registry = self.registry.lock().await;
let sandbox = if sandbox_id.is_empty() {
registry
.values()
.find(|record| record.snapshot.name == sandbox_name)
.map(|record| record.snapshot.clone())
} else {
registry
.get(sandbox_id)
.map(|record| record.snapshot.clone())
};
Ok(sandbox)
}
pub async fn current_snapshots(&self) -> Vec<Sandbox> {
let registry = self.registry.lock().await;
let mut snapshots = registry
.values()
.map(|record| record.snapshot.clone())
.collect::<Vec<_>>();
snapshots.sort_by(|left, right| left.name.cmp(&right.name));
snapshots
}
async fn restore_persisted_sandboxes(&self) {
let state_root = sandboxes_root_dir(&self.config.state_dir);
let mut entries = match tokio::fs::read_dir(&state_root).await {
Ok(entries) => entries,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return,
Err(err) => {
warn!(
state_root = %state_root.display(),
error = %err,
"vm driver: failed to scan persisted sandboxes"
);
return;
}
};
loop {
let entry = match entries.next_entry().await {
Ok(Some(entry)) => entry,
Ok(None) => break,
Err(err) => {
warn!(
state_root = %state_root.display(),
error = %err,
"vm driver: failed to continue scanning persisted sandboxes"
);
break;
}
};
let state_dir = entry.path();
let is_dir = match entry.file_type().await {
Ok(file_type) => file_type.is_dir(),
Err(err) => {
warn!(
state_dir = %state_dir.display(),
error = %err,
"vm driver: failed to inspect persisted sandbox state dir"
);
continue;
}
};
if !is_dir {
continue;
}
let request_path = state_dir.join(SANDBOX_REQUEST_FILE);
let sandbox = match read_sandbox_request(&request_path).await {
Ok(sandbox) => sandbox,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => continue,
Err(err) => {
warn!(
state_dir = %state_dir.display(),
error = %err,
"vm driver: failed to read persisted sandbox request"
);
continue;
}
};
if let Err(status) =
validate_restored_sandbox_state(&self.config.state_dir, &state_dir, &sandbox)
{
warn!(
sandbox_id = %sandbox.id,
state_dir = %state_dir.display(),
error = %status.message(),
"vm driver: ignoring invalid persisted sandbox state"
);
continue;
}
self.restore_persisted_sandbox(sandbox, state_dir).await;
}
}
async fn restore_persisted_sandbox(&self, sandbox: Sandbox, state_dir: PathBuf) {
let Some(image_ref) = self.resolved_sandbox_image(&sandbox) else {
warn!(
sandbox_id = %sandbox.id,
sandbox_name = %sandbox.name,
"vm driver: cannot restore persisted sandbox without image"
);
return;
};
let tls_paths = match self.config.tls_paths() {
Ok(paths) => paths,
Err(err) => {
warn!(
sandbox_id = %sandbox.id,
sandbox_name = %sandbox.name,
error = %err,
"vm driver: cannot restore persisted sandbox TLS configuration"
);
return;
}
};
if let Err(err) = self.ensure_extension_state_dirs(&state_dir).await {
warn!(
sandbox_id = %sandbox.id,
sandbox_name = %sandbox.name,
state_dir = %state_dir.display(),
error = %err.message(),
"vm driver: cannot restore persisted sandbox extension state"
);
return;
}
let persisted = RestoreContext {
sandbox: sandbox.clone(),
state_dir: state_dir.clone(),
};
if let Err(err) = self.lifecycle_extensions.before_restore(&persisted).await {
warn!(
sandbox_id = %sandbox.id,
sandbox_name = %sandbox.name,
state_dir = %state_dir.display(),
error = %err,
"vm driver: lifecycle extension rejected persisted sandbox restore"
);
return;
}
let snapshot = sandbox_snapshot(&sandbox, provisioning_condition(), false);
{
let mut registry = self.registry.lock().await;
if registry.contains_key(&sandbox.id) {
return;
}
registry.insert(
sandbox.id.clone(),
SandboxRecord {
snapshot: snapshot.clone(),
state_dir: state_dir.clone(),
process: None,
provisioning_task: None,
gpu_bdf: None,
qemu_network_allocated: false,
deleting: false,
},
);
}
self.publish_platform_event(
sandbox.id.clone(),
platform_event(
"vm",
"Normal",
"Restoring",
"Restoring persisted VM sandbox after driver restart".to_string(),
),
);
self.publish_snapshot(snapshot);
let driver = self.clone();
let sandbox_id = sandbox.id.clone();
let task = tokio::spawn(async move {
driver
.provision_sandbox(
sandbox,
image_ref,
state_dir,
tls_paths,
OverlayPreparation::PreserveExisting,
)
.await;
});
let mut registry = self.registry.lock().await;
if let Some(record) = registry.get_mut(&sandbox_id) {
if record.deleting {
task.abort();
} else {
record.provisioning_task = Some(task);
}
} else {
task.abort();
}
}
fn release_gpu(&self, sandbox_id: &str) {
if let Some(inventory) = self.gpu_inventory.as_ref()
&& let Ok(mut inv) = inventory.lock()
{
inv.release(sandbox_id);
}
}
fn release_subnet(&self, sandbox_id: &str) {
if let Ok(mut alloc) = self.subnet_allocator.lock() {
alloc.release(sandbox_id);
}
}
fn release_allocations(&self, sandbox_id: &str, has_gpu: bool, has_qemu_network: bool) {
if has_gpu {
self.release_gpu(sandbox_id);
}
if has_qemu_network {
self.release_subnet(sandbox_id);
}
}
fn release_gpu_and_subnet(&self, sandbox_id: &str) {
self.release_gpu(sandbox_id);
self.release_subnet(sandbox_id);
}
async fn ensure_extension_state_dirs(&self, state_dir: &Path) -> Result<(), Status> {
for extension_name in self.lifecycle_extensions.names() {
let extension_dir = extension_state_dir(state_dir, &extension_name).map_err(|err| {
Status::failed_precondition(format!(
"invalid VM lifecycle extension '{}': {}",
extension_name,
err.message()
))
})?;
create_private_dir_all(&extension_dir)
.await
.map_err(|err| {
Status::internal(format!(
"create VM lifecycle extension state dir '{}' failed: {err}",
extension_dir.display()
))
})?;
}
Ok(())
}
#[allow(clippy::result_large_err)]
fn resolve_launch_plan_backend(
&self,
sandbox_id: &str,
is_gpu: bool,
gpu_bdf: Option<String>,
plan: &mut LaunchPlan,
) -> Result<(), Status> {
if plan.kernel_image.is_some() {
plan.require_backend_feature(BackendFeature::ExternalKernelImage);
}
if !plan.guest_init_dropins.is_empty() {
plan.require_backend_feature(BackendFeature::GuestInitDropins);
}
if plan.required_backends.contains(&VmBackend::Qemu)
|| plan.backend == VmBackend::Qemu
|| plan
.required_backend_features
.iter()
.any(|feature| feature.requires_qemu())
{
self.configure_qemu_launch_plan(sandbox_id, is_gpu, gpu_bdf, plan)?;
}
Ok(())
}
#[allow(clippy::result_large_err)]
fn configure_qemu_launch_plan(
&self,
sandbox_id: &str,
is_gpu: bool,
gpu_bdf: Option<String>,
plan: &mut LaunchPlan,
) -> Result<(), Status> {
plan.backend = VmBackend::Qemu;
if is_gpu {
plan.vcpus = self.config.gpu_vcpus;
plan.mem_mib = self.config.gpu_mem_mib;
}
if plan.gpu_bdf.is_none() {
plan.gpu_bdf = gpu_bdf;
}
if has_complete_qemu_network(plan) {
return Ok(());
}
let subnet = self
.subnet_allocator
.lock()
.map_err(|e| Status::internal(format!("subnet allocator lock poisoned: {e}")))?
.allocate(sandbox_id)
.map_err(Status::failed_precondition)?;
let mac = mac_from_sandbox_id(sandbox_id);
plan.tap_device = Some(tap_device_name(sandbox_id));
plan.guest_ip = Some(subnet.guest_ip.to_string());
plan.host_ip = Some(subnet.host_ip.to_string());
plan.vsock_cid = Some(allocate_vsock_cid());
plan.guest_mac = Some(format!(
"{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}",
mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]
));
plan.gateway_port = gateway_port_from_endpoint(&self.config.openshell_endpoint);
Ok(())
}
#[allow(clippy::result_large_err)]
fn validate_launch_plan_backend(is_gpu: bool, plan: &LaunchPlan) -> Result<(), Status> {
// NOTE: this guard exists because the non-GPU QEMU launch path
// (PCI device transport, VFIO root port wiring) has not landed
// yet. Until then, even though the resolver will happily promote
// a plan to QEMU when an extension requires `PciPassthrough` or
// `ExternalKernelImage`, the launch itself is blocked here so we
// don't spawn a QEMU instance with no concrete device backing.
// Remove this guard once the non-GPU QEMU launch path supports
// emitting `pcie-root-port` + `vfio-pci` for arbitrary device
// descriptors.
if plan.backend == VmBackend::Qemu && !is_gpu {
let offending_feature = plan
.required_backend_features
.iter()
.find(|feature| feature.requires_qemu())
.map_or("(explicit QEMU backend requirement)", |feature| {
feature.as_str()
});
return Err(Status::failed_precondition(format!(
"vm lifecycle extension required '{offending_feature}', which resolves to the QEMU backend, \
but non-GPU QEMU launch is not yet supported (pending PCI device transport)"
)));
}
if plan.backend != VmBackend::Qemu && is_gpu {
return Err(Status::failed_precondition(
"GPU sandbox launch requires the QEMU backend",
));
}
if plan.required_backends.contains(&VmBackend::Libkrun)
&& plan.required_backends.contains(&VmBackend::Qemu)
{
return Err(Status::failed_precondition(
"VM lifecycle extensions requested conflicting VM backends",
));
}
if plan.required_backends.contains(&VmBackend::Libkrun)
&& plan.backend != VmBackend::Libkrun
{
return Err(Status::failed_precondition(
"VM lifecycle extension requires the libkrun backend",
));
}
if plan.required_backends.contains(&VmBackend::Qemu) && plan.backend != VmBackend::Qemu {
return Err(Status::failed_precondition(
"VM lifecycle extension requires the QEMU backend",
));
}
if plan.backend != VmBackend::Qemu
&& let Some(feature) = plan
.required_backend_features
.iter()
.find(|feature| feature.requires_qemu())
{
return Err(Status::failed_precondition(format!(
"VM backend feature '{}' requires a VM backend with PCI-style launch support",
feature.as_str()
)));
}
if plan.kernel_image.is_some() && plan.backend != VmBackend::Qemu {
return Err(Status::failed_precondition(
"selected kernel image requires a VM backend that supports external kernel images",
));
}
if let Some(kernel_image) = &plan.kernel_image
&& !kernel_image.is_file()
{
return Err(Status::failed_precondition(format!(
"selected kernel image does not exist: {}",
kernel_image.display()
)));
}
Ok(())
}
async fn mark_qemu_network_allocated(&self, sandbox_id: &str) -> Result<(), Status> {
let mut registry = self.registry.lock().await;
match registry.get_mut(sandbox_id) {
Some(record) if !record.deleting => {
record.qemu_network_allocated = true;
Ok(())
}
_ => Err(Status::cancelled("sandbox provisioning cancelled")),
}
}
#[allow(clippy::result_large_err)]
fn build_vm_launch_plan(
&self,
sandbox_id: &str,
needs_qemu: bool,
is_gpu: bool,
gpu_bdf: Option<String>,
) -> Result<LaunchPlan, Status> {
if !needs_qemu {
return Ok(LaunchPlan {
backend: VmBackend::Libkrun,
vcpus: self.config.vcpus,
mem_mib: self.config.mem_mib,
required_backends: Vec::new(),
required_backend_features: Vec::new(),
kernel_profile: None,
kernel_image: None,
gpu_bdf: None,
tap_device: None,
guest_ip: None,
host_ip: None,
vsock_cid: None,
guest_mac: None,
gateway_port: None,
guest_init_dropins: Vec::new(),
env: Vec::new(),
});
}
let subnet = self
.subnet_allocator
.lock()
.map_err(|e| Status::internal(format!("subnet allocator lock poisoned: {e}")))?
.allocate(sandbox_id)
.map_err(Status::failed_precondition)?;
let vsock_cid = allocate_vsock_cid();
let mac = mac_from_sandbox_id(sandbox_id);
let mac_str = format!(
"{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}",
mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]
);
let tap = tap_device_name(sandbox_id);
let gateway_port = gateway_port_from_endpoint(&self.config.openshell_endpoint);
let (vcpus, mem_mib) = if is_gpu {
(self.config.gpu_vcpus, self.config.gpu_mem_mib)
} else {
(self.config.vcpus, self.config.mem_mib)
};
Ok(LaunchPlan {
backend: VmBackend::Qemu,
vcpus,
mem_mib,
required_backends: Vec::new(),
required_backend_features: Vec::new(),
kernel_profile: None,
kernel_image: None,
gpu_bdf,
tap_device: Some(tap),
guest_ip: Some(subnet.guest_ip.to_string()),
host_ip: Some(subnet.host_ip.to_string()),
vsock_cid: Some(vsock_cid),
guest_mac: Some(mac_str),
gateway_port,
guest_init_dropins: Vec::new(),
env: Vec::new(),
})
}
async fn ensure_provisioning_active(&self, sandbox_id: &str) -> Result<(), Status> {
let registry = self.registry.lock().await;
match registry.get(sandbox_id) {
Some(record) if !record.deleting => Ok(()),
_ => Err(Status::cancelled("sandbox provisioning cancelled")),
}
}
async fn assign_gpu_to_record(
&self,
sandbox_id: &str,
gpu_device: &str,
) -> Result<String, Status> {
let mut registry = self.registry.lock().await;
match registry.get_mut(sandbox_id) {
Some(record) if !record.deleting => {}
_ => return Err(Status::cancelled("sandbox provisioning cancelled")),
}
let inventory = self
.gpu_inventory
.as_ref()
.ok_or_else(|| Status::internal("GPU inventory not initialized"))?;
let assignment = inventory
.lock()
.map_err(|e| Status::internal(format!("GPU inventory lock poisoned: {e}")))?
.assign(sandbox_id, gpu_device)
.map_err(Status::failed_precondition)?;
let record = registry
.get_mut(sandbox_id)
.expect("sandbox record exists while registry lock is held");
record.gpu_bdf = Some(assignment.bdf.clone());
tracing::info!(
sandbox_id = %sandbox_id,
bdf = %assignment.bdf,
gpu_name = %assignment.name,
iommu_group = assignment.iommu_group,
"assigned GPU to sandbox"
);
Ok(assignment.bdf)
}
async fn fail_provisioning(
&self,
sandbox_id: &str,
state_dir: &Path,
reason: &str,
message: &str,
remove_state: bool,
) {
self.release_gpu_and_subnet(sandbox_id);
let snapshot = {
let mut registry = self.registry.lock().await;
let Some(record) = registry.get_mut(sandbox_id) else {
return;
};
if record.deleting {
return;
}
record.process = None;
record.provisioning_task = None;
record.gpu_bdf = None;
record.qemu_network_allocated = false;
record.snapshot.status = Some(status_with_condition(
&record.snapshot,
error_condition(reason, message),
false,
));
Some(record.snapshot.clone())
};
if remove_state {
let _ = tokio::fs::remove_dir_all(state_dir).await;
}
self.publish_platform_event(
sandbox_id.to_string(),
platform_event(
"vm",
"Warning",
reason,
format!("VM provisioning failed: {message}"),
),
);
if let Some(snapshot) = snapshot {
self.publish_snapshot(snapshot);
}
}
async fn prepare_runtime_images(
&self,
sandbox_id: &str,
image_ref: &str,
) -> Result<RuntimeImagePlan, Status> {
let bootstrap_image_ref = self.bootstrap_image_ref(image_ref);
let bootstrap_image_identity = self
.ensure_cached_bootstrap_rootfs_image(sandbox_id, &bootstrap_image_ref)
.await?;
let root_disk = image_cache_rootfs_image(&self.config.state_dir, &bootstrap_image_identity);
if image_ref.trim() == bootstrap_image_ref.trim() {
return Ok(RuntimeImagePlan {
root_disk,
image_disk: None,
image_identity: bootstrap_image_identity.clone(),
bootstrap_image_identity,
});
}
let prepared = self
.ensure_prepared_image_disk(sandbox_id, image_ref, &root_disk)
.await?;
Ok(RuntimeImagePlan {
root_disk,
image_disk: Some(prepared.disk_path),
image_identity: prepared.image_identity,
bootstrap_image_identity,
})
}
fn bootstrap_image_ref(&self, sandbox_image_ref: &str) -> String {
let configured = self.config.bootstrap_image.trim();
if !configured.is_empty() {
return configured.to_string();
}
let default = self.config.default_image.trim();
if !default.is_empty() {
return default.to_string();
}
sandbox_image_ref.to_string()
}
async fn prepare_runtime_overlay(
&self,
overlay_disk: &Path,
tls_paths: Option<&VmDriverTlsPaths>,
sandbox_token: Option<&str>,
preparation: OverlayPreparation,
) -> Result<(), String> {
let tls_materials = match tls_paths {
Some(paths) => Some(read_guest_tls_materials(paths).await?),
None => None,
};
let sandbox_token = sandbox_token.map(str::to_string);
let overlay_disk = overlay_disk.to_path_buf();
let overlay_size_bytes = self
.config
.overlay_disk_mib
.checked_mul(1024 * 1024)
.ok_or_else(|| {
format!(
"overlay disk size {} MiB is too large",
self.config.overlay_disk_mib
)
})?;
let template_path = overlay_template_image(&self.config.state_dir, overlay_size_bytes);
if !overlay_template_image_ready(&template_path, overlay_size_bytes).await? {
let _cache_guard = self.image_cache_lock.lock().await;
let template_path = template_path.clone();
tokio::task::spawn_blocking(move || {
ensure_sandbox_overlay_template_image(&template_path, overlay_size_bytes)
})
.await
.map_err(|err| format!("overlay template preparation panicked: {err}"))??;
}
tokio::task::spawn_blocking(move || {
prepare_sandbox_overlay_image(
&template_path,
&overlay_disk,
tls_materials.as_ref(),
sandbox_token.as_deref(),
preparation,
overlay_size_bytes,
)
})
.await
.map_err(|err| format!("overlay image preparation panicked: {err}"))?
}
fn resolved_sandbox_image(&self, sandbox: &Sandbox) -> Option<String> {
requested_sandbox_image(sandbox)
.map(ToOwned::to_owned)
.or_else(|| {
let image = self.config.default_image.trim();
(!image.is_empty()).then(|| image.to_string())
})
}
async fn ensure_cached_bootstrap_rootfs_image(
&self,
sandbox_id: &str,
image_ref: &str,
) -> Result<String, Status> {
if let Some((engine, image_identity)) =
self.resolve_local_container_image(image_ref).await?
{
return self
.ensure_cached_local_image_rootfs_image(
sandbox_id,
image_ref,
&engine,
&image_identity,
)
.await;
}
info!(image_ref = %image_ref, "vm driver: ensuring cached root disk image (registry)");
let reference = parse_registry_reference(image_ref)?;
let client = registry_client();
let auth = registry_auth(image_ref)?;
info!(image_ref = %image_ref, "vm driver: authenticating with registry");
self.publish_vm_progress(
sandbox_id,
"AuthenticatingRegistry",
format!("Authenticating registry access for image \"{image_ref}\""),
HashMap::from([
("image_ref".to_string(), image_ref.to_string()),
("image_source".to_string(), "registry".to_string()),
]),
);
client
.auth(&reference, &auth, RegistryOperation::Pull)
.await
.map_err(|err| {
Status::failed_precondition(format!(
"failed to authenticate registry access for vm sandbox image '{image_ref}': {err}"
))
})?;
info!(image_ref = %image_ref, "vm driver: fetching manifest digest");
self.publish_vm_progress(
sandbox_id,
"FetchingManifest",
format!("Fetching manifest for image \"{image_ref}\""),
HashMap::from([
("image_ref".to_string(), image_ref.to_string()),
("image_source".to_string(), "registry".to_string()),
]),
);
let source_image_identity = client
.fetch_manifest_digest(&reference, &auth)
.await
.map_err(|err| {
Status::failed_precondition(format!(
"failed to resolve vm sandbox image '{image_ref}': {err}"
))
})?;
info!(
image_ref = %image_ref,
image_identity = %source_image_identity,
"vm driver: manifest digest resolved"
);
let image_identity = bootstrap_image_cache_identity(&source_image_identity);
let image_path = image_cache_rootfs_image(&self.config.state_dir, &image_identity);
// Emit a driver progress hint for cache hits too and immediately
// follow with `Pulled` so the image step still advances cleanly.
self.publish_platform_event(
sandbox_id.to_string(),
platform_event(
"vm",
"Normal",
"Pulling",
format!("Pulling image \"{image_ref}\""),
),
);
if tokio::fs::metadata(&image_path).await.is_ok() {
info!(
image_identity = %image_identity,
image_path = %image_path.display(),
"vm driver: root disk image cache hit (no build needed)"
);
self.publish_vm_progress(
sandbox_id,
"CacheHit",
format!("Using cached VM root disk for image \"{image_ref}\""),
HashMap::from([
("image_ref".to_string(), image_ref.to_string()),
("image_source".to_string(), "registry".to_string()),
("cache_hit".to_string(), "true".to_string()),
("image_identity".to_string(), image_identity.clone()),
]),
);
self.publish_pulled_event(sandbox_id, image_ref, &image_path)
.await;
return Ok(image_identity);
}
info!(
image_identity = %image_identity,
"vm driver: root disk image cache miss, acquiring build lock"
);
self.publish_vm_progress(
sandbox_id,
"CacheMiss",
format!("Preparing VM root disk cache for image \"{image_ref}\""),
HashMap::from([
("image_ref".to_string(), image_ref.to_string()),
("image_source".to_string(), "registry".to_string()),
("cache_hit".to_string(), "false".to_string()),
("image_identity".to_string(), image_identity.clone()),
]),
);
self.publish_vm_progress(
sandbox_id,
"WaitingForImageCacheLock",
"Waiting for VM image cache build lock".to_string(),
HashMap::from([
("image_ref".to_string(), image_ref.to_string()),
("image_identity".to_string(), image_identity.clone()),
]),
);
let _cache_guard = self.image_cache_lock.lock().await;
info!(
image_identity = %image_identity,
"vm driver: build lock acquired"
);
if tokio::fs::metadata(&image_path).await.is_ok() {
info!(
image_identity = %image_identity,
"vm driver: root disk image cache hit after lock (built by another task)"
);
self.publish_vm_progress(
sandbox_id,
"CacheHit",
format!("Using cached VM root disk for image \"{image_ref}\""),
HashMap::from([
("image_ref".to_string(), image_ref.to_string()),
("image_source".to_string(), "registry".to_string()),
("cache_hit".to_string(), "true".to_string()),
("image_identity".to_string(), image_identity.clone()),
]),
);
self.publish_pulled_event(sandbox_id, image_ref, &image_path)
.await;
return Ok(image_identity);
}
self.build_cached_registry_image_rootfs_image(
sandbox_id,
&client,
&reference,
&auth,
image_ref,
&image_identity,
)
.await?;
self.publish_pulled_event(sandbox_id, image_ref, &image_path)
.await;
Ok(image_identity)
}
async fn resolve_local_container_image(
&self,
image_ref: &str,
) -> Result<Option<(Docker, String)>, Status> {
let required_local_image = is_openshell_local_build_image_ref(image_ref);
let engine = match connect_local_container_engine().await {
Some(engine) => engine,
None if required_local_image => {
return Err(Status::failed_precondition(format!(
"no container engine (Docker/Podman) available for locally built sandbox image '{image_ref}'"
)));
}
None => {
warn!(
image_ref = %image_ref,
"vm driver: no local container engine available, falling back to registry"
);
return Ok(None);
}
};
match engine.inspect_image(image_ref).await {
Ok(inspect) => {
if let Some(message) = local_image_platform_mismatch(
image_ref,
inspect.os.as_deref(),
inspect.architecture.as_deref(),
) {
if required_local_image {
return Err(Status::failed_precondition(message));
}
warn!(
image_ref = %image_ref,
%message,
"vm driver: local container image platform mismatch, falling back to registry"
);
return Ok(None);
}
let image_identity = inspect.id.filter(|id| !id.trim().is_empty()).ok_or_else(
|| {
Status::failed_precondition(format!(
"local container image '{image_ref}' inspect response has no image ID"
))
},
)?;
info!(
image_ref = %image_ref,
image_identity = %image_identity,
"vm driver: resolved image from local container engine"
);
Ok(Some((engine, image_identity)))
}
Err(err) if is_docker_not_found_error(&err) && required_local_image => {
Err(Status::failed_precondition(format!(
"locally built sandbox image '{image_ref}' is not present in the local container engine"
)))
}
Err(err) if is_docker_not_found_error(&err) => Ok(None),
Err(err) if required_local_image => Err(Status::failed_precondition(format!(
"failed to inspect locally built sandbox image '{image_ref}': {err}"
))),
Err(err) => {
warn!(
image_ref = %image_ref,
error = %err,
"vm driver: local container image inspection failed, falling back to registry"
);
Ok(None)
}
}
}
async fn ensure_cached_local_image_rootfs_image(
&self,
sandbox_id: &str,
image_ref: &str,
docker: &Docker,
image_identity: &str,
) -> Result<String, Status> {
let cache_identity = bootstrap_image_cache_identity(image_identity);
let image_path = image_cache_rootfs_image(&self.config.state_dir, &cache_identity);
self.publish_platform_event(
sandbox_id.to_string(),
platform_event(
"vm",
"Normal",
"Pulling",
format!("Pulling image \"{image_ref}\""),
),
);
if tokio::fs::metadata(&image_path).await.is_ok() {
self.publish_vm_progress(
sandbox_id,
"CacheHit",
format!("Using cached VM root disk for local image \"{image_ref}\""),
HashMap::from([
("image_ref".to_string(), image_ref.to_string()),
("image_source".to_string(), "local_docker".to_string()),
("cache_hit".to_string(), "true".to_string()),
("image_identity".to_string(), cache_identity.clone()),
]),
);
self.publish_pulled_event(sandbox_id, image_ref, &image_path)
.await;
return Ok(cache_identity);
}
self.publish_vm_progress(
sandbox_id,
"CacheMiss",
format!("Preparing VM root disk cache for local image \"{image_ref}\""),
HashMap::from([
("image_ref".to_string(), image_ref.to_string()),
("image_source".to_string(), "local_docker".to_string()),
("cache_hit".to_string(), "false".to_string()),
("image_identity".to_string(), cache_identity.clone()),
]),
);
self.publish_vm_progress(
sandbox_id,
"WaitingForImageCacheLock",
"Waiting for VM image cache build lock".to_string(),
HashMap::from([
("image_ref".to_string(), image_ref.to_string()),
("image_identity".to_string(), cache_identity.clone()),
]),
);
let _cache_guard = self.image_cache_lock.lock().await;
if tokio::fs::metadata(&image_path).await.is_ok() {
self.publish_vm_progress(
sandbox_id,
"CacheHit",
format!("Using cached VM root disk for local image \"{image_ref}\""),
HashMap::from([
("image_ref".to_string(), image_ref.to_string()),
("image_source".to_string(), "local_docker".to_string()),
("cache_hit".to_string(), "true".to_string()),
("image_identity".to_string(), cache_identity.clone()),
]),
);
self.publish_pulled_event(sandbox_id, image_ref, &image_path)
.await;
return Ok(cache_identity);
}
self.build_cached_local_image_rootfs_image(sandbox_id, docker, image_ref, &cache_identity)
.await?;
self.publish_pulled_event(sandbox_id, image_ref, &image_path)
.await;
Ok(cache_identity)
}
async fn ensure_prepared_image_disk(
&self,
sandbox_id: &str,
image_ref: &str,
bootstrap_root_disk: &Path,
) -> Result<PreparedImageDisk, Status> {
if let Some((docker, image_identity)) =
self.resolve_local_container_image(image_ref).await?
{
return self
.ensure_prepared_local_image_disk(
sandbox_id,
image_ref,
&docker,
&image_identity,
bootstrap_root_disk,
)
.await;
}
self.ensure_prepared_registry_image_disk(sandbox_id, image_ref, bootstrap_root_disk)
.await
}
async fn ensure_prepared_local_image_disk(
&self,
sandbox_id: &str,
image_ref: &str,
docker: &Docker,
image_identity: &str,
bootstrap_root_disk: &Path,
) -> Result<PreparedImageDisk, Status> {
let cache_identity = prepared_image_cache_identity(image_identity);
let image_path = image_cache_rootfs_image(&self.config.state_dir, &cache_identity);
if tokio::fs::metadata(&image_path).await.is_ok() {
self.publish_prepared_cache_hit(sandbox_id, image_ref, "local_docker", &cache_identity);
return Ok(PreparedImageDisk {
image_identity: cache_identity,
disk_path: image_path,
});
}
self.publish_prepared_cache_miss(sandbox_id, image_ref, "local_docker", &cache_identity);
let _cache_guard = self.image_cache_lock.lock().await;
if tokio::fs::metadata(&image_path).await.is_ok() {
self.publish_prepared_cache_hit(sandbox_id, image_ref, "local_docker", &cache_identity);
return Ok(PreparedImageDisk {
image_identity: cache_identity,
disk_path: image_path,
});
}
let staging_dir = image_cache_staging_dir(&self.config.state_dir, &cache_identity);
let rootfs_archive = staging_dir.join(IMAGE_EXPORT_ROOTFS_ARCHIVE);
self.reset_image_staging_dir(&staging_dir).await?;
self.publish_vm_progress(
sandbox_id,
"ExportingRootfs",
format!("Exporting rootfs from local image \"{image_ref}\""),
HashMap::from([
("image_ref".to_string(), image_ref.to_string()),
("image_source".to_string(), "local_docker".to_string()),
("image_identity".to_string(), cache_identity.clone()),
]),
);
if let Err(err) =
export_local_image_rootfs_to_path(docker, image_ref, &rootfs_archive).await
{
let _ = tokio::fs::remove_dir_all(&staging_dir).await;
return Err(err);
}
let payload = GuestImagePayload {
image_ref: image_ref.to_string(),
image_identity: cache_identity.clone(),
source: GuestImagePayloadSource::LocalDocker { rootfs_archive },
};
self.build_prepared_image_disk(
sandbox_id,
image_ref,
"local_docker",
&cache_identity,
bootstrap_root_disk,
&staging_dir,
&payload,
)
.await?;
Ok(PreparedImageDisk {
image_identity: cache_identity,
disk_path: image_path,
})
}
async fn ensure_prepared_registry_image_disk(
&self,
sandbox_id: &str,
image_ref: &str,
bootstrap_root_disk: &Path,
) -> Result<PreparedImageDisk, Status> {
let reference = parse_registry_reference(image_ref)?;
let client = registry_client();
let auth = registry_auth(image_ref)?;
self.publish_vm_progress(
sandbox_id,
"AuthenticatingRegistry",
format!("Authenticating registry access for image \"{image_ref}\""),
HashMap::from([
("image_ref".to_string(), image_ref.to_string()),
("image_source".to_string(), "registry".to_string()),
]),
);
client
.auth(&reference, &auth, RegistryOperation::Pull)
.await
.map_err(|err| {
Status::failed_precondition(format!(
"failed to authenticate registry access for vm sandbox image '{image_ref}': {err}"
))
})?;
self.publish_vm_progress(
sandbox_id,
"FetchingManifest",
format!("Fetching manifest for image \"{image_ref}\""),
HashMap::from([
("image_ref".to_string(), image_ref.to_string()),
("image_source".to_string(), "registry".to_string()),
]),
);
let source_image_identity = client
.fetch_manifest_digest(&reference, &auth)
.await
.map_err(|err| {
Status::failed_precondition(format!(
"failed to resolve vm sandbox image '{image_ref}': {err}"
))
})?;
let cache_identity = prepared_image_cache_identity(&source_image_identity);
let image_path = image_cache_rootfs_image(&self.config.state_dir, &cache_identity);
if tokio::fs::metadata(&image_path).await.is_ok() {
self.publish_prepared_cache_hit(sandbox_id, image_ref, "registry", &cache_identity);
return Ok(PreparedImageDisk {
image_identity: cache_identity,
disk_path: image_path,
});
}
self.publish_prepared_cache_miss(sandbox_id, image_ref, "registry", &cache_identity);
let _cache_guard = self.image_cache_lock.lock().await;
if tokio::fs::metadata(&image_path).await.is_ok() {
self.publish_prepared_cache_hit(sandbox_id, image_ref, "registry", &cache_identity);
return Ok(PreparedImageDisk {
image_identity: cache_identity,
disk_path: image_path,
});
}
let staging_dir = image_cache_staging_dir(&self.config.state_dir, &cache_identity);
self.reset_image_staging_dir(&staging_dir).await?;
let layout_dir = staging_dir.join(GUEST_IMAGE_OCI_LAYOUT_DIR);
let (manifest, _) = client
.pull_image_manifest(&reference, &auth)
.await
.map_err(|err| {
Status::failed_precondition(format!(
"failed to pull vm sandbox image manifest '{image_ref}': {err}"
))
})?;
tokio::fs::create_dir_all(oci_layout_blobs_dir(&layout_dir))
.await
.map_err(|err| Status::internal(format!("create guest OCI layout failed: {err}")))?;
download_registry_descriptor_blob_file(
&client,
&reference,
image_ref,
&layout_dir,
&manifest.config,
"config",
)
.await?;
let total_layers = manifest.layers.len();
let total_bytes: i64 = manifest.layers.iter().map(|layer| layer.size.max(0)).sum();
futures::stream::iter(manifest.layers.iter().cloned().enumerate())
.map(|(index, layer)| {
let client = client.clone();
let reference = reference.clone();
let layout_dir = layout_dir.clone();
async move {
self.publish_registry_layer_progress(
sandbox_id,
image_ref,
&layer,
index,
total_layers,
total_bytes,
);
download_registry_descriptor_blob_file(
&client,
&reference,
image_ref,
&layout_dir,
&layer,
&format!("layer {}", index + 1),
)
.await
}
})
.buffer_unordered(registry_layer_download_concurrency())
.try_collect::<Vec<_>>()
.await?;
write_oci_layout_for_manifest(&layout_dir, GUEST_IMAGE_OCI_REF, &manifest)
.map_err(|err| Status::internal(format!("write OCI layout failed: {err}")))?;
let payload = GuestImagePayload {
image_ref: image_ref.to_string(),
image_identity: cache_identity.clone(),
source: GuestImagePayloadSource::RegistryOciLayout { layout_dir },
};
self.build_prepared_image_disk(
sandbox_id,
image_ref,
"registry",
&cache_identity,
bootstrap_root_disk,
&staging_dir,
&payload,
)
.await?;
Ok(PreparedImageDisk {
image_identity: cache_identity,
disk_path: image_path,
})
}
async fn reset_image_staging_dir(&self, staging_dir: &Path) -> Result<(), Status> {
tokio::fs::create_dir_all(image_cache_root_dir(&self.config.state_dir))
.await
.map_err(|err| Status::internal(format!("create image cache dir failed: {err}")))?;
if tokio::fs::metadata(staging_dir).await.is_ok() {
tokio::fs::remove_dir_all(staging_dir)
.await
.map_err(|err| {
Status::internal(format!(
"remove stale image cache staging dir failed: {err}"
))
})?;
}
tokio::fs::create_dir_all(staging_dir).await.map_err(|err| {
Status::internal(format!("create image cache staging dir failed: {err}"))
})
}
#[allow(clippy::too_many_arguments)]
async fn build_prepared_image_disk(
&self,
sandbox_id: &str,
image_ref: &str,
image_source: &str,
image_identity: &str,
bootstrap_root_disk: &Path,
staging_dir: &Path,
payload: &GuestImagePayload,
) -> Result<(), Status> {
let cache_dir = image_cache_dir(&self.config.state_dir, image_identity);
let image_path = image_cache_rootfs_image(&self.config.state_dir, image_identity);
let prepared_image = staging_dir.join(IMAGE_CACHE_ROOTFS_IMAGE);
tokio::fs::create_dir_all(&cache_dir).await.map_err(|err| {
Status::internal(format!("create prepared image cache dir failed: {err}"))
})?;
let payload_for_size = payload.clone();
let min_size = self
.config
.overlay_disk_mib
.checked_mul(1024 * 1024)
.ok_or_else(|| Status::internal("prepared image disk size overflow"))?;
let image_size = tokio::task::spawn_blocking(move || {
prepared_image_disk_size_bytes(&payload_for_size, min_size)
})
.await
.map_err(|err| {
Status::internal(format!("prepared image size calculation panicked: {err}"))
})?
.map_err(Status::internal)?;
let payload_for_disk = payload.clone();
let prepared_image_for_disk = prepared_image.clone();
self.publish_vm_progress(
sandbox_id,
"CreatingRootDisk",
"Formatting prepared VM image disk".to_string(),
HashMap::from([
("image_ref".to_string(), image_ref.to_string()),
("image_source".to_string(), image_source.to_string()),
("image_identity".to_string(), image_identity.to_string()),
]),
);
tokio::task::spawn_blocking(move || {
create_image_prep_disk(&prepared_image_for_disk, image_size, &payload_for_disk)
})
.await
.map_err(|err| Status::internal(format!("prepared image disk build panicked: {err}")))?
.map_err(Status::failed_precondition)?;
self.publish_vm_progress(
sandbox_id,
"PreparingRootfs",
format!("Preparing VM image rootfs for \"{image_ref}\""),
HashMap::from([
("image_ref".to_string(), image_ref.to_string()),
("image_source".to_string(), image_source.to_string()),
("image_identity".to_string(), image_identity.to_string()),
]),
);
if let Err(err) = self
.run_image_prep_vm(bootstrap_root_disk, &prepared_image, staging_dir)
.await
{
let _ = tokio::fs::remove_dir_all(staging_dir).await;
return Err(err);
}
if tokio::fs::metadata(&image_path).await.is_ok() {
let _ = tokio::fs::remove_dir_all(staging_dir).await;
return Ok(());
}
tokio::fs::rename(&prepared_image, &image_path)
.await
.map_err(|err| Status::internal(format!("store prepared image disk failed: {err}")))?;
let _ = tokio::fs::remove_dir_all(staging_dir).await;
Ok(())
}
async fn run_image_prep_vm(
&self,
bootstrap_root_disk: &Path,
prep_disk: &Path,
run_dir: &Path,
) -> Result<(), Status> {
let console_output = run_dir.join("image-prep-console.log");
let mut command = Command::new(&self.launcher_bin);
command.kill_on_drop(true);
command.stdin(Stdio::null());
command.stdout(Stdio::inherit());
command.stderr(Stdio::inherit());
command.arg("--internal-run-vm");
command.arg("--vm-root-disk").arg(bootstrap_root_disk);
command.arg("--vm-overlay-disk").arg(prep_disk);
command.arg("--vm-exec").arg(sandbox_guest_init_path());
command.arg("--vm-workdir").arg("/");
command.arg("--vm-console-output").arg(&console_output);
command.arg("--vm-vcpus").arg(self.config.vcpus.to_string());
command
.arg("--vm-mem-mib")
.arg(self.config.mem_mib.to_string());
command
.arg("--vm-krun-log-level")
.arg(self.config.krun_log_level.to_string());
command
.arg("--vm-env")
.arg(format!("OPENSHELL_VM_INIT_MODE={IMAGE_PREP_INIT_MODE}"));
let mut child = command
.spawn()
.map_err(|err| Status::internal(format!("failed to run image-prep vm: {err}")))?;
let status = child
.wait()
.await
.map_err(|err| Status::internal(format!("failed to wait for image-prep vm: {err}")))?;
if status.success() {
return Ok(());
}
let console = tokio::fs::read_to_string(&console_output)
.await
.unwrap_or_default();
Err(Status::failed_precondition(format!(
"image-prep vm exited with status {status}: {console}"
)))
}
fn publish_prepared_cache_hit(
&self,
sandbox_id: &str,
image_ref: &str,
image_source: &str,
image_identity: &str,
) {
self.publish_vm_progress(
sandbox_id,
"CacheHit",
format!("Using cached prepared VM image disk for \"{image_ref}\""),
HashMap::from([
("image_ref".to_string(), image_ref.to_string()),
("image_source".to_string(), image_source.to_string()),
("cache_hit".to_string(), "true".to_string()),
("image_identity".to_string(), image_identity.to_string()),
]),
);
}
fn publish_prepared_cache_miss(
&self,
sandbox_id: &str,
image_ref: &str,
image_source: &str,
image_identity: &str,
) {
self.publish_vm_progress(
sandbox_id,
"CacheMiss",
format!("Preparing VM image disk cache for \"{image_ref}\""),
HashMap::from([
("image_ref".to_string(), image_ref.to_string()),
("image_source".to_string(), image_source.to_string()),
("cache_hit".to_string(), "false".to_string()),
("image_identity".to_string(), image_identity.to_string()),
]),
);
}
#[allow(clippy::similar_names)]
async fn build_cached_local_image_rootfs_image(
&self,
sandbox_id: &str,
docker: &Docker,
image_ref: &str,
image_identity: &str,
) -> Result<(), Status> {
let cache_dir = image_cache_dir(&self.config.state_dir, image_identity);
let image_path = image_cache_rootfs_image(&self.config.state_dir, image_identity);
let staging_dir = image_cache_staging_dir(&self.config.state_dir, image_identity);
let exported_rootfs = staging_dir.join(IMAGE_EXPORT_ROOTFS_ARCHIVE);
let prepared_rootfs = staging_dir.join("rootfs");
let prepared_image = staging_dir.join(IMAGE_CACHE_ROOTFS_IMAGE);
tokio::fs::create_dir_all(image_cache_root_dir(&self.config.state_dir))
.await
.map_err(|err| Status::internal(format!("create image cache dir failed: {err}")))?;
tokio::fs::create_dir_all(&cache_dir)
.await
.map_err(|err| Status::internal(format!("create image cache dir failed: {err}")))?;
if tokio::fs::metadata(&staging_dir).await.is_ok() {
tokio::fs::remove_dir_all(&staging_dir)
.await
.map_err(|err| {
Status::internal(format!(
"remove stale image cache staging dir failed: {err}"
))
})?;
}
tokio::fs::create_dir_all(&staging_dir)
.await
.map_err(|err| {
Status::internal(format!("create image cache staging dir failed: {err}"))
})?;
self.publish_vm_progress(
sandbox_id,
"ExportingRootfs",
format!("Exporting rootfs from local image \"{image_ref}\""),
HashMap::from([
("image_ref".to_string(), image_ref.to_string()),
("image_source".to_string(), "local_docker".to_string()),
("image_identity".to_string(), image_identity.to_string()),
]),
);
if let Err(err) =
export_local_image_rootfs_to_path(docker, image_ref, &exported_rootfs).await
{
let _ = tokio::fs::remove_dir_all(&staging_dir).await;
return Err(err);
}
let image_ref_owned = image_ref.to_string();
let image_identity_owned = image_identity.to_string();
let exported_rootfs_for_build = exported_rootfs.clone();
let prepared_rootfs_for_build = prepared_rootfs.clone();
let sandbox_uid = self.config.resolve_sandbox_uid();
let sandbox_gid = self.config.resolve_sandbox_gid(sandbox_uid);
self.publish_vm_progress(
sandbox_id,
"PreparingRootfs",
format!(
"Preparing VM rootfs for local image \"{image_ref}\" (sandbox uid={sandbox_uid})"
),
HashMap::from([
("image_ref".to_string(), image_ref.to_string()),
("image_source".to_string(), "local_docker".to_string()),
("image_identity".to_string(), image_identity.to_string()),
("sandbox_uid".to_string(), sandbox_uid.to_string()),
]),
);
let prepare_result = tokio::task::spawn_blocking(move || {
extract_rootfs_archive_to(&exported_rootfs_for_build, &prepared_rootfs_for_build)?;
prepare_sandbox_rootfs_from_image_root(
&prepared_rootfs_for_build,
&image_identity_owned,
sandbox_uid,
sandbox_gid,
)
.map_err(|err| {
format!("vm sandbox image '{image_ref_owned}' is not base-compatible: {err}")
})
})
.await
.map_err(|err| Status::internal(format!("local image preparation panicked: {err}")))?;
if let Err(err) = prepare_result {
let _ = tokio::fs::remove_dir_all(&staging_dir).await;
return Err(Status::failed_precondition(err));
}
self.publish_vm_progress(
sandbox_id,
"CreatingRootDisk",
"Formatting VM root disk image".to_string(),
HashMap::from([
("image_ref".to_string(), image_ref.to_string()),
("image_source".to_string(), "local_docker".to_string()),
("image_identity".to_string(), image_identity.to_string()),
]),
);
let prepared_rootfs_for_build = prepared_rootfs.clone();
let prepared_image_for_build = prepared_image.clone();
let build_result = tokio::task::spawn_blocking(move || {
create_rootfs_image_from_dir(&prepared_rootfs_for_build, &prepared_image_for_build)
})
.await
.map_err(|err| Status::internal(format!("rootfs image build panicked: {err}")))?;
if let Err(err) = build_result {
let _ = tokio::fs::remove_dir_all(&staging_dir).await;
return Err(Status::failed_precondition(err));
}
if tokio::fs::metadata(&image_path).await.is_ok() {
let _ = tokio::fs::remove_dir_all(&staging_dir).await;
return Ok(());
}
tokio::fs::rename(&prepared_image, &image_path)
.await
.map_err(|err| Status::internal(format!("store cached rootfs image failed: {err}")))?;
let _ = tokio::fs::remove_dir_all(&staging_dir).await;
Ok(())
}
#[allow(clippy::similar_names)]
async fn build_cached_registry_image_rootfs_image(
&self,
sandbox_id: &str,
client: &OciClient,
reference: &Reference,
auth: &RegistryAuth,
image_ref: &str,
image_identity: &str,
) -> Result<(), Status> {
let cache_dir = image_cache_dir(&self.config.state_dir, image_identity);
let image_path = image_cache_rootfs_image(&self.config.state_dir, image_identity);
let staging_dir = image_cache_staging_dir(&self.config.state_dir, image_identity);
let prepared_rootfs = staging_dir.join("rootfs");
let prepared_image = staging_dir.join(IMAGE_CACHE_ROOTFS_IMAGE);
tokio::fs::create_dir_all(image_cache_root_dir(&self.config.state_dir))
.await
.map_err(|err| Status::internal(format!("create image cache dir failed: {err}")))?;
tokio::fs::create_dir_all(&cache_dir)
.await
.map_err(|err| Status::internal(format!("create image cache dir failed: {err}")))?;
if tokio::fs::metadata(&staging_dir).await.is_ok() {
tokio::fs::remove_dir_all(&staging_dir)
.await
.map_err(|err| {
Status::internal(format!(
"remove stale image cache staging dir failed: {err}"
))
})?;
}
tokio::fs::create_dir_all(&staging_dir)
.await
.map_err(|err| {
Status::internal(format!("create image cache staging dir failed: {err}"))
})?;
info!(
image_ref = %image_ref,
staging_dir = %staging_dir.display(),
"vm driver: pulling registry image layers"
);
if let Err(err) = self
.pull_registry_image_rootfs(
sandbox_id,
client,
reference,
auth,
image_ref,
&staging_dir,
&prepared_rootfs,
)
.await
{
warn!(
image_ref = %image_ref,
error = %err.message(),
"vm driver: pull_registry_image_rootfs failed"
);
let _ = tokio::fs::remove_dir_all(&staging_dir).await;
return Err(err);
}
info!(
image_ref = %image_ref,
"vm driver: image layers pulled, preparing rootfs image"
);
let image_ref_owned = image_ref.to_string();
let image_identity_owned = image_identity.to_string();
let prepared_rootfs_for_build = prepared_rootfs.clone();
let sandbox_uid = self.config.resolve_sandbox_uid();
let sandbox_gid = self.config.resolve_sandbox_gid(sandbox_uid);
self.publish_vm_progress(
sandbox_id,
"PreparingRootfs",
format!("Preparing VM rootfs for image \"{image_ref}\" (sandbox uid={sandbox_uid})"),
HashMap::from([
("image_ref".to_string(), image_ref.to_string()),
("image_source".to_string(), "registry".to_string()),
("image_identity".to_string(), image_identity.to_string()),
("sandbox_uid".to_string(), sandbox_uid.to_string()),
]),
);
let prepare_result = tokio::task::spawn_blocking(move || {
prepare_sandbox_rootfs_from_image_root(
&prepared_rootfs_for_build,
&image_identity_owned,
sandbox_uid,
sandbox_gid,
)
.map_err(|err| {
format!("vm sandbox image '{image_ref_owned}' is not base-compatible: {err}")
})
})
.await
.map_err(|err| Status::internal(format!("image rootfs preparation panicked: {err}")))?;
if let Err(err) = prepare_result {
warn!(
image_ref = %image_ref,
error = %err,
"vm driver: rootfs preparation failed"
);
let _ = tokio::fs::remove_dir_all(&staging_dir).await;
return Err(Status::failed_precondition(err));
}
self.publish_vm_progress(
sandbox_id,
"CreatingRootDisk",
"Formatting VM root disk image".to_string(),
HashMap::from([
("image_ref".to_string(), image_ref.to_string()),
("image_source".to_string(), "registry".to_string()),
("image_identity".to_string(), image_identity.to_string()),
]),
);
let prepared_rootfs_for_build = prepared_rootfs.clone();
let prepared_image_for_build = prepared_image.clone();
let build_result = tokio::task::spawn_blocking(move || {
create_rootfs_image_from_dir(&prepared_rootfs_for_build, &prepared_image_for_build)
})
.await
.map_err(|err| Status::internal(format!("image rootfs build panicked: {err}")))?;
if let Err(err) = build_result {
warn!(
image_ref = %image_ref,
error = %err,
"vm driver: rootfs image build failed"
);
let _ = tokio::fs::remove_dir_all(&staging_dir).await;
return Err(Status::failed_precondition(err));
}
if tokio::fs::metadata(&image_path).await.is_ok() {
info!(
image_identity = %image_identity,
"vm driver: another task wrote image while we were building, discarding ours"
);
let _ = tokio::fs::remove_dir_all(&staging_dir).await;
return Ok(());
}
tokio::fs::rename(&prepared_image, &image_path)
.await
.map_err(|err| Status::internal(format!("store cached rootfs image failed: {err}")))?;
info!(
image_identity = %image_identity,
image_path = %image_path.display(),
"vm driver: root disk image committed to cache"
);
let _ = tokio::fs::remove_dir_all(&staging_dir).await;
Ok(())
}
/// Watch the launcher child process and surface errors as driver
/// conditions.
///
/// The driver no longer owns the `Ready` transition — the gateway
/// promotes a sandbox to `Ready` the moment its supervisor session
/// lands (see `openshell-server/src/compute/mod.rs`). This loop only
/// handles the sad paths: the child process failing to start, exiting
/// abnormally, or becoming unpollable. Those still surface as driver
/// `Error` conditions so the gateway can reason about a dead VM.
async fn monitor_sandbox(&self, sandbox_id: String) {
loop {
let process = {
let registry = self.registry.lock().await;
let Some(record) = registry.get(&sandbox_id) else {
return;
};
let Some(process) = record.process.as_ref() else {
return;
};
process.clone()
};
let exit_status = {
let mut process = process.lock().await;
if process.deleting {
return;
}
match process.child.try_wait() {
Ok(status) => status,
Err(err) => {
if let Some(snapshot) = self
.set_snapshot_condition(
&sandbox_id,
error_condition("ProcessPollFailed", &err.to_string()),
false,
)
.await
{
self.publish_snapshot(snapshot);
}
self.publish_platform_event(
sandbox_id.clone(),
platform_event(
"vm",
"Warning",
"ProcessPollFailed",
format!("Failed to poll VM helper process: {err}"),
),
);
return;
}
}
};
if let Some(status) = exit_status {
let message = status.code().map_or_else(
|| "VM process exited".to_string(),
|code| format!("VM process exited with status {code}"),
);
if let Some(snapshot) = self
.set_snapshot_condition(
&sandbox_id,
error_condition("ProcessExited", &message),
false,
)
.await
{
self.publish_snapshot(snapshot);
}
self.publish_platform_event(
sandbox_id.clone(),
platform_event("vm", "Warning", "ProcessExited", message),
);
let (has_gpu, has_qemu_network, cleanup_ctx) = {
let registry = self.registry.lock().await;
registry
.get(&sandbox_id)
.map_or((false, false, None), |record| {
(
record.gpu_bdf.is_some(),
record.qemu_network_allocated,
Some((record.snapshot.clone(), record.state_dir.clone())),
)
})
};
// Give lifecycle extensions a chance to release host
// resources they allocated in `before_launch` (e.g. device
// bindings, OVS flows). The driver releases its own
// allocations just below; without this, extension-owned
// resources would leak whenever a VM helper exits without an
// explicit delete. Cleanup is best-effort and idempotent, so
// a later delete that also fires `after_delete` is safe.
if let Some((sandbox, state_dir)) = cleanup_ctx {
self.lifecycle_extensions
.after_launch_failed(&sandbox, &state_dir, LaunchAbortReason::ProcessExited)
.await;
}
self.release_allocations(&sandbox_id, has_gpu, has_qemu_network);
return;
}
tokio::time::sleep(Duration::from_millis(250)).await;
}
}
async fn set_snapshot_condition(
&self,
sandbox_id: &str,
condition: SandboxCondition,
deleting: bool,
) -> Option<Sandbox> {
let mut registry = self.registry.lock().await;
let record = registry.get_mut(sandbox_id)?;
record.snapshot.status = Some(status_with_condition(&record.snapshot, condition, deleting));
Some(record.snapshot.clone())
}
fn publish_snapshot(&self, sandbox: Sandbox) {
let _ = self.events.send(WatchSandboxesEvent {
payload: Some(watch_sandboxes_event::Payload::Sandbox(
WatchSandboxesSandboxEvent {
sandbox: Some(sandbox),
},
)),
});
}
fn publish_deleted(&self, sandbox_id: String) {
let _ = self.events.send(WatchSandboxesEvent {
payload: Some(watch_sandboxes_event::Payload::Deleted(
WatchSandboxesDeletedEvent { sandbox_id },
)),
});
}
fn publish_platform_event(&self, sandbox_id: String, event: PlatformEvent) {
let _ = self.events.send(WatchSandboxesEvent {
payload: Some(watch_sandboxes_event::Payload::PlatformEvent(
WatchSandboxesPlatformEvent {
sandbox_id,
event: Some(event),
},
)),
});
}
fn publish_vm_progress(
&self,
sandbox_id: &str,
reason: &str,
message: String,
metadata: HashMap<String, String>,
) {
let mut event = platform_event("vm", "Normal", reason, message);
event.metadata = metadata;
attach_vm_progress_metadata(&mut event);
self.publish_platform_event(sandbox_id.to_string(), event);
}
}
#[tonic::async_trait]
impl ComputeDriver for VmDriver {
async fn get_capabilities(
&self,
_request: Request<GetCapabilitiesRequest>,
) -> Result<Response<GetCapabilitiesResponse>, Status> {
Ok(Response::new(self.capabilities()))
}
async fn validate_sandbox_create(
&self,
request: Request<ValidateSandboxCreateRequest>,
) -> Result<Response<ValidateSandboxCreateResponse>, Status> {
let sandbox = request
.into_inner()
.sandbox
.ok_or_else(|| Status::invalid_argument("sandbox is required"))?;
self.validate_sandbox(&sandbox)?;
Ok(Response::new(ValidateSandboxCreateResponse {}))
}
async fn create_sandbox(
&self,
request: Request<CreateSandboxRequest>,
) -> Result<Response<CreateSandboxResponse>, Status> {
let sandbox = request
.into_inner()
.sandbox
.ok_or_else(|| Status::invalid_argument("sandbox is required"))?;
let response = self.create_sandbox(&sandbox).await?;
Ok(Response::new(response))
}
async fn get_sandbox(
&self,
request: Request<GetSandboxRequest>,
) -> Result<Response<GetSandboxResponse>, Status> {
let request = request.into_inner();
if request.sandbox_id.is_empty() && request.sandbox_name.is_empty() {
return Err(Status::invalid_argument(
"sandbox_id or sandbox_name is required",
));
}
let sandbox = self
.get_sandbox(&request.sandbox_id, &request.sandbox_name)
.await?
.ok_or_else(|| Status::not_found("sandbox not found"))?;
if !request.sandbox_id.is_empty() && request.sandbox_id != sandbox.id {
return Err(Status::failed_precondition(
"sandbox_id did not match the fetched sandbox",
));
}
Ok(Response::new(GetSandboxResponse {
sandbox: Some(sandbox),
}))
}
async fn list_sandboxes(
&self,
_request: Request<ListSandboxesRequest>,
) -> Result<Response<ListSandboxesResponse>, Status> {
Ok(Response::new(ListSandboxesResponse {
sandboxes: self.current_snapshots().await,
}))
}
async fn stop_sandbox(
&self,
_request: Request<StopSandboxRequest>,
) -> Result<Response<StopSandboxResponse>, Status> {
Err(Status::unimplemented(
"stop sandbox is not implemented by the vm compute driver",
))
}
async fn delete_sandbox(
&self,
request: Request<DeleteSandboxRequest>,
) -> Result<Response<DeleteSandboxResponse>, Status> {
let request = request.into_inner();
let response = self
.delete_sandbox(&request.sandbox_id, &request.sandbox_name)
.await?;
Ok(Response::new(response))
}
type WatchSandboxesStream =
Pin<Box<dyn Stream<Item = Result<WatchSandboxesEvent, Status>> + Send + 'static>>;
async fn watch_sandboxes(
&self,
_request: Request<WatchSandboxesRequest>,
) -> Result<Response<Self::WatchSandboxesStream>, Status> {
let initial = self.current_snapshots().await;
let mut rx = self.events.subscribe();
let (tx, out_rx) = mpsc::channel(WATCH_BUFFER);
tokio::spawn(async move {
let mut sent = HashSet::new();
for sandbox in initial {
sent.insert(sandbox.id.clone());
if tx
.send(Ok(WatchSandboxesEvent {
payload: Some(watch_sandboxes_event::Payload::Sandbox(
WatchSandboxesSandboxEvent {
sandbox: Some(sandbox),
},
)),
}))
.await
.is_err()
{
return;
}
}
loop {
match rx.recv().await {
Ok(event) => {
if let Some(watch_sandboxes_event::Payload::Sandbox(sandbox_event)) =
&event.payload
&& let Some(sandbox) = &sandbox_event.sandbox
&& !sent.insert(sandbox.id.clone())
{
// duplicate snapshots are still forwarded
}
if tx.send(Ok(event)).await.is_err() {
return;
}
}
Err(broadcast::error::RecvError::Lagged(_)) => {}
Err(broadcast::error::RecvError::Closed) => return,
}
}
});
Ok(Response::new(Box::pin(ReceiverStream::new(out_rx))))
}
}
#[cfg(target_os = "linux")]
fn check_gpu_privileges() -> Result<(), String> {
if !rustix::process::geteuid().is_root() {
return Err(
"GPU support requires root privileges for VFIO bind/unbind and TAP networking. \
Run with sudo or ensure CAP_SYS_ADMIN + CAP_NET_ADMIN capabilities are set."
.to_string(),
);
}
Ok(())
}
// `tonic::Status` is ~176 bytes; it's the standard error type across the
// gRPC API surface, so boxing here would diverge from every other handler.
#[allow(clippy::result_large_err)]
fn validate_vm_sandbox(sandbox: &Sandbox, gpu_enabled: bool) -> Result<(), Status> {
validate_sandbox_id(&sandbox.id)?;
let spec = sandbox
.spec
.as_ref()
.ok_or_else(|| Status::invalid_argument("sandbox spec is required"))?;
if let Some(template) = spec.template.as_ref() {
validate_vm_sandbox_template(template)?;
}
validate_gpu_request(sandbox, gpu_enabled)?;
Ok(())
}
#[allow(clippy::result_large_err)]
fn validate_vm_sandbox_template(template: &SandboxTemplate) -> Result<(), Status> {
if !template.agent_socket_path.is_empty() {
return Err(Status::failed_precondition(
"vm sandboxes do not support template.agent_socket_path",
));
}
if template.platform_config.is_some() {
return Err(Status::failed_precondition(
"vm sandboxes do not support template.platform_config",
));
}
Ok(())
}
#[allow(clippy::result_large_err)]
fn validate_gpu_request(sandbox: &Sandbox, gpu_enabled: bool) -> Result<(), Status> {
let spec = sandbox
.spec
.as_ref()
.ok_or_else(|| Status::invalid_argument("sandbox spec is required"))?;
let gpu_requirements = driver_gpu_requirements(spec.resource_requirements.as_ref());
let gpu_count =
effective_driver_gpu_count(gpu_requirements).map_err(Status::invalid_argument)?;
if gpu_requirements.is_some() && !gpu_enabled {
return Err(Status::failed_precondition(
"GPU support is not enabled on this driver; start with --gpu",
));
}
let _ = vm_gpu_device_id(sandbox)?;
if gpu_count.is_some_and(|count| count > 1) {
return Err(Status::invalid_argument(
"VM GPU sandboxes support only one GPU",
));
}
Ok(())
}
#[allow(clippy::result_large_err)]
fn vm_gpu_device_id(sandbox: &Sandbox) -> Result<Option<String>, Status> {
let Some(spec) = sandbox.spec.as_ref() else {
return Ok(None);
};
let gpu_device_ids = VmSandboxDriverConfig::from_sandbox(sandbox)
.map_err(Status::invalid_argument)?
.gpu_device_ids
.unwrap_or_default();
let gpu_requirements = driver_gpu_requirements(spec.resource_requirements.as_ref());
validate_specific_gpu_device_request(
gpu_requirements,
&gpu_device_ids,
"driver_config.gpu_device_ids",
)
.map_err(Status::invalid_argument)?;
if gpu_device_ids.len() > 1 {
return Err(Status::invalid_argument(
"vm driver currently supports at most one gpu_device_ids entry",
));
}
Ok(gpu_requirements
.is_some()
.then(|| gpu_device_ids.into_iter().next().unwrap_or_default()))
}
#[allow(clippy::result_large_err)]
fn validate_sandbox_id(sandbox_id: &str) -> Result<(), Status> {
if sandbox_id.is_empty() {
return Err(Status::invalid_argument("sandbox id is required"));
}
if sandbox_id.len() > 128 {
return Err(Status::invalid_argument(
"sandbox id exceeds maximum length (128 bytes)",
));
}
if matches!(sandbox_id, "." | "..") {
return Err(Status::invalid_argument(
"sandbox id must match [A-Za-z0-9._-]{1,128}",
));
}
if !sandbox_id
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
{
return Err(Status::invalid_argument(
"sandbox id must match [A-Za-z0-9._-]{1,128}",
));
}
Ok(())
}
#[allow(clippy::result_large_err)]
fn parse_registry_reference(image_ref: &str) -> Result<Reference, Status> {
Reference::try_from(image_ref).map_err(|err| {
Status::failed_precondition(format!(
"invalid vm sandbox image reference '{image_ref}': {err}"
))
})
}
/// Try to connect to a local container engine (Docker or Podman).
///
/// Tries Docker first (`connect_with_local_defaults`, which respects
/// `DOCKER_HOST`). If Docker is unavailable, falls back to the Podman
/// socket, which exposes a Docker-compatible API.
async fn connect_local_container_engine() -> Option<Docker> {
if let Ok(docker) = Docker::connect_with_local_defaults()
&& docker.ping().await.is_ok()
{
return Some(docker);
}
let podman_socket = podman_socket_path();
if podman_socket.exists()
&& let Ok(docker) =
Docker::connect_with_unix(podman_socket.to_str()?, 120, bollard::API_DEFAULT_VERSION)
&& docker.ping().await.is_ok()
{
info!(
socket = %podman_socket.display(),
"vm driver: connected to Podman (Docker-compatible API)"
);
return Some(docker);
}
None
}
/// Podman user socket path for the current platform.
fn podman_socket_path() -> PathBuf {
#[cfg(target_os = "macos")]
{
let home = std::env::var("HOME").unwrap_or_default();
PathBuf::from(home).join(".local/share/containers/podman/machine/podman.sock")
}
#[cfg(target_os = "linux")]
{
std::env::var("XDG_RUNTIME_DIR").map_or_else(
|_| {
let uid = nix::unistd::getuid();
PathBuf::from(format!("/run/user/{uid}/podman/podman.sock"))
},
|xdg| PathBuf::from(xdg).join("podman/podman.sock"),
)
}
}
fn is_openshell_local_build_image_ref(image_ref: &str) -> bool {
image_ref.starts_with("openshell/sandbox-from:")
}
fn local_image_platform_mismatch(
image_ref: &str,
actual_os: Option<&str>,
actual_arch: Option<&str>,
) -> Option<String> {
let actual_os = actual_os.unwrap_or("unknown");
let actual_arch = actual_arch.unwrap_or("unknown");
let expected_os = "linux";
let expected_arch = linux_oci_arch();
(actual_os != expected_os || actual_arch != expected_arch).then(|| {
format!(
"local Docker image '{image_ref}' is {actual_os}/{actual_arch}, but VM sandboxes require {expected_os}/{expected_arch}"
)
})
}
fn is_docker_not_found_error(err: &BollardError) -> bool {
matches!(
err,
BollardError::DockerResponseServerError {
status_code: 404,
..
}
)
}
async fn export_local_image_rootfs_to_path(
docker: &Docker,
image_ref: &str,
tar_path: &Path,
) -> Result<(), Status> {
let container_name = format!(
"openshell-vm-rootfs-export-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
);
let create_options = CreateContainerOptionsBuilder::default()
.name(container_name.as_str())
.build();
let container = docker
.create_container(
Some(create_options),
ContainerCreateBody {
image: Some(image_ref.to_string()),
..Default::default()
},
)
.await
.map_err(|err| {
Status::failed_precondition(format!(
"failed to create temporary export container for local Docker image '{image_ref}': {err}"
))
})?;
let container_id = container.id;
let export_result = async {
if let Some(parent) = tar_path.parent() {
tokio::fs::create_dir_all(parent).await.map_err(|err| {
Status::internal(format!(
"create export dir {} failed: {err}",
parent.display()
))
})?;
}
let mut file = tokio::fs::File::create(tar_path).await.map_err(|err| {
Status::internal(format!("create {} failed: {err}", tar_path.display()))
})?;
let mut stream = docker.export_container(&container_id);
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|err| {
Status::failed_precondition(format!(
"failed to export local Docker image '{image_ref}': {err}"
))
})?;
file.write_all(&chunk).await.map_err(|err| {
Status::internal(format!("write {} failed: {err}", tar_path.display()))
})?;
}
file.flush()
.await
.map_err(|err| Status::internal(format!("flush {} failed: {err}", tar_path.display())))
}
.await;
let cleanup_result = docker
.remove_container(
&container_id,
Some(RemoveContainerOptionsBuilder::default().force(true).build()),
)
.await;
match (export_result, cleanup_result) {
(Ok(()), Ok(())) => Ok(()),
(Err(err), _) => Err(err),
(Ok(()), Err(err)) => Err(Status::internal(format!(
"failed to remove temporary export container for local Docker image '{image_ref}': {err}"
))),
}
}
fn registry_client() -> OciClient {
OciClient::new(ClientConfig {
platform_resolver: Some(Box::new(linux_platform_resolver)),
..Default::default()
})
}
fn linux_platform_resolver(manifests: &[ImageIndexEntry]) -> Option<String> {
let expected_arch = linux_oci_arch();
manifests
.iter()
.find_map(|entry| {
let platform = entry.platform.as_ref()?;
(platform.os.to_string() == "linux"
&& platform.architecture.to_string() == expected_arch)
.then(|| entry.digest.clone())
})
.or_else(|| {
manifests.iter().find_map(|entry| {
let platform = entry.platform.as_ref()?;
(platform.os.to_string() == "linux").then(|| entry.digest.clone())
})
})
}
fn linux_oci_arch() -> &'static str {
match std::env::consts::ARCH {
"x86_64" => "amd64",
"aarch64" => "arm64",
"arm" => "arm",
other => other,
}
}
#[allow(clippy::result_large_err)]
fn registry_auth(image_ref: &str) -> Result<RegistryAuth, Status> {
let username = env_non_empty("OPENSHELL_REGISTRY_USERNAME");
let token = env_non_empty("OPENSHELL_REGISTRY_TOKEN");
match token {
Some(token) => {
let username = match username {
Some(username) => username,
None if image_reference_registry_host(image_ref)
.eq_ignore_ascii_case("ghcr.io") =>
{
"__token__".to_string()
}
None => {
return Err(Status::failed_precondition(
"OPENSHELL_REGISTRY_USERNAME is required when OPENSHELL_REGISTRY_TOKEN is set for non-GHCR registries",
));
}
};
Ok(RegistryAuth::Basic(username, token))
}
None => Ok(RegistryAuth::Anonymous),
}
}
fn env_non_empty(key: &str) -> Option<String> {
std::env::var(key)
.ok()
.filter(|value| !value.trim().is_empty())
}
fn image_reference_registry_host(image_ref: &str) -> &str {
let mut parts = image_ref.splitn(2, '/');
let first = parts.next().unwrap_or(image_ref);
let has_path = parts.next().is_some();
if has_path
&& (first.contains('.') || first.contains(':') || first.eq_ignore_ascii_case("localhost"))
{
first
} else {
"docker.io"
}
}
impl VmDriver {
#[allow(clippy::too_many_arguments)]
async fn pull_registry_image_rootfs(
&self,
sandbox_id: &str,
client: &OciClient,
reference: &Reference,
auth: &RegistryAuth,
image_ref: &str,
staging_dir: &Path,
rootfs: &Path,
) -> Result<(), Status> {
client
.auth(reference, auth, RegistryOperation::Pull)
.await
.map_err(|err| {
Status::failed_precondition(format!(
"failed to authenticate registry access for vm sandbox image '{image_ref}': {err}"
))
})?;
let (manifest, _) = client
.pull_image_manifest(reference, auth)
.await
.map_err(|err| {
Status::failed_precondition(format!(
"failed to pull vm sandbox image manifest '{image_ref}': {err}"
))
})?;
tokio::fs::create_dir_all(rootfs)
.await
.map_err(|err| Status::internal(format!("create rootfs dir failed: {err}")))?;
tokio::fs::create_dir_all(staging_dir.join("layers"))
.await
.map_err(|err| Status::internal(format!("create layer staging dir failed: {err}")))?;
let total_layers = manifest.layers.len();
let total_bytes: i64 = manifest.layers.iter().map(|layer| layer.size.max(0)).sum();
let mut layers = futures::stream::iter(manifest.layers.iter().cloned().enumerate())
.map(|(index, layer)| async move {
self.publish_registry_layer_progress(
sandbox_id,
image_ref,
&layer,
index,
total_layers,
total_bytes,
);
download_registry_layer_blob(
client,
reference,
image_ref,
staging_dir,
layer,
index,
)
.await
})
.buffer_unordered(registry_layer_download_concurrency())
.try_collect::<Vec<_>>()
.await?;
layers.sort_by_key(|layer| layer.index);
for layer in &layers {
apply_registry_layer_blob(image_ref, rootfs, layer).await?;
}
Ok(())
}
fn publish_registry_layer_progress(
&self,
sandbox_id: &str,
image_ref: &str,
layer: &OciDescriptor,
index: usize,
total_layers: usize,
total_bytes: i64,
) {
let mut metadata = HashMap::new();
metadata.insert("layer_index".to_string(), (index + 1).to_string());
metadata.insert("layer_total".to_string(), total_layers.to_string());
metadata.insert("layer_digest".to_string(), layer.digest.clone());
metadata.insert("layer_size_bytes".to_string(), layer.size.to_string());
metadata.insert("image_ref".to_string(), image_ref.to_string());
if total_bytes > 0 {
metadata.insert("image_size_bytes".to_string(), total_bytes.to_string());
}
let mut event = platform_event(
"vm",
"Normal",
"PullingLayer",
format!(
"Pulling layer {}/{} ({} bytes) for image \"{image_ref}\"",
index + 1,
total_layers,
layer.size
),
);
event.metadata = metadata;
attach_vm_progress_metadata(&mut event);
self.publish_platform_event(sandbox_id.to_string(), event);
}
/// Emit a `Pulled` platform event with progress metadata for the CLI.
async fn publish_pulled_event(&self, sandbox_id: &str, image_ref: &str, image_path: &Path) {
let mut metadata = HashMap::from([("image_ref".to_string(), image_ref.to_string())]);
let size_suffix = tokio::fs::metadata(image_path).await.map_or_else(
|_| String::new(),
|meta| {
metadata.insert("image_size_bytes".to_string(), meta.len().to_string());
format!(" Image size: {} bytes.", meta.len())
},
);
self.publish_vm_progress(
sandbox_id,
"Pulled",
format!("Successfully pulled image \"{image_ref}\".{size_suffix}"),
metadata,
);
}
}
struct DownloadedRegistryLayer {
index: usize,
digest: String,
layer_root: PathBuf,
}
async fn download_registry_layer_blob(
client: &OciClient,
reference: &Reference,
image_ref: &str,
staging_dir: &Path,
layer: OciDescriptor,
index: usize,
) -> Result<DownloadedRegistryLayer, Status> {
let digest_component = sanitize_image_identity(&layer.digest);
let blob_path = staging_dir
.join("layers")
.join(format!("{index:02}-{digest_component}.blob"));
let layer_root = staging_dir
.join("layers")
.join(format!("{index:02}-{digest_component}.root"));
let mut file = tokio::fs::File::create(&blob_path)
.await
.map_err(|err| Status::internal(format!("create layer blob failed: {err}")))?;
client
.pull_blob(reference, &layer, &mut file)
.await
.map_err(|err| {
Status::failed_precondition(format!(
"failed to download layer '{}' for vm sandbox image '{image_ref}': {err}",
layer.digest
))
})?;
file.flush()
.await
.map_err(|err| Status::internal(format!("flush layer blob failed: {err}")))?;
let blob_path_for_digest = blob_path.clone();
let expected_digest = layer.digest.clone();
tokio::task::spawn_blocking(move || {
verify_descriptor_digest(&blob_path_for_digest, &expected_digest)
})
.await
.map_err(|err| Status::internal(format!("layer digest verification panicked: {err}")))?
.map_err(|err| {
Status::failed_precondition(format!(
"vm sandbox image layer verification failed for '{}': {err}",
layer.digest
))
})?;
let blob_path_for_unpack = blob_path.clone();
let layer_root_for_unpack = layer_root.clone();
let media_type = layer.media_type.clone();
tokio::task::spawn_blocking(move || {
extract_layer_blob_to_dir(&blob_path_for_unpack, &media_type, &layer_root_for_unpack)
})
.await
.map_err(|err| Status::internal(format!("layer extraction panicked: {err}")))?
.map_err(|err| {
Status::failed_precondition(format!(
"failed to extract layer '{}' for vm sandbox image '{image_ref}': {err}",
layer.digest
))
})?;
Ok(DownloadedRegistryLayer {
index,
digest: layer.digest,
layer_root,
})
}
async fn apply_registry_layer_blob(
image_ref: &str,
rootfs: &Path,
layer: &DownloadedRegistryLayer,
) -> Result<(), Status> {
let layer_root_for_unpack = layer.layer_root.clone();
let rootfs_for_unpack = rootfs.to_path_buf();
tokio::task::spawn_blocking(move || {
apply_layer_dir_to_rootfs(&layer_root_for_unpack, &rootfs_for_unpack)
})
.await
.map_err(|err| Status::internal(format!("layer application panicked: {err}")))?
.map_err(|err| {
Status::failed_precondition(format!(
"failed to apply layer '{}' for vm sandbox image '{image_ref}': {err}",
layer.digest
))
})
}
async fn download_registry_descriptor_blob_file(
client: &OciClient,
reference: &Reference,
image_ref: &str,
layout_dir: &Path,
descriptor: &OciDescriptor,
kind: &str,
) -> Result<(), Status> {
let blob_path = oci_layout_blob_path(layout_dir, &descriptor.digest)
.map_err(|err| Status::failed_precondition(format!("invalid {kind} digest: {err}")))?;
if let Some(parent) = blob_path.parent() {
tokio::fs::create_dir_all(parent)
.await
.map_err(|err| Status::internal(format!("create OCI blob dir failed: {err}")))?;
}
let mut file = tokio::fs::File::create(&blob_path)
.await
.map_err(|err| Status::internal(format!("create OCI {kind} blob failed: {err}")))?;
client
.pull_blob(reference, descriptor, &mut file)
.await
.map_err(|err| {
Status::failed_precondition(format!(
"failed to download {kind} '{}' for vm sandbox image '{image_ref}': {err}",
descriptor.digest
))
})?;
file.flush()
.await
.map_err(|err| Status::internal(format!("flush OCI {kind} blob failed: {err}")))?;
let blob_path_for_digest = blob_path.clone();
let expected_digest = descriptor.digest.clone();
tokio::task::spawn_blocking(move || {
verify_descriptor_digest(&blob_path_for_digest, &expected_digest)
})
.await
.map_err(|err| Status::internal(format!("OCI {kind} digest verification panicked: {err}")))?
.map_err(|err| {
Status::failed_precondition(format!(
"vm sandbox image {kind} verification failed for '{}': {err}",
descriptor.digest
))
})
}
fn verify_descriptor_digest(path: &Path, expected_digest: &str) -> Result<(), String> {
let expected = expected_digest
.strip_prefix("sha256:")
.ok_or_else(|| format!("unsupported layer digest '{expected_digest}'"))?;
let actual = compute_file_sha256_hex(path)?;
if actual == expected {
Ok(())
} else {
Err(format!(
"digest mismatch for {}: expected sha256:{expected}, got sha256:{actual}",
path.display()
))
}
}
fn compute_file_sha256_hex(path: &Path) -> Result<String, String> {
let mut file = fs::File::open(path).map_err(|err| format!("open {}: {err}", path.display()))?;
let mut hasher = Sha256::new();
let mut buffer = vec![0_u8; 64 * 1024].into_boxed_slice();
loop {
let read = file
.read(&mut buffer)
.map_err(|err| format!("read {}: {err}", path.display()))?;
if read == 0 {
break;
}
hasher.update(&buffer[..read]);
}
Ok(format!("{:x}", hasher.finalize()))
}
fn compute_bytes_sha256_hex(bytes: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(bytes);
format!("{:x}", hasher.finalize())
}
fn extract_layer_blob_to_dir(
blob_path: &Path,
media_type: &str,
dest: &Path,
) -> Result<(), String> {
if dest.exists() {
fs::remove_dir_all(dest).map_err(|err| format!("remove {}: {err}", dest.display()))?;
}
fs::create_dir_all(dest).map_err(|err| format!("create {}: {err}", dest.display()))?;
let file =
fs::File::open(blob_path).map_err(|err| format!("open {}: {err}", blob_path.display()))?;
match layer_compression_from_media_type(media_type)? {
LayerCompression::None => extract_tar_reader_to_dir(file, dest),
LayerCompression::Gzip => extract_tar_reader_to_dir(GzDecoder::new(file), dest),
LayerCompression::Zstd => {
let decoder = zstd::stream::read::Decoder::new(file)
.map_err(|err| format!("decompress {}: {err}", blob_path.display()))?;
extract_tar_reader_to_dir(decoder, dest)
}
}
}
fn extract_tar_reader_to_dir(reader: impl Read, dest: &Path) -> Result<(), String> {
let mut archive = tar::Archive::new(reader);
archive
.unpack(dest)
.map_err(|err| format!("extract layer into {}: {err}", dest.display()))
}
// `media_type` is an OCI media type string (e.g. `application/vnd.oci.image.layer.v1.tar+gzip`),
// not a filesystem path, so case-sensitive comparison is correct.
#[allow(clippy::case_sensitive_file_extension_comparisons)]
fn layer_compression_from_media_type(media_type: &str) -> Result<LayerCompression, String> {
if media_type.is_empty() {
return Err("layer media type is missing".to_string());
}
if media_type.ends_with("+zstd") {
return Ok(LayerCompression::Zstd);
}
if media_type.ends_with("+gzip") || media_type.ends_with(".gzip") {
return Ok(LayerCompression::Gzip);
}
if media_type.ends_with(".tar")
|| media_type.ends_with("tar")
|| media_type == "application/vnd.oci.image.layer.v1.tar"
|| media_type == "application/vnd.oci.image.layer.nondistributable.v1.tar"
{
return Ok(LayerCompression::None);
}
Err(format!("unsupported layer media type '{media_type}'"))
}
fn apply_layer_dir_to_rootfs(layer_root: &Path, rootfs: &Path) -> Result<(), String> {
merge_layer_directory(layer_root, rootfs)
}
fn merge_layer_directory(source_dir: &Path, target_dir: &Path) -> Result<(), String> {
fs::create_dir_all(target_dir)
.map_err(|err| format!("create {}: {err}", target_dir.display()))?;
let mut entries = fs::read_dir(source_dir)
.map_err(|err| format!("read {}: {err}", source_dir.display()))?
.collect::<Result<Vec<_>, _>>()
.map_err(|err| format!("read {}: {err}", source_dir.display()))?;
entries.sort_by_key(fs::DirEntry::file_name);
if entries
.iter()
.any(|entry| entry.file_name().to_string_lossy() == ".wh..wh..opq")
{
clear_directory_contents(target_dir)?;
}
for entry in entries {
let file_name = entry.file_name();
let name = file_name.to_string_lossy();
if name == ".wh..wh..opq" {
continue;
}
if let Some(hidden_name) = name.strip_prefix(".wh.") {
remove_path_if_exists(&target_dir.join(hidden_name))?;
continue;
}
let source_path = entry.path();
let dest_path = target_dir.join(&file_name);
let metadata = fs::symlink_metadata(&source_path)
.map_err(|err| format!("stat {}: {err}", source_path.display()))?;
let file_type = metadata.file_type();
if file_type.is_dir() {
if let Ok(dest_metadata) = fs::symlink_metadata(&dest_path)
&& !dest_metadata.file_type().is_dir()
&& !path_is_dir_or_symlink_to_dir(&dest_path)?
{
remove_path_if_exists(&dest_path)?;
}
fs::create_dir_all(&dest_path)
.map_err(|err| format!("create {}: {err}", dest_path.display()))?;
merge_layer_directory(&source_path, &dest_path)?;
if fs::symlink_metadata(&dest_path)
.map_err(|err| format!("stat {}: {err}", dest_path.display()))?
.file_type()
.is_dir()
{
fs::set_permissions(&dest_path, metadata.permissions())
.map_err(|err| format!("chmod {}: {err}", dest_path.display()))?;
}
} else if file_type.is_file() {
remove_path_if_exists(&dest_path)?;
if let Some(parent) = dest_path.parent() {
fs::create_dir_all(parent)
.map_err(|err| format!("create {}: {err}", parent.display()))?;
}
fs::copy(&source_path, &dest_path).map_err(|err| {
format!(
"copy {} to {}: {err}",
source_path.display(),
dest_path.display()
)
})?;
fs::set_permissions(&dest_path, metadata.permissions())
.map_err(|err| format!("chmod {}: {err}", dest_path.display()))?;
} else if file_type.is_symlink() {
copy_symlink(&source_path, &dest_path)?;
} else {
return Err(format!(
"unsupported layer entry type at {}",
source_path.display()
));
}
}
Ok(())
}
fn path_is_dir_or_symlink_to_dir(path: &Path) -> Result<bool, String> {
match fs::metadata(path) {
Ok(metadata) => Ok(metadata.file_type().is_dir()),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(err) => Err(format!("stat {}: {err}", path.display())),
}
}
fn clear_directory_contents(dir: &Path) -> Result<(), String> {
if !dir.exists() {
return Ok(());
}
for entry in fs::read_dir(dir).map_err(|err| format!("read {}: {err}", dir.display()))? {
let entry = entry.map_err(|err| format!("read {}: {err}", dir.display()))?;
remove_path_if_exists(&entry.path())?;
}
Ok(())
}
fn remove_path_if_exists(path: &Path) -> Result<(), String> {
let Ok(metadata) = fs::symlink_metadata(path) else {
return Ok(());
};
if metadata.file_type().is_dir() {
fs::remove_dir_all(path).map_err(|err| format!("remove {}: {err}", path.display()))
} else {
fs::remove_file(path).map_err(|err| format!("remove {}: {err}", path.display()))
}
}
#[cfg(unix)]
fn copy_symlink(source_path: &Path, dest_path: &Path) -> Result<(), String> {
let target = fs::read_link(source_path)
.map_err(|err| format!("readlink {}: {err}", source_path.display()))?;
remove_path_if_exists(dest_path)?;
if let Some(parent) = dest_path.parent() {
fs::create_dir_all(parent).map_err(|err| format!("create {}: {err}", parent.display()))?;
}
std::os::unix::fs::symlink(&target, dest_path).map_err(|err| {
format!(
"symlink {} to {}: {err}",
target.display(),
dest_path.display()
)
})
}
#[cfg(not(unix))]
fn copy_symlink(_source_path: &Path, _dest_path: &Path) -> Result<(), String> {
Err("symlink layers are only supported on Unix hosts".to_string())
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum LayerCompression {
None,
Gzip,
Zstd,
}
fn requested_sandbox_image(sandbox: &Sandbox) -> Option<&str> {
sandbox
.spec
.as_ref()
.and_then(|spec| spec.template.as_ref())
.map(|template| template.image.trim())
.filter(|image| !image.is_empty())
}
fn merged_environment(sandbox: &Sandbox) -> HashMap<String, String> {
let mut environment = sandbox
.spec
.as_ref()
.and_then(|spec| spec.template.as_ref())
.map_or_else(HashMap::new, |template| template.environment.clone());
if let Some(spec) = sandbox.spec.as_ref() {
environment.extend(spec.environment.clone());
}
environment
}
/// Rewrites loopback host references in a gateway URL to a hostname the guest
/// can reach via gvproxy.
///
/// The driver receives the gateway endpoint from `--openshell-endpoint`, which
/// in local/dev/e2e setups is typically `http://127.0.0.1:<port>`. That URL is
/// useless inside the guest because the guest's loopback interface is its own,
/// not the host's. Inside the guest we need a name that gvproxy will translate
/// into the host's loopback address.
///
/// We rewrite to `host.openshell.internal`, which gvproxy's embedded DNS resolves
/// to the host-loopback IP `192.168.127.254`. gvproxy installs a default NAT entry
/// rewriting that destination to the host's `127.0.0.1` and dialing out from the
/// host process, so any port the host is listening on becomes reachable. The
/// gateway IP `192.168.127.1` does **not** do this — it only listens on gvproxy's
/// own service ports (DNS, DHCP, HTTP API). The guest init script also seeds the
/// hostname in `/etc/hosts` so resolution works even if gvproxy's DNS isn't in
/// resolv.conf (e.g. when DHCP fails).
///
/// Non-loopback URLs are returned unchanged.
fn guest_visible_openshell_endpoint(endpoint: &str) -> String {
let Ok(mut url) = Url::parse(endpoint) else {
return endpoint.to_string();
};
let should_rewrite = match url.host() {
Some(Host::Ipv4(ip)) => ip.is_loopback(),
Some(Host::Ipv6(ip)) => ip.is_loopback(),
Some(Host::Domain(host)) => host.eq_ignore_ascii_case("localhost"),
None => false,
};
if should_rewrite && url.set_host(Some(GVPROXY_HOST_LOOPBACK_ALIAS)).is_ok() {
return url.to_string();
}
endpoint.to_string()
}
fn gateway_port_from_endpoint(endpoint: &str) -> Option<u16> {
Url::parse(endpoint).ok().and_then(|url| url.port())
}
fn has_complete_qemu_network(plan: &LaunchPlan) -> bool {
plan.tap_device.is_some()
&& plan.guest_ip.is_some()
&& plan.host_ip.is_some()
&& plan.vsock_cid.is_some()
&& plan.guest_mac.is_some()
}
fn guest_visible_openshell_endpoint_for_tap(endpoint: &str, host_ip: &str) -> String {
let Ok(mut url) = Url::parse(endpoint) else {
return endpoint.to_string();
};
if url.set_host(Some(host_ip)).is_ok() {
url.to_string()
} else {
endpoint.to_string()
}
}
fn build_guest_environment(
sandbox: &Sandbox,
config: &VmDriverConfig,
endpoint_override: Option<&str>,
) -> Vec<String> {
let openshell_endpoint = endpoint_override.map_or_else(
|| guest_visible_openshell_endpoint(&config.openshell_endpoint),
String::from,
);
// 1. User-supplied environment (lowest priority).
let user_env = merged_environment(sandbox);
let mut environment: HashMap<String, String> = HashMap::new();
environment.extend(user_env.clone());
if !user_env.is_empty()
&& let Ok(json) = serde_json::to_string(&user_env)
{
environment.insert(
openshell_core::sandbox_env::USER_ENVIRONMENT.to_string(),
json,
);
}
// 2. Required driver vars (highest priority -- always overwrite).
environment.insert("HOME".to_string(), "/root".to_string());
environment.insert(
"PATH".to_string(),
"/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin".to_string(),
);
environment.insert("TERM".to_string(), "xterm".to_string());
environment.insert(
openshell_core::sandbox_env::ENDPOINT.to_string(),
openshell_endpoint,
);
environment.insert(
openshell_core::sandbox_env::SANDBOX_ID.to_string(),
sandbox.id.clone(),
);
environment.insert(
openshell_core::sandbox_env::SANDBOX.to_string(),
sandbox.name.clone(),
);
environment.insert(
openshell_core::sandbox_env::SSH_SOCKET_PATH.to_string(),
GUEST_SSH_SOCKET_PATH.to_string(),
);
environment.insert(
openshell_core::sandbox_env::SANDBOX_COMMAND.to_string(),
"tail -f /dev/null".to_string(),
);
environment.insert(
openshell_core::sandbox_env::LOG_LEVEL.to_string(),
openshell_core::driver_utils::sandbox_log_level(sandbox, &config.log_level),
);
if config.requires_tls_materials() {
environment.insert(
openshell_core::sandbox_env::TLS_CA.to_string(),
GUEST_TLS_CA_PATH.to_string(),
);
environment.insert(
openshell_core::sandbox_env::TLS_CERT.to_string(),
GUEST_TLS_CERT_PATH.to_string(),
);
environment.insert(
openshell_core::sandbox_env::TLS_KEY.to_string(),
GUEST_TLS_KEY_PATH.to_string(),
);
}
environment.insert(
openshell_core::sandbox_env::TELEMETRY_ENABLED.to_string(),
openshell_core::telemetry::enabled_env_value().to_string(),
);
environment.remove(openshell_core::sandbox_env::SANDBOX_TOKEN);
environment.remove(openshell_core::sandbox_env::SANDBOX_TOKEN_FILE);
if sandbox
.spec
.as_ref()
.is_some_and(|spec| !spec.sandbox_token.is_empty())
{
environment.insert(
openshell_core::sandbox_env::SANDBOX_TOKEN_FILE.to_string(),
GUEST_SANDBOX_TOKEN_PATH.to_string(),
);
}
let mut pairs = environment.into_iter().collect::<Vec<_>>();
pairs.sort_by(|left, right| left.0.cmp(&right.0));
pairs
.into_iter()
.map(|(key, value)| format!("{key}={value}"))
.collect()
}
fn sandboxes_root_dir(root: &Path) -> PathBuf {
root.join("sandboxes")
}
async fn create_private_dir_all(path: &Path) -> Result<(), std::io::Error> {
tokio::fs::create_dir_all(path).await?;
restrict_owner_only_dir(path).await
}
#[cfg(unix)]
async fn restrict_owner_only_dir(path: &Path) -> Result<(), std::io::Error> {
tokio::fs::set_permissions(path, fs::Permissions::from_mode(0o700)).await
}
#[cfg(not(unix))]
async fn restrict_owner_only_dir(_path: &Path) -> Result<(), std::io::Error> {
Ok(())
}
#[allow(clippy::result_large_err)]
fn sandbox_state_dir(root: &Path, sandbox_id: &str) -> Result<PathBuf, Status> {
validate_sandbox_id(sandbox_id)?;
Ok(sandboxes_root_dir(root).join(sandbox_id))
}
fn sandbox_overlay_image(state_dir: &Path) -> PathBuf {
state_dir.join(SANDBOX_OVERLAY_IMAGE)
}
fn overlay_template_image(root: &Path, size_bytes: u64) -> PathBuf {
image_cache_root_dir(root)
.join(OVERLAY_TEMPLATE_CACHE_DIR)
.join(OVERLAY_TEMPLATE_CACHE_LAYOUT_VERSION)
.join(format!("{size_bytes}.ext4"))
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct SandboxRuntimeDiskPaths {
overlay_disk: PathBuf,
}
fn sandbox_runtime_disk_paths(state_dir: &Path) -> SandboxRuntimeDiskPaths {
SandboxRuntimeDiskPaths {
overlay_disk: sandbox_overlay_image(state_dir),
}
}
#[allow(clippy::result_large_err)]
fn validate_sandbox_state_dir(root: &Path, state_dir: &Path) -> Result<(), Status> {
let sandboxes_root = sandboxes_root_dir(root);
let relative = state_dir.strip_prefix(&sandboxes_root).map_err(|_| {
Status::internal(format!(
"refusing to use sandbox state path outside vm state root: {}",
state_dir.display()
))
})?;
let mut components = relative.components();
match components.next() {
Some(Component::Normal(_)) => {}
_ => {
return Err(Status::internal(format!(
"refusing to use malformed sandbox state path: {}",
state_dir.display()
)));
}
}
if components.next().is_some() {
return Err(Status::internal(format!(
"refusing to use nested sandbox state path: {}",
state_dir.display()
)));
}
Ok(())
}
async fn remove_sandbox_state_dir(root: &Path, state_dir: &Path) -> Result<(), Status> {
validate_sandbox_state_dir(root, state_dir)?;
let metadata = match tokio::fs::symlink_metadata(state_dir).await {
Ok(metadata) => metadata,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(err) => {
return Err(Status::internal(format!(
"failed to stat sandbox state dir: {err}"
)));
}
};
let file_type = metadata.file_type();
if file_type.is_symlink() {
return Err(Status::internal(format!(
"refusing to remove symlinked sandbox state dir: {}",
state_dir.display()
)));
}
if !file_type.is_dir() {
return Err(Status::internal(format!(
"sandbox state path is not a directory: {}",
state_dir.display()
)));
}
tokio::fs::remove_dir_all(state_dir)
.await
.map_err(|err| Status::internal(format!("failed to remove state dir: {err}")))
}
fn image_cache_root_dir(root: &Path) -> PathBuf {
root.join(IMAGE_CACHE_ROOT_DIR)
}
fn image_cache_dir(root: &Path, image_identity: &str) -> PathBuf {
image_cache_root_dir(root).join(sanitize_image_identity(image_identity))
}
fn image_cache_rootfs_image(root: &Path, image_identity: &str) -> PathBuf {
image_cache_dir(root, image_identity).join(IMAGE_CACHE_ROOTFS_IMAGE)
}
fn image_cache_staging_dir(root: &Path, image_identity: &str) -> PathBuf {
image_cache_root_dir(root).join(format!(
"{}.staging-{}",
sanitize_image_identity(image_identity),
unique_image_cache_suffix()
))
}
fn oci_layout_blobs_dir(layout_dir: &Path) -> PathBuf {
layout_dir.join("blobs").join("sha256")
}
fn oci_layout_blob_path(layout_dir: &Path, digest: &str) -> Result<PathBuf, String> {
let hex = sha256_digest_hex(digest)?;
Ok(oci_layout_blobs_dir(layout_dir).join(hex))
}
fn sha256_digest_hex(digest: &str) -> Result<&str, String> {
let Some((algorithm, hex)) = digest.split_once(':') else {
return Err(format!("digest '{digest}' is missing an algorithm"));
};
if algorithm != "sha256" {
return Err(format!("unsupported digest algorithm '{algorithm}'"));
}
if hex.is_empty() || !hex.chars().all(|ch| ch.is_ascii_hexdigit()) {
return Err(format!("digest '{digest}' is not a valid sha256 digest"));
}
Ok(hex)
}
fn write_oci_layout_for_manifest(
layout_dir: &Path,
ref_name: &str,
manifest: &OciImageManifest,
) -> Result<(), String> {
fs::create_dir_all(oci_layout_blobs_dir(layout_dir))
.map_err(|err| format!("create OCI layout blobs dir failed: {err}"))?;
fs::write(
layout_dir.join("oci-layout"),
br#"{"imageLayoutVersion":"1.0.0"}"#,
)
.map_err(|err| format!("write OCI layout marker failed: {err}"))?;
let manifest_bytes = serde_json::to_vec(manifest)
.map_err(|err| format!("serialize OCI manifest failed: {err}"))?;
let manifest_digest = format!("sha256:{}", compute_bytes_sha256_hex(&manifest_bytes));
let manifest_blob = oci_layout_blob_path(layout_dir, &manifest_digest)
.map_err(|err| format!("compute OCI manifest blob path failed: {err}"))?;
fs::write(&manifest_blob, &manifest_bytes)
.map_err(|err| format!("write OCI manifest blob failed: {err}"))?;
let media_type = manifest
.media_type
.clone()
.unwrap_or_else(|| OCI_IMAGE_MEDIA_TYPE.to_string());
let index = serde_json::json!({
"schemaVersion": 2,
"manifests": [
{
"mediaType": media_type,
"digest": manifest_digest,
"size": manifest_bytes.len(),
"annotations": {
"org.opencontainers.image.ref.name": ref_name
}
}
]
});
let index_bytes = serde_json::to_vec_pretty(&index)
.map_err(|err| format!("serialize OCI index failed: {err}"))?;
fs::write(layout_dir.join("index.json"), index_bytes)
.map_err(|err| format!("write OCI index failed: {err}"))?;
Ok(())
}
fn bootstrap_image_cache_identity(image_identity: &str) -> String {
format!(
"{BOOTSTRAP_IMAGE_CACHE_LAYOUT_VERSION}:openshell-{}:{image_identity}",
openshell_core::VERSION
)
}
fn prepared_image_cache_identity(image_identity: &str) -> String {
format!(
"{PREPARED_IMAGE_CACHE_LAYOUT_VERSION}:openshell-{}:{image_identity}",
openshell_core::VERSION
)
}
fn registry_layer_download_concurrency() -> usize {
let value = std::env::var("OPENSHELL_VM_IMAGE_PULL_CONCURRENCY").ok();
registry_layer_download_concurrency_value(value.as_deref())
}
fn registry_layer_download_concurrency_value(value: Option<&str>) -> usize {
value
.and_then(|value| value.parse::<usize>().ok())
.filter(|value| *value > 0)
.map_or(DEFAULT_REGISTRY_LAYER_DOWNLOAD_CONCURRENCY, |value| {
value.min(MAX_REGISTRY_LAYER_DOWNLOAD_CONCURRENCY)
})
}
fn sanitize_image_identity(image_identity: &str) -> String {
image_identity
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == '.' {
ch
} else {
'-'
}
})
.collect()
}
fn unique_image_cache_suffix() -> String {
let counter = IMAGE_CACHE_BUILD_COUNTER.fetch_add(1, Ordering::Relaxed);
format!("{}-{counter}", openshell_core::time::now_ms())
}
async fn write_sandbox_image_metadata(
state_dir: &Path,
image_ref: &str,
image_identity: &str,
) -> Result<(), std::io::Error> {
tokio::fs::write(
state_dir.join(IMAGE_IDENTITY_FILE),
format!("{image_identity}\n"),
)
.await?;
tokio::fs::write(
state_dir.join(IMAGE_REFERENCE_FILE),
format!("{image_ref}\n"),
)
.await?;
Ok(())
}
async fn write_sandbox_request(state_dir: &Path, sandbox: &Sandbox) -> Result<(), std::io::Error> {
restrict_owner_only_dir(state_dir).await?;
write_private_file(
&state_dir.join(SANDBOX_REQUEST_FILE),
sandbox.encode_to_vec(),
)
.await
}
async fn read_sandbox_request(path: &Path) -> Result<Sandbox, std::io::Error> {
let bytes = tokio::fs::read(path).await?;
Sandbox::decode(bytes.as_slice()).map_err(|err| {
std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("decode persisted sandbox request: {err}"),
)
})
}
async fn write_private_file(path: &Path, bytes: Vec<u8>) -> Result<(), std::io::Error> {
tokio::fs::write(path, bytes).await?;
restrict_owner_read_write(path).await
}
#[cfg(unix)]
async fn restrict_owner_read_write(path: &Path) -> Result<(), std::io::Error> {
tokio::fs::set_permissions(path, fs::Permissions::from_mode(0o600)).await
}
#[cfg(not(unix))]
async fn restrict_owner_read_write(_path: &Path) -> Result<(), std::io::Error> {
Ok(())
}
#[allow(clippy::result_large_err)]
fn validate_restored_sandbox_state(
root: &Path,
state_dir: &Path,
sandbox: &Sandbox,
) -> Result<(), Status> {
validate_sandbox_id(&sandbox.id)?;
validate_sandbox_state_dir(root, state_dir)?;
let Some(dir_name) = state_dir.file_name().and_then(|name| name.to_str()) else {
return Err(Status::internal(format!(
"sandbox state path has no valid directory name: {}",
state_dir.display()
)));
};
if dir_name != sandbox.id {
return Err(Status::internal(format!(
"sandbox state dir '{}' does not match persisted sandbox id '{}'",
dir_name, sandbox.id
)));
}
Ok(())
}
#[derive(Debug, Clone)]
struct GuestTlsMaterials {
ca: Vec<u8>,
cert: Vec<u8>,
key: Vec<u8>,
}
async fn read_guest_tls_materials(paths: &VmDriverTlsPaths) -> Result<GuestTlsMaterials, String> {
let ca = tokio::fs::read(&paths.ca)
.await
.map_err(|err| format!("read {}: {err}", paths.ca.display()))?;
let cert = tokio::fs::read(&paths.cert)
.await
.map_err(|err| format!("read {}: {err}", paths.cert.display()))?;
let key = tokio::fs::read(&paths.key)
.await
.map_err(|err| format!("read {}: {err}", paths.key.display()))?;
Ok(GuestTlsMaterials { ca, cert, key })
}
async fn overlay_template_image_ready(path: &Path, size_bytes: u64) -> Result<bool, String> {
match tokio::fs::metadata(path).await {
Ok(metadata) => Ok(metadata.is_file() && metadata.len() == size_bytes),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(err) => Err(format!("stat overlay template {}: {err}", path.display())),
}
}
fn ensure_sandbox_overlay_template_image(
template_path: &Path,
size_bytes: u64,
) -> Result<(), String> {
if let Ok(metadata) = fs::metadata(template_path)
&& metadata.is_file()
&& metadata.len() == size_bytes
{
return Ok(());
}
let parent = template_path.parent().ok_or_else(|| {
format!(
"overlay template path has no parent: {}",
template_path.display()
)
})?;
fs::create_dir_all(parent).map_err(|err| {
format!(
"create overlay template cache dir {}: {err}",
parent.display()
)
})?;
let staging_image = parent.join(format!(
".{}.staging-{}-{}",
template_path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("overlay-template.ext4"),
std::process::id(),
openshell_core::time::now_ms()
));
let result = (|| {
create_empty_sandbox_overlay_image(&staging_image, size_bytes)?;
fs::rename(&staging_image, template_path).map_err(|err| {
format!(
"move overlay template {} to {}: {err}",
staging_image.display(),
template_path.display()
)
})
})();
if result.is_err() {
let _ = fs::remove_file(&staging_image);
}
result
}
fn create_empty_sandbox_overlay_image(overlay_disk: &Path, size_bytes: u64) -> Result<(), String> {
let staging_dir = overlay_staging_dir(overlay_disk);
if staging_dir.exists() {
fs::remove_dir_all(&staging_dir)
.map_err(|err| format!("remove stale overlay staging dir: {err}"))?;
}
let result = (|| {
fs::create_dir_all(staging_dir.join("upper"))
.map_err(|err| format!("create overlay upper dir: {err}"))?;
fs::create_dir_all(staging_dir.join("work"))
.map_err(|err| format!("create overlay work dir: {err}"))?;
fs::create_dir_all(staging_dir.join("config"))
.map_err(|err| format!("create overlay config dir: {err}"))?;
create_ext4_image_from_dir_with_size(&staging_dir, overlay_disk, size_bytes)
})();
let _ = fs::remove_dir_all(&staging_dir);
result
}
fn create_sandbox_overlay_image_from_template(
template_path: &Path,
overlay_disk: &Path,
tls_materials: Option<&GuestTlsMaterials>,
sandbox_token: Option<&str>,
) -> Result<(), String> {
clone_or_copy_sparse_file(template_path, overlay_disk)?;
if let Some(tls) = tls_materials {
inject_guest_tls_materials(overlay_disk, tls)?;
}
if let Some(token) = sandbox_token {
inject_guest_sandbox_token(overlay_disk, token)?;
}
Ok(())
}
fn prepare_sandbox_overlay_image(
template_path: &Path,
overlay_disk: &Path,
tls_materials: Option<&GuestTlsMaterials>,
sandbox_token: Option<&str>,
preparation: OverlayPreparation,
expected_size_bytes: u64,
) -> Result<(), String> {
if preparation == OverlayPreparation::PreserveExisting {
match fs::metadata(overlay_disk) {
Ok(metadata) if metadata.is_file() && metadata.len() == expected_size_bytes => {
if let Some(tls) = tls_materials {
inject_guest_tls_materials(overlay_disk, tls)?;
}
if let Some(token) = sandbox_token {
inject_guest_sandbox_token(overlay_disk, token)?;
}
return Ok(());
}
Ok(metadata) if metadata.is_file() => {
return Err(format!(
"existing overlay disk '{}' has size {}, expected {}",
overlay_disk.display(),
metadata.len(),
expected_size_bytes
));
}
Ok(_) => {
return Err(format!(
"existing overlay path '{}' is not a file",
overlay_disk.display()
));
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
Err(err) => {
return Err(format!(
"stat overlay disk {}: {err}",
overlay_disk.display()
));
}
}
}
create_sandbox_overlay_image_from_template(
template_path,
overlay_disk,
tls_materials,
sandbox_token,
)
}
fn inject_guest_tls_materials(
overlay_disk: &Path,
materials: &GuestTlsMaterials,
) -> Result<(), String> {
write_rootfs_image_file(
overlay_disk,
&overlay_upper_path(GUEST_TLS_CA_PATH),
&materials.ca,
)?;
write_rootfs_image_file(
overlay_disk,
&overlay_upper_path(GUEST_TLS_CERT_PATH),
&materials.cert,
)?;
let key_path = overlay_upper_path(GUEST_TLS_KEY_PATH);
write_rootfs_image_file(overlay_disk, &key_path, &materials.key)?;
set_rootfs_image_file_mode(overlay_disk, &key_path, 0o600)
}
fn inject_guest_sandbox_token(overlay_disk: &Path, token: &str) -> Result<(), String> {
let token_path = overlay_upper_path(GUEST_SANDBOX_TOKEN_PATH);
write_rootfs_image_file(overlay_disk, &token_path, format!("{token}\n").as_bytes())?;
set_rootfs_image_file_mode(overlay_disk, &token_path, 0o600)
}
#[allow(clippy::result_large_err)]
fn inject_guest_init_dropins(
overlay_disk: &Path,
dropins: &[GuestInitDropin],
) -> Result<(), Status> {
validate_guest_init_dropins(dropins).map_err(Status::failed_precondition)?;
// Drop-ins are *executed* in a child shell by run_openshell_init_dropins
// in the guest init script, not sourced into the parent. Mode 0o755 is
// required (the runner skips anything that is not `-x`) and is the
// contract drop-in authors should rely on.
for dropin in dropins {
let guest_path = overlay_upper_path(&format!("{GUEST_INIT_DROPIN_DIR}/{}", dropin.name));
write_rootfs_image_file(overlay_disk, &guest_path, &dropin.contents).map_err(|err| {
Status::internal(format!(
"write VM guest init drop-in '{}' failed: {err}",
dropin.name
))
})?;
set_rootfs_image_file_mode(overlay_disk, &guest_path, 0o755).map_err(|err| {
Status::internal(format!(
"set VM guest init drop-in '{}' executable failed: {err}",
dropin.name
))
})?;
}
// Write the allow-list manifest the guest runner consults. We write it
// unconditionally — including an empty manifest when no drop-ins were
// injected — so the guest always fails closed: only names the driver
// explicitly injected this launch are eligible to run, and a guest
// image cannot smuggle in extra `init.d` entries.
write_guest_init_dropin_manifest(overlay_disk, dropins)?;
Ok(())
}
/// Render the drop-in allow-list as newline-separated, ASCII-sorted,
/// de-duplicated names. Names are already validated to be path-safe by
/// [`validate_guest_init_dropins`].
fn render_guest_init_dropin_manifest(dropins: &[GuestInitDropin]) -> Vec<u8> {
let mut names: Vec<&str> = dropins.iter().map(|d| d.name.as_str()).collect();
names.sort_unstable();
names.dedup();
let mut body = names.join("\n");
if !body.is_empty() {
body.push('\n');
}
body.into_bytes()
}
#[allow(clippy::result_large_err)]
fn write_guest_init_dropin_manifest(
overlay_disk: &Path,
dropins: &[GuestInitDropin],
) -> Result<(), Status> {
let guest_path = overlay_upper_path(GUEST_INIT_DROPIN_MANIFEST);
let contents = render_guest_init_dropin_manifest(dropins);
write_rootfs_image_file(overlay_disk, &guest_path, &contents).map_err(|err| {
Status::internal(format!(
"write VM guest init drop-in manifest failed: {err}"
))
})?;
set_rootfs_image_file_mode(overlay_disk, &guest_path, 0o644).map_err(|err| {
Status::internal(format!(
"set VM guest init drop-in manifest mode failed: {err}"
))
})?;
Ok(())
}
fn validate_guest_init_dropins(dropins: &[GuestInitDropin]) -> Result<(), String> {
let mut names = HashSet::new();
for dropin in dropins {
validate_guest_init_dropin_name(&dropin.name)?;
if !names.insert(dropin.name.clone()) {
return Err(format!("duplicate VM guest init drop-in '{}'", dropin.name));
}
}
Ok(())
}
fn validate_guest_init_dropin_name(name: &str) -> Result<(), String> {
if name.is_empty() || name == "." || name == ".." {
return Err("VM guest init drop-in name is empty or reserved".to_string());
}
if !name
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == '.')
{
return Err(format!(
"VM guest init drop-in name '{name}' must contain only ASCII letters, numbers, '.', '-', or '_'"
));
}
Ok(())
}
fn overlay_upper_path(guest_path: &str) -> String {
format!("/upper/{}", guest_path.trim_start_matches('/'))
}
fn create_image_prep_disk(
image_path: &Path,
size_bytes: u64,
payload: &GuestImagePayload,
) -> Result<(), String> {
let staging_dir = overlay_staging_dir(image_path);
if staging_dir.exists() {
fs::remove_dir_all(&staging_dir)
.map_err(|err| format!("remove stale image-prep staging dir: {err}"))?;
}
let result = (|| {
fs::create_dir_all(staging_dir.join("upper").join("srv"))
.map_err(|err| format!("create image-prep env dir: {err}"))?;
fs::create_dir_all(staging_dir.join("work"))
.map_err(|err| format!("create image-prep work dir: {err}"))?;
fs::create_dir_all(staging_dir.join("config"))
.map_err(|err| format!("create image-prep config dir: {err}"))?;
stage_guest_image_payload(&staging_dir, payload)?;
create_ext4_image_from_dir_with_size(&staging_dir, image_path, size_bytes)
})();
let _ = fs::remove_dir_all(&staging_dir);
result
}
fn stage_guest_image_payload(
staging_dir: &Path,
payload: &GuestImagePayload,
) -> Result<(), String> {
let image_dir = staging_dir.join("config").join(GUEST_IMAGE_CONFIG_DIR);
fs::create_dir_all(&image_dir).map_err(|err| {
format!(
"create guest image config dir {}: {err}",
image_dir.display()
)
})?;
fs::write(image_dir.join("ref"), payload.image_ref.as_bytes())
.map_err(|err| format!("write guest image ref: {err}"))?;
fs::write(
image_dir.join("identity"),
payload.image_identity.as_bytes(),
)
.map_err(|err| format!("write guest image identity: {err}"))?;
match &payload.source {
GuestImagePayloadSource::RegistryOciLayout { layout_dir } => {
fs::write(image_dir.join("source"), b"oci-layout")
.map_err(|err| format!("write guest image source: {err}"))?;
copy_dir_recursive(layout_dir, &image_dir.join(GUEST_IMAGE_OCI_LAYOUT_DIR))?;
}
GuestImagePayloadSource::LocalDocker { rootfs_archive } => {
fs::write(image_dir.join("source"), b"local-docker")
.map_err(|err| format!("write guest image source: {err}"))?;
let dest = image_dir.join(IMAGE_EXPORT_ROOTFS_ARCHIVE);
fs::copy(rootfs_archive, &dest).map_err(|err| {
format!(
"copy guest image rootfs archive {} to {}: {err}",
rootfs_archive.display(),
dest.display()
)
})?;
}
}
Ok(())
}
fn copy_dir_recursive(source: &Path, dest: &Path) -> Result<(), String> {
fs::create_dir_all(dest).map_err(|err| format!("create {}: {err}", dest.display()))?;
for entry in fs::read_dir(source).map_err(|err| format!("read {}: {err}", source.display()))? {
let entry = entry.map_err(|err| format!("read {}: {err}", source.display()))?;
let source_path = entry.path();
let dest_path = dest.join(entry.file_name());
let metadata = fs::symlink_metadata(&source_path)
.map_err(|err| format!("stat {}: {err}", source_path.display()))?;
if metadata.file_type().is_dir() {
copy_dir_recursive(&source_path, &dest_path)?;
} else if metadata.file_type().is_file() {
if let Some(parent) = dest_path.parent() {
fs::create_dir_all(parent)
.map_err(|err| format!("create {}: {err}", parent.display()))?;
}
fs::copy(&source_path, &dest_path).map_err(|err| {
format!(
"copy {} to {}: {err}",
source_path.display(),
dest_path.display()
)
})?;
} else {
return Err(format!(
"unsupported payload entry type at {}",
source_path.display()
));
}
}
Ok(())
}
fn prepared_image_disk_size_bytes(
payload: &GuestImagePayload,
minimum_size_bytes: u64,
) -> Result<u64, String> {
let payload_size = match &payload.source {
GuestImagePayloadSource::RegistryOciLayout { layout_dir } => dir_size_bytes(layout_dir)?,
GuestImagePayloadSource::LocalDocker { rootfs_archive } => fs::metadata(rootfs_archive)
.map_err(|err| format!("stat {}: {err}", rootfs_archive.display()))?
.len(),
};
let requested = payload_size
.saturating_mul(3)
.saturating_add(512 * 1024 * 1024);
Ok(minimum_size_bytes.max(requested))
}
fn dir_size_bytes(path: &Path) -> Result<u64, String> {
let metadata =
fs::symlink_metadata(path).map_err(|err| format!("stat {}: {err}", path.display()))?;
if metadata.file_type().is_file() {
return Ok(metadata.len());
}
if metadata.file_type().is_symlink() {
return Ok(0);
}
let mut total = 0_u64;
for entry in fs::read_dir(path).map_err(|err| format!("read {}: {err}", path.display()))? {
let entry = entry.map_err(|err| format!("read {}: {err}", path.display()))?;
total = total.saturating_add(dir_size_bytes(&entry.path())?);
}
Ok(total)
}
#[cfg(test)]
fn stage_guest_tls_materials(
staging_dir: &Path,
materials: &GuestTlsMaterials,
) -> Result<(), String> {
let tls_dir = staging_dir
.join("upper")
.join(GUEST_TLS_CA_PATH.trim_start_matches('/'))
.parent()
.ok_or_else(|| "guest TLS CA path has no parent".to_string())?
.to_path_buf();
fs::create_dir_all(&tls_dir)
.map_err(|err| format!("create guest TLS dir {}: {err}", tls_dir.display()))?;
let ca_path = staging_dir
.join("upper")
.join(GUEST_TLS_CA_PATH.trim_start_matches('/'));
let cert_path = staging_dir
.join("upper")
.join(GUEST_TLS_CERT_PATH.trim_start_matches('/'));
let key_path = staging_dir
.join("upper")
.join(GUEST_TLS_KEY_PATH.trim_start_matches('/'));
fs::write(&ca_path, &materials.ca)
.map_err(|err| format!("write guest TLS CA {}: {err}", ca_path.display()))?;
fs::write(&cert_path, &materials.cert)
.map_err(|err| format!("write guest TLS cert {}: {err}", cert_path.display()))?;
fs::write(&key_path, &materials.key)
.map_err(|err| format!("write guest TLS key {}: {err}", key_path.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
fs::set_permissions(&key_path, fs::Permissions::from_mode(0o600))
.map_err(|err| format!("chmod guest TLS key {}: {err}", key_path.display()))?;
}
Ok(())
}
fn overlay_staging_dir(overlay_disk: &Path) -> PathBuf {
let parent = overlay_disk.parent().unwrap_or_else(|| Path::new("."));
parent.join(format!(
".openshell-overlay-staging-{}-{}",
std::process::id(),
openshell_core::time::now_ms()
))
}
async fn terminate_vm_process(child: &mut Child) -> Result<(), std::io::Error> {
if let Some(pid) = child.id()
&& let Err(err) = kill(Pid::from_raw(pid.cast_signed()), Signal::SIGTERM)
&& err != Errno::ESRCH
{
return Err(std::io::Error::other(format!(
"send SIGTERM to vm process {pid}: {err}"
)));
}
match tokio::time::timeout(Duration::from_secs(5), child.wait()).await {
Ok(Ok(_)) => Ok(()),
Ok(Err(err)) => Err(err),
Err(_) => {
child.kill().await?;
child.wait().await.map(|_| ())
}
}
}
fn sandbox_snapshot(sandbox: &Sandbox, condition: SandboxCondition, deleting: bool) -> Sandbox {
Sandbox {
id: sandbox.id.clone(),
name: sandbox.name.clone(),
namespace: sandbox.namespace.clone(),
status: Some(SandboxStatus {
sandbox_name: sandbox.name.clone(),
instance_id: String::new(),
agent_fd: String::new(),
sandbox_fd: String::new(),
conditions: vec![condition],
deleting,
}),
..Default::default()
}
}
fn status_with_condition(
snapshot: &Sandbox,
condition: SandboxCondition,
deleting: bool,
) -> SandboxStatus {
SandboxStatus {
sandbox_name: snapshot.name.clone(),
instance_id: String::new(),
agent_fd: String::new(),
sandbox_fd: String::new(),
conditions: vec![condition],
deleting,
}
}
fn provisioning_condition() -> SandboxCondition {
SandboxCondition {
r#type: "Ready".to_string(),
status: "False".to_string(),
reason: "Starting".to_string(),
message: "VM is starting".to_string(),
last_transition_time: String::new(),
}
}
fn deleting_condition() -> SandboxCondition {
SandboxCondition {
r#type: "Ready".to_string(),
status: "False".to_string(),
reason: "Deleting".to_string(),
message: "Sandbox is being deleted".to_string(),
last_transition_time: String::new(),
}
}
fn error_condition(reason: &str, message: &str) -> SandboxCondition {
SandboxCondition {
r#type: "Ready".to_string(),
status: "False".to_string(),
reason: reason.to_string(),
message: message.to_string(),
last_transition_time: String::new(),
}
}
fn platform_event(source: &str, event_type: &str, reason: &str, message: String) -> PlatformEvent {
let mut event = PlatformEvent {
timestamp_ms: openshell_core::time::now_ms(),
source: source.to_string(),
r#type: event_type.to_string(),
reason: reason.to_string(),
message,
metadata: HashMap::new(),
};
attach_vm_progress_metadata(&mut event);
event
}
fn attach_vm_progress_metadata(event: &mut PlatformEvent) {
if event.source != "vm" {
return;
}
match event.reason.as_str() {
"Scheduled" => {
mark_progress_complete(
&mut event.metadata,
PROGRESS_STEP_REQUESTING_SANDBOX,
"Sandbox allocated",
);
mark_progress_active(&mut event.metadata, PROGRESS_STEP_PULLING_IMAGE);
}
"Pulling" => {
mark_progress_active(&mut event.metadata, PROGRESS_STEP_PULLING_IMAGE);
if let Some(image_ref) = event.metadata.get("image_ref").cloned() {
mark_progress_detail(&mut event.metadata, image_ref);
} else if let Some(image_ref) = pulling_image_from_message(&event.message) {
mark_progress_detail(&mut event.metadata, image_ref);
}
}
"Pulled" => {
let label = pulled_label(event);
mark_progress_complete(&mut event.metadata, PROGRESS_STEP_PULLING_IMAGE, label);
mark_progress_active(&mut event.metadata, PROGRESS_STEP_STARTING_SANDBOX);
}
"PullingLayer" => {
if let Some(detail) = pulling_layer_detail(&event.metadata) {
mark_progress_detail(&mut event.metadata, detail);
}
}
"ResolvingImage" => mark_progress_detail(&mut event.metadata, "Resolving image"),
"AuthenticatingRegistry" => {
mark_progress_detail(&mut event.metadata, "Authenticating registry");
}
"FetchingManifest" => mark_progress_detail(&mut event.metadata, "Fetching image manifest"),
"CacheHit" => mark_progress_detail(&mut event.metadata, "Using cached root disk"),
"CacheMiss" => mark_progress_detail(&mut event.metadata, "Preparing image cache"),
"WaitingForImageCacheLock" => {
mark_progress_detail(&mut event.metadata, "Waiting for image cache lock");
}
"ExportingRootfs" => {
mark_progress_detail(&mut event.metadata, "Exporting local image rootfs");
}
"PreparingRootfs" => mark_progress_detail(&mut event.metadata, "Preparing rootfs"),
"CreatingRootDisk" => mark_progress_detail(&mut event.metadata, "Formatting root disk"),
"PreparingOverlay" => mark_progress_detail(&mut event.metadata, "Preparing overlay disk"),
"Started" => mark_progress_detail(&mut event.metadata, "Waiting for VM supervisor"),
_ => {}
}
}
fn pulling_image_from_message(message: &str) -> Option<String> {
let image = message
.strip_prefix("Pulling image ")
.map(str::trim)
.map(|value| value.trim_matches('"'))?;
(!image.is_empty()).then(|| image.to_string())
}
fn pulled_label(event: &PlatformEvent) -> String {
event
.metadata
.get("image_size_bytes")
.and_then(|value| value.parse::<u64>().ok())
.map_or_else(
|| "Image pulled".to_string(),
|bytes| format!("Image pulled ({})", format_bytes(bytes)),
)
}
fn pulling_layer_detail(metadata: &HashMap<String, String>) -> Option<String> {
let index = metadata.get("layer_index")?;
let total = metadata.get("layer_total")?;
let size = metadata
.get("layer_size_bytes")
.and_then(|value| value.parse::<u64>().ok())
.map(format_bytes);
Some(size.map_or_else(
|| format!("Layer {index}/{total}"),
|size| format!("Layer {index}/{total} ({size})"),
))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::gpu::{SubnetAllocator, allocate_vsock_cid, mac_from_sandbox_id, tap_device_name};
use openshell_core::progress::{
PROGRESS_ACTIVE_DETAIL_KEY, PROGRESS_ACTIVE_STEP_KEY, PROGRESS_COMPLETE_LABEL_KEY,
PROGRESS_COMPLETE_STEP_KEY,
};
use openshell_core::proto::compute::v1::{
DriverSandboxSpec as SandboxSpec, DriverSandboxTemplate as SandboxTemplate,
GpuResourceRequirements, ResourceRequirements,
};
use prost_types::{Struct, Value, value::Kind};
use std::fs;
use std::path::Path;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use tonic::Code;
static ENV_LOCK: std::sync::LazyLock<std::sync::Mutex<()>> =
std::sync::LazyLock::new(|| std::sync::Mutex::new(()));
fn gpu_device_ids_config(device_ids: &[&str]) -> Struct {
list_string_driver_config("gpu_device_ids", device_ids)
}
fn gpu_device_id_typo_config(device_ids: &[&str]) -> Struct {
list_string_driver_config("gpu_device_id", device_ids)
}
fn list_string_driver_config(field: &str, values: &[&str]) -> Struct {
Struct {
fields: std::iter::once((
field.to_string(),
Value {
kind: Some(Kind::ListValue(prost_types::ListValue {
values: values
.iter()
.map(|device_id| Value {
kind: Some(Kind::StringValue((*device_id).to_string())),
})
.collect(),
})),
},
))
.collect(),
}
}
fn gpu_resources(count: Option<u32>) -> ResourceRequirements {
ResourceRequirements {
gpu: Some(GpuResourceRequirements { count }),
}
}
#[test]
fn vm_pulling_layer_event_adds_progress_detail_metadata() {
let mut event = platform_event(
"vm",
"Normal",
"PullingLayer",
"Pulling layer 3/8 for image".to_string(),
);
event.metadata = HashMap::from([
("layer_index".to_string(), "3".to_string()),
("layer_total".to_string(), "8".to_string()),
("layer_size_bytes".to_string(), "44040192".to_string()),
]);
attach_vm_progress_metadata(&mut event);
assert_eq!(
event
.metadata
.get(PROGRESS_ACTIVE_DETAIL_KEY)
.map(String::as_str),
Some("Layer 3/8 (42 MB)")
);
}
#[test]
fn vm_pulled_event_adds_completed_image_progress_metadata() {
let mut event = platform_event(
"vm",
"Normal",
"Pulled",
"Successfully pulled image".to_string(),
);
event
.metadata
.insert("image_size_bytes".to_string(), "44040192".to_string());
attach_vm_progress_metadata(&mut event);
assert_eq!(
event
.metadata
.get(PROGRESS_COMPLETE_STEP_KEY)
.map(String::as_str),
Some(PROGRESS_STEP_PULLING_IMAGE)
);
assert_eq!(
event
.metadata
.get(PROGRESS_COMPLETE_LABEL_KEY)
.map(String::as_str),
Some("Image pulled (42 MB)")
);
assert_eq!(
event
.metadata
.get(PROGRESS_ACTIVE_STEP_KEY)
.map(String::as_str),
Some(PROGRESS_STEP_STARTING_SANDBOX)
);
}
#[test]
fn validate_vm_sandbox_rejects_gpu_when_not_enabled() {
let sandbox = Sandbox {
id: "sandbox-123".to_string(),
spec: Some(SandboxSpec {
resource_requirements: Some(gpu_resources(None)),
..Default::default()
}),
..Default::default()
};
let err = validate_vm_sandbox(&sandbox, false)
.expect_err("gpu should be rejected when not enabled");
assert_eq!(err.code(), Code::FailedPrecondition);
assert!(err.message().contains("GPU support is not enabled"));
}
#[test]
fn validate_vm_sandbox_rejects_missing_gpu_support_before_request_shape() {
let sandbox = Sandbox {
id: "sandbox-123".to_string(),
spec: Some(SandboxSpec {
resource_requirements: Some(gpu_resources(Some(2))),
template: Some(SandboxTemplate {
driver_config: Some(gpu_device_ids_config(&["0000:2d:00.0"])),
..Default::default()
}),
..Default::default()
}),
..Default::default()
};
let err = validate_vm_sandbox(&sandbox, false)
.expect_err("missing GPU support should be rejected before request shape");
assert_eq!(err.code(), Code::FailedPrecondition);
assert!(err.message().contains("GPU support is not enabled"));
}
#[test]
fn validate_vm_sandbox_accepts_gpu_when_enabled() {
let sandbox = Sandbox {
id: "sandbox-123".to_string(),
spec: Some(SandboxSpec {
resource_requirements: Some(gpu_resources(None)),
..Default::default()
}),
..Default::default()
};
validate_vm_sandbox(&sandbox, true).expect("gpu should be accepted when enabled");
}
#[test]
fn validate_vm_sandbox_accepts_gpu_count_one() {
let sandbox = Sandbox {
id: "sandbox-123".to_string(),
spec: Some(SandboxSpec {
resource_requirements: Some(gpu_resources(Some(1))),
..Default::default()
}),
..Default::default()
};
validate_vm_sandbox(&sandbox, true).expect("one GPU should be accepted when enabled");
}
#[test]
fn validate_vm_sandbox_accepts_single_gpu_device_without_gpu_count() {
let sandbox = Sandbox {
id: "sandbox-123".to_string(),
spec: Some(SandboxSpec {
resource_requirements: Some(gpu_resources(None)),
template: Some(SandboxTemplate {
driver_config: Some(gpu_device_ids_config(&["0000:2d:00.0"])),
..Default::default()
}),
..Default::default()
}),
..Default::default()
};
validate_vm_sandbox(&sandbox, true)
.expect("single exact GPU device should be compatible with a default GPU request");
}
#[test]
fn validate_vm_sandbox_rejects_multiple_gpu_device_ids_without_gpu_count() {
let sandbox = Sandbox {
id: "sandbox-123".to_string(),
spec: Some(SandboxSpec {
resource_requirements: Some(gpu_resources(None)),
template: Some(SandboxTemplate {
driver_config: Some(gpu_device_ids_config(&["0000:2d:00.0", "0000:31:00.0"])),
..Default::default()
}),
..Default::default()
}),
..Default::default()
};
let err = validate_vm_sandbox(&sandbox, true)
.expect_err("multiple GPU device IDs without count should be rejected");
assert_eq!(err.code(), Code::InvalidArgument);
assert!(
err.message()
.contains("gpu count (1) must match driver_config.gpu_device_ids length (2)")
);
}
#[test]
fn validate_vm_sandbox_accepts_gpu_count_matching_device_id() {
let sandbox = Sandbox {
id: "sandbox-123".to_string(),
spec: Some(SandboxSpec {
resource_requirements: Some(gpu_resources(Some(1))),
template: Some(SandboxTemplate {
driver_config: Some(gpu_device_ids_config(&["0000:2d:00.0"])),
..Default::default()
}),
..Default::default()
}),
..Default::default()
};
validate_vm_sandbox(&sandbox, true)
.expect("matching explicit GPU device count should be accepted");
}
#[test]
fn validate_vm_sandbox_rejects_gpu_count_above_one() {
let sandbox = Sandbox {
id: "sandbox-123".to_string(),
spec: Some(SandboxSpec {
resource_requirements: Some(gpu_resources(Some(2))),
..Default::default()
}),
..Default::default()
};
let err = validate_vm_sandbox(&sandbox, true)
.expect_err("multiple GPU VM request should be rejected");
assert_eq!(err.code(), Code::InvalidArgument);
assert!(err.message().contains("support only one GPU"));
}
#[test]
fn validate_vm_sandbox_rejects_gpu_count_mismatched_device_id() {
let sandbox = Sandbox {
id: "sandbox-123".to_string(),
spec: Some(SandboxSpec {
resource_requirements: Some(gpu_resources(Some(2))),
template: Some(SandboxTemplate {
driver_config: Some(gpu_device_ids_config(&["0000:2d:00.0"])),
..Default::default()
}),
..Default::default()
}),
..Default::default()
};
let err = validate_vm_sandbox(&sandbox, true)
.expect_err("mismatched explicit GPU device count should be rejected");
assert_eq!(err.code(), Code::InvalidArgument);
assert!(
err.message()
.contains("gpu count (2) must match driver_config.gpu_device_ids length (1)")
);
}
#[test]
fn validate_vm_sandbox_rejects_gpu_device_without_gpu_request() {
let sandbox = Sandbox {
id: "sandbox-123".to_string(),
spec: Some(SandboxSpec {
template: Some(SandboxTemplate {
driver_config: Some(gpu_device_ids_config(&["0000:2d:00.0"])),
..Default::default()
}),
..Default::default()
}),
..Default::default()
};
let err = validate_vm_sandbox(&sandbox, true)
.expect_err("gpu_device_ids without a GPU request should be rejected");
assert_eq!(err.code(), Code::InvalidArgument);
assert!(err.message().contains("requires a gpu request"));
}
#[test]
fn validate_vm_sandbox_rejects_multiple_gpu_device_ids() {
let sandbox = Sandbox {
id: "sandbox-123".to_string(),
spec: Some(SandboxSpec {
resource_requirements: Some(gpu_resources(Some(2))),
template: Some(SandboxTemplate {
driver_config: Some(gpu_device_ids_config(&["0000:2d:00.0", "0000:31:00.0"])),
..Default::default()
}),
..Default::default()
}),
..Default::default()
};
let err =
validate_vm_sandbox(&sandbox, true).expect_err("multiple GPUs should be rejected");
assert_eq!(err.code(), Code::InvalidArgument);
assert!(err.message().contains("at most one gpu_device_ids"));
}
#[test]
fn validate_vm_sandbox_rejects_empty_gpu_device_ids() {
let sandbox = Sandbox {
id: "sandbox-123".to_string(),
spec: Some(SandboxSpec {
resource_requirements: Some(gpu_resources(None)),
template: Some(SandboxTemplate {
driver_config: Some(gpu_device_ids_config(&[])),
..Default::default()
}),
..Default::default()
}),
..Default::default()
};
let err =
validate_vm_sandbox(&sandbox, true).expect_err("empty GPU IDs should be rejected");
assert_eq!(err.code(), Code::InvalidArgument);
assert!(err.message().contains("non-empty list"));
}
#[test]
fn validate_vm_sandbox_rejects_unknown_driver_config_fields() {
let sandbox = Sandbox {
id: "sandbox-123".to_string(),
spec: Some(SandboxSpec {
resource_requirements: Some(gpu_resources(None)),
template: Some(SandboxTemplate {
driver_config: Some(gpu_device_id_typo_config(&["0000:2d:00.0"])),
..Default::default()
}),
..Default::default()
}),
..Default::default()
};
let err =
validate_vm_sandbox(&sandbox, true).expect_err("unknown field should be rejected");
assert_eq!(err.code(), Code::InvalidArgument);
assert!(err.message().contains("unknown field"));
}
#[test]
fn validate_vm_sandbox_rejects_template_errors_before_device_config() {
let sandbox = Sandbox {
id: "sandbox-123".to_string(),
spec: Some(SandboxSpec {
resource_requirements: Some(gpu_resources(None)),
template: Some(SandboxTemplate {
agent_socket_path: "/tmp/agent.sock".to_string(),
driver_config: Some(gpu_device_ids_config(&[])),
..Default::default()
}),
..Default::default()
}),
..Default::default()
};
let err =
validate_vm_sandbox(&sandbox, true).expect_err("template error should be rejected");
assert_eq!(err.code(), Code::FailedPrecondition);
assert!(err.message().contains("agent_socket_path"));
}
#[test]
fn validate_vm_sandbox_rejects_platform_config() {
let sandbox = Sandbox {
id: "sandbox-123".to_string(),
spec: Some(SandboxSpec {
template: Some(SandboxTemplate {
platform_config: Some(Struct {
fields: std::iter::once((
"runtime_class_name".to_string(),
Value {
kind: Some(Kind::StringValue("kata".to_string())),
},
))
.collect(),
}),
..Default::default()
}),
..Default::default()
}),
..Default::default()
};
let err =
validate_vm_sandbox(&sandbox, false).expect_err("platform config should be rejected");
assert_eq!(err.code(), Code::FailedPrecondition);
assert!(err.message().contains("platform_config"));
}
#[test]
fn validate_vm_sandbox_accepts_template_image() {
let sandbox = Sandbox {
id: "sandbox-123".to_string(),
spec: Some(SandboxSpec {
template: Some(SandboxTemplate {
image: "ghcr.io/example/sandbox:latest".to_string(),
..Default::default()
}),
..Default::default()
}),
..Default::default()
};
validate_vm_sandbox(&sandbox, false).expect("template.image should be accepted");
}
#[test]
fn validate_vm_sandbox_accepts_template_resources_as_noop() {
use openshell_core::proto::compute::v1::DriverResourceRequirements;
let sandbox = Sandbox {
id: "sandbox-123".to_string(),
spec: Some(SandboxSpec {
template: Some(SandboxTemplate {
resources: Some(DriverResourceRequirements {
cpu_limit: "2".to_string(),
memory_limit: "4Gi".to_string(),
..Default::default()
}),
..Default::default()
}),
..Default::default()
}),
..Default::default()
};
validate_vm_sandbox(&sandbox, false)
.expect("template.resources should be accepted and ignored");
}
#[test]
fn validate_vm_sandbox_rejects_path_unsafe_ids() {
let mut unsafe_ids = [
"",
".",
"..",
"../escape",
"/tmp/escape",
"nested/path",
"nested\\path",
"bad\nid",
"bad id",
"unicodé",
]
.into_iter()
.map(str::to_string)
.collect::<Vec<_>>();
unsafe_ids.push("a".repeat(129));
for sandbox_id in unsafe_ids {
let sandbox = Sandbox {
id: sandbox_id.clone(),
spec: Some(SandboxSpec {
template: Some(SandboxTemplate {
image: "ghcr.io/example/sandbox:latest".to_string(),
..Default::default()
}),
..Default::default()
}),
..Default::default()
};
let err = validate_vm_sandbox(&sandbox, false)
.expect_err("path-unsafe sandbox id should be rejected");
assert_eq!(err.code(), Code::InvalidArgument, "id={sandbox_id:?}");
assert!(err.message().contains("sandbox id"), "id={sandbox_id:?}");
}
}
#[test]
fn sandbox_state_dir_rejects_path_unsafe_ids() {
let err = sandbox_state_dir(Path::new("/tmp/openshell-vm"), "../escape")
.expect_err("path traversal should be rejected");
assert_eq!(err.code(), Code::InvalidArgument);
}
#[test]
fn sandbox_runtime_disk_paths_use_per_sandbox_overlay() {
let driver_state = Path::new("/tmp/openshell-vm");
let state_dir = driver_state.join("sandboxes").join("sandbox-123");
let disks = sandbox_runtime_disk_paths(&state_dir);
assert_eq!(disks.overlay_disk, state_dir.join(SANDBOX_OVERLAY_IMAGE));
}
#[test]
fn overlay_template_image_is_keyed_by_size_and_layout() {
let path = overlay_template_image(Path::new("/tmp/openshell-vm"), 4 * 1024 * 1024);
assert_eq!(
path,
Path::new("/tmp/openshell-vm")
.join(IMAGE_CACHE_ROOT_DIR)
.join(OVERLAY_TEMPLATE_CACHE_DIR)
.join(OVERLAY_TEMPLATE_CACHE_LAYOUT_VERSION)
.join("4194304.ext4")
);
}
#[tokio::test]
async fn sandbox_request_metadata_round_trips_for_resume() {
let base = unique_temp_dir();
let state_dir = base.join("sandboxes").join("sandbox-123");
std::fs::create_dir_all(&state_dir).unwrap();
let sandbox = Sandbox {
id: "sandbox-123".to_string(),
name: "resume-sandbox".to_string(),
namespace: "vm-dev".to_string(),
spec: Some(SandboxSpec {
environment: HashMap::from([("KEY".to_string(), "value".to_string())]),
template: Some(SandboxTemplate {
image: "ghcr.io/example/sandbox:latest".to_string(),
..Default::default()
}),
..Default::default()
}),
..Default::default()
};
write_sandbox_request(&state_dir, &sandbox)
.await
.expect("write sandbox request");
let restored = read_sandbox_request(&state_dir.join(SANDBOX_REQUEST_FILE))
.await
.expect("read sandbox request");
assert_eq!(restored, sandbox);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
let dir_mode = std::fs::metadata(&state_dir).unwrap().permissions().mode() & 0o777;
let file_mode = std::fs::metadata(state_dir.join(SANDBOX_REQUEST_FILE))
.unwrap()
.permissions()
.mode()
& 0o777;
assert_eq!(dir_mode, 0o700);
assert_eq!(file_mode, 0o600);
}
validate_restored_sandbox_state(&base, &state_dir, &restored)
.expect("restored state should validate");
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn prepare_sandbox_overlay_preserves_existing_overlay_on_resume() {
let base = unique_temp_dir();
std::fs::create_dir_all(&base).unwrap();
let template = base.join("template.ext4");
let overlay = base.join("overlay.ext4");
std::fs::write(&template, b"fresh-overlay").unwrap();
std::fs::write(&overlay, b"saved-overlay").unwrap();
prepare_sandbox_overlay_image(
&template,
&overlay,
None,
None,
OverlayPreparation::PreserveExisting,
"saved-overlay".len() as u64,
)
.expect("preserve existing overlay");
assert_eq!(std::fs::read(&overlay).unwrap(), b"saved-overlay");
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn prepare_sandbox_overlay_creates_missing_overlay_on_resume() {
let base = unique_temp_dir();
std::fs::create_dir_all(&base).unwrap();
let template = base.join("template.ext4");
let overlay = base.join("overlay.ext4");
std::fs::write(&template, b"fresh-overlay").unwrap();
prepare_sandbox_overlay_image(
&template,
&overlay,
None,
None,
OverlayPreparation::PreserveExisting,
"fresh-overlay".len() as u64,
)
.expect("create missing overlay");
assert_eq!(std::fs::read(&overlay).unwrap(), b"fresh-overlay");
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn overlay_upper_path_targets_overlay_upperdir() {
assert_eq!(
overlay_upper_path(GUEST_TLS_KEY_PATH),
"/upper/opt/openshell/tls/tls.key"
);
}
#[test]
fn capabilities_report_configured_default_image() {
let driver = VmDriver {
config: VmDriverConfig {
default_image: "openshell/sandbox:dev".to_string(),
..Default::default()
},
launcher_bin: PathBuf::from("/tmp/openshell-driver-vm"),
registry: Arc::new(Mutex::new(HashMap::new())),
image_cache_lock: Arc::new(Mutex::new(())),
events: broadcast::channel(WATCH_BUFFER).0,
gpu_inventory: None,
subnet_allocator: Arc::new(std::sync::Mutex::new(SubnetAllocator::new(
Ipv4Addr::new(10, 0, 128, 0),
17,
))),
lifecycle_extensions: Arc::new(LifecycleExtensionRegistry::new()),
};
assert_eq!(driver.capabilities().default_image, "openshell/sandbox:dev");
}
#[test]
fn resolved_sandbox_image_prefers_template_image() {
let driver = VmDriver {
config: VmDriverConfig {
default_image: "openshell/sandbox:default".to_string(),
..Default::default()
},
launcher_bin: PathBuf::from("/tmp/openshell-driver-vm"),
registry: Arc::new(Mutex::new(HashMap::new())),
image_cache_lock: Arc::new(Mutex::new(())),
events: broadcast::channel(WATCH_BUFFER).0,
gpu_inventory: None,
subnet_allocator: Arc::new(std::sync::Mutex::new(SubnetAllocator::new(
Ipv4Addr::new(10, 0, 128, 0),
17,
))),
lifecycle_extensions: Arc::new(LifecycleExtensionRegistry::new()),
};
let sandbox = Sandbox {
spec: Some(SandboxSpec {
template: Some(SandboxTemplate {
image: "ghcr.io/example/custom:latest".to_string(),
..Default::default()
}),
..Default::default()
}),
..Default::default()
};
assert_eq!(
driver.resolved_sandbox_image(&sandbox).as_deref(),
Some("ghcr.io/example/custom:latest")
);
}
#[test]
fn resolved_sandbox_image_falls_back_to_driver_default() {
let driver = VmDriver {
config: VmDriverConfig {
default_image: "openshell/sandbox:default".to_string(),
..Default::default()
},
launcher_bin: PathBuf::from("/tmp/openshell-driver-vm"),
registry: Arc::new(Mutex::new(HashMap::new())),
image_cache_lock: Arc::new(Mutex::new(())),
events: broadcast::channel(WATCH_BUFFER).0,
gpu_inventory: None,
subnet_allocator: Arc::new(std::sync::Mutex::new(SubnetAllocator::new(
Ipv4Addr::new(10, 0, 128, 0),
17,
))),
lifecycle_extensions: Arc::new(LifecycleExtensionRegistry::new()),
};
let sandbox = Sandbox {
spec: Some(SandboxSpec {
template: Some(SandboxTemplate::default()),
..Default::default()
}),
..Default::default()
};
assert_eq!(
driver.resolved_sandbox_image(&sandbox).as_deref(),
Some("openshell/sandbox:default")
);
}
#[test]
fn resolved_sandbox_image_returns_none_without_template_or_default() {
let driver = VmDriver {
config: VmDriverConfig::default(),
launcher_bin: PathBuf::from("/tmp/openshell-driver-vm"),
registry: Arc::new(Mutex::new(HashMap::new())),
image_cache_lock: Arc::new(Mutex::new(())),
events: broadcast::channel(WATCH_BUFFER).0,
gpu_inventory: None,
subnet_allocator: Arc::new(std::sync::Mutex::new(SubnetAllocator::new(
Ipv4Addr::new(10, 0, 128, 0),
17,
))),
lifecycle_extensions: Arc::new(LifecycleExtensionRegistry::new()),
};
let sandbox = Sandbox {
spec: Some(SandboxSpec {
template: Some(SandboxTemplate::default()),
..Default::default()
}),
..Default::default()
};
assert!(driver.resolved_sandbox_image(&sandbox).is_none());
}
#[test]
fn bootstrap_image_ref_prefers_explicit_bootstrap_image() {
let driver = VmDriver {
config: VmDriverConfig {
default_image: "openshell/sandbox:default".to_string(),
bootstrap_image: "openshell/sandbox-bootstrap:latest".to_string(),
..Default::default()
},
launcher_bin: PathBuf::from("/tmp/openshell-driver-vm"),
registry: Arc::new(Mutex::new(HashMap::new())),
image_cache_lock: Arc::new(Mutex::new(())),
events: broadcast::channel(WATCH_BUFFER).0,
gpu_inventory: None,
subnet_allocator: Arc::new(std::sync::Mutex::new(SubnetAllocator::new(
Ipv4Addr::new(10, 0, 128, 0),
17,
))),
lifecycle_extensions: Arc::new(LifecycleExtensionRegistry::new()),
};
assert_eq!(
driver.bootstrap_image_ref("ghcr.io/example/app:latest"),
"openshell/sandbox-bootstrap:latest"
);
}
#[test]
fn bootstrap_image_ref_falls_back_to_default_image() {
let driver = VmDriver {
config: VmDriverConfig {
default_image: "openshell/sandbox:default".to_string(),
..Default::default()
},
launcher_bin: PathBuf::from("/tmp/openshell-driver-vm"),
registry: Arc::new(Mutex::new(HashMap::new())),
image_cache_lock: Arc::new(Mutex::new(())),
events: broadcast::channel(WATCH_BUFFER).0,
gpu_inventory: None,
subnet_allocator: Arc::new(std::sync::Mutex::new(SubnetAllocator::new(
Ipv4Addr::new(10, 0, 128, 0),
17,
))),
lifecycle_extensions: Arc::new(LifecycleExtensionRegistry::new()),
};
assert_eq!(
driver.bootstrap_image_ref("ghcr.io/example/app:latest"),
"openshell/sandbox:default"
);
}
#[test]
fn bootstrap_image_ref_falls_back_to_requested_image() {
let driver = VmDriver {
config: VmDriverConfig::default(),
launcher_bin: PathBuf::from("/tmp/openshell-driver-vm"),
registry: Arc::new(Mutex::new(HashMap::new())),
image_cache_lock: Arc::new(Mutex::new(())),
events: broadcast::channel(WATCH_BUFFER).0,
gpu_inventory: None,
subnet_allocator: Arc::new(std::sync::Mutex::new(SubnetAllocator::new(
Ipv4Addr::new(10, 0, 128, 0),
17,
))),
lifecycle_extensions: Arc::new(LifecycleExtensionRegistry::new()),
};
assert_eq!(
driver.bootstrap_image_ref("ghcr.io/example/app:latest"),
"ghcr.io/example/app:latest"
);
}
#[test]
fn merged_environment_prefers_spec_values() {
let sandbox = Sandbox {
spec: Some(SandboxSpec {
environment: HashMap::from([("A".to_string(), "spec".to_string())]),
template: Some(SandboxTemplate {
environment: HashMap::from([
("A".to_string(), "template".to_string()),
("B".to_string(), "template".to_string()),
]),
..Default::default()
}),
..Default::default()
}),
..Default::default()
};
let merged = merged_environment(&sandbox);
assert_eq!(merged.get("A"), Some(&"spec".to_string()));
assert_eq!(merged.get("B"), Some(&"template".to_string()));
}
#[test]
fn build_guest_environment_sets_supervisor_defaults() {
let config = VmDriverConfig {
openshell_endpoint: "http://127.0.0.1:8080".to_string(),
..Default::default()
};
let sandbox = Sandbox {
id: "sandbox-123".to_string(),
name: "breezy-rhinoceros".to_string(),
spec: Some(SandboxSpec::default()),
..Default::default()
};
let env = build_guest_environment(&sandbox, &config, None);
assert!(env.contains(&"HOME=/root".to_string()));
assert!(env.contains(&format!(
"OPENSHELL_ENDPOINT=http://{GVPROXY_HOST_LOOPBACK_ALIAS}:8080/"
)));
assert!(env.contains(&"OPENSHELL_SANDBOX_ID=sandbox-123".to_string()));
assert!(env.contains(&"OPENSHELL_SANDBOX=breezy-rhinoceros".to_string()));
assert!(env.contains(&format!(
"OPENSHELL_SSH_SOCKET_PATH={GUEST_SSH_SOCKET_PATH}"
)));
}
#[test]
fn build_guest_environment_uses_token_file_without_raw_token_env() {
let config = VmDriverConfig {
openshell_endpoint: "http://127.0.0.1:8080".to_string(),
..Default::default()
};
let sandbox = Sandbox {
id: "sandbox-123".to_string(),
name: "sandbox-123".to_string(),
spec: Some(SandboxSpec {
sandbox_token: "secret.jwt.value".to_string(),
environment: HashMap::from([(
openshell_core::sandbox_env::SANDBOX_TOKEN.to_string(),
"user-provided-token".to_string(),
)]),
..Default::default()
}),
..Default::default()
};
let env = build_guest_environment(&sandbox, &config, None);
assert!(!env.iter().any(|v| v.starts_with(&format!(
"{}=",
openshell_core::sandbox_env::SANDBOX_TOKEN
))));
assert!(env.contains(&format!(
"{}={GUEST_SANDBOX_TOKEN_PATH}",
openshell_core::sandbox_env::SANDBOX_TOKEN_FILE
)));
}
#[test]
fn build_guest_environment_uses_deployment_telemetry_toggle() {
let _guard = ENV_LOCK.lock().unwrap();
temp_env::with_vars(
[(
openshell_core::sandbox_env::TELEMETRY_ENABLED,
Some("false"),
)],
|| {
let config = VmDriverConfig {
openshell_endpoint: "http://127.0.0.1:8080".to_string(),
..Default::default()
};
let sandbox = Sandbox {
id: "sandbox-123".to_string(),
name: "sandbox-123".to_string(),
spec: Some(SandboxSpec {
environment: HashMap::from([(
openshell_core::sandbox_env::TELEMETRY_ENABLED.to_string(),
"true".to_string(),
)]),
..Default::default()
}),
..Default::default()
};
let env = build_guest_environment(&sandbox, &config, None);
let telemetry_entries = env
.iter()
.filter(|entry| {
entry.starts_with(&format!(
"{}=",
openshell_core::sandbox_env::TELEMETRY_ENABLED
))
})
.collect::<Vec<_>>();
assert_eq!(telemetry_entries.len(), 1);
assert_eq!(
telemetry_entries[0],
&format!("{}=false", openshell_core::sandbox_env::TELEMETRY_ENABLED)
);
},
);
}
#[test]
fn build_guest_environment_uses_endpoint_override_for_tap() {
let config = VmDriverConfig {
openshell_endpoint: "http://127.0.0.1:8080".to_string(),
..Default::default()
};
let sandbox = Sandbox {
id: "sandbox-123".to_string(),
name: "sandbox-123".to_string(),
spec: Some(SandboxSpec::default()),
..Default::default()
};
let env = build_guest_environment(&sandbox, &config, Some("http://10.0.128.1:8080"));
assert!(
env.contains(&"OPENSHELL_ENDPOINT=http://10.0.128.1:8080".to_string()),
"TAP endpoint override must replace the default"
);
let endpoint_count = env
.iter()
.filter(|e| e.starts_with("OPENSHELL_ENDPOINT="))
.count();
assert_eq!(
endpoint_count, 1,
"must have exactly one OPENSHELL_ENDPOINT"
);
}
#[test]
fn guest_visible_openshell_endpoint_rewrites_loopback_hosts_to_gvproxy_host_alias() {
assert_eq!(
guest_visible_openshell_endpoint("http://127.0.0.1:8080"),
format!("http://{GVPROXY_HOST_LOOPBACK_ALIAS}:8080/")
);
assert_eq!(
guest_visible_openshell_endpoint("http://localhost:8080"),
format!("http://{GVPROXY_HOST_LOOPBACK_ALIAS}:8080/")
);
assert_eq!(
guest_visible_openshell_endpoint("https://[::1]:8443"),
format!("https://{GVPROXY_HOST_LOOPBACK_ALIAS}:8443/")
);
}
#[test]
fn guest_visible_openshell_endpoint_preserves_non_loopback_hosts() {
assert_eq!(
guest_visible_openshell_endpoint(&format!(
"http://{OPENSHELL_HOST_GATEWAY_ALIAS}:8080"
)),
format!("http://{OPENSHELL_HOST_GATEWAY_ALIAS}:8080")
);
assert_eq!(
guest_visible_openshell_endpoint(&format!("http://{GVPROXY_HOST_LOOPBACK_ALIAS}:8080")),
format!("http://{GVPROXY_HOST_LOOPBACK_ALIAS}:8080")
);
assert_eq!(
guest_visible_openshell_endpoint("http://192.168.127.1:8080"),
"http://192.168.127.1:8080"
);
assert_eq!(
guest_visible_openshell_endpoint("https://gateway.internal:8443"),
"https://gateway.internal:8443"
);
}
#[test]
fn image_reference_registry_host_defaults_to_docker_hub() {
assert_eq!(image_reference_registry_host("ubuntu:24.04"), "docker.io");
assert_eq!(
image_reference_registry_host("library/ubuntu:24.04"),
"docker.io"
);
assert_eq!(
image_reference_registry_host("ghcr.io/nvidia/openshell/base:latest"),
"ghcr.io"
);
assert_eq!(
image_reference_registry_host("localhost/example:dev"),
"localhost"
);
assert_eq!(
image_reference_registry_host("localhost:5000/example/sandbox:dev"),
"localhost:5000"
);
}
#[test]
fn openshell_local_build_image_ref_matches_cli_tags() {
assert!(is_openshell_local_build_image_ref(
"openshell/sandbox-from:123"
));
assert!(!is_openshell_local_build_image_ref("ubuntu:24.04"));
assert!(!is_openshell_local_build_image_ref(
"ghcr.io/nvidia/openshell/base:latest"
));
}
#[test]
fn local_image_platform_mismatch_checks_guest_platform() {
assert!(
local_image_platform_mismatch(
"openshell/sandbox-from:123",
Some("linux"),
Some(linux_oci_arch()),
)
.is_none()
);
let err = local_image_platform_mismatch(
"openshell/sandbox-from:123",
Some("linux"),
Some("wrong-arch"),
)
.expect("architecture mismatch should be reported");
assert!(err.contains("wrong-arch"));
assert!(err.contains(linux_oci_arch()));
let err = local_image_platform_mismatch("openshell/sandbox-from:123", None, None)
.expect("unknown platform should be reported");
assert!(err.contains("unknown/unknown"));
}
#[test]
fn apply_layer_dir_to_rootfs_honors_whiteouts() {
let base = unique_temp_dir();
let rootfs = base.join("rootfs");
let layer = base.join("layer");
fs::create_dir_all(rootfs.join("dir")).unwrap();
fs::write(rootfs.join("removed.txt"), "old").unwrap();
fs::write(rootfs.join("dir/old.txt"), "old").unwrap();
fs::create_dir_all(layer.join("dir")).unwrap();
fs::write(layer.join(".wh.removed.txt"), "").unwrap();
fs::write(layer.join("dir/.wh..wh..opq"), "").unwrap();
fs::write(layer.join("dir/new.txt"), "new").unwrap();
apply_layer_dir_to_rootfs(&layer, &rootfs).unwrap();
assert!(!rootfs.join("removed.txt").exists());
assert!(!rootfs.join("dir/old.txt").exists());
assert_eq!(
fs::read_to_string(rootfs.join("dir/new.txt")).unwrap(),
"new"
);
let _ = fs::remove_dir_all(base);
}
#[test]
fn apply_layer_dir_to_rootfs_preserves_lower_symlink_dirs() {
let base = unique_temp_dir();
let rootfs = base.join("rootfs");
let layer = base.join("layer");
fs::create_dir_all(rootfs.join("usr/bin")).unwrap();
fs::write(rootfs.join("usr/bin/bash"), "bash").unwrap();
std::os::unix::fs::symlink("usr/bin", rootfs.join("bin")).unwrap();
fs::create_dir_all(layer.join("bin")).unwrap();
fs::write(layer.join("bin/foo"), "foo").unwrap();
apply_layer_dir_to_rootfs(&layer, &rootfs).unwrap();
assert!(
fs::symlink_metadata(rootfs.join("bin"))
.unwrap()
.file_type()
.is_symlink(),
"lower /bin symlink should be preserved"
);
assert_eq!(
fs::read_to_string(rootfs.join("usr/bin/bash")).unwrap(),
"bash"
);
assert_eq!(
fs::read_to_string(rootfs.join("usr/bin/foo")).unwrap(),
"foo"
);
let _ = fs::remove_dir_all(base);
}
#[test]
fn layer_compression_from_media_type_supports_common_formats() {
assert_eq!(
layer_compression_from_media_type("application/vnd.oci.image.layer.v1.tar").unwrap(),
LayerCompression::None
);
assert_eq!(
layer_compression_from_media_type("application/vnd.oci.image.layer.v1.tar+gzip")
.unwrap(),
LayerCompression::Gzip
);
assert_eq!(
layer_compression_from_media_type("application/vnd.oci.image.layer.v1.tar+zstd")
.unwrap(),
LayerCompression::Zstd
);
}
#[test]
fn build_guest_environment_includes_tls_paths_for_https_endpoint() {
let config = VmDriverConfig {
openshell_endpoint: "https://127.0.0.1:8443".to_string(),
guest_tls_ca: Some(PathBuf::from("/host/ca.crt")),
guest_tls_cert: Some(PathBuf::from("/host/tls.crt")),
guest_tls_key: Some(PathBuf::from("/host/tls.key")),
..Default::default()
};
let sandbox = Sandbox {
id: "sandbox-123".to_string(),
name: "sandbox-123".to_string(),
spec: Some(SandboxSpec::default()),
..Default::default()
};
let env = build_guest_environment(&sandbox, &config, None);
assert!(env.contains(&format!("OPENSHELL_TLS_CA={GUEST_TLS_CA_PATH}")));
assert!(env.contains(&format!("OPENSHELL_TLS_CERT={GUEST_TLS_CERT_PATH}")));
assert!(env.contains(&format!("OPENSHELL_TLS_KEY={GUEST_TLS_KEY_PATH}")));
}
#[test]
fn vm_driver_config_requires_tls_materials_for_https_endpoint() {
let config = VmDriverConfig {
openshell_endpoint: "https://127.0.0.1:8443".to_string(),
..Default::default()
};
let err = config
.tls_paths()
.expect_err("https endpoint should require TLS materials");
assert!(err.contains("OPENSHELL_VM_TLS_CA"));
}
#[tokio::test]
async fn delete_sandbox_keeps_registry_entry_when_cleanup_fails() {
let base = unique_temp_dir();
let driver_state = base.join("driver-state");
let (events, _) = broadcast::channel(WATCH_BUFFER);
let driver = VmDriver {
config: VmDriverConfig {
state_dir: driver_state.clone(),
..Default::default()
},
launcher_bin: PathBuf::from("openshell-driver-vm"),
registry: Arc::new(Mutex::new(HashMap::new())),
image_cache_lock: Arc::new(Mutex::new(())),
events,
gpu_inventory: None,
subnet_allocator: Arc::new(std::sync::Mutex::new(SubnetAllocator::new(
Ipv4Addr::new(10, 0, 128, 0),
17,
))),
lifecycle_extensions: Arc::new(LifecycleExtensionRegistry::new()),
};
let state_file = sandbox_state_dir(&driver_state, "sandbox-123").unwrap();
std::fs::create_dir_all(state_file.parent().unwrap()).unwrap();
std::fs::write(&state_file, "not a directory").unwrap();
insert_test_record(
&driver,
"sandbox-123",
state_file.clone(),
spawn_exited_child(),
)
.await;
let err = driver
.delete_sandbox("sandbox-123", "sandbox-123")
.await
.expect_err("state dir cleanup should fail for a file path");
assert!(err.message().contains("not a directory"));
assert!(driver.registry.lock().await.contains_key("sandbox-123"));
std::fs::remove_file(&state_file).unwrap();
let retry_state_dir = sandbox_state_dir(&driver_state, "sandbox-123").unwrap();
std::fs::create_dir_all(&retry_state_dir).unwrap();
{
let mut registry = driver.registry.lock().await;
let record = registry.get_mut("sandbox-123").unwrap();
record.state_dir = retry_state_dir;
record.process = Some(Arc::new(Mutex::new(VmProcess {
child: spawn_exited_child(),
deleting: false,
})));
}
let response = driver
.delete_sandbox("sandbox-123", "sandbox-123")
.await
.expect("delete retry should succeed once cleanup works");
assert!(response.deleted);
assert!(!driver.registry.lock().await.contains_key("sandbox-123"));
let _ = std::fs::remove_dir_all(base);
}
#[tokio::test]
async fn delete_sandbox_cleans_provisioning_record_without_process() {
let base = unique_temp_dir();
let driver_state = base.join("driver-state");
let (events, _) = broadcast::channel(WATCH_BUFFER);
let driver = VmDriver {
config: VmDriverConfig {
state_dir: driver_state.clone(),
..Default::default()
},
launcher_bin: PathBuf::from("openshell-driver-vm"),
registry: Arc::new(Mutex::new(HashMap::new())),
image_cache_lock: Arc::new(Mutex::new(())),
events,
gpu_inventory: None,
subnet_allocator: Arc::new(std::sync::Mutex::new(SubnetAllocator::new(
Ipv4Addr::new(10, 0, 128, 0),
17,
))),
lifecycle_extensions: Arc::new(LifecycleExtensionRegistry::new()),
};
let state_dir = sandbox_state_dir(&driver_state, "sandbox-123").unwrap();
std::fs::create_dir_all(&state_dir).unwrap();
{
let mut registry = driver.registry.lock().await;
registry.insert(
"sandbox-123".to_string(),
SandboxRecord {
snapshot: Sandbox {
id: "sandbox-123".to_string(),
name: "sandbox-123".to_string(),
..Default::default()
},
state_dir: state_dir.clone(),
process: None,
provisioning_task: None,
gpu_bdf: None,
qemu_network_allocated: false,
deleting: false,
},
);
}
let response = driver
.delete_sandbox("sandbox-123", "sandbox-123")
.await
.expect("delete should handle accepted-but-not-started sandboxes");
assert!(response.deleted);
assert!(!driver.registry.lock().await.contains_key("sandbox-123"));
assert!(!state_dir.exists());
let _ = std::fs::remove_dir_all(base);
}
#[tokio::test]
async fn duplicate_create_keeps_existing_state_dir() {
let base = unique_temp_dir();
let driver_state = base.join("driver-state");
let (events, _) = broadcast::channel(WATCH_BUFFER);
let driver = VmDriver {
config: VmDriverConfig {
state_dir: driver_state.clone(),
default_image: "ghcr.io/example/sandbox:latest".to_string(),
..Default::default()
},
launcher_bin: PathBuf::from("openshell-driver-vm"),
registry: Arc::new(Mutex::new(HashMap::new())),
image_cache_lock: Arc::new(Mutex::new(())),
events,
gpu_inventory: None,
subnet_allocator: Arc::new(std::sync::Mutex::new(SubnetAllocator::new(
Ipv4Addr::new(10, 0, 128, 0),
17,
))),
lifecycle_extensions: Arc::new(LifecycleExtensionRegistry::new()),
};
let state_dir = sandbox_state_dir(&driver_state, "sandbox-123").unwrap();
std::fs::create_dir_all(&state_dir).unwrap();
std::fs::write(state_dir.join("overlay.ext4"), b"live overlay").unwrap();
{
let mut registry = driver.registry.lock().await;
registry.insert(
"sandbox-123".to_string(),
SandboxRecord {
snapshot: Sandbox {
id: "sandbox-123".to_string(),
name: "sandbox-123".to_string(),
..Default::default()
},
state_dir: state_dir.clone(),
process: None,
provisioning_task: None,
gpu_bdf: None,
qemu_network_allocated: false,
deleting: false,
},
);
}
let err = driver
.create_sandbox(&Sandbox {
id: "sandbox-123".to_string(),
name: "sandbox-123".to_string(),
spec: Some(SandboxSpec::default()),
..Default::default()
})
.await
.expect_err("duplicate create should fail");
assert_eq!(err.code(), Code::AlreadyExists);
assert!(state_dir.join("overlay.ext4").exists());
assert!(driver.registry.lock().await.contains_key("sandbox-123"));
let _ = std::fs::remove_dir_all(base);
}
#[tokio::test]
async fn remove_sandbox_state_dir_rejects_paths_outside_state_root() {
let base = unique_temp_dir();
let state_root = base.join("driver-state");
let outside = base.join("outside");
std::fs::create_dir_all(&outside).unwrap();
let err = remove_sandbox_state_dir(&state_root, &outside)
.await
.expect_err("outside state paths should be rejected");
assert!(err.message().contains("outside vm state root"));
let _ = std::fs::remove_dir_all(base);
}
#[cfg(unix)]
#[tokio::test]
async fn remove_sandbox_state_dir_rejects_symlinked_state_dir() {
let base = unique_temp_dir();
let state_root = base.join("driver-state");
let target = base.join("target");
let state_dir = sandbox_state_dir(&state_root, "sandbox-123").unwrap();
std::fs::create_dir_all(&target).unwrap();
std::fs::create_dir_all(state_dir.parent().unwrap()).unwrap();
std::os::unix::fs::symlink(&target, &state_dir).unwrap();
let err = remove_sandbox_state_dir(&state_root, &state_dir)
.await
.expect_err("symlinked state dir should be rejected");
assert!(err.message().contains("symlinked sandbox state dir"));
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn validate_openshell_endpoint_accepts_loopback_hosts() {
validate_openshell_endpoint("http://127.0.0.1:8080")
.expect("ipv4 loopback should be allowed for TSI");
validate_openshell_endpoint("http://localhost:8080")
.expect("localhost should be allowed for TSI");
validate_openshell_endpoint("http://[::1]:8080")
.expect("ipv6 loopback should be allowed for TSI");
}
#[test]
fn validate_openshell_endpoint_rejects_unspecified_hosts() {
let err = validate_openshell_endpoint("http://0.0.0.0:8080")
.expect_err("unspecified endpoint should fail");
assert!(err.contains("not reachable from sandbox VMs"));
}
#[test]
fn validate_openshell_endpoint_accepts_host_gateway() {
validate_openshell_endpoint("http://host.containers.internal:8080")
.expect("guest-reachable host alias should be accepted");
validate_openshell_endpoint("http://192.168.127.1:8080")
.expect("gateway IP should be accepted");
validate_openshell_endpoint(&format!("http://{OPENSHELL_HOST_GATEWAY_ALIAS}:8080"))
.expect("openshell host alias should be accepted");
validate_openshell_endpoint("https://gateway.internal:8443")
.expect("dns endpoint should be accepted");
}
#[test]
fn prepared_image_cache_identity_includes_rootfs_layout_and_openshell_version() {
assert_eq!(
prepared_image_cache_identity("sha256:local-image"),
format!(
"sandbox-prepared-rootfs-ext4-umoci-v3:openshell-{}:sha256:local-image",
openshell_core::VERSION
)
);
}
#[test]
fn bootstrap_image_cache_identity_includes_rootfs_layout_and_openshell_version() {
assert_eq!(
bootstrap_image_cache_identity("sha256:bootstrap-image"),
format!(
"sandbox-bootstrap-rootfs-ext4-v3:openshell-{}:sha256:bootstrap-image",
openshell_core::VERSION
)
);
}
#[test]
fn stage_guest_image_payload_copies_registry_oci_layout() {
let base = unique_temp_dir();
let staging_dir = base.join("staging");
let layout_dir = base.join("layout");
let blob_dir = layout_dir.join("blobs").join("sha256");
fs::create_dir_all(&blob_dir).unwrap();
fs::write(
layout_dir.join("oci-layout"),
r#"{"imageLayoutVersion":"1.0.0"}"#,
)
.unwrap();
fs::write(layout_dir.join("index.json"), "{}").unwrap();
fs::write(blob_dir.join("abc"), "blob").unwrap();
stage_guest_image_payload(
&staging_dir,
&GuestImagePayload {
image_ref: "ghcr.io/example/app:latest".to_string(),
image_identity: prepared_image_cache_identity("sha256:abc"),
source: GuestImagePayloadSource::RegistryOciLayout { layout_dir },
},
)
.unwrap();
let image_dir = staging_dir.join("config").join(GUEST_IMAGE_CONFIG_DIR);
assert_eq!(
fs::read_to_string(image_dir.join("source")).unwrap(),
"oci-layout"
);
assert_eq!(
fs::read_to_string(image_dir.join("ref")).unwrap(),
"ghcr.io/example/app:latest"
);
assert_eq!(
fs::read_to_string(
image_dir
.join(GUEST_IMAGE_OCI_LAYOUT_DIR)
.join("blobs")
.join("sha256")
.join("abc")
)
.unwrap(),
"blob"
);
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn registry_layer_download_concurrency_is_bounded() {
assert_eq!(
registry_layer_download_concurrency_value(None),
DEFAULT_REGISTRY_LAYER_DOWNLOAD_CONCURRENCY
);
assert_eq!(
registry_layer_download_concurrency_value(Some("0")),
DEFAULT_REGISTRY_LAYER_DOWNLOAD_CONCURRENCY
);
assert_eq!(registry_layer_download_concurrency_value(Some("8")), 8);
assert_eq!(
registry_layer_download_concurrency_value(Some("999")),
MAX_REGISTRY_LAYER_DOWNLOAD_CONCURRENCY
);
}
#[test]
fn sanitize_image_identity_rewrites_path_separators() {
assert_eq!(
sanitize_image_identity("sha256:abc/def@ghi"),
"sha256-abc-def-ghi"
);
}
#[tokio::test]
async fn read_guest_tls_materials_reports_missing_input() {
let base = unique_temp_dir();
let source_dir = base.join("missing-source");
let err = read_guest_tls_materials(&VmDriverTlsPaths {
ca: source_dir.join("ca.crt"),
cert: source_dir.join("tls.crt"),
key: source_dir.join("tls.key"),
})
.await
.expect_err("missing TLS materials should fail before image injection");
assert!(err.contains("ca.crt"));
let _ = std::fs::remove_dir_all(base);
}
#[cfg(unix)]
#[test]
fn stage_guest_tls_materials_places_files_in_overlay_upper_with_private_key_mode() {
use std::os::unix::fs::PermissionsExt as _;
let base = unique_temp_dir();
let materials = GuestTlsMaterials {
ca: b"ca".to_vec(),
cert: b"cert".to_vec(),
key: b"key".to_vec(),
};
stage_guest_tls_materials(&base, &materials).expect("stage TLS materials");
assert_eq!(
fs::read(
base.join("upper")
.join(GUEST_TLS_CA_PATH.trim_start_matches('/'))
)
.unwrap(),
b"ca"
);
assert_eq!(
fs::read(
base.join("upper")
.join(GUEST_TLS_CERT_PATH.trim_start_matches('/'))
)
.unwrap(),
b"cert"
);
let key_path = base
.join("upper")
.join(GUEST_TLS_KEY_PATH.trim_start_matches('/'));
assert_eq!(fs::read(&key_path).unwrap(), b"key");
assert_eq!(
fs::metadata(&key_path).unwrap().permissions().mode() & 0o777,
0o600
);
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn subnet_allocator_assigns_and_releases() {
let mut alloc = SubnetAllocator::new(Ipv4Addr::new(10, 0, 128, 0), 17);
let s1 = alloc.allocate("sandbox-1").unwrap();
assert_eq!(s1.host_ip, Ipv4Addr::new(10, 0, 128, 1));
assert_eq!(s1.guest_ip, Ipv4Addr::new(10, 0, 128, 2));
assert_eq!(s1.prefix_len, 30);
let s2 = alloc.allocate("sandbox-2").unwrap();
assert_ne!(s1.host_ip, s2.host_ip);
alloc.release("sandbox-1");
let s3 = alloc.allocate("sandbox-3").unwrap();
assert!(s3.host_ip != s2.host_ip);
}
#[test]
fn tap_device_name_fits_ifnamsiz() {
let name = tap_device_name("sandbox-abc-def-ghi");
assert!(name.len() <= 15);
assert!(name.starts_with("vmtap-"));
}
#[test]
fn mac_address_is_locally_administered() {
let mac = mac_from_sandbox_id("test-sandbox");
assert_eq!(mac[0] & 0x02, 0x02);
assert_eq!(mac[0] & 0x01, 0x00);
}
#[test]
fn vsock_cid_monotonically_increases() {
let cid1 = allocate_vsock_cid();
let cid2 = allocate_vsock_cid();
assert!(cid2 > cid1);
}
fn unique_temp_dir() -> PathBuf {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let suffix = COUNTER.fetch_add(1, Ordering::Relaxed);
std::env::temp_dir().join(format!(
"openshell-vm-driver-test-{}-{nanos}-{suffix}",
std::process::id()
))
}
fn spawn_exited_child() -> Child {
Command::new("sh")
.arg("-c")
.arg("exit 0")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.unwrap()
}
async fn insert_test_record(
driver: &VmDriver,
sandbox_id: &str,
state_dir: PathBuf,
child: Child,
) {
let sandbox = Sandbox {
id: sandbox_id.to_string(),
name: sandbox_id.to_string(),
..Default::default()
};
let process = Arc::new(Mutex::new(VmProcess {
child,
deleting: false,
}));
let mut registry = driver.registry.lock().await;
registry.insert(
sandbox_id.to_string(),
SandboxRecord {
snapshot: sandbox,
state_dir,
process: Some(process),
provisioning_task: None,
gpu_bdf: None,
qemu_network_allocated: false,
deleting: false,
},
);
}
use crate::lifecycle::{
BackendFeature, LaunchPlan, LifecycleError, LifecycleExtension, LifecycleExtensionRegistry,
LifecycleResult,
};
use crate::runtime::VmBackend;
fn test_driver_with_extensions(extensions: LifecycleExtensionRegistry) -> VmDriver {
let (events, _) = broadcast::channel(WATCH_BUFFER);
VmDriver {
config: VmDriverConfig {
openshell_endpoint: "http://127.0.0.1:8080".to_string(),
vcpus: 2,
mem_mib: 2048,
gpu_vcpus: 8,
gpu_mem_mib: 16384,
..Default::default()
},
launcher_bin: PathBuf::from("openshell-driver-vm"),
registry: Arc::new(Mutex::new(HashMap::new())),
image_cache_lock: Arc::new(Mutex::new(())),
events,
gpu_inventory: None,
subnet_allocator: Arc::new(std::sync::Mutex::new(SubnetAllocator::new(
Ipv4Addr::new(10, 0, 128, 0),
17,
))),
lifecycle_extensions: Arc::new(extensions),
}
}
#[derive(Debug)]
struct QemuRequiringExtension {
name: String,
}
#[tonic::async_trait]
impl LifecycleExtension for QemuRequiringExtension {
fn name(&self) -> &str {
&self.name
}
async fn configure_launch(
&self,
_sandbox: &Sandbox,
_state_dir: &Path,
plan: &mut LaunchPlan,
) -> LifecycleResult<()> {
plan.require_backend(VmBackend::Qemu);
plan.require_backend_feature(BackendFeature::PciPassthrough);
Ok(())
}
async fn before_launch(
&self,
_sandbox: &Sandbox,
_state_dir: &Path,
plan: &mut LaunchPlan,
) -> LifecycleResult<()> {
if plan.backend != VmBackend::Qemu {
return Err(LifecycleError::new(
"qemu-requiring extension demands QEMU backend",
));
}
plan.env.push("EXT_DECLARED_QEMU=1".to_string());
Ok(())
}
}
#[derive(Debug)]
struct AlwaysFailsExtension;
#[tonic::async_trait]
impl LifecycleExtension for AlwaysFailsExtension {
fn name(&self) -> &'static str {
"always-fails"
}
async fn before_launch(
&self,
_sandbox: &Sandbox,
_state_dir: &Path,
_plan: &mut LaunchPlan,
) -> LifecycleResult<()> {
Err(LifecycleError::resource_exhausted("pool empty"))
}
}
#[test]
fn empty_registry_keeps_non_gpu_sandbox_on_libkrun() {
let driver = test_driver_with_extensions(LifecycleExtensionRegistry::new());
let plan = driver
.build_vm_launch_plan("sandbox-x", false, false, None)
.expect("plan should build");
assert_eq!(plan.backend, VmBackend::Libkrun);
assert_eq!(plan.vcpus, 2);
assert_eq!(plan.mem_mib, 2048);
assert!(plan.tap_device.is_none());
assert!(plan.guest_ip.is_none());
assert!(plan.host_ip.is_none());
assert!(plan.vsock_cid.is_none());
assert!(plan.guest_mac.is_none());
assert!(plan.gpu_bdf.is_none());
assert!(plan.env.is_empty());
}
#[test]
fn empty_registry_has_no_extension_descriptors() {
let driver = test_driver_with_extensions(LifecycleExtensionRegistry::new());
assert!(driver.lifecycle_extensions.descriptors().is_empty());
}
#[test]
fn gpu_sandbox_uses_qemu_backend_and_gpu_sizing() {
let driver = test_driver_with_extensions(LifecycleExtensionRegistry::new());
let plan = driver
.build_vm_launch_plan("sandbox-gpu", true, true, Some("0000:01:00.0".to_string()))
.expect("gpu plan should build");
assert_eq!(plan.backend, VmBackend::Qemu);
assert_eq!(plan.vcpus, 8);
assert_eq!(plan.mem_mib, 16384);
assert_eq!(plan.gpu_bdf.as_deref(), Some("0000:01:00.0"));
assert!(plan.tap_device.is_some());
assert!(plan.guest_ip.is_some());
assert!(plan.host_ip.is_some());
assert!(plan.vsock_cid.is_some());
assert!(plan.guest_mac.is_some());
}
#[test]
fn launch_plan_rejects_external_kernel_on_unsupported_backend() {
let mut plan = LaunchPlan {
backend: VmBackend::Libkrun,
vcpus: 2,
mem_mib: 2048,
required_backends: Vec::new(),
required_backend_features: Vec::new(),
kernel_profile: None,
kernel_image: Some(PathBuf::from("/tmp/openshell-test-kernel")),
gpu_bdf: None,
tap_device: None,
guest_ip: None,
host_ip: None,
vsock_cid: None,
guest_mac: None,
gateway_port: None,
guest_init_dropins: Vec::new(),
env: Vec::new(),
};
let err = VmDriver::validate_launch_plan_backend(false, &plan)
.expect_err("external kernels require a compatible backend");
assert_eq!(err.code(), Code::FailedPrecondition);
assert!(err.message().contains("external kernel images"));
let base = unique_temp_dir();
std::fs::create_dir_all(&base).unwrap();
let kernel = base.join("vmlinux");
std::fs::write(&kernel, b"kernel").unwrap();
plan.backend = VmBackend::Qemu;
plan.kernel_image = Some(kernel);
VmDriver::validate_launch_plan_backend(true, &plan).expect("existing kernel is accepted");
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn backend_feature_requirements_select_qemu_launch_plan() {
let driver = test_driver_with_extensions(LifecycleExtensionRegistry::new());
let mut plan = driver
.build_vm_launch_plan("sandbox-vfio", false, false, None)
.expect("base plan should build");
plan.require_backend_feature(BackendFeature::PciPassthrough);
driver
.resolve_launch_plan_backend("sandbox-vfio", false, None, &mut plan)
.expect("backend feature should resolve");
assert_eq!(plan.backend, VmBackend::Qemu);
assert!(plan.tap_device.is_some());
assert!(plan.guest_ip.is_some());
assert!(plan.host_ip.is_some());
assert!(plan.vsock_cid.is_some());
assert!(plan.guest_mac.is_some());
driver.release_subnet("sandbox-vfio");
}
#[test]
fn explicit_backend_requirement_selects_qemu_launch_plan() {
let driver = test_driver_with_extensions(LifecycleExtensionRegistry::new());
let mut plan = driver
.build_vm_launch_plan("sandbox-qemu", false, false, None)
.expect("base plan should build");
plan.require_backend(VmBackend::Qemu);
driver
.resolve_launch_plan_backend("sandbox-qemu", false, None, &mut plan)
.expect("backend requirement should resolve");
assert_eq!(plan.backend, VmBackend::Qemu);
assert!(plan.tap_device.is_some());
assert!(plan.guest_ip.is_some());
assert!(plan.host_ip.is_some());
driver.release_subnet("sandbox-qemu");
}
#[test]
fn guest_init_dropin_feature_does_not_force_qemu() {
let driver = test_driver_with_extensions(LifecycleExtensionRegistry::new());
let mut plan = driver
.build_vm_launch_plan("sandbox-init", false, false, None)
.expect("base plan should build");
plan.require_backend_feature(BackendFeature::GuestInitDropins);
driver
.resolve_launch_plan_backend("sandbox-init", false, None, &mut plan)
.expect("guest init feature should resolve");
assert_eq!(plan.backend, VmBackend::Libkrun);
assert!(plan.tap_device.is_none());
}
#[test]
fn guest_init_dropin_validation_rejects_unsafe_or_duplicate_names() {
validate_guest_init_dropins(&[GuestInitDropin::new("50-vfio.sh", b"true\n".to_vec())])
.expect("safe drop-in name is accepted");
let err = validate_guest_init_dropins(&[GuestInitDropin::new(
"../50-vfio.sh",
b"true\n".to_vec(),
)])
.expect_err("path traversal is rejected");
assert!(err.contains("must contain only ASCII"));
let err = validate_guest_init_dropins(&[
GuestInitDropin::new("50-vfio.sh", b"true\n".to_vec()),
GuestInitDropin::new("50-vfio.sh", b"true\n".to_vec()),
])
.expect_err("duplicate drop-ins are rejected");
assert!(err.contains("duplicate"));
}
#[test]
fn guest_init_dropin_manifest_lists_only_injected_names_sorted() {
let manifest = render_guest_init_dropin_manifest(&[
GuestInitDropin::new("50-vfio.sh", b"true\n".to_vec()),
GuestInitDropin::new("10-nemo.sh", b"true\n".to_vec()),
]);
assert_eq!(
String::from_utf8(manifest).unwrap(),
"10-nemo.sh\n50-vfio.sh\n"
);
}
#[test]
fn guest_init_dropin_manifest_is_empty_when_no_dropins() {
// An empty manifest is the fail-closed signal that nothing under
// init.d should run.
assert!(render_guest_init_dropin_manifest(&[]).is_empty());
}
#[tokio::test]
async fn extension_can_validate_backend_in_before_launch() {
let extension = Arc::new(QemuRequiringExtension {
name: "validate".to_string(),
});
let extensions = LifecycleExtensionRegistry::with(vec![extension.clone()]);
let sandbox = Sandbox {
id: "sandbox-validate".to_string(),
name: "sandbox-validate".to_string(),
..Default::default()
};
let mut libkrun_plan = LaunchPlan {
backend: VmBackend::Libkrun,
vcpus: 2,
mem_mib: 2048,
required_backends: Vec::new(),
required_backend_features: Vec::new(),
kernel_profile: None,
kernel_image: None,
gpu_bdf: None,
tap_device: None,
guest_ip: None,
host_ip: None,
vsock_cid: None,
guest_mac: None,
gateway_port: None,
guest_init_dropins: Vec::new(),
env: Vec::new(),
};
let err = extensions
.before_launch(&sandbox, Path::new("/tmp/state"), &mut libkrun_plan)
.await
.expect_err("backend mismatch should fail validation");
assert!(err.message().contains("demands QEMU"));
let mut qemu_plan = LaunchPlan {
backend: VmBackend::Qemu,
vcpus: 2,
mem_mib: 2048,
required_backends: Vec::new(),
required_backend_features: Vec::new(),
kernel_profile: None,
kernel_image: None,
gpu_bdf: None,
tap_device: Some("vmtap-x".to_string()),
guest_ip: Some("10.0.0.2".to_string()),
host_ip: Some("10.0.0.1".to_string()),
vsock_cid: Some(7),
guest_mac: Some("02:00:00:00:00:01".to_string()),
gateway_port: Some(8080),
guest_init_dropins: Vec::new(),
env: Vec::new(),
};
extensions
.before_launch(&sandbox, Path::new("/tmp/state"), &mut qemu_plan)
.await
.expect("QEMU backend should satisfy the extension");
assert!(qemu_plan.env.contains(&"EXT_DECLARED_QEMU=1".to_string()));
}
#[tokio::test]
async fn lifecycle_error_resource_exhausted_propagates() {
let extensions = LifecycleExtensionRegistry::with(vec![Arc::new(AlwaysFailsExtension)]);
let sandbox = Sandbox {
id: "sandbox-resource".to_string(),
name: "sandbox-resource".to_string(),
..Default::default()
};
let mut plan = LaunchPlan {
backend: VmBackend::Qemu,
vcpus: 2,
mem_mib: 2048,
required_backends: Vec::new(),
required_backend_features: Vec::new(),
kernel_profile: None,
kernel_image: None,
gpu_bdf: None,
tap_device: Some("vmtap-x".to_string()),
guest_ip: Some("10.0.0.2".to_string()),
host_ip: Some("10.0.0.1".to_string()),
vsock_cid: Some(7),
guest_mac: Some("02:00:00:00:00:01".to_string()),
gateway_port: Some(8080),
guest_init_dropins: Vec::new(),
env: Vec::new(),
};
let err = extensions
.before_launch(&sandbox, Path::new("/tmp/state"), &mut plan)
.await
.expect_err("scripted pool exhaustion should surface");
assert!(err.is_resource_exhausted());
assert_eq!(err.message(), "pool empty");
}
}