#![cfg(target_os = "windows")]
#![allow(
clippy::too_many_lines,
clippy::used_underscore_binding,
clippy::doc_markdown
)]
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use zlayer_agent::runtimes::hcs::{HcsConfig, HcsRuntime};
use zlayer_agent::{ContainerId, ContainerState, Runtime};
use zlayer_spec::{DeploymentSpec, ServiceSpec};
const TEST_IMAGE_DEFAULT: &str = "mcr.microsoft.com/windows/nanoserver:ltsc2022";
fn test_image() -> String {
std::env::var("ZLAYER_HCS_TEST_IMAGE").unwrap_or_else(|_| TEST_IMAGE_DEFAULT.to_string())
}
#[allow(clippy::cast_possible_truncation)]
fn unique_name(prefix: &str) -> String {
let pid = std::process::id();
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64
% 1_000_000;
format!("{prefix}-{pid}-{ts}")
}
fn fresh_storage_root(tag: &str) -> PathBuf {
std::env::temp_dir().join(format!("zlayer-hcs-hyperv-e2e-{}", unique_name(tag)))
}
fn test_config(tag: &str) -> (HcsConfig, PathBuf) {
let storage_root = fresh_storage_root(tag);
let slice: ipnet::IpNet = "10.220.99.16/28"
.parse()
.expect("test slice CIDR must be valid");
let cfg = HcsConfig {
storage_root: storage_root.clone(),
default_scratch_size_gb: 20,
slice_cidr: Some(slice),
..HcsConfig::default()
};
let _ = tag; (cfg, storage_root)
}
async fn make_runtime(tag: &str) -> (Arc<HcsRuntime>, PathBuf) {
let (cfg, storage_root) = test_config(tag);
let runtime = HcsRuntime::new(cfg)
.await
.expect("HcsRuntime::new must succeed on a Windows host with HCS available");
(Arc::new(runtime), storage_root)
}
fn spec_from_yaml(yaml: &str, service: &str) -> ServiceSpec {
serde_yaml::from_str::<DeploymentSpec>(yaml)
.expect("test YAML must parse into DeploymentSpec")
.services
.remove(service)
.unwrap_or_else(|| panic!("test YAML is missing service {service:?}"))
}
fn echo_spec_hyperv() -> ServiceSpec {
let image = test_image();
let yaml = format!(
r#"
version: v1
deployment: hcs-hyperv-e2e
services:
echo:
rtype: service
isolation: hyperv
image:
name: {image}
command:
entrypoint: ["cmd", "/c", "echo", "hello from hyperv"]
endpoints:
- name: dummy
protocol: tcp
port: 8080
scale:
mode: fixed
replicas: 1
"#
);
spec_from_yaml(&yaml, "echo")
}
fn long_lived_spec_hyperv() -> ServiceSpec {
let image = test_image();
let yaml = format!(
r#"
version: v1
deployment: hcs-hyperv-e2e
services:
longlived:
rtype: service
isolation: hyperv
image:
name: {image}
command:
entrypoint: ["cmd", "/c", "ping", "-n", "60", "127.0.0.1"]
endpoints:
- name: dummy
protocol: tcp
port: 8080
scale:
mode: fixed
replicas: 1
"#
);
spec_from_yaml(&yaml, "longlived")
}
async fn wait_for_state(
runtime: &dyn Runtime,
id: &ContainerId,
expected: ContainerState,
budget: Duration,
) -> Result<ContainerState, String> {
let start = std::time::Instant::now();
let poll = Duration::from_millis(200);
let mut last: Option<ContainerState> = None;
while start.elapsed() < budget {
match runtime.container_state(id).await {
Ok(state) => {
let matches = match (&state, &expected) {
(ContainerState::Exited { .. }, ContainerState::Exited { .. }) => true,
(a, b) => a == b,
};
if matches {
return Ok(state);
}
last = Some(state);
}
Err(e) => return Err(format!("container_state error: {e}")),
}
tokio::time::sleep(poll).await;
}
Err(format!(
"timed out after {budget:?} waiting for {expected:?}; last observed = {last:?}"
))
}
fn rm_storage_root(root: &PathBuf) {
let _ = std::fs::remove_dir_all(root);
}
#[tokio::test(flavor = "multi_thread")]
#[ignore = "requires Windows host with Hyper-V feature + Windows Containers feature enabled, plus nanoserver:ltsc2022 layers"]
async fn windows_hcs_hyperv_smoke_create_start_stop_remove() {
let outcome = tokio::time::timeout(Duration::from_secs(900), async {
let (runtime, storage_root) = make_runtime("smoke").await;
runtime
.pull_image(&test_image())
.await
.expect("pull_image must succeed before create_container");
let id = ContainerId::new(unique_name("smoke"), 1);
let spec = echo_spec_hyperv();
let create_result = runtime.create_container(&id, &spec).await;
let runtime_for_body = runtime.clone();
let id_for_body = id.clone();
let body = async move {
create_result.expect("create_container must succeed under Hyper-V isolation");
runtime_for_body
.start_container(&id_for_body)
.await
.expect("start_container must succeed under Hyper-V isolation");
let final_state = wait_for_state(
runtime_for_body.as_ref(),
&id_for_body,
ContainerState::Exited { code: 0 },
Duration::from_secs(180),
)
.await
.expect("Hyper-V container must reach Exited within 180s");
match final_state {
ContainerState::Exited { code } => {
assert_eq!(code, 0, "echo entrypoint must exit with code 0");
}
other => panic!("expected Exited, got {other:?}"),
}
};
let body_outcome = std::panic::AssertUnwindSafe(body)
.catch_unwind_async()
.await;
let _ = runtime.remove_container(&id).await;
rm_storage_root(&storage_root);
if let Err(p) = body_outcome {
std::panic::resume_unwind(p);
}
})
.await;
outcome
.expect("windows_hcs_hyperv_smoke_create_start_stop_remove exceeded the 900s outer budget");
}
#[tokio::test(flavor = "multi_thread")]
#[ignore = "requires Windows host with Hyper-V feature + Windows Containers feature enabled, plus nanoserver:ltsc2022 layers"]
async fn windows_hcs_hyperv_exec_inside_container() {
let outcome = tokio::time::timeout(Duration::from_secs(900), async {
let (runtime, storage_root) = make_runtime("exec").await;
runtime
.pull_image(&test_image())
.await
.expect("pull_image must succeed before create_container");
let id = ContainerId::new(unique_name("exec"), 1);
let spec = long_lived_spec_hyperv();
let create_result = runtime.create_container(&id, &spec).await;
let runtime_for_body = runtime.clone();
let id_for_body = id.clone();
let body = async move {
create_result.expect("create_container must succeed under Hyper-V isolation");
runtime_for_body
.start_container(&id_for_body)
.await
.expect("start_container must succeed under Hyper-V isolation");
wait_for_state(
runtime_for_body.as_ref(),
&id_for_body,
ContainerState::Running,
Duration::from_secs(120),
)
.await
.expect("Hyper-V container must reach Running within 120s");
let cmd: Vec<String> = vec!["cmd".into(), "/c".into(), "hostname".into()];
let (exit_code, stdout, _stderr) = runtime_for_body
.exec(&id_for_body, &cmd)
.await
.expect("exec must succeed against a Running Hyper-V container");
assert_eq!(
exit_code, 0,
"`cmd /c hostname` must exit with code 0 inside the UVM",
);
assert!(
!stdout.is_empty(),
"`cmd /c hostname` must print at least the container hostname to stdout",
);
};
let body_outcome = std::panic::AssertUnwindSafe(body)
.catch_unwind_async()
.await;
let _ = runtime.stop_container(&id, Duration::from_secs(5)).await;
let _ = runtime.remove_container(&id).await;
rm_storage_root(&storage_root);
if let Err(p) = body_outcome {
std::panic::resume_unwind(p);
}
})
.await;
outcome.expect("windows_hcs_hyperv_exec_inside_container exceeded the 900s outer budget");
}
#[tokio::test(flavor = "multi_thread")]
#[ignore = "requires Windows host with Hyper-V feature + Windows Containers feature enabled, plus nanoserver:ltsc2022 layers"]
async fn windows_hcs_hyperv_concurrent_pair() {
let outcome = tokio::time::timeout(Duration::from_secs(1200), async {
let (runtime, storage_root) = make_runtime("pair").await;
runtime
.pull_image(&test_image())
.await
.expect("pull_image must succeed before create_container");
let id_a = ContainerId::new(unique_name("pair-a"), 1);
let id_b = ContainerId::new(unique_name("pair-b"), 1);
let spec_a = long_lived_spec_hyperv();
let spec_b = long_lived_spec_hyperv();
let create_a = runtime.create_container(&id_a, &spec_a).await;
let create_b = runtime.create_container(&id_b, &spec_b).await;
let runtime_for_body = runtime.clone();
let id_a_for_body = id_a.clone();
let id_other_for_body = id_b.clone();
let body = async move {
create_a.expect("create_container(a) must succeed under Hyper-V isolation");
create_b.expect("create_container(b) must succeed under Hyper-V isolation");
let (start_a, start_b) = tokio::join!(
runtime_for_body.start_container(&id_a_for_body),
runtime_for_body.start_container(&id_other_for_body),
);
start_a.expect("start_container(a) must succeed");
start_b.expect("start_container(b) must succeed");
let (wait_a, wait_b) = tokio::join!(
wait_for_state(
runtime_for_body.as_ref(),
&id_a_for_body,
ContainerState::Running,
Duration::from_secs(180),
),
wait_for_state(
runtime_for_body.as_ref(),
&id_other_for_body,
ContainerState::Running,
Duration::from_secs(180),
),
);
wait_a.expect("container a must reach Running within 180s");
wait_b.expect("container b must reach Running within 180s");
runtime_for_body
.stop_container(&id_a_for_body, Duration::from_secs(10))
.await
.expect("stop_container(a) must succeed independently of b");
runtime_for_body
.remove_container(&id_a_for_body)
.await
.expect("remove_container(a) must succeed independently of b");
let state_b = runtime_for_body
.container_state(&id_other_for_body)
.await
.expect("container_state(b) must still be queryable after a is gone");
assert_eq!(
state_b,
ContainerState::Running,
"tearing down container a must not affect container b",
);
runtime_for_body
.stop_container(&id_other_for_body, Duration::from_secs(10))
.await
.expect("stop_container(b) must succeed");
runtime_for_body
.remove_container(&id_other_for_body)
.await
.expect("remove_container(b) must succeed");
};
let body_outcome = std::panic::AssertUnwindSafe(body)
.catch_unwind_async()
.await;
let _ = runtime.stop_container(&id_a, Duration::from_secs(5)).await;
let _ = runtime.remove_container(&id_a).await;
let _ = runtime.stop_container(&id_b, Duration::from_secs(5)).await;
let _ = runtime.remove_container(&id_b).await;
rm_storage_root(&storage_root);
if let Err(p) = body_outcome {
std::panic::resume_unwind(p);
}
})
.await;
outcome.expect("windows_hcs_hyperv_concurrent_pair exceeded the 1200s outer budget");
}
trait CatchUnwindAsyncExt: std::future::Future + Sized {
async fn catch_unwind_async(
self,
) -> Result<Self::Output, Box<dyn std::any::Any + Send + 'static>>;
}
impl<F> CatchUnwindAsyncExt for std::panic::AssertUnwindSafe<F>
where
F: std::future::Future,
{
async fn catch_unwind_async(
self,
) -> Result<F::Output, Box<dyn std::any::Any + Send + 'static>> {
use std::future::Future;
use std::pin::Pin;
use std::task::Poll;
let mut inner = Box::pin(self.0);
std::future::poll_fn(move |cx| {
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
Pin::new(&mut inner).as_mut().poll(cx)
}));
match result {
Ok(Poll::Pending) => Poll::Pending,
Ok(Poll::Ready(v)) => Poll::Ready(Ok(v)),
Err(panic) => Poll::Ready(Err(panic)),
}
})
.await
}
}