From a31d4868cf46e790f567adaf5eaa2b116c9fbb8a Mon Sep 17 00:00:00 2001 From: Soham Das Date: Tue, 29 Sep 2026 16:04:22 -0700 Subject: [PATCH 1/5] [LXC] Route the streaming backend through the engine and report it in discovery Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/Build.Linux.Job.yml | 38 ++ .github/workflows/SDK.Dotnet.Test.Job.yml | 57 ++ .../workflows/SDK.Integration.Test.Job.yml | 6 +- .github/workflows/lxc-e2e.yml | 16 + docs/ci-validation-infrastructure.md | 2 +- docs/lxc-support/lxc-backend.md | 51 +- docs/pull-requests.md | 20 + sdk/dotnet/Microsoft.Mxc.Sdk.Tests/LxcHost.cs | 42 ++ .../MxcSandboxLxcE2ETests.cs | 98 ++++ sdk/dotnet/README.md | 8 +- .../integration/native-streaming.test.ts | 150 ++++- src/Cargo.lock | 1 + src/core/mxc-sdk/Cargo.toml | 5 +- src/core/mxc-sdk/README.md | 16 +- src/core/mxc-sdk/src/lib.rs | 8 +- src/core/mxc-sdk/tests/sdk_helpers.rs | 48 +- src/core/mxc-sdk/tests/streaming_lxc.rs | 528 ++++++++++++++++++ src/core/mxc_engine/src/dispatch.rs | 95 +++- src/core/mxc_engine/src/platform.rs | 111 +++- src/ffi/mxc_ffi/tests/ffi.rs | 40 ++ 20 files changed, 1283 insertions(+), 57 deletions(-) create mode 100644 sdk/dotnet/Microsoft.Mxc.Sdk.Tests/LxcHost.cs create mode 100644 sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcSandboxLxcE2ETests.cs create mode 100644 src/core/mxc-sdk/tests/streaming_lxc.rs diff --git a/.github/workflows/Build.Linux.Job.yml b/.github/workflows/Build.Linux.Job.yml index 23c95781e..c079d9a7c 100644 --- a/.github/workflows/Build.Linux.Job.yml +++ b/.github/workflows/Build.Linux.Job.yml @@ -118,6 +118,44 @@ jobs: cargo test --locked --release --target "${{ matrix.target }}" -p mxc_engine -p mxc_ffi --lib state_aware cargo test --locked --release --target "${{ matrix.target }}" -p mxc-sdk --test state_aware + # The LXC streaming arm and the discovery matrix it feeds are Linux-only + # code that no other lane compiles, let alone runs. None of these need LXC + # installed: the engine tests inject their probes, and the dispatch and + # FFI pins are refused inside LXC's own preparation before a container is + # created. The live suite that does need LXC runs in lxc-e2e.yml. + # + # Each run is checked for a non-zero pass count because `cargo test` with + # a name filter that matches nothing also exits 0, so a renamed test would + # otherwise turn this gate into a no-op. + - name: Test LXC dispatch and discovery + working-directory: src + shell: bash + run: | + # No `-e`: the pipeline's status is checked explicitly below so a + # failure reports which pinned run failed, rather than aborting mute. + set -uo pipefail + run_pinned() { + local label="$1"; shift + local log status + log="$(mktemp)" + cargo test --locked --release --target "${{ matrix.target }}" "$@" 2>&1 | tee "$log" + # `tee` succeeds even when cargo does not, so read cargo's own + # status before trusting the summary below. + status=${PIPESTATUS[0]} + if [ "$status" -ne 0 ]; then + echo "::error::$label failed (cargo exited $status)." + exit "$status" + fi + if ! grep -qE 'test result: ok\. [1-9][0-9]* passed' "$log"; then + echo "::error::$label ran no test; the filter no longer matches anything." + exit 1 + fi + } + run_pinned "LXC discovery matrix" -p mxc_engine --lib linux_support + run_pinned "LXC dispatch arm" -p mxc_engine --lib streaming_lxc_reaches_the_backend_on_linux + run_pinned "LXC FFI routing" -p mxc_ffi --test ffi extern_spawn_request_reaches_the_lxc_backend + run_pinned "SDK platform helpers" -p mxc-sdk --test sdk_helpers + # Runs the Bubblewrap executor characterization tests. lxc-exec was built # into src/target//release above, where find_binary() locates it. - name: Test executor characterization (wxc_e2e_tests) diff --git a/.github/workflows/SDK.Dotnet.Test.Job.yml b/.github/workflows/SDK.Dotnet.Test.Job.yml index 0e2a36a97..e8837f4f2 100644 --- a/.github/workflows/SDK.Dotnet.Test.Job.yml +++ b/.github/workflows/SDK.Dotnet.Test.Job.yml @@ -62,6 +62,63 @@ jobs: } Write-Host "$($Matches[1]) tests passed" + # LXC creates, starts, and attaches to a system container, so the suite + # above can only skip it. This installs the substrate and reruns just + # those tests as root. + - name: Install LXC + if: matrix.os_label == 'linux' + shell: bash + run: | + set -euo pipefail + # Runner images can carry package indexes referencing versions the + # mirrors have already dropped, so a single update is a flaky gate. + for attempt in 1 2 3; do + if sudo apt-get -o Acquire::Retries=3 update; then + break + fi + if [ "$attempt" -eq 3 ]; then + echo "::error::Failed to refresh Linux package indexes after $attempt attempts" + exit 1 + fi + sleep 5 + done + sudo DEBIAN_FRONTEND=noninteractive apt-get -o Acquire::Retries=3 install -y \ + lxc lxc-templates lxc-utils iptables debootstrap uidmap bridge-utils + + # The policy these tests use permits no network, so the container starts + # with no interface and the runner's Docker-set FORWARD policy cannot + # reach them. + # + # MXC_LXC_TESTS_REQUIRE_EXECUTION turns an honest skip into a failure: + # this step exists to execute them, so a skip means a prerequisite + # disappeared and the gate would go green having tested nothing. The + # count is checked for the same reason: a filter that selects no test + # also exits 0. + - name: Test the LXC backend + if: matrix.os_label == 'linux' + shell: bash + env: + MXC_LXC_TESTS_REQUIRE_EXECUTION: "1" + run: | + set -uo pipefail + log="$RUNNER_TEMP/dotnet-test-lxc.log" + sudo --preserve-env=MXC_LXC_TESTS_REQUIRE_EXECUTION,PATH,HOME,DOTNET_ROOT \ + "$(command -v dotnet)" test \ + --project Microsoft.Mxc.Sdk.Tests/Microsoft.Mxc.Sdk.Tests.csproj \ + --no-build --no-restore -- --filter-method '*MxcSandboxLxcE2ETests*' 2>&1 | tee "$log" + # `tee` succeeds even when the run beneath it fails, and a partially + # passing run still prints a non-zero succeeded count, so the run's + # own status has to be read before the summary is trusted. + status=${PIPESTATUS[0]} + if [ "$status" -ne 0 ]; then + echo "::error::dotnet test exited $status" + exit "$status" + fi + if ! grep -qE 'succeeded: *[1-9]' "$log"; then + echo '::error::The filter selected no LXC test, so this gate verified nothing.' + exit 1 + fi + # Exercise the opt-in backends as one complete native unit. The managed # tests dry-run both backends and verify WSLC's daemon + SDK DLL were # propagated into the application output. diff --git a/.github/workflows/SDK.Integration.Test.Job.yml b/.github/workflows/SDK.Integration.Test.Job.yml index 7e7fb36c4..22b0aaccb 100644 --- a/.github/workflows/SDK.Integration.Test.Job.yml +++ b/.github/workflows/SDK.Integration.Test.Job.yml @@ -200,11 +200,15 @@ jobs: # Linux needs root for Bubblewrap unprivileged-userns paths; `sudo -E` # preserves the env vars above. + # + # This lane installs LXC and starts lxc-net specifically so the LXC + # backend is covered, so MXC_LXC_TESTS_REQUIRE_EXECUTION turns an LXC + # skip into a failure here rather than a green job that tested nothing. - name: npm test shell: bash run: | if [ "${{ matrix.os_label }}" = "linux" ]; then - sudo -E npm test + sudo -E MXC_LXC_TESTS_REQUIRE_EXECUTION=1 npm test else npm test fi diff --git a/.github/workflows/lxc-e2e.yml b/.github/workflows/lxc-e2e.yml index a7eb27db1..cabc358e8 100644 --- a/.github/workflows/lxc-e2e.yml +++ b/.github/workflows/lxc-e2e.yml @@ -114,6 +114,20 @@ jobs: MXC_LXC_TESTS_REQUIRE_EXECUTION: "1" run: sudo --preserve-env=MXC_LXC_TESTS_REQUIRE_EXECUTION,PATH,HOME "$(command -v cargo)" test -p wxc_e2e_tests --test e2e_lxc_network_capability + # `lxc-exec` routes through `mxc_engine::run` and never reaches + # `spawn_sandbox`, so no shell script can drive the streaming handle. + # This lane already has LXC installed, and these cases need root to + # create and start a container. + # + # `sdk_helpers` runs here too: this is the only lane where + # `platform_support()` sees a host with LXC, so it is the only one that + # exercises the LXC side of the discovery contract. + - name: Run LXC streaming SDK tests + working-directory: src + env: + MXC_LXC_TESTS_REQUIRE_EXECUTION: "1" + run: sudo --preserve-env=MXC_LXC_TESTS_REQUIRE_EXECUTION,PATH,HOME "$(command -v cargo)" test -p mxc-sdk --test streaming_lxc --test sdk_helpers + - name: Show leftover firewall state on failure if: failure() run: | @@ -123,6 +137,8 @@ jobs: echo "--- MXC chains ---" sudo iptables -S | grep -E '^-N MXC-' || echo "none" sudo ip6tables -S | grep -E '^-N MXC-' || echo "none" + echo "--- leftover containers ---" + sudo lxc-ls -f || echo "none" - name: Upload logs on failure if: failure() || cancelled() diff --git a/docs/ci-validation-infrastructure.md b/docs/ci-validation-infrastructure.md index bf507cbdc..db4486883 100644 --- a/docs/ci-validation-infrastructure.md +++ b/docs/ci-validation-infrastructure.md @@ -226,7 +226,7 @@ get fixed or wired. | Process T1 | ✅ Good | Windows 24H2+ only. Runs the primitives suite, tier-gated to `base-container`. Includes the schema 0.8 directional networking phases (capability matrix, model-3 equivalence, explicit egress rules, host loopback, runtime proxy, reject surface) and the legacy 0.7 network lane. Remaining failures are genuine MXC bugs or harness limitations. | | Process T3 | ✅ Good | Windows 23H2 only. Runs the primitives suite tier-gated to `appcontainer-dacl`, plus `T3-Workloads.ps1` (real programs — pwsh, git, node, python, cmd — on top of the T3 primitives). The 0.8 networking phases assert the documented *rejection* behavior here, since AppContainer cannot carry egress rules, proxy peer identity, or host-loopback configuration. | | Bubblewrap | ✅ Good | | -| LXC | ✅ Good | Some networking tests fail on distros other than Ubuntu 24.04; seems to be an issue with MXC. | +| LXC | ✅ Good | Some networking tests fail on distros other than Ubuntu 24.04; seems to be an issue with MXC. The in-process streaming handle is covered at PR time instead, by `lxc-e2e.yml` (see [`pull-requests.md`](pull-requests.md)), because this matrix runs prebuilt binaries and `lxc-exec` never reaches `spawn_sandbox`. | | WSLC | ✅ Good | Might have to retry hung jobs - this is an issue with overzealous agent reclaiming. | | IsolationSession | ✅ Good | Runs the one-shot and state-aware suites, the Rust SDK in-process and helper tests, the C# end-to-end tests, the Node SDK suite and the COM apartment probe. Fails when the host cannot run isolation sessions, when a suite executes nothing, or when the run changes the set of local accounts. | | Windows Sandbox | ⛔ Blocked | Images don't support `Containers-DisposableClientVM` opt. feature | diff --git a/docs/lxc-support/lxc-backend.md b/docs/lxc-support/lxc-backend.md index 2ba8693ac..75c3cef7b 100644 --- a/docs/lxc-support/lxc-backend.md +++ b/docs/lxc-support/lxc-backend.md @@ -209,6 +209,7 @@ import { } from '@microsoft/mxc-sdk'; const policy: SandboxPolicy = { + version: '0.8.0-alpha', filesystem: { readwritePaths: ['/tmp/output'], readonlyPaths: ['/opt/tools'], @@ -229,6 +230,51 @@ pty.onData((data) => console.log(data)); pty.onExit((e) => console.log('Exit:', e.exitCode)); ``` +## Streaming + +LXC implements `SandboxBackend`, so `mxc_sdk::spawn_sandbox`, `mxc_sdk::run`, +and every SDK built on `mxc_spawn_request` / `mxc_run_request` reach it +in-process. The handle serves live stdin, stdout, and stderr, plus `wait` and +`kill`. + +**Pipes, not a pty.** The streaming path wires the workload to ordinary pipes, +so `isatty()` is false inside the container. The `lxc-exec` binary is +unchanged: it allocates a pty and bridges it to the host's stdio, which is why +an interactive shell still renders under it and not here. + +**`StdioMode::Inherit` is refused.** Handing the workload the host's own stdio +means `mxc_pty`, which runs to completion and cannot return a handle. It also +reads the host's stdin and installs a process-wide window-size handler, neither +of which a library may do to its caller. Stream over pipes, or run `lxc-exec`. + +**`kill()` stops the container.** The workload runs in the container's PID +namespace under container init, so nothing aimed at the host `lxc-attach` +process or its process group reaches it — including a descendant the workload +backgrounded. `lxc-stop -k` is what reaches them, and it takes the network +namespace down with the workload rather than after it. It stops the container +rather than releasing it; the release happens when the run reaches a terminal +path below. + +**One live sandbox per container name, per process.** A second sandbox naming a +`containerId` this process already holds is refused rather than queued, because +LXC reads a run's network section only when the container starts — serving the +second would mean stopping the first one's workload to restart it under a +different policy. Omit `containerId` to get a generated name instead. The claim +is process-local and released when the handle drops, so another process, +including an `lxc-exec` run, can still take the same container. + +**Teardown is owed on every terminal path.** Completing, timing out, and being +dropped without a `wait` all remove the `/etc/hosts` proxy pin, the egress and +ingress chains, and the container itself. A teardown step that fails after a +`wait` is reported through `Sandbox::warnings`; after a bare drop there is no +handle left to report through, so a caller that wants to see those failures has +to wait. + +**The streaming path holds root for as long as the handle lives.** `lxc-exec` +is a short-lived process; an SDK host streaming a sandbox keeps a +root-privileged container open for the length of the session. Weigh that +against the threat model before embedding it in a long-lived service. + ## Building ```bash @@ -329,6 +375,5 @@ The zone query should answer the zone you assigned. state-aware request is rejected. - **Streaming gives pipes, not a terminal.** The `SandboxBackend` path wires stdin, stdout, and stderr to pipes and refuses `StdioMode::Inherit`, so the - workload sees `isatty() == false`; the `lxc-exec` binary keeps its pty. The - engine does not route to this path yet, so the in-process SDK APIs still - report `UnsupportedContainment` for LXC. + workload sees `isatty() == false`; the `lxc-exec` binary keeps its pty. See + [Streaming](#streaming). diff --git a/docs/pull-requests.md b/docs/pull-requests.md index ac1407e40..7bc364f8a 100644 --- a/docs/pull-requests.md +++ b/docs/pull-requests.md @@ -9,6 +9,26 @@ it fans out to the reusable `Build.Windows.Job.yml`, `Build.Linux.Job.yml`, and x64/arm64, Linux x64/arm64, and macOS arm64 hosts, then runs the lint, versioning, and SDK jobs. +### LXC (`lxc-e2e.yml`) + +A separate workflow, because the primary Linux lane does not install LXC and +does not run as root. It triggers on PRs targeting `main`, so a PR stacked on +another branch gets no LXC gating until it is retargeted — dispatch it manually +for a stacked head. It runs, in order: + +| Step | What it covers | +|------|----------------| +| `tests/scripts/run_lxc_all_tests.sh` | The shell suites, driving the `lxc-exec` binary end to end. | +| `cargo test -p wxc_e2e_tests --test e2e_lxc_network_capability` | That the attached workload has `CAP_NET_ADMIN` dropped. | +| `cargo test -p mxc-sdk --test streaming_lxc --test sdk_helpers` | The in-process streaming handle: live stdio, exit codes, container-scoped kill, timeout, concurrent-name refusal, and that every terminal path releases the container. `sdk_helpers` rides along because this is the only lane where `platform_support()` sees a host with LXC. `lxc-exec` routes through `mxc_engine::run` and never reaches `spawn_sandbox`, so no shell script can cover this. | + +All three set `MXC_LXC_TESTS_REQUIRE_EXECUTION=1`, which turns a skipped +prerequisite into a failure. Without it a lane provisioned for LXC could go +green having run nothing. + +The .NET binding's LXC tests run in `SDK.Dotnet.Test.Job.yml`, which installs +LXC on its Linux leg and reruns just those tests as root. + ## Azure Pipelines (optional on PRs, required on `main`) The ADO pipeline (`MXC-PR-Build`) is the Azure version of the PR pipeline. The official diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/LxcHost.cs b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/LxcHost.cs new file mode 100644 index 000000000..20895d087 --- /dev/null +++ b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/LxcHost.cs @@ -0,0 +1,42 @@ +// 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 LXC suites share. A live run needs Linux, an +/// installed LXC, and root, because LXC creates, starts, and attaches to a +/// system container. +/// +internal static class LxcHost +{ + // 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(() => + OperatingSystem.IsLinux() + && Environment.IsPrivilegedProcess + && MxcSandbox.GetAvailableBackends() + .Any(b => b.Backend == ContainmentBackend.Lxc)); + + // Without this, a run in which everything skipped is indistinguishable from + // one that passed. The same variable the Rust and shell LXC suites honour. + private static bool SkipsAreFailures => + Environment.GetEnvironmentVariable("MXC_LXC_TESTS_REQUIRE_EXECUTION") is "1" or "true"; + + /// Skips the calling test when LXC is unavailable, or fails it when + /// skips have been declared failures. + internal static void Require() + { + Assert.False( + SkipsAreFailures && !Available.Value, + "MXC_LXC_TESTS_REQUIRE_EXECUTION is set, but this host cannot run LXC. " + + "That needs Linux, root, and an installed LXC that " + + "GetAvailableBackends() reports."); + Assert.SkipUnless( + Available.Value, + "this host has no LXC backend, or the test is not running as root"); + } +} diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcSandboxLxcE2ETests.cs b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcSandboxLxcE2ETests.cs new file mode 100644 index 000000000..5b008ed03 --- /dev/null +++ b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcSandboxLxcE2ETests.cs @@ -0,0 +1,98 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text; +using Microsoft.Mxc.Sdk; +using Xunit; + +namespace Microsoft.Mxc.Sdk.Tests; + +/// +/// Runs LXC sandboxes against a live host through this binding. +/// +/// +/// The engine's own suite covers the backend; these establish that the managed +/// type, the request envelope, the C ABI and the native library agree, which is +/// the part no Rust test can see. LXC reaches the binding with no C# production +/// code of its own, so without these the path is unverified. +/// +[Collection("MxcLiveHost")] +public class MxcSandboxLxcE2ETests +{ + // `lxc-attach` runs the command through `/bin/sh -c`, so these are bare + // shell lines. A policy that permits no network starts the container with + // no interface, which skips the DHCP wait a networked run pays for. + // + // The timeout bounds the backend's own wait: with none, a wedged attach + // would hang this suite until the CI job's cap. A healthy workload here + // finishes in well under a second, so it only ever fires on a failure. + private const int WaitBoundMs = 180_000; + + private static SandboxRequest Request(string command, string containerName) => + new( + new SandboxPolicy { Version = "0.7.0-alpha", TimeoutMs = WaitBoundMs }, + command) + { + ContainerName = containerName, + Containment = new LxcContainment(), + }; + + [Fact] + public void Run_ReturnsTheWorkloadsOutputAndExitCode() + { + LxcHost.Require(); + + var result = MxcSandbox.Run( + Request("printf 'mxc_lxc_run_ok\\n'; exit 3", "mxc-dotnet-run")); + + Assert.False(result.TimedOut); + Assert.Equal(3, result.ExitCode); + Assert.Contains("mxc_lxc_run_ok", result.Stdout, StringComparison.Ordinal); + } + + [Fact] + public void Spawn_StreamsStandardOutputBeforeTheWorkloadExits() + { + LxcHost.Require(); + + // Blocking on stdin: the workload cannot reach its exit until this test + // lets it, so reading the first line proves the output was streamed + // rather than buffered until completion. + using var proc = MxcSandbox.Spawn( + Request( + "printf 'mxc_lxc_stream_ok\\n'; IFS= read -r _; printf 'mxc_lxc_done\\n'", + "mxc-dotnet-spawn")); + + var stdin = proc.StandardInput; + var stdout = proc.StandardOutput; + Assert.NotNull(stdin); + Assert.NotNull(stdout); + + using var reader = new StreamReader(stdout!, Encoding.UTF8); + var first = ReadLineWithin(reader, TimeSpan.FromMinutes(3)); + Assert.Contains("mxc_lxc_stream_ok", first, StringComparison.Ordinal); + Assert.False( + proc.TryGetExitCode(out _), + "the first line arrived only after the workload exited, so it was not streamed"); + + using (var writer = new StreamWriter(stdin!, Encoding.UTF8)) + { + writer.Write("continue\n"); + } + + var result = proc.Wait(); + Assert.False(result.TimedOut); + Assert.Equal(0, result.ExitCode); + } + + /// + /// Reads one line on a worker thread, so a stream that never delivers fails + /// here rather than hanging the job until its workflow timeout. + /// + private static string ReadLineWithin(StreamReader reader, TimeSpan deadline) + { + var read = Task.Run(reader.ReadLine); + Assert.True(read.Wait(deadline), $"no output within {deadline}"); + return read.Result ?? string.Empty; + } +} diff --git a/sdk/dotnet/README.md b/sdk/dotnet/README.md index 90a30e82d..22cd908bd 100644 --- a/sdk/dotnet/README.md +++ b/sdk/dotnet/README.md @@ -377,9 +377,11 @@ request.Containment = new LxcContainment }; ``` -The managed SDK can represent LXC settings, but its in-process `Run`, -`RunAsync`, and `Spawn` surfaces reject LXC because the backend does not expose -captured pipe-based execution. Use the standalone `lxc-exec` binary for LXC. +`Run`, `RunAsync`, and `Spawn` all serve LXC in-process on Linux. It needs +root, and its stdio is pipes rather than a pty, so the workload sees no +terminal — unlike the standalone `lxc-exec` binary, which allocates one. Two +live sandboxes in one process cannot share a `ContainerName`; the second is +refused. #### WSL Container options diff --git a/sdk/node/tests/integration/native-streaming.test.ts b/sdk/node/tests/integration/native-streaming.test.ts index ec4daa04e..f9ca819da 100644 --- a/sdk/node/tests/integration/native-streaming.test.ts +++ b/sdk/node/tests/integration/native-streaming.test.ts @@ -14,6 +14,7 @@ import { debugSpawnOptions, getSdkPackageRoot, isLinuxBubblewrap, + isLinuxRoot, sandboxSkipReason, sdk, supportedVersions, @@ -24,6 +25,8 @@ interface NativeSandbox { readonly standardOutput: Readable | null; readonly standardError: Readable | null; waitAsync(): Promise<{ exitCode: number; timedOut: boolean }>; + kill(): void; + dispose(): void; } interface RequestModule { @@ -45,17 +48,53 @@ const nativeStreamingNodeRequirement = os.platform() === 'win32' const supportsNativeStreamingRuntime = os.platform() === 'win32' ? semver.satisfies(process.version, '>=24.21.0 <25 || >=26.8.0') : semver.gte(process.version, '24.0.0'); -const skipReason = - sandboxSkipReason ?? + +// Everything that stops native streaming from running at all, before any +// particular backend is chosen. +const nativeStreamingSkipReason = (!platformSupport.isSupported ? `Platform not supported: ${platformSupport.reason}` : undefined) ?? (!supportsNativeStreamingRuntime ? `Native streaming on ${os.platform()} requires ` + nativeStreamingNodeRequirement - : undefined) ?? + : undefined); + +// The cases below build their config from an abstract policy, which resolves +// to Bubblewrap on Linux. +const skipReason = + sandboxSkipReason ?? + nativeStreamingSkipReason ?? (os.platform() === 'linux' && !isLinuxBubblewrap ? 'Native streaming requires Bubblewrap on Linux' : undefined); +// Gated like the other LXC suites rather than on MXC_SKIP_OS_BUILD_DEPENDENT_TESTS: +// the Linux integration lane installs LXC and runs under sudo specifically so +// the LXC backend gets covered there. +const lxcSkipReason = + nativeStreamingSkipReason ?? + (os.platform() !== 'linux' ? 'LXC streaming is available on Linux only' : undefined) ?? + (!isLinuxRoot + ? 'LXC creates, starts, and attaches to a system container, which needs root (sudo npm test)' + : undefined) ?? + (!platformSupport.availableMethods.includes('lxc') + ? 'LXC is not installed on this host' + : undefined) ?? + (process.env.MXC_SKIP_LXC_TESTS === '1' + ? 'Skipped: LXC tests disabled (MXC_SKIP_LXC_TESTS)' + : undefined); + +// A lane provisioned to execute these reports a skip as success, so the gate +// would go green having tested nothing. The same switch the Rust and .NET LXC +// suites read. +const lxcExecutionRequired = + process.env.MXC_LXC_TESTS_REQUIRE_EXECUTION !== undefined && + process.env.MXC_LXC_TESTS_REQUIRE_EXECUTION !== '0'; +if (lxcExecutionRequired && lxcSkipReason) { + throw new Error( + `MXC_LXC_TESTS_REQUIRE_EXECUTION is set, but the LXC streaming tests would skip: ${lxcSkipReason}`, + ); +} + describe(`Internal native streaming (schema ${schemaVersion})`, { skip: skipReason }, () => { it('delivers output before the sandbox exits', { timeout: 30000 }, async () => { const packageRoot = getSdkPackageRoot(); @@ -151,3 +190,108 @@ describe(`Internal native streaming (schema ${schemaVersion})`, { skip: skipReas assert.strictEqual(result.exitCode, 0); }); }); + +// LXC is reachable in-process only through an explicit `containment: 'lxc'`; +// the abstract policy the block above uses resolves to Bubblewrap on Linux. +describe(`Internal native streaming over LXC (schema ${schemaVersion})`, { + skip: lxcSkipReason, +}, () => { + // Creating and starting a container, and destroying it again afterwards, is + // far slower than spawning a process. + const LXC_TEST_TIMEOUT = 300000; + + // Shorter than the framework's own timeout on purpose. node:test abandons a + // timed-out pending test without unwinding its `finally`, so a stalled stream + // would leave the root-owned container running; rejecting from inside the + // `try` keeps the cleanup reachable. + const LXC_STEP_TIMEOUT = 240000; + + function within(work: Promise, what: string): Promise { + let timer: NodeJS.Timeout; + const deadline = new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error(`${what} did not settle within ${LXC_STEP_TIMEOUT}ms`)), + LXC_STEP_TIMEOUT, + ); + }); + return Promise.race([work, deadline]).finally(() => clearTimeout(timer)) as Promise; + } + + it('delivers output before the sandbox exits', { timeout: LXC_TEST_TIMEOUT }, async () => { + const packageRoot = getSdkPackageRoot(); + const requestModule = await import(pathToFileURL( + path.join(packageRoot, 'dist', 'bindings', 'request.js'), + ).href) as RequestModule; + const streamingModule = await import(pathToFileURL( + path.join(packageRoot, 'dist', 'bindings', 'streaming.js'), + ).href) as StreamingModule; + + // `lxc-attach` runs the command through `/bin/sh -c`, and the container is + // a BusyBox image. + const command = "printf 'STREAM_FIRST\\n'; IFS= read -r _; " + + "printf 'STREAM_SECOND\\n'; printf 'STREAM_ERROR\\n' >&2"; + // A policy permitting no network starts the container with no interface, + // so this does not wait out a DHCP lease it would never use. + const config = sdk.createConfigFromPolicy( + { version: schemaVersion.raw }, + 'lxc', + `mxc-node-stream-${process.pid}`, + ); + config.process!.commandLine = command; + const request = requestModule.prepareRequestSpec(config, { + experimental: debugSpawnOptions.experimental, + }); + const sandbox = await streamingModule.spawnBindingSandboxProcess(request); + // The workload blocks on stdin, so a failed assertion below would abandon a + // running root-owned container whose per-PID name no later run can find. + let settled = false; + try { + const standardInput = sandbox.standardInput; + const standardOutput = sandbox.standardOutput; + const standardError = sandbox.standardError; + assert.ok(standardInput, 'streaming stdin should be available'); + assert.ok(standardOutput, 'streaming stdout should be available'); + assert.ok(standardError, 'streaming stderr should be available'); + + let stdout = ''; + let stderr = ''; + let resolveFirstChunk: (() => void) | undefined; + const firstChunk = new Promise((resolve) => { + resolveFirstChunk = resolve; + }); + standardOutput.on('data', (data: Buffer) => { + stdout += data.toString(); + if (stdout.includes('STREAM_FIRST')) { + resolveFirstChunk?.(); + } + }); + standardError.on('data', (data: Buffer) => { + stderr += data.toString(); + }); + + let completed = false; + const wait = sandbox.waitAsync().then((result) => { + completed = true; + return result; + }); + const outputEnded = once(standardOutput, 'end'); + const errorEnded = once(standardError, 'end'); + await within(firstChunk, 'the first output chunk'); + assert.strictEqual(completed, false, 'first output should arrive before process completion'); + standardInput.end('continue\n'); + + const result = await within(wait, 'the sandbox wait'); + settled = true; + await within(Promise.all([outputEnded, errorEnded]), 'the stream end events'); + assert.strictEqual(result.exitCode, 0, stderr); + assert.ok(stdout.includes('STREAM_FIRST')); + assert.ok(stdout.includes('STREAM_SECOND')); + assert.ok(stderr.includes('STREAM_ERROR')); + } finally { + if (!settled) { + sandbox.kill(); + } + sandbox.dispose(); + } + }); +}); diff --git a/src/Cargo.lock b/src/Cargo.lock index bd47b425b..8356b6208 100644 --- a/src/Cargo.lock +++ b/src/Cargo.lock @@ -1579,6 +1579,7 @@ version = "0.9.0" dependencies = [ "bwrap_common", "libc", + "lxc_common", "mxc_engine", "serde_json", "wxc_common", diff --git a/src/core/mxc-sdk/Cargo.toml b/src/core/mxc-sdk/Cargo.toml index de7bd7559..271dec420 100644 --- a/src/core/mxc-sdk/Cargo.toml +++ b/src/core/mxc-sdk/Cargo.toml @@ -39,6 +39,9 @@ required-features = ["isolation_session"] [target.'cfg(target_os = "macos")'.dev-dependencies] libc = { workspace = true } -# Skip gate for the Bubblewrap streaming tests. +# Skip gates for the Linux streaming tests, and the backend probes +# `platform_support`'s reported set is asserted against. [target.'cfg(target_os = "linux")'.dev-dependencies] bwrap_common = { workspace = true } +libc = { workspace = true } +lxc_common = { workspace = true } diff --git a/src/core/mxc-sdk/README.md b/src/core/mxc-sdk/README.md index 415fb3340..93dfa44fc 100644 --- a/src/core/mxc-sdk/README.md +++ b/src/core/mxc-sdk/README.md @@ -140,8 +140,9 @@ let request = build_request_with_containment( # Ok::<(), mxc_sdk::Error>(()) ``` -This models the LXC request for configuration parity. The in-process `run` and -`spawn_sandbox` APIs reject it; execute LXC requests with `lxc-exec`. +This runs through `run` and `spawn_sandbox` like any other backend. LXC needs +root, and it streams over pipes, so the workload sees no TTY — unlike the +`lxc-exec` binary, which allocates a pty. Filesystem-policy discovery helpers are also available to feed a policy: [`available_tools_policy`] (PATH + tool/SDK environment directories), @@ -506,6 +507,7 @@ default): | Host | Backend(s) | Selected by | |---------|-------------------------------------------------|----------------------------------| | Linux | Bubblewrap | `Containment::Process` or `Containment::Bubblewrap` | +| Linux | LXC | `Containment::Lxc` | | macOS | Seatbelt | `Containment::Process` or `Containment::Seatbelt` | | Windows | ProcessContainer (AppContainer + BaseContainer) | `Containment::Process` | | Windows | Explicit ProcessContainer configuration | `Containment::ProcessContainer` | @@ -529,9 +531,13 @@ Hyperlight — cannot be selected with `build_request_with_containment`; use the executor binaries instead. Windows Sandbox state-aware lifecycle is also available through the raw exact-JSON entry points above. -`Containment::Lxc` models explicit LXC distribution settings, but `run` and -`spawn_sandbox` reject it because the LXC backend does not expose captured -pipe-based execution. Use the standalone `lxc-exec` binary for LXC. +`Containment::Lxc` names the LXC backend, served by `run` and `spawn_sandbox` +with piped stdio. `Containment::Process` resolves to Bubblewrap on Linux, so +LXC is reachable only by naming it. It needs root. Its stdio is pipes rather +than a pty, so the workload sees no TTY — unlike the `lxc-exec` binary, which +allocates one. `kill()` stops the whole container, which is the only way to +reach a workload in its PID namespace, and dropping the handle tears the +container down synchronously. ### WSLC diff --git a/src/core/mxc-sdk/src/lib.rs b/src/core/mxc-sdk/src/lib.rs index c1659ddeb..4b88013ff 100644 --- a/src/core/mxc-sdk/src/lib.rs +++ b/src/core/mxc-sdk/src/lib.rs @@ -38,16 +38,16 @@ //! | Backend | Host | Selected by | //! |---------|------|-------------| //! | Bubblewrap | Linux | [`Containment::Process`] or [`Containment::Bubblewrap`] | +//! | LXC | Linux | [`Containment::Lxc`] | //! | Seatbelt | macOS | [`Containment::Process`] or [`Containment::Seatbelt`] | //! | ProcessContainer (AppContainer / BaseContainer) | Windows | [`Containment::Process`] | //! | Explicit ProcessContainer configuration | Windows | [`Containment::ProcessContainer`] | //! | WSLC (WSL Container) | Windows | [`Containment::Wslc`] | //! | IsolationSession | Windows | [`Containment::IsolationSession`] | //! -//! [`Containment::Lxc`] models explicit LXC settings, but the in-process -//! [`run`] and [`spawn_sandbox`] APIs return -//! [`ErrorCode::UnsupportedContainment`] because LXC does not expose captured -//! pipe-based execution. Use the standalone `lxc-exec` binary for LXC. +//! LXC is reachable only by naming it: [`Containment::Process`] resolves to +//! Bubblewrap on Linux. It needs root, and it streams over pipes, so the +//! workload sees no TTY — unlike the `lxc-exec` binary, which allocates a pty. //! //! WSLC requires the crate's `wslc` build feature, and IsolationSession //! requires the `isolation_session` build feature. WSLC's container has no diff --git a/src/core/mxc-sdk/tests/sdk_helpers.rs b/src/core/mxc-sdk/tests/sdk_helpers.rs index 032b73b97..a27890580 100644 --- a/src/core/mxc-sdk/tests/sdk_helpers.rs +++ b/src/core/mxc-sdk/tests/sdk_helpers.rs @@ -207,16 +207,50 @@ fn build_request_then_run_seatbelt() { #[cfg(target_os = "linux")] #[test] -fn platform_support_linux_reports_only_bubblewrap() { +fn platform_support_linux_reports_the_backends_it_can_launch() { + // Asserted as invariants rather than by re-running the probes: an + // expectation rebuilt from the same calls `platform_support` makes has no + // independent oracle and cannot fail. The LXC-only and both-present + // matrices are pinned by the injected-probe tests in `mxc_engine`. let support = platform_support(); - // Bubblewrap is the only SDK-launchable Linux backend; `lxc` is a - // host-capability backend reported by `available_backends()`, not here. - // Assert the exact set so re-advertising a non-launchable backend fails. + + for method in &support.available_methods { + assert!( + matches!(method.as_str(), "lxc" | "bubblewrap"), + "only the two SDK-launchable Linux backends may be reported, got: {method}" + ); + } + assert_eq!( + support.available_methods.len(), + support + .available_methods + .iter() + .collect::>() + .len(), + "a backend must not be reported twice: {:?}", + support.available_methods + ); assert_eq!( - support.available_methods, - vec!["bubblewrap".to_string()], - "Linux platform_support must report exactly bubblewrap (lxc excluded)" + support.is_supported, + !support.available_methods.is_empty(), + "a host with a launchable backend must report itself supported, and one \ + without must not: {support:?}" ); + assert_eq!( + support.bubblewrap_network.is_some(), + support + .available_methods + .iter() + .any(|method| method == "bubblewrap"), + "the bubblewrap network capability is reported exactly when bubblewrap is: {support:?}" + ); + if support.available_methods.len() == 2 { + assert_eq!( + support.available_methods, + ["lxc", "bubblewrap"], + "the reported order must match the TypeScript SDK's" + ); + } } #[cfg(target_os = "windows")] diff --git a/src/core/mxc-sdk/tests/streaming_lxc.rs b/src/core/mxc-sdk/tests/streaming_lxc.rs new file mode 100644 index 000000000..be323f6db --- /dev/null +++ b/src/core/mxc-sdk/tests/streaming_lxc.rs @@ -0,0 +1,528 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! LXC streaming API tests: live stdio, exit-status fidelity, container-scoped +//! kill, timeout, and teardown after every terminal path. +//! +//! Linux-gated file, and a live one: every case creates, starts, and destroys a +//! real container, so it needs LXC installed and root. `lxc-exec` cannot stand +//! in for any of it — that binary runs through `mxc_engine::run`, which never +//! reaches `spawn_sandbox`. +//! +//! Tests skip when LXC is missing or the runner is unprivileged, unless +//! `MXC_LXC_TESTS_REQUIRE_EXECUTION` turns a skip into a failure. + +#![cfg(target_os = "linux")] + +use std::io::{BufRead, BufReader, Read, Write}; +use std::process::Command; +use std::sync::{Mutex, MutexGuard}; +use std::time::{Duration, Instant}; + +use mxc_sdk::policy::{Containment, FilesystemSection, NetworkSection}; +use mxc_sdk::{build_request_with_containment, spawn_sandbox, SandboxPolicy, WaitOutcome}; + +/// The bound on a read or wait that should already have finished. Long enough +/// to create a container, start it, and destroy it again on a loaded CI runner. +const LIVE_TIMEOUT_MS: u32 = 180_000; +const LIVE_TIMEOUT: Duration = Duration::from_millis(LIVE_TIMEOUT_MS as u64); + +/// Far past [`LIVE_TIMEOUT`], so a workload still holding a pipe or a wait open +/// at that deadline is one the sandbox failed to terminate. +const SLEEP_SECONDS: u64 = 600; + +/// One case's containers at a time. +/// +/// Every case here creates real root-owned containers on a shared runner, and +/// the networked one needs the bridge to itself. Cases that want two live +/// sandboxes run both inside one guard. +fn exclusive() -> MutexGuard<'static, ()> { + static LIVE_SANDBOX: Mutex<()> = Mutex::new(()); + LIVE_SANDBOX.lock().unwrap_or_else(|e| e.into_inner()) +} + +/// Reports a skip, or fails when this lane was provisioned to execute the suite. +fn not_ready(reason: &str) -> bool { + // A lane provisioned for this suite reports a skip as success, so the gate + // would go green having tested nothing. + if std::env::var("MXC_LXC_TESTS_REQUIRE_EXECUTION").is_ok_and(|value| value != "0") { + panic!("strict mode: {reason}"); + } + println!("SKIPPED: {reason}"); + false +} + +/// Whether this host can run a live LXC sandbox. Reuses the backend's own +/// availability probe so this gate cannot drift from what discovery reports. +fn lxc_ready() -> bool { + if !lxc_common::availability::is_lxc_available() { + return not_ready("lxc-ls is not installed — this host cannot run a system container"); + } + // SAFETY: `geteuid` is a thread-safe, side-effect-free libc call. + if unsafe { libc::geteuid() } != 0 { + return not_ready("LXC needs root to create, start, and attach to a container"); + } + true +} + +/// A container name unique to this test binary's process, so a leak audit can +/// name exactly the container the case created. +fn container_name(case: &str) -> String { + format!("mxc-stream-{case}-{}", std::process::id()) +} + +/// Whether `lxc-ls` still lists `name`. Reads the same `lxcpath` the backend +/// writes to, which differs between a root and an unprivileged caller. +fn container_is_defined(name: &str) -> bool { + let lxcpath = lxc_common::lxc_bindings::resolve_default_lxcpath(); + let output = Command::new("lxc-ls") + .arg("-P") + .arg(&lxcpath) + .arg("-1") + .output() + .expect("lxc-ls runs on a host that passed the availability gate"); + // An `lxc-ls` that failed prints nothing, which would otherwise read as + // "no containers" and pass every leak assertion in this file. + assert!( + output.status.success(), + "lxc-ls -P {lxcpath} exited {}: {}", + output.status, + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8_lossy(&output.stdout) + .lines() + .any(|listed| listed.trim() == name) +} + +/// Fails when the sandbox left its container behind. Every terminal path — +/// `wait`, a timeout, a kill, and a bare drop — owes this. +fn assert_container_released(name: &str) { + assert!( + !container_is_defined(name), + "container {name} is still defined, so the sandbox leaked it" + ); +} + +/// Whether `lxc-ls` still lists `name` as started. Mirrors the backend's own +/// `LxcContainer::is_running`, so the test cannot disagree with it about what +/// running means. +fn container_is_running(name: &str) -> bool { + let lxcpath = lxc_common::lxc_bindings::resolve_default_lxcpath(); + let output = Command::new("lxc-info") + .arg("-P") + .arg(&lxcpath) + .arg("-n") + .arg(name) + .arg("-s") + .output() + .expect("lxc-info runs on a host that passed the availability gate"); + // A container that is gone reports no state at all, which is not running. + output.status.success() && String::from_utf8_lossy(&output.stdout).contains("RUNNING") +} + +/// An LXC streaming request (`/tmp` read-write, no network) with the given +/// command, container name, and timeout (ms; `0` == run until exit). +/// +/// Cases that end in `wait()` pass [`LIVE_TIMEOUT_MS`] rather than `0`: with no +/// script timeout the backend waits on `lxc-attach` forever, so a wedged attach +/// would hold this file's mutex until the CI job's own cap. A healthy workload +/// here finishes in well under a second, so the bound only ever fires on a +/// failure, and it fires as a reported timeout with teardown rather than a hang. +fn lxc_request(command: &str, name: &str, timeout_ms: u32) -> mxc_sdk::SandboxRequest { + lxc_request_with_network(command, name, timeout_ms, None) +} + +fn lxc_request_with_network( + command: &str, + name: &str, + timeout_ms: u32, + network: Option, +) -> mxc_sdk::SandboxRequest { + let policy = SandboxPolicy { + version: "0.7.0-alpha".to_string(), + filesystem: Some(FilesystemSection { + readwrite_paths: vec!["/tmp".to_string()], + readonly_paths: vec![], + denied_paths: vec![], + clear_policy_on_exit: None, + }), + network, + ui: None, + timeout_ms: if timeout_ms == 0 { + None + } else { + Some(timeout_ms) + }, + }; + build_request_with_containment( + &policy, + &Containment::Lxc(mxc_sdk::configs::Lxc::default()), + command, + Some(name), + ) + .expect("build_request_with_containment should succeed") +} + +/// Reads `stream` to EOF on its own thread, so a stream a killed workload +/// failed to close shows up as this deadline rather than a hung test run. +fn read_to_end_within( + stream: Box, + deadline: Duration, + what: &str, +) -> std::io::Result { + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let mut stream = stream; + let mut text = String::new(); + let outcome = stream.read_to_string(&mut text).map(|_| text); + let _ = tx.send(outcome); + }); + rx.recv_timeout(deadline) + .unwrap_or_else(|_| panic!("{what} never reached EOF within {deadline:?}")) +} + +/// Blocks until the workload's first line arrives, proving the container is +/// live before the case does anything to it, and hands back the reader so the +/// rest of the stream can be drained. +/// +/// Bounded on its own thread: a workload whose output never arrives would +/// otherwise hold this file's mutex until the whole CI job times out. +fn read_first_line_within( + stream: Box, + deadline: Duration, + expected: &str, +) -> BufReader> { + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let mut reader = BufReader::new(stream); + let mut line = String::new(); + let outcome = reader.read_line(&mut line).map(|_| (line, reader)); + let _ = tx.send(outcome); + }); + let (line, reader) = rx + .recv_timeout(deadline) + .unwrap_or_else(|_| panic!("no {expected:?} line within {deadline:?}")) + .expect("read stdout"); + assert!( + line.contains(expected), + "expected {expected:?} as the first line, got: {line:?}" + ); + reader +} + +#[test] +fn streaming_lxc_delivers_stdout_before_exit() { + if !lxc_ready() { + return; + } + let _guard = exclusive(); + let name = container_name("early"); + + // Blocking on stdin rather than sleeping: the workload cannot reach its + // exit until this test lets it, so the "still running" assertion below + // cannot race a slow runner. + let mut proc = spawn_sandbox(lxc_request( + "printf 'FIRST\\n'; IFS= read -r _; printf 'SECOND\\n'", + &name, + LIVE_TIMEOUT_MS, + )) + .expect("spawn"); + let mut stdin = proc.take_stdin().expect("stdin available"); + let stdout = read_first_line_within( + proc.take_stdout().expect("stdout available"), + LIVE_TIMEOUT, + "FIRST", + ); + + assert!( + proc.try_wait().expect("try_wait").is_none(), + "the first line arrived only after the workload exited, so it was not streamed" + ); + + stdin.write_all(b"continue\n").expect("write stdin"); + drop(stdin); + + let rest = read_to_end_within(Box::new(stdout), LIVE_TIMEOUT, "stdout").expect("read stdout"); + assert!(rest.contains("SECOND"), "got: {rest:?}"); + assert_eq!(proc.wait().expect("wait"), WaitOutcome::Exited(0)); + assert_container_released(&name); +} + +#[test] +fn streaming_lxc_keeps_stdout_and_stderr_apart() { + if !lxc_ready() { + return; + } + let _guard = exclusive(); + let name = container_name("streams"); + + let mut proc = spawn_sandbox(lxc_request( + "printf 'TO_STDOUT\\n'; printf 'TO_STDERR\\n' >&2", + &name, + LIVE_TIMEOUT_MS, + )) + .expect("spawn"); + let stdout = proc.take_stdout().expect("stdout available"); + let stderr = proc.take_stderr().expect("stderr available"); + + let out = read_to_end_within(stdout, LIVE_TIMEOUT, "stdout").expect("read stdout"); + let err = read_to_end_within(stderr, LIVE_TIMEOUT, "stderr").expect("read stderr"); + + assert!(out.contains("TO_STDOUT"), "got: {out:?}"); + assert!( + !out.contains("TO_STDERR"), + "stderr leaked into stdout: {out:?}" + ); + assert!(err.contains("TO_STDERR"), "got: {err:?}"); + assert!( + !err.contains("TO_STDOUT"), + "stdout leaked into stderr: {err:?}" + ); + + assert_eq!(proc.wait().expect("wait"), WaitOutcome::Exited(0)); + assert_container_released(&name); +} + +#[test] +fn streaming_lxc_delivers_stdin_and_closing_it_sends_eof() { + if !lxc_ready() { + return; + } + let _guard = exclusive(); + let name = container_name("stdin"); + + // `cat` runs until EOF, so it exits only because the writer was dropped. + let mut proc = spawn_sandbox(lxc_request("cat", &name, LIVE_TIMEOUT_MS)).expect("spawn"); + let mut stdin = proc.take_stdin().expect("stdin available"); + let stdout = proc.take_stdout().expect("stdout available"); + + stdin.write_all(b"ping-pong\n").expect("write stdin"); + drop(stdin); + + let out = read_to_end_within(stdout, LIVE_TIMEOUT, "stdout").expect("read stdout"); + assert!(out.contains("ping-pong"), "got: {out:?}"); + + assert_eq!(proc.wait().expect("wait"), WaitOutcome::Exited(0)); + assert_container_released(&name); +} + +#[test] +fn streaming_lxc_wait_reports_the_workloads_exit_code() { + if !lxc_ready() { + return; + } + let _guard = exclusive(); + + // `lxc-attach` sits between the SDK and the workload: pins that the + // workload's status propagates, not the attach process's own. + for code in [0, 1, 42] { + let name = container_name(&format!("exit{code}")); + let mut proc = spawn_sandbox(lxc_request(&format!("exit {code}"), &name, LIVE_TIMEOUT_MS)) + .expect("spawn"); + assert_eq!( + proc.wait().expect("wait"), + WaitOutcome::Exited(code), + "workload exited {code}" + ); + assert_container_released(&name); + } +} + +#[test] +fn streaming_lxc_kill_stops_the_whole_container() { + if !lxc_ready() { + return; + } + let _guard = exclusive(); + let name = container_name("kill"); + + // The backgrounded sleep stands in for a descendant the workload leaves + // behind: it inherits stdout and outlives the foreground one, which keeps + // the attach alive so the kill has something to reach. + let mut proc = spawn_sandbox(lxc_request( + &format!("sleep {SLEEP_SECONDS} & printf 'READY\\n'; sleep {SLEEP_SECONDS}"), + &name, + LIVE_TIMEOUT_MS, + )) + .expect("spawn"); + let stdout = read_first_line_within( + proc.take_stdout().expect("stdout available"), + LIVE_TIMEOUT, + "READY", + ); + + assert!( + proc.try_wait().expect("try_wait").is_none(), + "the workload should still be running when it is killed" + ); + assert!( + container_is_running(&name), + "the container must be running before the kill, or the kill proves nothing" + ); + + proc.kill().expect("kill"); + + // A container cannot be stopped while a process of its own is alive, so + // this is what proves the kill reached the workload and the descendant it + // backgrounded, rather than stopping at the host `lxc-attach` process. The + // workload lives in the container's PID namespace, where nothing aimed at + // that host process or its group can follow it. + assert!( + !container_is_running(&name), + "the container is still running after kill(), so the workload survived it" + ); + + // The descendant held the write end of this pipe, so EOF follows from the + // same fact and is read before `wait()` can supply it through teardown. + read_to_end_within(Box::new(stdout), LIVE_TIMEOUT, "stdout after kill").expect("read stdout"); + + assert_ne!( + proc.wait().expect("wait after kill"), + WaitOutcome::Exited(0), + "a killed workload should not report success" + ); + assert_container_released(&name); +} + +#[test] +fn streaming_lxc_timeout_reports_timed_out_and_tears_down() { + if !lxc_ready() { + return; + } + let _guard = exclusive(); + let name = container_name("timeout"); + + let mut proc = spawn_sandbox(lxc_request( + &format!("printf 'READY\\n'; sleep {SLEEP_SECONDS}"), + &name, + 2_000, + )) + .expect("spawn"); + let _stdout = read_first_line_within( + proc.take_stdout().expect("stdout available"), + LIVE_TIMEOUT, + "READY", + ); + + // `wait` starts the deadline clock and ends by destroying the container, so + // the bound covers teardown too and only has to rule out the 600s sleep. + let start = Instant::now(); + assert_eq!( + proc.wait().expect("wait yields an outcome"), + WaitOutcome::TimedOut, + "a workload outliving its timeout should report a timeout" + ); + assert!( + start.elapsed() < LIVE_TIMEOUT, + "the timeout should fire near 2s, not wait out the {SLEEP_SECONDS}s sleep (elapsed: {:?})", + start.elapsed() + ); + assert_container_released(&name); +} + +#[test] +fn streaming_lxc_dropping_the_handle_tears_down() { + if !lxc_ready() { + return; + } + let _guard = exclusive(); + let name = container_name("drop"); + + { + let mut proc = spawn_sandbox(lxc_request( + &format!("printf 'READY\\n'; sleep {SLEEP_SECONDS}"), + &name, + LIVE_TIMEOUT_MS, + )) + .expect("spawn"); + let _stdout = read_first_line_within( + proc.take_stdout().expect("stdout available"), + LIVE_TIMEOUT, + "READY", + ); + assert!( + container_is_running(&name), + "the container should be running while the sandbox is alive" + ); + } + + // `Drop` kills, reaps, and tears down before it returns, so there is + // nothing to wait for here. + assert_container_released(&name); +} + +#[test] +fn streaming_lxc_refuses_a_container_a_live_sandbox_holds() { + if !lxc_ready() { + return; + } + let _guard = exclusive(); + let name = container_name("shared"); + + let mut held = spawn_sandbox(lxc_request( + &format!("printf 'READY\\n'; sleep {SLEEP_SECONDS}"), + &name, + LIVE_TIMEOUT_MS, + )) + .expect("spawn"); + let _stdout = read_first_line_within( + held.take_stdout().expect("stdout available"), + LIVE_TIMEOUT, + "READY", + ); + + // LXC applies a run's network section only when the container starts, so + // serving a second sandbox on the same container would mean stopping this + // workload to restart it under the other run's policy. + let refusal = match spawn_sandbox(lxc_request("true", &name, LIVE_TIMEOUT_MS)) { + Ok(_) => panic!("a second sandbox on a live container must be refused"), + Err(e) => e, + }; + assert!( + refusal.message.contains(&name), + "the refusal should name the container the caller asked for, got: {}", + refusal.message + ); + + held.kill().expect("kill"); + let _ = held.wait(); + assert_container_released(&name); + drop(held); + + // The refusal must not strand the name: the claim is released with the + // handle, so the next sandbox can have it. + let mut reused = + spawn_sandbox(lxc_request("true", &name, LIVE_TIMEOUT_MS)).expect("spawn after release"); + assert_eq!(reused.wait().expect("wait"), WaitOutcome::Exited(0)); + assert_container_released(&name); +} + +#[test] +fn streaming_lxc_tears_down_a_networked_container() { + if !lxc_ready() { + return; + } + let _guard = exclusive(); + let name = container_name("network"); + + // Outbound access puts the container on the bridge and installs egress + // chains, so this is the case whose teardown has firewall rules to remove. + let mut network = NetworkSection::default(); + network.allow_outbound = true; + let mut proc = spawn_sandbox(lxc_request_with_network( + "printf 'NETWORKED\\n'", + &name, + LIVE_TIMEOUT_MS, + Some(network), + )) + .expect("spawn"); + let stdout = proc.take_stdout().expect("stdout available"); + + let out = read_to_end_within(stdout, LIVE_TIMEOUT, "stdout").expect("read stdout"); + assert!(out.contains("NETWORKED"), "got: {out:?}"); + + assert_eq!(proc.wait().expect("wait"), WaitOutcome::Exited(0)); + // The chains live in the container's own network namespace, so destroying + // the container is what proves they are gone. + assert_container_released(&name); +} diff --git a/src/core/mxc_engine/src/dispatch.rs b/src/core/mxc_engine/src/dispatch.rs index d5d87fced..6456f62af 100644 --- a/src/core/mxc_engine/src/dispatch.rs +++ b/src/core/mxc_engine/src/dispatch.rs @@ -13,13 +13,13 @@ //! (Windows AppContainer / BaseContainer, with the full three-tier fallback — //! BaseContainer, AppContainer + BFS, AppContainer + DACL — shared with the //! run-to-completion path via `process_container_common::dispatcher`), Bubblewrap -//! (Linux), Seatbelt (macOS), WSLC, and IsolationSession (Windows, +//! and LXC (Linux), Seatbelt (macOS), WSLC, and IsolationSession (Windows, //! behind the `wslc` and `isolation_session` features). Every other backend — //! including the remaining experimental ones (Windows Sandbox, MicroVM, -//! Hyperlight) and LXC (no streaming path suitable for the library) — returns -//! [`MxcError::unsupported_containment`]; callers that need those must drive the -//! standalone executor binaries (whose run-to-completion path will, in a later -//! increment, also route through this engine). +//! Hyperlight) — returns [`MxcError::unsupported_containment`]; callers that +//! need those must drive the standalone executor binaries (whose +//! run-to-completion path will, in a later increment, also route through this +//! engine). use wxc_common::logger::Logger; use wxc_common::models::{ContainmentBackend, ExecutionRequest, ScriptResponse}; @@ -72,6 +72,7 @@ pub fn spawn_runner( match &request.containment { ContainmentBackend::Seatbelt => spawn_seatbelt(request, logger), ContainmentBackend::Bubblewrap => spawn_bubblewrap(request, logger), + ContainmentBackend::Lxc => spawn_lxc(request, logger), ContainmentBackend::ProcessContainer => spawn_process_container(request, logger), ContainmentBackend::Wslc => spawn_wslc(request, logger), ContainmentBackend::IsolationSession => spawn_isolation_session(request, logger), @@ -128,6 +129,34 @@ fn spawn_bubblewrap( )) } +/// Serves piped stdio. `lxc-exec` keeps the pty path, which cannot hand back a +/// handle, so the two never share a launch. +#[cfg(target_os = "linux")] +fn spawn_lxc( + request: &ExecutionRequest, + logger: &mut Logger, +) -> Result, MxcError> { + use wxc_common::sandbox_process::{SandboxBackend, StdioMode}; + let mut runner = lxc_common::lxc_runner::LxcScriptRunner::new( + &request.lxc_config, + &request.container_id, + &request.lifecycle, + ); + runner + .spawn(request, logger, StdioMode::Pipes) + .map_err(map_spawn_error) +} + +#[cfg(not(target_os = "linux"))] +fn spawn_lxc( + _request: &ExecutionRequest, + _logger: &mut Logger, +) -> Result, MxcError> { + Err(MxcError::unsupported_containment( + "LXC is only available on Linux", + )) +} + #[cfg(target_os = "macos")] fn spawn_seatbelt( request: &ExecutionRequest, @@ -366,20 +395,19 @@ mod tests { #[test] fn streaming_rejects_unsupported_containment() { - // LXC has no streaming path in the library; selecting it must surface a - // clear `UnsupportedContainment` rather than spawning. The public - // `SandboxRequest` can't choose a backend, so drive dispatch with the - // internal model. + // `Vm` has no streaming arm on any host, so it stands in for every + // backend the catch-all must refuse. The public `SandboxRequest` can't + // choose a backend, so drive dispatch with the internal model. let mut request = build_request(&minimal_policy(), "echo hello", None).expect("build_request"); - request.inner.containment = ContainmentBackend::Lxc; + request.inner.containment = ContainmentBackend::Vm; let mut logger = Logger::new(Mode::Buffer); let err = match spawn_runner(&request.inner, &mut logger) { - Ok(_) => panic!("LXC must be rejected"), + Ok(_) => panic!("Vm must be rejected"), Err(e) => e, }; assert_eq!(err.code, MxcErrorCode::UnsupportedContainment); - assert!(err.message.contains("lxc"), "got: {}", err.message); + assert!(err.message.contains("vm"), "got: {}", err.message); } #[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] @@ -439,6 +467,49 @@ mod tests { assert!(err.message.contains("Windows"), "got: {}", err.message); } + #[cfg(not(target_os = "linux"))] + #[test] + fn streaming_rejects_lxc_off_linux() { + // LXC is a Linux-host backend; selecting it anywhere else must be a + // clear `UnsupportedContainment` rather than a confusing spawn failure. + let mut request = + build_request(&minimal_policy(), "echo hello", None).expect("build_request"); + request.inner.containment = ContainmentBackend::Lxc; + let mut logger = Logger::new(Mode::Buffer); + let err = match spawn_runner(&request.inner, &mut logger) { + Ok(_) => panic!("LXC must be rejected off Linux"), + Err(e) => e, + }; + assert_eq!(err.code, MxcErrorCode::UnsupportedContainment); + assert!(err.message.contains("Linux"), "got: {}", err.message); + } + + #[cfg(target_os = "linux")] + #[test] + fn streaming_lxc_reaches_the_backend_on_linux() { + // Locks the dispatch arm itself on the one host where it exists: an + // empty distribution is refused inside LXC's own preparation, before a + // container name is claimed or a container created, so this needs + // neither LXC nor root and still fails if the arm is removed. + let mut request = + build_request(&minimal_policy(), "echo hello", None).expect("build_request"); + request.inner.containment = ContainmentBackend::Lxc; + request.inner.lxc_config.distribution = String::new(); + request.inner.lxc_config.release = String::new(); + let mut logger = Logger::new(Mode::Buffer); + let err = match spawn_runner(&request.inner, &mut logger) { + Ok(_) => panic!("an empty LXC distribution must be refused"), + Err(e) => e, + }; + assert_ne!(err.code, MxcErrorCode::UnsupportedContainment); + assert!( + err.message + .contains("LXC distribution and release are required"), + "got: {}", + err.message + ); + } + #[cfg(all(target_os = "windows", feature = "wslc"))] #[test] fn streaming_wslc_without_optin_reaches_policy_validation() { diff --git a/src/core/mxc_engine/src/platform.rs b/src/core/mxc_engine/src/platform.rs index 84833230a..74391be3c 100644 --- a/src/core/mxc_engine/src/platform.rs +++ b/src/core/mxc_engine/src/platform.rs @@ -41,6 +41,11 @@ pub struct PlatformSupport { pub reason: Option, /// Containment backends available on this host, by wire name /// (e.g. `"seatbelt"`, `"bubblewrap"`, `"processcontainer"`). + /// + /// Reports that the backend's tooling is present and usable, not that the + /// calling process is privileged enough to reach it. `"lxc"` in particular + /// needs root for a system container, or a configured subuid/subgid + /// delegation for an unprivileged one; neither is probed here. pub available_methods: Vec, /// Bubblewrap host network capability. `None` off Linux, and when /// `bubblewrap` itself is unavailable. @@ -59,8 +64,13 @@ struct BwrapProbe(F); #[cfg(target_os = "linux")] struct ProxyEnforcementProbe(G); +/// The `lxc-ls` gate, injected for the same reason. #[cfg(target_os = "linux")] -fn linux_platform_support_with( +struct LxcProbe(H); + +#[cfg(target_os = "linux")] +fn linux_platform_support_with( + lxc: LxcProbe, bwrap: BwrapProbe, proxy_enforcement: ProxyEnforcementProbe, ) -> PlatformSupport @@ -70,19 +80,42 @@ where bwrap_common::bwrap_version::BwrapUnavailable, >, G: FnOnce() -> Result<(), String>, + H: FnOnce() -> bool, { - match (bwrap.0)() { + let lxc_available = (lxc.0)(); + let bwrap_probe = (bwrap.0)(); + + // Listed in the TypeScript SDK's order, so a caller moving off + // `getPlatformSupport` reads the same answer. + let mut available_methods = Vec::new(); + if lxc_available { + available_methods.push("lxc".to_string()); + } + if bwrap_probe.is_ok() { + available_methods.push("bubblewrap".to_string()); + } + + match bwrap_probe { Ok(_) => PlatformSupport { is_supported: true, - available_methods: vec!["bubblewrap".to_string()], + available_methods, // Walked only once `bwrap` itself is usable: the network // dependencies say nothing on a host that cannot run the backend, // and the walk costs several subprocess spawns. bubblewrap_network: Some(bubblewrap_network_support((proxy_enforcement.0)())), ..Default::default() }, + // `reason` says why the platform is unsupported, and LXC alone makes it + // supported, so the bwrap detail has nowhere to go here. + Err(_) if lxc_available => PlatformSupport { + is_supported: true, + available_methods, + ..Default::default() + }, Err(err) => PlatformSupport { - reason: Some(err.to_string()), + reason: Some(format!( + "Neither LXC nor Bubblewrap is available on this system ({err})" + )), ..Default::default() }, } @@ -91,12 +124,17 @@ where /// Detect MXC support on the current host. /// /// Mirrors the SDK's `getPlatformSupport`, restricted to the backends the -/// `mxc-sdk` library can actually run. On Windows the isolation tier and UI -/// capabilities come from the in-process fallback probe rather than a -/// `wxc-exec --probe` subprocess, and `wslc` is reported when the host has the -/// WSL Container runtime (requires the `wslc` feature). The broader -/// host-capability set (backends the host can run but the SDK cannot launch) is -/// reported separately by [`available_backends`](crate::available_backends). +/// `mxc-sdk` library can actually run. On Linux both `bubblewrap` and `lxc` are +/// reported when present, and either one alone makes the host supported. On +/// Windows the isolation tier and UI capabilities come from the in-process +/// fallback probe rather than a `wxc-exec --probe` subprocess, and `wslc` is +/// reported when the host has the WSL Container runtime (requires the `wslc` +/// feature). The broader host-capability set (backends the host can run but the +/// SDK cannot launch) is reported separately by +/// [`available_backends`](crate::available_backends). +/// +/// Every probe here answers "is the tooling usable", not "may this process use +/// it" — see [`PlatformSupport::available_methods`]. pub fn platform_support() -> PlatformSupport { #[cfg(target_os = "macos")] { @@ -118,12 +156,12 @@ pub fn platform_support() -> PlatformSupport { #[cfg(target_os = "linux")] { - // Presence alone is not enough: `bwrap` must also be new enough for - // every flag the argument builder emits (see - // `bwrap_common::bwrap_version::MIN_BWRAP_VERSION`). `lxc` is a - // host-capability backend the SDK can't launch, so it is reported by - // `available_backends()` rather than here. + // Presence alone is not enough for `bwrap`: it must also be new enough + // for every flag the argument builder emits (see + // `bwrap_common::bwrap_version::MIN_BWRAP_VERSION`). LXC has no such + // floor, so a clean `lxc-ls --version` is its whole gate. linux_platform_support_with( + LxcProbe(lxc_common::availability::is_lxc_available), BwrapProbe(bwrap_common::bwrap_version::probe_bwrap), ProxyEnforcementProbe(bwrap_common::proxy_network::probe_proxy_enforcement), ) @@ -226,7 +264,9 @@ mod tests { use super::linux_platform_support_with; use super::platform_support; #[cfg(target_os = "linux")] - use super::{bubblewrap_network_support, BwrapProbe, ProxyEnforcement, ProxyEnforcementProbe}; + use super::{ + bubblewrap_network_support, BwrapProbe, LxcProbe, ProxyEnforcement, ProxyEnforcementProbe, + }; #[cfg(target_os = "linux")] use bwrap_common::bwrap_version::{BwrapUnavailable, BwrapVersion, MIN_BWRAP_VERSION}; use wxc_common::models::ContainmentBackend; @@ -313,6 +353,7 @@ mod tests { #[test] fn linux_support_reports_bubblewrap_when_probe_succeeds() { let support = linux_platform_support_with( + LxcProbe(|| false), BwrapProbe(|| Ok(MIN_BWRAP_VERSION)), ProxyEnforcementProbe(|| Ok(())), ); @@ -328,19 +369,54 @@ mod tests { ); } + /// Both Linux backends are SDK-launchable, so a host carrying both must + /// advertise both. + #[cfg(target_os = "linux")] + #[test] + fn linux_support_reports_lxc_alongside_bubblewrap() { + let support = linux_platform_support_with( + LxcProbe(|| true), + BwrapProbe(|| Ok(MIN_BWRAP_VERSION)), + ProxyEnforcementProbe(|| Ok(())), + ); + assert!(support.is_supported); + assert_eq!(support.available_methods, ["lxc", "bubblewrap"]); + } + + /// An LXC-only host can launch a sandbox, so reporting it unsupported would + /// send every discovery caller away from a backend that works. + #[cfg(target_os = "linux")] + #[test] + fn linux_support_stands_on_lxc_alone() { + let support = linux_platform_support_with( + LxcProbe(|| true), + BwrapProbe(|| Err(BwrapUnavailable::TooOld(BwrapVersion::new(0, 4, 1)))), + ProxyEnforcementProbe(|| { + panic!("the network walk must not run without a usable bwrap") + }), + ); + assert!(support.is_supported); + assert_eq!(support.reason, None); + assert_eq!(support.available_methods, ["lxc"]); + assert!(support.bubblewrap_network.is_none()); + } + #[cfg(target_os = "linux")] #[test] fn linux_support_preserves_probe_failure_reason() { let failure = BwrapUnavailable::TooOld(BwrapVersion::new(0, 4, 1)); let expected = failure.to_string(); let support = linux_platform_support_with( + LxcProbe(|| false), BwrapProbe(|| Err(failure)), ProxyEnforcementProbe(|| { panic!("the network walk must not run without a usable bwrap") }), ); assert!(!support.is_supported); - assert_eq!(support.reason.as_deref(), Some(expected.as_str())); + let reason = support.reason.expect("an unsupported host explains itself"); + assert!(reason.contains(&expected), "got: {reason}"); + assert!(reason.contains("LXC"), "got: {reason}"); assert!(support.available_methods.is_empty()); assert!(support.bubblewrap_network.is_none()); } @@ -351,6 +427,7 @@ mod tests { #[test] fn linux_support_reports_bubblewrap_without_proxy_enforcement() { let support = linux_platform_support_with( + LxcProbe(|| false), BwrapProbe(|| Ok(MIN_BWRAP_VERSION)), ProxyEnforcementProbe(|| Err("slirp4netns not found".to_string())), ); diff --git a/src/ffi/mxc_ffi/tests/ffi.rs b/src/ffi/mxc_ffi/tests/ffi.rs index 420ba8ed5..858436ef7 100644 --- a/src/ffi/mxc_ffi/tests/ffi.rs +++ b/src/ffi/mxc_ffi/tests/ffi.rs @@ -312,3 +312,43 @@ fn extern_run_executes_command() { // SAFETY: `out` was filled by `mxc_run_request`. unsafe { mxc_run_result_free(&mut out) }; } + +/// Pins that the C ABI routes an LXC request into the LXC backend rather than +/// refusing the containment. The empty distribution is refused by LXC's own +/// preparation before any container is created, so the answer is the same on +/// every Linux host and nothing is left behind. +#[cfg(target_os = "linux")] +#[test] +fn extern_spawn_request_reaches_the_lxc_backend() { + let request = CString::new( + r#"{ + "policy": { "version": "0.7.0-alpha" }, + "command": "echo hello-lxc", + "containment": { "type": "lxc", "distribution": "", "release": "" } + }"#, + ) + .unwrap(); + let mut handle: *mut MxcSandbox = ptr::null_mut(); + // SAFETY: `MxcErrorDetail` contains integers and nullable pointers. + let mut error: MxcErrorDetail = unsafe { std::mem::zeroed() }; + // SAFETY: valid request and writable fresh out-parameters. + let status = unsafe { mxc_spawn_request(request.as_ptr(), &mut handle, &mut error) }; + + assert_ne!( + status, + mxc_ffi::MXC_STATUS_UNSUPPORTED_CONTAINMENT, + "the engine must route LXC to a real backend arm on Linux" + ); + assert!(handle.is_null()); + // SAFETY: the message is a valid C string filled by `mxc_spawn_request`. + let message = unsafe { CStr::from_ptr(error.message_utf8) } + .to_str() + .unwrap(); + assert!( + message.contains("LXC distribution and release are required"), + "unexpected message: {message}" + ); + + // SAFETY: `error` was filled by `mxc_spawn_request`. + unsafe { mxc_error_detail_free(&mut error) }; +} From 70ee321aca6ace845d1b63c9fdcfa27345c38ba3 Mon Sep 17 00:00:00 2001 From: Soham Das Date: Tue, 29 Sep 2026 16:47:12 -0700 Subject: [PATCH 2/5] [LXC] State the streaming tests' network policy in the directional form LXC accepts Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../MxcSandboxLxcE2ETests.cs | 23 +++++++++-- .../integration/native-streaming.test.ts | 14 +++++-- src/core/mxc-sdk/tests/streaming_lxc.rs | 39 +++++++++++++++---- src/core/mxc_engine/src/dispatch.rs | 20 +++++----- src/ffi/mxc_ffi/tests/ffi.rs | 13 +++---- 5 files changed, 78 insertions(+), 31 deletions(-) diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcSandboxLxcE2ETests.cs b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcSandboxLxcE2ETests.cs index 5b008ed03..b9eaa73df 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcSandboxLxcE2ETests.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcSandboxLxcE2ETests.cs @@ -20,8 +20,12 @@ namespace Microsoft.Mxc.Sdk.Tests; public class MxcSandboxLxcE2ETests { // `lxc-attach` runs the command through `/bin/sh -c`, so these are bare - // shell lines. A policy that permits no network starts the container with - // no interface, which skips the DHCP wait a networked run pays for. + // shell lines. + // + // The network policy is stated in the schema 0.8 directional form and + // permits nothing, so the container starts with no interface and skips the + // DHCP wait. A legacy policy naming no network would default to + // `enforcementMode: 'capabilities'`, which LXC refuses outright. // // The timeout bounds the backend's own wait: with none, a wedged attach // would hang this suite until the CI job's cap. A healthy workload here @@ -30,7 +34,20 @@ public class MxcSandboxLxcE2ETests private static SandboxRequest Request(string command, string containerName) => new( - new SandboxPolicy { Version = "0.7.0-alpha", TimeoutMs = WaitBoundMs }, + new SandboxPolicy + { + Version = "0.9.0-alpha", + TimeoutMs = WaitBoundMs, + Network = new NetworkPolicy + { + Egress = new NetworkEgressPolicy { Default = NetworkAction.Deny }, + Ingress = new NetworkIngressPolicy + { + Default = NetworkAction.Deny, + HostLoopback = NetworkAction.Deny, + }, + }, + }, command) { ContainerName = containerName, diff --git a/sdk/node/tests/integration/native-streaming.test.ts b/sdk/node/tests/integration/native-streaming.test.ts index f9ca819da..1e024f9e5 100644 --- a/sdk/node/tests/integration/native-streaming.test.ts +++ b/sdk/node/tests/integration/native-streaming.test.ts @@ -230,10 +230,18 @@ describe(`Internal native streaming over LXC (schema ${schemaVersion})`, { // a BusyBox image. const command = "printf 'STREAM_FIRST\\n'; IFS= read -r _; " + "printf 'STREAM_SECOND\\n'; printf 'STREAM_ERROR\\n' >&2"; - // A policy permitting no network starts the container with no interface, - // so this does not wait out a DHCP lease it would never use. + // A network policy stated in the directional form and permitting nothing: + // the container starts with no interface, so this does not wait out a DHCP + // lease it would never use. A policy naming no network at all would default + // to `enforcementMode: 'capabilities'`, which LXC refuses outright. const config = sdk.createConfigFromPolicy( - { version: schemaVersion.raw }, + { + version: schemaVersion.raw, + network: { + egress: { default: 'deny' }, + ingress: { default: 'deny', hostLoopback: 'deny' }, + }, + }, 'lxc', `mxc-node-stream-${process.pid}`, ); diff --git a/src/core/mxc-sdk/tests/streaming_lxc.rs b/src/core/mxc-sdk/tests/streaming_lxc.rs index be323f6db..0485620c1 100644 --- a/src/core/mxc-sdk/tests/streaming_lxc.rs +++ b/src/core/mxc-sdk/tests/streaming_lxc.rs @@ -20,7 +20,10 @@ use std::sync::{Mutex, MutexGuard}; use std::time::{Duration, Instant}; use mxc_sdk::policy::{Containment, FilesystemSection, NetworkSection}; -use mxc_sdk::{build_request_with_containment, spawn_sandbox, SandboxPolicy, WaitOutcome}; +use mxc_sdk::{ + build_request_with_containment, spawn_sandbox, NetworkAction, NetworkEgressSection, + NetworkIngressSection, SandboxPolicy, WaitOutcome, +}; /// The bound on a read or wait that should already have finished. Long enough /// to create a container, start it, and destroy it again on a loaded CI runner. @@ -129,24 +132,44 @@ fn container_is_running(name: &str) -> bool { /// here finishes in well under a second, so the bound only ever fires on a /// failure, and it fires as a reported timeout with teardown rather than a hang. fn lxc_request(command: &str, name: &str, timeout_ms: u32) -> mxc_sdk::SandboxRequest { - lxc_request_with_network(command, name, timeout_ms, None) + lxc_request_with_network(command, name, timeout_ms, isolated_network()) +} + +/// Permits nothing in either direction, which starts the container with no +/// interface at all and so skips the DHCP wait a networked run pays for. +/// +/// Stated in the schema 0.8 directional form on purpose. A legacy policy that +/// names no network defaults to `enforcementMode: 'capabilities'`, which +/// selects Windows AppContainer capability SIDs; LXC has no mechanism for that +/// and refuses the request before it reaches a container. +fn isolated_network() -> NetworkSection { + let mut egress = NetworkEgressSection::default(); + egress.default = Some(NetworkAction::Deny); + let mut ingress = NetworkIngressSection::default(); + ingress.default = Some(NetworkAction::Deny); + ingress.host_loopback = Some(NetworkAction::Deny); + + let mut network = NetworkSection::default(); + network.egress = Some(egress); + network.ingress = Some(ingress); + network } fn lxc_request_with_network( command: &str, name: &str, timeout_ms: u32, - network: Option, + network: NetworkSection, ) -> mxc_sdk::SandboxRequest { let policy = SandboxPolicy { - version: "0.7.0-alpha".to_string(), + version: "0.8.0-alpha".to_string(), filesystem: Some(FilesystemSection { readwrite_paths: vec!["/tmp".to_string()], readonly_paths: vec![], denied_paths: vec![], clear_policy_on_exit: None, }), - network, + network: Some(network), ui: None, timeout_ms: if timeout_ms == 0 { None @@ -507,13 +530,15 @@ fn streaming_lxc_tears_down_a_networked_container() { // Outbound access puts the container on the bridge and installs egress // chains, so this is the case whose teardown has firewall rules to remove. + let mut egress = NetworkEgressSection::default(); + egress.default = Some(NetworkAction::Allow); let mut network = NetworkSection::default(); - network.allow_outbound = true; + network.egress = Some(egress); let mut proc = spawn_sandbox(lxc_request_with_network( "printf 'NETWORKED\\n'", &name, LIVE_TIMEOUT_MS, - Some(network), + network, )) .expect("spawn"); let stdout = proc.take_stdout().expect("stdout available"); diff --git a/src/core/mxc_engine/src/dispatch.rs b/src/core/mxc_engine/src/dispatch.rs index 6456f62af..4aab31938 100644 --- a/src/core/mxc_engine/src/dispatch.rs +++ b/src/core/mxc_engine/src/dispatch.rs @@ -487,10 +487,11 @@ mod tests { #[cfg(target_os = "linux")] #[test] fn streaming_lxc_reaches_the_backend_on_linux() { - // Locks the dispatch arm itself on the one host where it exists: an - // empty distribution is refused inside LXC's own preparation, before a - // container name is claimed or a container created, so this needs - // neither LXC nor root and still fails if the arm is removed. + // Locks the dispatch arm itself on the one host where it exists. The + // empty distribution is a backstop: whichever of LXC's own refusals + // fires first, all of them are reached only through this arm, and all + // of them come before a container name is claimed or a container + // created — so this needs neither LXC nor root. let mut request = build_request(&minimal_policy(), "echo hello", None).expect("build_request"); request.inner.containment = ContainmentBackend::Lxc; @@ -498,16 +499,13 @@ mod tests { request.inner.lxc_config.release = String::new(); let mut logger = Logger::new(Mode::Buffer); let err = match spawn_runner(&request.inner, &mut logger) { - Ok(_) => panic!("an empty LXC distribution must be refused"), + Ok(_) => panic!("an LXC request the backend refuses must not spawn"), Err(e) => e, }; assert_ne!(err.code, MxcErrorCode::UnsupportedContainment); - assert!( - err.message - .contains("LXC distribution and release are required"), - "got: {}", - err.message - ); + // Only `lxc_common` produces a message opening with `LXC`, so this is + // the request having reached the backend rather than the catch-all. + assert!(err.message.starts_with("LXC"), "got: {}", err.message); } #[cfg(all(target_os = "windows", feature = "wslc"))] diff --git a/src/ffi/mxc_ffi/tests/ffi.rs b/src/ffi/mxc_ffi/tests/ffi.rs index 858436ef7..064cbb377 100644 --- a/src/ffi/mxc_ffi/tests/ffi.rs +++ b/src/ffi/mxc_ffi/tests/ffi.rs @@ -314,9 +314,10 @@ fn extern_run_executes_command() { } /// Pins that the C ABI routes an LXC request into the LXC backend rather than -/// refusing the containment. The empty distribution is refused by LXC's own -/// preparation before any container is created, so the answer is the same on -/// every Linux host and nothing is left behind. +/// refusing the containment. The empty distribution is a backstop: whichever of +/// LXC's own refusals fires first, all are reached only through that arm and +/// all come before a container is created, so the answer is the same on every +/// Linux host and nothing is left behind. #[cfg(target_os = "linux")] #[test] fn extern_spawn_request_reaches_the_lxc_backend() { @@ -344,10 +345,8 @@ fn extern_spawn_request_reaches_the_lxc_backend() { let message = unsafe { CStr::from_ptr(error.message_utf8) } .to_str() .unwrap(); - assert!( - message.contains("LXC distribution and release are required"), - "unexpected message: {message}" - ); + // Only the LXC backend produces a message opening with `LXC`. + assert!(message.starts_with("LXC"), "unexpected message: {message}"); // SAFETY: `error` was filled by `mxc_spawn_request`. unsafe { mxc_error_detail_free(&mut error) }; From 851cdbfe9b5cad8ef48bff3e6c7a12ce5f764f74 Mon Sep 17 00:00:00 2001 From: Soham Das Date: Tue, 29 Sep 2026 19:05:47 -0700 Subject: [PATCH 3/5] [LXC] Carry the selected Node toolchain through sudo on the Linux integration lane Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/SDK.Integration.Test.Job.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/SDK.Integration.Test.Job.yml b/.github/workflows/SDK.Integration.Test.Job.yml index 22b0aaccb..958526fd3 100644 --- a/.github/workflows/SDK.Integration.Test.Job.yml +++ b/.github/workflows/SDK.Integration.Test.Job.yml @@ -201,6 +201,11 @@ jobs: # Linux needs root for Bubblewrap unprivileged-userns paths; `sudo -E` # preserves the env vars above. # + # `sudo` resets PATH from its own `secure_path`, which picks the image's + # Node ahead of the one setup-node installed, so the suite would run on a + # different major than this matrix selected. `env "PATH=$PATH"` carries + # the chosen toolchain through. + # # This lane installs LXC and starts lxc-net specifically so the LXC # backend is covered, so MXC_LXC_TESTS_REQUIRE_EXECUTION turns an LXC # skip into a failure here rather than a green job that tested nothing. @@ -208,7 +213,7 @@ jobs: shell: bash run: | if [ "${{ matrix.os_label }}" = "linux" ]; then - sudo -E MXC_LXC_TESTS_REQUIRE_EXECUTION=1 npm test + sudo -E env "PATH=$PATH" MXC_LXC_TESTS_REQUIRE_EXECUTION=1 npm test else npm test fi From 4fc38f3d3b1facbb4b2e13b57d9ff9406ce37779 Mon Sep 17 00:00:00 2001 From: Soham Das Date: Thu, 1 Oct 2026 15:07:51 -0700 Subject: [PATCH 4/5] Addressed PR comments Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 948c2709-7922-4c67-8b1c-1d589d6edd03 --- .../MxcSandboxLxcE2ETests.cs | 23 ++++++----------- .../integration/native-streaming.test.ts | 1 - src/backends/lxc/common/src/lxc_bindings.rs | 7 ------ src/core/mxc-sdk/tests/streaming_lxc.rs | 25 +++++++------------ src/ffi/mxc_ffi/tests/ffi.rs | 14 ++++++----- 5 files changed, 24 insertions(+), 46 deletions(-) diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcSandboxLxcE2ETests.cs b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcSandboxLxcE2ETests.cs index b9eaa73df..9505529f9 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcSandboxLxcE2ETests.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcSandboxLxcE2ETests.cs @@ -13,23 +13,12 @@ namespace Microsoft.Mxc.Sdk.Tests; /// /// The engine's own suite covers the backend; these establish that the managed /// type, the request envelope, the C ABI and the native library agree, which is -/// the part no Rust test can see. LXC reaches the binding with no C# production -/// code of its own, so without these the path is unverified. +/// the part no Rust test can see. /// [Collection("MxcLiveHost")] public class MxcSandboxLxcE2ETests { - // `lxc-attach` runs the command through `/bin/sh -c`, so these are bare - // shell lines. - // - // The network policy is stated in the schema 0.8 directional form and - // permits nothing, so the container starts with no interface and skips the - // DHCP wait. A legacy policy naming no network would default to - // `enforcementMode: 'capabilities'`, which LXC refuses outright. - // - // The timeout bounds the backend's own wait: with none, a wedged attach - // would hang this suite until the CI job's cap. A healthy workload here - // finishes in well under a second, so it only ever fires on a failure. + // A healthy run finishes in under a second, so this only trips on a wedged attach. private const int WaitBoundMs = 180_000; private static SandboxRequest Request(string command, string containerName) => @@ -38,6 +27,9 @@ private static SandboxRequest Request(string command, string containerName) => { Version = "0.9.0-alpha", TimeoutMs = WaitBoundMs, + + // A policy naming no network defaults to `enforcementMode: + // 'capabilities'`, which LXC refuses outright. Network = new NetworkPolicy { Egress = new NetworkEgressPolicy { Default = NetworkAction.Deny }, @@ -73,8 +65,7 @@ public void Spawn_StreamsStandardOutputBeforeTheWorkloadExits() LxcHost.Require(); // Blocking on stdin: the workload cannot reach its exit until this test - // lets it, so reading the first line proves the output was streamed - // rather than buffered until completion. + // lets it. using var proc = MxcSandbox.Spawn( Request( "printf 'mxc_lxc_stream_ok\\n'; IFS= read -r _; printf 'mxc_lxc_done\\n'", @@ -104,7 +95,7 @@ public void Spawn_StreamsStandardOutputBeforeTheWorkloadExits() /// /// Reads one line on a worker thread, so a stream that never delivers fails - /// here rather than hanging the job until its workflow timeout. + /// here rather than hanging the job. /// private static string ReadLineWithin(StreamReader reader, TimeSpan deadline) { diff --git a/sdk/node/tests/integration/native-streaming.test.ts b/sdk/node/tests/integration/native-streaming.test.ts index 1e024f9e5..2519c8479 100644 --- a/sdk/node/tests/integration/native-streaming.test.ts +++ b/sdk/node/tests/integration/native-streaming.test.ts @@ -236,7 +236,6 @@ describe(`Internal native streaming over LXC (schema ${schemaVersion})`, { // to `enforcementMode: 'capabilities'`, which LXC refuses outright. const config = sdk.createConfigFromPolicy( { - version: schemaVersion.raw, network: { egress: { default: 'deny' }, ingress: { default: 'deny', hostLoopback: 'deny' }, diff --git a/src/backends/lxc/common/src/lxc_bindings.rs b/src/backends/lxc/common/src/lxc_bindings.rs index 9d629caba..b7121b134 100644 --- a/src/backends/lxc/common/src/lxc_bindings.rs +++ b/src/backends/lxc/common/src/lxc_bindings.rs @@ -329,9 +329,6 @@ impl LxcContainer { let (stderr, stderr_canceller) = match wrap_pipe(child.stderr.take()) { Ok(pipe) => pipe, - - // Without this the tool keeps running, unreaped, after this call - // has reported failure. Err(e) => { let _ = child.kill(); let _ = child.wait(); @@ -558,8 +555,6 @@ impl LxcContainer { force_clear_env, )); - // The drop needs CAP_SETPCAP, which an unprivileged caller lacks, and a - // run with no chains has nothing to protect anyway. if firewall == ContainerFirewall::Installed { confine_network_capabilities(&mut cmd); } @@ -720,8 +715,6 @@ impl LxcContainer { .stdout(Stdio::piped()) .stderr(Stdio::piped()); - // The drop needs CAP_SETPCAP, which an unprivileged caller lacks, and a - // run with no chains has nothing to protect anyway. if firewall == ContainerFirewall::Installed { confine_network_capabilities(&mut cmd); } diff --git a/src/core/mxc-sdk/tests/streaming_lxc.rs b/src/core/mxc-sdk/tests/streaming_lxc.rs index 0485620c1..517ae60a9 100644 --- a/src/core/mxc-sdk/tests/streaming_lxc.rs +++ b/src/core/mxc-sdk/tests/streaming_lxc.rs @@ -161,22 +161,15 @@ fn lxc_request_with_network( timeout_ms: u32, network: NetworkSection, ) -> mxc_sdk::SandboxRequest { - let policy = SandboxPolicy { - version: "0.8.0-alpha".to_string(), - filesystem: Some(FilesystemSection { - readwrite_paths: vec!["/tmp".to_string()], - readonly_paths: vec![], - denied_paths: vec![], - clear_policy_on_exit: None, - }), - network: Some(network), - ui: None, - timeout_ms: if timeout_ms == 0 { - None - } else { - Some(timeout_ms) - }, - }; + let mut policy = SandboxPolicy::default(); + policy.filesystem = Some(FilesystemSection { + readwrite_paths: vec!["/tmp".to_string()], + readonly_paths: vec![], + denied_paths: vec![], + clear_policy_on_exit: None, + }); + policy.network = Some(network); + policy.timeout_ms = (timeout_ms != 0).then_some(timeout_ms); build_request_with_containment( &policy, &Containment::Lxc(mxc_sdk::configs::Lxc::default()), diff --git a/src/ffi/mxc_ffi/tests/ffi.rs b/src/ffi/mxc_ffi/tests/ffi.rs index 064cbb377..2ea3477d8 100644 --- a/src/ffi/mxc_ffi/tests/ffi.rs +++ b/src/ffi/mxc_ffi/tests/ffi.rs @@ -18,6 +18,8 @@ use mxc_ffi::{ mxc_error_detail_free, mxc_probe_request_json, mxc_probe_request_json_with_error, mxc_probe_sandbox_request_json_with_error, MxcErrorDetail, }; +#[cfg(target_os = "linux")] +use mxc_ffi::{mxc_error_detail_free, mxc_spawn_request, MxcErrorDetail, MxcSandbox}; /// An empty, all-null result to hand to `mxc_run_request`. fn zeroed_result() -> MxcRunResult { @@ -313,17 +315,17 @@ fn extern_run_executes_command() { unsafe { mxc_run_result_free(&mut out) }; } -/// Pins that the C ABI routes an LXC request into the LXC backend rather than -/// refusing the containment. The empty distribution is a backstop: whichever of -/// LXC's own refusals fires first, all are reached only through that arm and -/// all come before a container is created, so the answer is the same on every -/// Linux host and nothing is left behind. +/// Pins that `mxc_spawn_request` reaches the LXC backend rather than refusing +/// the containment. +/// +/// The empty distribution makes LXC refuse before creating a container, so the +/// result is the same on every Linux host. #[cfg(target_os = "linux")] #[test] fn extern_spawn_request_reaches_the_lxc_backend() { let request = CString::new( r#"{ - "policy": { "version": "0.7.0-alpha" }, + "policy": {}, "command": "echo hello-lxc", "containment": { "type": "lxc", "distribution": "", "release": "" } }"#, From 26cf4809e84ed9412ee87343a514e19a25b48068 Mon Sep 17 00:00:00 2001 From: Soham Das Date: Thu, 1 Oct 2026 17:27:35 -0700 Subject: [PATCH 5/5] Trimmed comments Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 948c2709-7922-4c67-8b1c-1d589d6edd03 --- .github/workflows/Build.Linux.Job.yml | 18 +++------- .github/workflows/lxc-e2e.yml | 11 ++---- docs/ci-validation-infrastructure.md | 2 +- docs/lxc-support/lxc-backend.md | 50 +++++++++------------------ docs/pull-requests.md | 15 +------- sdk/dotnet/README.md | 6 ---- 6 files changed, 26 insertions(+), 76 deletions(-) diff --git a/.github/workflows/Build.Linux.Job.yml b/.github/workflows/Build.Linux.Job.yml index c079d9a7c..92dde3437 100644 --- a/.github/workflows/Build.Linux.Job.yml +++ b/.github/workflows/Build.Linux.Job.yml @@ -118,29 +118,21 @@ jobs: cargo test --locked --release --target "${{ matrix.target }}" -p mxc_engine -p mxc_ffi --lib state_aware cargo test --locked --release --target "${{ matrix.target }}" -p mxc-sdk --test state_aware - # The LXC streaming arm and the discovery matrix it feeds are Linux-only - # code that no other lane compiles, let alone runs. None of these need LXC - # installed: the engine tests inject their probes, and the dispatch and - # FFI pins are refused inside LXC's own preparation before a container is - # created. The live suite that does need LXC runs in lxc-e2e.yml. - # - # Each run is checked for a non-zero pass count because `cargo test` with - # a name filter that matches nothing also exits 0, so a renamed test would - # otherwise turn this gate into a no-op. + # These need no LXC installed: the engine tests inject their probes, and + # the dispatch and FFI pins are refused before a container is created. - name: Test LXC dispatch and discovery working-directory: src shell: bash run: | - # No `-e`: the pipeline's status is checked explicitly below so a - # failure reports which pinned run failed, rather than aborting mute. + # No `-e`: each run's status is checked below so a failure names it. set -uo pipefail run_pinned() { local label="$1"; shift local log status log="$(mktemp)" cargo test --locked --release --target "${{ matrix.target }}" "$@" 2>&1 | tee "$log" - # `tee` succeeds even when cargo does not, so read cargo's own - # status before trusting the summary below. + + # `tee` succeeds even when cargo does not. status=${PIPESTATUS[0]} if [ "$status" -ne 0 ]; then echo "::error::$label failed (cargo exited $status)." diff --git a/.github/workflows/lxc-e2e.yml b/.github/workflows/lxc-e2e.yml index cabc358e8..7f38fe02c 100644 --- a/.github/workflows/lxc-e2e.yml +++ b/.github/workflows/lxc-e2e.yml @@ -114,14 +114,9 @@ jobs: MXC_LXC_TESTS_REQUIRE_EXECUTION: "1" run: sudo --preserve-env=MXC_LXC_TESTS_REQUIRE_EXECUTION,PATH,HOME "$(command -v cargo)" test -p wxc_e2e_tests --test e2e_lxc_network_capability - # `lxc-exec` routes through `mxc_engine::run` and never reaches - # `spawn_sandbox`, so no shell script can drive the streaming handle. - # This lane already has LXC installed, and these cases need root to - # create and start a container. - # - # `sdk_helpers` runs here too: this is the only lane where - # `platform_support()` sees a host with LXC, so it is the only one that - # exercises the LXC side of the discovery contract. + # These create and start a container, so they need this lane's root and + # its LXC install. `sdk_helpers` rides along because this is the only lane + # where `platform_support()` sees a host with LXC. - name: Run LXC streaming SDK tests working-directory: src env: diff --git a/docs/ci-validation-infrastructure.md b/docs/ci-validation-infrastructure.md index db4486883..3207f298d 100644 --- a/docs/ci-validation-infrastructure.md +++ b/docs/ci-validation-infrastructure.md @@ -226,7 +226,7 @@ get fixed or wired. | Process T1 | ✅ Good | Windows 24H2+ only. Runs the primitives suite, tier-gated to `base-container`. Includes the schema 0.8 directional networking phases (capability matrix, model-3 equivalence, explicit egress rules, host loopback, runtime proxy, reject surface) and the legacy 0.7 network lane. Remaining failures are genuine MXC bugs or harness limitations. | | Process T3 | ✅ Good | Windows 23H2 only. Runs the primitives suite tier-gated to `appcontainer-dacl`, plus `T3-Workloads.ps1` (real programs — pwsh, git, node, python, cmd — on top of the T3 primitives). The 0.8 networking phases assert the documented *rejection* behavior here, since AppContainer cannot carry egress rules, proxy peer identity, or host-loopback configuration. | | Bubblewrap | ✅ Good | | -| LXC | ✅ Good | Some networking tests fail on distros other than Ubuntu 24.04; seems to be an issue with MXC. The in-process streaming handle is covered at PR time instead, by `lxc-e2e.yml` (see [`pull-requests.md`](pull-requests.md)), because this matrix runs prebuilt binaries and `lxc-exec` never reaches `spawn_sandbox`. | +| LXC | ✅ Good | Some networking tests fail on distros other than Ubuntu 24.04; seems to be an issue with MXC. The in-process streaming handle is covered at PR time by `lxc-e2e.yml` instead, because this matrix runs prebuilt binaries. | | WSLC | ✅ Good | Might have to retry hung jobs - this is an issue with overzealous agent reclaiming. | | IsolationSession | ✅ Good | Runs the one-shot and state-aware suites, the Rust SDK in-process and helper tests, the C# end-to-end tests, the Node SDK suite and the COM apartment probe. Fails when the host cannot run isolation sessions, when a suite executes nothing, or when the run changes the set of local accounts. | | Windows Sandbox | ⛔ Blocked | Images don't support `Containers-DisposableClientVM` opt. feature | diff --git a/docs/lxc-support/lxc-backend.md b/docs/lxc-support/lxc-backend.md index 75c3cef7b..4800a8f68 100644 --- a/docs/lxc-support/lxc-backend.md +++ b/docs/lxc-support/lxc-backend.md @@ -237,43 +237,25 @@ and every SDK built on `mxc_spawn_request` / `mxc_run_request` reach it in-process. The handle serves live stdin, stdout, and stderr, plus `wait` and `kill`. -**Pipes, not a pty.** The streaming path wires the workload to ordinary pipes, -so `isatty()` is false inside the container. The `lxc-exec` binary is -unchanged: it allocates a pty and bridges it to the host's stdio, which is why -an interactive shell still renders under it and not here. - -**`StdioMode::Inherit` is refused.** Handing the workload the host's own stdio -means `mxc_pty`, which runs to completion and cannot return a handle. It also -reads the host's stdin and installs a process-wide window-size handler, neither -of which a library may do to its caller. Stream over pipes, or run `lxc-exec`. - -**`kill()` stops the container.** The workload runs in the container's PID -namespace under container init, so nothing aimed at the host `lxc-attach` -process or its process group reaches it — including a descendant the workload -backgrounded. `lxc-stop -k` is what reaches them, and it takes the network -namespace down with the workload rather than after it. It stops the container -rather than releasing it; the release happens when the run reaches a terminal -path below. +**Pipes, not a pty.** `isatty()` is false inside the container, and +`StdioMode::Inherit` is refused. Run `lxc-exec` for a terminal. + +**`kill()` stops the container,** not just the workload: the workload runs under +container init, where nothing aimed at the host `lxc-attach` process reaches it. **One live sandbox per container name, per process.** A second sandbox naming a `containerId` this process already holds is refused rather than queued, because -LXC reads a run's network section only when the container starts — serving the -second would mean stopping the first one's workload to restart it under a -different policy. Omit `containerId` to get a generated name instead. The claim -is process-local and released when the handle drops, so another process, -including an `lxc-exec` run, can still take the same container. - -**Teardown is owed on every terminal path.** Completing, timing out, and being -dropped without a `wait` all remove the `/etc/hosts` proxy pin, the egress and -ingress chains, and the container itself. A teardown step that fails after a -`wait` is reported through `Sandbox::warnings`; after a bare drop there is no -handle left to report through, so a caller that wants to see those failures has -to wait. - -**The streaming path holds root for as long as the handle lives.** `lxc-exec` -is a short-lived process; an SDK host streaming a sandbox keeps a -root-privileged container open for the length of the session. Weigh that -against the threat model before embedding it in a long-lived service. +LXC reads a run's network section only when the container starts. Omit +`containerId` for a generated name. The claim is released when the handle drops. + +**Teardown is owed on every terminal path,** including a drop without `wait`: +the proxy pin and the network chains come down, and the container is released. +A teardown failure is reported through `Sandbox::warnings`, which a bare drop +leaves no handle to read. + +**The container stays up for as long as the handle lives.** `lxc-exec` is +short-lived by comparison. Weigh that against the threat model before embedding +streaming in a long-lived service. ## Building diff --git a/docs/pull-requests.md b/docs/pull-requests.md index 7bc364f8a..bb4df2a06 100644 --- a/docs/pull-requests.md +++ b/docs/pull-requests.md @@ -14,20 +14,7 @@ versioning, and SDK jobs. A separate workflow, because the primary Linux lane does not install LXC and does not run as root. It triggers on PRs targeting `main`, so a PR stacked on another branch gets no LXC gating until it is retargeted — dispatch it manually -for a stacked head. It runs, in order: - -| Step | What it covers | -|------|----------------| -| `tests/scripts/run_lxc_all_tests.sh` | The shell suites, driving the `lxc-exec` binary end to end. | -| `cargo test -p wxc_e2e_tests --test e2e_lxc_network_capability` | That the attached workload has `CAP_NET_ADMIN` dropped. | -| `cargo test -p mxc-sdk --test streaming_lxc --test sdk_helpers` | The in-process streaming handle: live stdio, exit codes, container-scoped kill, timeout, concurrent-name refusal, and that every terminal path releases the container. `sdk_helpers` rides along because this is the only lane where `platform_support()` sees a host with LXC. `lxc-exec` routes through `mxc_engine::run` and never reaches `spawn_sandbox`, so no shell script can cover this. | - -All three set `MXC_LXC_TESTS_REQUIRE_EXECUTION=1`, which turns a skipped -prerequisite into a failure. Without it a lane provisioned for LXC could go -green having run nothing. - -The .NET binding's LXC tests run in `SDK.Dotnet.Test.Job.yml`, which installs -LXC on its Linux leg and reruns just those tests as root. +for a stacked head. ## Azure Pipelines (optional on PRs, required on `main`) diff --git a/sdk/dotnet/README.md b/sdk/dotnet/README.md index 22cd908bd..1fe7c6bc5 100644 --- a/sdk/dotnet/README.md +++ b/sdk/dotnet/README.md @@ -377,12 +377,6 @@ request.Containment = new LxcContainment }; ``` -`Run`, `RunAsync`, and `Spawn` all serve LXC in-process on Linux. It needs -root, and its stdio is pipes rather than a pty, so the workload sees no -terminal — unlike the standalone `lxc-exec` binary, which allocates one. Two -live sandboxes in one process cannot share a `ContainerName`; the second is -refused. - #### WSL Container options `WslcContainment` selects the WSLC backend and carries its image,