# Containers > Throwaway Linux containers for .NET integration tests — on WSL Containers with no Docker installation, or on Docker. A Testcontainers-style library for .NET with one API and two interchangeable backends: Microsoft WSL Containers (WSLC) with no Docker installation, or Docker through Testcontainers. Containers are created through backend-neutral abstractions (Purview.Containers.Core) and the backend is resolved at run time, so the same test code runs on WSLC (Purview.Containers.Wsl) or Docker (Purview.Containers.Docker) unchanged. The WSLC backend talks to the WSLC managed C# API directly — no wslc.exe, no docker CLI, no Docker.DotNet. Typed service modules add PostgreSQL, Redis, SQL Server, MySQL, RabbitMQ, Azurite, and NATS, each with its own defaults, configuration, readiness wait strategy, and connection-string helper. The umbrella package Purview.Containers brings the abstractions plus both backends in a single reference. - Repository: https://github.com/purview-dev/containers - Package: https://www.nuget.org/packages/Purview.Containers - Project page: https://purview.dev/projects/containers/ - Documentation: https://purview.dev/docs/containers/ - Full machine-readable content: https://purview.dev/projects/containers/llms-full.txt # Purview Containers Throwaway Linux containers for .NET integration testing — Testcontainers-style APIs for **Microsoft WSL Containers (WSLC)** with no Docker installation, and for **Docker** through Testcontainers. The same test code runs on either runtime; see [Backends: WSLC or Docker](backends/). [Get started](getting-started/) [Choose a backend](backends/) ## Guides - [Getting Started](getting-started/) - [Using in your tests (auto)](using-in-your-tests/) - [Backends: WSLC or Docker](backends/) - [Consumer requirements](consumer-requirements/) - [Architecture](architecture/) - [Lifecycle](lifecycle/) - [Networking](networking/) - [Wait strategies](wait-strategies/) - [Modules](modules/) - [Testing](testing/) - [Packaging](packaging/) - [Release flow](release-flow/) - [Contributing modules](contributing-modules/) - [Contributing](contributing/) --- # Getting Started This guide installs the packages, runs a first generic container, switches to a typed service module, and points you at the test workflow. ## Requirements Everything runs on one of two backends; pick the one that matches your machine — see [Backends: WSLC or Docker](../backends/) for the full comparison. - **WSL Containers backend:** Windows 10/11 with **WSL Containers** (`wsl --install --no-distribution`), verified against WSL **3.0.1.0**. A consuming project must be **.NET 10 or later**: a platform-neutral `net10.0` project binds the portable facade and gets automatic WSLC-or-Docker selection, while a Windows target framework (`net10.0-windows10.0.19041.0`, x64 or arm64) binds the implementation directly. The `Purview.Containers.Wsl` package ships MSBuild defaults for `WindowsSdkPackageVersion` and `PlatformTarget`; an unsupported target framework fails the build with `PCC0001` and a 32-bit Windows consumer with `PCC0002`. - **Docker backend:** any reachable Docker daemon, on any platform, with a `net10.0` or later project. No Windows target framework and no `PCC` guards apply. - Verify the host with `wsl --version` and `wslc version`, or with `docker info`. The library never installs a runtime for you — `WslContainerRuntime.GetInfoAsync()` and `DockerContainerBackend.GetInfoAsync()` report what is missing. - The full consumer contract, every error, and the `EnableWindowsTargeting` workaround for non-Windows CI agents live in [Consumer Requirements](../consumer-requirements/). > **Experimental.** The API, defaults and packaging rules can change between prereleases; there is no > production support guarantee. ## 1. Reference a package | Package | Use it when | | --- | --- | | `Purview.Containers` | The umbrella: `Core` + both backends. One reference, zero config — WSLC on Windows, Docker elsewhere (auto). **Recommended default.** | | `Purview.Containers.Wsl` | WSL Containers only — a Windows dev machine with no Docker. | | `Purview.Containers.Docker` | Docker only — any platform, CI, Linux/macOS. | Start with the umbrella: ```bash dotnet add package Purview.Containers ``` or pick a single backend: ```bash dotnet add package Purview.Containers.Wsl # WSLC only (Windows) dotnet add package Purview.Containers.Docker # Docker only (any platform) ``` A service module is backend-neutral, so it needs any backend (or the umbrella) alongside it: ```bash dotnet add package Purview.Containers.PostgreSql dotnet add package Purview.Containers # ...or Purview.Containers.Wsl / Purview.Containers.Docker ``` The umbrella is the zero-config shape — see [Using it in your tests](../using-in-your-tests/) for the full story. ## 2. Run a generic container The same code works on either backend — only the package reference from step 1 decides where it runs: ```csharp using Purview.Containers; using Purview.Containers.Waiting; await using var container = new ContainerBuilder() .WithImage("docker.io/library/redis:latest") .WithPortBinding(6379, assignRandomHostPort: true) .WithWaitStrategy(Wait.ForTcpPort(6379)) .Build(); await container.StartAsync(); ushort port = container.GetMappedPublicPort(6379); ``` `Build()` validates the accumulated configuration and resolves the backend when the container starts; `StartAsync()` creates the container, starts it, and only returns once every configured wait strategy is satisfied. `DisposeAsync()` stops and deletes the container (and never terminates a shared WSLC session). ## 3. Use a typed module ```csharp using Npgsql; using Purview.Containers.PostgreSql; await using var postgres = new PostgreSqlBuilder() .WithDatabase("tests") .WithUsername("postgres") .WithPassword("postgres") .Build(); await postgres.StartAsync(); // waits for pg_isready await using var connection = new NpgsqlConnection(postgres.GetConnectionString()); await connection.OpenAsync(); ``` Each module ships a bespoke README inside the package (`Purview.Containers.`) and a page in the [Modules](../modules/) reference. ## 4. Run the tests ```powershell just test # dotnet test, one test module at a time just test '/*/*/*/*[Category=Unit]' # unit tests only (no runtime required) ``` Integration tests need a **running backend**: WSL Containers or a Docker daemon, depending on which you selected. A WSLC session exclusively locks its image-store VHD, so test modules run serially by default — see [Testing](../testing/) for the details and how to run a subset. To see a container start end to end, run one of the samples in this repository: ```powershell just sample-wsl # needs WSL Containers just sample-docker # needs a Docker daemon ``` ## 5. Build and pack locally ```powershell just build # dotnet build of src/Containers.slnx (Debug) just pack # build + dotnet pack into ./artifacts just pipeline-pack-validate # shared pipeline: restore, build, lint, test, pack, validate ``` See [Contributing](../contributing/) for the full local workflow and [Release Flow](../release-flow/) for versioning and publishing. ## Next steps - [Architecture](../architecture/) — the session model, concurrency rules and cleanup decisions. - [Wait Strategies](../wait-strategies/) — started vs ready, and composing your own checks. - [Networking](../networking/) — port mapping behaviour under WSLC. - [Packaging](../packaging/) — what lands in each package. --- # Using it in your tests (auto) The zero-configuration shape: **one project, no backend choice** in your code or environment. The same tests run on **WSL Containers** on a Windows developer machine and on **Docker** on a Linux CI runner (or a Mac) — nothing changes between them. ## The project ```xml net10.0 ``` Three things make this work, and nothing else is required: - **A platform-neutral target framework** (`net10.0`). `Purview.Containers.Wsl` is multi-target; a `net10.0` project binds its portable facade, which runs the WSLC implementation on Windows and reports `wsl` unavailable everywhere else. (A Windows target framework still works — it binds the implementation directly — but it cannot run on a Linux CI runner.) - **The umbrella package** (`Purview.Containers`), which brings `Purview.Containers.Core` (the abstractions) and both backends. WSLC is preferred where it is usable and Docker is the fallback, so the one project runs everywhere. Prefer to be explicit? Reference `Purview.Containers.Core` plus `Purview.Containers.Wsl` and/or `Purview.Containers.Docker` individually — a Linux-only CI job can skip the WSL backend and its ~19 MB payload with `Core` + `.Docker` only. - **No registration line, no environment variable.** Each backend package ships `buildTransitive` assets that generate a module initializer in your assembly, so the process discovers the registered backends by itself. Those assets flow transitively through the umbrella. ## The test ```csharp using Npgsql; using Purview.Containers.PostgreSql; using Purview.Containers.Redis; using StackExchange.Redis; public class CacheAndDatabaseTests { [Test] public async Task PostgreSql_is_usable() { await using var postgres = new PostgreSqlBuilder().WithDatabase("app").Build(); await postgres.StartAsync(); // returns once pg_isready succeeds await using var connection = new NpgsqlConnection(postgres.GetConnectionString()); await connection.OpenAsync(); // ...your assertions against a real database... } [Test] public async Task Redis_is_usable() { await using var redis = new RedisBuilder().Build(); await redis.StartAsync(); // returns once redis-cli ping succeeds await using var cache = await ConnectionMultiplexer.ConnectAsync(redis.GetConnectionString()); await cache.GetDatabase().PingAsync(); // ...your assertions against a real cache... } } ``` That is the whole story: no image names, no ports, no `Testcontainers`, no `docker`, no `wslc`. The builder default image, the port mapping (a random host port), and the readiness check all come from the module; `GetConnectionString()` points at the mapped host port. ## What happens on each host | Host | Selected backend | Why | | --- | --- | --- | | Windows with WSL Containers | `wsl` | The facade loads the WSLC implementation and WSLC is preferred. | | Windows without WSL Containers, with Docker | `docker` | The facade reports `wsl` unavailable, so selection falls through. | | Linux / macOS (any CI runner) | `docker` | The facade only ever activates on Windows. | Automatic selection probes the registered backends in priority order (`wsl` is `0`, `docker` is `100`) and returns the first one that is both **available** and **compatible**. When none is usable, the exception lists each backend's availability, version and missing components — see [Backends: WSLC or Docker](../backends/). ## Pinning, and seeing what it picked Auto is the default and needs no configuration. Two things help while debugging: ```csharp // Which backends can this machine run, and what version? foreach (var backend in await ContainerBackends.ProbeAllAsync()) { Console.WriteLine($"{backend.Name}: usable={backend.IsUsable} version={backend.Version}"); } ``` ```bash # Fail loudly instead of falling back (the usual choice in CI). PURVIEW_CONTAINERS_BACKEND=docker # or: wsl ``` A named backend never silently falls back: if it cannot run, the test fails with that backend's own diagnostics. ## Requirements - The abstractions and every service module are portable `net10.0`; see [Consumer Requirements](../consumer-requirements/) for the exact target-framework contract, the `PCC0001`/`PCC0002` guards and the `EnableWindowsTargeting` workaround for non-Windows build agents. - The per-module connection-string shapes (Redis, PostgreSQL, SQL Server, MySQL, RabbitMQ, Azurite, NATS) are in [Modules](../modules/#connection-strings). - A live sample of this shape is `samples/getting-started/AutoSample` (`just sample-auto`). ## Related - [Getting Started](../getting-started/) — install and the first container. - [Backends: WSLC or Docker](../backends/) — the comparison, side-by-side setup and CI examples. - [Modules](../modules/) — the service modules and their connection strings. - [Consumer Requirements](../consumer-requirements/) — the target-framework contract and the guards. --- # Backends: WSL Containers or Docker `Purview.Containers` has **one API and two interchangeable backends**. The same test code, the same service modules (`Purview.Containers.PostgreSql`, `Redis`, `MsSql`, `RabbitMq`, `Azurite`, `Nats`, `MySql`), and the same connection-string accessors run on either runtime — you choose which one by the package you reference, and can override it in code or from the environment. - `**Purview.Containers**` — the umbrella: `Core` plus both backends, for one reference with automatic selection. - `**Purview.Containers.Core**` — the backend-neutral abstractions (the API you code against); usually arrives transitively. - `**Purview.Containers.Wsl**` — throwaway containers on **Microsoft WSL Containers (WSLC)**, the Windows runtime with no Docker installation. - `**Purview.Containers.Docker**` — throwaway containers on **any reachable Docker daemon**, driven by Testcontainers. ## Which package do I reference? | Package | Use it when | | --- | --- | | `Purview.Containers` | The umbrella: `Core` + both backends. One reference, zero config — WSLC on Windows, Docker elsewhere (auto). **Recommended default.** | | `Purview.Containers.Core` | The backend-neutral abstractions (the API). Reference it explicitly with one or both backends when you want a minimal dependency surface; otherwise it arrives transitively. | | `Purview.Containers.Wsl` | WSL Containers only — a Windows dev machine with no Docker. | | `Purview.Containers.Docker` | Docker only — any platform, CI, Linux/macOS. | Service modules (`Purview.Containers.PostgreSql`, `Redis`, …) are backend-neutral: add any backend package (or the umbrella) alongside one to run it. ## At a glance | | WSL Containers | Docker | | --- | --- | --- | | **Package** | `Purview.Containers.Wsl` | `Purview.Containers.Docker` | | **Prerequisite** | Windows 10/11 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 | | **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 | ## Image store location (WSLC) By default WSLC images are pulled once into a shared store under the local profile: ```text %LOCALAPPDATA%\Purview\WslContainers\images ``` To put the image store somewhere else — a different drive, a project-local cache, or a per-run folder — set the process-wide environment variable (works for every consumer shape, including a platform-neutral `net10.0` facade): ```powershell $env:PURVIEW_CONTAINERS_STORAGE_PATH = 'D:\wslc-images' # PowerShell ``` ```bash export PURVIEW_CONTAINERS_STORAGE_PATH=/mnt/d/wslc-images # bash / WSL / CI ``` Or configure the backend in code: ```csharp using Purview.Containers; using Purview.Containers.Wsl; ContainerBackends.Use(new WslContainerBackend(WslContainerRuntimeOptions.Default with { StoragePath = @"D:\wslc-images", StorageMode = StorageMode.Shared, })); ``` `StorageMode.Shared` (the default) keeps a stable, warm image store at `StoragePath` (or the default above when it is unset); `StorageMode.PerSession` gives each session a throwaway store under `%LOCALAPPDATA%\Purview\WslContainers\sessions\{name}`. The full `WslContainerRuntimeOptions` set (CPU, memory, GPU, session name, `StoragePath`, `StorageMode`, timeout) is honoured on both a platform-neutral `net10.0` consumer — where the facade forwards it to the Windows build at run time — and a Windows target framework. The pre-rename `WSL_CONTAINERS_STORAGE_PATH` is still honoured as a fallback. ## Switch between them without touching test code: ```bash # auto (the default) | wsl | docker | export PURVIEW_CONTAINERS_BACKEND=docker # bash / zsh / CI ``` ```powershell $env:PURVIEW_CONTAINERS_BACKEND = 'wsl' # PowerShell ``` ## Verify the host before you rely on it Each backend reports its own readiness, so a missing runtime is a clear message instead of a timeout deep inside a test. WSL Containers: ```powershell wsl --version # WSL itself wslc version # the WSLC runtime ``` ```csharp using Purview.Containers.Wsl; var info = await new WslContainerBackend().GetInfoAsync(); Console.WriteLine($"{info.Name}: usable={info.IsUsable} version={info.Version}"); if (!info.IsUsable) { Console.WriteLine(string.Join("; ", info.MissingComponents)); } ``` Docker: ```bash docker info ``` ```csharp using Purview.Containers.Docker; var info = await new DockerContainerBackend().GetInfoAsync(); Console.WriteLine($"{info.Name}: usable={info.IsUsable} version={info.Version}"); if (!info.IsUsable) { Console.WriteLine(string.Join("; ", info.MissingComponents)); } ``` `ContainerBackends.ProbeAllAsync()` reports every registered backend at once, which is the quickest way to answer "what can this machine run?": ```csharp foreach (var backend in await ContainerBackends.ProbeAllAsync()) { Console.WriteLine($"{backend.Name}: usable={backend.IsUsable} version={backend.Version}"); } ``` ## The same test, either backend The test body never names a backend, so it is identical on both runtimes: ```csharp using Purview.Containers; using Purview.Containers.Waiting; public class CacheTests { [Test] public async Task Cache_IsReachableOnItsMappedPort() { await using var container = new ContainerBuilder() .WithImage("redis:7") .WithPortBinding(6379, assignRandomHostPort: true) .WithWaitStrategy(Wait.ForTcpPort(6379)) .Build(); await container.StartAsync(); ushort port = container.GetMappedPublicPort(6379); await Assert.That(port).IsGreaterThan((ushort)0); } } ``` The **package reference** decides which runtime executes it. Three project shapes cover every case. ### Option 1 — WSLC only (Windows) ```bash dotnet add package Purview.Containers.Wsl ``` ```xml net10.0 enable enable ``` This project runs WSLC on a Windows host. A platform-neutral `net10.0` project binds the portable facade; a Windows target framework (`net10.0-windows10.0.19041.0`, x64 or arm64) binds the implementation directly. `WindowsSdkPackageVersion` is supplied by the package. A project older than .NET 10 fails the build with `PCC0001`, and a 32-bit Windows consumer with `PCC0002`; see [Consumer Requirements](../consumer-requirements/). > On a non-Windows host this project has **no usable backend** (the facade reports `wsl` unavailable). > To also run on Docker there, add `Purview.Containers.Docker` or use the umbrella — see Option 3. ### Option 2 — Docker only (any platform) ```bash dotnet add package Purview.Containers.Docker ``` ```xml net10.0 enable enable ``` That project restores and builds on Linux, macOS and Windows — no Windows target framework, no `WindowsSdkPackageVersion`, no Docker Desktop licence requirement beyond the daemon you already run. ### Option 3 — both backends, automatic selection (recommended) Add the umbrella `Purview.Containers`: it brings `Core` plus both backends, so `auto` selects WSLC on a machine that can run it and Docker otherwise — no multi-targeting and no conditional references: ```bash dotnet add package Purview.Containers ``` ```xml net10.0 enable enable ``` `auto` probes the registered backends in priority order (`wsl` before `docker`) and uses the first that is usable, so this one project runs on WSLC on a developer's Windows machine and on Docker in a Linux CI job. A Windows target framework is still supported when you want the implementation bound at compile time, but it is not required. Prefer the explicit form? Reference `Purview.Containers.Core` plus both backends instead of the umbrella: ```bash dotnet add package Purview.Containers.Core dotnet add package Purview.Containers.Wsl dotnet add package Purview.Containers.Docker ``` If your code needs a Windows-only API the portable facade does not expose (for example `WslContainer`, or `WslContainerBackend(runtime)` to pin a specific WSLC session), guard it with `#if WINDOWS` — defined by the SDK only for a Windows target framework — so a portable target still compiles: ```csharp #if WINDOWS using Purview.Containers.Wsl; #endif // ... #if WINDOWS var backend = new WslContainerBackend(runtimeForIsolation); #else var backend = new DockerContainerBackend(); #endif ``` ### Typed modules work the same way A module is backend-neutral, so only the backend package line changes: ```bash dotnet add package Purview.Containers.PostgreSql # the module dotnet add package Purview.Containers # ...or Purview.Containers.Wsl / Purview.Containers.Docker ``` ```csharp using Npgsql; using Purview.Containers.PostgreSql; await using var postgres = new PostgreSqlBuilder() .WithDatabase("tests") .WithUsername("postgres") .WithPassword("postgres") .Build(); await postgres.StartAsync(); // ready when pg_isready succeeds, on either backend await using var connection = new NpgsqlConnection(postgres.GetConnectionString()); await connection.OpenAsync(); ``` ## Choosing at run time Selection is resolved once per process, in this order: | # | Rule | How to set it | Behaviour | | --- | --- | --- | --- | | 1 | Pinned instance | `ContainerBackends.Use(new DockerContainerBackend())`, or `WithBackend(...)` on one builder | used as-is: never probed, never substituted | | 2 | Named backend | `PURVIEW_CONTAINERS_BACKEND=wsl\|docker\|`, or `ContainerBackends.Use(ContainerBackendSelection.Named("docker"))` | probed; a missing or unusable backend fails with its own diagnostics and **no fallback** | | 3 | Automatic detection | the default (`auto`) | every registered backend is probed in auto-priority order (`wsl` at 0, `docker` at 100); the first *available and compatible* one wins, so WSLC is preferred over Docker | ```csharp using Purview.Containers; // Automatic detection (the default)... var container = new ContainerBuilder().WithImage("alpine:3.19").Build(); // ...or pin it for this builder only. var pinned = new ContainerBuilder() .WithBackend(new DockerContainerBackend()) .WithImage("alpine:3.19") .Build(); // ...or pin it for the process. ContainerBackends.Use(ContainerBackendSelection.Named("docker")); ``` `Build()` never needs a backend: it validates the configuration and returns a container that resolves the backend when it starts. That is what lets the same test suite run on whichever runtime the machine has. To fail loudly instead of falling back — the usual choice in CI — name the backend: ``` PURVIEW_CONTAINERS_BACKEND=docker # if Docker is not reachable, the test fails and says why ``` ## In CI A hosted Linux runner already has a Docker daemon, so nothing has to be started alongside your tests — reference `Purview.Containers.Docker` in a `net10.0` test project and run `dotnet test`: ```yaml name: tests on: [push, pull_request] jobs: integration-linux: name: Integration tests (Docker) runs-on: ubuntu-latest env: # Optional. "auto" (the default) also selects Docker when WSL Containers is absent. PURVIEW_CONTAINERS_BACKEND: docker steps: - uses: actions/checkout@v4 - uses: actions/setup-dotnet@v4 with: dotnet-version: 10.0.x - run: dotnet restore - run: dotnet build --configuration Release --no-restore - run: dotnet test --configuration Release --no-build ``` - On a Linux job, target a platform-neutral framework (`net10.0` or later). The WSL Containers package is portable — its `net10.0` facade loads the WSLC implementation on a Windows host and reports `wsl` as unavailable elsewhere — so one test project can reference both backends and `auto` selects WSLC on a Windows developer machine and Docker on the Linux runner. - Keep your integration tests in their own project or category if you want the fast unit tests to stay runtime-free; both can run in the same job. - Pinning `PURVIEW_CONTAINERS_BACKEND=docker` makes a runner without a usable daemon **fail loudly** with the probe report instead of quietly selecting something else. For a **Windows** job that should exercise WSLC, run the same code against `Purview.Containers.Wsl`: ```yaml integration-windows: name: Integration tests (WSL Containers) runs-on: windows-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-dotnet@v4 with: dotnet-version: 11.0.x - run: dotnet test --configuration Release ``` > WSL Containers is a Windows developer runtime: hosted GitHub runners do not guarantee a WSL Containers > installation, so the practical options are a **self-hosted Windows runner**, or running WSLC suites > locally and Docker suites in CI. Either way the test code is the same. ## What differs in practice | Capability | WSL Containers | Docker | | --- | --- | --- | | Random host ports | ✅ native (`windowsPort=0`) | ✅ assigned by the daemon | | Fixed host ports | ✅ | ✅ | | IPv6 host mapping | ❌ IPv4 loopback only | ✅ | | UDP port mappings | ❌ `ContainerNotSupportedException` | ✅ | | Bind mounts of host directories | ✅ | ✅ | | 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 | | 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 | Unsupported options throw `ContainerNotSupportedException` rather than being ignored, so a difference between backends never turns into a silently weaker test. ## Troubleshooting **`No container backend is registered.`** — no backend package is referenced by the test project (and the assembly that would register it was never loaded). Add one: `dotnet add package Purview.Containers.Docker` or `dotnet add package Purview.Containers.Wsl`. **`No usable container backend was found (selection: auto).`** — every registered backend failed its probe; the report names each one and why: ```text No usable container backend was found (selection: auto). wsl: unavailable (Sdk; SdkNeedsUpdate) docker: unavailable (HttpRequestException: Connection refused) Install or fix a backend, or set PURVIEW_CONTAINERS_BACKEND to one of: wsl, docker. ``` Fix the runtime it names (for WSLC: `wsl --install --no-distribution`; for Docker: start the daemon, or point `DOCKER_HOST` at it), or pin the backend that does work. **`The selected container backend 'docker' is not usable: …`** — you named a backend that cannot run here. This is intentional: a named backend never falls back to another one, so a CI job cannot pass by silently using the wrong runtime. **`The container backend 'docker' is not registered. Registered backends: wsl.`** — the selection names a backend whose package is not referenced by the project. **`PCC0001` / `PCC0002`** — a build-time guard from the WSL Containers backend package: the consuming project is neither .NET 10+ (Windows or platform-neutral), or is a 32-bit Windows consumer. See [Consumer Requirements](../consumer-requirements/), or switch the project to the Docker backend. **The same image is pulled twice** — expected when both runtimes are used: WSLC and Docker keep separate image stores. ## Related - [Getting Started](../getting-started/) — install, first container, first typed module. - [Consumer Requirements](../consumer-requirements/) — the target-framework contract, the guards and the workarounds. - [Architecture](../architecture/#backend-selection) — how resolution and registration work internally. - [Modules](../modules/) — the service modules and their readiness strategies. --- # Consumer Requirements > **Experimental.** `Purview.Containers.*` is an experimental project: the public API, the > defaults and these requirements can change between prereleases, and there is no production > support guarantee. Treat every version as a preview and pin the exact package version you build > against. The WSL Containers backend package is **multi-target**. It ships a Windows build (`net10.0-windows10.0.19041.0`, the implementation, built on the `Microsoft.WSL.Containers` projection) and a platform-neutral build (`net10.0`, a facade). A Windows-targeting project binds the implementation directly; any other project binds the facade, which builds on **every** platform and, on a Windows host, loads the implementation at run time. That is what lets a `net10.0` test project that references the WSL backend (or the umbrella `Purview.Containers`, which also brings Docker) run on WSLC on a developer's Windows machine and on Docker in a Linux CI job with no configuration change. This page is the authoritative statement of that contract, of the workarounds that exist for it, and of how each of them is verified. ## Which package requires what | 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. | | `Purview.Containers.Core` | `net10.0`, any platform | Backend-neutral abstractions (namespace `Purview.Containers`). | | `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. | 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 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/). ## Copy this into your project Most projects need **nothing at all**: a `net10.0` project binds the facade and gets automatic backend selection. A Windows-targeting project can be explicit: ```xml net10.0-windows10.0.19041.0 x64 10.0.26100.80 ``` The last two lines are optional for most projects: the packages ship MSBuild defaults that supply them (see [What the packages do for you](https://github.com/purview-dev/containers/blob/main/docs/wiki#what-the-packages-do-for-you)). They are shown here because being explicit makes the requirements visible to the next person who reads your project file, and because the defaults only fill in a value when you have not chosen one. ## The requirements in detail | # | Requirement | Why it exists | | --- | --- | --- | | 1 | **.NET 10 or later** | `Purview.Containers.Wsl` ships `lib/net10.0/` (facade) and `lib/net10.0-windows10.0.19041/` (implementation). `Purview.Containers` and the service modules ship `lib/net10.0/` too. | | 2 | **A Windows-specific target framework that names the OS version** (`net10.0-windows10.0.19041.0` or later) — *only when you want the implementation bound directly; a platform-neutral `net10.0` project binds the facade and still runs WSLC on a Windows host* | The `Microsoft.WSL.Containers` projection only ships Windows assets. `net10.0-windows` on its own is not enough. | | 3 | **64-bit** (`PlatformTarget` `x64`/`arm64`, or a matching `RuntimeIdentifier`) | The native `wslcsdk.dll` ships for `win-x64` and `win-arm64` only. | | 4 | **`WindowsSdkPackageVersion` at least `10.0.26100.80`** | The projection is compiled against `Microsoft.Windows.SDK.NET` `10.0.26100.79`; 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 containers | The library drives WSLC; it never installs or updates WSL for you. | ### 1. .NET 10 or later `Purview.Containers.Wsl` ships a `net10.0` facade and a `net10.0-windows10.0.19041.0` implementation, so it restores on any .NET 10+ project. The repository's own SDK is .NET 11 (`global.json` pins `11.0.100-rc.1.26425.128`), but that is a build-time detail, not a consumer requirement. ### 2. A Windows-specific target framework (optional) The **recommended** choice is a platform-neutral target framework. It binds the portable facade and gives you the automatic "WSLC on a Windows machine, Docker everywhere else" behaviour: ```xml net10.0 ``` A Windows target framework binds the implementation directly (no run-time load): ```xml net10.0-windows10.0.19041.0 ``` ```xml net10.0-windows ``` `net10.0-windows` resolves its platform version to `7.0`, which is older than the `10.0.19041.0` the implementation was built against, so NuGet selects no compile assets for it. Always spell out the version. (The `Microsoft.WSL.Containers` package itself is `net8.0-windows10.0.19041.0`, which a `net10.0-windows10.0.19041.0` project consumes without issue.) ### 3. 64-bit consumers only The native WSL Containers SDK is shipped for `win-x64` and `win-arm64` only. A 32-bit (`x86`) consumer builds but cannot load `wslcsdk.dll` at runtime, so the packages fail the build instead: ```text error PCC0002: Purview.Containers.Wsl requires a 64-bit (x64 or arm64) consumer ... ``` ### 4. Windows SDK targeting pack version `Microsoft.WSL.Containers` 3.0.1's projection references `Microsoft.Windows.SDK.NET` `10.0.26100.79`. Targeting a Windows target framework (`net10.0-windows10.0.19041.0`) resolves a lower pack (`10.0.19041.x`) by default and the compiler rejects the mismatch: ```text error CS1705: Assembly 'wslcsdkcs' ... uses 'Microsoft.Windows.SDK.NET' which has a higher version than referenced assembly 'Microsoft.Windows.SDK.NET' ``` Pin `10.0.26100.80` (the nearest published version) as shown above. The packages set this default for you, so you only need it if you want to override the value deliberately. ### 5. Host prerequisites The build requirements above are host-independent. Running containers needs a Windows 10/11 host with WSL Containers installed (`wsl --install --no-distribution`) — see [Getting Started](../getting-started/). ## What the packages do for you `Purview.Containers.Wsl` ships two `buildTransitive` MSBuild files. NuGet imports them for **direct and transitive references**, so a project that references the backend package directly — or through another project that does — gets them automatically. > A **service module** (`Purview.Containers.PostgreSql`, `Redis`, `MsSql`, `RabbitMq`, `Azurite`, `Nats`, > `MySql`) is backend-neutral and does **not** bring a backend, so reference the module **and** > `Purview.Containers.Wsl` (or another backend package) to run it. Use `Purview.Containers.Docker` to run > the same module on Docker, and `PURVIEW_CONTAINERS_BACKEND` to choose between them. | Package path | Effect | | --- | --- | | `buildTransitive/Purview.Containers.Wsl.props` | Defaults `WindowsSdkPackageVersion` to `10.0.26100.80` when the consumer has not set it, and registers the `wsl` backend. | | `buildTransitive/Purview.Containers.Wsl.targets` | For a **Windows** consumer: defaults `PlatformTarget` to `x64` when it is unset (or `AnyCPU`) and no `RuntimeIdentifier` is selected, and fails the build with `PCC0001`/`PCC0002` when the target framework or the platform cannot be supported. For a **platform-neutral** consumer: copies the Windows implementation payload (the implementation, the WSLC projection, its Windows SDK dependencies and the native SDK) into `wslc/` next to the output on a Windows build host, so the facade can load it at run time. | A consumer therefore only has to choose a target framework — and the portable one is best, because it runs on WSLC locally and Docker in CI with no further configuration: ```xml net10.0 ``` An explicit `PlatformTarget`, `RuntimeIdentifier` or `WindowsSdkPackageVersion` in the consumer always wins over these defaults. > Because `buildTransitive` assets are framework-agnostic, NuGet no longer reports `NU1202` for an > incompatible target framework: restore succeeds and the failure comes from the guard targets > instead. That is deliberate — `PCC0001` names the required target framework, whereas an empty > compile-asset set only produces `CS0246` errors in your own code. ## Error reference | Error | Raised by | Meaning | Fix | | --- | --- | --- | --- | | `PCC0001` | the shipped guard target | The consumer's target framework is neither .NET 10+ on Windows 10.0.19041.0+ nor a platform-neutral .NET 10+ framework | Retarget as above. | | `PCC0002` | the shipped guard target | The consumer is not 64-bit | Remove your `PlatformTarget`, set it to `x64`/`arm64`, or choose a matching `RuntimeIdentifier`. | | `CS1705` | the C# compiler | `WindowsSdkPackageVersion` is older than the projection requires | Set `WindowsSdkPackageVersion` to `10.0.26100.80` or later. | | `CS8012` | the C# compiler | A referenced assembly targets a different processor (seen when a consumer forces `x86`) | Use a 64-bit consumer. | | `NETSDK1100` | the .NET SDK | A Windows-targeted project is being built on a non-Windows operating system | Set `EnableWindowsTargeting` (see below). | | `CS0234`/`CS0246` for `Microsoft.WSL`/`Purview` types | the C# compiler | The package contributed no compile assets (an unsupported target framework, or an unverified escape hatch) | Fix the target framework; `PCC0001` explains the same problem with a clearer message. | ## Workaround: building on a non-Windows CI agent `net*-windows…` projects can be restored, compiled and unit-tested on Linux or macOS, provided the build opts in: ```xml true ``` Without it the SDK fails with `NETSDK1100` (*"To build a project targeting Windows on this operating system, set the EnableWindowsTargeting property to true"*). This is exactly how this repository's own CI works: the shared `purview-dev/build` workflow runs on **`ubuntu-latest`**, so `src/Directory.Build.props` sets `EnableWindowsTargeting=true` and the pipeline is filtered to `[Category=Unit]`. See [Testing](../testing/). What it does and does not do: - ✅ restores the Windows targeting packs and compiles `net*-windows` assemblies; - ✅ runs unit tests that never touch the WSLC runtime; - ❌ does not provide WSL Containers — the WSLC integration suites need a Windows host; - ❌ does not make `wslcsdk.dll` loadable; anything that opens a session must run on Windows. ## Workaround: consuming from a project older than .NET 10 **There is none.** A project that targets Windows but not .NET 10 (for example `net8.0-windows10.0.19041.0`) cannot use these packages, and the repository verifies that no escape hatch exists. The historical suggestion is `AssetTargetFallback`, which tells NuGet to consider an additional target framework for the project's package references: ```xml net8.0-windows10.0.19041.0 net10.0-windows10.0.19041.0 ``` It does **not** work here. `AssetTargetFallback` is not applied to `netcoreapp`-family packages, so the package still contributes no compile assets and the build fails in your own code with `CS0234`/`CS0246`. `PCC0001` fires as well. Even if it did compile, it would not help: the `lib/` assemblies target .NET 10, so they can only be loaded by a .NET 10+ runtime. A consumer would have to move to .NET 10 to run them anyway. If you need this library from a project you cannot retarget, keep the container work in a small **.NET 10+ test project** (which can reference the older project) rather than trying to consume the packages from the older project. ## Multi-targeting Multi-targeting is **no longer required** for the WSL Containers backend: a platform-neutral `net10.0` target binds the portable facade. If you multi-target for other reasons, reference the package unconditionally — both inner builds are supported: ```xml net10.0;net10.0-windows10.0.19041.0 ``` The Windows inner build binds the implementation; the portable inner build binds the facade. The historical pattern — multi-targeting with the reference only on the Windows inner build — still works, because the package keeps a `net10.0-windows10.0.19041` build, but the condition is no longer needed. ## Verifying these requirements `just verify-consumers` packs the solution and builds twenty-one throwaway consumer projects against the produced packages, asserting every claim on this page: | Case | Consumer | Expected outcome | | --- | --- | --- | | 01 | `.NET 11` + Windows with the documented settings | builds; `wslcsdk.dll` copied to the output | | 02 | …with a stale `WindowsSdkPackageVersion` | `CS1705` | | 03 | …with `PlatformTarget=x86` | `PCC0002` | | 04 | …with no settings at all | builds via the shipped `buildTransitive` defaults | | 05 | a module package plus the WSL Containers backend, with no settings | builds; `wslcsdk.dll` copied (the defaults are transitive through the backend) | | 06 | `net8.0-windows` + `AssetTargetFallback` | `PCC0001` — the escape hatch does not work | | 07 | `net8.0-windows` | `PCC0001` | | 08 | plain `net11.0` (platform-neutral facade) | builds; the `wsl` registration is generated | | 09 | …with `PlatformTarget=AnyCPU` | builds (corrected to `x64`) | | 10 | the `net11.0-windows` shorthand (no OS version) | `PCC0001` | | 11 | multi-targeting with a conditional `PackageReference` | builds | | 12 | multi-targeting with an unconditional `PackageReference` | builds (both inner builds are supported) | | 13 | a `net10.0` consumer of the portable abstractions | builds | | 14 | a `net10.0` consumer of the Docker backend | builds; the backend registration is generated into the consumer | | 15 | a `net10.0` consumer of a service module, with no backend package | builds | | 16 | a `.NET 11` Windows consumer with **both** backends referenced | builds; both registrations are generated | | 17 | the documented backend example on WSL Containers | builds | | 18 | the documented backend example on Docker (`net10.0`) | builds | | 19 | a plain `net10.0` consumer of the WSL Containers backend | builds; the `wsl` registration is generated (the portable facade) | | 20 | a `net10.0` consumer of two modules with **both** backends (the auto shape) | builds; both registrations are generated | | 21 | a `net10.0` consumer of modules with only the umbrella `Purview.Containers` | builds; both registrations are generated (one reference) | The script is `scripts/verify-consumers.ps1` and it only writes to the temp folder. It needs network access (it restores transitive dependencies from nuget.org), so it is a local/CI-explicit step rather than part of the `[Category=Unit]` filter. ## Related - [Getting Started](../getting-started/) — prerequisites and the first container. - [Packaging](../packaging/) — what each package contains, including the `buildTransitive` assets. - [Testing](../testing/) — the Linux CI run, the unit filter and the serial WSLC rule. - [Modules](../modules/) — the service modules that inherit these defaults. --- # Architecture Design for a Testcontainers-style .NET library with pluggable container backends: `Purview.Containers`. ## Core model The library is split into a backend-neutral abstraction assembly and one assembly per backend: ``` Purview.Containers (net10.0) umbrella: references Core, Wsl and Docker └─ Purview.Containers.Core (net10.0, portable) the backend-neutral abstractions ├─ IContainer / IContainerConfiguration / ContainerConfiguration the container contract ├─ ContainerBuilder fluent configuration + validation ├─ ContainerBase typed module container (delegates to the backend) ├─ ContainerBackends + IContainerBackend backend registry and selection ├─ Waiting / Images / Mounts / Networking / Diagnostics readiness, model, secrets ├─ IConnectionStringProvider / ConnectionMode / ContainerConnectionStringProvider connection strings └─ Runtime/ContainerException neutral error taxonomy Purview.Containers.Wsl (net10.0 facade + net10.0-windows10.0.19041.0 implementation) ├─ WslContainerBackend : IContainerBackend registers itself as "wsl" ├─ WslContainerRuntime : IContainerRuntime process singleton, owns the shared session │ ├─ SessionSettings (name, storagePath, cpu/mem, gpu, timeout) │ ├─ SemaphoreSlim -> serialises session-mutating and container-lifecycle ops │ ├─ ImageCatalog -> pull policies + keyed dedup of concurrent pulls │ ├─ 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 IContainer : IConnectionStringProvider, IAsyncDisposable StartAsync / StopAsync / DisposeAsync / ExecAsync / GetMappedPublicPort / GetConnectionString / GetLogsAsync / tailing IAsyncEnumerable ``` 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 umbrella package: it carries no code of its own and simply references `Core` plus both backends, so one reference gives a consumer the whole "auto" setup. Microsoft types (`Session`, `Container`, `Process`, `ContainerSettings`, …) are **runtime implementation details** kept behind the public interfaces. They are not exposed through the public API surface (an opt-in accessor is the only escape hatch). ## Backend selection `ContainerBackends` is the process-wide registry; `ResolveAsync()` chooses the backend with this precedence: 1. **Pinned instance** — `ContainerBackends.Use(new WslContainerBackend())`, or `WithBackend(...)` on a single builder. Used as-is: never probed, never substituted. 2. **Named backend** — `PURVIEW_CONTAINERS_BACKEND=wsl|docker|` (or `Use(ContainerBackendSelection.Named(...))`). The named backend is probed: an unknown name lists what is registered, and an unusable one fails with its own diagnostics. There is no fallback. 3. **Automatic detection** — every registered backend is probed in **auto-priority** order and the first *available and compatible* one wins. A backend positions itself with `IContainerBackendPreference` (lower `AutoPriority` first; a backend without it counts as 0, so registration order is preserved for ties). The WSL Containers backend declares a lower priority than Docker, so a machine that can run both prefers WSLC. When none is usable the exception lists each backend's availability, version and missing components, plus the `PURVIEW_CONTAINERS_BACKEND` values that would work. Resolution is cached per process, so probes run once; `Reset()` (tests) and `Register`/`Use` invalidate the cache. Package consumers get registration from the generated module initializer in `Purview.Containers.Core.targets`; project-reference consumers register explicitly with `Register(...)`. Consumer-facing guidance (including the CI example) is in [Backends: WSLC or Docker](../backends/). ## Session lifetime model **One shared, process-wide session.** Findings that drove this: - Session start is cheap (~20-30 ms) because the WSL VM is shared under the session manager. - The image store is keyed by the **storage path**: a stable storage path gives a warm image cache across sessions and process restarts. - Per-container sessions would force a fresh storage path per container → a cold image store (re-pull) per container, which is the dominant cost (alpine ~2-3 s, python ~3-4 s). Not acceptable for test throughput. - `Session`/`Container` lifecycle calls are **not reliably thread-safe**: a concurrent `Container.Start()` occasionally fails with `0x8000FFFF` (E_UNEXPECTED). Container isolation is achieved by unique container names + unique host ports, not separate sessions. Design rules: 1. **One lazily-started session per process** with a deterministic name `wslc-{processId}-{8 hex}` (unique per machine; session names are reserved until the session is disposed) and a **stable storage path** (default `%LOCALAPPDATA%\Purview\WslContainers\images`), configurable via `WslContainerRuntimeOptions.StoragePath` or `PURVIEW_CONTAINERS_STORAGE_PATH`. 2. The session VM is capped at **4096 MB by default** (`WslContainerRuntimeOptions.Default`). This is required for SQL Server (which refuses to start below 2000 MB — `sqlservr: This program requires a machine with at least 2000 megabytes of memory`) and harmless for lighter containers. Override via `WslContainerRuntimeOptions.MemorySizeInMB`. 2. `IContainerBackend` is the seam for backends: a backend package (starting with `Purview.Containers.Wsl`) supplies `IContainer` instances, and `ContainerBackends` resolves which one runs. `IContainerRuntime` remains the WSLC-internal seam so advanced users/tests can substitute a per-container-session runtime for isolation experiments. 3. Container `DisposeAsync` never terminates the shared session; it deletes the container only. 4. The runtime registers a **process-exit handler** and `IAsyncDisposable` to `Terminate()`+`Dispose()` the session at shutdown. ## Session naming and storage - Name: `wslc-{pid}-{random8}`. Never place secrets/credentials in names, paths, or logs. - **Storage is shared by default** (`StorageMode.Shared`): all sessions use `%LOCALAPPDATA%\Purview\WslContainers\images`, so the image store is pulled once and reused across process runs. Set `StorageMode.PerSession` (or an explicit `StoragePath` / `PURVIEW_CONTAINERS_STORAGE_PATH`) for isolation; in code, configure the backend with `new WslContainerBackend(new WslContainerRuntimeOptions { StoragePath = … })`. Session names stay unique per process; only the path is shared. - **Concurrent sharing is not possible**: a running session exclusively locks its `storage.vhdx` (a second session on the same path fails with `0x80070020`). The lock is taken lazily on the first store access, so contention can surface on `GetImages()` rather than at session start; the runtime detects it there too and falls back to an isolated per-process store. Sequential reuse works: after a session ends, a new session on the same path sees its images. - Cleanup: normal shutdown terminates+disposes the session (frees the name). Orphaned sessions from crashed processes block only their own name; they do not block other sessions or storage-path reuse. ## Image management - `PullPolicy { Missing, Always, Never }`, default `Missing`. - `EnsureImageAsync`: consult `GetImages()` (session store) → pull when missing. - Concurrent pulls of the same image are **deduplicated** by a keyed async lock (WSLC does not dedupe). - Registry auth via `Session.Authenticate(uri, user, pass)` → `AuthenticateResult.IdentityToken` → `PullImageOptions.RegistryAuth`. Credentials live in a `Secret` type; never logged or serialized. ## Ports - `PortBinding(containerPort, hostPort?, protocol, hostAddress?)`. - **Random host port = native `windowsPort=0`** (race-free). The assigned port is read from `Inspect().Ports`. - Explicit host ports are bound by WSLC at `Start`; a conflict throws `0x80072740` (surface as a clear `WslContainerPortInUseException`). - Default host bind is IPv4 loopback only; IPv6 is not mapped. - UDP → `ContainerNotSupportedException`. ## Concurrency - All session-mutating operations (`Pull`, `CreateContainer`, `Start`, `Stop`, `Delete`, `Tag`, `DeleteImage`, VHD ops) go through a per-session `SemaphoreSlim`. This avoids the racy `0x8000FFFF` observed with concurrent `Start`. - `GetImages()` reads (and locks) `storage.vhdx`, so it is **not** side-effect-free and goes through the same gate. Container-handle reads (`Inspect`) are allowed concurrently once the container is running; `Exec` also funnels through the lock because it creates a process on the container. - Bounded per-container output buffers prevent runaway memory. ### Shared-store contention (cross-process) `storage.vhdx` is opened lazily on the **first store access**, not at session start, so a second process on the same path can start its session successfully and only fail later on its first read (`GetImages()`) with `0x80070020`. The runtime therefore verifies the store once, under a gate, on the first `GetSessionAsync`; when a concurrent process holds the default shared store, that session is discarded and the runtime transparently switches to an isolated per-process store. Explicit `StoragePath`/`PURVIEW_CONTAINERS_STORAGE_PATH`/`StorageMode.PerSession` configuration (or `new WslContainerBackend(new WslContainerRuntimeOptions { StoragePath = … })`) opts out of the fallback. Isolated stores are transient and are removed when their session terminates. ## Cleanup & reaper decision **No Ryuk-style sidecar process is needed.** - `Session.Dispose()` removes the session from the manager and frees its name. - The library registers a process-exit handler so the session is always disposed on normal termination. - Crash residue: an orphaned session from a dead process remains registered with a dead `Creator PID`. It reserves only its own name. It can be swept with `wslc --session system session terminate`; the library documents this as an optional maintenance step and may offer a dev-time sweep utility (CLI-based, clearly isolated from the core runtime). - `Container.DisposeAsync` is idempotent: stop (SIGTERM→SIGKILL) then `Delete(Force)`; swallow `RPC_E_DISCONNECTED`/`ContainerNotFound` on double-delete. ## 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. - `Microsoft.Extensions.Logging` integration is optional/future; the core works without a host or DI. ## Extensibility - Public surface deliberately small; WSLC types internal. - One opt-in accessor (`IWslContainerAccessor`) exposes `Inspect()` / raw handle for advanced users. - Third-party modules subclass the generic builder (see [Modules](../modules/)). --- # Lifecycle Container lifecycle semantics for `Purview.Containers`. ## Public API ```csharp public interface IContainer : IAsyncDisposable { Task StartAsync(CancellationToken ct = default); Task StopAsync(CancellationToken ct = default); ValueTask DisposeAsync(); // restart is intentionally NOT offered (WSLC has no restart; stop+start is not equivalent) } ``` ## State model 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. | | `DeleteAsync` | `Container.Delete(Force)`. Double-delete throws `0x80010108` (`RPC_E_DISCONNECTED`) → swallowed. | | `DisposeAsync` | Stop (if running) → Delete → release the WinRT container object. **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`. - `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. - 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`. ## Secrets & names - 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 ``). --- # 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. `GetNetworkIp()` reads `Inspect().NetworkSettings.Networks.bridge.IPAddress` (Bridged only). 4. TCP/HTTP wait strategies connect to `127.0.0.1:` (IPv4 only). 5. **Multi-container environments are deliberately deferred.** Because there is no native DNS/alias, a future `ContainerEnvironmentBuilder` would wire containers together by passing each other's IPs (from `GetNetworkIp()`) into configuration/env. The core API must not preclude this (it doesn't — `GetNetworkIp` + environment are enough). ## 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. --- # Wait strategies Composable readiness waits. **A started WSLC container is not necessarily a ready service** — `Container.Start()` returns once the init process is running; readiness is checked separately. > **Status: implemented.** Verified by integration tests (`tests/Wsl.IntegrationTests/WaitStrategyTests.cs`). ## Model ``` IWaitStrategy TimeSpan? Timeout // null → container StartupTimeout TimeSpan Interval // default 250 ms int? Retries // optional max failed checks Task UntilAsync(WaitContext context, CancellationToken ct) WaitContext { IContainer Container; string? NetworkIp; int? GetHostPort(ushort); int? FirstHostPort; } Wait (factory) ForContainerRunning() ForTcpPort(ushort containerPort, TimeSpan? connectTimeout = null) ForHttp(string path = "/") → HttpWaitStrategy { ForPort, ForScheme, ForStatusCode, ForStatusPredicate, ForHeader, AllowInsecureTls } ForLogMessage(string) | ForLogMessage(Regex) ForCommand(params string[]) → CommandWaitStrategy { ForExitCode } ForCustom(Func>) ForAll(params IWaitStrategy[]) | ForAny(params IWaitStrategy[]) 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). ## Strategies ### Container running `ForContainerRunning()` — checks `Container.State == Running`. This only means the init process started; it is NOT a service-readiness check. ### TCP `ForTcpPort(8080)` — connects to `127.0.0.1:`. Refused/reset/timeout ⇒ not ready. IPv4 only (WSLC maps IPv4 loopback only). ### HTTP ```csharp Wait.ForHttp("/health") .ForPort(8080) .ForStatusCode(HttpStatusCode.OK) ``` - Scheme default `http`; `ForScheme("https")` supported. - TLS certificate validation is **ON by default**; `AllowInsecureTls()` disables it for **this request handler only** (never global). ### Log `ForLogMessage("database system is ready")` matches (case-insensitive substring) or regex against the **accumulated** init-process output. The container buffers output from process start, so messages emitted before the waiter's first check still match. ### Command/exec `ForCommand("pg_isready", "-U", "postgres")` — executes the command via `ExecAsync`; ready when exit code equals the expected code (default 0, configurable via `.ForExitCode(...)`). Transient exec failures during startup are treated as not-ready. ### Custom / composition `ForCustom((context, ct) => ...)` for arbitrary checks. `ForAll(...)` requires every strategy ready on the same check; `ForAny(...)` requires at least one. ## Started vs ready - `ForContainerRunning()` = started. - TCP/HTTP/log/command = service readiness. - Modules choose their own readiness check (PostgreSQL `pg_isready`, Redis `redis-cli ping`, RabbitMQ `rabbitmq-diagnostics ping`, SQL Server host-side connection). --- # Connection strings Every container implements `IConnectionStringProvider`, so you can ask any container for its connection string without knowing the module type: ```csharp IContainer container = new PostgreSqlBuilder().Build(); await container.StartAsync(); string connectionString = container.GetConnectionString(); // ConnectionMode.Host string same = container.GetConnectionString(ConnectionMode.Host); // explicit ``` ## Connection modes `ConnectionMode` describes how the connection string targets the container: | Mode | Meaning | Support | | --- | --- | --- | | `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`. Neither backend supports container-to-container networking today: WSLC has no managed networks or inter-container DNS, and the Docker backend does not attach containers to a shared network. Multi-container wiring is deliberately deferred (see [Networking](../networking/)). ## How it works A module's builder registers a connection string provider that delegates to the module's own `GetConnectionString()`: ```csharp sealed class PostgreSqlConnectionStringProvider : ContainerConnectionStringProvider { protected override string GetHostConnectionString() => Container.GetConnectionString(); } ``` `ContainerConnectionStringProvider` is the base class: `Configure` runs once, after the container has started (so runtime-assigned ports are available), and dispatches `GetConnectionString(ConnectionMode)` to `GetHostConnectionString()` / `GetContainerConnectionString()`. The named overload (`GetConnectionString(name, mode)`) exists for modules with several endpoints; the base throws `ConnectionStringNameNotSupportedException` unless a provider overrides it. ## Custom provider Override the connection string a container exposes with `WithConnectionStringProvider`: ```csharp sealed class ReadOnlyPostgreSql : ContainerConnectionStringProvider { protected override string GetHostConnectionString() => $"{Container.GetConnectionString()};ApplicationName=readonly"; } var postgres = new PostgreSqlBuilder() .WithConnectionStringProvider(new ReadOnlyPostgreSql()) .Build(); ``` Providers that produce an empty connection string throw `ConnectionStringNotAvailableException`, and a provider used before `Configure` throws `ConnectionStringProviderNotConfiguredException`. ## Module reference See [Modules](../modules/) for each module's default connection string and any extra endpoint accessors. --- # Modules Module architecture for `Purview.Containers`. ## Principle Modules are thin packages layered on the backend-neutral abstractions ([`Purview.Containers.Core`](../architecture/)). A module supplies only: - default image - default ports - default environment variables - module-specific configuration (`WithXxx`) - module-specific wait strategy - connection string / endpoint generation - module-specific convenience APIs Modules must **not** duplicate container runtime infrastructure, and they must never reference a backend package (`Purview.Containers.Wsl`, …): the container base resolves the backend through `ContainerBackends.ResolveAsync()`, which is what lets the same module package run on WSLC or Docker. Every module targets `net10.0` and is portable. ## Builder model A generic CRTP base with immutable built configurations: ```csharp public abstract class ContainerBuilder where TBuilder : ContainerBuilder where TContainer : IContainer where TConfiguration : ContainerConfiguration, new() { public TBuilder WithImage(string image) { /* accumulate */ return (TBuilder)this; } // ... every fluent method returns (TBuilder)this public virtual TContainer Build() { TConfiguration configuration = BuildConfiguration(); // immutable snapshot Validate(configuration); return CreateContainer(configuration); } protected virtual TConfiguration BuildConfiguration(); // override to add module fields protected virtual void Validate(ContainerConfiguration); // override to add module validation protected abstract TContainer CreateContainer(TConfiguration configuration); } ``` - The builder accumulates mutable working state; `Build()` snapshots it into an **immutable `ContainerConfiguration` record**. No mutable state leaks between builders. - Modules override `BuildConfiguration()` to set module fields via `with` on the base snapshot, and override `CreateContainer(...)` to construct their container type. - Configuration `ToString()` redacts sensitive environment values and `Secret`-typed fields (see `Diagnostics/SecretRedactor.cs`). ## Reference module (PostgreSQL) ```csharp public sealed class PostgreSqlBuilder : ContainerBuilder { public const ushort PostgreSqlPort = 5432; public const string PostgreSqlImage = "postgres:17"; public PostgreSqlBuilder() : this(PostgreSqlImage) { } public PostgreSqlBuilder(string image) { WithImage(image).WithPortBinding(PostgreSqlPort, assignRandomHostPort: true); } public PostgreSqlBuilder WithDatabase(string database) { this.database = database; WithEnvironment("POSTGRES_DB", database); return this; } protected override PostgreSqlConfiguration BuildConfiguration() { PostgreSqlConfiguration configuration = base.BuildConfiguration(); return configuration with { Database = database, Username = username, Password = password, // default readiness unless the caller supplied their own strategies WaitStrategies = configuration.WaitStrategies.Count > 0 ? configuration.WaitStrategies : new[] { (IWaitStrategy)Wait.ForCommand("pg_isready", "-U", username, "-d", database) }, }; } protected override PostgreSqlContainer CreateContainer(PostgreSqlConfiguration configuration) => new(configuration, Backend); } public sealed class PostgreSqlContainer : ContainerBase { private readonly PostgreSqlConfiguration configuration; internal PostgreSqlContainer(PostgreSqlConfiguration configuration, IContainerBackend backend) : base(configuration, backend) => this.configuration = configuration; public string GetConnectionString() { var builder = new NpgsqlConnectionStringBuilder { Host = "localhost", Port = GetMappedPublicPort(PostgreSqlBuilder.PostgreSqlPort), Database = configuration.Database, Username = configuration.Username, Password = configuration.Password.Value, }; return builder.ConnectionString; } } ``` ## Connection strings - Never cached before port mapping is final; generated from runtime state (`GetMappedPublicPort`). - Prefer client connection-string builders: `NpgsqlConnectionStringBuilder`, `SqlConnectionStringBuilder`, `UriBuilder`, etc. Avoid handcrafted escaping. - Credentials are stored as `Secret` in the module configuration; diagnostics and `ToString()` never reveal them. Every module builder also registers a connection string provider, so the polymorphic `IContainer.GetConnectionString()` returns the same value as the module's own `GetConnectionString()`. See [Connection Strings](../connection-strings/). `ConnectionMode.Container` (container-to-container) is not supported yet. Every module exposes `GetConnectionString()` with the same shape it has in Testcontainers, so test code that leans on the Testcontainers modules ports across unchanged: | Module | `GetConnectionString()` | Extra accessors | |---|---|---| | PostgreSQL | Npgsql string: `Host`, `Port`, `Database`, `Username`, `Password` | — | | Redis | `host:port` (e.g. `localhost:6379`) | — | | SQL Server | `SqlConnectionStringBuilder`: `Data Source=host,port`, `Database` (default `master`, set with `WithDatabase`), `User Id=sa`, `Password`, `TrustServerCertificate=True` | — | | MySQL | `MySqlConnectionStringBuilder`: `Server`, `Port`, `Database`, `User ID`, `Password` | — | | RabbitMQ | `amqp://user:pass@host:port/vhost` | `GetAmqpEndpoint()`, `GetManagementEndpoint()` | | Azurite | Azure Storage string: `DefaultEndpointsProtocol=http`, `AccountName`, `AccountKey`, `Blob/Queue/TableEndpoint` | `GetBlobEndpoint()`, `GetQueueEndpoint()`, `GetTableEndpoint()` | | NATS | `nats://host:port` | `GetClientEndpoint()`, `GetMonitoringEndpoint()` | ## Module status | Module | Image | Readiness | Client | Status | |---|---|---|---|---| | PostgreSQL | `postgres:17` | `pg_isready` | Npgsql | ✅ implemented | | Redis | `redis:7` | `redis-cli ping` | StackExchange.Redis | ✅ implemented — also usable with Valkey/Garnet via `WithImage` | | SQL Server | `mcr.microsoft.com/mssql/server:2022-latest` | host `SqlClient` connection | Microsoft.Data.SqlClient | ✅ implemented — requires `.AcceptLicense()`; the connection string defaults to `Database=master` (`WithDatabase(...)` to change it); session needs ≥ 2000 MB memory | | RabbitMQ | `rabbitmq:3-management` | log `"Server startup complete"` | RabbitMQ.Client | ✅ implemented — AMQP + management endpoints | | Azurite | `mcr.microsoft.com/azure-storage/azurite` | log `"successfully listening"` | Azure.Storage.* | ✅ implemented — blob/queue/table endpoints; the well-known `devstoreaccount1` key is a placeholder in `AzuriteAccount.Key` until the consuming repo supplies it | | NATS | `nats:2` | log `"Listening for client connections"` | NATS.Client.Core | ✅ implemented — client + monitoring endpoints | | MySQL | `mysql:8` | host `MySqlConnector` connection | MySqlConnector | ✅ implemented — uses a real connection poll (the image logs `"ready for connections"` during its temporary init server) | Note on RabbitMQ readiness: the default wait uses the canonical **`Server startup complete`** log signal rather than `rabbitmq-diagnostics ping` — under WSLC the exec-based probe races with the Erlang cookie setup and can trigger a startup failure (`eacces` reading `.erlang.cookie`). WSLC auto-provisions image `VOLUME` declarations on an ext4 device by default; bind-mounting a Windows directory onto one replaces it with drvfs, which ignores Unix `chown` and breaks permission-sensitive images — do not bind-mount onto image volumes unless you intend that. ## Custom modules (third-party) See [Contributing Modules](../contributing-modules/). --- # Testing How the test suite is organised, why it runs serially, and how to run a subset. ## Test categories Test projects are discovered under `src/tests` and the SDK stamps every test assembly with a TUnit category: | Project | Category | Runtime needed | | --- | --- | --- | | `*.UnitTests` | `Unit` | none — pure logic, parsing, configuration and wait-strategy units, some with in-process fakes. | | `Wsl.*` and module `*.IntegrationTests` | `Integration` | a **WSLC host** — real containers on a shared WSLC session. | | `Docker.IntegrationTests`, `Modules.DockerIntegrationTests` | `Integration` | any **Docker daemon** — these target `net10.0`, so they also run on Linux. | `purview-build.json` filters the shared pipeline run to `/*/*/*/*[Category=Unit]`, so the standard pipeline never starts a container. The Docker integration suites are the exception: the PR workflow (`.github/workflows/pr.yml`) adds an **`integration-docker`** job that runs them explicitly on `ubuntu-latest`, where a Docker daemon is available. That job is the standing proof that the library and every service module work off Windows/WSL — the same modules a developer runs on WSLC locally. The WSLC suites still run only on a WSLC host. > The shared `purview-dev/build` workflow runs on **`ubuntu-latest`**, so the pipeline builds the portable > `net10.0` projects and the `net11.0-windows…` test projects on Linux. That works because > `src/Directory.Build.props` sets `EnableWindowsTargeting=true`; without it the SDK reports `NETSDK1100`. > See [Consumer Requirements](../consumer-requirements/) for what that workaround does and does not > cover. ```powershell just test # every discovered test project just test '/*/*/*/*[Category=Unit]' # unit tests only just test '/*/*/*/*[Category=Integration]' --max-parallel-test-modules 1 ``` Integration suites target whichever backend is selected. Automatic detection prefers WSLC and falls back to Docker, so a WSLC host runs the WSLC suites and a Docker-only host runs the Docker ones; pin one with `PURVIEW_CONTAINERS_BACKEND=wsl|docker` to fail loudly instead of falling back. The Docker suites (`Docker.IntegrationTests` for the container contract, `Modules.DockerIntegrationTests` for the seven service modules) skip themselves when no daemon is reachable, and the WSLC suites skip themselves when the host lacks the WSL Containers components. See [Backends: WSLC or Docker](../backends/) for consumer-facing setup and CI examples. ```powershell just test src/tests/Docker.IntegrationTests/Docker.IntegrationTests.csproj # Docker contract just test src/tests/Modules.DockerIntegrationTests/Modules.DockerIntegrationTests.csproj # the seven modules on Docker ``` ## Why test modules run serially A WSLC session **exclusively locks its `storage.vhdx`**, and the lock is taken lazily on the first store access rather than at session start. Running test assemblies in parallel therefore makes the losers fail with `0x80070020`: ```text The process cannot access the file because it is being used by another process. ``` `just test` passes `--max-parallel-test-modules 1` for that reason. The runtime verifies the store once and transparently falls back to an isolated per-process store, but running modules serially keeps the warm shared image cache (no per-process re-pull) and is the fastest option. In Visual Studio, untick **Run Tests in Parallel** (or set *Maximum Parallel Test Projects* to 1) before running the WSLC integration suites. ## Running the WSLC suites in CI (manual) The Docker half of the matrix runs on every pull request. The WSLC half cannot: it needs a Windows host with WSL Containers, which no GitHub-hosted runner provides, so it is a **manual** workflow — `.github/workflows/integration-wsl.yml` — that runs on a self-hosted runner. Trigger it from **Actions → Integration (WSL Containers) → Run workflow**, or: ```bash gh workflow run "Integration (WSL Containers)" --ref main gh run watch ``` It runs `Wsl.IntegrationTests` and then the seven service-module suites (`PostgreSql`, `Redis`, `MsSql`, `MySql`, `RabbitMq`, `Azurite`, `Nats`) one project at a time, so the shared WSLC image store is never contended. Two optional inputs: `ref` (a branch, tag or SHA other than the selected one) and `filter` (a TUnit treenode filter, defaulting to every test). ### Self-hosted runner prerequisites Register a **Windows x64** runner for this repository (or organisation) with the labels `self-hosted` and the custom **`wslc`** label, and install on that machine: - Windows 10/11 with **WSL Containers**: `wsl --install --no-distribution`, verified with `wsl --version` and `wslc version`. - The **.NET 11 SDK** the repository pins (`11.0.100-rc.1.26425.128`). The job's first step prints `wsl --version` and `wslc version`, so a mis-provisioned runner fails immediately instead of surfacing as container timeouts. The WSLC suites skip themselves when the host lacks the WSL Containers components, so a runner without it would report successes-with-skips rather than real coverage — the host check is what makes that visible. `actionlint` is told about the custom label in `.github/actionlint.yaml`. ## Expectations - 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 without WSLC can still run the unit suites. ## Verifying the consumer contract `Wsl.UnitTests/ConsumerRequirementsTests.cs` guards the shape of the shipped library inside the normal unit run: the Windows build targets `.NETCoreApp,Version=v10.0`, targets `Windows10.0.19041.0` and declares only that OS platform. If the target framework ever drifts, that test fails — and [Consumer Requirements](../consumer-requirements/), the package READMEs and the shipped `buildTransitive` defaults all have to move with it. The behavioural side is covered by `just verify-consumers`, which packs the packages and builds throwaway consumer projects for every documented outcome (see [Consumer Requirements](../consumer-requirements/#verifying-these-requirements)). It is deliberately outside the `[Category=Unit]` filter because it packs and restores from nuget.org: ```powershell just verify-consumers # 21 consumer projects, all assertions just verify-consumers -Keep # same, keeping the generated projects for inspection ``` ## Related - [Architecture](../architecture/) — session lifetime, the concurrency gate and the shared-store fallback. - [Getting Started](../getting-started/) — running the tests locally. --- # Packaging 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`. ## What a package contains Each package ships exactly: | Entry | Source | | --- | --- | | `lib/$(TFM)/Purview.Containers.*.dll` | The built assembly: `net10.0` for `Purview.Containers` (umbrella), `Purview.Containers.Core` and the service modules; `Purview.Containers.Wsl` ships `net10.0` (the portable facade) and `net10.0-windows10.0.19041` (the implementation). | | `lib/$(TFM)/.xml` | XML documentation, generated because `GenerateDocumentationFile` is on for packable projects. | | `README.md` | The package's bespoke `Sdk/README.md` (see below). | | `purview-logo-light.png` | The shared Purview package icon (`assets/images/purview-logo-light.png`). | | `buildTransitive/Purview.Containers.Core.props` / `.targets` | **Core (abstractions) package.** Declares the `PurviewContainersBackends` list and turns it into a generated backend initializer in the consuming assembly. | | `buildTransitive/Purview.Containers.Wsl.props` / `.targets` | **WSL Containers backend only.** Defaults `WindowsSdkPackageVersion`/`PlatformTarget` and raises `PCC0001`/`PCC0002` for unsupported consumers. | | `buildTransitive/Purview.Containers.Docker.props` | **Docker backend only.** Adds the Docker backend to the `PurviewContainersBackends` list. | | `payload/win-x64/*`, `payload/win-arm64/*` | **WSL Containers backend only.** The Windows implementation, the WSLC projection, its Windows SDK dependencies and the native SDK. The `net10.0` facade loads them at run time on a Windows host; the `buildTransitive` targets copy the matching folder to the consumer output. | Portable PDBs are delivered through the `.snupkg`, never inside the `.nupkg`. The umbrella `Purview.Containers` package has no `buildTransitive` of its own: its dependencies' assets flow transitively. `$(TFM)` is expanded per shipped framework: `Purview.Containers`, `Purview.Containers.Core`, `Purview.Containers.Docker` and the service modules ship `lib/net10.0/`; `Purview.Containers.Wsl` ships both `lib/net10.0/` (the facade) and `lib/net10.0-windows10.0.19041/` (the implementation), with the `payload/` folders alongside. Every package therefore restores on a portable `net10.0` project. Consumers must target .NET 10+; see [Consumer Requirements](../consumer-requirements/). ## Package metadata Metadata is split by where its source of truth lives: | Metadata | Source | | --- | --- | | `id`, `description`, `tags`, licence, authors | each project's `.csproj` | | `version`, `repository`, `bugs` | `package.json` (applied by `Purview.BuildSdk`) | | icon, package README, **project site** | `src/Directory.Build.props`, for every packable project | The **project site** (`PackageProjectUrl`) is `https://github.com/purview-dev/containers`, the repository that hosts this documentation wiki; it shows as *Project Site* on nuget.org. Repository and commit metadata come from SourceLink, so the `repository` entry in a locally packed `.nupkg` points at the branch and commit that produced it. ## The `Sdk/` folder convention `Purview.BuildSdk` packs a packable project's `Sdk/` folder automatically (`PurviewAutoSdkPack`): | Source path | Package path | | --- | --- | | `Sdk/*.md`, `Sdk/*.png`, `Sdk/*.svg`, … | package root | | `Sdk/build/**`, `Sdk/buildTransitive/**`, `Sdk/buildMultiTargeting/**` | the matching package folder | | `Sdk/**` (anything else) | `Sdk/**` | Two consequences matter for this repository: 1. **Each packable project owns its documentation** in `src/src//Sdk/README.md`. Because a README-named file is already being packed, the SDK leaves the repository-root `README.md` out of the package, so every package ships documentation specific to itself. 2. **The icon is linked, not copied.** `src/Directory.Build.props` adds the shared logo as a `None` item with `Link="Sdk/purview-logo-light.png"`, and the SDK packs linked `Sdk/` files at the package root and declares ``/``. Projects must therefore keep `true` literally in the `.csproj`: the SDK resolves `IsPackable` by scanning the project file, and both property groups are conditioned on it. ## Pack validation `just pipeline-pack-validate` runs the shared `Purview.Build` pipeline with `Build:RunPack=true`, `Build:ValidatePack=true` and `Release:Mode=None`. `purview-build.json` configures the validation: - `RequireSymbolPackage` / `RequireSymbolFiles` — every `.nupkg` needs a matching `.snupkg`, and every `.snupkg` must contain at least one `.pdb`. - `RequiredContent` — because `RequireExplicitContent` defaults to `true`, this map is the **exhaustive** declaration of every package's contents: a produced package with no matching rule fails, and any packed entry not matched by one of that package's globs fails. `$(TFM)` expands to each target framework the package actually ships, so the rules are satisfied per framework. - Additional checks always run: file names must match the nuspec id/version, and `.nupkg` files must not contain PDBs outside `tools/`/`analyzers/`. Adding a packable project therefore means adding a `RequiredContent` entry keyed by its lowercase package id alongside the `Sdk/README.md`. Removing content from a package means removing the corresponding glob. ## Adding a package 1. Create `src/src//.csproj` with `true`, an assembly/package id, a description and tags, and add it to `src/Containers.slnx`. 2. Add `Sdk/README.md` documenting the package (see the existing modules for the shape). 3. Add the package to `RequiredContent` in `purview-build.json`. 4. Prove it with `just pack` and `just pipeline-pack-validate`. > **Packaging a backend or a new MSBuild asset?** NuGet only auto-imports `buildTransitive/.props` > and `buildTransitive/.targets`, so an asset named after anything else — including a shorter > product name — is shipped but never applied. A regression here is invisible to the build of this > repository, because our own projects reference each other by project and never import these assets; > `just verify-consumers` is the check that catches it. The `Sdk/buildTransitive/` assets are per package, and NuGet only auto-imports a file named after its own package id (`buildTransitive/.props|targets`). The abstractions package ships the backend registration, each backend package ships its own registration entry (and, for the WSL backend, the consumer defaults and guards), and the service modules ship none — a module is backend-neutral, so a consumer adds a backend package explicitly. They are declared in `RequiredContent` like any other asset, so a package that starts or stops shipping MSBuild assets has to update that manifest — a produced package with no rule, or a packed entry matched by no glob, fails validation. ## Related - [Release Flow](../release-flow/) — how packages are versioned and published. - [Contributing Modules](../contributing-modules/) — the module contract a new package follows. --- # Release Flow How this repository builds, versions, validates and publishes `Purview.Containers.*`. ## Versioning `package.json` at the repository root is the authoritative version (`1.0.0-prerelease.1` today). The SDK applies that value to `Version` and `PackageVersion` for every project, so a release is a `package.json` bump — never a manual project-file edit. `package.json` also carries the repository, homepage and issue URLs that end up in each nuspec. The project is **experimental**, so versions stay on a `-prerelease.N` suffix: consumers should pin an exact version rather than float, and each bump can change the API or the [consumer requirements](../consumer-requirements/). ## Local commands The `Justfile` wraps the common steps: | Command | What it does | | --- | --- | | `just build` | `dotnet build src/Containers.slnx` (Debug). | | `just test` | `dotnet test` across the solution, one test module at a time (see [Testing](../testing/)). | | `just lint-check` / `just lint-fix` | CSharpier check / format over the repository root. | | `just pack` | Build (Debug) and `dotnet pack` into `./artifacts`. | | `just verify-consumers` | Pack, then build throwaway consumer projects that assert the published consumer contract ([Consumer Requirements](../consumer-requirements/)). | | `just scrub` | Delete `bin`/`obj`, clean, re-restore with `--force-evaluate`, and shut down the build server. | | `just pipeline-pack-validate` | Shared pipeline: restore, build, lint, test, pack and **validate** the packages, without publishing. | ## The shared pipeline All pipeline recipes first install the `Purview.Build` tool into `.tools/purview-build` and then run it against `purview-build.json`: | Recipe | Pipeline arguments | | --- | --- | | `just pipeline-pr` | default (full PR pipeline) | | `just pipeline-build` | `--Build:RunTests=false --Release:Mode=None` | | `just pipeline-tests` | `--Build:RunTests=true --Release:Mode=None` | | `just pipeline-pack-validate` | `--Build:RunPack=true --Build:ValidatePack=true --Release:Mode=None` | | `just pipeline-local-release` | `--Release:Mode=LocalNuGet` | | `just pipeline-release` | `--Release:Mode=NuGet` | The pipeline cleans `artifacts/` before it runs, builds in **Release**, lints with CSharpier, runs the test filter from `purview-build.json` (`[Category=Unit]`, so no WSLC host is needed), packs, and then validates the produced packages against the exhaustive `RequiredContent` manifest. Any module failure fails the run with the failing module's output. ## GitHub Actions - `.github/workflows/pr.yml` — pull requests build and test via the shared `purview-dev/build/.github/workflows/purview-build.yml`, with pack and validation enabled. - `.github/workflows/release.yml` — a push to `main` calls `purview-dev/build/.github/workflows/purview-release.yml` with `release-mode: NuGet`, which packs, publishes to NuGet and creates the `v` GitHub release. Both workflows pin `dotnet-version` to the SDK in `global.json` (`11.0.100-rc.1.26425.128`); keep them in sync when the SDK is bumped, and keep `purview-build.json` pointing at `src/Containers.slnx`. The shared workflow runs on **`ubuntu-latest`**, so the Linux agent builds the portable `net10.0` projects and the `net11.0-windows10.0.19041.0` test projects. That only works because `src/Directory.Build.props` sets `EnableWindowsTargeting=true`; removing it fails the pipeline with `NETSDK1100`. The agent has no WSL Containers, which is why the pipeline is filtered to `[Category=Unit]` — see [Consumer Requirements](../consumer-requirements/) and [Testing](../testing/). ## Related - [Packaging](../packaging/) — the package contents and the validation manifest. - [Contributing](../contributing/) — the local workflow before raising a PR. --- # Contributing a module How to add a new service module to `Purview.Containers`. ## Files ``` src/src/MyService/ MyService.csproj -> PackageId Purview.Containers.MyService MyServiceConfiguration.cs -> immutable record, module fields MyServiceBuilder.cs -> fluent builder MyServiceContainer.cs -> container, connection string / endpoints src/tests/MyService.UnitTests/ src/tests/MyService.IntegrationTests/ ``` ## Steps 1. **Reference the abstractions**: ``. A module never references a backend package; the Purview SDK supplies the pack defaults and the `InternalsVisibleTo` entries for the module's test projects. 2. **Configuration record** — derive from `ContainerConfiguration`, add module fields; credentials as `Secret`: ```csharp public sealed record MyServiceConfiguration : ContainerConfiguration { public string Username { get; init; } = "default"; public Secret Password { get; init; } = Secret.From("default"); } ``` 3. **Builder** — subclass `ContainerBuilder`; default image/ports in the constructor; override `BuildConfiguration()` to snapshot module fields, and `CreateContainer(...)`: ```csharp public class MyServiceBuilder : ContainerBuilder { public const ushort DefaultPort = 9000; public MyServiceBuilder() { WithImage("myservice:latest").WithPortBinding(DefaultPort, assignRandomHostPort: true); } public MyServiceBuilder WithPassword(string password) { this.password = Secret.From(password); WithEnvironment("MYSERVICE_PASSWORD", password); return this; } protected override MyServiceConfiguration BuildConfiguration() { MyServiceConfiguration configuration = base.BuildConfiguration(); return configuration with { Username = username, Password = password, WaitStrategies = configuration.WaitStrategies.Count > 0 ? configuration.WaitStrategies : new[] { (IWaitStrategy)Wait.ForTcpPort(DefaultPort) }, }; } protected override MyServiceContainer CreateContainer(MyServiceConfiguration configuration) => new(configuration, Backend); } ``` 4. **Container** — expose `GetConnectionString()` / endpoints using `GetMappedPublicPort`, built from runtime state (never cached before start). Add a provider so the polymorphic `IContainer.GetConnectionString()` works, and wire it in the builder constructor: ```csharp sealed class MyServiceConnectionStringProvider : ContainerConnectionStringProvider { protected override string GetHostConnectionString() => Container.GetConnectionString(); } // in the builder constructor: WithImage("myservice:latest") .WithPortBinding(DefaultPort, assignRandomHostPort: true) .WithConnectionStringProvider(new MyServiceConnectionStringProvider()); ``` 5. **Wait strategy** — prefer verifying the service itself (exec a readiness command or a host client connection), not merely that a TCP port is open. See [Wait Strategies](../wait-strategies/). Default waits are applied in `BuildConfiguration()` unless the caller supplied their own. 6. **Secrets** — passwords/usernames go into a `Secret`-typed field; configuration `ToString()` redacts sensitive values automatically. 7. **Tests** — unit tests use `BuildConfigurationForTesting()` (internal test hook on the module builder); integration tests use TUnit, the shared `WslcTest.SkipIfUnavailableAsync()` helper from `src/tests/SharedTestingFramework`, and the real client. ## Conventions - Package ID `Purview.Containers.MyService` (namespace prefix `Purview`). - Module is thin: no session management, no port allocation logic, no output buffering. - Default networking is `Bridged` (from the core defaults); ports use native random allocation unless a fixed host port is explicitly requested. - The module is **backend-neutral** (`net10.0`, references `Purview.Containers.Core` only) and therefore does **not** bring a backend or inherit its consumer requirements. A consumer references the module *and* a backend package (`Purview.Containers.Wsl` for WSLC, `Purview.Containers.Docker` for Docker). See [Consumer Requirements](../consumer-requirements/). - If a Testcontainers capability has no WSLC equivalent (e.g. UDP, TTY, `--user`), throw `ContainerNotSupportedException` at build/validation rather than silently ignoring it. --- # Contributing Local workflow for the repository: prerequisites, the change loop, and what to check before raising a pull request. ## Prerequisites - Windows with **WSL Containers** for integration tests; see [Getting Started](../getting-started/). - .NET SDK 11 (pinned in `global.json` as `11.0.100-rc.1.26425.128`). - The library projects split by framework: `src/src/Directory.Build.props` sets `net10.0` for the backend-neutral abstractions and the service modules. `src/src/Wsl/Wsl.csproj` is multi-target (`net10.0` facade + `net10.0-windows10.0.19041.0` implementation) because the WSL Containers projection is Windows-only. `src/tests` keeps the Windows target (`src/Directory.Build.props`), which also sets `EnableWindowsTargeting=true` so the solution builds on the Linux CI agent. Do not remove that property: without it the shared pipeline fails with `NETSDK1100`. See [Consumer Requirements](../consumer-requirements/). - [just](https://github.com/casey/just) for the repository recipes, and `dotnet tool restore` for the pinned local tools (CSharpier, dotnet-inspect). - [Bun](https://bun.sh) only for `just version` and the commit hook tooling; the commit hook runs `npx commitlint` via lefthook (`.config/lefthook.yml`). ## The change loop 1. Inspect the working tree and locate the implementation, tests, documentation and existing patterns for the change. 2. Confirm behaviour from code and tests rather than memory or documentation alone. 3. Make the smallest coherent change; keep public behaviour unless the task explicitly changes it. 4. Add or update tests for behaviour changes, and update documentation when public behaviour changes. 5. Run the narrowest meaningful validation first, then broader validation in proportion to risk. ```powershell just build # fast compile check just test '/*/*/*/*[Category=Unit]' # unit tests (no WSLC host needed) just lint-fix # CSharpier format (just lint-check to verify only) just pack # build + pack into ./artifacts just verify-consumers # build throwaway consumers against the packed packages just pipeline-pack-validate # full local gate: restore, build, lint, test, pack, validate ``` `just scrub` resets the repository (`bin`/`obj`, clean, forced restore, build-server shutdown) when a stale restore or compiler state is suspected. ## Commits Commit messages follow Conventional Commits (`commitlint.config.mts`): a lower-case type from `build`, `chore`, `ci`, `docs`, `feat`, `fix`, `perf`, `refactor`, `revert`, `style`, `test`, an optional scope, and a subject under 100 characters with no trailing full stop. The `commit-msg` lefthook runs `npx commitlint --edit` and rejects anything else. ## Documentation expectations A change is not complete until the affected documentation matches: - **`src/src//Sdk/README.md`** — the package's own README, shipped inside the `.nupkg`. Update it whenever a module's public API, defaults, readiness or endpoints change. - **`docs/wiki`** — the user-facing wiki aggregated by the purview-dev website. Update the topic page ([Consumer Requirements](../consumer-requirements/), [Architecture](../architecture/), [Lifecycle](../lifecycle/), [Networking](../networking/), [Wait Strategies](../wait-strategies/), [Modules](../modules/), [Testing](../testing/), [Packaging](../packaging/), [Release Flow](../release-flow/)) and `_Sidebar.md` when adding a page. - **`purview-build.json`** — the exhaustive `PackValidation.RequiredContent` manifest has to keep matching what the packages actually contain; see [Packaging](../packaging/). - **`README.md`** — the repository front page, when the shape of the project changes. ## Before you raise a PR - Confirm the requested behaviour and scope are satisfied and that only intended files changed. - Review public API, package-content and dependency-direction implications. - Run `just lint-check`, the relevant tests, and `just pipeline-pack-validate` when packaging, dependencies or package assets changed. - State exactly what validation ran; if something could not run, say why and what risk remains. ## Related - [Contributing Modules](../contributing-modules/) — the module contract for new service packages. - [Packaging](../packaging/) — package contents and validation rules. - [Release Flow](../release-flow/) — versioning and what CI does on a PR and on release.