diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcWslcLifecycleE2ETests.cs b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcWslcLifecycleE2ETests.cs new file mode 100644 index 000000000..e25a89fda --- /dev/null +++ b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcWslcLifecycleE2ETests.cs @@ -0,0 +1,170 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Mxc.Sdk; +using Xunit; + +namespace Microsoft.Mxc.Sdk.Tests; + +/// +/// Drives the state-aware lifecycle against a live WSLC host, which needs +/// Windows, a host running WSL2 with the WSLC runtime, and a native library +/// built with the wslc feature. +/// +/// +/// These establish that the lifecycle works end to end through this binding +/// rather than only through the engine: the id provision mints, and the output +/// and exit code an exec returns. The image is expected to be cached already; +/// pulling is covered by the WSLC executor E2E suite. +/// +[Collection("MxcLiveHost")] +public class MxcWslcLifecycleE2ETests +{ + /// + /// Deprovisions a sandbox the test did not deprovision itself. Provision + /// mints a real container, so an assertion that throws part-way would + /// otherwise leave it on the host. + /// + private sealed class Teardown : IDisposable + { + private SandboxId? _id; + + public Teardown(SandboxId id) => _id = id; + + /// + /// Gives up ownership once the test has deprovisioned itself. + /// Deprovision is not idempotent, so without this the disposal below + /// would report a failure that did not happen. + /// + public void Defuse() => _id = null; + + public void Dispose() + { + if (_id is not { } id) + { + return; + } + _id = null; + try + { + MxcLifecycle.StopSandbox(id); + } + catch (MxcException) + { + // A sandbox that never started, or already stopped, still has to + // be deprovisioned — that is the step that frees the container. + } + try + { + MxcLifecycle.DeprovisionSandbox(id); + } + catch (MxcException e) + { + Console.Error.WriteLine( + $"WARNING: deprovision failed, the container may leak: {e.Message}"); + } + } + } + + private sealed record Started(SandboxId Id, Teardown Teardown); + + private static Started Provision() + { + var provisioned = MxcLifecycle.ProvisionSandbox( + StateAwareContainment.Wslc, + new WslcProvisionOptions { Image = WslcHost.Image }); + return new Started(provisioned.SandboxId, new Teardown(provisioned.SandboxId)); + } + + private static Started ProvisionAndStart() + { + var started = Provision(); + try + { + MxcLifecycle.StartSandbox(started.Id); + return started; + } + catch + { + started.Teardown.Dispose(); + throw; + } + } + + /// Provision reaches the backend through this binding and mints an + /// id the later phases accept. + [Fact] + public void Provision_ReturnsAUsableSandboxId() + { + WslcHost.Require(); + + var started = Provision(); + using (started.Teardown) + { + // Post-provision phases resolve the backend from this prefix. + Assert.StartsWith("wslc:", started.Id.Value, StringComparison.Ordinal); + + MxcLifecycle.StartSandbox(started.Id); + } + } + + /// The lifecycle runs a command and its output reaches the caller. + [Fact] + public async Task Lifecycle_RunsEndToEnd() + { + WslcHost.Require(); + + var started = ProvisionAndStart(); + using (started.Teardown) + { + var run = await MxcLifecycle.ExecInSandboxAsync( + started.Id, + "echo dotnet-wslc-marker", + TestContext.Current.CancellationToken); + + Assert.False(run.TimedOut); + Assert.Equal(0, run.ExitCode); + Assert.Contains("dotnet-wslc-marker", run.Stdout); + } + } + + /// The sandboxed process's exit code must reach the caller + /// unchanged, through the streaming handle's wait. + [Fact] + public void Exec_PropagatesANonZeroExitCode() + { + WslcHost.Require(); + + var started = ProvisionAndStart(); + using (started.Teardown) + { + using var proc = MxcLifecycle.ExecInSandbox(started.Id, "exit 7"); + var result = proc.Wait(); + + Assert.False(result.TimedOut); + Assert.Equal(7, result.ExitCode); + } + } + + /// + /// Stop and deprovision must be reachable through this binding, and a + /// deprovisioned id must not still be usable. + /// + [Fact] + public void Deprovision_RetiresTheSandboxId() + { + WslcHost.Require(); + + var started = ProvisionAndStart(); + using (started.Teardown) + { + MxcLifecycle.StopSandbox(started.Id); + MxcLifecycle.DeprovisionSandbox(started.Id); + started.Teardown.Defuse(); + + var ex = Assert.Throws( + () => MxcLifecycle.StartSandbox(started.Id)); + Assert.Equal(ErrorCode.NotProvisioned, ex.Code); + } + } +} diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/WslcHost.cs b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/WslcHost.cs new file mode 100644 index 000000000..0ff79a810 --- /dev/null +++ b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/WslcHost.cs @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Mxc.Sdk; +using Xunit; + +namespace Microsoft.Mxc.Sdk.Tests; + +/// +/// The live-host gate the WSLC suite shares. A live run needs Windows, a host +/// running WSL2 with the WSLC runtime, and a native library built with the +/// wslc feature. +/// +internal static class WslcHost +{ + // Evaluated once: this answer decides failure versus skip, so it has to be + // the same for every test that consults it. + private static readonly Lazy Available = new(() => + MxcSandbox.GetAvailableBackends() + .Any(b => b.Backend == ContainmentBackend.Wslc)); + + // Without this, a run in which everything skipped is indistinguishable from + // one that passed. The same variable the Rust WSLC suite honours. + private static bool SkipsAreFailures => + Environment.GetEnvironmentVariable("MXC_WSLC_TESTS_REQUIRED") is "1" or "true"; + + internal static string Image => + Environment.GetEnvironmentVariable("MXC_WSLC_TEST_IMAGE") ?? "alpine:latest"; + + /// Skips the calling test when the backend is unavailable, or fails + /// it when skips have been declared failures. + internal static void Require() + { + Assert.False( + SkipsAreFailures && !Available.Value, + "MXC_WSLC_TESTS_REQUIRED is set, but GetAvailableBackends() does not " + + "report the WSLC backend. That needs both a build with " + + "MxcWithWslc and a host running WSL2 with the WSLC runtime."); + Assert.SkipUnless( + Available.Value, + "GetAvailableBackends() does not report the WSLC backend"); + } +} diff --git a/sdk/dotnet/README.md b/sdk/dotnet/README.md index ef58b4b74..8f7e2e3c0 100644 --- a/sdk/dotnet/README.md +++ b/sdk/dotnet/README.md @@ -747,7 +747,13 @@ exception messages and stack traces. See The isolation-session end-to-end tests skip unless `GetAvailableBackends()` reports that backend, which needs both a build with `-p:MxcWithIsolationSession=true` and a host running the OS-side service. Set - `MXC_ISO_TESTS_REQUIRED=1` (or `true`) to turn those skips into failures. + `MXC_ISO_TESTS_REQUIRED=1` (or `true`) to turn those skips into failures. The + WSLC end-to-end tests work the same way against + `-p:MxcWithWslc=true` and a host running WSL2 with the WSLC runtime, with + `MXC_WSLC_TESTS_REQUIRED=1` as their gate and `MXC_WSLC_TEST_IMAGE` selecting + the image to provision from (the default `alpine:latest` is expected to be + cached already). Both live-host suites share one xUnit collection, so they + never run concurrently. Build/test everything: `dotnet test --solution sdk/dotnet/Microsoft.Mxc.Sdk.slnx`. diff --git a/src/core/mxc-sdk/README.md b/src/core/mxc-sdk/README.md index 7c29bb11d..409e75119 100644 --- a/src/core/mxc-sdk/README.md +++ b/src/core/mxc-sdk/README.md @@ -624,6 +624,28 @@ host probe fails closed, which is why [`platform_support`] drops `"wslc"`, and a `Containment::Wslc` run then fails with an error naming the directory it searched. +#### Host-gated tests + +`tests/wslc_state_aware.rs` drives the live lifecycle — provision, start, exec, +deprovision — against a host running WSL2 with the WSLC runtime. A cargo +integration test runs from `target//deps/`, one directory below where +the profile build stages the runtime files, so the suite copies both of them +beside its own binary first. Without that copy the host probe finds no +`wslcsdk.dll`, drops `"wslc"`, and every test skips on a host that can run it. + +Build the daemon before running the suite, because `--features wslc` does not +pull it into the dependency graph: + +```text +cargo build -p wxc_wslc_daemon -p mxc-sdk --features mxc-sdk/wslc +cargo test -p mxc-sdk --features wslc --test wslc_state_aware +``` + +| Variable | Effect | +|----------|--------| +| `MXC_WSLC_TESTS_REQUIRED=1` | Turns every skip into a failure, so a run that tested nothing is distinguishable from one that passed. | +| `MXC_WSLC_TEST_IMAGE` | Selects the image to provision from; the default `alpine:latest` is expected to be cached already. | + ## Telemetry consent MXC only ever collects telemetry on Windows, and only after the end user has diff --git a/src/core/mxc-sdk/tests/wslc_state_aware.rs b/src/core/mxc-sdk/tests/wslc_state_aware.rs new file mode 100644 index 000000000..6df5f1eb9 --- /dev/null +++ b/src/core/mxc-sdk/tests/wslc_state_aware.rs @@ -0,0 +1,279 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! WSLC state-aware lifecycle reachability from the Rust SDK. +//! +//! Host-dependent tests skip when the backend is unavailable, so this file is +//! safe to run on any host. They assume the image is already cached; pulling is +//! covered by the WSLC executor E2E suite. + +#![cfg(all(target_os = "windows", feature = "wslc"))] + +use std::path::Path; +use std::sync::{Mutex, MutexGuard, OnceLock}; + +use mxc_sdk::{ + sandbox, ErrorCode, ExecRequest, LifecycleRequest, OperationOptions, Output, ProvisionRequest, + SandboxId, WaitOutcome, +}; + +const VERSION: &str = "0.9.0-alpha"; + +const EXEC_TIMEOUT_MS: u32 = 60_000; + +const RUNTIME_UNITS: [&str; 2] = ["wslcsdk.dll", "wxc-wslc-daemon.exe"]; + +/// Cargo stages the WSLC runtime units one directory above `deps/`, where this +/// test binary runs. +fn stage_runtime_units() -> Result<(), String> { + let exe = + std::env::current_exe().map_err(|e| format!("cannot locate this test binary: {e}"))?; + let test_dir = exe + .parent() + .ok_or_else(|| format!("{} has no parent directory", exe.display()))?; + let profile_dir = test_dir + .parent() + .ok_or_else(|| format!("{} has no parent directory", test_dir.display()))?; + + for unit in RUNTIME_UNITS { + let source = profile_dir.join(unit); + let staged = test_dir.join(unit); + if !source.exists() { + if staged.exists() { + continue; + } + return Err(format!( + "{unit} is beside neither this test binary nor {}; produce both units with \ + `cargo build -p wxc_wslc_daemon -p mxc-sdk --features mxc-sdk/wslc`", + profile_dir.display() + )); + } + if is_already_staged(&source, &staged) { + continue; + } + std::fs::copy(&source, &staged).map_err(|e| { + format!( + "cannot stage {} over {}: {e}. A daemon left running by an earlier run holds \ + its own image open; stop it or wait out its idle timeout", + source.display(), + staged.display() + ) + })?; + } + Ok(()) +} + +/// Compares content rather than timestamps, because every rebuild re-copies an +/// identical `wslcsdk.dll` into the profile directory. +fn is_already_staged(source: &Path, staged: &Path) -> bool { + let Ok(source) = std::fs::read(source) else { + return false; + }; + let Ok(staged) = std::fs::read(staged) else { + return false; + }; + source == staged +} + +fn staging() -> &'static Result<(), String> { + static STAGED: OnceLock> = OnceLock::new(); + STAGED.get_or_init(stage_runtime_units) +} + +fn host_supports_wslc() -> bool { + mxc_sdk::available_backends() + .iter() + .any(|b| b.backend == "wslc") +} + +/// A skipped test reports as a pass, so a fully-skipped suite looks like one +/// that ran. `MXC_WSLC_TESTS_REQUIRED=1` turns every skip into a failure. +fn skips_are_failures() -> bool { + matches!( + std::env::var("MXC_WSLC_TESTS_REQUIRED").as_deref(), + Ok("1") | Ok("true") + ) +} + +macro_rules! skip_unless_supported { + () => { + if let Err(reason) = staging() { + assert!( + !skips_are_failures(), + "MXC_WSLC_TESTS_REQUIRED is set, but the WSLC runtime units are not beside \ + this test binary: {reason}" + ); + eprintln!("skipping: {reason} (set MXC_WSLC_TESTS_REQUIRED=1 to make skips fail)"); + return; + } + if !host_supports_wslc() { + assert!( + !skips_are_failures(), + "MXC_WSLC_TESTS_REQUIRED is set, but available_backends() does not report the \ + WSLC backend. That needs a build with the wslc feature and a host running \ + WSL2 with the WSLC runtime." + ); + eprintln!( + "skipping: WSLC is not available on this host \ + (set MXC_WSLC_TESTS_REQUIRED=1 to make skips fail)" + ); + return; + } + }; +} + +/// Concurrent WSLC sessions sharing an image store fail against each other. +fn host_lock() -> MutexGuard<'static, ()> { + static LOCK: Mutex<()> = Mutex::new(()); + LOCK.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +/// Provision mints a real container; a failure before deprovision leaves it on +/// the host. +struct Teardown(Option); + +impl Teardown { + /// Gives up ownership after the test has deprovisioned itself, which is not + /// idempotent — a second call fails the id as unprovisioned. + fn defuse(mut self) { + self.0 = None; + } +} + +impl Drop for Teardown { + fn drop(&mut self) { + let Some(id) = self.0.take() else { + return; + }; + let _ = sandbox::stop( + &id, + LifecycleRequest::new(VERSION), + OperationOptions::default(), + ); + if let Err(e) = sandbox::deprovision( + &id, + LifecycleRequest::new(VERSION), + OperationOptions::default(), + ) { + eprintln!("WARNING: deprovision of {id} failed, the container may leak: {e:?}"); + } + } +} + +fn test_image() -> String { + std::env::var("MXC_WSLC_TEST_IMAGE").unwrap_or_else(|_| "alpine:latest".to_string()) +} + +fn provision() -> (SandboxId, Teardown) { + let request = ProvisionRequest::wslc(VERSION, Some(test_image()), None); + let provisioned = sandbox::provision(request, OperationOptions::default()) + .expect("provision must succeed with the test image cached"); + let sandbox_id = provisioned.sandbox_id; + let teardown = Teardown(Some(sandbox_id.clone())); + (sandbox_id, teardown) +} + +fn provision_and_start() -> (SandboxId, Teardown) { + let (sandbox_id, teardown) = provision(); + sandbox::start( + &sandbox_id, + LifecycleRequest::new(VERSION), + OperationOptions::default(), + ) + .expect("start must succeed"); + (sandbox_id, teardown) +} + +fn exec_capture(sandbox_id: &SandboxId, command: &str) -> Output { + let mut request = ExecRequest::new(VERSION, command); + request.set_timeout(EXEC_TIMEOUT_MS); + sandbox::exec(sandbox_id, request, OperationOptions::default()) + .expect("exec must return a handle") + .wait_with_output() + .expect("waiting on the exec must succeed") +} + +/// Staging is what makes the backend reachable from a test binary. +#[test] +fn available_backends_reports_wslc_to_a_test_binary() { + skip_unless_supported!(); + + let reported: Vec = mxc_sdk::available_backends() + .iter() + .map(|b| b.backend.clone()) + .collect(); + assert!( + reported.iter().any(|backend| backend == "wslc"), + "available_backends() reported {reported:?}" + ); +} + +#[test] +fn provision_returns_a_usable_sandbox_id() { + skip_unless_supported!(); + let _serialized = host_lock(); + + let (sandbox_id, _teardown) = provision(); + + // Post-provision phases resolve the backend from this prefix. + assert!( + sandbox_id.as_str().starts_with("wslc:"), + "provision returned {sandbox_id}" + ); +} + +#[test] +fn start_and_exec_return_the_workload_output() { + skip_unless_supported!(); + let _serialized = host_lock(); + + let (sandbox_id, _teardown) = provision_and_start(); + let output = exec_capture(&sandbox_id, "echo rust-wslc-marker"); + + assert_eq!(output.outcome, WaitOutcome::Exited(0)); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("rust-wslc-marker"), + "exec stdout did not carry the marker, got: {stdout:?}" + ); +} + +#[test] +fn exec_propagates_a_non_zero_exit_code() { + skip_unless_supported!(); + let _serialized = host_lock(); + + let (sandbox_id, _teardown) = provision_and_start(); + let output = exec_capture(&sandbox_id, "exit 7"); + + assert_eq!(output.outcome, WaitOutcome::Exited(7)); +} + +#[test] +fn deprovision_retires_the_sandbox_id() { + skip_unless_supported!(); + let _serialized = host_lock(); + + let (sandbox_id, teardown) = provision_and_start(); + sandbox::stop( + &sandbox_id, + LifecycleRequest::new(VERSION), + OperationOptions::default(), + ) + .expect("stop must succeed"); + sandbox::deprovision( + &sandbox_id, + LifecycleRequest::new(VERSION), + OperationOptions::default(), + ) + .expect("deprovision must succeed"); + teardown.defuse(); + + let error = sandbox::start( + &sandbox_id, + LifecycleRequest::new(VERSION), + OperationOptions::default(), + ) + .expect_err("a deprovisioned id must not start"); + assert_eq!(error.code, ErrorCode::NotProvisioned); +}