#![cfg(all(target_os = "windows", feature = "wsl"))]
use std::collections::HashMap;
use std::net::IpAddr;
use std::path::PathBuf;
use std::process::{Output, Stdio};
use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use oci_spec::runtime::{
LinuxDevice, LinuxDeviceBuilder, LinuxDeviceType, Mount, MountBuilder, Spec,
};
use serde::Deserialize;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::sync::mpsc;
use tokio::sync::RwLock;
use tokio_stream::wrappers::ReceiverStream;
use zlayer_observability::logs::{LogEntry, LogSource, LogStream};
use zlayer_registry::{CompressionType, ImageConfig};
use zlayer_spec::{GpuSpec, PullPolicy, RegistryAuth, ServiceSpec};
use crate::bundle::BundleBuilder;
use crate::cgroups_stats::ContainerStats;
use crate::error::{AgentError, Result};
use crate::overlay_manager::make_interface_name;
use crate::runtime::{
validate_signal, ContainerId, ContainerInspectDetails, ContainerState, ExecEvent,
ExecEventStream, ImageInfo, OverlayAttachKind, PruneResult, Runtime,
};
const DEFAULT_BUNDLE_ROOT: &str = "/var/lib/zlayer/bundles";
const DEFAULT_LOG_ROOT: &str = "/var/lib/zlayer/logs";
const WAIT_POLL_CAP: Duration = Duration::from_secs(24 * 60 * 60);
const WAIT_POLL_INTERVAL: Duration = Duration::from_millis(500);
const DEFAULT_RUNTIME_BINARY: &str = "/usr/local/bin/zlayer";
const DEFAULT_OCI_STATE_ROOT: &str = "/var/lib/zlayer/oci/state";
#[derive(Clone, Debug)]
pub struct Wsl2DelegateConfig {
pub distro: String,
pub runtime_binary: Option<String>,
pub bundle_root: String,
pub log_root: String,
pub oci_state_root: PathBuf,
}
impl Default for Wsl2DelegateConfig {
fn default() -> Self {
Self {
distro: zlayer_wsl::distro::configured_distro(),
runtime_binary: Some(DEFAULT_RUNTIME_BINARY.to_string()),
bundle_root: DEFAULT_BUNDLE_ROOT.to_string(),
log_root: DEFAULT_LOG_ROOT.to_string(),
oci_state_root: PathBuf::from(DEFAULT_OCI_STATE_ROOT),
}
}
}
#[derive(Clone, Debug)]
struct CachedImage {
layers: Vec<(PathBuf, String)>,
config: ImageConfig,
}
fn wsl2_layer_stage_dir(image: &str) -> PathBuf {
let safe = image.replace([':', '/', '@'], "_");
std::env::temp_dir().join("zlayer-wsl2-layers").join(safe)
}
#[derive(Debug, Clone)]
enum NetnsState {
Created,
Configured {
#[allow(dead_code)]
ip: IpAddr,
},
}
#[async_trait]
pub trait WslRunner: Send + Sync + 'static {
async fn run(&self, cmd: &str, args: &[&str]) -> anyhow::Result<Output>;
}
struct DefaultWslRunner;
#[async_trait]
impl WslRunner for DefaultWslRunner {
async fn run(&self, cmd: &str, args: &[&str]) -> anyhow::Result<Output> {
zlayer_wsl::distro::wsl_exec(cmd, args).await
}
}
pub struct Wsl2DelegateRuntime {
config: ResolvedConfig,
pids: Arc<RwLock<HashMap<ContainerId, u32>>>,
ips: Arc<RwLock<HashMap<ContainerId, IpAddr>>>,
bundle_roots: Arc<RwLock<HashMap<ContainerId, String>>>,
image_cache: Arc<RwLock<HashMap<String, CachedImage>>>,
netns: Arc<RwLock<HashMap<ContainerId, NetnsState>>>,
runner: Arc<dyn WslRunner>,
auth_context: Option<crate::runtime::ContainerAuthContext>,
secrets_provider:
parking_lot::RwLock<Option<std::sync::Arc<dyn zlayer_secrets::SecretsProvider>>>,
}
#[derive(Clone, Debug)]
struct ResolvedConfig {
distro: String,
runtime_binary: String,
bundle_root: String,
log_root: String,
oci_state_root: PathBuf,
}
impl std::fmt::Debug for Wsl2DelegateRuntime {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Wsl2DelegateRuntime")
.field("config", &self.config)
.finish_non_exhaustive()
}
}
impl Wsl2DelegateRuntime {
pub async fn try_new(
auth_context: Option<crate::runtime::ContainerAuthContext>,
) -> Result<Option<Self>> {
Self::try_new_with_config(Wsl2DelegateConfig::default(), auth_context).await
}
#[allow(clippy::too_many_lines)]
pub async fn try_new_with_config(
config: Wsl2DelegateConfig,
auth_context: Option<crate::runtime::ContainerAuthContext>,
) -> Result<Option<Self>> {
let status = match zlayer_wsl::detect::detect_wsl().await {
Ok(s) => s,
Err(e) => {
tracing::warn!(
error = %e,
"WSL2 detection failed; Linux container support disabled"
);
return Ok(None);
}
};
if !status.wsl2_available {
tracing::info!(
wsl_installed = status.wsl_installed,
"WSL2 not available; Linux container support disabled on this node"
);
return Ok(None);
}
if config.distro == zlayer_wsl::distro::DISTRO_NAME {
if let Err(e) = zlayer_wsl::setup::ensure_wsl_backend_ready().await {
tracing::warn!(
error = %e,
"failed to bootstrap WSL2 zlayer distro; Linux container support disabled"
);
return Ok(None);
}
}
let runtime_binary = config
.runtime_binary
.clone()
.unwrap_or_else(|| DEFAULT_RUNTIME_BINARY.to_string());
match wsl_exec_in(&config.distro, &runtime_binary, &["runtime", "--help"]).await {
Ok(out) if out.status.success() => {}
Ok(out) => {
let stderr = String::from_utf8_lossy(&out.stderr);
return Err(AgentError::Configuration(format!(
"zlayer binary at '{runtime_binary}' in distro '{}' does not expose \
the `runtime` subcommand (status {:?}): {}",
config.distro,
out.status.code(),
stderr.trim(),
)));
}
Err(e) => {
return Err(AgentError::Configuration(format!(
"zlayer binary at '{runtime_binary}' in distro '{}' does not expose \
the `runtime` subcommand: {e}",
config.distro,
)));
}
}
if let Err(e) = wsl_exec_in(&config.distro, "mkdir", &["-p", &config.log_root]).await {
tracing::warn!(
distro = %config.distro,
log_root = %config.log_root,
error = %e,
"failed to pre-create youki log root; container_logs may be empty until youki creates it"
);
}
Ok(Some(Self {
config: ResolvedConfig {
distro: config.distro,
runtime_binary,
bundle_root: config.bundle_root,
log_root: config.log_root,
oci_state_root: config.oci_state_root,
},
pids: Arc::new(RwLock::new(HashMap::new())),
ips: Arc::new(RwLock::new(HashMap::new())),
bundle_roots: Arc::new(RwLock::new(HashMap::new())),
image_cache: Arc::new(RwLock::new(HashMap::new())),
netns: Arc::new(RwLock::new(HashMap::new())),
runner: Arc::new(DefaultWslRunner),
auth_context,
secrets_provider: parking_lot::RwLock::new(None),
}))
}
pub async fn record_container_ip(&self, id: &ContainerId, ip: IpAddr) {
self.ips.write().await.insert(id.clone(), ip);
}
fn bundle_dir(&self, id: &ContainerId) -> String {
format!("{}/{}", self.config.bundle_root, id_slug(id))
}
fn log_path(&self, id: &ContainerId) -> String {
format!("{}/{}.youki.log", self.config.log_root, id_slug(id))
}
fn toolchain_overlay_dirs(&self, id: &ContainerId) -> (String, String, String) {
let base = self.bundle_dir(id);
(
format!("{base}/toolchain-upper"),
format!("{base}/toolchain-work"),
format!("{base}/toolchain-merged"),
)
}
async fn unmount_toolchain_overlay(&self, id: &ContainerId) {
let (_, _, tc_merged) = self.toolchain_overlay_dirs(id);
let _ = self.wsl("fusermount", &["-u", &tc_merged]).await;
}
async fn zlayer_runtime(&self, args: &[&str]) -> Result<Output> {
let state_root_owned = self.config.oci_state_root.to_string_lossy().into_owned();
let mut full_args: Vec<&str> = Vec::with_capacity(args.len() + 3);
full_args.push("runtime");
full_args.push("--state-root");
full_args.push(state_root_owned.as_str());
full_args.extend(args.iter().copied());
wsl_exec_in(&self.config.distro, &self.config.runtime_binary, &full_args).await
}
async fn wsl(&self, cmd: &str, args: &[&str]) -> Result<Output> {
wsl_exec_in(&self.config.distro, cmd, args).await
}
async fn wsl_run(&self, cmd: &str, args: &[&str]) -> Result<Output> {
self.runner.run(cmd, args).await.map_err(|e| {
AgentError::Network(format!("wsl.exe -d {} -- {cmd}: {e}", self.config.distro))
})
}
fn wg_iface_name(slug: &str) -> String {
make_interface_name(&[slug], "wg")
}
async fn provision_named_netns(&self, id: &ContainerId, slug: &str) -> Result<()> {
let mk_ns = self.wsl_run("ip", &["netns", "add", slug]).await?;
if !mk_ns.status.success() {
let stderr = String::from_utf8_lossy(&mk_ns.stderr);
if !stderr.to_ascii_lowercase().contains("file exists") {
return Err(AgentError::Network(format!(
"ip netns add {slug} failed (status {:?}): {}",
mk_ns.status.code(),
stderr.trim()
)));
}
}
self.netns
.write()
.await
.insert(id.clone(), NetnsState::Created);
Ok(())
}
async fn distro_default_gateway(&self) -> Result<String> {
let out = self.wsl_run("ip", &["route", "show", "default"]).await?;
if !out.status.success() {
return Err(AgentError::Network(format!(
"ip route show default failed (status {:?}): {}",
out.status.code(),
String::from_utf8_lossy(&out.stderr).trim()
)));
}
let stdout = String::from_utf8_lossy(&out.stdout);
parse_default_gateway(&stdout).ok_or_else(|| {
AgentError::Network(format!(
"could not parse WSL distro default gateway from `ip route show default`: {:?}",
stdout.trim()
))
})
}
#[allow(clippy::too_many_lines)]
async fn push_overlay_config_inner(
&self,
id: &ContainerId,
slug: &str,
wg_iface: &str,
config: &zlayer_types::overlayd::GuestOverlayConfig,
) -> Result<()> {
let gateway = self.distro_default_gateway().await?;
let keyfile = format!("/run/zlayer-wg-{slug}.key");
let write_key = format!(
"umask 077 && printf '%s' {} > {}",
sh_single_quote(&config.private_key),
keyfile
);
self.run_checked("sh", &["-c", &write_key], "write wg private key")
.await?;
self.run_checked(
"ip",
&["link", "add", wg_iface, "type", "wireguard"],
"ip link add wireguard",
)
.await?;
let listen_port = config.listen_port.to_string();
self.run_checked(
"wg",
&[
"set",
wg_iface,
"private-key",
&keyfile,
"listen-port",
&listen_port,
],
"wg set private-key/listen-port",
)
.await?;
let _ = self.wsl_run("rm", &["-f", &keyfile]).await;
for peer in &config.peers {
let endpoint = rewrite_endpoint_host(&peer.endpoint, &gateway);
let keepalive = peer.persistent_keepalive_secs.to_string();
let mut args: Vec<&str> = vec!["set", wg_iface, "peer", &peer.public_key];
if !endpoint.is_empty() {
args.push("endpoint");
args.push(&endpoint);
}
args.push("allowed-ips");
args.push(&peer.allowed_ips);
args.push("persistent-keepalive");
args.push(&keepalive);
self.run_checked("wg", &args, "wg set peer").await?;
}
self.run_checked(
"ip",
&["link", "set", wg_iface, "netns", slug],
"ip link set wg netns",
)
.await?;
let addr = format!("{}/{}", config.overlay_ip, config.prefix_len);
self.run_checked(
"ip",
&[
"netns", "exec", slug, "ip", "addr", "add", &addr, "dev", wg_iface,
],
"ip addr add overlay",
)
.await?;
self.run_checked(
"ip",
&["netns", "exec", slug, "ip", "link", "set", wg_iface, "up"],
"ip link set wg up",
)
.await?;
self.run_checked(
"ip",
&["netns", "exec", slug, "ip", "link", "set", "lo", "up"],
"ip link set lo up",
)
.await?;
self.run_checked(
"ip",
&[
"netns", "exec", slug, "ip", "route", "add", "default", "dev", wg_iface,
],
"ip route add default",
)
.await?;
if let Some(dns) = config.dns_server {
use std::fmt::Write as _;
let mut content = format!("nameserver {dns}\n");
if let Some(domain) = &config.dns_domain {
let _ = writeln!(content, "search {domain}");
}
let etc_dir = format!("{}/rootfs/etc", self.bundle_dir(id));
let write_resolv = format!(
"mkdir -p {} && printf '%s' {} > {}/resolv.conf",
etc_dir,
sh_single_quote(&content),
etc_dir
);
self.run_checked("sh", &["-c", &write_resolv], "write resolv.conf")
.await?;
}
Ok(())
}
async fn run_checked(&self, cmd: &str, args: &[&str], what: &str) -> Result<()> {
let out = self.wsl_run(cmd, args).await?;
if out.status.success() {
Ok(())
} else {
Err(AgentError::Network(format!(
"{what} ({cmd} {}) failed (status {:?}): {}",
args.join(" "),
out.status.code(),
String::from_utf8_lossy(&out.stderr).trim()
)))
}
}
async fn teardown_container_netns(&self, id: &ContainerId) {
let state = self.netns.write().await.remove(id);
if state.is_none() {
return;
}
let slug = id_slug(id);
let wg_iface = Self::wg_iface_name(&slug);
let _ = self.wsl_run("ip", &["link", "delete", &wg_iface]).await;
let _ = self
.wsl_run(
"ip",
&["netns", "exec", &slug, "ip", "link", "delete", &wg_iface],
)
.await;
let _ = self.wsl_run("ip", &["netns", "delete", &slug]).await;
}
async fn query_state(&self, id: &ContainerId) -> Result<YoukiState> {
let slug = id_slug(id);
let output = self.zlayer_runtime(&["state", &slug]).await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.to_ascii_lowercase().contains("does not exist")
|| stderr.to_ascii_lowercase().contains("not found")
{
return Err(AgentError::NotFound {
container: id.to_string(),
reason: format!("zlayer runtime state reports container unknown: {stderr}"),
});
}
return Err(zlayer_runtime_error("state", &output));
}
parse_youki_state(&output)
}
}
#[async_trait]
impl Runtime for Wsl2DelegateRuntime {
fn set_secrets_provider(&self, provider: std::sync::Arc<dyn zlayer_secrets::SecretsProvider>) {
*self.secrets_provider.write() = Some(provider);
}
async fn pull_image(&self, image: &str) -> Result<()> {
self.pull_image_with_policy(
image,
PullPolicy::IfNotPresent,
None,
zlayer_spec::SourcePolicy::default(),
)
.await
}
async fn pull_image_with_policy(
&self,
image: &str,
policy: PullPolicy,
auth: Option<&RegistryAuth>,
_source: zlayer_spec::SourcePolicy,
) -> Result<()> {
if matches!(policy, PullPolicy::IfNotPresent | PullPolicy::Never)
&& self.image_cache.read().await.contains_key(image)
{
if matches!(policy, PullPolicy::Never) {
return Ok(());
}
tracing::debug!(image, "image cache hit; skipping re-pull");
return Ok(());
}
if matches!(policy, PullPolicy::Never) {
return Err(AgentError::PullFailed {
image: image.to_string(),
reason: "PullPolicy::Never but image is not in the WSL2 delegate cache".to_string(),
});
}
let registry_auth = match auth {
Some(a) => zlayer_registry::RegistryAuth::Basic(a.username.clone(), a.password.clone()),
None => {
zlayer_core::AuthResolver::new(zlayer_core::AuthConfig::default()).resolve(image)
}
};
let cache = zlayer_registry::BlobCache::new().map_err(|e| AgentError::PullFailed {
image: image.to_string(),
reason: format!("failed to create blob cache: {e}"),
})?;
let cache_arc: Arc<Box<dyn zlayer_registry::BlobCacheBackend>> = Arc::new(Box::new(cache));
let puller = zlayer_registry::ImagePuller::with_platform(
cache_arc,
zlayer_spec::TargetPlatform::new(
zlayer_spec::OsKind::Linux,
zlayer_spec::ArchKind::Amd64,
),
);
let stage = wsl2_layer_stage_dir(image);
let _ = tokio::fs::remove_dir_all(&stage).await;
let layers = puller
.pull_image_to_files_with_policy(image, ®istry_auth, &stage, policy)
.await
.map_err(|e| AgentError::PullFailed {
image: image.to_string(),
reason: format!("registry pull failed: {e}"),
})?;
let config = puller
.pull_image_config(image, ®istry_auth)
.await
.map_err(|e| AgentError::PullFailed {
image: image.to_string(),
reason: format!("image config fetch failed: {e}"),
})?;
self.image_cache
.write()
.await
.insert(image.to_string(), CachedImage { layers, config });
Ok(())
}
#[allow(clippy::too_many_lines)]
async fn create_container(&self, id: &ContainerId, spec: &ServiceSpec) -> Result<()> {
let bundle_dir = self.bundle_dir(id);
let rootfs_dir = format!("{bundle_dir}/rootfs");
let slug = id_slug(id);
let mkdir = self.wsl("mkdir", &["-p", &rootfs_dir]).await?;
if !mkdir.status.success() {
return Err(AgentError::CreateFailed {
id: id.to_string(),
reason: format!(
"mkdir -p {rootfs_dir} failed (status {:?}): {}",
mkdir.status.code(),
String::from_utf8_lossy(&mkdir.stderr).trim()
),
});
}
let image_name = spec.image.name.to_string();
let cached = if let Some(c) = self.image_cache.read().await.get(&image_name).cloned() {
c
} else {
self.pull_image_with_policy(
&image_name,
spec.image.pull_policy,
None,
spec.image.source_policy.unwrap_or_default(),
)
.await?;
self.image_cache
.read()
.await
.get(&image_name)
.cloned()
.ok_or_else(|| AgentError::CreateFailed {
id: id.to_string(),
reason: format!(
"image {} missing from WSL2 delegate cache after pull",
spec.image.name
),
})?
};
let tar_sh_cmd = format!("cd {rootfs_dir} && tar -xf - --no-same-owner");
for (i, (layer_path, media_type)) in cached.layers.iter().enumerate() {
wsl_extract_layer_file(
&["-d", &self.config.distro, "--", "sh", "-c", &tar_sh_cmd],
layer_path,
media_type,
)
.await
.map_err(|e| AgentError::CreateFailed {
id: id.to_string(),
reason: format!(
"streaming layer {i} ({media_type}) of {} into WSL2 rootfs failed: {e}",
spec.image.name
),
})?;
}
let netns_path: Option<PathBuf> = match &spec.network_mode {
zlayer_spec::NetworkMode::Container { id: target } => {
let target_cid =
ContainerId::parse_display(target).ok_or_else(|| AgentError::CreateFailed {
id: id.to_string(),
reason: format!(
"network target container {target:?} is not a resolvable container id"
),
})?;
let pid = self
.get_container_pid(&target_cid)
.await
.map_err(|e| AgentError::CreateFailed {
id: id.to_string(),
reason: format!(
"network target container {target} not found or not running: {e}"
),
})?
.ok_or_else(|| AgentError::CreateFailed {
id: id.to_string(),
reason: format!(
"network target container {target} is not running (no pid)"
),
})?;
Some(PathBuf::from(format!("/proc/{pid}/ns/net")))
}
zlayer_spec::NetworkMode::Host | zlayer_spec::NetworkMode::None => None,
_ => {
self.provision_named_netns(id, &slug).await.map_err(|e| {
AgentError::CreateFailed {
id: id.to_string(),
reason: format!("failed to provision overlay netns: {e}"),
}
})?;
Some(PathBuf::from(format!("/run/netns/{slug}")))
}
};
let toolchain_wsl: Option<String> = {
let host = zlayer_paths::ZLayerDirs::system_default().toolchain_cache();
if let Some(lower) = zlayer_wsl::paths::windows_to_wsl(&host) {
let _ = self.wsl("mkdir", &["-p", &lower]).await;
let (tc_upper, tc_work, tc_merged) = self.toolchain_overlay_dirs(id);
let _ = self
.wsl("mkdir", &["-p", &tc_upper, &tc_work, &tc_merged])
.await;
let overlay_opt = format!("lowerdir={lower},upperdir={tc_upper},workdir={tc_work}");
match self
.wsl("fuse-overlayfs", &["-o", &overlay_opt, &tc_merged])
.await
{
Ok(out) if out.status.success() => {
tracing::debug!(
container = %id,
lower = %lower,
merged = %tc_merged,
"mounted per-container fuse-overlayfs toolchain view"
);
Some(tc_merged)
}
Ok(out) => {
tracing::warn!(
container = %id,
lower = %lower,
status = ?out.status.code(),
stderr = %String::from_utf8_lossy(&out.stderr).trim(),
"fuse-overlayfs toolchain mount failed; falling back to raw /mnt bind"
);
Some(lower)
}
Err(e) => {
tracing::warn!(
container = %id,
lower = %lower,
error = %e,
"fuse-overlayfs unavailable in distro; falling back to raw /mnt bind"
);
Some(lower)
}
}
} else {
None
}
};
let mut builder = BundleBuilder::new(PathBuf::from(&bundle_dir))
.with_image_config(cached.config.clone())
.with_hostname(slug.clone())
.with_host_network(spec.host_network)
.with_netns_path(netns_path);
if let Some(tc) = toolchain_wsl {
builder = builder.with_toolchain_cache(PathBuf::from(tc));
}
if let (Some(provider), Some(scope)) = (
self.secrets_provider.read().clone(),
spec.secret_scope.clone(),
) {
builder = builder
.with_secrets_provider(provider)
.with_deployment_scope(scope);
}
let mut oci_spec = builder
.build_spec_only(id, spec, &HashMap::new())
.await
.map_err(|e| AgentError::CreateFailed {
id: id.to_string(),
reason: format!("failed to build OCI spec on Windows host: {e}"),
})?;
let gpu_probe = DefaultWslGpuHostProbe { runtime: self };
apply_wsl_gpu_to_spec(&mut oci_spec, spec, &gpu_probe).await?;
if let Some(ref auth_ctx) = self.auth_context {
let deployment = spec.deployment.as_deref().unwrap_or(&id.service);
let access = crate::auth::resolve_container_api_access(deployment, &spec.labels);
let container_id = format!("{}-{}", id.service, id.replica);
let jti = format!("container:{}:{}", id.service, container_id);
let token_jti = if let Some(sink) = auth_ctx.token_sink.as_ref() {
let now = chrono::Utc::now();
let rec = zlayer_types::storage::StoredAccessToken {
id: jti.clone(),
name: id.service.clone(),
subject: jti.clone(),
roles: Vec::new(),
scopes: access.scopes.clone(),
expires_at: now
+ chrono::Duration::seconds(
i64::try_from(access.ttl.as_secs()).unwrap_or(i64::MAX),
),
created_at: now,
created_by: deployment.to_string(),
revoked_at: None,
};
if sink.persist(rec).await {
Some(jti)
} else {
None
}
} else {
None
};
let token = crate::auth::mint_container_token(
&auth_ctx.jwt_secret,
&id.service,
&container_id,
access.scopes,
access.ttl,
token_jti,
)
.map_err(|e| AgentError::CreateFailed {
id: id.to_string(),
reason: format!("Failed to mint container token: {e}"),
})?;
let mut env = oci_spec
.process()
.as_ref()
.and_then(|p| p.env().clone())
.unwrap_or_default();
env.push(format!("ZLAYER_API_URL={}", auth_ctx.api_url));
env.push(format!("ZLAYER_TOKEN={token}"));
if access.mount_socket {
let socket_dest = zlayer_paths::ZLayerDirs::default_socket_path();
env.push(format!("ZLAYER_SOCKET={socket_dest}"));
}
if let Some(process) = oci_spec.process_mut().as_mut() {
process.set_env(Some(env));
}
if access.mount_socket {
let socket_dest = zlayer_paths::ZLayerDirs::default_socket_path();
let mut mounts = oci_spec.mounts().clone().unwrap_or_default();
let socket_mount = MountBuilder::default()
.destination(PathBuf::from(&socket_dest))
.typ("bind".to_string())
.source(PathBuf::from(&auth_ctx.socket_path))
.options(vec!["rbind".to_string(), "rw".to_string()])
.build()
.map_err(|e| AgentError::CreateFailed {
id: id.to_string(),
reason: format!("failed to build daemon socket bind mount: {e}"),
})?;
mounts.push(socket_mount);
oci_spec.set_mounts(Some(mounts));
}
}
let config_json =
serde_json::to_string_pretty(&oci_spec).map_err(|e| AgentError::CreateFailed {
id: id.to_string(),
reason: format!("failed to serialize OCI spec to JSON: {e}"),
})?;
let config_path = format!("{bundle_dir}/config.json");
let tee_sh_cmd = format!("tee {config_path} > /dev/null");
wsl_stdin_pipe(
&["-d", &self.config.distro, "--", "sh", "-c", &tee_sh_cmd],
config_json.as_bytes(),
)
.await
.map_err(|e| AgentError::CreateFailed {
id: id.to_string(),
reason: format!("failed to write config.json into WSL2 bundle: {e}"),
})?;
let log_path = self.log_path(id);
let create = self
.zlayer_runtime(&["create", &slug, "--bundle", &bundle_dir, "--log", &log_path])
.await?;
if !create.status.success() {
let stderr = String::from_utf8_lossy(&create.stderr).trim().to_string();
self.unmount_toolchain_overlay(id).await;
let _ = self.wsl("rm", &["-rf", &bundle_dir]).await;
self.teardown_container_netns(id).await;
return Err(AgentError::CreateFailed {
id: id.to_string(),
reason: format!(
"zlayer runtime create failed (status {:?}): {stderr}",
create.status.code(),
),
});
}
self.bundle_roots
.write()
.await
.insert(id.clone(), bundle_dir);
Ok(())
}
async fn start_container(&self, id: &ContainerId) -> Result<()> {
let slug = id_slug(id);
let output = self.zlayer_runtime(&["start", &slug]).await?;
if !output.status.success() {
self.teardown_container_netns(id).await;
return Err(AgentError::StartFailed {
id: id.to_string(),
reason: format!(
"zlayer runtime start failed (status {:?}): {}",
output.status.code(),
String::from_utf8_lossy(&output.stderr).trim()
),
});
}
if let Ok(state) = self.query_state(id).await {
if let Some(pid) = state.pid {
self.pids.write().await.insert(id.clone(), pid);
}
}
Ok(())
}
async fn stop_container(&self, id: &ContainerId, timeout: Duration) -> Result<()> {
let slug = id_slug(id);
let term = self
.zlayer_runtime(&["kill", "--all", &slug, "SIGTERM"])
.await?;
if !term.status.success() {
let stderr = String::from_utf8_lossy(&term.stderr).to_ascii_lowercase();
if !(stderr.contains("stopped") || stderr.contains("not running")) {
return Err(zlayer_runtime_error("kill", &term));
}
}
let deadline = tokio::time::Instant::now() + timeout;
loop {
match self.query_state(id).await {
Ok(state) if state.is_stopped() => return Ok(()),
Ok(_) => {}
Err(AgentError::NotFound { .. }) => return Ok(()),
Err(e) => return Err(e),
}
if tokio::time::Instant::now() >= deadline {
break;
}
tokio::time::sleep(WAIT_POLL_INTERVAL).await;
}
let kill = self
.zlayer_runtime(&["kill", "--all", &slug, "SIGKILL"])
.await?;
if !kill.status.success() {
let stderr = String::from_utf8_lossy(&kill.stderr).to_ascii_lowercase();
if !(stderr.contains("stopped") || stderr.contains("not running")) {
return Err(zlayer_runtime_error("kill", &kill));
}
}
Ok(())
}
async fn remove_container(&self, id: &ContainerId) -> Result<()> {
let slug = id_slug(id);
let output = self.zlayer_runtime(&["delete", &slug]).await?;
if let Some(auth_ctx) = self.auth_context.as_ref() {
if let Some(sink) = auth_ctx.token_sink.as_ref() {
let container_id = format!("{}-{}", id.service, id.replica);
let jti = format!("container:{}:{}", id.service, container_id);
sink.revoke(&jti).await;
}
}
self.teardown_container_netns(id).await;
self.pids.write().await.remove(id);
self.ips.write().await.remove(id);
self.unmount_toolchain_overlay(id).await;
if let Some(bundle_dir) = self.bundle_roots.write().await.remove(id) {
if let Err(e) = self.wsl("rm", &["-rf", &bundle_dir]).await {
tracing::debug!(
container = %id,
bundle_dir = %bundle_dir,
error = %e,
"failed to remove WSL2 bundle dir; leaving for GC"
);
}
}
let log_path = self.log_path(id);
if let Err(e) = self.wsl("rm", &["-f", &log_path]).await {
tracing::debug!(
container = %id,
log_path = %log_path,
error = %e,
"failed to remove youki log file; leaving for GC"
);
}
if output.status.success() {
Ok(())
} else {
let stderr = String::from_utf8_lossy(&output.stderr).to_ascii_lowercase();
if stderr.contains("does not exist") || stderr.contains("not found") {
return Ok(());
}
Err(zlayer_runtime_error("delete", &output))
}
}
async fn container_state(&self, id: &ContainerId) -> Result<ContainerState> {
let state = self.query_state(id).await?;
Ok(state.as_container_state())
}
async fn container_logs(&self, id: &ContainerId, tail: usize) -> Result<Vec<LogEntry>> {
let log_path = self.log_path(id);
let tail_str = tail.to_string();
let output = self.wsl("tail", &["-n", &tail_str, &log_path]).await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).to_ascii_lowercase();
if stderr.contains("no such file") || stderr.contains("cannot open") {
return Ok(Vec::new());
}
return Err(zlayer_runtime_error("tail", &output));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let now = chrono::Utc::now();
let source = LogSource::Container(id.to_string());
let service = Some(id.service.clone());
let entries = stdout
.lines()
.filter(|l| !l.is_empty())
.map(|line| LogEntry {
timestamp: now,
stream: LogStream::Stdout,
message: line.to_string(),
source: source.clone(),
service: service.clone(),
deployment: None,
})
.collect();
Ok(entries)
}
async fn exec(&self, id: &ContainerId, cmd: &[String]) -> Result<(i32, String, String)> {
if cmd.is_empty() {
return Err(AgentError::InvalidSpec(
"exec command must not be empty".to_string(),
));
}
let slug = id_slug(id);
let mut args: Vec<&str> = vec!["exec", &slug, "--"];
args.extend(cmd.iter().map(String::as_str));
let output = self.zlayer_runtime(&args).await?;
let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
let exit = output.status.code().unwrap_or(-1);
Ok((exit, stdout, stderr))
}
async fn get_container_stats(&self, id: &ContainerId) -> Result<ContainerStats> {
let slug = id_slug(id);
let output = self.zlayer_runtime(&["events", &slug, "--stats"]).await;
let now = std::time::Instant::now();
match output {
Ok(out) if out.status.success() => Ok(parse_youki_stats(&out, now)),
Ok(out) => Err(zlayer_runtime_error("events", &out)),
Err(e) => Err(e),
}
}
async fn wait_container(&self, id: &ContainerId) -> Result<i32> {
let start = tokio::time::Instant::now();
loop {
match self.query_state(id).await {
Ok(state) if state.is_stopped() => {
return Ok(state.exit_code.unwrap_or(0));
}
Ok(_) => {}
Err(e) => return Err(e),
}
if start.elapsed() >= WAIT_POLL_CAP {
return Err(AgentError::Timeout {
timeout: WAIT_POLL_CAP,
});
}
tokio::time::sleep(WAIT_POLL_INTERVAL).await;
}
}
async fn get_logs(&self, id: &ContainerId) -> Result<Vec<LogEntry>> {
self.container_logs(id, usize::MAX).await
}
async fn get_container_pid(&self, id: &ContainerId) -> Result<Option<u32>> {
if let Some(pid) = self.pids.read().await.get(id).copied() {
return Ok(Some(pid));
}
match self.query_state(id).await {
Ok(state) => {
if let Some(pid) = state.pid {
self.pids.write().await.insert(id.clone(), pid);
Ok(Some(pid))
} else {
Ok(None)
}
}
Err(AgentError::NotFound { .. }) => Ok(None),
Err(e) => Err(e),
}
}
async fn get_container_ip(&self, id: &ContainerId) -> Result<Option<IpAddr>> {
Ok(self.ips.read().await.get(id).copied())
}
fn overlay_attach_kind(&self) -> OverlayAttachKind {
OverlayAttachKind::GuestManaged
}
async fn push_overlay_config(
&self,
id: &ContainerId,
config: &zlayer_types::overlayd::GuestOverlayConfig,
) -> Result<()> {
let slug = id_slug(id);
let wg_iface = Self::wg_iface_name(&slug);
match self
.push_overlay_config_inner(id, &slug, &wg_iface, config)
.await
{
Ok(()) => {
self.ips.write().await.insert(id.clone(), config.overlay_ip);
self.netns.write().await.insert(
id.clone(),
NetnsState::Configured {
ip: config.overlay_ip,
},
);
tracing::info!(
container = %id,
overlay_ip = %config.overlay_ip,
wg_iface = %wg_iface,
"configured WSL2 guest overlay WireGuard device"
);
Ok(())
}
Err(e) => {
let _ = self.wsl_run("ip", &["link", "delete", &wg_iface]).await;
let _ = self
.wsl_run(
"ip",
&["netns", "exec", &slug, "ip", "link", "delete", &wg_iface],
)
.await;
Err(e)
}
}
}
async fn list_images(&self) -> Result<Vec<ImageInfo>> {
Err(AgentError::Unsupported(
"list_images is not supported by the WSL2 delegate runtime \
(youki has no image registry; images are managed on the host)"
.to_string(),
))
}
async fn remove_image(&self, _image: &str, _force: bool) -> Result<()> {
Err(AgentError::Unsupported(
"remove_image is not supported by the WSL2 delegate runtime".to_string(),
))
}
async fn prune_images(&self) -> Result<PruneResult> {
Err(AgentError::Unsupported(
"prune_images is not supported by the WSL2 delegate runtime".to_string(),
))
}
async fn kill_container(&self, id: &ContainerId, signal: Option<&str>) -> Result<()> {
let canonical = validate_signal(signal.unwrap_or("SIGKILL"))?;
let slug = id_slug(id);
let output = self.zlayer_runtime(&["kill", &slug, &canonical]).await?;
if output.status.success() {
Ok(())
} else {
Err(zlayer_runtime_error("kill", &output))
}
}
async fn tag_image(&self, _source: &str, _target: &str) -> Result<()> {
Err(AgentError::Unsupported(
"tag_image is not supported by the WSL2 delegate runtime".to_string(),
))
}
async fn inspect_detailed(&self, id: &ContainerId) -> Result<ContainerInspectDetails> {
let exit_code = match self.query_state(id).await {
Ok(state) => state.exit_code,
Err(e @ AgentError::NotFound { .. }) => return Err(e),
Err(_) => None,
};
let ipv4 = self.get_container_ip(id).await?.map(|ip| ip.to_string());
Ok(ContainerInspectDetails {
ports: Vec::new(),
networks: Vec::new(),
ipv4,
health: None,
exit_code,
})
}
async fn exec_stream(&self, id: &ContainerId, cmd: &[String]) -> Result<ExecEventStream> {
if cmd.is_empty() {
return Err(AgentError::InvalidSpec(
"exec command must not be empty".to_string(),
));
}
let slug = id_slug(id);
let state_root = self.config.oci_state_root.to_string_lossy().into_owned();
let mut argv: Vec<String> = Vec::with_capacity(10 + cmd.len());
argv.push("-d".to_string());
argv.push(self.config.distro.clone());
argv.push("--".to_string());
argv.push(self.config.runtime_binary.clone());
argv.push("runtime".to_string());
argv.push("--state-root".to_string());
argv.push(state_root);
argv.push("exec".to_string());
argv.push(slug);
argv.push("--".to_string());
argv.extend(cmd.iter().cloned());
let mut child = tokio::process::Command::new("wsl.exe")
.args(&argv)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| AgentError::Network(format!("wsl.exe spawn for exec_stream: {e}")))?;
let stdout = child.stdout.take().ok_or_else(|| {
AgentError::Internal("wsl.exe child did not expose a stdout handle".to_string())
})?;
let stderr = child.stderr.take().ok_or_else(|| {
AgentError::Internal("wsl.exe child did not expose a stderr handle".to_string())
})?;
Ok(spawn_exec_event_stream(child, stdout, stderr))
}
}
async fn wsl_exec_in(distro: &str, cmd: &str, args: &[&str]) -> Result<Output> {
let mut wsl_args: Vec<&str> = vec!["-d", distro, "--", cmd];
wsl_args.extend_from_slice(args);
tokio::process::Command::new("wsl.exe")
.args(&wsl_args)
.output()
.await
.map_err(|e| AgentError::Network(format!("wsl.exe -d {distro} -- {cmd}: {e}")))
}
fn spawn_exec_event_stream(
mut child: tokio::process::Child,
stdout: tokio::process::ChildStdout,
stderr: tokio::process::ChildStderr,
) -> ExecEventStream {
let (tx, rx) = mpsc::channel::<ExecEvent>(128);
let tx_stdout = tx.clone();
let stdout_task = tokio::spawn(async move {
let mut reader = BufReader::new(stdout).lines();
loop {
match reader.next_line().await {
Ok(Some(line)) => {
if tx_stdout.send(ExecEvent::Stdout(line)).await.is_err() {
break;
}
}
Ok(None) => break,
Err(e) => {
tracing::warn!(error = %e, "exec_stream: stdout read error");
break;
}
}
}
});
let tx_stderr = tx.clone();
let stderr_task = tokio::spawn(async move {
let mut reader = BufReader::new(stderr).lines();
loop {
match reader.next_line().await {
Ok(Some(line)) => {
if tx_stderr.send(ExecEvent::Stderr(line)).await.is_err() {
break;
}
}
Ok(None) => break,
Err(e) => {
tracing::warn!(error = %e, "exec_stream: stderr read error");
break;
}
}
}
});
tokio::spawn(async move {
let _ = stdout_task.await;
let _ = stderr_task.await;
let exit_code = match child.wait().await {
Ok(status) => status.code().unwrap_or(-1),
Err(e) => {
tracing::warn!(
error = %e,
"wsl.exe exec_stream child wait failed; reporting exit -1"
);
-1
}
};
let _ = tx.send(ExecEvent::Exit(exit_code)).await;
});
Box::pin(ReceiverStream::new(rx))
}
async fn wsl_stdin_pipe(args: &[&str], stdin_bytes: &[u8]) -> std::io::Result<()> {
let mut child = tokio::process::Command::new("wsl.exe")
.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
if let Some(mut stdin) = child.stdin.take() {
stdin.write_all(stdin_bytes).await?;
stdin.shutdown().await?;
} else {
return Err(std::io::Error::other(
"wsl.exe child did not expose a stdin handle",
));
}
let output = child.wait_with_output().await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(std::io::Error::other(format!(
"wsl.exe exited with {:?}: {}",
output.status.code(),
stderr.trim()
)));
}
Ok(())
}
async fn wsl_extract_layer_file(
args: &[&str],
layer_path: &std::path::Path,
media_type: &str,
) -> std::io::Result<()> {
use std::io::{BufRead as _, Read as _};
let mut child = tokio::process::Command::new("wsl.exe")
.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
let mut stdin = child
.stdin
.take()
.ok_or_else(|| std::io::Error::other("wsl.exe child did not expose a stdin handle"))?;
let file = std::fs::File::open(layer_path)?;
let mut buffered = std::io::BufReader::new(file);
let compression = match CompressionType::from_media_type(media_type) {
Some(c) => c,
None => CompressionType::from_magic_bytes(buffered.fill_buf()?),
};
let mut reader: Box<dyn std::io::Read + Send> = match compression {
CompressionType::None => Box::new(buffered),
CompressionType::Gzip => Box::new(flate2::read::GzDecoder::new(buffered)),
CompressionType::Zstd => Box::new(zstd::stream::Decoder::new(buffered)?),
};
let mut buf = vec![0u8; 1024 * 1024];
loop {
let n = reader.read(&mut buf)?;
if n == 0 {
break;
}
stdin.write_all(&buf[..n]).await?;
}
stdin.shutdown().await?;
drop(stdin);
let output = child.wait_with_output().await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(std::io::Error::other(format!(
"wsl.exe exited with {:?}: {}",
output.status.code(),
stderr.trim()
)));
}
Ok(())
}
fn zlayer_runtime_error(subcommand: &str, output: &Output) -> AgentError {
let status = output.status.code();
let stderr = String::from_utf8_lossy(&output.stderr);
AgentError::Network(format!(
"zlayer runtime {subcommand} failed (status {status:?}): {}",
stderr.trim()
))
}
fn id_slug(id: &ContainerId) -> String {
let raw = id.to_string();
raw.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
c
} else {
'-'
}
})
.collect()
}
fn parse_default_gateway(route_output: &str) -> Option<String> {
for line in route_output.lines() {
let mut prev = "";
for tok in line.split_whitespace() {
if prev == "via" {
return Some(tok.to_string());
}
prev = tok;
}
}
None
}
fn rewrite_endpoint_host(endpoint: &str, gateway: &str) -> String {
if endpoint.is_empty() {
return String::new();
}
match endpoint.rsplit_once(':') {
Some((_host, port)) => format!("{gateway}:{port}"),
None => endpoint.to_string(),
}
}
fn sh_single_quote(s: &str) -> String {
format!("'{}'", s.replace('\'', "'\\''"))
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
struct YoukiState {
status: String,
#[serde(default)]
pid: Option<u32>,
#[serde(default)]
exit_code: Option<i32>,
}
impl YoukiState {
fn is_stopped(&self) -> bool {
self.status.eq_ignore_ascii_case("stopped")
}
fn as_container_state(&self) -> ContainerState {
match self.status.to_ascii_lowercase().as_str() {
"creating" => ContainerState::Pending,
"created" => ContainerState::Initializing,
"running" => ContainerState::Running,
"stopped" => ContainerState::Exited {
code: self.exit_code.unwrap_or(0),
},
other => ContainerState::Failed {
reason: format!("unknown youki state: {other}"),
},
}
}
}
fn parse_youki_state(output: &Output) -> Result<YoukiState> {
let stdout = std::str::from_utf8(&output.stdout).map_err(|e| {
AgentError::Internal(format!("zlayer runtime state: stdout not utf-8: {e}"))
})?;
serde_json::from_str::<YoukiState>(stdout.trim()).map_err(|e| {
AgentError::Internal(format!(
"zlayer runtime state: failed to parse JSON: {e} (raw: {:?})",
stdout.chars().take(256).collect::<String>()
))
})
}
fn parse_youki_stats(output: &Output, timestamp: std::time::Instant) -> ContainerStats {
let raw = String::from_utf8_lossy(&output.stdout);
let v: serde_json::Value = match serde_json::from_str(raw.trim()) {
Ok(v) => v,
Err(_) => {
return ContainerStats {
cpu_usage_usec: 0,
memory_bytes: 0,
memory_limit: u64::MAX,
timestamp,
};
}
};
let cpu_ns = v
.pointer("/cpu/usage/total")
.and_then(serde_json::Value::as_u64)
.unwrap_or(0);
let cpu_usage_usec = cpu_ns / 1_000;
let memory_bytes = v
.pointer("/memory/usage/usage")
.and_then(serde_json::Value::as_u64)
.unwrap_or(0);
let memory_limit = v
.pointer("/memory/usage/limit")
.and_then(serde_json::Value::as_u64)
.unwrap_or(u64::MAX);
ContainerStats {
cpu_usage_usec,
memory_bytes,
memory_limit,
timestamp,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct WslGpuHostState {
pub dxg_devno: Option<(i64, i64)>,
pub wsl_lib_present: bool,
pub wsl_drivers_present: bool,
}
#[async_trait]
pub(crate) trait WslGpuHostProbe: Send + Sync {
async fn probe(&self) -> Result<WslGpuHostState>;
}
struct DefaultWslGpuHostProbe<'a> {
runtime: &'a Wsl2DelegateRuntime,
}
#[async_trait]
impl WslGpuHostProbe for DefaultWslGpuHostProbe<'_> {
async fn probe(&self) -> Result<WslGpuHostState> {
let dxg_devno = match self.runtime.wsl("stat", &["-c", "%t %T", "/dev/dxg"]).await {
Ok(output) if output.status.success() => {
let raw = String::from_utf8_lossy(&output.stdout);
parse_stat_hex_devno(raw.trim())
}
_ => None,
};
let wsl_lib_present = self
.runtime
.wsl("test", &["-d", "/usr/lib/wsl"])
.await
.map(|o| o.status.success())
.unwrap_or(false);
let wsl_drivers_present = self
.runtime
.wsl("test", &["-d", "/usr/lib/wsl/drivers"])
.await
.map(|o| o.status.success())
.unwrap_or(false);
Ok(WslGpuHostState {
dxg_devno,
wsl_lib_present,
wsl_drivers_present,
})
}
}
fn parse_stat_hex_devno(s: &str) -> Option<(i64, i64)> {
let mut parts = s.split_ascii_whitespace();
let major = i64::from_str_radix(parts.next()?, 16).ok()?;
let minor = i64::from_str_radix(parts.next()?, 16).ok()?;
Some((major, minor))
}
pub(crate) fn inject_wsl_gpu_mounts(
mounts: &mut Vec<Mount>,
env: &mut Vec<String>,
devices: &mut Vec<LinuxDevice>,
_gpu_spec: &GpuSpec,
host: &WslGpuHostState,
) -> Result<()> {
let (major, minor) = host
.dxg_devno
.ok_or_else(|| AgentError::WslGpuUnavailable {
reason: "/dev/dxg is not exposed by the WSL2 kernel inside the configured distro; \
enable WSL2 GPU support (Windows 11 + a recent WSL kernel) or drop \
`resources.gpu` from the service spec"
.to_string(),
})?;
let has_dxg_mount = mounts
.iter()
.any(|m| m.destination().as_path() == std::path::Path::new("/dev/dxg"));
if !has_dxg_mount {
let mount = MountBuilder::default()
.destination("/dev/dxg".to_string())
.source("/dev/dxg".to_string())
.typ("bind".to_string())
.options(vec!["bind".to_string(), "rw".to_string()])
.build()
.map_err(|e| AgentError::WslGpuUnavailable {
reason: format!("failed to build /dev/dxg bind mount: {e}"),
})?;
mounts.push(mount);
}
let has_dxg_device = devices
.iter()
.any(|d| d.path().as_path() == std::path::Path::new("/dev/dxg"));
if !has_dxg_device {
let device = LinuxDeviceBuilder::default()
.path("/dev/dxg")
.typ(LinuxDeviceType::C)
.major(major)
.minor(minor)
.file_mode(0o666u32)
.uid(0u32)
.gid(0u32)
.build()
.map_err(|e| AgentError::WslGpuUnavailable {
reason: format!("failed to build /dev/dxg device node: {e}"),
})?;
devices.push(device);
}
if host.wsl_lib_present {
let has_wsl_lib_mount = mounts
.iter()
.any(|m| m.destination().as_path() == std::path::Path::new("/usr/lib/wsl"));
if !has_wsl_lib_mount {
let mount = MountBuilder::default()
.destination("/usr/lib/wsl".to_string())
.source("/usr/lib/wsl".to_string())
.typ("bind".to_string())
.options(vec!["bind".to_string(), "ro".to_string()])
.build()
.map_err(|e| AgentError::WslGpuUnavailable {
reason: format!("failed to build /usr/lib/wsl bind mount: {e}"),
})?;
mounts.push(mount);
}
} else {
tracing::warn!(
"WSL2 GPU: /usr/lib/wsl missing on host; container will see /dev/dxg but no \
WSLg shim libraries (libdxcore.so etc.). GPU workloads will fail at dlopen."
);
}
let mut new_prefix: Vec<&str> = Vec::new();
if host.wsl_drivers_present {
new_prefix.push("/usr/lib/wsl/drivers");
}
if host.wsl_lib_present {
new_prefix.push("/usr/lib/wsl/lib");
}
if !new_prefix.is_empty() {
let prefix_joined = new_prefix.join(":");
if let Some(entry) = env.iter_mut().find(|e| e.starts_with("LD_LIBRARY_PATH=")) {
let existing = entry.split_once('=').map_or("", |(_, v)| v).to_string();
*entry = if existing.is_empty() {
format!("LD_LIBRARY_PATH={prefix_joined}")
} else {
format!("LD_LIBRARY_PATH={prefix_joined}:{existing}")
};
} else {
env.push(format!("LD_LIBRARY_PATH={prefix_joined}"));
}
}
Ok(())
}
async fn apply_wsl_gpu_to_spec(
oci_spec: &mut Spec,
service: &ServiceSpec,
probe: &dyn WslGpuHostProbe,
) -> Result<()> {
let Some(gpu_spec) = service.resources.gpu.as_ref() else {
return Ok(());
};
let host = probe.probe().await?;
let mut mounts = oci_spec.mounts().clone().unwrap_or_default();
let mut env = oci_spec
.process()
.as_ref()
.and_then(|p| p.env().clone())
.unwrap_or_default();
let mut devices = oci_spec
.linux()
.as_ref()
.and_then(|l| l.devices().clone())
.unwrap_or_default();
inject_wsl_gpu_mounts(&mut mounts, &mut env, &mut devices, gpu_spec, &host)?;
oci_spec.set_mounts(Some(mounts));
if let Some(process) = oci_spec.process_mut().as_mut() {
process.set_env(Some(env));
}
if let Some(linux) = oci_spec.linux_mut().as_mut() {
linux.set_devices(Some(devices));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::net::Ipv4Addr;
#[cfg(not(target_os = "windows"))]
use std::os::unix::process::ExitStatusExt;
#[cfg(target_os = "windows")]
use std::os::windows::process::ExitStatusExt;
use std::process::ExitStatus;
use std::sync::Mutex as StdMutex;
fn cid(service: &str, replica: u32) -> ContainerId {
ContainerId::new(service, replica)
}
#[cfg(target_os = "windows")]
fn make_exit_status(code: i32) -> ExitStatus {
#[allow(clippy::cast_sign_loss)]
let raw = code as u32;
ExitStatus::from_raw(raw)
}
#[cfg(not(target_os = "windows"))]
fn make_exit_status(code: i32) -> ExitStatus {
ExitStatus::from_raw(code << 8)
}
fn fake_output(stdout: &str, code: i32) -> Output {
Output {
status: make_exit_status(code),
stdout: stdout.as_bytes().to_vec(),
stderr: Vec::new(),
}
}
fn fake_output_err(stderr: &str, code: i32) -> Output {
Output {
status: make_exit_status(code),
stdout: Vec::new(),
stderr: stderr.as_bytes().to_vec(),
}
}
type CallLog = Arc<StdMutex<Vec<(String, Vec<String>)>>>;
struct RecordingRunner {
calls: CallLog,
responses: StdMutex<HashMap<String, Output>>,
}
impl RecordingRunner {
fn new() -> Self {
Self {
calls: Arc::new(StdMutex::new(Vec::new())),
responses: StdMutex::new(HashMap::new()),
}
}
fn calls_handle(&self) -> CallLog {
Arc::clone(&self.calls)
}
fn key(cmd: &str, args: &[&str]) -> String {
let mut k = String::from(cmd);
for a in args {
k.push(' ');
k.push_str(a);
}
k
}
fn set_response(&self, cmd: &str, args: &[&str], output: Output) {
self.responses
.lock()
.expect("responses mutex poisoned")
.insert(Self::key(cmd, args), output);
}
}
#[async_trait]
impl WslRunner for RecordingRunner {
async fn run(&self, cmd: &str, args: &[&str]) -> anyhow::Result<Output> {
self.calls.lock().expect("calls mutex poisoned").push((
cmd.to_string(),
args.iter().map(|s| (*s).to_string()).collect(),
));
let key = Self::key(cmd, args);
if let Some(out) = self
.responses
.lock()
.expect("responses mutex poisoned")
.remove(&key)
{
Ok(out)
} else {
Ok(fake_output("", 0))
}
}
}
fn default_resolved_config() -> ResolvedConfig {
ResolvedConfig {
distro: zlayer_wsl::distro::DISTRO_NAME.to_string(),
runtime_binary: DEFAULT_RUNTIME_BINARY.to_string(),
bundle_root: DEFAULT_BUNDLE_ROOT.to_string(),
log_root: DEFAULT_LOG_ROOT.to_string(),
oci_state_root: PathBuf::from(DEFAULT_OCI_STATE_ROOT),
}
}
fn test_runtime_with_runner(
config: ResolvedConfig,
runner: Arc<dyn WslRunner>,
) -> Wsl2DelegateRuntime {
Wsl2DelegateRuntime {
config,
pids: Arc::new(RwLock::new(HashMap::new())),
ips: Arc::new(RwLock::new(HashMap::new())),
bundle_roots: Arc::new(RwLock::new(HashMap::new())),
image_cache: Arc::new(RwLock::new(HashMap::new())),
netns: Arc::new(RwLock::new(HashMap::new())),
runner,
auth_context: None,
secrets_provider: parking_lot::RwLock::new(None),
}
}
fn test_runtime(config: ResolvedConfig) -> Wsl2DelegateRuntime {
test_runtime_with_runner(config, Arc::new(DefaultWslRunner))
}
fn make_runtime(runner: Arc<dyn WslRunner>) -> Wsl2DelegateRuntime {
test_runtime_with_runner(default_resolved_config(), runner)
}
#[test]
fn id_slug_sanitizes_special_chars() {
assert_eq!(id_slug(&cid("web", 0)), "web-rep-0");
let weird = cid("my svc/with:quotes", 3);
let slug = id_slug(&weird);
assert!(
slug.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'),
"slug must be alnum/dash/underscore only: {slug}"
);
assert!(
slug.contains("my-svc"),
"slug should preserve alnum: {slug}"
);
assert!(slug.ends_with("-rep-3"), "slug should keep replica: {slug}");
}
#[test]
fn parse_youki_state_running() {
let raw = r#"{"ociVersion":"1.0.2","id":"web-rep-0","status":"running","pid":12345,"bundle":"/var/lib/zlayer/bundles/web-rep-0"}"#;
let out = fake_output(raw, 0);
let state = parse_youki_state(&out).expect("valid state JSON");
assert_eq!(state.status, "running");
assert_eq!(state.pid, Some(12345));
assert_eq!(state.exit_code, None);
assert!(!state.is_stopped());
assert_eq!(state.as_container_state(), ContainerState::Running);
}
#[test]
fn parse_youki_state_stopped() {
let raw = r#"{"ociVersion":"1.0.2","id":"job-rep-0","status":"stopped","exitCode":42}"#;
let out = fake_output(raw, 0);
let state = parse_youki_state(&out).expect("valid state JSON");
assert_eq!(state.status, "stopped");
assert_eq!(state.exit_code, Some(42));
assert!(state.is_stopped());
assert_eq!(
state.as_container_state(),
ContainerState::Exited { code: 42 }
);
}
#[test]
fn parse_youki_state_creating_maps_to_pending() {
let raw = r#"{"ociVersion":"1.0.2","id":"x","status":"creating"}"#;
let out = fake_output(raw, 0);
let state = parse_youki_state(&out).expect("valid state JSON");
assert_eq!(state.as_container_state(), ContainerState::Pending);
}
#[test]
fn parse_youki_state_unknown_maps_to_failed() {
let raw = r#"{"ociVersion":"1.0.2","id":"x","status":"paused"}"#;
let out = fake_output(raw, 0);
let state = parse_youki_state(&out).expect("valid state JSON");
assert!(matches!(
state.as_container_state(),
ContainerState::Failed { .. }
));
}
#[test]
fn parse_youki_stats_handles_full_payload() {
let raw = r#"{"cpu":{"usage":{"total":2500000000}},"memory":{"usage":{"usage":104857600,"limit":268435456}}}"#;
let out = fake_output(raw, 0);
let stats = parse_youki_stats(&out, std::time::Instant::now());
assert_eq!(stats.cpu_usage_usec, 2_500_000);
assert_eq!(stats.memory_bytes, 104_857_600);
assert_eq!(stats.memory_limit, 268_435_456);
}
#[test]
fn parse_youki_stats_defaults_on_malformed() {
let out = fake_output("not json", 0);
let stats = parse_youki_stats(&out, std::time::Instant::now());
assert_eq!(stats.cpu_usage_usec, 0);
assert_eq!(stats.memory_bytes, 0);
assert_eq!(stats.memory_limit, u64::MAX);
}
#[tokio::test]
async fn record_container_ip_then_get_returns_it() {
let runtime = make_runtime(Arc::new(RecordingRunner::new()));
let id = cid("web", 2);
let ip = IpAddr::V4(Ipv4Addr::new(10, 200, 0, 42));
assert_eq!(runtime.get_container_ip(&id).await.unwrap(), None);
runtime.record_container_ip(&id, ip).await;
assert_eq!(runtime.get_container_ip(&id).await.unwrap(), Some(ip));
}
#[ignore = "hangs on hosts with a real `zlayer` WSL distro; see comment + B8"]
#[tokio::test]
async fn create_container_no_longer_returns_unsupported() {
use zlayer_spec::DeploymentSpec;
let runtime = test_runtime(default_resolved_config());
let id = cid("svc", 0);
let yaml = r"
version: v1
deployment: wsl2-g2-test
services:
svc:
rtype: service
image:
name: ghcr.io/blackleafdigital/zlayer/test-fixtures:latest
endpoints:
- name: http
protocol: http
port: 8080
";
let spec = serde_yaml::from_str::<DeploymentSpec>(yaml)
.expect("valid deployment yaml")
.services
.remove("svc")
.expect("service 'svc' present");
let err = runtime.create_container(&id, &spec).await.unwrap_err();
assert!(
!matches!(err, AgentError::Unsupported(_)),
"create_container must not return Unsupported after G-2 (got {err:?})",
);
}
#[tokio::test]
async fn exec_stream_pump_yields_line_events_then_exit() {
use futures_util::stream::StreamExt as _;
let mut child = tokio::process::Command::new("cmd.exe")
.args(["/c", "echo a & echo b & exit 7"])
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("cmd.exe must be available on Windows test hosts");
let stdout = child.stdout.take().expect("piped stdout");
let stderr = child.stderr.take().expect("piped stderr");
let mut stream = spawn_exec_event_stream(child, stdout, stderr);
let mut stdout_lines: Vec<String> = Vec::new();
let mut exit_code: Option<i32> = None;
while let Some(ev) = stream.next().await {
match ev {
ExecEvent::Stdout(line) => stdout_lines.push(line.trim().to_string()),
ExecEvent::Stderr(_) => {}
ExecEvent::Exit(code) => {
exit_code = Some(code);
break;
}
}
}
assert_eq!(
stdout_lines,
vec!["a".to_string(), "b".to_string()],
"stdout should have been split into one event per line",
);
assert_eq!(
exit_code,
Some(7),
"terminal Exit event must carry the child's exit code",
);
}
#[test]
fn log_path_uses_configured_log_root() {
let runtime = test_runtime(ResolvedConfig {
distro: "zlayer".to_string(),
runtime_binary: DEFAULT_RUNTIME_BINARY.to_string(),
bundle_root: "/custom/bundles".to_string(),
log_root: "/custom/logs".to_string(),
oci_state_root: PathBuf::from(DEFAULT_OCI_STATE_ROOT),
});
let id = cid("web", 7);
assert_eq!(
runtime.log_path(&id),
"/custom/logs/web-rep-7.youki.log",
"log_path must be <log_root>/<slug>.youki.log",
);
}
#[test]
fn log_path_uses_default_log_root_by_default() {
let runtime = test_runtime(default_resolved_config());
let id = cid("svc", 0);
assert!(
runtime.log_path(&id).starts_with(DEFAULT_LOG_ROOT),
"default log_path should live under DEFAULT_LOG_ROOT ({DEFAULT_LOG_ROOT}), got {}",
runtime.log_path(&id),
);
}
#[test]
fn default_config_matches_previous_hardcoded_values() {
let cfg = Wsl2DelegateConfig::default();
assert_eq!(cfg.distro, zlayer_wsl::distro::configured_distro());
assert_eq!(cfg.runtime_binary.as_deref(), Some(DEFAULT_RUNTIME_BINARY));
assert_eq!(cfg.bundle_root, DEFAULT_BUNDLE_ROOT);
assert_eq!(cfg.log_root, DEFAULT_LOG_ROOT);
assert_eq!(cfg.oci_state_root, PathBuf::from(DEFAULT_OCI_STATE_ROOT));
}
#[test]
fn custom_config_propagates_into_runtime_fields() {
let runtime = test_runtime(ResolvedConfig {
distro: "ubuntu-lts".to_string(),
runtime_binary: "/opt/zlayer/bin/zlayer".to_string(),
bundle_root: "/srv/zlayer/bundles".to_string(),
log_root: "/srv/zlayer/logs".to_string(),
oci_state_root: PathBuf::from("/srv/zlayer/oci-state"),
});
let id = cid("api", 0);
assert_eq!(runtime.config.distro, "ubuntu-lts");
assert_eq!(runtime.config.runtime_binary, "/opt/zlayer/bin/zlayer");
assert_eq!(
runtime.bundle_dir(&id),
"/srv/zlayer/bundles/api-rep-0",
"bundle_dir should use the configured bundle_root",
);
assert_eq!(
runtime.log_path(&id),
"/srv/zlayer/logs/api-rep-0.youki.log",
"log_path should use the configured log_root",
);
}
#[test]
fn zlayer_runtime_prefixes_args_with_state_root() {
let cfg = ResolvedConfig {
distro: "zlayer".to_string(),
runtime_binary: DEFAULT_RUNTIME_BINARY.to_string(),
bundle_root: DEFAULT_BUNDLE_ROOT.to_string(),
log_root: DEFAULT_LOG_ROOT.to_string(),
oci_state_root: PathBuf::from("/var/lib/zlayer/oci/state"),
};
let state_root_owned = cfg.oci_state_root.to_string_lossy().into_owned();
let user_args: &[&str] = &["create", "id", "--bundle", "/b", "--log", "/l"];
let mut full_args: Vec<&str> = Vec::with_capacity(user_args.len() + 3);
full_args.push("runtime");
full_args.push("--state-root");
full_args.push(state_root_owned.as_str());
full_args.extend(user_args.iter().copied());
assert_eq!(
full_args,
vec![
"runtime",
"--state-root",
"/var/lib/zlayer/oci/state",
"create",
"id",
"--bundle",
"/b",
"--log",
"/l",
],
"zlayer_runtime must prefix `runtime --state-root <root>` to its argv",
);
}
#[test]
fn overlay_attach_kind_is_guest_managed() {
let runtime = test_runtime(default_resolved_config());
assert_eq!(
runtime.overlay_attach_kind(),
OverlayAttachKind::GuestManaged
);
}
#[test]
fn parse_default_gateway_extracts_via() {
assert_eq!(
parse_default_gateway("default via 172.20.16.1 dev eth0 proto kernel"),
Some("172.20.16.1".to_string())
);
assert_eq!(
parse_default_gateway("10.0.0.0/24 dev eth0 scope link\n"),
None
);
assert_eq!(parse_default_gateway(""), None);
}
#[test]
fn rewrite_endpoint_host_keeps_port() {
assert_eq!(
rewrite_endpoint_host("10.200.0.5:51820", "172.20.16.1"),
"172.20.16.1:51820"
);
assert_eq!(rewrite_endpoint_host("", "172.20.16.1"), "");
assert_eq!(rewrite_endpoint_host("hostonly", "172.20.16.1"), "hostonly");
}
#[test]
fn sh_single_quote_escapes() {
assert_eq!(sh_single_quote("abc+/=="), "'abc+/=='");
assert_eq!(sh_single_quote("a'b"), "'a'\\''b'");
}
#[test]
fn wg_iface_name_is_ifnamsiz_safe() {
let name = Wsl2DelegateRuntime::wg_iface_name("web-rep-0");
assert_eq!(name, Wsl2DelegateRuntime::wg_iface_name("web-rep-0"));
assert!(
name.starts_with("zl-") && name.len() <= 15,
"wg iface must be zl- prefixed and <= 15 chars, got {name:?}"
);
let long = Wsl2DelegateRuntime::wg_iface_name(
"a-very-long-service-name-that-overflows-ifnamsiz-rep-12345",
);
assert!(long.len() <= 15, "long slug must hash to <= 15: {long:?}");
}
#[tokio::test]
async fn push_overlay_config_emits_wireguard_sequence() {
use zlayer_types::overlayd::{GuestOverlayConfig, PeerSpec};
let runner = Arc::new(RecordingRunner::new());
let calls = runner.calls_handle();
runner.set_response(
"ip",
&["route", "show", "default"],
fake_output("default via 172.20.16.1 dev eth0\n", 0),
);
let runtime = make_runtime(runner.clone());
let id = cid("web", 0);
let cfg = GuestOverlayConfig {
overlay_ip: IpAddr::V4(Ipv4Addr::new(10, 200, 0, 42)),
prefix_len: 16,
private_key: "PRIVKEYBASE64==".to_string(),
public_key: "PUBKEYBASE64==".to_string(),
listen_port: 51820,
peers: vec![PeerSpec {
public_key: "PEERPUB==".to_string(),
endpoint: "10.200.0.1:51820".to_string(),
allowed_ips: "10.200.0.0/16".to_string(),
persistent_keepalive_secs: 25,
candidates: Vec::new(),
}],
dns_server: Some(IpAddr::V4(Ipv4Addr::new(10, 200, 0, 1))),
dns_domain: Some("svc.zlayer.local".to_string()),
};
runtime
.push_overlay_config(&id, &cfg)
.await
.expect("push must succeed against the recording runner");
let joined: Vec<String> = calls
.lock()
.unwrap()
.iter()
.map(|(c, a)| format!("{} {}", c, a.join(" ")))
.collect();
let wg = Wsl2DelegateRuntime::wg_iface_name("web-rep-0");
let must_contain = [
format!("ip link add {wg} type wireguard"),
format!("wg set {wg} private-key"),
format!("wg set {wg} peer PEERPUB== endpoint 172.20.16.1:51820 allowed-ips 10.200.0.0/16 persistent-keepalive 25"),
format!("ip link set {wg} netns web-rep-0"),
format!("ip netns exec web-rep-0 ip addr add 10.200.0.42/16 dev {wg}"),
"ip netns exec web-rep-0 ip route add default dev".to_string(),
];
for needle in &must_contain {
assert!(
joined.iter().any(|c| c.contains(needle.as_str())),
"expected a command matching {needle:?} in {joined:?}"
);
}
assert_eq!(
runtime.get_container_ip(&id).await.unwrap(),
Some(cfg.overlay_ip)
);
assert!(matches!(
runtime.netns.read().await.get(&id),
Some(NetnsState::Configured { .. })
));
}
#[tokio::test]
async fn push_overlay_config_hard_errors_on_failure() {
use zlayer_types::overlayd::GuestOverlayConfig;
let runner = Arc::new(RecordingRunner::new());
runner.set_response(
"ip",
&["route", "show", "default"],
fake_output("default via 172.20.16.1 dev eth0\n", 0),
);
let wg = Wsl2DelegateRuntime::wg_iface_name("web-rep-7");
runner.set_response(
"ip",
&["link", "add", &wg, "type", "wireguard"],
fake_output_err("RTNETLINK answers: Operation not permitted", 2),
);
let runtime = make_runtime(runner.clone());
let id = cid("web", 7);
let cfg = GuestOverlayConfig {
overlay_ip: IpAddr::V4(Ipv4Addr::new(10, 200, 0, 99)),
prefix_len: 16,
private_key: "K==".to_string(),
public_key: "P==".to_string(),
listen_port: 51820,
peers: Vec::new(),
dns_server: None,
dns_domain: None,
};
let err = runtime.push_overlay_config(&id, &cfg).await.unwrap_err();
assert!(
matches!(err, AgentError::Network(_)),
"expected a hard Network error, got {err:?}"
);
assert_eq!(runtime.get_container_ip(&id).await.unwrap(), None);
}
#[tokio::test]
async fn teardown_container_netns_deletes_wg_and_ns() {
let runner = Arc::new(RecordingRunner::new());
let calls = runner.calls_handle();
let runtime = make_runtime(runner.clone());
let id = cid("web", 0);
runtime
.netns
.write()
.await
.insert(id.clone(), NetnsState::Created);
runtime.teardown_container_netns(&id).await;
let joined: Vec<String> = calls
.lock()
.unwrap()
.iter()
.map(|(c, a)| format!("{} {}", c, a.join(" ")))
.collect();
let wg = Wsl2DelegateRuntime::wg_iface_name("web-rep-0");
assert!(
joined.iter().any(|c| c == &format!("ip link delete {wg}")),
"teardown must delete the wg device, got {joined:?}"
);
assert!(
joined.iter().any(|c| c == "ip netns delete web-rep-0"),
"teardown must delete the netns, got {joined:?}"
);
assert!(runtime.netns.read().await.get(&id).is_none());
calls.lock().unwrap().clear();
let id2 = cid("web", 9);
runtime.teardown_container_netns(&id2).await;
assert!(
calls.lock().unwrap().is_empty(),
"teardown without state must be a no-op"
);
}
fn test_gpu_spec() -> GpuSpec {
GpuSpec {
count: 1,
vendor: "nvidia".to_string(),
mode: None,
model: None,
scheduling: None,
distributed: None,
sharding: None,
sharing: None,
mps_pipe_dir: None,
mps_log_dir: None,
time_slice_index: None,
time_slicing_config_path: None,
}
}
fn happy_host_state() -> WslGpuHostState {
WslGpuHostState {
dxg_devno: Some((10, 117)),
wsl_lib_present: true,
wsl_drivers_present: true,
}
}
#[test]
fn inject_wsl_gpu_mounts_adds_dxg_mount() {
let mut mounts: Vec<Mount> = Vec::new();
let mut env: Vec<String> = Vec::new();
let mut devices: Vec<LinuxDevice> = Vec::new();
let gpu = test_gpu_spec();
let host = happy_host_state();
inject_wsl_gpu_mounts(&mut mounts, &mut env, &mut devices, &gpu, &host)
.expect("inject must succeed on happy host");
let dxg_mounts: Vec<_> = mounts
.iter()
.filter(|m| m.destination().as_path() == std::path::Path::new("/dev/dxg"))
.collect();
assert_eq!(
dxg_mounts.len(),
1,
"expected exactly one /dev/dxg mount, got {}: {:?}",
dxg_mounts.len(),
mounts
);
assert_eq!(
dxg_mounts[0]
.source()
.as_ref()
.map(std::path::PathBuf::as_path),
Some(std::path::Path::new("/dev/dxg"))
);
assert_eq!(dxg_mounts[0].typ().as_deref(), Some("bind"));
let dxg_devs: Vec<_> = devices
.iter()
.filter(|d| d.path().as_path() == std::path::Path::new("/dev/dxg"))
.collect();
assert_eq!(dxg_devs.len(), 1);
assert_eq!(dxg_devs[0].major(), 10);
assert_eq!(dxg_devs[0].minor(), 117);
assert_eq!(dxg_devs[0].typ(), LinuxDeviceType::C);
}
#[test]
fn inject_wsl_gpu_mounts_respects_existing_user_mount() {
let pre_existing = MountBuilder::default()
.destination("/dev/dxg".to_string())
.source("/dev/dxg".to_string())
.typ("bind".to_string())
.options(vec!["bind".to_string(), "rw".to_string()])
.build()
.unwrap();
let mut mounts = vec![pre_existing];
let pre_existing_dev = LinuxDeviceBuilder::default()
.path("/dev/dxg")
.typ(LinuxDeviceType::C)
.major(10)
.minor(117)
.file_mode(0o666u32)
.uid(0u32)
.gid(0u32)
.build()
.unwrap();
let mut devices = vec![pre_existing_dev];
let mut env: Vec<String> = Vec::new();
let gpu = test_gpu_spec();
let host = happy_host_state();
inject_wsl_gpu_mounts(&mut mounts, &mut env, &mut devices, &gpu, &host)
.expect("inject must succeed on happy host");
assert_eq!(
mounts
.iter()
.filter(|m| m.destination().as_path() == std::path::Path::new("/dev/dxg"))
.count(),
1,
"expected no duplicate /dev/dxg mount, mounts: {mounts:?}"
);
assert_eq!(
devices
.iter()
.filter(|d| d.path().as_path() == std::path::Path::new("/dev/dxg"))
.count(),
1,
"expected no duplicate /dev/dxg device, devices: {devices:?}"
);
}
#[test]
fn inject_wsl_gpu_mounts_prepends_ld_library_path() {
let mut mounts: Vec<Mount> = Vec::new();
let mut env: Vec<String> = vec!["LD_LIBRARY_PATH=/foo".to_string()];
let mut devices: Vec<LinuxDevice> = Vec::new();
let gpu = test_gpu_spec();
let host = happy_host_state();
inject_wsl_gpu_mounts(&mut mounts, &mut env, &mut devices, &gpu, &host)
.expect("inject must succeed on happy host");
let ld_entries: Vec<_> = env
.iter()
.filter(|e| e.starts_with("LD_LIBRARY_PATH="))
.collect();
assert_eq!(
ld_entries.len(),
1,
"exactly one LD_LIBRARY_PATH entry: {env:?}"
);
assert_eq!(
ld_entries[0], "LD_LIBRARY_PATH=/usr/lib/wsl/drivers:/usr/lib/wsl/lib:/foo",
"unexpected LD_LIBRARY_PATH: {env:?}"
);
}
#[test]
fn inject_wsl_gpu_mounts_appends_ld_library_path_when_absent() {
let mut mounts: Vec<Mount> = Vec::new();
let mut env: Vec<String> = vec!["PATH=/bin".to_string()];
let mut devices: Vec<LinuxDevice> = Vec::new();
let gpu = test_gpu_spec();
let host = happy_host_state();
inject_wsl_gpu_mounts(&mut mounts, &mut env, &mut devices, &gpu, &host).unwrap();
assert!(env.contains(&"PATH=/bin".to_string()));
assert!(env
.iter()
.any(|e| e == "LD_LIBRARY_PATH=/usr/lib/wsl/drivers:/usr/lib/wsl/lib"));
}
#[test]
fn inject_wsl_gpu_mounts_fails_when_dxg_missing() {
let mut mounts: Vec<Mount> = Vec::new();
let mut env: Vec<String> = Vec::new();
let mut devices: Vec<LinuxDevice> = Vec::new();
let gpu = test_gpu_spec();
let host = WslGpuHostState {
dxg_devno: None,
wsl_lib_present: true,
wsl_drivers_present: true,
};
let err = inject_wsl_gpu_mounts(&mut mounts, &mut env, &mut devices, &gpu, &host)
.expect_err("expected WslGpuUnavailable when /dev/dxg missing");
assert!(
matches!(err, AgentError::WslGpuUnavailable { .. }),
"expected WslGpuUnavailable, got: {err:?}"
);
assert!(mounts.is_empty(), "no mount should have been pushed");
assert!(devices.is_empty(), "no device should have been pushed");
}
#[tokio::test]
async fn apply_wsl_gpu_to_spec_no_op_when_gpu_not_requested() {
struct ExplodingProbe;
#[async_trait]
impl WslGpuHostProbe for ExplodingProbe {
async fn probe(&self) -> Result<WslGpuHostState> {
panic!("probe must not run when no GPU is requested");
}
}
let mut oci_spec = Spec::default();
let mounts_before = oci_spec.mounts().clone();
let yaml = r"
version: v1
deployment: gpu-noop-test
services:
cpu-only:
rtype: service
image:
name: alpine:latest
";
let deployment: zlayer_spec::DeploymentSpec =
serde_yaml::from_str(yaml).expect("parse fixture deployment");
let service = deployment
.services
.get("cpu-only")
.cloned()
.expect("cpu-only service");
assert!(
service.resources.gpu.is_none(),
"fixture must not request GPU"
);
apply_wsl_gpu_to_spec(&mut oci_spec, &service, &ExplodingProbe)
.await
.expect("no-op should succeed");
assert_eq!(oci_spec.mounts(), &mounts_before);
let has_dxg = oci_spec.mounts().as_ref().is_some_and(|ms| {
ms.iter()
.any(|m| m.destination().as_path() == std::path::Path::new("/dev/dxg"))
});
assert!(
!has_dxg,
"no /dev/dxg mount should appear for CPU-only spec"
);
}
#[test]
fn parse_stat_hex_devno_handles_typical_wsl_output() {
assert_eq!(parse_stat_hex_devno("a 75"), Some((10, 117)));
assert_eq!(parse_stat_hex_devno(" a 75 "), Some((10, 117)));
assert_eq!(parse_stat_hex_devno(""), None);
assert_eq!(parse_stat_hex_devno("nothex zz"), None);
assert_eq!(parse_stat_hex_devno("a"), None);
}
#[tokio::test]
async fn provision_named_netns_idempotent_on_existing_ns() {
let runner = Arc::new(RecordingRunner::new());
let runtime = make_runtime(runner.clone());
let id = cid("web", 3);
runner.set_response(
"ip",
&["netns", "add", "web-rep-3"],
fake_output_err(
"Cannot create namespace file \"/run/netns/web-rep-3\": File exists",
1,
),
);
runtime
.provision_named_netns(&id, "web-rep-3")
.await
.expect("pre-existing netns must be reused, not error");
assert!(matches!(
runtime.netns.read().await.get(&id),
Some(NetnsState::Created)
));
}
}