mirror of
https://github.com/NVIDIA/OpenShell.git
synced 2026-10-02 07:34:45 +08:00
test(openshell): integrate accepted macOS sandbox workflow fixes
Combine the reviewed signing verification, launch validation, workload identity, host-tool preflight, preparation deadline and cleanup patches against current main for compatibility verification. Preserve both independent test groups and populate the preparation field in the affected VM fixtures. Source branches retain their own PR scope. Related to #3955 Signed-off-by: Shiju <shiju@nvidia.com>
This commit is contained in:
@@ -96,6 +96,37 @@ jobs:
|
||||
retention-days: 5
|
||||
if-no-files-found: error
|
||||
|
||||
smoke-macos-vm-driver:
|
||||
name: Verify packaged macOS VM driver
|
||||
needs: package
|
||||
runs-on: macos-15-xlarge
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ inputs['checkout-ref'] || github.sha }}
|
||||
|
||||
- name: Test signature verification
|
||||
run: bash tasks/scripts/test-macos-vm-driver-signature.sh
|
||||
|
||||
- name: Download packaged VM driver
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: driver-vm-macos
|
||||
path: package
|
||||
|
||||
# Check the final archive after packaging on Linux. Re-signing here would
|
||||
# hide missing entitlements in the bytes that users actually download.
|
||||
- name: Verify extracted VM driver
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir extracted
|
||||
tar -xzf package/openshell-driver-vm-aarch64-apple-darwin.tar.gz -C extracted
|
||||
test "$(find extracted -type f | wc -l | tr -d ' ')" = 1
|
||||
bash tasks/scripts/verify-macos-vm-driver.sh extracted/openshell-driver-vm
|
||||
extracted/openshell-driver-vm --version | grep -q '^openshell-driver-vm '
|
||||
|
||||
smoke-prover:
|
||||
name: Smoke packaged prover (${{ matrix.triple }})
|
||||
needs: package
|
||||
|
||||
@@ -25,6 +25,11 @@ jobs:
|
||||
runs-on: macos-latest-xlarge
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
|
||||
|
||||
- name: Ensure VM driver
|
||||
run: |
|
||||
launchctl setenv OPENSHELL_COMPUTE_DRIVER vm
|
||||
@@ -37,6 +42,11 @@ jobs:
|
||||
openshell --version
|
||||
openshell status
|
||||
|
||||
- name: Verify installed VM driver
|
||||
run: |
|
||||
bash tasks/scripts/verify-macos-vm-driver.sh "$(brew --prefix openshell)/libexec/openshell-driver-vm"
|
||||
"$(brew --prefix openshell)/libexec/openshell-driver-vm" --version
|
||||
|
||||
# GitHub-hosted macOS runners do not expose the Hypervisor.framework
|
||||
# support libkrun needs. Sandbox launch is covered by the VM E2E lane.
|
||||
- name: Collect Homebrew diagnostics
|
||||
|
||||
@@ -1230,14 +1230,15 @@ pub async fn sandbox_create(
|
||||
SandboxPhase::Error => {
|
||||
drop(stream);
|
||||
drop(client);
|
||||
let provisioning_timed_out = last_sandbox
|
||||
let timed_out_provisioning = last_sandbox
|
||||
.status
|
||||
.as_ref()
|
||||
.and_then(|status| status.provisioning.as_ref())
|
||||
.is_some_and(|record| record.timeout_time.is_some());
|
||||
let create_result = if provisioning_timed_out {
|
||||
.filter(|record| record.timeout_time.is_some());
|
||||
let create_result = if let Some(record) = timed_out_provisioning {
|
||||
Err(miette::miette!(
|
||||
"{last_error_reason}\nSandbox '{sandbox_name}' was retained. Inspect it with `openshell sandbox get {sandbox_name}`; repair its configuration, then run `openshell sandbox start {sandbox_name}` after cleanup completes."
|
||||
"{}",
|
||||
retained_sandbox_timeout_message(&sandbox_name, &last_error_reason, record)
|
||||
))
|
||||
} else if last_error_reason.is_empty() {
|
||||
Err(miette::miette!(
|
||||
@@ -1271,6 +1272,24 @@ pub async fn sandbox_create(
|
||||
}
|
||||
}
|
||||
|
||||
/// Use the persisted phase to select recovery guidance. Preparation may expire
|
||||
/// before any policy is evaluated, so it must not tell the user to repair policy.
|
||||
fn retained_sandbox_timeout_message(
|
||||
sandbox_name: &str,
|
||||
error_reason: &str,
|
||||
record: &openshell_core::proto::SandboxProvisioning,
|
||||
) -> String {
|
||||
let recovery = if record.preparation_deadline.is_some() && record.admission_start_time.is_none()
|
||||
{
|
||||
"check image preparation and supervisor startup diagnostics and the gateway's `image_preparation_timeout_seconds` budget"
|
||||
} else {
|
||||
"repair its configuration"
|
||||
};
|
||||
format!(
|
||||
"{error_reason}\nSandbox '{sandbox_name}' was retained. Inspect it with `openshell sandbox get {sandbox_name}`; {recovery}, then run `openshell sandbox start {sandbox_name}` after cleanup completes."
|
||||
)
|
||||
}
|
||||
|
||||
/// Resolved source for the `--from` flag on `sandbox create`.
|
||||
#[derive(Debug)]
|
||||
enum ResolvedSource {
|
||||
@@ -2783,6 +2802,15 @@ fn sandbox_to_json(sandbox: &Sandbox) -> serde_json::Value {
|
||||
"configuration_change_id": record.configuration_change_id,
|
||||
"configuration_change_time": record.configuration_change_time.as_ref().map(ToString::to_string),
|
||||
"first_rejection_time": record.first_rejection_time.as_ref().map(ToString::to_string),
|
||||
"phase": if record.deadline.is_none() && record.timeout_time.is_none() {
|
||||
"ready"
|
||||
} else if record.preparation_deadline.is_some() && record.admission_start_time.is_none() {
|
||||
"preparation"
|
||||
} else {
|
||||
"admission"
|
||||
},
|
||||
"preparation_deadline": record.preparation_deadline.as_ref().map(ToString::to_string),
|
||||
"admission_start_time": record.admission_start_time.as_ref().map(ToString::to_string),
|
||||
"deadline": record.deadline.as_ref().map(ToString::to_string),
|
||||
"timeout_time": record.timeout_time.as_ref().map(ToString::to_string),
|
||||
"cleanup_completed_time": record.cleanup_completed_time.as_ref().map(ToString::to_string),
|
||||
@@ -7792,6 +7820,79 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retained_sandbox_timeout_message_matches_expired_phase() {
|
||||
let mut record = openshell_core::proto::SandboxProvisioning {
|
||||
preparation_deadline: openshell_core::time::timestamp_from_millis(1_800_000).ok(),
|
||||
timeout_time: openshell_core::time::timestamp_from_millis(1_800_000).ok(),
|
||||
..Default::default()
|
||||
};
|
||||
let message = super::retained_sandbox_timeout_message(
|
||||
"cold-image",
|
||||
"ImagePreparationTimedOut: preparation expired",
|
||||
&record,
|
||||
);
|
||||
assert!(message.starts_with("ImagePreparationTimedOut: preparation expired\n"));
|
||||
assert!(message.contains("Sandbox 'cold-image' was retained"));
|
||||
assert!(message.contains("image preparation and supervisor startup diagnostics"));
|
||||
assert!(message.contains("image_preparation_timeout_seconds"));
|
||||
assert!(!message.contains("repair its configuration"));
|
||||
assert!(message.contains("openshell sandbox get cold-image"));
|
||||
assert!(message.contains("openshell sandbox start cold-image` after cleanup completes"));
|
||||
|
||||
// An admission timeout retains preparation timestamps. Its completed
|
||||
// transition must select configuration repair rather than a larger budget.
|
||||
record.admission_start_time = openshell_core::time::timestamp_from_millis(600_000).ok();
|
||||
let admission_without_preparation = openshell_core::proto::SandboxProvisioning {
|
||||
timeout_time: record.timeout_time,
|
||||
..Default::default()
|
||||
};
|
||||
for admission_record in [&record, &admission_without_preparation] {
|
||||
let message = super::retained_sandbox_timeout_message(
|
||||
"invalid-policy",
|
||||
"ProvisioningTimedOut: repair window expired",
|
||||
admission_record,
|
||||
);
|
||||
assert!(message.contains("repair its configuration"));
|
||||
assert!(!message.contains("image_preparation_timeout_seconds"));
|
||||
assert!(
|
||||
message.contains("openshell sandbox start invalid-policy` after cleanup completes")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provisioning_json_distinguishes_preparation_and_admission() {
|
||||
let mut sandbox = Sandbox::default();
|
||||
sandbox.set_phase(SandboxPhase::Provisioning.into());
|
||||
let ceiling = openshell_core::time::timestamp_from_millis(1_800_000).ok();
|
||||
sandbox.status.as_mut().unwrap().provisioning =
|
||||
Some(openshell_core::proto::SandboxProvisioning {
|
||||
preparation_deadline: ceiling,
|
||||
deadline: ceiling,
|
||||
..Default::default()
|
||||
});
|
||||
let json = super::sandbox_to_json(&sandbox);
|
||||
assert_eq!(json["provisioning"]["phase"], "preparation");
|
||||
assert_eq!(
|
||||
json["provisioning"]["preparation_deadline"],
|
||||
"1970-01-01T00:30:00Z"
|
||||
);
|
||||
assert!(json["provisioning"]["admission_start_time"].is_null());
|
||||
sandbox
|
||||
.status
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.provisioning
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.admission_start_time = openshell_core::time::timestamp_from_millis(600_000).ok();
|
||||
assert_eq!(
|
||||
super::sandbox_to_json(&sandbox)["provisioning"]["phase"],
|
||||
"admission"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sandbox_json_exposes_repair_diagnostic_and_accepted_generation() {
|
||||
use openshell_core::proto::{ConfigurationAdmissionState, SandboxConfigurationAdmission};
|
||||
|
||||
@@ -240,6 +240,10 @@ pub struct Config {
|
||||
/// TTL for SSH session tokens, in seconds. 0 disables expiry.
|
||||
pub ssh_session_ttl_secs: u64,
|
||||
|
||||
/// Absolute image preparation and initial supervisor startup budget for new
|
||||
/// sandbox attempts, in seconds. Must be between 1 and 86400, inclusive.
|
||||
pub image_preparation_timeout_seconds: u32,
|
||||
|
||||
/// Maximum gRPC requests allowed per rate-limit window.
|
||||
///
|
||||
/// When paired with [`Self::grpc_rate_limit_window_secs`], positive values
|
||||
@@ -865,6 +869,7 @@ impl Config {
|
||||
credential_drivers: Vec::new(),
|
||||
default_credential_driver: None,
|
||||
ssh_session_ttl_secs: default_ssh_session_ttl_secs(),
|
||||
image_preparation_timeout_seconds: 1800,
|
||||
grpc_rate_limit_requests: None,
|
||||
grpc_rate_limit_window_secs: None,
|
||||
service_routing: ServiceRoutingConfig::default(),
|
||||
|
||||
@@ -0,0 +1,599 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! Host filesystem tools shared by VM image operations and gateway preflight.
|
||||
//!
|
||||
//! The gateway launches the VM driver with its environment unchanged. Resolve
|
||||
//! tools here so both binaries select the same installation without linking the
|
||||
//! driver runtime into the gateway or starting it during preflight.
|
||||
|
||||
use std::ffi::OsStr;
|
||||
use std::fs;
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::watch;
|
||||
|
||||
const PROBE_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const INSTALL_GUIDANCE: &str = "Install e2fsprogs 1.43 or newer, or repair the selected installation and the gateway service PATH; rerun config preflight with the service account and environment";
|
||||
|
||||
/// Resolve a tool using the VM driver's inherited PATH and existing package prefixes.
|
||||
///
|
||||
/// Names are ordered alternatives: the formatter tries `mke2fs` before
|
||||
/// `mkfs.ext4`. A present but broken installation returns an error and stops
|
||||
/// lookup. The returned absolute path is also used for
|
||||
/// image operations; the caller's environment is not changed.
|
||||
pub fn resolve(names: &[&str]) -> Result<PathBuf, String> {
|
||||
resolve_in(names, &search_dirs(std::env::var_os("PATH").as_deref()))
|
||||
}
|
||||
|
||||
fn search_dirs(path: Option<&OsStr>) -> Vec<PathBuf> {
|
||||
let mut dirs: Vec<_> = path
|
||||
.map(std::env::split_paths)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect();
|
||||
// Preserve the VM driver's existing package-prefix fallbacks. Additional
|
||||
// installations, including Linux sbin directories, belong on service PATH.
|
||||
for root in ["/opt/homebrew/opt/e2fsprogs", "/usr/local/opt/e2fsprogs"] {
|
||||
dirs.push(Path::new(root).join("sbin"));
|
||||
dirs.push(Path::new(root).join("bin"));
|
||||
}
|
||||
dirs
|
||||
}
|
||||
|
||||
fn resolve_in(names: &[&str], dirs: &[PathBuf]) -> Result<PathBuf, String> {
|
||||
for name in names {
|
||||
for directory in dirs {
|
||||
let candidate = directory.join(name);
|
||||
let metadata = match fs::metadata(&candidate) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
|
||||
Err(error) => {
|
||||
return Err(format!(
|
||||
"inspect {}: {error}. {INSTALL_GUIDANCE}",
|
||||
candidate.display()
|
||||
));
|
||||
}
|
||||
};
|
||||
if !metadata.is_file() || metadata.permissions().mode() & 0o111 == 0 {
|
||||
return Err(format!(
|
||||
"{} is not an executable file. {INSTALL_GUIDANCE}",
|
||||
candidate.display()
|
||||
));
|
||||
}
|
||||
// Canonicalization makes relative PATH entries unambiguous in the
|
||||
// report and ensures execution cannot redo PATH lookup differently.
|
||||
return candidate.canonicalize().map_err(|error| {
|
||||
format!(
|
||||
"resolve {}: {error}. {INSTALL_GUIDANCE}",
|
||||
candidate.display()
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(format!(
|
||||
"{} not found in the VM driver's search directories: {}. {INSTALL_GUIDANCE}",
|
||||
names.join(" or "),
|
||||
dirs.iter()
|
||||
.map(|path| path.display().to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(":")
|
||||
))
|
||||
}
|
||||
|
||||
/// Check a formatter, `debugfs`, and `e2fsck` without creating images or driver state.
|
||||
///
|
||||
/// Each selected executable receives only `-V`, with null stdin, a five-second
|
||||
/// deadline, and at most 8 KiB captured per stream. Errors retain the selected
|
||||
/// path and the tool's diagnostics. Image health requires a separate check.
|
||||
pub async fn preflight(cancellation: watch::Receiver<bool>) -> Result<Vec<String>, String> {
|
||||
preflight_in_with_cancellation(
|
||||
&search_dirs(std::env::var_os("PATH").as_deref()),
|
||||
cancellation,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
async fn preflight_in(dirs: &[PathBuf]) -> Result<Vec<String>, String> {
|
||||
let (_sender, cancellation) = watch::channel(false);
|
||||
preflight_in_with_cancellation(dirs, cancellation).await
|
||||
}
|
||||
|
||||
async fn preflight_in_with_cancellation(
|
||||
dirs: &[PathBuf],
|
||||
mut cancellation: watch::Receiver<bool>,
|
||||
) -> Result<Vec<String>, String> {
|
||||
let mut reports = Vec::new();
|
||||
for (names, identity) in [
|
||||
(&["mke2fs", "mkfs.ext4"][..], "mke2fs"),
|
||||
(&["debugfs"][..], "debugfs"),
|
||||
(&["e2fsck"][..], "e2fsck"),
|
||||
] {
|
||||
let result = async {
|
||||
let path = resolve_in(names, dirs)?;
|
||||
let label = path.display();
|
||||
let output = run_version_probe(Command::new(&path), &mut cancellation)
|
||||
.await
|
||||
.map_err(|error| format!("run {label} -V: {error}. {INSTALL_GUIDANCE}"))?;
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
if !output.status.success() {
|
||||
return Err(format!(
|
||||
"{label} -V failed with status {}\nstdout: {stdout}\nstderr: {stderr}\n{INSTALL_GUIDANCE}",
|
||||
output.status
|
||||
));
|
||||
}
|
||||
let version = supported_version(identity, &stdout)
|
||||
.or_else(|| supported_version(identity, &stderr))
|
||||
.ok_or_else(|| format!(
|
||||
"{label} is not a supported {identity} executable\nstdout: {stdout}\nstderr: {stderr}\n{INSTALL_GUIDANCE}"
|
||||
))?;
|
||||
Ok(format!("VM host tool {identity}: {label} ({version})"))
|
||||
}.await;
|
||||
match result {
|
||||
Ok(report) => reports.push(report),
|
||||
Err(error) => {
|
||||
// Retain earlier selected paths even when a later tool fails.
|
||||
reports.push(error);
|
||||
return Err(reports.join("\n"));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(reports)
|
||||
}
|
||||
|
||||
// Keep the group leader unreaped until group cleanup so its PID cannot be
|
||||
// reused while a descendant still holds a captured output pipe open.
|
||||
struct ProbeProcess {
|
||||
child: tokio::process::Child,
|
||||
group: Option<nix::unistd::Pid>,
|
||||
}
|
||||
|
||||
impl ProbeProcess {
|
||||
fn stop_group(&mut self) -> Result<Option<nix::unistd::Pid>, String> {
|
||||
let Some(group) = self.group.take() else {
|
||||
return Ok(None);
|
||||
};
|
||||
match nix::sys::signal::killpg(group, nix::sys::signal::Signal::SIGKILL) {
|
||||
Ok(()) | Err(nix::errno::Errno::ESRCH) => Ok(None),
|
||||
// Darwin can return EPERM for a group containing only our exited,
|
||||
// unreaped leader. Defer judgment until after its owned reap.
|
||||
#[cfg(target_os = "macos")]
|
||||
Err(nix::errno::Errno::EPERM) => Ok(Some(group)),
|
||||
Err(error) => Err(format!("terminate version probe process group: {error}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ProbeProcess {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.stop_group();
|
||||
// kill_on_drop schedules the direct child for reaping if the caller
|
||||
// aborts its future. CLI signal cancellation instead awaits cleanup.
|
||||
}
|
||||
}
|
||||
|
||||
async fn cancelled(cancellation: &mut watch::Receiver<bool>) {
|
||||
loop {
|
||||
if *cancellation.borrow_and_update() {
|
||||
return;
|
||||
}
|
||||
if cancellation.changed().await.is_err() {
|
||||
// A closed sender does not request cancellation.
|
||||
std::future::pending::<()>().await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn observe_exit(group: nix::unistd::Pid) -> Result<(), String> {
|
||||
use rustix::process::{Pid, WaitId, WaitIdOptions, waitid};
|
||||
|
||||
let pid = Pid::from_raw(group.as_raw()).ok_or("invalid version probe process ID")?;
|
||||
loop {
|
||||
let exited = waitid(
|
||||
WaitId::Pid(pid),
|
||||
WaitIdOptions::EXITED | WaitIdOptions::NOHANG | WaitIdOptions::NOWAIT,
|
||||
)
|
||||
.map(|status| status.is_some());
|
||||
match exited {
|
||||
Ok(false) | Err(rustix::io::Errno::INTR) => {
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
Ok(true) => return Ok(()),
|
||||
Err(error) => return Err(format!("observe version probe exit: {error}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_version_probe(
|
||||
command: Command,
|
||||
cancellation: &mut watch::Receiver<bool>,
|
||||
) -> Result<std::process::Output, String> {
|
||||
use std::process::Stdio;
|
||||
|
||||
if *cancellation.borrow() {
|
||||
return Err("host tool checks cancelled".to_string());
|
||||
}
|
||||
let child = tokio::process::Command::from(command)
|
||||
.arg("-V")
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.process_group(0)
|
||||
.kill_on_drop(true)
|
||||
.spawn()
|
||||
.map_err(|error| error.to_string())?;
|
||||
let group = child
|
||||
.id()
|
||||
.and_then(|id| i32::try_from(id).ok())
|
||||
.map(nix::unistd::Pid::from_raw)
|
||||
.ok_or_else(|| "version probe has no valid process ID".to_string())?;
|
||||
let mut probe = ProbeProcess {
|
||||
child,
|
||||
group: Some(group),
|
||||
};
|
||||
let stdout = probe
|
||||
.child
|
||||
.stdout
|
||||
.take()
|
||||
.ok_or("version probe stdout is unavailable")?;
|
||||
let stderr = probe
|
||||
.child
|
||||
.stderr
|
||||
.take()
|
||||
.ok_or("version probe stderr is unavailable")?;
|
||||
let output = tokio::select! {
|
||||
biased;
|
||||
() = cancelled(cancellation) => Err("host tool checks cancelled".to_string()),
|
||||
result = tokio::time::timeout(PROBE_TIMEOUT, async {
|
||||
let (stdout, stderr, ()) = tokio::try_join!(
|
||||
read_probe_output(stdout, "stdout"),
|
||||
read_probe_output(stderr, "stderr"),
|
||||
observe_exit(group),
|
||||
)?;
|
||||
Ok((stdout, stderr))
|
||||
}) => result.unwrap_or_else(|_| Err("timed out after 5 seconds".to_string())),
|
||||
};
|
||||
// Signal the group before reaping its leader, then retire the stored group
|
||||
// ID before awaiting anything else. No later drop can signal a reused PID.
|
||||
let group_cleanup = probe.stop_group();
|
||||
let status = tokio::time::timeout(Duration::from_secs(1), probe.child.wait())
|
||||
.await
|
||||
.map_err(|_| "version probe cleanup exceeded one second".to_string())
|
||||
.and_then(|result| result.map_err(|error| format!("reap version probe: {error}")));
|
||||
let status = match (group_cleanup, status) {
|
||||
(Ok(Some(group)), Ok(status)) => {
|
||||
// Signal 0 only queries existence; it never signals a process.
|
||||
// ESRCH after the owned reap proves no group members remain. If
|
||||
// the ID was reused, this check can only fail conservatively.
|
||||
match nix::sys::signal::killpg(group, None) {
|
||||
Err(nix::errno::Errno::ESRCH) => Ok(status),
|
||||
result => Err(format!(
|
||||
"version probe process group remains after cleanup: {result:?}"
|
||||
)),
|
||||
}
|
||||
}
|
||||
(Ok(None), status) | (Err(_), status @ Err(_)) => status,
|
||||
(Err(error), Ok(_)) => Err(error),
|
||||
(Ok(Some(_)), Err(error)) => Err(error),
|
||||
};
|
||||
match (output, status) {
|
||||
(Ok((stdout, stderr)), Ok(status)) => Ok(std::process::Output {
|
||||
status,
|
||||
stdout,
|
||||
stderr,
|
||||
}),
|
||||
(Err(error), Ok(_)) | (Ok(_), Err(error)) => Err(error),
|
||||
(Err(error), Err(cleanup)) => Err(format!("{error}; {cleanup}")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_probe_output(
|
||||
reader: impl tokio::io::AsyncRead + Unpin,
|
||||
stream: &str,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
use tokio::io::AsyncReadExt as _;
|
||||
// Read one extra byte to distinguish complete output from overflow. Stop
|
||||
// on overflow instead of draining an unbounded writer until the deadline.
|
||||
const MAX_BYTES: usize = 8 * 1024;
|
||||
let mut output = Vec::new();
|
||||
reader
|
||||
.take((MAX_BYTES + 1) as u64)
|
||||
.read_to_end(&mut output)
|
||||
.await
|
||||
.map_err(|error| format!("read version probe {stream}: {error}"))?;
|
||||
if output.len() > MAX_BYTES {
|
||||
output.truncate(MAX_BYTES);
|
||||
return Err(format!(
|
||||
"version probe {stream} exceeded {MAX_BYTES} bytes: {} [truncated]",
|
||||
String::from_utf8_lossy(&output)
|
||||
));
|
||||
}
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
fn supported_version<'a>(identity: &str, output: &'a str) -> Option<&'a str> {
|
||||
output.lines().find_map(|line| {
|
||||
let mut words = line.split_whitespace();
|
||||
if words.next() != Some(identity) {
|
||||
return None;
|
||||
}
|
||||
let version = words.next()?;
|
||||
let mut parts = version.split('.');
|
||||
let major = parts.next()?.parse::<u32>().ok()?;
|
||||
let minor = parts.next()?.parse::<u32>().ok()?;
|
||||
// Reject malformed version tokens rather than accepting an arbitrary
|
||||
// suffix after an otherwise valid major/minor pair.
|
||||
if let Some(patch) = parts.next()
|
||||
&& (patch.parse::<u32>().is_err() || parts.next().is_some())
|
||||
{
|
||||
return None;
|
||||
}
|
||||
// VM image preparation uses mke2fs -d and ext4 filesystem features.
|
||||
// Keep all host tools on the supported e2fsprogs release baseline.
|
||||
((major, minor) >= (1, 43)).then_some(version)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn fake_e2fs_tool(directory: &Path, name: &str, body: &str) {
|
||||
let path = directory.join(name);
|
||||
fs::write(&path, format!("#!/bin/sh\n{body}\n")).expect("write tool");
|
||||
fs::set_permissions(path, fs::Permissions::from_mode(0o755)).expect("make executable");
|
||||
}
|
||||
|
||||
fn fake_e2fs_installation(directory: &Path) {
|
||||
for name in ["mke2fs", "debugfs", "e2fsck"] {
|
||||
fake_e2fs_tool(
|
||||
directory,
|
||||
name,
|
||||
&format!("test \"$1\" = -V || exit 64\necho '{name} 1.47.4' >&2"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn filesystem_preflight_accepts_private_prefix_and_formatter_alias() {
|
||||
let temp = tempfile::tempdir().expect("private prefix");
|
||||
fake_e2fs_installation(temp.path());
|
||||
fs::rename(temp.path().join("mke2fs"), temp.path().join("mkfs.ext4"))
|
||||
.expect("use formatter alias");
|
||||
preflight_in(&[temp.path().to_path_buf()])
|
||||
.await
|
||||
.expect("all required tools available");
|
||||
let selected = resolve_in(&["debugfs"], &[temp.path().to_path_buf()])
|
||||
.expect("execution uses same resolver");
|
||||
let expected = temp
|
||||
.path()
|
||||
.join("debugfs")
|
||||
.canonicalize()
|
||||
.expect("tool path");
|
||||
assert_eq!(selected, expected);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn filesystem_preflight_rejects_missing_tools() {
|
||||
let temp = tempfile::tempdir().expect("clean host search path");
|
||||
let error = preflight_in(&[temp.path().to_path_buf()])
|
||||
.await
|
||||
.expect_err("missing formatter must reject preparation");
|
||||
assert!(error.contains("mke2fs or mkfs.ext4 not found"), "{error}");
|
||||
assert!(error.contains("gateway service PATH"), "{error}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn filesystem_preflight_requires_debugfs_and_recovery_tool() {
|
||||
for missing in ["debugfs", "e2fsck"] {
|
||||
let temp = tempfile::tempdir().expect("partial installation");
|
||||
fake_e2fs_installation(temp.path());
|
||||
fs::remove_file(temp.path().join(missing)).expect("remove required tool");
|
||||
let error = preflight_in(&[temp.path().to_path_buf()])
|
||||
.await
|
||||
.expect_err("missing tool");
|
||||
assert!(error.contains(&format!("{missing} not found")), "{error}");
|
||||
assert!(
|
||||
error.contains("VM host tool mke2fs:"),
|
||||
"earlier path lost: {error}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn filesystem_preflight_retains_missing_interpreter_error() {
|
||||
let temp = tempfile::tempdir().expect("broken installation");
|
||||
let path = temp.path().join("mke2fs");
|
||||
fs::write(&path, "#!/nonexistent/e2fsprogs-interpreter\n").expect("broken executable");
|
||||
fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).expect("executable bit");
|
||||
fake_e2fs_tool(temp.path(), "mkfs.ext4", "echo 'mke2fs 1.47.4' >&2");
|
||||
let error = preflight_in(&[temp.path().to_path_buf()])
|
||||
.await
|
||||
.expect_err("loader error");
|
||||
assert!(error.contains("mke2fs -V:"), "{error}");
|
||||
assert!(error.contains("No such file or directory"), "{error}");
|
||||
assert!(!error.contains("mke2fs or mkfs.ext4 not found"), "{error}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn filesystem_preflight_rejects_nonexecutable_before_another_installation() {
|
||||
let first = tempfile::tempdir().expect("first installation");
|
||||
let second = tempfile::tempdir().expect("second installation");
|
||||
fs::write(first.path().join("mke2fs"), b"not executable").expect("write broken tool");
|
||||
fake_e2fs_installation(second.path());
|
||||
let path = std::env::join_paths([first.path(), second.path()]).expect("search path");
|
||||
let error = preflight_in(&std::env::split_paths(&path).collect::<Vec<_>>())
|
||||
.await
|
||||
.expect_err("broken installation must not fall back");
|
||||
assert!(error.contains("is not an executable file"), "{error}");
|
||||
assert!(
|
||||
error.contains(&first.path().display().to_string()),
|
||||
"{error}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn filesystem_preflight_preserves_failed_tool_output() {
|
||||
let first = tempfile::tempdir().expect("first installation");
|
||||
let second = tempfile::tempdir().expect("second installation");
|
||||
fake_e2fs_tool(first.path(), "mke2fs", "echo 'loader failed' >&2\nexit 42");
|
||||
fake_e2fs_installation(second.path());
|
||||
let path = std::env::join_paths([first.path(), second.path()]).expect("search path");
|
||||
let error = preflight_in(&std::env::split_paths(&path).collect::<Vec<_>>())
|
||||
.await
|
||||
.expect_err("real execution failure must not fall back");
|
||||
assert!(error.contains("42"), "{error}");
|
||||
assert!(error.contains("loader failed"), "{error}");
|
||||
assert!(
|
||||
error.contains(&first.path().display().to_string()),
|
||||
"{error}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn filesystem_preflight_rejects_incompatible_tool() {
|
||||
let temp = tempfile::tempdir().expect("old installation");
|
||||
fake_e2fs_installation(temp.path());
|
||||
fake_e2fs_tool(temp.path(), "debugfs", "echo 'debugfs 1.42.13' >&2");
|
||||
let error = preflight_in(&[temp.path().to_path_buf()])
|
||||
.await
|
||||
.expect_err("old tool must reject preparation");
|
||||
assert!(
|
||||
error.contains("not a supported debugfs executable"),
|
||||
"{error}"
|
||||
);
|
||||
assert!(error.contains("debugfs 1.42.13"), "{error}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn filesystem_preflight_stops_a_hung_version_probe() {
|
||||
let temp = tempfile::tempdir().expect("hung installation");
|
||||
fake_e2fs_tool(temp.path(), "mke2fs", "exec /bin/sleep 30");
|
||||
let error = preflight_in(&[temp.path().to_path_buf()])
|
||||
.await
|
||||
.expect_err("preflight must finish even if a tool hangs");
|
||||
assert!(error.contains("timed out after 5 seconds"), "{error}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn filesystem_preflight_bounds_each_output_stream() {
|
||||
let temp = tempfile::tempdir().expect("noisy installation");
|
||||
for (redirect, stream) in [("", "stdout"), (">&2", "stderr")] {
|
||||
fake_e2fs_tool(
|
||||
temp.path(),
|
||||
"mke2fs",
|
||||
&format!("/bin/dd if=/dev/zero bs=16384 count=4 {redirect} 2>/dev/null\nexit 42"),
|
||||
);
|
||||
let error = preflight_in(&[temp.path().to_path_buf()])
|
||||
.await
|
||||
.expect_err("oversized probe output must fail early");
|
||||
assert!(error.contains(&format!("{stream} exceeded 8192 bytes")));
|
||||
assert!(
|
||||
error.len() < 9000,
|
||||
"diagnostic grew to {} bytes",
|
||||
error.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn filesystem_preflight_timeout_terminates_wrapper_descendants() {
|
||||
let temp = tempfile::tempdir().expect("wrapper installation");
|
||||
let marker = temp.path().join("survived-timeout");
|
||||
fake_e2fs_tool(
|
||||
temp.path(),
|
||||
"mke2fs",
|
||||
&format!(
|
||||
"(/bin/sleep 6; echo survived > '{}') &\nwait",
|
||||
marker.display()
|
||||
),
|
||||
);
|
||||
let error = preflight_in(&[temp.path().to_path_buf()])
|
||||
.await
|
||||
.expect_err("wrapper must time out");
|
||||
assert!(error.contains("timed out after 5 seconds"), "{error}");
|
||||
tokio::time::sleep(Duration::from_millis(1300)).await;
|
||||
assert!(
|
||||
!marker.exists(),
|
||||
"wrapper descendant survived its probe timeout"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn filesystem_preflight_retains_exited_leader_until_held_pipe_cleanup() {
|
||||
let temp = tempfile::tempdir().expect("wrapper installation");
|
||||
let marker = temp.path().join("survived-exited-leader");
|
||||
fake_e2fs_tool(
|
||||
temp.path(),
|
||||
"mke2fs",
|
||||
&format!(
|
||||
"(/bin/sleep 6; echo survived > '{}') &\necho 'mke2fs 1.47.4'\nexit 0",
|
||||
marker.display()
|
||||
),
|
||||
);
|
||||
let error = preflight_in(&[temp.path().to_path_buf()])
|
||||
.await
|
||||
.expect_err("descendant holds output pipe");
|
||||
assert!(error.contains("timed out after 5 seconds"), "{error}");
|
||||
tokio::time::sleep(Duration::from_millis(1300)).await;
|
||||
assert!(
|
||||
!marker.exists(),
|
||||
"descendant of exited probe leader survived cleanup"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn filesystem_preflight_cancellation_terminates_wrapper_descendants() {
|
||||
let temp = tempfile::tempdir().expect("wrapper installation");
|
||||
let ready = temp.path().join("child-ready");
|
||||
let marker = temp.path().join("survived-cancellation");
|
||||
fake_e2fs_tool(
|
||||
temp.path(),
|
||||
"mke2fs",
|
||||
&format!(
|
||||
"(echo ready > '{}'; /bin/sleep 6; echo survived > '{}') &\nwait",
|
||||
ready.display(),
|
||||
marker.display()
|
||||
),
|
||||
);
|
||||
let path = temp.path().to_path_buf();
|
||||
let probe = tokio::spawn(async move { preflight_in(&[path]).await });
|
||||
for _ in 0..400 {
|
||||
if ready.exists() {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
if !ready.exists() {
|
||||
probe.abort();
|
||||
let result = probe.await;
|
||||
panic!("probe descendant did not start: {result:?}");
|
||||
}
|
||||
probe.abort();
|
||||
assert!(probe.await.expect_err("cancelled task").is_cancelled());
|
||||
tokio::time::sleep(Duration::from_millis(6300)).await;
|
||||
assert!(!marker.exists(), "wrapper descendant survived cancellation");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filesystem_preflight_requires_expected_identity_and_version() {
|
||||
assert_eq!(
|
||||
supported_version("mke2fs", "mke2fs 1.43 (test)"),
|
||||
Some("1.43")
|
||||
);
|
||||
for output in [
|
||||
"other 1.47.4",
|
||||
"mke2fs unknown",
|
||||
"mke2fs 1.42.13",
|
||||
"mke2fs 1.47.garbage",
|
||||
"mke2fs 1.47.4.5",
|
||||
"",
|
||||
] {
|
||||
assert_eq!(supported_version("mke2fs", output), None, "{output}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,13 @@ use crate::proto::extension::v1::{PeerMetadata, ProtocolVersion};
|
||||
pub const PROTOCOL_MAJOR: u32 = 1;
|
||||
pub const PROTOCOL_MINOR: u32 = 0;
|
||||
|
||||
/// Capability for compute drivers that require gateway-minted launch credentials.
|
||||
///
|
||||
/// Drivers require this capability when every launch needs a
|
||||
/// [`crate::jwt::SandboxLaunchAuthentication`] bundle. The gateway checks its
|
||||
/// configured signer before accepting sandbox creation.
|
||||
pub const COMPUTE_LAUNCH_AUTHENTICATION: &str = "openshell.compute.launch-authentication";
|
||||
|
||||
const MAX_IMPLEMENTATION_NAME_BYTES: usize = 128;
|
||||
const MAX_IMPLEMENTATION_VERSION_BYTES: usize = 128;
|
||||
const MAX_CAPABILITY_BYTES: usize = 128;
|
||||
@@ -103,6 +110,10 @@ pub enum NegotiationError {
|
||||
#[must_use]
|
||||
pub fn gateway_metadata(family: ExtensionFamily) -> PeerMetadata {
|
||||
let contract = family.contract_capability();
|
||||
let mut supported_capabilities = vec![contract.clone()];
|
||||
if family == ExtensionFamily::Compute {
|
||||
supported_capabilities.push(COMPUTE_LAUNCH_AUTHENTICATION.to_string());
|
||||
}
|
||||
PeerMetadata {
|
||||
protocol_version: Some(ProtocolVersion {
|
||||
major: PROTOCOL_MAJOR,
|
||||
@@ -110,7 +121,7 @@ pub fn gateway_metadata(family: ExtensionFamily) -> PeerMetadata {
|
||||
}),
|
||||
implementation_name: "openshell/gateway".to_string(),
|
||||
implementation_version: crate::VERSION.to_string(),
|
||||
supported_capabilities: vec![contract.clone()],
|
||||
supported_capabilities,
|
||||
required_capabilities: vec![contract],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ pub mod denial;
|
||||
pub mod driver_mounts;
|
||||
pub mod driver_utils;
|
||||
pub mod dynamic_string_allowlist;
|
||||
#[cfg(unix)]
|
||||
pub mod e2fsprogs;
|
||||
pub mod endpoint_path;
|
||||
pub mod endpoint_status;
|
||||
pub mod error;
|
||||
|
||||
@@ -221,6 +221,10 @@ The requested sandbox image is never selected as the bootstrap image. Operators
|
||||
must configure either `bootstrap_image` or `default_image`; when both are empty,
|
||||
the driver fails during startup.
|
||||
|
||||
Image and writable-overlay preparation run in owned worker processes. Stopping or deleting a sandbox cancels its worker and formatter processes, waits for them to exit, then removes the attempt's temporary files under `<state-dir>/images/preparations/`. New overlays, including retries after an interrupted first start, are built there and renamed into sandbox state only after completion. A file lock inherited by the worker's children keeps cleanup from deleting files that a process still owns. If cleanup cannot establish that the processes have stopped, the operation returns an error and retains both temporary files and sandbox state.
|
||||
|
||||
At startup, the driver reclaims inactive attempts in this directory. It preserves active attempts, committed image caches, and unmarked staging from older releases. Concurrent preparations serialize image-cache publication; an interrupted attempt cannot publish a partial disk over an existing cache entry. Gateway upload slots for `rootfs_tar_path` keep their existing, separate lifecycle.
|
||||
|
||||
Each sandbox gets its own sparse writable
|
||||
`<state-dir>/sandboxes/<id>/overlay.ext4`. Guest init mounts overlayfs as `/`
|
||||
with the prepared image rootfs as lowerdir when present, otherwise the bootstrap
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -35,6 +35,9 @@ struct Args {
|
||||
#[arg(long, hide = true, default_value_t = false)]
|
||||
internal_run_vm: bool,
|
||||
|
||||
#[arg(long, hide = true)]
|
||||
internal_prepare_image: Option<PathBuf>,
|
||||
|
||||
#[arg(long = "vm-root-disk", hide = true, alias = "vm-rootfs")]
|
||||
vm_root_disk: Option<PathBuf>,
|
||||
|
||||
@@ -247,6 +250,12 @@ struct Args {
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let args = Args::parse();
|
||||
if let Some(request) = args.internal_prepare_image {
|
||||
openshell_driver_vm::driver::run_image_preparation_worker(&request)
|
||||
.await
|
||||
.map_err(|error| miette::miette!("{error}"))?;
|
||||
return Ok(());
|
||||
}
|
||||
if args.internal_run_vm {
|
||||
// The VM launcher arms procguard after resolving its runtime so its
|
||||
// libkrun worker cannot outlive the launcher.
|
||||
|
||||
@@ -0,0 +1,876 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! Own image-preparation processes and the files they may still be writing.
|
||||
//!
|
||||
//! A lease is inherited by every preparation subprocess. Cancellation first
|
||||
//! kills and reaps the worker; staging is removed only after the last lease
|
||||
//! descriptor closes. A restarted driver uses the same proof of inactivity.
|
||||
|
||||
#![allow(unsafe_code)]
|
||||
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
use std::io::{self, Read, Write};
|
||||
use std::os::fd::AsRawFd;
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{ExitStatus, Stdio};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use nix::sys::signal::{Signal, killpg};
|
||||
use nix::unistd::Pid;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::process::{Child, ChildStdout, Command};
|
||||
use tokio::sync::Mutex;
|
||||
use tonic::Status;
|
||||
|
||||
use super::{
|
||||
OverlayPreparation, RuntimeImagePlan, SandboxOwnerIdentity, VmDriverConfig,
|
||||
WorkloadIdentityRequest,
|
||||
};
|
||||
|
||||
const ATTEMPTS_DIR: &str = "preparations";
|
||||
const REQUEST_FILE: &str = "request.json";
|
||||
const LEASE_MARKER: &[u8] = b"openshell-image-preparation-v1\n";
|
||||
const CLEANUP_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(super) struct Request {
|
||||
pub config: VmDriverConfig,
|
||||
pub sandbox_id: String,
|
||||
pub image_ref: String,
|
||||
pub rootfs_tar: Option<PathBuf>,
|
||||
pub bootstrap_only: bool,
|
||||
pub overlay: Option<OverlayRequest>,
|
||||
pub lease_fd: i32,
|
||||
pub parent_pid: u32,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(super) struct OverlayRequest {
|
||||
pub source_disk: PathBuf,
|
||||
pub preparation: OverlayPreparation,
|
||||
pub requested_identity: Option<WorkloadIdentitySelectors>,
|
||||
}
|
||||
|
||||
/// Keep the requested selectors across the worker boundary. The worker must
|
||||
/// validate them against the persisted overlay owner before changing any files;
|
||||
/// the parent's current default identity cannot substitute for that owner.
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(super) struct WorkloadIdentitySelectors {
|
||||
user: String,
|
||||
group: String,
|
||||
}
|
||||
|
||||
impl From<&WorkloadIdentityRequest> for WorkloadIdentitySelectors {
|
||||
fn from(request: &WorkloadIdentityRequest) -> Self {
|
||||
Self {
|
||||
user: request.user.clone(),
|
||||
group: request.group.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<WorkloadIdentitySelectors> for WorkloadIdentityRequest {
|
||||
fn from(selectors: WorkloadIdentitySelectors) -> Self {
|
||||
Self {
|
||||
user: selectors.user,
|
||||
group: selectors.group,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub(super) enum Output {
|
||||
Images(RuntimeImagePlan),
|
||||
Overlay(SandboxOwnerIdentity),
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub(super) enum Message {
|
||||
Event(Vec<u8>),
|
||||
Complete(Result<Output, String>),
|
||||
}
|
||||
|
||||
pub(super) struct Attempt {
|
||||
pub directory: PathBuf,
|
||||
lease_path: PathBuf,
|
||||
lease: Option<File>,
|
||||
child: Option<Child>,
|
||||
}
|
||||
|
||||
/// Cleanup survives cancellation of the provisioning future itself. Lifecycle
|
||||
/// calls also await the same mutex, so they cannot report completion early.
|
||||
pub(super) struct CleanupOnDrop(pub Arc<Mutex<Attempt>>);
|
||||
|
||||
impl Drop for CleanupOnDrop {
|
||||
fn drop(&mut self) {
|
||||
let attempt = self.0.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(error) = attempt.lock().await.cleanup().await {
|
||||
tracing::warn!(%error, "image preparation cleanup incomplete; staging retained");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl Attempt {
|
||||
/// Create and lock the lease before publishing the directory. A concurrent
|
||||
/// reconciler can never observe this attempt without its ownership lock.
|
||||
pub fn create(cache_root: &Path) -> io::Result<Self> {
|
||||
let root = cache_root.join(ATTEMPTS_DIR);
|
||||
fs::create_dir_all(&root)?;
|
||||
let name = format!("attempt-{:032x}", rand::random::<u128>());
|
||||
let directory = root.join(&name);
|
||||
let lease_path = root.join(format!("{name}.lease"));
|
||||
let mut lease = OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.mode(0o600)
|
||||
.open(&lease_path)?;
|
||||
lock(&lease, false)?;
|
||||
lease.write_all(LEASE_MARKER)?;
|
||||
lease.sync_data()?;
|
||||
fs::create_dir(&directory)?;
|
||||
Ok(Self {
|
||||
directory,
|
||||
lease_path,
|
||||
lease: Some(lease),
|
||||
child: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Spawn while the caller holds the sandbox registry lock, then register
|
||||
/// this attempt before yielding. Stop/delete cannot miss a live worker.
|
||||
pub fn spawn(&mut self, launcher: &Path, mut request: Request) -> io::Result<ChildStdout> {
|
||||
let lease = self
|
||||
.lease
|
||||
.as_ref()
|
||||
.ok_or_else(|| io::Error::other("preparation lease is closed"))?;
|
||||
let fd = lease.as_raw_fd();
|
||||
request.lease_fd = fd;
|
||||
request.parent_pid = std::process::id();
|
||||
let request_path = self.directory.join(REQUEST_FILE);
|
||||
let mut file = OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.mode(0o600)
|
||||
.open(&request_path)?;
|
||||
serde_json::to_writer(&mut file, &request)?;
|
||||
file.flush()?;
|
||||
let mut command = Command::new(launcher);
|
||||
command.arg("--internal-prepare-image").arg(request_path);
|
||||
command
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::inherit());
|
||||
command.process_group(0).kill_on_drop(true);
|
||||
// SAFETY: the child only makes an async-signal-safe fcntl call. The
|
||||
// parent keeps this descriptor open until the child has exited.
|
||||
unsafe {
|
||||
command.pre_exec(move || {
|
||||
if libc::fcntl(fd, libc::F_SETFD, 0) == -1 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
let mut child = command.spawn()?;
|
||||
let stdout = child
|
||||
.stdout
|
||||
.take()
|
||||
.ok_or_else(|| io::Error::other("preparation stdout is missing"))?;
|
||||
self.child = Some(child);
|
||||
Ok(stdout)
|
||||
}
|
||||
|
||||
pub async fn wait(&mut self) -> io::Result<ExitStatus> {
|
||||
// EOF can precede observable process exit. Let normal worker teardown
|
||||
// finish before signaling, preserving its real status and reserving
|
||||
// its PID until any remaining descendants have been stopped.
|
||||
wait_for_worker_exit(self.child.as_ref().and_then(Child::id)).await?;
|
||||
self.kill_group()?;
|
||||
self.child
|
||||
.as_mut()
|
||||
.ok_or_else(|| io::Error::other("preparation worker is missing"))?
|
||||
.wait()
|
||||
.await
|
||||
}
|
||||
|
||||
/// Do not report successful cleanup while a formatter or other descendant
|
||||
/// still owns the lease. Preserve uncertain staging for the next reconcile.
|
||||
pub async fn cleanup(&mut self) -> Result<(), Status> {
|
||||
let signal_result = self.kill_group();
|
||||
#[cfg(target_os = "macos")]
|
||||
let signal_result = match signal_result {
|
||||
// Darwin can reject signaling an exiting process before waitid
|
||||
// exposes its terminal status. Keep immediate cancellation first,
|
||||
// then retry the same guarded signal only after observing exit.
|
||||
Err(error) if error.raw_os_error() == Some(libc::EPERM) => {
|
||||
wait_for_worker_exit(self.child.as_ref().and_then(Child::id))
|
||||
.await
|
||||
.and_then(|()| self.kill_group())
|
||||
}
|
||||
result => result,
|
||||
};
|
||||
signal_result
|
||||
.map_err(|error| Status::internal(format!("terminate image preparation: {error}")))?;
|
||||
if let Some(child) = self.child.as_mut() {
|
||||
tokio::time::timeout(CLEANUP_TIMEOUT, child.wait())
|
||||
.await
|
||||
.map_err(|_| {
|
||||
Status::deadline_exceeded("image preparation did not stop; staging retained")
|
||||
})?
|
||||
.map_err(|error| Status::internal(format!("reap image preparation: {error}")))?;
|
||||
}
|
||||
// Do not explicitly unlock: inherited descriptors must continue to
|
||||
// protect files until the last worker or descendant closes its copy.
|
||||
self.lease.take();
|
||||
let deadline = tokio::time::Instant::now() + CLEANUP_TIMEOUT;
|
||||
loop {
|
||||
match reclaim(&self.directory, &self.lease_path) {
|
||||
Ok(true) => return Ok(()),
|
||||
Ok(false) if tokio::time::Instant::now() < deadline => {
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
Ok(false) => {
|
||||
return Err(Status::deadline_exceeded(
|
||||
"image preparation descendants still own staging; files retained",
|
||||
));
|
||||
}
|
||||
Err(error) => {
|
||||
return Err(Status::internal(format!(
|
||||
"reclaim image preparation staging: {error}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// macOS may replace the staging lease when proving an exited process group
|
||||
// has no remaining owner; other platforms only read the worker state.
|
||||
#[cfg_attr(not(target_os = "macos"), allow(clippy::needless_pass_by_ref_mut))]
|
||||
fn kill_group(&mut self) -> io::Result<()> {
|
||||
if let Some(id) = self.child.as_ref().and_then(Child::id) {
|
||||
let pid = i32::try_from(id).map_err(io::Error::other)?;
|
||||
match killpg(Pid::from_raw(pid), Signal::SIGKILL) {
|
||||
Ok(()) | Err(nix::errno::Errno::ESRCH) => {}
|
||||
#[cfg(target_os = "macos")]
|
||||
Err(nix::errno::Errno::EPERM)
|
||||
if self.exited_worker_has_no_descendant_owner(id)? => {}
|
||||
Err(error) => return Err(io::Error::from_raw_os_error(error as i32)),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Darwin rejects signals to a group containing only an exited leader.
|
||||
/// Accept that case only while its PID remains reserved and no descendant
|
||||
/// owns the inherited staging lease. Live or uncertain ownership still fails.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn exited_worker_has_no_descendant_owner(&mut self, id: u32) -> io::Result<bool> {
|
||||
if !worker_has_exited(id)? {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// Open the probe before dropping our copy so a concurrent reconciler
|
||||
// cannot remove the lease pathname between release and open. Reacquire
|
||||
// ownership on success and retain it until the caller reaps the worker.
|
||||
let lease = OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.custom_flags(libc::O_NOFOLLOW)
|
||||
.open(&self.lease_path)?;
|
||||
self.lease.take();
|
||||
if !lock(&lease, true)? {
|
||||
return Ok(false);
|
||||
}
|
||||
self.lease = Some(lease);
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
/// Observe termination without releasing the PID or process-group identity.
|
||||
fn worker_has_exited(id: u32) -> io::Result<bool> {
|
||||
// SAFETY: waitid writes initialized storage. WNOWAIT leaves the owned
|
||||
// child unreaped, so its PID cannot be reused before descendant signaling.
|
||||
let mut status: libc::siginfo_t = unsafe { std::mem::zeroed() };
|
||||
if unsafe {
|
||||
libc::waitid(
|
||||
libc::P_PID,
|
||||
id,
|
||||
&raw mut status,
|
||||
libc::WEXITED | libc::WNOHANG | libc::WNOWAIT,
|
||||
)
|
||||
} != 0
|
||||
{
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
Ok(matches!(
|
||||
status.si_code,
|
||||
libc::CLD_EXITED | libc::CLD_KILLED | libc::CLD_DUMPED
|
||||
))
|
||||
}
|
||||
|
||||
/// Bound EOF-to-exit observation without blocking the runtime or reaping.
|
||||
/// Dropping this future leaves the worker available to cancellation cleanup.
|
||||
async fn wait_for_worker_exit(id: Option<u32>) -> io::Result<()> {
|
||||
let Some(id) = id else {
|
||||
return Ok(());
|
||||
};
|
||||
tokio::time::timeout(CLEANUP_TIMEOUT, async {
|
||||
loop {
|
||||
if worker_has_exited(id)? {
|
||||
return Ok(());
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|_| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::TimedOut,
|
||||
"image preparation worker did not exit before cleanup deadline",
|
||||
)
|
||||
})?
|
||||
}
|
||||
|
||||
impl Drop for Attempt {
|
||||
fn drop(&mut self) {
|
||||
// Runtime shutdown may prevent the asynchronous reaper from running.
|
||||
// Signal only this worker's still-unreaped process group; the lease
|
||||
// remains held by descendants until the kernel closes their files.
|
||||
let _ = self.kill_group();
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove only directories created by this protocol whose inherited lease has
|
||||
/// no remaining owner. Legacy staging and shared committed images are outside
|
||||
/// this namespace and are never inferred to be inactive from their age.
|
||||
pub(super) fn reconcile(cache_root: &Path) -> io::Result<()> {
|
||||
let root = cache_root.join(ATTEMPTS_DIR);
|
||||
let entries = match fs::read_dir(&root) {
|
||||
Ok(entries) => entries,
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()),
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
for entry in entries {
|
||||
let entry = entry?;
|
||||
let name = entry.file_name();
|
||||
let Some(name) = name.to_str().filter(|name| valid_attempt_name(name)) else {
|
||||
continue;
|
||||
};
|
||||
if !entry.file_type()?.is_dir() {
|
||||
continue;
|
||||
}
|
||||
if let Err(error) = reclaim(&entry.path(), &root.join(format!("{name}.lease"))) {
|
||||
tracing::warn!(path = %entry.path().display(), %error, "image preparation ownership uncertain; staging retained");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn valid_attempt_name(name: &str) -> bool {
|
||||
name.strip_prefix("attempt-").is_some_and(|suffix| {
|
||||
suffix.len() == 32 && suffix.bytes().all(|byte| byte.is_ascii_hexdigit())
|
||||
})
|
||||
}
|
||||
|
||||
fn reclaim(directory: &Path, lease_path: &Path) -> io::Result<bool> {
|
||||
if !directory.try_exists()? && !lease_path.try_exists()? {
|
||||
return Ok(true);
|
||||
}
|
||||
let lease = match OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.custom_flags(libc::O_NOFOLLOW)
|
||||
.open(lease_path)
|
||||
{
|
||||
Ok(lease) if lease.metadata()?.is_file() => lease,
|
||||
Ok(_) => return Ok(false),
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(false),
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
if !lock(&lease, true)? {
|
||||
return Ok(false);
|
||||
}
|
||||
let mut marker = Vec::new();
|
||||
(&lease).take(64).read_to_end(&mut marker)?;
|
||||
if marker != LEASE_MARKER {
|
||||
return Ok(false);
|
||||
}
|
||||
match fs::remove_dir_all(directory) {
|
||||
Ok(()) => {}
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
match fs::remove_file(lease_path) {
|
||||
Ok(()) => {}
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn lock(file: &File, nonblocking: bool) -> io::Result<bool> {
|
||||
let operation = libc::LOCK_EX | if nonblocking { libc::LOCK_NB } else { 0 };
|
||||
// SAFETY: flock receives a valid borrowed file descriptor and no pointers.
|
||||
if unsafe { libc::flock(file.as_raw_fd(), operation) } == 0 {
|
||||
return Ok(true);
|
||||
}
|
||||
let error = io::Error::last_os_error();
|
||||
if nonblocking && error.kind() == io::ErrorKind::WouldBlock {
|
||||
Ok(false)
|
||||
} else {
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn cache_lock(cache_root: &Path) -> io::Result<File> {
|
||||
let file = OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.mode(0o600)
|
||||
.custom_flags(libc::O_NOFOLLOW)
|
||||
.open(cache_root.join("preparation.lock"))?;
|
||||
lock(&file, false)?;
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
pub(super) fn read_request(path: &Path) -> Result<(Request, PathBuf), String> {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
let file = OpenOptions::new()
|
||||
.read(true)
|
||||
.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK)
|
||||
.open(path)
|
||||
.map_err(|error| error.to_string())?;
|
||||
let metadata = file.metadata().map_err(|error| error.to_string())?;
|
||||
if !metadata.is_file() || metadata.len() > 1024 * 1024 {
|
||||
return Err(
|
||||
"image preparation request must be a regular file no larger than 1 MiB".to_string(),
|
||||
);
|
||||
}
|
||||
let request: Request = serde_json::from_reader(file).map_err(|error| error.to_string())?;
|
||||
let directory = path
|
||||
.parent()
|
||||
.ok_or("preparation request has no directory")?;
|
||||
let name = directory
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.filter(|name| valid_attempt_name(name))
|
||||
.ok_or("invalid preparation directory name")?;
|
||||
let expected_root = super::image_cache_root_dir(&request.config.state_dir).join(ATTEMPTS_DIR);
|
||||
if path.file_name().and_then(|name| name.to_str()) != Some(REQUEST_FILE)
|
||||
|| directory.parent() != Some(expected_root.as_path())
|
||||
{
|
||||
return Err("preparation request is outside the driver staging directory".to_string());
|
||||
}
|
||||
let lease_file = OpenOptions::new()
|
||||
.read(true)
|
||||
.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK)
|
||||
.open(expected_root.join(format!("{name}.lease")))
|
||||
.map_err(|error| error.to_string())?;
|
||||
let lease = lease_file.metadata().map_err(|error| error.to_string())?;
|
||||
if !lease.is_file() {
|
||||
return Err("image preparation lease must be a regular file".to_string());
|
||||
}
|
||||
let mut marker = Vec::new();
|
||||
lease_file
|
||||
.take(64)
|
||||
.read_to_end(&mut marker)
|
||||
.map_err(|error| error.to_string())?;
|
||||
if marker != LEASE_MARKER {
|
||||
return Err("image preparation lease marker is invalid".to_string());
|
||||
}
|
||||
// SAFETY: fstat writes only to the supplied initialized stat storage. An
|
||||
// invalid inherited descriptor is rejected rather than taken into ownership.
|
||||
let mut inherited: libc::stat = unsafe { std::mem::zeroed() };
|
||||
if request.lease_fd < 3 || unsafe { libc::fstat(request.lease_fd, &raw mut inherited) } != 0 {
|
||||
return Err("image preparation lease was not inherited".to_string());
|
||||
}
|
||||
// Match MetadataExt::dev's representation: Darwin dev_t is signed, while
|
||||
// Linux dev_t is already u64. The cast intentionally preserves that API's
|
||||
// conversion, including the sign extension of a signed device identifier.
|
||||
#[allow(clippy::cast_sign_loss, trivial_numeric_casts)]
|
||||
let inherited_device = inherited.st_dev as u64;
|
||||
if inherited.st_ino != lease.ino() || inherited_device != lease.dev() {
|
||||
return Err("image preparation lease was not inherited".to_string());
|
||||
}
|
||||
Ok((request, directory.to_path_buf()))
|
||||
}
|
||||
|
||||
/// A process-group watcher also covers host utilities on Linux, where a
|
||||
/// parent-death signal on the worker alone cannot kill its children.
|
||||
pub(super) fn watch_parent(parent_pid: u32) -> Result<(), String> {
|
||||
let expected = i32::try_from(parent_pid).map_err(|error| error.to_string())?;
|
||||
if nix::unistd::getpgrp() != nix::unistd::getpid() {
|
||||
return Err("preparation worker must own its process group".to_string());
|
||||
}
|
||||
std::thread::Builder::new()
|
||||
.name("image-prep-parent".to_string())
|
||||
.spawn(move || {
|
||||
loop {
|
||||
if nix::unistd::getppid().as_raw() != expected {
|
||||
let _ = killpg(nix::unistd::getpgrp(), Signal::SIGKILL);
|
||||
std::process::exit(1);
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
})
|
||||
.map_err(|error| error.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn send(message: &Message) -> io::Result<()> {
|
||||
let mut output = io::stdout().lock();
|
||||
serde_json::to_writer(&mut output, message)?;
|
||||
output.write_all(b"\n")?;
|
||||
output.flush()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::os::unix::fs::{PermissionsExt, symlink};
|
||||
|
||||
fn request(root: &Path) -> Request {
|
||||
Request {
|
||||
config: VmDriverConfig {
|
||||
state_dir: root.to_path_buf(),
|
||||
..VmDriverConfig::default()
|
||||
},
|
||||
sandbox_id: "test-sandbox".to_string(),
|
||||
image_ref: "test-image".to_string(),
|
||||
rootfs_tar: None,
|
||||
bootstrap_only: false,
|
||||
overlay: None,
|
||||
lease_fd: -1,
|
||||
parent_pid: 0,
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_file(path: &Path) {
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
while !path.exists() {
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("worker readiness");
|
||||
}
|
||||
|
||||
async fn wait_for_worker_exit_without_reaping(attempt: &Attempt) {
|
||||
let id = attempt.child.as_ref().unwrap().id().unwrap();
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
loop {
|
||||
let exited = {
|
||||
// SAFETY: waitid writes initialized exit information and
|
||||
// WNOWAIT leaves the owned child's PID reserved for cleanup.
|
||||
let mut status: libc::siginfo_t = unsafe { std::mem::zeroed() };
|
||||
assert_eq!(
|
||||
unsafe {
|
||||
libc::waitid(
|
||||
libc::P_PID,
|
||||
id,
|
||||
&raw mut status,
|
||||
libc::WEXITED | libc::WNOHANG | libc::WNOWAIT,
|
||||
)
|
||||
},
|
||||
0
|
||||
);
|
||||
matches!(
|
||||
status.si_code,
|
||||
libc::CLD_EXITED | libc::CLD_KILLED | libc::CLD_DUMPED
|
||||
)
|
||||
};
|
||||
if exited {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("worker must exit without being reaped");
|
||||
}
|
||||
|
||||
async fn worker_at_stdout_eof(temp: &tempfile::TempDir, script: &str) -> Attempt {
|
||||
let launcher = temp.path().join("worker");
|
||||
fs::write(&launcher, script).unwrap();
|
||||
fs::set_permissions(&launcher, fs::Permissions::from_mode(0o700)).unwrap();
|
||||
let cache = super::super::image_cache_root_dir(temp.path());
|
||||
let mut attempt = Attempt::create(&cache).unwrap();
|
||||
let mut stdout = attempt.spawn(&launcher, request(temp.path())).unwrap();
|
||||
let mut output = Vec::new();
|
||||
tokio::time::timeout(
|
||||
Duration::from_secs(5),
|
||||
tokio::io::AsyncReadExt::read_to_end(&mut stdout, &mut output),
|
||||
)
|
||||
.await
|
||||
.expect("worker must close stdout")
|
||||
.unwrap();
|
||||
attempt
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stdout_eof_keeps_successful_worker_exit_status() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
// stdout can close before the kernel exposes the final exit status.
|
||||
// Keep that interval deterministic without requiring scheduler timing.
|
||||
let mut attempt =
|
||||
worker_at_stdout_eof(&temp, "#!/bin/sh\nexec 1>&-\nsleep 0.1\nexit 0\n").await;
|
||||
let status = attempt.wait().await;
|
||||
attempt.cleanup().await.expect("cleanup EOF worker");
|
||||
|
||||
let status = status.expect("wait after stdout EOF");
|
||||
assert!(status.success(), "worker exit after stdout EOF: {status}");
|
||||
assert!(!attempt.directory.exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stdout_eof_with_live_descendant_cleans_group_after_worker_exit() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let mut attempt = worker_at_stdout_eof(
|
||||
&temp,
|
||||
"#!/bin/sh\nsleep 300 >/dev/null &\nexec 1>&-\nsleep 0.1\nexit 0\n",
|
||||
)
|
||||
.await;
|
||||
let status = attempt.wait().await;
|
||||
attempt.cleanup().await.expect("stop inherited lease owner");
|
||||
|
||||
let status = status.expect("preserve leader status");
|
||||
assert!(status.success(), "worker exit with descendant: {status}");
|
||||
assert!(!attempt.directory.exists());
|
||||
assert!(attempt.child.as_ref().unwrap().id().is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancelling_stdout_eof_wait_leaves_worker_for_cleanup() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let mut attempt = worker_at_stdout_eof(&temp, "#!/bin/sh\nexec 1>&-\nsleep 300\n").await;
|
||||
let result = tokio::time::timeout(Duration::from_millis(50), attempt.wait()).await;
|
||||
attempt
|
||||
.cleanup()
|
||||
.await
|
||||
.expect("cancelled wait cleans worker");
|
||||
|
||||
assert!(result.is_err(), "waiting for exit must remain cancellable");
|
||||
assert!(!attempt.directory.exists());
|
||||
assert!(attempt.child.as_ref().unwrap().id().is_none());
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[tokio::test]
|
||||
async fn cleanup_after_stdout_eof_waits_for_exit_publication() {
|
||||
// Repeated natural exits exercise Darwin's short interval between
|
||||
// closing stdout and making terminal status observable to waitid.
|
||||
for _ in 0..8 {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let mut attempt = worker_at_stdout_eof(&temp, "#!/bin/sh\nexit 0\n").await;
|
||||
let result = attempt.cleanup().await;
|
||||
if result.is_err() {
|
||||
// Reap this owned worker even when checking faulty behavior.
|
||||
attempt.cleanup().await.expect("retry owned test cleanup");
|
||||
}
|
||||
result.expect("natural EOF must not fail cancellation cleanup");
|
||||
assert!(!attempt.directory.exists());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn completed_worker_without_descendants_keeps_successful_exit_status() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let cache = super::super::image_cache_root_dir(temp.path());
|
||||
let launcher = temp.path().join("worker");
|
||||
fs::write(&launcher, "#!/bin/sh\nexit 0\n").unwrap();
|
||||
fs::set_permissions(&launcher, fs::Permissions::from_mode(0o700)).unwrap();
|
||||
let mut attempt = Attempt::create(&cache).unwrap();
|
||||
let directory = attempt.directory.clone();
|
||||
let _stdout = attempt.spawn(&launcher, request(temp.path())).unwrap();
|
||||
wait_for_worker_exit_without_reaping(&attempt).await;
|
||||
|
||||
// macOS rejects killpg for a group containing only a zombie. The
|
||||
// completed worker must still return its original successful status.
|
||||
assert!(
|
||||
attempt
|
||||
.wait()
|
||||
.await
|
||||
.expect("completed worker status")
|
||||
.success()
|
||||
);
|
||||
attempt.cleanup().await.expect("reclaim completed attempt");
|
||||
assert!(!directory.exists());
|
||||
assert!(attempt.child.as_ref().unwrap().id().is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn exited_worker_with_live_descendant_stops_writer_before_cleanup() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let cache = super::super::image_cache_root_dir(temp.path());
|
||||
let launcher = temp.path().join("worker");
|
||||
fs::write(&launcher, "#!/bin/sh\nsleep 300 &\nexit 0\n").unwrap();
|
||||
fs::set_permissions(&launcher, fs::Permissions::from_mode(0o700)).unwrap();
|
||||
let mut attempt = Attempt::create(&cache).unwrap();
|
||||
let directory = attempt.directory.clone();
|
||||
let _stdout = attempt.spawn(&launcher, request(temp.path())).unwrap();
|
||||
wait_for_worker_exit_without_reaping(&attempt).await;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let id = attempt.child.as_ref().unwrap().id().unwrap();
|
||||
assert!(
|
||||
!attempt.exited_worker_has_no_descendant_owner(id).unwrap(),
|
||||
"an exited leader cannot prove its live descendant stopped"
|
||||
);
|
||||
assert!(directory.exists());
|
||||
}
|
||||
attempt
|
||||
.cleanup()
|
||||
.await
|
||||
.expect("kill the live descendant before reclaiming its staging");
|
||||
assert!(!directory.exists());
|
||||
assert!(attempt.child.as_ref().unwrap().id().is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cleanup_kills_and_reaps_worker_and_formatter_before_removing_staging() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let cache = super::super::image_cache_root_dir(temp.path());
|
||||
let launcher = temp.path().join("worker");
|
||||
// The shell and the simulated formatter both inherit the attempt lease.
|
||||
// Killing only the shell leaves the lease locked and makes cleanup fail.
|
||||
fs::write(&launcher, "#!/bin/sh\nroot=$(dirname \"$2\")\nsleep 300 &\nprintf '%s' \"$!\" > \"$root/ready\"\nwait\n").unwrap();
|
||||
fs::set_permissions(&launcher, fs::Permissions::from_mode(0o700)).unwrap();
|
||||
let mut attempt = Attempt::create(&cache).unwrap();
|
||||
let directory = attempt.directory.clone();
|
||||
let _stdout = attempt.spawn(&launcher, request(temp.path())).unwrap();
|
||||
wait_for_file(&directory.join("ready")).await;
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let id = attempt.child.as_ref().unwrap().id().unwrap();
|
||||
assert!(!attempt.exited_worker_has_no_descendant_owner(id).unwrap());
|
||||
assert!(attempt.lease.is_some(), "a live worker retains its lease");
|
||||
}
|
||||
reconcile(&cache).unwrap();
|
||||
assert!(directory.exists(), "a live formatter protects its staging");
|
||||
attempt
|
||||
.cleanup()
|
||||
.await
|
||||
.expect("stop and reclaim preparation");
|
||||
assert!(!directory.exists());
|
||||
assert!(!attempt.lease_path.exists());
|
||||
assert!(
|
||||
attempt
|
||||
.child
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.try_wait()
|
||||
.unwrap()
|
||||
.is_some()
|
||||
);
|
||||
attempt.cleanup().await.expect("cleanup is idempotent");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn aborting_provisioning_still_runs_cleanup() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let cache = super::super::image_cache_root_dir(temp.path());
|
||||
let attempt = Arc::new(Mutex::new(Attempt::create(&cache).unwrap()));
|
||||
let directory = attempt.lock().await.directory.clone();
|
||||
fs::write(directory.join("partial.ext4"), b"unfinished image").unwrap();
|
||||
let (ready, started) = tokio::sync::oneshot::channel();
|
||||
let task = tokio::spawn({
|
||||
let attempt = attempt.clone();
|
||||
async move {
|
||||
let _cleanup = CleanupOnDrop(attempt);
|
||||
ready.send(()).unwrap();
|
||||
std::future::pending::<()>().await;
|
||||
}
|
||||
});
|
||||
started.await.unwrap();
|
||||
task.abort();
|
||||
assert!(task.await.unwrap_err().is_cancelled());
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
while directory.exists() {
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("cancelled future must reclaim its staging");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restart_reclaims_inactive_attempts_and_preserves_active_shared_and_unknown_data() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let cache = super::super::image_cache_root_dir(temp.path());
|
||||
let inactive = Attempt::create(&cache).unwrap();
|
||||
let inactive_directory = inactive.directory.clone();
|
||||
fs::write(inactive_directory.join("partial.ext4"), b"unfinished").unwrap();
|
||||
drop(inactive);
|
||||
let active = Attempt::create(&cache).unwrap();
|
||||
let committed = cache.join("shared-image");
|
||||
fs::create_dir(&committed).unwrap();
|
||||
fs::write(committed.join("rootfs.ext4"), b"valid shared image").unwrap();
|
||||
let legacy = cache.join("image.staging-legacy");
|
||||
fs::create_dir(&legacy).unwrap();
|
||||
let unmarked = cache.join(ATTEMPTS_DIR).join(format!("attempt-{:032x}", 1));
|
||||
fs::create_dir(&unmarked).unwrap();
|
||||
let link = cache.join(ATTEMPTS_DIR).join(format!("attempt-{:032x}", 2));
|
||||
symlink(&committed, &link).unwrap();
|
||||
fs::write(unmarked.with_extension("lease"), b"unrelated lock").unwrap();
|
||||
|
||||
reconcile(&cache).unwrap();
|
||||
|
||||
assert!(!inactive_directory.exists());
|
||||
assert!(active.directory.exists());
|
||||
assert_eq!(
|
||||
fs::read(committed.join("rootfs.ext4")).unwrap(),
|
||||
b"valid shared image"
|
||||
);
|
||||
assert!(legacy.exists());
|
||||
assert!(unmarked.exists());
|
||||
assert!(link.is_symlink());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn separate_workers_serialize_cache_publication() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let cache = temp.path().to_path_buf();
|
||||
let first = cache_lock(&cache).unwrap();
|
||||
let (entered, mut receiver) = tokio::sync::mpsc::channel(1);
|
||||
let second = tokio::task::spawn_blocking(move || {
|
||||
let _second = cache_lock(&cache).unwrap();
|
||||
entered.blocking_send(()).unwrap();
|
||||
});
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_millis(50), receiver.recv())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
drop(first);
|
||||
tokio::time::timeout(Duration::from_secs(5), receiver.recv())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
second.await.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_rejects_request_without_inherited_lease() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let cache = super::super::image_cache_root_dir(temp.path());
|
||||
let attempt = Attempt::create(&cache).unwrap();
|
||||
let path = attempt.directory.join(REQUEST_FILE);
|
||||
fs::write(&path, serde_json::to_vec(&request(temp.path())).unwrap()).unwrap();
|
||||
let error = read_request(&path)
|
||||
.err()
|
||||
.expect("missing inherited fd rejected");
|
||||
assert!(error.contains("lease was not inherited"));
|
||||
}
|
||||
}
|
||||
@@ -370,36 +370,20 @@ pub fn set_rootfs_image_file_mode(
|
||||
/// Replay the ext4 journal and repair automatically correctable filesystem
|
||||
/// state before the driver mutates a preserved guest disk offline.
|
||||
pub fn recover_rootfs_image(image_path: &Path) -> Result<(), String> {
|
||||
let mut failures = Vec::new();
|
||||
let mut unavailable = Vec::new();
|
||||
|
||||
for candidate in e2fs_tool_candidates("e2fsck") {
|
||||
let label = candidate.display().to_string();
|
||||
match Command::new(&candidate)
|
||||
.arg("-p")
|
||||
.arg("-f")
|
||||
.arg(image_path)
|
||||
.output()
|
||||
{
|
||||
Ok(output) if matches!(output.status.code(), Some(0..=2)) => return Ok(()),
|
||||
Ok(output) => failures.push(format!(
|
||||
"{label} failed with status {}\nstdout: {}\nstderr: {}",
|
||||
output.status,
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
)),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
unavailable.push(format!("{label} not found"));
|
||||
}
|
||||
Err(error) => failures.push(format!("run {label}: {error}")),
|
||||
}
|
||||
let mut command = e2fs_command("e2fsck")?;
|
||||
let path = PathBuf::from(command.get_program());
|
||||
let output = command.arg("-p").arg("-f").arg(image_path).output();
|
||||
match output {
|
||||
Ok(output) if matches!(output.status.code(), Some(0..=2)) => Ok(()),
|
||||
Ok(output) => Err(format!(
|
||||
"{} failed with status {}\nstdout: {}\nstderr: {}",
|
||||
path.display(),
|
||||
output.status,
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
)),
|
||||
Err(error) => Err(format!("run {}: {error}", path.display())),
|
||||
}
|
||||
|
||||
Err(if failures.is_empty() {
|
||||
unavailable.join("\n")
|
||||
} else {
|
||||
failures.join("\n")
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -668,75 +652,33 @@ fn round_up_to_mib(bytes: u64) -> u64 {
|
||||
bytes.div_ceil(MIB) * MIB
|
||||
}
|
||||
|
||||
enum FormatterAttempt {
|
||||
Succeeded,
|
||||
Failed(String),
|
||||
Unavailable(String),
|
||||
}
|
||||
|
||||
fn format_ext4_image_from_dir(source: &Path, image_path: &Path) -> Result<(), String> {
|
||||
let candidates = ["mke2fs", "mkfs.ext4"]
|
||||
.into_iter()
|
||||
.flat_map(e2fs_tool_candidates);
|
||||
run_ext4_formatter_candidates(candidates, |candidate| {
|
||||
let label = candidate.display().to_string();
|
||||
let output = Command::new(candidate)
|
||||
.arg("-q")
|
||||
.arg("-F")
|
||||
.arg("-t")
|
||||
.arg("ext4")
|
||||
.arg("-E")
|
||||
.arg("root_owner=0:0")
|
||||
.arg("-d")
|
||||
.arg(source)
|
||||
.arg(image_path)
|
||||
.output();
|
||||
match output {
|
||||
Ok(output) if output.status.success() => FormatterAttempt::Succeeded,
|
||||
Ok(output) => FormatterAttempt::Failed(format!(
|
||||
"{label} failed with status {}\nstdout: {}\nstderr: {}",
|
||||
output.status,
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
)),
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
|
||||
FormatterAttempt::Unavailable(format!("{label} not found"))
|
||||
}
|
||||
Err(err) => FormatterAttempt::Failed(format!("run {label}: {err}")),
|
||||
}
|
||||
})
|
||||
.map_err(|details| {
|
||||
format!(
|
||||
"failed to create ext4 rootfs image from {}: {details}. Install e2fsprogs (mke2fs/mkfs.ext4) and retry",
|
||||
source.display()
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn run_ext4_formatter_candidates(
|
||||
candidates: impl IntoIterator<Item = PathBuf>,
|
||||
mut run: impl FnMut(&Path) -> FormatterAttempt,
|
||||
) -> Result<(), String> {
|
||||
let mut failures = Vec::new();
|
||||
let mut unavailable = Vec::new();
|
||||
|
||||
for candidate in candidates {
|
||||
match run(&candidate) {
|
||||
FormatterAttempt::Succeeded => return Ok(()),
|
||||
FormatterAttempt::Failed(error) => failures.push(error),
|
||||
FormatterAttempt::Unavailable(error) => unavailable.push(error),
|
||||
}
|
||||
}
|
||||
|
||||
if failures.is_empty() {
|
||||
Err(if unavailable.is_empty() {
|
||||
"no ext4 formatter candidates configured".to_string()
|
||||
} else {
|
||||
unavailable.join("\n")
|
||||
})
|
||||
} else {
|
||||
Err(failures.join("\n"))
|
||||
let path = openshell_core::e2fsprogs::resolve(&["mke2fs", "mkfs.ext4"])?;
|
||||
let output = Command::new(&path)
|
||||
.arg("-q")
|
||||
.arg("-F")
|
||||
.arg("-t")
|
||||
.arg("ext4")
|
||||
.arg("-E")
|
||||
.arg("root_owner=0:0")
|
||||
.arg("-d")
|
||||
.arg(source)
|
||||
.arg(image_path)
|
||||
.output()
|
||||
.map_err(|error| format!("run {}: {error}", path.display()))?;
|
||||
if output.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
// A selected formatter failure must retain its diagnostics. Retrying with
|
||||
// another installation can overwrite a partial image and hide the cause.
|
||||
Err(format!(
|
||||
"failed to create ext4 rootfs image from {}: {} failed with status {}\nstdout: {}\nstderr: {}",
|
||||
source.display(),
|
||||
path.display(),
|
||||
output.status,
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
))
|
||||
}
|
||||
|
||||
fn ensure_rootfs_image_parent_dirs(image_path: &Path, guest_path: &str) {
|
||||
@@ -885,52 +827,40 @@ pub fn ext4_image_has_directory(image_path: &Path, guest_path: &str) -> Result<b
|
||||
let quoted_path = debugfs_quote_absolute_path(guest_path)
|
||||
.ok_or_else(|| format!("invalid debugfs guest path '{guest_path}'"))?;
|
||||
let command = format!("stat {quoted_path}");
|
||||
let mut last_error = None;
|
||||
|
||||
for candidate in e2fs_tool_candidates("debugfs") {
|
||||
let label = candidate.display().to_string();
|
||||
match Command::new(&candidate)
|
||||
.arg("-R")
|
||||
.arg(&command)
|
||||
.arg(image_path)
|
||||
.output()
|
||||
{
|
||||
Ok(output) if output.status.success() => {
|
||||
// debugfs exits 0 whether or not the path exists; the answer
|
||||
// is only in its output.
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
if stdout.contains("Type: directory") {
|
||||
return Ok(true);
|
||||
}
|
||||
if stdout.contains("Type: ") || stderr.contains("File not found") {
|
||||
return Ok(false);
|
||||
}
|
||||
return Err(format!(
|
||||
"debugfs command '{command}' produced unrecognized output for {}\nstdout: {stdout}\nstderr: {stderr}",
|
||||
image_path.display()
|
||||
));
|
||||
let output = e2fs_command("debugfs")?
|
||||
.arg("-R")
|
||||
.arg(&command)
|
||||
.arg(image_path)
|
||||
.output();
|
||||
match output {
|
||||
Ok(output) if output.status.success() => {
|
||||
// debugfs exits 0 whether or not the path exists; the answer
|
||||
// is only in its output.
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
if stdout.contains("Type: directory") {
|
||||
return Ok(true);
|
||||
}
|
||||
Ok(output) => {
|
||||
last_error = Some(format!(
|
||||
"{label} failed with status {}\nstdout: {}\nstderr: {}",
|
||||
output.status,
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
));
|
||||
if stdout.contains("Type: ") || stderr.contains("File not found") {
|
||||
return Ok(false);
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
last_error = Some(format!("{label} not found"));
|
||||
}
|
||||
Err(error) => last_error = Some(format!("run {label}: {error}")),
|
||||
Err(format!(
|
||||
"debugfs command '{command}' produced unrecognized output for {}\nstdout: {stdout}\nstderr: {stderr}",
|
||||
image_path.display()
|
||||
))
|
||||
}
|
||||
Ok(output) => Err(format!(
|
||||
"debugfs command '{command}' failed for {}: debugfs failed with status {}\nstdout: {}\nstderr: {}. Install e2fsprogs (debugfs) and retry",
|
||||
image_path.display(),
|
||||
output.status,
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
)),
|
||||
Err(error) => Err(format!(
|
||||
"debugfs command '{command}' failed for {}: {error}. Install e2fsprogs (debugfs) and retry",
|
||||
image_path.display()
|
||||
)),
|
||||
}
|
||||
|
||||
Err(format!(
|
||||
"debugfs command '{command}' failed for {}: {}. Install e2fsprogs (debugfs) and retry",
|
||||
image_path.display(),
|
||||
last_error.unwrap_or_else(|| "debugfs not found".to_string())
|
||||
))
|
||||
}
|
||||
|
||||
fn sandbox_guest_user_ids_from_image_path(
|
||||
@@ -949,45 +879,33 @@ fn sandbox_guest_user_ids_from_image_path(
|
||||
let quoted_path = debugfs_quote_absolute_path(guest_path)
|
||||
.expect("the static passwd path is a valid debugfs path");
|
||||
let command = format!("cat {quoted_path}");
|
||||
let mut last_error = None;
|
||||
|
||||
for candidate in e2fs_tool_candidates("debugfs") {
|
||||
let label = candidate.display().to_string();
|
||||
match Command::new(&candidate)
|
||||
.arg("-R")
|
||||
.arg(&command)
|
||||
.arg(image_path)
|
||||
.output()
|
||||
{
|
||||
Ok(output) if output.status.success() => {
|
||||
let passwd = String::from_utf8(output.stdout).map_err(|error| {
|
||||
format!(
|
||||
"read {guest_path} from {} as UTF-8: {error}",
|
||||
image_path.display()
|
||||
)
|
||||
})?;
|
||||
return parse_sandbox_guest_user_ids(&passwd, &image_path.display().to_string());
|
||||
}
|
||||
Ok(output) => {
|
||||
last_error = Some(format!(
|
||||
"{label} failed with status {}\nstdout: {}\nstderr: {}",
|
||||
output.status,
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
));
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
last_error = Some(format!("{label} not found"));
|
||||
}
|
||||
Err(error) => last_error = Some(format!("run {label}: {error}")),
|
||||
let output = e2fs_command("debugfs")?
|
||||
.arg("-R")
|
||||
.arg(&command)
|
||||
.arg(image_path)
|
||||
.output();
|
||||
match output {
|
||||
Ok(output) if output.status.success() => {
|
||||
let passwd = String::from_utf8(output.stdout).map_err(|error| {
|
||||
format!(
|
||||
"read {guest_path} from {} as UTF-8: {error}",
|
||||
image_path.display()
|
||||
)
|
||||
})?;
|
||||
parse_sandbox_guest_user_ids(&passwd, &image_path.display().to_string())
|
||||
}
|
||||
Ok(output) => Err(format!(
|
||||
"debugfs command '{command}' failed for {}: debugfs failed with status {}\nstdout: {}\nstderr: {}. Install e2fsprogs (debugfs) and retry",
|
||||
image_path.display(),
|
||||
output.status,
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
)),
|
||||
Err(error) => Err(format!(
|
||||
"debugfs command '{command}' failed for {}: {error}. Install e2fsprogs (debugfs) and retry",
|
||||
image_path.display()
|
||||
)),
|
||||
}
|
||||
|
||||
Err(format!(
|
||||
"debugfs command '{command}' failed for {}: {}. Install e2fsprogs (debugfs) and retry",
|
||||
image_path.display(),
|
||||
last_error.unwrap_or_else(|| "debugfs not found".to_string())
|
||||
))
|
||||
}
|
||||
|
||||
fn sandbox_guest_user_ids(rootfs: &Path) -> Result<Option<(u32, u32)>, String> {
|
||||
@@ -1037,83 +955,57 @@ fn run_debugfs_batch(image_path: &Path, commands: &[String]) -> Result<(), Strin
|
||||
}
|
||||
|
||||
fn run_debugfs_batch_file(image_path: &Path, command_path: &Path) -> Result<(), String> {
|
||||
let mut last_error = None;
|
||||
for candidate in e2fs_tool_candidates("debugfs") {
|
||||
let label = candidate.display().to_string();
|
||||
let output = Command::new(&candidate)
|
||||
.arg("-w")
|
||||
.arg("-f")
|
||||
.arg(command_path)
|
||||
.arg(image_path)
|
||||
.output();
|
||||
match output {
|
||||
Ok(output) if output.status.success() => return Ok(()),
|
||||
Ok(output) => {
|
||||
last_error = Some(format!(
|
||||
"{label} failed with status {}\nstdout: {}\nstderr: {}",
|
||||
output.status,
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
));
|
||||
}
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
|
||||
last_error = Some(format!("{label} not found"));
|
||||
}
|
||||
Err(err) => {
|
||||
last_error = Some(format!("run {label}: {err}"));
|
||||
}
|
||||
}
|
||||
let output = e2fs_command("debugfs")?
|
||||
.arg("-w")
|
||||
.arg("-f")
|
||||
.arg(command_path)
|
||||
.arg(image_path)
|
||||
.output();
|
||||
match output {
|
||||
Ok(output) if output.status.success() => Ok(()),
|
||||
Ok(output) => Err(format!(
|
||||
"debugfs batch {} failed for {}: debugfs failed with status {}\nstdout: {}\nstderr: {}. Install e2fsprogs (debugfs) and retry",
|
||||
command_path.display(),
|
||||
image_path.display(),
|
||||
output.status,
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
)),
|
||||
Err(error) => Err(format!(
|
||||
"debugfs batch {} failed for {}: {error}. Install e2fsprogs (debugfs) and retry",
|
||||
command_path.display(),
|
||||
image_path.display()
|
||||
)),
|
||||
}
|
||||
Err(format!(
|
||||
"debugfs batch {} failed for {}: {}. Install e2fsprogs (debugfs) and retry",
|
||||
command_path.display(),
|
||||
image_path.display(),
|
||||
last_error.unwrap_or_else(|| "debugfs not found".to_string())
|
||||
))
|
||||
}
|
||||
|
||||
fn run_debugfs(image_path: &Path, command: &str) -> Result<(), String> {
|
||||
let mut last_error = None;
|
||||
for candidate in e2fs_tool_candidates("debugfs") {
|
||||
let label = candidate.display().to_string();
|
||||
let output = Command::new(&candidate)
|
||||
.arg("-w")
|
||||
.arg("-R")
|
||||
.arg(command)
|
||||
.arg(image_path)
|
||||
.output();
|
||||
match output {
|
||||
Ok(output) if output.status.success() => return Ok(()),
|
||||
Ok(output) => {
|
||||
last_error = Some(format!(
|
||||
"{label} failed with status {}\nstdout: {}\nstderr: {}",
|
||||
output.status,
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
));
|
||||
}
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
|
||||
last_error = Some(format!("{label} not found"));
|
||||
}
|
||||
Err(err) => {
|
||||
last_error = Some(format!("run {label}: {err}"));
|
||||
}
|
||||
}
|
||||
let output = e2fs_command("debugfs")?
|
||||
.arg("-w")
|
||||
.arg("-R")
|
||||
.arg(command)
|
||||
.arg(image_path)
|
||||
.output();
|
||||
match output {
|
||||
Ok(output) if output.status.success() => Ok(()),
|
||||
Ok(output) => Err(format!(
|
||||
"debugfs command '{command}' failed for {}: debugfs failed with status {}\nstdout: {}\nstderr: {}. Install e2fsprogs (debugfs) and retry",
|
||||
image_path.display(),
|
||||
output.status,
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
)),
|
||||
Err(error) => Err(format!(
|
||||
"debugfs command '{command}' failed for {}: {error}. Install e2fsprogs (debugfs) and retry",
|
||||
image_path.display()
|
||||
)),
|
||||
}
|
||||
Err(format!(
|
||||
"debugfs command '{command}' failed for {}: {}. Install e2fsprogs (debugfs) and retry",
|
||||
image_path.display(),
|
||||
last_error.unwrap_or_else(|| "debugfs not found".to_string())
|
||||
))
|
||||
}
|
||||
|
||||
fn e2fs_tool_candidates(tool: &str) -> Vec<PathBuf> {
|
||||
let mut candidates = vec![PathBuf::from(tool)];
|
||||
for root in ["/opt/homebrew/opt/e2fsprogs", "/usr/local/opt/e2fsprogs"] {
|
||||
candidates.push(Path::new(root).join("sbin").join(tool));
|
||||
candidates.push(Path::new(root).join("bin").join(tool));
|
||||
}
|
||||
candidates
|
||||
fn e2fs_command(tool: &str) -> Result<Command, String> {
|
||||
// Preflight and image operations must execute the same selected file with
|
||||
// the same inherited environment, including when PATH is restricted.
|
||||
Ok(Command::new(openshell_core::e2fsprogs::resolve(&[tool])?))
|
||||
}
|
||||
|
||||
fn temporary_injection_path(image_path: &Path) -> PathBuf {
|
||||
@@ -1558,10 +1450,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn recover_rootfs_image_accepts_clean_ext4_image() {
|
||||
if !e2fs_tool_candidates("e2fsck")
|
||||
.iter()
|
||||
.any(|candidate| Command::new(candidate).arg("-V").output().is_ok())
|
||||
{
|
||||
if e2fs_command("e2fsck").is_err() {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1580,10 +1469,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn ext4_image_has_directory_distinguishes_directories_files_and_missing_paths() {
|
||||
if !e2fs_tool_candidates("debugfs")
|
||||
.iter()
|
||||
.any(|candidate| Command::new(candidate).arg("-V").output().is_ok())
|
||||
{
|
||||
if e2fs_command("debugfs").is_err() {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1668,58 +1554,90 @@ mod tests {
|
||||
assert_eq!(debugfs_quote_argument("/tmp/bad\npath"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formatter_candidates_preserve_executed_failure_over_missing_fallback() {
|
||||
let candidates = vec![PathBuf::from("mke2fs"), PathBuf::from("missing")];
|
||||
|
||||
let err = run_ext4_formatter_candidates(candidates, |candidate| {
|
||||
if candidate == Path::new("mke2fs") {
|
||||
FormatterAttempt::Failed(
|
||||
"mke2fs failed with status 1\nstdout: formatter output\nstderr: no space left"
|
||||
.to_string(),
|
||||
)
|
||||
} else {
|
||||
FormatterAttempt::Unavailable("missing not found".to_string())
|
||||
}
|
||||
})
|
||||
.expect_err("formatter should fail");
|
||||
|
||||
assert!(err.contains("mke2fs failed with status 1"));
|
||||
assert!(err.contains("no space left"));
|
||||
assert!(!err.contains("missing not found"));
|
||||
fn run_tool_fixture_test(test: &str, directory: &Path) {
|
||||
// Isolate PATH in a child test process. Other tests may prepare images
|
||||
// concurrently and must never observe this deliberately broken tool.
|
||||
let output = Command::new(std::env::current_exe().expect("test executable"))
|
||||
.args(["--exact", test, "--nocapture"])
|
||||
.env("PATH", directory)
|
||||
.env("OPENSHELL_ROOTFS_TOOL_TEST_ROOT", directory)
|
||||
.output()
|
||||
.expect("isolated tool test");
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"stdout: {}\nstderr: {}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formatter_candidates_report_all_missing_tools() {
|
||||
let candidates = vec![PathBuf::from("mke2fs"), PathBuf::from("mkfs.ext4")];
|
||||
fn formatter_preserves_selected_tool_failure_without_retry() {
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
|
||||
let err = run_ext4_formatter_candidates(candidates, |candidate| {
|
||||
FormatterAttempt::Unavailable(format!("{} not found", candidate.display()))
|
||||
})
|
||||
.expect_err("formatter should be unavailable");
|
||||
|
||||
assert!(err.contains("mke2fs not found"));
|
||||
assert!(err.contains("mkfs.ext4 not found"));
|
||||
if let Some(directory) = std::env::var_os("OPENSHELL_ROOTFS_TOOL_TEST_ROOT") {
|
||||
let directory = PathBuf::from(directory);
|
||||
let image = directory.join("rootfs.ext4");
|
||||
File::create(&image)
|
||||
.expect("image")
|
||||
.set_len(16 * 1024 * 1024)
|
||||
.expect("image size");
|
||||
let source = directory.join("source");
|
||||
fs::create_dir(&source).expect("source directory");
|
||||
let error = format_ext4_image_from_dir(&source, &image)
|
||||
.expect_err("the selected formatter failure must not select another installation");
|
||||
assert!(error.contains("selected-formatter-failed"), "{error}");
|
||||
assert!(error.contains("42"), "{error}");
|
||||
return;
|
||||
}
|
||||
let directory = tempfile::tempdir().expect("tool installation");
|
||||
for (name, body) in [
|
||||
("mke2fs", "echo selected-formatter-failed >&2\nexit 42"),
|
||||
("mkfs.ext4", "exit 0"),
|
||||
] {
|
||||
let path = directory.path().join(name);
|
||||
fs::write(&path, format!("#!/bin/sh\n{body}\n")).expect("tool fixture");
|
||||
fs::set_permissions(path, fs::Permissions::from_mode(0o755)).expect("executable");
|
||||
}
|
||||
run_tool_fixture_test(
|
||||
"rootfs::tests::formatter_preserves_selected_tool_failure_without_retry",
|
||||
directory.path(),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formatter_candidates_accept_successful_fallback() {
|
||||
let candidates = vec![PathBuf::from("first"), PathBuf::from("second")];
|
||||
let mut attempted = Vec::new();
|
||||
fn recovery_preserves_selected_path_and_execution_errors() {
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
|
||||
run_ext4_formatter_candidates(candidates, |candidate| {
|
||||
attempted.push(candidate.to_path_buf());
|
||||
if candidate == Path::new("second") {
|
||||
FormatterAttempt::Succeeded
|
||||
} else {
|
||||
FormatterAttempt::Failed("first failed".to_string())
|
||||
if let Some(directory) = std::env::var_os("OPENSHELL_ROOTFS_TOOL_TEST_ROOT") {
|
||||
let directory = PathBuf::from(directory);
|
||||
let path = directory.join("e2fsck");
|
||||
for (script, expected) in [
|
||||
(
|
||||
"#!/nonexistent/e2fsprogs-interpreter\n",
|
||||
"No such file or directory",
|
||||
),
|
||||
(
|
||||
"#!/bin/sh\necho recovery-failed >&2\nexit 4\n",
|
||||
"recovery-failed",
|
||||
),
|
||||
] {
|
||||
fs::write(&path, script).expect("recovery tool");
|
||||
fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).expect("executable");
|
||||
let error = recover_rootfs_image(&directory.join("overlay.ext4"))
|
||||
.expect_err("recovery failure");
|
||||
assert!(
|
||||
error.contains(path.canonicalize().unwrap().to_str().unwrap()),
|
||||
"{error}"
|
||||
);
|
||||
assert!(error.contains(expected), "{error}");
|
||||
}
|
||||
})
|
||||
.expect("fallback should succeed");
|
||||
|
||||
assert_eq!(
|
||||
attempted,
|
||||
vec![PathBuf::from("first"), PathBuf::from("second")]
|
||||
return;
|
||||
}
|
||||
let directory = tempfile::tempdir().expect("tool installation");
|
||||
run_tool_fixture_test(
|
||||
"rootfs::tests::recovery_preserves_selected_path_and_execution_errors",
|
||||
directory.path(),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
#![cfg(feature = "compute-driver")]
|
||||
#![allow(unsafe_code)]
|
||||
|
||||
use std::fs::{self, File};
|
||||
use std::os::fd::AsRawFd;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::os::unix::process::CommandExt;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
use openshell_driver_vm::VmDriverConfig;
|
||||
|
||||
/// Exercise the actual worker entry point and a real sparse-file copy without
|
||||
/// downloading an image, running a VM, or requiring host filesystem utilities.
|
||||
#[test]
|
||||
fn worker_publishes_complete_overlay_and_reports_resolved_owner() {
|
||||
worker_overlay_case(false);
|
||||
}
|
||||
|
||||
/// Retrying an interrupted first start must never turn an incomplete copy
|
||||
/// into persisted overlay state. The next retry must still be able to start.
|
||||
#[test]
|
||||
fn interrupted_missing_overlay_retry_does_not_publish_partial_state() {
|
||||
worker_overlay_case(true);
|
||||
}
|
||||
|
||||
fn worker_overlay_case(interrupt_retry: bool) {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let cache = root.path().join("images");
|
||||
let attempts = cache.join("preparations");
|
||||
let name = "attempt-00000000000000000000000000000001";
|
||||
let attempt = attempts.join(name);
|
||||
fs::create_dir_all(&attempt).unwrap();
|
||||
let lease_path = attempts.join(format!("{name}.lease"));
|
||||
fs::write(&lease_path, b"openshell-image-preparation-v1\n").unwrap();
|
||||
let lease = File::open(&lease_path).unwrap();
|
||||
let fd = lease.as_raw_fd();
|
||||
// SAFETY: this descriptor belongs to the live fixture file.
|
||||
assert_eq!(unsafe { libc::flock(fd, libc::LOCK_EX) }, 0);
|
||||
|
||||
let size = 1024 * 1024;
|
||||
let template = cache.join("overlay-templates/sandbox-overlay-ext4-v1/1048576.ext4");
|
||||
fs::create_dir_all(template.parent().unwrap()).unwrap();
|
||||
let mut expected = vec![0_u8; size];
|
||||
expected[..13].copy_from_slice(b"template-data");
|
||||
fs::write(&template, &expected).unwrap();
|
||||
let sandbox = root.path().join("sandboxes/worker-test");
|
||||
fs::create_dir_all(&sandbox).unwrap();
|
||||
if interrupt_retry {
|
||||
// A cancelled Fresh attempt can persist the owner before publishing
|
||||
// its first overlay. Start retries this state with PreserveExisting.
|
||||
fs::write(
|
||||
sandbox.join("sandbox-owner-state"),
|
||||
b"sandbox-owner-v2:1000:1000\n",
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let source = cache.join("test-image/rootfs.ext4");
|
||||
fs::create_dir_all(source.parent().unwrap()).unwrap();
|
||||
fs::write(&source, b"identity comes from driver configuration").unwrap();
|
||||
let config = VmDriverConfig {
|
||||
state_dir: root.path().to_path_buf(),
|
||||
sandbox_uid: Some(1000),
|
||||
sandbox_gid: Some(1000),
|
||||
overlay_disk_mib: 1,
|
||||
..VmDriverConfig::default()
|
||||
};
|
||||
let request = attempt.join("request.json");
|
||||
fs::write(
|
||||
&request,
|
||||
serde_json::to_vec(&serde_json::json!({
|
||||
"config": config,
|
||||
"sandbox_id": "worker-test",
|
||||
"image_ref": "",
|
||||
"rootfs_tar": null,
|
||||
"bootstrap_only": false,
|
||||
"overlay": {
|
||||
"source_disk": source,
|
||||
"preparation": if interrupt_retry { "PreserveExisting" } else { "Fresh" },
|
||||
},
|
||||
"lease_fd": fd,
|
||||
"parent_pid": std::process::id(),
|
||||
}))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut command = Command::new(env!("CARGO_BIN_EXE_openshell-driver-vm"));
|
||||
command
|
||||
.arg("--internal-prepare-image")
|
||||
.arg(&request)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.process_group(0);
|
||||
// SAFETY: only fcntl runs between fork and exec, and the fixture keeps its
|
||||
// descriptor alive until the worker has exited.
|
||||
unsafe {
|
||||
command.pre_exec(move || {
|
||||
if libc::fcntl(fd, libc::F_SETFD, 0) == -1 {
|
||||
return Err(std::io::Error::last_os_error());
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
if interrupt_retry {
|
||||
let tools = root.path().join("tools");
|
||||
fs::create_dir(&tools).unwrap();
|
||||
let copy_ready = root.path().join("copy-ready");
|
||||
let cp = tools.join("cp");
|
||||
// Control only the external copy command. The actual worker selects
|
||||
// the destination and executes its production preservation checks.
|
||||
fs::write(
|
||||
&cp,
|
||||
"#!/bin/sh\nfor argument in \"$@\"; do destination=\"$argument\"; done\nprintf partial > \"$destination\"\nprintf '%s' \"$destination\" > \"$COPY_READY\"\nsleep 300\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::set_permissions(&cp, fs::Permissions::from_mode(0o700)).unwrap();
|
||||
command
|
||||
.env("PATH", format!("{}:/usr/bin:/bin", tools.display()))
|
||||
.env("COPY_READY", ©_ready);
|
||||
let mut interrupted = command.spawn().unwrap();
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
|
||||
while !copy_ready.exists() && std::time::Instant::now() < deadline {
|
||||
if interrupted.try_wait().unwrap().is_some() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
}
|
||||
// Kill and reap even if readiness fails, so failed tests do not leave
|
||||
// a blocked helper or worker behind.
|
||||
if interrupted.try_wait().unwrap().is_none() {
|
||||
let pid = i32::try_from(interrupted.id()).unwrap();
|
||||
let _ = nix::sys::signal::killpg(
|
||||
nix::unistd::Pid::from_raw(pid),
|
||||
nix::sys::signal::Signal::SIGKILL,
|
||||
);
|
||||
}
|
||||
interrupted.wait().unwrap();
|
||||
assert!(copy_ready.exists(), "worker must reach the controlled copy");
|
||||
assert!(
|
||||
!sandbox.join("overlay.ext4").exists(),
|
||||
"interrupted retry must leave no partial persisted overlay"
|
||||
);
|
||||
let destination = fs::read_to_string(©_ready).unwrap();
|
||||
assert!(std::path::Path::new(&destination).starts_with(&attempt));
|
||||
// Retry using the real copy utility. The incomplete attempt image
|
||||
// must not prevent publishing the complete overlay.
|
||||
command
|
||||
.env("PATH", "/usr/bin:/bin")
|
||||
.env_remove("COPY_READY");
|
||||
}
|
||||
let mut child = command.spawn().unwrap();
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
|
||||
while child.try_wait().unwrap().is_none() {
|
||||
if std::time::Instant::now() >= deadline {
|
||||
let pid = i32::try_from(child.id()).unwrap();
|
||||
let _ = nix::sys::signal::killpg(
|
||||
nix::unistd::Pid::from_raw(pid),
|
||||
nix::sys::signal::Signal::SIGKILL,
|
||||
);
|
||||
let _ = child.wait();
|
||||
panic!("image preparation worker timed out");
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
}
|
||||
let output = child.wait_with_output().unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
let messages = String::from_utf8(output.stdout).unwrap();
|
||||
let completion = messages
|
||||
.lines()
|
||||
.map(|line| {
|
||||
serde_json::from_str::<serde_json::Value>(line)
|
||||
.expect("worker stdout must contain only protocol messages")
|
||||
})
|
||||
.find_map(|message| message.get("Complete").cloned())
|
||||
.expect("worker completion");
|
||||
assert_eq!(
|
||||
completion,
|
||||
serde_json::json!({"Ok": {"Overlay": {"uid": 1000, "gid": 1000}}})
|
||||
);
|
||||
assert_eq!(fs::read(sandbox.join("overlay.ext4")).unwrap(), expected);
|
||||
assert_eq!(
|
||||
fs::read(&template).unwrap(),
|
||||
expected,
|
||||
"the shared template is immutable"
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_to_string(sandbox.join("sandbox-owner-state")).unwrap(),
|
||||
"sandbox-owner-v2:1000:1000\n"
|
||||
);
|
||||
}
|
||||
@@ -398,6 +398,15 @@ impl openshell_server::ComputeDriverFactory for VmFactory {
|
||||
true
|
||||
}
|
||||
|
||||
async fn preflight_host_tools(
|
||||
&self,
|
||||
cancellation: tokio::sync::watch::Receiver<bool>,
|
||||
) -> openshell_core::Result<Vec<String>> {
|
||||
openshell_core::e2fsprogs::preflight(cancellation)
|
||||
.await
|
||||
.map_err(openshell_core::Error::config)
|
||||
}
|
||||
|
||||
fn validate_config(
|
||||
&self,
|
||||
context: openshell_server::ComputeDriverConfigContext<'_>,
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
#![cfg(all(unix, feature = "compute-driver-vm"))]
|
||||
|
||||
use std::fs;
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Output;
|
||||
use std::time::Duration;
|
||||
|
||||
struct Fixture {
|
||||
root: tempfile::TempDir,
|
||||
tools: PathBuf,
|
||||
config: PathBuf,
|
||||
}
|
||||
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
let root = tempfile::tempdir().expect("fixture root");
|
||||
let tools = root.path().join("tools");
|
||||
fs::create_dir(&tools).expect("tools directory");
|
||||
let config = root.path().join("gateway.toml");
|
||||
let fixture = Self {
|
||||
root,
|
||||
tools,
|
||||
config,
|
||||
};
|
||||
fixture.write_config(Some("vm"));
|
||||
for name in ["mke2fs", "debugfs", "e2fsck"] {
|
||||
fixture.tool(name, &format!(
|
||||
"test \"$1\" = -V || exit 64\ntest \"$#\" = 1 || exit 65\nread ignored && exit 66\ntest \"$PREFLIGHT_TEST_ENV\" = inherited || exit 67\necho '{name} 1.47.4' >&2"
|
||||
));
|
||||
}
|
||||
fixture
|
||||
}
|
||||
|
||||
fn write_config(&self, driver: Option<&str>) {
|
||||
let selector =
|
||||
driver.map_or_else(String::new, |name| format!("compute_driver = {name:?}\n"));
|
||||
fs::write(&self.config, format!(
|
||||
"[openshell]\nversion = 2\n[openshell.gateway]\ndisable_tls = true\n{selector}[openshell.drivers.vm]\nbootstrap_image = 'unreachable.invalid/vm:must-not-pull'\nstate_dir = {:?}\n",
|
||||
self.root.path().join("vm-state")
|
||||
)).expect("gateway configuration");
|
||||
}
|
||||
|
||||
fn tool(&self, name: &str, body: &str) {
|
||||
let path = self.tools.join(name);
|
||||
fs::write(&path, format!("#!/bin/sh\n{body}\n")).expect("tool fixture");
|
||||
fs::set_permissions(path, fs::Permissions::from_mode(0o755)).expect("executable fixture");
|
||||
}
|
||||
|
||||
fn command(&self, replay: &[&str]) -> tokio::process::Command {
|
||||
let mut command = tokio::process::Command::new(env!("CARGO_BIN_EXE_openshell-gateway"));
|
||||
command
|
||||
.env_clear()
|
||||
.env("HOME", self.root.path())
|
||||
.env("PATH", &self.tools)
|
||||
.env("XDG_CONFIG_HOME", self.root.path().join("config-home"))
|
||||
.env("XDG_STATE_HOME", self.root.path().join("state-home"))
|
||||
.env("PREFLIGHT_TEST_ENV", "inherited")
|
||||
.args(["config", "preflight"])
|
||||
.kill_on_drop(true);
|
||||
if replay.is_empty() {
|
||||
command.arg("--path").arg(&self.config);
|
||||
} else {
|
||||
command
|
||||
.arg("--")
|
||||
.arg("--config")
|
||||
.arg(&self.config)
|
||||
.args(replay);
|
||||
}
|
||||
command
|
||||
}
|
||||
|
||||
async fn run(&self, replay: &[&str]) -> Output {
|
||||
let original_config = fs::read(&self.config).expect("original config");
|
||||
let mut command = self.command(replay);
|
||||
let output = tokio::time::timeout(Duration::from_secs(15), command.output())
|
||||
.await
|
||||
.expect("preflight must finish")
|
||||
.expect("run gateway preflight");
|
||||
assert_eq!(
|
||||
fs::read(&self.config).expect("unchanged config"),
|
||||
original_config
|
||||
);
|
||||
for name in ["vm-state", "state-home", "config-home", ".local"] {
|
||||
assert!(
|
||||
!self.root.path().join(name).exists(),
|
||||
"preflight created {name}"
|
||||
);
|
||||
}
|
||||
output
|
||||
}
|
||||
}
|
||||
|
||||
fn combined(output: &Output) -> String {
|
||||
format!(
|
||||
"{}{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_vm_reports_tools_without_starting_driver_or_creating_state() {
|
||||
let fixture = Fixture::new();
|
||||
let output = fixture.run(&[]).await;
|
||||
assert!(output.status.success(), "{}", combined(&output));
|
||||
let report = String::from_utf8_lossy(&output.stdout);
|
||||
for name in ["mke2fs", "debugfs", "e2fsck"] {
|
||||
let path = fixture
|
||||
.tools
|
||||
.join(name)
|
||||
.canonicalize()
|
||||
.expect("selected path");
|
||||
assert!(
|
||||
report.contains(path.to_str().expect("UTF-8 path")),
|
||||
"{report}"
|
||||
);
|
||||
}
|
||||
// PATH contains only the filesystem fixtures, never a VM driver binary.
|
||||
assert!(!fixture.tools.join("openshell-driver-vm").exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn config_file_retains_selected_tool_error_and_corrective_guidance() {
|
||||
let fixture = Fixture::new();
|
||||
fixture.tool("debugfs", "echo 'fixture loader failure' >&2\nexit 42");
|
||||
let output = fixture.run(&[]).await;
|
||||
assert!(!output.status.success());
|
||||
let report = combined(&output);
|
||||
for expected in [
|
||||
"debugfs",
|
||||
"fixture loader failure",
|
||||
"42",
|
||||
"gateway service PATH",
|
||||
"mke2fs",
|
||||
] {
|
||||
assert!(report.contains(expected), "missing {expected}: {report}");
|
||||
}
|
||||
assert!(
|
||||
report.contains(fixture.tools.to_str().expect("tool path")),
|
||||
"{report}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_vm_rejects_nonexecutable_and_unsupported_tools() {
|
||||
let fixture = Fixture::new();
|
||||
fs::set_permissions(
|
||||
fixture.tools.join("mke2fs"),
|
||||
fs::Permissions::from_mode(0o644),
|
||||
)
|
||||
.unwrap();
|
||||
let output = fixture.run(&[]).await;
|
||||
assert!(!output.status.success());
|
||||
assert!(
|
||||
combined(&output).contains("not an executable file"),
|
||||
"{}",
|
||||
combined(&output)
|
||||
);
|
||||
|
||||
fixture.tool("mke2fs", "echo 'mke2fs 1.42.13' >&2");
|
||||
let output = fixture.run(&[]).await;
|
||||
assert!(!output.status.success());
|
||||
// The diagnostic renderer wraps long selected paths and error text.
|
||||
let report = combined(&output)
|
||||
.split_whitespace()
|
||||
.filter(|word| *word != "│")
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
assert!(report.contains("not a supported mke2fs"), "{report}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_vm_rejects_hanging_tool_with_deadline() {
|
||||
let fixture = Fixture::new();
|
||||
fixture.tool("mke2fs", "exec /bin/sleep 30");
|
||||
let start = std::time::Instant::now();
|
||||
let output = fixture.run(&[]).await;
|
||||
assert!(!output.status.success());
|
||||
assert!(
|
||||
combined(&output).contains("timed out after 5 seconds"),
|
||||
"{}",
|
||||
combined(&output)
|
||||
);
|
||||
assert!(start.elapsed() < Duration::from_secs(12));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn signals_stop_probe_wrapper_and_descendants_before_cli_exit() {
|
||||
use nix::sys::signal::{Signal, kill};
|
||||
use nix::unistd::Pid;
|
||||
use std::process::Stdio;
|
||||
|
||||
for signal in [Signal::SIGINT, Signal::SIGTERM] {
|
||||
let fixture = Fixture::new();
|
||||
let ready = fixture.root.path().join("probe-ready");
|
||||
let survived = fixture.root.path().join("survived-signal");
|
||||
fixture.tool(
|
||||
"mke2fs",
|
||||
&format!(
|
||||
"(echo ready > '{}'; /bin/sleep 2; echo survived > '{}') &\nwait",
|
||||
ready.display(),
|
||||
survived.display()
|
||||
),
|
||||
);
|
||||
let child = fixture
|
||||
.command(&[])
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.expect("preflight process");
|
||||
let pid =
|
||||
Pid::from_raw(i32::try_from(child.id().expect("gateway PID")).expect("valid PID"));
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
while !ready.exists() {
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("probe descendant ready");
|
||||
kill(pid, signal).expect("signal gateway");
|
||||
let output = tokio::time::timeout(Duration::from_secs(5), child.wait_with_output())
|
||||
.await
|
||||
.expect("signal cleanup deadline")
|
||||
.expect("preflight exit");
|
||||
assert!(!output.status.success());
|
||||
tokio::time::sleep(Duration::from_millis(2200)).await;
|
||||
assert!(!survived.exists(), "probe descendant survived {signal}");
|
||||
assert!(
|
||||
combined(&output).contains(&format!("interrupted by {signal}")),
|
||||
"{}",
|
||||
combined(&output)
|
||||
);
|
||||
assert!(!fixture.root.path().join("vm-state").exists());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remote_vm_reports_unperformed_checks_without_running_local_tools() {
|
||||
let fixture = Fixture::new();
|
||||
fixture.tool("mke2fs", "echo 'local tool must not run' >&2\nexit 42");
|
||||
let socket = fixture.root.path().join("absent-remote.sock");
|
||||
let output = fixture
|
||||
.run(&["--compute-driver-socket", socket.to_str().unwrap()])
|
||||
.await;
|
||||
assert!(output.status.success(), "{}", combined(&output));
|
||||
assert!(combined(&output).contains("host tool checks not performed for a remote endpoint"));
|
||||
assert!(!combined(&output).contains("local tool must not run"));
|
||||
assert!(!Path::new(&socket).exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn vm_table_without_selection_does_not_probe_tools() {
|
||||
let fixture = Fixture::new();
|
||||
fixture.write_config(None);
|
||||
fixture.tool("mke2fs", "exit 42");
|
||||
let output = fixture.run(&[]).await;
|
||||
assert!(output.status.success(), "{}", combined(&output));
|
||||
assert!(!combined(&output).contains("VM host tool"));
|
||||
}
|
||||
|
||||
#[cfg(feature = "compute-driver-docker")]
|
||||
#[tokio::test]
|
||||
async fn unrelated_driver_does_not_require_vm_tools() {
|
||||
let fixture = Fixture::new();
|
||||
fixture.tool("mke2fs", "exit 42");
|
||||
let output = fixture.run(&["--compute-driver", "docker"]).await;
|
||||
assert!(output.status.success(), "{}", combined(&output));
|
||||
assert!(!combined(&output).contains("VM host tool"));
|
||||
}
|
||||
@@ -2247,7 +2247,29 @@ mod linux {
|
||||
})
|
||||
}
|
||||
|
||||
fn driver_identity(&self) -> DriverIdentity {
|
||||
let identity = &self.config.workload_identity;
|
||||
if self.config.resource_claims.contains_key("vm.generation") {
|
||||
DriverIdentity::Vm {
|
||||
uid: identity.uid,
|
||||
gid: identity.gid,
|
||||
}
|
||||
} else {
|
||||
DriverIdentity::Resolved {
|
||||
uid: identity.uid,
|
||||
gid: identity.gid,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn attach(&self, policy: SandboxPolicyWire) -> Response {
|
||||
// Validate before acknowledging policy activation. The protected
|
||||
// VM identity remains fixed across reconnects and later execs.
|
||||
if let Err(error) =
|
||||
resolve_process_identity(&mut policy.clone().into(), &self.driver_identity())
|
||||
{
|
||||
return guest_error(BoundaryErrorKind::Denied, error.to_string());
|
||||
}
|
||||
let mut state = lock(&self.state);
|
||||
let accepted = match &*state {
|
||||
RuntimeState::AwaitingAttach => {
|
||||
@@ -2498,10 +2520,7 @@ mod linux {
|
||||
.build()
|
||||
);
|
||||
}
|
||||
let driver_identity = DriverIdentity::Resolved {
|
||||
uid: self.config.workload_identity.uid,
|
||||
gid: self.config.workload_identity.gid,
|
||||
};
|
||||
let driver_identity = self.driver_identity();
|
||||
if let Err(error) = resolve_process_identity(&mut policy, &driver_identity) {
|
||||
return guest_error(BoundaryErrorKind::Process, error.to_string());
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ const MAX_ACCOUNT_FIELD_SIZE: usize = 1024;
|
||||
pub enum DriverIdentity {
|
||||
/// Platform-selected identity used by Kubernetes and `OpenShift`.
|
||||
Resolved { uid: u32, gid: u32 },
|
||||
/// VM overlay owner. Explicit policy selectors must agree with this pair.
|
||||
Vm { uid: u32, gid: u32 },
|
||||
/// Raw OCI `Config.User` selected by Docker and Podman.
|
||||
OciUser { declaration: String },
|
||||
/// Drivers with no authoritative identity metadata.
|
||||
@@ -94,6 +96,18 @@ pub fn resolve_process_identity(
|
||||
driver_identity: &DriverIdentity,
|
||||
) -> Result<ResolvedProcessIdentity> {
|
||||
match driver_identity {
|
||||
DriverIdentity::Vm { uid, gid } => {
|
||||
validate_vm_process_identity_at(
|
||||
policy,
|
||||
*uid,
|
||||
*gid,
|
||||
Path::new(PASSWD_PATH),
|
||||
Path::new(GROUP_PATH),
|
||||
)?;
|
||||
policy.process.run_as_user = Some(uid.to_string());
|
||||
policy.process.run_as_group = Some(gid.to_string());
|
||||
Ok(ResolvedProcessIdentity::default())
|
||||
}
|
||||
DriverIdentity::Resolved { uid, gid } => {
|
||||
policy.process.run_as_user = Some(uid.to_string());
|
||||
policy.process.run_as_group = Some(gid.to_string());
|
||||
@@ -132,6 +146,57 @@ pub fn resolve_process_identity(
|
||||
}
|
||||
}
|
||||
|
||||
/// Check selectors before replacing them with the immutable overlay owner.
|
||||
/// Names are resolved inside the workload's filesystem, never through host NSS.
|
||||
/// The user and group are independent; an omitted selector accepts the driver
|
||||
/// default even when the other selector is explicit.
|
||||
fn validate_vm_process_identity_at(
|
||||
policy: &SandboxPolicy,
|
||||
uid: u32,
|
||||
gid: u32,
|
||||
passwd_path: &Path,
|
||||
group_path: &Path,
|
||||
) -> Result<()> {
|
||||
for (field, selector, expected, account_path) in [
|
||||
(
|
||||
"run_as_user",
|
||||
policy.process.run_as_user.as_deref(),
|
||||
uid,
|
||||
passwd_path,
|
||||
),
|
||||
(
|
||||
"run_as_group",
|
||||
policy.process.run_as_group.as_deref(),
|
||||
gid,
|
||||
group_path,
|
||||
),
|
||||
] {
|
||||
let Some(selector) = selector.filter(|value| !value.is_empty()) else {
|
||||
continue;
|
||||
};
|
||||
validate_component(selector, field)?;
|
||||
let resolved = match selector.parse::<u32>() {
|
||||
Ok(value) => value,
|
||||
Err(_) if field == "run_as_user" => find_passwd_by_name(account_path, selector)?
|
||||
.map(|entry| entry.uid)
|
||||
.ok_or_else(|| {
|
||||
miette::miette!("VM {field} '{selector}' was not found in /etc/passwd")
|
||||
})?,
|
||||
Err(_) => find_group_by_name(account_path, selector)?
|
||||
.map(|entry| entry.gid)
|
||||
.ok_or_else(|| {
|
||||
miette::miette!("VM {field} '{selector}' was not found in /etc/group")
|
||||
})?,
|
||||
};
|
||||
if resolved != expected {
|
||||
return Err(miette::miette!(
|
||||
"VM {field} '{selector}' resolves to {resolved}, but the workload identity is {uid}:{gid}; omit the selector or request the driver-owned identity"
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(clippy::similar_names)]
|
||||
fn resolve_oci_process_identity_at(
|
||||
policy: &mut SandboxPolicy,
|
||||
@@ -548,6 +613,81 @@ mod tests {
|
||||
policy
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vm_identity_checks_each_selector_before_replacing_policy() {
|
||||
for (user, group) in [
|
||||
(None, None),
|
||||
(Some("1000"), None),
|
||||
(None, Some("1001")),
|
||||
(Some(""), Some("")),
|
||||
] {
|
||||
let mut requested = policy(user, group);
|
||||
resolve_process_identity(
|
||||
&mut requested,
|
||||
&DriverIdentity::Vm {
|
||||
uid: 1000,
|
||||
gid: 1001,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(requested.process.run_as_user.as_deref(), Some("1000"));
|
||||
assert_eq!(requested.process.run_as_group.as_deref(), Some("1001"));
|
||||
}
|
||||
for (user, group, field) in [
|
||||
(Some("10000"), Some("10001"), "run_as_user"),
|
||||
(Some("10000"), None, "run_as_user"),
|
||||
(None, Some("10001"), "run_as_group"),
|
||||
(Some("1000"), Some("10001"), "run_as_group"),
|
||||
] {
|
||||
let mut requested = policy(user, group);
|
||||
let before = requested.clone();
|
||||
let error = resolve_process_identity(
|
||||
&mut requested,
|
||||
&DriverIdentity::Vm {
|
||||
uid: 1000,
|
||||
gid: 1001,
|
||||
},
|
||||
)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(error.contains(field), "{error}");
|
||||
assert!(error.contains("1000:1001"), "{error}");
|
||||
assert_eq!(requested.process.run_as_user, before.process.run_as_user);
|
||||
assert_eq!(requested.process.run_as_group, before.process.run_as_group);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vm_symbolic_identity_uses_workload_accounts_independently() {
|
||||
let (_dir, passwd, group) = account_files(
|
||||
"sandbox:x:2000:3000::/sandbox:/bin/sh\n",
|
||||
"sandbox:x:2001:\n",
|
||||
);
|
||||
let requested = policy(Some("sandbox"), Some("sandbox"));
|
||||
// The group selector uses /etc/group, not the user's primary group.
|
||||
validate_vm_process_identity_at(&requested, 2000, 2001, &passwd, &group).unwrap();
|
||||
for (uid, gid, field) in [(2002, 2001, "run_as_user"), (2000, 2002, "run_as_group")] {
|
||||
let error = validate_vm_process_identity_at(&requested, uid, gid, &passwd, &group)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(error.contains(field), "{error}");
|
||||
}
|
||||
fs::write(&group, "other:x:2001:\n").unwrap();
|
||||
assert!(
|
||||
validate_vm_process_identity_at(&requested, 2000, 2001, &passwd, &group)
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("not found")
|
||||
);
|
||||
fs::write(&group, "sandbox:x:2001:\nsandbox:x:2001:\n").unwrap();
|
||||
assert!(
|
||||
validate_vm_process_identity_at(&requested, 2000, 2001, &passwd, &group)
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("ambiguous")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn per_field_policy_precedence_resolves_complete_pair() {
|
||||
let (_dir, passwd, group) = account_files(
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! Load and validate sandbox launch signing before gateway startup connects drivers.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use openshell_core::{Error, GatewayJwtConfig};
|
||||
|
||||
use super::sandbox_jwt::{ExtensionJwtIssuer, SandboxSessionJwtAuthority};
|
||||
|
||||
pub struct LaunchSigningAuthorities {
|
||||
pub extension: Arc<ExtensionJwtIssuer>,
|
||||
pub sandbox_session: Arc<SandboxSessionJwtAuthority>,
|
||||
}
|
||||
|
||||
pub fn load(config: &GatewayJwtConfig) -> openshell_core::Result<LaunchSigningAuthorities> {
|
||||
let signing_pem = std::fs::read(&config.signing_key_path).map_err(|error| {
|
||||
Error::config(format!(
|
||||
"cannot read sandbox launch-signing private key from {}: {error}",
|
||||
config.signing_key_path.display()
|
||||
))
|
||||
})?;
|
||||
let public_pem = std::fs::read(&config.public_key_path).map_err(|error| {
|
||||
Error::config(format!(
|
||||
"cannot read sandbox launch-signing public key from {}: {error}",
|
||||
config.public_key_path.display()
|
||||
))
|
||||
})?;
|
||||
let kid = std::fs::read_to_string(&config.kid_path)
|
||||
.map_err(|error| {
|
||||
Error::config(format!(
|
||||
"cannot read sandbox launch-signing key ID from {}: {error}",
|
||||
config.kid_path.display()
|
||||
))
|
||||
})?
|
||||
.trim()
|
||||
.to_string();
|
||||
if kid.is_empty() {
|
||||
return Err(Error::config(format!(
|
||||
"sandbox launch-signing key ID file {} is empty",
|
||||
config.kid_path.display()
|
||||
)));
|
||||
}
|
||||
let extension = ExtensionJwtIssuer::from_pem(
|
||||
&signing_pem,
|
||||
&public_pem,
|
||||
kid.clone(),
|
||||
&config.gateway_id,
|
||||
config.token_ttl(),
|
||||
)
|
||||
.map_err(|_| {
|
||||
Error::config(
|
||||
"invalid sandbox launch-signing bundle: expected Ed25519 private and public keys",
|
||||
)
|
||||
})?;
|
||||
let sandbox_session = SandboxSessionJwtAuthority::from_pem(
|
||||
&signing_pem,
|
||||
&public_pem,
|
||||
kid,
|
||||
&config.gateway_id,
|
||||
config.sandbox_token_ttl(),
|
||||
)
|
||||
.map_err(|error| {
|
||||
// This constructor maps core SessionJwtError variants to fixed text;
|
||||
// preserve their actionable field and lifetime diagnostics.
|
||||
Error::config(format!("invalid sandbox launch-signing bundle: {error}"))
|
||||
})?;
|
||||
|
||||
// Parsing two keys does not establish that they are a pair. Sign and verify
|
||||
// a local probe so mismatched keys fail before a driver prepares an image.
|
||||
// The probe is never persisted or sent to a driver or supervisor.
|
||||
let identity = super::sandbox_session::PersistedSandboxIdentity::new()
|
||||
.map_err(|_| Error::config("cannot initialize sandbox launch-signing validation"))?;
|
||||
let probe = sandbox_session
|
||||
.mint_persisted_launch("launch-signing-preflight", &identity)
|
||||
.map_err(|_| Error::config("sandbox launch-signing key cannot mint session credentials"))?;
|
||||
sandbox_session
|
||||
.verify_gateway_token(probe.supervisor.gateway_token.expose_secret())
|
||||
.map_err(|_| {
|
||||
Error::config("sandbox launch-signing private and public keys do not match")
|
||||
})?;
|
||||
|
||||
Ok(LaunchSigningAuthorities {
|
||||
extension: Arc::new(extension),
|
||||
sandbox_session: Arc::new(sandbox_session),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use openshell_bootstrap::jwt::generate_jwt_key;
|
||||
|
||||
fn bundle(directory: &std::path::Path) -> GatewayJwtConfig {
|
||||
let material = generate_jwt_key().expect("generate signing material");
|
||||
std::fs::create_dir_all(directory).unwrap();
|
||||
let config = GatewayJwtConfig {
|
||||
signing_key_path: directory.join("signing.pem"),
|
||||
public_key_path: directory.join("public.pem"),
|
||||
kid_path: directory.join("kid"),
|
||||
gateway_id: "test-gateway".to_string(),
|
||||
ttl_secs: None,
|
||||
};
|
||||
std::fs::write(&config.signing_key_path, material.signing_key_pem).unwrap();
|
||||
std::fs::write(&config.public_key_path, material.public_key_pem).unwrap();
|
||||
std::fs::write(&config.kid_path, material.kid).unwrap();
|
||||
config
|
||||
}
|
||||
|
||||
fn failure(config: &GatewayJwtConfig) -> String {
|
||||
match load(config) {
|
||||
Ok(_) => panic!("invalid bundle must fail before driver startup"),
|
||||
Err(error) => error.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_bundle_mints_and_verifies_launch_credentials() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let config = bundle(directory.path());
|
||||
assert!(load(&config).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discovered_bundle_mints_and_verifies_launch_credentials() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
bundle(&directory.path().join("jwt"));
|
||||
let config = crate::defaults::local_jwt_config(directory.path())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(load(&config).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_signing_file_names_the_required_file() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let config = bundle(directory.path());
|
||||
std::fs::remove_file(&config.signing_key_path).unwrap();
|
||||
let error = failure(&config);
|
||||
assert!(error.contains("cannot read sandbox launch-signing private key"));
|
||||
assert!(error.contains("signing.pem"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_signing_metadata_keeps_specific_diagnostics() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let mut config = bundle(directory.path());
|
||||
config.ttl_secs = std::num::NonZeroU64::new(1);
|
||||
assert!(failure(&config).contains("between 60 and 3600 seconds"));
|
||||
config.ttl_secs = None;
|
||||
config.gateway_id = "invalid gateway secret-marker".to_string();
|
||||
let error = failure(&config);
|
||||
assert!(error.contains("gateway ID is invalid"));
|
||||
assert!(!error.contains("secret-marker"));
|
||||
config.gateway_id = "valid-gateway".to_string();
|
||||
std::fs::write(&config.kid_path, "invalid kid secret-marker").unwrap();
|
||||
let error = failure(&config);
|
||||
assert!(error.contains("key ID is invalid"));
|
||||
assert!(!error.contains("secret-marker"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_public_key_and_key_id_name_the_required_file() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let config = bundle(directory.path());
|
||||
std::fs::remove_file(&config.public_key_path).unwrap();
|
||||
assert!(failure(&config).contains("cannot read sandbox launch-signing public key"));
|
||||
let config = bundle(directory.path());
|
||||
std::fs::remove_file(&config.kid_path).unwrap();
|
||||
assert!(failure(&config).contains("cannot read sandbox launch-signing key ID"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_signing_material_is_not_in_diagnostics() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let config = bundle(directory.path());
|
||||
let marker = "secret-marker-that-must-not-be-logged";
|
||||
std::fs::write(&config.signing_key_path, marker).unwrap();
|
||||
let error = failure(&config);
|
||||
assert!(error.contains("invalid sandbox launch-signing bundle"));
|
||||
assert!(!error.contains(marker));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_key_id_is_reported_before_driver_startup() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let config = bundle(directory.path());
|
||||
std::fs::write(&config.kid_path, " \n").unwrap();
|
||||
assert!(failure(&config).contains("is empty"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mismatched_keys_are_rejected_before_driver_startup() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let config = bundle(directory.path());
|
||||
let other = generate_jwt_key().unwrap();
|
||||
std::fs::write(&config.public_key_path, other.public_key_pem).unwrap();
|
||||
assert!(failure(&config).contains("private and public keys do not match"));
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ pub mod extension_mint_limit;
|
||||
pub mod guard;
|
||||
mod http;
|
||||
pub mod identity;
|
||||
pub mod launch_signing;
|
||||
pub mod method_authz;
|
||||
pub mod oidc;
|
||||
pub mod peer;
|
||||
|
||||
@@ -287,7 +287,12 @@ pub async fn run_cli_with_compute_drivers(compute_drivers: ComputeDriverRegistry
|
||||
Some(Commands::GenerateCerts(args)) => certgen::run(args).await,
|
||||
Some(Commands::Config(args)) => match args.command {
|
||||
ConfigCommand::Preflight(args) => {
|
||||
run_config_preflight_with_drivers(args, cli.run, &matches, &compute_drivers)
|
||||
let driver =
|
||||
run_config_preflight_with_drivers(args, cli.run, &matches, &compute_drivers)?;
|
||||
for report in preflight_host_tools(driver).await? {
|
||||
println!("{report}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
},
|
||||
None => Box::pin(run_from_args(cli.run, matches, compute_drivers)).await,
|
||||
@@ -366,7 +371,15 @@ fn prepare_server_config_with_drivers(
|
||||
args.disable_tls,
|
||||
)
|
||||
.map_err(|error| miette::miette!("invalid gateway guest TLS configuration: {error}"))?;
|
||||
let local_jwt = defaults::complete_local_jwt_config()?;
|
||||
// Explicit signing configuration must not depend on an unrelated, partial
|
||||
// local bundle left by a package-managed installation.
|
||||
let explicit_jwt = file
|
||||
.as_ref()
|
||||
.and_then(|file| file.openshell.gateway.gateway_jwt.clone());
|
||||
let gateway_jwt = match explicit_jwt {
|
||||
Some(jwt) => Some(jwt),
|
||||
None => defaults::complete_local_jwt_config()?,
|
||||
};
|
||||
|
||||
let bind = SocketAddr::new(args.bind_address, args.port);
|
||||
|
||||
@@ -560,6 +573,18 @@ fn prepare_server_config_with_drivers(
|
||||
config.policy_validation_failure_mode = mode;
|
||||
}
|
||||
|
||||
if let Some(seconds) = file
|
||||
.as_ref()
|
||||
.and_then(|f| f.openshell.gateway.image_preparation_timeout_seconds)
|
||||
{
|
||||
if !(1..=86_400).contains(&seconds) {
|
||||
return Err(miette::miette!(
|
||||
"image_preparation_timeout_seconds must be between 1 and 86400"
|
||||
));
|
||||
}
|
||||
config.image_preparation_timeout_seconds = seconds;
|
||||
}
|
||||
|
||||
if let Some(issuer) = args.oidc_issuer.clone() {
|
||||
config = config.with_oidc(openshell_core::OidcConfig {
|
||||
issuer,
|
||||
@@ -578,14 +603,7 @@ fn prepare_server_config_with_drivers(
|
||||
// package-managed starts also auto-detect the JWT bundle written next to
|
||||
// the generated TLS bundle so upgrades pick up sandbox auth without a
|
||||
// user-authored config file.
|
||||
if let Some(jwt) = file
|
||||
.as_ref()
|
||||
.and_then(|f| f.openshell.gateway.gateway_jwt.clone())
|
||||
{
|
||||
config.gateway_jwt = Some(jwt);
|
||||
} else if let Some(jwt) = local_jwt {
|
||||
config.gateway_jwt = Some(jwt);
|
||||
}
|
||||
config.gateway_jwt = gateway_jwt;
|
||||
|
||||
Ok(ServerStartupConfig {
|
||||
config,
|
||||
@@ -760,7 +778,7 @@ fn run_config_preflight(
|
||||
Some(detect_preflight_test_driver),
|
||||
PreflightTestFactory,
|
||||
)?)?;
|
||||
run_config_preflight_with_drivers(args, run, matches, ®istry)
|
||||
run_config_preflight_with_drivers(args, run, matches, ®istry).map(|_| ())
|
||||
}
|
||||
|
||||
fn run_config_preflight_with_drivers(
|
||||
@@ -768,7 +786,7 @@ fn run_config_preflight_with_drivers(
|
||||
run: RunArgs,
|
||||
matches: &ArgMatches,
|
||||
compute_drivers: &ComputeDriverRegistry,
|
||||
) -> Result<()> {
|
||||
) -> Result<Option<crate::ConfiguredComputeDriver>> {
|
||||
if args.gateway_args.is_empty() {
|
||||
return run_effective_config_preflight(args.path, run, matches, compute_drivers);
|
||||
}
|
||||
@@ -783,7 +801,7 @@ fn run_config_preflight_with_drivers(
|
||||
clap::error::ErrorKind::DisplayHelp | clap::error::ErrorKind::DisplayVersion
|
||||
) =>
|
||||
{
|
||||
return Ok(());
|
||||
return Ok(None);
|
||||
}
|
||||
Err(error) => return Err(miette::miette!("{error}")),
|
||||
};
|
||||
@@ -792,7 +810,7 @@ fn run_config_preflight_with_drivers(
|
||||
if replay.command.is_some() {
|
||||
// A valid non-daemon action does not consume gateway startup
|
||||
// configuration. Let the immediately following invocation perform it.
|
||||
return Ok(());
|
||||
return Ok(None);
|
||||
}
|
||||
run_effective_config_preflight(None, replay.run, &replay_matches, compute_drivers)
|
||||
}
|
||||
@@ -802,7 +820,7 @@ fn run_effective_config_preflight(
|
||||
mut run: RunArgs,
|
||||
matches: &ArgMatches,
|
||||
compute_drivers: &ComputeDriverRegistry,
|
||||
) -> Result<()> {
|
||||
) -> Result<Option<crate::ConfiguredComputeDriver>> {
|
||||
let path = if path_override.is_some() {
|
||||
path_override
|
||||
} else {
|
||||
@@ -850,8 +868,9 @@ fn run_effective_config_preflight(
|
||||
gateway_tls_enabled: !run.disable_tls,
|
||||
endpoint_overrides: &endpoint_overrides,
|
||||
};
|
||||
let mut selected_driver = None;
|
||||
if let Some(selection) = selection.as_ref() {
|
||||
crate::validate_compute_driver_config(
|
||||
selected_driver = Some(crate::validate_compute_driver_config(
|
||||
compute_drivers,
|
||||
selection.name(),
|
||||
run.name.trim(),
|
||||
@@ -859,7 +878,7 @@ fn run_effective_config_preflight(
|
||||
&run.log_level,
|
||||
driver_startup,
|
||||
true,
|
||||
)?;
|
||||
)?);
|
||||
} else if file.is_some() {
|
||||
// Runtime auto-detection may connect local API sockets or launch a
|
||||
// bounded discovery command. Preflight must not perform those
|
||||
@@ -881,11 +900,11 @@ fn run_effective_config_preflight(
|
||||
)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
Ok(selected_driver)
|
||||
})();
|
||||
|
||||
match (validation, path.as_ref()) {
|
||||
(Ok(()), _) => Ok(()),
|
||||
(Ok(driver), _) => Ok(driver),
|
||||
(Err(_), Some(path)) => Err(miette::miette!(
|
||||
"{}",
|
||||
config_file::ConfigPreflightError::invalid_current(path)
|
||||
@@ -894,6 +913,56 @@ fn run_effective_config_preflight(
|
||||
}
|
||||
}
|
||||
|
||||
/// Run executable probes outside the pure configuration-validation context.
|
||||
async fn preflight_host_tools(
|
||||
driver: Option<crate::ConfiguredComputeDriver>,
|
||||
) -> Result<Vec<String>> {
|
||||
match driver {
|
||||
Some(crate::ConfiguredComputeDriver::Registered(registration)) => {
|
||||
let (cancellation_tx, cancellation_rx) = tokio::sync::watch::channel(false);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use tokio::signal::unix::{SignalKind, signal};
|
||||
|
||||
// Register before polling the hook: a probe owns a separate
|
||||
// process group, so default CLI termination cannot clean it up.
|
||||
let mut interrupt = signal(SignalKind::interrupt())
|
||||
.map_err(|error| miette::miette!("register preflight SIGINT: {error}"))?;
|
||||
let mut terminate = signal(SignalKind::terminate())
|
||||
.map_err(|error| miette::miette!("register preflight SIGTERM: {error}"))?;
|
||||
let check = registration.factory.preflight_host_tools(cancellation_rx);
|
||||
tokio::pin!(check);
|
||||
let reason = tokio::select! {
|
||||
biased;
|
||||
_ = interrupt.recv() => "SIGINT",
|
||||
_ = terminate.recv() => "SIGTERM",
|
||||
result = &mut check => return result.map_err(|error| miette::miette!("{error}")),
|
||||
};
|
||||
cancellation_tx.send_replace(true);
|
||||
// The hook owns its children. Await its cancellation cleanup
|
||||
// before the short-lived CLI shuts down the Tokio runtime.
|
||||
let _ = check.await;
|
||||
Err(miette::miette!(
|
||||
"host tool preflight interrupted by {reason}"
|
||||
))
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
let _cancellation_tx = cancellation_tx;
|
||||
registration
|
||||
.factory
|
||||
.preflight_host_tools(cancellation_rx)
|
||||
.await
|
||||
.map_err(|error| miette::miette!("{error}"))
|
||||
}
|
||||
}
|
||||
Some(crate::ConfiguredComputeDriver::Remote { name }) => Ok(vec![format!(
|
||||
"compute driver '{name}': host tool checks not performed for a remote endpoint; run preflight on the driver host with its service account and environment"
|
||||
)]),
|
||||
None => Ok(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_preflight_semantics(
|
||||
args: &RunArgs,
|
||||
matches: &ArgMatches,
|
||||
@@ -3292,6 +3361,7 @@ version = 2
|
||||
|
||||
[openshell.gateway]
|
||||
policy_validation_failure_mode = "retain_last_valid"
|
||||
image_preparation_timeout_seconds = 2400
|
||||
|
||||
[openshell.drivers.docker]
|
||||
unknown_docker_key = true
|
||||
@@ -3317,6 +3387,7 @@ mem_mib = "not-a-number"
|
||||
super::prepare_server_config(&mut args, &matches).expect("server config is prepared");
|
||||
|
||||
assert_eq!(prepared.config.compute_driver.as_deref(), Some("podman"));
|
||||
assert_eq!(prepared.config.image_preparation_timeout_seconds, 2400);
|
||||
assert_eq!(
|
||||
prepared.config.policy_validation_failure_mode,
|
||||
openshell_core::PolicyValidationFailureMode::RetainLastValid
|
||||
@@ -3325,4 +3396,87 @@ mem_mib = "not-a-number"
|
||||
assert!(file.openshell.drivers.contains_key("docker"));
|
||||
assert!(file.openshell.drivers.contains_key("vm"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_launch_signing_config_ignores_partial_local_bundle() {
|
||||
let _lock = ENV_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let _state = EnvVarGuard::set("XDG_STATE_HOME", directory.path().to_str().unwrap());
|
||||
let _local = EnvVarGuard::set(
|
||||
"OPENSHELL_LOCAL_TLS_DIR",
|
||||
directory.path().to_str().unwrap(),
|
||||
);
|
||||
std::fs::create_dir(directory.path().join("jwt")).unwrap();
|
||||
std::fs::write(
|
||||
directory.path().join("jwt/signing.pem"),
|
||||
"incomplete local bundle",
|
||||
)
|
||||
.unwrap();
|
||||
let config_path = directory.path().join("gateway.toml");
|
||||
std::fs::write(
|
||||
&config_path,
|
||||
r#"
|
||||
[openshell]
|
||||
version = 2
|
||||
[openshell.gateway.gateway_jwt]
|
||||
signing_key_path = "/explicit/signing.pem"
|
||||
public_key_path = "/explicit/public.pem"
|
||||
kid_path = "/explicit/kid"
|
||||
gateway_id = "explicit-gateway"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let (mut args, matches) = parse_with_args(&[
|
||||
"openshell-gateway",
|
||||
"--config",
|
||||
config_path.to_str().unwrap(),
|
||||
"--db-url",
|
||||
"sqlite::memory:",
|
||||
"--compute-driver",
|
||||
"podman",
|
||||
"--disable-tls",
|
||||
]);
|
||||
let prepared = super::prepare_server_config(&mut args, &matches).unwrap();
|
||||
assert_eq!(
|
||||
prepared.config.gateway_jwt.unwrap().gateway_id,
|
||||
"explicit-gateway"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_config_rejects_unbounded_image_preparation() {
|
||||
let _lock = ENV_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let state = tempfile::tempdir().unwrap();
|
||||
let tls = tempfile::tempdir().unwrap();
|
||||
let _state = EnvVarGuard::set("XDG_STATE_HOME", state.path().to_str().unwrap());
|
||||
let _tls = EnvVarGuard::set("OPENSHELL_LOCAL_TLS_DIR", tls.path().to_str().unwrap());
|
||||
let config_path = state.path().join("gateway.toml");
|
||||
for seconds in [0, 86_401] {
|
||||
std::fs::write(&config_path, format!(
|
||||
"[openshell]\nversion = 2\n[openshell.gateway]\nimage_preparation_timeout_seconds = {seconds}\n"
|
||||
)).unwrap();
|
||||
let (mut args, matches) = parse_with_args(&[
|
||||
"openshell-gateway",
|
||||
"--config",
|
||||
config_path.to_str().unwrap(),
|
||||
"--db-url",
|
||||
"sqlite::memory:",
|
||||
"--compute-driver",
|
||||
"podman",
|
||||
"--disable-tls",
|
||||
]);
|
||||
let Err(error) = super::prepare_server_config(&mut args, &matches) else {
|
||||
panic!("unbounded preparation must be rejected");
|
||||
};
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("image_preparation_timeout_seconds must be between 1 and 86400")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -644,6 +644,7 @@ pub struct ComputeRuntime {
|
||||
telemetry_compute_driver: TelemetryComputeDriver,
|
||||
driver_process: Option<Arc<ManagedDriverProcess>>,
|
||||
default_image: String,
|
||||
image_preparation_timeout_seconds: u32,
|
||||
store: Arc<Store>,
|
||||
sandbox_index: SandboxIndex,
|
||||
sandbox_watch_bus: SandboxWatchBus,
|
||||
@@ -745,6 +746,7 @@ impl ComputeRuntime {
|
||||
telemetry_compute_driver: TelemetryComputeDriver::custom(),
|
||||
driver_process,
|
||||
default_image,
|
||||
image_preparation_timeout_seconds: 1800,
|
||||
store,
|
||||
sandbox_index,
|
||||
sandbox_watch_bus,
|
||||
@@ -826,6 +828,16 @@ impl ComputeRuntime {
|
||||
&self.default_image
|
||||
}
|
||||
|
||||
/// Validate the budget once at startup. Persisted attempts keep their
|
||||
/// original deadline even if the operator changes this value on restart.
|
||||
pub(crate) fn with_image_preparation_timeout(mut self, seconds: u32) -> Result<Self, String> {
|
||||
if !(1..=86_400).contains(&seconds) {
|
||||
return Err("image_preparation_timeout_seconds must be between 1 and 86400".into());
|
||||
}
|
||||
self.image_preparation_timeout_seconds = seconds;
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn driver_info_snapshots(&self) -> &[ComputeDriverInfoSnapshot] {
|
||||
std::slice::from_ref(&self.driver_info)
|
||||
@@ -1001,6 +1013,29 @@ impl ComputeRuntime {
|
||||
.await
|
||||
}
|
||||
|
||||
/// Check the selected driver's launch contract without contacting the driver.
|
||||
pub(crate) fn validate_launch_signer_configured(&self, configured: bool) -> Result<(), Status> {
|
||||
// AuthenticateSandbox handles driver-native bootstrap credentials. It
|
||||
// does not declare whether launches require a gateway signing bundle.
|
||||
let required = self
|
||||
.driver_info
|
||||
.negotiated_extension
|
||||
.required_capabilities
|
||||
.iter()
|
||||
.any(|capability| {
|
||||
capability == openshell_core::extension_protocol::COMPUTE_LAUNCH_AUTHENTICATION
|
||||
});
|
||||
if required && !configured {
|
||||
return Err(Status::failed_precondition(
|
||||
"the selected compute driver requires sandbox launch signing; configure \
|
||||
[openshell.gateway.gateway_jwt] or provide jwt/signing.pem, jwt/public.pem, \
|
||||
and jwt/kid under OPENSHELL_LOCAL_TLS_DIR (default: the OpenShell state \
|
||||
directory's tls/). Listener TLS does not configure sandbox launch signing",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn create_sandbox_authenticated(
|
||||
&self,
|
||||
sandbox: Sandbox,
|
||||
@@ -1035,6 +1070,13 @@ impl ComputeRuntime {
|
||||
lifecycle_guard: SandboxLifecycleGuard,
|
||||
global_guard: SandboxSyncGuard,
|
||||
) -> Result<Sandbox, Status> {
|
||||
// Defend the internal create path too, before consuming a staged archive
|
||||
// or persisting the sandbox. The gRPC handler checks before driver validation.
|
||||
self.validate_launch_signer_configured(
|
||||
launch_authentication
|
||||
.as_ref()
|
||||
.is_some_and(|auth| !auth.is_empty()),
|
||||
)?;
|
||||
self.validate_caller_driver_config(
|
||||
sandbox
|
||||
.spec
|
||||
@@ -1618,6 +1660,7 @@ impl ComputeRuntime {
|
||||
SandboxPhase::Starting,
|
||||
"Starting",
|
||||
"Sandbox start requested",
|
||||
self.image_preparation_timeout_seconds,
|
||||
);
|
||||
},
|
||||
)
|
||||
@@ -1699,7 +1742,7 @@ impl ComputeRuntime {
|
||||
.await.map_err(|error| Status::internal(error.to_string()))?
|
||||
.ok_or_else(|| Status::not_found("sandbox removed during startup"))?;
|
||||
if provisioning_deadline::timed_out(¤t) {
|
||||
return Err(Status::deadline_exceeded("provisioning repair window expired"));
|
||||
return Err(Status::deadline_exceeded("provisioning deadline expired"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2104,7 +2147,13 @@ impl ComputeRuntime {
|
||||
&sandbox_id,
|
||||
expected_resource_version,
|
||||
move |sandbox| {
|
||||
apply_lifecycle_phase(sandbox, phase, &reason, &message);
|
||||
apply_lifecycle_phase(
|
||||
sandbox,
|
||||
phase,
|
||||
&reason,
|
||||
&message,
|
||||
self.image_preparation_timeout_seconds,
|
||||
);
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -3311,22 +3360,27 @@ impl ComputeRuntime {
|
||||
}
|
||||
};
|
||||
let expected_runtime_identity = sandbox_compute_runtime_identity(&sandbox);
|
||||
// Recovery retains the original attempt and deadline. A
|
||||
// stalled driver must release this lifecycle gate after
|
||||
// expiry so the deadline worker can reclaim its compute.
|
||||
if let Err(err) = self
|
||||
.driver
|
||||
.call(
|
||||
openshell_otel::rpc::START_SANDBOX,
|
||||
Some(&sandbox_id),
|
||||
|driver| async move {
|
||||
driver
|
||||
.start_sandbox(Request::new(StartSandboxRequest {
|
||||
sandbox_id: driver_sandbox_id,
|
||||
name: sandbox_name,
|
||||
launch_authentication: Vec::new(),
|
||||
generation_id,
|
||||
expected_runtime_identity,
|
||||
}))
|
||||
.await
|
||||
},
|
||||
.await_provisioning_operation(
|
||||
&sandbox,
|
||||
self.driver.call(
|
||||
openshell_otel::rpc::START_SANDBOX,
|
||||
Some(&sandbox_id),
|
||||
|driver| async move {
|
||||
driver
|
||||
.start_sandbox(Request::new(StartSandboxRequest {
|
||||
sandbox_id: driver_sandbox_id,
|
||||
name: sandbox_name,
|
||||
launch_authentication: Vec::new(),
|
||||
generation_id,
|
||||
expected_runtime_identity,
|
||||
}))
|
||||
.await
|
||||
},
|
||||
),
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -5958,15 +6012,31 @@ fn public_status_from_driver(
|
||||
phase: SandboxPhase,
|
||||
current_policy_version: u32,
|
||||
) -> SandboxStatus {
|
||||
let mut conditions = status
|
||||
.conditions
|
||||
.iter()
|
||||
.map(public_condition_from_driver)
|
||||
.collect::<Vec<_>>();
|
||||
if let Some(identity) = &status.resolved_identity {
|
||||
// Keep the requested policy intact. This condition reports the
|
||||
// immutable runtime identity through existing CLI/API status views.
|
||||
conditions.retain(|condition| condition.r#type != "WorkloadIdentity");
|
||||
conditions.push(SandboxCondition {
|
||||
r#type: "WorkloadIdentity".to_string(),
|
||||
status: "True".to_string(),
|
||||
reason: "DriverResolved".to_string(),
|
||||
message: format!(
|
||||
"Resolved workload UID:GID is {}:{}",
|
||||
identity.uid, identity.gid
|
||||
),
|
||||
transition_time: None,
|
||||
});
|
||||
}
|
||||
SandboxStatus {
|
||||
agent_pod: status.instance_id.clone(),
|
||||
agent_fd: status.agent_fd.clone(),
|
||||
sandbox_fd: status.sandbox_fd.clone(),
|
||||
conditions: status
|
||||
.conditions
|
||||
.iter()
|
||||
.map(public_condition_from_driver)
|
||||
.collect(),
|
||||
conditions,
|
||||
phase: phase as i32,
|
||||
current_policy_version,
|
||||
main_process_instance_id: String::new(),
|
||||
@@ -6503,7 +6573,13 @@ fn is_recoverable_error_reason(sandbox: &Sandbox) -> bool {
|
||||
.is_some_and(|c| c.reason == CONDITION_RUNTIME_RESTART || c.reason == CONDITION_STOPPED)
|
||||
}
|
||||
|
||||
fn apply_lifecycle_phase(sandbox: &mut Sandbox, phase: SandboxPhase, reason: &str, message: &str) {
|
||||
fn apply_lifecycle_phase(
|
||||
sandbox: &mut Sandbox,
|
||||
phase: SandboxPhase,
|
||||
reason: &str,
|
||||
message: &str,
|
||||
image_preparation_timeout_seconds: u32,
|
||||
) {
|
||||
sandbox.set_phase(phase as i32);
|
||||
if matches!(phase, SandboxPhase::Stopping | SandboxPhase::Starting) {
|
||||
let status = sandbox.status.get_or_insert_with(Default::default);
|
||||
@@ -6514,8 +6590,9 @@ fn apply_lifecycle_phase(sandbox: &mut Sandbox, phase: SandboxPhase, reason: &st
|
||||
set_next_restart_at_ms(status, 0);
|
||||
set_main_process_started_at_ms(status, 0);
|
||||
if phase == SandboxPhase::Starting {
|
||||
status.provisioning = Some(provisioning_deadline::new_record(
|
||||
status.provisioning = Some(provisioning_deadline::new_preparation_record(
|
||||
openshell_core::time::now_ms(),
|
||||
image_preparation_timeout_seconds,
|
||||
));
|
||||
status.configuration_admission =
|
||||
Some(openshell_core::proto::SandboxConfigurationAdmission {
|
||||
@@ -6890,6 +6967,7 @@ pub fn new_test_runtime_with_driver(
|
||||
telemetry_compute_driver: TelemetryComputeDriver::custom(),
|
||||
driver_process: None,
|
||||
default_image: "openshell/sandbox:test".to_string(),
|
||||
image_preparation_timeout_seconds: 1800,
|
||||
store,
|
||||
sandbox_index: SandboxIndex::new(),
|
||||
sandbox_watch_bus: SandboxWatchBus::new(),
|
||||
@@ -7269,6 +7347,8 @@ mod tests {
|
||||
current_sandboxes: Vec<DriverSandbox>,
|
||||
workspace_rpcs_unimplemented: bool,
|
||||
omit_protocol_metadata: bool,
|
||||
requires_launch_authentication: bool,
|
||||
create_calls: AtomicUsize,
|
||||
}
|
||||
|
||||
#[tonic::async_trait]
|
||||
@@ -7305,12 +7385,19 @@ mod tests {
|
||||
rootfs_tar_staging_dir: String::new(),
|
||||
rootfs_tar_max_bytes: 0,
|
||||
extension: (!self.omit_protocol_metadata).then(|| {
|
||||
openshell_core::extension_protocol::extension_metadata(
|
||||
let mut metadata = openshell_core::extension_protocol::extension_metadata(
|
||||
ExtensionFamily::Compute,
|
||||
"openshell/test-driver",
|
||||
"test",
|
||||
[],
|
||||
)
|
||||
);
|
||||
if self.requires_launch_authentication {
|
||||
metadata.required_capabilities.push(
|
||||
openshell_core::extension_protocol::COMPUTE_LAUNCH_AUTHENTICATION
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
metadata
|
||||
}),
|
||||
}))
|
||||
}
|
||||
@@ -7370,6 +7457,7 @@ mod tests {
|
||||
&self,
|
||||
_request: Request<CreateSandboxRequest>,
|
||||
) -> Result<tonic::Response<CreateSandboxResponse>, Status> {
|
||||
self.create_calls.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(tonic::Response::new(CreateSandboxResponse::default()))
|
||||
}
|
||||
|
||||
@@ -7939,6 +8027,7 @@ mod tests {
|
||||
telemetry_compute_driver: TelemetryComputeDriver::custom(),
|
||||
driver_process: None,
|
||||
default_image: "openshell/sandbox:test".to_string(),
|
||||
image_preparation_timeout_seconds: 1800,
|
||||
store,
|
||||
sandbox_index: SandboxIndex::new(),
|
||||
sandbox_watch_bus: SandboxWatchBus::new(),
|
||||
@@ -9654,6 +9743,39 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_status_reports_driver_identity_without_rewriting_policy_or_readiness() {
|
||||
let mut driver_status =
|
||||
make_driver_status(make_driver_condition("Starting", "VM is starting"));
|
||||
let unresolved = public_status_from_driver(&driver_status, SandboxPhase::Provisioning, 0);
|
||||
assert!(
|
||||
!unresolved
|
||||
.conditions
|
||||
.iter()
|
||||
.any(|condition| condition.r#type == "WorkloadIdentity")
|
||||
);
|
||||
driver_status.resolved_identity = Some(
|
||||
openshell_core::proto::compute::v1::ResolvedWorkloadIdentity {
|
||||
uid: 1000,
|
||||
gid: 1001,
|
||||
source: "vm-config".into(),
|
||||
resource_digest: "sha256:image".into(),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let status = public_status_from_driver(&driver_status, SandboxPhase::Provisioning, 0);
|
||||
assert_eq!(status.phase, SandboxPhase::Provisioning as i32);
|
||||
assert_eq!(status.current_policy_version, 0);
|
||||
assert_eq!(status.conditions[0], unresolved.conditions[0]);
|
||||
let identity = status
|
||||
.conditions
|
||||
.iter()
|
||||
.find(|condition| condition.r#type == "WorkloadIdentity")
|
||||
.unwrap();
|
||||
assert_eq!(identity.reason, "DriverResolved");
|
||||
assert_eq!(identity.message, "Resolved workload UID:GID is 1000:1001");
|
||||
}
|
||||
|
||||
fn ready_driver_sandbox(id: &str, name: &str) -> DriverSandbox {
|
||||
DriverSandbox {
|
||||
id: id.to_string(),
|
||||
@@ -10453,18 +10575,19 @@ mod tests {
|
||||
assert!(!crate::policy_store::permits_initial_static_policy_repair(
|
||||
&blocked
|
||||
));
|
||||
// Simulate the supervisor's successful exact-generation admission report.
|
||||
// The supervisor report records the preparation-to-admission transition
|
||||
// together with acceptance of the exact configuration generation.
|
||||
runtime
|
||||
.store
|
||||
.update_message_cas::<Sandbox, _>(sandbox.object_id(), 0, |sandbox| {
|
||||
sandbox
|
||||
.status
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.configuration_admission
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.state = openshell_core::proto::ConfigurationAdmissionState::Accepted.into();
|
||||
let status = sandbox.status.as_mut().unwrap();
|
||||
provisioning_deadline::record_admission_start(
|
||||
status.provisioning.as_mut().unwrap(),
|
||||
openshell_core::time::now_ms(),
|
||||
)
|
||||
.unwrap();
|
||||
status.configuration_admission.as_mut().unwrap().state =
|
||||
openshell_core::proto::ConfigurationAdmissionState::Accepted.into();
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -14992,6 +15115,94 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn negotiated_launch_requirement_rejects_create_before_driver_or_store() {
|
||||
let driver = Arc::new(TestDriver {
|
||||
requires_launch_authentication: true,
|
||||
..Default::default()
|
||||
});
|
||||
let store = Arc::new(Store::connect("sqlite::memory:").await.unwrap());
|
||||
let runtime = ComputeRuntime::from_driver(
|
||||
"external-requires-launch".to_string(),
|
||||
driver.clone(),
|
||||
None,
|
||||
store.clone(),
|
||||
SandboxIndex::new(),
|
||||
SandboxWatchBus::new(),
|
||||
TracingLogBus::new(),
|
||||
Arc::new(SupervisorSessionRegistry::new()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
for authentication in [None, Some(Vec::new())] {
|
||||
let sandbox = sandbox_record(
|
||||
"sb-missing-signer",
|
||||
"missing-signer",
|
||||
SandboxPhase::Provisioning,
|
||||
);
|
||||
let error = runtime
|
||||
.create_sandbox_authenticated(sandbox, None, authentication, false)
|
||||
.await
|
||||
.expect_err("the negotiated launch requirement must be checked before create");
|
||||
assert_eq!(error.code(), Code::FailedPrecondition);
|
||||
assert!(error.message().contains("[openshell.gateway.gateway_jwt]"));
|
||||
assert!(error.message().contains("OPENSHELL_LOCAL_TLS_DIR"));
|
||||
assert!(error.message().contains("Listener TLS"));
|
||||
assert!(
|
||||
store
|
||||
.get_message::<Sandbox>("sb-missing-signer")
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(driver.create_calls.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
|
||||
let sandbox = sandbox_record("sb-with-signer", "with-signer", SandboxPhase::Provisioning);
|
||||
let identity = crate::auth::sandbox_session::PersistedSandboxIdentity::new().unwrap();
|
||||
let authentication = test_session_authority()
|
||||
.mint_persisted_launch("sb-with-signer", &identity)
|
||||
.unwrap();
|
||||
runtime
|
||||
.create_sandbox_authenticated(
|
||||
sandbox,
|
||||
None,
|
||||
Some(serde_json::to_vec(&authentication).unwrap()),
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(driver.create_calls.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn external_driver_without_launch_requirement_can_create_without_signer() {
|
||||
let driver = Arc::new(TestDriver::default());
|
||||
let runtime = ComputeRuntime::from_driver(
|
||||
"external-other-auth".to_string(),
|
||||
driver.clone(),
|
||||
None,
|
||||
Arc::new(Store::connect("sqlite::memory:").await.unwrap()),
|
||||
SandboxIndex::new(),
|
||||
SandboxWatchBus::new(),
|
||||
TracingLogBus::new(),
|
||||
Arc::new(SupervisorSessionRegistry::new()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
runtime.validate_launch_signer_configured(false).unwrap();
|
||||
runtime
|
||||
.create_sandbox(
|
||||
sandbox_record("sb-other-auth", "other-auth", SandboxPhase::Provisioning),
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(driver.create_calls.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_sandbox_returns_resource_version_one() {
|
||||
let runtime = test_runtime(Arc::new(TestDriver::default())).await;
|
||||
@@ -15210,6 +15421,162 @@ mod tests {
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn preparation_expiry_releases_stalled_start_recovery_for_cleanup() {
|
||||
let driver = ControlledDriver::new();
|
||||
driver.block_start();
|
||||
let runtime = test_runtime(driver.clone()).await;
|
||||
let now = openshell_core::time::now_ms();
|
||||
let preparation = provisioning_deadline::new_preparation_record(now, 1800);
|
||||
let mut sandbox = sandbox_record("sb-recovery-ttl", "recovery-ttl", SandboxPhase::Starting);
|
||||
sandbox.status.as_mut().unwrap().provisioning = Some(preparation.clone());
|
||||
runtime.store.put_message(&sandbox).await.unwrap();
|
||||
let recovered_runtime = runtime.clone();
|
||||
let mut recovery = tokio::spawn(async move {
|
||||
recovered_runtime
|
||||
.recover_persisted_lifecycle_transitions()
|
||||
.await
|
||||
});
|
||||
tokio::time::timeout(Duration::from_secs(1), driver.start_started.notified())
|
||||
.await
|
||||
.unwrap();
|
||||
runtime
|
||||
.reconcile_provisioning_deadlines(now + 1_800_000)
|
||||
.await
|
||||
.unwrap();
|
||||
let expired = runtime
|
||||
.store
|
||||
.get_message::<Sandbox>("sb-recovery-ttl")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(expired.phase(), i32::from(SandboxPhase::Error));
|
||||
let status = expired.status.as_ref().unwrap();
|
||||
let record = status.provisioning.as_ref().unwrap();
|
||||
assert_eq!(record.attempt_id, preparation.attempt_id);
|
||||
assert_eq!(
|
||||
record.preparation_deadline,
|
||||
preparation.preparation_deadline
|
||||
);
|
||||
assert!(
|
||||
status
|
||||
.conditions
|
||||
.iter()
|
||||
.any(|condition| condition.reason == "ImagePreparationTimedOut")
|
||||
);
|
||||
// The recovery RPC never receives its semaphore permit. The persisted
|
||||
// expiry must cancel its waiter and release the lifecycle gate itself.
|
||||
if let Ok(result) = tokio::time::timeout(Duration::from_secs(3), &mut recovery).await {
|
||||
result.unwrap().unwrap();
|
||||
} else {
|
||||
recovery.abort();
|
||||
let _ = recovery.await;
|
||||
panic!("expired recovery kept the lifecycle gate while the driver was blocked");
|
||||
}
|
||||
runtime
|
||||
.reconcile_provisioning_deadlines(now + 1_800_001)
|
||||
.await
|
||||
.unwrap();
|
||||
tokio::time::timeout(Duration::from_secs(1), async {
|
||||
loop {
|
||||
let sandbox = runtime
|
||||
.store
|
||||
.get_message::<Sandbox>("sb-recovery-ttl")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let record = sandbox
|
||||
.status
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.provisioning
|
||||
.as_ref()
|
||||
.unwrap();
|
||||
if record.cleanup_completed_time.is_some() {
|
||||
assert_eq!(record.attempt_id, preparation.attempt_id);
|
||||
break;
|
||||
}
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(driver.stop_calls(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn preparation_timeout_retains_reason_and_uses_existing_cleanup() {
|
||||
let driver = ControlledDriver::new();
|
||||
let runtime = test_runtime(driver.clone()).await;
|
||||
let mut sandbox = sandbox_record("sb-prepare", "prepare", SandboxPhase::Provisioning);
|
||||
sandbox.status.as_mut().unwrap().provisioning =
|
||||
Some(provisioning_deadline::new_preparation_record(0, 1800));
|
||||
runtime.store.put_message(&sandbox).await.unwrap();
|
||||
let sandbox = runtime
|
||||
.store
|
||||
.get_message::<Sandbox>("sb-prepare")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let gate = runtime.lifecycle_gates.lock_for("sb-prepare").await;
|
||||
let global = runtime.lock_global_for_lifecycle(&gate).await;
|
||||
assert!(
|
||||
runtime
|
||||
.claim_provisioning_timeout(&sandbox, 600_000)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
let expired = runtime
|
||||
.claim_provisioning_timeout(&sandbox, 1_800_000)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(expired.phase(), i32::from(SandboxPhase::Error));
|
||||
assert!(
|
||||
expired
|
||||
.status
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.conditions
|
||||
.iter()
|
||||
.any(|condition| { condition.reason == "ImagePreparationTimedOut" })
|
||||
);
|
||||
let record = expired
|
||||
.status
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.provisioning
|
||||
.as_ref()
|
||||
.unwrap();
|
||||
assert!(record.admission_start_time.is_none());
|
||||
assert!(record.preparation_deadline.is_some());
|
||||
assert!(record.cleanup_completed_time.is_none());
|
||||
drop(global);
|
||||
runtime
|
||||
.reclaim_provisioning_timeout(&expired, &gate)
|
||||
.await
|
||||
.unwrap();
|
||||
let reclaimed = runtime
|
||||
.store
|
||||
.get_message::<Sandbox>("sb-prepare")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(
|
||||
reclaimed
|
||||
.status
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.provisioning
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.cleanup_completed_time
|
||||
.is_some()
|
||||
);
|
||||
assert_eq!(driver.stop_calls(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn provisioning_timeout_persists_error_before_cleanup_and_preserves_record() {
|
||||
let driver = ControlledDriver::new();
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! Gateway-owned provisioning repair-window transitions.
|
||||
//! Gateway-owned image preparation and admission repair deadlines.
|
||||
//!
|
||||
//! Callers must persist each transition under the sandbox lifecycle fence. Times
|
||||
//! are gateway-assigned Unix milliseconds, never supervisor-supplied values.
|
||||
//! The timer remains armed after admission acceptance until compute becomes Ready.
|
||||
//! Preparation has an absolute ceiling. The first authenticated supervisor
|
||||
//! configuration report starts admission repair, which remains armed until Ready.
|
||||
|
||||
use openshell_core::proto::SandboxProvisioning;
|
||||
use openshell_core::time::{timestamp_from_millis, timestamp_to_millis};
|
||||
@@ -26,6 +27,8 @@ pub(super) struct ProvisioningDeadline {
|
||||
attempt_id: String,
|
||||
change: ConfigurationChange,
|
||||
first_rejection_at_ms: Option<i64>,
|
||||
preparation_deadline_at_ms: Option<i64>,
|
||||
admission_start_at_ms: Option<i64>,
|
||||
state: DeadlineState,
|
||||
}
|
||||
|
||||
@@ -50,6 +53,21 @@ impl ProvisioningDeadline {
|
||||
if record.deadline.is_some() && record.timeout_time.is_some() {
|
||||
return Err("provisioning cannot be armed and expired".into());
|
||||
}
|
||||
let preparation_deadline_at_ms = record
|
||||
.preparation_deadline
|
||||
.as_ref()
|
||||
.map(|value| millis(Some(value), "preparation deadline"))
|
||||
.transpose()?;
|
||||
let admission_start_at_ms = record
|
||||
.admission_start_time
|
||||
.as_ref()
|
||||
.map(|value| millis(Some(value), "admission start time"))
|
||||
.transpose()?;
|
||||
if admission_start_at_ms.is_some_and(|started| {
|
||||
preparation_deadline_at_ms.is_none_or(|ceiling| started >= ceiling)
|
||||
}) {
|
||||
return Err("admission must start before its preparation deadline".into());
|
||||
}
|
||||
let state = if record.timeout_time.is_some() {
|
||||
DeadlineState::Expired {
|
||||
expired_at_ms: millis(record.timeout_time.as_ref(), "timeout time")?,
|
||||
@@ -61,6 +79,13 @@ impl ProvisioningDeadline {
|
||||
} else {
|
||||
DeadlineState::Ready
|
||||
};
|
||||
if let Some(ceiling) = preparation_deadline_at_ms
|
||||
&& admission_start_at_ms.is_none()
|
||||
&& !matches!(state, DeadlineState::Expired { .. })
|
||||
&& !matches!(state, DeadlineState::Armed { deadline_at_ms } if deadline_at_ms == ceiling)
|
||||
{
|
||||
return Err("preparation must retain its absolute deadline until admission".into());
|
||||
}
|
||||
Ok(Self {
|
||||
attempt_id: record.attempt_id.clone(),
|
||||
change: ConfigurationChange {
|
||||
@@ -72,6 +97,8 @@ impl ProvisioningDeadline {
|
||||
.as_ref()
|
||||
.map(|value| millis(Some(value), "rejection time"))
|
||||
.transpose()?,
|
||||
preparation_deadline_at_ms,
|
||||
admission_start_at_ms,
|
||||
state,
|
||||
})
|
||||
}
|
||||
@@ -84,6 +111,12 @@ impl ProvisioningDeadline {
|
||||
record.first_rejection_time = self
|
||||
.first_rejection_at_ms
|
||||
.and_then(|value| timestamp_from_millis(value).ok());
|
||||
record.preparation_deadline = self
|
||||
.preparation_deadline_at_ms
|
||||
.and_then(|value| timestamp_from_millis(value).ok());
|
||||
record.admission_start_time = self
|
||||
.admission_start_at_ms
|
||||
.and_then(|value| timestamp_from_millis(value).ok());
|
||||
record.deadline = self
|
||||
.deadline_at_ms()
|
||||
.and_then(|value| timestamp_from_millis(value).ok());
|
||||
@@ -98,12 +131,38 @@ impl ProvisioningDeadline {
|
||||
attempt_id,
|
||||
change,
|
||||
first_rejection_at_ms: None,
|
||||
preparation_deadline_at_ms: None,
|
||||
admission_start_at_ms: None,
|
||||
state: DeadlineState::Armed {
|
||||
deadline_at_ms: now_ms.saturating_add(REPAIR_WINDOW_MS),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn is_preparing(&self) -> bool {
|
||||
self.preparation_deadline_at_ms.is_some() && self.admission_start_at_ms.is_none()
|
||||
}
|
||||
|
||||
/// Only an authenticated report for the current supervisor may call this.
|
||||
/// Persist it with the report: duplicate delivery or restart must not grant
|
||||
/// another admission window, and a late registration cannot revive compute.
|
||||
fn start_admission(&mut self, attempt_id: &str, now_ms: i64) -> bool {
|
||||
if attempt_id != self.attempt_id
|
||||
|| !self.is_preparing()
|
||||
|| now_ms < self.change.committed_at_ms
|
||||
|| self
|
||||
.deadline_at_ms()
|
||||
.is_none_or(|deadline| now_ms >= deadline)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
self.admission_start_at_ms = Some(now_ms);
|
||||
self.state = DeadlineState::Armed {
|
||||
deadline_at_ms: now_ms.saturating_add(REPAIR_WINDOW_MS),
|
||||
};
|
||||
true
|
||||
}
|
||||
|
||||
pub fn deadline_at_ms(&self) -> Option<i64> {
|
||||
match self.state {
|
||||
DeadlineState::Armed { deadline_at_ms } => Some(deadline_at_ms),
|
||||
@@ -124,10 +183,12 @@ impl ProvisioningDeadline {
|
||||
{
|
||||
return false;
|
||||
}
|
||||
self.state = DeadlineState::Armed {
|
||||
deadline_at_ms: deadline_at_ms
|
||||
.max(change.committed_at_ms.saturating_add(REPAIR_WINDOW_MS)),
|
||||
};
|
||||
if !self.is_preparing() {
|
||||
self.state = DeadlineState::Armed {
|
||||
deadline_at_ms: deadline_at_ms
|
||||
.max(change.committed_at_ms.saturating_add(REPAIR_WINDOW_MS)),
|
||||
};
|
||||
}
|
||||
self.change = change;
|
||||
self.first_rejection_at_ms = None;
|
||||
true
|
||||
@@ -140,6 +201,7 @@ impl ProvisioningDeadline {
|
||||
return false;
|
||||
};
|
||||
if !self.matches(attempt_id, change_id)
|
||||
|| self.is_preparing()
|
||||
|| self.first_rejection_at_ms.is_some()
|
||||
|| now_ms < self.change.committed_at_ms
|
||||
|| now_ms >= deadline_at_ms
|
||||
@@ -173,6 +235,7 @@ impl ProvisioningDeadline {
|
||||
/// must not call this method. Late readiness requires an explicit retry.
|
||||
pub fn ready(&mut self, attempt_id: &str, change_id: &str, now_ms: i64) -> bool {
|
||||
if !self.matches(attempt_id, change_id)
|
||||
|| self.is_preparing()
|
||||
|| self
|
||||
.deadline_at_ms()
|
||||
.is_none_or(|deadline| now_ms >= deadline)
|
||||
@@ -197,7 +260,8 @@ pub fn timed_out(sandbox: &openshell_core::proto::Sandbox) -> bool {
|
||||
.is_some_and(|record| record.timeout_time.is_some())
|
||||
}
|
||||
|
||||
/// Create an independent attempt. Supervisor reconnects must never call this.
|
||||
/// Adopt an existing untimed attempt without granting it a new preparation phase.
|
||||
/// New create/start operations use `new_preparation_record` instead.
|
||||
pub fn new_record(now_ms: i64) -> SandboxProvisioning {
|
||||
let mut record = SandboxProvisioning::default();
|
||||
ProvisioningDeadline::new(
|
||||
@@ -212,6 +276,26 @@ pub fn new_record(now_ms: i64) -> SandboxProvisioning {
|
||||
record
|
||||
}
|
||||
|
||||
/// Create an independent attempt with a fixed preparation budget. The gateway
|
||||
/// validates the configured seconds before constructing the runtime. Reconnects
|
||||
/// and driver progress must never call this function.
|
||||
pub fn new_preparation_record(now_ms: i64, timeout_seconds: u32) -> SandboxProvisioning {
|
||||
let mut record = new_record(now_ms);
|
||||
let ceiling = now_ms.saturating_add(i64::from(timeout_seconds) * 1_000);
|
||||
record.preparation_deadline = timestamp_from_millis(ceiling).ok();
|
||||
record.deadline.clone_from(&record.preparation_deadline);
|
||||
record
|
||||
}
|
||||
|
||||
/// The caller has checked the report's authentication, instance fence and
|
||||
/// configuration generation. Persist this transition in the same CAS as admission.
|
||||
pub fn record_admission_start(record: &mut SandboxProvisioning, now_ms: i64) -> Result<(), String> {
|
||||
let mut deadline = ProvisioningDeadline::from_record(record)?;
|
||||
deadline.start_admission(&record.attempt_id, now_ms);
|
||||
deadline.write_record(record);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Apply the first accepted rejection under the same CAS as admission evidence.
|
||||
/// The report's generation and supervisor instance must already be validated.
|
||||
pub fn record_rejection(record: &mut SandboxProvisioning, now_ms: i64) -> Result<(), String> {
|
||||
@@ -250,6 +334,10 @@ pub(super) fn reconcile_readiness(sandbox: &mut openshell_core::proto::Sandbox,
|
||||
if deadline.ready(&record.attempt_id, &record.configuration_change_id, now_ms) {
|
||||
deadline.write_record(record);
|
||||
} else {
|
||||
let awaiting_registration = deadline.is_preparing()
|
||||
&& deadline
|
||||
.deadline_at_ms()
|
||||
.is_some_and(|value| now_ms < value);
|
||||
status.phase = SandboxPhase::Provisioning.into();
|
||||
status
|
||||
.conditions
|
||||
@@ -257,8 +345,18 @@ pub(super) fn reconcile_readiness(sandbox: &mut openshell_core::proto::Sandbox,
|
||||
status.conditions.push(SandboxCondition {
|
||||
r#type: "Ready".into(),
|
||||
status: "False".into(),
|
||||
reason: "ProvisioningDeadlineElapsed".into(),
|
||||
message: "Provisioning deadline elapsed; awaiting compute reclamation".into(),
|
||||
reason: if awaiting_registration {
|
||||
"ConfigurationPending"
|
||||
} else {
|
||||
"ProvisioningDeadlineElapsed"
|
||||
}
|
||||
.into(),
|
||||
message: if awaiting_registration {
|
||||
"Waiting for an authenticated supervisor configuration report"
|
||||
} else {
|
||||
"Provisioning deadline elapsed; awaiting compute reclamation"
|
||||
}
|
||||
.into(),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
@@ -442,6 +540,7 @@ impl super::ComputeRuntime {
|
||||
return Ok(None);
|
||||
};
|
||||
let mut deadline = ProvisioningDeadline::from_record(record)?;
|
||||
let preparation_expired = deadline.is_preparing();
|
||||
if !deadline.expire(&record.attempt_id, &record.configuration_change_id, now_ms) {
|
||||
return Ok(None);
|
||||
}
|
||||
@@ -463,7 +562,9 @@ impl super::ComputeRuntime {
|
||||
.configuration_admission
|
||||
.as_ref()
|
||||
.map_or("", |admission| admission.error.as_str());
|
||||
let message = if diagnostic.is_empty() {
|
||||
let message = if preparation_expired {
|
||||
"Image preparation or initial supervisor startup exceeded its absolute deadline".to_string()
|
||||
} else if diagnostic.is_empty() {
|
||||
"Provisioning repair window expired after 300 seconds".to_string()
|
||||
} else {
|
||||
format!(
|
||||
@@ -476,7 +577,11 @@ impl super::ComputeRuntime {
|
||||
status.conditions.push(SandboxCondition {
|
||||
r#type: "Ready".into(),
|
||||
status: "False".into(),
|
||||
reason: "ProvisioningTimedOut".into(),
|
||||
reason: if preparation_expired {
|
||||
"ImagePreparationTimedOut"
|
||||
} else {
|
||||
"ProvisioningTimedOut"
|
||||
}.into(),
|
||||
message,
|
||||
transition_time: timestamp_from_millis(now_ms).ok(),
|
||||
});
|
||||
@@ -488,7 +593,8 @@ impl super::ComputeRuntime {
|
||||
self.sandbox_watch_bus.notify(current.object_id());
|
||||
tracing::warn!(
|
||||
sandbox_id = current.object_id(),
|
||||
"Sandbox provisioning repair window expired"
|
||||
preparation_expired,
|
||||
"Sandbox provisioning deadline expired"
|
||||
);
|
||||
Ok(Some(updated))
|
||||
}
|
||||
@@ -654,6 +760,87 @@ impl super::ComputeRuntime {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn existing_wire_record_does_not_gain_preparation_time() {
|
||||
use prost::Message;
|
||||
// Encoded before preparation timestamps existed: attempt a, change c,
|
||||
// configuration at epoch 0, and an admission deadline at 300 seconds.
|
||||
let bytes = [0x0a, 1, b'a', 0x12, 1, b'c', 0x1a, 0, 0x2a, 3, 8, 0xac, 2];
|
||||
let mut record = SandboxProvisioning::decode(bytes.as_slice()).unwrap();
|
||||
let before = record.clone();
|
||||
record_admission_start(&mut record, 299_999).unwrap();
|
||||
assert_eq!(record, before);
|
||||
assert!(record.preparation_deadline.is_none());
|
||||
assert!(!allows_admission(&record, 300_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preparation_over_five_minutes_gets_a_full_admission_repair_window() {
|
||||
let record = new_preparation_record(0, 1800);
|
||||
let mut timer = ProvisioningDeadline::from_record(&record).unwrap();
|
||||
let attempt = record.attempt_id;
|
||||
let change_id = record.configuration_change_id;
|
||||
assert!(!timer.expire(&attempt, &change_id, 600_000));
|
||||
assert!(!timer.ready(&attempt, &change_id, 600_000));
|
||||
assert!(timer.start_admission(&attempt, 600_000));
|
||||
assert_eq!(timer.deadline_at_ms(), Some(900_000));
|
||||
assert!(timer.rejected(&attempt, &change_id, 601_000));
|
||||
assert_eq!(timer.deadline_at_ms(), Some(901_000));
|
||||
assert!(!timer.start_admission(&attempt, 800_000));
|
||||
assert!(timer.configuration_changed(&attempt, change("updated", 800_000)));
|
||||
assert_eq!(timer.deadline_at_ms(), Some(1_100_000));
|
||||
assert_eq!(timer.admission_start_at_ms, Some(600_000));
|
||||
assert_eq!(timer.preparation_deadline_at_ms, Some(1_800_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preparation_config_changes_and_restart_preserve_the_absolute_ceiling() {
|
||||
use prost::Message;
|
||||
let mut record = new_preparation_record(0, 1800);
|
||||
let mut timer = ProvisioningDeadline::from_record(&record).unwrap();
|
||||
let attempt = record.attempt_id.clone();
|
||||
assert!(timer.configuration_changed(&attempt, change("first", 600_000)));
|
||||
assert!(timer.configuration_changed(&attempt, change("last", 1_799_000)));
|
||||
assert!(!timer.configuration_changed(&attempt, change("last", 1_799_500)));
|
||||
assert!(!timer.rejected(&attempt, "last", 1_799_500));
|
||||
timer.write_record(&mut record);
|
||||
let bytes = record.encode_to_vec();
|
||||
let restored = SandboxProvisioning::decode(bytes.as_slice()).unwrap();
|
||||
let mut timer = ProvisioningDeadline::from_record(&restored).unwrap();
|
||||
assert_eq!(timer.deadline_at_ms(), Some(1_800_000));
|
||||
assert!(!timer.start_admission("previous-attempt", 1_799_999));
|
||||
assert!(!timer.start_admission(&attempt, 1_800_000));
|
||||
assert!(timer.expire(&attempt, "last", 1_800_000));
|
||||
assert!(!timer.start_admission(&attempt, 1_799_999));
|
||||
assert!(!timer.ready(&attempt, "last", 1_799_999));
|
||||
assert!(!timer.configuration_changed(&attempt, change("late", 1_799_999)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admission_registration_roundtrip_does_not_restart_repair() {
|
||||
use prost::Message;
|
||||
let mut record = new_preparation_record(0, 1800);
|
||||
record_admission_start(&mut record, 600_000).unwrap();
|
||||
let before = record.clone();
|
||||
let bytes = record.encode_to_vec();
|
||||
let mut restored = SandboxProvisioning::decode(bytes.as_slice()).unwrap();
|
||||
record_admission_start(&mut restored, 899_999).unwrap();
|
||||
assert_eq!(restored, before);
|
||||
assert!(!allows_admission(&restored, 900_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_preparation_timing_cannot_grant_admission() {
|
||||
let mut record = new_preparation_record(0, 1800);
|
||||
record.deadline = timestamp_from_millis(1_800_001).ok();
|
||||
assert!(ProvisioningDeadline::from_record(&record).is_err());
|
||||
record.deadline = None;
|
||||
assert!(ProvisioningDeadline::from_record(&record).is_err());
|
||||
record.deadline = record.preparation_deadline;
|
||||
record.admission_start_time = timestamp_from_millis(1_800_000).ok();
|
||||
assert!(ProvisioningDeadline::from_record(&record).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protobuf_roundtrip_retains_deadline_and_cleanup_progress() {
|
||||
use prost::Message;
|
||||
|
||||
@@ -116,6 +116,9 @@ pub struct GatewayFileSection {
|
||||
// ── Sandbox / SSH ────────────────────────────────────────────────────
|
||||
#[serde(default)]
|
||||
pub ssh_session_ttl_secs: Option<u64>,
|
||||
/// Absolute preparation budget for new attempts; existing deadlines persist.
|
||||
#[serde(default)]
|
||||
pub image_preparation_timeout_seconds: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub grpc_rate_limit_requests: Option<u64>,
|
||||
#[serde(default)]
|
||||
|
||||
@@ -95,7 +95,11 @@ pub fn complete_local_tls_paths() -> Result<Option<LocalTlsPaths>> {
|
||||
|
||||
pub fn complete_local_jwt_config() -> Result<Option<GatewayJwtConfig>> {
|
||||
let dir = default_local_tls_dir()?;
|
||||
let paths = LocalJwtPaths::resolve(&dir);
|
||||
local_jwt_config(&dir)
|
||||
}
|
||||
|
||||
pub fn local_jwt_config(dir: &Path) -> Result<Option<GatewayJwtConfig>> {
|
||||
let paths = LocalJwtPaths::resolve(dir);
|
||||
let present = paths.files().iter().filter(|path| path.is_file()).count();
|
||||
match present {
|
||||
0 => Ok(None),
|
||||
|
||||
@@ -4424,7 +4424,7 @@ pub(super) async fn handle_report_sandbox_configuration(
|
||||
.map_err(Status::internal)?;
|
||||
if crate::compute::provisioning_deadline::timed_out(&sandbox) {
|
||||
return Err(Status::failed_precondition(
|
||||
"provisioning repair window expired; explicitly start the sandbox after cleanup",
|
||||
"provisioning deadline expired; explicitly start the sandbox after cleanup",
|
||||
));
|
||||
}
|
||||
let current = sandbox
|
||||
@@ -4510,10 +4510,10 @@ pub(super) async fn handle_report_sandbox_configuration(
|
||||
.claim_provisioning_timeout(&sandbox, now_ms)
|
||||
.await
|
||||
.map_err(Status::internal)?;
|
||||
return Err(Status::failed_precondition(
|
||||
"provisioning repair window expired",
|
||||
));
|
||||
return Err(Status::failed_precondition("provisioning deadline expired"));
|
||||
}
|
||||
crate::compute::provisioning_deadline::record_admission_start(record, now_ms)
|
||||
.map_err(Status::internal)?;
|
||||
if reported == ConfigurationAdmissionState::Rejected {
|
||||
crate::compute::provisioning_deadline::record_rejection(record, now_ms)
|
||||
.map_err(Status::internal)?;
|
||||
@@ -7718,6 +7718,143 @@ mod tests {
|
||||
request
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn preparation_registration_starts_repair_once_after_a_cold_start() {
|
||||
use crate::compute::provisioning_deadline::new_preparation_record;
|
||||
use openshell_core::proto::{
|
||||
ConfigurationAdmissionState, ReportSandboxConfigurationRequest,
|
||||
SandboxConfigurationAdmission, SandboxPhase,
|
||||
};
|
||||
use openshell_core::time::timestamp_to_millis;
|
||||
let state = test_server_state().await;
|
||||
let sandbox_id = "sb-cold-registration";
|
||||
let mut sandbox = test_sandbox(
|
||||
sandbox_id,
|
||||
"cold-registration",
|
||||
openshell_policy::restrictive_default_policy(),
|
||||
Vec::new(),
|
||||
);
|
||||
sandbox.set_phase(SandboxPhase::Provisioning.into());
|
||||
let preparation = new_preparation_record(current_time_ms() - 600_000, 1800);
|
||||
sandbox.status.as_mut().unwrap().provisioning = Some(preparation.clone());
|
||||
state.store.put_message(&sandbox).await.unwrap();
|
||||
let instance_id = uuid::Uuid::new_v4().to_string();
|
||||
let request = || {
|
||||
with_sandbox(
|
||||
Request::new(ReportSandboxConfigurationRequest {
|
||||
sandbox_id: sandbox_id.into(),
|
||||
admission: Some(SandboxConfigurationAdmission {
|
||||
instance_id: instance_id.clone(),
|
||||
state: ConfigurationAdmissionState::Pending.into(),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
}),
|
||||
sandbox_id,
|
||||
)
|
||||
};
|
||||
// A duplicate report racing the initial registration must share one
|
||||
// persisted transition; either may acquire the lifecycle fence first.
|
||||
let (first, duplicate) = tokio::join!(
|
||||
handle_report_sandbox_configuration(&state, request()),
|
||||
handle_report_sandbox_configuration(&state, request()),
|
||||
);
|
||||
first.unwrap();
|
||||
duplicate.unwrap();
|
||||
let saved = state
|
||||
.store
|
||||
.get_message::<Sandbox>(sandbox_id)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let record = saved
|
||||
.status
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.provisioning
|
||||
.as_ref()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
record.preparation_deadline,
|
||||
preparation.preparation_deadline
|
||||
);
|
||||
let start = timestamp_to_millis(record.admission_start_time.as_ref().unwrap()).unwrap();
|
||||
let deadline = timestamp_to_millis(record.deadline.as_ref().unwrap()).unwrap();
|
||||
assert_eq!(deadline - start, 300_000);
|
||||
handle_report_sandbox_configuration(&state, request())
|
||||
.await
|
||||
.unwrap();
|
||||
let repeated = state
|
||||
.store
|
||||
.get_message::<Sandbox>(sandbox_id)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
repeated
|
||||
.status
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.provisioning
|
||||
.as_ref()
|
||||
.unwrap(),
|
||||
record
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn preparation_expiry_rejects_registration_before_the_scanner_runs() {
|
||||
use crate::compute::provisioning_deadline::new_preparation_record;
|
||||
use openshell_core::proto::{
|
||||
ConfigurationAdmissionState, ReportSandboxConfigurationRequest,
|
||||
SandboxConfigurationAdmission, SandboxPhase,
|
||||
};
|
||||
let state = test_server_state().await;
|
||||
let sandbox_id = "sb-expired-preparation";
|
||||
let mut sandbox = test_sandbox(
|
||||
sandbox_id,
|
||||
"expired-preparation",
|
||||
openshell_policy::restrictive_default_policy(),
|
||||
Vec::new(),
|
||||
);
|
||||
sandbox.set_phase(SandboxPhase::Provisioning.into());
|
||||
sandbox.status.as_mut().unwrap().provisioning = Some(new_preparation_record(0, 1800));
|
||||
state.store.put_message(&sandbox).await.unwrap();
|
||||
let error = handle_report_sandbox_configuration(
|
||||
&state,
|
||||
with_sandbox(
|
||||
Request::new(ReportSandboxConfigurationRequest {
|
||||
sandbox_id: sandbox_id.into(),
|
||||
admission: Some(SandboxConfigurationAdmission {
|
||||
instance_id: uuid::Uuid::new_v4().to_string(),
|
||||
state: ConfigurationAdmissionState::Pending.into(),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
}),
|
||||
sandbox_id,
|
||||
),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(error.code(), Code::FailedPrecondition);
|
||||
let expired = state
|
||||
.store
|
||||
.get_message::<Sandbox>(sandbox_id)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(expired.phase(), i32::from(SandboxPhase::Error));
|
||||
let status = expired.status.unwrap();
|
||||
assert!(status.provisioning.unwrap().admission_start_time.is_none());
|
||||
assert!(
|
||||
status
|
||||
.conditions
|
||||
.iter()
|
||||
.any(|condition| condition.reason == "ImagePreparationTimedOut")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn provisioning_timeout_rejects_supervisor_registration() {
|
||||
use openshell_core::proto::{
|
||||
@@ -7756,7 +7893,7 @@ mod tests {
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(error.code(), Code::FailedPrecondition);
|
||||
assert!(error.message().contains("repair window expired"));
|
||||
assert!(error.message().contains("provisioning deadline expired"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -605,7 +605,12 @@ async fn handle_create_sandbox_inner(
|
||||
.status
|
||||
.as_mut()
|
||||
.expect("status initialized")
|
||||
.provisioning = Some(crate::compute::provisioning_deadline::new_record(now_ms));
|
||||
.provisioning = Some(
|
||||
crate::compute::provisioning_deadline::new_preparation_record(
|
||||
now_ms,
|
||||
state.config.image_preparation_timeout_seconds,
|
||||
),
|
||||
);
|
||||
crate::compute::provisioning_deadline::refresh_configuration(
|
||||
&state.store,
|
||||
&mut sandbox,
|
||||
@@ -629,6 +634,9 @@ async fn handle_create_sandbox_inner(
|
||||
)
|
||||
.await?;
|
||||
|
||||
state
|
||||
.compute
|
||||
.validate_launch_signer_configured(state.sandbox_session_jwt_authority.is_some())?;
|
||||
state
|
||||
.compute
|
||||
.validate_sandbox_create(&sandbox)
|
||||
@@ -3937,6 +3945,114 @@ mod tests {
|
||||
GpuResourceRequirements, SandboxServiceExposure, ServiceAuthorizationMode, ServiceEndpoint,
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_launch_signer_precedes_driver_validation_and_preserves_staged_archive() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let mut state = test_server_state().await;
|
||||
let mut metadata = openshell_core::extension_protocol::extension_metadata(
|
||||
openshell_core::extension_protocol::ExtensionFamily::Compute,
|
||||
"test/launch-authentication",
|
||||
"test",
|
||||
[],
|
||||
);
|
||||
metadata
|
||||
.required_capabilities
|
||||
.push(openshell_core::extension_protocol::COMPUTE_LAUNCH_AUTHENTICATION.to_string());
|
||||
let admission = openshell_core::resource_admission::DriverAdmissionConfig {
|
||||
allow_driver_config: true,
|
||||
..Default::default()
|
||||
};
|
||||
let driver = Arc::new(
|
||||
crate::test_support::FakeComputeDriver::new().with_capabilities(
|
||||
openshell_core::proto::compute::v1::GetCapabilitiesResponse {
|
||||
driver_name: "test".to_string(),
|
||||
default_image: "test/image:latest".to_string(),
|
||||
rootfs_tar_staging_dir: directory.path().to_string_lossy().into_owned(),
|
||||
rootfs_tar_max_bytes: 1024,
|
||||
resource_admission_policy: admission.acknowledgement(),
|
||||
extension: Some(metadata),
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
);
|
||||
let compute = crate::compute::ComputeRuntime::from_driver(
|
||||
"test".to_string(),
|
||||
driver.clone(),
|
||||
None,
|
||||
state.store.clone(),
|
||||
crate::sandbox_index::SandboxIndex::new(),
|
||||
crate::sandbox_watch::SandboxWatchBus::new(),
|
||||
crate::tracing_bus::TracingLogBus::new(),
|
||||
Arc::new(crate::supervisor_session::SupervisorSessionRegistry::new()),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.with_admission_policy(admission)
|
||||
.unwrap();
|
||||
Arc::get_mut(&mut state).unwrap().compute = compute;
|
||||
let staging = state.compute.rootfs_tar_staging();
|
||||
let slot = staging
|
||||
.begin("default", "dev-user", "rootfs.tar", 7)
|
||||
.unwrap();
|
||||
std::fs::write(&slot.upload_path, b"archive").unwrap();
|
||||
let driver_config = Struct {
|
||||
fields: std::iter::once((
|
||||
"test".to_string(),
|
||||
Value {
|
||||
kind: Some(Kind::StructValue(Struct {
|
||||
fields: std::iter::once((
|
||||
crate::compute::rootfs_tar::STAGING_TOKEN_FIELD.to_string(),
|
||||
Value {
|
||||
kind: Some(Kind::StringValue(slot.token.clone())),
|
||||
},
|
||||
))
|
||||
.collect(),
|
||||
})),
|
||||
},
|
||||
))
|
||||
.collect(),
|
||||
};
|
||||
driver.clear_calls();
|
||||
let error = handle_create_sandbox(
|
||||
&state,
|
||||
authed_request(CreateSandboxRequest {
|
||||
name: "missing-signer".to_string(),
|
||||
spec: Some(SandboxSpec {
|
||||
template: Some(SandboxTemplate {
|
||||
driver_config: Some(driver_config),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
}),
|
||||
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
||||
"default".to_string(),
|
||||
)),
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(error.code(), tonic::Code::FailedPrecondition);
|
||||
assert!(error.message().contains("sandbox launch signing"));
|
||||
assert!(
|
||||
driver.calls().is_empty(),
|
||||
"driver validation must not precede signing preflight"
|
||||
);
|
||||
assert_eq!(
|
||||
staging.peek(&slot.token).unwrap(),
|
||||
std::path::PathBuf::from(&slot.upload_path)
|
||||
);
|
||||
assert_eq!(std::fs::read(&slot.upload_path).unwrap(), b"archive");
|
||||
assert!(
|
||||
state
|
||||
.store
|
||||
.get_message_by_name::<Sandbox>("default", "missing-signer")
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
// ---- shell_escape ----
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -2403,6 +2403,31 @@ mod tests {
|
||||
|
||||
// ---- Exec validation ----
|
||||
|
||||
#[test]
|
||||
fn validate_static_fields_rejects_independent_process_identity_changes() {
|
||||
let baseline = ProtoSandboxPolicy {
|
||||
process: Some(openshell_core::proto::ProcessPolicy {
|
||||
run_as_user: "sandbox".into(),
|
||||
run_as_group: "1001".into(),
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
for (user, group) in [
|
||||
("10000", "1001"),
|
||||
("sandbox", "10001"),
|
||||
("", "1001"),
|
||||
("sandbox", ""),
|
||||
] {
|
||||
let mut changed = baseline.clone();
|
||||
changed.process = Some(openshell_core::proto::ProcessPolicy {
|
||||
run_as_user: user.into(),
|
||||
run_as_group: group.into(),
|
||||
});
|
||||
let error = validate_static_fields_unchanged(&baseline, &changed).unwrap_err();
|
||||
assert!(error.message().contains("process policy cannot be changed"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reject_control_chars_allows_normal_values() {
|
||||
assert!(reject_control_chars("hello world", "test").is_ok());
|
||||
|
||||
@@ -536,59 +536,16 @@ pub(crate) async fn run_server(
|
||||
// startup Describe calls can authenticate with gateway-caller tokens.
|
||||
let (extension_jwt_issuer, sandbox_session_jwt_authority) =
|
||||
if let Some(ref jwt) = config.gateway_jwt {
|
||||
let signing_pem = std::fs::read(&jwt.signing_key_path).map_err(|e| {
|
||||
Error::config(format!(
|
||||
"failed to read sandbox JWT signing key from {}: {e}",
|
||||
jwt.signing_key_path.display()
|
||||
))
|
||||
})?;
|
||||
let public_pem = std::fs::read(&jwt.public_key_path).map_err(|e| {
|
||||
Error::config(format!(
|
||||
"failed to read sandbox JWT public key from {}: {e}",
|
||||
jwt.public_key_path.display()
|
||||
))
|
||||
})?;
|
||||
let kid = std::fs::read_to_string(&jwt.kid_path)
|
||||
.map_err(|e| {
|
||||
Error::config(format!(
|
||||
"failed to read sandbox JWT kid from {}: {e}",
|
||||
jwt.kid_path.display()
|
||||
))
|
||||
})?
|
||||
.trim()
|
||||
.to_string();
|
||||
if kid.is_empty() {
|
||||
return Err(Error::config(format!(
|
||||
"sandbox JWT kid file {} is empty",
|
||||
jwt.kid_path.display()
|
||||
)));
|
||||
}
|
||||
let issuer = Arc::new(
|
||||
auth::sandbox_jwt::ExtensionJwtIssuer::from_pem(
|
||||
&signing_pem,
|
||||
&public_pem,
|
||||
kid.clone(),
|
||||
&jwt.gateway_id,
|
||||
jwt.token_ttl(),
|
||||
)
|
||||
.map_err(Error::config)?,
|
||||
);
|
||||
let session_authority = Arc::new(
|
||||
auth::sandbox_jwt::SandboxSessionJwtAuthority::from_pem(
|
||||
&signing_pem,
|
||||
&public_pem,
|
||||
kid,
|
||||
&jwt.gateway_id,
|
||||
jwt.sandbox_token_ttl(),
|
||||
)
|
||||
.map_err(Error::config)?,
|
||||
);
|
||||
let authorities = auth::launch_signing::load(jwt)?;
|
||||
info!(
|
||||
gateway_id = %jwt.gateway_id,
|
||||
ttl_secs = jwt.ttl_secs.map(std::num::NonZeroU64::get),
|
||||
"gateway-minted sandbox JWT enabled"
|
||||
);
|
||||
(Some(issuer), Some(session_authority))
|
||||
(
|
||||
Some(authorities.extension),
|
||||
Some(authorities.sandbox_session),
|
||||
)
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
@@ -1268,6 +1225,21 @@ pub trait ComputeDriverFactory: Send + Sync {
|
||||
false
|
||||
}
|
||||
|
||||
/// Check locally installed host tools after configuration validation.
|
||||
///
|
||||
/// Only the explicit `config preflight` command calls this hook. Probes
|
||||
/// must bound time and output, clean up on cancellation, and avoid driver
|
||||
/// startup, transport connections, images, and runtime state. Return
|
||||
/// operator-readable results including the selected executable paths.
|
||||
/// The process inherits the gateway's account and environment. When
|
||||
/// `cancellation` becomes true, finish process cleanup before returning.
|
||||
async fn preflight_host_tools(
|
||||
&self,
|
||||
_cancellation: watch::Receiver<bool>,
|
||||
) -> Result<Vec<String>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn build(&self, context: ComputeDriverBuildContext<'_>) -> Result<ComputeDriverInstance>;
|
||||
}
|
||||
|
||||
@@ -1732,6 +1704,9 @@ async fn build_compute_runtime(
|
||||
|
||||
let runtime = runtime
|
||||
.with_admission_policy(admission)
|
||||
.and_then(|runtime| {
|
||||
runtime.with_image_preparation_timeout(config.image_preparation_timeout_seconds)
|
||||
})
|
||||
.map_err(Error::config)?;
|
||||
Ok(runtime.with_telemetry_compute_driver(telemetry_compute_driver))
|
||||
}
|
||||
|
||||
@@ -126,12 +126,15 @@ mod tests {
|
||||
// inventories; the provider-environment file map is public-only. The
|
||||
// request has no provider-file capability field: older supervisors ignore
|
||||
// the additive file map while retaining the rest of the response.
|
||||
// Preparation timing adds two optional timestamps to SandboxProvisioning.
|
||||
// Existing rows decode with both absent and retain their active deadline;
|
||||
// no stored attempt gains another phase or time budget on upgrade.
|
||||
// Service authorization also extends both schemas additively. Legacy
|
||||
// payloads retain the safe Strip default.
|
||||
const PUBLIC_RPC_SCHEMA_SHA256: &str =
|
||||
"2e156c6ad3c8eb51bcd30dc13b173fe339b38207a1b1f98f7be2e0cad8e3bd45";
|
||||
"581125d215f2a1967eb73826c411c1e72a53fb3a30a20e85c51d96bbb518e078";
|
||||
const DURABLE_SCHEMA_SHA256: &str =
|
||||
"38165d9d76f49fcfe98a12f241e032838a2376c1d1a87ea2796fd33b9b1a3541";
|
||||
"c1e49c80c52a5c7458952b333e7ca9da2dd24478b41756b710ba29a5217b67d7";
|
||||
const PUBLIC_DURABLE_OVERLAP_SHA256: &str =
|
||||
"761dea31a521b0650840fe2a823ad6e36a265ed323ba4506889781d630df0ee3";
|
||||
// A persisted Sandbox without endpoint status retains its lifecycle fields;
|
||||
|
||||
@@ -140,6 +140,13 @@ impl Default for FakeComputeDriver {
|
||||
}
|
||||
|
||||
impl FakeComputeDriver {
|
||||
/// Override the handshake response to exercise gateway capability admission.
|
||||
#[must_use]
|
||||
pub fn with_capabilities(self, capabilities: GetCapabilitiesResponse) -> Self {
|
||||
self.with_state(|state| state.capabilities = capabilities);
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
|
||||
@@ -701,6 +701,14 @@ pub async fn run_sandbox(
|
||||
ImagePolicyDiscovery::Missing
|
||||
};
|
||||
|
||||
let vm_policy_identity = runtime_descriptor
|
||||
.resource_claims
|
||||
.contains_key("vm.generation")
|
||||
.then_some(VmPolicyIdentity {
|
||||
uid: runtime_descriptor.workload_identity.uid,
|
||||
gid: runtime_descriptor.workload_identity.gid,
|
||||
});
|
||||
|
||||
// Load policy and initialize OPA engine
|
||||
let openshell_endpoint_for_proxy = openshell_endpoint.clone();
|
||||
let sandbox_name_for_agg = sandbox.clone();
|
||||
@@ -721,6 +729,7 @@ pub async fn run_sandbox(
|
||||
policy_data,
|
||||
&extension_credentials,
|
||||
LocalPolicyIdentity::Required,
|
||||
vm_policy_identity,
|
||||
Some(image_discovery),
|
||||
&RemoteStartupGateway {
|
||||
endpoint: openshell_endpoint.clone().unwrap_or_default(),
|
||||
@@ -1092,6 +1101,7 @@ pub async fn run_sandbox(
|
||||
sandbox: poll_sandbox,
|
||||
opa_engine: poll_engine,
|
||||
loaded_policy_origin,
|
||||
vm_identity: vm_policy_identity,
|
||||
entrypoint_pid: poll_pid,
|
||||
interval_secs: poll_interval_secs,
|
||||
ocsf_enabled: poll_ocsf_enabled,
|
||||
@@ -2044,6 +2054,39 @@ enum LocalPolicyIdentity {
|
||||
EndpointOnly,
|
||||
}
|
||||
|
||||
/// The VM driver fixes overlay ownership before this supervisor starts. Guest
|
||||
/// init maps the `sandbox` account to this pair; the host must not resolve
|
||||
/// guest selectors through its own account database.
|
||||
#[derive(Clone, Copy)]
|
||||
struct VmPolicyIdentity {
|
||||
uid: u32,
|
||||
gid: u32,
|
||||
}
|
||||
|
||||
impl VmPolicyIdentity {
|
||||
fn validate(self, policy: &openshell_core::proto::SandboxPolicy) -> Result<()> {
|
||||
let Some(process) = policy.process.as_ref() else {
|
||||
return Ok(());
|
||||
};
|
||||
for (field, selector, expected) in [
|
||||
("run_as_user", process.run_as_user.as_str(), self.uid),
|
||||
("run_as_group", process.run_as_group.as_str(), self.gid),
|
||||
] {
|
||||
if !selector.is_empty()
|
||||
&& selector != "sandbox"
|
||||
&& selector.parse::<u32>() != Ok(expected)
|
||||
{
|
||||
return Err(miette::miette!(
|
||||
"VM {field} '{selector}' conflicts with the resolved workload identity {}:{}; omit the selector or request the driver-owned identity",
|
||||
self.uid,
|
||||
self.gid
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
struct CapturedProviderEnvironment {
|
||||
credentials: ProviderCredentialState,
|
||||
expires_at_ms: Option<i64>,
|
||||
@@ -2100,6 +2143,7 @@ async fn load_policy(
|
||||
extension_credentials,
|
||||
local_policy_identity,
|
||||
None,
|
||||
None,
|
||||
&RemoteStartupGateway {
|
||||
endpoint: openshell_endpoint.unwrap_or_default(),
|
||||
},
|
||||
@@ -2119,6 +2163,7 @@ async fn load_policy_with_gateway(
|
||||
policy_data: Option<String>,
|
||||
extension_credentials: &openshell_extension_core::ExtensionCredentialStore,
|
||||
local_policy_identity: LocalPolicyIdentity,
|
||||
vm_identity: Option<VmPolicyIdentity>,
|
||||
image_discovery: Option<ImagePolicyDiscovery>,
|
||||
gateway: &impl StartupGateway,
|
||||
) -> Result<(
|
||||
@@ -2352,6 +2397,23 @@ async fn load_policy_with_gateway(
|
||||
reconciliation_attempts = 0;
|
||||
continue;
|
||||
}
|
||||
// Admission must reject incompatible image-discovered or repaired
|
||||
// selectors before reporting this policy effective.
|
||||
if let Some(identity) = vm_identity
|
||||
&& let Err(error) = identity.validate(&proto_policy)
|
||||
{
|
||||
reject_startup_configuration(
|
||||
gateway,
|
||||
&mut rejection_log,
|
||||
id,
|
||||
&instance_id,
|
||||
&snapshot,
|
||||
&error.to_string(),
|
||||
)
|
||||
.await?;
|
||||
reconciliation_attempts = 0;
|
||||
continue;
|
||||
}
|
||||
let provider =
|
||||
grpc_retry("Startup provider environment", || gateway.provider(id)).await?;
|
||||
if provider.provider_env_revision != snapshot.provider_env_revision {
|
||||
@@ -2721,6 +2783,7 @@ async fn reload_gateway_policy_runtime(
|
||||
entrypoint_pid,
|
||||
middleware,
|
||||
transparent_tcp,
|
||||
None,
|
||||
|| {},
|
||||
)
|
||||
.await
|
||||
@@ -2732,8 +2795,16 @@ async fn reload_gateway_configuration_runtime(
|
||||
entrypoint_pid: u32,
|
||||
middleware: MiddlewareReloadContext<'_>,
|
||||
transparent_tcp: TransparentTcpReloadState,
|
||||
vm_identity: Option<VmPolicyIdentity>,
|
||||
commit_credentials: impl FnOnce(),
|
||||
) -> std::result::Result<PolicyGenerationGuard, GatewayRuntimeReloadError> {
|
||||
if let (Some(identity), Some(policy)) = (vm_identity, policy) {
|
||||
// Global policy changes also reach this path. Validate before any
|
||||
// policy generation, middleware or provider credentials are committed.
|
||||
identity
|
||||
.validate(policy)
|
||||
.map_err(GatewayRuntimeReloadError::PolicyValidation)?;
|
||||
}
|
||||
if let Some(policy) = policy
|
||||
&& policy_contains_explicit_tcp(policy)
|
||||
{
|
||||
@@ -3399,6 +3470,8 @@ struct PolicyPollLoopContext {
|
||||
/// explicit local-file override from an unbound gateway revision so the
|
||||
/// former is never replaced by policy polling.
|
||||
loaded_policy_origin: LoadedPolicyOrigin,
|
||||
/// Immutable VM overlay identity, also enforced for global policy updates.
|
||||
vm_identity: Option<VmPolicyIdentity>,
|
||||
entrypoint_pid: Arc<AtomicU32>,
|
||||
interval_secs: u64,
|
||||
ocsf_enabled: Arc<AtomicBool>,
|
||||
@@ -4307,6 +4380,7 @@ async fn run_policy_poll_loop_with_client<C: PolicyGatewayClient>(
|
||||
connector: &ctx.middleware_connector,
|
||||
},
|
||||
ctx.transparent_tcp,
|
||||
ctx.vm_identity,
|
||||
|| {
|
||||
if let Some(prepared) = prepared_provider.as_ref() {
|
||||
ctx.provider_credentials.install_prepared(prepared);
|
||||
@@ -5359,6 +5433,150 @@ network_policies:
|
||||
assert!(log.changed(&snapshot, "invalid policy"));
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn startup_rejects_vm_identity_until_matching_policy_is_available() {
|
||||
use openshell_core::proto::{ConfigurationAdmissionState, PolicySource};
|
||||
let mut policy = proto_policy_fixture();
|
||||
enrich_proto_baseline_paths(&mut policy);
|
||||
policy.process = Some(openshell_core::proto::ProcessPolicy {
|
||||
run_as_user: "10000".into(),
|
||||
run_as_group: "1001".into(),
|
||||
});
|
||||
let (reports, mut reported) = tokio::sync::mpsc::unbounded_channel();
|
||||
let gateway = TestStartupGateway {
|
||||
desired: Arc::new(std::sync::Mutex::new(settings_poll_result(
|
||||
Some(policy),
|
||||
1,
|
||||
PolicySource::Sandbox,
|
||||
))),
|
||||
reports,
|
||||
reject_next_accept: Arc::new(AtomicBool::new(false)),
|
||||
snapshot_error: None,
|
||||
report_error: None,
|
||||
pending_snapshot: false,
|
||||
pending_acceptance: false,
|
||||
};
|
||||
let active_gateway = gateway.clone();
|
||||
let handle = tokio::spawn(async move {
|
||||
load_policy_with_gateway(
|
||||
Some("sandbox-id".into()),
|
||||
Some("sandbox".into()),
|
||||
Some("http://unused.invalid".into()),
|
||||
None,
|
||||
None,
|
||||
&openshell_extension_core::ExtensionCredentialStore::new(),
|
||||
LocalPolicyIdentity::Required,
|
||||
Some(VmPolicyIdentity {
|
||||
uid: 1000,
|
||||
gid: 1001,
|
||||
}),
|
||||
Some(ImagePolicyDiscovery::Missing),
|
||||
&active_gateway,
|
||||
)
|
||||
.await
|
||||
});
|
||||
assert_eq!(
|
||||
reported.recv().await,
|
||||
Some(ConfigurationAdmissionState::Pending)
|
||||
);
|
||||
assert_eq!(
|
||||
reported.recv().await,
|
||||
Some(ConfigurationAdmissionState::Rejected)
|
||||
);
|
||||
assert!(
|
||||
!handle.is_finished(),
|
||||
"mismatched policy must never be returned as effective"
|
||||
);
|
||||
{
|
||||
let mut desired = gateway.desired.lock().unwrap();
|
||||
desired
|
||||
.policy
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.process
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.run_as_user = "sandbox".into();
|
||||
desired.config_revision += 1;
|
||||
}
|
||||
assert_eq!(
|
||||
reported.recv().await,
|
||||
Some(ConfigurationAdmissionState::Accepted)
|
||||
);
|
||||
handle
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("matching repair should permit startup");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vm_startup_identity_validates_selectors_independently() {
|
||||
let identity = VmPolicyIdentity {
|
||||
uid: 1000,
|
||||
gid: 1001,
|
||||
};
|
||||
for (user, group, accepted) in [
|
||||
("", "", true),
|
||||
("1000", "", true),
|
||||
("", "1001", true),
|
||||
("sandbox", "sandbox", true),
|
||||
("10000", "", false),
|
||||
("", "10001", false),
|
||||
] {
|
||||
let policy = openshell_core::proto::SandboxPolicy {
|
||||
process: Some(openshell_core::proto::ProcessPolicy {
|
||||
run_as_user: user.into(),
|
||||
run_as_group: group.into(),
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
identity.validate(&policy).is_ok(),
|
||||
accepted,
|
||||
"{user}:{group}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn vm_identity_conflict_prevents_runtime_policy_and_credential_commit() {
|
||||
let mut policy = proto_policy_fixture();
|
||||
let engine = OpaEngine::from_proto(&policy).unwrap();
|
||||
let before = engine.current_generation();
|
||||
policy.process = Some(openshell_core::proto::ProcessPolicy {
|
||||
run_as_user: "sandbox".into(),
|
||||
run_as_group: "10000".into(),
|
||||
});
|
||||
let committed = AtomicBool::new(false);
|
||||
let result = reload_gateway_configuration_runtime(
|
||||
&engine,
|
||||
Some(&policy),
|
||||
0,
|
||||
MiddlewareReloadContext {
|
||||
desired_services: &[],
|
||||
authentication: &MiddlewareAuthentication::default(),
|
||||
registry_changed: false,
|
||||
connector: &default_middleware_connector(),
|
||||
},
|
||||
TransparentTcpReloadState::default(),
|
||||
Some(VmPolicyIdentity {
|
||||
uid: 1000,
|
||||
gid: 1001,
|
||||
}),
|
||||
|| {
|
||||
committed.store(true, Ordering::SeqCst);
|
||||
},
|
||||
)
|
||||
.await;
|
||||
let Err(GatewayRuntimeReloadError::PolicyValidation(error)) = result else {
|
||||
panic!("conflicting VM group should fail policy validation");
|
||||
};
|
||||
assert!(error.to_string().contains("run_as_group '10000'"));
|
||||
assert!(error.to_string().contains("1000:1001"));
|
||||
assert_eq!(engine.current_generation(), before);
|
||||
assert!(!committed.load(Ordering::SeqCst));
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn startup_pending_gateway_calls_exhaust_their_budgets() {
|
||||
for pending_snapshot in [true, false] {
|
||||
@@ -5388,6 +5606,7 @@ network_policies:
|
||||
None,
|
||||
&openshell_extension_core::ExtensionCredentialStore::new(),
|
||||
LocalPolicyIdentity::Required,
|
||||
None,
|
||||
Some(ImagePolicyDiscovery::Missing),
|
||||
&gateway,
|
||||
),
|
||||
@@ -5464,6 +5683,7 @@ network_policies:
|
||||
None,
|
||||
&openshell_extension_core::ExtensionCredentialStore::new(),
|
||||
LocalPolicyIdentity::Required,
|
||||
None,
|
||||
Some(ImagePolicyDiscovery::Missing),
|
||||
&gateway,
|
||||
),
|
||||
@@ -5509,6 +5729,7 @@ network_policies:
|
||||
None,
|
||||
&openshell_extension_core::ExtensionCredentialStore::new(),
|
||||
LocalPolicyIdentity::Required,
|
||||
None,
|
||||
Some(ImagePolicyDiscovery::Missing),
|
||||
&active_gateway,
|
||||
)
|
||||
@@ -6495,6 +6716,7 @@ network_policies:
|
||||
sandbox: "sandbox-test-name".to_string(),
|
||||
opa_engine,
|
||||
loaded_policy_origin,
|
||||
vm_identity: None,
|
||||
entrypoint_pid: Arc::new(AtomicU32::new(0)),
|
||||
interval_secs: 0,
|
||||
ocsf_enabled: Arc::new(AtomicBool::new(false)),
|
||||
|
||||
@@ -2720,7 +2720,12 @@ fn sandbox_notes_for_view(
|
||||
} else {
|
||||
"compute cleanup pending"
|
||||
};
|
||||
let mut notes = format!("Provisioning timed out; {cleanup}");
|
||||
let mut notes =
|
||||
if record.preparation_deadline.is_some() && record.admission_start_time.is_none() {
|
||||
format!("Image preparation timed out; {cleanup}")
|
||||
} else {
|
||||
format!("Provisioning timed out; {cleanup}")
|
||||
};
|
||||
if !forwards.is_empty() {
|
||||
notes.push_str("; ");
|
||||
notes.push_str(&forwards);
|
||||
@@ -3340,6 +3345,26 @@ mod sandbox_notes_tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preparation_timeout_notes_identify_the_expired_phase() {
|
||||
let sandbox = Sandbox {
|
||||
status: Some(SandboxStatus {
|
||||
provisioning: Some(openshell_core::proto::SandboxProvisioning {
|
||||
preparation_deadline: openshell_core::time::timestamp_from_millis(1_800_000)
|
||||
.ok(),
|
||||
timeout_time: openshell_core::time::timestamp_from_millis(1_800_000).ok(),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
sandbox_notes(&sandbox, String::new()),
|
||||
"Image preparation timed out; compute cleanup pending"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configuration_rejection_precedes_forwards_and_clears_after_repair() {
|
||||
let condition = SandboxCondition {
|
||||
|
||||
@@ -120,7 +120,7 @@ Validate a gateway configuration before starting the daemon:
|
||||
|
||||
With no path, preflight validates a nonempty OPENSHELL_GATEWAY_CONFIG. If that
|
||||
variable is unset, it optionally validates an auto-discovered XDG config. The
|
||||
absence of either config succeeds. An explicit missing path, legacy schema-v1
|
||||
absence of either config still validates the effective daemon arguments. An explicit missing path, legacy schema-v1
|
||||
file, invalid TOML, symlink, or nonregular file fails with a nonzero status.
|
||||
Preflight merges file and environment values and applies read-only startup checks
|
||||
for selector and socket normalization, registered compute-driver configuration,
|
||||
@@ -134,6 +134,10 @@ Arguments after **--** replace **--path** mode and are parsed as the exact gatew
|
||||
daemon invocation. Package wrappers use this form so command-line overrides are
|
||||
validated before the same arguments reach startup.
|
||||
|
||||
An explicitly selected local **vm** driver also checks **mke2fs** or **mkfs.ext4**, **debugfs**, and **e2fsck**. Install e2fsprogs 1.43 or newer with the operating system's package manager, then run preflight with the gateway service's account, working directory, configuration, and environment. The command reports selected executable paths and versions. A restricted service **PATH** can select different tools from an interactive shell; include the installation's bin and sbin directories in that service's environment.
|
||||
|
||||
Each executable receives only **-V**, with a five-second deadline and an 8 KiB output limit per stream. Missing, non-executable, unsupported, or failing tools return nonzero status with installation or repair guidance. Preflight creates no images or runtime state and does not start the VM driver. Other drivers do not require these tools. A remote driver endpoint reports that host tool checks were not performed; check a local VM configuration on that host in the driver service's environment.
|
||||
|
||||
The Debian and Ubuntu systemd user unit runs preflight before certificate
|
||||
generation, while retaining its EnvironmentFile and bare ExecStart behavior. The
|
||||
Snap wrapper replays its effective daemon arguments through preflight. It first
|
||||
|
||||
@@ -139,6 +139,10 @@ credential_drivers = ["kubernetes-secrets"]
|
||||
|
||||
ssh_session_ttl_secs = 3600
|
||||
|
||||
# Absolute image preparation and initial supervisor startup budget.
|
||||
# Applies to new attempts only; allowed values are 1 through 86400 seconds.
|
||||
image_preparation_timeout_seconds = 1800
|
||||
|
||||
# Reject invalid policy generations securely by default. Set
|
||||
# "retain_last_valid" only when availability takes priority.
|
||||
policy_validation_failure_mode = "fail_closed"
|
||||
@@ -274,6 +278,12 @@ The client-certificate handshake policy is derived and has no `require_client_au
|
||||
|
||||
`[openshell.gateway.gateway_jwt] ttl_secs` controls generation-bound gateway-facing and Sandbox Protocol credentials minted for a sandbox session, plus typed extension JWTs. Omit it for non-expiring local sandbox session credentials: both session tokens carry `exp = 0`, supervisors skip periodic session-token renewal, and refresh responses omit their expiration timestamps. Typed extension JWTs retain a 900-second default when the field is omitted. Use omission only for local single-player Docker, Podman, or VM gateways. Explicit `0` is invalid. Kubernetes and other shared deployments should set a positive TTL; Helm renders `3600` seconds by default, and the gateway logs a warning when a Kubernetes gateway omits the field.
|
||||
|
||||
Listener TLS and sandbox launch signing use separate keys. A working HTTPS listener or mTLS client certificate does not supply the signing key needed to start a VM sandbox. Configure `signing_key_path`, `public_key_path`, `kid_path`, and `gateway_id` in `[openshell.gateway.gateway_jwt]`, as shown above. For a local installation, `openshell-gateway generate-certs --output-dir <tls-dir>` writes a signing bundle alongside the TLS bundle. Without explicit `gateway_jwt` configuration, the gateway discovers `jwt/signing.pem`, `jwt/public.pem`, and `jwt/kid` under `OPENSHELL_LOCAL_TLS_DIR`, or under the OpenShell state directory's `tls/` directory when that variable is unset. Explicit signing configuration takes precedence over local discovery.
|
||||
|
||||
The gateway rejects partial or invalid signing bundles at startup, including private and public keys that do not match. When the selected driver requires launch signing and no bundle is configured, sandbox creation fails before the driver validates or prepares the sandbox. The error names the signing configuration and discovery location; it does not print keys or tokens. The VM driver also validates launch material before resolving images or creating sandbox state.
|
||||
|
||||
External compute drivers declare this requirement by adding `openshell.compute.launch-authentication` to their extension metadata's `required_capabilities`. This requires a gateway that understands the launch-signing preflight and supplies `SandboxLaunchAuthentication` on creation. Drivers that use another launch mechanism can omit the requirement. The separate `supports_sandbox_authentication` capability describes the `AuthenticateSandbox` RPC and does not require launch signing.
|
||||
|
||||
`[openshell.gateway.auth] allow_unauthenticated_users = true` is an unsafe local-development and trusted-proxy escape hatch. It accepts user-facing CLI/API calls without OIDC or mTLS credentials while sandbox supervisors still authenticate with gateway-minted sandbox JWTs. Leave it false for shared and production gateways.
|
||||
|
||||
## OCSF JSONL Output
|
||||
@@ -1130,6 +1140,8 @@ runtime-selected profile.
|
||||
|
||||
Each sandbox runs inside its own libkrun microVM managed by the standalone `openshell-driver-vm` subprocess. Use this driver when you want stronger isolation than container namespaces alone.
|
||||
|
||||
The example uses explicit launch-signing paths. Point them at an existing signing bundle, or omit the `gateway_jwt` table to use local discovery. Listener and guest TLS certificates do not supply these signing credentials.
|
||||
|
||||
```toml
|
||||
[openshell]
|
||||
version = 2
|
||||
@@ -1144,6 +1156,13 @@ guest_tls_ca = "/var/lib/openshell/guest-tls/ca.pem"
|
||||
guest_tls_cert = "/var/lib/openshell/guest-tls/client.pem"
|
||||
guest_tls_key = "/var/lib/openshell/guest-tls/client-key.pem"
|
||||
|
||||
# Sandbox launch signing is separate from listener and guest TLS.
|
||||
[openshell.gateway.gateway_jwt]
|
||||
signing_key_path = "/etc/openshell/jwt/signing.pem"
|
||||
public_key_path = "/etc/openshell/jwt/public.pem"
|
||||
kid_path = "/etc/openshell/jwt/kid"
|
||||
gateway_id = "openshell"
|
||||
|
||||
[openshell.drivers.vm]
|
||||
state_dir = "/var/lib/openshell/vm"
|
||||
# Where the gateway looks for the openshell-driver-vm subprocess binary.
|
||||
@@ -1211,6 +1230,15 @@ overlay_disk_mib = 4096
|
||||
# rootfs_tar_max_bytes = 10737418240
|
||||
```
|
||||
|
||||
To generate a local bundle for discovery, run these commands in fish and retain the same environment when starting the gateway:
|
||||
|
||||
```shell
|
||||
set -gx OPENSHELL_LOCAL_TLS_DIR ~/.local/state/openshell/tls
|
||||
openshell-gateway generate-certs --output-dir "$OPENSHELL_LOCAL_TLS_DIR"
|
||||
```
|
||||
|
||||
The command also installs local CLI mTLS credentials. For explicit signing configuration, set the example's signing paths to `jwt/signing.pem`, `jwt/public.pem`, and `jwt/kid` under that directory. The gateway loads those paths in preference to local discovery.
|
||||
|
||||
Rootfs tar staging requires the gateway and the VM driver to share a filesystem
|
||||
and run as the same user. That holds for the managed VM driver, which the
|
||||
gateway starts as a subprocess. If you point `compute_driver_endpoints` at an
|
||||
@@ -1248,22 +1276,9 @@ changing it:
|
||||
openshell-gateway config preflight --path ~/.config/openshell/gateway.toml
|
||||
```
|
||||
|
||||
Without `--path`, the command validates a nonempty `OPENSHELL_GATEWAY_CONFIG`.
|
||||
Otherwise, it validates an existing XDG gateway config when one is discovered.
|
||||
When neither source selects a config, preflight succeeds. An explicit missing path,
|
||||
a legacy schema-v1 file, invalid TOML, a symlink, or any nonregular file fails.
|
||||
Preflight merges the selected file with the current `OPENSHELL_*` environment and
|
||||
applies the daemon's read-only startup checks. These checks include selector and
|
||||
socket normalization, registered-driver selection and configuration, rate-limit
|
||||
pairs, TLS and mTLS relationships, interceptor registrations, and supervisor
|
||||
middleware registrations. When a selected file omits `compute_driver`, preflight
|
||||
validates each configured table for an auto-detectable driver without running the
|
||||
runtime detection probes, which can connect local sockets or launch discovery
|
||||
commands. It validates complete guest TLS path sets without requiring
|
||||
package-generated certificates to exist before certificate generation. It does
|
||||
not construct a compute driver or connect to a transport. A failed
|
||||
preflight always preserves the file; it never migrates, replaces, or rewrites
|
||||
configuration.
|
||||
Without `--path`, the command validates a nonempty `OPENSHELL_GATEWAY_CONFIG`. Otherwise, it validates an existing XDG gateway config when one is discovered. When neither source selects a config, preflight validates the effective daemon arguments. An explicit missing path, a legacy schema-v1 file, invalid TOML, a symlink, or any nonregular file fails. Preflight merges the selected file with the current `OPENSHELL_*` environment and applies the daemon's read-only startup checks. These checks include selector and socket normalization, registered-driver selection and configuration, rate-limit pairs, TLS and mTLS relationships, interceptor registrations, and supervisor middleware registrations. When a selected file omits `compute_driver`, preflight validates each configured table for an auto-detectable driver without running the runtime detection probes, which can connect local sockets or launch discovery commands. It validates complete guest TLS path sets without requiring package-generated certificates to exist before certificate generation. It does not construct a compute driver or connect to a transport. A failed preflight always preserves the file; it never migrates, replaces, or rewrites configuration.
|
||||
|
||||
Config preflight validates configuration without loading launch-signing keys, so a successful result does not confirm that the key files exist or form a usable pair. Gateway startup validates configured or discovered signing bundles. Sandbox creation checks the selected driver's launch-signing requirement before image preparation.
|
||||
|
||||
To validate the exact daemon arguments that a wrapper will pass, place them after
|
||||
`--` instead of using `--path`:
|
||||
@@ -1290,3 +1305,26 @@ $EDITOR ~/.config/openshell/gateway.toml
|
||||
openshell-gateway config preflight --path ~/.config/openshell/gateway.toml
|
||||
systemctl --user restart openshell-gateway
|
||||
```
|
||||
|
||||
### Check local VM host tools
|
||||
|
||||
For an explicitly selected local `vm` driver, preflight also checks a formatter (`mke2fs` or `mkfs.ext4`), `debugfs`, and `e2fsck`. Install e2fsprogs 1.43 or newer with your operating system's package manager. OpenShell does not install or manage these host dependencies.
|
||||
|
||||
For example, on macOS:
|
||||
|
||||
```shell
|
||||
brew install e2fsprogs
|
||||
openshell-gateway config preflight -- --config ~/.config/openshell/gateway.toml --compute-driver vm
|
||||
```
|
||||
|
||||
Run preflight with the account, configuration, working directory, and environment intended for the gateway. A successful check in an interactive shell does not check a service with a different `PATH`. For a service whose environment uses a private installation, reproduce its configured path explicitly:
|
||||
|
||||
```shell
|
||||
env PATH=/opt/company/e2fsprogs/sbin:/opt/company/e2fsprogs/bin:/usr/bin:/bin /usr/local/bin/openshell-gateway config preflight -- --config ~/.config/openshell/gateway.toml --compute-driver vm
|
||||
```
|
||||
|
||||
Replace the executable and configuration paths with your installation's paths, and run this command as the service account. Preflight and VM image operations search inherited `PATH` entries, then the existing e2fsprogs `sbin` and `bin` directories under `/opt/homebrew/opt/e2fsprogs` and `/usr/local/opt/e2fsprogs`. They prefer `mke2fs` and try `mkfs.ext4` only when `mke2fs` is absent. A selected file that cannot run is an error; fix that installation or the service's `PATH` before rerunning the command.
|
||||
|
||||
The command reports each selected executable path and version. Each tool receives only `-V`, with a five-second deadline and an 8 KiB output limit per stream. Missing, non-executable, outdated, or failing tools return a nonzero exit status with corrective guidance and the selected tool's error. The checks create no gateway state or images and do not start a driver or VM. Version checks confirm the installation; they do not test image creation or filesystem recovery.
|
||||
|
||||
Other drivers do not require these VM tools. A VM configuration table alone does not select the VM driver. When a remote driver socket is configured, preflight reports that host tool checks were not performed; run the check on that host in the driver service's environment with a local VM configuration.
|
||||
|
||||
@@ -366,14 +366,11 @@ check the sandbox log for the specific error:
|
||||
openshell sandbox get my-sandbox --output json
|
||||
```
|
||||
|
||||
You have 300 seconds to fix the configuration. Replace the policy with
|
||||
`openshell policy set`, or fix the provider configuration. Until the workload
|
||||
first starts, you can also change filesystem, Landlock, and process settings.
|
||||
Each change to the policy, providers, or settings restarts the 300 seconds.
|
||||
You have 300 seconds to fix the configuration after the supervisor starts admission. Image preparation has its own deadline and does not consume that repair time. Replace the policy with `openshell policy set`, or fix the provider configuration. Until the workload first starts, you can also change filesystem, Landlock, and process settings. Each effective change to the policy, providers, or settings restarts the 300 seconds; writing an unchanged value does not.
|
||||
|
||||
If the time runs out, the sandbox moves to `Error` with the reason
|
||||
`ProvisioningTimedOut`. Fixing the configuration does not restart it. After you
|
||||
fix it, start the sandbox again, which begins a new 300-second window:
|
||||
fix it, start the sandbox again. Admission gets a new 300-second window after preparation:
|
||||
|
||||
```shell
|
||||
openshell sandbox start my-sandbox
|
||||
|
||||
@@ -89,11 +89,9 @@ and skipped.
|
||||
| `run_as_group` | string | Driver default | `sandbox` or a numeric GID for the workload. |
|
||||
|
||||
A numeric ID must be from `1` through `4294967294`, so OpenShell rejects root.
|
||||
Each field is independent, so you can set one and let the compute driver choose
|
||||
the other. Only Docker and Podman apply these fields, and only from the policy
|
||||
that you pass when you create the sandbox. Without them, Docker and Podman use
|
||||
the image's `USER`. Kubernetes and VM sandboxes run as the identity configured
|
||||
for their driver.
|
||||
Each field is independent, so you can set one and let the compute driver choose the other. Docker and Podman use these fields to select the workload identity from the policy passed at creation; omitted fields use the image's `USER`. Kubernetes uses the identity configured for its driver.
|
||||
|
||||
MicroVM preserves the driver's resolved overlay owner. An explicit numeric selector must match that owner; `sandbox` names the guest account reconciled to that owner. A conflicting selector fails before the workload starts, and omitted fields accept the driver default. `openshell sandbox get <name>` reports the resolved UID:GID in its `WorkloadIdentity` condition. This condition reports identity selection, not workload readiness. Sandbox policy updates cannot change process selectors; global policy updates must still match the resolved identity. Ordinary exec uses the same identity as the canonical process.
|
||||
|
||||
```yaml showLineNumbers={false}
|
||||
process:
|
||||
|
||||
@@ -915,30 +915,20 @@ Management operations remain available while startup is blocked. After repair,
|
||||
the supervisor completes startup without recreating the sandbox. Starting a
|
||||
stopped sandbox repeats configuration admission before launching its workload.
|
||||
|
||||
The gateway enforces a 300-second provisioning repair window, independently of
|
||||
the CLI wait timeout. An effective policy, settings, provider, profile, or
|
||||
attachment change resets the window from its stored change time. The first
|
||||
failed configuration load for that change grants another full window. Repeated
|
||||
failures and reconnects do not extend it; reaching `Ready` clears it.
|
||||
Image preparation and initial supervisor startup have an absolute deadline of 1800 seconds by default, independently of the CLI wait timeout. Operators can set `image_preparation_timeout_seconds` in `[openshell.gateway]` to any value from 1 through 86400. Each new create or explicit start stores its deadline. Progress events, configuration edits, and gateway or driver restarts cannot extend it. Changing the gateway setting affects new attempts only.
|
||||
|
||||
When the window expires, the sandbox enters `Error` with reason
|
||||
`ProvisioningTimedOut`. The gateway stops its workload and supervisor compute,
|
||||
retaining the sandbox record, diagnostic, and restartable storage. Cleanup can
|
||||
remain pending if the backend is unavailable; the gateway retries it. Inspect
|
||||
`provisioning` in JSON output for the deadline, timeout, and cleanup timestamps.
|
||||
TUI NOTES distinguishes pending cleanup from reclaimed compute.
|
||||
The first authenticated configuration report from the current supervisor ends preparation and starts a separate 300-second admission repair window. A slow image download therefore does not consume time reserved for fixing policy. During admission, an effective policy, settings, provider, profile, or attachment change resets the window from its stored change time. The first failed configuration load for that change grants another full window. Repeated failures, duplicate reports, and reconnects do not extend it; reaching `Ready` clears it.
|
||||
|
||||
Repair the configuration, wait for cleanup to complete, then explicitly restart:
|
||||
Preparation expiry sets the sandbox to `Error` with reason `ImagePreparationTimedOut`; admission repair expiry uses `ProvisioningTimedOut`. The gateway stops its workload and supervisor compute, retaining the sandbox record, diagnostic, and restartable storage. Cleanup can remain pending if the backend is unavailable; the gateway retries it. Inspect `provisioning` in JSON output for the active `phase`, `deadline`, original `preparation_deadline`, `admission_start_time`, and cleanup timestamps. TUI NOTES identifies preparation timeout and distinguishes pending cleanup from reclaimed compute.
|
||||
|
||||
For `ImagePreparationTimedOut`, inspect image preparation and supervisor startup diagnostics and check whether the configured preparation budget is sufficient. For `ProvisioningTimedOut`, repair the rejected configuration. In either case, wait for cleanup to complete, then explicitly restart:
|
||||
|
||||
```shell
|
||||
openshell sandbox get my-sandbox --output json
|
||||
openshell sandbox start my-sandbox
|
||||
```
|
||||
|
||||
Editing configuration after expiry does not restart compute. A retry gets a new
|
||||
300-second window, while static-policy restrictions from any previous activation
|
||||
remain in force. Timed-out records are retained even for ephemeral creates; use
|
||||
`sandbox delete` when you no longer need the diagnostic or stored state.
|
||||
Editing configuration after expiry does not restart compute. An explicit retry gets a new preparation deadline and a separate admission repair window, while static-policy restrictions from any previous activation remain in force. Attempts already active when the gateway is upgraded retain their stored deadline. Timed-out records are retained even for ephemeral creates; use `sandbox delete` when you no longer need the diagnostic or stored state.
|
||||
|
||||
| Phase | Description |
|
||||
| ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
|
||||
@@ -284,7 +284,7 @@ openshell sandbox create \
|
||||
|
||||
## Sandbox User Identity
|
||||
|
||||
Set `process.run_as_user` and `process.run_as_group` in the sandbox policy to choose the sandbox user. Any non-root UID or GID is allowed. When a field is unset, the driver supplies it:
|
||||
On Docker and Podman, set `process.run_as_user` and `process.run_as_group` in the creation policy to choose the sandbox user. Selectors accept `sandbox` or numeric IDs from `1` through `4294967294`. Kubernetes and MicroVM select the identity through driver configuration. When a field is unset, the driver supplies it:
|
||||
|
||||
| Driver | Default identity |
|
||||
|---|---|
|
||||
@@ -292,4 +292,6 @@ Set `process.run_as_user` and `process.run_as_group` in the sandbox policy to ch
|
||||
| Kubernetes | OpenShift SCC namespace annotations, otherwise `1000`. Override with `sandbox_uid` and `sandbox_gid`. |
|
||||
| MicroVM | The image's `sandbox` account, otherwise `1000`. Override with `sandbox_uid` and `sandbox_gid`. |
|
||||
|
||||
MicroVM persists the resolved UID:GID with the writable overlay and retains it across restarts, even if driver defaults change. Explicit policy selectors must match that identity. For example, `run_as_user: "10000"` fails when the overlay owner is `1000:1000`; OpenShell reports both values without changing file ownership. The symbolic selector `sandbox` refers to the guest account for that owner, and each omitted selector accepts its driver default. The `WorkloadIdentity` condition in `openshell sandbox get <name>` shows the resolved pair separately from workload readiness. Canonical and exec processes use this same pair; live policy updates cannot change it.
|
||||
|
||||
On Docker, the image's `WORKDIR` becomes the workspace. Images with no `WORKDIR`, `/`, or `/sandbox` use `/sandbox`. Any other `WORKDIR` must exist in the image and be writable by the sandbox user. Podman, Kubernetes, and MicroVM always use `/sandbox`.
|
||||
|
||||
@@ -4,10 +4,12 @@
|
||||
//! VM-driver-specific assertions for the sandbox root filesystem.
|
||||
|
||||
use std::process::Stdio;
|
||||
use std::time::Duration;
|
||||
|
||||
use openshell_e2e::harness::binary::openshell_cmd;
|
||||
use openshell_e2e::harness::cli::{run_cli, wait_for_sandbox_phase};
|
||||
use openshell_e2e::harness::output::strip_ansi;
|
||||
use openshell_e2e::harness::sandbox::SandboxGuard;
|
||||
use openshell_e2e::harness::sandbox::{SandboxGuard, unique_sandbox_name};
|
||||
|
||||
#[tokio::test]
|
||||
async fn vm_overlay() {
|
||||
@@ -55,3 +57,156 @@ async fn vm_overlay() {
|
||||
|
||||
sandbox.cleanup().await;
|
||||
}
|
||||
|
||||
const IDENTITY_MAIN: &str = "set -eu; if ! test -f /sandbox/canonical-identity; then printf '%s:%s\\n' \"$(id -u)\" \"$(id -g)\" > /sandbox/canonical-identity; fi; echo vm-identity-ready; exec sleep infinity";
|
||||
|
||||
fn identity_policy(user: &str, group: &str) -> tempfile::NamedTempFile {
|
||||
let file = tempfile::NamedTempFile::new().expect("temporary identity policy");
|
||||
std::fs::write(
|
||||
file.path(),
|
||||
format!(
|
||||
r#"version: 1
|
||||
filesystem_policy:
|
||||
include_workdir: true
|
||||
read_only: [/usr, /lib, /lib64, /proc, /etc, /dev/urandom]
|
||||
read_write: [/sandbox, /tmp, /dev/null]
|
||||
landlock:
|
||||
compatibility: hard_requirement
|
||||
process:
|
||||
run_as_user: "{user}"
|
||||
run_as_group: "{group}"
|
||||
network_policies: {{}}
|
||||
"#
|
||||
),
|
||||
)
|
||||
.expect("write identity policy");
|
||||
file
|
||||
}
|
||||
|
||||
async fn assert_workload_identity(sandbox: &SandboxGuard) -> (String, String) {
|
||||
let output = sandbox.exec(&[
|
||||
"sh", "-c",
|
||||
"set -eu; actual=$(id -u):$(id -g); test \"$(cat /sandbox/canonical-identity)\" = \"$actual\"; test \"$(stat -c %u:%g /sandbox/canonical-identity)\" = \"$actual\"; printf 'identity=%s\\n' \"$actual\"",
|
||||
]).await.expect("canonical and exec identities and file ownership agree");
|
||||
let clean = strip_ansi(&output);
|
||||
let pair = clean
|
||||
.lines()
|
||||
.find_map(|line| line.strip_prefix("identity="))
|
||||
.expect("observed workload identity");
|
||||
let (uid, gid) = pair.split_once(':').expect("UID:GID pair");
|
||||
let (status, code) = run_cli(&["sandbox", "get", &sandbox.name, "--output", "json"]).await;
|
||||
assert_eq!(code, 0, "{status}");
|
||||
let status: serde_json::Value =
|
||||
serde_json::from_str(&strip_ansi(&status)).expect("sandbox status JSON");
|
||||
assert!(
|
||||
status["conditions"]
|
||||
.as_array()
|
||||
.expect("conditions")
|
||||
.iter()
|
||||
.any(|condition| {
|
||||
condition["type"] == "WorkloadIdentity"
|
||||
&& condition["message"] == format!("Resolved workload UID:GID is {pair}")
|
||||
}),
|
||||
"status must report observed workload identity: {status}"
|
||||
);
|
||||
(uid.to_string(), gid.to_string())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn vm_identity_matches_status_and_rejects_conflicts() {
|
||||
// Use observed defaults so the test also works with configured driver IDs.
|
||||
// The canonical process writes a file before signaling readiness; exec
|
||||
// verifies its content and ownership independently of the status report.
|
||||
let default_policy = identity_policy("", "");
|
||||
let mut sandbox = SandboxGuard::create_keep_with_args(
|
||||
&[
|
||||
"--policy",
|
||||
default_policy.path().to_str().unwrap(),
|
||||
"--no-tty",
|
||||
],
|
||||
&["sh", "-c", IDENTITY_MAIN],
|
||||
"vm-identity-ready",
|
||||
)
|
||||
.await
|
||||
.expect("omitted selectors use driver identity");
|
||||
let (uid, gid) = assert_workload_identity(&sandbox).await;
|
||||
let original = sandbox
|
||||
.exec(&["stat", "-c", "%u:%g", "/sandbox/canonical-identity"])
|
||||
.await
|
||||
.unwrap();
|
||||
let (output, code) = run_cli(&["sandbox", "stop", &sandbox.name]).await;
|
||||
assert_eq!(code, 0, "{output}");
|
||||
wait_for_sandbox_phase(&sandbox.name, "Stopped", Duration::from_secs(60))
|
||||
.await
|
||||
.unwrap();
|
||||
let (output, code) = run_cli(&["sandbox", "start", &sandbox.name]).await;
|
||||
assert_eq!(code, 0, "{output}");
|
||||
wait_for_sandbox_phase(&sandbox.name, "Ready", Duration::from_secs(120))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
assert_workload_identity(&sandbox).await,
|
||||
(uid.clone(), gid.clone())
|
||||
);
|
||||
let restored = sandbox
|
||||
.exec(&["stat", "-c", "%u:%g", "/sandbox/canonical-identity"])
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
strip_ansi(&restored),
|
||||
strip_ansi(&original),
|
||||
"restart must retain the owned overlay file"
|
||||
);
|
||||
sandbox.cleanup().await;
|
||||
|
||||
for (user, group) in [
|
||||
(uid.as_str(), ""),
|
||||
("", gid.as_str()),
|
||||
("sandbox", "sandbox"),
|
||||
] {
|
||||
let policy = identity_policy(user, group);
|
||||
let mut matching = SandboxGuard::create_keep_with_args(
|
||||
&["--policy", policy.path().to_str().unwrap(), "--no-tty"],
|
||||
&["sh", "-c", IDENTITY_MAIN],
|
||||
"vm-identity-ready",
|
||||
)
|
||||
.await
|
||||
.expect("matching numeric or symbolic selector");
|
||||
assert_eq!(
|
||||
assert_workload_identity(&matching).await,
|
||||
(uid.clone(), gid.clone())
|
||||
);
|
||||
matching.cleanup().await;
|
||||
}
|
||||
|
||||
let wrong_uid = if uid == "10000" { "10001" } else { "10000" };
|
||||
let wrong_gid = if gid == "10000" { "10001" } else { "10000" };
|
||||
for (user, group, field) in [
|
||||
(wrong_uid, "", "run_as_user"),
|
||||
("", wrong_gid, "run_as_group"),
|
||||
] {
|
||||
let policy = identity_policy(user, group);
|
||||
let name = unique_sandbox_name();
|
||||
let mut cleanup = SandboxGuard::manage_existing(name.clone());
|
||||
let result = SandboxGuard::create(&[
|
||||
"--name",
|
||||
&name,
|
||||
"--policy",
|
||||
policy.path().to_str().unwrap(),
|
||||
"--no-tty",
|
||||
])
|
||||
.await;
|
||||
let error = match result {
|
||||
Ok(mut launched) => {
|
||||
launched.cleanup().await;
|
||||
panic!("conflicting VM identity reached Ready");
|
||||
}
|
||||
Err(error) => error,
|
||||
};
|
||||
assert!(
|
||||
error.contains(field) && error.contains(&format!("{uid}:{gid}")),
|
||||
"rejection must name selector and resolved identity: {error}"
|
||||
);
|
||||
cleanup.cleanup().await;
|
||||
}
|
||||
}
|
||||
|
||||
+10
-2
@@ -1184,7 +1184,7 @@ message SandboxStatus {
|
||||
SandboxConfigurationAdmission configuration_admission = 11;
|
||||
// Durable first-acceptance marker. Absent on legacy records; never reset by restart.
|
||||
optional bool configuration_activated = 12;
|
||||
// Gateway-owned repair window. Retained after timeout for inspection and retry.
|
||||
// Gateway-owned provisioning deadlines, retained after timeout for inspection and retry.
|
||||
SandboxProvisioning provisioning = 13;
|
||||
// Consecutive policy-driven restart number in the current crash loop.
|
||||
uint32 restart_count = 14;
|
||||
@@ -3769,7 +3769,7 @@ message SandboxProvisioning {
|
||||
string configuration_change_id = 2;
|
||||
google.protobuf.Timestamp configuration_change_time = 3;
|
||||
google.protobuf.Timestamp first_rejection_time = 4;
|
||||
// Present only while the repair window is armed.
|
||||
// Active preparation or admission-repair deadline. Absent after Ready/timeout.
|
||||
google.protobuf.Timestamp deadline = 5;
|
||||
google.protobuf.Timestamp timeout_time = 6;
|
||||
// Set only after both supervisor and workload compute have been reclaimed.
|
||||
@@ -3781,6 +3781,14 @@ message SandboxProvisioning {
|
||||
// Attachment edits have their own durable clock; status writes do not change it.
|
||||
string attachment_change_id = 10;
|
||||
google.protobuf.Timestamp attachment_change_time = 11;
|
||||
// Absolute ceiling for image preparation and initial supervisor startup.
|
||||
// Set once per new attempt; progress, configuration writes, and restarts
|
||||
// cannot extend it. Absent on attempts created before preparation timing.
|
||||
google.protobuf.Timestamp preparation_deadline = 12;
|
||||
// First authenticated supervisor configuration report for this attempt.
|
||||
// Starts the admission repair window. Absent while preparing, including
|
||||
// after a preparation timeout. Duplicate reports never change this value.
|
||||
google.protobuf.Timestamp admission_start_time = 13;
|
||||
}
|
||||
|
||||
// Create-time request to expose one loopback HTTP service in a sandbox.
|
||||
|
||||
@@ -3118,7 +3118,7 @@ type SandboxStatus struct {
|
||||
ConfigurationAdmission *SandboxConfigurationAdmission `protobuf:"bytes,11,opt,name=configuration_admission,json=configurationAdmission,proto3" json:"configuration_admission,omitempty"`
|
||||
// Durable first-acceptance marker. Absent on legacy records; never reset by restart.
|
||||
ConfigurationActivated *bool `protobuf:"varint,12,opt,name=configuration_activated,json=configurationActivated,proto3,oneof" json:"configuration_activated,omitempty"`
|
||||
// Gateway-owned repair window. Retained after timeout for inspection and retry.
|
||||
// Gateway-owned provisioning deadlines, retained after timeout for inspection and retry.
|
||||
Provisioning *SandboxProvisioning `protobuf:"bytes,13,opt,name=provisioning,proto3" json:"provisioning,omitempty"`
|
||||
// Consecutive policy-driven restart number in the current crash loop.
|
||||
RestartCount uint32 `protobuf:"varint,14,opt,name=restart_count,json=restartCount,proto3" json:"restart_count,omitempty"`
|
||||
@@ -17637,7 +17637,7 @@ type SandboxProvisioning struct {
|
||||
ConfigurationChangeId string `protobuf:"bytes,2,opt,name=configuration_change_id,json=configurationChangeId,proto3" json:"configuration_change_id,omitempty"`
|
||||
ConfigurationChangeTime *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=configuration_change_time,json=configurationChangeTime,proto3" json:"configuration_change_time,omitempty"`
|
||||
FirstRejectionTime *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=first_rejection_time,json=firstRejectionTime,proto3" json:"first_rejection_time,omitempty"`
|
||||
// Present only while the repair window is armed.
|
||||
// Active preparation or admission-repair deadline. Absent after Ready/timeout.
|
||||
Deadline *timestamppb.Timestamp `protobuf:"bytes,5,opt,name=deadline,proto3" json:"deadline,omitempty"`
|
||||
TimeoutTime *timestamppb.Timestamp `protobuf:"bytes,6,opt,name=timeout_time,json=timeoutTime,proto3" json:"timeout_time,omitempty"`
|
||||
// Set only after both supervisor and workload compute have been reclaimed.
|
||||
@@ -17649,8 +17649,16 @@ type SandboxProvisioning struct {
|
||||
// Attachment edits have their own durable clock; status writes do not change it.
|
||||
AttachmentChangeId string `protobuf:"bytes,10,opt,name=attachment_change_id,json=attachmentChangeId,proto3" json:"attachment_change_id,omitempty"`
|
||||
AttachmentChangeTime *timestamppb.Timestamp `protobuf:"bytes,11,opt,name=attachment_change_time,json=attachmentChangeTime,proto3" json:"attachment_change_time,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
// Absolute ceiling for image preparation and initial supervisor startup.
|
||||
// Set once per new attempt; progress, configuration writes, and restarts
|
||||
// cannot extend it. Absent on attempts created before preparation timing.
|
||||
PreparationDeadline *timestamppb.Timestamp `protobuf:"bytes,12,opt,name=preparation_deadline,json=preparationDeadline,proto3" json:"preparation_deadline,omitempty"`
|
||||
// First authenticated supervisor configuration report for this attempt.
|
||||
// Starts the admission repair window. Absent while preparing, including
|
||||
// after a preparation timeout. Duplicate reports never change this value.
|
||||
AdmissionStartTime *timestamppb.Timestamp `protobuf:"bytes,13,opt,name=admission_start_time,json=admissionStartTime,proto3" json:"admission_start_time,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *SandboxProvisioning) Reset() {
|
||||
@@ -17760,6 +17768,20 @@ func (x *SandboxProvisioning) GetAttachmentChangeTime() *timestamppb.Timestamp {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *SandboxProvisioning) GetPreparationDeadline() *timestamppb.Timestamp {
|
||||
if x != nil {
|
||||
return x.PreparationDeadline
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *SandboxProvisioning) GetAdmissionStartTime() *timestamppb.Timestamp {
|
||||
if x != nil {
|
||||
return x.AdmissionStartTime
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create-time request to expose one loopback HTTP service in a sandbox.
|
||||
type SandboxServiceExposure struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
@@ -19141,7 +19163,7 @@ const file_openshell_proto_rawDesc = "" +
|
||||
"\x04path\x18\x04 \x01(\tR\x04path\x12=\n" +
|
||||
"\vlast_result\x18\x05 \x01(\x0e2\x1c.openshell.v1.EndpointResultR\n" +
|
||||
"lastResult\x12H\n" +
|
||||
"\x12last_reported_time\x18j \x01(\v2\x1a.google.protobuf.TimestampR\x10lastReportedTimeJ\x04\b\x06\x10\aR\x10last_reported_at\"\xce\x05\n" +
|
||||
"\x12last_reported_time\x18j \x01(\v2\x1a.google.protobuf.TimestampR\x10lastReportedTimeJ\x04\b\x06\x10\aR\x10last_reported_at\"\xeb\x06\n" +
|
||||
"\x13SandboxProvisioning\x12\x1d\n" +
|
||||
"\n" +
|
||||
"attempt_id\x18\x01 \x01(\tR\tattemptId\x126\n" +
|
||||
@@ -19155,7 +19177,9 @@ const file_openshell_proto_rawDesc = "" +
|
||||
"\x12cleanup_retry_time\x18\t \x01(\v2\x1a.google.protobuf.TimestampR\x10cleanupRetryTime\x120\n" +
|
||||
"\x14attachment_change_id\x18\n" +
|
||||
" \x01(\tR\x12attachmentChangeId\x12P\n" +
|
||||
"\x16attachment_change_time\x18\v \x01(\v2\x1a.google.protobuf.TimestampR\x14attachmentChangeTime\"\xaa\x01\n" +
|
||||
"\x16attachment_change_time\x18\v \x01(\v2\x1a.google.protobuf.TimestampR\x14attachmentChangeTime\x12M\n" +
|
||||
"\x14preparation_deadline\x18\f \x01(\v2\x1a.google.protobuf.TimestampR\x13preparationDeadline\x12L\n" +
|
||||
"\x14admission_start_time\x18\r \x01(\v2\x1a.google.protobuf.TimestampR\x12admissionStartTime\"\xaa\x01\n" +
|
||||
"\x16SandboxServiceExposure\x12\x18\n" +
|
||||
"\aservice\x18\x01 \x01(\tR\aservice\x12\x1f\n" +
|
||||
"\vtarget_port\x18\x02 \x01(\rR\n" +
|
||||
@@ -20108,180 +20132,182 @@ var file_openshell_proto_depIdxs = []int32{
|
||||
275, // 316: openshell.v1.SandboxProvisioning.cleanup_completed_time:type_name -> google.protobuf.Timestamp
|
||||
275, // 317: openshell.v1.SandboxProvisioning.cleanup_retry_time:type_name -> google.protobuf.Timestamp
|
||||
275, // 318: openshell.v1.SandboxProvisioning.attachment_change_time:type_name -> google.protobuf.Timestamp
|
||||
19, // 319: openshell.v1.SandboxServiceExposure.authorization_mode:type_name -> openshell.v1.ServiceAuthorizationMode
|
||||
275, // 320: openshell.v1.UpdateProviderRequest.CredentialExpirationTimesEntry.value:type_name -> google.protobuf.Timestamp
|
||||
275, // 321: openshell.v1.GetSandboxProviderEnvironmentResponse.CredentialExpirationTimesEntry.value:type_name -> google.protobuf.Timestamp
|
||||
127, // 322: openshell.v1.GetSandboxProviderEnvironmentResponse.DynamicCredentialsEntry.value:type_name -> openshell.v1.ProviderProfileCredential
|
||||
156, // 323: openshell.v1.GetSandboxProviderEnvironmentResponse.StaticCredentialBindingsEntry.value:type_name -> openshell.v1.StaticCredentialBinding
|
||||
24, // 324: openshell.v1.OpenShell.Health:input_type -> openshell.v1.HealthRequest
|
||||
26, // 325: openshell.v1.OpenShell.GetCurrentUser:input_type -> openshell.v1.GetCurrentUserRequest
|
||||
28, // 326: openshell.v1.OpenShell.GetGatewayInfo:input_type -> openshell.v1.GetGatewayInfoRequest
|
||||
52, // 327: openshell.v1.OpenShell.CreateSandbox:input_type -> openshell.v1.CreateSandboxRequest
|
||||
60, // 328: openshell.v1.OpenShell.BeginRootfsTarStaging:input_type -> openshell.v1.BeginRootfsTarStagingRequest
|
||||
62, // 329: openshell.v1.OpenShell.GetSandbox:input_type -> openshell.v1.GetSandboxRequest
|
||||
63, // 330: openshell.v1.OpenShell.ListSandboxes:input_type -> openshell.v1.ListSandboxesRequest
|
||||
53, // 331: openshell.v1.OpenShell.CreateSandboxTemplate:input_type -> openshell.v1.CreateSandboxTemplateRequest
|
||||
54, // 332: openshell.v1.OpenShell.GetSandboxTemplate:input_type -> openshell.v1.GetSandboxTemplateRequest
|
||||
55, // 333: openshell.v1.OpenShell.ListSandboxTemplates:input_type -> openshell.v1.ListSandboxTemplatesRequest
|
||||
56, // 334: openshell.v1.OpenShell.DeleteSandboxTemplate:input_type -> openshell.v1.DeleteSandboxTemplateRequest
|
||||
64, // 335: openshell.v1.OpenShell.ListSandboxProviders:input_type -> openshell.v1.ListSandboxProvidersRequest
|
||||
65, // 336: openshell.v1.OpenShell.AttachSandboxProvider:input_type -> openshell.v1.AttachSandboxProviderRequest
|
||||
66, // 337: openshell.v1.OpenShell.DetachSandboxProvider:input_type -> openshell.v1.DetachSandboxProviderRequest
|
||||
82, // 338: openshell.v1.OpenShell.GetSandboxProviderStatus:input_type -> openshell.v1.GetSandboxProviderStatusRequest
|
||||
67, // 339: openshell.v1.OpenShell.DeleteSandbox:input_type -> openshell.v1.DeleteSandboxRequest
|
||||
68, // 340: openshell.v1.OpenShell.StopSandbox:input_type -> openshell.v1.StopSandboxRequest
|
||||
69, // 341: openshell.v1.OpenShell.StartSandbox:input_type -> openshell.v1.StartSandboxRequest
|
||||
87, // 342: openshell.v1.OpenShell.CreateSshSession:input_type -> openshell.v1.CreateSshSessionRequest
|
||||
89, // 343: openshell.v1.OpenShell.ExposeService:input_type -> openshell.v1.ExposeServiceRequest
|
||||
90, // 344: openshell.v1.OpenShell.GetService:input_type -> openshell.v1.GetServiceRequest
|
||||
91, // 345: openshell.v1.OpenShell.ListServices:input_type -> openshell.v1.ListServicesRequest
|
||||
93, // 346: openshell.v1.OpenShell.DeleteService:input_type -> openshell.v1.DeleteServiceRequest
|
||||
97, // 347: openshell.v1.OpenShell.RevokeSshSession:input_type -> openshell.v1.RevokeSshSessionRequest
|
||||
99, // 348: openshell.v1.OpenShell.ExecSandbox:input_type -> openshell.v1.ExecSandboxRequest
|
||||
105, // 349: openshell.v1.OpenShell.ForwardTcp:input_type -> openshell.v1.TcpForwardFrame
|
||||
106, // 350: openshell.v1.OpenShell.ExecSandboxInteractive:input_type -> openshell.v1.ExecSandboxInput
|
||||
113, // 351: openshell.v1.OpenShell.CreateProvider:input_type -> openshell.v1.CreateProviderRequest
|
||||
114, // 352: openshell.v1.OpenShell.GetProvider:input_type -> openshell.v1.GetProviderRequest
|
||||
115, // 353: openshell.v1.OpenShell.ListProviders:input_type -> openshell.v1.ListProvidersRequest
|
||||
120, // 354: openshell.v1.OpenShell.ListProviderProfiles:input_type -> openshell.v1.ListProviderProfilesRequest
|
||||
121, // 355: openshell.v1.OpenShell.GetProviderProfile:input_type -> openshell.v1.GetProviderProfileRequest
|
||||
145, // 356: openshell.v1.OpenShell.ImportProviderProfiles:input_type -> openshell.v1.ImportProviderProfilesRequest
|
||||
147, // 357: openshell.v1.OpenShell.UpdateProviderProfiles:input_type -> openshell.v1.UpdateProviderProfilesRequest
|
||||
149, // 358: openshell.v1.OpenShell.LintProviderProfiles:input_type -> openshell.v1.LintProviderProfilesRequest
|
||||
116, // 359: openshell.v1.OpenShell.UpdateProvider:input_type -> openshell.v1.UpdateProviderRequest
|
||||
133, // 360: openshell.v1.OpenShell.GetProviderRefreshStatus:input_type -> openshell.v1.GetProviderRefreshStatusRequest
|
||||
135, // 361: openshell.v1.OpenShell.ConfigureProviderRefresh:input_type -> openshell.v1.ConfigureProviderRefreshRequest
|
||||
137, // 362: openshell.v1.OpenShell.RotateProviderCredential:input_type -> openshell.v1.RotateProviderCredentialRequest
|
||||
139, // 363: openshell.v1.OpenShell.DeleteProviderRefresh:input_type -> openshell.v1.DeleteProviderRefreshRequest
|
||||
117, // 364: openshell.v1.OpenShell.DeleteProvider:input_type -> openshell.v1.DeleteProviderRequest
|
||||
152, // 365: openshell.v1.OpenShell.DeleteProviderProfile:input_type -> openshell.v1.DeleteProviderProfileRequest
|
||||
290, // 366: openshell.v1.OpenShell.GetSandboxConfig:input_type -> openshell.sandbox.v1.GetSandboxConfigRequest
|
||||
291, // 367: openshell.v1.OpenShell.GetGatewayConfig:input_type -> openshell.sandbox.v1.GetGatewayConfigRequest
|
||||
160, // 368: openshell.v1.OpenShell.UpdateConfig:input_type -> openshell.v1.UpdateConfigRequest
|
||||
170, // 369: openshell.v1.OpenShell.GetSandboxPolicyStatus:input_type -> openshell.v1.GetSandboxPolicyStatusRequest
|
||||
172, // 370: openshell.v1.OpenShell.ListSandboxPolicies:input_type -> openshell.v1.ListSandboxPoliciesRequest
|
||||
174, // 371: openshell.v1.OpenShell.ReportPolicyStatus:input_type -> openshell.v1.ReportPolicyStatusRequest
|
||||
247, // 372: openshell.v1.OpenShell.ReportEndpointStatus:input_type -> openshell.v1.ReportEndpointStatusRequest
|
||||
84, // 373: openshell.v1.OpenShell.ReportProviderReadiness:input_type -> openshell.v1.ReportProviderReadinessRequest
|
||||
177, // 374: openshell.v1.OpenShell.ReportSandboxConfiguration:input_type -> openshell.v1.ReportSandboxConfigurationRequest
|
||||
154, // 375: openshell.v1.OpenShell.GetSandboxProviderEnvironment:input_type -> openshell.v1.GetSandboxProviderEnvironmentRequest
|
||||
158, // 376: openshell.v1.OpenShell.ExchangeProviderSubjectToken:input_type -> openshell.v1.ExchangeProviderSubjectTokenRequest
|
||||
180, // 377: openshell.v1.OpenShell.GetSandboxLogs:input_type -> openshell.v1.GetSandboxLogsRequest
|
||||
181, // 378: openshell.v1.OpenShell.PushSandboxLogs:input_type -> openshell.v1.PushSandboxLogsRequest
|
||||
184, // 379: openshell.v1.OpenShell.ConnectSupervisor:input_type -> openshell.v1.SupervisorMessage
|
||||
191, // 380: openshell.v1.OpenShell.ReportMainProcessExit:input_type -> openshell.v1.ReportMainProcessExitRequest
|
||||
193, // 381: openshell.v1.OpenShell.FinalizeMainProcessExit:input_type -> openshell.v1.FinalizeMainProcessExitRequest
|
||||
199, // 382: openshell.v1.OpenShell.RelayStream:input_type -> openshell.v1.RelayFrame
|
||||
201, // 383: openshell.v1.OpenShell.PeerRelay:input_type -> openshell.v1.PeerRelayFrame
|
||||
84, // 384: openshell.v1.OpenShell.PeerReportProviderReadiness:input_type -> openshell.v1.ReportProviderReadinessRequest
|
||||
247, // 385: openshell.v1.OpenShell.PeerReportEndpointStatus:input_type -> openshell.v1.ReportEndpointStatusRequest
|
||||
82, // 386: openshell.v1.OpenShell.PeerGetSandboxProviderStatus:input_type -> openshell.v1.GetSandboxProviderStatusRequest
|
||||
109, // 387: openshell.v1.OpenShell.WatchSandbox:input_type -> openshell.v1.WatchSandboxRequest
|
||||
210, // 388: openshell.v1.OpenShell.SubmitPolicyAnalysis:input_type -> openshell.v1.SubmitPolicyAnalysisRequest
|
||||
212, // 389: openshell.v1.OpenShell.GetDraftPolicy:input_type -> openshell.v1.GetDraftPolicyRequest
|
||||
214, // 390: openshell.v1.OpenShell.ApproveDraftChunk:input_type -> openshell.v1.ApproveDraftChunkRequest
|
||||
216, // 391: openshell.v1.OpenShell.RejectDraftChunk:input_type -> openshell.v1.RejectDraftChunkRequest
|
||||
219, // 392: openshell.v1.OpenShell.ApproveAllDraftChunks:input_type -> openshell.v1.ApproveAllDraftChunksRequest
|
||||
221, // 393: openshell.v1.OpenShell.EditDraftChunk:input_type -> openshell.v1.EditDraftChunkRequest
|
||||
223, // 394: openshell.v1.OpenShell.UndoDraftChunk:input_type -> openshell.v1.UndoDraftChunkRequest
|
||||
225, // 395: openshell.v1.OpenShell.ClearDraftChunks:input_type -> openshell.v1.ClearDraftChunksRequest
|
||||
227, // 396: openshell.v1.OpenShell.GetDraftHistory:input_type -> openshell.v1.GetDraftHistoryRequest
|
||||
20, // 397: openshell.v1.OpenShell.IssueSandboxToken:input_type -> openshell.v1.IssueSandboxTokenRequest
|
||||
22, // 398: openshell.v1.OpenShell.RefreshSandboxToken:input_type -> openshell.v1.RefreshSandboxTokenRequest
|
||||
230, // 399: openshell.v1.OpenShell.CreateWorkspace:input_type -> openshell.v1.CreateWorkspaceRequest
|
||||
232, // 400: openshell.v1.OpenShell.GetWorkspace:input_type -> openshell.v1.GetWorkspaceRequest
|
||||
234, // 401: openshell.v1.OpenShell.ListWorkspaces:input_type -> openshell.v1.ListWorkspacesRequest
|
||||
236, // 402: openshell.v1.OpenShell.DeleteWorkspace:input_type -> openshell.v1.DeleteWorkspaceRequest
|
||||
239, // 403: openshell.v1.OpenShell.AddWorkspaceMember:input_type -> openshell.v1.AddWorkspaceMemberRequest
|
||||
241, // 404: openshell.v1.OpenShell.RemoveWorkspaceMember:input_type -> openshell.v1.RemoveWorkspaceMemberRequest
|
||||
243, // 405: openshell.v1.OpenShell.ListWorkspaceMembers:input_type -> openshell.v1.ListWorkspaceMembersRequest
|
||||
25, // 406: openshell.v1.OpenShell.Health:output_type -> openshell.v1.HealthResponse
|
||||
27, // 407: openshell.v1.OpenShell.GetCurrentUser:output_type -> openshell.v1.GetCurrentUserResponse
|
||||
29, // 408: openshell.v1.OpenShell.GetGatewayInfo:output_type -> openshell.v1.GetGatewayInfoResponse
|
||||
70, // 409: openshell.v1.OpenShell.CreateSandbox:output_type -> openshell.v1.SandboxResponse
|
||||
61, // 410: openshell.v1.OpenShell.BeginRootfsTarStaging:output_type -> openshell.v1.BeginRootfsTarStagingResponse
|
||||
70, // 411: openshell.v1.OpenShell.GetSandbox:output_type -> openshell.v1.SandboxResponse
|
||||
71, // 412: openshell.v1.OpenShell.ListSandboxes:output_type -> openshell.v1.ListSandboxesResponse
|
||||
57, // 413: openshell.v1.OpenShell.CreateSandboxTemplate:output_type -> openshell.v1.SandboxTemplateResponse
|
||||
57, // 414: openshell.v1.OpenShell.GetSandboxTemplate:output_type -> openshell.v1.SandboxTemplateResponse
|
||||
58, // 415: openshell.v1.OpenShell.ListSandboxTemplates:output_type -> openshell.v1.ListSandboxTemplatesResponse
|
||||
59, // 416: openshell.v1.OpenShell.DeleteSandboxTemplate:output_type -> openshell.v1.DeleteSandboxTemplateResponse
|
||||
72, // 417: openshell.v1.OpenShell.ListSandboxProviders:output_type -> openshell.v1.ListSandboxProvidersResponse
|
||||
73, // 418: openshell.v1.OpenShell.AttachSandboxProvider:output_type -> openshell.v1.AttachSandboxProviderResponse
|
||||
74, // 419: openshell.v1.OpenShell.DetachSandboxProvider:output_type -> openshell.v1.DetachSandboxProviderResponse
|
||||
83, // 420: openshell.v1.OpenShell.GetSandboxProviderStatus:output_type -> openshell.v1.GetSandboxProviderStatusResponse
|
||||
86, // 421: openshell.v1.OpenShell.DeleteSandbox:output_type -> openshell.v1.DeleteSandboxResponse
|
||||
70, // 422: openshell.v1.OpenShell.StopSandbox:output_type -> openshell.v1.SandboxResponse
|
||||
70, // 423: openshell.v1.OpenShell.StartSandbox:output_type -> openshell.v1.SandboxResponse
|
||||
88, // 424: openshell.v1.OpenShell.CreateSshSession:output_type -> openshell.v1.CreateSshSessionResponse
|
||||
96, // 425: openshell.v1.OpenShell.ExposeService:output_type -> openshell.v1.ServiceEndpointResponse
|
||||
96, // 426: openshell.v1.OpenShell.GetService:output_type -> openshell.v1.ServiceEndpointResponse
|
||||
92, // 427: openshell.v1.OpenShell.ListServices:output_type -> openshell.v1.ListServicesResponse
|
||||
94, // 428: openshell.v1.OpenShell.DeleteService:output_type -> openshell.v1.DeleteServiceResponse
|
||||
98, // 429: openshell.v1.OpenShell.RevokeSshSession:output_type -> openshell.v1.RevokeSshSessionResponse
|
||||
103, // 430: openshell.v1.OpenShell.ExecSandbox:output_type -> openshell.v1.ExecSandboxEvent
|
||||
105, // 431: openshell.v1.OpenShell.ForwardTcp:output_type -> openshell.v1.TcpForwardFrame
|
||||
103, // 432: openshell.v1.OpenShell.ExecSandboxInteractive:output_type -> openshell.v1.ExecSandboxEvent
|
||||
118, // 433: openshell.v1.OpenShell.CreateProvider:output_type -> openshell.v1.ProviderResponse
|
||||
118, // 434: openshell.v1.OpenShell.GetProvider:output_type -> openshell.v1.ProviderResponse
|
||||
119, // 435: openshell.v1.OpenShell.ListProviders:output_type -> openshell.v1.ListProvidersResponse
|
||||
144, // 436: openshell.v1.OpenShell.ListProviderProfiles:output_type -> openshell.v1.ListProviderProfilesResponse
|
||||
143, // 437: openshell.v1.OpenShell.GetProviderProfile:output_type -> openshell.v1.ProviderProfileResponse
|
||||
146, // 438: openshell.v1.OpenShell.ImportProviderProfiles:output_type -> openshell.v1.ImportProviderProfilesResponse
|
||||
148, // 439: openshell.v1.OpenShell.UpdateProviderProfiles:output_type -> openshell.v1.UpdateProviderProfilesResponse
|
||||
150, // 440: openshell.v1.OpenShell.LintProviderProfiles:output_type -> openshell.v1.LintProviderProfilesResponse
|
||||
118, // 441: openshell.v1.OpenShell.UpdateProvider:output_type -> openshell.v1.ProviderResponse
|
||||
134, // 442: openshell.v1.OpenShell.GetProviderRefreshStatus:output_type -> openshell.v1.GetProviderRefreshStatusResponse
|
||||
136, // 443: openshell.v1.OpenShell.ConfigureProviderRefresh:output_type -> openshell.v1.ConfigureProviderRefreshResponse
|
||||
138, // 444: openshell.v1.OpenShell.RotateProviderCredential:output_type -> openshell.v1.RotateProviderCredentialResponse
|
||||
140, // 445: openshell.v1.OpenShell.DeleteProviderRefresh:output_type -> openshell.v1.DeleteProviderRefreshResponse
|
||||
151, // 446: openshell.v1.OpenShell.DeleteProvider:output_type -> openshell.v1.DeleteProviderResponse
|
||||
153, // 447: openshell.v1.OpenShell.DeleteProviderProfile:output_type -> openshell.v1.DeleteProviderProfileResponse
|
||||
292, // 448: openshell.v1.OpenShell.GetSandboxConfig:output_type -> openshell.sandbox.v1.GetSandboxConfigResponse
|
||||
293, // 449: openshell.v1.OpenShell.GetGatewayConfig:output_type -> openshell.sandbox.v1.GetGatewayConfigResponse
|
||||
169, // 450: openshell.v1.OpenShell.UpdateConfig:output_type -> openshell.v1.UpdateConfigResponse
|
||||
171, // 451: openshell.v1.OpenShell.GetSandboxPolicyStatus:output_type -> openshell.v1.GetSandboxPolicyStatusResponse
|
||||
173, // 452: openshell.v1.OpenShell.ListSandboxPolicies:output_type -> openshell.v1.ListSandboxPoliciesResponse
|
||||
175, // 453: openshell.v1.OpenShell.ReportPolicyStatus:output_type -> openshell.v1.ReportPolicyStatusResponse
|
||||
248, // 454: openshell.v1.OpenShell.ReportEndpointStatus:output_type -> openshell.v1.ReportEndpointStatusResponse
|
||||
85, // 455: openshell.v1.OpenShell.ReportProviderReadiness:output_type -> openshell.v1.ReportProviderReadinessResponse
|
||||
178, // 456: openshell.v1.OpenShell.ReportSandboxConfiguration:output_type -> openshell.v1.ReportSandboxConfigurationResponse
|
||||
157, // 457: openshell.v1.OpenShell.GetSandboxProviderEnvironment:output_type -> openshell.v1.GetSandboxProviderEnvironmentResponse
|
||||
159, // 458: openshell.v1.OpenShell.ExchangeProviderSubjectToken:output_type -> openshell.v1.ExchangeProviderSubjectTokenResponse
|
||||
183, // 459: openshell.v1.OpenShell.GetSandboxLogs:output_type -> openshell.v1.GetSandboxLogsResponse
|
||||
182, // 460: openshell.v1.OpenShell.PushSandboxLogs:output_type -> openshell.v1.PushSandboxLogsResponse
|
||||
185, // 461: openshell.v1.OpenShell.ConnectSupervisor:output_type -> openshell.v1.GatewayMessage
|
||||
192, // 462: openshell.v1.OpenShell.ReportMainProcessExit:output_type -> openshell.v1.ReportMainProcessExitResponse
|
||||
194, // 463: openshell.v1.OpenShell.FinalizeMainProcessExit:output_type -> openshell.v1.FinalizeMainProcessExitResponse
|
||||
199, // 464: openshell.v1.OpenShell.RelayStream:output_type -> openshell.v1.RelayFrame
|
||||
201, // 465: openshell.v1.OpenShell.PeerRelay:output_type -> openshell.v1.PeerRelayFrame
|
||||
85, // 466: openshell.v1.OpenShell.PeerReportProviderReadiness:output_type -> openshell.v1.ReportProviderReadinessResponse
|
||||
248, // 467: openshell.v1.OpenShell.PeerReportEndpointStatus:output_type -> openshell.v1.ReportEndpointStatusResponse
|
||||
83, // 468: openshell.v1.OpenShell.PeerGetSandboxProviderStatus:output_type -> openshell.v1.GetSandboxProviderStatusResponse
|
||||
110, // 469: openshell.v1.OpenShell.WatchSandbox:output_type -> openshell.v1.SandboxStreamEvent
|
||||
211, // 470: openshell.v1.OpenShell.SubmitPolicyAnalysis:output_type -> openshell.v1.SubmitPolicyAnalysisResponse
|
||||
213, // 471: openshell.v1.OpenShell.GetDraftPolicy:output_type -> openshell.v1.GetDraftPolicyResponse
|
||||
215, // 472: openshell.v1.OpenShell.ApproveDraftChunk:output_type -> openshell.v1.ApproveDraftChunkResponse
|
||||
217, // 473: openshell.v1.OpenShell.RejectDraftChunk:output_type -> openshell.v1.RejectDraftChunkResponse
|
||||
220, // 474: openshell.v1.OpenShell.ApproveAllDraftChunks:output_type -> openshell.v1.ApproveAllDraftChunksResponse
|
||||
222, // 475: openshell.v1.OpenShell.EditDraftChunk:output_type -> openshell.v1.EditDraftChunkResponse
|
||||
224, // 476: openshell.v1.OpenShell.UndoDraftChunk:output_type -> openshell.v1.UndoDraftChunkResponse
|
||||
226, // 477: openshell.v1.OpenShell.ClearDraftChunks:output_type -> openshell.v1.ClearDraftChunksResponse
|
||||
229, // 478: openshell.v1.OpenShell.GetDraftHistory:output_type -> openshell.v1.GetDraftHistoryResponse
|
||||
21, // 479: openshell.v1.OpenShell.IssueSandboxToken:output_type -> openshell.v1.IssueSandboxTokenResponse
|
||||
23, // 480: openshell.v1.OpenShell.RefreshSandboxToken:output_type -> openshell.v1.RefreshSandboxTokenResponse
|
||||
231, // 481: openshell.v1.OpenShell.CreateWorkspace:output_type -> openshell.v1.CreateWorkspaceResponse
|
||||
233, // 482: openshell.v1.OpenShell.GetWorkspace:output_type -> openshell.v1.GetWorkspaceResponse
|
||||
235, // 483: openshell.v1.OpenShell.ListWorkspaces:output_type -> openshell.v1.ListWorkspacesResponse
|
||||
237, // 484: openshell.v1.OpenShell.DeleteWorkspace:output_type -> openshell.v1.DeleteWorkspaceResponse
|
||||
240, // 485: openshell.v1.OpenShell.AddWorkspaceMember:output_type -> openshell.v1.AddWorkspaceMemberResponse
|
||||
242, // 486: openshell.v1.OpenShell.RemoveWorkspaceMember:output_type -> openshell.v1.RemoveWorkspaceMemberResponse
|
||||
244, // 487: openshell.v1.OpenShell.ListWorkspaceMembers:output_type -> openshell.v1.ListWorkspaceMembersResponse
|
||||
406, // [406:488] is the sub-list for method output_type
|
||||
324, // [324:406] is the sub-list for method input_type
|
||||
324, // [324:324] is the sub-list for extension type_name
|
||||
324, // [324:324] is the sub-list for extension extendee
|
||||
0, // [0:324] is the sub-list for field type_name
|
||||
275, // 319: openshell.v1.SandboxProvisioning.preparation_deadline:type_name -> google.protobuf.Timestamp
|
||||
275, // 320: openshell.v1.SandboxProvisioning.admission_start_time:type_name -> google.protobuf.Timestamp
|
||||
19, // 321: openshell.v1.SandboxServiceExposure.authorization_mode:type_name -> openshell.v1.ServiceAuthorizationMode
|
||||
275, // 322: openshell.v1.UpdateProviderRequest.CredentialExpirationTimesEntry.value:type_name -> google.protobuf.Timestamp
|
||||
275, // 323: openshell.v1.GetSandboxProviderEnvironmentResponse.CredentialExpirationTimesEntry.value:type_name -> google.protobuf.Timestamp
|
||||
127, // 324: openshell.v1.GetSandboxProviderEnvironmentResponse.DynamicCredentialsEntry.value:type_name -> openshell.v1.ProviderProfileCredential
|
||||
156, // 325: openshell.v1.GetSandboxProviderEnvironmentResponse.StaticCredentialBindingsEntry.value:type_name -> openshell.v1.StaticCredentialBinding
|
||||
24, // 326: openshell.v1.OpenShell.Health:input_type -> openshell.v1.HealthRequest
|
||||
26, // 327: openshell.v1.OpenShell.GetCurrentUser:input_type -> openshell.v1.GetCurrentUserRequest
|
||||
28, // 328: openshell.v1.OpenShell.GetGatewayInfo:input_type -> openshell.v1.GetGatewayInfoRequest
|
||||
52, // 329: openshell.v1.OpenShell.CreateSandbox:input_type -> openshell.v1.CreateSandboxRequest
|
||||
60, // 330: openshell.v1.OpenShell.BeginRootfsTarStaging:input_type -> openshell.v1.BeginRootfsTarStagingRequest
|
||||
62, // 331: openshell.v1.OpenShell.GetSandbox:input_type -> openshell.v1.GetSandboxRequest
|
||||
63, // 332: openshell.v1.OpenShell.ListSandboxes:input_type -> openshell.v1.ListSandboxesRequest
|
||||
53, // 333: openshell.v1.OpenShell.CreateSandboxTemplate:input_type -> openshell.v1.CreateSandboxTemplateRequest
|
||||
54, // 334: openshell.v1.OpenShell.GetSandboxTemplate:input_type -> openshell.v1.GetSandboxTemplateRequest
|
||||
55, // 335: openshell.v1.OpenShell.ListSandboxTemplates:input_type -> openshell.v1.ListSandboxTemplatesRequest
|
||||
56, // 336: openshell.v1.OpenShell.DeleteSandboxTemplate:input_type -> openshell.v1.DeleteSandboxTemplateRequest
|
||||
64, // 337: openshell.v1.OpenShell.ListSandboxProviders:input_type -> openshell.v1.ListSandboxProvidersRequest
|
||||
65, // 338: openshell.v1.OpenShell.AttachSandboxProvider:input_type -> openshell.v1.AttachSandboxProviderRequest
|
||||
66, // 339: openshell.v1.OpenShell.DetachSandboxProvider:input_type -> openshell.v1.DetachSandboxProviderRequest
|
||||
82, // 340: openshell.v1.OpenShell.GetSandboxProviderStatus:input_type -> openshell.v1.GetSandboxProviderStatusRequest
|
||||
67, // 341: openshell.v1.OpenShell.DeleteSandbox:input_type -> openshell.v1.DeleteSandboxRequest
|
||||
68, // 342: openshell.v1.OpenShell.StopSandbox:input_type -> openshell.v1.StopSandboxRequest
|
||||
69, // 343: openshell.v1.OpenShell.StartSandbox:input_type -> openshell.v1.StartSandboxRequest
|
||||
87, // 344: openshell.v1.OpenShell.CreateSshSession:input_type -> openshell.v1.CreateSshSessionRequest
|
||||
89, // 345: openshell.v1.OpenShell.ExposeService:input_type -> openshell.v1.ExposeServiceRequest
|
||||
90, // 346: openshell.v1.OpenShell.GetService:input_type -> openshell.v1.GetServiceRequest
|
||||
91, // 347: openshell.v1.OpenShell.ListServices:input_type -> openshell.v1.ListServicesRequest
|
||||
93, // 348: openshell.v1.OpenShell.DeleteService:input_type -> openshell.v1.DeleteServiceRequest
|
||||
97, // 349: openshell.v1.OpenShell.RevokeSshSession:input_type -> openshell.v1.RevokeSshSessionRequest
|
||||
99, // 350: openshell.v1.OpenShell.ExecSandbox:input_type -> openshell.v1.ExecSandboxRequest
|
||||
105, // 351: openshell.v1.OpenShell.ForwardTcp:input_type -> openshell.v1.TcpForwardFrame
|
||||
106, // 352: openshell.v1.OpenShell.ExecSandboxInteractive:input_type -> openshell.v1.ExecSandboxInput
|
||||
113, // 353: openshell.v1.OpenShell.CreateProvider:input_type -> openshell.v1.CreateProviderRequest
|
||||
114, // 354: openshell.v1.OpenShell.GetProvider:input_type -> openshell.v1.GetProviderRequest
|
||||
115, // 355: openshell.v1.OpenShell.ListProviders:input_type -> openshell.v1.ListProvidersRequest
|
||||
120, // 356: openshell.v1.OpenShell.ListProviderProfiles:input_type -> openshell.v1.ListProviderProfilesRequest
|
||||
121, // 357: openshell.v1.OpenShell.GetProviderProfile:input_type -> openshell.v1.GetProviderProfileRequest
|
||||
145, // 358: openshell.v1.OpenShell.ImportProviderProfiles:input_type -> openshell.v1.ImportProviderProfilesRequest
|
||||
147, // 359: openshell.v1.OpenShell.UpdateProviderProfiles:input_type -> openshell.v1.UpdateProviderProfilesRequest
|
||||
149, // 360: openshell.v1.OpenShell.LintProviderProfiles:input_type -> openshell.v1.LintProviderProfilesRequest
|
||||
116, // 361: openshell.v1.OpenShell.UpdateProvider:input_type -> openshell.v1.UpdateProviderRequest
|
||||
133, // 362: openshell.v1.OpenShell.GetProviderRefreshStatus:input_type -> openshell.v1.GetProviderRefreshStatusRequest
|
||||
135, // 363: openshell.v1.OpenShell.ConfigureProviderRefresh:input_type -> openshell.v1.ConfigureProviderRefreshRequest
|
||||
137, // 364: openshell.v1.OpenShell.RotateProviderCredential:input_type -> openshell.v1.RotateProviderCredentialRequest
|
||||
139, // 365: openshell.v1.OpenShell.DeleteProviderRefresh:input_type -> openshell.v1.DeleteProviderRefreshRequest
|
||||
117, // 366: openshell.v1.OpenShell.DeleteProvider:input_type -> openshell.v1.DeleteProviderRequest
|
||||
152, // 367: openshell.v1.OpenShell.DeleteProviderProfile:input_type -> openshell.v1.DeleteProviderProfileRequest
|
||||
290, // 368: openshell.v1.OpenShell.GetSandboxConfig:input_type -> openshell.sandbox.v1.GetSandboxConfigRequest
|
||||
291, // 369: openshell.v1.OpenShell.GetGatewayConfig:input_type -> openshell.sandbox.v1.GetGatewayConfigRequest
|
||||
160, // 370: openshell.v1.OpenShell.UpdateConfig:input_type -> openshell.v1.UpdateConfigRequest
|
||||
170, // 371: openshell.v1.OpenShell.GetSandboxPolicyStatus:input_type -> openshell.v1.GetSandboxPolicyStatusRequest
|
||||
172, // 372: openshell.v1.OpenShell.ListSandboxPolicies:input_type -> openshell.v1.ListSandboxPoliciesRequest
|
||||
174, // 373: openshell.v1.OpenShell.ReportPolicyStatus:input_type -> openshell.v1.ReportPolicyStatusRequest
|
||||
247, // 374: openshell.v1.OpenShell.ReportEndpointStatus:input_type -> openshell.v1.ReportEndpointStatusRequest
|
||||
84, // 375: openshell.v1.OpenShell.ReportProviderReadiness:input_type -> openshell.v1.ReportProviderReadinessRequest
|
||||
177, // 376: openshell.v1.OpenShell.ReportSandboxConfiguration:input_type -> openshell.v1.ReportSandboxConfigurationRequest
|
||||
154, // 377: openshell.v1.OpenShell.GetSandboxProviderEnvironment:input_type -> openshell.v1.GetSandboxProviderEnvironmentRequest
|
||||
158, // 378: openshell.v1.OpenShell.ExchangeProviderSubjectToken:input_type -> openshell.v1.ExchangeProviderSubjectTokenRequest
|
||||
180, // 379: openshell.v1.OpenShell.GetSandboxLogs:input_type -> openshell.v1.GetSandboxLogsRequest
|
||||
181, // 380: openshell.v1.OpenShell.PushSandboxLogs:input_type -> openshell.v1.PushSandboxLogsRequest
|
||||
184, // 381: openshell.v1.OpenShell.ConnectSupervisor:input_type -> openshell.v1.SupervisorMessage
|
||||
191, // 382: openshell.v1.OpenShell.ReportMainProcessExit:input_type -> openshell.v1.ReportMainProcessExitRequest
|
||||
193, // 383: openshell.v1.OpenShell.FinalizeMainProcessExit:input_type -> openshell.v1.FinalizeMainProcessExitRequest
|
||||
199, // 384: openshell.v1.OpenShell.RelayStream:input_type -> openshell.v1.RelayFrame
|
||||
201, // 385: openshell.v1.OpenShell.PeerRelay:input_type -> openshell.v1.PeerRelayFrame
|
||||
84, // 386: openshell.v1.OpenShell.PeerReportProviderReadiness:input_type -> openshell.v1.ReportProviderReadinessRequest
|
||||
247, // 387: openshell.v1.OpenShell.PeerReportEndpointStatus:input_type -> openshell.v1.ReportEndpointStatusRequest
|
||||
82, // 388: openshell.v1.OpenShell.PeerGetSandboxProviderStatus:input_type -> openshell.v1.GetSandboxProviderStatusRequest
|
||||
109, // 389: openshell.v1.OpenShell.WatchSandbox:input_type -> openshell.v1.WatchSandboxRequest
|
||||
210, // 390: openshell.v1.OpenShell.SubmitPolicyAnalysis:input_type -> openshell.v1.SubmitPolicyAnalysisRequest
|
||||
212, // 391: openshell.v1.OpenShell.GetDraftPolicy:input_type -> openshell.v1.GetDraftPolicyRequest
|
||||
214, // 392: openshell.v1.OpenShell.ApproveDraftChunk:input_type -> openshell.v1.ApproveDraftChunkRequest
|
||||
216, // 393: openshell.v1.OpenShell.RejectDraftChunk:input_type -> openshell.v1.RejectDraftChunkRequest
|
||||
219, // 394: openshell.v1.OpenShell.ApproveAllDraftChunks:input_type -> openshell.v1.ApproveAllDraftChunksRequest
|
||||
221, // 395: openshell.v1.OpenShell.EditDraftChunk:input_type -> openshell.v1.EditDraftChunkRequest
|
||||
223, // 396: openshell.v1.OpenShell.UndoDraftChunk:input_type -> openshell.v1.UndoDraftChunkRequest
|
||||
225, // 397: openshell.v1.OpenShell.ClearDraftChunks:input_type -> openshell.v1.ClearDraftChunksRequest
|
||||
227, // 398: openshell.v1.OpenShell.GetDraftHistory:input_type -> openshell.v1.GetDraftHistoryRequest
|
||||
20, // 399: openshell.v1.OpenShell.IssueSandboxToken:input_type -> openshell.v1.IssueSandboxTokenRequest
|
||||
22, // 400: openshell.v1.OpenShell.RefreshSandboxToken:input_type -> openshell.v1.RefreshSandboxTokenRequest
|
||||
230, // 401: openshell.v1.OpenShell.CreateWorkspace:input_type -> openshell.v1.CreateWorkspaceRequest
|
||||
232, // 402: openshell.v1.OpenShell.GetWorkspace:input_type -> openshell.v1.GetWorkspaceRequest
|
||||
234, // 403: openshell.v1.OpenShell.ListWorkspaces:input_type -> openshell.v1.ListWorkspacesRequest
|
||||
236, // 404: openshell.v1.OpenShell.DeleteWorkspace:input_type -> openshell.v1.DeleteWorkspaceRequest
|
||||
239, // 405: openshell.v1.OpenShell.AddWorkspaceMember:input_type -> openshell.v1.AddWorkspaceMemberRequest
|
||||
241, // 406: openshell.v1.OpenShell.RemoveWorkspaceMember:input_type -> openshell.v1.RemoveWorkspaceMemberRequest
|
||||
243, // 407: openshell.v1.OpenShell.ListWorkspaceMembers:input_type -> openshell.v1.ListWorkspaceMembersRequest
|
||||
25, // 408: openshell.v1.OpenShell.Health:output_type -> openshell.v1.HealthResponse
|
||||
27, // 409: openshell.v1.OpenShell.GetCurrentUser:output_type -> openshell.v1.GetCurrentUserResponse
|
||||
29, // 410: openshell.v1.OpenShell.GetGatewayInfo:output_type -> openshell.v1.GetGatewayInfoResponse
|
||||
70, // 411: openshell.v1.OpenShell.CreateSandbox:output_type -> openshell.v1.SandboxResponse
|
||||
61, // 412: openshell.v1.OpenShell.BeginRootfsTarStaging:output_type -> openshell.v1.BeginRootfsTarStagingResponse
|
||||
70, // 413: openshell.v1.OpenShell.GetSandbox:output_type -> openshell.v1.SandboxResponse
|
||||
71, // 414: openshell.v1.OpenShell.ListSandboxes:output_type -> openshell.v1.ListSandboxesResponse
|
||||
57, // 415: openshell.v1.OpenShell.CreateSandboxTemplate:output_type -> openshell.v1.SandboxTemplateResponse
|
||||
57, // 416: openshell.v1.OpenShell.GetSandboxTemplate:output_type -> openshell.v1.SandboxTemplateResponse
|
||||
58, // 417: openshell.v1.OpenShell.ListSandboxTemplates:output_type -> openshell.v1.ListSandboxTemplatesResponse
|
||||
59, // 418: openshell.v1.OpenShell.DeleteSandboxTemplate:output_type -> openshell.v1.DeleteSandboxTemplateResponse
|
||||
72, // 419: openshell.v1.OpenShell.ListSandboxProviders:output_type -> openshell.v1.ListSandboxProvidersResponse
|
||||
73, // 420: openshell.v1.OpenShell.AttachSandboxProvider:output_type -> openshell.v1.AttachSandboxProviderResponse
|
||||
74, // 421: openshell.v1.OpenShell.DetachSandboxProvider:output_type -> openshell.v1.DetachSandboxProviderResponse
|
||||
83, // 422: openshell.v1.OpenShell.GetSandboxProviderStatus:output_type -> openshell.v1.GetSandboxProviderStatusResponse
|
||||
86, // 423: openshell.v1.OpenShell.DeleteSandbox:output_type -> openshell.v1.DeleteSandboxResponse
|
||||
70, // 424: openshell.v1.OpenShell.StopSandbox:output_type -> openshell.v1.SandboxResponse
|
||||
70, // 425: openshell.v1.OpenShell.StartSandbox:output_type -> openshell.v1.SandboxResponse
|
||||
88, // 426: openshell.v1.OpenShell.CreateSshSession:output_type -> openshell.v1.CreateSshSessionResponse
|
||||
96, // 427: openshell.v1.OpenShell.ExposeService:output_type -> openshell.v1.ServiceEndpointResponse
|
||||
96, // 428: openshell.v1.OpenShell.GetService:output_type -> openshell.v1.ServiceEndpointResponse
|
||||
92, // 429: openshell.v1.OpenShell.ListServices:output_type -> openshell.v1.ListServicesResponse
|
||||
94, // 430: openshell.v1.OpenShell.DeleteService:output_type -> openshell.v1.DeleteServiceResponse
|
||||
98, // 431: openshell.v1.OpenShell.RevokeSshSession:output_type -> openshell.v1.RevokeSshSessionResponse
|
||||
103, // 432: openshell.v1.OpenShell.ExecSandbox:output_type -> openshell.v1.ExecSandboxEvent
|
||||
105, // 433: openshell.v1.OpenShell.ForwardTcp:output_type -> openshell.v1.TcpForwardFrame
|
||||
103, // 434: openshell.v1.OpenShell.ExecSandboxInteractive:output_type -> openshell.v1.ExecSandboxEvent
|
||||
118, // 435: openshell.v1.OpenShell.CreateProvider:output_type -> openshell.v1.ProviderResponse
|
||||
118, // 436: openshell.v1.OpenShell.GetProvider:output_type -> openshell.v1.ProviderResponse
|
||||
119, // 437: openshell.v1.OpenShell.ListProviders:output_type -> openshell.v1.ListProvidersResponse
|
||||
144, // 438: openshell.v1.OpenShell.ListProviderProfiles:output_type -> openshell.v1.ListProviderProfilesResponse
|
||||
143, // 439: openshell.v1.OpenShell.GetProviderProfile:output_type -> openshell.v1.ProviderProfileResponse
|
||||
146, // 440: openshell.v1.OpenShell.ImportProviderProfiles:output_type -> openshell.v1.ImportProviderProfilesResponse
|
||||
148, // 441: openshell.v1.OpenShell.UpdateProviderProfiles:output_type -> openshell.v1.UpdateProviderProfilesResponse
|
||||
150, // 442: openshell.v1.OpenShell.LintProviderProfiles:output_type -> openshell.v1.LintProviderProfilesResponse
|
||||
118, // 443: openshell.v1.OpenShell.UpdateProvider:output_type -> openshell.v1.ProviderResponse
|
||||
134, // 444: openshell.v1.OpenShell.GetProviderRefreshStatus:output_type -> openshell.v1.GetProviderRefreshStatusResponse
|
||||
136, // 445: openshell.v1.OpenShell.ConfigureProviderRefresh:output_type -> openshell.v1.ConfigureProviderRefreshResponse
|
||||
138, // 446: openshell.v1.OpenShell.RotateProviderCredential:output_type -> openshell.v1.RotateProviderCredentialResponse
|
||||
140, // 447: openshell.v1.OpenShell.DeleteProviderRefresh:output_type -> openshell.v1.DeleteProviderRefreshResponse
|
||||
151, // 448: openshell.v1.OpenShell.DeleteProvider:output_type -> openshell.v1.DeleteProviderResponse
|
||||
153, // 449: openshell.v1.OpenShell.DeleteProviderProfile:output_type -> openshell.v1.DeleteProviderProfileResponse
|
||||
292, // 450: openshell.v1.OpenShell.GetSandboxConfig:output_type -> openshell.sandbox.v1.GetSandboxConfigResponse
|
||||
293, // 451: openshell.v1.OpenShell.GetGatewayConfig:output_type -> openshell.sandbox.v1.GetGatewayConfigResponse
|
||||
169, // 452: openshell.v1.OpenShell.UpdateConfig:output_type -> openshell.v1.UpdateConfigResponse
|
||||
171, // 453: openshell.v1.OpenShell.GetSandboxPolicyStatus:output_type -> openshell.v1.GetSandboxPolicyStatusResponse
|
||||
173, // 454: openshell.v1.OpenShell.ListSandboxPolicies:output_type -> openshell.v1.ListSandboxPoliciesResponse
|
||||
175, // 455: openshell.v1.OpenShell.ReportPolicyStatus:output_type -> openshell.v1.ReportPolicyStatusResponse
|
||||
248, // 456: openshell.v1.OpenShell.ReportEndpointStatus:output_type -> openshell.v1.ReportEndpointStatusResponse
|
||||
85, // 457: openshell.v1.OpenShell.ReportProviderReadiness:output_type -> openshell.v1.ReportProviderReadinessResponse
|
||||
178, // 458: openshell.v1.OpenShell.ReportSandboxConfiguration:output_type -> openshell.v1.ReportSandboxConfigurationResponse
|
||||
157, // 459: openshell.v1.OpenShell.GetSandboxProviderEnvironment:output_type -> openshell.v1.GetSandboxProviderEnvironmentResponse
|
||||
159, // 460: openshell.v1.OpenShell.ExchangeProviderSubjectToken:output_type -> openshell.v1.ExchangeProviderSubjectTokenResponse
|
||||
183, // 461: openshell.v1.OpenShell.GetSandboxLogs:output_type -> openshell.v1.GetSandboxLogsResponse
|
||||
182, // 462: openshell.v1.OpenShell.PushSandboxLogs:output_type -> openshell.v1.PushSandboxLogsResponse
|
||||
185, // 463: openshell.v1.OpenShell.ConnectSupervisor:output_type -> openshell.v1.GatewayMessage
|
||||
192, // 464: openshell.v1.OpenShell.ReportMainProcessExit:output_type -> openshell.v1.ReportMainProcessExitResponse
|
||||
194, // 465: openshell.v1.OpenShell.FinalizeMainProcessExit:output_type -> openshell.v1.FinalizeMainProcessExitResponse
|
||||
199, // 466: openshell.v1.OpenShell.RelayStream:output_type -> openshell.v1.RelayFrame
|
||||
201, // 467: openshell.v1.OpenShell.PeerRelay:output_type -> openshell.v1.PeerRelayFrame
|
||||
85, // 468: openshell.v1.OpenShell.PeerReportProviderReadiness:output_type -> openshell.v1.ReportProviderReadinessResponse
|
||||
248, // 469: openshell.v1.OpenShell.PeerReportEndpointStatus:output_type -> openshell.v1.ReportEndpointStatusResponse
|
||||
83, // 470: openshell.v1.OpenShell.PeerGetSandboxProviderStatus:output_type -> openshell.v1.GetSandboxProviderStatusResponse
|
||||
110, // 471: openshell.v1.OpenShell.WatchSandbox:output_type -> openshell.v1.SandboxStreamEvent
|
||||
211, // 472: openshell.v1.OpenShell.SubmitPolicyAnalysis:output_type -> openshell.v1.SubmitPolicyAnalysisResponse
|
||||
213, // 473: openshell.v1.OpenShell.GetDraftPolicy:output_type -> openshell.v1.GetDraftPolicyResponse
|
||||
215, // 474: openshell.v1.OpenShell.ApproveDraftChunk:output_type -> openshell.v1.ApproveDraftChunkResponse
|
||||
217, // 475: openshell.v1.OpenShell.RejectDraftChunk:output_type -> openshell.v1.RejectDraftChunkResponse
|
||||
220, // 476: openshell.v1.OpenShell.ApproveAllDraftChunks:output_type -> openshell.v1.ApproveAllDraftChunksResponse
|
||||
222, // 477: openshell.v1.OpenShell.EditDraftChunk:output_type -> openshell.v1.EditDraftChunkResponse
|
||||
224, // 478: openshell.v1.OpenShell.UndoDraftChunk:output_type -> openshell.v1.UndoDraftChunkResponse
|
||||
226, // 479: openshell.v1.OpenShell.ClearDraftChunks:output_type -> openshell.v1.ClearDraftChunksResponse
|
||||
229, // 480: openshell.v1.OpenShell.GetDraftHistory:output_type -> openshell.v1.GetDraftHistoryResponse
|
||||
21, // 481: openshell.v1.OpenShell.IssueSandboxToken:output_type -> openshell.v1.IssueSandboxTokenResponse
|
||||
23, // 482: openshell.v1.OpenShell.RefreshSandboxToken:output_type -> openshell.v1.RefreshSandboxTokenResponse
|
||||
231, // 483: openshell.v1.OpenShell.CreateWorkspace:output_type -> openshell.v1.CreateWorkspaceResponse
|
||||
233, // 484: openshell.v1.OpenShell.GetWorkspace:output_type -> openshell.v1.GetWorkspaceResponse
|
||||
235, // 485: openshell.v1.OpenShell.ListWorkspaces:output_type -> openshell.v1.ListWorkspacesResponse
|
||||
237, // 486: openshell.v1.OpenShell.DeleteWorkspace:output_type -> openshell.v1.DeleteWorkspaceResponse
|
||||
240, // 487: openshell.v1.OpenShell.AddWorkspaceMember:output_type -> openshell.v1.AddWorkspaceMemberResponse
|
||||
242, // 488: openshell.v1.OpenShell.RemoveWorkspaceMember:output_type -> openshell.v1.RemoveWorkspaceMemberResponse
|
||||
244, // 489: openshell.v1.OpenShell.ListWorkspaceMembers:output_type -> openshell.v1.ListWorkspaceMembersResponse
|
||||
408, // [408:490] is the sub-list for method output_type
|
||||
326, // [326:408] is the sub-list for method input_type
|
||||
326, // [326:326] is the sub-list for extension type_name
|
||||
326, // [326:326] is the sub-list for extension extendee
|
||||
0, // [0:326] is the sub-list for field type_name
|
||||
}
|
||||
|
||||
func init() { file_openshell_proto_init() }
|
||||
|
||||
@@ -896,8 +896,7 @@ Use the VM driver logs and host diagnostics available in the user's environment.
|
||||
|
||||
- The VM driver process is running and reachable by the gateway.
|
||||
- The runtime rootfs exists and matches the expected architecture.
|
||||
- `mke2fs` or `mkfs.ext4` and `debugfs` from e2fsprogs are installed; explicit
|
||||
`sandbox_uid`/`sandbox_gid` does not remove this prerequisite.
|
||||
- `mke2fs` or `mkfs.ext4`, `debugfs`, and `e2fsck` from e2fsprogs are installed. Run `openshell-gateway config preflight` with the intended local VM configuration, service account, and environment to check the selected paths and versions before startup. A remote endpoint reports that host checks were not performed. Explicit `sandbox_uid`/`sandbox_gid` does not remove this prerequisite.
|
||||
- A persisted overlay identity error is resolved from its owner marker, overlay
|
||||
upper layer, prepared rootfs, explicit config, or current image. Do not assign
|
||||
`10001:10001` unless the persisted state reports that legacy identity.
|
||||
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env bash
|
||||
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
VERIFY="${ROOT}/tasks/scripts/verify-macos-vm-driver.sh"
|
||||
|
||||
if [[ $(uname -s) != Darwin ]]; then
|
||||
echo "error: the VM-driver signature regression requires macOS" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
work=$(mktemp -d "${TMPDIR:-/tmp}/openshell-vm-signature-test.XXXXXXXX")
|
||||
trap 'rm -rf "$work"' EXIT
|
||||
|
||||
# Use a real Mach-O and Apple's signing tools. Mock codesign output would not
|
||||
# detect a signature invalidated after signing or lost during archive assembly.
|
||||
printf 'int main(void) { return 0; }\n' | /usr/bin/cc -x c - -o "$work/unsigned"
|
||||
/usr/bin/codesign --remove-signature "$work/unsigned"
|
||||
|
||||
check_archive() {
|
||||
local label=$1
|
||||
local binary=$2
|
||||
local expected=$3
|
||||
local diagnostic=${4:-}
|
||||
local case_dir="$work/cases/$label"
|
||||
mkdir -p "$case_dir/raw" "$case_dir/extracted"
|
||||
cp "$binary" "$case_dir/raw/openshell-driver-vm"
|
||||
chmod +x "$case_dir/raw/openshell-driver-vm"
|
||||
tar -czf "$case_dir/driver.tar.gz" -C "$case_dir/raw" openshell-driver-vm
|
||||
tar -xzf "$case_dir/driver.tar.gz" -C "$case_dir/extracted"
|
||||
cmp "$binary" "$case_dir/extracted/openshell-driver-vm"
|
||||
|
||||
if bash "$VERIFY" "$case_dir/extracted/openshell-driver-vm" >"$case_dir/result" 2>&1; then
|
||||
if [[ $expected != pass ]]; then
|
||||
echo "FAIL: $label unexpectedly passed verification" >&2
|
||||
exit 1
|
||||
fi
|
||||
elif [[ $expected == pass ]]; then
|
||||
cat "$case_dir/result" >&2
|
||||
echo "FAIL: $label was rejected" >&2
|
||||
exit 1
|
||||
elif ! grep -Fq "$diagnostic" "$case_dir/result"; then
|
||||
cat "$case_dir/result" >&2
|
||||
echo "FAIL: $label failed without the expected diagnostic" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "PASS: $label"
|
||||
}
|
||||
|
||||
cp "$work/unsigned" "$work/signed"
|
||||
/usr/bin/codesign --force --sign - --entitlements "$ROOT/crates/openshell-driver-vm/entitlements.plist" "$work/signed"
|
||||
check_archive valid "$work/signed" pass
|
||||
check_archive unsigned "$work/unsigned" fail 'code object is not signed'
|
||||
|
||||
cp "$work/unsigned" "$work/no-entitlement"
|
||||
/usr/bin/codesign --force --sign - "$work/no-entitlement"
|
||||
check_archive missing-entitlement "$work/no-entitlement" fail 'com.apple.security.hypervisor'
|
||||
|
||||
for kind in false string; do
|
||||
cp "$ROOT/crates/openshell-driver-vm/entitlements.plist" "$work/$kind.plist"
|
||||
if [[ $kind == false ]]; then
|
||||
/usr/bin/plutil -replace 'com\.apple\.security\.hypervisor' -bool NO "$work/$kind.plist"
|
||||
else
|
||||
/usr/bin/plutil -replace 'com\.apple\.security\.hypervisor' -string true "$work/$kind.plist"
|
||||
fi
|
||||
cp "$work/unsigned" "$work/$kind"
|
||||
/usr/bin/codesign --force --sign - --entitlements "$work/$kind.plist" "$work/$kind"
|
||||
check_archive "$kind-entitlement" "$work/$kind" fail 'com.apple.security.hypervisor'
|
||||
done
|
||||
|
||||
# Changing a signed byte simulates a packaging tool rewriting the executable
|
||||
# after signing. Inspecting only its entitlement plist would miss this defect.
|
||||
cp "$work/signed" "$work/tampered"
|
||||
printf '\001' | dd of="$work/tampered" bs=1 seek=4096 count=1 conv=notrunc 2>/dev/null
|
||||
check_archive tampered "$work/tampered" fail 'invalid signature'
|
||||
|
||||
echo "macOS VM-driver signature regressions passed"
|
||||
Executable
+41
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env bash
|
||||
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [[ $# -ne 1 ]]; then
|
||||
echo "Usage: verify-macos-vm-driver.sh <openshell-driver-vm>" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [[ $(uname -s) != Darwin ]]; then
|
||||
echo "error: macOS is required to verify the VM driver's code signature" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
binary=$1
|
||||
if [[ $binary != /* ]]; then
|
||||
binary="$PWD/$binary"
|
||||
fi
|
||||
if [[ ! -f $binary || ! -x $binary ]]; then
|
||||
echo "error: VM driver is not an executable file: $binary" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Verify the installed or extracted bytes, without signing or otherwise repairing
|
||||
# them. A successful codesign display alone does not validate the signature.
|
||||
/usr/bin/codesign --verify --strict --all-architectures "$binary"
|
||||
|
||||
entitlements=$(mktemp "${TMPDIR:-/tmp}/openshell-vm-entitlements.XXXXXXXX")
|
||||
trap 'rm -f "$entitlements"' EXIT
|
||||
/usr/bin/codesign --display --entitlements - --xml "$binary" >"$entitlements"
|
||||
|
||||
# Dots belong to the entitlement name, not nested dictionary keys. Require a
|
||||
# boolean true: a string that happens to print "true" is not an entitlement grant.
|
||||
if ! granted=$(/usr/bin/plutil -extract 'com\.apple\.security\.hypervisor' raw -expect bool "$entitlements") || [[ $granted != true ]]; then
|
||||
echo "error: VM driver must have the boolean com.apple.security.hypervisor entitlement set to true" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Verified VM driver signature and Hypervisor entitlement: $binary"
|
||||
Reference in New Issue
Block a user