Files
krishicks 704880e5c4 test(podman): gate gateway discovery test on Linux (#2580)
Run the constructor gateway-discovery error test only on Linux, matching the
production branch that inspects the Podman bridge gateway. macOS uses its
Podman machine callback path and correctly skips that inspection.

Signed-off-by: Kris Hicks <khicks@nvidia.com>
2026-08-03 15:04:56 +00:00

2249 lines
84 KiB
Rust

// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
//! Podman compute driver.
use crate::client::{PodmanApiError, PodmanClient, VolumeInspect};
use crate::config::PodmanComputeConfig;
use crate::container::{self, LABEL_MANAGED_FILTER, LABEL_SANDBOX_ID, PodmanSandboxDriverConfig};
use crate::watcher::{
self, WatchStream, driver_sandbox_from_inspect, driver_sandbox_from_list_entry,
};
use openshell_core::ComputeDriverError;
use openshell_core::config::CDI_GPU_DEVICE_ALL;
use openshell_core::driver_utils::supervisor_image_should_refresh;
use openshell_core::gpu::{
CdiGpuDefaultSelector, CdiGpuInventory, CdiGpuSelectionError, driver_gpu_requirements,
effective_driver_gpu_count, validate_specific_gpu_device_request,
};
#[cfg(target_os = "linux")]
use openshell_core::proto::compute::v1::GatewayDefaultRouteInterfaceRequirement;
#[cfg(target_os = "macos")]
use openshell_core::proto::compute::v1::GatewayLoopbackInterfaceRequirement;
use openshell_core::proto::compute::v1::{
DriverSandbox, GatewayListenerRequirement, GetCapabilitiesResponse, GpuResourceRequirements,
gateway_listener_requirement::Selector,
};
#[cfg(target_os = "linux")]
use std::net::{IpAddr, SocketAddr};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use tracing::{debug, info, warn};
use url::Url;
impl From<PodmanApiError> for ComputeDriverError {
fn from(value: PodmanApiError) -> Self {
match value {
PodmanApiError::Conflict(_) => Self::AlreadyExists,
PodmanApiError::NotFound(msg) => Self::Message(format!("not found: {msg}")),
other => Self::Message(other.to_string()),
}
}
}
/// Podman compute driver managing sandbox containers via the Podman REST API.
#[derive(Clone)]
pub struct PodmanComputeDriver {
client: PodmanClient,
config: PodmanComputeConfig,
/// The host's IP on the bridge network, when that bridge exists in the
/// gateway's network namespace (notably rootful Podman).
network_gateway_ip: Option<String>,
/// Whether Podman's service is running without root privileges.
rootless: bool,
/// Rootless network helper reported by Podman, such as `pasta`.
rootless_network_cmd: String,
gpu_selector: Arc<CdiGpuDefaultSelector>,
gpu_inventory_refresh: Arc<dyn Fn() -> (CdiGpuInventory, bool) + Send + Sync>,
}
impl std::fmt::Debug for PodmanComputeDriver {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PodmanComputeDriver")
.field("socket_path", &self.config.socket_path)
.field("default_image", &self.config.default_image)
.field("network_name", &self.config.network_name)
.field("rootless", &self.rootless)
.field("rootless_network_cmd", &self.rootless_network_cmd)
.field("gpu_inventory", &self.gpu_selector.device_ids())
.finish()
}
}
struct ValidatedPodmanSandbox<'a> {
driver_config: PodmanSandboxDriverConfig,
gpu_requirements: Option<&'a GpuResourceRequirements>,
}
/// Construct and validate a container name from a sandbox.
///
/// Combines the prefix with workspace, name, and ID, then validates the
/// result against Podman's naming rules before any resources are created.
fn validated_container_name(sandbox: &DriverSandbox) -> Result<String, ComputeDriverError> {
let name = container::container_name(&sandbox.workspace, &sandbox.name, &sandbox.id);
crate::client::validate_name(&name)
.map_err(|e| ComputeDriverError::Precondition(e.to_string()))?;
Ok(name)
}
fn podman_volume_is_bind_backed(volume: &VolumeInspect) -> bool {
(volume.driver.is_empty() || volume.driver == "local")
&& volume.options.get("o").is_some_and(|options| {
options.split(',').any(|option| {
let option = option.trim();
option.eq_ignore_ascii_case("bind") || option.eq_ignore_ascii_case("rbind")
})
})
}
async fn create_sandbox_token_secret(
client: &PodmanClient,
sandbox: &DriverSandbox,
) -> Result<Option<String>, ComputeDriverError> {
let Some(token) = sandbox
.spec
.as_ref()
.map(|spec| spec.sandbox_token.trim())
.filter(|token| !token.is_empty())
else {
return Ok(None);
};
let secret_name = container::token_secret_name(&sandbox.id);
client
.create_secret(&secret_name, format!("{token}\n").as_bytes())
.await
.map_err(ComputeDriverError::from)?;
Ok(Some(secret_name))
}
async fn cleanup_sandbox_token_secret(client: &PodmanClient, secret_name: &str) {
if let Err(err) = client.remove_secret(secret_name).await {
warn!(
secret = %secret_name,
error = %err,
"Failed to remove Podman sandbox token secret"
);
}
}
/// Read the operator's proxy credentials file and stage it as a per-sandbox
/// Podman secret, so the credentials reach the supervisor through a root-only
/// mount rather than the container environment.
///
/// Fails closed: when `proxy_auth_file` is configured but cannot be read or
/// does not hold a valid `user:pass` credential, the sandbox is not created.
/// Credential validation is shared with the in-container supervisor through
/// [`openshell_core::driver_utils::parse_upstream_proxy_credential`], so a
/// credential staged here can never be rejected at supervisor startup.
async fn create_sandbox_proxy_auth_secret(
client: &PodmanClient,
config: &PodmanComputeConfig,
sandbox: &DriverSandbox,
) -> Result<Option<String>, ComputeDriverError> {
let Some(path) = config.proxy_auth_file.as_deref() else {
return Ok(None);
};
// Bounded, blocking read shared with the supervisor: rejects non-regular
// files (e.g. /dev/zero) and caps the size so a hostile path cannot
// exhaust gateway memory.
let path_owned = path.to_string();
let raw = tokio::task::spawn_blocking(move || {
openshell_core::driver_utils::read_upstream_proxy_credential_file(&path_owned)
})
.await
.map_err(|e| ComputeDriverError::Message(format!("proxy_auth_file read task failed: {e}")))?
.map_err(ComputeDriverError::Message)?;
let credential =
openshell_core::driver_utils::parse_upstream_proxy_credential(&raw).map_err(|err| {
ComputeDriverError::InvalidArgument(format!("proxy_auth_file '{path}': {err}"))
})?;
let secret_name = container::proxy_auth_secret_name(&sandbox.id);
client
.create_secret(&secret_name, format!("{credential}\n").as_bytes())
.await
.map_err(ComputeDriverError::from)?;
Ok(Some(secret_name))
}
async fn cleanup_sandbox_proxy_auth_secret(client: &PodmanClient, secret_name: &str) {
if let Err(err) = client.remove_secret(secret_name).await {
warn!(
secret = %secret_name,
error = %err,
"Failed to remove Podman sandbox proxy-auth secret"
);
}
}
fn local_podman_cdi_gpu_inventory_from(dev_root: &Path) -> CdiGpuInventory {
let mut device_ids = std::fs::read_dir(dev_root)
.ok()
.into_iter()
.flat_map(|entries| entries.filter_map(Result::ok))
.filter_map(|entry| {
let name = entry.file_name();
let name = name.to_str()?;
let index = name.strip_prefix("nvidia")?;
(!index.is_empty() && index.chars().all(|ch| ch.is_ascii_digit()))
.then(|| format!("nvidia.com/gpu={index}"))
})
.collect::<Vec<_>>();
if local_podman_all_gpu_default_supported_from(dev_root) {
device_ids.push(CDI_GPU_DEVICE_ALL.to_string());
}
CdiGpuInventory::new(device_ids)
}
fn local_podman_cdi_gpu_inventory() -> CdiGpuInventory {
local_podman_cdi_gpu_inventory_from(Path::new("/dev"))
}
fn local_podman_all_gpu_default_supported_from(dev_root: &Path) -> bool {
dev_root.join("dxg").exists()
}
fn local_podman_all_gpu_default_supported() -> bool {
local_podman_all_gpu_default_supported_from(Path::new("/dev"))
}
fn local_podman_gpu_selector_state() -> (CdiGpuInventory, bool) {
(
local_podman_cdi_gpu_inventory(),
local_podman_all_gpu_default_supported(),
)
}
fn podman_gpu_selection_error(err: CdiGpuSelectionError) -> ComputeDriverError {
ComputeDriverError::Precondition(err.to_string())
}
/// Resolve the socket to connect to: explicit configuration wins, otherwise
/// fall back to `detect`. Returns an error if neither resolves.
///
/// Takes `detect` as a parameter (rather than calling
/// [`openshell_core::config::detect_podman_socket`] directly) so tests can
/// exercise the precedence deterministically, without touching real
/// environment variables or the filesystem.
fn resolve_socket_path(
configured: Option<PathBuf>,
detect: impl FnOnce() -> Option<PathBuf>,
) -> Result<PathBuf, PodmanApiError> {
configured.or_else(detect).ok_or_else(|| {
PodmanApiError::InvalidInput(
"no responsive Podman API socket found; set OPENSHELL_PODMAN_SOCKET \
or configure socket_path"
.to_string(),
)
})
}
impl PodmanComputeDriver {
/// Create a new driver, verifying the Podman socket is reachable.
pub async fn new(mut config: PodmanComputeConfig) -> Result<Self, PodmanApiError> {
const MAX_PING_RETRIES: u32 = 5;
const PING_RETRY_DELAY: Duration = Duration::from_secs(2);
let socket_path = resolve_socket_path(
config.socket_path.clone(),
openshell_core::config::detect_podman_socket,
)?;
config.socket_path = Some(socket_path.clone());
if !socket_path.exists() {
if cfg!(target_os = "macos") {
warn!(
path = %socket_path.display(),
"Podman socket not found; is podman machine running? \
Try `podman machine start` or set OPENSHELL_PODMAN_SOCKET to override."
);
} else {
warn!(
path = %socket_path.display(),
"Podman socket not found; is the Podman service running? \
Set OPENSHELL_PODMAN_SOCKET or XDG_RUNTIME_DIR to override."
);
}
}
// Validate TLS configuration before connecting. Partial configs
// (e.g. CA set but cert/key missing) are rejected early so operators
// get a clear error instead of a silent fallback to plaintext HTTP.
config.validate_tls_config()?;
config.validate_runtime_limits()?;
config.validate_host_gateway_ip()?;
config.validate_proxy_config()?;
let client = PodmanClient::new(socket_path);
// Verify connectivity, retrying briefly to tolerate transient socket
// unavailability (e.g. podman.socket restarting after a package
// upgrade). The systemd unit uses Wants=podman.socket (not Requires),
// so the gateway may start while the socket is briefly re-activating.
let mut attempts = 0;
loop {
match client.ping().await {
Ok(()) => break,
Err(e) if attempts < MAX_PING_RETRIES => {
attempts += 1;
warn!(
attempt = attempts,
max_retries = MAX_PING_RETRIES,
error = %e,
"Podman socket not ready, retrying"
);
tokio::time::sleep(PING_RETRY_DELAY).await;
}
Err(e) => return Err(e),
}
}
// Verify cgroups v2, detect rootless mode, and log system info.
let (rootless, rootless_network_cmd) = match client.system_info().await {
Ok(info) => {
if info.host.cgroup_version != "v2" {
return Err(PodmanApiError::Connection(format!(
"cgroups v2 is required; detected cgroups '{}'. \
Ensure your host uses a unified cgroup hierarchy \
(systemd.unified_cgroup_hierarchy=1).",
info.host.cgroup_version
)));
}
info!(
cgroup_version = %info.host.cgroup_version,
network_backend = %info.host.network_backend,
rootless = info.host.security.rootless,
rootless_network_cmd = %info.host.rootless_network_cmd,
"Connected to Podman"
);
(info.host.security.rootless, info.host.rootless_network_cmd)
}
Err(e) => {
return Err(PodmanApiError::Connection(format!(
"failed to query Podman system info: {e}"
)));
}
};
// Rootless pre-flight: warn if subuid/subgid ranges look missing.
// Not a hard error because some systems configure these via LDAP or
// other mechanisms that /etc/subuid does not reflect.
if !cfg!(target_os = "macos") && rustix::process::getuid().as_raw() != 0 {
check_subuid_range();
}
// Auto-detect the gRPC callback endpoint before deciding whether this
// topology needs the Podman bridge gateway address.
if config.grpc_endpoint.is_empty() {
let scheme = if config.tls_enabled() {
"https"
} else {
"http"
};
config.grpc_endpoint = format!(
"{scheme}://host.containers.internal:{}",
config.gateway_port
);
info!(
grpc_endpoint = %config.grpc_endpoint,
tls = config.tls_enabled(),
"Auto-detected gRPC endpoint"
);
}
// Ensure the bridge network exists. Inspect its gateway only when the
// selected Linux callback topology will bind that exact address.
client.ensure_network(&config.network_name).await?;
let uses_local_callback_alias = Url::parse(&config.grpc_endpoint)
.ok()
.as_ref()
.is_some_and(callback_endpoint_uses_local_alias);
let needs_network_gateway_ip = cfg!(target_os = "linux")
&& uses_local_callback_alias
&& !rootless
&& config.host_gateway_ip.trim().is_empty();
let network_gateway_ip = if needs_network_gateway_ip {
client.network_gateway_ip(&config.network_name).await?
} else {
None
};
info!(
network = %config.network_name,
gateway_ip = ?network_gateway_ip,
"Bridge network ready"
);
let (gpu_inventory, allow_all_default_gpu) = local_podman_gpu_selector_state();
if !gpu_inventory.is_empty() {
info!(
device_count = gpu_inventory.as_slice().len(),
"Discovered local Podman NVIDIA CDI GPU devices"
);
}
Ok(Self {
client,
config,
network_gateway_ip,
rootless,
rootless_network_cmd,
gpu_selector: Arc::new(CdiGpuDefaultSelector::new(
gpu_inventory,
allow_all_default_gpu,
)),
gpu_inventory_refresh: Arc::new(local_podman_gpu_selector_state),
})
}
/// The host's IP on the bridge network, if available.
///
/// Used to request the exact rootful gateway callback listener when no
/// explicit host-gateway override is configured.
#[must_use]
pub fn network_gateway_ip(&self) -> Option<&str> {
self.network_gateway_ip.as_deref()
}
/// Report driver capabilities.
pub fn capabilities(&self) -> Result<GetCapabilitiesResponse, ComputeDriverError> {
Ok(openshell_core::driver_utils::build_capabilities_response(
"podman",
openshell_core::VERSION,
&self.config.default_image,
))
}
/// Report the gateway exposure needed by Podman's standard local callback aliases.
///
/// Rootful Podman binds the exact bridge address behind the sandbox alias.
/// Rootless pasta follows the host's default-route interface, while Podman
/// Machine forwards the alias to gateway loopback. Other rootless helpers
/// cannot use a direct host listener.
pub fn gateway_listener_requirements(
&self,
) -> Result<Vec<GatewayListenerRequirement>, ComputeDriverError> {
let endpoint = Url::parse(&self.config.grpc_endpoint).map_err(|err| {
ComputeDriverError::Precondition(format!(
"invalid Podman gateway callback endpoint '{}': {err}",
self.config.grpc_endpoint
))
})?;
let uses_local_callback_alias = callback_endpoint_uses_local_alias(&endpoint);
if !uses_local_callback_alias {
return Ok(Vec::new());
}
let callback_port = endpoint.port_or_known_default().ok_or_else(|| {
ComputeDriverError::Precondition(format!(
"Podman gateway callback endpoint '{}' has no port",
self.config.grpc_endpoint
))
})?;
if callback_port != self.config.gateway_port {
return Err(ComputeDriverError::Precondition(format!(
"Podman local callback endpoint '{}' uses port {callback_port}, but the gateway primary listener uses port {}; configure grpc_endpoint with the gateway primary listener port",
self.config.grpc_endpoint, self.config.gateway_port
)));
}
#[cfg(target_os = "linux")]
{
if self.rootless {
validate_rootless_local_callback_helper(&self.rootless_network_cmd)?;
if self.config.host_gateway_ip.trim().is_empty() {
return Ok(vec![GatewayListenerRequirement {
reason:
"Podman rootless pasta callback uses the host default-route interface"
.to_string(),
selector: Some(Selector::DefaultRouteInterface(
GatewayDefaultRouteInterfaceRequirement {},
)),
}]);
}
}
let gateway_ip = if self.config.host_gateway_ip.trim().is_empty() {
self.network_gateway_ip.as_deref().ok_or_else(|| {
ComputeDriverError::Precondition(format!(
"Podman network '{}' did not report a host bridge gateway address for local callback alias '{}'",
self.config.network_name,
endpoint.host_str().unwrap_or_default()
))
})?
} else {
self.config.host_gateway_ip.trim()
};
let gateway_ip = gateway_ip.parse::<IpAddr>().map_err(|err| {
ComputeDriverError::Precondition(format!(
"Podman callback gateway address '{gateway_ip}' is invalid: {err}"
))
})?;
Ok(vec![GatewayListenerRequirement {
reason: format!("Podman network '{}' host gateway", self.config.network_name),
selector: Some(Selector::ExactBindAddress(
SocketAddr::new(gateway_ip, callback_port).to_string(),
)),
}])
}
#[cfg(target_os = "macos")]
{
Ok(vec![GatewayListenerRequirement {
reason: "Podman machine callback forwarding terminates on gateway loopback"
.to_string(),
selector: Some(Selector::LoopbackInterface(
GatewayLoopbackInterfaceRequirement {},
)),
}])
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
{
Ok(Vec::new())
}
}
#[must_use]
pub fn default_image(&self) -> &str {
&self.config.default_image
}
/// Validate a sandbox before creation.
pub async fn validate_sandbox_create(
&self,
sandbox: &DriverSandbox,
) -> Result<(), ComputeDriverError> {
let _ = self.validated_sandbox_create(sandbox).await?;
Ok(())
}
async fn validated_sandbox_create<'a>(
&self,
sandbox: &'a DriverSandbox,
) -> Result<ValidatedPodmanSandbox<'a>, ComputeDriverError> {
let gpu_requirements = sandbox
.spec
.as_ref()
.and_then(|spec| spec.resource_requirements.as_ref())
.and_then(|requirements| driver_gpu_requirements(Some(requirements)));
let driver_config = PodmanSandboxDriverConfig::from_sandbox(sandbox)?;
Self::validate_gpu_request(gpu_requirements, &driver_config)?;
self.validate_user_volume_mounts_available(sandbox).await?;
let _ = self.resolve_gpu_cdi_devices(
gpu_requirements,
&driver_config,
CdiGpuDefaultSelector::peek_device_ids,
)?;
Ok(ValidatedPodmanSandbox {
driver_config,
gpu_requirements,
})
}
fn validate_gpu_request(
gpu_requirements: Option<&GpuResourceRequirements>,
driver_config: &PodmanSandboxDriverConfig,
) -> Result<(), ComputeDriverError> {
let _ = effective_driver_gpu_count(gpu_requirements)
.map_err(ComputeDriverError::InvalidArgument)?;
if let Some(cdi_devices) = driver_config.cdi_devices.as_deref() {
validate_specific_gpu_device_request(
gpu_requirements,
cdi_devices,
"driver_config.cdi_devices",
)
.map_err(ComputeDriverError::InvalidArgument)?;
}
Ok(())
}
fn refresh_gpu_inventory(&self) {
let (inventory, allow_all_default_gpu) = (self.gpu_inventory_refresh)();
self.gpu_selector.refresh(inventory, allow_all_default_gpu);
}
fn resolve_gpu_cdi_devices(
&self,
gpu_requirements: Option<&GpuResourceRequirements>,
driver_config: &PodmanSandboxDriverConfig,
select_default_devices: fn(
&CdiGpuDefaultSelector,
u32,
) -> Result<Vec<String>, CdiGpuSelectionError>,
) -> Result<Option<Vec<String>>, ComputeDriverError> {
if let Some(cdi_devices) = driver_config.cdi_devices.as_deref() {
validate_specific_gpu_device_request(
gpu_requirements,
cdi_devices,
"driver_config.cdi_devices",
)
.map_err(ComputeDriverError::InvalidArgument)?;
return Ok(Some(cdi_devices.to_vec()));
}
let Some(count) = effective_driver_gpu_count(gpu_requirements)
.map_err(ComputeDriverError::InvalidArgument)?
else {
return Ok(None);
};
self.refresh_gpu_inventory();
select_default_devices(&self.gpu_selector, count)
.map(Some)
.map_err(podman_gpu_selection_error)
}
async fn validate_user_volume_mounts_available(
&self,
sandbox: &DriverSandbox,
) -> Result<(), ComputeDriverError> {
let volumes =
container::podman_driver_volume_mount_sources(sandbox, self.config.enable_bind_mounts)
.map_err(ComputeDriverError::Precondition)?;
for volume in volumes {
match self.client.inspect_volume(&volume).await {
Ok(volume_info) => {
if !self.config.enable_bind_mounts && podman_volume_is_bind_backed(&volume_info)
{
return Err(ComputeDriverError::Precondition(format!(
"podman volume '{volume}' is backed by a host bind mount and requires enable_bind_mounts = true in [openshell.drivers.podman]"
)));
}
}
Err(PodmanApiError::NotFound(_)) => {
return Err(ComputeDriverError::Precondition(format!(
"podman volume '{volume}' does not exist"
)));
}
Err(err) => return Err(ComputeDriverError::from(err)),
}
}
Ok(())
}
/// Create a sandbox container.
pub async fn create_sandbox(&self, sandbox: &DriverSandbox) -> Result<(), ComputeDriverError> {
if sandbox.name.is_empty() {
return Err(ComputeDriverError::Precondition(
"sandbox name is required".into(),
));
}
if sandbox.id.is_empty() {
return Err(ComputeDriverError::Precondition(
"sandbox id is required".into(),
));
}
// Validate the composed container name early, before creating any
// resources (volume), so we don't leave orphans when the name is
// invalid.
let name = validated_container_name(sandbox)?;
let validated = self.validated_sandbox_create(sandbox).await?;
let vol_name = container::volume_name(&sandbox.id);
info!(
sandbox_id = %sandbox.id,
sandbox_name = %sandbox.name,
container = %name,
"Creating sandbox container"
);
// 1a. Pull the supervisor image if needed. The supervisor binary
// is shipped in a standalone OCI image and mounted into sandbox
// containers via Podman's type=image mount. Refresh mutable tags
// like latest/dev, but avoid registry checks for pinned images.
let supervisor_pull_policy = supervisor_image_pull_policy(&self.config.supervisor_image);
info!(
image = %self.config.supervisor_image,
policy = supervisor_pull_policy,
"Ensuring supervisor image"
);
self.client
.pull_image(&self.config.supervisor_image, supervisor_pull_policy)
.await
.map_err(ComputeDriverError::from)?;
// 1b. Pull the sandbox image if needed (Podman does not pull on create).
let image = container::resolve_image(sandbox, &self.config);
if image.is_empty() {
return Err(ComputeDriverError::Precondition(
"no sandbox image configured: set default_image in [openshell.drivers.podman] \
or provide an image in the sandbox template"
.to_string(),
));
}
let pull_policy = self.config.image_pull_policy.as_str();
info!(image = %image, policy = %pull_policy, "Ensuring sandbox image");
self.client
.pull_image(image, pull_policy)
.await
.map_err(ComputeDriverError::from)?;
let inspected_image = self
.client
.inspect_image(image)
.await
.map_err(ComputeDriverError::from)?;
if inspected_image.id.is_empty() {
return Err(ComputeDriverError::Precondition(format!(
"podman image '{image}' inspection did not return an immutable image ID"
)));
}
let image_user = inspected_image
.config
.as_ref()
.map_or("", |config| config.user.as_str());
for image in
container::podman_driver_image_mount_sources(sandbox, self.config.enable_bind_mounts)
.map_err(ComputeDriverError::Precondition)?
{
info!(image = %image, policy = %pull_policy, "Ensuring image mount source");
self.client
.pull_image(&image, pull_policy)
.await
.map_err(ComputeDriverError::from)?;
}
// 2. Create workspace volume and per-sandbox token secret.
if let Err(e) = self.client.create_volume(&vol_name).await {
return Err(ComputeDriverError::from(e));
}
let token_secret_name = match create_sandbox_token_secret(&self.client, sandbox).await {
Ok(name) => name,
Err(e) => {
let _ = self.client.remove_volume(&vol_name).await;
return Err(e);
}
};
let proxy_auth_secret_name =
match create_sandbox_proxy_auth_secret(&self.client, &self.config, sandbox).await {
Ok(name) => name,
Err(e) => {
let _ = self.client.remove_volume(&vol_name).await;
if let Some(secret) = token_secret_name.as_deref() {
cleanup_sandbox_token_secret(&self.client, secret).await;
}
return Err(e);
}
};
// Clean up the volume and both per-sandbox secrets on any failure past
// this point.
let cleanup_created = || async {
let _ = self.client.remove_volume(&vol_name).await;
if let Some(secret) = token_secret_name.as_deref() {
cleanup_sandbox_token_secret(&self.client, secret).await;
}
if let Some(secret) = proxy_auth_secret_name.as_deref() {
cleanup_sandbox_proxy_auth_secret(&self.client, secret).await;
}
};
// 3. Create container.
let gpu_devices = match self.resolve_gpu_cdi_devices(
validated.gpu_requirements,
&validated.driver_config,
CdiGpuDefaultSelector::next_device_ids,
) {
Ok(devices) => devices,
Err(e) => {
cleanup_created().await;
return Err(e);
}
};
let spec = match container::build_container_spec_for_image(
sandbox,
&self.config,
token_secret_name.as_deref(),
gpu_devices.as_deref(),
image,
&inspected_image.id,
image_user,
) {
Ok(spec) => spec,
Err(e) => {
cleanup_created().await;
return Err(e);
}
};
match self.client.create_container(&spec).await {
Ok(_) => {}
Err(PodmanApiError::Conflict(_)) => {
// Clean up the volume we just created. It is keyed by *this*
// sandbox's ID, not the conflicting container's ID (which
// has the same name but a different ID), so it would be
// orphaned otherwise.
cleanup_created().await;
return Err(ComputeDriverError::AlreadyExists);
}
Err(e) => {
cleanup_created().await;
return Err(ComputeDriverError::from(e));
}
}
// 5. Start container.
if let Err(e) = self.client.start_container(&name).await {
warn!(
sandbox_name = %sandbox.name,
error = %e,
"Failed to start container; cleaning up"
);
let _ = self
.client
.remove_container(&name, self.config.stop_timeout_secs)
.await;
cleanup_created().await;
return Err(ComputeDriverError::from(e));
}
info!(
sandbox_id = %sandbox.id,
sandbox_name = %sandbox.name,
"Sandbox container started"
);
Ok(())
}
/// Find the Podman container ID for a sandbox by its sandbox ID using label lookup.
async fn find_container_id(
&self,
sandbox_id: &str,
) -> Result<Option<String>, ComputeDriverError> {
let id_filter = format!("{LABEL_SANDBOX_ID}={sandbox_id}");
let entries = self
.client
.list_containers(&[LABEL_MANAGED_FILTER, &id_filter])
.await
.map_err(ComputeDriverError::from)?;
Ok(entries.first().map(|e| e.id.clone()))
}
/// Stop a sandbox container without deleting it.
pub async fn stop_sandbox(&self, sandbox_id: &str) -> Result<(), ComputeDriverError> {
let container_id = self.find_container_id(sandbox_id).await?.ok_or_else(|| {
ComputeDriverError::Precondition("sandbox container not found".into())
})?;
info!(sandbox_id = %sandbox_id, container = %container_id, "Stopping sandbox container");
self.client
.stop_container(&container_id, self.config.stop_timeout_secs)
.await
.map_err(ComputeDriverError::from)
}
/// Delete a sandbox container and its workspace volume.
pub async fn delete_sandbox(&self, sandbox_id: &str) -> Result<bool, ComputeDriverError> {
if sandbox_id.is_empty() {
return Err(ComputeDriverError::Precondition(
"sandbox id is required".into(),
));
}
let Some(container_id) = self.find_container_id(sandbox_id).await? else {
debug!(sandbox_id = %sandbox_id, "Sandbox container not found (already deleted)");
let vol = container::volume_name(sandbox_id);
if let Err(e) = self.client.remove_volume(&vol).await {
warn!(sandbox_id = %sandbox_id, volume = %vol, error = %e, "Failed to remove workspace volume");
}
cleanup_sandbox_token_secret(&self.client, &container::token_secret_name(sandbox_id))
.await;
cleanup_sandbox_proxy_auth_secret(
&self.client,
&container::proxy_auth_secret_name(sandbox_id),
)
.await;
return Ok(false);
};
info!(sandbox_id = %sandbox_id, container = %container_id, "Deleting sandbox container");
// Keep stop, timeout, and removal in one Podman operation. Splitting
// stop and remove can race with another container starting an image
// mount when the stop reaches its timeout.
let container_existed = match self
.client
.remove_container(&container_id, self.config.stop_timeout_secs)
.await
{
Ok(()) => true,
Err(PodmanApiError::NotFound(_)) => false,
Err(e) => return Err(ComputeDriverError::from(e)),
};
// Remove workspace volume.
let vol = container::volume_name(sandbox_id);
if let Err(e) = self.client.remove_volume(&vol).await {
warn!(
sandbox_id = %sandbox_id,
volume = %vol,
error = %e,
"Failed to remove workspace volume"
);
}
cleanup_sandbox_token_secret(&self.client, &container::token_secret_name(sandbox_id)).await;
cleanup_sandbox_proxy_auth_secret(
&self.client,
&container::proxy_auth_secret_name(sandbox_id),
)
.await;
Ok(container_existed)
}
/// Check whether a sandbox container exists.
pub async fn sandbox_exists(&self, sandbox_id: &str) -> Result<bool, ComputeDriverError> {
let id_filter = format!("{LABEL_SANDBOX_ID}={sandbox_id}");
let entries = self
.client
.list_containers(&[LABEL_MANAGED_FILTER, &id_filter])
.await
.map_err(ComputeDriverError::from)?;
Ok(!entries.is_empty())
}
/// Fetch a single sandbox by ID.
pub async fn get_sandbox(
&self,
sandbox_id: &str,
) -> Result<Option<DriverSandbox>, ComputeDriverError> {
let id_filter = format!("{LABEL_SANDBOX_ID}={sandbox_id}");
let entries = self
.client
.list_containers(&[LABEL_MANAGED_FILTER, &id_filter])
.await
.map_err(ComputeDriverError::from)?;
let Some(entry) = entries.first() else {
return Ok(None);
};
if entry.state == "running" {
Ok(self
.client
.inspect_container(&entry.id)
.await
.ok()
.and_then(|inspect| driver_sandbox_from_inspect(&inspect))
.or_else(|| driver_sandbox_from_list_entry(entry)))
} else {
Ok(driver_sandbox_from_list_entry(entry))
}
}
/// List all managed sandboxes.
///
/// Only inspects running containers (to get health status). Non-running
/// containers are built directly from the list entry data.
pub async fn list_sandboxes(&self) -> Result<Vec<DriverSandbox>, ComputeDriverError> {
let entries = self
.client
.list_containers(&[LABEL_MANAGED_FILTER])
.await
.map_err(ComputeDriverError::from)?;
let mut sandboxes = Vec::with_capacity(entries.len());
for entry in &entries {
if entry.state == "running" {
// Running containers need inspect for health check status.
match self.client.inspect_container(&entry.id).await {
Ok(inspect) => {
if let Some(sandbox) = driver_sandbox_from_inspect(&inspect) {
sandboxes.push(sandbox);
continue;
}
}
Err(e) => {
let name = entry.names.first().cloned().unwrap_or_default();
warn!(
container = %name,
error = %e,
"Failed to inspect running container during list, falling back to list entry"
);
}
}
}
// Non-running containers (or inspect fallback): build from list data.
if let Some(sandbox) = driver_sandbox_from_list_entry(entry) {
sandboxes.push(sandbox);
}
}
sandboxes.sort_by(|a, b| a.name.cmp(&b.name).then_with(|| a.id.cmp(&b.id)));
Ok(sandboxes)
}
/// Start watching all managed sandbox containers.
pub async fn watch_sandboxes(&self) -> Result<WatchStream, ComputeDriverError> {
watcher::start_watch(self.client.clone())
.await
.map_err(ComputeDriverError::from)
}
}
#[cfg(test)]
impl PodmanComputeDriver {
pub(crate) fn for_tests(config: PodmanComputeConfig) -> Self {
Self::for_tests_with_gpu_inventory(config, CdiGpuInventory::default())
}
pub(crate) fn for_tests_with_gpu_inventory(
config: PodmanComputeConfig,
gpu_inventory: CdiGpuInventory,
) -> Self {
Self::for_tests_with_gpu_inventory_and_all_fallback(config, gpu_inventory, false)
}
pub(crate) fn for_tests_with_gpu_inventory_and_all_fallback(
config: PodmanComputeConfig,
gpu_inventory: CdiGpuInventory,
allow_all_default_gpu: bool,
) -> Self {
let client = PodmanClient::new(config.socket_path.clone().unwrap_or_default());
let refresh_inventory = gpu_inventory.clone();
Self {
client,
config,
network_gateway_ip: None,
rootless: false,
rootless_network_cmd: String::new(),
gpu_selector: Arc::new(CdiGpuDefaultSelector::new(
gpu_inventory,
allow_all_default_gpu,
)),
gpu_inventory_refresh: Arc::new(move || {
(refresh_inventory.clone(), allow_all_default_gpu)
}),
}
}
}
fn supervisor_image_pull_policy(image: &str) -> &'static str {
if supervisor_image_should_refresh(image) {
"newer"
} else {
"missing"
}
}
/// Check whether the current user has subuid/subgid ranges configured.
///
/// Rootless Podman requires entries in `/etc/subuid` and `/etc/subgid` for
/// the running user. If missing, container creation fails with an obscure
/// error. This pre-flight check emits a warning to guide operators.
fn check_subuid_range() {
let uid = nix::unistd::getuid().as_raw();
let username = nix::unistd::User::from_uid(nix::unistd::Uid::from_raw(uid))
.ok()
.flatten()
.map(|u| u.name);
let has_range = |path: &str| -> bool {
let Ok(content) = std::fs::read_to_string(path) else {
return false;
};
let uid_str = uid.to_string();
content.lines().any(|line| {
let Some(entry) = line.split(':').next() else {
return false;
};
entry == uid_str || username.as_deref() == Some(entry)
})
};
if !has_range("/etc/subuid") || !has_range("/etc/subgid") {
let user_display = username.as_deref().map_or_else(
|| format!("UID {uid}"),
|name| format!("{name} (UID {uid})"),
);
warn!(
user = %user_display,
"Rootless Podman detected but no /etc/subuid or /etc/subgid entry found. \
Container creation may fail. Add entries with: \
sudo usermod --add-subuids 100000-165535 --add-subgids 100000-165535 $(whoami)"
);
}
}
fn callback_endpoint_uses_local_alias(endpoint: &Url) -> bool {
endpoint
.host_str()
.is_some_and(|host| matches!(host, "host.containers.internal" | "host.openshell.internal"))
}
#[cfg(any(target_os = "linux", test))]
fn validate_rootless_local_callback_helper(
rootless_network_cmd: &str,
) -> Result<(), ComputeDriverError> {
let rootless_network_cmd = rootless_network_cmd.trim();
if rootless_network_cmd == "pasta" {
return Ok(());
}
let reported = if rootless_network_cmd.is_empty() {
"<missing>"
} else {
rootless_network_cmd
};
Err(ComputeDriverError::Precondition(format!(
"Podman rootless network helper '{reported}' does not support direct local gateway callbacks; configure pasta or use an explicitly remote grpc_endpoint"
)))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_utils::{StubResponse, spawn_podman_stub};
use hyper::StatusCode;
use openshell_core::proto::compute::v1::{
DriverSandboxSpec, DriverSandboxTemplate, ResourceRequirements,
};
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
// ── socket resolution ───────────────────────────────────────────────
//
// These test resolve_socket_path directly with an injected detector, so
// they are deterministic regardless of the host's real environment
// variables or whether a Podman socket happens to be running.
#[test]
fn resolve_socket_path_prefers_explicit_configuration() {
let path = resolve_socket_path(Some(PathBuf::from("/explicit.sock")), || {
Some(PathBuf::from("/detected.sock"))
})
.unwrap();
assert_eq!(path, PathBuf::from("/explicit.sock"));
}
#[test]
fn resolve_socket_path_uses_detected_socket_when_unconfigured() {
let path = resolve_socket_path(None, || Some(PathBuf::from("/detected.sock"))).unwrap();
assert_eq!(path, PathBuf::from("/detected.sock"));
}
#[test]
fn resolve_socket_path_errors_when_neither_source_resolves() {
let err = resolve_socket_path(None, || None).unwrap_err();
assert!(err.to_string().contains("no responsive Podman API socket"));
}
fn cdi_devices_config(device_ids: &[&str]) -> prost_types::Struct {
prost_types::Struct {
fields: std::iter::once((
"cdi_devices".to_string(),
prost_types::Value {
kind: Some(prost_types::value::Kind::ListValue(
prost_types::ListValue {
values: device_ids
.iter()
.map(|device_id| prost_types::Value {
kind: Some(prost_types::value::Kind::StringValue(
(*device_id).to_string(),
)),
})
.collect(),
},
)),
},
))
.collect(),
}
}
fn gpu_resources(count: Option<u32>) -> ResourceRequirements {
ResourceRequirements {
gpu: Some(GpuResourceRequirements { count }),
}
}
#[test]
fn podman_driver_error_from_conflict() {
let err = ComputeDriverError::from(PodmanApiError::Conflict("exists".into()));
assert!(matches!(err, ComputeDriverError::AlreadyExists));
}
#[test]
fn podman_driver_error_from_not_found() {
let err = ComputeDriverError::from(PodmanApiError::NotFound("gone".into()));
assert!(matches!(err, ComputeDriverError::Message(_)));
}
#[test]
fn validate_gpu_request_accepts_gpu_count_request_shape() {
let gpu = GpuResourceRequirements { count: Some(2) };
let driver_config = PodmanSandboxDriverConfig::default();
PodmanComputeDriver::validate_gpu_request(Some(&gpu), &driver_config)
.expect("default GPU count shape should be accepted before inventory selection");
}
#[test]
fn validate_gpu_request_accepts_single_cdi_device_without_gpu_count() {
let gpu = GpuResourceRequirements { count: None };
let mut driver_config = PodmanSandboxDriverConfig::default();
driver_config.cdi_devices = Some(vec!["nvidia.com/gpu=0".to_string()]);
PodmanComputeDriver::validate_gpu_request(Some(&gpu), &driver_config)
.expect("single exact CDI device should pass count validation");
}
#[test]
fn validate_gpu_request_rejects_multiple_cdi_devices_without_gpu_count() {
let gpu = GpuResourceRequirements { count: None };
let mut driver_config = PodmanSandboxDriverConfig::default();
driver_config.cdi_devices = Some(vec![
"nvidia.com/gpu=0".to_string(),
"nvidia.com/gpu=1".to_string(),
]);
let err = PodmanComputeDriver::validate_gpu_request(Some(&gpu), &driver_config)
.expect_err("missing CDI device count should be rejected for multiple devices");
assert!(matches!(err, ComputeDriverError::InvalidArgument(_)));
assert!(
err.to_string()
.contains("gpu count (1) must match driver_config.cdi_devices length (2)")
);
}
#[test]
fn validate_gpu_request_rejects_cdi_devices_without_gpu_request() {
let mut driver_config = PodmanSandboxDriverConfig::default();
driver_config.cdi_devices = Some(vec!["nvidia.com/gpu=0".to_string()]);
let err = PodmanComputeDriver::validate_gpu_request(None, &driver_config)
.expect_err("missing GPU request should be rejected");
assert!(matches!(err, ComputeDriverError::InvalidArgument(_)));
assert!(err.to_string().contains("requires a gpu request"));
}
#[test]
fn validate_gpu_request_rejects_mismatched_cdi_device_count() {
let gpu = GpuResourceRequirements { count: Some(2) };
let mut driver_config = PodmanSandboxDriverConfig::default();
driver_config.cdi_devices = Some(vec!["nvidia.com/gpu=0".to_string()]);
let err = PodmanComputeDriver::validate_gpu_request(Some(&gpu), &driver_config)
.expect_err("mismatched CDI device count should be rejected");
assert!(matches!(err, ComputeDriverError::InvalidArgument(_)));
assert!(
err.to_string()
.contains("gpu count (2) must match driver_config.cdi_devices length (1)")
);
}
// ── grpc_endpoint auto-detection ───────────────────────────────────
//
// PodmanComputeDriver::new() fills grpc_endpoint when it is empty.
// The scheme (http vs https) depends on whether TLS client certs are
// configured. These tests simulate the auto-detection logic.
#[test]
fn grpc_endpoint_http_without_tls() {
let mut cfg = PodmanComputeConfig {
gateway_port: 8081,
..PodmanComputeConfig::default()
};
if cfg.grpc_endpoint.is_empty() {
let scheme = if cfg.tls_enabled() { "https" } else { "http" };
cfg.grpc_endpoint = format!("{scheme}://host.containers.internal:{}", cfg.gateway_port);
}
assert_eq!(cfg.grpc_endpoint, "http://host.containers.internal:8081");
}
#[test]
fn grpc_endpoint_https_with_tls() {
let mut cfg = PodmanComputeConfig {
gateway_port: 8080,
guest_tls_ca: Some(PathBuf::from("/tls/ca.crt")),
guest_tls_cert: Some(PathBuf::from("/tls/tls.crt")),
guest_tls_key: Some(PathBuf::from("/tls/tls.key")),
..PodmanComputeConfig::default()
};
if cfg.grpc_endpoint.is_empty() {
let scheme = if cfg.tls_enabled() { "https" } else { "http" };
cfg.grpc_endpoint = format!("{scheme}://host.containers.internal:{}", cfg.gateway_port);
}
assert_eq!(cfg.grpc_endpoint, "https://host.containers.internal:8080");
}
#[test]
fn partial_tls_config_returns_error() {
let cfg = PodmanComputeConfig {
gateway_port: 8080,
guest_tls_ca: Some(PathBuf::from("/tls/ca.crt")),
// guest_tls_cert and guest_tls_key not set — incomplete TLS config.
..PodmanComputeConfig::default()
};
assert!(!cfg.tls_enabled());
let err = cfg
.validate_tls_config()
.expect_err("partial TLS config should be rejected");
let msg = err.to_string();
assert!(
msg.contains("OPENSHELL_PODMAN_TLS_CERT"),
"error should name the missing cert: {msg}"
);
assert!(
msg.contains("OPENSHELL_PODMAN_TLS_KEY"),
"error should name the missing key: {msg}"
);
}
#[test]
fn explicit_grpc_endpoint_takes_precedence() {
let mut cfg = PodmanComputeConfig {
grpc_endpoint: "https://gateway.internal:9000".to_string(),
gateway_port: 8081,
..PodmanComputeConfig::default()
};
if cfg.grpc_endpoint.is_empty() {
let scheme = if cfg.tls_enabled() { "https" } else { "http" };
cfg.grpc_endpoint = format!("{scheme}://host.containers.internal:{}", cfg.gateway_port);
}
assert_eq!(cfg.grpc_endpoint, "https://gateway.internal:9000");
}
#[test]
fn rootless_slirp_allows_remote_callback_endpoint() {
let mut driver = PodmanComputeDriver::for_tests(PodmanComputeConfig {
grpc_endpoint: "https://gateway.internal:9000".to_string(),
..PodmanComputeConfig::default()
});
driver.rootless = true;
driver.rootless_network_cmd = "slirp4netns".to_string();
let requirements = driver.gateway_listener_requirements().unwrap();
assert!(requirements.is_empty());
}
#[test]
#[cfg(target_os = "linux")]
fn rootful_local_callback_alias_requests_discovered_network_gateway() {
let mut driver = PodmanComputeDriver::for_tests(PodmanComputeConfig {
grpc_endpoint: "http://host.openshell.internal:17670".to_string(),
..PodmanComputeConfig::default()
});
driver.network_gateway_ip = Some("10.89.1.1".to_string());
let requirements = driver.gateway_listener_requirements().unwrap();
assert_eq!(requirements.len(), 1);
assert_eq!(
requirements[0].selector,
Some(Selector::ExactBindAddress("10.89.1.1:17670".to_string()))
);
}
#[test]
#[cfg(target_os = "linux")]
fn configured_host_gateway_overrides_discovered_network_gateway() {
let mut driver = PodmanComputeDriver::for_tests(PodmanComputeConfig {
grpc_endpoint: "http://host.containers.internal:17670".to_string(),
host_gateway_ip: "10.90.1.1".to_string(),
..PodmanComputeConfig::default()
});
driver.network_gateway_ip = Some("10.89.1.1".to_string());
driver.rootless = true;
driver.rootless_network_cmd = "pasta".to_string();
let requirements = driver.gateway_listener_requirements().unwrap();
assert_eq!(
requirements[0].selector,
Some(Selector::ExactBindAddress("10.90.1.1:17670".to_string()))
);
}
#[test]
#[cfg(target_os = "linux")]
fn rootless_pasta_requests_default_route_interface() {
let mut driver = PodmanComputeDriver::for_tests(PodmanComputeConfig {
grpc_endpoint: "http://host.openshell.internal:17670".to_string(),
..PodmanComputeConfig::default()
});
driver.rootless = true;
driver.rootless_network_cmd = "pasta".to_string();
let requirements = driver.gateway_listener_requirements().unwrap();
assert!(matches!(
requirements[0].selector,
Some(Selector::DefaultRouteInterface(_))
));
}
#[test]
fn rootless_non_pasta_helpers_are_rejected() {
for (rootless_network_cmd, reported) in [
("slirp4netns", "slirp4netns"),
("", "<missing>"),
("unknown-helper", "unknown-helper"),
] {
let err = validate_rootless_local_callback_helper(rootless_network_cmd).unwrap_err();
assert!(matches!(err, ComputeDriverError::Precondition(_)));
assert!(err.to_string().contains(reported));
assert!(err.to_string().contains("configure pasta"));
assert!(err.to_string().contains("remote grpc_endpoint"));
}
}
#[test]
fn rootless_pasta_is_accepted_for_local_callbacks() {
validate_rootless_local_callback_helper("pasta").unwrap();
}
#[test]
#[cfg(target_os = "linux")]
fn rootless_slirp_rejects_explicit_host_gateway_override() {
let mut driver = PodmanComputeDriver::for_tests(PodmanComputeConfig {
grpc_endpoint: "http://host.openshell.internal:17670".to_string(),
host_gateway_ip: "10.90.1.1".to_string(),
..PodmanComputeConfig::default()
});
driver.rootless = true;
driver.rootless_network_cmd = "slirp4netns".to_string();
let err = driver.gateway_listener_requirements().unwrap_err();
assert!(matches!(err, ComputeDriverError::Precondition(_)));
assert!(err.to_string().contains("slirp4netns"));
}
#[cfg(target_os = "linux")]
#[tokio::test]
async fn constructor_preserves_required_network_gateway_discovery_error() {
let (socket_path, _request_log, handle) = spawn_podman_stub(
"network-gateway-error",
vec![
StubResponse::new(StatusCode::OK, ""),
StubResponse::new(
StatusCode::OK,
r#"{
"host": {
"cgroupVersion": "v2",
"networkBackend": "netavark",
"security": {"rootless": false},
"remoteSocket": {"path": "/run/podman/podman.sock"}
},
"version": {"Version": "5.0.0"}
}"#,
),
StubResponse::new(StatusCode::CREATED, "{}"),
StubResponse::new(
StatusCode::INTERNAL_SERVER_ERROR,
r#"{"message":"network gateway inspection failed"}"#,
),
],
);
let config = PodmanComputeConfig {
socket_path: Some(socket_path.clone()),
grpc_endpoint: "http://host.containers.internal:8080".to_string(),
..PodmanComputeConfig::default()
};
let Err(err) = PodmanComputeDriver::new(config).await else {
panic!("required network gateway discovery failure should prevent startup");
};
assert!(
err.to_string()
.contains("network gateway inspection failed"),
"unexpected startup error: {err}"
);
handle.await.expect("stub task should finish");
}
#[tokio::test]
async fn constructor_skips_network_gateway_discovery_for_remote_callback() {
let (socket_path, request_log, handle) = spawn_podman_stub(
"remote-callback-no-network-gateway",
vec![
StubResponse::new(StatusCode::OK, ""),
StubResponse::new(
StatusCode::OK,
r#"{
"host": {
"cgroupVersion": "v2",
"networkBackend": "netavark",
"security": {"rootless": false}
}
}"#,
),
StubResponse::new(StatusCode::CREATED, "{}"),
],
);
let config = PodmanComputeConfig {
socket_path: Some(socket_path.clone()),
grpc_endpoint: "https://gateway.example.test:9443".to_string(),
..PodmanComputeConfig::default()
};
let driver = PodmanComputeDriver::new(config)
.await
.expect("remote callbacks must not require bridge gateway inspection");
assert!(driver.network_gateway_ip().is_none());
assert!(driver.gateway_listener_requirements().unwrap().is_empty());
handle.await.expect("stub task should finish");
assert_eq!(
request_log
.lock()
.expect("request log lock should not be poisoned")
.as_slice(),
[
"GET /_ping".to_string(),
format!("GET {}", api_path("/libpod/info")),
format!("POST {}", api_path("/libpod/networks/create")),
]
);
}
#[test]
#[cfg(target_os = "linux")]
fn rootful_local_callback_alias_requires_concrete_gateway_address() {
let driver = PodmanComputeDriver::for_tests(PodmanComputeConfig {
grpc_endpoint: "http://host.openshell.internal:17670".to_string(),
..PodmanComputeConfig::default()
});
let err = driver.gateway_listener_requirements().unwrap_err();
assert!(
err.to_string()
.contains("did not report a host bridge gateway address")
);
}
#[test]
#[cfg(target_os = "macos")]
fn podman_machine_callback_alias_requests_loopback_listener() {
let driver = PodmanComputeDriver::for_tests(PodmanComputeConfig {
grpc_endpoint: "http://host.openshell.internal:17670".to_string(),
..PodmanComputeConfig::default()
});
let requirements = driver.gateway_listener_requirements().unwrap();
assert_eq!(requirements.len(), 1);
assert!(matches!(
requirements[0].selector,
Some(Selector::LoopbackInterface(_))
));
}
#[test]
fn explicit_remote_callback_does_not_request_gateway_listener() {
let driver = PodmanComputeDriver::for_tests(PodmanComputeConfig {
grpc_endpoint: "https://gateway.example.test:9443".to_string(),
gateway_port: 17670,
..PodmanComputeConfig::default()
});
assert!(driver.gateway_listener_requirements().unwrap().is_empty());
}
#[test]
fn local_callback_alias_requires_primary_listener_port() {
for grpc_endpoint in [
"http://host.openshell.internal:17671",
"http://host.containers.internal",
] {
let driver = PodmanComputeDriver::for_tests(PodmanComputeConfig {
grpc_endpoint: grpc_endpoint.to_string(),
gateway_port: 17670,
..PodmanComputeConfig::default()
});
let err = driver.gateway_listener_requirements().unwrap_err();
assert!(
matches!(err, ComputeDriverError::Precondition(_)),
"mismatched local callback port should fail precondition: {err}"
);
assert!(
err.to_string()
.contains("gateway primary listener uses port 17670"),
"unexpected error for {grpc_endpoint}: {err}"
);
}
}
#[test]
fn local_podman_cdi_gpu_inventory_maps_nvidia_device_nodes() {
let root = std::env::temp_dir().join(format!(
"openshell-podman-gpu-test-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system time should be after unix epoch")
.as_nanos()
));
fs::create_dir(&root).expect("create temp dev root");
fs::write(root.join("nvidia2"), "").expect("create nvidia2");
fs::write(root.join("nvidiactl"), "").expect("create nvidiactl");
fs::write(root.join("nvidia0"), "").expect("create nvidia0");
let inventory = local_podman_cdi_gpu_inventory_from(&root);
fs::remove_dir_all(&root).expect("remove temp dev root");
assert_eq!(
inventory.as_slice(),
&vec![
"nvidia.com/gpu=0".to_string(),
"nvidia.com/gpu=2".to_string()
]
);
}
#[test]
fn local_podman_cdi_gpu_inventory_maps_dxg_to_all_gpu_fallback() {
let root = std::env::temp_dir().join(format!(
"openshell-podman-dxg-test-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system time should be after unix epoch")
.as_nanos()
));
fs::create_dir(&root).expect("create temp dev root");
fs::write(root.join("dxg"), "").expect("create dxg");
let inventory = local_podman_cdi_gpu_inventory_from(&root);
let allow_all_default = local_podman_all_gpu_default_supported_from(&root);
fs::remove_dir_all(&root).expect("remove temp dev root");
assert_eq!(inventory.as_slice(), &vec![CDI_GPU_DEVICE_ALL.to_string()]);
assert!(allow_all_default);
}
#[tokio::test]
async fn validate_sandbox_create_accepts_default_gpu_with_inventory() {
use openshell_core::proto::compute::v1::DriverSandboxSpec;
let driver = PodmanComputeDriver::for_tests_with_gpu_inventory(
PodmanComputeConfig::default(),
CdiGpuInventory::new(["nvidia.com/gpu=0"]),
);
let sandbox = DriverSandbox {
spec: Some(DriverSandboxSpec {
resource_requirements: Some(gpu_resources(None)),
..Default::default()
}),
..Default::default()
};
driver.validate_sandbox_create(&sandbox).await.unwrap();
}
#[tokio::test]
async fn validate_sandbox_create_accepts_all_only_inventory_when_dxg_fallback_allowed() {
use openshell_core::proto::compute::v1::DriverSandboxSpec;
let driver = PodmanComputeDriver::for_tests_with_gpu_inventory_and_all_fallback(
PodmanComputeConfig::default(),
CdiGpuInventory::new([CDI_GPU_DEVICE_ALL]),
true,
);
let sandbox = DriverSandbox {
spec: Some(DriverSandboxSpec {
resource_requirements: Some(gpu_resources(None)),
..Default::default()
}),
..Default::default()
};
driver.validate_sandbox_create(&sandbox).await.unwrap();
}
#[tokio::test]
async fn validate_sandbox_create_rejects_all_only_inventory_without_dxg_fallback() {
use openshell_core::proto::compute::v1::DriverSandboxSpec;
let driver = PodmanComputeDriver::for_tests_with_gpu_inventory(
PodmanComputeConfig::default(),
CdiGpuInventory::new([CDI_GPU_DEVICE_ALL]),
);
let sandbox = DriverSandbox {
spec: Some(DriverSandboxSpec {
resource_requirements: Some(gpu_resources(None)),
..Default::default()
}),
..Default::default()
};
let err = driver.validate_sandbox_create(&sandbox).await.unwrap_err();
assert!(err.to_string().contains("nvidia.com/gpu=all"));
}
#[tokio::test]
async fn validate_sandbox_create_passes_explicit_cdi_device_id_without_inventory() {
use openshell_core::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate};
let driver = PodmanComputeDriver::for_tests(PodmanComputeConfig::default());
let sandbox = DriverSandbox {
spec: Some(DriverSandboxSpec {
resource_requirements: Some(gpu_resources(None)),
template: Some(DriverSandboxTemplate {
driver_config: Some(cdi_devices_config(&["nvidia.com/gpu=0"])),
..Default::default()
}),
..Default::default()
}),
..Default::default()
};
driver.validate_sandbox_create(&sandbox).await.unwrap();
}
#[test]
fn driver_default_gpu_selection_consumes_distinct_devices_for_creates() {
use openshell_core::proto::compute::v1::DriverSandboxSpec;
let driver = PodmanComputeDriver::for_tests_with_gpu_inventory(
PodmanComputeConfig::default(),
CdiGpuInventory::new(["nvidia.com/gpu=0", "nvidia.com/gpu=1"]),
);
let first_sandbox = DriverSandbox {
id: "sbx-first".to_string(),
name: "first".to_string(),
spec: Some(DriverSandboxSpec {
resource_requirements: Some(gpu_resources(None)),
..Default::default()
}),
..Default::default()
};
let second_sandbox = DriverSandbox {
id: "sbx-second".to_string(),
name: "second".to_string(),
spec: Some(DriverSandboxSpec {
resource_requirements: Some(gpu_resources(None)),
..Default::default()
}),
..Default::default()
};
assert_eq!(
driver.gpu_selector.peek_device_ids(1).unwrap(),
vec!["nvidia.com/gpu=0".to_string()]
);
let first_devices = driver.gpu_selector.next_device_ids(1).unwrap();
let first_spec = container::build_container_spec_with_token_and_gpu_devices(
&first_sandbox,
&driver.config,
None,
Some(&first_devices),
)
.unwrap();
assert_eq!(
driver.gpu_selector.peek_device_ids(1).unwrap(),
vec!["nvidia.com/gpu=1".to_string()]
);
let second_devices = driver.gpu_selector.next_device_ids(1).unwrap();
let second_spec = container::build_container_spec_with_token_and_gpu_devices(
&second_sandbox,
&driver.config,
None,
Some(&second_devices),
)
.unwrap();
assert_eq!(
first_spec["devices"][0]["path"].as_str(),
Some("nvidia.com/gpu=0")
);
assert_eq!(
second_spec["devices"][0]["path"].as_str(),
Some("nvidia.com/gpu=1")
);
}
#[test]
fn supervisor_pull_policy_refreshes_mutable_tags_only() {
assert_eq!(
supervisor_image_pull_policy("ghcr.io/nvidia/openshell/supervisor:dev"),
"newer"
);
assert_eq!(
supervisor_image_pull_policy("ghcr.io/nvidia/openshell/supervisor:latest"),
"newer"
);
assert_eq!(
supervisor_image_pull_policy("ghcr.io/nvidia/openshell/supervisor"),
"newer"
);
assert_eq!(
supervisor_image_pull_policy(
"ghcr.io/nvidia/openshell/supervisor:0.0.47-dev.13-g57b71c68f"
),
"missing"
);
assert_eq!(
supervisor_image_pull_policy("ghcr.io/nvidia/openshell/supervisor@sha256:abc123"),
"missing"
);
}
fn test_driver(socket_path: PathBuf) -> PodmanComputeDriver {
let config = PodmanComputeConfig {
socket_path: Some(socket_path),
stop_timeout_secs: 10,
..PodmanComputeConfig::default()
};
PodmanComputeDriver::for_tests(config)
}
fn test_driver_with_config(config: PodmanComputeConfig) -> PodmanComputeDriver {
PodmanComputeDriver::for_tests(config)
}
fn json_struct(value: serde_json::Value) -> prost_types::Struct {
let serde_json::Value::Object(object) = value else {
panic!("expected JSON object");
};
openshell_core::proto_struct::json_object_to_struct(object)
.expect("test JSON must convert to a protobuf Struct")
}
fn sandbox_with_volume_mount(volume: &str) -> DriverSandbox {
DriverSandbox {
id: "sandbox-123".to_string(),
name: "demo".to_string(),
namespace: String::new(),
spec: Some(DriverSandboxSpec {
template: Some(DriverSandboxTemplate {
driver_config: Some(json_struct(serde_json::json!({
"mounts": [{
"type": "volume",
"source": volume,
"target": "/sandbox/work"
}]
}))),
..Default::default()
}),
..Default::default()
}),
status: None,
workspace: String::new(),
}
}
fn api_path(path: &str) -> String {
format!("/v5.0.0{path}")
}
#[test]
fn podman_local_volume_with_bind_option_is_bind_backed() {
let volume = VolumeInspect {
driver: "local".to_string(),
options: HashMap::from([("o".to_string(), "rw,bind".to_string())]),
};
assert!(podman_volume_is_bind_backed(&volume));
}
#[test]
fn podman_local_volume_with_rbind_option_is_bind_backed() {
let volume = VolumeInspect {
driver: "local".to_string(),
options: HashMap::from([("o".to_string(), "rw,rbind".to_string())]),
};
assert!(podman_volume_is_bind_backed(&volume));
}
#[test]
fn podman_empty_driver_volume_with_bind_option_is_bind_backed() {
let volume = VolumeInspect {
driver: String::new(),
options: HashMap::from([("o".to_string(), "bind".to_string())]),
};
assert!(podman_volume_is_bind_backed(&volume));
}
#[test]
fn podman_local_volume_without_bind_option_is_not_bind_backed() {
let volume = VolumeInspect {
driver: "local".to_string(),
options: HashMap::from([("o".to_string(), "addr=127.0.0.1,rw".to_string())]),
};
assert!(!podman_volume_is_bind_backed(&volume));
}
#[test]
fn podman_nonlocal_volume_with_bind_option_is_not_bind_backed() {
let volume = VolumeInspect {
driver: "custom".to_string(),
options: HashMap::from([("o".to_string(), "bind".to_string())]),
};
assert!(!podman_volume_is_bind_backed(&volume));
}
#[tokio::test]
async fn validate_sandbox_rejects_bind_backed_named_volume_unless_enabled() {
let (socket_path, request_log, handle) = spawn_podman_stub(
"bind-volume-disabled",
vec![StubResponse::new(
StatusCode::OK,
r#"{"Name":"work-bind","Driver":"local","Options":{"type":"none","o":"rw,bind","device":"/srv/work"}}"#,
)],
);
let driver = test_driver(socket_path.clone());
let sandbox = sandbox_with_volume_mount("work-bind");
let err = driver
.validate_sandbox_create(&sandbox)
.await
.expect_err("bind-backed volume should require bind mount opt-in");
match err {
ComputeDriverError::Precondition(message) => {
assert!(message.contains("enable_bind_mounts = true"));
}
other => panic!("expected precondition error, got {other:?}"),
}
handle.await.expect("stub task should finish");
assert_eq!(
request_log
.lock()
.expect("request log lock should not be poisoned")
.as_slice(),
[format!(
"GET {}",
api_path("/libpod/volumes/work-bind/json")
)]
);
let _ = fs::remove_file(socket_path);
}
#[tokio::test]
async fn validate_sandbox_rejects_rbind_backed_named_volume_unless_enabled() {
let (socket_path, request_log, handle) = spawn_podman_stub(
"rbind-volume-disabled",
vec![StubResponse::new(
StatusCode::OK,
r#"{"Name":"work-rbind","Driver":"local","Options":{"type":"none","o":"rw,rbind","device":"/srv/work"}}"#,
)],
);
let driver = test_driver(socket_path.clone());
let sandbox = sandbox_with_volume_mount("work-rbind");
let err = driver
.validate_sandbox_create(&sandbox)
.await
.expect_err("rbind-backed volume should require bind mount opt-in");
match err {
ComputeDriverError::Precondition(message) => {
assert!(message.contains("enable_bind_mounts = true"));
}
other => panic!("expected precondition error, got {other:?}"),
}
handle.await.expect("stub task should finish");
assert_eq!(
request_log
.lock()
.expect("request log lock should not be poisoned")
.as_slice(),
[format!(
"GET {}",
api_path("/libpod/volumes/work-rbind/json")
)]
);
let _ = fs::remove_file(socket_path);
}
#[tokio::test]
async fn validate_sandbox_allows_bind_backed_named_volume_when_enabled() {
let (socket_path, _request_log, handle) = spawn_podman_stub(
"bind-volume-enabled",
vec![StubResponse::new(
StatusCode::OK,
r#"{"Name":"work-bind","Driver":"local","Options":{"type":"none","o":"rw,bind","device":"/srv/work"}}"#,
)],
);
let config = PodmanComputeConfig {
socket_path: Some(socket_path.clone()),
enable_bind_mounts: true,
..PodmanComputeConfig::default()
};
let driver = test_driver_with_config(config);
let sandbox = sandbox_with_volume_mount("work-bind");
driver
.validate_sandbox_create(&sandbox)
.await
.expect("bind-backed volume should be allowed when bind mounts are enabled");
handle.await.expect("stub task should finish");
let _ = fs::remove_file(socket_path);
}
#[tokio::test]
async fn delete_sandbox_cleans_up_volume_when_container_is_already_gone() {
let sandbox_id = "sandbox-123";
let volume_name = container::volume_name(sandbox_id);
let (socket_path, request_log, handle) = spawn_podman_stub(
"delete-not-found",
vec![
// list_containers returns empty (container already gone)
StubResponse::new(StatusCode::OK, "[]"),
// remove_volume
StubResponse::new(StatusCode::NO_CONTENT, ""),
],
);
let driver = test_driver(socket_path.clone());
let deleted = driver
.delete_sandbox(sandbox_id)
.await
.expect("delete should succeed");
assert!(!deleted, "missing container should report deleted=false");
handle.await.expect("stub task should finish");
let requests = request_log
.lock()
.expect("request log lock should not be poisoned")
.clone();
assert!(requests[0].contains("/libpod/containers/json"));
assert_eq!(
requests[1],
format!(
"DELETE {}",
api_path(&format!("/libpod/volumes/{volume_name}"))
)
);
let _ = fs::remove_file(socket_path);
}
/// Write a valid `user:pass` credential to a unique path for proxy-auth
/// secret tests. Caller removes it.
fn write_proxy_auth_file(test_name: &str) -> PathBuf {
let path = crate::test_utils::unique_socket_path(test_name).with_extension("auth");
fs::write(&path, "user:pass\n").expect("write proxy auth file");
path
}
fn proxy_auth_config(socket_path: PathBuf, auth_file: &Path) -> PodmanComputeConfig {
PodmanComputeConfig {
socket_path: Some(socket_path),
stop_timeout_secs: 10,
proxy_auth_file: Some(auth_file.to_string_lossy().into_owned()),
proxy_auth_allow_insecure: Some(true),
..PodmanComputeConfig::default()
}
}
fn plain_sandbox(id: &str, name: &str) -> DriverSandbox {
DriverSandbox {
id: id.to_string(),
name: name.to_string(),
namespace: String::new(),
workspace: String::new(),
spec: None,
status: None,
}
}
fn secret_delete_request(sandbox_id: &str) -> String {
format!(
"DELETE {}",
api_path(&format!(
"/libpod/secrets/{}",
container::proxy_auth_secret_name(sandbox_id)
))
)
}
#[tokio::test]
async fn create_sandbox_removes_proxy_auth_secret_on_container_create_failure() {
// A credential secret is staged before the container is created, so a
// container-create failure must remove it — no credential residue.
let sandbox_id = "sandbox-cc";
let auth_file = write_proxy_auth_file("create-fail");
let (socket_path, request_log, handle) = spawn_podman_stub(
"create-container-fail",
vec![
StubResponse::new(StatusCode::OK, "{}"), // pull supervisor image
StubResponse::new(StatusCode::OK, "{}"), // pull sandbox image
StubResponse::new(
StatusCode::OK,
r#"{"Id":"sha256:sandbox","Config":{"User":"1234:1235"}}"#,
), // inspect sandbox image
StubResponse::new(StatusCode::CREATED, "{}"), // create volume
StubResponse::new(StatusCode::CREATED, "{}"), // create proxy-auth secret
StubResponse::new(StatusCode::INTERNAL_SERVER_ERROR, r#"{"message":"boom"}"#), // create container
StubResponse::new(StatusCode::NO_CONTENT, ""), // cleanup: remove volume
StubResponse::new(StatusCode::NO_CONTENT, ""), // cleanup: remove proxy-auth secret
],
);
let driver = test_driver_with_config(proxy_auth_config(socket_path.clone(), &auth_file));
driver
.create_sandbox(&plain_sandbox(sandbox_id, "demo"))
.await
.expect_err("container create should fail");
handle.await.expect("stub task should finish");
let requests = request_log
.lock()
.expect("request log lock should not be poisoned")
.clone();
assert!(
requests.contains(&secret_delete_request(sandbox_id)),
"proxy-auth secret must be removed on container-create failure: {requests:?}"
);
let _ = fs::remove_file(&auth_file);
let _ = fs::remove_file(socket_path);
}
#[tokio::test]
async fn create_sandbox_removes_proxy_auth_secret_on_start_failure() {
// The container is created but fails to start; the staged credential
// secret must still be removed.
let sandbox_id = "sandbox-sf";
let auth_file = write_proxy_auth_file("start-fail");
let (socket_path, request_log, handle) = spawn_podman_stub(
"create-start-fail",
vec![
StubResponse::new(StatusCode::OK, "{}"), // pull supervisor image
StubResponse::new(StatusCode::OK, "{}"), // pull sandbox image
StubResponse::new(
StatusCode::OK,
r#"{"Id":"sha256:sandbox","Config":{"User":"1234:1235"}}"#,
), // inspect sandbox image
StubResponse::new(StatusCode::CREATED, "{}"), // create volume
StubResponse::new(StatusCode::CREATED, "{}"), // create proxy-auth secret
StubResponse::new(StatusCode::CREATED, "{}"), // create container
StubResponse::new(StatusCode::INTERNAL_SERVER_ERROR, r#"{"message":"boom"}"#), // start container
StubResponse::new(StatusCode::NO_CONTENT, ""), // cleanup: remove container
StubResponse::new(StatusCode::NO_CONTENT, ""), // cleanup: remove volume
StubResponse::new(StatusCode::NO_CONTENT, ""), // cleanup: remove proxy-auth secret
],
);
let driver = test_driver_with_config(proxy_auth_config(socket_path.clone(), &auth_file));
driver
.create_sandbox(&plain_sandbox(sandbox_id, "demo"))
.await
.expect_err("container start should fail");
handle.await.expect("stub task should finish");
let requests = request_log
.lock()
.expect("request log lock should not be poisoned")
.clone();
assert!(
requests.contains(&secret_delete_request(sandbox_id)),
"proxy-auth secret must be removed on start failure: {requests:?}"
);
let _ = fs::remove_file(&auth_file);
let _ = fs::remove_file(socket_path);
}
#[tokio::test]
async fn delete_sandbox_removes_proxy_auth_secret() {
// Deleting a sandbox (here already gone out of band) must remove the
// per-sandbox proxy-auth secret so credentials never outlive it.
let sandbox_id = "sandbox-del";
let (socket_path, request_log, handle) = spawn_podman_stub(
"delete-proxy-auth",
vec![
StubResponse::new(StatusCode::OK, "[]"), // list_containers (not found)
StubResponse::new(StatusCode::NO_CONTENT, ""), // remove volume
StubResponse::new(StatusCode::NO_CONTENT, ""), // remove token secret
StubResponse::new(StatusCode::NO_CONTENT, ""), // remove proxy-auth secret
],
);
let driver = test_driver(socket_path.clone());
driver
.delete_sandbox(sandbox_id)
.await
.expect("delete should succeed");
handle.await.expect("stub task should finish");
let requests = request_log
.lock()
.expect("request log lock should not be poisoned")
.clone();
assert!(
requests.contains(&secret_delete_request(sandbox_id)),
"proxy-auth secret must be removed on delete: {requests:?}"
);
let _ = fs::remove_file(socket_path);
}
#[tokio::test]
async fn delete_sandbox_finds_container_by_label_and_removes() {
let sandbox_id = "sandbox-request-id";
let container_id = "abc123def456";
let container_name = "openshell-default--demo-sandbox-request-id";
let volume_name = container::volume_name(sandbox_id);
let list_body = serde_json::json!([{
"Id": container_id,
"Names": [container_name],
"State": "running",
"Labels": {
LABEL_SANDBOX_ID: sandbox_id
}
}])
.to_string();
let (socket_path, request_log, handle) = spawn_podman_stub(
"delete-label-lookup",
vec![
// list_containers by label
StubResponse::new(StatusCode::OK, list_body),
// single timed remove_container operation
StubResponse::new(StatusCode::NO_CONTENT, ""),
// remove_volume
StubResponse::new(StatusCode::NO_CONTENT, ""),
],
);
let driver = test_driver(socket_path.clone());
let deleted = driver
.delete_sandbox(sandbox_id)
.await
.expect("delete should succeed");
assert!(deleted, "existing container should report deleted=true");
handle.await.expect("stub task should finish");
let requests = request_log
.lock()
.expect("request log lock should not be poisoned")
.clone();
assert!(requests[0].contains("/libpod/containers/json"));
assert_eq!(
requests[1],
format!(
"DELETE {}",
api_path(&format!(
"/libpod/containers/{container_id}?force=true&volumes=true&timeout=10"
))
)
);
assert_eq!(
requests[2],
format!(
"DELETE {}",
api_path(&format!("/libpod/volumes/{volume_name}"))
)
);
let _ = fs::remove_file(socket_path);
}
}