diff --git a/crates/openshell-driver-vm/runtime/kernel/openshell.kconfig b/crates/openshell-driver-vm/runtime/kernel/openshell.kconfig index 4249e71121..1427cca123 100644 --- a/crates/openshell-driver-vm/runtime/kernel/openshell.kconfig +++ b/crates/openshell-driver-vm/runtime/kernel/openshell.kconfig @@ -15,6 +15,10 @@ CONFIG_VIRTIO_BLK=y CONFIG_EXT4_FS=y CONFIG_EXT4_USE_FOR_EXT2=y +# Host-directory sharing via virtiofs (used for VM driver bind mounts). +CONFIG_FUSE_FS=y +CONFIG_VIRTIO_FS=y + # Cgroups used for process supervision and resource limits. CONFIG_CGROUPS=y CONFIG_CGROUP_DEVICE=y diff --git a/crates/openshell-driver-vm/scripts/openshell-vm-sandbox-init.sh b/crates/openshell-driver-vm/scripts/openshell-vm-sandbox-init.sh index 0f63316195..d27b761097 100644 --- a/crates/openshell-driver-vm/scripts/openshell-vm-sandbox-init.sh +++ b/crates/openshell-driver-vm/scripts/openshell-vm-sandbox-init.sh @@ -538,6 +538,35 @@ run_openshell_init_dropins() { done < <(LC_ALL=C sort -u "$manifest") } +mount_virtiofs_shares() { + local manifest + manifest="$(root_path /.openshell/mounts.manifest)" + [ -f "$manifest" ] || return 0 + + ts "mounting virtiofs shares" + local tag target mode mount_opts guest_target + while IFS=$'\t' read -r tag target mode; do + [ -n "$tag" ] || continue + case "$mode" in + ro) mount_opts="-o ro" ;; + rw) mount_opts="" ;; + *) + ts "FATAL: unknown virtiofs mount mode '${mode}' for tag ${tag}" + exit 1 + ;; + esac + guest_target="$(root_path "$target")" + mkdir -p "$guest_target" 2>/dev/null || true + # shellcheck disable=SC2086 + if mount -t virtiofs $mount_opts "$tag" "$guest_target"; then + ts " mounted virtiofs ${tag} -> ${target} (${mode})" + else + ts "FATAL: failed to mount virtiofs ${tag} at ${target}" + exit 1 + fi + done < "$manifest" +} + run_post_overlay_setup() { # Source QEMU-injected environment variables if present. The file lives in # the overlay upperdir so the cached bootstrap rootfs remains immutable. @@ -563,6 +592,8 @@ run_post_overlay_setup() { mount -t cgroup2 cgroup2 "$(root_path /sys/fs/cgroup)" 2>/dev/null & wait + mount_virtiofs_shares + reconcile_sandbox_account setup_sandbox_workdir diff --git a/crates/openshell-driver-vm/src/driver.rs b/crates/openshell-driver-vm/src/driver.rs index 1ad10f654b..b480d031e1 100644 --- a/crates/openshell-driver-vm/src/driver.rs +++ b/crates/openshell-driver-vm/src/driver.rs @@ -36,6 +36,7 @@ use oci_client::manifest::{ use oci_client::secrets::RegistryAuth; use oci_client::{Reference, RegistryOperation}; use openshell_core::UpstreamProxyConfig; +use openshell_core::driver_mounts; use openshell_core::gpu::{ driver_gpu_requirements, effective_driver_gpu_count, validate_specific_gpu_device_request, }; @@ -110,6 +111,19 @@ const DEFAULT_ROOTFS_TAR_MAX_BYTES: u64 = 10 * 1024 * 1024 * 1024; const ROOTFS_TAR_STAGING_DIR: &str = "rootfs-tar-staging"; const VM_CONSOLE_DIAGNOSTIC_BYTES: u64 = 8 * 1024; +#[derive(Debug, Clone, serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct VmMountConfig { + source: String, + target: String, + #[serde(default = "default_read_only")] + read_only: bool, +} + +fn default_read_only() -> bool { + false +} + #[derive(Debug, Clone, Default, serde::Deserialize)] #[serde(default, deny_unknown_fields)] struct VmSandboxDriverConfig { @@ -119,6 +133,8 @@ struct VmSandboxDriverConfig { )] gpu_device_ids: Option>, rootfs_tar_path: Option, + #[serde(default)] + mounts: Vec, } impl VmSandboxDriverConfig { @@ -287,6 +303,9 @@ pub struct VmDriverConfig { /// Maximum rootfs tar file size in bytes. Defaults to 10 GiB. #[serde(default, skip_serializing_if = "Option::is_none")] pub rootfs_tar_max_bytes: Option, + + #[serde(default)] + pub enable_bind_mounts: bool, } /// Redacting `Debug` so a proxy URL or credential path never reaches a log. @@ -348,6 +367,7 @@ impl std::fmt::Debug for VmDriverConfig { ) .field("rootfs_tar_staging_dir", &self.rootfs_tar_staging_dir) .field("rootfs_tar_max_bytes", &self.rootfs_tar_max_bytes) + .field("enable_bind_mounts", &self.enable_bind_mounts) .finish() } } @@ -383,6 +403,7 @@ impl Default for VmDriverConfig { sandbox_gid: None, rootfs_tar_staging_dir: None, rootfs_tar_max_bytes: None, + enable_bind_mounts: false, } } } @@ -1343,6 +1364,11 @@ impl VmDriver { }; let needs_qemu = is_gpu; + validate_vm_driver_mounts( + &driver_config.mounts, + self.config.enable_bind_mounts, + needs_qemu, + )?; let mut plan = match self.build_vm_launch_plan(&sandbox.id, needs_qemu, is_gpu, gpu_bdf.clone()) { @@ -1511,6 +1537,10 @@ impl VmDriver { &channel_tls, ) .map_err(|error| Status::internal(format!("inject VM boundary configuration: {error}")))?; + if !driver_config.mounts.is_empty() { + inject_guest_mount_manifest(&overlay_disk, &driver_config.mounts) + .map_err(|error| Status::internal(format!("inject VM mount manifest: {error}")))?; + } write_private_file( &state_dir.join(HOST_BOUNDARY_GENERATION_FILE), boundary_generation.as_bytes().to_vec(), @@ -1573,6 +1603,13 @@ impl VmDriver { for env in sandbox_owner_state.guest_environment() { command.arg("--vm-env").arg(env); } + for (i, mount) in driver_config.mounts.iter().enumerate() { + let tag = virtiofs_tag(i); + let mode = if mount.read_only { "ro" } else { "rw" }; + command + .arg("--vm-mount") + .arg(format!("{}\t{}\t{tag}\t{mode}", mount.source, mount.target)); + } info!( sandbox_id = %sandbox.id, @@ -3638,6 +3675,10 @@ impl VmDriver { .await .map_err(|err| Status::internal(format!("failed to wait for image-prep vm: {err}")))?; if status.success() { + let console = tokio::fs::read_to_string(&console_output) + .await + .unwrap_or_default(); + info!(console = %console, "image-prep vm completed successfully"); return Ok(()); } let console = tokio::fs::read_to_string(&console_output) @@ -4465,6 +4506,100 @@ fn validate_vm_sandbox_template(template: &SandboxTemplate) -> Result<(), Status Ok(()) } +const VM_RESERVED_GUEST_PATHS: &[&str] = &[ + "/.openshell", + "/.openshell-bootstrap", + "/overlay", + "/lower", + "/newroot", + "/image-cache", + "/srv", + "/proc", + "/sys", + "/dev", + "/tmp", +]; + +const VIRTIOFS_TAG_PREFIX: &str = "osfs"; + +fn virtiofs_tag(index: usize) -> String { + format!("{VIRTIOFS_TAG_PREFIX}{index}") +} + +fn validate_no_control_chars(value: &str, field: &str) -> Result<(), String> { + if value.chars().any(char::is_control) { + return Err(format!("{field} must not contain control characters")); + } + Ok(()) +} + +#[allow(clippy::result_large_err)] +fn validate_vm_driver_mounts( + mounts: &[VmMountConfig], + enable_bind_mounts: bool, + is_qemu: bool, +) -> Result<(), Status> { + if mounts.is_empty() { + return Ok(()); + } + if is_qemu { + return Err(Status::failed_precondition( + "virtiofs mounts are not supported with the QEMU backend", + )); + } + if !enable_bind_mounts { + return Err(Status::failed_precondition( + "vm bind mounts require enable_bind_mounts = true in the VM driver configuration", + )); + } + let mut targets = HashSet::new(); + for mount in mounts { + driver_mounts::validate_absolute_mount_source(&mount.source, "vm mount source") + .map_err(Status::invalid_argument)?; + validate_no_control_chars(&mount.source, "vm mount source") + .map_err(Status::invalid_argument)?; + let source_path = Path::new(&mount.source); + if !source_path.exists() { + return Err(Status::failed_precondition(format!( + "vm mount source path does not exist: {}", + mount.source + ))); + } + if !source_path.is_dir() { + return Err(Status::invalid_argument(format!( + "vm mount source must be a directory, not a file: {}", + mount.source + ))); + } + driver_mounts::validate_container_mount_target(&mount.target) + .map_err(Status::invalid_argument)?; + let normalized = driver_mounts::normalize_mount_target(&mount.target); + driver_mounts::validate_workspace_mount_target( + &normalized, + driver_mounts::DEFAULT_WORKSPACE_ROOT, + ) + .map_err(Status::invalid_argument)?; + for reserved in VM_RESERVED_GUEST_PATHS { + let reserved_path = Path::new(reserved); + let target_path = Path::new(&normalized); + if driver_mounts::path_is_or_under(target_path, reserved_path) + || driver_mounts::path_is_or_under(reserved_path, target_path) + { + return Err(Status::invalid_argument(format!( + "vm mount target '{}' conflicts with VM-internal path '{reserved}'", + mount.target + ))); + } + } + if !targets.insert(normalized.clone()) { + return Err(Status::invalid_argument(format!( + "duplicate vm driver_config mount target '{normalized}'" + ))); + } + } + Ok(()) +} + #[allow(clippy::result_large_err)] fn validate_gpu_request(sandbox: &Sandbox, gpu_enabled: bool) -> Result<(), Status> { let spec = sandbox @@ -6335,6 +6470,22 @@ fn inject_guest_boundary_bundle( Ok(()) } +fn inject_guest_mount_manifest( + overlay_disk: &Path, + mounts: &[VmMountConfig], +) -> Result<(), String> { + let mut manifest = String::new(); + for (i, mount) in mounts.iter().enumerate() { + let tag = virtiofs_tag(i); + let mode = if mount.read_only { "ro" } else { "rw" }; + writeln!(manifest, "{tag}\t{}\t{mode}", mount.target).expect("write to String cannot fail"); + } + let guest_path = overlay_upper_path("/.openshell/mounts.manifest"); + write_rootfs_image_file(overlay_disk, &guest_path, manifest.as_bytes())?; + set_rootfs_image_file_mode(overlay_disk, &guest_path, 0o644)?; + Ok(()) +} + fn guest_boundary_config_path(generation: &str) -> String { format!("{GUEST_BOUNDARY_CONFIG_DIR}/bootstrap-{generation}.json") } @@ -10672,4 +10823,171 @@ mod tests { assert!(gpu.default_selection_supported); assert!(gpu.count_selection_supported); } + + // ── VM mount config tests ────────────────────────────────────────── + + #[test] + fn vm_mount_config_deserializes_with_default_readwrite() { + let json = serde_json::json!({ + "mounts": [{"source": "/host/data", "target": "/sandbox/data"}] + }); + let config: VmSandboxDriverConfig = serde_json::from_value(json).unwrap(); + assert_eq!(config.mounts.len(), 1); + assert_eq!(config.mounts[0].source, "/host/data"); + assert_eq!(config.mounts[0].target, "/sandbox/data"); + assert!(!config.mounts[0].read_only); + } + + #[test] + fn vm_mount_config_deserializes_explicit_readwrite() { + let json = serde_json::json!({ + "mounts": [{"source": "/host/data", "target": "/sandbox/data", "read_only": false}] + }); + let config: VmSandboxDriverConfig = serde_json::from_value(json).unwrap(); + assert!(!config.mounts[0].read_only); + } + + #[test] + fn vm_mount_validation_requires_enable_bind_mounts() { + let dir = std::env::temp_dir(); + let mounts = vec![VmMountConfig { + source: dir.display().to_string(), + target: "/sandbox/data".to_string(), + read_only: true, + }]; + let err = validate_vm_driver_mounts(&mounts, false, false).unwrap_err(); + assert_eq!(err.code(), Code::FailedPrecondition); + assert!(err.message().contains("enable_bind_mounts")); + } + + #[test] + fn vm_mount_validation_allows_when_enabled() { + let dir = std::env::temp_dir(); + let mounts = vec![VmMountConfig { + source: dir.display().to_string(), + target: "/sandbox/data".to_string(), + read_only: true, + }]; + assert!(validate_vm_driver_mounts(&mounts, true, false).is_ok()); + } + + #[test] + fn vm_mount_validation_rejects_relative_source() { + let mounts = vec![VmMountConfig { + source: "relative/path".to_string(), + target: "/sandbox/data".to_string(), + read_only: true, + }]; + let err = validate_vm_driver_mounts(&mounts, true, false).unwrap_err(); + assert_eq!(err.code(), Code::InvalidArgument); + } + + #[test] + fn vm_mount_validation_rejects_reserved_openshell_target() { + let dir = std::env::temp_dir(); + let mounts = vec![VmMountConfig { + source: dir.display().to_string(), + target: "/opt/openshell/data".to_string(), + read_only: true, + }]; + let err = validate_vm_driver_mounts(&mounts, true, false).unwrap_err(); + assert_eq!(err.code(), Code::InvalidArgument); + } + + #[test] + fn vm_mount_validation_rejects_vm_internal_paths() { + let dir = std::env::temp_dir(); + for reserved in VM_RESERVED_GUEST_PATHS { + let mounts = vec![VmMountConfig { + source: dir.display().to_string(), + target: reserved.to_string(), + read_only: true, + }]; + let err = validate_vm_driver_mounts(&mounts, true, false).unwrap_err(); + assert_eq!( + err.code(), + Code::InvalidArgument, + "expected rejection for target {reserved}" + ); + assert!( + err.message().contains("VM-internal path"), + "expected VM-internal path message for {reserved}, got: {}", + err.message() + ); + } + } + + #[test] + fn vm_mount_validation_rejects_duplicate_targets() { + let dir = std::env::temp_dir(); + let mounts = vec![ + VmMountConfig { + source: dir.display().to_string(), + target: "/sandbox/data".to_string(), + read_only: true, + }, + VmMountConfig { + source: dir.display().to_string(), + target: "/sandbox/data".to_string(), + read_only: false, + }, + ]; + let err = validate_vm_driver_mounts(&mounts, true, false).unwrap_err(); + assert_eq!(err.code(), Code::InvalidArgument); + assert!(err.message().contains("duplicate")); + } + + #[test] + fn vm_mount_validation_empty_passes() { + assert!(validate_vm_driver_mounts(&[], false, false).is_ok()); + } + + #[test] + fn vm_mount_validation_rejects_qemu_backend() { + let dir = std::env::temp_dir(); + let mounts = vec![VmMountConfig { + source: dir.display().to_string(), + target: "/sandbox/data".to_string(), + read_only: true, + }]; + let err = validate_vm_driver_mounts(&mounts, true, true).unwrap_err(); + assert_eq!(err.code(), Code::FailedPrecondition); + assert!(err.message().contains("QEMU")); + } + + #[test] + fn vm_mount_validation_rejects_file_source() { + let file = tempfile::NamedTempFile::new().unwrap(); + let mounts = vec![VmMountConfig { + source: file.path().display().to_string(), + target: "/sandbox/data".to_string(), + read_only: true, + }]; + let err = validate_vm_driver_mounts(&mounts, true, false).unwrap_err(); + assert_eq!(err.code(), Code::InvalidArgument); + assert!(err.message().contains("must be a directory")); + } + + #[test] + fn vm_mount_manifest_renders_tab_separated_entries() { + let mounts = [ + VmMountConfig { + source: "/host/a".to_string(), + target: "/sandbox/a".to_string(), + read_only: true, + }, + VmMountConfig { + source: "/host/b".to_string(), + target: "/sandbox/b".to_string(), + read_only: false, + }, + ]; + let mut manifest = String::new(); + for (i, mount) in mounts.iter().enumerate() { + let tag = virtiofs_tag(i); + let mode = if mount.read_only { "ro" } else { "rw" }; + writeln!(manifest, "{tag}\t{}\t{mode}", mount.target).unwrap(); + } + assert_eq!(manifest, "osfs0\t/sandbox/a\tro\nosfs1\t/sandbox/b\trw\n"); + } } diff --git a/crates/openshell-driver-vm/src/ffi.rs b/crates/openshell-driver-vm/src/ffi.rs index f84ea35743..4e4c554b58 100644 --- a/crates/openshell-driver-vm/src/ffi.rs +++ b/crates/openshell-driver-vm/src/ffi.rs @@ -54,6 +54,14 @@ type KrunDisableImplicitVsock = unsafe extern "C" fn(ctx_id: u32) -> i32; type KrunAddVsock = unsafe extern "C" fn(ctx_id: u32, tsi_features: u32) -> i32; type KrunAddVsockPort2 = unsafe extern "C" fn(ctx_id: u32, port: u32, filepath: *const c_char, listen: bool) -> i32; +type KrunAddVirtiofs4 = unsafe extern "C" fn( + ctx_id: u32, + tag: *const c_char, + path: *const c_char, + shm_size: u64, + read_only: bool, + semantics: u32, +) -> i32; // Field names mirror the libkrun C API symbol names (`krun_*`); preserving // the prefix keeps the FFI binding 1:1 with the upstream library. @@ -72,6 +80,7 @@ pub struct LibKrun { pub krun_disable_implicit_vsock: KrunDisableImplicitVsock, pub krun_add_vsock: KrunAddVsock, pub krun_add_vsock_port2: KrunAddVsockPort2, + pub krun_add_virtiofs4: KrunAddVirtiofs4, } static LIBKRUN: OnceLock = OnceLock::new(); @@ -134,6 +143,7 @@ impl LibKrun { )?, krun_add_vsock: load_symbol(library, b"krun_add_vsock\0", &libkrun_path)?, krun_add_vsock_port2: load_symbol(library, b"krun_add_vsock_port2\0", &libkrun_path)?, + krun_add_virtiofs4: load_symbol(library, b"krun_add_virtiofs4\0", &libkrun_path)?, }) } } diff --git a/crates/openshell-driver-vm/src/lib.rs b/crates/openshell-driver-vm/src/lib.rs index 8e3f41a15d..267944909d 100644 --- a/crates/openshell-driver-vm/src/lib.rs +++ b/crates/openshell-driver-vm/src/lib.rs @@ -42,5 +42,6 @@ pub use lifecycle::{ }; #[cfg(feature = "compute-driver")] pub use runtime::{ - VM_RUNTIME_DIR_ENV, VmBackend, VmLaunchConfig, VsockPortMap, configured_runtime_dir, run_vm, + VM_RUNTIME_DIR_ENV, VmBackend, VmLaunchConfig, VmMount, VsockPortMap, configured_runtime_dir, + run_vm, }; diff --git a/crates/openshell-driver-vm/src/main.rs b/crates/openshell-driver-vm/src/main.rs index 65f37eae56..32501f678e 100644 --- a/crates/openshell-driver-vm/src/main.rs +++ b/crates/openshell-driver-vm/src/main.rs @@ -9,7 +9,7 @@ use openshell_core::proto::compute::v1::compute_driver_server::ComputeDriverServ #[cfg(target_os = "macos")] use openshell_driver_vm::{VM_RUNTIME_DIR_ENV, configured_runtime_dir}; use openshell_driver_vm::{ - VmBackend, VmDriver, VmDriverConfig, VmLaunchConfig, VsockPortMap, procguard, run_vm, + VmBackend, VmDriver, VmDriverConfig, VmLaunchConfig, VmMount, VsockPortMap, procguard, run_vm, }; use std::io; use std::net::SocketAddr; @@ -215,6 +215,9 @@ struct Args { #[arg(long, env = "OPENSHELL_VM_PROXY_CA_BUNDLE")] proxy_ca_bundle: Option, + #[arg(long, env = "OPENSHELL_VM_ENABLE_BIND_MOUNTS", default_value_t = false)] + enable_bind_mounts: bool, + #[arg(long, env = "OPENSHELL_VM_ROOTFS_TAR_STAGING_DIR")] rootfs_tar_staging_dir: Option, @@ -235,6 +238,9 @@ struct Args { #[arg(long, hide = true)] vm_vsock_control_socket: Option, + + #[arg(long, hide = true)] + vm_mount: Vec, } #[tokio::main] @@ -308,6 +314,7 @@ async fn main() -> Result<()> { sandbox_gid: args.sandbox_gid, rootfs_tar_staging_dir: args.rootfs_tar_staging_dir.clone(), rootfs_tar_max_bytes: args.rootfs_tar_max_bytes, + enable_bind_mounts: args.enable_bind_mounts, }) .await .map_err(|err| miette::miette!("{err}"))?; @@ -594,6 +601,12 @@ fn build_vm_launch_config(args: &Args) -> std::result::Result return Err(format!("unknown VM backend: {other}")), }; + let mounts = args + .vm_mount + .iter() + .map(|m| parse_vm_mount_arg(m)) + .collect::, _>>()?; + Ok(VmLaunchConfig { root_disk, overlay_disk, @@ -627,6 +640,38 @@ fn build_vm_launch_config(args: &Args) -> std::result::Result std::result::Result { + let mut parts = arg.splitn(4, '\t'); + let source = parts + .next() + .ok_or_else(|| format!("invalid --vm-mount format: {arg}"))?; + let target = parts + .next() + .ok_or_else(|| format!("invalid --vm-mount format (missing target): {arg}"))?; + let tag = parts + .next() + .ok_or_else(|| format!("invalid --vm-mount format (missing tag): {arg}"))?; + let mode = parts + .next() + .ok_or_else(|| format!("invalid --vm-mount format (missing mode): {arg}"))?; + let read_only = match mode { + "ro" => true, + "rw" => false, + _ => { + return Err(format!( + "invalid --vm-mount mode '{mode}': expected 'ro' or 'rw'" + )); + } + }; + Ok(VmMount { + host_path: PathBuf::from(source), + guest_target: target.to_string(), + tag: tag.to_string(), + read_only, }) } @@ -686,7 +731,7 @@ fn maybe_reexec_internal_vm_with_runtime_env() -> Result<()> { mod tests { use super::{ Args, ComputeDriverListenMode, PeerCredentials, authorize_peer_credentials, - compute_driver_listen_mode, + compute_driver_listen_mode, parse_vm_mount_arg, }; use clap::Parser; use std::path::PathBuf; @@ -926,4 +971,31 @@ mod tests { } ); } + + #[test] + fn parse_vm_mount_arg_parses_readonly() { + let m = parse_vm_mount_arg("/host/src\t/guest/dst\tosfs0\tro").unwrap(); + assert_eq!(m.host_path, PathBuf::from("/host/src")); + assert_eq!(m.guest_target, "/guest/dst"); + assert_eq!(m.tag, "osfs0"); + assert!(m.read_only); + } + + #[test] + fn parse_vm_mount_arg_parses_readwrite() { + let m = parse_vm_mount_arg("/host/src\t/guest/dst\tosfs1\trw").unwrap(); + assert!(!m.read_only); + } + + #[test] + fn parse_vm_mount_arg_rejects_unknown_mode() { + let err = parse_vm_mount_arg("/host/src\t/guest/dst\tosfs0\treadonly").unwrap_err(); + assert!(err.contains("expected 'ro' or 'rw'")); + } + + #[test] + fn parse_vm_mount_arg_rejects_missing_fields() { + assert!(parse_vm_mount_arg("/host/src\t/guest/dst").is_err()); + assert!(parse_vm_mount_arg("/host/src").is_err()); + } } diff --git a/crates/openshell-driver-vm/src/runtime.rs b/crates/openshell-driver-vm/src/runtime.rs index 4d0680bffe..95bde0ebfd 100644 --- a/crates/openshell-driver-vm/src/runtime.rs +++ b/crates/openshell-driver-vm/src/runtime.rs @@ -32,6 +32,15 @@ pub struct VsockPortMap { pub host_initiated: bool, } +/// A host directory shared into the VM guest via virtiofs. +#[derive(Debug, Clone)] +pub struct VmMount { + pub tag: String, + pub host_path: PathBuf, + pub guest_target: String, + pub read_only: bool, +} + pub struct VmLaunchConfig { pub root_disk: PathBuf, pub overlay_disk: PathBuf, @@ -49,6 +58,7 @@ pub struct VmLaunchConfig { pub gpu_bdf: Option, pub vsock_cid: Option, pub vsock_port_map: Option, + pub mounts: Vec, } pub fn run_vm(config: &VmLaunchConfig) -> Result<(), String> { @@ -59,6 +69,9 @@ pub fn run_vm(config: &VmLaunchConfig) -> Result<(), String> { } fn run_qemu_vm(config: &VmLaunchConfig) -> Result<(), String> { + if !config.mounts.is_empty() { + return Err("virtiofs mounts are not yet supported with the QEMU backend".to_string()); + } let gpu_bdf = config .gpu_bdf .as_deref() @@ -336,6 +349,10 @@ fn run_libkrun_vm(config: &VmLaunchConfig) -> Result<(), String> { vm.add_vsock_port(port_map)?; } + for mount in &config.mounts { + vm.add_virtiofs(&mount.tag, &mount.host_path, mount.read_only)?; + } + vm.set_console_output(&config.console_output)?; let env = libkrun_guest_env(config); @@ -661,6 +678,25 @@ impl VmContext { ) } + fn add_virtiofs(&self, tag: &str, host_path: &Path, read_only: bool) -> Result<(), String> { + const SEMANTICS_SIMPLIFIED: u32 = 1; + let tag_c = CString::new(tag).map_err(|e| format!("invalid virtiofs tag: {e}"))?; + let path_c = path_to_cstring(host_path)?; + check( + unsafe { + (self.krun.krun_add_virtiofs4)( + self.ctx_id, + tag_c.as_ptr(), + path_c.as_ptr(), + 0, + read_only, + SEMANTICS_SIMPLIFIED, + ) + }, + "krun_add_virtiofs4", + ) + } + fn start_enter(&self) -> i32 { unsafe { (self.krun.krun_start_enter)(self.ctx_id) } } @@ -777,6 +813,7 @@ mod tests { gpu_bdf: Some("0000:01:00.0".to_string()), vsock_cid: Some(4), vsock_port_map: None, + mounts: Vec::new(), } } diff --git a/crates/openshell-gateway/src/vm.rs b/crates/openshell-gateway/src/vm.rs index 07735c7a9c..216fb87047 100644 --- a/crates/openshell-gateway/src/vm.rs +++ b/crates/openshell-gateway/src/vm.rs @@ -126,6 +126,10 @@ pub struct VmComputeConfig { pub provider_spiffe_workload_api_tcp_endpoint: Option, #[serde(default)] pub provider_spiffe_allow_guest_tcp: bool, + + /// Allow bind-mounting host directories into VM sandboxes. + #[serde(default)] + pub enable_bind_mounts: bool, } impl VmComputeConfig { @@ -251,6 +255,7 @@ impl Default for VmComputeConfig { proxy_ca_bundle: None, provider_spiffe_workload_api_tcp_endpoint: None, provider_spiffe_allow_guest_tcp: false, + enable_bind_mounts: false, } } } @@ -585,6 +590,9 @@ pub async fn spawn( command.arg("--guest-tls-key").arg(tls.key); } append_vm_proxy_and_spiffe_args(&mut command, vm_config); + if vm_config.enable_bind_mounts { + command.arg("--enable-bind-mounts"); + } let mut child = command.spawn().map_err(|e| { Error::execution(format!( diff --git a/docs/reference/gateway-config.mdx b/docs/reference/gateway-config.mdx index 75599ae19f..5764babca7 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -962,6 +962,9 @@ overlay_disk_mib = 4096 # the exposure; host-only sockets are never exposed automatically. # provider_spiffe_workload_api_tcp_endpoint = "tcp:192.0.2.10:8081" # provider_spiffe_allow_guest_tcp = true +# Unsafe operator override. Host bind mounts expose gateway-host paths inside +# VM sandboxes and can negate OpenShell isolation and filesystem controls. +# enable_bind_mounts = false # Where the gateway stages rootfs tar archives for `--from ./rootfs.tar`. # Defaults to /rootfs-tar-staging. The gateway creates one # request-scoped subdirectory per staging slot and removes it after use.