From d829b2a038638224741fbd6243812afdc3312d12 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Thu, 29 Oct 2026 13:25:51 +0000 Subject: [PATCH 1/3] feat: better wslc feature parity with docker --- .config/dotnet-tools.json | 50 +-- AGENTS.md | 20 + README.md | 13 +- docs/wiki/Architecture.md | 13 +- docs/wiki/Backend-Capabilities.md | 136 ++++++ docs/wiki/Backends.md | 40 +- docs/wiki/Connection-Strings.md | 9 +- docs/wiki/Consumer-Requirements.md | 14 +- docs/wiki/Contributing-Modules.md | 4 +- docs/wiki/Home.md | 3 +- docs/wiki/Lifecycle.md | 34 +- docs/wiki/Networking.md | 71 ++-- docs/wiki/Testing.md | 10 +- docs/wiki/Wait-Strategies.md | 8 +- docs/wiki/_Sidebar.md | 1 + package.json | 2 +- scripts/verify-consumers.ps1 | 21 +- .../purview-containers-capabilities/SKILL.md | 135 ++++++ .../SKILL.md | 6 +- skills/purview-containers-lifecycle/SKILL.md | 64 ++- skills/purview-containers-quickstart/SKILL.md | 6 +- skills/purview-containers-testing/SKILL.md | 23 +- src/src/Core/ContainerBase.cs | 114 ++++- src/src/Core/ContainerBuilder.cs | 154 ++++++- src/src/Core/ContainerConfiguration.cs | 34 ++ src/src/Core/ContainerResourceArchive.cs | 67 +++ src/src/Core/ContainerResourceMapping.cs | 4 + src/src/Core/Core.csproj | 4 + src/src/Core/ExecOptions.cs | 13 +- src/src/Core/IContainer.cs | 20 +- src/src/Core/IContainerBackend.cs | 12 + src/src/Core/IContainerConfiguration.cs | 33 ++ src/src/Core/IContainerResource.cs | 11 + src/src/Core/ImageBuildConfiguration.cs | 29 ++ src/src/Core/NetworkConfiguration.cs | 20 + src/src/Core/Runtime/ContainerException.cs | 4 + src/src/Core/Sdk/README.md | 16 +- src/src/Core/Waiting/ExitCodeWaitStrategy.cs | 17 + src/src/Core/Waiting/Wait.cs | 3 + src/src/Core/Waiting/WaitStrategyRunner.cs | 13 +- src/src/Docker/DockerContainer.cs | 286 ++++++++++--- src/src/Docker/DockerContainerBackend.cs | 211 +++++++++- src/src/Docker/DockerFileCopy.cs | 24 ++ src/src/Docker/DockerResource.cs | 25 ++ src/src/Docker/Sdk/README.md | 16 +- src/src/Docker/WslDockerHost.cs | 159 +++++++ src/src/Wsl/LogBuffer.cs | 24 +- src/src/Wsl/Sdk/README.md | 40 +- src/src/Wsl/Wsl.csproj | 4 + src/src/Wsl/WslCommand.cs | 125 ++++++ src/src/Wsl/WslContainer.cs | 342 +++++++++------ src/src/Wsl/WslContainerBackend.Facade.cs | 12 + src/src/Wsl/WslContainerBackend.cs | 47 +++ src/src/Wsl/WslContainerRuntime.cs | 2 + src/src/Wsl/WslContainerSession.cs | 193 ++++++++- src/src/Wsl/WslCreateArguments.cs | 110 +++++ src/src/Wsl/WslFileCopy.cs | 43 ++ src/src/Wsl/WslResource.cs | 17 + src/tests/BackendContractTests.cs | 395 ++++++++++++++++++ .../Docker.IntegrationTests.csproj | 2 + .../DockerContainerTests.cs | 81 ++++ .../Docker.UnitTests/WslDockerHostTests.cs | 139 ++++++ .../AlpineLifecycleTests.cs | 26 ++ src/tests/Wsl.IntegrationTests/ExecTests.cs | 13 +- .../Wsl.IntegrationTests.csproj | 6 +- src/tests/Wsl.UnitTests/ContainerBaseTests.cs | 53 +++ .../Wsl.UnitTests/ContainerBuilderTests.cs | 58 +++ src/tests/Wsl.UnitTests/FakeContainer.cs | 11 +- .../Wsl.UnitTests/WaitStrategyRunnerTests.cs | 36 ++ 69 files changed, 3381 insertions(+), 370 deletions(-) create mode 100644 docs/wiki/Backend-Capabilities.md create mode 100644 skills/purview-containers-capabilities/SKILL.md create mode 100644 src/src/Core/ContainerResourceArchive.cs create mode 100644 src/src/Core/ContainerResourceMapping.cs create mode 100644 src/src/Core/IContainerResource.cs create mode 100644 src/src/Core/ImageBuildConfiguration.cs create mode 100644 src/src/Core/NetworkConfiguration.cs create mode 100644 src/src/Core/Waiting/ExitCodeWaitStrategy.cs create mode 100644 src/src/Docker/DockerFileCopy.cs create mode 100644 src/src/Docker/DockerResource.cs create mode 100644 src/src/Docker/WslDockerHost.cs create mode 100644 src/src/Wsl/WslCommand.cs create mode 100644 src/src/Wsl/WslCreateArguments.cs create mode 100644 src/src/Wsl/WslFileCopy.cs create mode 100644 src/src/Wsl/WslResource.cs create mode 100644 src/tests/BackendContractTests.cs create mode 100644 src/tests/Docker.UnitTests/WslDockerHostTests.cs create mode 100644 src/tests/Wsl.UnitTests/ContainerBaseTests.cs diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index f99a073..8080adf 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -1,27 +1,27 @@ { - "version": 1, - "isRoot": true, - "tools": { - "csharpier": { - "version": "1.3.0", - "commands": [ - "csharpier" - ], - "rollForward": false - }, - "dotnet-inspect": { - "version": "0.26.0", - "commands": [ - "dotnet-inspect" - ], - "rollForward": false - }, - "purview.build": { - "version": "0.7.1", - "commands": [ - "purview-build" - ], - "rollForward": false - } - } + "version": 1, + "isRoot": true, + "tools": { + "csharpier": { + "version": "1.3.0", + "commands": [ + "csharpier" + ], + "rollForward": false + }, + "dotnet-inspect": { + "version": "0.26.0", + "commands": [ + "dotnet-inspect" + ], + "rollForward": false + }, + "purview.build": { + "version": "0.8.0", + "commands": [ + "purview-build" + ], + "rollForward": false + } + } } \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index b224567..c87e397 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,6 +52,21 @@ whenever a target framework, a backend package or the `buildTransitive` assets c ## Runtime invariants +- Shared extended APIs cover entrypoint/user/labels, exec overrides, resource copy, one-shot exit waits, + managed bridge networks/aliases, builds and reuse. WSLC must target the SDK-owned session with a + bounded `wslc` subprocess for SDK-missing capabilities; ordinary SDK-only consumers need no CLI. +- Never swallow caller cancellation as a successful exec/stop. Startup is a total five-minute managed + budget; exec defaults to one minute, stop to 15 seconds and cleanup to 30 seconds. Synchronous WSLC + native calls cannot be safely interrupted; never advertise hard zero-hang guarantees. +- Reuse is explicit, named and incompatible with auto-remove. Docker follows Testcontainers; + WSLC reuse is session-scoped. Explicit deletion bypasses reuse retention. Dispose containers before + managed networks or built images. CLI diagnostics must not echo environment/build output. +- Restart an exited WSLC reusable container through the CLI **before** reopening its SDK event handle. + Calling native `Start()` after attaching that handle fails with "Process handle has already been attached". +- Docker pre-start resource archives run inside Testcontainers' synchronous `Created` event, before init + startup, with the startup cancellation token. Do not use its directory mapping overload: it drops empty + directories. Both backends share the archive builder and preserve exact file targets. + - **One shared, process-wide session** (`WslContainerRuntime.Instance`), lazily started, named `wslc-{pid}-{random8}`. Container isolation comes from unique names and unique host ports, not from extra sessions. @@ -74,6 +89,10 @@ whenever a target framework, a backend package or the `buildTransitive` assets c - **`DisposeAsync` is idempotent** and never terminates the shared session; a process-exit hook disposes the session so its name is released. - **Secrets use `Secret`** and must never reach logs, names, `ToString()` or diagnostics (they are redacted). +- **Docker bind sources on Windows** are translated only for a verified local WSL2 engine: match the + connected loopback TCP daemon's ID against running distributions before invoking that distribution's + `wslpath`. Never assume the default distribution or `/mnt`, rewrite remote/Desktop paths, or change + named volumes and mount access modes. ## Consumer requirements and compatibility @@ -243,6 +262,7 @@ npx skills add purview-dev/containers | `skills/purview-containers-lifecycle/` | start/stop/dispose semantics, cleanup guarantees, wait strategies, WSLC networking | | `skills/purview-containers-testing/` | the test tiers, why modules run serially, pinning a backend, the CI shape | | `skills/purview-containers-consumer-requirements/` | the target-framework contract, `PCC0001`/`PCC0002` and the other consumer build errors | +| `skills/purview-containers-capabilities/` | parity APIs, networks/aliases, copy, builds, reuse, cancellation and SDK/CLI boundaries | Every skill carries the `purview-containers` prefix. Skills install into one flat directory per agent, shared with every skill a developer has installed from anywhere, so an unprefixed name diff --git a/README.md b/README.md index 8ee6a3f..3a3b197 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,16 @@ A Testcontainers-style library for .NET that runs throwaway Linux containers for > **[Backends: WSLC or Docker](docs/wiki/Backends.md)** — comparison, side-by-side project setup, CI > example and troubleshooting. -The WSLC backend is built directly against the `Microsoft.WSL.Containers` NuGet package (the WSLC managed C# API) — no `wslc.exe`/`wsl.exe`/`docker` CLI. The Docker backend is built on [Testcontainers for .NET](https://dotnet.testcontainers.org/). +The WSLC backend uses `Microsoft.WSL.Containers` for ordinary lifecycle operations and a bounded +`wslc` CLI fallback for entrypoint/user/labels, managed networks, reuse, resource copy and image builds. +It requires no Docker installation. The Docker backend is built on +[Testcontainers for .NET](https://dotnet.testcontainers.org/). + +Both backends expose file copy/read, one-shot exit waits, managed bridge networks/aliases and +Dockerfile builds through the same contract. Startup has a five-minute total budget; exec defaults +to one minute, stop to 15 seconds and cleanup to 30 seconds, all configurable. Cancellation is +propagated; synchronous native WSLC calls cannot be forcibly interrupted. See +[Capabilities and Cancellation](docs/wiki/Backend-Capabilities.md) for APIs, ownership and limits. > **Experimental.** This project is an experiment in running throwaway containers through Microsoft > WSL Containers. The public API, defaults and packaging rules can change between prereleases, and @@ -54,6 +63,8 @@ want to fix the runtime. **Docker backend** - Any reachable Docker daemon, and a `net10.0` (or later) project on any platform. +- Windows bind mounts are translated automatically for a verified local Docker Engine inside WSL2; + no consumer path setting is needed. See [WSL2 bind mounts](docs/wiki/Backends.md#windows-bind-mounts-with-docker-engine-in-wsl2). .NET SDK 11 is required to build this repository (it pins `11.0.100-rc.1`); SDK 10 is enough for a Docker consumer. diff --git a/docs/wiki/Architecture.md b/docs/wiki/Architecture.md index 6956d8d..3676caf 100644 --- a/docs/wiki/Architecture.md +++ b/docs/wiki/Architecture.md @@ -26,13 +26,20 @@ Purview.Containers.Wsl (net10.0 facade + net10.0-windows10.0.1 │ ├─ PortAllocator -> native random host ports (windowsPort=0) │ └─ SessionHandle -> Microsoft.WSL.Containers.Session (internal) ├─ WslContainer : IContainer WSLC-backed container - └─ WslContainerSession : IContainerSession image pull + container create/start/stop/delete/exec + ├─ WslContainerSession : IContainerSession image pull + container create/start/stop/delete/exec + └─ WslCommand / WslResource / WslFileCopy bounded CLI fallback in that same session IContainer : IConnectionStringProvider, IAsyncDisposable StartAsync / StopAsync / DisposeAsync / ExecAsync / GetMappedPublicPort / GetConnectionString / GetLogsAsync / tailing IAsyncEnumerable + CopyAsync / ReadFileAsync / WaitForExitAsync / DeleteAsync ``` +`IContainerBackend.CreateNetworkAsync` and `BuildImageAsync` return owned `IContainerResource` +instances on either backend. Docker delegates to Testcontainers. WSLC CLI-backed resources target +the SDK-owned session/store, while ordinary lifecycle operations remain SDK-backed. Dispose containers +before networks/images. See [Capabilities and Cancellation](Backend-Capabilities.md). + A module (`Purview.Containers.PostgreSql`, `Purview.Containers.Redis`, …) derives its container from `ContainerBase` and references `Purview.Containers.Core` only. The backend is resolved at run time through `ContainerBackends`, so the same module package works on WSLC or Docker. `Purview.Containers` is the @@ -141,7 +148,9 @@ Isolated stores are transient and are removed when their session terminates. ## Observability - `System.Diagnostics.ActivitySource("Purview.Containers")` emits `wslcontainer.session.start`, `wslcontainer.image.pull`, `wslcontainer.container.create/start/stop/delete`, `wslcontainer.exec.create`, and `wslcontainer.wait`. Tags carry container name/id/image and strategy — never credentials. -- Tailing `GetLogsAsync(CancellationToken)` ends when the container's init process exits: the log buffer is flushed and its channel completed on process exit (and on container disposal), so an `await foreach` over the stream terminates instead of waiting forever. The string overload (`GetLogsAsync(stream, ct)`) reads the accumulated buffer only. +- Tailing `GetLogsAsync(CancellationToken)` polls output until live container state reports exit; it does + not depend solely on native exit events. The snapshot overload uses the SDK buffer or CLI logs for + CLI-created containers. Always supply an enumeration token. - `Microsoft.Extensions.Logging` integration is optional/future; the core works without a host or DI. ## Extensibility diff --git a/docs/wiki/Backend-Capabilities.md b/docs/wiki/Backend-Capabilities.md new file mode 100644 index 0000000..2d5b165 --- /dev/null +++ b/docs/wiki/Backend-Capabilities.md @@ -0,0 +1,136 @@ +# Backend capabilities and cancellation + +The Docker backend is implemented with Testcontainers for .NET. WSLC uses +`Microsoft.WSL.Containers` for ordinary lifecycle operations and the `wslc` CLI for capabilities +missing from the managed SDK. Neither backend silently switches runtimes to implement a feature. +This is a Testcontainers-style API, not a source-compatible replacement for every Testcontainers API. +Supply the command explicitly when overriding an entrypoint; Docker-style entrypoint overrides +do not preserve an image's default command. + +## Shared capabilities + +| Capability | API | WSLC implementation | +| --- | --- | --- | +| Image command and environment | `WithCommand`, `WithEnvironment` | SDK normally | +| Entrypoint, user, labels | `WithEntrypoint`, `WithUser`, `WithLabel` | CLI creation, SDK handle reopened before startup | +| Exec overrides | `ExecOptions.Environment`, `WorkingDirectory`, `Timeout` | SDK process | +| Pre-start files/directories | `WithResourceMapping(source, target)` | CLI tar copy before init starts | +| Runtime files/directories | `CopyAsync(source, target)`, `ReadFileAsync(path)` | CLI tar copy; reads return exact bytes | +| One-shot containers | `Wait.ForExitCode(expected)`, `WaitForExitAsync(ct)` | Live init-process state/exit code | +| Managed bridge networks and aliases | `CreateNetworkAsync`, `WithNetwork`, `WithNetworkAliases` | CLI network and container creation; alias DNS | +| Dockerfile builds | `BuildImageAsync` | CLI build in the owning SDK session/store | +| Reuse | `WithName(...).WithReuse()` | Same configuration/name within the owning session | +| Explicit deletion | `DeleteAsync(ct)` | Force-delete, including reusable containers | + +Use absolute Linux destinations for copy/mapping operations. A file source names the destination +file; a directory source copies its contents beneath the destination directory. `ReadFileAsync` +reads a regular file, not a directory archive. Prefer resource copy over host bind mounts when a +remote daemon cannot see the test host filesystem. +Empty directories are preserved, including in mappings copied before init starts. + +## Bounded defaults + +| Operation | Default | Configuration | +| --- | --- | --- | +| Startup: backend resolution, image, create, start, readiness | 5 minutes total | `WithStartupTimeout` | +| Exec and init-exit wait | 1 minute | `WithExecTimeout`; individual `ExecOptions.Timeout` | +| Stop, including force-kill escalation | 15 seconds | `WithStopTimeout` | +| Disposal/deletion | 30 seconds | `WithCleanupTimeout` | +| File operations and log snapshots | 1 minute | `WithOperationTimeout` | +| Managed network create/delete | 1 minute | `NetworkConfiguration.Timeout` | +| Image build | 5 minutes | `ImageBuildConfiguration.Timeout` | + +All deadlines must be positive finite timer durations. Cancellation tokens are propagated to async +operations; cancellation and an expired operation deadline throw `OperationCanceledException`. +Readiness strategy exhaustion throws `ContainerTimeoutException`. A smaller strategy timeout can +shorten, but cannot extend, the overall startup budget. A custom readiness predicate that ignores its +token cannot block the managed waiter indefinitely; its own work is not forcibly terminated. +Live log tails have no automatic deadline: cancel their enumeration token to stop following. + +An unexpected one-shot exit fails immediately with `ContainerExitCodeException`, rather than +retrying readiness until startup expires. Docker exec cancellation stops waiting/reading on the +client; Docker does not provide an exec-process kill endpoint, so that process can continue inside +the container. WSLC exec cancellation signals the owned process. Neither means transactional rollback. + +Failed startup runs bounded cleanup before rethrowing the original error, so the caller may observe +up to the cleanup budget in addition to the startup budget. A failed-start container is disposed; +build a new instance to retry. + +**Native limitation:** synchronous WSLC calls (session start, create, signal, inspect, delete, +terminate) cannot be safely interrupted by a .NET token. Deadlines bound managed gate waits, +polling, image-pull waits and CLI processes, not a native call that has already entered the SDK. +The library deliberately does not abandon a native operation while releasing its session gate. +It does not promise a hard "zero hangs" guarantee against a blocked runtime or operating system. +CLI cancellation kills the owned command process tree, but does not roll back server-side work +already accepted by the runtime. Normal session cleanup remains the fallback. + +## Networks + +Resolve once and use that backend for the network and every attached container: + +```csharp +var backend = await ContainerBackends.ResolveAsync(ct); +await using var network = await backend.CreateNetworkAsync(cancellationToken: ct); +await using var server = new ContainerBuilder(backend) + .WithImage("redis:7") + .WithNetwork(network.Name) + .WithNetworkAliases("cache") + .Build(); +await server.StartAsync(ct); +``` + +Other containers on this network can use `cache:6379`; host clients still use published ports. +Dispose all attached containers before the network. The default WSLC bridge still has no +container-name DNS. `ConnectionMode.Container` is not automatically implemented by service-module +connection-string providers: use the explicit alias and service port when constructing such URLs. + +## Image builds and files + +```csharp +var backend = await ContainerBackends.ResolveAsync(ct); +await using var image = await backend.BuildImageAsync(new ImageBuildConfiguration +{ + ContextDirectory = Path.GetFullPath("test-image"), + Name = $"my-test:{Guid.NewGuid():N}", + Dockerfile = "Dockerfile", + Timeout = TimeSpan.FromMinutes(5), +}, ct); +await using var container = new ContainerBuilder(backend) + .WithImage(image.Name) + .WithResourceMapping(Path.GetFullPath("fixture.json"), "/tmp/fixture.json") + .WithCommand("sleep", "300") + .Build(); +await container.StartAsync(ct); +await container.CopyAsync(Path.GetFullPath("more-fixtures"), "/tmp/more", ct); +byte[] content = await container.ReadFileAsync("/tmp/fixture.json", ct); +``` + +The returned `IContainerResource` owns the built image. Dispose containers before disposing it; +deleting an image still used by a container can fail. The same ownership rule applies to networks. +Use unique names for independently owned resources. + +## Reuse and platform limits + +Reuse is opt-in, requires a stable explicit name, and cannot be combined with auto-remove. +`DisposeAsync` no longer promises deletion for a reusable container. Call `DeleteAsync(ct)` to +remove it deliberately. +Delete before disposing the wrapper; to remove a retained container later, reopen it with matching +reuse settings and delete through that wrapper. + +Docker delegates reuse to Testcontainers: it survives wrapper/process lifetimes and opts out of +normal resource-reaper cleanup. WSLC reuse lasts only as long as its owning session, checks the +requested configuration fingerprint before reopening a named container, and is not cross-process +reuse. Do not depend on identical stop behavior during reusable-wrapper disposal between runtimes. +Do not share a reusable container with tests that mutate it concurrently. +The WSLC fingerprint checks configuration, not mapped-file contents or mutable image-tag contents. +Delete and recreate the container when those inputs change. + +WSLC CLI-backed features require `wslc` on `PATH`. They fail with a prerequisite error if absent, +rather than changing the default SDK-only path. CLI creation cannot currently combine privileged +mode with CLI-only creation options and requires single-line environment values. Environment values +are passed through a temporary env file removed after creation, not rendered in command diagnostics. +Labels and names must not contain secrets. CLI errors deliberately omit potentially sensitive output. + +UDP mapping and interactive stdin remain unsupported by the WSLC public contract; capture-based +exec rejects interactive stdin on both backends. Managed networks do not imply support for arbitrary +drivers, dynamic attachment APIs, or every Testcontainers network/build customization. diff --git a/docs/wiki/Backends.md b/docs/wiki/Backends.md index 56b7558..c8f3c0e 100644 --- a/docs/wiki/Backends.md +++ b/docs/wiki/Backends.md @@ -35,12 +35,39 @@ Service modules (`Purview.Containers.PostgreSql`, `Redis`, …) are backend-neut | **Prerequisite** | Windows 11 (22H2 or later) or Windows Server 2025 with WSL Containers (`wsl --install --no-distribution`) | any reachable Docker daemon (Docker Desktop, Docker Engine in WSL2, a VM, or a CI runner) | | **Host OS** | Windows only | Windows, Linux, macOS | | **Project target framework** | `net10.0` or later, any platform (portable facade), or `net10.0-windows10.0.19041.0`, x64 or arm64 (implementation bound directly) | `net10.0` or later, any platform | -| **How containers run** | the `Microsoft.WSL.Containers` managed API (daemonless) | the Docker Engine API via Testcontainers | +| **How containers run** | the managed SDK, with `wslc` CLI fallback for SDK-missing features | the Docker Engine API via Testcontainers | | **Images** | a shared store (`%LOCALAPPDATA%\Purview\WslContainers\images`) reused across runs; the location is configurable | the daemon's own image store | | **Leak protection** | session disposal plus a process-exit hook | the Testcontainers resource reaper (Ryuk) | | **Check the host** | `wsl --version`, `wslc version` | `docker info` | | **Typical fit** | local Windows development without Docker Desktop, fastest cold start | CI runners, non-Windows hosts, teams already running Docker | +## Windows bind mounts with Docker Engine in WSL2 + +When a Windows process connects to a Docker Engine inside a local WSL2 distribution over loopback TCP, +the Docker backend automatically translates Windows drive paths in `WithBindMount` to paths in that +distribution. No environment variable or code change is needed: + +```csharp +await using var container = new ContainerBuilder() + .WithImage("alpine:3.19") + .WithBindMount(@"P:\project\data", "/data", readOnly: true) + .Build(); +``` + +The backend verifies the connected daemon's identity against the engines in running WSL distributions, +then asks the matching distribution's `wslpath` to translate the source. It does not assume the default +distribution or hard-code `/mnt`, so a custom drive mount root is honoured. Read-only and writable +mounts, including single-file mounts, retain their requested access mode. + +The owning distribution must have `docker` and `wslpath` available to its default user, and that user +must be able to query the engine through `/var/run/docker.sock`. If no local daemon matches, sources +are left unchanged rather than guessed. Docker Desktop, non-loopback remote daemons, native Linux +paths and named volumes are unaffected. Once an engine is identified, a failed path translation is +reported rather than silently sending an invalid Windows source to Linux. + +This behaviour applies to containers created through `Purview.Containers.Docker`; it does not intercept +other libraries or direct `docker` CLI invocations. + ## Image store location (WSLC) By default WSLC images are pulled once into a shared store under the local profile: @@ -429,8 +456,10 @@ release its image-store VHD. | Named volumes | ✅ (session VHD) | ✅ (Docker volumes) | | GPU exposure | ✅ (`EnableGPU` session setting) | depends on the daemon and host runtime | | `ExecOptions.WorkingDirectory` / `Timeout` | ✅ | ✅ | -| `ExecOptions.Environment` | ✅ | ❌ `ContainerNotSupportedException` | -| Log tailing | streamed while the init process runs | polled until the container stops | +| `ExecOptions.Environment` | ✅ | ✅ | +| Entrypoint/user/labels, copy, builds, networks/aliases | ✅ CLI fallback | ✅ Testcontainers | +| Reuse | session-scoped, explicit name | Testcontainers reuse | +| Log tailing | polled until the container stops | polled until the container stops | | Leak protection | session disposal + process-exit hook | Testcontainers resource reaper (Ryuk) | | Image store | shared WSLC store, warm across runs | the daemon's store | | Lifetime cost | one process-wide session, cheap start | first pull per image, per daemon | @@ -438,6 +467,9 @@ release its image-store VHD. Unsupported options throw `ContainerNotSupportedException` rather than being ignored, so a difference between backends never turns into a silently weaker test. +See [Capabilities and Cancellation](Backend-Capabilities.md) for shared APIs, managed deadlines, +resource ownership and the synchronous-native-call cancellation limitation. + ## Troubleshooting **`No container backend is registered.`** — no backend package is referenced by the test project (and the @@ -478,5 +510,3 @@ image stores. - [Architecture](Architecture.md#backend-selection) — how resolution and registration work internally. - [Modules](Modules.md) — the service modules and their readiness strategies. - - diff --git a/docs/wiki/Connection-Strings.md b/docs/wiki/Connection-Strings.md index ca58216..daa3b26 100644 --- a/docs/wiki/Connection-Strings.md +++ b/docs/wiki/Connection-Strings.md @@ -20,10 +20,11 @@ string same = container.GetConnectionString(ConnectionMode.Host); // explici | `Host` | Test host → container, using the mapped host port (`127.0.0.1:{port}`). | ✅ both backends | | `Container` | Container → container, using the container network. | ❌ not supported yet | -`ConnectionMode.Container` throws `ConnectionStringModeNotSupportedException`. WSLC has no managed networks -or inter-container DNS, and the Docker backend does not attach containers to a shared network, so no -module builds a container-to-container connection string. Containers can still reach each other by -bridge IP (`IContainer.NetworkIp`) — see [Networking](Networking.md). +`ConnectionMode.Container` throws `ConnectionStringModeNotSupportedException` in the built-in providers. +Managed networks and aliases are available on both backends, but do not automatically supply a +module-specific container connection string. Construct it from an explicit alias and internal service +port, or provide a custom connection-string provider. Bridge IPs also remain available through +`IContainer.NetworkIp` — see [Networking](Networking.md). ## How it works diff --git a/docs/wiki/Consumer-Requirements.md b/docs/wiki/Consumer-Requirements.md index 867c83a..7babaf3 100644 --- a/docs/wiki/Consumer-Requirements.md +++ b/docs/wiki/Consumer-Requirements.md @@ -17,6 +17,12 @@ that exist for it, and of how each of them is verified. ## Which package requires what +The WSLC SDK-only path keeps its existing prerequisites. Entry-point/user/label overrides, managed +networks, reuse, file copy and Dockerfile builds additionally require the installed `wslc` CLI on PATH. +The CLI operates in the SDK-owned session/store, not the user's default CLI session. Missing CLI support +fails explicitly, without switching backends. See [Capabilities and Cancellation](Backend-Capabilities.md) +for operation deadlines, resource ownership and native cancellation limits. + | Package | Target framework | Notes | | --- | --- | --- | | `Purview.Containers` | `net10.0`, any platform | The umbrella: brings `Purview.Containers.Core` plus both backends. One reference; no target-framework requirement beyond .NET 10. | @@ -30,6 +36,12 @@ example `Purview.Containers.Docker`) needs only a portable `net10.0` project: th abstractions and the service modules are portable `net10.0` assets. For how to use and switch between the backends, see [Backends: WSLC or Docker](Backends.md). +For Windows bind mounts targeting a local WSL2 Docker Engine, the Docker backend automatically +translates drive paths after verifying the daemon's identity. No consumer configuration is required; +the owning distribution's default user must be able to run `docker info` against +`/var/run/docker.sock` and use `wslpath`. See +[WSL2 bind mounts](Backends.md#windows-bind-mounts-with-docker-engine-in-wsl2) for the detection boundary. + ## Copy this into your project Most projects need **nothing at all**: a `net10.0` project binds the facade and gets automatic backend @@ -277,5 +289,3 @@ rather than part of the `[Category=Unit]` filter. - [Packaging](Packaging.md) — what each package contains, including the `buildTransitive` assets. - [Testing](Testing.md) — the Linux CI run, the unit filter and the serial WSLC rule. - [Modules](Modules.md) — the service modules that inherit these defaults. - - diff --git a/docs/wiki/Contributing-Modules.md b/docs/wiki/Contributing-Modules.md index 72a9c78..7f16eba 100644 --- a/docs/wiki/Contributing-Modules.md +++ b/docs/wiki/Contributing-Modules.md @@ -87,4 +87,6 @@ public class MyServiceBuilder : ContainerBuilder WaitForExitAsync(CancellationToken ct = default); ValueTask DisposeAsync(); - // restart is intentionally NOT offered (WSLC has no restart; stop+start is not equivalent) + // restart is not part of this shared contract } ``` @@ -21,17 +23,20 @@ WSLC `ContainerState`: `Invalid → Created → Running → Exited → Deleted`. | Operation | Behaviour | |---|---| | `StartAsync` | Creates the session if needed, ensures image (per pull policy), `CreateContainer`, then `Container.Start()`. A missing init binary throws `ArgumentException 0x80070057` with the OCI message → surfaced as `WslContainerStartupException`. | -| `StopAsync` | `Container.Stop(SIGTERM, grace)` then, if still running, `Stop(SIGKILL, ...)`. **Idempotent** — WSLC accepts stopping an already-stopped container. | +| `StopAsync` | Signal the init process with SIGTERM, allow 10 seconds to exit, then send SIGKILL and wait up to 5 seconds. Managed polling avoids the native blocking `Container.Stop` call and does not hold the session gate during the grace period. **Idempotent.** | | `DeleteAsync` | `Container.Delete(Force)`. Double-delete throws `0x80010108` (`RPC_E_DISCONNECTED`) → swallowed. | -| `DisposeAsync` | Stop (if running) → Delete → release the WinRT container object. **Idempotent.** | +| `DisposeAsync` | Normally stop (if running), delete and release handles. Reusable containers are retained. **Idempotent.** | ## Cleanup guarantees -- `DisposeAsync` runs in `finally` by callers, and the container object is also registered for cleanup by the runtime so disposal happens even if a test throws after `StartAsync`. +- Use `await using` so caller failures always reach `DisposeAsync`. The runtime process-exit hook + owns session cleanup; it is not a substitute for disposing each container. - `EnableAutoRemove` defaults to `false`: the library owns deletion via `DisposeAsync`. WSLC auto-removal would delete one-shot containers before their state/output could be inspected. Opt in with `.WithAutoRemove()` when desired. - Partial startup failure (image pull / create / start / wait): the container is torn down via the same `DisposeAsync` path. - Cancellation: a cancelled `StartAsync`/wait triggers the same teardown. -- Container crash: `InitProcess.Exited` fires; the container is then in `Exited` state and `DisposeAsync` deletes it. +- Builder-produced containers serialize start/stop/disposal. Disposal during startup cancels startup + before waiting for cleanup, instead of waiting for the full startup budget. +- Container crash: live state/exit-code reads observe completion even when a native exit event is missed. - Session termination (crash): the session `Terminated` event marks the runtime session as dead; subsequent container operations throw a clear `WslContainerSessionTerminatedException`. - Test-process termination: a process-exit hook disposes the shared session (frees the name). Crashed processes leave an orphaned session that only reserves its name; sweep with `wslc --session system session terminate`. @@ -39,4 +44,21 @@ WSLC `ContainerState`: `Invalid → Created → Running → Exited → Deleted`. - Container names are generated uniquely (`{service}-{processId}-{random8}`); never contain credentials. - Session names are `wslc-{pid}-{random8}`. -- Diagnostics redact secrets via a `Secret` wrapper (`ToString()` returns ``). \ No newline at end of file +- `Secret.ToString()` returns ``; CLI errors omit raw output. Container logs can still contain + application secrets, so do not print them indiscriminately. + +## Deadlines and reuse + +Startup has a five-minute total managed budget (including image pull and all readiness checks). +Exec/exit waits default to one minute, stop to 15 seconds, cleanup/delete to 30 seconds, and file/log +snapshots to one minute. Fluent timeout overrides are available. Cancellation propagates as +`OperationCanceledException`, including WSLC stop and exec; it is not a successful exec result. +Partial startup cleanup may consume the separate cleanup budget before the original error returns. + +Use `Wait.ForExitCode(expected)` for a one-shot init process, then `WaitForExitAsync(ct)` to obtain its +exit code. With reuse enabled, `DisposeAsync` retains the container and `DeleteAsync(ct)` explicitly +removes it. Docker reuse follows Testcontainers; WSLC reuse ends with its owning session. + +Synchronous WSLC native calls cannot be safely interrupted. Managed waits and CLI commands are bounded, +not native SDK calls already entered. Do not treat cancellation as a hard operating-system guarantee. +See [Capabilities and Cancellation](Backend-Capabilities.md) for full ownership and limitation details. \ No newline at end of file diff --git a/docs/wiki/Networking.md b/docs/wiki/Networking.md index fcf4b6f..eed4a2a 100644 --- a/docs/wiki/Networking.md +++ b/docs/wiki/Networking.md @@ -1,30 +1,45 @@ # Networking -WSLC networking behaviour and how `Purview.Containers` models it. - -## Verified behaviour - -| Mode (`ContainerNetworkingMode`) | Effect | Port mappings | -|---|---|---| -| `null` (default) | Docker `NetworkMode: "none"` — no network, no IP | ❌ `CreateContainer` throws `0x80070057` | -| `None` | same as null | ❌ | -| `Bridged` | container gets `172.17.0.x` on the session bridge; host→container mapped ports work | ✅ | - -- Host bind address is **IPv4 loopback only** (`HostIp: 127.0.0.1`). IPv6 `::1` does not connect to the mapped port. -- **Clients must use `127.0.0.1`, not `localhost`.** `localhost` resolves to `::1` first on .NET; most clients (Npgsql, StackExchange.Redis) fall back to IPv4, but **Microsoft.Data.SqlClient hangs on `::1` without falling back**, so the SQL Server module's connection strings use `127.0.0.1,{port}` explicitly. -- **Containers in the same session can reach each other by IP (Bridged)** — `wget http://:8080/` works. -- **No name/DNS resolution between containers.** `wget http://:8080/` and `http://:8080/` fail with "bad address"; `/etc/hosts` contains only localhost + the container's own `IP `. -- There are **no managed network objects** (no `network create/connect` in `Microsoft.WSL.Containers`, unlike the CLI). - -## Library design - -1. **Default `NetworkingMode = Bridged`** for containers that bind ports (or, by default, for all containers to be useful). This is baked into `ContainerBuilder` defaults. -2. `GetMappedPublicPort(containerPort)` reads `Inspect().Ports["/tcp"][0].HostPort`. -3. `IContainer.NetworkIp` is the container's bridge IP after start: WSLC reads `Inspect().NetworkSettings.Networks.bridge.IPAddress` (Bridged only), and Docker reports the Testcontainers `IpAddress` (the default bridge). It is `null` before start or without a network. -4. TCP/HTTP wait strategies connect to `127.0.0.1:` (IPv4 only). -5. **Containers are wired together by IP.** Because there is no native DNS/alias, a dependent container is given its dependency's `NetworkIp` through configuration or environment. A module that owns a dependency starts it first and overrides `ContainerBase.ConfigureForStart` to inject the address, as the Azure Service Bus Emulator does with its SQL Server sidecar (`SQL_SERVER=`). A general `ContainerEnvironmentBuilder` is still deferred. - -## What NOT to model - -- Do **not** invent Docker-style `Network` objects; the underlying semantics are just "bridged with IPs". -- Do **not** claim container-name resolution works; it does not. \ No newline at end of file +Containers default to `ContainerNetworkingMode.Bridged`. The default WSLC bridge supports +container-to-container IP traffic but not container-name DNS. `None` disables networking. + +## Managed networks and alias DNS + +Both backends can create an owned bridge network: + +```csharp +var backend = await ContainerBackends.ResolveAsync(ct); +await using var network = await backend.CreateNetworkAsync(cancellationToken: ct); +await using var database = new ContainerBuilder(backend) + .WithImage("redis:7") + .WithNetwork(network.Name) + .WithNetworkAliases("cache") + .Build(); +await database.StartAsync(ct); +``` + +Attach another container using the same backend and `WithNetwork(network.Name)` to reach +`cache:6379`. Dispose containers before disposing the network. Names/aliases must not contain secrets. +Network aliases require an explicit network; a named network cannot be combined with `None`. + +Docker uses Testcontainers networks. WSLC uses the `wslc` CLI in the SDK-owned session because the +managed SDK exposes only bridge/none settings, not network creation. This path requires `wslc` on +PATH; the ordinary default-bridge path remains SDK-only. See +[Capabilities and Cancellation](Backend-Capabilities.md) for timeouts and platform limits. + +## Host connections and IPs + +- WSLC host bindings are IPv4 loopback only. Clients should use `127.0.0.1`, not `localhost`: + IPv6-first clients, notably SQL Server clients, may not fall back. +- Docker bindings default to `127.0.0.1`; explicit `PortBinding.HostAddress` and TCP/UDP protocol + selections are forwarded to Docker. +- Random host ports are allocated by the runtime, never by probing for an unused port. +- `GetMappedPublicPort` returns the assigned host port. TCP/HTTP readiness checks use the mapped + host port on IPv4 loopback. +- `NetworkIp` is read after startup for the selected network, and is null before start or without a + network. Prefer explicit aliases for container-to-container traffic on managed networks. +- Built-in module `ConnectionMode.Container` providers remain unimplemented; use explicit aliases + and service ports to construct such connection strings. + +UDP mapping remains unsupported by the WSLC public contract. Dynamic network attachment and arbitrary +network drivers are not exposed by the shared API. diff --git a/docs/wiki/Testing.md b/docs/wiki/Testing.md index f49c855..caf71b0 100644 --- a/docs/wiki/Testing.md +++ b/docs/wiki/Testing.md @@ -52,8 +52,8 @@ skip into a failure — the CI integration jobs do. See [Backends: WSLC or Docke consumer-facing setup and CI examples. ```powershell -just test src/tests/Docker.IntegrationTests/Docker.IntegrationTests.csproj # Docker contract -just test src/tests/Modules.Docker.IntegrationTests/Modules.Docker.IntegrationTests.csproj # the nine modules on Docker +dotnet test --project src/tests/Docker.IntegrationTests/Docker.IntegrationTests.csproj --treenode-filter '/*/*/*/*' +dotnet test --project src/tests/Modules.Docker.IntegrationTests/Modules.Docker.IntegrationTests.csproj --treenode-filter '/*/*/*/*' ``` ## Why test modules run serially @@ -126,6 +126,12 @@ immediately instead of surfacing as container timeouts. `actionlint` is told abo ## Expectations +`src/tests/BackendContractTests.cs` is compiled into both backend integration projects. It checks +exec settings, exact file/directory mappings, delayed and failed one-shot exits, streamed stdout/stderr, +network aliases, builds, reuse, cancellation and bounded readiness cleanup. Run both complete projects +for shared capability changes. The unit tier also covers uncooperative readiness and disposal during +startup; it cannot substitute for real runtime validation. + - The `Wsl.IntegrationTests` module runs many real containers in one session and can take several minutes, because WSLC serialises container operations. Slow-test warnings while it runs are expected. - Integration tests skip themselves when the host lacks the required WSL/WSLC components, so a machine diff --git a/docs/wiki/Wait-Strategies.md b/docs/wiki/Wait-Strategies.md index 11ff773..319a87a 100644 --- a/docs/wiki/Wait-Strategies.md +++ b/docs/wiki/Wait-Strategies.md @@ -17,6 +17,7 @@ WaitContext { IContainer Container; string? NetworkIp; int? GetHostPort(ushort); Wait (factory) ForContainerRunning() + ForExitCode(long expectedExitCode = 0) ForTcpPort(ushort containerPort, TimeSpan? connectTimeout = null) ForHttp(string path = "/") → HttpWaitStrategy { ForPort, ForScheme, ForStatusCode, ForStatusPredicate, ForHeader, AllowInsecureTls } ForLogMessage(string) | ForLogMessage(Regex) @@ -28,8 +29,11 @@ WaitStrategy.WithTimeout / .WithInterval / .WithRetries (fluent) ``` - Waits run inside `StartAsync()` after the container starts. `StartAsync` only returns once every configured strategy is ready (or throws `ContainerTimeoutException`). -- Default timeout = the container's `StartupTimeout` (5 min, overridable via `.WithStartupTimeout(...)` or per-strategy `.WithTimeout(...)`). -- Timeout failure produces a diagnostic including container name, image, state, mapped ports, the strategy type, the last check error, and a tail of stdout/stderr (bounded, secret-redacted). +- Default timeout = the container's `StartupTimeout` (5 min). This is also the total managed startup + budget, including image/create/start and all strategies; per-strategy timeouts can shorten it, not + extend it. `Wait.ForExitCode(expected)` supports one-shot completion. +- Timeout failure produces a diagnostic including container name, image, state, mapped ports, the strategy type, the last check error, and a bounded tail of stdout/stderr. Application output can contain secrets; diagnostics are not automatically safe to publish. +- An unexpected one-shot exit fails immediately with `ContainerExitCodeException`. ## Strategies diff --git a/docs/wiki/_Sidebar.md b/docs/wiki/_Sidebar.md index f50afe1..67e1d13 100644 --- a/docs/wiki/_Sidebar.md +++ b/docs/wiki/_Sidebar.md @@ -2,6 +2,7 @@ - [Getting Started](Getting-Started.md) - [Using in Your Tests (auto)](Using-in-Your-Tests.md) - [Backends: WSLC or Docker](Backends.md) +- [Capabilities and Cancellation](Backend-Capabilities.md) - [Consumer Requirements](Consumer-Requirements.md) - [Architecture](Architecture.md) - [Lifecycle](Lifecycle.md) diff --git a/package.json b/package.json index 1d3d3e2..6365b69 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "containers", - "version": "1.0.0-prerelease.9", + "version": "1.0.0-prerelease.10", "description": "Testcontainers-style library for .NET that runs throwaway Linux containers for integration testing, on Microsoft WSL Containers or Docker.", "license": "MIT", "author": { diff --git a/scripts/verify-consumers.ps1 b/scripts/verify-consumers.ps1 index 545f98a..2590f6d 100644 --- a/scripts/verify-consumers.ps1 +++ b/scripts/verify-consumers.ps1 @@ -300,6 +300,26 @@ public static class Program return container.GetMappedPublicPort(6379); } + public static async Task CapabilitiesAsync(IContainerBackend backend, CancellationToken ct) + { + await using var network = await backend.CreateNetworkAsync(new NetworkConfiguration(), ct); + await using var image = await backend.BuildImageAsync(new ImageBuildConfiguration + { + ContextDirectory = Path.GetFullPath("fixture"), + Name = "consumer:smoke", + }, ct); + await using var container = new ContainerBuilder(backend).WithImage(image.Name) + .WithName("consumer-smoke").WithReuse().WithNetwork(network.Name).WithNetworkAliases("worker") + .WithEntrypoint("/bin/sh").WithCommand("-c", "exit 0").WithUser("1000:1000").WithLabel("suite", "smoke") + .WithResourceMapping(Path.GetFullPath("fixture.json"), "/tmp/fixture.json") + .WithExecTimeout(TimeSpan.FromSeconds(10)).WithStopTimeout(TimeSpan.FromSeconds(5)) + .WithCleanupTimeout(TimeSpan.FromSeconds(10)).WithOperationTimeout(TimeSpan.FromSeconds(10)) + .WithWaitStrategy(Wait.ForExitCode()).Build(); + await container.StartAsync(ct); + _ = await container.WaitForExitAsync(ct); + await container.DeleteAsync(ct); + } + public static void Main() { } } '@ @@ -565,4 +585,3 @@ Write-Host ("{0}/{1} consumer checks passed." -f ($results.Count - $failed.Count if ($failed.Count -gt 0) { exit 1 } - diff --git a/skills/purview-containers-capabilities/SKILL.md b/skills/purview-containers-capabilities/SKILL.md new file mode 100644 index 0000000..e76053c --- /dev/null +++ b/skills/purview-containers-capabilities/SKILL.md @@ -0,0 +1,135 @@ +--- +name: purview-containers-capabilities +description: >- + Use when configuring Testcontainers-style capabilities on Purview.Containers Docker or WSLC: + entrypoint, user, labels, exec overrides, file copy, resource mappings, one-shot exit waits, + managed networks and DNS aliases, Dockerfile image builds, reusable containers, cancellation + deadlines, and the WSLC SDK versus CLI capability boundary. +metadata: + product: Purview.Containers + repository: purview-dev/containers +--- + +# Capabilities on Docker and WSLC + +Docker uses Testcontainers for .NET. WSLC ordinarily uses its managed SDK; entrypoint/user/labels, +managed networks, reuse, image builds and copy need the `wslc` CLI on PATH. The CLI targets the same +SDK-owned session and image store, not the user's default CLI session. There is no Docker dependency +on the WSLC path and no silent runtime fallback when an operation is unsupported. + +## Resolve once for related resources + +```csharp +var backend = await ContainerBackends.ResolveAsync(ct); +await using var network = await backend.CreateNetworkAsync(cancellationToken: ct); +await using var container = new ContainerBuilder(backend) + .WithImage("alpine:latest") + .WithNetwork(network.Name) + .WithNetworkAliases("worker") + .WithEntrypoint("/bin/sh") + .WithCommand("-c", "echo ready; sleep 300") + .WithUser("1000:1000") + .WithLabel("suite", "example") + .WithWaitStrategy(Wait.ForLogMessage("ready")) + .Build(); +await container.StartAsync(ct); +``` + +Declare networks/images before their containers so reverse-order async disposal removes containers +first. Containers on a custom bridge can resolve its aliases. The default WSLC bridge still lacks +container-name DNS. `ConnectionMode.Container` remains unsupported by the built-in connection-string +providers; build the service URL from its explicit network alias and internal service port. + +## Files and one-shot jobs + +`WithResourceMapping(hostSource, absoluteContainerTarget)` copies before init starts. +`CopyAsync(hostSource, absoluteContainerTarget, ct)` copies while running. +A file source names the destination file; a directory source copies its contents under the target. +`ReadFileAsync(absoluteContainerFile, ct)` returns exact bytes. Copy is preferable to a bind mount +when the daemon cannot access the test host's files. + +```csharp +await using var job = new ContainerBuilder(backend) + .WithImage("alpine:latest") + .WithEntrypoint("/bin/sh") + .WithCommand("-c", "exit 7") + .WithWaitStrategy(Wait.ForExitCode(7)) + .Build(); +await job.StartAsync(ct); +long exitCode = await job.WaitForExitAsync(ct); +``` + +An unexpected exit fails immediately with `ContainerExitCodeException`, not readiness retries. + +Exec supports environment and working-directory overrides on both backends: + +```csharp +var result = await container.ExecAsync(["sh", "-c", "printf '%s' \"$VALUE\""], new ExecOptions +{ + Environment = new Dictionary { ["VALUE"] = "literal value" }, + WorkingDirectory = "/tmp", + Timeout = TimeSpan.FromSeconds(10), +}, ct); +``` + +Commands are argv, not automatically a shell script. Invoke a shell explicitly when needed. +Interactive stdin is not exposed by capture-based exec and is rejected. + +## Builds and reuse + +```csharp +await using var image = await backend.BuildImageAsync(new ImageBuildConfiguration +{ + ContextDirectory = Path.GetFullPath("test-image"), + Name = $"suite-image:{Guid.NewGuid():N}", + Dockerfile = "Dockerfile", + Timeout = TimeSpan.FromMinutes(5), +}, ct); +// Pass image.Name to WithImage; dispose all its containers before image. +``` + +`WithName("stable-name").WithReuse()` keeps a container instead of deleting it on disposal. +It requires an explicit name and cannot use auto-remove. Docker follows Testcontainers' experimental +reuse semantics and disables normal reaper cleanup. WSLC reopens a matching named container only in +the owning session; its configuration fingerprint must match. WSLC reuse is NOT cross-process. +`DeleteAsync(ct)` explicitly removes either reusable container. Reuse does not isolate test data. +Delete before disposing the wrapper; reopen a retained container with matching reuse settings to +delete it later. +The WSLC fingerprint checks configuration, not the contents of mapped files or mutable image tags; +delete/recreate when those inputs change. + +## Cancellation and defaults + +| Operation | Default | Override | +| --- | --- | --- | +| Full startup, including image and readiness | 5 minutes | `WithStartupTimeout` | +| Exec and exit wait | 1 minute | `WithExecTimeout`, `ExecOptions.Timeout` | +| Stop including escalation | 15 seconds | `WithStopTimeout` | +| Cleanup/delete | 30 seconds | `WithCleanupTimeout` | +| Copy/read/log snapshot | 1 minute | `WithOperationTimeout` | +| Network create/delete | 1 minute | `NetworkConfiguration.Timeout` | +| Build | 5 minutes | `ImageBuildConfiguration.Timeout` | + +Always forward caller tokens. Deadlines are positive finite durations. Operation cancellation/deadlines +throw `OperationCanceledException`; readiness exhaustion throws `ContainerTimeoutException`. +Live logs need an enumeration cancellation token and intentionally have no automatic deadline. +Startup failure/cancellation performs bounded cleanup before propagating the original exception; +create a new container instance to retry. + +Do NOT claim "zero hangs": synchronous native WSLC calls cannot be forcibly interrupted safely. +Managed gate waits, polling and CLI commands are bounded. Native SDK calls already entered can +outlive a token deadline. CLI cancellation kills the owned command tree, not server-side operations +already submitted. Custom readiness callbacks must still stop their own work on cancellation. +Docker exec cancellation stops the client wait, not necessarily the remote process: Docker has no +exec-process kill endpoint. WSLC signals its owned exec process. Dispose the container to end remaining +processes and never assume cancellation rolls back application side effects. + +CLI-only creation currently rejects privileged mode and multi-line environment values. Ordinary +SDK-backed privileged containers are unaffected. Never put secrets in names or labels; CLI errors +omit raw command/build output to prevent accidental disclosure. + +## Related skills + +Use `purview-containers-quickstart` for installation/selection, +`purview-containers-lifecycle` for lifecycle ownership, and +`purview-containers-testing` for test execution. diff --git a/skills/purview-containers-consumer-requirements/SKILL.md b/skills/purview-containers-consumer-requirements/SKILL.md index 28a7ee4..543a0cd 100644 --- a/skills/purview-containers-consumer-requirements/SKILL.md +++ b/skills/purview-containers-consumer-requirements/SKILL.md @@ -23,6 +23,10 @@ metadata: A consumer of any backend other than WSL needs only a portable `net10.0` project. +WSLC features missing from the managed SDK (entrypoint/user/labels, managed networks, reuse, copy +and Dockerfile builds) additionally require `wslc` on PATH. The ordinary SDK-only path is unchanged. +Use `purview-containers-capabilities` for the feature/cancellation boundary. + ## Most projects need nothing ```xml @@ -55,7 +59,7 @@ value you have not chosen. | 2 | **A Windows target framework that names the OS version** — *only* if you want the implementation bound directly | the `Microsoft.WSL.Containers` projection ships Windows assets only | | 3 | **64-bit** — `PlatformTarget` `x64`/`arm64`, or a matching `RuntimeIdentifier` | the native `wslcsdk.dll` ships for `win-x64` and `win-arm64` only | | 4 | **`WindowsSdkPackageVersion` ≥ `10.0.26100.80`** | the projection is compiled against `Microsoft.Windows.SDK.NET` `10.0.26100.79`, and the pack the SDK resolves for a `10.0.19041.0` target framework is older | -| 5 | **A Windows 10/11 host with WSL Containers**, to actually run anything | the library drives WSLC; it never installs or updates it for you | +| 5 | **Windows 11 or Windows Server 2025 with WSL Containers**, to actually run anything | the library drives WSLC; it never installs or updates it for you; a Windows target framework does not imply Windows 10 runtime support | ### Always spell out the Windows version diff --git a/skills/purview-containers-lifecycle/SKILL.md b/skills/purview-containers-lifecycle/SKILL.md index 96da38e..747387a 100644 --- a/skills/purview-containers-lifecycle/SKILL.md +++ b/skills/purview-containers-lifecycle/SKILL.md @@ -4,7 +4,7 @@ description: >- Use when reasoning about a Purview.Containers container's lifecycle or readiness - StartAsync, StopAsync and DisposeAsync semantics, cleanup guarantees and why there is no restart, the Wait strategy family and started-versus-ready, and WSL Containers networking, port mapping and the - absence of inter-container DNS. + default-bridge DNS limitations and managed network aliases. metadata: product: Purview.Containers repository: purview-dev/containers @@ -19,36 +19,40 @@ public interface IContainer : IAsyncDisposable { Task StartAsync(CancellationToken ct = default); Task StopAsync(CancellationToken ct = default); + Task DeleteAsync(CancellationToken ct = default); + Task WaitForExitAsync(CancellationToken ct = default); ValueTask DisposeAsync(); // restart is intentionally NOT offered } ``` -**There is no `RestartAsync`.** WSLC has no restart operation, and stop-then-start is not equivalent, -so offering one would be a method that quietly means something different per backend. +**There is no `RestartAsync` in the shared contract.** Do not infer native runtime capabilities from +that API choice. WSLC's `ContainerState` runs `Invalid → Created → Running → Exited → Deleted`. | Operation | Behaviour | | --- | --- | | `StartAsync` | creates the session if needed, ensures the image per the pull policy, creates the container, starts it, then waits. A missing init binary throws `ArgumentException 0x80070057` with the OCI message, surfaced as `WslContainerStartupException`. | -| `StopAsync` | `Stop(SIGTERM, grace)`, then `Stop(SIGKILL, …)` if still running. **Idempotent** — WSLC accepts stopping an already-stopped container. | +| `StopAsync` | Signal init with SIGTERM, poll up to 10 seconds, then SIGKILL and up to 5 seconds more, bounded by `StopTimeout`. Cancellation propagates; no blocking native `Container.Stop`. | | `DeleteAsync` | `Delete(Force)`. A double delete throws `0x80010108` (`RPC_E_DISCONNECTED`) and is swallowed. | -| `DisposeAsync` | stop if running → delete → release the WinRT object. **Idempotent.** | +| `DisposeAsync` | normally stop, delete and release handles; reusable containers are retained. **Idempotent.** | ## Cleanup guarantees These are the ones that matter when a test throws: -- `DisposeAsync` runs in a `finally` by callers, **and** the container object is registered for - cleanup by the runtime, so disposal happens even if a test throws after `StartAsync`. +- Use `await using` so failures run `DisposeAsync`. The process-exit hook owns session cleanup, + not a per-container disposal registry. - `EnableAutoRemove` defaults to **`false`**: the library owns deletion. WSLC auto-removal would delete a one-shot container before its state or output could be inspected — precisely when you need them. Opt in with `.WithAutoRemove()`. - A **partial startup failure** — image pull, create, start or wait — tears down through the same `DisposeAsync` path. So does a **cancelled** `StartAsync` or wait. -- A **container crash** fires `InitProcess.Exited`; the container is then `Exited` and `DisposeAsync` - deletes it. +- Builder-produced containers serialize lifecycle operations; disposal cancels an in-flight startup + before waiting for cleanup. A failed-start wrapper is terminal: build a new one to retry. +- A **container crash** is observable through live state/exit-code reads, even if an exit event is + missed; normal disposal deletes it. - A **session crash** marks the runtime session dead, and subsequent operations throw `WslContainerSessionTerminatedException` rather than failing obscurely. - **Test-process termination** disposes the shared session through a process-exit hook, freeing the @@ -60,6 +64,8 @@ These are the ones that matter when a test throws: Container names are generated uniquely as `{service}-{processId}-{random8}` and **never contain credentials**; session names are `wslc-{pid}-{random8}`. Diagnostics redact secrets through a `Secret` wrapper whose `ToString()` returns ``. +Application output can contain secrets; do not assume log snapshots or timeout diagnostics are safe +to publish. CLI failures omit raw command/build output. ## Wait strategies @@ -92,10 +98,10 @@ Details worth knowing: - Waits run **inside** `StartAsync()`, after the container starts. `StartAsync` only returns when every strategy is ready, or throws `ContainerTimeoutException`. -- The default timeout is the container's `StartupTimeout` — 5 minutes, overridable with - `.WithStartupTimeout(…)` or per-strategy. -- A timeout diagnostic includes the container name, image, state, mapped ports, the strategy type, - the last check error, and a bounded, secret-redacted tail of stdout/stderr. +- `StartupTimeout` is a five-minute total managed budget for backend resolution, image, creation, + start and readiness. A strategy may shorten that budget, never extend it. +- A timeout diagnostic includes the container name, image, state, mapped ports, strategy and a bounded + log tail. Application logs can contain secrets; do not print them indiscriminately. - The container buffers output from process start, so a log message emitted **before** the waiter's first check still matches. - HTTP TLS validation is **on** by default. `AllowInsecureTls()` disables it for that request handler @@ -122,11 +128,11 @@ Verified WSLC behaviour, and the parts that surprise people: Npgsql and StackExchange.Redis fall back to IPv4; `Microsoft.Data.SqlClient` **hangs** without falling back. - Containers in the same session **can** reach each other by IP under `Bridged`. -- There is **no name or DNS resolution between containers**. `http://:8080/` and +- On the **default bridge** there is no name/DNS resolution between containers. `http://:8080/` and `http://:8080/` fail with "bad address"; `/etc/hosts` holds only localhost and the container's own `IP `. -- There are **no managed network objects** — `Microsoft.WSL.Containers` has no `network - create/connect`, unlike the CLI. +- Use `IContainerBackend.CreateNetworkAsync` and `WithNetwork`/`WithNetworkAliases` for managed + networks with alias DNS on both backends. WSLC uses the CLI in the SDK-owned session. How the library models it: `GetMappedPublicPort(containerPort)` reads `Inspect().Ports["/tcp"][0].HostPort`; `IContainer.NetworkIp` is the bridge IP after start @@ -134,14 +140,26 @@ How the library models it: `GetMappedPublicPort(containerPort)` reads `IpAddress`; `null` before start or without a network); TCP and HTTP waits connect to `127.0.0.1:`. -**What not to model.** Do not invent Docker-style `Network` objects — the semantics are just "bridged -with IPs". Do not claim container-name resolution works; it does not. Containers are wired -together by passing a dependency's `NetworkIp` into configuration or environment: a module that owns a -dependency starts it first and injects the address from `ContainerBase.ConfigureForStart` (the Azure -Service Bus Emulator does this with its SQL Server sidecar). A general multi-container environment -builder is still deferred. +Built-in modules can still wire dependencies by `NetworkIp` through `ConfigureForStart`. Managed +networks add explicit alias DNS; they do not automatically add `ConnectionMode.Container` providers. +Dispose attached containers before the owned network. + +## Cancellation, exit waits and reuse + +Defaults are one minute for exec/exit waits and copy/read/log snapshots, 15 seconds for stop and +30 seconds for cleanup. Use `WithExecTimeout`, `WithStopTimeout`, `WithCleanupTimeout` and +`WithOperationTimeout` to override. `ExecOptions.Timeout` overrides one exec call. +Cancellation/operation deadlines throw `OperationCanceledException`, never a successful exec result. +Readiness exhaustion throws `ContainerTimeoutException`. Live log tails need a caller token. + +Native synchronous WSLC calls cannot be interrupted safely. Managed waits and CLI processes are +bounded, but do not claim hard zero-hang guarantees for a blocked native runtime. +`Wait.ForExitCode(expected)` handles one-shot jobs; `WaitForExitAsync(ct)` returns the actual code. +`WithName(...).WithReuse()` retains a named container on disposal; `DeleteAsync(ct)` explicitly removes +it. WSLC reuse ends with the owning session; Docker follows Testcontainers reuse semantics. ## Related skills `purview-containers-quickstart` for packages, backends and connection strings; -`purview-containers-testing` for how a suite is organised and why it runs serially. +`purview-containers-testing` for how a suite is organised and why it runs serially; +`purview-containers-capabilities` for networks, files, builds and the SDK/CLI boundary. diff --git a/skills/purview-containers-quickstart/SKILL.md b/skills/purview-containers-quickstart/SKILL.md index 6d439f7..d6f0008 100644 --- a/skills/purview-containers-quickstart/SKILL.md +++ b/skills/purview-containers-quickstart/SKILL.md @@ -147,8 +147,9 @@ string same = container.GetConnectionString(ConnectionMode.Host); | `Host` | test host → container, via the mapped host port (`127.0.0.1:{port}`) | both backends | | `Container` | container → container, via the container network | **not supported** — throws `ConnectionStringModeNotSupportedException` | -Container-to-container is deliberately deferred: WSLC has no managed networks or inter-container DNS, -and the Docker backend does not attach containers to a shared network. +Built-in container-to-container connection-string providers remain deferred. Both backends now support +explicit managed networks and aliases (`CreateNetworkAsync`, `WithNetwork`, `WithNetworkAliases`). +Use an explicit alias/service port to construct a container-to-container URL. **Use `127.0.0.1`, never `localhost`.** `localhost` resolves to `::1` first on .NET, and while most clients fall back to IPv4, `Microsoft.Data.SqlClient` **hangs** on `::1` without falling back — which @@ -179,3 +180,4 @@ provider producing an empty string throws `ConnectionStringNotAvailableException `purview-containers-lifecycle` for start/stop/dispose semantics, wait strategies and networking; `purview-containers-testing` for suite layout, serial execution and CI; and `purview-containers-consumer-requirements` for the target-framework contract and the `PCC` guards. +Use `purview-containers-capabilities` for copy, builds, managed networks, reuse and cancellation defaults. diff --git a/skills/purview-containers-testing/SKILL.md b/skills/purview-containers-testing/SKILL.md index 5d8be98..1a8e22d 100644 --- a/skills/purview-containers-testing/SKILL.md +++ b/skills/purview-containers-testing/SKILL.md @@ -3,7 +3,7 @@ name: purview-containers-testing description: >- Use when organising or running a test suite that uses Purview.Containers - the Unit and Integration tiers, why test modules must run serially under WSL Containers, pinning or skipping a backend, - running the Docker suites in CI and the WSL suites on a self-hosted runner, and verifying the + running Docker and WSLC contract suites in hosted CI and the full WSLC matrix, and verifying the consumer contract. metadata: product: Purview.Containers @@ -32,7 +32,7 @@ Windows and WSL — the same modules a developer runs on WSLC locally. just test # every discovered project just test '/*/*/*/*[Category=Unit]' # unit only just test '/*/*/*/*[Category=Integration]' --max-parallel-test-modules 1 -just test src/tests/Docker.IntegrationTests/Docker.IntegrationTests.csproj +dotnet test --project src/tests/Docker.IntegrationTests/Docker.IntegrationTests.csproj --treenode-filter '/*/*/*/*' ``` Filtering uses TUnit's `--treenode-filter`, not `--filter`: this runs on Microsoft.Testing.Platform, @@ -66,16 +66,16 @@ PURVIEW_CONTAINERS_BACKEND=wsl # or docker ``` Both halves skip themselves when their runtime is unreachable: the Docker suites when no daemon -responds, the WSLC suites when the host lacks the WSL Containers components. That is what lets a -machine without WSLC still run the unit tier — and it is also why a mis-provisioned CI runner reports -*successes with skips* rather than real coverage. Print `wsl --version` and `wslc version` as the -job's first step so that failure is visible immediately. +responds, the WSLC suites when the host lacks the WSL Containers components. Set +`PURVIEW_CONTAINERS_REQUIRE_BACKEND=wsl|docker` in CI to fail instead of silently skipping coverage. +Print `wsl --version` and `wslc version` in WSLC jobs so provisioning failures are visible. ## CI shape - **Docker half** — runs on every pull request, on `ubuntu-latest`. -- **WSL half** — cannot: it needs a Windows host with WSL Containers, which no GitHub-hosted runner - provides. It is a **manual** workflow on a self-hosted runner: +- **WSLC contract** — runs on every pull request on `windows-2025`, with nested virtualisation and + WSL Containers installed by the workflow. +- **Full WSLC matrix** — runs nightly on `windows-2025`, or on demand on a hosted or self-hosted runner: ```bash gh workflow run "Integration (WSL Containers)" --ref main @@ -83,7 +83,7 @@ job's first step so that failure is visible immediately. ``` It runs `Wsl.IntegrationTests` then each service-module suite **one project at a time**, so the - shared WSLC image store is never contended. Inputs: `ref` and `filter` (a treenode filter). + shared WSLC image store is never contended. Use `scope: contract` for the backend contract alone. Self-hosted runner prerequisites: a **Windows x64** runner labelled `self-hosted` and `wslc`, with WSL Containers installed (`wsl --install --no-distribution`) and the pinned .NET SDK. Tell @@ -95,6 +95,11 @@ WSL Containers installed (`wsl --install --no-distribution`) and the pinned .NET ## Expectations +The repository's `src/tests/BackendContractTests.cs` is compiled into both backend integration +suites. It exercises exec settings, binary pre-start/runtime copies, delayed and failed one-shot +exits, network alias DNS, image builds, reuse, cancellation and bounded readiness cleanup. Run both +complete suites when changing a shared contract; a unit-only run cannot prove backend behaviour. + `Wsl.IntegrationTests` runs many real containers in one session and can take several minutes, because WSLC serialises container operations. Slow-test warnings while it runs are expected, not a signal. diff --git a/src/src/Core/ContainerBase.cs b/src/src/Core/ContainerBase.cs index 6de0aca..ea07687 100644 --- a/src/src/Core/ContainerBase.cs +++ b/src/src/Core/ContainerBase.cs @@ -1,3 +1,5 @@ +using System.Diagnostics.CodeAnalysis; + namespace Purview.Containers; /// @@ -5,6 +7,11 @@ namespace Purview.Containers; /// and delegates every member to it, which keeps the /// module packages backend-neutral: a module package depends on this abstraction assembly only. /// +[SuppressMessage( + "Usage", + "CA2213:Disposable fields should be disposed", + Justification = "The gate has no native wait handle and remains usable by concurrent callers unwinding after disposal." +)] public abstract class ContainerBase : IContainer { IContainer? _container; @@ -12,6 +19,9 @@ public abstract class ContainerBase : IContainer Action? _configureConnectionStringProvider; int _started; int _disposed; + int _cleanupStarted; + readonly CancellationTokenSource _startupCancellation = new(); + readonly SemaphoreSlim _lifecycleGate = new(1, 1); /// /// Creates a container bound to a backend. Prefer building via a module builder. When @@ -56,24 +66,72 @@ protected ContainerBase(IContainerConfiguration configuration, IContainerBackend public string? NetworkIp => _container?.NetworkIp; /// + [SuppressMessage( + "Design", + "CA1031:Do not catch general exception types", + Justification = "Failed-start cleanup must preserve the original startup/cancellation exception." + )] public virtual async Task StartAsync(CancellationToken cancellationToken = default) { ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) == 1, this); - if (Interlocked.Exchange(ref _started, 1) == 1) + using var deadline = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, + _startupCancellation.Token + ); + deadline.CancelAfter(Configuration.StartupTimeout); + await _lifecycleGate.WaitAsync(deadline.Token).ConfigureAwait(false); + try { - return; + ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) == 1, this); + deadline.Token.ThrowIfCancellationRequested(); + if (Volatile.Read(ref _started) == 1) + return; + var backend = Backend ?? await ContainerBackends.ResolveAsync(deadline.Token).ConfigureAwait(false); + _container = backend.CreateContainer(ConfigureForStart(WithResolvedName())); + await _container.StartAsync(deadline.Token).ConfigureAwait(false); + _configureConnectionStringProvider?.Invoke(); + Volatile.Write(ref _started, 1); + } + catch + { + if (_container is not null) + { + try + { + await _container + .DisposeAsync() + .AsTask() + .WaitAsync(Configuration.CleanupTimeout, CancellationToken.None) + .ConfigureAwait(false); + } + catch { } + } + Volatile.Write(ref _disposed, 1); + throw; + } + finally + { + _lifecycleGate.Release(); } - - var backend = Backend ?? await ContainerBackends.ResolveAsync(cancellationToken).ConfigureAwait(false); - _container = backend.CreateContainer(ConfigureForStart(WithResolvedName())); - await _container.StartAsync(cancellationToken).ConfigureAwait(false); - _configureConnectionStringProvider?.Invoke(); } /// - public virtual Task StopAsync(CancellationToken cancellationToken = default) + public virtual async Task StopAsync(CancellationToken cancellationToken = default) { - return _container?.StopAsync(cancellationToken) ?? Task.CompletedTask; + using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + deadline.CancelAfter(Configuration.StopTimeout); + await _lifecycleGate.WaitAsync(deadline.Token).ConfigureAwait(false); + try + { + ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) == 1, this); + deadline.Token.ThrowIfCancellationRequested(); + if (_container is not null) + await _container.StopAsync(deadline.Token).ConfigureAwait(false); + } + finally + { + _lifecycleGate.Release(); + } } /// @@ -153,20 +211,48 @@ public virtual IAsyncEnumerable GetLogsAsync(CancellationToke return Container.GetLogsAsync(cancellationToken); } + /// + public virtual Task CopyAsync(string source, string target, CancellationToken cancellationToken = default) => + Container.CopyAsync(source, target, cancellationToken); + + /// + public virtual Task DeleteAsync(CancellationToken cancellationToken = default) => + Container.DeleteAsync(cancellationToken); + + /// + public virtual Task ReadFileAsync(string path, CancellationToken cancellationToken = default) => + Container.ReadFileAsync(path, cancellationToken); + + /// + public virtual Task WaitForExitAsync(CancellationToken cancellationToken = default) => + Container.WaitForExitAsync(cancellationToken); + /// public virtual async ValueTask DisposeAsync() { - if (Interlocked.Exchange(ref _disposed, 1) == 1) + Volatile.Write(ref _disposed, 1); + if (Interlocked.Exchange(ref _cleanupStarted, 1) == 1) { return; } - if (_container is not null) + using CancellationTokenSource deadline = new(Configuration.CleanupTimeout); + await _startupCancellation.CancelAsync().ConfigureAwait(false); + var entered = false; + try { - await _container.DisposeAsync().ConfigureAwait(false); + await _lifecycleGate.WaitAsync(deadline.Token).ConfigureAwait(false); + entered = true; + if (_container is not null) + await _container.DisposeAsync().AsTask().WaitAsync(deadline.Token).ConfigureAwait(false); + GC.SuppressFinalize(this); + } + finally + { + if (entered) + _lifecycleGate.Release(); + _startupCancellation.Dispose(); } - - GC.SuppressFinalize(this); } /// diff --git a/src/src/Core/ContainerBuilder.cs b/src/src/Core/ContainerBuilder.cs index a447dcb..615b842 100644 --- a/src/src/Core/ContainerBuilder.cs +++ b/src/src/Core/ContainerBuilder.cs @@ -37,6 +37,17 @@ public abstract class ContainerBuilder readonly List _waitStrategies = []; RegistryCredentials? _registryCredentials; IConnectionStringProvider? _connectionStringProvider; + string[] _entrypoint = []; + string? _user; + readonly Dictionary _labels = new(StringComparer.Ordinal); + string? _network; + string[] _networkAliases = []; + bool _reuse; + readonly List _resourceMappings = []; + TimeSpan _execTimeout = TimeSpan.FromMinutes(1); + TimeSpan _stopTimeout = TimeSpan.FromSeconds(15); + TimeSpan _cleanupTimeout = TimeSpan.FromSeconds(30); + TimeSpan _operationTimeout = TimeSpan.FromMinutes(1); /// The backend the built container will use. When null it is resolved at start time via . protected IContainerBackend? Backend { get; private set; } @@ -153,6 +164,90 @@ public TBuilder WithCommand(params string[] command) return (TBuilder)this; } + /// Overrides the image entrypoint. + public TBuilder WithEntrypoint(params string[] entrypoint) + { + ArgumentNullException.ThrowIfNull(entrypoint); + _entrypoint = [.. entrypoint]; + return (TBuilder)this; + } + + /// Sets the Linux user or uid[:gid]. + public TBuilder WithUser(string user) + { + ArgumentException.ThrowIfNullOrWhiteSpace(user); + _user = user; + return (TBuilder)this; + } + + /// Adds non-secret metadata to the container. + public TBuilder WithLabel(string key, string value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(key); + _labels[key] = value; + return (TBuilder)this; + } + + /// Attaches to an existing managed network. + public TBuilder WithNetwork(string network) + { + ArgumentException.ThrowIfNullOrWhiteSpace(network); + _network = network; + return (TBuilder)this; + } + + /// Sets DNS aliases on the selected managed network. + public TBuilder WithNetworkAliases(params string[] aliases) + { + ArgumentNullException.ThrowIfNull(aliases); + _networkAliases = [.. aliases]; + return (TBuilder)this; + } + + /// Retains the container on disposal. Requires an explicit name; WSLC reuse is session-scoped. + public TBuilder WithReuse(bool reuse = true) + { + _reuse = reuse; + return (TBuilder)this; + } + + /// Copies a host file or directory to the container before startup. + public TBuilder WithResourceMapping(string source, string target) + { + ArgumentException.ThrowIfNullOrWhiteSpace(source); + ArgumentException.ThrowIfNullOrWhiteSpace(target); + _resourceMappings.Add(new(source, target)); + return (TBuilder)this; + } + + /// Sets the default exec/exit-wait deadline (one minute by default). + public TBuilder WithExecTimeout(TimeSpan timeout) + { + _execTimeout = timeout; + return (TBuilder)this; + } + + /// Sets the total stop budget (15 seconds by default). + public TBuilder WithStopTimeout(TimeSpan timeout) + { + _stopTimeout = timeout; + return (TBuilder)this; + } + + /// Sets the managed cleanup budget (30 seconds by default). + public TBuilder WithCleanupTimeout(TimeSpan timeout) + { + _cleanupTimeout = timeout; + return (TBuilder)this; + } + + /// Sets the file operation/log-snapshot deadline (one minute by default). + public TBuilder WithOperationTimeout(TimeSpan timeout) + { + _operationTimeout = timeout; + return (TBuilder)this; + } + /// Sets the working directory for the init process. public TBuilder WithWorkingDirectory(string workingDirectory) { @@ -317,20 +412,31 @@ protected virtual TConfiguration BuildConfiguration() Name = _name, Hostname = _hostname, DomainName = _domainName, - Environment = _environment, - Command = _command, + Environment = new Dictionary(_environment, StringComparer.Ordinal), + Command = _command.ToArray(), WorkingDirectory = _workingDirectory, - PortBindings = _portBindings, - BindMounts = _bindMounts, - NamedVolumes = _namedVolumes, + PortBindings = _portBindings.ToArray(), + BindMounts = _bindMounts.ToArray(), + NamedVolumes = _namedVolumes.ToArray(), NetworkingMode = _networkingMode, Privileged = _privileged, EnableGPU = _enableGPU, EnableAutoRemove = _enableAutoRemove, PullPolicy = _pullPolicy, StartupTimeout = _startupTimeout, - WaitStrategies = _waitStrategies, + WaitStrategies = _waitStrategies.ToArray(), RegistryCredentials = _registryCredentials, + Entrypoint = [.. _entrypoint], + User = _user, + Labels = new Dictionary(_labels, StringComparer.Ordinal), + Network = _network, + NetworkAliases = [.. _networkAliases], + Reuse = _reuse, + ResourceMappings = _resourceMappings.ToArray(), + ExecTimeout = _execTimeout, + StopTimeout = _stopTimeout, + CleanupTimeout = _cleanupTimeout, + OperationTimeout = _operationTimeout, }; } @@ -351,6 +457,42 @@ protected virtual void Validate(ContainerConfiguration configuration) ); } + foreach ( + var timeout in new[] + { + configuration.ExecTimeout, + configuration.StopTimeout, + configuration.CleanupTimeout, + configuration.OperationTimeout, + configuration.StartupTimeout, + } + ) + { + if (timeout <= TimeSpan.Zero || timeout.TotalMilliseconds > uint.MaxValue - 1) + { + throw new ContainerConfigurationException( + "Operation deadlines must be positive, finite timer durations." + ); + } + } + + if (configuration.Reuse && (string.IsNullOrWhiteSpace(configuration.Name) || configuration.EnableAutoRemove)) + { + throw new ContainerConfigurationException( + "Reuse requires an explicit name and cannot be combined with auto-remove." + ); + } + + if (configuration.NetworkAliases.Count > 0 && string.IsNullOrWhiteSpace(configuration.Network)) + { + throw new ContainerConfigurationException("Network aliases require WithNetwork(...)."); + } + + if (configuration.Network is not null && configuration.NetworkingMode == ContainerNetworkingMode.None) + { + throw new ContainerConfigurationException("A managed network cannot be combined with NetworkingMode.None."); + } + foreach (var portBinding in configuration.PortBindings) { if (portBinding.ContainerPort == 0) diff --git a/src/src/Core/ContainerConfiguration.cs b/src/src/Core/ContainerConfiguration.cs index e5fc4e9..9d3158d 100644 --- a/src/src/Core/ContainerConfiguration.cs +++ b/src/src/Core/ContainerConfiguration.cs @@ -65,6 +65,40 @@ public record ContainerConfiguration : IContainerConfiguration /// public RegistryCredentials? RegistryCredentials { get; init; } + /// + public IReadOnlyList Entrypoint { get; init; } = Array.Empty(); + + /// + public string? User { get; init; } + + /// + public IReadOnlyDictionary Labels { get; init; } = new Dictionary(); + + /// + public string? Network { get; init; } + + /// + public IReadOnlyList NetworkAliases { get; init; } = Array.Empty(); + + /// + public bool Reuse { get; init; } + + /// + public IReadOnlyList ResourceMappings { get; init; } = + Array.Empty(); + + /// + public TimeSpan ExecTimeout { get; init; } = TimeSpan.FromMinutes(1); + + /// + public TimeSpan StopTimeout { get; init; } = TimeSpan.FromSeconds(15); + + /// + public TimeSpan CleanupTimeout { get; init; } = TimeSpan.FromSeconds(30); + + /// + public TimeSpan OperationTimeout { get; init; } = TimeSpan.FromMinutes(1); + /// /// Renders the configuration, redacting environment variables whose keys look sensitive /// (PASSWORD, TOKEN, SECRET, KEY, ...). Secrets wrapped in always render as redacted. diff --git a/src/src/Core/ContainerResourceArchive.cs b/src/src/Core/ContainerResourceArchive.cs new file mode 100644 index 0000000..2db8437 --- /dev/null +++ b/src/src/Core/ContainerResourceArchive.cs @@ -0,0 +1,67 @@ +using System.Formats.Tar; +using Purview.Containers.Runtime; + +namespace Purview.Containers; + +static class ContainerResourceArchive +{ + internal static async Task CreateAsync( + string source, + string target, + CancellationToken cancellationToken + ) + { + ArgumentException.ThrowIfNullOrWhiteSpace(source); + ArgumentException.ThrowIfNullOrWhiteSpace(target); + cancellationToken.ThrowIfCancellationRequested(); + if (!target.StartsWith('/') || target.Split('/').Contains("..", StringComparer.Ordinal)) + throw new ContainerConfigurationException("Copy targets must be absolute container paths without '..'."); + + MemoryStream archive = new(); + try + { + using (TarWriter writer = new(archive, leaveOpen: true)) + { + if (Directory.Exists(source)) + { + if (target.Trim('/').Length > 0) + await writer + .WriteEntryAsync( + new PaxTarEntry(TarEntryType.Directory, target.Trim('/')) + { + Mode = + UnixFileMode.UserRead + | UnixFileMode.UserWrite + | UnixFileMode.UserExecute + | UnixFileMode.GroupRead + | UnixFileMode.GroupExecute + | UnixFileMode.OtherRead + | UnixFileMode.OtherExecute, + }, + cancellationToken + ) + .ConfigureAwait(false); + foreach ( + var entry in Directory.EnumerateFileSystemEntries(source, "*", SearchOption.AllDirectories) + ) + { + cancellationToken.ThrowIfCancellationRequested(); + var relative = Path.GetRelativePath(source, entry).Replace('\\', '/'); + await writer + .WriteEntryAsync(entry, $"{target.Trim('/')}/{relative}", cancellationToken) + .ConfigureAwait(false); + } + } + else + await writer.WriteEntryAsync(source, target.Trim('/'), cancellationToken).ConfigureAwait(false); + } + archive.Position = 0; + return archive; + } + catch + { + await archive.DisposeAsync().ConfigureAwait(false); + throw; + } + } +} diff --git a/src/src/Core/ContainerResourceMapping.cs b/src/src/Core/ContainerResourceMapping.cs new file mode 100644 index 0000000..7071476 --- /dev/null +++ b/src/src/Core/ContainerResourceMapping.cs @@ -0,0 +1,4 @@ +namespace Purview.Containers; + +/// A host file or directory copied to an absolute container path before startup. +public sealed record ContainerResourceMapping(string Source, string Target); diff --git a/src/src/Core/Core.csproj b/src/src/Core/Core.csproj index 38cc6ff..4f3e663 100644 --- a/src/src/Core/Core.csproj +++ b/src/src/Core/Core.csproj @@ -6,4 +6,8 @@ MIT Purview + + + + diff --git a/src/src/Core/ExecOptions.cs b/src/src/Core/ExecOptions.cs index ffa8236..b47e65c 100644 --- a/src/src/Core/ExecOptions.cs +++ b/src/src/Core/ExecOptions.cs @@ -9,8 +9,17 @@ public sealed record ExecOptions /// Working directory for the executed process. public string? WorkingDirectory { get; init; } - /// Maximum time to wait for the process to exit. When exceeded, the process is signalled. - public TimeSpan? Timeout { get; init; } + /// Maximum managed exec wait. WSLC signals the process on cancellation; Docker may leave it running. + public TimeSpan? Timeout + { + get; + init + { + if (value is { } timeout && (timeout <= TimeSpan.Zero || timeout.TotalMilliseconds > uint.MaxValue - 1)) + throw new ArgumentOutOfRangeException(nameof(value), "Timeout must be positive and finite."); + field = value; + } + } /// Enable standard input for the process. Not yet exposed via the synchronous capture path. public bool EnableStandardInput { get; init; } diff --git a/src/src/Core/IContainer.cs b/src/src/Core/IContainer.cs index 98e98de..8b098ec 100644 --- a/src/src/Core/IContainer.cs +++ b/src/src/Core/IContainer.cs @@ -1,6 +1,6 @@ namespace Purview.Containers; -/// A throwaway Linux container running on WSL Containers. +/// A Linux container on the selected backend. public interface IContainer : IConnectionStringProvider, IAsyncDisposable { /// Container identifier, available after start. @@ -17,7 +17,7 @@ public interface IContainer : IConnectionStringProvider, IAsyncDisposable /// /// The container's bridge-network IP address, available after start. Other containers on the same backend - /// reach this one at that address (there is no container-name DNS). null before start, or when the + /// reach this one at that address (custom networks also support configured DNS aliases). null before start, or when the /// container has no network (). /// string? NetworkIp { get; } @@ -28,6 +28,10 @@ public interface IContainer : IConnectionStringProvider, IAsyncDisposable /// Stops the container (SIGTERM then SIGKILL). Idempotent. Task StopAsync(CancellationToken cancellationToken = default); + /// Deletes the container even when reuse is enabled. Idempotent. + Task DeleteAsync(CancellationToken cancellationToken = default) => + throw new Runtime.ContainerNotSupportedException("This backend does not support explicit deletion."); + /// Runs a command inside the running container without a shell. Task ExecAsync( string[] command, @@ -46,4 +50,16 @@ Task ExecAsync( /// Tails init-process output as it is produced. IAsyncEnumerable GetLogsAsync(CancellationToken cancellationToken); + + /// Copies a host file or directory to an absolute container path. + Task CopyAsync(string source, string target, CancellationToken cancellationToken = default) => + throw new Runtime.ContainerNotSupportedException("This backend does not support file copy."); + + /// Reads the exact bytes of a container file. + Task ReadFileAsync(string path, CancellationToken cancellationToken = default) => + throw new Runtime.ContainerNotSupportedException("This backend does not support file reads."); + + /// Waits for the init process to exit and returns its exit code. Uses the exec deadline. + Task WaitForExitAsync(CancellationToken cancellationToken = default) => + throw new Runtime.ContainerNotSupportedException("This backend does not support exit waits."); } diff --git a/src/src/Core/IContainerBackend.cs b/src/src/Core/IContainerBackend.cs index d40756e..3301e4c 100644 --- a/src/src/Core/IContainerBackend.cs +++ b/src/src/Core/IContainerBackend.cs @@ -15,4 +15,16 @@ public interface IContainerBackend /// Reports availability, version and missing components for diagnostics and selection. Task GetInfoAsync(CancellationToken cancellationToken = default); + + /// Creates a bridge network. Dispose it after every attached container. + Task CreateNetworkAsync( + NetworkConfiguration? configuration = null, + CancellationToken cancellationToken = default + ) => throw new Runtime.ContainerNotSupportedException("This backend does not support managed networks."); + + /// Builds an image from a host-side Dockerfile context. Dispose it after its containers. + Task BuildImageAsync( + ImageBuildConfiguration configuration, + CancellationToken cancellationToken = default + ) => throw new Runtime.ContainerNotSupportedException("This backend does not support image builds."); } diff --git a/src/src/Core/IContainerConfiguration.cs b/src/src/Core/IContainerConfiguration.cs index dcbc9af..6564e48 100644 --- a/src/src/Core/IContainerConfiguration.cs +++ b/src/src/Core/IContainerConfiguration.cs @@ -61,4 +61,37 @@ public interface IContainerConfiguration /// Optional private-registry credentials used when pulling the image. RegistryCredentials? RegistryCredentials { get; } + + /// Override the image entrypoint; an empty list retains the image default. + IReadOnlyList Entrypoint => Array.Empty(); + + /// Linux user or uid[:gid]. Null retains the image default. + string? User => null; + + /// Container metadata. Never put secrets in labels. + IReadOnlyDictionary Labels => new Dictionary(); + + /// Name of a previously created network, or null for the default bridge. + string? Network => null; + + /// DNS aliases on the explicitly selected network. + IReadOnlyList NetworkAliases => Array.Empty(); + + /// Keep the container on dispose for reuse. WSLC reuse ends with the owning session. + bool Reuse => false; + + /// Files/directories copied before the init process starts. + IReadOnlyList ResourceMappings => Array.Empty(); + + /// Default exec and init-exit wait deadline. + TimeSpan ExecTimeout => TimeSpan.FromMinutes(1); + + /// Total stop budget, including force-kill escalation. + TimeSpan StopTimeout => TimeSpan.FromSeconds(15); + + /// Total managed disposal budget. + TimeSpan CleanupTimeout => TimeSpan.FromSeconds(30); + + /// Deadline for file operations and log snapshots (not live log tails). + TimeSpan OperationTimeout => TimeSpan.FromMinutes(1); } diff --git a/src/src/Core/IContainerResource.cs b/src/src/Core/IContainerResource.cs new file mode 100644 index 0000000..63b3199 --- /dev/null +++ b/src/src/Core/IContainerResource.cs @@ -0,0 +1,11 @@ +namespace Purview.Containers; + +/// A backend-owned network or built image. Dispose after its containers. +public interface IContainerResource : IAsyncDisposable +{ + /// The network name or image reference to pass to a container builder. + string Name { get; } + + /// Deletes the resource. Containers using it must be disposed first. + Task DeleteAsync(CancellationToken cancellationToken = default); +} diff --git a/src/src/Core/ImageBuildConfiguration.cs b/src/src/Core/ImageBuildConfiguration.cs new file mode 100644 index 0000000..a0431e4 --- /dev/null +++ b/src/src/Core/ImageBuildConfiguration.cs @@ -0,0 +1,29 @@ +namespace Purview.Containers; + +/// A Dockerfile build on the selected backend, not on the host's default engine. +public sealed record ImageBuildConfiguration +{ + /// The host directory containing the build context. + public required string ContextDirectory { get; init; } + + /// The resulting image reference. + public required string Name { get; init; } + + /// Dockerfile path relative to the build context. + public string Dockerfile { get; init; } = "Dockerfile"; + + /// Optional target stage. + public string? Target { get; init; } + + /// Maximum managed build wait, including context transfer. + public TimeSpan Timeout + { + get; + init + { + if (value <= TimeSpan.Zero || value.TotalMilliseconds > uint.MaxValue - 1) + throw new ArgumentOutOfRangeException(nameof(value), "Timeout must be positive and finite."); + field = value; + } + } = TimeSpan.FromMinutes(5); +} diff --git a/src/src/Core/NetworkConfiguration.cs b/src/src/Core/NetworkConfiguration.cs new file mode 100644 index 0000000..67d2848 --- /dev/null +++ b/src/src/Core/NetworkConfiguration.cs @@ -0,0 +1,20 @@ +namespace Purview.Containers; + +/// Configuration for a managed bridge network with container-name/alias DNS. +public sealed record NetworkConfiguration +{ + /// Optional network name. A unique name is generated when omitted. + public string? Name { get; init; } + + /// Maximum managed create/delete wait. + public TimeSpan Timeout + { + get; + init + { + if (value <= TimeSpan.Zero || value.TotalMilliseconds > uint.MaxValue - 1) + throw new ArgumentOutOfRangeException(nameof(value), "Timeout must be positive and finite."); + field = value; + } + } = TimeSpan.FromMinutes(1); +} diff --git a/src/src/Core/Runtime/ContainerException.cs b/src/src/Core/Runtime/ContainerException.cs index d2eae77..eef1134 100644 --- a/src/src/Core/Runtime/ContainerException.cs +++ b/src/src/Core/Runtime/ContainerException.cs @@ -35,6 +35,10 @@ public class ContainerStartupException(string message, Exception innerException) /// An operation did not complete within its configured timeout. public class ContainerTimeoutException(string message) : ContainerException(message) { } +/// A completed one-shot init process returned an unexpected exit code. +public class ContainerExitCodeException(long expected, long actual) + : ContainerException($"The container exited with code {actual}; expected {expected}.") { } + /// /// No container backend is registered, or the explicitly selected backend cannot be used on this host. /// diff --git a/src/src/Core/Sdk/README.md b/src/src/Core/Sdk/README.md index 121fc79..225c250 100644 --- a/src/src/Core/Sdk/README.md +++ b/src/src/Core/Sdk/README.md @@ -26,7 +26,21 @@ separate package. | Diagnostics | `Secret`, `SecretRedactor`, `ContainerState`, `ExecResult`, `ContainerLogEntry` | | Errors | `Purview.Containers.Runtime`: `ContainerException` and its specialisations | -## Backend selection +## Additional capabilities and deadlines + +The shared contract includes entrypoint/user/labels, exec environment overrides, pre-start +`WithResourceMapping`, runtime `CopyAsync`/`ReadFileAsync`, `Wait.ForExitCode`/`WaitForExitAsync`, +`IContainerBackend.CreateNetworkAsync`/`BuildImageAsync`, and explicit `DeleteAsync`. +Networks/images return an `IContainerResource`; dispose attached containers first. +Reuse requires `WithName(...).WithReuse()` and changes disposal ownership. + +Defaults: total startup 5 minutes; exec/exit wait 1 minute; stop 15 seconds; cleanup 30 seconds; +copy/read/log snapshots 1 minute. Fluent timeout overrides and cancellation tokens are supported. +Native synchronous WSLC calls cannot be safely interrupted; these are managed deadlines, not a +hard runtime isolation guarantee. See the +[capability contract](https://github.com/purview-dev/containers/blob/main/docs/wiki/Backend-Capabilities.md). + +## Backend registration A container is produced by an `IContainerBackend`. Backend packages register themselves into the consuming assembly through the `buildTransitive` assets shipped here: diff --git a/src/src/Core/Waiting/ExitCodeWaitStrategy.cs b/src/src/Core/Waiting/ExitCodeWaitStrategy.cs new file mode 100644 index 0000000..040dc1a --- /dev/null +++ b/src/src/Core/Waiting/ExitCodeWaitStrategy.cs @@ -0,0 +1,17 @@ +namespace Purview.Containers.Waiting; + +sealed class ExitCodeWaitStrategy(long expectedExitCode) : WaitStrategy +{ + public override async Task UntilAsync(WaitContext context, CancellationToken cancellationToken) + { + if (context.Container.State != ContainerState.Exited) + { + return false; + } + + var actual = await context.Container.WaitForExitAsync(cancellationToken).ConfigureAwait(false); + if (actual != expectedExitCode) + throw new Runtime.ContainerExitCodeException(expectedExitCode, actual); + return true; + } +} diff --git a/src/src/Core/Waiting/Wait.cs b/src/src/Core/Waiting/Wait.cs index bd571ed..891a290 100644 --- a/src/src/Core/Waiting/Wait.cs +++ b/src/src/Core/Waiting/Wait.cs @@ -5,6 +5,9 @@ namespace Purview.Containers.Waiting; /// Factory for composable wait strategies. public static class Wait { + /// Waits for a one-shot init process to exit with the expected code. + public static WaitStrategy ForExitCode(long exitCode = 0) => new ExitCodeWaitStrategy(exitCode); + /// Waits until the container init process is running. This only means the process started, not that a service is ready. public static WaitStrategy ForContainerRunning() { diff --git a/src/src/Core/Waiting/WaitStrategyRunner.cs b/src/src/Core/Waiting/WaitStrategyRunner.cs index d28e885..53fd360 100644 --- a/src/src/Core/Waiting/WaitStrategyRunner.cs +++ b/src/src/Core/Waiting/WaitStrategyRunner.cs @@ -55,7 +55,7 @@ CancellationToken cancellationToken bool ready; try { - ready = await strategy.UntilAsync(context, token).ConfigureAwait(false); + ready = await strategy.UntilAsync(context, token).WaitAsync(token).ConfigureAwait(false); if (ready) { return; @@ -69,6 +69,10 @@ CancellationToken cancellationToken { throw; } + catch (ContainerExitCodeException) + { + throw; + } catch (Exception ex) { lastError = ex; @@ -131,7 +135,12 @@ CancellationToken cancellationToken try { - var logs = await context.Container.GetLogsAsync(stream: null, cancellationToken).ConfigureAwait(false); + using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + deadline.CancelAfter(TimeSpan.FromSeconds(2)); + var logs = await context + .Container.GetLogsAsync(stream: null, deadline.Token) + .WaitAsync(deadline.Token) + .ConfigureAwait(false); var tail = logs.Length > 2000 ? logs[^2000..] : logs; if (tail.Length > 0) { diff --git a/src/src/Docker/DockerContainer.cs b/src/src/Docker/DockerContainer.cs index 448c240..a57934d 100644 --- a/src/src/Docker/DockerContainer.cs +++ b/src/src/Docker/DockerContainer.cs @@ -1,4 +1,6 @@ +using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; +using DotNet.Testcontainers.Configurations; using Purview.Containers.Runtime; using Purview.Containers.Waiting; using TcContainer = DotNet.Testcontainers.Containers.IContainer; @@ -14,26 +16,39 @@ namespace Purview.Containers.Docker; public sealed class DockerContainer : IContainer { readonly IContainerConfiguration _configuration; + readonly Func> _create; + TcContainer? _handle; int _disposed; - - internal DockerContainer(IContainerConfiguration configuration, TcContainer container, string name) + bool _deleted; + readonly CancellationTokenSource _lifetime = new(); + Task? _exitTask; + + internal DockerContainer( + IContainerConfiguration configuration, + Func> create, + string name + ) { _configuration = configuration; - Handle = container; + _create = create; Name = name; } /// The container Testcontainers created for this adapter. - internal TcContainer Handle { get; } + internal TcContainer Handle => _handle ?? throw new InvalidOperationException("Container has not been started."); /// - public string Id => Handle.Id; + public string Id => _handle?.Id ?? string.Empty; /// public string Name { get; } /// - public ContainerState State => ToState(Handle.State); + public ContainerState State => + _deleted ? ContainerState.Deleted + : _handle is null ? ContainerState.Created + : _exitTask?.IsCompletedSuccessfully == true ? ContainerState.Exited + : ToState(Handle.State); /// public string Image => _configuration.Image; @@ -42,28 +57,60 @@ internal DockerContainer(IContainerConfiguration configuration, TcContainer cont public string? NetworkIp { get; private set; } /// + [SuppressMessage( + "Design", + "CA1031", + Justification = "Failed-start cleanup must preserve the original error or cancellation." + )] public async Task StartAsync(CancellationToken cancellationToken = default) { ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) == 1, this); - await Handle.StartAsync(cancellationToken).ConfigureAwait(false); + using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + deadline.CancelAfter(_configuration.StartupTimeout); + deadline.Token.ThrowIfCancellationRequested(); + try + { + _handle ??= await _create(deadline.Token).ConfigureAwait(false); + await Handle.StartAsync(deadline.Token).ConfigureAwait(false); + _exitTask = ObserveExitAsync(); + _ = _exitTask.ContinueWith( + static task => _ = task.Exception, + CancellationToken.None, + TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default + ); - // Testcontainers reports an unspecified address (or none) when the container has no network. - NetworkIp = Handle.IpAddress is { Length: > 0 } ip && ip != "0.0.0.0" ? ip : null; + // Testcontainers reports an unspecified address (or none) when the container has no network. + NetworkIp = Handle.IpAddress is { Length: > 0 } ip && ip != "0.0.0.0" ? ip : null; - if (_configuration.WaitStrategies.Count > 0) + if (_configuration.WaitStrategies.Count > 0) + { + WaitContext context = new(this, GetMappedPublicPorts(), NetworkIp); + await WaitStrategyRunner + .RunAsync(context, _configuration.WaitStrategies, _configuration.StartupTimeout, deadline.Token) + .ConfigureAwait(false); + } + } + catch { - WaitContext context = new(this, GetMappedPublicPorts(), NetworkIp); - await WaitStrategyRunner - .RunAsync(context, _configuration.WaitStrategies, _configuration.StartupTimeout, cancellationToken) - .ConfigureAwait(false); + try + { + await DisposeAsync().ConfigureAwait(false); + } + catch { } + throw; } } /// - public Task StopAsync(CancellationToken cancellationToken = default) + public async Task StopAsync(CancellationToken cancellationToken = default) { ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) == 1, this); - return Handle.StopAsync(cancellationToken); + using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + deadline.CancelAfter(_configuration.StopTimeout); + deadline.Token.ThrowIfCancellationRequested(); + if (_handle is not null) + await Handle.StopAsync(deadline.Token).ConfigureAwait(false); } /// @@ -75,6 +122,8 @@ public async Task ExecAsync( { ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) == 1, this); ArgumentNullException.ThrowIfNull(command); + if (command.Length == 0) + throw new ArgumentException("Command cannot be empty.", nameof(command)); if (options?.EnableStandardInput == true) { @@ -83,34 +132,58 @@ public async Task ExecAsync( ); } - if (options?.Environment is { Count: > 0 }) - { - throw new ContainerNotSupportedException( - "ExecOptions.Environment is not supported by the Docker backend; set it on the container instead." - ); - } - using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - if (options?.Timeout is TimeSpan timeout) + timeoutSource.CancelAfter(options?.Timeout ?? _configuration.ExecTimeout); + timeoutSource.Token.ThrowIfCancellationRequested(); + + // Testcontainers' argv overload cannot supply exec settings and logs argv. Send environment + // separately through its Docker client transport rather than embedding secrets in a shell command. + using var client = TestcontainersSettings + .OS.DockerEndpointAuthConfig.GetDockerClientBuilder(Guid.NewGuid()) + .Build(); + var created = await client + .Exec.CreateContainerExecAsync( + Handle.Id, + new global::Docker.DotNet.Models.ContainerExecCreateParameters + { + Cmd = command, + Env = options?.Environment?.Select(pair => $"{pair.Key}={pair.Value}").ToList() ?? [], + WorkingDir = options?.WorkingDirectory ?? string.Empty, + AttachStdout = true, + AttachStderr = true, + }, + timeoutSource.Token + ) + .ConfigureAwait(false); + using var stream = await client + .Exec.StartContainerExecAsync( + created.ID, + new global::Docker.DotNet.Models.ContainerExecStartParameters(), + timeoutSource.Token + ) + .ConfigureAwait(false); + var (stdout, stderr) = await stream.ReadOutputToEndAsync(timeoutSource.Token).ConfigureAwait(false); + var inspect = await client + .Exec.InspectContainerExecAsync(created.ID, timeoutSource.Token) + .ConfigureAwait(false); + while (inspect.Running) { - timeoutSource.CancelAfter(timeout); + await Task.Delay(50, timeoutSource.Token).ConfigureAwait(false); + inspect = await client + .Exec.InspectContainerExecAsync(created.ID, timeoutSource.Token) + .ConfigureAwait(false); } - - // Testcontainers exposes a single argv exec overload, so a requested working directory is applied - // by executing through the shell. - var argv = options?.WorkingDirectory is string workingDirectory - ? ["sh", "-c", $"cd {Quote(workingDirectory)} && {string.Join(' ', command.Select(Quote))}"] - : command; - - var result = await Handle.ExecAsync(argv, timeoutSource.Token).ConfigureAwait(false); - return new ExecResult(result.ExitCode ?? -1, result.Stdout, result.Stderr); + return new ExecResult(inspect.ExitCode ?? -1, stdout, stderr); } /// public ushort GetMappedPublicPort(ushort containerPort) { ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) == 1, this); - return Handle.GetMappedPublicPort(containerPort); + var binding = _configuration.PortBindings.FirstOrDefault(binding => binding.ContainerPort == containerPort); + return Handle.GetMappedPublicPort( + $"{containerPort}/{(binding?.Protocol == Networking.PortProtocol.Udp ? "udp" : "tcp")}" + ); } /// @@ -120,7 +193,9 @@ public IReadOnlyDictionary GetMappedPublicPorts() Dictionary mappings = []; foreach (var binding in _configuration.PortBindings) { - mappings[binding.ContainerPort] = Handle.GetMappedPublicPort(binding.ContainerPort); + mappings[binding.ContainerPort] = Handle.GetMappedPublicPort( + $"{binding.ContainerPort}/{(binding.Protocol == Networking.PortProtocol.Udp ? "udp" : "tcp")}" + ); } return mappings; @@ -172,17 +247,18 @@ [EnumeratorCancellation] CancellationToken cancellationToken // Testcontainers exposes no positioned log stream, so the accumulated output is polled and new lines // are emitted until the container is no longer running. - var emitted = 0; + var emittedStdout = 0; + var emittedStderr = 0; while (true) { + var exited = State is not ContainerState.Running; var (stdout, stderr) = await ReadLogsAsync(cancellationToken).ConfigureAwait(false); - var lines = Split(stdout, stderr); - for (; emitted < lines.Count; emitted++) - { - yield return lines[emitted]; - } + foreach (var entry in NewLogEntries(stdout, LogOutput.Stdout, ref emittedStdout, exited)) + yield return entry; + foreach (var entry in NewLogEntries(stderr, LogOutput.Stderr, ref emittedStderr, exited)) + yield return entry; - if (State is not ContainerState.Running) + if (exited) { yield break; } @@ -191,6 +267,19 @@ [EnumeratorCancellation] CancellationToken cancellationToken } } + static ContainerLogEntry[] NewLogEntries(string content, LogOutput stream, ref int emitted, bool exited) + { + var lines = content.Split('\n'); + var completeCount = exited && lines[^1].Length > 0 ? lines.Length : lines.Length - 1; + var entries = lines + .Skip(emitted) + .Take(Math.Max(0, completeCount - emitted)) + .Select(line => new ContainerLogEntry(DateTimeOffset.UtcNow, stream, line.TrimEnd('\r'))) + .ToArray(); + emitted = completeCount; + return entries; + } + /// public async ValueTask DisposeAsync() { @@ -199,43 +288,102 @@ public async ValueTask DisposeAsync() return; } - await Handle.DisposeAsync().ConfigureAwait(false); - GC.SuppressFinalize(this); + try + { + await _lifetime.CancelAsync().ConfigureAwait(false); + if (_handle is not null) + await Handle.DisposeAsync().AsTask().WaitAsync(_configuration.CleanupTimeout).ConfigureAwait(false); + if (!_configuration.Reuse) + _deleted = true; + } + finally + { + _lifetime.Dispose(); + GC.SuppressFinalize(this); + } + } + + /// + public async Task CopyAsync(string source, string target, CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) == 1, this); + using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + deadline.CancelAfter(_configuration.OperationTimeout); + await DockerFileCopy.CopyAsync(Handle.Id, source, target, deadline.Token).ConfigureAwait(false); } - Task<(string Stdout, string Stderr)> ReadLogsAsync(CancellationToken cancellationToken) + /// + public async Task DeleteAsync(CancellationToken cancellationToken = default) { - // Docker filters logs by a time window and compares it against UTC log timestamps, so both bounds are - // UTC: the epoch (DateTime.MinValue is not a valid Docker timestamp) and now plus a small margin that - // absorbs second-level truncation. - return Handle.GetLogsAsync( - DateTime.UnixEpoch, - DateTime.UtcNow.AddMinutes(1), - timestampsEnabled: false, - cancellationToken - ); + ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) == 1, this); + using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + deadline.CancelAfter(_configuration.CleanupTimeout); + deadline.Token.ThrowIfCancellationRequested(); + if (_handle is not null && !_deleted) + { + using var client = TestcontainersSettings + .OS.DockerEndpointAuthConfig.GetDockerClientBuilder(Guid.NewGuid()) + .Build(); + try + { + await client + .Containers.RemoveContainerAsync( + Handle.Id, + new global::Docker.DotNet.Models.ContainerRemoveParameters + { + Force = true, + RemoveVolumes = true, + }, + deadline.Token + ) + .ConfigureAwait(false); + } + catch (global::Docker.DotNet.DockerApiException exception) + when (exception.StatusCode == System.Net.HttpStatusCode.NotFound) { } + _deleted = true; + } } - static List Split(string stdout, string stderr) + /// + public async Task ReadFileAsync(string path, CancellationToken cancellationToken = default) { - List entries = []; - Append(entries, stdout, LogOutput.Stdout); - Append(entries, stderr, LogOutput.Stderr); - return entries; + ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) == 1, this); + using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + deadline.CancelAfter(_configuration.OperationTimeout); + return await Handle.ReadFileAsync(path, deadline.Token).ConfigureAwait(false); } - static void Append(List entries, string content, LogOutput stream) + /// + public async Task WaitForExitAsync(CancellationToken cancellationToken = default) { - foreach (var line in content.Split('\n', StringSplitOptions.RemoveEmptyEntries)) - { - entries.Add(new ContainerLogEntry(DateTimeOffset.Now, stream, line.TrimEnd('\r'))); - } + ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) == 1, this); + using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + deadline.CancelAfter(_configuration.ExecTimeout); + return await (_exitTask ?? throw new InvalidOperationException("Container has not been started.")) + .WaitAsync(deadline.Token) + .ConfigureAwait(false); } - static string Quote(string value) => - value.Contains('\'', StringComparison.Ordinal) - ? $"\"{value.Replace("\"", "\\\"", StringComparison.Ordinal)}\"" - : $"'{value}'"; + async Task ObserveExitAsync() + { + using var client = TestcontainersSettings + .OS.DockerEndpointAuthConfig.GetDockerClientBuilder(Guid.NewGuid()) + .Build(); + var result = await client.Containers.WaitContainerAsync(Handle.Id, _lifetime.Token).ConfigureAwait(false); + return result.StatusCode; + } + + async Task<(string Stdout, string Stderr)> ReadLogsAsync(CancellationToken cancellationToken) + { + // Docker filters logs by a time window and compares it against UTC log timestamps, so both bounds are + // UTC: the epoch (DateTime.MinValue is not a valid Docker timestamp) and now plus a small margin that + // absorbs second-level truncation. + using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + deadline.CancelAfter(_configuration.OperationTimeout); + return await Handle + .GetLogsAsync(DateTime.UnixEpoch, DateTime.UtcNow.AddMinutes(1), timestampsEnabled: false, deadline.Token) + .ConfigureAwait(false); + } static ContainerState ToState(TcStates state) => state switch diff --git a/src/src/Docker/DockerContainerBackend.cs b/src/src/Docker/DockerContainerBackend.cs index 1f440fd..eafcfb8 100644 --- a/src/src/Docker/DockerContainerBackend.cs +++ b/src/src/Docker/DockerContainerBackend.cs @@ -1,3 +1,4 @@ +using Docker.DotNet.Models; using DotNet.Testcontainers.Configurations; using TcContainerBuilder = DotNet.Testcontainers.Builders.ContainerBuilder; using TcPullPolicy = DotNet.Testcontainers.Images.PullPolicy; @@ -11,6 +12,8 @@ namespace Purview.Containers.Docker; /// public sealed class DockerContainerBackend : IContainerBackend, IContainerBackendPreference { + readonly Lazy _wslDockerHost = new(WslDockerHost.Detect); + /// Stable backend identifier. public string Name => "docker"; @@ -27,21 +30,23 @@ public sealed class DockerContainerBackend : IContainerBackend, IContainerBacken public IContainer CreateContainer(IContainerConfiguration configuration) { ArgumentNullException.ThrowIfNull(configuration); - var container = CreateBuilder(configuration).Build(); - return new DockerContainer(configuration, container, ContainerName.Generate(configuration)); + var name = ContainerName.Generate(configuration); + return new DockerContainer(configuration, token => CreateHandleAsync(configuration, name, token), name); } /// public async Task GetInfoAsync(CancellationToken cancellationToken = default) { + using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + deadline.CancelAfter(TimeSpan.FromSeconds(10)); try { // Resolving the endpoint throws when no Docker environment can be discovered, and the ping // throws when the daemon is not running; both are reported as "unavailable" with the reason. var endpoint = TestcontainersSettings.OS.DockerEndpointAuthConfig; using var client = endpoint.GetDockerClientBuilder(Guid.NewGuid()).Build(); - await client.System.PingAsync(cancellationToken).ConfigureAwait(false); - var info = await client.System.GetSystemInfoAsync(cancellationToken).ConfigureAwait(false); + await client.System.PingAsync(deadline.Token).ConfigureAwait(false); + var info = await client.System.GetSystemInfoAsync(deadline.Token).ConfigureAwait(false); return new ContainerBackendInfo( Name, IsAvailable: true, @@ -50,7 +55,7 @@ public async Task GetInfoAsync(CancellationToken cancellat [] ); } - catch (Exception exception) when (exception is not OperationCanceledException) + catch (Exception exception) when (!cancellationToken.IsCancellationRequested) { return new ContainerBackendInfo( Name, @@ -62,12 +67,133 @@ public async Task GetInfoAsync(CancellationToken cancellat } } - static TcContainerBuilder CreateBuilder(IContainerConfiguration configuration) + /// + public async Task CreateNetworkAsync( + NetworkConfiguration? configuration = null, + CancellationToken cancellationToken = default + ) + { + configuration ??= new(); + using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + deadline.CancelAfter(configuration.Timeout); + deadline.Token.ThrowIfCancellationRequested(); + var name = configuration.Name ?? $"purview-{Guid.NewGuid():N}"; + var network = new DotNet.Testcontainers.Builders.NetworkBuilder().WithName(name).Build(); + try + { + await network.CreateAsync(deadline.Token).ConfigureAwait(false); + return new DockerResource(name, network, network, configuration.Timeout); + } + catch + { + await network + .DisposeAsync() + .AsTask() + .WaitAsync(TimeSpan.FromSeconds(30), CancellationToken.None) + .ConfigureAwait(false); + throw; + } + } + + /// + public async Task BuildImageAsync( + ImageBuildConfiguration configuration, + CancellationToken cancellationToken = default + ) + { + ArgumentNullException.ThrowIfNull(configuration); + using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + deadline.CancelAfter(configuration.Timeout); + deadline.Token.ThrowIfCancellationRequested(); + var builder = new DotNet.Testcontainers.Builders.ImageFromDockerfileBuilder() + .WithName(configuration.Name) + .WithDockerfileDirectory(configuration.ContextDirectory) + .WithDockerfile(configuration.Dockerfile); + if (configuration.Target is not null) + builder = builder.WithTarget(configuration.Target); + var image = builder.Build(); + try + { + await image.CreateAsync(deadline.Token).ConfigureAwait(false); + return new DockerResource(configuration.Name, image, image, TimeSpan.FromSeconds(30)); + } + catch + { + await image + .DisposeAsync() + .AsTask() + .WaitAsync(TimeSpan.FromSeconds(30), CancellationToken.None) + .ConfigureAwait(false); + throw; + } + } + + async Task CreateHandleAsync( + IContainerConfiguration configuration, + string name, + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + if (configuration.RegistryCredentials is { } credentials && configuration.PullPolicy != Images.PullPolicy.Never) + { + var endpoint = TestcontainersSettings.OS.DockerEndpointAuthConfig; + using var client = endpoint.GetDockerClientBuilder(Guid.NewGuid()).Build(); + var pull = configuration.PullPolicy == Images.PullPolicy.Always; + if (!pull) + { + try + { + await client.Images.InspectImageAsync(configuration.Image, cancellationToken).ConfigureAwait(false); + } + catch (global::Docker.DotNet.DockerImageNotFoundException) + { + pull = true; + } + } + if (pull) + { + await client + .Images.CreateImageAsync( + new ImagesCreateParameters { FromImage = configuration.Image }, + new AuthConfig + { + ServerAddress = credentials.ServerAddress.ToString(), + Username = credentials.Username, + Password = credentials.Password.Value, + }, + new Progress(), + cancellationToken + ) + .ConfigureAwait(false); + } + } + cancellationToken.ThrowIfCancellationRequested(); + var container = CreateBuilder(configuration, name).Build(); + if (configuration.ResourceMappings.Count > 0) + { + // Testcontainers' created event is synchronous and precedes init startup. Its directory + // mappings omit empty directories, so use our cancellable archive transport here. + container.Created += (_, _) => + { + foreach (var mapping in configuration.ResourceMappings) + DockerFileCopy + .CopyAsync(container.Id, mapping.Source, mapping.Target, cancellationToken) + .GetAwaiter() + .GetResult(); + }; + } + return container; + } + + TcContainerBuilder CreateBuilder(IContainerConfiguration configuration, string name) { var builder = new TcContainerBuilder(configuration.Image) + .WithName(name) .WithAutoRemove(configuration.EnableAutoRemove) + .WithReuse(configuration.Reuse) .WithImagePullPolicy( - configuration.PullPolicy switch + (configuration.RegistryCredentials is null ? configuration.PullPolicy : Images.PullPolicy.Never) switch { Images.PullPolicy.Always => TcPullPolicy.Always, Images.PullPolicy.Never => TcPullPolicy.Never, @@ -75,6 +201,17 @@ static TcContainerBuilder CreateBuilder(IContainerConfiguration configuration) } ); + builder = ApplyAdvancedConfiguration(builder, configuration); + if (configuration.ResourceMappings.Count > 0) + builder = builder.WithLabel( + "purview.containers.resource-mappings", + Convert.ToHexString( + System.Security.Cryptography.SHA256.HashData( + System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(configuration.ResourceMappings) + ) + ) + ); + if (!string.IsNullOrWhiteSpace(configuration.Name)) { builder = builder.WithName(configuration.Name); @@ -107,23 +244,75 @@ static TcContainerBuilder CreateBuilder(IContainerConfiguration configuration) foreach (var binding in configuration.PortBindings) { + var port = $"{binding.ContainerPort}/{(binding.Protocol == Networking.PortProtocol.Udp ? "udp" : "tcp")}"; builder = binding.HostPort is ushort hostPort - ? builder.WithPortBinding(hostPort, binding.ContainerPort) - : builder.WithPortBinding(binding.ContainerPort, assignRandomHostPort: true); + ? builder.WithPortBinding(hostPort.ToString(System.Globalization.CultureInfo.InvariantCulture), port) + : builder.WithPortBinding(port, assignRandomHostPort: true); } foreach (var mount in configuration.BindMounts) { - builder = builder.WithBindMount(mount.HostPath, mount.ContainerPath, ToAccessMode(mount.ReadOnly)); + var source = + OperatingSystem.IsWindows() && WslDockerHost.IsWindowsDrivePath(mount.HostPath) + ? _wslDockerHost.Value?.Resolve(mount.HostPath) ?? mount.HostPath + : mount.HostPath; + builder = builder.WithBindMount(source, mount.ContainerPath, ToAccessMode(mount.ReadOnly)); } foreach (var volume in configuration.NamedVolumes) { builder = builder.WithVolumeMount(volume.Name, volume.ContainerPath, ToAccessMode(volume.ReadOnly)); } - return builder; } + static TcContainerBuilder ApplyAdvancedConfiguration( + TcContainerBuilder builder, + IContainerConfiguration configuration + ) + { + if (configuration.Entrypoint.Count > 0) + builder = builder.WithEntrypoint([.. configuration.Entrypoint]); + if (configuration.Labels.Count > 0) + builder = builder.WithLabel(configuration.Labels); + if (configuration.NetworkingMode == Networking.ContainerNetworkingMode.None) + builder = builder.WithNetwork("none"); + else if (configuration.Network is not null) + builder = builder.WithNetwork(configuration.Network); + if (configuration.NetworkAliases.Count > 0) + builder = builder.WithNetworkAliases(configuration.NetworkAliases); + return builder.WithCreateParameterModifier(parameters => + { + if (configuration.User is not null) + parameters.User = configuration.User; + if (configuration.DomainName is not null) + parameters.Domainname = configuration.DomainName; + parameters.StopTimeout = TimeSpan.FromSeconds( + Math.Max(1, Math.Min(10, configuration.StopTimeout.TotalSeconds * 2 / 3)) + ); + parameters.HostConfig ??= new(); + if (configuration.EnableGPU) + parameters.HostConfig.DeviceRequests = + [ + new DeviceRequest { Count = -1, Capabilities = [new List { "gpu" }] }, + ]; + foreach (var binding in configuration.PortBindings) + { + var key = + $"{binding.ContainerPort}/{(binding.Protocol == Networking.PortProtocol.Udp ? "udp" : "tcp")}"; + parameters.HostConfig.PortBindings ??= + new Dictionary>(); + parameters.HostConfig.PortBindings[key] = + [ + new global::Docker.DotNet.Models.PortBinding + { + HostIP = binding.HostAddress ?? "127.0.0.1", + HostPort = binding.HostPort?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? "0", + }, + ]; + } + }); + } + static AccessMode ToAccessMode(bool readOnly) => readOnly ? AccessMode.ReadOnly : AccessMode.ReadWrite; } diff --git a/src/src/Docker/DockerFileCopy.cs b/src/src/Docker/DockerFileCopy.cs new file mode 100644 index 0000000..4e42d41 --- /dev/null +++ b/src/src/Docker/DockerFileCopy.cs @@ -0,0 +1,24 @@ +using DotNet.Testcontainers.Configurations; + +namespace Purview.Containers.Docker; + +static class DockerFileCopy +{ + internal static async Task CopyAsync(string id, string source, string target, CancellationToken cancellationToken) + { + using var archive = await ContainerResourceArchive + .CreateAsync(source, target, cancellationToken) + .ConfigureAwait(false); + using var client = TestcontainersSettings + .OS.DockerEndpointAuthConfig.GetDockerClientBuilder(Guid.NewGuid()) + .Build(); + await client + .Containers.ExtractArchiveToContainerAsync( + id, + new global::Docker.DotNet.Models.CopyToContainerParameters { Path = "/" }, + archive, + cancellationToken + ) + .ConfigureAwait(false); + } +} diff --git a/src/src/Docker/DockerResource.cs b/src/src/Docker/DockerResource.cs new file mode 100644 index 0000000..6b37709 --- /dev/null +++ b/src/src/Docker/DockerResource.cs @@ -0,0 +1,25 @@ +using DotNet.Testcontainers; + +namespace Purview.Containers.Docker; + +sealed class DockerResource(string name, IFutureResource resource, IAsyncDisposable disposable, TimeSpan timeout) + : IContainerResource +{ + int _disposed; + + public string Name => name; + + public async Task DeleteAsync(CancellationToken cancellationToken = default) + { + using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + deadline.CancelAfter(timeout); + await resource.DeleteAsync(deadline.Token).WaitAsync(deadline.Token).ConfigureAwait(false); + } + + public async ValueTask DisposeAsync() + { + if (Interlocked.Exchange(ref _disposed, 1) == 1) + return; + await disposable.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(30)).ConfigureAwait(false); + } +} diff --git a/src/src/Docker/Sdk/README.md b/src/src/Docker/Sdk/README.md index c6f9676..050c04a 100644 --- a/src/src/Docker/Sdk/README.md +++ b/src/src/Docker/Sdk/README.md @@ -45,10 +45,22 @@ PURVIEW_CONTAINERS_BACKEND=docker # or "auto" (the default) to probe WSLC fi ## Behaviour notes +- Windows drive paths in bind mounts are translated automatically when the connected loopback TCP + daemon is verified as a local WSL2 Docker Engine. The backend compares daemon IDs across running + distributions, then invokes `wslpath` in the matching distribution, so custom drive mount roots work + without consumer configuration. That distribution must expose its daemon through + `/var/run/docker.sock` and allow its default user to run `docker info`. Docker Desktop, remote + daemons, Linux paths and named volumes keep their existing behaviour. - Readiness uses the same shared wait strategies as every other backend (host TCP/HTTP, an in-container command, or a log match), so module behaviour does not change with the backend. -- `ExecAsync` supports `WorkingDirectory` and `Timeout`; `Environment` and `EnableStandardInput` throw - `ContainerNotSupportedException` rather than being silently ignored. +- `ExecAsync` supports environment, working-directory and timeout overrides. Capture-based exec + rejects interactive stdin. Cancellation/deadlines propagate as `OperationCanceledException`. +- Entrypoint, user, labels, pre-start/runtime file copy, one-shot exit waits, managed networks/aliases, + Dockerfile builds and opt-in reuse are available. Defaults are five minutes for total startup, + one minute for exec/copy/read/log snapshots, 15 seconds for stop and 30 seconds for cleanup. + Reuse follows Testcontainers semantics and disables normal reaper cleanup; `DeleteAsync` removes + retained containers explicitly. See + [capabilities and ownership](https://github.com/purview-dev/containers/blob/main/docs/wiki/Backend-Capabilities.md). - Log tailing polls the accumulated output until the container stops, because the Testcontainers API exposes no positioned log stream. diff --git a/src/src/Docker/WslDockerHost.cs b/src/src/Docker/WslDockerHost.cs new file mode 100644 index 0000000..bdd8569 --- /dev/null +++ b/src/src/Docker/WslDockerHost.cs @@ -0,0 +1,159 @@ +using System.ComponentModel; +using System.Diagnostics; +using System.Text; +using DotNet.Testcontainers.Configurations; + +namespace Purview.Containers.Docker; + +/// Translates Windows bind sources only after identifying the daemon's local WSL distribution. +sealed class WslDockerHost(string distribution, Func run) +{ + internal static bool IsWindowsDrivePath(string path) => + path.Length >= 3 && char.IsAsciiLetter(path[0]) && path[1] == ':' && path[2] is '\\' or '/'; + + internal string Resolve(string path) + { + if (!IsWindowsDrivePath(path)) + { + return path; + } + + var translated = run([ + "--distribution", + distribution, + "--exec", + "wslpath", + "-a", + "-u", + path.Replace('\\', '/'), + ]); + if ( + string.IsNullOrWhiteSpace(translated) + || !translated.StartsWith('/') + || translated.Contains('\n', StringComparison.Ordinal) + ) + { + throw new InvalidOperationException( + $"Could not translate a bind mount into WSL distribution '{distribution}'." + ); + } + + return translated; + } + + internal static WslDockerHost? Detect() + { + var endpoint = TestcontainersSettings.OS.DockerEndpointAuthConfig; + if (!OperatingSystem.IsWindows() || !IsLocalTcp(endpoint.Endpoint)) + { + return null; + } + + using var client = endpoint.GetDockerClientBuilder(Guid.NewGuid()).Build(); + using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(5)); + var info = client.System.GetSystemInfoAsync(timeout.Token).ConfigureAwait(false).GetAwaiter().GetResult(); + var distribution = FindDistribution(endpoint.Endpoint, info.KernelVersion, info.OperatingSystem, info.ID, Run); + + return distribution is null ? null : new WslDockerHost(distribution, Run); + } + + internal static string? FindDistribution( + Uri endpoint, + string? kernelVersion, + string? operatingSystem, + string? daemonId, + Func run + ) + { + if ( + !IsLocalTcp(endpoint) + || kernelVersion?.Contains("microsoft", StringComparison.OrdinalIgnoreCase) != true + || operatingSystem?.Contains("Docker Desktop", StringComparison.OrdinalIgnoreCase) == true + || string.IsNullOrWhiteSpace(daemonId) + ) + { + return null; + } + + var distributions = run(["--list", "--running", "--quiet"]); + foreach ( + var candidate in (distributions ?? string.Empty).Split( + ['\r', '\n', '\0'], + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries + ) + ) + { + // Compare daemon identities, not the default distro or a hostname: several distributions + // can run different Docker engines under the same WSL kernel. + var id = run([ + "--distribution", + candidate, + "--exec", + "env", + "-u", + "DOCKER_HOST", + "-u", + "DOCKER_TLS_VERIFY", + "-u", + "DOCKER_CERT_PATH", + "-u", + "DOCKER_CONTEXT", + "docker", + "--host", + "unix:///var/run/docker.sock", + "info", + "--format", + "{{.ID}}", + ]); + if (string.Equals(id, daemonId, StringComparison.Ordinal)) + { + return candidate; + } + } + + return null; + } + + static bool IsLocalTcp(Uri endpoint) => endpoint.Scheme is "tcp" or "http" or "https" && endpoint.IsLoopback; + + static string? Run(string[] arguments) + { + try + { + ProcessStartInfo startInfo = new("wsl.exe") + { + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + StandardOutputEncoding = arguments[0] == "--list" ? Encoding.Unicode : Encoding.UTF8, + }; + foreach (var argument in arguments) + { + startInfo.ArgumentList.Add(argument); + } + + using var process = Process.Start(startInfo); + if (process is null) + { + return null; + } + + var output = process.StandardOutput.ReadToEndAsync(); + var error = process.StandardError.ReadToEndAsync(); + if (!process.WaitForExit(5000)) + { + process.Kill(entireProcessTree: true); + process.WaitForExit(); + return null; + } + + Task.WhenAll(output, error).ConfigureAwait(false).GetAwaiter().GetResult(); + return process.ExitCode == 0 ? output.GetAwaiter().GetResult().Trim() : null; + } + catch (Exception exception) when (exception is Win32Exception or IOException or InvalidOperationException) + { + return null; + } + } +} diff --git a/src/src/Wsl/LogBuffer.cs b/src/src/Wsl/LogBuffer.cs index 3248de8..c28c7aa 100644 --- a/src/src/Wsl/LogBuffer.cs +++ b/src/src/Wsl/LogBuffer.cs @@ -26,6 +26,7 @@ sealed class LogBuffer readonly StringBuilder _stdoutPending = new(); readonly StringBuilder _stderrPending = new(); int _attached; + Process? _process; public void Attach(Process process) { @@ -36,11 +37,24 @@ public void Attach(Process process) process.OutputReceived += OnStdout; process.ErrorReceived += OnStderr; - process.Exited += _ => - { - FlushRemaining(); - Complete(); - }; + process.Exited += OnExited; + _process = process; + } + + void OnExited(int exitCode) + { + FlushRemaining(); + Complete(); + } + + public void Detach() + { + if (_process is not { } process) + return; + process.OutputReceived -= OnStdout; + process.ErrorReceived -= OnStderr; + process.Exited -= OnExited; + _process = null; } public IReadOnlyList Snapshot() diff --git a/src/src/Wsl/Sdk/README.md b/src/src/Wsl/Sdk/README.md index 535c984..dd6e776 100644 --- a/src/src/Wsl/Sdk/README.md +++ b/src/src/Wsl/Sdk/README.md @@ -2,8 +2,9 @@ The **WSL Containers (WSLC) backend** for [`Purview.Containers.Core`](https://www.nuget.org/packages/Purview.Containers.Core): throwaway Linux containers for .NET integration testing built directly on the `Microsoft.WSL.Containers` -managed API, with **no Docker installation** and no `wslc.exe`/`wsl.exe`/`docker` CLI, Docker.DotNet or -Testcontainers dependency. +managed API, with **no Docker installation**, Docker.DotNet or Testcontainers dependency. Capabilities +missing from the SDK use a bounded `wslc` CLI fallback in the same session: entrypoint/user/labels, +managed networks/aliases, reuse, file copy and Dockerfile builds require `wslc` on PATH. ```bash dotnet add package Purview.Containers.Wsl @@ -26,7 +27,7 @@ code on Docker in Linux CI, reference ## Requirements -- Windows 10/11 with **WSL Containers** (`wsl --install --no-distribution`), verified against WSL 3.0.1.0. +- Windows 11 or Windows Server 2025 with **WSL Containers** (`wsl --install --no-distribution`). - **A .NET 10 project.** The recommended target framework is platform-neutral (`net10.0`): it binds the facade and gives you automatic WSLC-or-Docker selection. A Windows target framework (`net10.0-windows10.0.19041.0`, x64 or arm64) binds the implementation directly. Anything older than @@ -46,6 +47,7 @@ code on Docker in Linux CI, reference ## Quick start ```csharp +using Purview.Containers; using Purview.Containers.Wsl; using Purview.Containers.Waiting; @@ -71,6 +73,10 @@ invalid images, ports and mounts fail during configuration rather than at pull t | `WithName`, `WithHostname`, `WithDomainName` | Container identity. Names are generated uniquely when unset. | | `WithEnvironment(string, string)`, `WithEnvironment(IReadOnlyDictionary)` | Init-process environment. | | `WithCommand(params string[])`, `WithWorkingDirectory` | Init-process argv (no shell) and working directory. | +| `WithEntrypoint`, `WithUser`, `WithLabel` | Init-process overrides and non-secret metadata (CLI creation). | +| `WithNetwork`, `WithNetworkAliases`, `WithReuse` | Managed network attachment/DNS aliases and explicit session-scoped reuse. | +| `WithResourceMapping` | Pre-start file/directory copy, including empty directories. | +| `WithExecTimeout`, `WithStopTimeout`, `WithCleanupTimeout`, `WithOperationTimeout` | Positive finite managed deadlines. | | `WithPortBinding(ushort, bool assignRandomHostPort)`, `WithPortBinding(ushort, ushort hostPort, ...)`, `WithPortBinding(PortBinding)` | Host port mappings; random host ports use native `windowsPort=0` allocation. | | `WithBindMount(string hostPath, string containerPath, bool readOnly = false)` | Bind-mount a Windows directory. | | `WithVolumeMount(string name, string containerPath, bool readOnly = false)` | Named volume on the session VHD. | @@ -78,10 +84,26 @@ invalid images, ports and mounts fail during configuration rather than at pull t ## Container API +Both backends expose `CopyAsync`, `ReadFileAsync`, `WaitForExitAsync`, `Wait.ForExitCode`, +`CreateNetworkAsync` and `BuildImageAsync`, plus explicit `DeleteAsync`. WSLC reuse via +`WithName(...).WithReuse()` is session-scoped, not cross-process. Dispose containers before owned +networks/images. The defaults are five minutes for total startup, one minute for exec/exit waits and +copy/read/log snapshots, 15 seconds for stop and 30 seconds for cleanup; fluent timeout overrides +are available. Exec/stop cancellation is propagated, not returned as a successful result. + +Synchronous native SDK calls cannot be interrupted safely. Managed waits and CLI processes are +bounded, but a blocked native call can outlive cancellation. CLI creation currently rejects privileged +mode combined with CLI-only features and multi-line environment values. See +[capabilities and cancellation](https://github.com/purview-dev/containers/blob/main/docs/wiki/Backend-Capabilities.md). + `IContainer` (and `WslContainer`) exposes `StartAsync`, `StopAsync`, `ExecAsync`, `GetMappedPublicPort`, `GetMappedPublicPorts`, `GetLogsAsync(LogOutput?)` and a tailing `IAsyncEnumerable` `GetLogsAsync(CancellationToken)`. `Id`, `Name`, `State` and `Image` describe the running container. -`DisposeAsync` stops and deletes the container and is idempotent; it never terminates the shared session. +`DisposeAsync` normally stops and deletes the container; reusable containers are retained. It is +idempotent and never terminates the shared session. +Stopping signals the init process with SIGTERM, waits up to 10 seconds, then escalates to SIGKILL +with a further 5-second exit wait. The grace period uses managed polling rather than the native +blocking `Container.Stop` call and does not hold up other containers' session operations. ## Wait strategies @@ -90,6 +112,7 @@ invalid images, ports and mounts fail during configuration rather than at pull t | Factory | Ready when | | -- | -- | | `Wait.ForContainerRunning()` | The init process is running (started, not necessarily a ready service). | +| `Wait.ForExitCode(expected)` | A one-shot init process exits with the expected code; a mismatch fails immediately. | | `Wait.ForTcpPort(port)` | A TCP connection to the mapped host port succeeds. | | `Wait.ForHttp(path)` | An HTTP(S) request succeeds; configure `ForPort`, `ForStatusCode`, `ForHeader`, `AllowInsecureTls`. | | `Wait.ForLogMessage(string)` / `Wait.ForLogMessage(Regex)` | Accumulated init-process output matches. | @@ -98,8 +121,10 @@ invalid images, ports and mounts fail during configuration rather than at pull t | `Wait.ForAll(...)` / `Wait.ForAny(...)` | All / any contained strategy succeeds. | Every strategy supports `.WithTimeout(...)`, `.WithInterval(...)` and `.WithRetries(...)`. The default -timeout is the container's `StartupTimeout` (5 minutes). Timeouts throw `ContainerTimeoutException` with -a diagnostic naming the container, image, state, mapped ports, strategy and a bounded, secret-redacted log tail. +timeout is the container's `StartupTimeout` (5 minutes total). Strategy exhaustion throws +`ContainerTimeoutException`; caller cancellation or the overall startup deadline throws +`OperationCanceledException`. Diagnostics include a bounded log tail that can contain application +secrets: do not publish it indiscriminately. ## Runtime, sessions and storage @@ -133,7 +158,8 @@ hatch is the opt-in accessor for `Inspect()` and raw handles. `System.Diagnostics.ActivitySource("Purview.Containers")` emits session, pull, container lifecycle, exec and wait spans. Credentials and other sensitive values are wrapped in `Secret` and redacted from `ToString()` -and diagnostics. +and their formatted diagnostics. Application logs are not automatically secret-safe; CLI failures omit +raw command/build output. ## Documentation diff --git a/src/src/Wsl/Wsl.csproj b/src/src/Wsl/Wsl.csproj index 4fc5060..ac94dfc 100644 --- a/src/src/Wsl/Wsl.csproj +++ b/src/src/Wsl/Wsl.csproj @@ -54,6 +54,10 @@ + + + + diff --git a/src/src/Wsl/WslCommand.cs b/src/src/Wsl/WslCommand.cs new file mode 100644 index 0000000..51fb4d0 --- /dev/null +++ b/src/src/Wsl/WslCommand.cs @@ -0,0 +1,125 @@ +using System.ComponentModel; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using Purview.Containers.Runtime; + +namespace Purview.Containers.Wsl; + +static class WslCommand +{ + internal sealed record Result(int ExitCode, byte[] Output, string Error); + + [SuppressMessage( + "Reliability", + "CA2025", + Justification = "Cancellation kills and drains the owned process; bounded cleanup then closes its pipes if the runtime stalls." + )] + internal static async Task RunAsync( + string session, + IReadOnlyList arguments, + CancellationToken cancellationToken, + Stream? input = null, + bool allowFailure = false, + bool captureOutput = true + ) + { + cancellationToken.ThrowIfCancellationRequested(); + ProcessStartInfo info = new("wslc") + { + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + RedirectStandardInput = input is not null, + }; + info.ArgumentList.Add("--session"); + info.ArgumentList.Add(session); + foreach (var argument in arguments) + info.ArgumentList.Add(argument); + System.Diagnostics.Process process; + try + { + process = + System.Diagnostics.Process.Start(info) + ?? throw new ContainerPrerequisiteException("Could not start wslc."); + } + catch (Win32Exception) + { + throw new ContainerPrerequisiteException( + "This feature requires the wslc CLI on PATH. Install/update WSL Containers." + ); + } + using (process) + { + var read = ReadOutputAsync(process.StandardOutput.BaseStream, captureOutput, cancellationToken); + var error = process.StandardError.ReadToEndAsync(cancellationToken); + var write = input is null ? Task.CompletedTask : WriteInputAsync(process, input, cancellationToken); + var pending = Task.WhenAll(process.WaitForExitAsync(cancellationToken), read, error, write); + try + { + await pending.WaitAsync(cancellationToken).ConfigureAwait(false); + if (process.ExitCode != 0 && !allowFailure) + { + // CLI errors can echo arguments, environment values or Dockerfile output. + throw new ContainerException( + $"WSLC '{arguments[0]}' failed with exit code {process.ExitCode}. No command output is included because it may contain secrets." + ); + } + return new(process.ExitCode, await read.ConfigureAwait(false), await error.ConfigureAwait(false)); + } + catch + { + try + { + if (!process.HasExited) + process.Kill(entireProcessTree: true); + } + catch (InvalidOperationException) { } + catch (Win32Exception) { } + try + { + await pending.WaitAsync(TimeSpan.FromSeconds(5), CancellationToken.None).ConfigureAwait(false); + } + catch (Exception exception) + when (exception + is OperationCanceledException + or TimeoutException + or IOException + or InvalidOperationException + ) + { + _ = pending.ContinueWith( + static task => _ = task.Exception, + CancellationToken.None, + TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default + ); + } + throw; + } + } + } + + static async Task ReadOutputAsync(Stream stream, bool capture, CancellationToken cancellationToken) + { + using MemoryStream output = new(); + await stream.CopyToAsync(capture ? output : Stream.Null, cancellationToken).ConfigureAwait(false); + return output.ToArray(); + } + + static async Task WriteInputAsync( + System.Diagnostics.Process process, + Stream input, + CancellationToken cancellationToken + ) + { + try + { + await input.CopyToAsync(process.StandardInput.BaseStream, cancellationToken).ConfigureAwait(false); + } + finally + { + process.StandardInput.Close(); + } + } +} diff --git a/src/src/Wsl/WslContainer.cs b/src/src/Wsl/WslContainer.cs index 1eee2ac..03766e0 100644 --- a/src/src/Wsl/WslContainer.cs +++ b/src/src/Wsl/WslContainer.cs @@ -6,7 +6,6 @@ using MsContainer = Microsoft.WSL.Containers.Container; using MsContainerState = Microsoft.WSL.Containers.ContainerState; using MsProcess = Microsoft.WSL.Containers.Process; -using MsProcessState = Microsoft.WSL.Containers.ProcessState; using MsSignal = Microsoft.WSL.Containers.Signal; namespace Purview.Containers.Wsl; @@ -26,6 +25,8 @@ public class WslContainer : IContainer string? _networkIp; int _started; int _disposed; + ContainerState _state; + bool _usesCLI; /// Creates a container bound to a runtime. Prefer building via a . public WslContainer(ContainerConfiguration configuration, IContainerRuntime runtime) @@ -50,7 +51,7 @@ public WslContainer(ContainerConfiguration configuration, IContainerRuntime runt public string Id => _handle?.Id ?? string.Empty; /// - public ContainerState State { get; private set; } + public ContainerState State => _handle is { } handle ? MapState(handle.State) : _state; /// public string Image => Configuration.Image; @@ -62,68 +63,97 @@ public WslContainer(ContainerConfiguration configuration, IContainerRuntime runt public virtual async Task StartAsync(CancellationToken cancellationToken = default) { ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) == 1, this); + cancellationToken.ThrowIfCancellationRequested(); if (Volatile.Read(ref _started) == 1) { return; } - var sessionLocal = (WslContainerSession)await Runtime.GetSessionAsync(cancellationToken).ConfigureAwait(false); - _session = sessionLocal; - await _session - .EnsureImageAsync( - Configuration.Image, - Configuration.PullPolicy, - progress: null, - Configuration.RegistryCredentials, - cancellationToken - ) - .ConfigureAwait(false); + using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + deadline.CancelAfter(Configuration.StartupTimeout); + var token = deadline.Token; + try + { + var sessionLocal = (WslContainerSession)await Runtime.GetSessionAsync(token).ConfigureAwait(false); + _session = sessionLocal; + await _session + .EnsureImageAsync( + Configuration.Image, + Configuration.PullPolicy, + progress: null, + Configuration.RegistryCredentials, + token + ) + .ConfigureAwait(false); - var settings = WslSettingsMapper.ToContainerSettings(Configuration, Name); - var container = await _session.CreateContainerAsync(settings, cancellationToken).ConfigureAwait(false); - _handle = container; - _logBuffer.Attach(container.InitProcess); - await _session.StartContainerAsync(container, cancellationToken).ConfigureAwait(false); + _usesCLI = + Configuration.Entrypoint.Count > 0 + || Configuration.User is not null + || Configuration.Labels.Count > 0 + || Configuration.Network is not null + || Configuration.Reuse; + var container = _usesCLI + ? await _session.CreateConfiguredContainerAsync(Configuration, Name, token).ConfigureAwait(false) + : await _session + .CreateContainerAsync(WslSettingsMapper.ToContainerSettings(Configuration, Name), token) + .ConfigureAwait(false); + _handle = container; + _logBuffer.Attach(container.InitProcess); + if (container.State == MsContainerState.Created) + { + foreach (var mapping in Configuration.ResourceMappings) + await WslFileCopy + .CopyAsync(_session, container.Id, mapping.Source, mapping.Target, token) + .ConfigureAwait(false); + await _session.StartContainerAsync(container, token).ConfigureAwait(false); + } - State = MapState(container.State); - _portMappings = WslInspectParser.ParseHostPorts(container.Inspect()); - _networkIp = WslInspectParser.TryGetNetworkIp(container.Inspect()); - Volatile.Write(ref _started, 1); + _state = MapState(container.State); + _portMappings = WslInspectParser.ParseHostPorts(container.Inspect()); + _networkIp = WslInspectParser.TryGetNetworkIp(container.Inspect(), Configuration.Network ?? "bridge"); + Volatile.Write(ref _started, 1); - if (Configuration.WaitStrategies.Count > 0) + if (Configuration.WaitStrategies.Count > 0) + { + WaitContext waitContext = new(this, _portMappings, _networkIp); + await WaitStrategyRunner + .RunAsync(waitContext, Configuration.WaitStrategies, Configuration.StartupTimeout, token) + .ConfigureAwait(false); + } + } + catch { - WaitContext waitContext = new(this, _portMappings, _networkIp); - await WaitStrategyRunner - .RunAsync(waitContext, Configuration.WaitStrategies, Configuration.StartupTimeout, cancellationToken) - .ConfigureAwait(false); + try + { + await DisposeAsync().ConfigureAwait(false); + } + catch { } + throw; } } /// public virtual async Task StopAsync(CancellationToken cancellationToken = default) { - EnsureStarted(); + ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) == 1, this); + using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + deadline.CancelAfter(Configuration.StopTimeout); + deadline.Token.ThrowIfCancellationRequested(); if (_handle is null || _session is null) { return; } - try - { - await _session - .StopContainerAsync(_handle, MsSignal.SIGTERM, TimeSpan.FromSeconds(10), cancellationToken) - .ConfigureAwait(false); - } - catch (WslContainerSessionTerminatedException) - { - throw; - } - catch - { - // already stopped; idempotent by design - } + await _session + .StopContainerAsync( + _handle, + MsSignal.SIGTERM, + TimeSpan.FromSeconds(Math.Min(10, Configuration.StopTimeout.TotalSeconds * 2 / 3)), + deadline.Token + ) + .ConfigureAwait(false); - State = MapState(_handle.State); + _state = MapState(_handle.State); } /// @@ -140,6 +170,12 @@ public virtual async Task ExecAsync( } EnsureStarted(); + if (options?.EnableStandardInput == true) + throw new Runtime.ContainerNotSupportedException("Exec capture does not expose standard input."); + using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + deadline.CancelAfter(options?.Timeout ?? Configuration.ExecTimeout); + var token = deadline.Token; + token.ThrowIfCancellationRequested(); if (_handle is null || _session is null) { throw new InvalidOperationException("Container is not started."); @@ -156,61 +192,36 @@ public virtual async Task ExecAsync( settings.EnvironmentVariables = new Dictionary(options.Environment); } - var process = await _session.CreateProcessAsync(_handle, settings, cancellationToken).ConfigureAwait(false); - using var cancelRegistration = cancellationToken.Register( - static state => - { - if (state is MsProcess processToSignal) - { - try - { - processToSignal.Signal(MsSignal.SIGKILL); - } - catch { } - } - }, - process - ); - + using var process = await _session.CreateProcessAsync(_handle, settings, token).ConfigureAwait(false); StringBuilder stdout = new(); StringBuilder stderr = new(); + Lock outputLock = new(); TaskCompletionSource exitSource = new(TaskCreationOptions.RunContinuationsAsynchronously); - process.OutputReceived += data => stdout.Append(Encoding.UTF8.GetString(data)); - process.ErrorReceived += data => stderr.Append(Encoding.UTF8.GetString(data)); + process.OutputReceived += data => + { + lock (outputLock) + stdout.Append(Encoding.UTF8.GetString(data)); + }; + process.ErrorReceived += data => + { + lock (outputLock) + stderr.Append(Encoding.UTF8.GetString(data)); + }; process.Exited += code => exitSource.TrySetResult(code); - await _session.StartProcessAsync(process, cancellationToken).ConfigureAwait(false); - - var exitTask = exitSource.Task; - int exitCode; - if (options?.Timeout is TimeSpan timeout) + try { - var completed = await Task.WhenAny(exitTask, Task.Delay(timeout, cancellationToken)).ConfigureAwait(false); - if (completed == exitTask) - { - exitCode = await exitTask.ConfigureAwait(false); - } - else - { - TryKill(process); - exitCode = await PollForExitAsync(process, exitTask).ConfigureAwait(false); - } + await _session.StartProcessAsync(process, token).ConfigureAwait(false); + // State can report exit before output callbacks drain; the exit event follows output delivery. + var exitCode = await exitSource.Task.WaitAsync(token).ConfigureAwait(false); + lock (outputLock) + return new ExecResult(exitCode, stdout.ToString(), stderr.ToString()); } - else + catch { - try - { - exitCode = await exitTask.WaitAsync(Timeout.InfiniteTimeSpan, cancellationToken).ConfigureAwait(false); - } - catch (OperationCanceledException) - { - TryKill(process); - exitCode = await PollForExitAsync(process, exitTask).ConfigureAwait(false); - } + TryKill(process); + throw; } - - process.Dispose(); - return new ExecResult(exitCode, stdout.ToString(), stderr.ToString()); } static void TryKill(MsProcess process) @@ -222,29 +233,6 @@ static void TryKill(MsProcess process) catch { } } - // WSLC does not reliably raise Process.Exited after Signal(SIGKILL); poll the process state instead. - static async Task PollForExitAsync(MsProcess process, Task exitTask) - { - var deadline = DateTime.UtcNow.AddSeconds(15); - while (DateTime.UtcNow < deadline) - { - if (exitTask.IsCompleted) - { - return await exitTask.ConfigureAwait(false); - } - - var state = process.State; - if (state is MsProcessState.Exited or MsProcessState.Signalled) - { - return process.ExitCode; - } - - await Task.Delay(100).ConfigureAwait(false); - } - - return -1; - } - /// public virtual ushort GetMappedPublicPort(ushort containerPort) { @@ -292,10 +280,27 @@ public string GetConnectionString(string name, ConnectionMode connectionMode = C } /// - public virtual Task GetLogsAsync(LogOutput? stream = null, CancellationToken cancellationToken = default) + public virtual async Task GetLogsAsync( + LogOutput? stream = null, + CancellationToken cancellationToken = default + ) { ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) == 1, this); - return Task.FromResult(_logBuffer.GetLogs(stream)); + cancellationToken.ThrowIfCancellationRequested(); + if (_usesCLI && _session is not null && _handle is not null) + { + using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + deadline.CancelAfter(Configuration.OperationTimeout); + var result = await WslCommand.RunAsync(_session.Name, ["logs", Id], deadline.Token).ConfigureAwait(false); + var stdout = Encoding.UTF8.GetString(result.Output); + return stream switch + { + LogOutput.Stdout => stdout, + LogOutput.Stderr => result.Error, + _ => stdout + result.Error, + }; + } + return _logBuffer.GetLogs(stream); } /// @@ -304,18 +309,100 @@ [EnumeratorCancellation] CancellationToken cancellationToken ) { ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) == 1, this); - foreach (var entry in _logBuffer.Snapshot()) + if (!_usesCLI) { - yield return entry; + await foreach (var entry in _logBuffer.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false)) + yield return entry; + yield break; } + var stdoutEmitted = 0; + var stderrEmitted = 0; + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + var exited = State != ContainerState.Running; + using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + deadline.CancelAfter(Configuration.OperationTimeout); + var logs = await WslCommand.RunAsync(_session!.Name, ["logs", Id], deadline.Token).ConfigureAwait(false); + foreach ( + var entry in NewLogEntries( + Encoding.UTF8.GetString(logs.Output), + LogOutput.Stdout, + ref stdoutEmitted, + exited + ) + ) + yield return entry; + foreach (var entry in NewLogEntries(logs.Error, LogOutput.Stderr, ref stderrEmitted, exited)) + yield return entry; + if (exited) + yield break; + await Task.Delay(100, cancellationToken).ConfigureAwait(false); + } + } + + static ContainerLogEntry[] NewLogEntries(string content, LogOutput stream, ref int emitted, bool exited) + { + var lines = content.Split('\n'); + var completeCount = exited && lines[^1].Length > 0 ? lines.Length : lines.Length - 1; + var entries = lines + .Skip(emitted) + .Take(Math.Max(0, completeCount - emitted)) + .Select(line => new ContainerLogEntry(DateTimeOffset.UtcNow, stream, line.TrimEnd('\r'))) + .ToArray(); + emitted = completeCount; + return entries; + } - var reader = _logBuffer.Reader; - await foreach (var entry in reader.ReadAllAsync(cancellationToken).ConfigureAwait(false)) + /// + public virtual async Task CopyAsync(string source, string target, CancellationToken cancellationToken = default) + { + EnsureStarted(); + using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + deadline.CancelAfter(Configuration.OperationTimeout); + await WslFileCopy.CopyAsync(_session!, Id, source, target, deadline.Token).ConfigureAwait(false); + } + + /// + public virtual async Task DeleteAsync(CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) == 1, this); + using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + deadline.CancelAfter(Configuration.CleanupTimeout); + deadline.Token.ThrowIfCancellationRequested(); + if (_handle is not null && _session is not null) { - yield return entry; + await _session + .DeleteContainerAsync(_handle, DeleteContainerOption.Force, deadline.Token) + .ConfigureAwait(false); + _handle.Dispose(); + _handle = null; + _state = ContainerState.Deleted; + _logBuffer.Complete(); } } + /// + public virtual async Task ReadFileAsync(string path, CancellationToken cancellationToken = default) + { + EnsureStarted(); + using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + deadline.CancelAfter(Configuration.OperationTimeout); + return await WslFileCopy.ReadAsync(_session!, Id, path, deadline.Token).ConfigureAwait(false); + } + + /// + public virtual async Task WaitForExitAsync(CancellationToken cancellationToken = default) + { + EnsureStarted(); + using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + deadline.CancelAfter(Configuration.ExecTimeout); + while (State == ContainerState.Running) + await Task.Delay(100, deadline.Token).ConfigureAwait(false); + deadline.Token.ThrowIfCancellationRequested(); + return _handle!.InitProcess.ExitCode; + } + /// public virtual async ValueTask DisposeAsync() { @@ -328,6 +415,14 @@ public virtual async ValueTask DisposeAsync() { if (_handle is not null && _session is not null) { + _state = MapState(_handle.State); + if (Configuration.Reuse) + { + _logBuffer.Detach(); + _handle.Dispose(); + return; + } + using CancellationTokenSource cleanup = new(Configuration.CleanupTimeout); try { if (_handle.State == MsContainerState.Running) @@ -336,8 +431,8 @@ await _session .StopContainerAsync( _handle, MsSignal.SIGTERM, - TimeSpan.FromSeconds(10), - CancellationToken.None + TimeSpan.FromSeconds(Math.Min(10, Configuration.StopTimeout.TotalSeconds * 2 / 3)), + cleanup.Token ) .ConfigureAwait(false); } @@ -346,8 +441,9 @@ await _session try { + _state = MapState(_handle.State); await _session - .DeleteContainerAsync(_handle, DeleteContainerOption.Force, CancellationToken.None) + .DeleteContainerAsync(_handle, DeleteContainerOption.Force, cleanup.Token) .ConfigureAwait(false); } catch { } @@ -361,6 +457,7 @@ await _session } finally { + _logBuffer.Detach(); _logBuffer.Complete(); _handle = null; GC.SuppressFinalize(this); @@ -369,6 +466,7 @@ await _session void EnsureStarted() { + ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) == 1, this); if (Volatile.Read(ref _started) == 0) { throw new InvalidOperationException("Container has not been started. Call StartAsync() first."); diff --git a/src/src/Wsl/WslContainerBackend.Facade.cs b/src/src/Wsl/WslContainerBackend.Facade.cs index 0fe458d..bd44e2c 100644 --- a/src/src/Wsl/WslContainerBackend.Facade.cs +++ b/src/src/Wsl/WslContainerBackend.Facade.cs @@ -74,6 +74,18 @@ IContainerBackend Resolve() => $"The WSL Containers backend cannot run here. {WslPayload.FailureReason}" ); + /// + public Task CreateNetworkAsync( + NetworkConfiguration? configuration = null, + CancellationToken cancellationToken = default + ) => Resolve().CreateNetworkAsync(configuration, cancellationToken); + + /// + public Task BuildImageAsync( + ImageBuildConfiguration configuration, + CancellationToken cancellationToken = default + ) => Resolve().BuildImageAsync(configuration, cancellationToken); + static ContainerBackendInfo Unavailable(string reason) => new("wsl", IsAvailable: false, IsCompatible: false, Version: string.Empty, MissingComponents: [reason]); } diff --git a/src/src/Wsl/WslContainerBackend.cs b/src/src/Wsl/WslContainerBackend.cs index 9f1cb6b..9c226e2 100644 --- a/src/src/Wsl/WslContainerBackend.cs +++ b/src/src/Wsl/WslContainerBackend.cs @@ -108,4 +108,51 @@ public async Task GetInfoAsync(CancellationToken cancellat info.MissingComponents ); } + + /// + public async Task CreateNetworkAsync( + NetworkConfiguration? configuration = null, + CancellationToken cancellationToken = default + ) + { + configuration ??= new(); + using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + deadline.CancelAfter(configuration.Timeout); + var session = (WslContainerSession)await Runtime.GetSessionAsync(deadline.Token).ConfigureAwait(false); + var name = configuration.Name ?? $"purview-{Guid.NewGuid():N}"; + await session + .RunCommandAsync(["network", "create", name], configuration.Timeout, deadline.Token) + .ConfigureAwait(false); + return new WslResource(session, name, network: true, configuration.Timeout); + } + + /// + public async Task BuildImageAsync( + ImageBuildConfiguration configuration, + CancellationToken cancellationToken = default + ) + { + ArgumentNullException.ThrowIfNull(configuration); + using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + deadline.CancelAfter(configuration.Timeout); + var session = (WslContainerSession)await Runtime.GetSessionAsync(deadline.Token).ConfigureAwait(false); + List args = + [ + "build", + "--progress", + "plain", + "--tag", + configuration.Name, + "--file", + Path.Combine(configuration.ContextDirectory, configuration.Dockerfile), + ]; + if (configuration.Target is not null) + { + args.Add("--target"); + args.Add(configuration.Target); + } + args.Add(configuration.ContextDirectory); + await session.RunCommandAsync([.. args], configuration.Timeout, deadline.Token).ConfigureAwait(false); + return new WslResource(session, configuration.Name, network: false, TimeSpan.FromSeconds(30)); + } } diff --git a/src/src/Wsl/WslContainerRuntime.cs b/src/src/Wsl/WslContainerRuntime.cs index dc961df..4612796 100644 --- a/src/src/Wsl/WslContainerRuntime.cs +++ b/src/src/Wsl/WslContainerRuntime.cs @@ -56,6 +56,7 @@ public WslContainerRuntime(WslContainerRuntimeOptions options) /// public async Task GetInfoAsync(CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); await Task.Yield(); try { @@ -86,6 +87,7 @@ public async Task GetInfoAsync(CancellationToken cancel /// public async Task GetSessionAsync(CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) == 1, this); var local = GetOrCreateSession(); if (Volatile.Read(ref _storeVerified) == 1) diff --git a/src/src/Wsl/WslContainerSession.cs b/src/src/Wsl/WslContainerSession.cs index 41bfc51..eba83ce 100644 --- a/src/src/Wsl/WslContainerSession.cs +++ b/src/src/Wsl/WslContainerSession.cs @@ -1,5 +1,7 @@ using System.Collections.Concurrent; +using System.Diagnostics; using System.Diagnostics.CodeAnalysis; +using System.Text.Json; using Microsoft.WSL.Containers; using Purview.Containers.Images; using Purview.Containers.Wsl.Diagnostics; @@ -138,6 +140,7 @@ async Task> ReadImagesAsync(CancellationToken cancel public async Task TerminateAsync(CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); if (Interlocked.Exchange(ref _terminated, 1) == 1) { return; @@ -221,6 +224,130 @@ CancellationToken cancellationToken } } + internal async Task CreateConfiguredContainerAsync( + ContainerConfiguration configuration, + string name, + CancellationToken cancellationToken + ) + { + await EnsureStartedAsync(cancellationToken).ConfigureAwait(false); + await _lifecycleGate.WaitAsync(cancellationToken).ConfigureAwait(false); + string? environmentFile = null; + var owner = Guid.NewGuid().ToString("N"); + try + { + ThrowIfTerminated(); + var hash = WslCreateArguments.ReuseHash(configuration); + if (configuration.Reuse) + { + var inspect = await WslCommand + .RunAsync(Name, ["inspect", "--format", "json", name], cancellationToken, allowFailure: true) + .ConfigureAwait(false); + if (inspect.ExitCode == 0) + { + using var document = JsonDocument.Parse(inspect.Output); + var labels = document.RootElement[0].GetProperty("Config").GetProperty("Labels"); + if (!labels.TryGetProperty("purview.containers.reuse", out var value) || value.GetString() != hash) + throw new WslContainerException( + "A reusable container with this name exists but has different configuration." + ); + if (document.RootElement[0].GetProperty("State").GetProperty("Status").GetString() == "exited") + await WslCommand.RunAsync(Name, ["start", name], cancellationToken).ConfigureAwait(false); + return _session.OpenContainer(name, ProcessOutputMode.Event); + } + } + + if (configuration.Environment.Count > 0) + { + WslCreateArguments.ValidateEnvironment(configuration); + environmentFile = Path.GetTempFileName(); + await File.WriteAllLinesAsync( + environmentFile, + configuration.Environment.Select(pair => $"{pair.Key}={pair.Value}"), + cancellationToken + ) + .ConfigureAwait(false); + } + var args = WslCreateArguments.Build(configuration, name, hash, owner, environmentFile); + await WslCommand.RunAsync(Name, args, cancellationToken).ConfigureAwait(false); + return _session.OpenContainer(name, ProcessOutputMode.Event); + } + catch + { + // Cancellation can arrive after the CLI created the object but before it returned its ID. + if (!configuration.Reuse) + { + using CancellationTokenSource cleanup = new(configuration.CleanupTimeout); + try + { + var inspect = await WslCommand + .RunAsync(Name, ["inspect", "--format", "json", name], cleanup.Token, allowFailure: true) + .ConfigureAwait(false); + if (inspect.ExitCode == 0) + { + using var document = JsonDocument.Parse(inspect.Output); + var labels = document.RootElement[0].GetProperty("Config").GetProperty("Labels"); + if ( + labels.TryGetProperty("purview.containers.owner", out var value) + && value.GetString() == owner + ) + await WslCommand + .RunAsync(Name, ["remove", "--force", name], cleanup.Token, allowFailure: true) + .ConfigureAwait(false); + } + } + catch { } + } + throw; + } + finally + { + try + { + if (environmentFile is not null) + File.Delete(environmentFile); + } + finally + { + _lifecycleGate.Release(); + } + } + } + + internal async Task RunCommandAsync(string[] arguments, TimeSpan timeout, CancellationToken cancellationToken) + { + using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + deadline.CancelAfter(timeout); + await _lifecycleGate.WaitAsync(deadline.Token).ConfigureAwait(false); + try + { + ThrowIfTerminated(); + await WslCommand.RunAsync(Name, arguments, deadline.Token, captureOutput: false).ConfigureAwait(false); + } + finally + { + _lifecycleGate.Release(); + } + } + + internal async Task RunCopyCommandAsync( + string[] arguments, + CancellationToken cancellationToken, + Stream? input = null + ) + { + await _lifecycleGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + ThrowIfTerminated(); + return await WslCommand.RunAsync(Name, arguments, cancellationToken, input).ConfigureAwait(false); + } + finally + { + _lifecycleGate.Release(); + } + } + internal async Task StartContainerAsync(MsContainer container, CancellationToken cancellationToken) { using var activity = WslContainerActivity.Source.StartActivity("wslcontainer.container.start"); @@ -246,11 +373,34 @@ CancellationToken cancellationToken { using var activity = WslContainerActivity.Source.StartActivity("wslcontainer.container.stop"); activity?.SetTag("container.id", container.Id); + // Native Container.Stop can block beyond its timeout while holding the session gate. + // Signal PID 1 directly and poll with managed delays so other containers can still make progress. + await SignalContainerAsync(container, signal, cancellationToken).ConfigureAwait(false); + if (await WaitForContainerExitAsync(container, timeout, cancellationToken).ConfigureAwait(false)) + { + return; + } + + await SignalContainerAsync(container, Signal.SIGKILL, cancellationToken).ConfigureAwait(false); + if ( + !await WaitForContainerExitAsync(container, TimeSpan.FromSeconds(5), cancellationToken) + .ConfigureAwait(false) + ) + { + throw new TimeoutException("The WSL container did not exit after SIGKILL."); + } + } + + async Task SignalContainerAsync(MsContainer container, Signal signal, CancellationToken cancellationToken) + { await _lifecycleGate.WaitAsync(cancellationToken).ConfigureAwait(false); try { ThrowIfTerminated(); - container.Stop(signal, timeout); + if (container.State == Microsoft.WSL.Containers.ContainerState.Running) + { + container.InitProcess.Signal(signal); + } } finally { @@ -258,6 +408,33 @@ CancellationToken cancellationToken } } + async Task WaitForContainerExitAsync( + MsContainer container, + TimeSpan timeout, + CancellationToken cancellationToken + ) + { + var stopwatch = Stopwatch.StartNew(); + while (container.State == Microsoft.WSL.Containers.ContainerState.Running) + { + ThrowIfTerminated(); + cancellationToken.ThrowIfCancellationRequested(); + var remaining = timeout - stopwatch.Elapsed; + if (remaining <= TimeSpan.Zero) + { + return false; + } + + await Task.Delay( + remaining < TimeSpan.FromMilliseconds(100) ? remaining : TimeSpan.FromMilliseconds(100), + cancellationToken + ) + .ConfigureAwait(false); + } + + return true; + } + internal async Task DeleteContainerAsync( MsContainer container, DeleteContainerOption option, @@ -340,7 +517,7 @@ CancellationToken cancellationToken var lazy = _activePulls.GetOrAdd( reference.FullReference, _ => new Lazy( - () => PullCoreAsync(reference, progress, credentials, CancellationToken.None), + () => PullWithDeadlineAsync(reference, progress, credentials), LazyThreadSafetyMode.ExecutionAndPublication ) ); @@ -358,6 +535,18 @@ CancellationToken cancellationToken await pull.WaitAsync(cancellationToken).ConfigureAwait(false); } + async Task PullWithDeadlineAsync( + Image reference, + IProgress? progress, + RegistryCredentials? credentials + ) + { + using CancellationTokenSource deadline = new(TimeSpan.FromMinutes(5)); + await PullCoreAsync(reference, progress, credentials, deadline.Token) + .WaitAsync(deadline.Token) + .ConfigureAwait(false); + } + async Task PullCoreAsync( Image reference, IProgress? progress, diff --git a/src/src/Wsl/WslCreateArguments.cs b/src/src/Wsl/WslCreateArguments.cs new file mode 100644 index 0000000..5da956b --- /dev/null +++ b/src/src/Wsl/WslCreateArguments.cs @@ -0,0 +1,110 @@ +using System.Security.Cryptography; +using System.Text.Json; +using Purview.Containers.Runtime; + +namespace Purview.Containers.Wsl; + +static class WslCreateArguments +{ + internal static string ReuseHash(ContainerConfiguration configuration) => + Convert.ToHexString( + SHA256.HashData( + JsonSerializer.SerializeToUtf8Bytes( + new + { + configuration.Image, + configuration.Command, + configuration.Entrypoint, + configuration.User, + Environment = configuration.Environment.OrderBy(pair => pair.Key, StringComparer.Ordinal), + Labels = configuration.Labels.OrderBy(pair => pair.Key, StringComparer.Ordinal), + configuration.Network, + configuration.NetworkAliases, + configuration.NetworkingMode, + configuration.Hostname, + configuration.DomainName, + configuration.WorkingDirectory, + configuration.BindMounts, + configuration.NamedVolumes, + configuration.PortBindings, + configuration.Privileged, + configuration.EnableGPU, + configuration.ResourceMappings, + } + ) + ) + ); + + internal static void ValidateEnvironment(ContainerConfiguration configuration) + { + if ( + configuration.Environment.Any(pair => + pair.Key.Contains('=', StringComparison.Ordinal) + || pair.Key.Contains('\n', StringComparison.Ordinal) + || pair.Key.Contains('\r', StringComparison.Ordinal) + || pair.Value.Contains('\n', StringComparison.Ordinal) + || pair.Value.Contains('\r', StringComparison.Ordinal) + ) + ) + throw new ContainerNotSupportedException("WSLC CLI-only features require single-line environment values."); + } + + internal static string[] Build( + ContainerConfiguration configuration, + string name, + string hash, + string owner, + string? environmentFile + ) + { + List args = ["create", "--name", name, "--pull", "never"]; + void Add(string option, string? value) + { + if (value is not null) + { + args.Add(option); + args.Add(value); + } + } + Add("--hostname", configuration.Hostname); + Add("--domainname", configuration.DomainName); + Add("--workdir", configuration.WorkingDirectory); + Add("--user", configuration.User); + Add( + "--network", + configuration.Network + ?? (configuration.NetworkingMode == Networking.ContainerNetworkingMode.None ? "none" : "bridge") + ); + foreach (var alias in configuration.NetworkAliases) + Add("--network-alias", alias); + foreach (var label in configuration.Labels) + Add("--label", $"{label.Key}={label.Value}"); + Add("--label", $"purview.containers.owner={owner}"); + if (configuration.Reuse) + Add("--label", $"purview.containers.reuse={hash}"); + if (configuration.EnableGPU) + Add("--gpus", "all"); + if (configuration.EnableAutoRemove) + args.Add("--rm"); + if (configuration.Privileged) + throw new ContainerNotSupportedException( + "The WSLC CLI does not expose privileged creation. Do not combine privileged mode with CLI-only features." + ); + Add("--env-file", environmentFile); + foreach (var mount in configuration.BindMounts) + Add("--volume", $"{mount.HostPath}:{mount.ContainerPath}{(mount.ReadOnly ? ":ro" : string.Empty)}"); + foreach (var volume in configuration.NamedVolumes) + Add("--volume", $"{volume.Name}:{volume.ContainerPath}{(volume.ReadOnly ? ":ro" : string.Empty)}"); + foreach (var binding in configuration.PortBindings) + Add( + "--publish", + $"{binding.HostAddress ?? "127.0.0.1"}:{binding.HostPort ?? 0}:{binding.ContainerPort}/{(binding.Protocol == Networking.PortProtocol.Udp ? "udp" : "tcp")}" + ); + if (configuration.Entrypoint.Count > 0) + Add("--entrypoint", configuration.Entrypoint[0]); + args.Add(configuration.Image); + args.AddRange(configuration.Entrypoint.Skip(1)); + args.AddRange(configuration.Command); + return [.. args]; + } +} diff --git a/src/src/Wsl/WslFileCopy.cs b/src/src/Wsl/WslFileCopy.cs new file mode 100644 index 0000000..9b79636 --- /dev/null +++ b/src/src/Wsl/WslFileCopy.cs @@ -0,0 +1,43 @@ +using System.Formats.Tar; + +namespace Purview.Containers.Wsl; + +static class WslFileCopy +{ + internal static async Task CopyAsync( + WslContainerSession session, + string id, + string source, + string target, + CancellationToken cancellationToken + ) + { + using var archive = await ContainerResourceArchive + .CreateAsync(source, target, cancellationToken) + .ConfigureAwait(false); + await session.RunCopyCommandAsync(["cp", "-", $"{id}:/"], cancellationToken, archive).ConfigureAwait(false); + } + + internal static async Task ReadAsync( + WslContainerSession session, + string id, + string path, + CancellationToken cancellationToken + ) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + var result = await session + .RunCopyCommandAsync(["cp", $"{id}:{path}", "-"], cancellationToken) + .ConfigureAwait(false); + using MemoryStream archive = new(result.Output); + using TarReader reader = new(archive); + var entry = await reader.GetNextEntryAsync(cancellationToken: cancellationToken).ConfigureAwait(false); + if (entry is { EntryType: TarEntryType.RegularFile or TarEntryType.V7RegularFile, DataStream: { } data }) + { + using MemoryStream output = new(); + await data.CopyToAsync(output, cancellationToken).ConfigureAwait(false); + return output.ToArray(); + } + throw new FileNotFoundException("The container path did not contain a regular file."); + } +} diff --git a/src/src/Wsl/WslResource.cs b/src/src/Wsl/WslResource.cs new file mode 100644 index 0000000..9dad9fd --- /dev/null +++ b/src/src/Wsl/WslResource.cs @@ -0,0 +1,17 @@ +namespace Purview.Containers.Wsl; + +sealed class WslResource(WslContainerSession session, string name, bool network, TimeSpan timeout) : IContainerResource +{ + int _disposed; + public string Name => name; + + public Task DeleteAsync(CancellationToken cancellationToken = default) => + session.RunCommandAsync(network ? ["network", "remove", Name] : ["rmi", Name], timeout, cancellationToken); + + public async ValueTask DisposeAsync() + { + if (Interlocked.Exchange(ref _disposed, 1) == 1) + return; + await DeleteAsync().ConfigureAwait(false); + } +} diff --git a/src/tests/BackendContractTests.cs b/src/tests/BackendContractTests.cs new file mode 100644 index 0000000..5b813de --- /dev/null +++ b/src/tests/BackendContractTests.cs @@ -0,0 +1,395 @@ +using System.Text; +using Purview.Containers.Waiting; +#if DOCKER_BACKEND +using Backend = Purview.Containers.Docker.DockerContainerBackend; +#else +using Backend = Purview.Containers.Wsl.WslContainerBackend; +#endif + +namespace Purview.Containers; + +#if !DOCKER_BACKEND +[Explicit] +#endif +[NotInParallel] +public class BackendContractTests +{ + static Backend CreateBackend() => new(); + + static Task EnsureAvailableAsync() => +#if DOCKER_BACKEND + Docker.DockerTest.SkipIfUnavailableAsync(); +#else + WslcTest.SkipIfUnavailableAsync(); +#endif + + static Container CreateContainer(IContainerBackend backend, Action? configure = null) + { + var builder = new ContainerBuilder(backend).WithImage("alpine:latest").WithCommand("/bin/sleep", "300"); + configure?.Invoke(builder); + return builder.Build(); + } + + [Test] + public async Task StartAsync_EntrypointUserAndExecOverrides_PreserveArguments(CancellationToken cancellationToken) + { + // Arrange + await EnsureAvailableAsync(); + await using var container = CreateContainer( + CreateBackend(), + builder => + builder + .WithEntrypoint("/bin/sh") + .WithCommand("-c", "echo ready; sleep 300") + .WithUser("1000:1000") + .WithLabel("purview.test", "metadata") + .WithWaitStrategy(Wait.ForLogMessage("ready")) + ); + await container.StartAsync(cancellationToken); + var directory = "/tmp/a'b$HOME"; + await container.ExecAsync(["mkdir", directory], cancellationToken: cancellationToken); + + // Act + var result = await container.ExecAsync( + ["sh", "-c", "id -u; printf '%s\\n' \"$VALUE\"; pwd"], + new ExecOptions + { + WorkingDirectory = directory, + Environment = new Dictionary { ["VALUE"] = "literal '$HOME;$(echo wrong)" }, + }, + cancellationToken + ); + + // Assert + await Assert.That(result.ExitCode).IsEqualTo(0); + await Assert.That(result.Stdout).Contains("1000\nliteral '$HOME;$(echo wrong)\n" + directory); + } + + [Test] + public async Task CopyAsync_PreStartAndRuntimeMappings_PreserveFileBytes(CancellationToken cancellationToken) + { + // Arrange + await EnsureAvailableAsync(); + var directory = Path.Combine(Path.GetTempPath(), $"purview-copy-{Guid.NewGuid():N}"); + Directory.CreateDirectory(directory); + try + { + var source = Path.Combine(directory, "input.bin"); + var emptyDirectory = Directory.CreateDirectory(Path.Combine(directory, "empty")).FullName; + byte[] content = [0, 1, 2, 255, 13, 10, 128]; + await File.WriteAllBytesAsync(source, content, cancellationToken); + await using var container = CreateContainer( + CreateBackend(), + builder => + builder + .WithResourceMapping(source, "/tmp/pre-start.bin") + .WithResourceMapping(directory, "/tmp/pre-start-dir") + .WithCommand( + "/bin/sh", + "-c", + "test -f /tmp/pre-start.bin && test -f /tmp/pre-start-dir/input.bin && test -d /tmp/pre-start-dir/empty && echo ready; sleep 300" + ) + .WithWaitStrategy(Wait.ForLogMessage("ready")) + ); + + // Act + await container.StartAsync(cancellationToken); + var initial = await container.ReadFileAsync("/tmp/pre-start.bin", cancellationToken); + await container.CopyAsync(directory, "/tmp/copied", cancellationToken); + var copied = await container.ReadFileAsync("/tmp/copied/input.bin", cancellationToken); + await container.CopyAsync(source, "/tmp/runtime.bin", cancellationToken); + var runtime = await container.ReadFileAsync("/tmp/runtime.bin", cancellationToken); + await container.CopyAsync(emptyDirectory, "/tmp/empty-copy", cancellationToken); + var directories = await container.ExecAsync( + ["sh", "-c", "test -d /tmp/empty-copy && test -d /tmp/copied/empty"], + cancellationToken: cancellationToken + ); + + // Assert + await Assert.That(initial).IsEquivalentTo(content); + await Assert.That(copied).IsEquivalentTo(content); + await Assert.That(runtime).IsEquivalentTo(content); + await Assert.That(directories.ExitCode).IsEqualTo(0); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + [Test] + public async Task StartAsync_OneShotExitWait_ReturnsExpectedExitCode(CancellationToken cancellationToken) + { + // Arrange + await EnsureAvailableAsync(); + await using var container = CreateContainer( + CreateBackend(), + builder => + builder + .WithEntrypoint("/bin/sh") + .WithCommand("-c", "sleep 1; echo complete; exit 7") + .WithWaitStrategy(Wait.ForExitCode(7)) + ); + + // Act + await container.StartAsync(cancellationToken); + var exitCode = await container.WaitForExitAsync(cancellationToken); + + // Assert + await Assert.That(exitCode).IsEqualTo(7L); + await Assert.That(container.State).IsEqualTo(ContainerState.Exited); + } + + [Test] + public async Task GetLogsAsync_PartialLinesAndStderr_AreDrainedOnExit(CancellationToken cancellationToken) + { + // Arrange + await EnsureAvailableAsync(); + await using var container = CreateContainer( + CreateBackend(), + builder => + builder + .WithEntrypoint("/bin/sh") + .WithCommand( + "-c", + "printf first; sleep 1; printf ' line\\n'; printf 'problem\\n' >&2; printf final" + ) + ); + await container.StartAsync(cancellationToken); + using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + deadline.CancelAfter(TimeSpan.FromSeconds(10)); + List entries = []; + + // Act + await foreach (var entry in container.GetLogsAsync(deadline.Token)) + entries.Add(entry); + + // Assert + await Assert + .That(entries.Where(entry => entry.Stream == LogOutput.Stdout).Select(entry => entry.Text)) + .IsEquivalentTo(["first line", "final"]); + await Assert + .That(entries.Where(entry => entry.Stream == LogOutput.Stderr).Select(entry => entry.Text)) + .IsEquivalentTo(["problem"]); + } + + [Test] + public async Task StartAsync_OneShotUnexpectedExit_FailsWithoutReadinessRetries(CancellationToken cancellationToken) + { + // Arrange + await EnsureAvailableAsync(); + await using var container = CreateContainer( + CreateBackend(), + builder => + builder.WithEntrypoint("/bin/sh").WithCommand("-c", "exit 2").WithWaitStrategy(Wait.ForExitCode()) + ); + + // Act + await Assert.That(() => container.StartAsync(cancellationToken)).Throws(); + + // Assert + await Assert.That(container.State).IsNotEqualTo(ContainerState.Running); + } + + [Test] + public async Task CreateNetworkAsync_Aliases_ResolveBetweenContainers(CancellationToken cancellationToken) + { + // Arrange + await EnsureAvailableAsync(); + var backend = CreateBackend(); + await using var network = await backend.CreateNetworkAsync(cancellationToken: cancellationToken); + await using var server = CreateContainer( + backend, + builder => builder.WithNetwork(network.Name).WithNetworkAliases("peer") + ); + await using var client = CreateContainer(backend, builder => builder.WithNetwork(network.Name)); + await server.StartAsync(cancellationToken); + await client.StartAsync(cancellationToken); + + // Act + var result = await client.ExecAsync( + ["ping", "-c", "1", "-W", "3", "peer"], + cancellationToken: cancellationToken + ); + + // Assert + await Assert.That(result.ExitCode).IsEqualTo(0); + } + + [Test] + public async Task BuildImageAsync_Dockerfile_RunsOnSelectedBackend(CancellationToken cancellationToken) + { + // Arrange + await EnsureAvailableAsync(); + var directory = Path.Combine(Path.GetTempPath(), $"purview-build-{Guid.NewGuid():N}"); + Directory.CreateDirectory(directory); + try + { + await File.WriteAllTextAsync( + Path.Combine(directory, "Dockerfile"), + "FROM alpine:latest\nRUN echo built > /probe\nCMD [\"echo image-default\"]\n", + cancellationToken + ); + var backend = CreateBackend(); + + // Act + await using var image = await backend.BuildImageAsync( + new ImageBuildConfiguration + { + ContextDirectory = directory, + Name = $"purview-contract:{Guid.NewGuid():N}", + }, + cancellationToken + ); + await using var container = CreateContainer(backend, builder => builder.WithImage(image.Name)); + await container.StartAsync(cancellationToken); + var bytes = await container.ReadFileAsync("/probe", cancellationToken); + await using var job = CreateContainer( + backend, + builder => + builder + .WithImage(image.Name) + .WithEntrypoint("/bin/sh", "-c") + .WithCommand("echo explicit-command") + .WithWaitStrategy(Wait.ForExitCode()) + ); + await job.StartAsync(cancellationToken); + var logs = await job.GetLogsAsync(stream: null, cancellationToken: cancellationToken); + + // Assert + await Assert.That(Encoding.UTF8.GetString(bytes).Trim()).IsEqualTo("built"); + await Assert.That(logs).Contains("explicit-command"); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + [Test] + public async Task StartAsync_Reuse_RetainsIdentityUntilExplicitDeletion(CancellationToken cancellationToken) + { + // Arrange + await EnsureAvailableAsync(); + var backend = CreateBackend(); + var name = $"purview-reuse-{Guid.NewGuid():N}"; + var first = CreateContainer(backend, builder => builder.WithName(name).WithReuse()); + await first.StartAsync(cancellationToken); + var id = first.Id; + await first.StopAsync(cancellationToken); + await first.DisposeAsync(); + await using var second = CreateContainer(backend, builder => builder.WithName(name).WithReuse()); + // Act + await second.StartAsync(cancellationToken); + try + { + // Assert + await Assert.That(second.Id).IsEqualTo(id); + await Assert.That(second.State).IsEqualTo(ContainerState.Running); + } + finally + { + await second.DeleteAsync(cancellationToken); + } + } + + [Test] + public async Task StartAsync_NameCollision_DoesNotDeleteExistingContainer(CancellationToken cancellationToken) + { + // Arrange + await EnsureAvailableAsync(); + var backend = CreateBackend(); + var name = $"purview-collision-{Guid.NewGuid():N}"; + await using var first = CreateContainer( + backend, + builder => builder.WithName(name).WithLabel("suite", "collision") + ); + await first.StartAsync(cancellationToken); + await using var second = CreateContainer( + backend, + builder => builder.WithName(name).WithLabel("suite", "collision") + ); + + // Act + await Assert.That(() => second.StartAsync(cancellationToken)).Throws(); + var result = await first.ExecAsync(["echo", "retained"], cancellationToken: cancellationToken); + + // Assert + await Assert.That(result.Stdout).Contains("retained"); + } + + [Test] + public async Task ExecAsync_CancelledWhileRunning_ThrowsAndLeavesContainerUsable( + CancellationToken cancellationToken + ) + { + // Arrange + await EnsureAvailableAsync(); + await using var container = CreateContainer(CreateBackend()); + await container.StartAsync(cancellationToken); + using var cancelled = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + cancelled.CancelAfter(TimeSpan.FromMilliseconds(150)); + + // Act + await Assert + .That(async () => + { + await container.ExecAsync(["sleep", "60"], cancellationToken: cancelled.Token); + }) + .Throws(); + var result = await container.ExecAsync(["echo", "still-usable"], cancellationToken: cancellationToken); + + // Assert + await Assert.That(result.Stdout).Contains("still-usable"); + } + + [Test] + public async Task StopAsync_PreCancelled_PropagatesCancellation(CancellationToken cancellationToken) + { + // Arrange + await EnsureAvailableAsync(); + await using var container = CreateContainer(CreateBackend()); + await container.StartAsync(cancellationToken); + using CancellationTokenSource cancelled = new(); + await cancelled.CancelAsync(); + + // Act + await Assert.That(() => container.StopAsync(cancelled.Token)).Throws(); + await Assert + .That(async () => + { + await container.GetLogsAsync(null, cancelled.Token); + }) + .Throws(); + await Assert + .That(async () => + { + await container.ReadFileAsync("/etc/os-release", cancelled.Token); + }) + .Throws(); + + // Assert + await Assert.That(container.State).IsEqualTo(ContainerState.Running); + } + + [Test] + public async Task StartAsync_UncooperativeReadiness_IsBoundedAndCleansUp(CancellationToken cancellationToken) + { + // Arrange + await EnsureAvailableAsync(); + TaskCompletionSource never = new(TaskCreationOptions.RunContinuationsAsynchronously); + await using var container = CreateContainer( + CreateBackend(), + builder => + builder + .WithStartupTimeout(TimeSpan.FromSeconds(2)) + .WithWaitStrategy(Wait.ForCustom((_, _) => never.Task)) + ); + + // Act + await Assert.That(() => container.StartAsync(cancellationToken)).Throws(); + + // Assert + await Assert.That(container.State).IsNotEqualTo(ContainerState.Running); + } +} diff --git a/src/tests/Docker.IntegrationTests/Docker.IntegrationTests.csproj b/src/tests/Docker.IntegrationTests/Docker.IntegrationTests.csproj index b97203f..e2d7178 100644 --- a/src/tests/Docker.IntegrationTests/Docker.IntegrationTests.csproj +++ b/src/tests/Docker.IntegrationTests/Docker.IntegrationTests.csproj @@ -7,9 +7,11 @@ Windows-specific framework - that would make the suite unrunnable off Windows. --> net10.0 + $(DefineConstants);DOCKER_BACKEND + diff --git a/src/tests/Docker.IntegrationTests/DockerContainerTests.cs b/src/tests/Docker.IntegrationTests/DockerContainerTests.cs index 5dda8e8..f96ae59 100644 --- a/src/tests/Docker.IntegrationTests/DockerContainerTests.cs +++ b/src/tests/Docker.IntegrationTests/DockerContainerTests.cs @@ -30,6 +30,87 @@ static Container Build(ushort? mappedPort = null, IWaitStrategy? wait = null) return builder.Build(); } + static Container BuildWithMount(string source, bool readOnly) => + new ContainerBuilder() + .WithBackend(DockerContainerBackend.Create()) + .WithImage(Image) + .WithCommand("/bin/sh", "-c", "echo ready && sleep 60") + .WithBindMount(source, "/mounted", readOnly) + .Build(); + + [Test] + [Arguments(false)] + [Arguments(true)] + public async Task Start_BindMount_ReadsHostFilesWithoutPathConfiguration( + bool mountFile, + CancellationToken cancellationToken + ) + { + // Arrange + await SkipIfUnavailableAsync(); + var directory = Path.Combine(Path.GetTempPath(), $"purview bind {Guid.NewGuid():N}"); + Directory.CreateDirectory(directory); + var file = Path.Combine(directory, "input.txt"); + + try + { + await File.WriteAllTextAsync(file, "host-content", cancellationToken); + await using var container = BuildWithMount(mountFile ? file : directory, readOnly: true); + + // Act + await container.StartAsync(cancellationToken); + var read = await container.ExecAsync( + ["cat", mountFile ? "/mounted" : "/mounted/input.txt"], + cancellationToken: cancellationToken + ); + var write = await container.ExecAsync( + ["sh", "-c", mountFile ? "echo changed > /mounted" : "echo changed > /mounted/input.txt"], + cancellationToken: cancellationToken + ); + + // Assert + await Assert.That(read.IsSuccess).IsTrue(); + await Assert.That(read.Stdout).Contains("host-content"); + await Assert.That(write.IsSuccess).IsFalse(); + await Assert.That(await File.ReadAllTextAsync(file, cancellationToken)).IsEqualTo("host-content"); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + [Test] + public async Task Start_WritableBindMount_WritesBackToTheHost(CancellationToken cancellationToken) + { + // Arrange + await SkipIfUnavailableAsync(); + var directory = Path.Combine(Path.GetTempPath(), $"purview bind {Guid.NewGuid():N}"); + Directory.CreateDirectory(directory); + + try + { + await using var container = BuildWithMount(directory, readOnly: false); + + // Act + await container.StartAsync(cancellationToken); + var write = await container.ExecAsync( + ["sh", "-c", "printf container-content > /mounted/output.txt"], + cancellationToken: cancellationToken + ); + + // Assert + await Assert.That(write.IsSuccess).IsTrue(); + await Assert + .That(await File.ReadAllTextAsync(Path.Combine(directory, "output.txt"), cancellationToken)) + .IsEqualTo("container-content"); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + [Test] public async Task GetInfoAsync_ReportsTheDockerServerVersion() { diff --git a/src/tests/Docker.UnitTests/WslDockerHostTests.cs b/src/tests/Docker.UnitTests/WslDockerHostTests.cs new file mode 100644 index 0000000..a836473 --- /dev/null +++ b/src/tests/Docker.UnitTests/WslDockerHostTests.cs @@ -0,0 +1,139 @@ +namespace Purview.Containers.Docker; + +public sealed class WslDockerHostTests +{ + static readonly Uri LocalEndpoint = new("tcp://127.0.0.1:2376"); + + static WslDockerHost CreateHost(Func run) => new("DockerEngine", run); + + [Test] + public async Task FindDistribution_MultipleEngines_SelectsTheMatchingDaemon() + { + // Arrange + List calls = []; + string? Run(string[] arguments) + { + calls.Add(arguments); + return arguments[0] == "--list" ? "Ubuntu\r\nDockerEngine\r\n" + : arguments[1] == "Ubuntu" ? "other-daemon" + : "connected-daemon"; + } + + // Act + var result = WslDockerHost.FindDistribution( + LocalEndpoint, + "6.6-microsoft-standard-WSL2", + "Ubuntu", + "connected-daemon", + Run + ); + + // Assert + await Assert.That(result).IsEqualTo("DockerEngine"); + await Assert.That(calls).Count().IsEqualTo(3); + await Assert.That(calls[2]).Contains("unix:///var/run/docker.sock"); + await Assert.That(calls[2]).Contains("DOCKER_TLS_VERIFY"); + } + + [Test] + [Arguments("tcp://remote.example:2376", "6.6-microsoft-standard-WSL2", "Ubuntu")] + [Arguments("npipe://./pipe/docker_engine", "6.6-microsoft-standard-WSL2", "Docker Desktop")] + [Arguments("tcp://127.0.0.1:2376", "6.6-microsoft-standard-WSL2", "Docker Desktop")] + [Arguments("tcp://127.0.0.1:2376", "6.8.0-generic", "Ubuntu")] + public async Task FindDistribution_NotALocalWslEngine_DoesNotProbeWsl( + string endpoint, + string kernel, + string operatingSystem + ) + { + // Arrange + var probed = false; + string? Run(string[] _) + { + probed = true; + return null; + } + + // Act + var result = WslDockerHost.FindDistribution(new Uri(endpoint), kernel, operatingSystem, "daemon", Run); + + // Assert + await Assert.That(result).IsNull(); + await Assert.That(probed).IsFalse(); + } + + [Test] + [Arguments(null)] + [Arguments("Ubuntu\n")] + public async Task FindDistribution_NoMatchingDaemon_LeavesPathsUnmapped(string? distributions) + { + // Arrange + string? Run(string[] arguments) => arguments[0] == "--list" ? distributions : "other-daemon"; + + // Act + var result = WslDockerHost.FindDistribution(LocalEndpoint, "microsoft-WSL2", "Ubuntu", "connected-daemon", Run); + + // Assert + await Assert.That(result).IsNull(); + } + + [Test] + public async Task Resolve_WindowsPath_UsesTheOwningDistributionsActualMountRoot() + { + // Arrange + string[]? actual = null; + var host = CreateHost(arguments => + { + actual = arguments; + return "/windows/p/project with spaces"; + }); + + // Act + var result = host.Resolve(@"P:\project with spaces"); + + // Assert + await Assert.That(result).IsEqualTo("/windows/p/project with spaces"); + await Assert + .That(actual) + .IsEquivalentTo([ + "--distribution", + "DockerEngine", + "--exec", + "wslpath", + "-a", + "-u", + "P:/project with spaces", + ]); + } + + [Test] + [Arguments("/srv/project")] + [Arguments(@"\\server\share\project")] + [Arguments("named-volume")] + public async Task Resolve_NonDriveSource_PreservesTheSource(string source) + { + // Arrange + var host = CreateHost(_ => throw new InvalidOperationException("WSL must not be invoked.")); + + // Act + var result = host.Resolve(source); + + // Assert + await Assert.That(result).IsEqualTo(source); + } + + [Test] + [Arguments(null)] + [Arguments("P:/project")] + public async Task Resolve_TranslationFails_ReportsTheFailure(string? translated) + { + // Arrange + var host = CreateHost(_ => translated); + + // Act + var act = () => host.Resolve(@"P:\project"); + + // Assert + await Assert.That(act).Throws(); + } +} diff --git a/src/tests/Wsl.IntegrationTests/AlpineLifecycleTests.cs b/src/tests/Wsl.IntegrationTests/AlpineLifecycleTests.cs index b2feb9f..b87261d 100644 --- a/src/tests/Wsl.IntegrationTests/AlpineLifecycleTests.cs +++ b/src/tests/Wsl.IntegrationTests/AlpineLifecycleTests.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using Purview.Containers.Waiting; namespace Purview.Containers.Wsl; @@ -5,6 +6,31 @@ namespace Purview.Containers.Wsl; [Explicit] public class AlpineLifecycleTests { + static Container CreateSignalIgnoringContainer() => + new ContainerBuilder() + .WithImage("alpine:latest") + .WithCommand("/bin/sh", "-c", "trap '' TERM; echo ready; while :; do sleep 1; done") + .WithWaitStrategy(Wait.ForLogMessage("ready")) + .Build(); + + [Test] + public async Task StopAsync_InitIgnoresSigterm_KillsWithinTheGracePeriod(CancellationToken cancellationToken) + { + // Arrange + await WslcTest.SkipIfUnavailableAsync(); + await using var container = CreateSignalIgnoringContainer(); + await container.StartAsync(cancellationToken); + + // Act + var stopwatch = Stopwatch.StartNew(); + await container.StopAsync(cancellationToken); + stopwatch.Stop(); + + // Assert + await Assert.That(container.State).IsEqualTo(ContainerState.Exited); + await Assert.That(stopwatch.Elapsed).IsLessThan(TimeSpan.FromSeconds(20)); + } + [Test] public async Task EchoContainer_ProducesOutputAndCleansUp() { diff --git a/src/tests/Wsl.IntegrationTests/ExecTests.cs b/src/tests/Wsl.IntegrationTests/ExecTests.cs index 7e241a8..b007283 100644 --- a/src/tests/Wsl.IntegrationTests/ExecTests.cs +++ b/src/tests/Wsl.IntegrationTests/ExecTests.cs @@ -73,11 +73,12 @@ public async Task Exec_TimesOut_AndKillsProcess() .Build(); await container.StartAsync(); - var result = await container.ExecAsync( - ["/bin/sleep", "60"], - new ExecOptions { Timeout = TimeSpan.FromSeconds(2) } - ); - - await Assert.That(result.ExitCode).IsNotEqualTo(0); + await Assert + .That(async () => + await container + .ExecAsync(["/bin/sleep", "60"], new ExecOptions { Timeout = TimeSpan.FromSeconds(2) }) + .ConfigureAwait(false) + ) + .Throws(); } } diff --git a/src/tests/Wsl.IntegrationTests/Wsl.IntegrationTests.csproj b/src/tests/Wsl.IntegrationTests/Wsl.IntegrationTests.csproj index 6b512ec..76b1770 100644 --- a/src/tests/Wsl.IntegrationTests/Wsl.IntegrationTests.csproj +++ b/src/tests/Wsl.IntegrationTests/Wsl.IntegrationTests.csproj @@ -1 +1,5 @@ - + + + + + diff --git a/src/tests/Wsl.UnitTests/ContainerBaseTests.cs b/src/tests/Wsl.UnitTests/ContainerBaseTests.cs new file mode 100644 index 0000000..337fd91 --- /dev/null +++ b/src/tests/Wsl.UnitTests/ContainerBaseTests.cs @@ -0,0 +1,53 @@ +namespace Purview.Containers.Wsl; + +public class ContainerBaseTests +{ + [Test] + public async Task DisposeAsync_DuringStartup_CancelsStartupBeforeWaitingForCleanup( + CancellationToken cancellationToken + ) + { + // Arrange + TaskCompletionSource entered = new(TaskCreationOptions.RunContinuationsAsynchronously); + var disposed = false; + await using var container = CreateContainer( + async token => + { + entered.TrySetResult(); + await Task.Delay(Timeout.InfiniteTimeSpan, token); + }, + () => disposed = true + ); + var startup = container.StartAsync(cancellationToken); + await entered.Task.WaitAsync(cancellationToken); + + // Act + await container.DisposeAsync(); + + // Assert + await Assert.That(() => startup).Throws(); + await Assert.That(disposed).IsTrue(); + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Reliability", + "CA2000", + Justification = "ContainerBase takes ownership of the fake returned by the backend and disposes it." + )] + static Container CreateContainer(Func starting, Action disposing) => + new ContainerBuilder(new FakeBackend(new FakeContainer { Starting = starting, Disposing = disposing })) + .WithImage("fake:latest") + .WithStartupTimeout(TimeSpan.FromSeconds(5)) + .WithCleanupTimeout(TimeSpan.FromMilliseconds(500)) + .Build(); + + sealed class FakeBackend(IContainer container) : IContainerBackend + { + public string Name => "fake"; + + public IContainer CreateContainer(IContainerConfiguration configuration) => container; + + public Task GetInfoAsync(CancellationToken cancellationToken = default) => + Task.FromResult(new ContainerBackendInfo(Name, true, true, string.Empty, [])); + } +} diff --git a/src/tests/Wsl.UnitTests/ContainerBuilderTests.cs b/src/tests/Wsl.UnitTests/ContainerBuilderTests.cs index 1ea9846..db31206 100644 --- a/src/tests/Wsl.UnitTests/ContainerBuilderTests.cs +++ b/src/tests/Wsl.UnitTests/ContainerBuilderTests.cs @@ -20,6 +20,64 @@ protected override WslContainer CreateContainer(ContainerConfiguration configura static readonly string[] Expected = ["sleep", "10"]; + [Test] + public async Task BuildConfiguration_BuilderChanges_DoNotMutateBuiltConfiguration() + { + // Arrange + var builder = new TestBuilder() + .WithImage("alpine") + .WithCommand("sleep", "1") + .WithEnvironment("VALUE", "original") + .WithLabel("label", "original"); + var configuration = builder.BuildConfig(); + + // Act + builder.WithCommand("false").WithEnvironment("VALUE", "changed").WithLabel("label", "changed"); + + // Assert + await Assert.That(configuration.Command[0]).IsEqualTo("sleep"); + await Assert.That(configuration.Environment["VALUE"]).IsEqualTo("original"); + await Assert.That(configuration.Labels["label"]).IsEqualTo("original"); + } + + [Test] + public async Task Build_InvalidNewOptions_FailsBeforeBackendCreation() + { + // Arrange + var missingName = new TestBuilder().WithImage("alpine").WithReuse(); + var missingNetwork = new TestBuilder().WithImage("alpine").WithNetworkAliases("peer"); + var invalidTimeout = new TestBuilder().WithImage("alpine").WithExecTimeout(Timeout.InfiniteTimeSpan); + + // Act / Assert + await Assert.That(() => missingName.Build()).Throws(); + await Assert.That(() => missingNetwork.Build()).Throws(); + await Assert.That(() => invalidTimeout.Build()).Throws(); + } + + [Test] + [Arguments(-1d)] + [Arguments(0d)] + [Arguments(4294967295d)] + public async Task OperationOptions_InvalidDeadline_RejectsInfiniteOrNonPositiveWaits(double milliseconds) + { + // Arrange + var timeout = TimeSpan.FromMilliseconds(milliseconds); + + // Act / Assert + await Assert.That(() => new ExecOptions { Timeout = timeout }).Throws(); + await Assert.That(() => new NetworkConfiguration { Timeout = timeout }).Throws(); + await Assert + .That(() => + new ImageBuildConfiguration + { + ContextDirectory = ".", + Name = "unused", + Timeout = timeout, + } + ) + .Throws(); + } + [Test] public async Task BuildConfiguration_PopulatesAllCommonFields() { diff --git a/src/tests/Wsl.UnitTests/FakeContainer.cs b/src/tests/Wsl.UnitTests/FakeContainer.cs index 2d1bd15..8cf3c2d 100644 --- a/src/tests/Wsl.UnitTests/FakeContainer.cs +++ b/src/tests/Wsl.UnitTests/FakeContainer.cs @@ -17,8 +17,11 @@ public sealed class FakeContainer : IContainer public IReadOnlyDictionary PortMappings { get; init; } = new Dictionary(); public Func? Exec { get; set; } + public Func? Starting { get; init; } + public Action? Disposing { get; init; } - public Task StartAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; + public Task StartAsync(CancellationToken cancellationToken = default) => + Starting?.Invoke(cancellationToken) ?? Task.CompletedTask; public Task StopAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; @@ -79,5 +82,9 @@ [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cance yield break; } - public ValueTask DisposeAsync() => ValueTask.CompletedTask; + public ValueTask DisposeAsync() + { + Disposing?.Invoke(); + return ValueTask.CompletedTask; + } } diff --git a/src/tests/Wsl.UnitTests/WaitStrategyRunnerTests.cs b/src/tests/Wsl.UnitTests/WaitStrategyRunnerTests.cs index 32a1601..1937baa 100644 --- a/src/tests/Wsl.UnitTests/WaitStrategyRunnerTests.cs +++ b/src/tests/Wsl.UnitTests/WaitStrategyRunnerTests.cs @@ -5,6 +5,42 @@ namespace Purview.Containers.Wsl; public class WaitStrategyRunnerTests { + [Test] + public async Task RunAsync_PredicateIgnoresCancellation_StillHonoursDeadline(CancellationToken cancellationToken) + { + // Arrange + await using FakeContainer container = new() { Name = "uncooperative" }; + WaitContext context = new(container, container.PortMappings, networkIp: null); + TaskCompletionSource never = new(TaskCreationOptions.RunContinuationsAsynchronously); + var strategy = Wait.ForCustom((_, _) => never.Task).WithTimeout(TimeSpan.FromMilliseconds(100)); + + // Act / Assert + await Assert + .That(() => + WaitStrategyRunner + .RunAsync(context, [strategy], TimeSpan.FromSeconds(1), cancellationToken) + .WaitAsync(TimeSpan.FromSeconds(3), cancellationToken) + ) + .Throws(); + } + + [Test] + public async Task RunAsync_CallerCancels_PropagatesCancellation(CancellationToken cancellationToken) + { + // Arrange + await using FakeContainer container = new(); + WaitContext context = new(container, container.PortMappings, networkIp: null); + TaskCompletionSource never = new(TaskCreationOptions.RunContinuationsAsynchronously); + var strategy = Wait.ForCustom((_, _) => never.Task); + using var cancelled = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + cancelled.CancelAfter(TimeSpan.FromMilliseconds(100)); + + // Act / Assert + await Assert + .That(() => WaitStrategyRunner.RunAsync(context, [strategy], TimeSpan.FromMinutes(1), cancelled.Token)) + .Throws(); + } + [Test] public async Task RunAsync_SucceedsWhenConditionEventuallyTrue(CancellationToken cancellationToken) { From 07c070f8a742f3a29e7dafe08a8c44fe327a4edd Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Thu, 29 Oct 2026 14:53:04 +0000 Subject: [PATCH 2/3] feat: added Aspire and Environment helpers for cross container usages --- .config/purview-build.json | 12 + AGENTS.md | 6 + Directory.Packages.props | 3 +- README.md | 18 +- docs/wiki/Consumer-Requirements.md | 11 + docs/wiki/Packaging.md | 4 + global.json | 2 +- scripts/verify-consumers.ps1 | 32 ++ src/Containers.slnx | 3 + src/Directory.Build.props | 1 + src/src/Aspire/Aspire.csproj | 13 + .../ContainerEnvironmentBuilderExtensions.cs | 62 +++ src/src/Aspire/Sdk/README.md | 34 ++ src/src/Environment/ContainerBindMount.cs | 60 +++ .../ContainerEnvironmentOptions.cs | 20 + .../ContainerEnvironmentResolver.cs | 363 ++++++++++++++++++ src/src/Environment/ContainerPathMapping.cs | 37 ++ src/src/Environment/ContainerProcess.cs | 58 +++ src/src/Environment/Environment.csproj | 9 + src/src/Environment/Sdk/README.md | 67 ++++ .../Docker.IntegrationTests.csproj | 1 - .../Docker.UnitTests/Docker.UnitTests.csproj | 6 +- ...tainerEnvironmentBuilderExtensionsTests.cs | 53 +++ .../ContainerEnvironmentResolverTests.cs | 222 +++++++++++ .../ContainerPathMappingTests.cs | 48 +++ .../Environment.UnitTests.csproj | 11 + 26 files changed, 1147 insertions(+), 9 deletions(-) create mode 100644 src/src/Aspire/Aspire.csproj create mode 100644 src/src/Aspire/ContainerEnvironmentBuilderExtensions.cs create mode 100644 src/src/Aspire/Sdk/README.md create mode 100644 src/src/Environment/ContainerBindMount.cs create mode 100644 src/src/Environment/ContainerEnvironmentOptions.cs create mode 100644 src/src/Environment/ContainerEnvironmentResolver.cs create mode 100644 src/src/Environment/ContainerPathMapping.cs create mode 100644 src/src/Environment/ContainerProcess.cs create mode 100644 src/src/Environment/Environment.csproj create mode 100644 src/src/Environment/Sdk/README.md create mode 100644 src/tests/Environment.UnitTests/ContainerEnvironmentBuilderExtensionsTests.cs create mode 100644 src/tests/Environment.UnitTests/ContainerEnvironmentResolverTests.cs create mode 100644 src/tests/Environment.UnitTests/ContainerPathMappingTests.cs create mode 100644 src/tests/Environment.UnitTests/Environment.UnitTests.csproj diff --git a/.config/purview-build.json b/.config/purview-build.json index d359f0c..a1c0634 100644 --- a/.config/purview-build.json +++ b/.config/purview-build.json @@ -27,6 +27,18 @@ // (assets/images/purview-logo-light.png, linked as Sdk/purview-logo-light.png). A package with // no rule here, or an entry matching none of its globs, fails validation. "RequiredContent": { + "purview.containers.environment": [ + "lib/$(TFM)/Purview.Containers.Environment.dll", + "lib/$(TFM)/Purview.Containers.Environment.xml", + "README.md", + "purview-logo-light.png" + ], + "purview.containers.aspire": [ + "lib/$(TFM)/Purview.Containers.Aspire.dll", + "lib/$(TFM)/Purview.Containers.Aspire.xml", + "README.md", + "purview-logo-light.png" + ], // Purview.Containers is the umbrella: it has no buildTransitive of its own, it just depends on // Core + Wsl + Docker, whose assets flow transitively. "purview.containers": [ diff --git a/AGENTS.md b/AGENTS.md index c87e397..8cf1b27 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,6 +28,8 @@ whenever a target framework, a backend package or the `buildTransitive` assets c | `src/src/Core` | Backend-neutral abstractions (`Purview.Containers.Core`, `net10.0`, namespace `Purview.Containers`): `IContainer`/`ContainerConfiguration`, `ContainerBuilder`, `ContainerBase`, `IContainerBackend`/`ContainerBackends`, wait strategies, images, networking, mounts, diagnostics | | `src/src/Wsl` | WSL Containers backend (`Purview.Containers.Wsl`, multi-target `net10.0` facade + `net10.0-windows10.0.19041.0` implementation): `WslContainerBackend`, the shared session runtime, `WslContainer`, `WslContainerSession`, and the portable facade (`WslPayload`, `*.Facade.cs`) that loads the implementation at run time | | `src/src/Docker` | Docker backend (`Purview.Containers.Docker`, `net10.0`): `DockerContainerBackend` + `DockerContainer` over Testcontainers | +| `src/src/Environment` | Standalone CLI bind-sharing resolver (`Purview.Containers.Environment`, `net10.0`); BCL-only, no backend or Aspire dependency | +| `src/src/Aspire` | Optional `Purview.Containers.Aspire` adapter; translates bind sources at `BeforeResourceStartedEvent` without changing endpoints or named volumes | | `src/src/` | Service modules (`Purview.Containers.`, `net10.0`); each is a thin layer over the abstractions and carries a bespoke `Sdk/README.md` | | `src/src//Sdk` | Package-only assets. `Sdk/README.md` is packed as the package README (suppressing the repo-root README); `Sdk/buildTransitive/**` ships MSBuild assets to consumers (the abstractions' backend registration, each backend package's own registration entry, and the WSL backend's consumer defaults and guards) | | `src/tests` | TUnit unit (`*.UnitTests`) and container integration (`*.IntegrationTests`) projects: the WSLC suites need a WSLC host, `Docker.IntegrationTests` and `Modules.DockerIntegrationTests` need a Docker daemon | @@ -93,6 +95,10 @@ whenever a target framework, a backend package or the `buildTransitive` assets c connected loopback TCP daemon's ID against running distributions before invoking that distribution's `wslpath`. Never assume the default distribution or `/mnt`, rewrite remote/Desktop paths, or change named volumes and mount access modes. +- The standalone `Environment` package is a separate, opt-in CLI path: it verifies native, running-WSL + and explicit shared-remote candidates with two-way file markers. It does not replace the Docker + backend's daemon-identity detection or the Microsoft WSL Containers backend. Keep the resolver + BCL-only and the Aspire dependency in the adapter; neither is included in the umbrella package. ## Consumer requirements and compatibility diff --git a/Directory.Packages.props b/Directory.Packages.props index 1c97c54..fc8aab4 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -36,6 +36,7 @@ + @@ -52,7 +53,7 @@ OSI-approved open-source projects; it never ships in a Purview package. --> - + diff --git a/README.md b/README.md index 3a3b197..9581e06 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,20 @@ propagated; synchronous native WSLC calls cannot be forcibly interrupted. See ## Prerequisites +### Standalone bind sharing and Aspire + +`Purview.Containers.Environment` resolves bind paths for Docker-compatible CLI consumers without +referencing the umbrella package, Testcontainers or WSLC. It verifies native paths, running WSL +translations and explicit shared-remote mappings against the daemon using two-way file markers. +`Purview.Containers.Aspire` adds opt-in `builder.AddContainerEnvironment()` integration for any +Aspire AppHost. Both target `net10.0`; the adapter requires Aspire 13.6.4 or later. +These are separate from the existing Docker backend's automatic WSL2 path handling. +Docker Engine inside a WSL distribution is not Microsoft WSL Containers. +See the [environment package guide](src/src/Environment/Sdk/README.md) and +[Aspire adapter guide](src/src/Aspire/Sdk/README.md) for setup, permissions and remote-host limits. + +### Container backends + Pick a backend — see [Backends: WSLC or Docker](docs/wiki/Backends.md) for the comparison — or skip the choice: reference the umbrella `Purview.Containers` and it brings the abstractions plus both backends, selecting WSLC on Windows and Docker elsewhere automatically. Pick a single backend below only when you @@ -156,6 +170,8 @@ src/ Core/ backend-neutral abstractions (Purview.Containers.Core) Wsl/ WSL Containers backend (Purview.Containers.Wsl) Docker/ Docker backend (Purview.Containers.Docker) + Environment/ standalone verified CLI bind sharing (no backend dependencies) + Aspire/ optional Aspire adapter over Environment Containers/ umbrella package (Purview.Containers): Core + both backends PostgreSql/ Redis/ MsSql/ MySql/ RabbitMq/ Azurite/ Nats/ AzureKeyVaultEmulator/ @@ -312,7 +328,7 @@ just sample-docker # needs a Docker daemon ### Verifying the consumer contract ```powershell -just verify-consumers # pack, then build 21 throwaway consumer projects against ./artifacts +just verify-consumers # pack, then build 23 throwaway consumer projects against ./artifacts just verify-consumers -Keep # same, keeping the generated projects for inspection ``` diff --git a/docs/wiki/Consumer-Requirements.md b/docs/wiki/Consumer-Requirements.md index 7babaf3..72e12d3 100644 --- a/docs/wiki/Consumer-Requirements.md +++ b/docs/wiki/Consumer-Requirements.md @@ -30,6 +30,17 @@ for operation deadlines, resource ownership and native cancellation limits. | `Purview.Containers.` | `net10.0`, any platform | Service modules. Restore anywhere; needs a backend package to actually run. | | `Purview.Containers.Wsl` | `net10.0` and `net10.0-windows10.0.19041.0` | The WSL Containers backend: a portable facade plus the Windows implementation. **The requirements on this page are its contract.** | | `Purview.Containers.Docker` | `net10.0`, any platform | The Docker backend (Testcontainers). Needs a reachable Docker daemon, not a Windows target framework. | +| `Purview.Containers.Environment` | `net10.0`, any platform | Standalone Docker-compatible CLI bind-sharing resolver; no Aspire, Testcontainers or WSLC dependency. | +| `Purview.Containers.Aspire` | `net10.0`, any platform | Optional adapter requiring Aspire 13.6.4+ and the Environment package; no backend dependency. | + +The standalone resolver requires a reachable Linux-container daemon through its selected CLI, working +credential helpers on `PATH`, and client write access to bind-source directories for verification markers. +Preload `alpine:3.19` offline or set `CONTAINER_ENVIRONMENT_PROBE_IMAGE`. Windows WSL fallback only +queries already-running distributions; remote hosts require a genuinely shared filesystem and explicit +`CONTAINER_ENVIRONMENT_PATH_MAPPINGS`. No snapshot fallback or endpoint rewriting is performed. +See the [resolver guide](https://github.com/purview-dev/containers/blob/main/src/src/Environment/Sdk/README.md) and +[Aspire guide](https://github.com/purview-dev/containers/blob/main/src/src/Aspire/Sdk/README.md). +The portable package contracts are exercised by `just verify-consumers` cases 22 and 23. Everything below describes the **WSL Containers backend**. A consumer of a different backend (for example `Purview.Containers.Docker`) needs only a portable `net10.0` project: the diff --git a/docs/wiki/Packaging.md b/docs/wiki/Packaging.md index 3514b29..4271fb3 100644 --- a/docs/wiki/Packaging.md +++ b/docs/wiki/Packaging.md @@ -3,6 +3,10 @@ Every project under `src/src` is packable and ships as `Purview.Containers.*`; test projects never pack. Packages are produced into `./artifacts` with a matching `.snupkg`. +The optional `Purview.Containers.Environment` and `Purview.Containers.Aspire` packages also ship +portable `net10.0` assets, their own READMEs and the shared icon. Environment is BCL-only; Aspire +depends only on Environment and `Aspire.Hosting`. Neither is pulled in by the umbrella package. + ## What a package contains Each package ships exactly: diff --git a/global.json b/global.json index 8b388e3..d704407 100644 --- a/global.json +++ b/global.json @@ -10,4 +10,4 @@ "test": { "runner": "Microsoft.Testing.Platform" } -} \ No newline at end of file +} diff --git a/scripts/verify-consumers.ps1 b/scripts/verify-consumers.ps1 index 2590f6d..68e9391 100644 --- a/scripts/verify-consumers.ps1 +++ b/scripts/verify-consumers.ps1 @@ -33,6 +33,8 @@ 19 plain net10.0 + WSL backend (portable facade, auto) -> builds, wsl registration generated 20 net10.0 + Redis/PostgreSql + both backends (auto) -> builds, both registrations generated 21 net10.0 + umbrella Purview.Containers + modules (one ref) -> builds, both registrations generated + 22 net10.0 + standalone environment resolver -> builds, no backend required + 23 net10.0 + Aspire adapter -> builds, no backend required .PARAMETER FeedPath Folder holding the packed .nupkg files. Defaults to /artifacts. @@ -454,6 +456,36 @@ $cases = @( -Items $umbrellaItems ` -Sources @{ 'Smoke.Auto.cs' = $autoSource } ` -RequireText @('WslContainerBackend.Create()', 'DockerContainerBackend.Create()') + + New-ConsumerCase -Id '22' -Name 'net10.0 + standalone environment resolver' ` + -Framework 'net10.0' ` + -Package 'Purview.Containers.Environment' ` + -Sources @{ 'Smoke.cs' = @' +using Purview.Containers.Environment; +public static class Program +{ + public static void Main() + { + using var resolver = new ContainerEnvironmentResolver(new ContainerEnvironmentOptions()); + _ = new ContainerBindMount(System.IO.Path.GetTempPath(), "/data", IsReadOnly: true).ToString(); + } +} +'@ } + + New-ConsumerCase -Id '23' -Name 'net10.0 + Aspire bind-sharing adapter' ` + -Framework 'net10.0' ` + -Package 'Purview.Containers.Aspire' ` + -Sources @{ 'Smoke.cs' = @' +using Aspire.Hosting; +public static class Program +{ + public static void Main() + { + var builder = DistributedApplication.CreateBuilder([]); + builder.AddContainerEnvironment(); + } +} +'@ } ) diff --git a/src/Containers.slnx b/src/Containers.slnx index 3035886..954ac87 100644 --- a/src/Containers.slnx +++ b/src/Containers.slnx @@ -10,6 +10,8 @@ + + @@ -32,6 +34,7 @@ + diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 6e52685..70028da 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -1,6 +1,7 @@ Purview.Containers + true net11.0-windows10.0.19041.0 x64 10.0.26100.80 diff --git a/src/src/Aspire/Aspire.csproj b/src/src/Aspire/Aspire.csproj new file mode 100644 index 0000000..f8c0647 --- /dev/null +++ b/src/src/Aspire/Aspire.csproj @@ -0,0 +1,13 @@ + + + true + Aspire bind mount integration for verified local, WSL and shared remote container environments. + containers;aspire;docker;wsl;bind-mounts + MIT + Purview + + + + + + diff --git a/src/src/Aspire/ContainerEnvironmentBuilderExtensions.cs b/src/src/Aspire/ContainerEnvironmentBuilderExtensions.cs new file mode 100644 index 0000000..19d3503 --- /dev/null +++ b/src/src/Aspire/ContainerEnvironmentBuilderExtensions.cs @@ -0,0 +1,62 @@ +using System.Collections.Concurrent; +using Aspire.Hosting.ApplicationModel; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Purview.Containers.Environment; + +#pragma warning disable IDE0130 // Aspire extension methods belong beside Aspire's own builder extensions. +namespace Aspire.Hosting; + +/// Adapts resource bind mounts without depending on any particular Aspire integration. +public static class ContainerEnvironmentBuilderExtensions +{ + /// + /// Verifies and resolves bind mounts immediately before containers start. Named volumes, target + /// paths, read-only flags, resource endpoints and publish manifests are left unchanged. + /// + public static IDistributedApplicationBuilder AddContainerEnvironment( + this IDistributedApplicationBuilder builder, + ContainerEnvironmentResolver? environment = null + ) + { + ArgumentNullException.ThrowIfNull(builder); + if (builder.Services.Any(service => service.ServiceType == typeof(ContainerEnvironmentRegistration))) + return builder; + + builder.Services.TryAddSingleton(); + environment ??= ContainerEnvironmentResolver.ForRuntime( + builder.Configuration["ASPIRE_CONTAINER_RUNTIME"] ?? "docker" + ); + ConcurrentDictionary originalSources = new( + ReferenceEqualityComparer.Instance + ); + + builder.Eventing.Subscribe( + async (evt, cancellationToken) => + { + // This event runs after BeforeStart subscribers add mounts, but before DCP renders them. + foreach (var mount in evt.Resource.Annotations.OfType().ToArray()) + { + if (mount.Type != ContainerMountType.BindMount || mount.Source is null) + continue; + + var original = originalSources.GetOrAdd(mount, mount.Source); + var source = await environment + .ResolveBindSourceAsync(original, cancellationToken) + .ConfigureAwait(false); + if (source == mount.Source) + continue; + + ContainerMountAnnotation replacement = new(source, mount.Target, mount.Type, mount.IsReadOnly); + originalSources[replacement] = original; + evt.Resource.Annotations.Remove(mount); + evt.Resource.Annotations.Add(replacement); + originalSources.TryRemove(mount, out _); + } + } + ); + + return builder; + } + + sealed class ContainerEnvironmentRegistration; +} diff --git a/src/src/Aspire/Sdk/README.md b/src/src/Aspire/Sdk/README.md new file mode 100644 index 0000000..b5b830a --- /dev/null +++ b/src/src/Aspire/Sdk/README.md @@ -0,0 +1,34 @@ +# Purview.Containers.Aspire + +> **Experimental.** Pin the exact prerelease version; the API and defaults may change. + +Apply verified container bind-source resolution to any Aspire AppHost. No AspireC4 types or +resource names are used. + +```csharp +var builder = DistributedApplication.CreateBuilder(args); +builder.AddContainerEnvironment(); + +builder.AddContainer("example", "your-image") + .WithBindMount(localDirectory, "/data", isReadOnly: true); + +await builder.Build().RunAsync(); +``` + +The adapter uses Aspire's selected `ASPIRE_CONTAINER_RUNTIME` (default `docker`), and the underlying +CLI retains its Docker context and TLS configuration. Call once during builder configuration. +Registration is idempotent. + +Resolution happens at `BeforeResourceStartedEvent`, after application startup hooks have added +their mounts and before DCP renders the container specification. Only bind sources are replaced: +named volumes, target paths, read-only flags, endpoints and publish-mode manifests stay unchanged. +Keep local source directories/files present before starting the container. + +Supply a `Purview.Containers.Environment.ContainerEnvironmentResolver` instance as the second argument for +explicit mappings or a custom probe timeout/image. Otherwise the core package's environment +configuration and verified native/WSL discovery are used. + +See the [environment package README](https://github.com/purview-dev/containers/blob/main/src/src/Environment/Sdk/README.md) +for supported environments, marker permissions, offline probe images and remote-filesystem limits. +This adapter does not configure remote service routing; retain Aspire's endpoint discovery and prove +reachability with real service requests. diff --git a/src/src/Environment/ContainerBindMount.cs b/src/src/Environment/ContainerBindMount.cs new file mode 100644 index 0000000..4510de1 --- /dev/null +++ b/src/src/Environment/ContainerBindMount.cs @@ -0,0 +1,60 @@ +using System.Text; + +namespace Purview.Containers.Environment; + +/// A Docker CLI bind mount, with CSV-safe formatting for paths containing commas or quotes. +public sealed record ContainerBindMount(string Source, string Target, bool IsReadOnly = false) +{ + /// Formats the value of a Docker --mount option, not a shell command. + public override string ToString() => + string.Join( + ",", + new[] { "type=bind", $"source={Source}", $"target={Target}" } + .Concat(IsReadOnly ? ["readonly"] : Array.Empty()) + .Select(Quote) + ); + + internal static string Quote(string value) => + value.IndexOfAny([',', '"', '\r', '\n']) >= 0 + ? $"\"{value.Replace("\"", "\"\"", StringComparison.Ordinal)}\"" + : value; + + internal static IReadOnlyList ParseFields(string value) + { + List fields = []; + StringBuilder field = new(); + var quoted = false; + + for (var i = 0; i < value.Length; i++) + { + var character = value[i]; + if (character == '"') + { + if (quoted && i + 1 < value.Length && value[i + 1] == '"') + { + field.Append('"'); + i++; + } + else + { + quoted = !quoted; + } + } + else if (character == ',' && !quoted) + { + fields.Add(field.ToString()); + field.Clear(); + } + else + { + field.Append(character); + } + } + + if (quoted) + throw new FormatException("The container mount has an unterminated CSV field."); + + fields.Add(field.ToString()); + return fields; + } +} diff --git a/src/src/Environment/ContainerEnvironmentOptions.cs b/src/src/Environment/ContainerEnvironmentOptions.cs new file mode 100644 index 0000000..e7b5c9a --- /dev/null +++ b/src/src/Environment/ContainerEnvironmentOptions.cs @@ -0,0 +1,20 @@ +namespace Purview.Containers.Environment; + +/// Configuration for a single container runtime connection. +public sealed class ContainerEnvironmentOptions +{ + /// The Docker-compatible CLI. Its existing context, environment and TLS settings are preserved. + public string RuntimeCommand { get; set; } = "docker"; + + /// An optional running WSL distribution used to translate Windows paths. + public string? WslDistribution { get; set; } + + /// Explicit client-root to daemon-root mappings, with the most specific root winning. + public Dictionary PathMappings { get; set; } = []; + + /// The Linux image used to verify file sharing. Pin or mirror it for restricted environments. + public string ProbeImage { get; set; } = "alpine:3.19"; + + /// The maximum duration of each runtime or WSL command, including an image pull. + public TimeSpan CommandTimeout { get; set; } = TimeSpan.FromSeconds(60); +} diff --git a/src/src/Environment/ContainerEnvironmentResolver.cs b/src/src/Environment/ContainerEnvironmentResolver.cs new file mode 100644 index 0000000..26c022e --- /dev/null +++ b/src/src/Environment/ContainerEnvironmentResolver.cs @@ -0,0 +1,363 @@ +using System.Collections.Concurrent; +using System.ComponentModel; +using System.Diagnostics; +using System.Text.Json; + +namespace Purview.Containers.Environment; + +/// +/// Resolves bind sources against the selected daemon, verifying that both sides see the same files. +/// Does not change Docker contexts, environment variables, port forwarding or machine configuration. +/// +public sealed class ContainerEnvironmentResolver : IDisposable +{ + static readonly HashSet BooleanRunOptions = new(StringComparer.Ordinal) + { + "--detach", + "--disable-content-trust", + "--help", + "--init", + "--interactive", + "--oom-kill-disable", + "--privileged", + "--publish-all", + "--read-only", + "--rm", + "--sig-proxy", + "--tty", + "--use-api-socket", + }; + static readonly ConcurrentDictionary Environments = new( + StringComparer.Ordinal + ); + readonly ContainerEnvironmentOptions _options; + readonly ContainerPathMapping[] _mappings; + readonly ConcurrentDictionary _directories = new( + OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal + ); + readonly SemaphoreSlim _resolutionLock = new(1, 1); + readonly Func> _probe; + readonly Func>> _wslCandidates; + + /// Creates an isolated resolver. Keep it for the duration of a run to cache verified mappings. + public ContainerEnvironmentResolver(ContainerEnvironmentOptions options) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentException.ThrowIfNullOrWhiteSpace(options.RuntimeCommand); + ArgumentException.ThrowIfNullOrWhiteSpace(options.ProbeImage); + ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(options.CommandTimeout, TimeSpan.Zero); + _options = new() + { + RuntimeCommand = options.RuntimeCommand, + ProbeImage = options.ProbeImage, + CommandTimeout = options.CommandTimeout, + WslDistribution = options.WslDistribution, + }; + _mappings = options + .PathMappings.Select(pair => new ContainerPathMapping(pair.Key, pair.Value)) + .OrderByDescending(mapping => mapping.Specificity) + .ToArray(); + _probe = ProbeAsync; + _wslCandidates = GetWslCandidatesAsync; + } + + internal ContainerEnvironmentResolver( + ContainerEnvironmentOptions options, + Func> probe, + Func>> wslCandidates + ) + : this(options) + { + _probe = probe; + _wslCandidates = wslCandidates; + } + + /// + /// Gets a process-scoped shared resolver for a CLI. Reads CONTAINER_ENVIRONMENT_PATH_MAPPINGS (JSON), + /// CONTAINER_ENVIRONMENT_WSL_DISTRIBUTION and CONTAINER_ENVIRONMENT_PROBE_IMAGE on first use. + /// + public static ContainerEnvironmentResolver ForRuntime(string runtimeCommand = "docker") => + Environments.GetOrAdd( + runtimeCommand, + command => + { + ContainerEnvironmentOptions options = new() + { + RuntimeCommand = command, + WslDistribution = System.Environment.GetEnvironmentVariable( + "CONTAINER_ENVIRONMENT_WSL_DISTRIBUTION" + ), + ProbeImage = + System.Environment.GetEnvironmentVariable("CONTAINER_ENVIRONMENT_PROBE_IMAGE") ?? "alpine:3.19", + }; + var mappings = System.Environment.GetEnvironmentVariable("CONTAINER_ENVIRONMENT_PATH_MAPPINGS"); + if (!string.IsNullOrWhiteSpace(mappings)) + options.PathMappings = + JsonSerializer.Deserialize>(mappings) + ?? throw new FormatException("CONTAINER_ENVIRONMENT_PATH_MAPPINGS must be a JSON object."); + return new(options); + } + ); + + /// Releases synchronization resources. Do not dispose instances returned by . + public void Dispose() => _resolutionLock.Dispose(); + + /// + /// Resolves a local file or directory into a verified daemon bind source. Explicit mappings take + /// precedence; otherwise native paths are tried before translations from running WSL distributions. + /// + public async Task ResolveBindSourceAsync(string source, CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(source); + source = Path.GetFullPath(source); + var isFile = File.Exists(source); + var directory = isFile ? Path.GetDirectoryName(source) : source; + if (!Directory.Exists(directory)) + throw new DirectoryNotFoundException($"Container bind source '{source}' does not exist on the client."); + + await _resolutionLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + if (!_directories.TryGetValue(directory, out var resolved)) + { + resolved = await ResolveDirectoryAsync(directory, cancellationToken).ConfigureAwait(false); + _directories[directory] = resolved; + } + return isFile + ? resolved.TrimEnd('/', '\\') + + (resolved.Contains('\\', StringComparison.Ordinal) ? "\\" : "/") + + Path.GetFileName(source) + : resolved; + } + finally + { + _resolutionLock.Release(); + } + } + + /// + /// Resolves --mount bind sources in a constructed run or create invocation. + /// Volume mounts and arguments following the image name are left unchanged. + /// + public async Task ResolveBindMountsAsync(ProcessStartInfo startInfo, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(startInfo); + var arguments = startInfo.ArgumentList; + var verb = arguments.Count > 0 && arguments[0] == "container" ? 1 : 0; + if (verb >= arguments.Count || arguments[verb] is not ("run" or "create")) + return; + + for (var i = verb + 1; i < arguments.Count; i++) + { + var argument = arguments[i]; + if (argument == "--" || !argument.StartsWith('-')) + break; + if (argument == "--mount" && i + 1 < arguments.Count) + { + i++; + arguments[i] = await ResolveMountAsync(arguments[i], cancellationToken).ConfigureAwait(false); + } + else if (argument.StartsWith("--mount=", StringComparison.Ordinal)) + { + arguments[i] = + "--mount=" + + await ResolveMountAsync(argument["--mount=".Length..], cancellationToken).ConfigureAwait(false); + } + else if ( + !argument.Contains('=', StringComparison.Ordinal) + && !BooleanRunOptions.Contains(argument) + && !( + argument.Length > 1 + && argument[1..].All(character => "ditP".Contains(character, StringComparison.Ordinal)) + ) + ) + { + // Other Docker run options take a separate value, unless a short option embeds it. + if (argument.StartsWith("--", StringComparison.Ordinal) || argument.Length == 2) + i++; + } + } + } + + async Task ResolveMountAsync(string value, CancellationToken cancellationToken) + { + var fields = ContainerBindMount.ParseFields(value).ToArray(); + if (!fields.Contains("type=bind", StringComparer.Ordinal)) + return value; + + for (var i = 0; i < fields.Length; i++) + { + if ( + fields[i].StartsWith("source=", StringComparison.Ordinal) + || fields[i].StartsWith("src=", StringComparison.Ordinal) + ) + { + var separator = fields[i].IndexOf('=', StringComparison.Ordinal); + fields[i] = + fields[i][..(separator + 1)] + + await ResolveBindSourceAsync(fields[i][(separator + 1)..], cancellationToken) + .ConfigureAwait(false); + } + } + return string.Join(",", fields.Select(ContainerBindMount.Quote)); + } + + async Task ResolveDirectoryAsync(string directory, CancellationToken cancellationToken) + { + var explicitPath = _mappings.Select(mapping => mapping.Map(directory)).FirstOrDefault(path => path is not null); + if (explicitPath is not null) + { + if (await _probe(directory, explicitPath, cancellationToken).ConfigureAwait(false)) + return explicitPath; + throw Unshared(directory); + } + + if (await _probe(directory, directory, cancellationToken).ConfigureAwait(false)) + return directory; + + var candidates = await _wslCandidates(directory, cancellationToken).ConfigureAwait(false); + foreach (var candidate in candidates.Distinct(StringComparer.Ordinal)) + { + if (await _probe(directory, candidate, cancellationToken).ConfigureAwait(false)) + return candidate; + } + throw Unshared(directory); + } + + InvalidOperationException Unshared(string directory) => + new( + $"Container runtime '{_options.RuntimeCommand}' cannot share client directory '{directory}' with its daemon. " + + "A working Docker connection does not imply shared files. Configure CONTAINER_ENVIRONMENT_PATH_MAPPINGS " + + "(a JSON object of client roots to daemon roots), or CONTAINER_ENVIRONMENT_WSL_DISTRIBUTION for WSL. " + + "The selected daemon must see the same files; unshared remote filesystems need explicit synchronization." + ); + + async Task ProbeAsync(string directory, string daemonDirectory, CancellationToken cancellationToken) + { + var id = Guid.NewGuid().ToString("N"); + var name = "container-environment-" + id; + var inputName = "." + name + ".in"; + var outputName = "." + name + ".out"; + var input = Path.Combine(directory, inputName); + var output = Path.Combine(directory, outputName); + try + { + await File.WriteAllTextAsync(input, id, cancellationToken).ConfigureAwait(false); + var result = await RunAsync( + _options.RuntimeCommand, + [ + "run", + "--rm", + "--name", + name, + "--mount", + new ContainerBindMount(daemonDirectory, "/probe").ToString(), + _options.ProbeImage, + "sh", + "-c", + "test \"$(cat \"/probe/$1\" 2>/dev/null)\" = \"$2\" || exit 42; " + + "printf '%s' \"$2\"; printf '%s' \"$2\" > \"/probe/$3\" || exit 43", + "probe", + inputName, + id, + outputName, + ], + cancellationToken + ) + .ConfigureAwait(false); + if ( + result.ExitCode is not (0 or 42 or 43) + && !result.Error.Contains("mount", StringComparison.OrdinalIgnoreCase) + ) + { + throw new InvalidOperationException( + $"Container environment probe using '{_options.RuntimeCommand}' and image '{_options.ProbeImage}' failed: " + + result.Error.Trim() + ); + } + return result.ExitCode == 0 + && result.Output.Trim() == id + && File.Exists(output) + && await File.ReadAllTextAsync(output, cancellationToken).ConfigureAwait(false) == id; + } + finally + { + // A cancelled docker client does not necessarily stop the container it asked the daemon to run. + try + { + await ContainerProcess + .RunAsync( + _options.RuntimeCommand, + ["rm", "--force", name], + TimeSpan.FromSeconds(5), + CancellationToken.None + ) + .ConfigureAwait(false); + } + catch (Exception ex) when (ex is Win32Exception or TimeoutException or InvalidOperationException) + { + Trace.TraceWarning("Could not remove container environment probe '{0}': {1}", name, ex.Message); + } + finally + { + File.Delete(input); + File.Delete(output); + } + } + } + + async Task> GetWslCandidatesAsync(string directory, CancellationToken cancellationToken) + { + if (!OperatingSystem.IsWindows()) + return []; + + try + { + var running = await ContainerProcess + .RunAsync( + "wsl.exe", + ["--list", "--quiet", "--running"], + _options.CommandTimeout, + cancellationToken, + unicode: true + ) + .ConfigureAwait(false); + var distributions = running + .Output.Split(['\r', '\n', '\0'], StringSplitOptions.RemoveEmptyEntries) + .Select(value => value.Trim()) + .Where(value => value.Length > 0); + if (!string.IsNullOrWhiteSpace(_options.WslDistribution)) + { + if (!distributions.Contains(_options.WslDistribution, StringComparer.Ordinal)) + throw new InvalidOperationException( + $"WSL distribution '{_options.WslDistribution}' is not running. Start it before running container tests." + ); + distributions = [_options.WslDistribution]; + } + + List candidates = []; + foreach (var distribution in distributions) + { + var result = await RunAsync( + "wsl.exe", + ["--distribution", distribution, "--exec", "wslpath", "-a", "-u", directory], + cancellationToken + ) + .ConfigureAwait(false); + if (result.ExitCode == 0 && result.Output.Trim().StartsWith('/')) + candidates.Add(result.Output.Trim()); + } + return candidates; + } + catch (Win32Exception) when (string.IsNullOrWhiteSpace(_options.WslDistribution)) + { + return []; + } + } + + Task RunAsync( + string command, + IReadOnlyList arguments, + CancellationToken cancellationToken + ) => ContainerProcess.RunAsync(command, arguments, _options.CommandTimeout, cancellationToken); +} diff --git a/src/src/Environment/ContainerPathMapping.cs b/src/src/Environment/ContainerPathMapping.cs new file mode 100644 index 0000000..7217d6f --- /dev/null +++ b/src/src/Environment/ContainerPathMapping.cs @@ -0,0 +1,37 @@ +namespace Purview.Containers.Environment; + +/// Maps paths beneath a client root without matching similarly named sibling directories. +public sealed class ContainerPathMapping +{ + readonly string _clientRoot; + readonly string _daemonRoot; + + /// Creates a mapping. Daemon paths are interpreted by the daemon, not by the client OS. + public ContainerPathMapping(string clientRoot, string daemonRoot) + { + ArgumentException.ThrowIfNullOrWhiteSpace(clientRoot); + ArgumentException.ThrowIfNullOrWhiteSpace(daemonRoot); + _clientRoot = Path.TrimEndingDirectorySeparator(Path.GetFullPath(clientRoot)); + _daemonRoot = daemonRoot.TrimEnd('/', '\\'); + if (_daemonRoot.Length == 0) + _daemonRoot = "/"; + } + + internal int Specificity => _clientRoot.Length; + + /// Returns a mapped path, or when the path is outside this root. + public string? Map(string clientPath) + { + var relative = Path.GetRelativePath(_clientRoot, Path.GetFullPath(clientPath)); + if ( + Path.IsPathRooted(relative) + || relative == ".." + || relative.StartsWith(".." + Path.DirectorySeparatorChar, StringComparison.Ordinal) + ) + return null; + + return relative == "." + ? _daemonRoot + : _daemonRoot.TrimEnd('/') + "/" + relative.Replace(Path.DirectorySeparatorChar, '/'); + } +} diff --git a/src/src/Environment/ContainerProcess.cs b/src/src/Environment/ContainerProcess.cs new file mode 100644 index 0000000..7220f69 --- /dev/null +++ b/src/src/Environment/ContainerProcess.cs @@ -0,0 +1,58 @@ +using System.Diagnostics; + +namespace Purview.Containers.Environment; + +sealed record ContainerProcessResult(int ExitCode, string Output, string Error); + +static class ContainerProcess +{ + internal static async Task RunAsync( + string command, + IReadOnlyList arguments, + TimeSpan timeout, + CancellationToken cancellationToken, + bool unicode = false + ) + { + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + cts.CancelAfter(timeout); + + ProcessStartInfo startInfo = new(command) + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + if (unicode) + startInfo.StandardOutputEncoding = System.Text.Encoding.Unicode; + foreach (var argument in arguments) + startInfo.ArgumentList.Add(argument); + + using var process = + Process.Start(startInfo) + ?? throw new InvalidOperationException($"Could not start container environment command '{command}'."); + + try + { + // Both pipes must be drained concurrently, including while a runtime pulls the probe image. + var output = process.StandardOutput.ReadToEndAsync(cts.Token); + var error = process.StandardError.ReadToEndAsync(cts.Token); + await process.WaitForExitAsync(cts.Token).ConfigureAwait(false); + return new(process.ExitCode, await output.ConfigureAwait(false), await error.ConfigureAwait(false)); + } + catch (OperationCanceledException) + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + await process.WaitForExitAsync(CancellationToken.None).ConfigureAwait(false); + } + if (cancellationToken.IsCancellationRequested) + throw; + throw new TimeoutException( + $"Container environment command '{command}' exceeded {timeout.TotalSeconds:0}s." + ); + } + } +} diff --git a/src/src/Environment/Environment.csproj b/src/src/Environment/Environment.csproj new file mode 100644 index 0000000..69a3673 --- /dev/null +++ b/src/src/Environment/Environment.csproj @@ -0,0 +1,9 @@ + + + true + Verified client-to-container-daemon bind mount resolution for local, WSL and shared remote Docker environments. + containers;docker;wsl;bind-mounts;environment + MIT + Purview + + diff --git a/src/src/Environment/Sdk/README.md b/src/src/Environment/Sdk/README.md new file mode 100644 index 0000000..505afed --- /dev/null +++ b/src/src/Environment/Sdk/README.md @@ -0,0 +1,67 @@ +# Purview.Containers.Environment + +> **Experimental.** Pin the exact prerelease version; the API and defaults may change. + +Resolve client-side bind sources against a Docker-compatible daemon without assuming the daemon +runs on the client machine. This library has no Aspire or AspireC4 dependency. + +```csharp +using Purview.Containers.Environment; + +using var environment = new ContainerEnvironmentResolver(new ContainerEnvironmentOptions()); +var source = await environment.ResolveBindSourceAsync(localDirectory, cancellationToken); +var mount = new ContainerBindMount(source, "/data", IsReadOnly: true); +// Pass mount.ToString() as the argument after --mount, using ProcessStartInfo.ArgumentList. +``` + +Keep the resolver for the duration of a run. Native paths are tried first. On Windows, if native +sharing fails, running WSL distributions supply candidates via `wslpath`. A candidate is accepted +only when a container reads a unique client-created marker and writes a marker the client can read. +Neither distribution names nor mount roots are assumed. No distribution is started automatically. +Files are resolved through their parent directory; target paths and named volumes are not translated. + +## Configuration + +The constructor accepts explicit options. `ContainerEnvironmentResolver.ForRuntime("docker")` instead caches +a resolver per command and snapshots these environment variables on first use: + +| Variable | Meaning | +|---|---| +| `CONTAINER_ENVIRONMENT_PATH_MAPPINGS` | JSON object mapping client roots to daemon roots; the longest matching root wins | +| `CONTAINER_ENVIRONMENT_WSL_DISTRIBUTION` | Restrict fallback translation to this already-running distribution | +| `CONTAINER_ENVIRONMENT_PROBE_IMAGE` | Override the default `alpine:3.19` probe image, for example with an internal mirror | + +Explicit mappings are authoritative and verified, never silently replaced by another mapping. +Root boundaries are respected: mapping `project` does not match `project-other`. + +The CLI inherits Docker's existing environment, active context, SSH/TLS configuration and credentials. +The resolver never changes them. A custom `RuntimeCommand` also supports Docker-compatible CLIs such +as Podman. Individual commands are bounded by `CommandTimeout` (60 seconds by default); cancellation +terminates the client process and attempts to remove only the uniquely named probe container. + +## Supported environments and limits + +- Native Linux Docker and Docker Desktop sharing: verified native paths. +- Windows client with a Linux daemon inside WSL: verified `wslpath` translation. +- Remote daemons with an already-shared filesystem: explicit verified root mappings. +- Unshared remote filesystems: fail with a configuration diagnostic. There is deliberately no silent + snapshot/copy fallback: it would make live file changes disappear. + +The client needs write access to the source directory to create and remove verification markers, +even when the eventual application mount is read-only. Successful directory resolutions are cached +for this resolver's lifetime; create a new resolver after changing Docker connections or file sharing. +The probe image must run Linux `sh`, `cat` and `printf`. Offline hosts must preload it. + +Daemon access, file sharing, and published-port connectivity are independent. This package resolves +file sharing only: it does not infer a service address from `DOCKER_HOST`, change endpoint URLs, +open firewalls, install forwarding rules, or expose an unauthenticated Docker API. Configure remote +service access in your orchestrator, and test it using an actual HTTP/database request. + +`ResolveBindMountsAsync` adapts `--mount` arguments in a `ProcessStartInfo` you construct. Prefer the +typed `ContainerBindMount` formatter to preserve paths containing commas and spaces. It does not +parse shell commands or rewrite `-v` arguments. Pass arguments beginning with `run`, `create`, +`container run` or `container create`; image command arguments are not rewritten. + +For Aspire applications, use the separate `Purview.Containers.Aspire` adapter. +Neither package brings the umbrella package, Testcontainers or the Microsoft WSL Containers backend. +Docker Engine hosted inside a WSL distribution is distinct from Microsoft WSL Containers (WSLC). diff --git a/src/tests/Docker.IntegrationTests/Docker.IntegrationTests.csproj b/src/tests/Docker.IntegrationTests/Docker.IntegrationTests.csproj index e2d7178..06d2864 100644 --- a/src/tests/Docker.IntegrationTests/Docker.IntegrationTests.csproj +++ b/src/tests/Docker.IntegrationTests/Docker.IntegrationTests.csproj @@ -12,7 +12,6 @@ -