mirror of
https://github.com/NVIDIA/OpenShell.git
synced 2026-11-01 07:53:42 +08:00
3387 lines
118 KiB
Rust
3387 lines
118 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::rootfs::{
|
|
create_rootfs_archive_from_dir, extract_rootfs_archive_to,
|
|
prepare_sandbox_rootfs_from_image_root, sandbox_guest_init_path,
|
|
};
|
|
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};
|
|
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, OciDescriptor};
|
|
use oci_client::secrets::RegistryAuth;
|
|
use oci_client::{Reference, RegistryOperation};
|
|
use openshell_core::proto::compute::v1::{
|
|
CreateSandboxRequest, CreateSandboxResponse, DeleteSandboxRequest, DeleteSandboxResponse,
|
|
DriverCondition as SandboxCondition, DriverPlatformEvent as PlatformEvent,
|
|
DriverSandbox as Sandbox, DriverSandboxStatus as SandboxStatus, GetCapabilitiesRequest,
|
|
GetCapabilitiesResponse, GetSandboxRequest, GetSandboxResponse, ListSandboxesRequest,
|
|
ListSandboxesResponse, StopSandboxRequest, StopSandboxResponse, ValidateSandboxCreateRequest,
|
|
ValidateSandboxCreateResponse, WatchSandboxesDeletedEvent, WatchSandboxesEvent,
|
|
WatchSandboxesPlatformEvent, WatchSandboxesRequest, WatchSandboxesSandboxEvent,
|
|
compute_driver_server::ComputeDriver, watch_sandboxes_event,
|
|
};
|
|
use openshell_vfio::SysfsRoot;
|
|
use sha2::{Digest, Sha256};
|
|
use std::collections::{HashMap, HashSet};
|
|
use std::fs;
|
|
use std::io::Read;
|
|
use std::net::Ipv4Addr;
|
|
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_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;
|
|
/// 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;
|
|
/// * `host.docker.internal` works the same way for Docker-flavoured tooling.
|
|
///
|
|
/// 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 = "host.containers.internal";
|
|
const GUEST_SSH_SOCKET_PATH: &str = "/run/openshell/ssh.sock";
|
|
const GUEST_TLS_DIR: &str = "/opt/openshell/tls";
|
|
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 IMAGE_CACHE_ROOT_DIR: &str = "images";
|
|
const IMAGE_CACHE_ROOTFS_ARCHIVE: &str = "rootfs.tar";
|
|
const IMAGE_EXPORT_ROOTFS_ARCHIVE: &str = "source-rootfs.tar";
|
|
const IMAGE_IDENTITY_FILE: &str = "image-identity";
|
|
const IMAGE_REFERENCE_FILE: &str = "image-reference";
|
|
static IMAGE_CACHE_BUILD_COUNTER: AtomicU64 = AtomicU64::new(0);
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct VmDriverTlsPaths {
|
|
ca: PathBuf,
|
|
cert: PathBuf,
|
|
key: PathBuf,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct VmDriverConfig {
|
|
pub openshell_endpoint: String,
|
|
pub state_dir: PathBuf,
|
|
pub launcher_bin: Option<PathBuf>,
|
|
pub default_image: String,
|
|
pub ssh_handshake_secret: String,
|
|
pub ssh_handshake_skew_secs: u64,
|
|
pub log_level: String,
|
|
pub krun_log_level: u32,
|
|
pub vcpus: u8,
|
|
pub mem_mib: u32,
|
|
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,
|
|
}
|
|
|
|
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(),
|
|
ssh_handshake_secret: String::new(),
|
|
ssh_handshake_skew_secs: 300,
|
|
log_level: "info".to_string(),
|
|
krun_log_level: 1,
|
|
vcpus: DEFAULT_VCPUS,
|
|
mem_mib: DEFAULT_MEM_MIB,
|
|
guest_tls_ca: None,
|
|
guest_tls_cert: None,
|
|
guest_tls_key: None,
|
|
gpu_enabled: false,
|
|
gpu_mem_mib: 8192,
|
|
gpu_vcpus: 4,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl VmDriverConfig {
|
|
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,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct SandboxRecord {
|
|
snapshot: Sandbox,
|
|
state_dir: PathBuf,
|
|
process: Arc<Mutex<VmProcess>>,
|
|
gpu_bdf: Option<String>,
|
|
}
|
|
|
|
#[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>>,
|
|
}
|
|
|
|
impl VmDriver {
|
|
pub async fn new(config: VmDriverConfig) -> Result<Self, String> {
|
|
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);
|
|
tokio::fs::create_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);
|
|
Ok(Self {
|
|
config,
|
|
launcher_bin,
|
|
registry: Arc::new(Mutex::new(HashMap::new())),
|
|
image_cache_lock: Arc::new(Mutex::new(())),
|
|
events,
|
|
gpu_inventory,
|
|
subnet_allocator,
|
|
})
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn capabilities(&self) -> GetCapabilitiesResponse {
|
|
let gpu_count = self
|
|
.gpu_inventory
|
|
.as_ref()
|
|
.and_then(|inv| inv.lock().ok())
|
|
.map_or(0, |inv| inv.gpu_count());
|
|
GetCapabilitiesResponse {
|
|
driver_name: DRIVER_NAME.to_string(),
|
|
driver_version: openshell_core::VERSION.to_string(),
|
|
default_image: self.config.default_image.clone(),
|
|
supports_gpu: self.gpu_inventory.is_some(),
|
|
gpu_count,
|
|
}
|
|
}
|
|
|
|
// `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)?;
|
|
|
|
if self.registry.lock().await.contains_key(&sandbox.id) {
|
|
return Err(Status::already_exists("sandbox already exists"));
|
|
}
|
|
|
|
let spec = sandbox.spec.as_ref();
|
|
let is_gpu = spec.is_some_and(|s| s.gpu);
|
|
let gpu_device = spec.map_or("", |s| s.gpu_device.as_str());
|
|
|
|
let state_dir = sandbox_state_dir(&self.config.state_dir, &sandbox.id)?;
|
|
let rootfs = state_dir.join("rootfs");
|
|
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 rootfs"
|
|
);
|
|
|
|
tokio::fs::create_dir_all(&state_dir)
|
|
.await
|
|
.map_err(|err| Status::internal(format!("create state dir failed: {err}")))?;
|
|
|
|
let tls_paths = self
|
|
.config
|
|
.tls_paths()
|
|
.map_err(Status::failed_precondition)?;
|
|
// Mirror the K8s `Scheduled` event so the CLI can complete the
|
|
// "Requesting sandbox" step and switch the spinner over to the
|
|
// image-pull phase before we block on the registry.
|
|
self.publish_platform_event(
|
|
sandbox.id.clone(),
|
|
platform_event(
|
|
"vm",
|
|
"Normal",
|
|
"Scheduled",
|
|
format!("Sandbox accepted by vm driver to image \"{image_ref}\""),
|
|
),
|
|
);
|
|
|
|
let image_identity = match self
|
|
.prepare_runtime_rootfs(&sandbox.id, &image_ref, &rootfs)
|
|
.await
|
|
{
|
|
Ok(image_identity) => {
|
|
info!(
|
|
sandbox_id = %sandbox.id,
|
|
image_identity = %image_identity,
|
|
"vm driver: rootfs prepared"
|
|
);
|
|
image_identity
|
|
}
|
|
Err(err) => {
|
|
warn!(
|
|
sandbox_id = %sandbox.id,
|
|
error = %err.message(),
|
|
"vm driver: rootfs preparation failed"
|
|
);
|
|
let _ = tokio::fs::remove_dir_all(&state_dir).await;
|
|
return Err(err);
|
|
}
|
|
};
|
|
if let Some(tls_paths) = tls_paths.as_ref()
|
|
&& let Err(err) = prepare_guest_tls_materials(&rootfs, tls_paths).await
|
|
{
|
|
let _ = tokio::fs::remove_dir_all(&state_dir).await;
|
|
return Err(Status::internal(format!(
|
|
"prepare guest TLS materials failed: {err}"
|
|
)));
|
|
}
|
|
|
|
if let Err(err) =
|
|
write_sandbox_image_metadata(&state_dir, &image_ref, &image_identity).await
|
|
{
|
|
let _ = tokio::fs::remove_dir_all(&state_dir).await;
|
|
return Err(Status::internal(format!(
|
|
"write sandbox image metadata failed: {err}"
|
|
)));
|
|
}
|
|
|
|
let gpu_bdf = if is_gpu {
|
|
let inventory = self
|
|
.gpu_inventory
|
|
.as_ref()
|
|
.ok_or_else(|| Status::internal("GPU inventory not initialized"))?;
|
|
match inventory
|
|
.lock()
|
|
.map_err(|e| Status::internal(format!("GPU inventory lock poisoned: {e}")))
|
|
.and_then(|mut inv| {
|
|
inv.assign(&sandbox.id, gpu_device)
|
|
.map_err(Status::failed_precondition)
|
|
}) {
|
|
Ok(assignment) => {
|
|
tracing::info!(
|
|
sandbox_id = %sandbox.id,
|
|
bdf = %assignment.bdf,
|
|
gpu_name = %assignment.name,
|
|
iommu_group = assignment.iommu_group,
|
|
"assigned GPU to sandbox"
|
|
);
|
|
Some(assignment.bdf)
|
|
}
|
|
Err(err) => {
|
|
let _ = tokio::fs::remove_dir_all(&state_dir).await;
|
|
return Err(err);
|
|
}
|
|
}
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let console_output = state_dir.join("rootfs-console.log");
|
|
let mut command = Command::new(&self.launcher_bin);
|
|
command.stdin(Stdio::null());
|
|
command.stdout(Stdio::inherit());
|
|
command.stderr(Stdio::inherit());
|
|
command.arg("--internal-run-vm");
|
|
command.arg("--vm-rootfs").arg(&rootfs);
|
|
command.arg("--vm-exec").arg(sandbox_guest_init_path());
|
|
command.arg("--vm-workdir").arg("/");
|
|
command.arg("--vm-console-output").arg(&console_output);
|
|
|
|
// Compute the endpoint override before building the env so
|
|
// there is a single OPENSHELL_ENDPOINT value in the env list.
|
|
let endpoint_override = if let Some(bdf) = gpu_bdf.as_ref() {
|
|
let subnet = match self
|
|
.subnet_allocator
|
|
.lock()
|
|
.map_err(|e| Status::internal(format!("subnet allocator lock poisoned: {e}")))
|
|
.and_then(|mut alloc| {
|
|
alloc
|
|
.allocate(&sandbox.id)
|
|
.map_err(Status::failed_precondition)
|
|
}) {
|
|
Ok(s) => s,
|
|
Err(err) => {
|
|
self.release_gpu_and_subnet(&sandbox.id);
|
|
let _ = tokio::fs::remove_dir_all(&state_dir).await;
|
|
return Err(err);
|
|
}
|
|
};
|
|
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 tap_endpoint = guest_visible_openshell_endpoint_for_tap(
|
|
&self.config.openshell_endpoint,
|
|
&subnet.host_ip.to_string(),
|
|
);
|
|
|
|
command.arg("--vm-backend").arg("qemu");
|
|
command
|
|
.arg("--vm-vcpus")
|
|
.arg(self.config.gpu_vcpus.to_string());
|
|
command
|
|
.arg("--vm-mem-mib")
|
|
.arg(self.config.gpu_mem_mib.to_string());
|
|
command.arg("--vm-gpu-bdf").arg(bdf);
|
|
command.arg("--vm-tap-device").arg(&tap);
|
|
command
|
|
.arg("--vm-guest-ip")
|
|
.arg(subnet.guest_ip.to_string());
|
|
command.arg("--vm-host-ip").arg(subnet.host_ip.to_string());
|
|
command.arg("--vm-vsock-cid").arg(vsock_cid.to_string());
|
|
command.arg("--vm-guest-mac").arg(&mac_str);
|
|
|
|
if let Some(port) = gateway_port_from_endpoint(&self.config.openshell_endpoint) {
|
|
command.arg("--vm-gateway-port").arg(port.to_string());
|
|
}
|
|
|
|
Some(tap_endpoint)
|
|
} else {
|
|
command.arg("--vm-vcpus").arg(self.config.vcpus.to_string());
|
|
command
|
|
.arg("--vm-mem-mib")
|
|
.arg(self.config.mem_mib.to_string());
|
|
None
|
|
};
|
|
|
|
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);
|
|
}
|
|
|
|
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"
|
|
);
|
|
if gpu_bdf.is_some() {
|
|
self.release_gpu_and_subnet(&sandbox.id);
|
|
}
|
|
let _ = tokio::fs::remove_dir_all(&state_dir).await;
|
|
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"
|
|
);
|
|
// Mirror the K8s `Started` event so the CLI can complete the
|
|
// "Starting sandbox" step. The supervisor-ready transition still
|
|
// promotes the sandbox to `Ready` separately.
|
|
self.publish_platform_event(
|
|
sandbox.id.clone(),
|
|
platform_event("vm", "Normal", "Started", "Started VM launcher".to_string()),
|
|
);
|
|
let snapshot = sandbox_snapshot(sandbox, provisioning_condition(), false);
|
|
let process = Arc::new(Mutex::new(VmProcess {
|
|
child,
|
|
deleting: false,
|
|
}));
|
|
|
|
{
|
|
let mut registry = self.registry.lock().await;
|
|
registry.insert(
|
|
sandbox.id.clone(),
|
|
SandboxRecord {
|
|
snapshot: snapshot.clone(),
|
|
state_dir: state_dir.clone(),
|
|
process: process.clone(),
|
|
gpu_bdf: gpu_bdf.clone(),
|
|
},
|
|
);
|
|
}
|
|
|
|
self.publish_snapshot(snapshot.clone());
|
|
tokio::spawn({
|
|
let driver = self.clone();
|
|
let sandbox_id = sandbox.id.clone();
|
|
async move {
|
|
driver.monitor_sandbox(sandbox_id).await;
|
|
}
|
|
});
|
|
|
|
Ok(CreateSandboxResponse {})
|
|
}
|
|
|
|
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 = {
|
|
let registry = self.registry.lock().await;
|
|
if let Some((id, record)) = registry.get_key_value(sandbox_id) {
|
|
Some((
|
|
id.clone(),
|
|
record.state_dir.clone(),
|
|
record.process.clone(),
|
|
record.gpu_bdf.clone(),
|
|
))
|
|
} else {
|
|
let matched_id = registry
|
|
.iter()
|
|
.find(|(_, record)| record.snapshot.name == sandbox_name)
|
|
.map(|(id, _)| id.clone());
|
|
matched_id.and_then(|id| {
|
|
registry.get(&id).map(|record| {
|
|
(
|
|
id,
|
|
record.state_dir.clone(),
|
|
record.process.clone(),
|
|
record.gpu_bdf.clone(),
|
|
)
|
|
})
|
|
})
|
|
}
|
|
};
|
|
|
|
let Some((record_id, state_dir, process, gpu_bdf)) = record else {
|
|
return Ok(DeleteSandboxResponse { deleted: false });
|
|
};
|
|
|
|
if let Some(snapshot) = self
|
|
.set_snapshot_condition(&record_id, deleting_condition(), true)
|
|
.await
|
|
{
|
|
self.publish_snapshot(snapshot);
|
|
}
|
|
|
|
{
|
|
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}")))?;
|
|
}
|
|
|
|
if gpu_bdf.is_some() {
|
|
self.release_gpu_and_subnet(&record_id);
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
fn release_gpu_and_subnet(&self, sandbox_id: &str) {
|
|
if let Some(inventory) = self.gpu_inventory.as_ref()
|
|
&& let Ok(mut inv) = inventory.lock()
|
|
{
|
|
inv.release(sandbox_id);
|
|
}
|
|
if let Ok(mut alloc) = self.subnet_allocator.lock() {
|
|
alloc.release(sandbox_id);
|
|
}
|
|
}
|
|
|
|
async fn prepare_runtime_rootfs(
|
|
&self,
|
|
sandbox_id: &str,
|
|
image_ref: &str,
|
|
rootfs: &Path,
|
|
) -> Result<String, Status> {
|
|
let image_identity = self
|
|
.ensure_cached_image_rootfs_archive(sandbox_id, image_ref)
|
|
.await?;
|
|
let archive_path = image_cache_rootfs_archive(&self.config.state_dir, &image_identity);
|
|
let rootfs_dest = rootfs.to_path_buf();
|
|
tokio::task::spawn_blocking(move || extract_rootfs_archive_to(&archive_path, &rootfs_dest))
|
|
.await
|
|
.map_err(|err| Status::internal(format!("sandbox rootfs extraction panicked: {err}")))?
|
|
.map_err(|err| Status::internal(format!("extract sandbox rootfs failed: {err}")))?;
|
|
|
|
Ok(image_identity)
|
|
}
|
|
|
|
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_image_rootfs_archive(
|
|
&self,
|
|
sandbox_id: &str,
|
|
image_ref: &str,
|
|
) -> Result<String, Status> {
|
|
if let Some((docker, image_identity)) = self.resolve_local_docker_image(image_ref).await? {
|
|
return self
|
|
.ensure_cached_local_image_rootfs_archive(
|
|
sandbox_id,
|
|
image_ref,
|
|
&docker,
|
|
&image_identity,
|
|
)
|
|
.await;
|
|
}
|
|
|
|
info!(image_ref = %image_ref, "vm driver: ensuring cached image rootfs archive (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");
|
|
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");
|
|
let 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 = %image_identity,
|
|
"vm driver: manifest digest resolved"
|
|
);
|
|
let archive_path = image_cache_rootfs_archive(&self.config.state_dir, &image_identity);
|
|
|
|
// Mirror the K8s `Pulling` event so the CLI flips to the
|
|
// image-pull spinner with the image name as detail. We emit it
|
|
// for cache hits too and immediately follow with `Pulled` so the
|
|
// spinner 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(&archive_path).await.is_ok() {
|
|
info!(
|
|
image_identity = %image_identity,
|
|
archive_path = %archive_path.display(),
|
|
"vm driver: image rootfs archive cache hit (no build needed)"
|
|
);
|
|
self.publish_pulled_event(sandbox_id, image_ref, &archive_path)
|
|
.await;
|
|
return Ok(image_identity);
|
|
}
|
|
|
|
info!(
|
|
image_identity = %image_identity,
|
|
"vm driver: image rootfs archive cache miss, acquiring build lock"
|
|
);
|
|
let _cache_guard = self.image_cache_lock.lock().await;
|
|
info!(
|
|
image_identity = %image_identity,
|
|
"vm driver: build lock acquired"
|
|
);
|
|
if tokio::fs::metadata(&archive_path).await.is_ok() {
|
|
info!(
|
|
image_identity = %image_identity,
|
|
"vm driver: image rootfs archive cache hit after lock (built by another task)"
|
|
);
|
|
self.publish_pulled_event(sandbox_id, image_ref, &archive_path)
|
|
.await;
|
|
return Ok(image_identity);
|
|
}
|
|
|
|
self.build_cached_registry_image_rootfs_archive(
|
|
sandbox_id,
|
|
&client,
|
|
&reference,
|
|
&auth,
|
|
image_ref,
|
|
&image_identity,
|
|
)
|
|
.await?;
|
|
self.publish_pulled_event(sandbox_id, image_ref, &archive_path)
|
|
.await;
|
|
Ok(image_identity)
|
|
}
|
|
|
|
async fn resolve_local_docker_image(
|
|
&self,
|
|
image_ref: &str,
|
|
) -> Result<Option<(Docker, String)>, Status> {
|
|
let required_local_image = is_openshell_local_build_image_ref(image_ref);
|
|
let docker = match Docker::connect_with_local_defaults() {
|
|
Ok(docker) => docker,
|
|
Err(err) if required_local_image => {
|
|
return Err(Status::failed_precondition(format!(
|
|
"failed to connect to local Docker daemon for locally built sandbox image '{image_ref}': {err}"
|
|
)));
|
|
}
|
|
Err(err) => {
|
|
warn!(
|
|
image_ref = %image_ref,
|
|
error = %err,
|
|
"vm driver: local Docker daemon unavailable, falling back to registry"
|
|
);
|
|
return Ok(None);
|
|
}
|
|
};
|
|
|
|
match docker.inspect_image(image_ref).await {
|
|
Ok(inspect) => {
|
|
if let Some(message) = local_docker_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 Docker 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 Docker image '{image_ref}' inspect response has no image ID"
|
|
))
|
|
})?;
|
|
info!(
|
|
image_ref = %image_ref,
|
|
image_identity = %image_identity,
|
|
"vm driver: resolved image from local Docker daemon"
|
|
);
|
|
Ok(Some((docker, 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 Docker daemon"
|
|
)))
|
|
}
|
|
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 Docker image inspection failed, falling back to registry"
|
|
);
|
|
Ok(None)
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn ensure_cached_local_image_rootfs_archive(
|
|
&self,
|
|
sandbox_id: &str,
|
|
image_ref: &str,
|
|
docker: &Docker,
|
|
image_identity: &str,
|
|
) -> Result<String, Status> {
|
|
let archive_path = image_cache_rootfs_archive(&self.config.state_dir, image_identity);
|
|
|
|
self.publish_platform_event(
|
|
sandbox_id.to_string(),
|
|
platform_event(
|
|
"vm",
|
|
"Normal",
|
|
"Pulling",
|
|
format!("Pulling image \"{image_ref}\""),
|
|
),
|
|
);
|
|
|
|
if tokio::fs::metadata(&archive_path).await.is_ok() {
|
|
self.publish_pulled_event(sandbox_id, image_ref, &archive_path)
|
|
.await;
|
|
return Ok(image_identity.to_string());
|
|
}
|
|
|
|
let _cache_guard = self.image_cache_lock.lock().await;
|
|
if tokio::fs::metadata(&archive_path).await.is_ok() {
|
|
self.publish_pulled_event(sandbox_id, image_ref, &archive_path)
|
|
.await;
|
|
return Ok(image_identity.to_string());
|
|
}
|
|
|
|
self.build_cached_local_image_rootfs_archive(docker, image_ref, image_identity)
|
|
.await?;
|
|
self.publish_pulled_event(sandbox_id, image_ref, &archive_path)
|
|
.await;
|
|
Ok(image_identity.to_string())
|
|
}
|
|
|
|
async fn build_cached_local_image_rootfs_archive(
|
|
&self,
|
|
docker: &Docker,
|
|
image_ref: &str,
|
|
image_identity: &str,
|
|
) -> Result<(), Status> {
|
|
let cache_dir = image_cache_dir(&self.config.state_dir, image_identity);
|
|
let archive_path = image_cache_rootfs_archive(&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_archive = staging_dir.join(IMAGE_CACHE_ROOTFS_ARCHIVE);
|
|
|
|
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}"))
|
|
})?;
|
|
|
|
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 prepared_archive_for_build = prepared_archive.clone();
|
|
let build_result = tokio::task::spawn_blocking(move || {
|
|
prepare_exported_rootfs_archive(
|
|
&image_ref_owned,
|
|
&image_identity_owned,
|
|
&exported_rootfs_for_build,
|
|
&prepared_rootfs_for_build,
|
|
&prepared_archive_for_build,
|
|
)
|
|
})
|
|
.await
|
|
.map_err(|err| Status::internal(format!("local image preparation 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(&archive_path).await.is_ok() {
|
|
let _ = tokio::fs::remove_dir_all(&staging_dir).await;
|
|
return Ok(());
|
|
}
|
|
|
|
tokio::fs::rename(&prepared_archive, &archive_path)
|
|
.await
|
|
.map_err(|err| Status::internal(format!("store cached image rootfs failed: {err}")))?;
|
|
let _ = tokio::fs::remove_dir_all(&staging_dir).await;
|
|
Ok(())
|
|
}
|
|
|
|
async fn build_cached_registry_image_rootfs_archive(
|
|
&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 archive_path = image_cache_rootfs_archive(&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_archive = staging_dir.join(IMAGE_CACHE_ROOTFS_ARCHIVE);
|
|
|
|
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 archive"
|
|
);
|
|
|
|
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 prepared_archive_for_build = prepared_archive.clone();
|
|
let build_result = tokio::task::spawn_blocking(move || {
|
|
prepare_sandbox_rootfs_from_image_root(
|
|
&prepared_rootfs_for_build,
|
|
&image_identity_owned,
|
|
)
|
|
.map_err(|err| {
|
|
format!("vm sandbox image '{image_ref_owned}' is not base-compatible: {err}")
|
|
})?;
|
|
create_rootfs_archive_from_dir(&prepared_rootfs_for_build, &prepared_archive_for_build)
|
|
})
|
|
.await
|
|
.map_err(|err| Status::internal(format!("image rootfs preparation panicked: {err}")))?;
|
|
|
|
if let Err(err) = build_result {
|
|
warn!(
|
|
image_ref = %image_ref,
|
|
error = %err,
|
|
"vm driver: rootfs archive build failed"
|
|
);
|
|
let _ = tokio::fs::remove_dir_all(&staging_dir).await;
|
|
return Err(Status::failed_precondition(err));
|
|
}
|
|
|
|
if tokio::fs::metadata(&archive_path).await.is_ok() {
|
|
info!(
|
|
image_identity = %image_identity,
|
|
"vm driver: another task wrote archive while we were building, discarding ours"
|
|
);
|
|
let _ = tokio::fs::remove_dir_all(&staging_dir).await;
|
|
return Ok(());
|
|
}
|
|
|
|
tokio::fs::rename(&prepared_archive, &archive_path)
|
|
.await
|
|
.map_err(|err| Status::internal(format!("store cached image rootfs failed: {err}")))?;
|
|
info!(
|
|
image_identity = %image_identity,
|
|
archive_path = %archive_path.display(),
|
|
"vm driver: image rootfs archive 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;
|
|
};
|
|
record.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 = {
|
|
let registry = self.registry.lock().await;
|
|
registry
|
|
.get(&sandbox_id)
|
|
.and_then(|r| r.gpu_bdf.as_ref())
|
|
.is_some()
|
|
};
|
|
if has_gpu {
|
|
self.release_gpu_and_subnet(&sandbox_id);
|
|
}
|
|
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),
|
|
},
|
|
)),
|
|
});
|
|
}
|
|
}
|
|
|
|
#[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")]
|
|
#[allow(unsafe_code)] // libc::geteuid is a thin syscall wrapper
|
|
fn check_gpu_privileges() -> Result<(), String> {
|
|
if unsafe { libc::geteuid() } != 0 {
|
|
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 spec.gpu && !gpu_enabled {
|
|
return Err(Status::failed_precondition(
|
|
"GPU support is not enabled on this driver; start with --gpu",
|
|
));
|
|
}
|
|
|
|
if !spec.gpu && !spec.gpu_device.is_empty() {
|
|
return Err(Status::invalid_argument("gpu_device requires gpu=true"));
|
|
}
|
|
|
|
if let Some(template) = spec.template.as_ref() {
|
|
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",
|
|
));
|
|
}
|
|
if template.resources.is_some() {
|
|
return Err(Status::failed_precondition(
|
|
"vm sandboxes do not support template.resources",
|
|
));
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[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}"
|
|
))
|
|
})
|
|
}
|
|
|
|
fn is_openshell_local_build_image_ref(image_ref: &str) -> bool {
|
|
image_ref.starts_with("openshell/sandbox-from:")
|
|
}
|
|
|
|
fn local_docker_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 prepare_exported_rootfs_archive(
|
|
image_ref: &str,
|
|
image_identity: &str,
|
|
exported_rootfs: &Path,
|
|
prepared_rootfs: &Path,
|
|
prepared_archive: &Path,
|
|
) -> Result<(), String> {
|
|
extract_rootfs_archive_to(exported_rootfs, prepared_rootfs)?;
|
|
prepare_sandbox_rootfs_from_image_root(prepared_rootfs, image_identity)
|
|
.map_err(|err| format!("vm sandbox image '{image_ref}' is not base-compatible: {err}"))?;
|
|
create_rootfs_archive_from_dir(prepared_rootfs, prepared_archive)
|
|
}
|
|
|
|
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();
|
|
for (index, layer) in manifest.layers.iter().enumerate() {
|
|
// Emit a per-layer progress event so the CLI can show
|
|
// "Layer 3/8 (12.4 MB)" as detail under the spinner.
|
|
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;
|
|
self.publish_platform_event(sandbox_id.to_string(), event);
|
|
|
|
pull_registry_layer(
|
|
client,
|
|
reference,
|
|
image_ref,
|
|
staging_dir,
|
|
rootfs,
|
|
layer,
|
|
index,
|
|
)
|
|
.await?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Emit a `Pulled` platform event with a message that mirrors the
|
|
/// kubelet's `Successfully pulled image ... Image size: N bytes.`
|
|
/// format so the CLI's `extract_image_size` parser works unchanged.
|
|
async fn publish_pulled_event(&self, sandbox_id: &str, image_ref: &str, archive_path: &Path) {
|
|
let size_suffix = tokio::fs::metadata(archive_path).await.map_or_else(
|
|
|_| String::new(),
|
|
|meta| format!(" Image size: {} bytes.", meta.len()),
|
|
);
|
|
self.publish_platform_event(
|
|
sandbox_id.to_string(),
|
|
platform_event(
|
|
"vm",
|
|
"Normal",
|
|
"Pulled",
|
|
format!("Successfully pulled image \"{image_ref}\".{size_suffix}"),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
async fn pull_registry_layer(
|
|
client: &OciClient,
|
|
reference: &Reference,
|
|
image_ref: &str,
|
|
staging_dir: &Path,
|
|
rootfs: &Path,
|
|
layer: &OciDescriptor,
|
|
index: usize,
|
|
) -> Result<(), 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 rootfs_for_unpack = rootfs.to_path_buf();
|
|
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)?;
|
|
apply_layer_dir_to_rootfs(&layer_root_for_unpack, &rootfs_for_unpack)
|
|
})
|
|
.await
|
|
.map_err(|err| Status::internal(format!("layer extraction panicked: {err}")))?
|
|
.map_err(|err| {
|
|
Status::failed_precondition(format!(
|
|
"failed to apply layer '{}' for vm sandbox image '{image_ref}': {err}",
|
|
layer.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 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.containers.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 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,
|
|
);
|
|
let mut environment = HashMap::from([
|
|
("HOME".to_string(), "/root".to_string()),
|
|
(
|
|
"PATH".to_string(),
|
|
"/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin".to_string(),
|
|
),
|
|
("TERM".to_string(), "xterm".to_string()),
|
|
("OPENSHELL_ENDPOINT".to_string(), openshell_endpoint),
|
|
("OPENSHELL_SANDBOX_ID".to_string(), sandbox.id.clone()),
|
|
("OPENSHELL_SANDBOX".to_string(), sandbox.name.clone()),
|
|
(
|
|
"OPENSHELL_SSH_SOCKET_PATH".to_string(),
|
|
GUEST_SSH_SOCKET_PATH.to_string(),
|
|
),
|
|
(
|
|
"OPENSHELL_SANDBOX_COMMAND".to_string(),
|
|
"tail -f /dev/null".to_string(),
|
|
),
|
|
(
|
|
"OPENSHELL_LOG_LEVEL".to_string(),
|
|
sandbox_log_level(sandbox, &config.log_level),
|
|
),
|
|
(
|
|
"OPENSHELL_SSH_HANDSHAKE_SECRET".to_string(),
|
|
config.ssh_handshake_secret.clone(),
|
|
),
|
|
]);
|
|
if config.requires_tls_materials() {
|
|
environment.extend(HashMap::from([
|
|
(
|
|
"OPENSHELL_TLS_CA".to_string(),
|
|
GUEST_TLS_CA_PATH.to_string(),
|
|
),
|
|
(
|
|
"OPENSHELL_TLS_CERT".to_string(),
|
|
GUEST_TLS_CERT_PATH.to_string(),
|
|
),
|
|
(
|
|
"OPENSHELL_TLS_KEY".to_string(),
|
|
GUEST_TLS_KEY_PATH.to_string(),
|
|
),
|
|
]));
|
|
}
|
|
environment.extend(merged_environment(sandbox));
|
|
|
|
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 sandbox_log_level(sandbox: &Sandbox, default_level: &str) -> String {
|
|
sandbox
|
|
.spec
|
|
.as_ref()
|
|
.map(|spec| spec.log_level.as_str())
|
|
.filter(|level| !level.is_empty())
|
|
.unwrap_or(default_level)
|
|
.to_string()
|
|
}
|
|
|
|
fn sandboxes_root_dir(root: &Path) -> PathBuf {
|
|
root.join("sandboxes")
|
|
}
|
|
|
|
#[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))
|
|
}
|
|
|
|
#[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_archive(root: &Path, image_identity: &str) -> PathBuf {
|
|
image_cache_dir(root, image_identity).join(IMAGE_CACHE_ROOTFS_ARCHIVE)
|
|
}
|
|
|
|
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 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}", current_time_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 prepare_guest_tls_materials(
|
|
rootfs: &Path,
|
|
paths: &VmDriverTlsPaths,
|
|
) -> Result<(), std::io::Error> {
|
|
let guest_tls_dir = rootfs.join(GUEST_TLS_DIR.trim_start_matches('/'));
|
|
tokio::fs::create_dir_all(&guest_tls_dir).await?;
|
|
|
|
copy_guest_tls_material(&paths.ca, &guest_tls_dir.join("ca.crt"), 0o644).await?;
|
|
copy_guest_tls_material(&paths.cert, &guest_tls_dir.join("tls.crt"), 0o644).await?;
|
|
copy_guest_tls_material(&paths.key, &guest_tls_dir.join("tls.key"), 0o600).await?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn copy_guest_tls_material(
|
|
source: &Path,
|
|
dest: &Path,
|
|
mode: u32,
|
|
) -> Result<(), std::io::Error> {
|
|
tokio::fs::copy(source, dest).await?;
|
|
tokio::fs::set_permissions(dest, fs::Permissions::from_mode(mode)).await?;
|
|
Ok(())
|
|
}
|
|
|
|
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 {
|
|
PlatformEvent {
|
|
timestamp_ms: current_time_ms(),
|
|
source: source.to_string(),
|
|
r#type: event_type.to_string(),
|
|
reason: reason.to_string(),
|
|
message,
|
|
metadata: HashMap::new(),
|
|
}
|
|
}
|
|
|
|
fn current_time_ms() -> i64 {
|
|
std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.map_or(0, |duration| {
|
|
i64::try_from(duration.as_millis()).unwrap_or(i64::MAX)
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::gpu::{SubnetAllocator, allocate_vsock_cid, mac_from_sandbox_id, tap_device_name};
|
|
use openshell_core::proto::compute::v1::{
|
|
DriverSandboxSpec as SandboxSpec, DriverSandboxTemplate as SandboxTemplate,
|
|
};
|
|
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;
|
|
|
|
#[test]
|
|
fn validate_vm_sandbox_rejects_gpu_when_not_enabled() {
|
|
let sandbox = Sandbox {
|
|
id: "sandbox-123".to_string(),
|
|
spec: Some(SandboxSpec {
|
|
gpu: true,
|
|
..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_accepts_gpu_when_enabled() {
|
|
let sandbox = Sandbox {
|
|
id: "sandbox-123".to_string(),
|
|
spec: Some(SandboxSpec {
|
|
gpu: true,
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
};
|
|
validate_vm_sandbox(&sandbox, true).expect("gpu should be accepted when enabled");
|
|
}
|
|
|
|
#[test]
|
|
fn validate_vm_sandbox_rejects_gpu_device_without_gpu() {
|
|
let sandbox = Sandbox {
|
|
id: "sandbox-123".to_string(),
|
|
spec: Some(SandboxSpec {
|
|
gpu: false,
|
|
gpu_device: "0000:2d:00.0".to_string(),
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
};
|
|
let err = validate_vm_sandbox(&sandbox, true)
|
|
.expect_err("gpu_device without gpu should be rejected");
|
|
assert_eq!(err.code(), Code::InvalidArgument);
|
|
assert!(err.message().contains("gpu_device requires gpu=true"));
|
|
}
|
|
|
|
#[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_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 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,
|
|
))),
|
|
};
|
|
|
|
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,
|
|
))),
|
|
};
|
|
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,
|
|
))),
|
|
};
|
|
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,
|
|
))),
|
|
};
|
|
let sandbox = Sandbox {
|
|
spec: Some(SandboxSpec {
|
|
template: Some(SandboxTemplate::default()),
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
};
|
|
|
|
assert!(driver.resolved_sandbox_image(&sandbox).is_none());
|
|
}
|
|
|
|
#[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(),
|
|
ssh_handshake_secret: "secret".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, 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(&format!(
|
|
"OPENSHELL_SSH_SOCKET_PATH={GUEST_SSH_SOCKET_PATH}"
|
|
)));
|
|
assert!(
|
|
env.contains(&"OPENSHELL_SSH_HANDSHAKE_SECRET=secret".to_string()),
|
|
"SSH handshake secret must be passed to the guest"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn build_guest_environment_uses_endpoint_override_for_tap() {
|
|
let config = VmDriverConfig {
|
|
openshell_endpoint: "http://127.0.0.1:8080".to_string(),
|
|
ssh_handshake_secret: "secret".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_docker_image_platform_mismatch_checks_guest_platform() {
|
|
assert!(
|
|
local_docker_image_platform_mismatch(
|
|
"openshell/sandbox-from:123",
|
|
Some("linux"),
|
|
Some(linux_oci_arch()),
|
|
)
|
|
.is_none()
|
|
);
|
|
|
|
let err = local_docker_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_docker_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(),
|
|
ssh_handshake_secret: "secret".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,
|
|
))),
|
|
};
|
|
|
|
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 = 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 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 prepare_exported_rootfs_archive_rewrites_docker_exported_rootfs() {
|
|
let base = unique_temp_dir();
|
|
let source_rootfs = base.join("source-rootfs");
|
|
let exported_rootfs = base.join("exported-rootfs.tar");
|
|
let prepared_rootfs = base.join("prepared-rootfs");
|
|
let prepared_archive = base.join("prepared-rootfs.tar");
|
|
let extracted = base.join("extracted");
|
|
|
|
for path in [
|
|
"bin/bash",
|
|
"bin/mount",
|
|
"bin/sed",
|
|
"sbin/ip",
|
|
"opt/openshell/bin/openshell-sandbox",
|
|
] {
|
|
let path = source_rootfs.join(path);
|
|
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
|
fs::write(path, "").unwrap();
|
|
}
|
|
|
|
create_rootfs_archive_from_dir(&source_rootfs, &exported_rootfs).unwrap();
|
|
prepare_exported_rootfs_archive(
|
|
"openshell/sandbox-from:123",
|
|
"sha256:local-image",
|
|
&exported_rootfs,
|
|
&prepared_rootfs,
|
|
&prepared_archive,
|
|
)
|
|
.unwrap();
|
|
extract_rootfs_archive_to(&prepared_archive, &extracted).unwrap();
|
|
|
|
assert!(extracted.join("srv/openshell-vm-sandbox-init.sh").is_file());
|
|
assert!(
|
|
extracted
|
|
.join("opt/openshell/bin/openshell-sandbox")
|
|
.is_file()
|
|
);
|
|
assert_eq!(
|
|
fs::read_to_string(extracted.join("opt/openshell/.rootfs-type")).unwrap(),
|
|
"sandbox\n"
|
|
);
|
|
assert!(
|
|
fs::read_to_string(extracted.join(".openshell-rootfs-variant"))
|
|
.unwrap()
|
|
.contains("sha256:local-image")
|
|
);
|
|
|
|
let _ = fs::remove_dir_all(base);
|
|
}
|
|
|
|
#[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 prepare_guest_tls_materials_copies_bundle_into_rootfs() {
|
|
let base = unique_temp_dir();
|
|
let source_dir = base.join("source");
|
|
let rootfs = base.join("rootfs");
|
|
std::fs::create_dir_all(&source_dir).unwrap();
|
|
std::fs::create_dir_all(&rootfs).unwrap();
|
|
|
|
let ca = source_dir.join("ca.crt");
|
|
let cert = source_dir.join("tls.crt");
|
|
let key = source_dir.join("tls.key");
|
|
std::fs::write(&ca, "ca").unwrap();
|
|
std::fs::write(&cert, "cert").unwrap();
|
|
std::fs::write(&key, "key").unwrap();
|
|
|
|
prepare_guest_tls_materials(
|
|
&rootfs,
|
|
&VmDriverTlsPaths {
|
|
ca: ca.clone(),
|
|
cert: cert.clone(),
|
|
key: key.clone(),
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let guest_dir = rootfs.join(GUEST_TLS_DIR.trim_start_matches('/'));
|
|
assert_eq!(
|
|
std::fs::read_to_string(guest_dir.join("ca.crt")).unwrap(),
|
|
"ca"
|
|
);
|
|
assert_eq!(
|
|
std::fs::read_to_string(guest_dir.join("tls.crt")).unwrap(),
|
|
"cert"
|
|
);
|
|
assert_eq!(
|
|
std::fs::read_to_string(guest_dir.join("tls.key")).unwrap(),
|
|
"key"
|
|
);
|
|
let key_mode = std::fs::metadata(guest_dir.join("tls.key"))
|
|
.unwrap()
|
|
.permissions()
|
|
.mode()
|
|
& 0o777;
|
|
assert_eq!(key_mode, 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,
|
|
gpu_bdf: None,
|
|
},
|
|
);
|
|
}
|
|
}
|