mirror of
https://github.com/NVIDIA/OpenShell.git
synced 2026-11-07 08:55:37 +08:00
feat(vm): add openshell-vm crate with libkrun microVM gateway (#611)
This commit is contained in:
@@ -0,0 +1,442 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! Embedded VM runtime resources.
|
||||
//!
|
||||
//! Native libraries (libkrun, libkrunfw, gvproxy) and the rootfs are embedded as
|
||||
//! zstd-compressed byte arrays and extracted to XDG cache directories on first use.
|
||||
//!
|
||||
//! Cache locations:
|
||||
//! - Runtime: `~/.local/share/openshell/vm-runtime/{version}/`
|
||||
//! - Rootfs: `~/.local/share/openshell/openshell-vm/{version}/instances/<name>/rootfs/`
|
||||
|
||||
use std::fs;
|
||||
use std::io::{Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use indicatif::{ProgressBar, ProgressStyle};
|
||||
|
||||
use crate::VmError;
|
||||
|
||||
// ── Platform-specific embedded resources ───────────────────────────────────
|
||||
|
||||
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
|
||||
mod resources {
|
||||
pub const LIBKRUN: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/libkrun.dylib.zst"));
|
||||
pub const LIBKRUNFW: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/libkrunfw.5.dylib.zst"));
|
||||
pub const GVPROXY: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/gvproxy.zst"));
|
||||
pub const ROOTFS: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/rootfs.tar.zst"));
|
||||
pub const LIBKRUN_NAME: &str = "libkrun.dylib";
|
||||
pub const LIBKRUNFW_NAME: &str = "libkrunfw.5.dylib";
|
||||
}
|
||||
|
||||
#[cfg(all(target_os = "linux", target_arch = "aarch64"))]
|
||||
mod resources {
|
||||
pub const LIBKRUN: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/libkrun.so.zst"));
|
||||
pub const LIBKRUNFW: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/libkrunfw.so.5.zst"));
|
||||
pub const GVPROXY: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/gvproxy.zst"));
|
||||
pub const ROOTFS: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/rootfs.tar.zst"));
|
||||
pub const LIBKRUN_NAME: &str = "libkrun.so";
|
||||
pub const LIBKRUNFW_NAME: &str = "libkrunfw.so.5";
|
||||
}
|
||||
|
||||
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
|
||||
mod resources {
|
||||
pub const LIBKRUN: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/libkrun.so.zst"));
|
||||
pub const LIBKRUNFW: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/libkrunfw.so.5.zst"));
|
||||
pub const GVPROXY: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/gvproxy.zst"));
|
||||
pub const ROOTFS: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/rootfs.tar.zst"));
|
||||
pub const LIBKRUN_NAME: &str = "libkrun.so";
|
||||
pub const LIBKRUNFW_NAME: &str = "libkrunfw.so.5";
|
||||
}
|
||||
|
||||
// Fallback for unsupported platforms (will fail at runtime)
|
||||
#[cfg(not(any(
|
||||
all(target_os = "macos", target_arch = "aarch64"),
|
||||
all(target_os = "linux", target_arch = "aarch64"),
|
||||
all(target_os = "linux", target_arch = "x86_64"),
|
||||
)))]
|
||||
mod resources {
|
||||
pub const LIBKRUN: &[u8] = &[];
|
||||
pub const LIBKRUNFW: &[u8] = &[];
|
||||
pub const GVPROXY: &[u8] = &[];
|
||||
pub const ROOTFS: &[u8] = &[];
|
||||
pub const LIBKRUN_NAME: &str = "libkrun";
|
||||
pub const LIBKRUNFW_NAME: &str = "libkrunfw";
|
||||
}
|
||||
|
||||
const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
// ── Public API ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Ensures the embedded VM runtime is extracted to the cache directory.
|
||||
///
|
||||
/// Returns the path to the runtime directory containing:
|
||||
/// - libkrun.{dylib,so}
|
||||
/// - libkrunfw.{5.dylib,.so.5}
|
||||
/// - gvproxy
|
||||
///
|
||||
/// On first call, this extracts the compressed embedded resources to the cache.
|
||||
/// Subsequent calls return the cached path if valid.
|
||||
pub fn ensure_runtime_extracted() -> Result<PathBuf, VmError> {
|
||||
// Check if embedded resources are available (non-empty)
|
||||
if resources::LIBKRUN.is_empty() {
|
||||
return Err(VmError::HostSetup(
|
||||
"VM runtime not embedded for this platform. \
|
||||
Supported: macOS ARM64, Linux ARM64, Linux x86_64"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let cache_dir = runtime_cache_dir()?;
|
||||
let version_marker = cache_dir.join(".version");
|
||||
|
||||
// Cache key: version + content fingerprint (so dev builds at 0.0.0
|
||||
// still invalidate when the embedded libraries change).
|
||||
let cache_key = runtime_cache_key();
|
||||
|
||||
// Check if already extracted with the correct cache key
|
||||
if version_marker.exists()
|
||||
&& let Ok(cached_key) = fs::read_to_string(&version_marker)
|
||||
&& cached_key.trim() == cache_key
|
||||
{
|
||||
// Validate files exist
|
||||
if validate_runtime_dir(&cache_dir).is_ok() {
|
||||
tracing::debug!(
|
||||
path = %cache_dir.display(),
|
||||
"Using cached VM runtime"
|
||||
);
|
||||
return Ok(cache_dir);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up old versions before extracting new one
|
||||
cleanup_old_versions(&cache_dir)?;
|
||||
|
||||
// Create fresh directory
|
||||
if cache_dir.exists() {
|
||||
fs::remove_dir_all(&cache_dir)
|
||||
.map_err(|e| VmError::HostSetup(format!("remove old cache: {e}")))?;
|
||||
}
|
||||
fs::create_dir_all(&cache_dir)
|
||||
.map_err(|e| VmError::HostSetup(format!("create cache dir: {e}")))?;
|
||||
|
||||
tracing::info!(
|
||||
path = %cache_dir.display(),
|
||||
version = VERSION,
|
||||
"Extracting embedded VM runtime"
|
||||
);
|
||||
|
||||
// Extract all resources
|
||||
extract_resource(resources::LIBKRUN, &cache_dir.join(resources::LIBKRUN_NAME))?;
|
||||
extract_resource(
|
||||
resources::LIBKRUNFW,
|
||||
&cache_dir.join(resources::LIBKRUNFW_NAME),
|
||||
)?;
|
||||
extract_resource(resources::GVPROXY, &cache_dir.join("gvproxy"))?;
|
||||
|
||||
// Make gvproxy executable
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(cache_dir.join("gvproxy"), fs::Permissions::from_mode(0o755))
|
||||
.map_err(|e| VmError::HostSetup(format!("chmod gvproxy: {e}")))?;
|
||||
}
|
||||
|
||||
// Write version marker (includes content fingerprint for cache invalidation)
|
||||
fs::write(&version_marker, runtime_cache_key())
|
||||
.map_err(|e| VmError::HostSetup(format!("write version marker: {e}")))?;
|
||||
|
||||
tracing::info!(
|
||||
path = %cache_dir.display(),
|
||||
"VM runtime extracted successfully"
|
||||
);
|
||||
|
||||
Ok(cache_dir)
|
||||
}
|
||||
|
||||
/// Returns the path where the runtime would be cached (without extracting).
|
||||
pub fn runtime_cache_path() -> Result<PathBuf, VmError> {
|
||||
runtime_cache_dir()
|
||||
}
|
||||
|
||||
/// Extract the embedded rootfs to the given destination directory.
|
||||
///
|
||||
/// If the destination already exists, it is returned as-is (no re-extraction).
|
||||
/// Otherwise the embedded `rootfs.tar.zst` is decompressed and unpacked into `dest`.
|
||||
///
|
||||
/// A `.version` marker is written after successful extraction so that
|
||||
/// version-mismatched rootfs directories are detected and rebuilt.
|
||||
pub fn extract_rootfs_to(dest: &Path) -> Result<(), VmError> {
|
||||
if resources::ROOTFS.is_empty() {
|
||||
return Err(VmError::HostSetup(
|
||||
"Rootfs not embedded. Build with: mise run vm:build:embedded".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let version_marker = dest.join(".version");
|
||||
|
||||
// Already extracted with the correct version — nothing to do.
|
||||
if version_marker.exists()
|
||||
&& let Ok(cached_version) = fs::read_to_string(&version_marker)
|
||||
&& cached_version.trim() == VERSION
|
||||
{
|
||||
tracing::debug!(
|
||||
path = %dest.display(),
|
||||
"Using cached rootfs"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Remove existing if present (version mismatch or incomplete extraction).
|
||||
if dest.exists() {
|
||||
eprintln!("Removing outdated rootfs at {}...", dest.display());
|
||||
fs::remove_dir_all(dest)
|
||||
.map_err(|e| VmError::HostSetup(format!("remove old rootfs: {e}")))?;
|
||||
}
|
||||
|
||||
// Extract with progress bar.
|
||||
extract_rootfs_with_progress(resources::ROOTFS, dest)?;
|
||||
|
||||
// Write version marker.
|
||||
fs::write(&version_marker, VERSION)
|
||||
.map_err(|e| VmError::HostSetup(format!("write rootfs version marker: {e}")))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Clean up rootfs directories from older versions.
|
||||
///
|
||||
/// Call this periodically (e.g. at startup) to reclaim disk from previous
|
||||
/// releases. Removes all version directories under the openshell-vm base
|
||||
/// except the current version.
|
||||
pub fn cleanup_old_rootfs() -> Result<(), VmError> {
|
||||
let base = rootfs_cache_base()?;
|
||||
if !base.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let current_version_dir = base.join(VERSION);
|
||||
cleanup_old_versions_in_base(&base, ¤t_version_dir)
|
||||
}
|
||||
|
||||
/// Check if the rootfs is embedded (non-empty).
|
||||
pub fn has_embedded_rootfs() -> bool {
|
||||
!resources::ROOTFS.is_empty()
|
||||
}
|
||||
|
||||
// ── Internal helpers ───────────────────────────────────────────────────────
|
||||
|
||||
/// Build a cache key that combines the version string with a short content
|
||||
/// fingerprint of the embedded runtime bytes.
|
||||
///
|
||||
/// Using the version alone is insufficient for dev builds (all `0.0.0`)
|
||||
/// because the embedded libraries can change between compiles without the
|
||||
/// version changing. The fingerprint is a simple XOR-fold of the first few
|
||||
/// bytes of each embedded resource — cheap to compute at startup without
|
||||
/// pulling in a hash dependency.
|
||||
fn runtime_cache_key() -> String {
|
||||
// XOR-fold the first 64 bytes of each resource to get a cheap fingerprint.
|
||||
let mut fp: u64 = 0;
|
||||
for (i, chunk) in [resources::LIBKRUN, resources::LIBKRUNFW, resources::GVPROXY]
|
||||
.iter()
|
||||
.enumerate()
|
||||
{
|
||||
let sample = &chunk[..chunk.len().min(64)];
|
||||
let mut word: u64 = 0;
|
||||
for (j, &b) in sample.iter().enumerate() {
|
||||
word ^= (b as u64) << ((j % 8) * 8);
|
||||
}
|
||||
// Mix in resource index so identical resources don't cancel out.
|
||||
fp ^= word.rotate_left((i as u32) * 13 + 7);
|
||||
// Also mix in the total length so size changes are detected.
|
||||
fp ^= (chunk.len() as u64).rotate_left((i as u32) * 17 + 3);
|
||||
}
|
||||
format!("{VERSION}-{fp:016x}")
|
||||
}
|
||||
|
||||
fn runtime_cache_dir() -> Result<PathBuf, VmError> {
|
||||
let base = openshell_core::paths::xdg_data_dir()
|
||||
.map_err(|e| VmError::HostSetup(format!("resolve XDG data dir: {e}")))?;
|
||||
Ok(base.join("openshell").join("vm-runtime").join(VERSION))
|
||||
}
|
||||
|
||||
fn runtime_cache_base() -> Result<PathBuf, VmError> {
|
||||
let base = openshell_core::paths::xdg_data_dir()
|
||||
.map_err(|e| VmError::HostSetup(format!("resolve XDG data dir: {e}")))?;
|
||||
Ok(base.join("openshell").join("vm-runtime"))
|
||||
}
|
||||
|
||||
fn rootfs_cache_base() -> Result<PathBuf, VmError> {
|
||||
let base = openshell_core::paths::xdg_data_dir()
|
||||
.map_err(|e| VmError::HostSetup(format!("resolve XDG data dir: {e}")))?;
|
||||
Ok(base.join("openshell").join("openshell-vm"))
|
||||
}
|
||||
|
||||
fn cleanup_old_versions(current_dir: &Path) -> Result<(), VmError> {
|
||||
cleanup_old_versions_in_base(&runtime_cache_base()?, current_dir)
|
||||
}
|
||||
|
||||
fn cleanup_old_versions_in_base(base: &Path, current_dir: &Path) -> Result<(), VmError> {
|
||||
if !base.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let entries = match fs::read_dir(base) {
|
||||
Ok(e) => e,
|
||||
Err(_) => return Ok(()), // Can't read, skip cleanup
|
||||
};
|
||||
|
||||
for entry in entries.filter_map(Result::ok) {
|
||||
let path = entry.path();
|
||||
// Skip if this is the current version directory or a parent of it
|
||||
if path.is_dir() && !current_dir.starts_with(&path) && path != current_dir {
|
||||
tracing::debug!(
|
||||
path = %path.display(),
|
||||
"Cleaning up old version"
|
||||
);
|
||||
if let Err(e) = fs::remove_dir_all(&path) {
|
||||
tracing::warn!(
|
||||
path = %path.display(),
|
||||
error = %e,
|
||||
"Failed to clean up old version"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn extract_resource(compressed: &[u8], dest: &Path) -> Result<(), VmError> {
|
||||
if compressed.is_empty() {
|
||||
return Err(VmError::HostSetup(format!(
|
||||
"embedded resource is empty: {}",
|
||||
dest.display()
|
||||
)));
|
||||
}
|
||||
|
||||
let decompressed = zstd::decode_all(compressed)
|
||||
.map_err(|e| VmError::HostSetup(format!("decompress {}: {e}", dest.display())))?;
|
||||
|
||||
let mut file = fs::File::create(dest)
|
||||
.map_err(|e| VmError::HostSetup(format!("create {}: {e}", dest.display())))?;
|
||||
|
||||
file.write_all(&decompressed)
|
||||
.map_err(|e| VmError::HostSetup(format!("write {}: {e}", dest.display())))?;
|
||||
|
||||
tracing::debug!(
|
||||
path = %dest.display(),
|
||||
compressed_size = compressed.len(),
|
||||
decompressed_size = decompressed.len(),
|
||||
"Extracted resource"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn extract_rootfs_with_progress(compressed: &[u8], dest: &Path) -> Result<(), VmError> {
|
||||
eprintln!("Extracting VM environment (first run)...");
|
||||
|
||||
// Create progress bar for decompression
|
||||
let pb = ProgressBar::new(compressed.len() as u64);
|
||||
pb.set_style(
|
||||
ProgressStyle::default_bar()
|
||||
.template(" Decompressing [{bar:40.cyan/blue}] {bytes}/{total_bytes}")
|
||||
.unwrap()
|
||||
.progress_chars("=>-"),
|
||||
);
|
||||
|
||||
// Wrap the compressed data in a progress reader
|
||||
let reader = ProgressReader::new(std::io::Cursor::new(compressed), pb.clone());
|
||||
|
||||
// Decompress zstd stream
|
||||
let decoder = zstd::Decoder::new(reader)
|
||||
.map_err(|e| VmError::HostSetup(format!("create zstd decoder: {e}")))?;
|
||||
|
||||
pb.finish_and_clear();
|
||||
|
||||
// Create destination directory
|
||||
fs::create_dir_all(dest).map_err(|e| VmError::HostSetup(format!("create rootfs dir: {e}")))?;
|
||||
|
||||
// Extract tar archive with progress
|
||||
eprintln!(" Extracting rootfs...");
|
||||
let mut archive = tar::Archive::new(decoder);
|
||||
archive
|
||||
.unpack(dest)
|
||||
.map_err(|e| VmError::HostSetup(format!("extract rootfs tarball: {e}")))?;
|
||||
|
||||
eprintln!(" Rootfs extracted to {}", dest.display());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A reader wrapper that updates a progress bar as data is read.
|
||||
struct ProgressReader<R> {
|
||||
inner: R,
|
||||
progress: ProgressBar,
|
||||
}
|
||||
|
||||
impl<R> ProgressReader<R> {
|
||||
fn new(inner: R, progress: ProgressBar) -> Self {
|
||||
Self { inner, progress }
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: Read> Read for ProgressReader<R> {
|
||||
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
|
||||
let n = self.inner.read(buf)?;
|
||||
self.progress.inc(n as u64);
|
||||
Ok(n)
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_runtime_dir(dir: &Path) -> Result<(), VmError> {
|
||||
let libkrun = dir.join(resources::LIBKRUN_NAME);
|
||||
let libkrunfw = dir.join(resources::LIBKRUNFW_NAME);
|
||||
let gvproxy = dir.join("gvproxy");
|
||||
|
||||
for path in [&libkrun, &libkrunfw, &gvproxy] {
|
||||
if !path.exists() {
|
||||
return Err(VmError::HostSetup(format!(
|
||||
"missing runtime file: {}",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
|
||||
// Check file is not empty (would indicate a stub)
|
||||
let size = fs::metadata(path).map(|m| m.len()).unwrap_or(0);
|
||||
if size == 0 {
|
||||
return Err(VmError::HostSetup(format!(
|
||||
"runtime file is empty (stub): {}",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_resources_not_empty() {
|
||||
// On supported platforms, resources should be non-empty
|
||||
#[cfg(any(
|
||||
all(target_os = "macos", target_arch = "aarch64"),
|
||||
all(target_os = "linux", target_arch = "aarch64"),
|
||||
all(target_os = "linux", target_arch = "x86_64"),
|
||||
))]
|
||||
{
|
||||
// Note: This test only passes if `mise run vm:setup` was run
|
||||
// before building. In CI without compressed artifacts, resources will be
|
||||
// empty stubs.
|
||||
if !resources::LIBKRUN.is_empty() {
|
||||
assert!(!resources::LIBKRUNFW.is_empty());
|
||||
assert!(!resources::GVPROXY.is_empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,767 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
use std::fs::{self, File};
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::os::unix::net::UnixStream;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::thread;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use base64::Engine as _;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::VmError;
|
||||
|
||||
/// Remove a directory, safely handling symlinks.
|
||||
///
|
||||
/// Uses `symlink_metadata` (lstat) to detect symlinks. If the path is a
|
||||
/// symlink (e.g. `var/run -> /run` in a Linux rootfs), the symlink itself
|
||||
/// is removed without following it — preventing traversal attacks where a
|
||||
/// symlink could redirect `remove_dir_all` to an arbitrary host path.
|
||||
/// If the path is a real directory, it is removed recursively.
|
||||
fn safe_remove_dir_all(path: &Path) -> Result<bool, VmError> {
|
||||
match fs::symlink_metadata(path) {
|
||||
Ok(meta) => {
|
||||
if meta.file_type().is_symlink() {
|
||||
// Remove the symlink itself, not the target it points to.
|
||||
fs::remove_file(path).map_err(|e| {
|
||||
VmError::RuntimeState(format!("reset: remove symlink {}: {e}", path.display()))
|
||||
})?;
|
||||
return Ok(true);
|
||||
}
|
||||
if !meta.is_dir() {
|
||||
return Ok(false); // Not a directory — nothing to remove.
|
||||
}
|
||||
fs::remove_dir_all(path).map_err(|e| {
|
||||
VmError::RuntimeState(format!("reset: remove {}: {e}", path.display()))
|
||||
})?;
|
||||
Ok(true)
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
|
||||
Err(e) => Err(VmError::RuntimeState(format!(
|
||||
"stat {}: {e}",
|
||||
path.display()
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
pub const VM_EXEC_VSOCK_PORT: u32 = 10_777;
|
||||
|
||||
const VM_STATE_NAME: &str = "vm-state.json";
|
||||
const VM_LOCK_NAME: &str = "vm.lock";
|
||||
const KUBECONFIG_ENV: &str = "KUBECONFIG=/etc/rancher/k3s/k3s.yaml";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct VmExecOptions {
|
||||
pub rootfs: Option<PathBuf>,
|
||||
pub command: Vec<String>,
|
||||
pub workdir: Option<String>,
|
||||
pub env: Vec<String>,
|
||||
pub tty: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct VmRuntimeState {
|
||||
pub pid: i32,
|
||||
pub exec_vsock_port: u32,
|
||||
pub socket_path: PathBuf,
|
||||
pub rootfs: PathBuf,
|
||||
pub console_log: PathBuf,
|
||||
pub started_at_ms: u128,
|
||||
/// PID of the gvproxy process (if networking uses gvproxy).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub gvproxy_pid: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ExecRequest {
|
||||
argv: Vec<String>,
|
||||
env: Vec<String>,
|
||||
cwd: Option<String>,
|
||||
tty: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
enum ClientFrame {
|
||||
Stdin { data: String },
|
||||
StdinClose,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
enum ServerFrame {
|
||||
Stdout { data: String },
|
||||
Stderr { data: String },
|
||||
Exit { code: i32 },
|
||||
Error { message: String },
|
||||
}
|
||||
|
||||
pub fn vm_exec_socket_path(rootfs: &Path) -> PathBuf {
|
||||
// Prefer XDG_RUNTIME_DIR (per-user, restricted permissions on Linux),
|
||||
// fall back to /tmp. Ownership/symlink validation happens in
|
||||
// secure_socket_base() when the gvproxy socket dir is created; here
|
||||
// we just compute the path. The parent directory is created (with
|
||||
// permission checks) at launch time via create_dir_all.
|
||||
let base = if let Some(xdg) = std::env::var_os("XDG_RUNTIME_DIR") {
|
||||
PathBuf::from(xdg)
|
||||
} else {
|
||||
let mut base = PathBuf::from("/tmp");
|
||||
if !base.is_dir() {
|
||||
base = std::env::temp_dir();
|
||||
}
|
||||
base
|
||||
};
|
||||
let dir = base.join("ovm-exec");
|
||||
let id = hash_path_id(rootfs);
|
||||
dir.join(format!("{id}.sock"))
|
||||
}
|
||||
|
||||
fn hash_path_id(path: &Path) -> String {
|
||||
let mut hash: u64 = 0xcbf29ce484222325;
|
||||
for byte in path.to_string_lossy().as_bytes() {
|
||||
hash ^= u64::from(*byte);
|
||||
hash = hash.wrapping_mul(0x100000001b3);
|
||||
}
|
||||
format!("{:012x}", hash & 0x0000_ffff_ffff_ffff)
|
||||
}
|
||||
|
||||
pub fn write_vm_runtime_state(
|
||||
rootfs: &Path,
|
||||
pid: i32,
|
||||
console_log: &Path,
|
||||
gvproxy_pid: Option<u32>,
|
||||
) -> Result<(), VmError> {
|
||||
let state = VmRuntimeState {
|
||||
pid,
|
||||
exec_vsock_port: VM_EXEC_VSOCK_PORT,
|
||||
socket_path: vm_exec_socket_path(rootfs),
|
||||
rootfs: rootfs.to_path_buf(),
|
||||
console_log: console_log.to_path_buf(),
|
||||
started_at_ms: now_ms()?,
|
||||
gvproxy_pid,
|
||||
};
|
||||
let path = vm_state_path(rootfs);
|
||||
let bytes = serde_json::to_vec_pretty(&state)
|
||||
.map_err(|e| VmError::RuntimeState(format!("serialize VM runtime state: {e}")))?;
|
||||
fs::create_dir_all(vm_run_dir(rootfs))
|
||||
.map_err(|e| VmError::RuntimeState(format!("create VM runtime dir: {e}")))?;
|
||||
fs::write(&path, bytes)
|
||||
.map_err(|e| VmError::RuntimeState(format!("write {}: {e}", path.display())))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn clear_vm_runtime_state(rootfs: &Path) {
|
||||
let state_path = vm_state_path(rootfs);
|
||||
let socket_path = vm_exec_socket_path(rootfs);
|
||||
let _ = fs::remove_file(state_path);
|
||||
let _ = fs::remove_file(socket_path);
|
||||
}
|
||||
|
||||
/// Wipe stale container runtime state from the rootfs.
|
||||
///
|
||||
/// After a crash or unclean shutdown, containerd and kubelet can retain
|
||||
/// references to pod sandboxes and containers that no longer exist. This
|
||||
/// causes `ContainerCreating` → `context deadline exceeded` loops because
|
||||
/// containerd blocks trying to clean up orphaned resources.
|
||||
///
|
||||
/// This function removes:
|
||||
/// - containerd runtime task state (running container metadata)
|
||||
/// - containerd sandbox controller shim state
|
||||
/// - containerd CRI plugin state (pod/container tracking)
|
||||
/// - containerd tmp mounts
|
||||
/// - kubelet pod state (volume mounts, pod status)
|
||||
///
|
||||
/// It preserves:
|
||||
/// - containerd images and content (no re-pull needed)
|
||||
/// - containerd snapshots (no re-extract needed)
|
||||
/// - containerd metadata database (meta.db — image/snapshot tracking)
|
||||
///
|
||||
/// **Note:** This is the only path that wipes the kine/SQLite database.
|
||||
/// Normal boots preserve `state.db` (and all cluster objects) across
|
||||
/// restarts. The init script clears stale bootstrap locks via `sqlite3`,
|
||||
/// and `recover_corrupt_kine_db` handles actual file corruption.
|
||||
pub fn reset_runtime_state(rootfs: &Path, gateway_name: &str) -> Result<(), VmError> {
|
||||
// Full reset: wipe all runtime state so the VM cold-starts from scratch.
|
||||
//
|
||||
// With the block-device layout, k3s server/agent state, containerd, PVCs,
|
||||
// and PKI all live on the state disk — the caller in lib.rs deletes the
|
||||
// entire state disk image file, which achieves a complete wipe in one
|
||||
// operation without touching the virtiofs rootfs.
|
||||
//
|
||||
// We still clean the virtiofs rootfs for paths that are NOT on the state
|
||||
// disk: kubelet pod volumes, CNI state, and the pre-init sentinel. These
|
||||
// paths are present in the rootfs regardless of the storage layout.
|
||||
let dirs_to_remove = [
|
||||
// Stale pod volume mounts and projected secrets
|
||||
rootfs.join("var/lib/kubelet/pods"),
|
||||
// CNI state: stale network namespace references from dead pods
|
||||
rootfs.join("var/lib/cni"),
|
||||
// Runtime state (PIDs, sockets) — on virtiofs, not block device
|
||||
rootfs.join("var/run"),
|
||||
];
|
||||
|
||||
let mut cleaned = 0usize;
|
||||
for dir in &dirs_to_remove {
|
||||
if safe_remove_dir_all(dir)? {
|
||||
cleaned += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the pre-initialized sentinel so the init script knows
|
||||
// this is a cold start and deploys manifests from staging.
|
||||
// We write a marker file so ensure-vm-rootfs.sh still sees the
|
||||
// rootfs as built (avoiding a full rebuild) while the init script
|
||||
// detects the cold start via the missing .initialized sentinel.
|
||||
let sentinel = rootfs.join("opt/openshell/.initialized");
|
||||
let reset_marker = rootfs.join("opt/openshell/.reset");
|
||||
if sentinel.exists() {
|
||||
fs::remove_file(&sentinel).map_err(|e| {
|
||||
VmError::RuntimeState(format!(
|
||||
"reset: remove sentinel {}: {e}",
|
||||
sentinel.display()
|
||||
))
|
||||
})?;
|
||||
fs::write(&reset_marker, "").map_err(|e| {
|
||||
VmError::RuntimeState(format!(
|
||||
"reset: write marker {}: {e}",
|
||||
reset_marker.display()
|
||||
))
|
||||
})?;
|
||||
cleaned += 1;
|
||||
}
|
||||
|
||||
// PKI lives on the state disk; deleting the state disk image (done by
|
||||
// the caller) rotates it automatically. Just note it for the log.
|
||||
eprintln!("Reset: PKI will be regenerated on next boot (state disk wiped)");
|
||||
|
||||
// Wipe host-side mTLS credentials so bootstrap_gateway() takes the
|
||||
// first-boot path and fetches new certs from the VM via the exec agent.
|
||||
if let Ok(home) = std::env::var("HOME") {
|
||||
let config_base =
|
||||
std::env::var("XDG_CONFIG_HOME").unwrap_or_else(|_| format!("{home}/.config"));
|
||||
let mtls_dir = PathBuf::from(&config_base)
|
||||
.join("openshell/gateways")
|
||||
.join(gateway_name)
|
||||
.join("mtls");
|
||||
if mtls_dir.is_dir() {
|
||||
fs::remove_dir_all(&mtls_dir).map_err(|e| {
|
||||
VmError::RuntimeState(format!(
|
||||
"reset: remove mTLS dir {}: {e}",
|
||||
mtls_dir.display()
|
||||
))
|
||||
})?;
|
||||
}
|
||||
// Also remove metadata so is_warm_boot() returns false.
|
||||
let metadata = PathBuf::from(&config_base)
|
||||
.join("openshell/gateways")
|
||||
.join(gateway_name)
|
||||
.join("metadata.json");
|
||||
if metadata.is_file() {
|
||||
fs::remove_file(&metadata).map_err(|e| {
|
||||
VmError::RuntimeState(format!(
|
||||
"reset: remove metadata {}: {e}",
|
||||
metadata.display()
|
||||
))
|
||||
})?;
|
||||
}
|
||||
}
|
||||
|
||||
eprintln!("Reset: cleaned {cleaned} state directories (full reset)");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove a corrupt kine (`SQLite`) database so k3s can recreate it on boot.
|
||||
///
|
||||
/// k3s uses kine with a `SQLite` backend at `var/lib/rancher/k3s/server/db/state.db`.
|
||||
/// If the VM is killed mid-write (SIGKILL, host crash, power loss), the database
|
||||
/// file may be left in a corrupt state — the `SQLite` header magic is missing or the
|
||||
/// file is truncated. k3s would open the DB, get `SQLITE_NOTADB` /
|
||||
/// `SQLITE_CORRUPT`, and crash at startup.
|
||||
///
|
||||
/// This function checks the `SQLite` file header (first 100 bytes only) and removes
|
||||
/// the database plus its WAL/SHM sidecar files if the header is invalid. k3s will
|
||||
/// create a fresh database on startup and cluster state will be re-applied from
|
||||
/// the auto-deploy manifests in `server/manifests/`.
|
||||
///
|
||||
/// **Stale bootstrap locks** (a kine application-level issue where a killed k3s
|
||||
/// server leaves a lock row that causes the next instance to hang) are handled
|
||||
/// separately by the init script (`openshell-vm-init.sh`), which runs
|
||||
/// `sqlite3 state.db "DELETE FROM kine WHERE name LIKE '/bootstrap/%'"` before
|
||||
/// starting k3s. This allows the database — and all persistent cluster state — to
|
||||
/// survive normal restarts.
|
||||
///
|
||||
/// **What is lost on corruption:** all cluster object records (Pods, Deployments,
|
||||
/// Secrets, `ConfigMaps`, CRDs, etc.) and the bootstrap token. These are re-created
|
||||
/// from manifests on the next boot.
|
||||
///
|
||||
/// **What is always preserved:** container images and snapshots (under
|
||||
/// `k3s/agent/`), PKI, and the `.initialized` sentinel.
|
||||
///
|
||||
/// This function is a no-op if `state.db` does not exist (e.g. first boot or
|
||||
/// after a full `--reset`).
|
||||
pub fn recover_corrupt_kine_db(rootfs: &Path) -> Result<(), VmError> {
|
||||
let db_path = rootfs.join("var/lib/rancher/k3s/server/db/state.db");
|
||||
if !db_path.exists() {
|
||||
return Ok(()); // Nothing to check — first boot or post-reset.
|
||||
}
|
||||
|
||||
// The SQLite file format begins with a 16-byte magic string.
|
||||
// Reference: https://www.sqlite.org/fileformat.html#the_database_header
|
||||
const SQLITE_MAGIC: &[u8] = b"SQLite format 3\x00";
|
||||
|
||||
// Read only the first 100 bytes (the minimum valid SQLite header size)
|
||||
// instead of loading the entire database into memory.
|
||||
let has_invalid_header = match File::open(&db_path).and_then(|mut f| {
|
||||
let mut buf = [0u8; 100];
|
||||
let n = f.read(&mut buf)?;
|
||||
Ok((n, buf))
|
||||
}) {
|
||||
Err(_) => true, // Can't read → treat as corrupt.
|
||||
Ok((n, _)) if n < 100 => true, // Too short to be a valid DB.
|
||||
Ok((_, buf)) => !buf.starts_with(SQLITE_MAGIC),
|
||||
};
|
||||
|
||||
if !has_invalid_header {
|
||||
return Ok(()); // Valid database — preserve it for warm boot.
|
||||
}
|
||||
|
||||
eprintln!(
|
||||
"Warning: kine database is corrupt ({}), removing for clean boot",
|
||||
db_path.display()
|
||||
);
|
||||
|
||||
remove_kine_db_files(&db_path)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove the kine `SQLite` database and its WAL/SHM sidecar files.
|
||||
fn remove_kine_db_files(db_path: &Path) -> Result<(), VmError> {
|
||||
if let Err(e) = fs::remove_file(db_path) {
|
||||
return Err(VmError::RuntimeState(format!(
|
||||
"failed to remove kine database {}: {e}",
|
||||
db_path.display()
|
||||
)));
|
||||
}
|
||||
// Also remove any WAL/SHM sidecar files left by an interrupted write.
|
||||
let _ = fs::remove_file(db_path.with_extension("db-wal"));
|
||||
let _ = fs::remove_file(db_path.with_extension("db-shm"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Acquire an exclusive lock on the rootfs lock file.
|
||||
///
|
||||
/// The lock is held for the lifetime of the returned `File` handle. When
|
||||
/// the process exits (even via SIGKILL), the OS releases the lock
|
||||
/// automatically. This provides a reliable guard against two VM processes
|
||||
/// sharing the same rootfs — even if the state file is deleted.
|
||||
///
|
||||
/// Returns `Ok(File)` on success. The caller must keep the `File` alive
|
||||
/// for as long as the VM is running.
|
||||
pub fn acquire_rootfs_lock(rootfs: &Path) -> Result<File, VmError> {
|
||||
let lock_path = vm_lock_path(rootfs);
|
||||
fs::create_dir_all(vm_run_dir(rootfs))
|
||||
.map_err(|e| VmError::RuntimeState(format!("create VM runtime dir: {e}")))?;
|
||||
|
||||
// Open (or create) the lock file without truncating so we can read
|
||||
// the holder's PID for the error message if the lock is held.
|
||||
let file = fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.open(&lock_path)
|
||||
.map_err(|e| {
|
||||
VmError::RuntimeState(format!("open lock file {}: {e}", lock_path.display()))
|
||||
})?;
|
||||
|
||||
// Try non-blocking exclusive lock.
|
||||
let fd = std::os::unix::io::AsRawFd::as_raw_fd(&file);
|
||||
let rc = unsafe { libc::flock(fd, libc::LOCK_EX | libc::LOCK_NB) };
|
||||
if rc != 0 {
|
||||
let err = std::io::Error::last_os_error();
|
||||
if err.raw_os_error() == Some(libc::EWOULDBLOCK) {
|
||||
// Another process holds the lock — read its PID for diagnostics.
|
||||
let holder_pid = fs::read_to_string(&lock_path).unwrap_or_default();
|
||||
let holder_pid = holder_pid.trim();
|
||||
return Err(VmError::RuntimeState(format!(
|
||||
"another process (pid {holder_pid}) is using rootfs {}. \
|
||||
Stop the running VM first",
|
||||
rootfs.display()
|
||||
)));
|
||||
}
|
||||
return Err(VmError::RuntimeState(format!(
|
||||
"lock rootfs {}: {err}",
|
||||
lock_path.display()
|
||||
)));
|
||||
}
|
||||
|
||||
// Lock acquired — write our PID (truncate first, then write).
|
||||
// This is informational only; the flock is the real guard.
|
||||
let _ = file.set_len(0);
|
||||
{
|
||||
let mut f = &file;
|
||||
let _ = write!(f, "{}", std::process::id());
|
||||
}
|
||||
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
/// Check whether the rootfs lock file is currently held by another process.
|
||||
///
|
||||
/// Returns `Ok(())` if the lock is free (or can be acquired), and an
|
||||
/// `Err` if another process holds it. Does NOT acquire the lock — use
|
||||
/// [`acquire_rootfs_lock`] for that.
|
||||
fn check_rootfs_lock_free(rootfs: &Path) -> Result<(), VmError> {
|
||||
let lock_path = vm_lock_path(rootfs);
|
||||
if !lock_path.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let Ok(file) = File::open(&lock_path) else {
|
||||
return Ok(()); // Can't open → treat as free
|
||||
};
|
||||
|
||||
let fd = std::os::unix::io::AsRawFd::as_raw_fd(&file);
|
||||
let rc = unsafe { libc::flock(fd, libc::LOCK_EX | libc::LOCK_NB) };
|
||||
if rc != 0 {
|
||||
let err = std::io::Error::last_os_error();
|
||||
if err.raw_os_error() == Some(libc::EWOULDBLOCK) {
|
||||
let holder_pid = fs::read_to_string(&lock_path).unwrap_or_default();
|
||||
let holder_pid = holder_pid.trim();
|
||||
return Err(VmError::RuntimeState(format!(
|
||||
"another process (pid {holder_pid}) is using rootfs {}. \
|
||||
Stop the running VM first",
|
||||
rootfs.display()
|
||||
)));
|
||||
}
|
||||
} else {
|
||||
// We acquired the lock — release it immediately since we're only probing.
|
||||
unsafe { libc::flock(fd, libc::LOCK_UN) };
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn ensure_vm_not_running(rootfs: &Path) -> Result<(), VmError> {
|
||||
// Primary guard: check the flock. This works even if the state file
|
||||
// has been deleted, because the kernel holds the lock until the
|
||||
// owning process exits.
|
||||
check_rootfs_lock_free(rootfs)?;
|
||||
|
||||
// Secondary guard: check the state file for any stale state.
|
||||
match load_vm_runtime_state(Some(rootfs)) {
|
||||
Ok(state) => Err(VmError::RuntimeState(format!(
|
||||
"VM is already running (pid {}) with exec socket {}",
|
||||
state.pid,
|
||||
state.socket_path.display()
|
||||
))),
|
||||
Err(VmError::RuntimeState(message))
|
||||
if message.starts_with("read VM runtime state")
|
||||
|| message.starts_with("VM is not running") =>
|
||||
{
|
||||
clear_vm_runtime_state(rootfs);
|
||||
Ok(())
|
||||
}
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn exec_running_vm(options: VmExecOptions) -> Result<i32, VmError> {
|
||||
let state = load_vm_runtime_state(options.rootfs.as_deref())?;
|
||||
let mut stream = UnixStream::connect(&state.socket_path).map_err(|e| {
|
||||
VmError::Exec(format!(
|
||||
"connect to VM exec socket {}: {e}",
|
||||
state.socket_path.display()
|
||||
))
|
||||
})?;
|
||||
let mut writer = stream
|
||||
.try_clone()
|
||||
.map_err(|e| VmError::Exec(format!("clone VM exec socket: {e}")))?;
|
||||
|
||||
let mut env = options.env;
|
||||
validate_env_vars(&env)?;
|
||||
if !env.iter().any(|item| item.starts_with("KUBECONFIG=")) {
|
||||
env.push(KUBECONFIG_ENV.to_string());
|
||||
}
|
||||
|
||||
let request = ExecRequest {
|
||||
argv: options.command,
|
||||
env,
|
||||
cwd: options.workdir,
|
||||
tty: options.tty,
|
||||
};
|
||||
send_json_line(&mut writer, &request)?;
|
||||
|
||||
let stdin_writer = writer;
|
||||
thread::spawn(move || {
|
||||
let _ = pump_stdin(stdin_writer);
|
||||
});
|
||||
|
||||
let mut reader = BufReader::new(&mut stream);
|
||||
let mut line = String::new();
|
||||
let stdout = std::io::stdout();
|
||||
let stderr = std::io::stderr();
|
||||
let mut stdout = stdout.lock();
|
||||
let mut stderr = stderr.lock();
|
||||
let mut exit_code = None;
|
||||
|
||||
loop {
|
||||
line.clear();
|
||||
let bytes = reader
|
||||
.read_line(&mut line)
|
||||
.map_err(|e| VmError::Exec(format!("read VM exec response from guest agent: {e}")))?;
|
||||
if bytes == 0 {
|
||||
break;
|
||||
}
|
||||
|
||||
let frame: ServerFrame = serde_json::from_str(line.trim_end())
|
||||
.map_err(|e| VmError::Exec(format!("decode VM exec response frame: {e}")))?;
|
||||
|
||||
match frame {
|
||||
ServerFrame::Stdout { data } => {
|
||||
let bytes = decode_payload(&data)?;
|
||||
stdout
|
||||
.write_all(&bytes)
|
||||
.map_err(|e| VmError::Exec(format!("write guest stdout: {e}")))?;
|
||||
stdout
|
||||
.flush()
|
||||
.map_err(|e| VmError::Exec(format!("flush guest stdout: {e}")))?;
|
||||
}
|
||||
ServerFrame::Stderr { data } => {
|
||||
let bytes = decode_payload(&data)?;
|
||||
stderr
|
||||
.write_all(&bytes)
|
||||
.map_err(|e| VmError::Exec(format!("write guest stderr: {e}")))?;
|
||||
stderr
|
||||
.flush()
|
||||
.map_err(|e| VmError::Exec(format!("flush guest stderr: {e}")))?;
|
||||
}
|
||||
ServerFrame::Exit { code } => {
|
||||
exit_code = Some(code);
|
||||
break;
|
||||
}
|
||||
ServerFrame::Error { message } => {
|
||||
return Err(VmError::Exec(message));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
exit_code.ok_or_else(|| {
|
||||
VmError::Exec("VM exec agent disconnected before returning an exit code".to_string())
|
||||
})
|
||||
}
|
||||
|
||||
/// Run a command inside the guest via the exec agent and capture its stdout.
|
||||
///
|
||||
/// Unlike [`exec_running_vm`], this function does not pump host stdin or write
|
||||
/// to the terminal. It collects all stdout frames into a `Vec<u8>` and returns
|
||||
/// them on success (exit code 0). Stderr output is discarded.
|
||||
///
|
||||
/// This is the building block for internal host→guest queries (e.g. reading
|
||||
/// files from the guest filesystem) without requiring a dedicated vsock server.
|
||||
pub fn exec_capture(socket_path: &Path, argv: Vec<String>) -> Result<Vec<u8>, VmError> {
|
||||
let mut stream = UnixStream::connect(socket_path).map_err(|e| {
|
||||
VmError::Exec(format!(
|
||||
"connect to VM exec socket {}: {e}",
|
||||
socket_path.display()
|
||||
))
|
||||
})?;
|
||||
let mut writer = stream
|
||||
.try_clone()
|
||||
.map_err(|e| VmError::Exec(format!("clone VM exec socket: {e}")))?;
|
||||
|
||||
let request = ExecRequest {
|
||||
argv,
|
||||
env: vec![],
|
||||
cwd: None,
|
||||
tty: false,
|
||||
};
|
||||
send_json_line(&mut writer, &request)?;
|
||||
|
||||
// Close stdin immediately — we have no input to send.
|
||||
send_json_line(&mut writer, &ClientFrame::StdinClose)?;
|
||||
|
||||
let mut reader = BufReader::new(&mut stream);
|
||||
let mut line = String::new();
|
||||
let mut stdout_buf = Vec::new();
|
||||
|
||||
loop {
|
||||
line.clear();
|
||||
let bytes = reader
|
||||
.read_line(&mut line)
|
||||
.map_err(|e| VmError::Exec(format!("read VM exec response: {e}")))?;
|
||||
if bytes == 0 {
|
||||
break;
|
||||
}
|
||||
|
||||
let frame: ServerFrame = serde_json::from_str(line.trim_end())
|
||||
.map_err(|e| VmError::Exec(format!("decode VM exec response frame: {e}")))?;
|
||||
|
||||
match frame {
|
||||
ServerFrame::Stdout { data } => {
|
||||
stdout_buf.extend_from_slice(&decode_payload(&data)?);
|
||||
}
|
||||
ServerFrame::Stderr { .. } => {
|
||||
// Discard stderr for capture mode.
|
||||
}
|
||||
ServerFrame::Exit { code } => {
|
||||
if code != 0 {
|
||||
return Err(VmError::Exec(format!(
|
||||
"guest command exited with code {code}"
|
||||
)));
|
||||
}
|
||||
return Ok(stdout_buf);
|
||||
}
|
||||
ServerFrame::Error { message } => {
|
||||
return Err(VmError::Exec(message));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(VmError::Exec(
|
||||
"VM exec agent disconnected before returning an exit code".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
fn vm_run_dir(rootfs: &Path) -> PathBuf {
|
||||
rootfs.parent().unwrap_or(rootfs).to_path_buf()
|
||||
}
|
||||
|
||||
pub fn vm_state_path(rootfs: &Path) -> PathBuf {
|
||||
vm_run_dir(rootfs).join(format!("{}-{}", rootfs_key(rootfs), VM_STATE_NAME))
|
||||
}
|
||||
|
||||
fn vm_lock_path(rootfs: &Path) -> PathBuf {
|
||||
vm_run_dir(rootfs).join(format!("{}-{}", rootfs_key(rootfs), VM_LOCK_NAME))
|
||||
}
|
||||
|
||||
fn rootfs_key(rootfs: &Path) -> String {
|
||||
let name = rootfs
|
||||
.file_name()
|
||||
.and_then(|part| part.to_str())
|
||||
.unwrap_or("openshell-vm");
|
||||
let mut out = String::with_capacity(name.len());
|
||||
for ch in name.chars() {
|
||||
if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
|
||||
out.push(ch);
|
||||
} else {
|
||||
out.push('_');
|
||||
}
|
||||
}
|
||||
if out.is_empty() {
|
||||
"openshell-vm".to_string()
|
||||
} else {
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
fn default_rootfs() -> Result<PathBuf, VmError> {
|
||||
crate::named_rootfs_dir("default")
|
||||
}
|
||||
|
||||
fn load_vm_runtime_state(rootfs: Option<&Path>) -> Result<VmRuntimeState, VmError> {
|
||||
let rootfs = match rootfs {
|
||||
Some(rootfs) => rootfs.to_path_buf(),
|
||||
None => default_rootfs()?,
|
||||
};
|
||||
let path = vm_state_path(&rootfs);
|
||||
let bytes = fs::read(&path).map_err(|e| {
|
||||
VmError::RuntimeState(format!(
|
||||
"read VM runtime state {}: {e}. Start the VM with `openshell-vm` first",
|
||||
path.display()
|
||||
))
|
||||
})?;
|
||||
let state: VmRuntimeState = serde_json::from_slice(&bytes)
|
||||
.map_err(|e| VmError::RuntimeState(format!("decode VM runtime state: {e}")))?;
|
||||
|
||||
if !process_alive(state.pid) {
|
||||
clear_vm_runtime_state(&state.rootfs);
|
||||
return Err(VmError::RuntimeState(format!(
|
||||
"VM is not running (stale pid {})",
|
||||
state.pid
|
||||
)));
|
||||
}
|
||||
|
||||
if !state.socket_path.exists() {
|
||||
return Err(VmError::RuntimeState(format!(
|
||||
"VM exec socket is not ready: {}",
|
||||
state.socket_path.display()
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(state)
|
||||
}
|
||||
|
||||
fn validate_env_vars(items: &[String]) -> Result<(), VmError> {
|
||||
for item in items {
|
||||
let (key, _value) = item.split_once('=').ok_or_else(|| {
|
||||
VmError::Exec(format!(
|
||||
"invalid environment variable `{item}`; expected KEY=VALUE"
|
||||
))
|
||||
})?;
|
||||
if key.is_empty()
|
||||
|| !key.chars().enumerate().all(|(idx, ch)| {
|
||||
ch == '_' || (ch.is_ascii_alphanumeric() && (idx > 0 || !ch.is_ascii_digit()))
|
||||
})
|
||||
{
|
||||
return Err(VmError::Exec(format!(
|
||||
"invalid environment variable name `{key}`"
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn send_json_line<T: Serialize>(writer: &mut UnixStream, value: &T) -> Result<(), VmError> {
|
||||
let mut bytes = serde_json::to_vec(value)
|
||||
.map_err(|e| VmError::Exec(format!("encode VM exec request: {e}")))?;
|
||||
bytes.push(b'\n');
|
||||
writer
|
||||
.write_all(&bytes)
|
||||
.map_err(|e| VmError::Exec(format!("write VM exec request: {e}")))
|
||||
}
|
||||
|
||||
fn pump_stdin(mut writer: UnixStream) -> Result<(), VmError> {
|
||||
let stdin = std::io::stdin();
|
||||
let mut stdin = stdin.lock();
|
||||
let mut buf = [0u8; 8192];
|
||||
|
||||
loop {
|
||||
let read = stdin
|
||||
.read(&mut buf)
|
||||
.map_err(|e| VmError::Exec(format!("read local stdin: {e}")))?;
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
let frame = ClientFrame::Stdin {
|
||||
data: base64::engine::general_purpose::STANDARD.encode(&buf[..read]),
|
||||
};
|
||||
send_json_line(&mut writer, &frame)?;
|
||||
}
|
||||
|
||||
send_json_line(&mut writer, &ClientFrame::StdinClose)
|
||||
}
|
||||
|
||||
fn decode_payload(data: &str) -> Result<Vec<u8>, VmError> {
|
||||
base64::engine::general_purpose::STANDARD
|
||||
.decode(data)
|
||||
.map_err(|e| VmError::Exec(format!("decode VM exec payload: {e}")))
|
||||
}
|
||||
|
||||
fn process_alive(pid: i32) -> bool {
|
||||
let rc = unsafe { libc::kill(pid, 0) };
|
||||
if rc == 0 {
|
||||
return true;
|
||||
}
|
||||
std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
|
||||
}
|
||||
|
||||
fn now_ms() -> Result<u128, VmError> {
|
||||
let duration = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_err(|e| VmError::RuntimeState(format!("read system clock: {e}")))?;
|
||||
Ok(duration.as_millis())
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! Minimal runtime-loaded bindings for the libkrun C API.
|
||||
//!
|
||||
//! We intentionally do not link libkrun at build time. Instead, the
|
||||
//! `openshell-vm` binary loads `libkrun` from the staged `openshell-vm.runtime/`
|
||||
//! sidecar bundle on first use.
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use libc::c_char;
|
||||
use libloading::Library;
|
||||
|
||||
use crate::VmError;
|
||||
|
||||
/// Runtime provenance information extracted from the bundle.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RuntimeProvenance {
|
||||
/// Path to the libkrun library that was loaded.
|
||||
pub libkrun_path: PathBuf,
|
||||
/// Paths to all libkrunfw libraries that were preloaded.
|
||||
pub libkrunfw_paths: Vec<PathBuf>,
|
||||
/// SHA-256 hash of the primary libkrunfw artifact (if computable).
|
||||
pub libkrunfw_sha256: Option<String>,
|
||||
/// Contents of provenance.json if present in the runtime bundle.
|
||||
pub provenance_json: Option<String>,
|
||||
/// Whether this is a custom (OpenShell-built) runtime.
|
||||
pub is_custom: bool,
|
||||
}
|
||||
|
||||
pub const KRUN_LOG_TARGET_DEFAULT: i32 = -1;
|
||||
pub const KRUN_LOG_LEVEL_OFF: u32 = 0;
|
||||
pub const KRUN_LOG_LEVEL_ERROR: u32 = 1;
|
||||
pub const KRUN_LOG_LEVEL_WARN: u32 = 2;
|
||||
pub const KRUN_LOG_LEVEL_INFO: u32 = 3;
|
||||
pub const KRUN_LOG_LEVEL_DEBUG: u32 = 4;
|
||||
pub const KRUN_LOG_LEVEL_TRACE: u32 = 5;
|
||||
pub const KRUN_LOG_STYLE_AUTO: u32 = 0;
|
||||
pub const KRUN_LOG_OPTION_NO_ENV: u32 = 1;
|
||||
pub const KRUN_DISK_FORMAT_RAW: u32 = 0;
|
||||
#[allow(dead_code)] // Used only on macOS (cfg-gated in state_disk_sync_mode)
|
||||
pub const KRUN_SYNC_RELAXED: u32 = 1;
|
||||
#[allow(dead_code)] // Used only on Linux (cfg-gated in state_disk_sync_mode)
|
||||
pub const KRUN_SYNC_FULL: u32 = 2;
|
||||
|
||||
type KrunInitLog =
|
||||
unsafe extern "C" fn(target_fd: i32, level: u32, style: u32, options: u32) -> i32;
|
||||
type KrunCreateCtx = unsafe extern "C" fn() -> i32;
|
||||
type KrunFreeCtx = unsafe extern "C" fn(ctx_id: u32) -> i32;
|
||||
type KrunSetVmConfig = unsafe extern "C" fn(ctx_id: u32, num_vcpus: u8, ram_mib: u32) -> i32;
|
||||
type KrunSetRoot = unsafe extern "C" fn(ctx_id: u32, root_path: *const c_char) -> i32;
|
||||
type KrunSetWorkdir = unsafe extern "C" fn(ctx_id: u32, workdir_path: *const c_char) -> i32;
|
||||
type KrunSetExec = unsafe extern "C" fn(
|
||||
ctx_id: u32,
|
||||
exec_path: *const c_char,
|
||||
argv: *const *const c_char,
|
||||
envp: *const *const c_char,
|
||||
) -> i32;
|
||||
type KrunSetPortMap = unsafe extern "C" fn(ctx_id: u32, port_map: *const *const c_char) -> i32;
|
||||
type KrunSetConsoleOutput = unsafe extern "C" fn(ctx_id: u32, filepath: *const c_char) -> i32;
|
||||
type KrunAddDisk3 = unsafe extern "C" fn(
|
||||
ctx_id: u32,
|
||||
block_id: *const c_char,
|
||||
disk_path: *const c_char,
|
||||
disk_format: u32,
|
||||
read_only: bool,
|
||||
direct_io: bool,
|
||||
sync_mode: u32,
|
||||
) -> i32;
|
||||
type KrunAddVsockPort2 =
|
||||
unsafe extern "C" fn(ctx_id: u32, port: u32, c_filepath: *const c_char, listen: bool) -> i32;
|
||||
type KrunStartEnter = unsafe extern "C" fn(ctx_id: u32) -> i32;
|
||||
type KrunDisableImplicitVsock = unsafe extern "C" fn(ctx_id: u32) -> i32;
|
||||
type KrunAddVsock = unsafe extern "C" fn(ctx_id: u32, tsi_features: u32) -> i32;
|
||||
#[cfg(target_os = "macos")]
|
||||
type KrunAddNetUnixgram = unsafe extern "C" fn(
|
||||
ctx_id: u32,
|
||||
c_path: *const c_char,
|
||||
fd: i32,
|
||||
c_mac: *const u8,
|
||||
features: u32,
|
||||
flags: u32,
|
||||
) -> i32;
|
||||
type KrunAddNetUnixstream = unsafe extern "C" fn(
|
||||
ctx_id: u32,
|
||||
c_path: *const c_char,
|
||||
fd: i32,
|
||||
c_mac: *const u8,
|
||||
features: u32,
|
||||
flags: u32,
|
||||
) -> i32;
|
||||
|
||||
pub struct LibKrun {
|
||||
pub krun_init_log: KrunInitLog,
|
||||
pub krun_create_ctx: KrunCreateCtx,
|
||||
pub krun_free_ctx: KrunFreeCtx,
|
||||
pub krun_set_vm_config: KrunSetVmConfig,
|
||||
pub krun_set_root: KrunSetRoot,
|
||||
pub krun_set_workdir: KrunSetWorkdir,
|
||||
pub krun_set_exec: KrunSetExec,
|
||||
pub krun_set_port_map: KrunSetPortMap,
|
||||
pub krun_set_console_output: KrunSetConsoleOutput,
|
||||
pub krun_add_disk3: Option<KrunAddDisk3>,
|
||||
pub krun_add_vsock_port2: KrunAddVsockPort2,
|
||||
pub krun_start_enter: KrunStartEnter,
|
||||
pub krun_disable_implicit_vsock: KrunDisableImplicitVsock,
|
||||
pub krun_add_vsock: KrunAddVsock,
|
||||
#[cfg(target_os = "macos")]
|
||||
pub krun_add_net_unixgram: KrunAddNetUnixgram,
|
||||
#[allow(dead_code)] // FFI symbol loaded for future use
|
||||
pub krun_add_net_unixstream: KrunAddNetUnixstream,
|
||||
}
|
||||
|
||||
static LIBKRUN: OnceLock<LibKrun> = OnceLock::new();
|
||||
static RUNTIME_PROVENANCE: OnceLock<RuntimeProvenance> = OnceLock::new();
|
||||
|
||||
pub fn libkrun() -> Result<&'static LibKrun, VmError> {
|
||||
if let Some(lib) = LIBKRUN.get() {
|
||||
return Ok(lib);
|
||||
}
|
||||
|
||||
let loaded = LibKrun::load()?;
|
||||
let _ = LIBKRUN.set(loaded);
|
||||
Ok(LIBKRUN.get().expect("libkrun should be initialized"))
|
||||
}
|
||||
|
||||
/// Return the provenance information for the loaded runtime.
|
||||
///
|
||||
/// Only available after [`libkrun()`] has been called successfully.
|
||||
pub fn runtime_provenance() -> Option<&'static RuntimeProvenance> {
|
||||
RUNTIME_PROVENANCE.get()
|
||||
}
|
||||
|
||||
impl LibKrun {
|
||||
fn load() -> Result<Self, VmError> {
|
||||
let path = runtime_libkrun_path()?;
|
||||
let runtime_dir = path.parent().ok_or_else(|| {
|
||||
VmError::HostSetup(format!("libkrun has no parent dir: {}", path.display()))
|
||||
})?;
|
||||
let krunfw_paths = preload_runtime_support_libraries(runtime_dir)?;
|
||||
|
||||
// Build and store provenance information.
|
||||
let provenance_json_path = runtime_dir.join("provenance.json");
|
||||
let provenance_json = fs::read_to_string(&provenance_json_path).ok();
|
||||
let is_custom = provenance_json.is_some();
|
||||
|
||||
let libkrunfw_sha256 = krunfw_paths.first().and_then(|p| compute_sha256(p).ok());
|
||||
|
||||
let provenance = RuntimeProvenance {
|
||||
libkrun_path: path.clone(),
|
||||
libkrunfw_paths: krunfw_paths,
|
||||
libkrunfw_sha256,
|
||||
provenance_json,
|
||||
is_custom,
|
||||
};
|
||||
let _ = RUNTIME_PROVENANCE.set(provenance);
|
||||
|
||||
let library = Box::leak(Box::new(unsafe {
|
||||
Library::new(&path).map_err(|e| {
|
||||
VmError::HostSetup(format!("load libkrun from {}: {e}", path.display()))
|
||||
})?
|
||||
}));
|
||||
|
||||
Ok(Self {
|
||||
krun_init_log: load_symbol(library, b"krun_init_log\0", &path)?,
|
||||
krun_create_ctx: load_symbol(library, b"krun_create_ctx\0", &path)?,
|
||||
krun_free_ctx: load_symbol(library, b"krun_free_ctx\0", &path)?,
|
||||
krun_set_vm_config: load_symbol(library, b"krun_set_vm_config\0", &path)?,
|
||||
krun_set_root: load_symbol(library, b"krun_set_root\0", &path)?,
|
||||
krun_set_workdir: load_symbol(library, b"krun_set_workdir\0", &path)?,
|
||||
krun_set_exec: load_symbol(library, b"krun_set_exec\0", &path)?,
|
||||
krun_set_port_map: load_symbol(library, b"krun_set_port_map\0", &path)?,
|
||||
krun_set_console_output: load_symbol(library, b"krun_set_console_output\0", &path)?,
|
||||
krun_add_disk3: load_optional_symbol(library, b"krun_add_disk3\0"),
|
||||
krun_add_vsock_port2: load_symbol(library, b"krun_add_vsock_port2\0", &path)?,
|
||||
krun_start_enter: load_symbol(library, b"krun_start_enter\0", &path)?,
|
||||
krun_disable_implicit_vsock: load_symbol(
|
||||
library,
|
||||
b"krun_disable_implicit_vsock\0",
|
||||
&path,
|
||||
)?,
|
||||
krun_add_vsock: load_symbol(library, b"krun_add_vsock\0", &path)?,
|
||||
#[cfg(target_os = "macos")]
|
||||
krun_add_net_unixgram: load_symbol(library, b"krun_add_net_unixgram\0", &path)?,
|
||||
krun_add_net_unixstream: load_symbol(library, b"krun_add_net_unixstream\0", &path)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn runtime_libkrun_path() -> Result<PathBuf, VmError> {
|
||||
Ok(crate::configured_runtime_dir()?.join(required_runtime_lib_name()))
|
||||
}
|
||||
|
||||
fn preload_runtime_support_libraries(runtime_dir: &Path) -> Result<Vec<PathBuf>, VmError> {
|
||||
let entries = fs::read_dir(runtime_dir)
|
||||
.map_err(|e| VmError::HostSetup(format!("read {}: {e}", runtime_dir.display())))?;
|
||||
|
||||
let mut support_libs: Vec<PathBuf> = entries
|
||||
.filter_map(Result::ok)
|
||||
.map(|entry| entry.path())
|
||||
.filter(|path| {
|
||||
path.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.is_some_and(|name| {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
name.starts_with("libkrunfw") && name.ends_with(".dylib")
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
name.starts_with("libkrunfw") && name.contains(".so")
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
support_libs.sort();
|
||||
|
||||
for path in &support_libs {
|
||||
let path_cstr = std::ffi::CString::new(path.to_string_lossy().as_bytes()).map_err(|e| {
|
||||
VmError::HostSetup(format!(
|
||||
"invalid support library path {}: {e}",
|
||||
path.display()
|
||||
))
|
||||
})?;
|
||||
let handle =
|
||||
unsafe { libc::dlopen(path_cstr.as_ptr(), libc::RTLD_NOW | libc::RTLD_GLOBAL) };
|
||||
if handle.is_null() {
|
||||
let error = unsafe {
|
||||
let err = libc::dlerror();
|
||||
if err.is_null() {
|
||||
"unknown dlopen error".to_string()
|
||||
} else {
|
||||
std::ffi::CStr::from_ptr(err).to_string_lossy().into_owned()
|
||||
}
|
||||
};
|
||||
return Err(VmError::HostSetup(format!(
|
||||
"preload runtime support library {}: {error}",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(support_libs)
|
||||
}
|
||||
|
||||
pub fn required_runtime_lib_name() -> &'static str {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
"libkrun.dylib"
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
"libkrun.so"
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute SHA-256 hash of a file, returning hex string.
|
||||
///
|
||||
/// Streams the file contents directly to `shasum -a 256` via a pipe,
|
||||
/// avoiding buffering the entire file in memory.
|
||||
fn compute_sha256(path: &Path) -> Result<String, std::io::Error> {
|
||||
use std::io::{Read, Write};
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
let mut file = fs::File::open(path)?;
|
||||
|
||||
// sha256sum is standard on Linux; shasum ships with macOS/Perl.
|
||||
let mut child = Command::new("sha256sum")
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.or_else(|_| {
|
||||
Command::new("shasum")
|
||||
.args(["-a", "256"])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
})?;
|
||||
|
||||
// Stream file contents directly to shasum's stdin in 8KB chunks.
|
||||
{
|
||||
let mut stdin = child
|
||||
.stdin
|
||||
.take()
|
||||
.ok_or_else(|| std::io::Error::other("failed to open shasum stdin"))?;
|
||||
let mut buf = [0u8; 8192];
|
||||
loop {
|
||||
let n = file.read(&mut buf)?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
stdin.write_all(&buf[..n])?;
|
||||
}
|
||||
// stdin is dropped here, closing the pipe so shasum can finish.
|
||||
}
|
||||
|
||||
let output = child.wait_with_output()?;
|
||||
if output.status.success() {
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
Ok(stdout
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.unwrap_or("unknown")
|
||||
.to_string())
|
||||
} else {
|
||||
Ok("unknown".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn load_symbol<T: Copy>(
|
||||
library: &'static Library,
|
||||
symbol: &[u8],
|
||||
path: &Path,
|
||||
) -> Result<T, VmError> {
|
||||
let loaded = unsafe {
|
||||
library.get::<T>(symbol).map_err(|e| {
|
||||
VmError::HostSetup(format!(
|
||||
"resolve {} from {}: {e}",
|
||||
String::from_utf8_lossy(symbol).trim_end_matches('\0'),
|
||||
path.display()
|
||||
))
|
||||
})?
|
||||
};
|
||||
Ok(*loaded)
|
||||
}
|
||||
|
||||
fn load_optional_symbol<T: Copy>(library: &'static Library, symbol: &[u8]) -> Option<T> {
|
||||
let loaded = unsafe { library.get::<T>(symbol).ok()? };
|
||||
Some(*loaded)
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! gRPC health check for verifying the gateway is fully ready.
|
||||
//!
|
||||
//! This module provides a proper gRPC health check that verifies the gateway
|
||||
//! service is not just accepting TCP connections, but is actually responding
|
||||
//! to gRPC requests. This ensures we don't mark the server as ready before
|
||||
//! it has fully booted.
|
||||
|
||||
use crate::VmError;
|
||||
use openshell_core::proto::{HealthRequest, ServiceStatus, open_shell_client::OpenShellClient};
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
use tonic::transport::{Certificate, ClientTlsConfig, Endpoint, Identity};
|
||||
|
||||
/// Load mTLS materials from the gateway's cert directory.
|
||||
fn load_mtls_materials(gateway_name: &str) -> Result<(Vec<u8>, Vec<u8>, Vec<u8>), String> {
|
||||
let home = std::env::var("HOME").map_err(|_| "HOME not set")?;
|
||||
let mtls_dir = PathBuf::from(home)
|
||||
.join(".config/openshell/gateways")
|
||||
.join(gateway_name)
|
||||
.join("mtls");
|
||||
|
||||
let ca = std::fs::read(mtls_dir.join("ca.crt"))
|
||||
.map_err(|e| format!("failed to read ca.crt: {e}"))?;
|
||||
let cert = std::fs::read(mtls_dir.join("tls.crt"))
|
||||
.map_err(|e| format!("failed to read tls.crt: {e}"))?;
|
||||
let key = std::fs::read(mtls_dir.join("tls.key"))
|
||||
.map_err(|e| format!("failed to read tls.key: {e}"))?;
|
||||
|
||||
Ok((ca, cert, key))
|
||||
}
|
||||
|
||||
/// Build a tonic TLS config from mTLS materials.
|
||||
fn build_tls_config(ca: Vec<u8>, cert: Vec<u8>, key: Vec<u8>) -> ClientTlsConfig {
|
||||
let ca_cert = Certificate::from_pem(ca);
|
||||
let identity = Identity::from_pem(cert, key);
|
||||
ClientTlsConfig::new()
|
||||
.ca_certificate(ca_cert)
|
||||
.identity(identity)
|
||||
}
|
||||
|
||||
/// Perform a gRPC health check against the gateway.
|
||||
///
|
||||
/// Returns `Ok(())` if the health check succeeds (service reports healthy),
|
||||
/// or an error describing why the check failed.
|
||||
async fn grpc_health_check(gateway_port: u16, gateway_name: &str) -> Result<(), String> {
|
||||
// Load mTLS materials
|
||||
let (ca, cert, key) = load_mtls_materials(gateway_name)?;
|
||||
let tls_config = build_tls_config(ca, cert, key);
|
||||
|
||||
// Build the channel with TLS
|
||||
let endpoint = format!("https://127.0.0.1:{gateway_port}");
|
||||
let channel = Endpoint::from_shared(endpoint.clone())
|
||||
.map_err(|e| format!("invalid endpoint: {e}"))?
|
||||
.connect_timeout(Duration::from_secs(5))
|
||||
.tls_config(tls_config)
|
||||
.map_err(|e| format!("TLS config error: {e}"))?
|
||||
.connect()
|
||||
.await
|
||||
.map_err(|e| format!("connection failed: {e}"))?;
|
||||
|
||||
// Create client and call health
|
||||
let mut client = OpenShellClient::new(channel);
|
||||
let response = client
|
||||
.health(HealthRequest {})
|
||||
.await
|
||||
.map_err(|e| format!("health RPC failed: {e}"))?;
|
||||
|
||||
let health = response.into_inner();
|
||||
if health.status == ServiceStatus::Healthy as i32 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("service not healthy: status={}", health.status))
|
||||
}
|
||||
}
|
||||
|
||||
/// Wait for the gateway service to be fully ready by polling the gRPC health endpoint.
|
||||
///
|
||||
/// This replaces the TCP-only probe with a proper gRPC health check that verifies
|
||||
/// the service is actually responding to requests, not just accepting connections.
|
||||
///
|
||||
/// Returns `Ok(())` when the gateway is confirmed healthy, or `Err` if the health
|
||||
/// check fails or times out. Falls back to TCP probe if mTLS materials aren't
|
||||
/// available yet.
|
||||
pub fn wait_for_gateway_ready(gateway_port: u16, gateway_name: &str) -> Result<(), VmError> {
|
||||
let start = std::time::Instant::now();
|
||||
let timeout = Duration::from_secs(90);
|
||||
let poll_interval = Duration::from_secs(1);
|
||||
|
||||
eprintln!("Waiting for gateway gRPC health check...");
|
||||
|
||||
// Create a runtime for async health checks
|
||||
let rt = match tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
{
|
||||
Ok(rt) => rt,
|
||||
Err(e) => {
|
||||
eprintln!(" failed to create tokio runtime: {e}, falling back to TCP probe");
|
||||
return wait_for_tcp_only(gateway_port, timeout, poll_interval);
|
||||
}
|
||||
};
|
||||
|
||||
loop {
|
||||
// Try gRPC health check
|
||||
let result = rt.block_on(async {
|
||||
tokio::time::timeout(
|
||||
Duration::from_secs(5),
|
||||
grpc_health_check(gateway_port, gateway_name),
|
||||
)
|
||||
.await
|
||||
});
|
||||
|
||||
match result {
|
||||
Ok(Ok(())) => {
|
||||
eprintln!("Gateway healthy [{:.1}s]", start.elapsed().as_secs_f64());
|
||||
return Ok(());
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
// gRPC call completed but failed
|
||||
if start.elapsed() >= timeout {
|
||||
return Err(VmError::Bootstrap(format!(
|
||||
"gateway health check failed after {:.0}s: {e}",
|
||||
timeout.as_secs_f64()
|
||||
)));
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
// Timeout on the health check itself
|
||||
if start.elapsed() >= timeout {
|
||||
return Err(VmError::Bootstrap(format!(
|
||||
"gateway health check timed out after {:.0}s",
|
||||
timeout.as_secs_f64()
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::thread::sleep(poll_interval);
|
||||
}
|
||||
}
|
||||
|
||||
/// Fallback TCP-only probe when gRPC health check can't be performed.
|
||||
fn wait_for_tcp_only(
|
||||
gateway_port: u16,
|
||||
timeout: Duration,
|
||||
poll_interval: Duration,
|
||||
) -> Result<(), VmError> {
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
loop {
|
||||
if host_tcp_probe(gateway_port) {
|
||||
eprintln!(
|
||||
"Service reachable (TCP) [{:.1}s]",
|
||||
start.elapsed().as_secs_f64()
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if start.elapsed() >= timeout {
|
||||
return Err(VmError::Bootstrap(format!(
|
||||
"gateway TCP probe failed after {:.0}s",
|
||||
timeout.as_secs_f64()
|
||||
)));
|
||||
}
|
||||
|
||||
std::thread::sleep(poll_interval);
|
||||
}
|
||||
}
|
||||
|
||||
/// Probe `127.0.0.1:port` from the host to verify the TCP path is working.
|
||||
///
|
||||
/// This is a fallback when gRPC health check isn't available.
|
||||
fn host_tcp_probe(gateway_port: u16) -> bool {
|
||||
use std::io::Read;
|
||||
use std::net::{SocketAddr, TcpStream};
|
||||
|
||||
let addr: SocketAddr = ([127, 0, 0, 1], gateway_port).into();
|
||||
let Ok(mut stream) = TcpStream::connect_timeout(&addr, Duration::from_secs(2)) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
// A short read timeout: if the server is alive it will wait for us
|
||||
// to send a TLS ClientHello, so the read will time out (= good).
|
||||
// If the connection resets or closes, the server is dead.
|
||||
stream
|
||||
.set_read_timeout(Some(Duration::from_millis(200)))
|
||||
.ok();
|
||||
let mut buf = [0u8; 1];
|
||||
match stream.read(&mut buf) {
|
||||
Err(e)
|
||||
if e.kind() == std::io::ErrorKind::WouldBlock
|
||||
|| e.kind() == std::io::ErrorKind::TimedOut =>
|
||||
{
|
||||
true // Timeout = server alive, waiting for ClientHello.
|
||||
}
|
||||
_ => false, // Reset, EOF, or unexpected data = not healthy.
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,241 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! Standalone openshell-vm binary.
|
||||
//!
|
||||
//! Boots a libkrun microVM running the `OpenShell` control plane (k3s +
|
||||
//! openshell-server). Each named instance gets its own rootfs extracted from
|
||||
//! the embedded tarball at
|
||||
//! `~/.local/share/openshell/openshell-vm/{version}/instances/<name>/rootfs`.
|
||||
//!
|
||||
//! # Codesigning (macOS)
|
||||
//!
|
||||
//! This binary must be codesigned with the `com.apple.security.hypervisor`
|
||||
//! entitlement. See `entitlements.plist` in this crate.
|
||||
//!
|
||||
//! ```sh
|
||||
//! codesign --entitlements crates/openshell-vm/entitlements.plist --force -s - target/debug/openshell-vm
|
||||
//! ```
|
||||
|
||||
use std::io::IsTerminal;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use clap::{Parser, Subcommand, ValueHint};
|
||||
|
||||
const DISABLE_STATE_DISK_ENV: &str = "OPENSHELL_VM_DISABLE_STATE_DISK";
|
||||
|
||||
/// Boot the `OpenShell` gateway microVM.
|
||||
///
|
||||
/// Starts a libkrun microVM running a k3s Kubernetes cluster with the
|
||||
/// `OpenShell` control plane. Use `--exec` to run a custom process instead.
|
||||
#[derive(Parser)]
|
||||
#[command(name = "openshell-vm", version)]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Option<GatewayCommand>,
|
||||
|
||||
/// Path to the rootfs directory (aarch64 Linux).
|
||||
/// Overrides the default instance-based rootfs resolution.
|
||||
#[arg(long, value_hint = ValueHint::DirPath)]
|
||||
rootfs: Option<PathBuf>,
|
||||
|
||||
/// Named VM instance.
|
||||
///
|
||||
/// When used alone, the rootfs resolves to
|
||||
/// `~/.local/share/openshell/openshell-vm/{version}/instances/<name>/rootfs`
|
||||
/// and is extracted from the embedded tarball on first use.
|
||||
/// When combined with `--rootfs`, only provides the instance identity
|
||||
/// (for exec, gateway name, etc.) while the rootfs comes from the
|
||||
/// explicit path.
|
||||
#[arg(long, default_value = "default")]
|
||||
name: String,
|
||||
|
||||
/// Executable path inside the VM. When set, runs this instead of
|
||||
/// the default k3s server.
|
||||
#[arg(long)]
|
||||
exec: Option<String>,
|
||||
|
||||
/// Arguments to the executable (requires `--exec`).
|
||||
#[arg(long, num_args = 1..)]
|
||||
args: Vec<String>,
|
||||
|
||||
/// Environment variables in `KEY=VALUE` form (requires `--exec`).
|
||||
#[arg(long, num_args = 1..)]
|
||||
env: Vec<String>,
|
||||
|
||||
/// Working directory inside the VM.
|
||||
#[arg(long, default_value = "/")]
|
||||
workdir: String,
|
||||
|
||||
/// Port mappings (`host_port:guest_port`).
|
||||
#[arg(long, short, num_args = 1..)]
|
||||
port: Vec<String>,
|
||||
|
||||
/// Number of virtual CPUs (default: 4 for openshell-vm, 2 for --exec).
|
||||
#[arg(long)]
|
||||
vcpus: Option<u8>,
|
||||
|
||||
/// RAM in MiB (default: 8192 for openshell-vm, 2048 for --exec).
|
||||
#[arg(long)]
|
||||
mem: Option<u32>,
|
||||
|
||||
/// libkrun log level (0=Off .. 5=Trace).
|
||||
#[arg(long, default_value_t = 1)]
|
||||
krun_log_level: u32,
|
||||
|
||||
/// Networking backend: "gvproxy" (default), "tsi", or "none".
|
||||
#[arg(long, default_value = "gvproxy")]
|
||||
net: String,
|
||||
|
||||
/// Wipe all runtime state (containerd, kubelet, k3s) before booting.
|
||||
/// Use this to recover from a corrupted state after a crash or
|
||||
/// unclean shutdown.
|
||||
#[arg(long)]
|
||||
reset: bool,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum GatewayCommand {
|
||||
/// Ensure the target rootfs exists, extracting the embedded rootfs if needed.
|
||||
PrepareRootfs {
|
||||
/// Recreate the target rootfs even if it already exists.
|
||||
#[arg(long)]
|
||||
force: bool,
|
||||
},
|
||||
|
||||
/// Execute a command inside a running openshell-vm VM.
|
||||
Exec {
|
||||
/// Working directory inside the VM.
|
||||
#[arg(long)]
|
||||
workdir: Option<String>,
|
||||
|
||||
/// Environment variables in `KEY=VALUE` form.
|
||||
#[arg(long, num_args = 1..)]
|
||||
env: Vec<String>,
|
||||
|
||||
/// Command and arguments to run inside the VM.
|
||||
#[arg(trailing_var_arg = true)]
|
||||
command: Vec<String>,
|
||||
},
|
||||
}
|
||||
|
||||
fn main() {
|
||||
tracing_subscriber::fmt::init();
|
||||
|
||||
let cli = Cli::parse();
|
||||
|
||||
let code = match run(cli) {
|
||||
Ok(code) => code,
|
||||
Err(e) => {
|
||||
eprintln!("Error: {e}");
|
||||
1
|
||||
}
|
||||
};
|
||||
|
||||
if code != 0 {
|
||||
std::process::exit(code);
|
||||
}
|
||||
}
|
||||
|
||||
fn run(cli: Cli) -> Result<i32, Box<dyn std::error::Error>> {
|
||||
if let Some(GatewayCommand::PrepareRootfs { force }) = &cli.command {
|
||||
let rootfs = openshell_vm::prepare_rootfs(cli.rootfs.clone(), &cli.name, *force)?;
|
||||
println!("{}", rootfs.display());
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
if let Some(GatewayCommand::Exec {
|
||||
workdir,
|
||||
env,
|
||||
mut command,
|
||||
}) = cli.command
|
||||
{
|
||||
let effective_tty = std::io::stdin().is_terminal();
|
||||
if command.is_empty() {
|
||||
if effective_tty {
|
||||
command.push("sh".to_string());
|
||||
} else {
|
||||
return Err("openshell-vm exec requires a command when stdin is not a TTY".into());
|
||||
}
|
||||
}
|
||||
return Ok(openshell_vm::exec_running_vm(
|
||||
openshell_vm::VmExecOptions {
|
||||
rootfs: Some(
|
||||
cli.rootfs
|
||||
.unwrap_or(openshell_vm::named_rootfs_dir(&cli.name)?),
|
||||
),
|
||||
command,
|
||||
workdir,
|
||||
env,
|
||||
tty: effective_tty,
|
||||
},
|
||||
)?);
|
||||
}
|
||||
|
||||
let net_backend = match cli.net.as_str() {
|
||||
"tsi" => openshell_vm::NetBackend::Tsi,
|
||||
"none" => openshell_vm::NetBackend::None,
|
||||
"gvproxy" => openshell_vm::NetBackend::Gvproxy {
|
||||
binary: openshell_vm::default_runtime_gvproxy_path(),
|
||||
},
|
||||
other => {
|
||||
return Err(
|
||||
format!("unknown --net backend: {other} (expected: gvproxy, tsi, none)").into(),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let rootfs = cli
|
||||
.rootfs
|
||||
.map_or_else(|| openshell_vm::ensure_named_rootfs(&cli.name), Ok)?;
|
||||
|
||||
let gateway_name = openshell_vm::gateway_name(&cli.name)?;
|
||||
|
||||
let mut config = if let Some(exec_path) = cli.exec {
|
||||
openshell_vm::VmConfig {
|
||||
rootfs,
|
||||
vcpus: cli.vcpus.unwrap_or(2),
|
||||
mem_mib: cli.mem.unwrap_or(2048),
|
||||
exec_path,
|
||||
args: cli.args,
|
||||
env: cli.env,
|
||||
workdir: cli.workdir,
|
||||
port_map: cli.port,
|
||||
vsock_ports: vec![],
|
||||
log_level: cli.krun_log_level,
|
||||
console_output: None,
|
||||
net: net_backend,
|
||||
reset: cli.reset,
|
||||
gateway_name,
|
||||
state_disk: None,
|
||||
}
|
||||
} else {
|
||||
let mut c = openshell_vm::VmConfig::gateway(rootfs);
|
||||
if !cli.port.is_empty() {
|
||||
c.port_map = cli.port;
|
||||
}
|
||||
if let Some(v) = cli.vcpus {
|
||||
c.vcpus = v;
|
||||
}
|
||||
if let Some(m) = cli.mem {
|
||||
c.mem_mib = m;
|
||||
}
|
||||
c.net = net_backend;
|
||||
c.reset = cli.reset;
|
||||
c.gateway_name = gateway_name;
|
||||
if state_disk_disabled() {
|
||||
c.state_disk = None;
|
||||
}
|
||||
c
|
||||
};
|
||||
config.log_level = cli.krun_log_level;
|
||||
|
||||
Ok(openshell_vm::launch(&config)?)
|
||||
}
|
||||
|
||||
fn state_disk_disabled() -> bool {
|
||||
matches!(
|
||||
std::env::var(DISABLE_STATE_DISK_ENV).ok().as_deref(),
|
||||
Some("1" | "true" | "TRUE" | "yes" | "YES")
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user