diff --git a/.agents/agents/sdk-consumer-setup.md b/.agents/agents/sdk-consumer-setup.md index 8e5066c..c3cc84f 100644 --- a/.agents/agents/sdk-consumer-setup.md +++ b/.agents/agents/sdk-consumer-setup.md @@ -14,11 +14,14 @@ Help a consuming repository adopt or troubleshoot `Purview.BuildSdk` correctly, variables, `.git` root, or a nearby `package.json`. `UsePackageJsonVersion=Strict` fails fast instead of silently skipping resolution. 4. If the bundled `.agents/**` content isn't appearing in the repo root, check `EnableAgentFolderInPackage` - (default `true`) and `AgentPackDestinationFolder` (default `.agents`) — the copy runs before build via + (default `true`) and `AgentPackDestinationFolder` (default `.agents`) - the copy runs before build via `EnsureAgentFolderInPackageTarget`. 5. For test-framework or project-shape questions, confirm the project follows repo naming and placement conventions the SDK expects, rather than introducing bespoke structure. -6. Re-run `dotnet build` (or the repo's canonical build command) after each configuration change to confirm +6. For naming, layout, and test readability questions, start from the engineering principles: naming and + placement are configuration, short project names are preferred, the detected test type becomes the baseline + category, and subject-based tests should be named for the subject they own. +7. Re-run `dotnet build` (or the repo's canonical build command) after each configuration change to confirm the fix. ## Constraints @@ -32,3 +35,4 @@ Help a consuming repository adopt or troubleshoot `Purview.BuildSdk` correctly, ## Related skill See `../skills/sdk-configuration-reference/SKILL.md` for the full property reference. +See `../skills/sdk-engineering-principles/SKILL.md` for the higher-level repository conventions. diff --git a/.agents/agents/sdk-repository-rationaliser.md b/.agents/agents/sdk-repository-rationaliser.md new file mode 100644 index 0000000..d41060b --- /dev/null +++ b/.agents/agents/sdk-repository-rationaliser.md @@ -0,0 +1,38 @@ +# sdk-repository-rationaliser (generic agent spec) + +## Goal + +Help a repository move toward `Purview.BuildSdk` conventions without unnecessary churn. + +## Workflow + +1. Start by reading the repo's `Directory.Build.props`, `Directory.Build.targets`, solution entry point, + and current project layout. +2. Identify the current `NamespacePrefix`, project naming scheme, and test project suffixes in use. +3. Compare the repo's structure against the engineering principles: + - short project names + - source/test split where practical + - exact shared/shared-testing names when SDK behavior is expected + - readable, scalable test naming +4. Separate findings into three groups: + - already aligned + - misaligned but harmless + - misaligned and blocking SDK automatic behavior +5. Prefer the smallest sequence of changes that improves predictability without forcing broad renames. +6. When recommending test changes, preserve readable behavior-oriented suites while tightening subject-based + suites toward `{SubjectName}Tests` and `{SubjectOrMemberUnderTest}_{Scenario}_{Expectation}`. +7. Confirm whether specialized dependencies such as `TUnit.Aspire` or `Testcontainers` are genuinely needed + rather than treating them as universal defaults. + +## Constraints + +- Do not assume every older repo should be renamed wholesale. +- Preserve meaningful established structure unless it interferes with SDK inference. +- Prefer explaining the effect on `RootNamespace`, `AssemblyName`, `PackageId`, `TestingType`, and + `TargetProjectName` instead of arguing from taste. + +## Related skills + +- `../skills/sdk-engineering-principles/SKILL.md` +- `../skills/project-placement-defaults/SKILL.md` +- `../skills/sdk-project-behavior-and-detection/SKILL.md` diff --git a/.agents/prompts/sdk-review-repository-shape.md b/.agents/prompts/sdk-review-repository-shape.md new file mode 100644 index 0000000..eb089c9 --- /dev/null +++ b/.agents/prompts/sdk-review-repository-shape.md @@ -0,0 +1,33 @@ +# sdk-review-repository-shape (generic prompt spec) + +Review a repository that uses `Purview.BuildSdk` for naming, placement, and test-structure alignment. + +## Required behaviour + +1. Inspect the repository's `Directory.Build.props`, `Directory.Build.targets`, solution entry point, and + project layout before making assumptions. +2. Identify whether the repository follows the SDK-friendly structure: + - source projects under `src/` + - test projects under `tests/` + - `.csproj` filenames matching directory names + - short project names with `NamespacePrefix` carrying the repo identity +3. Check whether test project names use recognised `*Tests` suffixes and whether shared/shared-testing + projects use exact SDK-recognised names. +4. Explain the consequences of deviations in terms of automatic `RootNamespace`, `AssemblyName`, + `PackageId`, `TargetProjectName`, and automatic project references. +5. Review test readability conventions: + - subject-based `{SubjectName}Tests` + - subject-based `{SubjectOrMemberUnderTest}_{Scenario}_{Expectation}` method names + - non-subject-based suites named clearly for their broader role + - appropriate use of TUnit categories and display names +6. Distinguish between: + - acceptable existing variance worth preserving + - structural debt that blocks the SDK's automatic behavior + - incremental rationalisation opportunities + +## Suggested output + +- A concise summary of whether the repo broadly fits the SDK conventions. +- A list of concrete mismatches, ordered by impact. +- A list of low-risk rationalisation steps for naming, placement, identity, or test readability. +- Explicit note of which behaviors are already automatic defaults and which require manual configuration. diff --git a/.agents/skills/sdk-engineering-principles/.gitignore b/.agents/skills/sdk-engineering-principles/.gitignore new file mode 100644 index 0000000..2799754 --- /dev/null +++ b/.agents/skills/sdk-engineering-principles/.gitignore @@ -0,0 +1,8 @@ +# Ignore all files +* + +# Don't ignore directories, so Git can traverse them +!*/ + +# Keep this file +!.gitignore \ No newline at end of file diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml new file mode 100644 index 0000000..5649e93 --- /dev/null +++ b/.github/actionlint.yaml @@ -0,0 +1,6 @@ +# actionlint configuration. The WSLC integration workflow targets a self-hosted runner with a custom +# `wslc` label (a Windows x64 host with WSL Containers installed); declaring it here lets actionlint +# validate .github/workflows/integration-wsl.yml. +self-hosted-runner: + labels: + - wslc \ No newline at end of file diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 35aa381..396adea 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -21,7 +21,7 @@ Keep product, architecture, and general engineering standards centralized in `AG - Never weaken the WSLC session/store invariants (single shared session, serialised session mutations, the shared-store fallback, `Secret` redaction) to make a test pass. - Treat the published consumer contract as an invariant too: `.NET 11` + a Windows target framework, - the `buildTransitive` defaults, and the `PWC0001`/`PWC0002` guards documented in + the `buildTransitive` defaults, and the `PCC0001`/`PCC0002` guards documented in `docs/wiki/Consumer-Requirements.md`. `just verify-consumers` must pass before a consumer-facing change is complete. - Consult the repository `.agents/` folder for additional skills/workflows that may improve execution diff --git a/.github/workflows/integration-wsl.yml b/.github/workflows/integration-wsl.yml new file mode 100644 index 0000000..67a12ef --- /dev/null +++ b/.github/workflows/integration-wsl.yml @@ -0,0 +1,89 @@ +name: Integration (WSL Containers) + +# Manual-only. The WSLC integration suites need a Windows host with WSL Containers installed, which no +# GitHub-hosted runner provides, so this job runs on a self-hosted Windows runner carrying the custom +# `wslc` label. It is deliberately not attached to pull_request or push: it is the deliberate, manual +# proof that the WSLC backend and every service module run on WSL Containers. The Docker half of the +# matrix runs on every pull request instead (see pr.yml, the `integration-docker` job) because it needs +# only a daemon and therefore works on a hosted Linux runner. +# +# Runner prerequisites are documented in docs/wiki/Testing.md. +on: + workflow_dispatch: + inputs: + ref: + description: Git ref (branch, tag or SHA) to test. Defaults to the ref this workflow is dispatched from. + required: false + default: "" + filter: + description: TUnit treenode filter. Defaults to every test. + required: false + default: "/*/*/*/*" + +concurrency: + # Never cancel a WSLC run in flight: it owns a session and its image store. + group: ${{ github.workflow }}-${{ inputs.ref || github.ref }} + cancel-in-progress: false + +jobs: + integration-wsl: + name: Integration (WSL Containers) + # A self-hosted Windows x64 runner with WSL Containers installed and the custom `wslc` label. + runs-on: [self-hosted, wslc] + timeout-minutes: 60 + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref || github.ref }} + + - name: Set up .NET + uses: actions/setup-dotnet@v4 + with: + # Keep in sync with `sdk.version` in global.json (and the other workflows). + dotnet-version: "11.0.100-rc.1.26425.128" + + - name: Verify the WSL Containers host + shell: pwsh + run: | + wsl --version + wslc version + + - name: Restore + run: dotnet restore src/WSLTestContainers.slnx + + - name: Build + run: dotnet build src/WSLTestContainers.slnx --configuration Release --no-restore + + - name: WSLC backend contract + # Real containers on a shared WSLC session; ~27 tests and ~4 minutes. + run: dotnet test src/tests/Wsl.IntegrationTests/Wsl.IntegrationTests.csproj --configuration Release --no-build --treenode-filter '${{ inputs.filter }}' + + - name: Service modules on WSLC + # Each module pulls its image and starts a real service. One project at a time keeps the shared + # WSLC image store uncontended (parallel sessions fail with 0x80070020). Every project runs so a + # single failure does not hide the rest, and the step fails at the end if any of them did. + shell: pwsh + run: | + $projects = @( + 'src/tests/PostgreSql.IntegrationTests/PostgreSql.IntegrationTests.csproj', + 'src/tests/Redis.IntegrationTests/Redis.IntegrationTests.csproj', + 'src/tests/MsSql.IntegrationTests/MsSql.IntegrationTests.csproj', + 'src/tests/MySql.IntegrationTests/MySql.IntegrationTests.csproj', + 'src/tests/RabbitMq.IntegrationTests/RabbitMq.IntegrationTests.csproj', + 'src/tests/Azurite.IntegrationTests/Azurite.IntegrationTests.csproj', + 'src/tests/Nats.IntegrationTests/Nats.IntegrationTests.csproj' + ) + + $failed = @() + foreach ($project in $projects) { + Write-Host "::group::$project" + dotnet test $project --configuration Release --no-build --treenode-filter '${{ inputs.filter }}' + if ($LASTEXITCODE -ne 0) { + $failed += $project + } + Write-Host "::endgroup::" + } + + if ($failed.Count -gt 0) { + throw "WSLC integration suites failed: $($failed -join ', ')" + } \ No newline at end of file diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 3959ea1..cb9c684 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -28,3 +28,22 @@ jobs: run-pack: true validate-pack: true secrets: inherit + + # The shared pipeline filters to [Category=Unit] because the WSLC integration suites need a Windows + # host. The Docker integration suites need only a Docker daemon, so they run here on the same Linux + # runner a consumer would use - the standing proof that the library and every service module work off + # Windows/WSL. The suites skip themselves when no daemon is reachable, so a non-zero result is a real + # failure, not a missing runtime. + integration-docker: + name: Integration (Docker) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + # Keep in sync with `sdk.version` in global.json. + dotnet-version: "11.0.100-rc.1.26425.128" + - name: Docker backend + run: dotnet test src/tests/Docker.IntegrationTests/Docker.IntegrationTests.csproj --configuration Release --treenode-filter '/*/*/*/*' + - name: Service modules on Docker + run: dotnet test src/tests/Modules.DockerIntegrationTests/Modules.DockerIntegrationTests.csproj --configuration Release --treenode-filter '/*/*/*/*' diff --git a/AGENTS.md b/AGENTS.md index c3403d5..7b95410 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,10 +2,12 @@ ## Purpose and authority -This repository contains `Purview.WslContainers`, a **WSLC-native** Testcontainers-style library for .NET: -throwaway Linux containers for integration testing on **Microsoft WSL Containers (WSLC)**, with no Docker -installation, plus the service modules (`PostgreSql`, `Redis`, `MsSql`, `RabbitMq`, `Azurite`, `Nats`, -`MySql`). +This repository contains `Purview.Containers`, a Testcontainers-style library for .NET that runs throwaway +Linux containers for integration testing on **Microsoft WSL Containers (WSLC)** — the Windows runtime with +no Docker installation — or on **Docker** through Testcontainers, plus the service modules (`PostgreSql`, +`Redis`, `MsSql`, `RabbitMq`, `Azurite`, `Nats`, `MySql`). +`docs/wiki/Backends.md` is the consumer guide to choosing and configuring a backend; keep it up to date +whenever a target framework, a backend package or the `buildTransitive` assets change. - This file is the repository-wide source of truth for AI agents. A more-specific `AGENTS.md` in a subtree, if one is ever added, takes precedence for that subtree. @@ -21,12 +23,18 @@ installation, plus the service modules (`PostgreSql`, `Redis`, `MsSql`, `RabbitM | Path | Purpose | | --- | --- | | `src/WSLTestContainers.slnx` | Canonical solution for restore, build, test and pack | -| `src/src/WslContainers` | Core runtime (`Purview.WslContainers`): `ContainerBuilder`, `WslContainer`, runtime, sessions, images, networking, mounts, wait strategies, diagnostics | -| `src/src/` | Service modules; each is a thin layer over the core and carries a bespoke `Sdk/README.md` | -| `src/src//Sdk` | Package-only assets. `Sdk/README.md` is packed as the package README (suppressing the repo-root README); `Sdk/buildTransitive/**` ships MSBuild assets to consumers (the core package's consumer defaults and guards) | -| `src/tests` | TUnit unit (`*.UnitTests`) and WSLC integration (`*.IntegrationTests`) projects | +| `src/src/Containers` | Umbrella package (`Purview.Containers`, `net10.0`): references `Core` and both backends; no code of its own | +| `src/src/Core` | Backend-neutral abstractions (`Purview.Containers.Core`, `net10.0`, namespace `Purview.Containers`): `IContainer`/`ContainerConfiguration`, `ContainerBuilder`, `ContainerBase`, `IContainerBackend`/`ContainerBackends`, wait strategies, images, networking, mounts, diagnostics | +| `src/src/Wsl` | WSL Containers backend (`Purview.Containers.Wsl`, multi-target `net10.0` facade + `net10.0-windows10.0.19041.0` implementation): `WslContainerBackend`, the shared session runtime, `WslContainer`, `WslContainerSession`, and the portable facade (`WslPayload`, `*.Facade.cs`) that loads the implementation at run time | +| `src/src/Docker` | Docker backend (`Purview.Containers.Docker`, `net10.0`): `DockerContainerBackend` + `DockerContainer` over Testcontainers | +| `src/src/` | Service modules (`Purview.Containers.`, `net10.0`); each is a thin layer over the abstractions and carries a bespoke `Sdk/README.md` | +| `src/src//Sdk` | Package-only assets. `Sdk/README.md` is packed as the package README (suppressing the repo-root README); `Sdk/buildTransitive/**` ships MSBuild assets to consumers (the abstractions' backend registration, each backend package's own registration entry, and the WSL backend's consumer defaults and guards) | +| `src/tests` | TUnit unit (`*.UnitTests`) and container integration (`*.IntegrationTests`) projects: the WSLC suites need a WSLC host, `Docker.IntegrationTests` and `Modules.DockerIntegrationTests` need a Docker daemon | | `spikes/WslcSpikes` | Phase 0 investigation harness (`s1`..`s14` probes); not part of the test run | -| `docs/wiki` | User-facing documentation wiki, aggregated by the purview-dev website | +| `spikes/DynamicLoadSpike` | Phase 0 feasibility probe for the portable facade: loads the WSLC projection from a local payload and drives a session from a plain `net10.0` process; not part of the test run | +| `spikes/PortableConsumerSpike` | Acceptance probe for the portable facade: a `net10.0` project that binds the facade and runs a real WSLC container through a `wslc/` payload; not part of the test run | +| `docs/wiki` | User-facing documentation wiki, aggregated by the purview-dev website. `Backends.md` is the consumer guide to choosing and configuring WSLC or Docker | +| `samples/getting-started` | Runnable consumer-shaped samples (WSLC and Docker) built with the solution; `just sample-wsl` / `just sample-docker` | | `mkdocs.yml` | Wiki site configuration (`docs_dir: docs/wiki`) | | `src/Directory.Build.props` / `src/Directory.Build.targets` | Solution-wide SDK import, package metadata and the shared package icon | | `purview-build.json` | Shared `Purview.Build` pipeline configuration: solution, test discovery/filter and the **exhaustive** pack-validation manifest | @@ -57,23 +65,41 @@ installation, plus the service modules (`PostgreSql`, `Redis`, `MsSql`, `RabbitM - **Random host ports are native** (`windowsPort=0`) and read back from the container's mapped ports — never probe for a free port. - **Default networking is `Bridged`**; only IPv4 loopback is mapped, and UDP is unsupported - (`WslContainerNotSupportedException`). + (`ContainerNotSupportedException`). +- **One backend registry per process** (`ContainerBackends`): backend packages register themselves through + the generated module initializer, `ResolveAsync()` returns the pinned backend, the + `PURVIEW_CONTAINERS_BACKEND`-selected backend, or the first *usable* registered backend in + auto-priority order (`IContainerBackendPreference.AutoPriority`, lowest first; `wsl` is 0 and `docker` + is 100, so WSLC is preferred when both are usable), and an empty/unusable registry throws + `ContainerBackendUnavailableException` naming the fix. A named or pinned backend never silently falls + back. A module must never reference a backend package — it resolves through the registry. - **`DisposeAsync` is idempotent** and never terminates the shared session; a process-exit hook disposes the session so its name is released. - **Secrets use `Secret`** and must never reach logs, names, `ToString()` or diagnostics (they are redacted). ## Consumer requirements and compatibility -- The packages are **.NET 11, Windows-only**: every project under `src/` targets - `net11.0-windows10.0.19041.0` (`src/Directory.Build.props`) and a consumer must match. The contract, - the failures that enforce it and the CI workarounds are documented in - [Consumer Requirements](docs/wiki/Consumer-Requirements.md); update that page whenever the target - framework, the `buildTransitive` defaults or the `PWC0001`/`PWC0002` guards change. -- `Purview.WslContainers` ships `Sdk/buildTransitive/Purview.WslContainers.{props,targets}` so consumers - inherit `WindowsSdkPackageVersion`/`PlatformTarget` defaults and a clear error for an unsupported - target framework (`PWC0001`) or a 32-bit consumer (`PWC0002`). Those assets are framework-agnostic, so - NuGet no longer raises `NU1202` at restore time — the guards are the fail-fast path. Keep them, and - keep the `RequiredContent` entries that declare them. +- The packages split by framework: `Purview.Containers.Core` (abstractions) and the service modules target + **`net10.0`** on any platform. `Purview.Containers.Wsl` is **multi-target**: `net10.0` (a portable + facade) and `net10.0-windows10.0.19041.0` (the WSLC implementation). `src/src/Directory.Build.props` + sets the `net10.0` subtree default and `Wsl.csproj` adds the Windows build; the WSLC tests keep the + Windows target because they drive WSLC and therefore bind the implementation, while the Docker + integration projects override to `net10.0` so they run on Linux. The contract, the + failures that enforce it and the CI workarounds are documented in + [Consumer Requirements](docs/wiki/Consumer-Requirements.md); update that page whenever a target + framework, the `buildTransitive` defaults, the payload layout or the `PCC0001`/`PCC0002` guards change. +- `Purview.Containers.Wsl` ships `Sdk/buildTransitive/Purview.Containers.Wsl.{props,targets}` so consumers + inherit `WindowsSdkPackageVersion`/`PlatformTarget` defaults, a clear error for an unsupported target + framework (`PCC0001`) or a 32-bit Windows consumer (`PCC0002`), and — for a platform-neutral consumer + on a Windows build host — a copy of the WSLC implementation payload (`wslc/`) that the `net10.0` facade + loads at run time. Those assets are framework-agnostic, so NuGet never raises `NU1202` at restore time; + the guards are the fail-fast path. Keep them, and keep the `RequiredContent` entries (including + `payload/**`) that declare them. They belong to the WSL backend package only. +- `Purview.Containers.Core` ships `Sdk/buildTransitive/Purview.Containers.Core.{props,targets}`, which turn + the `PurviewContainersBackends` list (each backend package appends its own entry) into a generated + module initializer in the consuming assembly. That is how a package consumer's process discovers its + backend without reflection or assembly scanning. A project-reference consumer (this repository's own + tests) registers explicitly instead — `WslcTest.EnsureBackendRegistered()`. - `src/Directory.Build.props` sets `EnableWindowsTargeting=true` because the shared CI agent is `ubuntu-latest`; removing it fails the pipeline with `NETSDK1100`. - The project is **experimental**: keep the experimental notice in `README.md`, `docs/wiki/Home.md` and @@ -85,7 +111,11 @@ installation, plus the service modules (`PostgreSql`, `Redis`, `MsSql`, `RabbitM strategy, and connection-string/endpoint accessors. It must not duplicate runtime infrastructure. - New modules follow the shape in [Contributing Modules](docs/wiki/Contributing-Modules.md): a `ContainerConfiguration`-derived record, a `ContainerBuilder` - subclass, and a container exposing `GetConnectionString()`/endpoints built from `GetMappedPublicPort`. + subclass, and a container deriving from `ContainerBase` that exposes + `GetConnectionString()`/endpoints built from `GetMappedPublicPort`. Take an `IContainerBackend` in the + builder and container constructors and pass it through as `new MyContainer(configuration, Backend)` — a `null` backend is resolved on start via `ContainerBackends.ResolveAsync()`; a + module package must never reference `Purview.Containers.Wsl` (that is what keeps it runnable on any + backend and restorable on Linux). - Prefer verifying the service for readiness (exec a readiness command or open a host client connection) over a bare TCP check. Where an image reports readiness too early (MySQL), a log match is wrong. - SQL Server requires an explicit `AcceptLicense()` call; never accept licensing terms on the caller's behalf. @@ -96,9 +126,15 @@ installation, plus the service modules (`PostgreSql`, `Redis`, `MsSql`, `RabbitM resolves `IsPackable` by scanning the project file, and the package metadata/icon conditions in `src/Directory.Build.props` depend on it. - Each package ships `lib/$(TFM)/.{dll,xml}`, `README.md` (from `Sdk/README.md`) and - `purview-logo-light.png`. PDBs ship only in the `.snupkg`. The core package additionally ships - `buildTransitive/Purview.WslContainers.{props,targets}` (see - [Consumer requirements and compatibility](#consumer-requirements-and-compatibility)). + `purview-logo-light.png`. PDBs ship only in the `.snupkg`. MSBuild assets are per package and must be + named after their own package id for NuGet to import them: `Purview.Containers.Core` ships + `buildTransitive/Purview.Containers.Core.{props,targets}` (backend registration), + `Purview.Containers.Wsl` ships `buildTransitive/Purview.Containers.Wsl.{props,targets}` (consumer + defaults and guards) and `Purview.Containers.Docker` ships + `buildTransitive/Purview.Containers.Docker.props` (its registration entry). The umbrella + `Purview.Containers` ships none of its own; `Core`'s and the backends' assets reach its consumers + transitively. See + [Consumer requirements and compatibility](#consumer-requirements-and-compatibility). - `PackValidation.RequireExplicitContent` defaults to `true`, so `purview-build.json`'s `RequiredContent` is the **exhaustive** manifest: a produced package with no rule, or a packed entry matched by no glob, fails validation. Adding or removing packaged content means updating that map. @@ -107,15 +143,20 @@ installation, plus the service modules (`PostgreSql`, `Redis`, `MsSql`, `RabbitM ## Testing - Test assemblies are categorised by the SDK: `*.UnitTests` → `Unit`, `*.IntegrationTests` → `Integration`. -- `purview-build.json` filters pipeline runs to `[Category=Unit]`, so the shared pipeline never needs a WSLC - host; keep it that way unless the task requires real containers. +- `purview-build.json` filters the shared pipeline to `[Category=Unit]`, so it never needs a WSLC host. The + **Docker** integration suites (`Docker.IntegrationTests`, `Modules.DockerIntegrationTests`) are the one + exception: they target `net10.0` and run in the `.github/workflows/pr.yml` `integration-docker` job on + `ubuntu-latest`, which is the standing proof that the library and modules work off Windows/WSL. Keep + them portable; they remove the SDK's automatic `SharedTestingFramework` reference because that helper is + WSLC/Windows-only. The **WSLC** suites still never run in the shared pipeline. - WSLC integration tests run **serially** (`--max-parallel-test-modules 1` in the `Justfile`): a session exclusively locks its image-store VHD, so parallel modules fail with `0x80070020`. -- Use the shared `WslcTest` helper in `src/tests/SharedTestingFramework` for integration skip/availability - checks. Unit tests may use the modules' `BuildConfigurationForTesting()` internal hook. -- `WslContainers.UnitTests/ConsumerRequirementsTests.cs` guards the published target framework - (`.NETCoreApp,Version=v11.0` plus `Windows10.0.19041.0`); keep it in step with - [Consumer Requirements](docs/wiki/Consumer-Requirements.md). +- Use the shared `WslcTest` helper in `src/tests/SharedTestingFramework` for WSLC integration + skip/availability checks; the Docker suites use their own `DockerTest` helper. Unit tests may use the + modules' `BuildConfigurationForTesting()` internal hook. +- `Wsl.UnitTests/ConsumerRequirementsTests.cs` guards the published target framework of the Windows + build (`.NETCoreApp,Version=v10.0` plus `Windows10.0.19041.0`), which the test project binds; keep it + in step with [Consumer Requirements](docs/wiki/Consumer-Requirements.md). - `just verify-consumers` packs and then builds throwaway consumer projects to assert the documented guard errors and workarounds. It restores from nuget.org, so it stays out of the `[Category=Unit]` filter and is a local/explicit step. @@ -142,7 +183,11 @@ Commit messages follow Conventional Commits enforced by the `commit-msg` lefthoo `purview-dev/build/.github/workflows/purview-build.yml`, with pack and validation enabled. - `.github/workflows/release.yml` runs the shared release pipeline with `release-mode: NuGet` on a push to `main`. -- Both workflows pin `dotnet-version` to `global.json`'s `sdk.version`; keep them in sync, and keep +- `.github/workflows/integration-wsl.yml` is a **manual** (`workflow_dispatch`) workflow that runs the + WSLC integration suites on a self-hosted Windows runner with the custom `wslc` label (documented in + [Testing](docs/wiki/Testing.md)); `.github/actionlint.yaml` declares that label. It is never attached to + `pull_request` or `push`. +- The workflows pin `dotnet-version` to `global.json`'s `sdk.version`; keep them in sync, and keep `purview-build.json` pointing at `src/WSLTestContainers.slnx`. The shared workflow runs on `ubuntu-latest`, which is why `EnableWindowsTargeting=true` must stay in `src/Directory.Build.props`. diff --git a/Directory.Packages.props b/Directory.Packages.props index 610dc72..641e840 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,20 +4,19 @@ + - - - + diff --git a/Justfile b/Justfile index d222b55..d314846 100644 --- a/Justfile +++ b/Justfile @@ -121,6 +121,28 @@ verify-consumers *args: just pack pwsh -NoProfile -File scripts/verify-consumers.ps1 {{ args }} +# ----------------------------------------------------------------------------- +# Samples +# ----------------------------------------------------------------------------- + +# Runs the WSLC getting-started sample: needs WSL Containers, starts a real container +[group('Samples')] +sample-wsl: + echo "Running {{ BLUE }}samples/getting-started/WslSample{{ NORMAL }} (needs WSL Containers)..." + dotnet run --project samples/getting-started/WslSample/WslSample.csproj + +# Runs the auto (zero-config) getting-started sample: picks WSL Containers on Windows or Docker elsewhere +[group('Samples')] +sample-auto: + echo "Running {{ BLUE }}samples/getting-started/AutoSample{{ NORMAL }} (WSL Containers on Windows, Docker elsewhere)..." + dotnet run --project samples/getting-started/AutoSample/AutoSample.csproj + +# Runs the Docker getting-started sample: needs a reachable Docker daemon, starts a real container +[group('Samples')] +sample-docker: + echo "Running {{ BLUE }}samples/getting-started/DockerSample{{ NORMAL }} (needs a Docker daemon)..." + dotnet run --project samples/getting-started/DockerSample/DockerSample.csproj + # ----------------------------------------------------------------------------- # Formatting # ----------------------------------------------------------------------------- diff --git a/README.md b/README.md index 61a5e0d..967634f 100644 --- a/README.md +++ b/README.md @@ -1,33 +1,57 @@ -# Purview.WslContainers +# Purview.Containers -[![NuGet version](https://img.shields.io/nuget/v/Purview.WslContainers.svg)](https://www.nuget.org/packages/Purview.WslContainers) -[![Release](https://github.com/purview-dev/wsl-containers/actions/workflows/release.yml/badge.svg)](https://github.com/purview-dev/wsl-containers/actions/workflows/release.yml) +[![NuGet version](https://img.shields.io/nuget/v/Purview.Containers.svg)](https://www.nuget.org/packages/Purview.Containers) +[![Release](https://github.com/purview-dev/containers/actions/workflows/release.yml/badge.svg)](https://github.com/purview-dev/containers/actions/workflows/release.yml) -A **WSLC-native** Testcontainers-style library for .NET that runs throwaway Linux containers for integration testing on **Microsoft WSL Containers (WSLC)** — with no Docker installation. +A Testcontainers-style library for .NET that runs throwaway Linux containers for integration testing — on **Microsoft WSL Containers (WSLC)** with no Docker installation, or on **Docker** through Testcontainers. -Built directly against the `Microsoft.WSL.Containers` NuGet package (the WSLC managed C# API). No `wslc.exe`/`wsl.exe`/`docker` CLI, no Docker.DotNet, no Testcontainers internally. +> **Two backends, one API — one reference.** Containers are created through the backend-neutral +> `Purview.Containers.Core` abstractions, so the same test suite runs on **WSL Containers** +> (`Purview.Containers.Wsl`) or **Docker** (`Purview.Containers.Docker`, driven by Testcontainers). Add the +> umbrella package `Purview.Containers` to a plain `net10.0` project — no Windows target framework needed — +> and it brings the abstractions plus both backends, gets WSLC on a Windows developer machine and Docker on +> a Linux CI runner **without changing a line of test code or configuration**, and can be pinned with +> `PURVIEW_CONTAINERS_BACKEND`. The `Purview.Containers.Wsl` package is multi-target: its `net10.0` facade +> loads the WSLC implementation at run time on Windows and reports `wsl` as unavailable everywhere else. +> +> **[Using it in your tests (auto)](docs/wiki/Using-in-Your-Tests.md)** — the copy-paste zero-config shape. +> +> **[Backends: WSLC or Docker](docs/wiki/Backends.md)** — comparison, side-by-side project setup, CI +> example and troubleshooting. + +The WSLC backend is built directly against the `Microsoft.WSL.Containers` NuGet package (the WSLC managed C# API) — no `wslc.exe`/`wsl.exe`/`docker` CLI. The Docker backend is built on [Testcontainers for .NET](https://dotnet.testcontainers.org/). > **Experimental.** This project is an experiment in running throwaway containers through Microsoft > WSL Containers. The public API, defaults and packaging rules can change between prereleases, and > there is no production support guarantee. Pin the exact package version you build against, and read > [Consumer Requirements](docs/wiki/Consumer-Requirements.md) before adopting it. -> **Status: Phase 8 in progress.** Core runtime, wait strategies, the `Image`/`Tag` parser, registry auth, -> observability, hardening, and **seven service modules** (PostgreSQL, Redis, SQL Server, RabbitMQ, Azurite, -> NATS, MySQL). **Images are shared by default** (`StorageMode.Shared`): sessions reuse a stable -> image store (`%LOCALAPPDATA%\Purview\WslContainers\images`) so images are pulled once, not per session; -> `StorageMode.PerSession` provides isolation. 76 unit tests pass across all modules. +> **Status: preview.** Backend-neutral abstractions, two backends (WSL Containers and Docker), wait +> strategies, the `Image`/`Tag` parser, registry auth, observability, hardening, and **seven service +> modules** (PostgreSQL, Redis, SQL Server, RabbitMQ, Azurite, NATS, MySQL). On WSLC, **images are shared +> by default** (`StorageMode.Shared`): sessions reuse a stable image store +> (`%LOCALAPPDATA%\Purview\WslContainers\images`) so images are pulled once, not per session; +> `StorageMode.PerSession` provides isolation. Runnable samples live in `samples/getting-started`. ## Prerequisites -- Windows 10/11. -- **WSL Containers**, installed via `wsl --install --no-distribution` (verified against WSL 3.0.1.0). -- .NET SDK 11 (the repo pins `11.0.100-rc.1`). -- A **consuming project must be a .NET 11 project that targets Windows specifically** — - `net11.0-windows10.0.19041.0`, x64 or arm64. The packages ship MSBuild defaults for the supporting - settings; the full contract, the exact errors raised when it is not met, and the - `EnableWindowsTargeting` workaround for non-Windows CI agents are documented in - [Consumer Requirements](docs/wiki/Consumer-Requirements.md). +Pick a backend — see [Backends: WSLC or Docker](docs/wiki/Backends.md) for the comparison. + +**WSL Containers backend** + +- Windows 10/11 with **WSL Containers**, installed via `wsl --install --no-distribution` (verified against + WSL 3.0.1.0). +- A consuming project that is **.NET 10 or later**. A platform-neutral `net10.0` project binds the + portable facade and gets automatic WSLC-or-Docker selection; 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 the supporting settings. + +**Docker backend** + +- Any reachable Docker daemon, and a `net10.0` (or later) project on any platform. + +.NET SDK 11 is required to build this repository (it pins `11.0.100-rc.1`); SDK 10 is enough for a Docker +consumer. Verify: @@ -36,11 +60,21 @@ wsl --version # WSL Containers installed (verified against 3.0.1.0) wslc version # e.g. 3.0.1.0 ``` -The library reports missing prerequisites via `WslContainerRuntime.GetInfoAsync()`; it never installs or updates WSL on its own. +```bash +docker info # a Docker daemon the tests can reach +``` + +The library reports missing prerequisites via `WslContainerRuntime.GetInfoAsync()` (WSLC) or +`DockerContainerBackend.GetInfoAsync()` (Docker); it never installs a runtime on its own. + +The full consumer contract, the exact errors raised when it is not met, and the `EnableWindowsTargeting` +workaround for non-Windows CI agents are documented in +[Consumer Requirements](docs/wiki/Consumer-Requirements.md). ## Target developer experience -The generic container API below is **implemented and working**: +The generic container API below is **implemented and working** — and the identical code runs on either +backend (see [Backends: WSLC or Docker](docs/wiki/Backends.md)): ```csharp await using var container = new ContainerBuilder() @@ -104,28 +138,28 @@ string amqp = rabbitMq.GetConnectionString(); ``` src/ - WslContainers/ core runtime (Containers, Images, Runtime, Networking, Mounts, Diagnostics) - WslContainers.PostgreSql/ PostgreSQL module (builder, container, Npgsql connection string) - WslContainers.Redis/ Redis module (builder, container, StackExchange.Redis connection string) - WslContainers.MsSql/ SQL Server module (builder, container, SqlClient connection string) - WslContainers.RabbitMq/ RabbitMQ module (builder, container, AMQP + management endpoints) - WslContainers.Azurite/ Azurite module (builder, container, blob/queue/table endpoints) - WslContainers.Nats/ NATS module (builder, container, client + monitoring endpoints) - WslContainers.MySql/ MySQL module (builder, container, MySqlConnector connection string) + Wsl/ core runtime + WSL Containers backend (package Purview.Containers.Wsl) + PostgreSql/ PostgreSQL module (builder, container, Npgsql connection string) + Redis/ Redis module (builder, container, StackExchange.Redis connection string) + MsSql/ SQL Server module (builder, container, SqlClient connection string) + RabbitMq/ RabbitMQ module (builder, container, AMQP + management endpoints) + Azurite/ Azurite module (builder, container, blob/queue/table endpoints) + Nats/ NATS module (builder, container, client + monitoring endpoints) + MySql/ MySQL module (builder, container, MySqlConnector connection string) tests/ SharedTestingFramework/ shared WSLC skip/helper for integration tests - WslContainers.UnitTests/ - WslContainers.IntegrationTests/ - WslContainers.PostgreSql.UnitTests/ - WslContainers.PostgreSql.IntegrationTests/ - WslContainers.Redis.UnitTests/ - WslContainers.Redis.IntegrationTests/ - WslContainers.MsSql.UnitTests/ - WslContainers.MsSql.IntegrationTests/ - WslContainers.RabbitMq.UnitTests/ - WslContainers.RabbitMq.IntegrationTests/ - WslContainers.Azurite.UnitTests/ - WslContainers.Azurite.IntegrationTests/ + Wsl.UnitTests/ + Wsl.IntegrationTests/ + PostgreSql.UnitTests/ + PostgreSql.IntegrationTests/ + Redis.UnitTests/ + Redis.IntegrationTests/ + MsSql.UnitTests/ + MsSql.IntegrationTests/ + RabbitMq.UnitTests/ + RabbitMq.IntegrationTests/ + Azurite.UnitTests/ + Azurite.IntegrationTests/ spikes/ WslcSpikes/ Phase 0 investigation harness (run: see below) docs/ @@ -142,7 +176,8 @@ The project documentation lives in [`docs/wiki`](docs/wiki/Home.md) and is publi (`mkdocs.yml`, `docs_dir: docs/wiki`, aggregated by the purview-dev website): - [Getting Started](docs/wiki/Getting-Started.md) — prerequisites, first container, first module. -- [Consumer Requirements](docs/wiki/Consumer-Requirements.md) — the .NET 11 + Windows target framework contract, the `PWC0001`/`PWC0002` guards, and the CI workarounds. +- [Backends: WSLC or Docker](docs/wiki/Backends.md) — how to choose, side-by-side project setup, CI example, troubleshooting. +- [Consumer Requirements](docs/wiki/Consumer-Requirements.md) — the target-framework contract, the `PCC0001`/`PCC0002` guards, and the CI workarounds. - [Architecture](docs/wiki/Architecture.md) — the shared session model, concurrency and cleanup decisions. - [Lifecycle](docs/wiki/Lifecycle.md), [Networking](docs/wiki/Networking.md), [Wait Strategies](docs/wiki/Wait-Strategies.md). - [Modules](docs/wiki/Modules.md) — the module contract and every shipped module. @@ -151,7 +186,7 @@ The project documentation lives in [`docs/wiki`](docs/wiki/Home.md) and is publi - [Contributing](docs/wiki/Contributing.md) and [Contributing Modules](docs/wiki/Contributing-Modules.md). Every package also ships its own `README.md` (from `src/src//Sdk/README.md`), so -`dotnet add package Purview.WslContainers.` brings documentation specific to that package. +`dotnet add package Purview.Containers.` brings documentation specific to that package. The PostgreSQL, Redis, SQL Server and RabbitMQ modules work today: @@ -233,18 +268,25 @@ just test # dotnet test, one test module at a time # "Maximum Parallel Test Projects" to 1) before running the WSLC integration tests. ``` -> The `WslContainers.IntegrationTests` module runs 27 real containers in one session and takes ~4 +To watch a container start end to end, run a sample — the same code, on either backend: + +```powershell +just sample-wsl # needs WSL Containers +just sample-docker # needs a Docker daemon +``` + +> The `Wsl.IntegrationTests` module runs 27 real containers in one session and takes ~4 > minutes because WSLC serialises container operations; expect slow-test warnings while it runs. ### Verifying the consumer contract ```powershell -just verify-consumers # pack, then build 12 throwaway consumer projects against ./artifacts +just verify-consumers # pack, then build 16 throwaway consumer projects against ./artifacts just verify-consumers -Keep # same, keeping the generated projects for inspection ``` `just verify-consumers` asserts every claim in [Consumer Requirements](docs/wiki/Consumer-Requirements.md): -the happy path, the shipped `buildTransitive` defaults, the `PWC0001`/`PWC0002` guards, and the +the happy path, the shipped `buildTransitive` defaults, the `PCC0001`/`PCC0002` guards, and the non-.NET-11 escape hatch that deliberately does not work. It needs network access and is therefore a local step rather than part of the `[Category=Unit]` pipeline filter. diff --git a/docs/wiki/Architecture.md b/docs/wiki/Architecture.md index 572d7fe..07216b0 100644 --- a/docs/wiki/Architecture.md +++ b/docs/wiki/Architecture.md @@ -1,29 +1,68 @@ # Architecture -Design for a WSLC-native Testcontainers-style .NET library: `Purview.WslContainers`. +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: + ``` -WslContainerRuntime (process singleton, IAsyncDisposable) - ├─ 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) - -IContainerRuntime - Task GetInfoAsync(CancellationToken ct = default) - Task GetSessionAsync(CancellationToken ct = default) - Task InitializeAsync(...) +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 + └─ 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 : IAsyncDisposable StartAsync / StopAsync / DisposeAsync / ExecAsync / GetMappedPublicPort / 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.md). + ## Session lifetime model (chosen after spikes) **One shared, process-wide session.** Findings that drove this: @@ -37,7 +76,7 @@ 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 — EXP S1/S14) and a **stable storage path** (default `%LOCALAPPDATA%\Purview.WslContainers\sessions\{name}\`), configurable. 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. `IContainerRuntime` is the seam so advanced users/tests can substitute a per-container-session runtime for isolation experiments. +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. @@ -46,7 +85,7 @@ Design rules: - 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` / `WSL_CONTAINERS_STORAGE_PATH`) + process runs. Set `StorageMode.PerSession` (or an explicit `StoragePath` / `PURVIEW_CONTAINERS_STORAGE_PATH`) for isolation. 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` — EXP S18). The lock is taken lazily on the @@ -68,7 +107,7 @@ Design rules: - **Random host port = native `windowsPort=0`** (race-free, EXP S4). 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 (EXP S12). -- UDP → `WslContainerNotSupportedException`. +- UDP → `ContainerNotSupportedException`. ## Concurrency @@ -83,7 +122,7 @@ on the same path can start its session successfully and only fail later on its f (`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`/`WSL_CONTAINERS_STORAGE_PATH`/`StorageMode.PerSession` configuration opts out of the +`StoragePath`/`PURVIEW_CONTAINERS_STORAGE_PATH`/`StorageMode.PerSession` configuration opts out of the fallback. Isolated stores are transient and are removed when their session terminates. ## Cleanup & reaper decision @@ -97,7 +136,7 @@ fallback. Isolated stores are transient and are removed when their session termi ## Observability -- `System.Diagnostics.ActivitySource("Purview.WslContainers")` 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. +- `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. diff --git a/docs/wiki/Backends.md b/docs/wiki/Backends.md new file mode 100644 index 0000000..137e930 --- /dev/null +++ b/docs/wiki/Backends.md @@ -0,0 +1,389 @@ +# 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, in code or from the +environment. + +- `**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. + +## 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 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 | + +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 on a Windows machine, Docker elsewhere (one project) + +```bash +dotnet add package Purview.Containers.Wsl +``` + +```xml + + + net10.0 + enable + enable + + + + + +``` + +A platform-neutral `net10.0` project binds the portable facade, so it selects WSLC on a Windows host +and Docker everywhere else. 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.md). + +### Option 2 — Docker anywhere + +```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 — one project, both backends (auto) + +Reference both backends from a single **platform-neutral** project. `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.Wsl +dotnet add package Purview.Containers.Docker +``` + +```xml + + + net10.0 + enable + enable + + + + + + +``` + +`auto` probes both 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. + +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.Wsl # ...or 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.md), 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.md) — install, first container, first typed module. +- [Consumer Requirements](Consumer-Requirements.md) — the target-framework contract, the guards and the workarounds. +- [Architecture](Architecture.md#backend-selection) — how resolution and registration work internally. +- [Modules](Modules.md) — the service modules and their readiness strategies. + + + diff --git a/docs/wiki/Consumer-Requirements.md b/docs/wiki/Consumer-Requirements.md index a6cc670..f6010eb 100644 --- a/docs/wiki/Consumer-Requirements.md +++ b/docs/wiki/Consumer-Requirements.md @@ -1,20 +1,42 @@ # Consumer Requirements -> **Experimental.** `Purview.WslContainers.*` is an experimental project: the public API, the +> **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. -Every package targets `net11.0-windows10.0.19041.0` and is built on the -`Microsoft.WSL.Containers` WinRT projection, so a consuming project **must be a .NET 11 (or later) -project that targets Windows specifically**. This page is the authoritative statement of that -contract, of the workarounds that exist for it, and of how each of them is verified. +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 the *same* `net10.0` test +project 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.md). ## 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 - net11.0-windows10.0.19041.0 + net10.0-windows10.0.19041.0 x64 10.0.26100.80 @@ -29,33 +51,41 @@ file, and because the defaults only fill in a value when you have not chosen one | # | Requirement | Why it exists | | --- | --- | --- | -| 1 | **.NET 11 or later** | Every `lib/` asset in every package is built for `net11.0-windows10.0.19041.0`. | -| 2 | **A Windows-specific target framework that names the OS version** (`net11.0-windows10.0.19041.0` or later) | The `Microsoft.WSL.Containers` projection only ships Windows assets. `net11.0-windows` on its own is not enough. | +| 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 11 or later +### 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. -The repository, the packages and the tests all target .NET 11 (`global.json` pins -`11.0.100-rc.1.26425.128`). A `net10.0-windows…` or `net8.0-windows…` project cannot consume the -packages; see [Non-.NET-11 Windows projects](#workaround-consuming-from-a-non-net-11-windows-project). +### 2. A Windows-specific target framework (optional) -### 2. A Windows-specific target framework +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 -net11.0-windows10.0.19041.0 +net10.0 ``` +A Windows target framework binds the implementation directly (no run-time load): + ```xml -net11.0-windows -net11.0 +net10.0-windows10.0.19041.0 ``` -`net11.0-windows` resolves its platform version to `7.0`, which is older than the `10.0.19041.0` -the packages were built against, so NuGet selects no compile assets for it. Always spell out the +```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 -`net11.0-windows10.0.19041.0` project consumes without issue.) +`net10.0-windows10.0.19041.0` project consumes without issue.) ### 3. 64-bit consumers only @@ -63,13 +93,13 @@ The native WSL Containers SDK is shipped for `win-x64` and `win-arm64` only. A 3 consumer builds but cannot load `wslcsdk.dll` at runtime, so the packages fail the build instead: ```text -error PWC0002: Purview.WslContainers requires a 64-bit (x64 or arm64) consumer ... +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 `net11.0-windows10.0.19041.0` resolves a lower pack +`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 @@ -88,25 +118,31 @@ with WSL Containers installed (`wsl --install --no-distribution`) — see ## What the packages do for you -`Purview.WslContainers` ships two `buildTransitive` MSBuild files. NuGet imports them for **direct -references and transitive references**, so every module package -(`Purview.WslContainers.PostgreSql`, `Redis`, `MsSql`, `RabbitMq`, `Azurite`, `Nats`, `MySql`) gets -them too through its dependency on the core package. +`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.WslContainers.props` | Defaults `WindowsSdkPackageVersion` to `10.0.26100.80` when the consumer has not set it. | -| `buildTransitive/Purview.WslContainers.targets` | Defaults `PlatformTarget` to `x64` when it is unset (or `AnyCPU`) and no `RuntimeIdentifier` is selected, and fails the build with `PWC0001`/`PWC0002` when the target framework or the platform cannot be supported. | +| `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: +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 - net11.0-windows10.0.19041.0 + net10.0 - + + ``` @@ -116,23 +152,23 @@ 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 — `PWC0001` names the required target framework, whereas an empty +> 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 | | --- | --- | --- | --- | -| `PWC0001` | the shipped guard target | The consumer's target framework is not .NET 11+ targeting Windows 10.0.19041.0+ | Retarget as above, or make the reference conditional when multi-targeting. | -| `PWC0002` | the shipped guard target | The consumer is not 64-bit | Remove your `PlatformTarget`, set it to `x64`/`arm64`, or choose a matching `RuntimeIdentifier`. | +| `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; `PWC0001` explains the same problem with a clearer message. | +| `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 -`net11.0-windows…` projects can be restored, compiled and unit-tested on Linux or macOS, provided +`net*-windows…` projects can be restored, compiled and unit-tested on Linux or macOS, provided the build opts in: ```xml @@ -155,9 +191,9 @@ What it does and does not do: - ❌ 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 non-.NET-11 Windows project +## Workaround: consuming from a project older than .NET 10 -**There is none.** A project that targets Windows but not .NET 11 (for example +**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. @@ -167,58 +203,68 @@ target framework for the project's package references: ```xml net8.0-windows10.0.19041.0 - net11.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`. `PWC0001` fires as well. +`CS0234`/`CS0246`. `PCC0001` fires as well. -Even if it did compile, it would not help: the `lib/` assemblies are .NET 11 assemblies, so they can -only be loaded by a .NET 11 runtime. A consumer would have to move to .NET 11 to run them anyway. +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 11 Windows test project** (which can reference the older project) rather than trying to +**.NET 10+ test project** (which can reference the older project) rather than trying to consume the packages from the older project. ## Multi-targeting -Only the Windows inner build may reference the packages, so make the reference conditional: +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 - net11.0;net11.0-windows10.0.19041.0 + net10.0;net10.0-windows10.0.19041.0 - - + + ``` -An unconditional reference fails the whole build: the `net11.0` inner build reports `PWC0001`. -Keep any code that uses the library behind the same condition — -`#if WINDOWS` (defined by the SDK for Windows target frameworks) or a conditional `Compile` item — -so the non-Windows inner build can still compile. +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 twelve throwaway consumer projects against the +`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` | `PWC0002` | +| 03 | …with `PlatformTarget=x86` | `PCC0002` | | 04 | …with no settings at all | builds via the shipped `buildTransitive` defaults | -| 05 | a module package with no settings | builds (the defaults are transitive) | -| 06 | `net8.0-windows` + `AssetTargetFallback` | `PWC0001` — the escape hatch does not work | -| 07 | `net8.0-windows` | `PWC0001` | -| 08 | plain `net11.0` | `PWC0001` | +| 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) | `PWC0001` | +| 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` | `PWC0001` | +| 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 diff --git a/docs/wiki/Contributing-Modules.md b/docs/wiki/Contributing-Modules.md index 5945bdd..ab7e901 100644 --- a/docs/wiki/Contributing-Modules.md +++ b/docs/wiki/Contributing-Modules.md @@ -1,22 +1,22 @@ # Contributing a module -How to add a new service module to `Purview.WslContainers`. +How to add a new service module to `Purview.Containers`. ## Files ``` -src/WslContainers.MyService/ - WslContainers.MyService.csproj -> PackageId Purview.WslContainers.MyService +src/MyService/ + MyService.csproj -> PackageId Purview.Containers.MyService MyServiceConfiguration.cs -> immutable record, module fields MyServiceBuilder.cs -> fluent builder MyServiceContainer.cs -> container, connection string / endpoints -tests/WslContainers.MyService.UnitTests/ -tests/WslContainers.MyService.IntegrationTests/ +tests/MyService.UnitTests/ +tests/MyService.IntegrationTests/ ``` ## Steps -1. **Reference the core**: `` (the Purview SDK adds the right `InternalsVisibleTo`/pack defaults). +1. **Reference the core**: `` (the Purview SDK adds the right `InternalsVisibleTo`/pack defaults). 2. **Configuration record** — derive from `ContainerConfiguration`, add module fields; credentials as `Secret`: ```csharp @@ -60,7 +60,7 @@ public class MyServiceBuilder : ContainerBuilder new(configuration, Runtime ?? WslContainerRuntime.Instance); + => new(configuration, Backend); } ``` @@ -71,8 +71,8 @@ public class MyServiceBuilder : ContainerBuilder **Experimental.** The API, defaults and packaging rules can change between prereleases; there is no > production support guarantee. ## 1. Reference a package -Reference the core runtime directly for a generic container: +Reference a backend package for generic containers: ```bash -dotnet add package Purview.WslContainers +dotnet add package Purview.Containers.Wsl # WSL Containers (WSLC on Windows, Docker elsewhere) +dotnet add package Purview.Containers.Docker # Docker / Testcontainers (any platform) ``` -or a service module, which depends on the core package: +or a service module, which is backend-neutral and needs a backend package alongside it: ```bash -dotnet add package Purview.WslContainers.PostgreSql +dotnet add package Purview.Containers.PostgreSql +dotnet add package Purview.Containers.Wsl # ...or Purview.Containers.Docker ``` +> **Want it to just work without choosing a backend?** Add `Purview.Containers` (the umbrella — it brings +> the abstractions and both backends) to a platform-neutral `net10.0` project and let automatic selection +> decide — WSLC on a Windows machine, Docker everywhere else, with no code or configuration change. See +> [Using it in your tests](Using-in-Your-Tests.md). + ## 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.WslContainers; -using Purview.WslContainers.Waiting; +using Purview.Containers; +using Purview.Containers.Waiting; await using var container = new ContainerBuilder() .WithImage("docker.io/library/redis:latest") @@ -49,15 +66,15 @@ await container.StartAsync(); ushort port = container.GetMappedPublicPort(6379); ``` -`Build()` validates the accumulated configuration; `StartAsync()` ensures the session and image, creates the -container, starts it, and only returns once every configured wait strategy is satisfied. `DisposeAsync()` -stops and deletes the container (and never terminates the shared session). +`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.WslContainers.PostgreSql; +using Purview.Containers.PostgreSql; await using var postgres = new PostgreSqlBuilder() .WithDatabase("tests") @@ -71,18 +88,26 @@ await using var connection = new NpgsqlConnection(postgres.GetConnectionString() await connection.OpenAsync(); ``` -Each module ships a bespoke README inside the package (`Purview.WslContainers.`) and a page in the +Each module ships a bespoke README inside the package (`Purview.Containers.`) and a page in the [Modules](Modules.md) reference. ## 4. Run the tests ```powershell just test # dotnet test, one test module at a time -just test '/*/*/*/*[Category=Unit]' # unit tests only (no WSLC required) +just test '/*/*/*/*[Category=Unit]' # unit tests only (no runtime required) ``` -Integration tests need a working WSLC installation. A WSLC session exclusively locks its image-store VHD, so -test modules run serially by default — see [Testing](Testing.md) for the details and how to run a subset. +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.md) 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 diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md index a9cadae..82fe737 100644 --- a/docs/wiki/Home.md +++ b/docs/wiki/Home.md @@ -1,12 +1,12 @@ -# Purview.WslContainers Wiki +# Purview.Containers Wiki -Purview.WslContainers is a **WSLC-native** Testcontainers-style library for .NET: it runs throwaway Linux -containers for integration testing on **Microsoft WSL Containers (WSLC)** with **no Docker installation**. -It is built directly against the `Microsoft.WSL.Containers` managed package — no `wslc.exe`/`wsl.exe`/ -`docker` CLI, no Docker.DotNet, and no Testcontainers dependency. +Purview.Containers is a Testcontainers-style library for .NET that runs throwaway Linux containers for +integration testing on **Microsoft WSL Containers (WSLC)** with **no Docker installation**, or on +**Docker** through Testcontainers. The WSLC backend is built directly against the +`Microsoft.WSL.Containers` managed package — no `wslc.exe`/`wsl.exe`/`docker` CLI. This wiki is the project documentation hub. The packages are published under the -`Purview.WslContainers.*` package IDs. +`Purview.Containers.*` package IDs. > **Experimental.** This project is an experiment in running throwaway containers through Microsoft > WSL Containers. The public API, defaults and packaging rules can change between prereleases, and @@ -16,6 +16,7 @@ This wiki is the project documentation hub. The packages are published under the ## Start here - [Getting Started](Getting-Started.md) +- [Backends: WSLC or Docker](Backends.md) - [Consumer Requirements](Consumer-Requirements.md) - [Architecture](Architecture.md) - [Lifecycle](Lifecycle.md) @@ -27,14 +28,17 @@ This wiki is the project documentation hub. The packages are published under the | Package | Purpose | | --- | --- | -| `Purview.WslContainers` | Core runtime: `ContainerBuilder`, sessions, images, ports, mounts, wait strategies, logs/exec, diagnostics. | -| `Purview.WslContainers.PostgreSql` | PostgreSQL container (`postgres:17`), `pg_isready` readiness, Npgsql connection string. | -| `Purview.WslContainers.Redis` | Redis-compatible container (`redis:7`), `redis-cli ping` readiness; also usable with Valkey/Garnet. | -| `Purview.WslContainers.MsSql` | SQL Server container (`mssql/server:2022-latest`), host-side `SqlClient` readiness, explicit EULA acceptance. | -| `Purview.WslContainers.RabbitMq` | RabbitMQ container (`rabbitmq:3-management`), AMQP + management endpoints. | -| `Purview.WslContainers.Azurite` | Azure Storage emulator (`azure-storage/azurite`), blob/queue/table endpoints. | -| `Purview.WslContainers.Nats` | NATS broker (`nats:2`), client + monitoring endpoints. | -| `Purview.WslContainers.MySql` | MySQL container (`mysql:8`), host-side `MySqlConnector` readiness. | +| `Purview.Containers` | The umbrella: references `Purview.Containers.Core` and both backends, so one reference runs the same tests on WSLC on Windows and Docker elsewhere. No code of its own. | +| `Purview.Containers.Core` | Backend-neutral abstractions: the container contract, builders, wait strategies, images, networking, mounts, diagnostics, backend selection (namespace `Purview.Containers`). | +| `Purview.Containers.Wsl` | The WSL Containers backend: sessions, images, ports, mounts, wait strategies, logs/exec, diagnostics for WSLC. | +| `Purview.Containers.Docker` | The Docker backend: the same containers on any reachable Docker daemon, driven by Testcontainers. | +| `Purview.Containers.PostgreSql` | PostgreSQL container (`postgres:17`), `pg_isready` readiness, Npgsql connection string. | +| `Purview.Containers.Redis` | Redis-compatible container (`redis:7`), `redis-cli ping` readiness; also usable with Valkey/Garnet. | +| `Purview.Containers.MsSql` | SQL Server container (`mssql/server:2022-latest`), host-side `SqlClient` readiness, explicit EULA acceptance. | +| `Purview.Containers.RabbitMq` | RabbitMQ container (`rabbitmq:3-management`), AMQP + management endpoints. | +| `Purview.Containers.Azurite` | Azure Storage emulator (`azure-storage/azurite`), blob/queue/table endpoints. | +| `Purview.Containers.Nats` | NATS broker (`nats:2`), client + monitoring endpoints. | +| `Purview.Containers.MySql` | MySQL container (`mysql:8`), host-side `MySqlConnector` readiness. | ## Feature highlights @@ -45,7 +49,7 @@ This wiki is the project documentation hub. The packages are published under the ports, instead of probing for a free port first. - **Fail-fast configuration** — invalid images and tags are rejected at configuration time (`Image.Parse`), UDP mappings and port `0` are rejected at build time, and module invariants (licences, passwords, required - fields) throw `WslContainerConfigurationException` before a pull is attempted. + fields) throw `ContainerConfigurationException` before a pull is attempted. - **Composable readiness** — TCP, HTTP(S), log-message, exec and custom wait strategies, with timeouts, intervals, retries, and `ForAll`/`ForAny` composition. - **Module model without duplication** — modules supply defaults (image, ports, environment, readiness, @@ -55,15 +59,20 @@ This wiki is the project documentation hub. The packages are published under the ## Requirements -- Windows 10/11 with **WSL Containers**, installed via `wsl --install --no-distribution`. The library - is verified against WSL **3.0.1.0**; `wsl --version` and `wslc version` should both report 3.0.1.0 - or later. +- **WSL Containers backend:** Windows 10/11 with **WSL Containers**, installed via + `wsl --install --no-distribution`. Verified against WSL **3.0.1.0**; `wsl --version` and `wslc version` + should both report 3.0.1.0 or later. +- **Docker backend:** any reachable Docker daemon (Docker Desktop, Docker Engine in WSL2, or a CI runner). + Verify with `docker info`. - .NET SDK 11 to build (the repository pins `11.0.100-rc.1.26425.128` in `global.json`). -- A consuming project must be a **.NET 11 project targeting Windows specifically**: - `net11.0-windows10.0.19041.0`, built for x64 or arm64, with `WindowsSdkPackageVersion` - `10.0.26100.80` or later. See [Consumer Requirements](Consumer-Requirements.md) for the full - contract, the exact errors raised when it is not met, and the `EnableWindowsTargeting` workaround - for non-Windows CI agents. +- A consuming project must be a **.NET 10 or later** project when it uses the **WSL Containers backend**. + The recommended shape is platform-neutral (`net10.0`): it binds the portable facade and gets automatic + WSLC-or-Docker selection. A Windows target framework (`net10.0-windows10.0.19041.0`, x64 or arm64) binds + the implementation directly. The abstractions and the service modules target `net10.0` and are portable, + but they need a backend package to run — see + [Backends: WSLC or Docker](Backends.md) for the side-by-side setup. The full contract, the exact errors + raised when it is not met, and the `EnableWindowsTargeting` workaround for non-Windows CI agents are in + [Consumer Requirements](Consumer-Requirements.md). ```powershell wsl --version # WSL Containers installed (verified against 3.0.1.0) @@ -73,14 +82,39 @@ wslc version # e.g. 3.0.1.0 The library reports missing prerequisites through `WslContainerRuntime.GetInfoAsync()`; it never installs or updates WSL on its own. +## Choosing a backend + +Two backends implement the same API. Automatic detection is the default; pin one in code or from the +environment: + +```powershell +# auto (the default) | wsl | docker | +$env:PURVIEW_CONTAINERS_BACKEND = "wsl" +``` + +```csharp +ContainerBackends.Use(ContainerBackendSelection.Named("docker")); // or Use(new WslContainerBackend()) +``` + +That is the switch that lets one test suite use WSLC on a developer machine and Docker in CI. Automatic +detection prefers WSLC and falls through to Docker when WSL Containers is not installed; a **named** +backend never silently falls back. + +**[Backends: WSLC or Docker](Backends.md)** has the full comparison, side-by-side project setup, the CI +example and the troubleshooting reference. + ## Repository layout | Path | Purpose | | --- | --- | | `src/WSLTestContainers.slnx` | Canonical solution for restore, build, test and pack. | -| `src/src/WslContainers` | Core runtime (`Purview.WslContainers`): containers, images, runtime, networking, mounts, diagnostics. | +| `src/src/Containers` | Umbrella package (`Purview.Containers`): references Core and both backends; no code of its own. | +| `src/src/Core` | Backend-neutral abstractions (`Purview.Containers.Core`, namespace `Purview.Containers`): the container contract, builders and backend selection. | +| `src/src/Wsl` | WSL Containers backend (`Purview.Containers.Wsl`): containers, images, runtime, networking, mounts, diagnostics. | +| `src/src/Docker` | Docker backend (`Purview.Containers.Docker`): the same containers on a Docker daemon, via Testcontainers. | | `src/src/` | Service modules; each carries a bespoke `Sdk/README.md` that ships as the package README. | -| `src/tests` | TUnit unit and integration test projects. | +| `src/tests` | TUnit unit and integration test projects (WSLC and Docker suites). | +| `samples/getting-started` | Runnable WSLC and Docker samples (`just sample-wsl`, `just sample-docker`). | | `spikes/WslcSpikes` | Phase 0 investigation harness (`s1`..`s14` behaviour probes). | | `docs/wiki` | This wiki, aggregated by the purview-dev website. | | `purview-build.json` | Shared `Purview.Build` pipeline configuration, including the exhaustive pack manifest. | diff --git a/docs/wiki/Lifecycle.md b/docs/wiki/Lifecycle.md index 5245e98..7c95d63 100644 --- a/docs/wiki/Lifecycle.md +++ b/docs/wiki/Lifecycle.md @@ -1,6 +1,6 @@ # Lifecycle -Container lifecycle semantics for `Purview.WslContainers`, derived from the Phase 0 spikes. +Container lifecycle semantics for `Purview.Containers`, derived from the Phase 0 spikes. ## Public API diff --git a/docs/wiki/Modules.md b/docs/wiki/Modules.md index ee1b3bc..e4af0e7 100644 --- a/docs/wiki/Modules.md +++ b/docs/wiki/Modules.md @@ -1,10 +1,11 @@ # Modules -Module architecture for `Purview.WslContainers`. +Module architecture for `Purview.Containers`. ## Principle -Modules are thin packages layered on the core. A module supplies only: +Modules are thin packages layered on the backend-neutral abstractions +([`Purview.Containers.Core`](Architecture.md)). A module supplies only: - default image - default ports @@ -14,7 +15,10 @@ Modules are thin packages layered on the core. A module supplies only: - connection string / endpoint generation - module-specific convenience APIs -Modules must **not** duplicate container runtime infrastructure. +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 @@ -23,7 +27,7 @@ A generic CRTP base with immutable built configurations: ```csharp public abstract class ContainerBuilder where TBuilder : ContainerBuilder - where TContainer : WslContainer + where TContainer : IContainer where TConfiguration : ContainerConfiguration, new() { public TBuilder WithImage(string image) { /* accumulate */ return (TBuilder)this; } @@ -87,15 +91,15 @@ public sealed class PostgreSqlBuilder : ContainerBuilder new(configuration, Runtime ?? WslContainerRuntime.Instance); + => new(configuration, Backend); } -public sealed class PostgreSqlContainer : WslContainer +public sealed class PostgreSqlContainer : ContainerBase { private readonly PostgreSqlConfiguration configuration; - internal PostgreSqlContainer(PostgreSqlConfiguration configuration, IContainerRuntime runtime) - : base(configuration, runtime) => this.configuration = configuration; + internal PostgreSqlContainer(PostgreSqlConfiguration configuration, IContainerBackend backend) + : base(configuration, backend) => this.configuration = configuration; public string GetConnectionString() { @@ -118,13 +122,26 @@ public sealed class PostgreSqlContainer : WslContainer - 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 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 (Phase 3) | | Redis | `redis:7` | `redis-cli ping` | StackExchange.Redis | ✅ implemented (Phase 4) — also usable with Valkey/Garnet via `WithImage` | -| SQL Server | `mcr.microsoft.com/mssql/server:2022-latest` | host `SqlClient` connection | Microsoft.Data.SqlClient | ✅ implemented (Phase 5) — requires `.AcceptLicense()`; session needs ≥ 2000 MB memory | +| SQL Server | `mcr.microsoft.com/mssql/server:2022-latest` | host `SqlClient` connection | Microsoft.Data.SqlClient | ✅ implemented (Phase 5) — 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 (Phase 6) — 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 | diff --git a/docs/wiki/Networking.md b/docs/wiki/Networking.md index eb0c661..2b5b06c 100644 --- a/docs/wiki/Networking.md +++ b/docs/wiki/Networking.md @@ -1,6 +1,6 @@ # Networking -WSLC networking behaviour (verified in Phase 0) and how `Purview.WslContainers` models it. +WSLC networking behaviour (verified in Phase 0) and how `Purview.Containers` models it. ## Verified behaviour diff --git a/docs/wiki/Packaging.md b/docs/wiki/Packaging.md index 4087509..89c61c9 100644 --- a/docs/wiki/Packaging.md +++ b/docs/wiki/Packaging.md @@ -1,6 +1,6 @@ # Packaging -Every project under `src/src` is packable and ships as `Purview.WslContainers.*`; test and spike projects +Every project under `src/src` is packable and ships as `Purview.Containers.*`; test and spike projects never pack. Packages are produced into `./artifacts` with a matching `.snupkg`. ## What a package contains @@ -9,17 +9,24 @@ Each package ships exactly: | Entry | Source | | --- | --- | -| `lib/$(TFM)/Purview.WslContainers[.].dll` | The built assembly (`net11.0-windows10.0.19041`). | -| `lib/$(TFM)/Purview.WslContainers[.].xml` | XML documentation, generated because `GenerateDocumentationFile` is on for packable projects. | +| `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.WslContainers.props` | **Core package only.** Defaults `WindowsSdkPackageVersion` for consumers. | -| `buildTransitive/Purview.WslContainers.targets` | **Core package only.** Defaults `PlatformTarget` to `x64` and raises `PWC0001`/`PWC0002` for unsupported consumers. | +| `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`. +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 `net11.0-windows10.0.19041.0`, so every package is a **.NET 11, Windows-only** package. -Consumers must target a matching framework; see [Consumer Requirements](Consumer-Requirements.md). +`$(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.md). ## Package metadata @@ -31,7 +38,7 @@ Metadata is split by where its source of truth lives: | `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/wsl-containers`, the +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. @@ -82,10 +89,19 @@ alongside the `Sdk/README.md`. Removing content from a package means removing th 3. Add the package to `RequiredContent` in `purview-build.json`. 4. Prove it with `just pack` and `just pipeline-pack-validate`. -The two `Sdk/buildTransitive/` files belong to the **core** package only: a module inherits them -through its dependency on `Purview.WslContainers`. 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. +> **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 diff --git a/docs/wiki/Release-Flow.md b/docs/wiki/Release-Flow.md index 57ff8bb..18de8b2 100644 --- a/docs/wiki/Release-Flow.md +++ b/docs/wiki/Release-Flow.md @@ -1,6 +1,6 @@ # Release Flow -How this repository builds, versions, validates and publishes `Purview.WslContainers.*`. +How this repository builds, versions, validates and publishes `Purview.Containers.*`. ## Versioning @@ -56,9 +56,9 @@ the run with the failing module's output. 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/WSLTestContainers.slnx`. -The shared workflow runs on **`ubuntu-latest`**, so the Linux agent builds these -`net11.0-windows10.0.19041.0` projects. That only works because `src/Directory.Build.props` sets -`EnableWindowsTargeting=true`; removing it fails the pipeline with `NETSDK1100`. The agent has no WSL +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.md) and [Testing](Testing.md). diff --git a/docs/wiki/Testing.md b/docs/wiki/Testing.md index ad1588e..6c32b2d 100644 --- a/docs/wiki/Testing.md +++ b/docs/wiki/Testing.md @@ -6,18 +6,23 @@ How the test suite is organised, why it runs serially, and how to run a subset. Test projects are discovered under `src/tests` and the SDK stamps every test assembly with a TUnit category: -| Project suffix | Category | Needs WSLC | +| Project | Category | Runtime needed | | --- | --- | --- | -| `*.UnitTests` | `Unit` | No — pure logic, parsing, configuration and wait-strategy units, some with in-process fakes. | -| `*.IntegrationTests` | `Integration` | Yes — real containers on a shared WSLC session. | - -`purview-build.json` filters the pipeline run to `/*/*/*/*[Category=Unit]`, so the shared pipeline never -starts containers. Run integration projects explicitly when you have a WSLC host. - -> The shared `purview-dev/build` workflow runs on **`ubuntu-latest`**, so the pipeline builds these -> `net11.0-windows…` projects on Linux. That works because `src/Directory.Build.props` sets -> `EnableWindowsTargeting=true`; without it the SDK reports `NETSDK1100`. See -> [Consumer Requirements](Consumer-Requirements.md) for what that workaround does and does not +| `*.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.md) for what that workaround does and does not > cover. ```powershell @@ -26,6 +31,19 @@ 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.md) 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 @@ -43,9 +61,42 @@ 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 `WslContainers.IntegrationTests` module runs many real containers in one session and can take several +- 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. @@ -54,8 +105,8 @@ running the WSLC integration suites. ## Verifying the consumer contract -`WslContainers.UnitTests/ConsumerRequirementsTests.cs` guards the shape of the shipped library inside -the normal unit run: the assembly targets `.NETCoreApp,Version=v11.0`, targets `Windows10.0.19041.0` +`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.md), the package READMEs and the shipped `buildTransitive` defaults all have to move with it. @@ -66,7 +117,7 @@ throwaway consumer projects for every documented outcome (see outside the `[Category=Unit]` filter because it packs and restores from nuget.org: ```powershell -just verify-consumers # 12 consumer projects, all assertions +just verify-consumers # 19 consumer projects, all assertions just verify-consumers -Keep # same, keeping the generated projects for inspection ``` diff --git a/docs/wiki/Using-in-Your-Tests.md b/docs/wiki/Using-in-Your-Tests.md new file mode 100644 index 0000000..6beddd1 --- /dev/null +++ b/docs/wiki/Using-in-Your-Tests.md @@ -0,0 +1,124 @@ +# 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.md). + +## 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.md) 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.md#connection-strings). +- A live sample of this shape is `samples/getting-started/AutoSample` (`just sample-auto`). + +## Related + +- [Getting Started](Getting-Started.md) — install and the first container. +- [Backends: WSLC or Docker](Backends.md) — the comparison, side-by-side setup and CI examples. +- [Modules](Modules.md) — the service modules and their connection strings. +- [Consumer Requirements](Consumer-Requirements.md) — the target-framework contract and the guards. \ No newline at end of file diff --git a/docs/wiki/Wait-Strategies.md b/docs/wiki/Wait-Strategies.md index c64baaf..fae89b5 100644 --- a/docs/wiki/Wait-Strategies.md +++ b/docs/wiki/Wait-Strategies.md @@ -2,7 +2,7 @@ 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 (Phase 2).** Verified by integration tests (`tests/WslContainers.IntegrationTests/WaitStrategyTests.cs`). +> **Status: implemented (Phase 2).** Verified by integration tests (`tests/Wsl.IntegrationTests/WaitStrategyTests.cs`). ## Model @@ -27,7 +27,7 @@ Wait (factory) WaitStrategy.WithTimeout / .WithInterval / .WithRetries (fluent) ``` -- Waits run inside `StartAsync()` after the container starts. `StartAsync` only returns once every configured strategy is ready (or throws `WslContainerTimeoutException`). +- 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). diff --git a/docs/wiki/Wslc-Capability-Matrix.md b/docs/wiki/Wslc-Capability-Matrix.md index 13c22c8..fe6c262 100644 --- a/docs/wiki/Wslc-Capability-Matrix.md +++ b/docs/wiki/Wslc-Capability-Matrix.md @@ -56,4 +56,4 @@ Legend: **Implemented** = planned in this library; **Mapped** = native WSLC API 5. Per-container CPU/memory limits — session-level only. 6. TTY, `--user`, labels, DNS options, tmpfs/shm/ulimit in `ContainerSettings`/`ProcessSettings`. -Where a Testcontainers feature has no WSLC equivalent, the library must either (a) provide the closest supported behaviour and document it, or (b) fail fast with a clear `WslContainerNotSupportedException`. \ No newline at end of file +Where a Testcontainers feature has no WSLC equivalent, the library must either (a) provide the closest supported behaviour and document it, or (b) fail fast with a clear `ContainerNotSupportedException`. \ No newline at end of file diff --git a/docs/wiki/_Sidebar.md b/docs/wiki/_Sidebar.md index e962982..8b7bdd1 100644 --- a/docs/wiki/_Sidebar.md +++ b/docs/wiki/_Sidebar.md @@ -1,5 +1,7 @@ - [Home](Home.md) - [Getting Started](Getting-Started.md) +- [Using in Your Tests (auto)](Using-in-Your-Tests.md) +- [Backends: WSLC or Docker](Backends.md) - [Consumer Requirements](Consumer-Requirements.md) - [Architecture](Architecture.md) - [Lifecycle](Lifecycle.md) diff --git a/docs/wiki/index.md b/docs/wiki/index.md index db80aa2..434f717 100644 --- a/docs/wiki/index.md +++ b/docs/wiki/index.md @@ -1,14 +1,17 @@ -# Purview WSL Test Containers +# Purview Containers -WSLC-native throwaway Linux containers for .NET integration testing — Testcontainers-style APIs built -directly on **Microsoft WSL Containers**, with no Docker installation. +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.md). [Get started](Getting-Started.md){ .md-button .md-button--primary } -[Documentation overview](Home.md){ .md-button } +[Choose a backend](Backends.md){ .md-button } ## Guides - [Getting Started](Getting-Started.md) +- [Using in your tests (auto)](Using-in-Your-Tests.md) +- [Backends: WSLC or Docker](Backends.md) - [Consumer requirements](Consumer-Requirements.md) - [Architecture](Architecture.md) - [Lifecycle](Lifecycle.md) diff --git a/global.json b/global.json index 186257a..d12cc2a 100644 --- a/global.json +++ b/global.json @@ -4,9 +4,9 @@ "allowPrerelease": true }, "msbuild-sdks": { - "Purview.BuildSdk": "1.0.2.0" + "Purview.BuildSdk": "1.0.2.2" }, "test": { "runner": "Microsoft.Testing.Platform" } -} \ No newline at end of file +} diff --git a/mkdocs.yml b/mkdocs.yml index 5151edb..ee2e8c8 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,6 +1,6 @@ -site_name: Purview WSL Test Containers -site_description: Developer documentation for Purview WSL Test Containers -repo_url: https://github.com/purview-dev/wsl-containers +site_name: Purview Containers +site_description: Developer documentation for Purview Containers +repo_url: https://github.com/purview-dev/containers edit_uri: edit/main/docs/wiki/ docs_dir: docs/wiki diff --git a/package.json b/package.json index fca5f41..603cc1c 100644 --- a/package.json +++ b/package.json @@ -1,17 +1,17 @@ { - "name": "purview-wsl-containers", - "version": "1.0.0-prerelease.1", + "name": "purview-containers", + "version": "1.0.0-prerelease.2", "license": "MIT", "author": { "name": "Kieron Lanning", "url": "https://kieronlanning.dev/" }, - "homepage": "https://purview.dev/projects/wsl-containers/", + "homepage": "https://purview.dev/projects/containers/", "bugs": { - "url": "https://github.com/purview-dev/wsl-containers/issues" + "url": "https://github.com/purview-dev/containers/issues" }, "repository": { "type": "git", - "url": "git+https://github.com/purview-dev/wsl-containers.git" + "url": "git+https://github.com/purview-dev/containers.git" } -} \ No newline at end of file +} diff --git a/purview-build.json b/purview-build.json index f1050a7..07f44fd 100644 --- a/purview-build.json +++ b/purview-build.json @@ -15,53 +15,85 @@ // (assets/images/purview-logo-light.png, linked as Sdk/purview-logo-light.png). A package with // no rule here, or an entry matching none of its globs, fails validation. "RequiredContent": { - "purview.wslcontainers": [ - "lib/$(TFM)/Purview.WslContainers.dll", - "lib/$(TFM)/Purview.WslContainers.xml", + // Purview.Containers is the umbrella: it has no buildTransitive of its own, it just depends on + // Core + Wsl + Docker, whose assets flow transitively. + "purview.containers": [ + "lib/$(TFM)/Purview.Containers.dll", + "lib/$(TFM)/Purview.Containers.xml", + "README.md", + "purview-logo-light.png" + ], + // Purview.Containers.Core owns the backend registration assets (buildTransitive). + "purview.containers.core": [ + "lib/$(TFM)/Purview.Containers.Core.dll", + "lib/$(TFM)/Purview.Containers.Core.xml", + "README.md", + "purview-logo-light.png", + "buildTransitive/Purview.Containers.Core.props", + "buildTransitive/Purview.Containers.Core.targets" + ], + "purview.containers.docker": [ + "lib/$(TFM)/Purview.Containers.Docker.dll", + "lib/$(TFM)/Purview.Containers.Docker.xml", + "README.md", + "purview-logo-light.png", + "buildTransitive/Purview.Containers.Docker.props" + ], + // Purview.Containers.Wsl is multi-target: lib/net10.0 is the portable facade, and + // lib/net10.0-windows10.0.19041 is the WSLC implementation. The portable consumer loads + // that implementation (plus the WSLC projection, its Windows SDK dependencies and the + // native SDK) from payload// via the buildTransitive targets. + "purview.containers.wsl": [ + "lib/net10.0/Purview.Containers.Wsl.dll", + "lib/net10.0/Purview.Containers.Wsl.xml", + "lib/net10.0-windows10.0.19041/Purview.Containers.Wsl.dll", + "lib/net10.0-windows10.0.19041/Purview.Containers.Wsl.xml", "README.md", "purview-logo-light.png", - "buildTransitive/Purview.WslContainers.props", - "buildTransitive/Purview.WslContainers.targets" + "buildTransitive/Purview.Containers.Wsl.props", + "buildTransitive/Purview.Containers.Wsl.targets", + "payload/win-x64/*", + "payload/win-arm64/*" ], - "purview.wslcontainers.azurite": [ - "lib/$(TFM)/Purview.WslContainers.Azurite.dll", - "lib/$(TFM)/Purview.WslContainers.Azurite.xml", + "purview.containers.azurite": [ + "lib/$(TFM)/Purview.Containers.Azurite.dll", + "lib/$(TFM)/Purview.Containers.Azurite.xml", "README.md", "purview-logo-light.png" ], - "purview.wslcontainers.mssql": [ - "lib/$(TFM)/Purview.WslContainers.MsSql.dll", - "lib/$(TFM)/Purview.WslContainers.MsSql.xml", + "purview.containers.mssql": [ + "lib/$(TFM)/Purview.Containers.MsSql.dll", + "lib/$(TFM)/Purview.Containers.MsSql.xml", "README.md", "purview-logo-light.png" ], - "purview.wslcontainers.mysql": [ - "lib/$(TFM)/Purview.WslContainers.MySql.dll", - "lib/$(TFM)/Purview.WslContainers.MySql.xml", + "purview.containers.mysql": [ + "lib/$(TFM)/Purview.Containers.MySql.dll", + "lib/$(TFM)/Purview.Containers.MySql.xml", "README.md", "purview-logo-light.png" ], - "purview.wslcontainers.nats": [ - "lib/$(TFM)/Purview.WslContainers.Nats.dll", - "lib/$(TFM)/Purview.WslContainers.Nats.xml", + "purview.containers.nats": [ + "lib/$(TFM)/Purview.Containers.Nats.dll", + "lib/$(TFM)/Purview.Containers.Nats.xml", "README.md", "purview-logo-light.png" ], - "purview.wslcontainers.postgresql": [ - "lib/$(TFM)/Purview.WslContainers.PostgreSql.dll", - "lib/$(TFM)/Purview.WslContainers.PostgreSql.xml", + "purview.containers.postgresql": [ + "lib/$(TFM)/Purview.Containers.PostgreSql.dll", + "lib/$(TFM)/Purview.Containers.PostgreSql.xml", "README.md", "purview-logo-light.png" ], - "purview.wslcontainers.rabbitmq": [ - "lib/$(TFM)/Purview.WslContainers.RabbitMq.dll", - "lib/$(TFM)/Purview.WslContainers.RabbitMq.xml", + "purview.containers.rabbitmq": [ + "lib/$(TFM)/Purview.Containers.RabbitMq.dll", + "lib/$(TFM)/Purview.Containers.RabbitMq.xml", "README.md", "purview-logo-light.png" ], - "purview.wslcontainers.redis": [ - "lib/$(TFM)/Purview.WslContainers.Redis.dll", - "lib/$(TFM)/Purview.WslContainers.Redis.xml", + "purview.containers.redis": [ + "lib/$(TFM)/Purview.Containers.Redis.dll", + "lib/$(TFM)/Purview.Containers.Redis.xml", "README.md", "purview-logo-light.png" ] diff --git a/samples/getting-started/AutoSample/AutoSample.csproj b/samples/getting-started/AutoSample/AutoSample.csproj new file mode 100644 index 0000000..f9ed7fa --- /dev/null +++ b/samples/getting-started/AutoSample/AutoSample.csproj @@ -0,0 +1,54 @@ + + + + Exe + net10.0 + enable + enable + false + + + + + + + + + + + $(MSBuildThisFileDirectory)../../../src/src/Wsl/bin/$(Configuration)/net10.0-windows10.0.19041.0/ + $(NuGetPackageRoot)microsoft.wsl.containers/3.0.1 + $(NuGetPackageRoot)microsoft.windows.sdk.net.ref/10.0.26100.80 + + + + + + + + + + + + + + + diff --git a/samples/getting-started/AutoSample/Program.cs b/samples/getting-started/AutoSample/Program.cs new file mode 100644 index 0000000..280576a --- /dev/null +++ b/samples/getting-started/AutoSample/Program.cs @@ -0,0 +1,50 @@ +// The zero-configuration sample: the same test-style code a consumer writes, with no backend choice in +// code or environment. On a Windows machine with WSL Containers this runs on WSLC; on a machine with only +// Docker it runs on Docker. Prerequisite: either backend reachable (see docs/wiki/Using-in-Your-Tests.md). +using Purview.Containers; +using Purview.Containers.Docker; +using Purview.Containers.PostgreSql; +using Purview.Containers.Redis; +using Purview.Containers.Runtime; +using Purview.Containers.Wsl; + +// A package consumer gets backend registration generated from the packages' buildTransitive assets, so +// this line does not appear in consumer code. The sample references the backends as projects, so it +// registers both explicitly - exactly what the generated initializer does - and lets automatic selection +// choose between them. +ContainerBackends.Register(WslContainerBackend.Create()); +ContainerBackends.Register(DockerContainerBackend.Create()); + +Console.WriteLine("Registered backends (auto picks the first usable one):"); +foreach (var backend in await ContainerBackends.ProbeAllAsync()) +{ + Console.WriteLine($" {backend.Name, -7} usable={backend.IsUsable} version={backend.Version}"); +} + +IContainerBackend selected; +try +{ + selected = await ContainerBackends.ResolveAsync(); +} +catch (ContainerBackendUnavailableException exception) +{ + Console.WriteLine(); + Console.WriteLine("No usable backend on this machine - start Docker or install WSL Containers."); + Console.WriteLine(exception.Message); + return 0; +} + +Console.WriteLine($"Selected backend: {selected.Name}"); +Console.WriteLine(); + +await using var redis = new RedisBuilder().Build(); +await redis.StartAsync(); +var ping = await redis.ExecAsync(["redis-cli", "ping"]); +Console.WriteLine($"redis : {redis.GetConnectionString()} -> {ping.Stdout.Trim()}"); + +await using var postgres = new PostgreSqlBuilder().WithDatabase("app").Build(); +await postgres.StartAsync(); +var ready = await postgres.ExecAsync(["pg_isready"]); +Console.WriteLine($"postgresql : {postgres.GetConnectionString()} -> {ready.Stdout.Trim()}"); + +return 0; diff --git a/samples/getting-started/DockerSample/DockerSample.csproj b/samples/getting-started/DockerSample/DockerSample.csproj new file mode 100644 index 0000000..7aede1f --- /dev/null +++ b/samples/getting-started/DockerSample/DockerSample.csproj @@ -0,0 +1,18 @@ + + + + Exe + net10.0 + enable + enable + false + + + + + + + diff --git a/samples/getting-started/DockerSample/Program.cs b/samples/getting-started/DockerSample/Program.cs new file mode 100644 index 0000000..8006760 --- /dev/null +++ b/samples/getting-started/DockerSample/Program.cs @@ -0,0 +1,28 @@ +// Docker sample: byte-for-byte the same container code as the WSLC sample, on the Docker backend. +// Prerequisite: a reachable Docker daemon (`docker info`). +using Purview.Containers; +using Purview.Containers.Docker; +using Purview.Containers.Waiting; + +// A package consumer gets backend registration generated from the package's buildTransitive assets, so +// this line does not appear in consumer code. The sample references the backend as a project, so it +// registers explicitly. +ContainerBackends.Register(new DockerContainerBackend()); + +foreach (var backend in await ContainerBackends.ProbeAllAsync()) +{ + Console.WriteLine($"backend : {backend.Name} usable={backend.IsUsable} version={backend.Version}"); +} + +await using var container = new ContainerBuilder() + .WithImage("alpine:3.19") + .WithCommand("/bin/sh", "-c", "echo ready && sleep 30") + .WithPortBinding(8080, assignRandomHostPort: true) + .WithWaitStrategy(Wait.ForLogMessage("ready")) + .Build(); + +await container.StartAsync(); + +Console.WriteLine($"container : {container.Name} ({container.Id[..12]})"); +Console.WriteLine($"host port : {container.GetMappedPublicPort(8080)} -> 8080"); +Console.WriteLine($"logs : {(await container.GetLogsAsync()).Trim()}"); diff --git a/samples/getting-started/README.md b/samples/getting-started/README.md new file mode 100644 index 0000000..2290964 --- /dev/null +++ b/samples/getting-started/README.md @@ -0,0 +1,38 @@ +# Getting-started samples + +Three runnable samples. `WslSample` and `DockerSample` differ in exactly one thing — which backend they +use — and the container code is identical, which is the point. `AutoSample` removes even that choice: it +references the umbrella (`Purview.Containers`) and lets automatic selection decide, so the same project +runs on WSL Containers on Windows and Docker everywhere else. See +[Backends: WSLC or Docker](../../docs/wiki/Backends.md) and +[Using it in your tests](../../docs/wiki/Using-in-Your-Tests.md). + +| Sample | Backend | Target framework | Prerequisite | +| --- | --- | --- | --- | +| `WslSample` | `Purview.Containers.Wsl` | `net11.0-windows10.0.19041.0` | Windows with WSL Containers (`wsl --install --no-distribution`) | +| `DockerSample` | `Purview.Containers.Docker` | `net10.0` | a reachable Docker daemon (`docker info`) | +| `AutoSample` | umbrella (auto) | `net10.0` | either — WSLC on Windows, Docker elsewhere | + +Run them from the repository root: + +```powershell +just sample-auto # or: dotnet run --project samples/getting-started/AutoSample +just sample-wsl # or: dotnet run --project samples/getting-started/WslSample +just sample-docker # or: dotnet run --project samples/getting-started/DockerSample +``` + +Each sample starts real containers, waits for them to be ready, prints the mapped host ports and the +services' output, and disposes them again. + +## These are repository samples + +They reference the backends as **projects** so they build from a clone with no package feed. A real +consumer uses packages instead: + +```bash +dotnet add package Purview.Containers.Docker # or Purview.Containers.Wsl +``` + +Because they are project references, no `buildTransitive` assets apply, so each sample registers its +backend explicitly (`ContainerBackends.Register(...)`); a package consumer gets that registration generated +automatically and never writes that line. diff --git a/samples/getting-started/WslSample/Program.cs b/samples/getting-started/WslSample/Program.cs new file mode 100644 index 0000000..a7ec7ab --- /dev/null +++ b/samples/getting-started/WslSample/Program.cs @@ -0,0 +1,28 @@ +// WSL Containers sample: the same code as the Docker sample, running on the WSLC backend. +// Prerequisite: `wsl --install --no-distribution`, then verify with `wsl --version` and `wslc version`. +using Purview.Containers; +using Purview.Containers.Waiting; +using Purview.Containers.Wsl; + +// A package consumer gets backend registration generated from the package's buildTransitive assets, so +// this line does not appear in consumer code. The sample references the backend as a project, so it +// registers explicitly. +ContainerBackends.Register(new WslContainerBackend()); + +foreach (var backend in await ContainerBackends.ProbeAllAsync()) +{ + Console.WriteLine($"backend : {backend.Name} usable={backend.IsUsable} version={backend.Version}"); +} + +await using var container = new ContainerBuilder() + .WithImage("alpine:3.19") + .WithCommand("/bin/sh", "-c", "echo ready && sleep 30") + .WithPortBinding(8080, assignRandomHostPort: true) + .WithWaitStrategy(Wait.ForLogMessage("ready")) + .Build(); + +await container.StartAsync(); + +Console.WriteLine($"container : {container.Name} ({container.Id[..12]})"); +Console.WriteLine($"host port : {container.GetMappedPublicPort(8080)} -> 8080"); +Console.WriteLine($"logs : {(await container.GetLogsAsync()).Trim()}"); diff --git a/samples/getting-started/WslSample/WslSample.csproj b/samples/getting-started/WslSample/WslSample.csproj new file mode 100644 index 0000000..0ef4530 --- /dev/null +++ b/samples/getting-started/WslSample/WslSample.csproj @@ -0,0 +1,25 @@ + + + + Exe + net11.0-windows10.0.19041.0 + x64 + + 10.0.26100.80 + enable + enable + false + + true + + + + + + + diff --git a/scripts/verify-consumers.ps1 b/scripts/verify-consumers.ps1 index 80f13e7..545f98a 100644 --- a/scripts/verify-consumers.ps1 +++ b/scripts/verify-consumers.ps1 @@ -14,16 +14,25 @@ Expectations and their documentation: 01 .NET 11 + Windows with the documented settings -> builds, wslcsdk.dll copied 02 ...with a stale WindowsSdkPackageVersion -> CS1705 - 03 ...with PlatformTarget=x86 -> fails PWC0002 + 03 ...with PlatformTarget=x86 -> fails PCC0002 04 ...with no settings at all (buildTransitive defaults) -> builds, wslcsdk.dll copied 05 a module package with no settings (defaults are transitive) -> builds - 06 net8.0-windows + AssetTargetFallback escape hatch -> PWC0001 (the hatch does not work) - 07 net8.0-windows without the escape hatch -> PWC0001 - 08 plain net11.0 (not Windows-specific) -> PWC0001 + 06 net8.0-windows + AssetTargetFallback escape hatch -> PCC0001 (the hatch does not work) + 07 net8.0-windows without the escape hatch -> PCC0001 + 08 plain net11.0 (platform-neutral facade) -> builds, wsl registration generated 09 ...with PlatformTarget=AnyCPU (corrected to x64) -> builds, wslcsdk.dll copied - 10 the net11.0-windows shorthand TFM (no OS version) -> PWC0001 + 10 the net11.0-windows shorthand TFM (no OS version) -> PCC0001 11 multi-targeting with a conditional PackageReference -> builds - 12 multi-targeting with an unconditional PackageReference -> PWC0001 + 12 multi-targeting with an unconditional PackageReference -> builds (both inner builds supported) + 13 net10.0 + core abstractions -> builds + 14 net10.0 + Docker backend -> builds, backend registration generated + 15 net10.0 + service module (no backend package) -> builds + 16 net11.0 windows + both backends (auto detection) -> builds, both registrations generated + 17 the documented backend example, on WSL Containers -> builds + 18 the documented backend example, on Docker (net10.0) -> builds + 19 plain net10.0 + WSL backend (portable facade, auto) -> builds, wsl registration generated + 20 net10.0 + Redis/PostgreSql + both backends (auto) -> builds, both registrations generated + 21 net10.0 + umbrella Purview.Containers + modules (one ref) -> builds, both registrations generated .PARAMETER FeedPath Folder holding the packed .nupkg files. Defaults to /artifacts. @@ -35,6 +44,9 @@ Scratch folder for the generated consumers. Defaults to %TEMP%/wslc-consumer-verification. +.PARAMETER Only + Runs only the cases with these ids, e.g. -Only 13,14. Defaults to every case. + .PARAMETER Keep Keep the generated consumer projects for inspection instead of deleting them. @@ -49,6 +61,7 @@ param( [string] $FeedPath, [string] $PackageVersion, [string] $WorkPath = (Join-Path ([System.IO.Path]::GetTempPath()) 'wslc-consumer-verification'), + [string[]] $Only = @(), [switch] $Keep ) @@ -67,7 +80,7 @@ if (-not (Test-Path $FeedPath)) { throw "Package feed '$FeedPath' does not exist. Run 'just pack' first." } -$corePackage = Join-Path $FeedPath "Purview.WslContainers.$PackageVersion.nupkg" +$corePackage = Join-Path $FeedPath "Purview.Containers.Wsl.$PackageVersion.nupkg" if (-not (Test-Path $corePackage)) { throw "Package '$corePackage' was not found. Run 'just pack' (or pass -FeedPath) first." } @@ -76,10 +89,10 @@ $globalPackagesLine = & dotnet nuget locals global-packages --list | Select-Obje $globalPackages = ($globalPackagesLine -replace '^global-packages:\s*', '').Trim() # The packages keep the same version between local packs, so a stale extraction in the global -# packages folder would silently test the previous build. Drop it before consuming the feed. -foreach ($packageId in 'purview.wslcontainers', 'purview.wslcontainers.postgresql') { - $packageFolder = Join-Path $globalPackages $packageId - $versioned = Join-Path $packageFolder $PackageVersion +# packages folder would silently test the previous build. Drop the extracted version of every +# Purview.Containers package before consuming the feed. +foreach ($packageFolder in @(Get-ChildItem -Path $globalPackages -Directory -Filter 'purview.containers*' -ErrorAction SilentlyContinue)) { + $versioned = Join-Path $packageFolder.FullName $PackageVersion if (Test-Path $versioned) { Remove-Item $versioned -Recurse -Force @@ -116,7 +129,8 @@ Set-Content -Path (Join-Path $WorkPath 'nuget.config') -Value $nugetConfig # OutputType=Exe so the runtime assets (wslcsdk.dll) are copied to the output for inspection. $apiSource = @' using Microsoft.WSL.Containers; -using Purview.WslContainers; +using Purview.Containers; +using Purview.Containers.Wsl; namespace Consumer; @@ -124,7 +138,7 @@ public static class Program { public static SessionSettings Session() => new("consumer-session", @"C:\temp\wslc"); - public static WslContainer Container() => + public static IContainer Container() => new ContainerBuilder().WithImage("docker.io/library/alpine:3.19").Build(); public static void Main() { } @@ -162,8 +176,8 @@ $projectTemplate = @' $net11Windows = 'net11.0-windows10.0.19041.0' $net8Windows = 'net8.0-windows10.0.19041.0' -$sdkPackage = 'Purview.WslContainers' -$modulePackage = 'Purview.WslContainers.PostgreSql' +$sdkPackage = 'Purview.Containers.Wsl' +$modulePackage = 'Purview.Containers.PostgreSql' $sdkVersion = '10.0.26100.80' $staleSdkVersion = '10.0.19041.38' $x64 = 'x64' @@ -178,6 +192,118 @@ $multiTargetItems = @' '@ +# A consumer that touches the Docker backend. Nothing registers the backend here: the generated module +# initializer arrives from the package's buildTransitive assets, which is what case 14 asserts. +$dockerSource = @' +using Purview.Containers; +using Purview.Containers.Docker; + +namespace Consumer; + +public static class Program +{ + public static async Task BackendName() => (await ContainerBackends.ResolveAsync()).Name; + + public static void Main() { } +} +'@ + +# A consumer that only references a service module: no backend package, so it restores and builds anywhere. +$portableModuleSource = @' +using Purview.Containers.PostgreSql; + +namespace Consumer; + +public static class Program +{ + public static PostgreSqlBuilder Builder() => new PostgreSqlBuilder().WithDatabase("tests"); + + public static void Main() { } +} +'@ + +# A second backend package alongside the first: the developer-machine shape (WSLC plus Docker present, so +# automatic detection picks WSLC and the environment can pin Docker). +$bothBackendsItems = @' + + + +'@ + +# The umbrella shape: one backend reference (Purview.Containers, which brings Core + WSL + Docker) plus the +# service modules. This is the "one reference" story documented in docs/wiki/Using-in-Your-Tests.md. +$umbrellaItems = @' + + + + +'@ + +# The realistic Windows consumer shape: a service module plus the WSL Containers backend. The module is +# backend-neutral, so the backend package is what supplies the WSL build defaults and the guards. +$wslBackendItems = @' + + + +'@ + +# The "auto" shape documented in docs/wiki/Using-in-Your-Tests.md: a platform-neutral consumer with two +# service modules and BOTH backends, so the one project runs on WSLC (Windows) and Docker (everywhere else). +$autoItems = @' + + + + + +'@ + +$autoSource = @' +using Purview.Containers; +using Purview.Containers.PostgreSql; +using Purview.Containers.Redis; + +namespace Consumer; + +public static class Program +{ + public static async Task SelectedBackendAsync() => (await ContainerBackends.ResolveAsync()).Name; + + public static RedisBuilder Cache() => new RedisBuilder(); + + public static PostgreSqlBuilder Database() => new PostgreSqlBuilder().WithDatabase("app"); + + public static void Main() { } +} +'@ + +# Mirrors the container code in the "The same test, either backend" example of docs/wiki/Backends.md, so +# the documented example is compiled against both backend packages on every run. The test assertion is +# omitted because the generated consumer project has no test framework. +$documentedBackendSource = @' +using Purview.Containers; +using Purview.Containers.Waiting; + +namespace Consumer; + +public static class Program +{ + public static async Task MappedPortAsync() + { + await using var container = new ContainerBuilder() + .WithImage("redis:7") + .WithPortBinding(6379, assignRandomHostPort: true) + .WithWaitStrategy(Wait.ForTcpPort(6379)) + .Build(); + + await container.StartAsync(); + + return container.GetMappedPublicPort(6379); + } + + public static void Main() { } +} +'@ + function New-ConsumerCase { param( [string] $Id, @@ -189,7 +315,8 @@ function New-ConsumerCase { [string] $Items = '', [hashtable] $Sources = @{ 'Smoke.cs' = $apiSource }, [string] $Expect = 'Builds', - [string] $RequireFile = '' + [string] $RequireFile = '', + [string[]] $RequireText = @() ) [pscustomobject]@{ @@ -203,6 +330,7 @@ function New-ConsumerCase { Sources = $Sources Expect = $Expect RequireFile = $RequireFile + RequireText = $RequireText } } @@ -214,32 +342,35 @@ $cases = @( -Properties "$staleSdkVersion$x64" -Expect 'CS1705' New-ConsumerCase -Id '03' -Name 'net11 windows + PlatformTarget=x86' ` - -Properties "$sdkVersionx86" -Expect 'PWC0002' + -Properties "$sdkVersionx86" -Expect 'PCC0002' New-ConsumerCase -Id '04' -Name 'net11 windows + buildTransitive defaults only' ` -RequireFile 'wslcsdk.dll' - New-ConsumerCase -Id '05' -Name 'module package + transitive defaults only' ` - -Package $modulePackage + New-ConsumerCase -Id '05' -Name 'module package + WSL backend (defaults are transitive)' ` + -Package $modulePackage ` + -Items $wslBackendItems ` + -RequireFile 'wslcsdk.dll' New-ConsumerCase -Id '06' -Name 'net8 windows + AssetTargetFallback escape hatch' ` -Framework "$net8Windows" ` - -Properties "$escapeHatch$sdkVersion$x64" -Expect 'PWC0001' + -Properties "$escapeHatch$sdkVersion$x64" -Expect 'PCC0001' New-ConsumerCase -Id '07' -Name 'net8 windows without the escape hatch' ` -Framework "$net8Windows" ` - -Properties "$sdkVersion$x64" -Expect 'PWC0001' + -Properties "$sdkVersion$x64" -Expect 'PCC0001' - New-ConsumerCase -Id '08' -Name 'plain net11.0 (not Windows-specific)' ` + New-ConsumerCase -Id '08' -Name 'plain net11.0 (platform-neutral facade)' ` -Framework 'net11.0' ` - -Properties "$sdkVersion$x64" -Expect 'PWC0001' + -Sources @{ 'Smoke.Portable.cs' = $portableSource } ` + -RequireText 'WslContainerBackend.Create()' New-ConsumerCase -Id '09' -Name 'net11 windows + PlatformTarget=AnyCPU' ` -Properties "$sdkVersionAnyCPU" -RequireFile 'wslcsdk.dll' New-ConsumerCase -Id '10' -Name 'net11.0-windows shorthand TFM (no OS version)' ` -Framework 'net11.0-windows' ` - -Properties "$sdkVersion$x64" -Expect 'PWC0001' + -Properties "$sdkVersion$x64" -Expect 'PCC0001' New-ConsumerCase -Id '11' -Name 'multi-targeting + conditional PackageReference' ` -Framework "net11.0;$net11Windows" ` @@ -252,13 +383,62 @@ $cases = @( -Framework "net11.0;$net11Windows" ` -Properties 'false' ` -Items $multiTargetItems ` - -Sources @{ 'Smoke.Windows.cs' = $apiSource; 'Smoke.Portable.cs' = $portableSource } ` - -Expect 'PWC0001' + -Sources @{ 'Smoke.Windows.cs' = $apiSource; 'Smoke.Portable.cs' = $portableSource } + + New-ConsumerCase -Id '13' -Name 'net10 + core abstractions' ` + -Framework 'net10.0' ` + -Package 'Purview.Containers.Core' ` + -Sources @{ 'Smoke.Portable.cs' = $portableSource } + + New-ConsumerCase -Id '14' -Name 'net10 + docker backend (generated registration)' ` + -Framework 'net10.0' ` + -Package 'Purview.Containers.Docker' ` + -RequireText 'DockerContainerBackend.Create()' ` + -Sources @{ 'Smoke.cs' = $dockerSource } + + New-ConsumerCase -Id '15' -Name 'net10 + service module (backend-neutral, no backend package)' ` + -Framework 'net10.0' ` + -Package $modulePackage ` + -Sources @{ 'Smoke.cs' = $portableModuleSource } + + New-ConsumerCase -Id '16' -Name 'net11 windows + both backends (auto detection, WSLC first)' ` + -Package $sdkPackage ` + -Items $bothBackendsItems ` + -RequireText 'WslContainerBackend.Create()' ` + -Sources @{ 'Smoke.cs' = $dockerSource } + + New-ConsumerCase -Id '17' -Name 'documented backend example on WSL Containers' ` + -Package $sdkPackage ` + -Sources @{ 'Smoke.cs' = $documentedBackendSource } + + New-ConsumerCase -Id '18' -Name 'documented backend example on Docker (net10.0)' ` + -Framework 'net10.0' ` + -Package 'Purview.Containers.Docker' ` + -Sources @{ 'Smoke.cs' = $documentedBackendSource } + + New-ConsumerCase -Id '19' -Name 'plain net10.0 + WSL backend (portable facade, auto)' ` + -Framework 'net10.0' ` + -Sources @{ 'Smoke.Portable.cs' = $portableSource } ` + -RequireText 'WslContainerBackend.Create()' + + New-ConsumerCase -Id '20' -Name 'net10.0 + Redis/PostgreSql + both backends (auto, zero-config)' ` + -Framework 'net10.0' ` + -Package $modulePackage ` + -Items $autoItems ` + -Sources @{ 'Smoke.Auto.cs' = $autoSource } ` + -RequireText @('WslContainerBackend.Create()', 'DockerContainerBackend.Create()') + + New-ConsumerCase -Id '21' -Name 'net10.0 + umbrella Purview.Containers + modules (one reference, auto)' ` + -Framework 'net10.0' ` + -Package 'Purview.Containers' ` + -Items $umbrellaItems ` + -Sources @{ 'Smoke.Auto.cs' = $autoSource } ` + -RequireText @('WslContainerBackend.Create()', 'DockerContainerBackend.Create()') ) Write-Host '' -Write-Host 'Purview.WslContainers consumer verification' -ForegroundColor Cyan +Write-Host 'Purview.Containers.Wsl consumer verification' -ForegroundColor Cyan Write-Host " feed : $FeedPath" Write-Host " version : $PackageVersion" Write-Host " scratch : $WorkPath" @@ -266,6 +446,22 @@ Write-Host '' $results = [System.Collections.Generic.List[object]]::new() +if ($Only.Count -gt 0) { + # `pwsh -File script.ps1 -Only 13,14` binds the value as one comma-joined string, so split it. Ids are + # zero-padded ('08'), so an unpadded value ('8') matches too. + $wanted = @($Only | ForEach-Object { $_ -split ',' } | ForEach-Object { $_.Trim() } | Where-Object { $_ }) + $cases = @( + $cases | Where-Object { + $id = $_.Id + $wanted -contains $id -or ($id -match '^\d+$' -and $wanted -contains ([string][int]$id)) + } + ) + + if ($cases.Count -eq 0) { + throw "No consumer cases matched -Only '$($wanted -join ', ')'." + } +} + foreach ($case in $cases) { $slug = ($case.Name -replace '[^a-zA-Z0-9]+', '-').Trim('-').ToLowerInvariant() $directory = Join-Path $WorkPath ("{0}-{1}" -f $case.Id, $slug) @@ -312,6 +508,23 @@ foreach ($case in $cases) { } } + if ($passed -and $case.RequireText.Count -gt 0) { + foreach ($pattern in $case.RequireText) { + $found = @( + Get-ChildItem -Path $directory -Recurse -File -Include *.cs, *.csproj, *.json -ErrorAction SilentlyContinue | + Select-String -Pattern $pattern -SimpleMatch -ErrorAction SilentlyContinue + ) + + if ($found.Count -eq 0) { + $passed = $false + $signal = "$signal; '$pattern' was not generated into the consumer" + } + else { + $signal = "$signal; generated '$pattern'" + } + } + } + $results.Add( [pscustomobject]@{ Id = $case.Id diff --git a/spikes/DynamicLoadSpike/DynamicLoadSpike.csproj b/spikes/DynamicLoadSpike/DynamicLoadSpike.csproj new file mode 100644 index 0000000..8c5d55d --- /dev/null +++ b/spikes/DynamicLoadSpike/DynamicLoadSpike.csproj @@ -0,0 +1,61 @@ + + + + Exe + false + net10.0 + enable + enable + x64 + true + $(NoWarn);CA1031;CA1304;CA1305;CA1307;CA1310;CA1849;CA2000;CA2027;CA1707;CA1822;CA1062;CA1515;CA1861;CA1819;IDE0040;IDE0060;IDE0074;IDE0007;IDE0305;CS4014 + + + + + $(NuGetPackageRoot)microsoft.wsl.containers/3.0.1 + $(NuGetPackageRoot)microsoft.windows.sdk.net.ref/10.0.26100.80 + + + + + + + + + diff --git a/spikes/DynamicLoadSpike/DynamicLoader.cs b/spikes/DynamicLoadSpike/DynamicLoader.cs new file mode 100644 index 0000000..c9a4b0d --- /dev/null +++ b/spikes/DynamicLoadSpike/DynamicLoader.cs @@ -0,0 +1,187 @@ +using System.Reflection; +using System.Runtime.InteropServices; +using System.Runtime.Loader; + +namespace DynamicLoadSpike; + +/// +/// Loads the WSLC projection (and the native SDK it P/Invokes) from the local payload folder and calls +/// into Microsoft.WSL.Containers.WslcService by reflection. This is the phase 0 proof that a +/// platform-neutral net10.0 process can drive WSLC on a Windows host. +/// +internal static class DynamicLoader +{ + const string ProjectionFile = "wslcsdkcs.dll"; + const string ServiceType = "Microsoft.WSL.Containers.WslcService"; + + public static Task RunAsync(string payloadDir) + { + if (!OperatingSystem.IsWindows()) + { + Console.WriteLine("[dynamic] skipped: this host is not Windows, so the WSLC payload is never loaded."); + Console.WriteLine( + "[dynamic] (a real build would report the 'wsl' backend as unavailable here and fall through to Docker)." + ); + return Task.FromResult(0); + } + + var projection = Path.Combine(payloadDir, ProjectionFile); + if (!File.Exists(projection)) + { + Console.WriteLine($"[dynamic] FAILED: projection not found at {projection}"); + return Task.FromResult(1); + } + + try + { + var context = new PayloadLoadContext(payloadDir); + context.Resolving += (_, name) => context.TryLoad(name); + + var assembly = context.LoadFromAssemblyPath(projection); + Console.WriteLine($"[dynamic] loaded assembly: {assembly.FullName}"); + + var service = assembly.GetType(ServiceType, throwOnError: true)!; + var version = service + .GetMethod("GetVersion", BindingFlags.Public | BindingFlags.Static)! + .Invoke(null, null); + Console.WriteLine($"[dynamic] {ServiceType}.GetVersion() = {Format(version)}"); + PrintProperties("version", version); + + var missing = service + .GetMethod("GetMissingComponents", BindingFlags.Public | BindingFlags.Static)! + .Invoke(null, null); + Console.WriteLine($"[dynamic] {ServiceType}.GetMissingComponents() = {Format(missing)}"); + var missingCount = PrintItems("missing", missing); + + // A statically-available runtime is not enough: the feature delegates real work through the + // WSLC projection, so prove a session can be created, started and released from here too. + Console.WriteLine( + missingCount == 0 + ? "[dynamic] host is WSLC-capable; probing a real session." + : "[dynamic] host is NOT WSLC-capable; skipping the session probe." + ); + var sessionOk = missingCount == 0 && ProbeSession(assembly); + + Console.WriteLine( + $"[dynamic] RESULT: {(sessionOk || missingCount != 0 ? "PASS" : "FAILED")} - a plain " + + $"{RuntimeInformation.FrameworkDescription} net10.0 process drove WSLC." + ); + return Task.FromResult(sessionOk || missingCount != 0 ? 0 : 1); + } + catch (Exception exception) + { + Console.WriteLine($"[dynamic] RESULT: FAILED - {exception.GetType().Name}: {exception.Message}"); + for (var inner = exception.InnerException; inner is not null; inner = inner.InnerException) + { + Console.WriteLine($"[dynamic] inner: {inner.GetType().Name} (0x{inner.HResult:X8}): {inner.Message}"); + } + + return Task.FromResult(1); + } + } + + static string Format(object? value) => value?.ToString() ?? ""; + + static void PrintProperties(string label, object? value) + { + if (value is null) + { + return; + } + + foreach (var property in value.GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + Console.WriteLine($"[dynamic] {label}.{property.Name} = {Format(property.GetValue(value))}"); + } + } + + static int PrintItems(string label, object? value) + { + var count = 0; + if (value is System.Collections.IEnumerable items and not string) + { + foreach (var item in items) + { + Console.WriteLine($"[dynamic] {label}[*] = {Format(item)}"); + count++; + } + } + + return count; + } + + static bool ProbeSession(Assembly assembly) + { + var name = $"dynload-{Environment.ProcessId}-{Guid.NewGuid().ToString("N")[..8]}"; + var storage = Path.Combine(Path.GetTempPath(), "dynload-spike", name); + object? session = null; + try + { + var settingsType = assembly.GetType("Microsoft.WSL.Containers.SessionSettings", throwOnError: true)!; + var settings = Activator.CreateInstance(settingsType, name, storage); + var sessionType = assembly.GetType("Microsoft.WSL.Containers.Session", throwOnError: true)!; + session = Activator.CreateInstance(sessionType, settings); + + sessionType.GetMethod("Start")!.Invoke(session, null); + Console.WriteLine($"[dynamic] session started: {name} (storage={storage})"); + PrintProperties("session", session); + + sessionType.GetMethod("Terminate")!.Invoke(session, null); + Console.WriteLine("[dynamic] session terminated."); + return true; + } + catch (Exception exception) + { + Console.WriteLine($"[dynamic] session probe FAILED: {exception.GetType().Name}: {exception.Message}"); + for (var inner = exception.InnerException; inner is not null; inner = inner.InnerException) + { + Console.WriteLine($"[dynamic] inner: {inner.GetType().Name} (0x{inner.HResult:X8}): {inner.Message}"); + } + + return false; + } + finally + { + (session as IDisposable)?.Dispose(); + TryDelete(storage); + } + } + + static void TryDelete(string directory) + { + try + { + if (Directory.Exists(directory)) + { + Directory.Delete(directory, recursive: true); + } + } + catch (Exception exception) + { + Console.WriteLine($"[dynamic] (cleanup) could not remove '{directory}': {exception.Message}"); + } + } +} + +/// +/// A dedicated load context so the projection and its Windows SDK dependencies resolve from the payload +/// folder (and the native wslcsdk.dll loads from there too), independently of the host app. +/// +internal sealed class PayloadLoadContext(string directory) : AssemblyLoadContext("wslc-payload", isCollectible: true) +{ + readonly string _directory = directory; + + public Assembly? TryLoad(AssemblyName name) + { + var candidate = Path.Combine(_directory, $"{name.Name}.dll"); + return File.Exists(candidate) ? LoadFromAssemblyPath(candidate) : null; + } + + protected override Assembly? Load(AssemblyName assemblyName) => TryLoad(assemblyName); + + protected override IntPtr LoadUnmanagedDll(string unmanagedDllName) + { + var candidate = Path.Combine(_directory, $"{unmanagedDllName}.dll"); + return File.Exists(candidate) ? LoadUnmanagedDllFromPath(candidate) : IntPtr.Zero; + } +} diff --git a/spikes/DynamicLoadSpike/PayloadInspector.cs b/spikes/DynamicLoadSpike/PayloadInspector.cs new file mode 100644 index 0000000..c39ae7b --- /dev/null +++ b/spikes/DynamicLoadSpike/PayloadInspector.cs @@ -0,0 +1,106 @@ +using System.Reflection.Metadata; +using System.Reflection.PortableExecutable; + +namespace DynamicLoadSpike; + +/// +/// Reads the WSLC projection's metadata without loading it, so the probe can report the projection's +/// real target framework and the exact dependency set (name + version) that a runtime payload has to +/// ship. Runs on any OS. +/// +internal static class PayloadInspector +{ + public static void Inspect(string path) + { + if (!File.Exists(path)) + { + Console.WriteLine($"[static] payload assembly not found: {path}"); + return; + } + + using var stream = File.OpenRead(path); + using var pe = new PEReader(stream); + if (!pe.HasMetadata) + { + Console.WriteLine("[static] payload has no CLI metadata (unexpected for a managed projection)."); + return; + } + + var md = pe.GetMetadataReader(); + var definition = md.GetAssemblyDefinition(); + Console.WriteLine($"[static] assembly : {md.GetString(definition.Name)} {definition.Version}"); + Console.WriteLine($"[static] file : {path}"); + + foreach (var handle in definition.GetCustomAttributes()) + { + var attribute = md.GetCustomAttribute(handle); + var name = AttributeTypeName(md, attribute); + if ( + !name.EndsWith("TargetFrameworkAttribute", StringComparison.Ordinal) + && !name.EndsWith("TargetPlatformAttribute", StringComparison.Ordinal) + ) + { + continue; + } + + var label = name[(name.LastIndexOf('.') + 1)..]; + Console.WriteLine($"[static] {label, -24}: {StringArgument(md, attribute) ?? ""}"); + } + + Console.WriteLine("[static] references :"); + foreach (var handle in md.AssemblyReferences) + { + var reference = md.GetAssemblyReference(handle); + Console.WriteLine($"[static] ref : {md.GetString(reference.Name)} {reference.Version}"); + } + } + + static string AttributeTypeName(MetadataReader md, CustomAttribute attribute) => + attribute.Constructor.Kind switch + { + HandleKind.MemberReference => TypeName( + md, + md.GetMemberReference((MemberReferenceHandle)attribute.Constructor).Parent + ), + HandleKind.MethodDefinition => TypeName( + md, + md.GetMethodDefinition((MethodDefinitionHandle)attribute.Constructor).GetDeclaringType() + ), + _ => "", + }; + + static string TypeName(MetadataReader md, EntityHandle handle) => + handle.Kind switch + { + HandleKind.TypeReference => Define(md, md.GetTypeReference((TypeReferenceHandle)handle)), + HandleKind.TypeDefinition => Define(md, md.GetTypeDefinition((TypeDefinitionHandle)handle)), + _ => "", + }; + + static string Define(MetadataReader md, TypeReference reference) + { + var ns = md.GetString(reference.Namespace); + return string.IsNullOrEmpty(ns) ? md.GetString(reference.Name) : $"{ns}.{md.GetString(reference.Name)}"; + } + + static string Define(MetadataReader md, TypeDefinition definition) + { + var ns = md.GetString(definition.Namespace); + return string.IsNullOrEmpty(ns) ? md.GetString(definition.Name) : $"{ns}.{md.GetString(definition.Name)}"; + } + + // The first fixed argument of TargetFrameworkAttribute/TargetPlatformAttribute is a string. + static string? StringArgument(MetadataReader md, CustomAttribute attribute) + { + try + { + var reader = md.GetBlobReader(attribute.Value); + reader.ReadUInt16(); // attribute prolog 0x0001 + return reader.ReadSerializedString(); + } + catch + { + return null; + } + } +} diff --git a/spikes/DynamicLoadSpike/Program.cs b/spikes/DynamicLoadSpike/Program.cs new file mode 100644 index 0000000..18c899f --- /dev/null +++ b/spikes/DynamicLoadSpike/Program.cs @@ -0,0 +1,30 @@ +using System.Reflection; +using System.Runtime.Versioning; +using DynamicLoadSpike; + +// Phase 0 feasibility probe for "dynamic WSLC from a platform-neutral TFM". +// +// dotnet run --project spikes/DynamicLoadSpike +// +// The static inspection runs on any OS; the dynamic load only runs on Windows. Exit code is 0 when +// the probe reached a verdict (even if the verdict is "WSLC unavailable here") and 1 only when the +// harness itself failed, so the run is useful on Linux CI as well. + +var payload = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "payload")); + +Console.WriteLine("[env] ---------------------------------------------------------------"); +Console.WriteLine( + $"[env] tfm : {typeof(Program).Assembly.GetCustomAttribute()?.FrameworkName}" +); +Console.WriteLine($"[env] runtime : {Environment.Version}"); +Console.WriteLine($"[env] isWindows : {OperatingSystem.IsWindows()}"); +Console.WriteLine($"[env] is64bit : {Environment.Is64BitProcess}"); +Console.WriteLine($"[env] payloadDir : {payload}"); +Console.WriteLine(); + +Console.WriteLine("[static] ------------------------------------------------------------"); +PayloadInspector.Inspect(Path.Combine(payload, "wslcsdkcs.dll")); +Console.WriteLine(); + +Console.WriteLine("[dynamic] -----------------------------------------------------------"); +return await DynamicLoader.RunAsync(payload); diff --git a/spikes/PortableConsumerSpike/PortableConsumerSpike.csproj b/spikes/PortableConsumerSpike/PortableConsumerSpike.csproj new file mode 100644 index 0000000..d6e8626 --- /dev/null +++ b/spikes/PortableConsumerSpike/PortableConsumerSpike.csproj @@ -0,0 +1,52 @@ + + + + Exe + false + net10.0 + enable + enable + x64 + true + $(NoWarn);CA1031;CA1304;CA1305;CA1307;CA1310;CA1849;CA2000;CA2027;CA1707;CA1822;CA1062;CA1515;CA1861;CA1819;IDE0040;IDE0060;IDE0074;IDE0007;IDE0305;CS4014 + + + + + + + + + + $(MSBuildThisFileDirectory)../../src/src/Wsl/bin/$(Configuration)/net10.0-windows10.0.19041.0/ + $(NuGetPackageRoot)microsoft.wsl.containers/3.0.1 + $(NuGetPackageRoot)microsoft.windows.sdk.net.ref/10.0.26100.80 + + + + + + + + + + + + + + + diff --git a/spikes/PortableConsumerSpike/Program.cs b/spikes/PortableConsumerSpike/Program.cs new file mode 100644 index 0000000..8b0a39d --- /dev/null +++ b/spikes/PortableConsumerSpike/Program.cs @@ -0,0 +1,50 @@ +using Purview.Containers; +using Purview.Containers.Waiting; +using Purview.Containers.Wsl; + +// Phase 1 acceptance probe: this project is a plain net10.0 consumer (no Windows target framework). +// Everything below is the documented, backend-neutral API - the "auto" story the feature exists for. + +Console.WriteLine($"[env] tfm : {System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription}"); +Console.WriteLine($"[env] isWindows: {OperatingSystem.IsWindows()}"); +Console.WriteLine(); + +// A package consumer gets this from the generated module initializer; a project-reference consumer +// registers explicitly. +ContainerBackends.Register(WslContainerBackend.Create()); + +var info = await WslContainerRuntime.Instance.GetInfoAsync(); +Console.WriteLine($"[host] wsl available={info.IsAvailable} compatible={info.IsCompatible} version={info.Version}"); +foreach (var missing in info.MissingComponents) +{ + Console.WriteLine($"[host] missing: {missing}"); +} + +foreach (var backend in await ContainerBackends.ProbeAllAsync()) +{ + Console.WriteLine($"[probe] {backend.Name}: usable={backend.IsUsable} version={backend.Version}"); +} + +if (!info.IsAvailable || !info.IsCompatible) +{ + Console.WriteLine("[result] WSLC is not usable on this host; auto-selection would fall through to Docker."); + return 0; +} + +Console.WriteLine(); + +await using var container = new ContainerBuilder() + .WithImage("alpine:3.19") + .WithCommand("/bin/sh", "-c", "echo ready && sleep 20") + .WithPortBinding(8080, assignRandomHostPort: true) + .WithWaitStrategy(Wait.ForLogMessage("ready")) + .Build(); + +await container.StartAsync(); + +Console.WriteLine($"[container] name : {container.Name}"); +Console.WriteLine($"[container] id : {container.Id[..12]}"); +Console.WriteLine($"[container] port : {container.GetMappedPublicPort(8080)} -> 8080"); +Console.WriteLine($"[container] logs : {(await container.GetLogsAsync()).Trim()}"); +Console.WriteLine("[result] PASS - a net10.0 project ran a real WSLC container through the portable facade."); +return 0; diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 39f39eb..cf18964 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -1,6 +1,6 @@ - Purview.WslContainers + Purview.Containers net11.0-windows10.0.19041.0 x64 10.0.26100.80 @@ -20,7 +20,7 @@ - https://github.com/purview-dev/wsl-containers + https://github.com/purview-dev/containers diff --git a/src/WSLTestContainers.slnx b/src/WSLTestContainers.slnx index cf4e852..6b9ec5c 100644 --- a/src/WSLTestContainers.slnx +++ b/src/WSLTestContainers.slnx @@ -11,18 +11,30 @@ + + + - + + + + + + + + + + @@ -36,7 +48,7 @@ - - + + diff --git a/src/src/Azurite/Azurite.csproj b/src/src/Azurite/Azurite.csproj index e9cd8ce..21d1e7c 100644 --- a/src/src/Azurite/Azurite.csproj +++ b/src/src/Azurite/Azurite.csproj @@ -1,13 +1,13 @@ true - Azurite module for Purview.WslContainers: throwaway Azure Storage emulators (blob/queue/table) on WSL Containers. - wsl;containers;azurite;azure;storage;testing;integration + Azurite module for Purview.Containers: throwaway Azure Storage emulators (blob/queue/table) on WSL Containers or Docker. + wsl;containers;docker;azurite;azure;storage;testing;integration MIT Purview - + diff --git a/src/src/Azurite/AzuriteAccount.cs b/src/src/Azurite/AzuriteAccount.cs index ce559ac..f1d9c34 100644 --- a/src/src/Azurite/AzuriteAccount.cs +++ b/src/src/Azurite/AzuriteAccount.cs @@ -1,4 +1,4 @@ -namespace Purview.WslContainers.Azurite; +namespace Purview.Containers.Azurite; /// Well-known Azurite emulator account details. public static class AzuriteAccount diff --git a/src/src/Azurite/AzuriteBuilder.cs b/src/src/Azurite/AzuriteBuilder.cs index 5990692..ce2dd7a 100644 --- a/src/src/Azurite/AzuriteBuilder.cs +++ b/src/src/Azurite/AzuriteBuilder.cs @@ -1,6 +1,6 @@ -using Purview.WslContainers.Waiting; +using Purview.Containers.Waiting; -namespace Purview.WslContainers.Azurite; +namespace Purview.Containers.Azurite; /// Fluent builder for an Azurite (Azure Storage emulator) test container. public class AzuriteBuilder : ContainerBuilder @@ -32,8 +32,8 @@ public AzuriteBuilder(string image) } /// Creates a builder using an explicit runtime. - public AzuriteBuilder(IContainerRuntime runtime) - : base(runtime) + public AzuriteBuilder(IContainerBackend backend) + : base(backend) { WithImage(AzuriteImage) .WithCommand("azurite", "--blobHost", "0.0.0.0", "--queueHost", "0.0.0.0", "--tableHost", "0.0.0.0") @@ -58,5 +58,5 @@ protected override AzuriteConfiguration BuildConfiguration() /// protected override AzuriteContainer CreateContainer(AzuriteConfiguration configuration) => - new(configuration, Runtime ?? WslContainerRuntime.Instance); + new(configuration, Backend); } diff --git a/src/src/Azurite/AzuriteConfiguration.cs b/src/src/Azurite/AzuriteConfiguration.cs index 5fe9554..09e2eea 100644 --- a/src/src/Azurite/AzuriteConfiguration.cs +++ b/src/src/Azurite/AzuriteConfiguration.cs @@ -1,4 +1,4 @@ -namespace Purview.WslContainers.Azurite; +namespace Purview.Containers.Azurite; /// Immutable configuration for an Azurite test container. public sealed record AzuriteConfiguration : ContainerConfiguration { } diff --git a/src/src/Azurite/AzuriteContainer.cs b/src/src/Azurite/AzuriteContainer.cs index 973d5de..a3f22ef 100644 --- a/src/src/Azurite/AzuriteContainer.cs +++ b/src/src/Azurite/AzuriteContainer.cs @@ -1,13 +1,13 @@ using System.Globalization; using System.Text; -namespace Purview.WslContainers.Azurite; +namespace Purview.Containers.Azurite; /// A throwaway Azurite (Azure Storage emulator) instance running on WSL Containers. -public sealed class AzuriteContainer : WslContainer +public sealed class AzuriteContainer : ContainerBase { - internal AzuriteContainer(AzuriteConfiguration configuration, IContainerRuntime runtime) - : base(configuration, runtime) { } + internal AzuriteContainer(AzuriteConfiguration configuration, IContainerBackend? backend) + : base(configuration, backend) { } /// Blob service endpoint. Safe to call after . public Uri GetBlobEndpoint() diff --git a/src/src/Azurite/Sdk/README.md b/src/src/Azurite/Sdk/README.md index 4e469ba..df24de4 100644 --- a/src/src/Azurite/Sdk/README.md +++ b/src/src/Azurite/Sdk/README.md @@ -1,29 +1,34 @@ -# Purview.WslContainers.Azurite +# Purview.Containers.Azurite Throwaway [Azurite](https://github.com/Azure/Azurite) (Azure Storage emulator) instances for .NET -integration testing, running as WSLC containers on **Microsoft WSL Containers** — no Docker installation. +integration testing on **WSL Containers (WSLC)** or **Docker**. ```bash -dotnet add package Purview.WslContainers.Azurite +dotnet add package Purview.Containers.Azurite ``` -Depends on `Purview.WslContainers` (the core runtime). -See the [Getting Started guide](https://github.com/purview-dev/wsl-containers/blob/main/docs/wiki/Getting-Started.md). +Backend-neutral: depends on `Purview.Containers.Core` and needs a backend package (`Purview.Containers.Wsl` or `Purview.Containers.Docker`). +See the [Getting Started guide](https://github.com/purview-dev/containers/blob/main/docs/wiki/Getting-Started.md). ## Requirements -- Windows 10/11 with **WSL Containers** (`wsl --install --no-distribution`). -- **A .NET 11 project targeting Windows specifically** (`net11.0-windows10.0.19041.0`, x64 or arm64). - `Purview.WslContainers` supplies `buildTransitive` defaults for `WindowsSdkPackageVersion` and - `PlatformTarget`, and rejects an unsupported consumer with `PWC0001`/`PWC0002` — see the - [consumer requirements](https://github.com/purview-dev/wsl-containers/blob/main/docs/wiki/Consumer-Requirements.md). +- **WSL Containers backend:** Windows 10/11 with WSL Containers (`wsl --install --no-distribution`), and a + `.NET 10` or later project. A platform-neutral `net10.0` project binds the portable facade and gets + automatic WSLC-or-Docker selection; a Windows target framework + (`net10.0-windows10.0.19041.0`, x64 or arm64) binds the implementation directly. The + `Purview.Containers.Wsl` package supplies `buildTransitive` defaults for + `WindowsSdkPackageVersion`/`PlatformTarget` and (for a platform-neutral consumer on a Windows build + host) the implementation payload, rejecting an unsupported consumer with `PCC0001`/`PCC0002` — see the + [consumer requirements](https://github.com/purview-dev/containers/blob/main/docs/wiki/Consumer-Requirements.md). +- **Docker backend:** any reachable Docker daemon (`docker info`), with a `net10.0` or later project on any + platform. No Windows target framework and no `PCC` guards apply. - **Experimental:** the API, defaults and packaging can change between prereleases; there is no production support guarantee. ## Quick start ```csharp -using Purview.WslContainers.Azurite; +using Purview.Containers.Azurite; await using var azurite = new AzuriteBuilder().Build(); @@ -52,5 +57,5 @@ for the `successfully listening` log signal. Endpoints and the connection string The well-known `devstoreaccount1` key is a published constant of the emulator, but this library does not embed it: `AzuriteAccount.Key` holds a placeholder. Supply the real key in your test infrastructure before exercising authenticated operations (anonymous/local development paths are unaffected when the client does -not require the key). See [Modules](https://github.com/purview-dev/wsl-containers/blob/main/docs/wiki/Modules.md) +not require the key). See [Modules](https://github.com/purview-dev/containers/blob/main/docs/wiki/Modules.md) for the module contract. diff --git a/src/src/Containers/Containers.csproj b/src/src/Containers/Containers.csproj new file mode 100644 index 0000000..aa5b8c6 --- /dev/null +++ b/src/src/Containers/Containers.csproj @@ -0,0 +1,22 @@ + + + true + + Purview.Containers: the umbrella package. Brings the abstractions (Purview.Containers.Core) and both backends (WSL Containers and Docker), so one reference runs the same tests on WSLC on Windows and Docker everywhere else. + containers;testing;integration;testcontainers;docker;wsl;wslc;bundle + MIT + Purview + + + + + + + + diff --git a/src/src/Containers/Sdk/README.md b/src/src/Containers/Sdk/README.md new file mode 100644 index 0000000..8c2795d --- /dev/null +++ b/src/src/Containers/Sdk/README.md @@ -0,0 +1,72 @@ +# Purview.Containers + +The **umbrella package**: one reference that brings the abstractions and both backends, so the same tests +run on **WSL Containers (WSLC)** on a Windows machine and on **Docker** everywhere else — no backend +choice in your code or environment. + +```bash +dotnet add package Purview.Containers +``` + +It depends on: + +- [`Purview.Containers.Core`](https://www.nuget.org/packages/Purview.Containers.Core) — the backend-neutral + abstractions (`IContainer`, `ContainerBuilder`, `ContainerBackends`, wait strategies, …; public namespace + `Purview.Containers`). +- [`Purview.Containers.Wsl`](https://www.nuget.org/packages/Purview.Containers.Wsl) — the WSL Containers + backend. +- [`Purview.Containers.Docker`](https://www.nuget.org/packages/Purview.Containers.Docker) — the Docker + backend (Testcontainers). + +## Quick start + +A service module plus this package is all you need: + +```bash +dotnet add package Purview.Containers.Redis +dotnet add package Purview.Containers +``` + +```csharp +using Purview.Containers.Redis; +using StackExchange.Redis; + +await using var redis = new RedisBuilder().Build(); +await redis.StartAsync(); // waits for redis-cli ping + +await using var cache = await ConnectionMultiplexer.ConnectAsync(redis.GetConnectionString()); +await cache.GetDatabase().PingAsync(); +``` + +There is no registration line and no `PURVIEW_CONTAINERS_BACKEND`: each backend package ships +`buildTransitive` assets that register themselves in your assembly, and automatic selection picks the +first usable backend (WSLC is preferred where it works, Docker otherwise). Use a **`net10.0`** (or later) +project — see [Using it in your tests](https://github.com/purview-dev/containers/blob/main/docs/wiki/Using-in-Your-Tests.md). + +## When to use this versus a single backend + +| You want | Reference | +| --- | --- | +| WSLC locally **and** Docker in CI, one project, zero config | `Purview.Containers` (this package) | +| Docker only (e.g. a Linux-only CI job) — avoids the ~19 MB WSLC payload | `Purview.Containers.Core` + `Purview.Containers.Docker` | +| WSLC only | `Purview.Containers.Core` + `Purview.Containers.Wsl` | + +Adding a future backend (for example Podman) means adding its package to this bundle; your project does +not change. + +## Requirements + +- **.NET 10 or later.** The abstractions and service modules are portable; the WSL Containers backend is + multi-target and stays dormant on non-Windows hosts. See the + [consumer requirements](https://github.com/purview-dev/containers/blob/main/docs/wiki/Consumer-Requirements.md). +- Either a Windows 10/11 host with **WSL Containers** (`wsl --install --no-distribution`) or a reachable + **Docker** daemon. + +## Documentation + +- [Using it in your tests (auto)](https://github.com/purview-dev/containers/blob/main/docs/wiki/Using-in-Your-Tests.md) +- [Backends: WSLC or Docker](https://github.com/purview-dev/containers/blob/main/docs/wiki/Backends.md) +- [Modules](https://github.com/purview-dev/containers/blob/main/docs/wiki/Modules.md) + +> **Experimental.** The public API, defaults and packaging rules can change between prereleases; there is +> no production support guarantee. \ No newline at end of file diff --git a/src/src/Core/Container.cs b/src/src/Core/Container.cs new file mode 100644 index 0000000..db042f8 --- /dev/null +++ b/src/src/Core/Container.cs @@ -0,0 +1,10 @@ +namespace Purview.Containers; + +/// +/// The generic, backend-agnostic container produced by . The backend is +/// resolved when the container starts, so a builder can be configured without knowing which runtime will +/// run it. +/// +/// Creates a container for the configuration, optionally bound to a specific backend. +public class Container(IContainerConfiguration configuration, IContainerBackend? backend = null) + : ContainerBase(configuration, backend) { } diff --git a/src/src/Core/ContainerBackendInfo.cs b/src/src/Core/ContainerBackendInfo.cs new file mode 100644 index 0000000..a958b1f --- /dev/null +++ b/src/src/Core/ContainerBackendInfo.cs @@ -0,0 +1,22 @@ +namespace Purview.Containers; + +/// +/// Availability and version information reported by a container backend. Used by diagnostics and by the +/// backend selection policy: a backend is selectable when it is both available and compatible. +/// +/// Backend identifier, matching . +/// True when every component the backend needs is installed. +/// True when the installed components are compatible with this library. +/// Reported backend version, or an empty string when unknown. +/// Components that are missing or need an update. +public sealed record ContainerBackendInfo( + string Name, + bool IsAvailable, + bool IsCompatible, + string Version, + IReadOnlyList MissingComponents +) +{ + /// True when the backend can be selected. + public bool IsUsable => IsAvailable && IsCompatible; +} diff --git a/src/src/Core/ContainerBackendSelection.cs b/src/src/Core/ContainerBackendSelection.cs new file mode 100644 index 0000000..3922b7b --- /dev/null +++ b/src/src/Core/ContainerBackendSelection.cs @@ -0,0 +1,48 @@ +namespace Purview.Containers; + +/// +/// Chooses which container backend runs new containers: automatic detection, or one named backend. +/// +/// +/// The value comes from, in order of precedence: , +/// the PURVIEW_CONTAINERS_BACKEND environment variable (see +/// ), and finally . +/// A backend named here is used even when another backend would also work, and the resolution fails with +/// the named backend's diagnostics instead of silently falling back. +/// +public readonly record struct ContainerBackendSelection +{ + ContainerBackendSelection(string? name) => Name = name; + + /// Probe every registered backend and use the first usable one. + public static ContainerBackendSelection Auto { get; } = new(null); + + /// Uses the backend registered under (e.g. wsl, docker). + public static ContainerBackendSelection Named(string name) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + return new ContainerBackendSelection(name.Trim()); + } + + /// The backend name, or null for automatic detection. + public string? Name { get; } + + /// True when the backend should be detected by probing. + public bool IsAuto => Name is null; + + /// + /// Parses a configuration value. null, empty, whitespace and auto (case-insensitive) + /// mean automatic detection; any other value names a backend. + /// + public static ContainerBackendSelection Parse(string? value) => + string.IsNullOrWhiteSpace(value) || string.Equals(value.Trim(), "auto", StringComparison.OrdinalIgnoreCase) + ? Auto + : Named(value); + + /// Reads the selection from the PURVIEW_CONTAINERS_BACKEND environment variable. + public static ContainerBackendSelection FromEnvironment() => + Parse(Environment.GetEnvironmentVariable(ContainerBackends.SelectionEnvironmentVariable)); + + /// + public override string ToString() => Name ?? "auto"; +} diff --git a/src/src/Core/ContainerBackends.cs b/src/src/Core/ContainerBackends.cs new file mode 100644 index 0000000..24f2395 --- /dev/null +++ b/src/src/Core/ContainerBackends.cs @@ -0,0 +1,306 @@ +using System.Text; +using Purview.Containers.Runtime; + +namespace Purview.Containers; + +/// +/// Registry and selection point for the container backends available to this process. +/// +/// +/// +/// Backend packages register themselves from the consuming assembly through the generated module +/// initializer in Purview.Containers.Backends.targets, so a package consumer gets registration +/// without reflection and without depending on which assemblies the runtime happens to load. A +/// project-reference consumer (this repository's own tests, for example) can register explicitly with +/// . +/// +/// +/// The backend that actually runs is chosen by using this precedence: +/// a backend pinned with , then the requested +/// (set in code or read from ), then +/// automatic probing of every registered backend in +/// order (lowest first; registration order breaks ties). A named backend never falls back to another one: +/// the failure carries that backend's own diagnostics. +/// +/// +public static class ContainerBackends +{ + /// + /// Environment variable that selects the backend: auto (the default), or the name of a + /// registered backend such as wsl or docker. Case-insensitive; an unrecognised value + /// fails the resolution rather than being ignored. + /// + public const string SelectionEnvironmentVariable = "PURVIEW_CONTAINERS_BACKEND"; + + static readonly Lock Sync = new(); + static readonly List Registered = []; + static IContainerBackend? PinnedBackend; + static ContainerBackendSelection? PinnedSelection; + static Task? Resolution; + + /// Every registered backend, in registration order. + public static IReadOnlyList All + { + get + { + lock (Sync) + { + return [.. Registered]; + } + } + } + + /// + /// The requested selection: the value set with , otherwise + /// the value, otherwise + /// . + /// + public static ContainerBackendSelection Selection + { + get + { + lock (Sync) + { + return PinnedSelection ?? ContainerBackendSelection.FromEnvironment(); + } + } + } + + /// + /// Registers a backend. Idempotent: registering a backend whose + /// is already present replaces that entry, so an explicitly registered backend and a generated + /// registration can coexist. + /// + public static void Register(IContainerBackend backend) + { + ArgumentNullException.ThrowIfNull(backend); + lock (Sync) + { + var index = Registered.FindIndex(existing => + string.Equals(existing.Name, backend.Name, StringComparison.Ordinal) + ); + if (index >= 0) + { + Registered[index] = backend; + } + else + { + Registered.Add(backend); + } + + Resolution = null; + } + } + + /// + /// Pins a backend instance, which then wins over every other selection rule. Pass null to return + /// to the configured selection. + /// + public static void Use(IContainerBackend? backend) + { + lock (Sync) + { + PinnedBackend = backend; + Resolution = null; + } + } + + /// + /// Pins the selection (automatic detection or one named backend), which wins over the environment + /// variable. Pass null to fall back to the environment. + /// + public static void Use(ContainerBackendSelection? selection) + { + lock (Sync) + { + PinnedSelection = selection; + Resolution = null; + } + } + + /// + /// Resolves the backend to use, probing usability when necessary and caching the result for the process. + /// + /// + /// No backend is registered, the requested backend is not registered, or no registered backend is usable. + /// The message carries each backend's diagnostics. + /// + public static async Task ResolveAsync(CancellationToken cancellationToken = default) + { + var cached = Resolution; + if (cached is not null) + { + return await cached.ConfigureAwait(false); + } + + Task resolution; + lock (Sync) + { + resolution = Resolution ??= ResolveCoreAsync(PinnedBackend, Selection, [.. Registered], cancellationToken); + } + + try + { + return await resolution.ConfigureAwait(false); + } + catch + { + // Do not cache a failed resolution: a host that installs a backend while the process runs, or a + // test that registers one after a failure, must be able to resolve again. + lock (Sync) + { + if (ReferenceEquals(Resolution, resolution)) + { + Resolution = null; + } + } + + throw; + } + } + + /// + /// Probes every registered backend and reports its availability, version and missing components. + /// Intended for diagnostics and for callers that want to apply their own policy. + /// + public static async Task> ProbeAllAsync( + CancellationToken cancellationToken = default + ) + { + List probes = []; + foreach (var backend in All) + { + probes.Add(await ProbeAsync(backend, cancellationToken).ConfigureAwait(false)); + } + + return probes; + } + + /// Clears every registration, every selection and the cached resolution. Intended for tests. + public static void Reset() + { + lock (Sync) + { + Registered.Clear(); + PinnedBackend = null; + PinnedSelection = null; + Resolution = null; + } + } + + static async Task ResolveCoreAsync( + IContainerBackend? pinned, + ContainerBackendSelection selection, + IContainerBackend[] registered, + CancellationToken cancellationToken + ) + { + if (registered.Length == 0) + { + throw new ContainerBackendUnavailableException( + "No container backend is registered. Reference a backend package (for example " + + "'Purview.Containers.Wsl' on Windows or 'Purview.Containers.Docker' with a reachable " + + "Docker daemon) or register one explicitly with ContainerBackends.Register(...)." + ); + } + + if (pinned is not null) + { + var pinnedInfo = await ProbeAsync(pinned, cancellationToken).ConfigureAwait(false); + if (!pinnedInfo.IsUsable) + { + throw new ContainerBackendUnavailableException( + $"The pinned container backend '{pinned.Name}' is not usable: {Describe(pinnedInfo)}." + ); + } + + // The pinned backend is usable, so return it even if another backend would also work. + return pinned; + } + + if (!selection.IsAuto) + { + var named = + registered.FirstOrDefault(backend => + string.Equals(backend.Name, selection.Name, StringComparison.OrdinalIgnoreCase) + ) + ?? throw new ContainerBackendUnavailableException( + $"The container backend '{selection.Name}' is not registered. Registered backends: " + + $"{string.Join(", ", registered.Select(backend => backend.Name))}." + ); + + var namedInfo = await ProbeAsync(named, cancellationToken).ConfigureAwait(false); + if (!namedInfo.IsUsable) + { + throw new ContainerBackendUnavailableException( + $"The selected container backend '{named.Name}' is not usable: {Describe(namedInfo)}." + ); + } + + // The named backend is usable, so return it even if another backend would also work. + return named; + } + + StringBuilder report = new("No usable container backend was found (selection: auto)."); + foreach (var backend in OrderedForAutoSelection(registered)) + { + var info = await ProbeAsync(backend, cancellationToken).ConfigureAwait(false); + report.Append(Environment.NewLine).Append(" ").Append(Describe(info)); + if (info.IsUsable) + { + return backend; + } + } + + report + .Append(Environment.NewLine) + .Append("Install or fix a backend, or set ") + .Append(SelectionEnvironmentVariable) + .Append(" to one of: ") + .Append(string.Join(", ", registered.Select(backend => backend.Name))) + .Append('.'); + throw new ContainerBackendUnavailableException(report.ToString()); + } + + /// + /// Orders the registered backends for automatic selection: lowest + /// first (backends without the interface count + /// as 0), with registration order preserved for ties, so the WSL Containers backend is preferred over + /// Docker on a machine that can run both. + /// + static IEnumerable OrderedForAutoSelection(IContainerBackend[] registered) => + registered.OrderBy(backend => backend is IContainerBackendPreference preference ? preference.AutoPriority : 0); + + static async Task ProbeAsync(IContainerBackend backend, CancellationToken cancellationToken) + { + try + { + return await backend.GetInfoAsync(cancellationToken).ConfigureAwait(false) + ?? Unavailable(backend.Name, "the backend reported no information"); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + return Unavailable(backend.Name, $"{exception.GetType().Name}: {exception.Message}"); + } + } + + static ContainerBackendInfo Unavailable(string name, string reason) => + new(name, IsAvailable: false, IsCompatible: false, Version: string.Empty, MissingComponents: [reason]); + + static string Describe(ContainerBackendInfo info) + { + if (!info.IsAvailable) + { + return $"{info.Name}: unavailable ({string.Join("; ", info.MissingComponents)})"; + } + + if (!info.IsCompatible) + { + return $"{info.Name}: incompatible, version {info.Version} " + + $"({string.Join("; ", info.MissingComponents)})"; + } + + // The backend is available and compatible, so the version is meaningful. + return $"{info.Name}: available, version {info.Version}"; + } +} diff --git a/src/src/Core/ContainerBase.cs b/src/src/Core/ContainerBase.cs new file mode 100644 index 0000000..84f37f2 --- /dev/null +++ b/src/src/Core/ContainerBase.cs @@ -0,0 +1,131 @@ +namespace Purview.Containers; + +/// +/// Base class for the typed containers a service module exposes. It creates the backend container on +/// and delegates every member to it, which keeps the +/// module packages backend-neutral: a module package depends on this abstraction assembly only. +/// +public abstract class ContainerBase : IContainer +{ + IContainer? _container; + int _started; + int _disposed; + + /// + /// Creates a container bound to a backend. Prefer building via a module builder. When + /// is null (the default) the backend is resolved on + /// through . + /// + protected ContainerBase(IContainerConfiguration configuration, IContainerBackend? backend = null) + { + ArgumentNullException.ThrowIfNull(configuration); + Configuration = configuration; + Backend = backend; + Name = ContainerName.Generate(configuration); + } + + /// + /// The backend that will create the container, or null to resolve it on + /// . + /// + protected IContainerBackend? Backend { get; } + + /// The immutable configuration this container was built from. + protected IContainerConfiguration Configuration { get; } + + /// The started backend container. + /// The container has not been started. + protected IContainer Container => + _container ?? throw new InvalidOperationException("Container has not been started. Call StartAsync() first."); + + /// + public string Name { get; } + + /// + public string Id => _container?.Id ?? string.Empty; + + /// + public ContainerState State => _container?.State ?? ContainerState.Created; + + /// + public string Image => Configuration.Image; + + /// + public virtual async Task StartAsync(CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) == 1, this); + if (Interlocked.Exchange(ref _started, 1) == 1) + { + return; + } + + var backend = Backend ?? await ContainerBackends.ResolveAsync(cancellationToken).ConfigureAwait(false); + _container = backend.CreateContainer(WithResolvedName()); + await _container.StartAsync(cancellationToken).ConfigureAwait(false); + } + + /// + public virtual Task StopAsync(CancellationToken cancellationToken = default) + { + return _container?.StopAsync(cancellationToken) ?? Task.CompletedTask; + } + + /// + public virtual Task ExecAsync( + string[] command, + ExecOptions? options = null, + CancellationToken cancellationToken = default + ) + { + return Container.ExecAsync(command, options, cancellationToken); + } + + /// + public virtual ushort GetMappedPublicPort(ushort containerPort) + { + return Container.GetMappedPublicPort(containerPort); + } + + /// + public virtual IReadOnlyDictionary GetMappedPublicPorts() + { + return Container.GetMappedPublicPorts(); + } + + /// + public virtual Task GetLogsAsync(LogOutput? stream = null, CancellationToken cancellationToken = default) + { + return Container.GetLogsAsync(stream, cancellationToken); + } + + /// + public virtual IAsyncEnumerable GetLogsAsync(CancellationToken cancellationToken) + { + return Container.GetLogsAsync(cancellationToken); + } + + /// + public virtual async ValueTask DisposeAsync() + { + if (Interlocked.Exchange(ref _disposed, 1) == 1) + { + return; + } + + if (_container is not null) + { + await _container.DisposeAsync().ConfigureAwait(false); + } + + GC.SuppressFinalize(this); + } + + /// + /// Snapshots the configuration with the resolved container name so the backend creates the container + /// under the name this instance reports. + /// + IContainerConfiguration WithResolvedName() + { + return Configuration is ContainerConfiguration concrete ? concrete with { Name = Name } : Configuration; + } +} diff --git a/src/src/WslContainers/ContainerBuilder.cs b/src/src/Core/ContainerBuilder.cs similarity index 80% rename from src/src/WslContainers/ContainerBuilder.cs rename to src/src/Core/ContainerBuilder.cs index fc83484..868649b 100644 --- a/src/src/WslContainers/ContainerBuilder.cs +++ b/src/src/Core/ContainerBuilder.cs @@ -1,20 +1,20 @@ -using Purview.WslContainers.Images; -using Purview.WslContainers.Mounts; -using Purview.WslContainers.Networking; -using Purview.WslContainers.Runtime; -using Purview.WslContainers.Waiting; +using Purview.Containers.Images; +using Purview.Containers.Mounts; +using Purview.Containers.Networking; +using Purview.Containers.Runtime; +using Purview.Containers.Waiting; -namespace Purview.WslContainers; +namespace Purview.Containers; /// /// Fluent builder base for containers and strongly typed service modules. /// Builder instances accumulate configuration; produces an immutable configuration -/// and a container bound to a runtime. +/// and a container bound to a backend. /// [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1005:Avoid excessive parameters on generic types")] public abstract class ContainerBuilder where TBuilder : ContainerBuilder - where TContainer : WslContainer + where TContainer : IContainer where TConfiguration : ContainerConfiguration, new() { string? _image; @@ -37,17 +37,17 @@ public abstract class ContainerBuilder readonly List _waitStrategies = []; RegistryCredentials? _registryCredentials; - /// The runtime the built container will use. Defaults to the process-wide . - protected IContainerRuntime? Runtime { get; private set; } + /// The backend the built container will use. When null it is resolved at start time via . + protected IContainerBackend? Backend { get; private set; } - /// Creates a builder using the default process-wide runtime. + /// Creates a builder using the default backend. protected ContainerBuilder() { } - /// Creates a builder using an explicit runtime. - protected ContainerBuilder(IContainerRuntime runtime) + /// Creates a builder using an explicit backend. + protected ContainerBuilder(IContainerBackend backend) { - ArgumentNullException.ThrowIfNull(runtime); - Runtime = runtime; + ArgumentNullException.ThrowIfNull(backend); + Backend = backend; } /// Sets the container image. Invalid references fail fast (at configuration time). @@ -60,7 +60,7 @@ public TBuilder WithImage(string image) } catch (ArgumentException ex) { - throw new WslContainerConfigurationException(ex.Message, ex); + throw new ContainerConfigurationException(ex.Message, ex); } return (TBuilder)this; @@ -79,7 +79,7 @@ public TBuilder WithTag(string tag) { if (_parsedImage is not Image current) { - throw new WslContainerConfigurationException("Set an image with WithImage(...) before applying a tag."); + throw new ContainerConfigurationException("Set an image with WithImage(...) before applying a tag."); } try @@ -89,7 +89,7 @@ public TBuilder WithTag(string tag) } catch (ArgumentException ex) { - throw new WslContainerConfigurationException(ex.Message, ex); + throw new ContainerConfigurationException(ex.Message, ex); } return (TBuilder)this; @@ -273,11 +273,11 @@ public TBuilder WithRegistryCredentials(Uri serverAddress, string username, stri return (TBuilder)this; } - /// Sets the runtime the built container binds to. - public TBuilder WithRuntime(IContainerRuntime runtime) + /// Pins the backend the built container is created on, overriding . + public TBuilder WithBackend(IContainerBackend backend) { - ArgumentNullException.ThrowIfNull(runtime); - Runtime = runtime; + ArgumentNullException.ThrowIfNull(backend); + Backend = backend; return (TBuilder)this; } @@ -296,7 +296,7 @@ protected virtual TConfiguration BuildConfiguration() { Image = _image - ?? throw new WslContainerConfigurationException( + ?? throw new ContainerConfigurationException( "An image is required. Call WithImage(...) before Build()." ), Name = _name, @@ -326,33 +326,26 @@ protected virtual void Validate(ContainerConfiguration configuration) if (string.IsNullOrWhiteSpace(configuration.Image)) { - throw new WslContainerConfigurationException("An image is required."); + throw new ContainerConfigurationException("An image is required."); } if (configuration.StartupTimeout <= TimeSpan.Zero) { - throw new WslContainerConfigurationException( + throw new ContainerConfigurationException( $"Startup timeout must be positive; got {configuration.StartupTimeout}." ); } foreach (var portBinding in configuration.PortBindings) { - if (portBinding.Protocol == PortProtocol.Udp) - { - throw new WslContainerNotSupportedException( - "UDP port mappings are not supported by the WSLC managed API." - ); - } - if (portBinding.ContainerPort == 0) { - throw new WslContainerConfigurationException("Container port 0 is invalid for a port binding."); + throw new ContainerConfigurationException("Container port 0 is invalid for a port binding."); } if (portBinding.HostPort == 0) { - throw new WslContainerConfigurationException("Host port 0 is invalid; use a random host port instead."); + throw new ContainerConfigurationException("Host port 0 is invalid; use a random host port instead."); } } @@ -360,7 +353,7 @@ protected virtual void Validate(ContainerConfiguration configuration) { if (string.IsNullOrWhiteSpace(bindMount.HostPath) || string.IsNullOrWhiteSpace(bindMount.ContainerPath)) { - throw new WslContainerConfigurationException("Bind mounts require a host path and a container path."); + throw new ContainerConfigurationException("Bind mounts require a host path and a container path."); } } @@ -368,31 +361,28 @@ protected virtual void Validate(ContainerConfiguration configuration) { if (string.IsNullOrWhiteSpace(namedVolume.Name) || string.IsNullOrWhiteSpace(namedVolume.ContainerPath)) { - throw new WslContainerConfigurationException( - "Named volumes require a volume name and a container path." - ); + throw new ContainerConfigurationException("Named volumes require a volume name and a container path."); } } } - /// Creates the container object bound to a runtime. + /// Creates the container object bound to a backend. protected abstract TContainer CreateContainer(TConfiguration configuration); } /// Fluent builder for a generic container. -public class ContainerBuilder : ContainerBuilder +public class ContainerBuilder : ContainerBuilder { /// Creates a builder for a generic container. public ContainerBuilder() { } - /// Creates a builder for a generic container using an explicit runtime. - public ContainerBuilder(IContainerRuntime runtime) - : base(runtime) { } + /// Creates a builder for a generic container using an explicit backend. + public ContainerBuilder(IContainerBackend backend) + : base(backend) { } /// - protected override WslContainer CreateContainer(ContainerConfiguration configuration) + protected override Container CreateContainer(ContainerConfiguration configuration) { - var runtime = Runtime ?? WslContainerRuntime.Instance; - return new WslContainer(configuration, runtime); + return new Container(configuration, Backend); } } diff --git a/src/src/WslContainers/ContainerConfiguration.cs b/src/src/Core/ContainerConfiguration.cs similarity index 94% rename from src/src/WslContainers/ContainerConfiguration.cs rename to src/src/Core/ContainerConfiguration.cs index 020b91b..e5fc4e9 100644 --- a/src/src/WslContainers/ContainerConfiguration.cs +++ b/src/src/Core/ContainerConfiguration.cs @@ -1,11 +1,11 @@ using System.Text; -using Purview.WslContainers.Diagnostics; -using Purview.WslContainers.Images; -using Purview.WslContainers.Mounts; -using Purview.WslContainers.Networking; -using Purview.WslContainers.Waiting; +using Purview.Containers.Diagnostics; +using Purview.Containers.Images; +using Purview.Containers.Mounts; +using Purview.Containers.Networking; +using Purview.Containers.Waiting; -namespace Purview.WslContainers; +namespace Purview.Containers; /// Immutable configuration for a generic WSLC container. public record ContainerConfiguration : IContainerConfiguration diff --git a/src/src/WslContainers/Containers/ContainerLogEntry.cs b/src/src/Core/ContainerLogEntry.cs similarity index 79% rename from src/src/WslContainers/Containers/ContainerLogEntry.cs rename to src/src/Core/ContainerLogEntry.cs index 7f8e6c1..cfc5e26 100644 --- a/src/src/WslContainers/Containers/ContainerLogEntry.cs +++ b/src/src/Core/ContainerLogEntry.cs @@ -1,4 +1,4 @@ -namespace Purview.WslContainers.Containers; +namespace Purview.Containers; /// A single line of container init-process output. public sealed record ContainerLogEntry(DateTimeOffset Timestamp, LogOutput Stream, string Text); diff --git a/src/src/Core/ContainerName.cs b/src/src/Core/ContainerName.cs new file mode 100644 index 0000000..1f08d85 --- /dev/null +++ b/src/src/Core/ContainerName.cs @@ -0,0 +1,41 @@ +namespace Purview.Containers; + +/// +/// Generates the unique container name used when a configuration does not set one. Shared by every +/// backend so a name is stable before and after the container starts. +/// +public static class ContainerName +{ + /// Builds a name from the image's short name, the process id and a random suffix. + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Design", + "CA1031:Do not catch general exception types", + Justification = "A container name must always be produced, even for an unparseable image reference." + )] + public static string Generate(string image) + { + string shortName; + try + { + shortName = Images.Image.Parse(image).ShortName; + } + catch + { + shortName = "container"; + } + + string safe = new([ + .. shortName.Select(character => + char.IsAsciiLetterOrDigit(character) || character is '-' or '_' ? character : '-' + ), + ]); + return $"{safe}-{Environment.ProcessId}-{Guid.NewGuid().ToString("N")[..6]}"; + } + + /// Builds a name for a configuration, preferring an explicitly configured name. + public static string Generate(IContainerConfiguration configuration) + { + ArgumentNullException.ThrowIfNull(configuration); + return configuration.Name ?? Generate(configuration.Image); + } +} diff --git a/src/src/WslContainers/Containers/ContainerState.cs b/src/src/Core/ContainerState.cs similarity index 91% rename from src/src/WslContainers/Containers/ContainerState.cs rename to src/src/Core/ContainerState.cs index 09df5b4..506fa5e 100644 --- a/src/src/WslContainers/Containers/ContainerState.cs +++ b/src/src/Core/ContainerState.cs @@ -1,4 +1,4 @@ -namespace Purview.WslContainers.Containers; +namespace Purview.Containers; /// Lifecycle state of a WSLC container. public enum ContainerState diff --git a/src/src/Core/Core.csproj b/src/src/Core/Core.csproj new file mode 100644 index 0000000..38cc6ff --- /dev/null +++ b/src/src/Core/Core.csproj @@ -0,0 +1,9 @@ + + + true + Backend-neutral container testing abstractions for .NET: the shared Testcontainers-style model behind Purview.Containers.Wsl and Purview.Containers.Docker. + containers;testing;integration;testcontainers;abstractions;docker;wsl + MIT + Purview + + diff --git a/src/src/Core/Diagnostics/ContainerActivity.cs b/src/src/Core/Diagnostics/ContainerActivity.cs new file mode 100644 index 0000000..d9cbbb2 --- /dev/null +++ b/src/src/Core/Diagnostics/ContainerActivity.cs @@ -0,0 +1,13 @@ +using System.Diagnostics; + +namespace Purview.Containers.Diagnostics; + +/// +/// Activity source for backend-neutral container operations. Listener names: +/// Purview.Containers (the WSL Containers backend emits to the same name). +/// +static class ContainerActivity +{ + /// Activities: wait. + public static readonly ActivitySource Source = new("Purview.Containers"); +} diff --git a/src/src/WslContainers/Diagnostics/Secret.cs b/src/src/Core/Diagnostics/Secret.cs similarity index 93% rename from src/src/WslContainers/Diagnostics/Secret.cs rename to src/src/Core/Diagnostics/Secret.cs index bc8ee2c..c1eac6d 100644 --- a/src/src/WslContainers/Diagnostics/Secret.cs +++ b/src/src/Core/Diagnostics/Secret.cs @@ -1,4 +1,4 @@ -namespace Purview.WslContainers.Diagnostics; +namespace Purview.Containers.Diagnostics; /// /// Wraps a sensitive value so it is never printed by default diagnostics. diff --git a/src/src/WslContainers/Diagnostics/SecretRedactor.cs b/src/src/Core/Diagnostics/SecretRedactor.cs similarity index 94% rename from src/src/WslContainers/Diagnostics/SecretRedactor.cs rename to src/src/Core/Diagnostics/SecretRedactor.cs index 08e7e66..5265036 100644 --- a/src/src/WslContainers/Diagnostics/SecretRedactor.cs +++ b/src/src/Core/Diagnostics/SecretRedactor.cs @@ -1,4 +1,4 @@ -namespace Purview.WslContainers.Diagnostics; +namespace Purview.Containers.Diagnostics; /// Redacts sensitive values from diagnostics and configuration rendering. static class SecretRedactor diff --git a/src/src/WslContainers/Containers/ExecOptions.cs b/src/src/Core/ExecOptions.cs similarity index 94% rename from src/src/WslContainers/Containers/ExecOptions.cs rename to src/src/Core/ExecOptions.cs index ccf2980..ffa8236 100644 --- a/src/src/WslContainers/Containers/ExecOptions.cs +++ b/src/src/Core/ExecOptions.cs @@ -1,4 +1,4 @@ -namespace Purview.WslContainers.Containers; +namespace Purview.Containers; /// Options for . public sealed record ExecOptions diff --git a/src/src/WslContainers/Containers/ExecResult.cs b/src/src/Core/ExecResult.cs similarity index 85% rename from src/src/WslContainers/Containers/ExecResult.cs rename to src/src/Core/ExecResult.cs index 4ef5489..8de48b3 100644 --- a/src/src/WslContainers/Containers/ExecResult.cs +++ b/src/src/Core/ExecResult.cs @@ -1,4 +1,4 @@ -namespace Purview.WslContainers.Containers; +namespace Purview.Containers; /// Result of executing a command inside a container. public sealed record ExecResult(long ExitCode, string Stdout, string Stderr) diff --git a/src/src/WslContainers/IContainer.cs b/src/src/Core/IContainer.cs similarity index 95% rename from src/src/WslContainers/IContainer.cs rename to src/src/Core/IContainer.cs index 2db4bbc..e7b2a46 100644 --- a/src/src/WslContainers/IContainer.cs +++ b/src/src/Core/IContainer.cs @@ -1,6 +1,4 @@ -using Purview.WslContainers.Containers; - -namespace Purview.WslContainers; +namespace Purview.Containers; /// A throwaway Linux container running on WSL Containers. public interface IContainer : IAsyncDisposable diff --git a/src/src/Core/IContainerBackend.cs b/src/src/Core/IContainerBackend.cs new file mode 100644 index 0000000..d40756e --- /dev/null +++ b/src/src/Core/IContainerBackend.cs @@ -0,0 +1,18 @@ +namespace Purview.Containers; + +/// +/// A container runtime provider: it creates containers and reports whether it is usable on this host. +/// Implemented by the backend packages (Purview.Containers.Wsl, Purview.Containers.Docker) +/// and selected through . +/// +public interface IContainerBackend +{ + /// Stable backend identifier, for example wsl or docker. + string Name { get; } + + /// Creates (but does not start) a container for the given configuration. + IContainer CreateContainer(IContainerConfiguration configuration); + + /// Reports availability, version and missing components for diagnostics and selection. + Task GetInfoAsync(CancellationToken cancellationToken = default); +} diff --git a/src/src/Core/IContainerBackendPreference.cs b/src/src/Core/IContainerBackendPreference.cs new file mode 100644 index 0000000..6c598bf --- /dev/null +++ b/src/src/Core/IContainerBackendPreference.cs @@ -0,0 +1,26 @@ +namespace Purview.Containers; + +/// +/// Optional backend metadata consulted by automatic backend selection. +/// +/// +/// +/// When several registered backends are usable, probes them +/// in order (lowest first) and returns the first usable one. A backend that +/// does not implement this interface is treated as priority 0, and equal priorities keep +/// registration order, so this is purely additive: existing backends behave exactly as before. +/// +/// +/// It exists so a documented preference — "on a machine that can run both, prefer WSL Containers over +/// Docker" — is expressed by the backends themselves rather than by the order NuGet happens to import +/// their buildTransitive props. A pinned instance or a named selection always overrides it. +/// +/// +public interface IContainerBackendPreference +{ + /// + /// Preference for automatic selection: a lower value wins. Only consulted for automatic + /// (auto) selection. + /// + int AutoPriority { get; } +} diff --git a/src/src/WslContainers/IContainerBuilder.cs b/src/src/Core/IContainerBuilder.cs similarity index 89% rename from src/src/WslContainers/IContainerBuilder.cs rename to src/src/Core/IContainerBuilder.cs index 0b3e680..67767a5 100644 --- a/src/src/WslContainers/IContainerBuilder.cs +++ b/src/src/Core/IContainerBuilder.cs @@ -1,4 +1,4 @@ -namespace Purview.WslContainers; +namespace Purview.Containers; /// Produces an immutable from fluent configuration. public interface IContainerBuilder diff --git a/src/src/WslContainers/IContainerConfiguration.cs b/src/src/Core/IContainerConfiguration.cs similarity index 92% rename from src/src/WslContainers/IContainerConfiguration.cs rename to src/src/Core/IContainerConfiguration.cs index b9d5631..dcbc9af 100644 --- a/src/src/WslContainers/IContainerConfiguration.cs +++ b/src/src/Core/IContainerConfiguration.cs @@ -1,9 +1,9 @@ -using Purview.WslContainers.Images; -using Purview.WslContainers.Mounts; -using Purview.WslContainers.Networking; -using Purview.WslContainers.Waiting; +using Purview.Containers.Images; +using Purview.Containers.Mounts; +using Purview.Containers.Networking; +using Purview.Containers.Waiting; -namespace Purview.WslContainers; +namespace Purview.Containers; /// Immutable container configuration consumed by the runtime at start time. public interface IContainerConfiguration diff --git a/src/src/WslContainers/Images/Image.cs b/src/src/Core/Images/Image.cs similarity index 92% rename from src/src/WslContainers/Images/Image.cs rename to src/src/Core/Images/Image.cs index be23cb4..55dd82f 100644 --- a/src/src/WslContainers/Images/Image.cs +++ b/src/src/Core/Images/Image.cs @@ -1,4 +1,4 @@ -namespace Purview.WslContainers.Images; +namespace Purview.Containers.Images; /// /// A parsed container image reference (registry / repository / tag). Immutable; invalid references @@ -29,7 +29,7 @@ public readonly record struct Image public string FullReference => $"{Registry}/{Repository}:{Tag}"; /// Last repository segment, e.g. alpine. - public string ShortName => Repository[(Repository.LastIndexOf('/', StringComparison.Ordinal) + 1)..]; + public string ShortName => Repository[(Repository.LastIndexOf('/') + 1)..]; /// /// Parses an image reference. Accepts alpine, alpine:latest, docker.io/library/alpine:latest, @@ -45,8 +45,8 @@ public static Image Parse(string reference) } var tag = DefaultTag; - var lastSlash = reference.LastIndexOf('/', StringComparison.Ordinal); - var lastColon = reference.LastIndexOf(':', StringComparison.Ordinal); + var lastSlash = reference.LastIndexOf('/'); + var lastColon = reference.LastIndexOf(':'); if (lastColon > lastSlash) { tag = NormalizeTag(reference[(lastColon + 1)..]); @@ -101,8 +101,8 @@ public Image WithTag(string tag) } /// - /// Returns true when a name returned by - /// (e.g. alpine:latest) refers to this image. + /// Returns true when a name returned by the backend's image list (e.g. alpine:latest) refers + /// to this image. /// public bool MatchesStoredName(string storedName) { diff --git a/src/src/WslContainers/Images/ImagePullProgress.cs b/src/src/Core/Images/ImagePullProgress.cs similarity index 81% rename from src/src/WslContainers/Images/ImagePullProgress.cs rename to src/src/Core/Images/ImagePullProgress.cs index 6306193..11ed4bd 100644 --- a/src/src/WslContainers/Images/ImagePullProgress.cs +++ b/src/src/Core/Images/ImagePullProgress.cs @@ -1,4 +1,4 @@ -namespace Purview.WslContainers.Images; +namespace Purview.Containers.Images; /// Progress reported while pulling an image. public sealed record ImagePullProgress(string? Id, string Status, ulong CurrentBytes, ulong TotalBytes); diff --git a/src/src/WslContainers/Images/ImageSummary.cs b/src/src/Core/Images/ImageSummary.cs similarity index 80% rename from src/src/WslContainers/Images/ImageSummary.cs rename to src/src/Core/Images/ImageSummary.cs index d85344d..d413dc5 100644 --- a/src/src/WslContainers/Images/ImageSummary.cs +++ b/src/src/Core/Images/ImageSummary.cs @@ -1,4 +1,4 @@ -namespace Purview.WslContainers.Images; +namespace Purview.Containers.Images; /// Summary of an image present in the session store. public sealed record ImageSummary(string Name, ulong Size, DateTimeOffset CreatedTimestamp); diff --git a/src/src/WslContainers/Images/PullPolicy.cs b/src/src/Core/Images/PullPolicy.cs similarity index 89% rename from src/src/WslContainers/Images/PullPolicy.cs rename to src/src/Core/Images/PullPolicy.cs index 2c99788..10cca6b 100644 --- a/src/src/WslContainers/Images/PullPolicy.cs +++ b/src/src/Core/Images/PullPolicy.cs @@ -1,4 +1,4 @@ -namespace Purview.WslContainers.Images; +namespace Purview.Containers.Images; /// Controls when an image is pulled. public enum PullPolicy diff --git a/src/src/WslContainers/Containers/LogStream.cs b/src/src/Core/LogStream.cs similarity index 82% rename from src/src/WslContainers/Containers/LogStream.cs rename to src/src/Core/LogStream.cs index 27e7268..1c04f8d 100644 --- a/src/src/WslContainers/Containers/LogStream.cs +++ b/src/src/Core/LogStream.cs @@ -1,4 +1,4 @@ -namespace Purview.WslContainers.Containers; +namespace Purview.Containers; /// Standard output stream of a container or exec process. public enum LogOutput diff --git a/src/src/WslContainers/Mounts/BindMount.cs b/src/src/Core/Mounts/BindMount.cs similarity index 81% rename from src/src/WslContainers/Mounts/BindMount.cs rename to src/src/Core/Mounts/BindMount.cs index 30f4eda..507aaef 100644 --- a/src/src/WslContainers/Mounts/BindMount.cs +++ b/src/src/Core/Mounts/BindMount.cs @@ -1,4 +1,4 @@ -namespace Purview.WslContainers.Mounts; +namespace Purview.Containers.Mounts; /// A bind mount of a Windows directory into the container. public sealed record BindMount(string HostPath, string ContainerPath, bool ReadOnly = false); diff --git a/src/src/WslContainers/Mounts/NamedVolume.cs b/src/src/Core/Mounts/NamedVolume.cs similarity index 83% rename from src/src/WslContainers/Mounts/NamedVolume.cs rename to src/src/Core/Mounts/NamedVolume.cs index bff93df..28db35b 100644 --- a/src/src/WslContainers/Mounts/NamedVolume.cs +++ b/src/src/Core/Mounts/NamedVolume.cs @@ -1,4 +1,4 @@ -namespace Purview.WslContainers.Mounts; +namespace Purview.Containers.Mounts; /// A named volume (session VHD) mounted into the container. Auto-provisioned on first use. public sealed record NamedVolume(string Name, string ContainerPath, bool ReadOnly = false); diff --git a/src/src/WslContainers/Networking/ContainerNetworkingMode.cs b/src/src/Core/Networking/ContainerNetworkingMode.cs similarity index 89% rename from src/src/WslContainers/Networking/ContainerNetworkingMode.cs rename to src/src/Core/Networking/ContainerNetworkingMode.cs index d139b58..302a397 100644 --- a/src/src/WslContainers/Networking/ContainerNetworkingMode.cs +++ b/src/src/Core/Networking/ContainerNetworkingMode.cs @@ -1,4 +1,4 @@ -namespace Purview.WslContainers.Networking; +namespace Purview.Containers.Networking; /// Container networking mode. WSLC only supports none and bridged. public enum ContainerNetworkingMode diff --git a/src/src/WslContainers/Networking/PortBinding.cs b/src/src/Core/Networking/PortBinding.cs similarity index 94% rename from src/src/WslContainers/Networking/PortBinding.cs rename to src/src/Core/Networking/PortBinding.cs index e7dc7f4..e28078c 100644 --- a/src/src/WslContainers/Networking/PortBinding.cs +++ b/src/src/Core/Networking/PortBinding.cs @@ -1,4 +1,4 @@ -namespace Purview.WslContainers.Networking; +namespace Purview.Containers.Networking; /// A host-to-container port mapping. /// The port inside the container. diff --git a/src/src/WslContainers/Networking/PortProtocol.cs b/src/src/Core/Networking/PortProtocol.cs similarity index 81% rename from src/src/WslContainers/Networking/PortProtocol.cs rename to src/src/Core/Networking/PortProtocol.cs index 1d1d7aa..bf894e0 100644 --- a/src/src/WslContainers/Networking/PortProtocol.cs +++ b/src/src/Core/Networking/PortProtocol.cs @@ -1,4 +1,4 @@ -namespace Purview.WslContainers.Networking; +namespace Purview.Containers.Networking; /// Port transport protocol. public enum PortProtocol diff --git a/src/src/WslContainers/RegistryCredentials.cs b/src/src/Core/RegistryCredentials.cs similarity index 90% rename from src/src/WslContainers/RegistryCredentials.cs rename to src/src/Core/RegistryCredentials.cs index 6998c9c..c9dc0f5 100644 --- a/src/src/WslContainers/RegistryCredentials.cs +++ b/src/src/Core/RegistryCredentials.cs @@ -1,6 +1,6 @@ -using Purview.WslContainers.Diagnostics; +using Purview.Containers.Diagnostics; -namespace Purview.WslContainers; +namespace Purview.Containers; /// Credentials for authenticating to a private container registry. public sealed record RegistryCredentials(Uri ServerAddress, string Username, Secret Password) diff --git a/src/src/Core/Runtime/ContainerException.cs b/src/src/Core/Runtime/ContainerException.cs new file mode 100644 index 0000000..d2eae77 --- /dev/null +++ b/src/src/Core/Runtime/ContainerException.cs @@ -0,0 +1,41 @@ +namespace Purview.Containers.Runtime; + +#pragma warning disable CA1032 // Implement standard exception constructors + +/// Base exception for all errors raised by a container backend. +public class ContainerException : Exception +{ + public ContainerException(string message) + : base(message) { } + + public ContainerException(string message, Exception innerException) + : base(message, innerException) { } +} + +/// Invalid container configuration detected before any backend operation. +public class ContainerConfigurationException : ContainerException +{ + public ContainerConfigurationException(string message) + : base(message) { } + + public ContainerConfigurationException(string message, Exception innerException) + : base(message, innerException) { } +} + +/// The backend's prerequisites are missing or incompatible with this library. +public class ContainerPrerequisiteException(string message) : ContainerException(message) { } + +/// A requested feature is not supported by the selected backend. +public class ContainerNotSupportedException(string message) : ContainerException(message) { } + +/// Starting the container failed (image, runtime, or environment). +public class ContainerStartupException(string message, Exception innerException) + : ContainerException(message, innerException) { } + +/// An operation did not complete within its configured timeout. +public class ContainerTimeoutException(string message) : ContainerException(message) { } + +/// +/// No container backend is registered, or the explicitly selected backend cannot be used on this host. +/// +public class ContainerBackendUnavailableException(string message) : ContainerException(message) { } diff --git a/src/src/Core/Sdk/README.md b/src/src/Core/Sdk/README.md new file mode 100644 index 0000000..8540ea7 --- /dev/null +++ b/src/src/Core/Sdk/README.md @@ -0,0 +1,71 @@ +# Purview.Containers.Core + +Backend-neutral container testing abstractions for .NET — the shared surface behind +[`Purview.Containers.Wsl`](https://www.nuget.org/packages/Purview.Containers.Wsl) (WSL Containers) and +`Purview.Containers.Docker` (Docker Engine via Testcontainers). + +```bash +dotnet add package Purview.Containers.Core +``` + +Most consumers reference [`Purview.Containers`](https://www.nuget.org/packages/Purview.Containers) (the +bundle that brings this package plus both backends), a single backend package, or a service module +instead; this package arrives transitively and supplies the model plus the backend registration hook. The +public namespace is `Purview.Containers`, so consumer code that uses it never needs to know it is a +separate package. + +## What is in the package + +| Area | Types | +| --- | --- | +| Container contract | `IContainer`, `IContainerConfiguration`, `ContainerConfiguration`, `ContainerBuilder`, `ContainerBase` | +| Backends | `IContainerBackend`, `ContainerBackendInfo`, `ContainerBackends`, `ContainerBackendUnavailableException` | +| Readiness | `Purview.Containers.Waiting`: `Wait`, `IWaitStrategy`, TCP/HTTP/command/log strategies | +| Configuration model | `PortBinding`, `BindMount`, `NamedVolume`, `RegistryCredentials`, `Image`, `PullPolicy` | +| Diagnostics | `Secret`, `SecretRedactor`, `ContainerState`, `ExecResult`, `ContainerLogEntry` | +| Errors | `Purview.Containers.Runtime`: `ContainerException` and its specialisations | + +## Backend selection + +A container is produced by an `IContainerBackend`. Backend packages register themselves into the +consuming assembly through the `buildTransitive` assets shipped here: + +```csharp +// generated by Purview.Containers.Core.targets +[ModuleInitializer] +internal static void Initialize() +{ + ContainerBackends.Register(WslContainerBackend.Create()); +} +``` + +`ContainerBackends.ResolveAsync()` picks the backend with this precedence: + +| # | Rule | How | +| --- | --- | --- | +| 1 | Pinned instance | `ContainerBackends.Use(new WslContainerBackend())`, or `WithBackend(...)` on one builder | +| 2 | Named backend | `ContainerBackends.Use(ContainerBackendSelection.Named("docker"))`, or `PURVIEW_CONTAINERS_BACKEND=docker` | +| 3 | Automatic detection | probe every registered backend; the first usable one wins (WSLC before Docker) | + +`PURVIEW_CONTAINERS_BACKEND` accepts `auto` (the default) or a backend name, and is case-insensitive. +A named or pinned backend **never** silently falls back: if it is missing or unusable, the resolution +fails with that backend's own diagnostics. When nothing is usable, the error lists every backend's +availability, version and missing components, and names the fix. Results are cached per process, so the +probe cost is paid once. + +`ContainerBackends.ProbeAllAsync()` returns the same probe information without selecting anything, for +callers that want to apply their own policy. + +## Requirements + +- **.NET 10 or later**, any platform (this package is portable). +- A **backend package**: `Purview.Containers.Wsl` (WSLC on a Windows host, Docker elsewhere) or + `Purview.Containers.Docker` (Docker Engine reachable from the test host). + +## Related + +- [Purview.Containers.Wsl](https://www.nuget.org/packages/Purview.Containers.Wsl) — WSL Containers backend. +- Documentation wiki: . + +> **Experimental.** The public API, defaults and packaging rules can change between prereleases, and +> there is no production support guarantee. Pin the exact package version you build against. diff --git a/src/src/Core/Sdk/buildTransitive/Purview.Containers.Core.props b/src/src/Core/Sdk/buildTransitive/Purview.Containers.Core.props new file mode 100644 index 0000000..d3c7734 --- /dev/null +++ b/src/src/Core/Sdk/buildTransitive/Purview.Containers.Core.props @@ -0,0 +1,16 @@ + + + + + + + diff --git a/src/src/Core/Sdk/buildTransitive/Purview.Containers.Core.targets b/src/src/Core/Sdk/buildTransitive/Purview.Containers.Core.targets new file mode 100644 index 0000000..a149855 --- /dev/null +++ b/src/src/Core/Sdk/buildTransitive/Purview.Containers.Core.targets @@ -0,0 +1,50 @@ + + + + + + <_PurviewContainersBackendType Include="$(PurviewContainersBackends)" /> + <_PurviewContainersRegistrationLine Include="// <auto-generated/>" /> + <_PurviewContainersRegistrationLine Include="// Registers the container backends referenced by this project." /> + <_PurviewContainersRegistrationLine Include="internal static class PurviewContainersBackendRegistration" /> + <_PurviewContainersRegistrationLine Include="{" /> + <_PurviewContainersRegistrationLine Include=" [global::System.Runtime.CompilerServices.ModuleInitializer]" /> + <_PurviewContainersRegistrationLine Include=" internal static void Initialize()" /> + <_PurviewContainersRegistrationLine Include=" {" /> + <_PurviewContainersRegistrationLine Include="@(_PurviewContainersBackendType->' global::Purview.Containers.ContainerBackends.Register(global::%(Identity).Create());')" /> + <_PurviewContainersRegistrationLine Include=" }" /> + <_PurviewContainersRegistrationLine Include="}" /> + + + + <_PurviewContainersRegistrationFile>$(IntermediateOutputPath)Purview.Containers.Backends.g.cs + + + + + + + + + + diff --git a/src/src/WslContainers/Waiting/AllWaitStrategy.cs b/src/src/Core/Waiting/AllWaitStrategy.cs similarity index 94% rename from src/src/WslContainers/Waiting/AllWaitStrategy.cs rename to src/src/Core/Waiting/AllWaitStrategy.cs index 2c8170c..12b629b 100644 --- a/src/src/WslContainers/Waiting/AllWaitStrategy.cs +++ b/src/src/Core/Waiting/AllWaitStrategy.cs @@ -1,4 +1,4 @@ -namespace Purview.WslContainers.Waiting; +namespace Purview.Containers.Waiting; /// Composes strategies; ready only when all contained strategies are ready on the same check. public sealed class AllWaitStrategy : WaitStrategy diff --git a/src/src/WslContainers/Waiting/AnyWaitStrategy.cs b/src/src/Core/Waiting/AnyWaitStrategy.cs similarity index 94% rename from src/src/WslContainers/Waiting/AnyWaitStrategy.cs rename to src/src/Core/Waiting/AnyWaitStrategy.cs index 1c698bf..503923e 100644 --- a/src/src/WslContainers/Waiting/AnyWaitStrategy.cs +++ b/src/src/Core/Waiting/AnyWaitStrategy.cs @@ -1,4 +1,4 @@ -namespace Purview.WslContainers.Waiting; +namespace Purview.Containers.Waiting; /// Composes strategies; ready when any contained strategy is ready on the same check. public sealed class AnyWaitStrategy : WaitStrategy diff --git a/src/src/WslContainers/Waiting/CommandWaitStrategy.cs b/src/src/Core/Waiting/CommandWaitStrategy.cs similarity index 96% rename from src/src/WslContainers/Waiting/CommandWaitStrategy.cs rename to src/src/Core/Waiting/CommandWaitStrategy.cs index ae04827..a4ff9f5 100644 --- a/src/src/WslContainers/Waiting/CommandWaitStrategy.cs +++ b/src/src/Core/Waiting/CommandWaitStrategy.cs @@ -1,6 +1,6 @@ using System.Diagnostics.CodeAnalysis; -namespace Purview.WslContainers.Waiting; +namespace Purview.Containers.Waiting; /// Waits until a command executed inside the container exits with the expected code (default 0). [SuppressMessage( diff --git a/src/src/WslContainers/Waiting/ContainerRunningWaitStrategy.cs b/src/src/Core/Waiting/ContainerRunningWaitStrategy.cs similarity index 81% rename from src/src/WslContainers/Waiting/ContainerRunningWaitStrategy.cs rename to src/src/Core/Waiting/ContainerRunningWaitStrategy.cs index 1841ba2..3671bfd 100644 --- a/src/src/WslContainers/Waiting/ContainerRunningWaitStrategy.cs +++ b/src/src/Core/Waiting/ContainerRunningWaitStrategy.cs @@ -1,6 +1,4 @@ -using Purview.WslContainers.Containers; - -namespace Purview.WslContainers.Waiting; +namespace Purview.Containers.Waiting; /// Waits until the container init process is running. public sealed class ContainerRunningWaitStrategy : WaitStrategy diff --git a/src/src/WslContainers/Waiting/CustomWaitStrategy.cs b/src/src/Core/Waiting/CustomWaitStrategy.cs similarity index 93% rename from src/src/WslContainers/Waiting/CustomWaitStrategy.cs rename to src/src/Core/Waiting/CustomWaitStrategy.cs index 3479195..65078e4 100644 --- a/src/src/WslContainers/Waiting/CustomWaitStrategy.cs +++ b/src/src/Core/Waiting/CustomWaitStrategy.cs @@ -1,4 +1,4 @@ -namespace Purview.WslContainers.Waiting; +namespace Purview.Containers.Waiting; /// Waits until a custom predicate returns true. public sealed class CustomWaitStrategy : WaitStrategy diff --git a/src/src/WslContainers/Waiting/HttpWaitStrategy.cs b/src/src/Core/Waiting/HttpWaitStrategy.cs similarity index 98% rename from src/src/WslContainers/Waiting/HttpWaitStrategy.cs rename to src/src/Core/Waiting/HttpWaitStrategy.cs index 17ca9f0..8da012f 100644 --- a/src/src/WslContainers/Waiting/HttpWaitStrategy.cs +++ b/src/src/Core/Waiting/HttpWaitStrategy.cs @@ -1,7 +1,7 @@ using System.Diagnostics.CodeAnalysis; using System.Net; -namespace Purview.WslContainers.Waiting; +namespace Purview.Containers.Waiting; /// Waits until an HTTP(S) request against the mapped host port succeeds. [SuppressMessage( diff --git a/src/src/WslContainers/Waiting/IWaitStrategy.cs b/src/src/Core/Waiting/IWaitStrategy.cs similarity index 94% rename from src/src/WslContainers/Waiting/IWaitStrategy.cs rename to src/src/Core/Waiting/IWaitStrategy.cs index eb638b8..3a898c8 100644 --- a/src/src/WslContainers/Waiting/IWaitStrategy.cs +++ b/src/src/Core/Waiting/IWaitStrategy.cs @@ -1,4 +1,4 @@ -namespace Purview.WslContainers.Waiting; +namespace Purview.Containers.Waiting; /// A composable readiness check evaluated until the container is ready or the timeout expires. public interface IWaitStrategy diff --git a/src/src/WslContainers/Waiting/LogMessageWaitStrategy.cs b/src/src/Core/Waiting/LogMessageWaitStrategy.cs similarity index 95% rename from src/src/WslContainers/Waiting/LogMessageWaitStrategy.cs rename to src/src/Core/Waiting/LogMessageWaitStrategy.cs index d23acb7..a417ea9 100644 --- a/src/src/WslContainers/Waiting/LogMessageWaitStrategy.cs +++ b/src/src/Core/Waiting/LogMessageWaitStrategy.cs @@ -1,6 +1,6 @@ using System.Text.RegularExpressions; -namespace Purview.WslContainers.Waiting; +namespace Purview.Containers.Waiting; /// Waits until the container's accumulated init-process output matches a message or regex. public sealed class LogMessageWaitStrategy : WaitStrategy diff --git a/src/src/WslContainers/Waiting/TcpPortWaitStrategy.cs b/src/src/Core/Waiting/TcpPortWaitStrategy.cs similarity index 96% rename from src/src/WslContainers/Waiting/TcpPortWaitStrategy.cs rename to src/src/Core/Waiting/TcpPortWaitStrategy.cs index f3f2b55..4845977 100644 --- a/src/src/WslContainers/Waiting/TcpPortWaitStrategy.cs +++ b/src/src/Core/Waiting/TcpPortWaitStrategy.cs @@ -1,7 +1,7 @@ using System.Net; using System.Net.Sockets; -namespace Purview.WslContainers.Waiting; +namespace Purview.Containers.Waiting; /// Waits until a TCP connection to the mapped host port succeeds (IPv4 loopback). public sealed class TcpPortWaitStrategy(ushort containerPort, TimeSpan connectTimeout) : WaitStrategy diff --git a/src/src/WslContainers/Waiting/Wait.cs b/src/src/Core/Waiting/Wait.cs similarity index 98% rename from src/src/WslContainers/Waiting/Wait.cs rename to src/src/Core/Waiting/Wait.cs index 1dbe2d8..bd571ed 100644 --- a/src/src/WslContainers/Waiting/Wait.cs +++ b/src/src/Core/Waiting/Wait.cs @@ -1,6 +1,6 @@ using System.Text.RegularExpressions; -namespace Purview.WslContainers.Waiting; +namespace Purview.Containers.Waiting; /// Factory for composable wait strategies. public static class Wait diff --git a/src/src/Core/Waiting/WaitContext.cs b/src/src/Core/Waiting/WaitContext.cs new file mode 100644 index 0000000..c0c6653 --- /dev/null +++ b/src/src/Core/Waiting/WaitContext.cs @@ -0,0 +1,25 @@ +namespace Purview.Containers.Waiting; + +/// Readiness-check context: the container plus resolved runtime state (ports, IP). +/// Creates the readiness context for a started container. +public sealed class WaitContext( + IContainer container, + IReadOnlyDictionary portMappings, + string? networkIp +) +{ + /// The container being checked. + public IContainer Container { get; } = container; + + /// The container bridge IP (Bridged networking only), if known. + public string? NetworkIp { get; } = networkIp; + + /// Returns the host port mapped to , or null when not mapped. + public int? GetHostPort(ushort containerPort) + { + return portMappings.TryGetValue(containerPort, out var hostPort) ? hostPort : null; + } + + /// The first mapped host port, or null when no ports are mapped. + public int? FirstHostPort => portMappings.Count > 0 ? portMappings.Values.First() : null; +} diff --git a/src/src/WslContainers/Waiting/WaitStrategy.cs b/src/src/Core/Waiting/WaitStrategy.cs similarity index 96% rename from src/src/WslContainers/Waiting/WaitStrategy.cs rename to src/src/Core/Waiting/WaitStrategy.cs index cdadd2a..d407521 100644 --- a/src/src/WslContainers/Waiting/WaitStrategy.cs +++ b/src/src/Core/Waiting/WaitStrategy.cs @@ -1,4 +1,4 @@ -namespace Purview.WslContainers.Waiting; +namespace Purview.Containers.Waiting; /// Base class for wait strategies with shared polling configuration. public abstract class WaitStrategy : IWaitStrategy diff --git a/src/src/WslContainers/Waiting/WaitStrategyRunner.cs b/src/src/Core/Waiting/WaitStrategyRunner.cs similarity index 85% rename from src/src/WslContainers/Waiting/WaitStrategyRunner.cs rename to src/src/Core/Waiting/WaitStrategyRunner.cs index 21b44d6..d28e885 100644 --- a/src/src/WslContainers/Waiting/WaitStrategyRunner.cs +++ b/src/src/Core/Waiting/WaitStrategyRunner.cs @@ -1,18 +1,21 @@ using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Text; -using Purview.WslContainers.Diagnostics; -using Purview.WslContainers.Runtime; +using Purview.Containers.Diagnostics; +using Purview.Containers.Runtime; -namespace Purview.WslContainers.Waiting; +namespace Purview.Containers.Waiting; -/// Runs wait strategies with timeout, interval and retries, producing actionable diagnostics on failure. +/// +/// Runs wait strategies with timeout, interval and retries, producing actionable diagnostics on failure. +/// Public so a container backend can execute the shared readiness contract. +/// [SuppressMessage( "Design", "CA1031:Do not catch general exception types", Justification = "Wait checks observe transient errors; diagnostics collection is best-effort." )] -static class WaitStrategyRunner +public static class WaitStrategyRunner { public static async Task RunAsync( WaitContext context, @@ -21,6 +24,8 @@ public static async Task RunAsync( CancellationToken cancellationToken ) { + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(strategies); foreach (var strategy in strategies) { await RunSingleAsync(context, strategy, defaultTimeout, cancellationToken).ConfigureAwait(false); @@ -34,7 +39,7 @@ static async Task RunSingleAsync( CancellationToken cancellationToken ) { - using var activity = WslContainersActivity.Source.StartActivity("wslcontainer.wait"); + using var activity = ContainerActivity.Source.StartActivity("wslcontainer.wait"); activity?.SetTag("container.name", context.Container.Name); activity?.SetTag("strategy", strategy.GetType().Name); var timeout = strategy.Timeout ?? defaultTimeout; @@ -85,7 +90,7 @@ CancellationToken cancellationToken var diagnostics = await BuildDiagnosticsAsync(context, timeout, lastError, cancellationToken) .ConfigureAwait(false); - throw new WslContainerTimeoutException( + throw new ContainerTimeoutException( $"Container '{context.Container.Name}' did not become ready within {timeout} for strategy {strategy.GetType().Name}. {diagnostics}" ); } diff --git a/src/src/Directory.Build.props b/src/src/Directory.Build.props new file mode 100644 index 0000000..d530b3d --- /dev/null +++ b/src/src/Directory.Build.props @@ -0,0 +1,23 @@ + + + + + + net10.0 + + + + + diff --git a/src/src/Docker/Docker.csproj b/src/src/Docker/Docker.csproj new file mode 100644 index 0000000..e82ba9a --- /dev/null +++ b/src/src/Docker/Docker.csproj @@ -0,0 +1,14 @@ + + + true + Docker backend for Purview.Containers: throwaway containers from any reachable Docker daemon, driven by Testcontainers. + docker;containers;testing;integration;testcontainers;dotnet + MIT + Purview + + + + + + + diff --git a/src/src/Docker/DockerContainer.cs b/src/src/Docker/DockerContainer.cs new file mode 100644 index 0000000..8e085a5 --- /dev/null +++ b/src/src/Docker/DockerContainer.cs @@ -0,0 +1,218 @@ +using System.Runtime.CompilerServices; +using Purview.Containers.Runtime; +using Purview.Containers.Waiting; +using TcContainer = DotNet.Testcontainers.Containers.IContainer; +using TcStates = DotNet.Testcontainers.Containers.TestcontainersStates; + +namespace Purview.Containers.Docker; + +/// +/// A Docker container adapted to the backend-neutral contract. Readiness uses +/// the shared wait strategies (host TCP/HTTP, in-container command, log match), so a module behaves the +/// same here as it does on WSLC. +/// +public sealed class DockerContainer : IContainer +{ + readonly IContainerConfiguration _configuration; + int _disposed; + + internal DockerContainer(IContainerConfiguration configuration, TcContainer container, string name) + { + _configuration = configuration; + Handle = container; + Name = name; + } + + /// The container Testcontainers created for this adapter. + internal TcContainer Handle { get; } + + /// + public string Id => Handle.Id; + + /// + public string Name { get; } + + /// + public ContainerState State => ToState(Handle.State); + + /// + public string Image => _configuration.Image; + + /// + public async Task StartAsync(CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) == 1, this); + await Handle.StartAsync(cancellationToken).ConfigureAwait(false); + + if (_configuration.WaitStrategies.Count > 0) + { + WaitContext context = new(this, GetMappedPublicPorts(), networkIp: null); + await WaitStrategyRunner + .RunAsync(context, _configuration.WaitStrategies, _configuration.StartupTimeout, cancellationToken) + .ConfigureAwait(false); + } + } + + /// + public Task StopAsync(CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) == 1, this); + return Handle.StopAsync(cancellationToken); + } + + /// + public async Task ExecAsync( + string[] command, + ExecOptions? options = null, + CancellationToken cancellationToken = default + ) + { + ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) == 1, this); + ArgumentNullException.ThrowIfNull(command); + + if (options?.EnableStandardInput == true) + { + throw new ContainerNotSupportedException( + "ExecOptions.EnableStandardInput is not supported by the Docker backend." + ); + } + + if (options?.Environment is { Count: > 0 }) + { + throw new ContainerNotSupportedException( + "ExecOptions.Environment is not supported by the Docker backend; set it on the container instead." + ); + } + + using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + if (options?.Timeout is TimeSpan timeout) + { + timeoutSource.CancelAfter(timeout); + } + + // Testcontainers exposes a single argv exec overload, so a requested working directory is applied + // by executing through the shell. + var argv = options?.WorkingDirectory is string workingDirectory + ? ["sh", "-c", $"cd {Quote(workingDirectory)} && {string.Join(' ', command.Select(Quote))}"] + : command; + + var result = await Handle.ExecAsync(argv, timeoutSource.Token).ConfigureAwait(false); + return new ExecResult(result.ExitCode ?? -1, result.Stdout, result.Stderr); + } + + /// + public ushort GetMappedPublicPort(ushort containerPort) + { + ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) == 1, this); + return Handle.GetMappedPublicPort(containerPort); + } + + /// + public IReadOnlyDictionary GetMappedPublicPorts() + { + ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) == 1, this); + Dictionary mappings = []; + foreach (var binding in _configuration.PortBindings) + { + mappings[binding.ContainerPort] = Handle.GetMappedPublicPort(binding.ContainerPort); + } + + return mappings; + } + + /// + public async Task GetLogsAsync(LogOutput? stream = null, CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) == 1, this); + var (stdout, stderr) = await ReadLogsAsync(cancellationToken).ConfigureAwait(false); + return stream switch + { + LogOutput.Stdout => stdout, + LogOutput.Stderr => stderr, + _ => stdout + stderr, + }; + } + + /// + public async IAsyncEnumerable GetLogsAsync( + [EnumeratorCancellation] CancellationToken cancellationToken + ) + { + ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) == 1, this); + + // Testcontainers exposes no positioned log stream, so the accumulated output is polled and new lines + // are emitted until the container is no longer running. + var emitted = 0; + while (true) + { + var (stdout, stderr) = await ReadLogsAsync(cancellationToken).ConfigureAwait(false); + var lines = Split(stdout, stderr); + for (; emitted < lines.Count; emitted++) + { + yield return lines[emitted]; + } + + if (State is not ContainerState.Running) + { + yield break; + } + + await Task.Delay(TimeSpan.FromMilliseconds(250), cancellationToken).ConfigureAwait(false); + } + } + + /// + public async ValueTask DisposeAsync() + { + if (Interlocked.Exchange(ref _disposed, 1) == 1) + { + return; + } + + await Handle.DisposeAsync().ConfigureAwait(false); + GC.SuppressFinalize(this); + } + + Task<(string Stdout, string Stderr)> ReadLogsAsync(CancellationToken cancellationToken) + { + // Docker filters logs by a time window and compares it against UTC log timestamps, so both bounds are + // UTC: the epoch (DateTime.MinValue is not a valid Docker timestamp) and now plus a small margin that + // absorbs second-level truncation. + return Handle.GetLogsAsync( + DateTime.UnixEpoch, + DateTime.UtcNow.AddMinutes(1), + timestampsEnabled: false, + cancellationToken + ); + } + + static List Split(string stdout, string stderr) + { + List entries = []; + Append(entries, stdout, LogOutput.Stdout); + Append(entries, stderr, LogOutput.Stderr); + return entries; + } + + static void Append(List entries, string content, LogOutput stream) + { + foreach (var line in content.Split('\n', StringSplitOptions.RemoveEmptyEntries)) + { + entries.Add(new ContainerLogEntry(DateTimeOffset.Now, stream, line.TrimEnd('\r'))); + } + } + + static string Quote(string value) => + value.Contains('\'', StringComparison.Ordinal) + ? $"\"{value.Replace("\"", "\\\"", StringComparison.Ordinal)}\"" + : $"'{value}'"; + + static ContainerState ToState(TcStates state) => + state switch + { + TcStates.Created => ContainerState.Created, + TcStates.Running or TcStates.Paused or TcStates.Restarting => ContainerState.Running, + TcStates.Exited or TcStates.Dead => ContainerState.Exited, + TcStates.Undefined or _ => ContainerState.Invalid, + }; +} diff --git a/src/src/Docker/DockerContainerBackend.cs b/src/src/Docker/DockerContainerBackend.cs new file mode 100644 index 0000000..1f440fd --- /dev/null +++ b/src/src/Docker/DockerContainerBackend.cs @@ -0,0 +1,129 @@ +using DotNet.Testcontainers.Configurations; +using TcContainerBuilder = DotNet.Testcontainers.Builders.ContainerBuilder; +using TcPullPolicy = DotNet.Testcontainers.Images.PullPolicy; + +namespace Purview.Containers.Docker; + +/// +/// The Docker backend: containers on any Docker daemon reachable from the test host — Docker Desktop, +/// Docker Engine inside WSL2, a remote daemon, or the daemon a CI runner provides. It drives the daemon +/// through Testcontainers, so the resource reaper (Ryuk) still cleans up after a crashed test host. +/// +public sealed class DockerContainerBackend : IContainerBackend, IContainerBackendPreference +{ + /// Stable backend identifier. + public string Name => "docker"; + + /// + /// Automatic selection preference: a higher value than WSLC, so a machine that can run both prefers + /// WSL Containers. + /// + public int AutoPriority => 100; + + /// Factory used by the generated backend registration. + public static DockerContainerBackend Create() => new(); + + /// + public IContainer CreateContainer(IContainerConfiguration configuration) + { + ArgumentNullException.ThrowIfNull(configuration); + var container = CreateBuilder(configuration).Build(); + return new DockerContainer(configuration, container, ContainerName.Generate(configuration)); + } + + /// + public async Task GetInfoAsync(CancellationToken cancellationToken = default) + { + try + { + // Resolving the endpoint throws when no Docker environment can be discovered, and the ping + // throws when the daemon is not running; both are reported as "unavailable" with the reason. + var endpoint = TestcontainersSettings.OS.DockerEndpointAuthConfig; + using var client = endpoint.GetDockerClientBuilder(Guid.NewGuid()).Build(); + await client.System.PingAsync(cancellationToken).ConfigureAwait(false); + var info = await client.System.GetSystemInfoAsync(cancellationToken).ConfigureAwait(false); + return new ContainerBackendInfo( + Name, + IsAvailable: true, + IsCompatible: true, + info.ServerVersion ?? string.Empty, + [] + ); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + return new ContainerBackendInfo( + Name, + IsAvailable: false, + IsCompatible: false, + Version: string.Empty, + MissingComponents: [$"{exception.GetType().Name}: {exception.Message}"] + ); + } + } + + static TcContainerBuilder CreateBuilder(IContainerConfiguration configuration) + { + var builder = new TcContainerBuilder(configuration.Image) + .WithAutoRemove(configuration.EnableAutoRemove) + .WithImagePullPolicy( + configuration.PullPolicy switch + { + Images.PullPolicy.Always => TcPullPolicy.Always, + Images.PullPolicy.Never => TcPullPolicy.Never, + Images.PullPolicy.Missing or _ => TcPullPolicy.Missing, + } + ); + + if (!string.IsNullOrWhiteSpace(configuration.Name)) + { + builder = builder.WithName(configuration.Name); + } + + if (!string.IsNullOrWhiteSpace(configuration.Hostname)) + { + builder = builder.WithHostname(configuration.Hostname); + } + + if (!string.IsNullOrWhiteSpace(configuration.WorkingDirectory)) + { + builder = builder.WithWorkingDirectory(configuration.WorkingDirectory); + } + + if (configuration.Command.Count > 0) + { + builder = builder.WithCommand([.. configuration.Command]); + } + + if (configuration.Environment.Count > 0) + { + builder = builder.WithEnvironment(configuration.Environment); + } + + if (configuration.Privileged) + { + builder = builder.WithPrivileged(true); + } + + foreach (var binding in configuration.PortBindings) + { + builder = binding.HostPort is ushort hostPort + ? builder.WithPortBinding(hostPort, binding.ContainerPort) + : builder.WithPortBinding(binding.ContainerPort, assignRandomHostPort: true); + } + + foreach (var mount in configuration.BindMounts) + { + builder = builder.WithBindMount(mount.HostPath, mount.ContainerPath, ToAccessMode(mount.ReadOnly)); + } + + foreach (var volume in configuration.NamedVolumes) + { + builder = builder.WithVolumeMount(volume.Name, volume.ContainerPath, ToAccessMode(volume.ReadOnly)); + } + + return builder; + } + + static AccessMode ToAccessMode(bool readOnly) => readOnly ? AccessMode.ReadOnly : AccessMode.ReadWrite; +} diff --git a/src/src/Docker/Sdk/README.md b/src/src/Docker/Sdk/README.md new file mode 100644 index 0000000..c6f9676 --- /dev/null +++ b/src/src/Docker/Sdk/README.md @@ -0,0 +1,56 @@ +# Purview.Containers.Docker + +The **Docker backend** for [`Purview.Containers.Core`](https://www.nuget.org/packages/Purview.Containers.Core): +throwaway containers for integration testing on any Docker daemon reachable from the test host — Docker +Desktop, Docker Engine inside WSL2, a remote daemon, Docker-in-Docker, or the daemon a CI runner +provides. It drives the daemon through [Testcontainers for .NET](https://dotnet.testcontainers.org/). + +```bash +dotnet add package Purview.Containers.Docker +``` + +```csharp +await using var container = new ContainerBuilder() + .WithImage("alpine:3.19") + .WithCommand("/bin/sh", "-c", "echo ready && sleep 30") + .WithWaitStrategy(Wait.ForLogMessage("ready")) + .Build(); + +await container.StartAsync(); +var result = await container.ExecAsync(["/bin/echo", "hello"]); +``` + +Referencing this package makes `docker` selectable; the module packages need no Docker-specific variant, +because a module reaches the backend through the shared abstractions: + +```bash +PURVIEW_CONTAINERS_BACKEND=docker # or "auto" (the default) to probe WSLC first, then Docker +``` + +| Rule | How | +| --- | --- | +| Pinned instance | `ContainerBackends.Use(new DockerContainerBackend())`, or `WithBackend(...)` | +| Named backend | `PURVIEW_CONTAINERS_BACKEND=docker` | +| Automatic detection | probe every registered backend; WSLC is preferred when it is usable | + +## Requirements + +- **.NET 10 or later**, any platform (this package is portable). +- A reachable Docker daemon. `DockerContainerBackend.GetInfoAsync()` pings it and reports the server + version, or the reason it could not be reached, so a missing daemon is a clear message and not a + timeout deep inside a test. +- The daemon must accept the standard Testcontainers configuration (`DOCKER_HOST`, Docker contexts, + `~/.testcontainers.properties`). The resource reaper (Ryuk) is used, so a crashed test host does not + leak containers. + +## Behaviour notes + +- Readiness uses the same shared wait strategies as every other backend (host TCP/HTTP, an in-container + command, or a log match), so module behaviour does not change with the backend. +- `ExecAsync` supports `WorkingDirectory` and `Timeout`; `Environment` and `EnableStandardInput` throw + `ContainerNotSupportedException` rather than being silently ignored. +- Log tailing polls the accumulated output until the container stops, because the Testcontainers API + exposes no positioned log stream. + +> **Experimental.** The public API, defaults and packaging rules can change between prereleases, and +> there is no production support guarantee. Pin the exact package version you build against. diff --git a/src/src/Docker/Sdk/buildTransitive/Purview.Containers.Docker.props b/src/src/Docker/Sdk/buildTransitive/Purview.Containers.Docker.props new file mode 100644 index 0000000..1c8854d --- /dev/null +++ b/src/src/Docker/Sdk/buildTransitive/Purview.Containers.Docker.props @@ -0,0 +1,15 @@ + + + + + $(PurviewContainersBackends);Purview.Containers.Docker.DockerContainerBackend + + diff --git a/src/src/MsSql/MsSql.csproj b/src/src/MsSql/MsSql.csproj index fbbc163..6baa24c 100644 --- a/src/src/MsSql/MsSql.csproj +++ b/src/src/MsSql/MsSql.csproj @@ -1,14 +1,14 @@ true - Microsoft SQL Server module for Purview.WslContainers: throwaway SQL Server databases on WSL Containers. - wsl;containers;sqlserver;sql;mssql;testing;integration + Microsoft SQL Server module for Purview.Containers: throwaway SQL Server databases on WSL Containers or Docker. + wsl;containers;docker;sqlserver;sql;mssql;testing;integration MIT Purview - + diff --git a/src/src/MsSql/MsSqlBuilder.cs b/src/src/MsSql/MsSqlBuilder.cs index 1431fb2..d65c59e 100644 --- a/src/src/MsSql/MsSqlBuilder.cs +++ b/src/src/MsSql/MsSqlBuilder.cs @@ -1,9 +1,9 @@ using Microsoft.Data.SqlClient; -using Purview.WslContainers.Diagnostics; -using Purview.WslContainers.Runtime; -using Purview.WslContainers.Waiting; +using Purview.Containers.Diagnostics; +using Purview.Containers.Runtime; +using Purview.Containers.Waiting; -namespace Purview.WslContainers.MsSql; +namespace Purview.Containers.MsSql; /// Fluent builder for a Microsoft SQL Server test container. public class MsSqlBuilder : ContainerBuilder @@ -15,6 +15,7 @@ public class MsSqlBuilder : ContainerBuilderCreates a builder with the default image. @@ -28,8 +29,8 @@ public MsSqlBuilder(string image) } /// Creates a builder using an explicit runtime. - public MsSqlBuilder(IContainerRuntime runtime) - : base(runtime) + public MsSqlBuilder(IContainerBackend backend) + : base(backend) { WithImage(MsSqlImage).WithPortBinding(MsSqlPort, assignRandomHostPort: true); } @@ -42,6 +43,17 @@ public MsSqlBuilder WithPassword(string password) return this; } + /// + /// Sets the initial catalog the generated connection string points at (default master). The + /// database is not created by the module; it must already exist on the server. + /// + public MsSqlBuilder WithDatabase(string database) + { + ArgumentException.ThrowIfNullOrWhiteSpace(database); + _database = database; + return this; + } + /// /// Explicitly accepts the SQL Server EULA (ACCEPT_EULA=Y). Required before Build; the library never /// accepts licensing terms on the caller's behalf. @@ -75,6 +87,7 @@ protected override MsSqlConfiguration BuildConfiguration() return configuration with { Password = _password, + Database = _database, AcceptLicense = _acceptLicense, Environment = environment, WaitStrategies = waitStrategies, @@ -87,23 +100,21 @@ protected override void Validate(ContainerConfiguration configuration) base.Validate(configuration); if (!_acceptLicense) { - throw new WslContainerConfigurationException( + throw new ContainerConfigurationException( "The SQL Server image requires accepting the EULA. Call AcceptLicense() explicitly before Build()." ); } if (_password.Value.Length < 8) { - throw new WslContainerConfigurationException( - "The SQL Server SA password must be at least 8 characters long." - ); + throw new ContainerConfigurationException("The SQL Server SA password must be at least 8 characters long."); } } /// protected override MsSqlContainer CreateContainer(MsSqlConfiguration configuration) { - return new MsSqlContainer(configuration, Runtime ?? WslContainerRuntime.Instance); + return new MsSqlContainer(configuration, Backend); } WaitStrategy BuildReadinessWait() @@ -123,6 +134,7 @@ WaitStrategy BuildReadinessWait() // 127.0.0.1 is required: WSLC maps IPv4 loopback only, and Microsoft.Data.SqlClient // hangs on the IPv6 ::1 address that 'localhost' resolves to. DataSource = $"127.0.0.1,{hostPort}", + InitialCatalog = _database, UserID = "sa", Password = _password.Value, TrustServerCertificate = true, diff --git a/src/src/MsSql/MsSqlConfiguration.cs b/src/src/MsSql/MsSqlConfiguration.cs index de960c4..678f4c2 100644 --- a/src/src/MsSql/MsSqlConfiguration.cs +++ b/src/src/MsSql/MsSqlConfiguration.cs @@ -1,6 +1,6 @@ -using Purview.WslContainers.Diagnostics; +using Purview.Containers.Diagnostics; -namespace Purview.WslContainers.MsSql; +namespace Purview.Containers.MsSql; /// Immutable configuration for a Microsoft SQL Server test container. public sealed record MsSqlConfiguration : ContainerConfiguration @@ -8,6 +8,12 @@ public sealed record MsSqlConfiguration : ContainerConfiguration /// SA password (MSSQL_SA_PASSWORD). Rendered as redacted. public Secret Password { get; init; } = Secret.From("YourStrong!Passw0rd"); + /// + /// Initial catalog the generated connection string points at (default master, matching the + /// Testcontainers SQL Server module). Set with . + /// + public string Database { get; init; } = "master"; + /// True when the SQL Server EULA has been explicitly accepted. public bool AcceptLicense { get; init; } } diff --git a/src/src/MsSql/MsSqlContainer.cs b/src/src/MsSql/MsSqlContainer.cs index eacce1e..bc2fa66 100644 --- a/src/src/MsSql/MsSqlContainer.cs +++ b/src/src/MsSql/MsSqlContainer.cs @@ -1,14 +1,14 @@ using Microsoft.Data.SqlClient; -namespace Purview.WslContainers.MsSql; +namespace Purview.Containers.MsSql; /// A throwaway Microsoft SQL Server instance running on WSL Containers. -public sealed class MsSqlContainer : WslContainer +public sealed class MsSqlContainer : ContainerBase { readonly MsSqlConfiguration _configuration; - internal MsSqlContainer(MsSqlConfiguration configuration, IContainerRuntime runtime) - : base(configuration, runtime) + internal MsSqlContainer(MsSqlConfiguration configuration, IContainerBackend? backend) + : base(configuration, backend) { _configuration = configuration; } @@ -21,6 +21,7 @@ public string GetConnectionString() // 127.0.0.1 is required: WSLC maps IPv4 loopback only, and Microsoft.Data.SqlClient // hangs on the IPv6 ::1 address that 'localhost' resolves to. DataSource = $"127.0.0.1,{GetMappedPublicPort(MsSqlBuilder.MsSqlPort)}", + InitialCatalog = _configuration.Database, UserID = "sa", Password = _configuration.Password.Value, TrustServerCertificate = true, diff --git a/src/src/MsSql/Sdk/README.md b/src/src/MsSql/Sdk/README.md index e69334e..15a2083 100644 --- a/src/src/MsSql/Sdk/README.md +++ b/src/src/MsSql/Sdk/README.md @@ -1,22 +1,26 @@ -# Purview.WslContainers.MsSql +# Purview.Containers.MsSql -Throwaway Microsoft SQL Server databases for .NET integration testing, running as WSLC containers on -**Microsoft WSL Containers** — no Docker installation. +Throwaway Microsoft SQL Server databases for .NET integration testing on **WSL Containers (WSLC)** or **Docker**. ```bash -dotnet add package Purview.WslContainers.MsSql +dotnet add package Purview.Containers.MsSql ``` -Depends on `Purview.WslContainers` (the core runtime) and brings `Microsoft.Data.SqlClient` for +Backend-neutral: depends on `Purview.Containers.Core` and needs a backend package (`Purview.Containers.Wsl` or `Purview.Containers.Docker`) and brings `Microsoft.Data.SqlClient` for connection-string generation and readiness probing. ## Requirements -- Windows 10/11 with **WSL Containers** (`wsl --install --no-distribution`). -- **A .NET 11 project targeting Windows specifically** (`net11.0-windows10.0.19041.0`, x64 or arm64). - `Purview.WslContainers` supplies `buildTransitive` defaults for `WindowsSdkPackageVersion` and - `PlatformTarget`, and rejects an unsupported consumer with `PWC0001`/`PWC0002` — see the - [consumer requirements](https://github.com/purview-dev/wsl-containers/blob/main/docs/wiki/Consumer-Requirements.md). +- **WSL Containers backend:** Windows 10/11 with WSL Containers (`wsl --install --no-distribution`), and a + `.NET 10` or later project. A platform-neutral `net10.0` project binds the portable facade and gets + automatic WSLC-or-Docker selection; a Windows target framework + (`net10.0-windows10.0.19041.0`, x64 or arm64) binds the implementation directly. The + `Purview.Containers.Wsl` package supplies `buildTransitive` defaults for + `WindowsSdkPackageVersion`/`PlatformTarget` and (for a platform-neutral consumer on a Windows build + host) the implementation payload, rejecting an unsupported consumer with `PCC0001`/`PCC0002` — see the + [consumer requirements](https://github.com/purview-dev/containers/blob/main/docs/wiki/Consumer-Requirements.md). +- **Docker backend:** any reachable Docker daemon (`docker info`), with a `net10.0` or later project on any + platform. No Windows target framework and no `PCC` guards apply. - **Experimental:** the API, defaults and packaging can change between prereleases; there is no production support guarantee. @@ -24,7 +28,7 @@ connection-string generation and readiness probing. ```csharp using Microsoft.Data.SqlClient; -using Purview.WslContainers.MsSql; +using Purview.Containers.MsSql; await using var sqlServer = new MsSqlBuilder() .WithPassword("SomeStrong!Password1") @@ -44,15 +48,16 @@ await connection.OpenAsync(); | `MsSqlBuilder()` / `MsSqlBuilder(string image)` | Default image `mcr.microsoft.com/mssql/server:2022-latest`, or a custom image. | | `MsSqlBuilder.MsSqlPort` (1433) | Container port, mapped to a random host port. | | `WithPassword(string)` | Sets `MSSQL_SA_PASSWORD` (default `YourStrong!Passw0rd`); stored as a redacted `Secret`. | +| `WithDatabase(string)` | Sets the initial catalog the connection string points at (default `master`, matching Testcontainers). The database must already exist. | | `AcceptLicense()` | Sets `ACCEPT_EULA=Y`. Required — the library never accepts licensing terms on your behalf. | -| `MsSqlContainer.GetConnectionString()` | `SqlConnectionStringBuilder` connection string for the mapped host port. | +| `MsSqlContainer.GetConnectionString()` | `SqlConnectionStringBuilder` connection string for the mapped host port (`Database=master` by default, `TrustServerCertificate=True`). | ## Behaviour and constraints - **Memory:** SQL Server refuses to start below 2000 MB. The default session VM is capped at 4096 MB (`WslContainerRuntimeOptions.Default`), so it works out of the box; override with `MemorySizeInMB` if you configure your own runtime. -- **Licence:** `Build()` throws `WslContainerConfigurationException` unless `AcceptLicense()` was called. +- **Licence:** `Build()` throws `ContainerConfigurationException` unless `AcceptLicense()` was called. The SA password must be at least 8 characters. - **Readiness:** a listening TCP port is not enough — the image reports readiness before it can serve queries, so the default strategy opens a host-side `SqlClient` connection every 2 seconds. The @@ -61,5 +66,6 @@ await connection.OpenAsync(); ## Documentation -- [Modules](https://github.com/purview-dev/wsl-containers/blob/main/docs/wiki/Modules.md) — the module contract and readiness choices. -- [Getting Started](https://github.com/purview-dev/wsl-containers/blob/main/docs/wiki/Getting-Started.md) — prerequisites and first-container walkthrough. +- [Backends: WSLC or Docker](https://github.com/purview-dev/containers/blob/main/docs/wiki/Backends.md) — choosing and configuring the runtime. +- [Modules](https://github.com/purview-dev/containers/blob/main/docs/wiki/Modules.md) — the module contract and readiness choices. +- [Getting Started](https://github.com/purview-dev/containers/blob/main/docs/wiki/Getting-Started.md) — prerequisites and first-container walkthrough. diff --git a/src/src/MySql/MySql.csproj b/src/src/MySql/MySql.csproj index b9e3992..0707306 100644 --- a/src/src/MySql/MySql.csproj +++ b/src/src/MySql/MySql.csproj @@ -1,14 +1,14 @@ true - MySQL module for Purview.WslContainers: throwaway MySQL databases on WSL Containers. - wsl;containers;mysql;database;testing;integration + MySQL module for Purview.Containers: throwaway MySQL databases on WSL Containers or Docker. + wsl;containers;docker;mysql;database;testing;integration MIT Purview - + diff --git a/src/src/MySql/MySqlBuilder.cs b/src/src/MySql/MySqlBuilder.cs index 908cd6e..41b9793 100644 --- a/src/src/MySql/MySqlBuilder.cs +++ b/src/src/MySql/MySqlBuilder.cs @@ -1,9 +1,9 @@ using MySqlConnector; -using Purview.WslContainers.Diagnostics; -using Purview.WslContainers.Runtime; -using Purview.WslContainers.Waiting; +using Purview.Containers.Diagnostics; +using Purview.Containers.Runtime; +using Purview.Containers.Waiting; -namespace Purview.WslContainers.MySql; +namespace Purview.Containers.MySql; /// Fluent builder for a MySQL test container. public class MySqlBuilder : ContainerBuilder @@ -30,8 +30,8 @@ public MySqlBuilder(string image) } /// Creates a builder using an explicit runtime. - public MySqlBuilder(IContainerRuntime runtime) - : base(runtime) + public MySqlBuilder(IContainerBackend backend) + : base(backend) { WithImage(MySqlImage).WithPortBinding(MySqlPort, assignRandomHostPort: true); } @@ -101,19 +101,19 @@ protected override void Validate(ContainerConfiguration configuration) base.Validate(configuration); if (string.IsNullOrWhiteSpace(_database)) { - throw new WslContainerConfigurationException("MySQL database cannot be empty."); + throw new ContainerConfigurationException("MySQL database cannot be empty."); } if (string.IsNullOrEmpty(_password.Value)) { - throw new WslContainerConfigurationException("MySQL password cannot be empty."); + throw new ContainerConfigurationException("MySQL password cannot be empty."); } } /// protected override MySqlContainer CreateContainer(MySqlConfiguration configuration) { - return new MySqlContainer(configuration, Runtime ?? WslContainerRuntime.Instance); + return new MySqlContainer(configuration, Backend); } WaitStrategy BuildReadinessWait() diff --git a/src/src/MySql/MySqlConfiguration.cs b/src/src/MySql/MySqlConfiguration.cs index 299841f..15a1a2a 100644 --- a/src/src/MySql/MySqlConfiguration.cs +++ b/src/src/MySql/MySqlConfiguration.cs @@ -1,6 +1,6 @@ -using Purview.WslContainers.Diagnostics; +using Purview.Containers.Diagnostics; -namespace Purview.WslContainers.MySql; +namespace Purview.Containers.MySql; /// Immutable configuration for a MySQL test container. public sealed record MySqlConfiguration : ContainerConfiguration diff --git a/src/src/MySql/MySqlContainer.cs b/src/src/MySql/MySqlContainer.cs index 8d78fb5..ab85b0c 100644 --- a/src/src/MySql/MySqlContainer.cs +++ b/src/src/MySql/MySqlContainer.cs @@ -1,14 +1,14 @@ using MySqlConnector; -namespace Purview.WslContainers.MySql; +namespace Purview.Containers.MySql; /// A throwaway MySQL database running on WSL Containers. -public sealed class MySqlContainer : WslContainer +public sealed class MySqlContainer : ContainerBase { readonly MySqlConfiguration _configuration; - internal MySqlContainer(MySqlConfiguration configuration, IContainerRuntime runtime) - : base(configuration, runtime) + internal MySqlContainer(MySqlConfiguration configuration, IContainerBackend? backend) + : base(configuration, backend) { _configuration = configuration; } diff --git a/src/src/MySql/Sdk/README.md b/src/src/MySql/Sdk/README.md index 2ff2c0d..11185b0 100644 --- a/src/src/MySql/Sdk/README.md +++ b/src/src/MySql/Sdk/README.md @@ -1,22 +1,26 @@ -# Purview.WslContainers.MySql +# Purview.Containers.MySql -Throwaway MySQL databases for .NET integration testing, running as WSLC containers on -**Microsoft WSL Containers** — no Docker installation. +Throwaway MySQL databases for .NET integration testing on **WSL Containers (WSLC)** or **Docker**. ```bash -dotnet add package Purview.WslContainers.MySql +dotnet add package Purview.Containers.MySql ``` -Depends on `Purview.WslContainers` (the core runtime) and brings `MySqlConnector` for readiness probing and +Backend-neutral: depends on `Purview.Containers.Core` and needs a backend package (`Purview.Containers.Wsl` or `Purview.Containers.Docker`) and brings `MySqlConnector` for readiness probing and connection-string generation. ## Requirements -- Windows 10/11 with **WSL Containers** (`wsl --install --no-distribution`). -- **A .NET 11 project targeting Windows specifically** (`net11.0-windows10.0.19041.0`, x64 or arm64). - `Purview.WslContainers` supplies `buildTransitive` defaults for `WindowsSdkPackageVersion` and - `PlatformTarget`, and rejects an unsupported consumer with `PWC0001`/`PWC0002` — see the - [consumer requirements](https://github.com/purview-dev/wsl-containers/blob/main/docs/wiki/Consumer-Requirements.md). +- **WSL Containers backend:** Windows 10/11 with WSL Containers (`wsl --install --no-distribution`), and a + `.NET 10` or later project. A platform-neutral `net10.0` project binds the portable facade and gets + automatic WSLC-or-Docker selection; a Windows target framework + (`net10.0-windows10.0.19041.0`, x64 or arm64) binds the implementation directly. The + `Purview.Containers.Wsl` package supplies `buildTransitive` defaults for + `WindowsSdkPackageVersion`/`PlatformTarget` and (for a platform-neutral consumer on a Windows build + host) the implementation payload, rejecting an unsupported consumer with `PCC0001`/`PCC0002` — see the + [consumer requirements](https://github.com/purview-dev/containers/blob/main/docs/wiki/Consumer-Requirements.md). +- **Docker backend:** any reachable Docker daemon (`docker info`), with a `net10.0` or later project on any + platform. No Windows target framework and no `PCC` guards apply. - **Experimental:** the API, defaults and packaging can change between prereleases; there is no production support guarantee. @@ -24,7 +28,7 @@ connection-string generation. ```csharp using MySqlConnector; -using Purview.WslContainers.MySql; +using Purview.Containers.MySql; await using var mysql = new MySqlBuilder() .WithDatabase("tests") @@ -50,7 +54,7 @@ await connection.OpenAsync(); | `WithRootPassword(string)` | Sets `MYSQL_ROOT_PASSWORD` (default `test`); stored as a redacted `Secret`. | | `MySqlContainer.GetConnectionString()` | `MySqlConnectionStringBuilder` connection string for the mapped host port. | -`Build()` rejects an empty database or password with `WslContainerConfigurationException`. +`Build()` rejects an empty database or password with `ContainerConfigurationException`. ## Readiness @@ -61,5 +65,6 @@ strategy with `WithWaitStrategy(...)` if you need different behaviour. ## Documentation -- [Modules](https://github.com/purview-dev/wsl-containers/blob/main/docs/wiki/Modules.md) — the module contract and readiness choices. -- [Wait Strategies](https://github.com/purview-dev/wsl-containers/blob/main/docs/wiki/Wait-Strategies.md) — overriding readiness checks. +- [Backends: WSLC or Docker](https://github.com/purview-dev/containers/blob/main/docs/wiki/Backends.md) — choosing and configuring the runtime. +- [Modules](https://github.com/purview-dev/containers/blob/main/docs/wiki/Modules.md) — the module contract and readiness choices. +- [Wait Strategies](https://github.com/purview-dev/containers/blob/main/docs/wiki/Wait-Strategies.md) — overriding readiness checks. diff --git a/src/src/Nats/Nats.csproj b/src/src/Nats/Nats.csproj index d2bf9b3..28391cf 100644 --- a/src/src/Nats/Nats.csproj +++ b/src/src/Nats/Nats.csproj @@ -1,13 +1,13 @@ true - NATS module for Purview.WslContainers: throwaway NATS message brokers on WSL Containers. - wsl;containers;nats;messaging;testing;integration + NATS module for Purview.Containers: throwaway NATS message brokers on WSL Containers or Docker. + wsl;containers;docker;nats;messaging;testing;integration MIT Purview - + diff --git a/src/src/Nats/NatsBuilder.cs b/src/src/Nats/NatsBuilder.cs index 52d5872..8fdbafd 100644 --- a/src/src/Nats/NatsBuilder.cs +++ b/src/src/Nats/NatsBuilder.cs @@ -1,6 +1,6 @@ -using Purview.WslContainers.Waiting; +using Purview.Containers.Waiting; -namespace Purview.WslContainers.Nats; +namespace Purview.Containers.Nats; /// Fluent builder for a NATS test container. public class NatsBuilder : ContainerBuilder @@ -27,8 +27,8 @@ public NatsBuilder(string image) } /// Creates a builder using an explicit runtime. - public NatsBuilder(IContainerRuntime runtime) - : base(runtime) + public NatsBuilder(IContainerBackend backend) + : base(backend) { WithImage(NatsImage) .WithPortBinding(ClientPort, assignRandomHostPort: true) @@ -52,6 +52,6 @@ protected override NatsConfiguration BuildConfiguration() /// protected override NatsContainer CreateContainer(NatsConfiguration configuration) { - return new NatsContainer(configuration, Runtime ?? WslContainerRuntime.Instance); + return new NatsContainer(configuration, Backend); } } diff --git a/src/src/Nats/NatsConfiguration.cs b/src/src/Nats/NatsConfiguration.cs index b887630..58f21d4 100644 --- a/src/src/Nats/NatsConfiguration.cs +++ b/src/src/Nats/NatsConfiguration.cs @@ -1,4 +1,4 @@ -namespace Purview.WslContainers.Nats; +namespace Purview.Containers.Nats; /// Immutable configuration for a NATS test container. public sealed record NatsConfiguration : ContainerConfiguration { } diff --git a/src/src/Nats/NatsContainer.cs b/src/src/Nats/NatsContainer.cs index 14e806b..62c2e30 100644 --- a/src/src/Nats/NatsContainer.cs +++ b/src/src/Nats/NatsContainer.cs @@ -1,10 +1,10 @@ -namespace Purview.WslContainers.Nats; +namespace Purview.Containers.Nats; /// A throwaway NATS broker running on WSL Containers. -public sealed class NatsContainer : WslContainer +public sealed class NatsContainer : ContainerBase { - internal NatsContainer(NatsConfiguration configuration, IContainerRuntime runtime) - : base(configuration, runtime) { } + internal NatsContainer(NatsConfiguration configuration, IContainerBackend? backend) + : base(configuration, backend) { } /// Client endpoint (nats://127.0.0.1:{port}). Safe to call after . public Uri GetClientEndpoint() diff --git a/src/src/Nats/Sdk/README.md b/src/src/Nats/Sdk/README.md index 8c43cfd..07620e4 100644 --- a/src/src/Nats/Sdk/README.md +++ b/src/src/Nats/Sdk/README.md @@ -1,29 +1,33 @@ -# Purview.WslContainers.Nats +# Purview.Containers.Nats -Throwaway NATS brokers for .NET integration testing, running as WSLC containers on -**Microsoft WSL Containers** — no Docker installation. +Throwaway NATS brokers for .NET integration testing on **WSL Containers (WSLC)** or **Docker**. ```bash -dotnet add package Purview.WslContainers.Nats +dotnet add package Purview.Containers.Nats ``` -Depends on `Purview.WslContainers` (the core runtime). `NATS.Client.Core` is not referenced by this +Backend-neutral: depends on `Purview.Containers.Core` and needs a backend package (`Purview.Containers.Wsl` or `Purview.Containers.Docker`). `NATS.Client.Core` is not referenced by this package — bring your own client. ## Requirements -- Windows 10/11 with **WSL Containers** (`wsl --install --no-distribution`). -- **A .NET 11 project targeting Windows specifically** (`net11.0-windows10.0.19041.0`, x64 or arm64). - `Purview.WslContainers` supplies `buildTransitive` defaults for `WindowsSdkPackageVersion` and - `PlatformTarget`, and rejects an unsupported consumer with `PWC0001`/`PWC0002` — see the - [consumer requirements](https://github.com/purview-dev/wsl-containers/blob/main/docs/wiki/Consumer-Requirements.md). +- **WSL Containers backend:** Windows 10/11 with WSL Containers (`wsl --install --no-distribution`), and a + `.NET 10` or later project. A platform-neutral `net10.0` project binds the portable facade and gets + automatic WSLC-or-Docker selection; a Windows target framework + (`net10.0-windows10.0.19041.0`, x64 or arm64) binds the implementation directly. The + `Purview.Containers.Wsl` package supplies `buildTransitive` defaults for + `WindowsSdkPackageVersion`/`PlatformTarget` and (for a platform-neutral consumer on a Windows build + host) the implementation payload, rejecting an unsupported consumer with `PCC0001`/`PCC0002` — see the + [consumer requirements](https://github.com/purview-dev/containers/blob/main/docs/wiki/Consumer-Requirements.md). +- **Docker backend:** any reachable Docker daemon (`docker info`), with a `net10.0` or later project on any + platform. No Windows target framework and no `PCC` guards apply. - **Experimental:** the API, defaults and packaging can change between prereleases; there is no production support guarantee. ## Quick start ```csharp -using Purview.WslContainers.Nats; +using Purview.Containers.Nats; using NATS.Client.Core; await using var nats = new NatsBuilder().Build(); @@ -50,5 +54,6 @@ with `WithWaitStrategy(...)`. Endpoint accessors resolve the mapped host ports, ## Documentation -- [Modules](https://github.com/purview-dev/wsl-containers/blob/main/docs/wiki/Modules.md) — the module contract and readiness choices. -- [Wait Strategies](https://github.com/purview-dev/wsl-containers/blob/main/docs/wiki/Wait-Strategies.md) — overriding readiness checks. +- [Backends: WSLC or Docker](https://github.com/purview-dev/containers/blob/main/docs/wiki/Backends.md) — choosing and configuring the runtime. +- [Modules](https://github.com/purview-dev/containers/blob/main/docs/wiki/Modules.md) — the module contract and readiness choices. +- [Wait Strategies](https://github.com/purview-dev/containers/blob/main/docs/wiki/Wait-Strategies.md) — overriding readiness checks. diff --git a/src/src/PostgreSql/PostgreSql.csproj b/src/src/PostgreSql/PostgreSql.csproj index 4f2480c..5c1ad01 100644 --- a/src/src/PostgreSql/PostgreSql.csproj +++ b/src/src/PostgreSql/PostgreSql.csproj @@ -1,14 +1,14 @@ true - PostgreSQL module for Purview.WslContainers: throwaway PostgreSQL databases on WSL Containers. - wsl;containers;postgresql;postgres;testing;integration + PostgreSQL module for Purview.Containers: throwaway PostgreSQL databases on WSL Containers or Docker. + wsl;containers;docker;postgresql;postgres;testing;integration MIT Purview - + diff --git a/src/src/PostgreSql/PostgreSqlBuilder.cs b/src/src/PostgreSql/PostgreSqlBuilder.cs index 0a6ca1a..cfc50f3 100644 --- a/src/src/PostgreSql/PostgreSqlBuilder.cs +++ b/src/src/PostgreSql/PostgreSqlBuilder.cs @@ -1,8 +1,8 @@ -using Purview.WslContainers.Diagnostics; -using Purview.WslContainers.Runtime; -using Purview.WslContainers.Waiting; +using Purview.Containers.Diagnostics; +using Purview.Containers.Runtime; +using Purview.Containers.Waiting; -namespace Purview.WslContainers.PostgreSql; +namespace Purview.Containers.PostgreSql; /// Fluent builder for a PostgreSQL test container. public class PostgreSqlBuilder : ContainerBuilder @@ -28,8 +28,8 @@ public PostgreSqlBuilder(string image) } /// Creates a builder using an explicit runtime. - public PostgreSqlBuilder(IContainerRuntime runtime) - : base(runtime) + public PostgreSqlBuilder(IContainerBackend backend) + : base(backend) { WithImage(PostgreSqlImage).WithPortBinding(PostgreSqlPort, assignRandomHostPort: true); } @@ -91,23 +91,23 @@ protected override void Validate(ContainerConfiguration configuration) base.Validate(configuration); if (string.IsNullOrWhiteSpace(_database)) { - throw new WslContainerConfigurationException("PostgreSQL database cannot be empty."); + throw new ContainerConfigurationException("PostgreSQL database cannot be empty."); } if (string.IsNullOrWhiteSpace(_username)) { - throw new WslContainerConfigurationException("PostgreSQL username cannot be empty."); + throw new ContainerConfigurationException("PostgreSQL username cannot be empty."); } if (string.IsNullOrEmpty(_password.Value)) { - throw new WslContainerConfigurationException("PostgreSQL password cannot be empty."); + throw new ContainerConfigurationException("PostgreSQL password cannot be empty."); } } /// protected override PostgreSqlContainer CreateContainer(PostgreSqlConfiguration configuration) { - return new PostgreSqlContainer(configuration, Runtime ?? WslContainerRuntime.Instance); + return new PostgreSqlContainer(configuration, Backend); } } diff --git a/src/src/PostgreSql/PostgreSqlConfiguration.cs b/src/src/PostgreSql/PostgreSqlConfiguration.cs index a24f585..0e43f11 100644 --- a/src/src/PostgreSql/PostgreSqlConfiguration.cs +++ b/src/src/PostgreSql/PostgreSqlConfiguration.cs @@ -1,6 +1,6 @@ -using Purview.WslContainers.Diagnostics; +using Purview.Containers.Diagnostics; -namespace Purview.WslContainers.PostgreSql; +namespace Purview.Containers.PostgreSql; /// Immutable configuration for a PostgreSQL test container. public sealed record PostgreSqlConfiguration : ContainerConfiguration diff --git a/src/src/PostgreSql/PostgreSqlContainer.cs b/src/src/PostgreSql/PostgreSqlContainer.cs index 77a7a0d..62d160e 100644 --- a/src/src/PostgreSql/PostgreSqlContainer.cs +++ b/src/src/PostgreSql/PostgreSqlContainer.cs @@ -1,14 +1,14 @@ using Npgsql; -namespace Purview.WslContainers.PostgreSql; +namespace Purview.Containers.PostgreSql; /// A throwaway PostgreSQL database running on WSL Containers. -public sealed class PostgreSqlContainer : WslContainer +public sealed class PostgreSqlContainer : ContainerBase { readonly PostgreSqlConfiguration _configuration; - internal PostgreSqlContainer(PostgreSqlConfiguration configuration, IContainerRuntime runtime) - : base(configuration, runtime) + internal PostgreSqlContainer(PostgreSqlConfiguration configuration, IContainerBackend? backend) + : base(configuration, backend) { _configuration = configuration; } diff --git a/src/src/PostgreSql/Sdk/README.md b/src/src/PostgreSql/Sdk/README.md index 874d0d5..db8a80c 100644 --- a/src/src/PostgreSql/Sdk/README.md +++ b/src/src/PostgreSql/Sdk/README.md @@ -1,22 +1,26 @@ -# Purview.WslContainers.PostgreSql +# Purview.Containers.PostgreSql -Throwaway PostgreSQL databases for .NET integration testing, running as WSLC containers on -**Microsoft WSL Containers** — no Docker installation. +Throwaway PostgreSQL databases for .NET integration testing on **WSL Containers (WSLC)** or **Docker**. ```bash -dotnet add package Purview.WslContainers.PostgreSql +dotnet add package Purview.Containers.PostgreSql ``` -Depends on `Purview.WslContainers` (the core runtime) and brings `Npgsql` for connection-string generation. -See the [Getting Started guide](https://github.com/purview-dev/wsl-containers/blob/main/docs/wiki/Getting-Started.md). +Backend-neutral: depends on `Purview.Containers.Core` and needs a backend package (`Purview.Containers.Wsl` or `Purview.Containers.Docker`) and brings `Npgsql` for connection-string generation. +See the [Getting Started guide](https://github.com/purview-dev/containers/blob/main/docs/wiki/Getting-Started.md). ## Requirements -- Windows 10/11 with **WSL Containers** (`wsl --install --no-distribution`). -- **A .NET 11 project targeting Windows specifically** (`net11.0-windows10.0.19041.0`, x64 or arm64). - `Purview.WslContainers` supplies `buildTransitive` defaults for `WindowsSdkPackageVersion` and - `PlatformTarget`, and rejects an unsupported consumer with `PWC0001`/`PWC0002` — see the - [consumer requirements](https://github.com/purview-dev/wsl-containers/blob/main/docs/wiki/Consumer-Requirements.md). +- **WSL Containers backend:** Windows 10/11 with WSL Containers (`wsl --install --no-distribution`), and a + `.NET 10` or later project. A platform-neutral `net10.0` project binds the portable facade and gets + automatic WSLC-or-Docker selection; a Windows target framework + (`net10.0-windows10.0.19041.0`, x64 or arm64) binds the implementation directly. The + `Purview.Containers.Wsl` package supplies `buildTransitive` defaults for + `WindowsSdkPackageVersion`/`PlatformTarget` and (for a platform-neutral consumer on a Windows build + host) the implementation payload, rejecting an unsupported consumer with `PCC0001`/`PCC0002` — see the + [consumer requirements](https://github.com/purview-dev/containers/blob/main/docs/wiki/Consumer-Requirements.md). +- **Docker backend:** any reachable Docker daemon (`docker info`), with a `net10.0` or later project on any + platform. No Windows target framework and no `PCC` guards apply. - **Experimental:** the API, defaults and packaging can change between prereleases; there is no production support guarantee. @@ -24,7 +28,7 @@ See the [Getting Started guide](https://github.com/purview-dev/wsl-containers/bl ```csharp using Npgsql; -using Purview.WslContainers.PostgreSql; +using Purview.Containers.PostgreSql; await using var postgres = new PostgreSqlBuilder() .WithDatabase("tests") @@ -49,11 +53,12 @@ await connection.OpenAsync(); | `WithPassword(string)` | Sets `POSTGRES_PASSWORD` (default `postgres`); stored as a redacted `Secret`. | | `PostgreSqlContainer.GetConnectionString()` | `NpgsqlConnectionStringBuilder` connection string for the mapped host port. | -`Build()` rejects an empty database, username or password with `WslContainerConfigurationException`. The +`Build()` rejects an empty database, username or password with `ContainerConfigurationException`. The default wait strategy runs `pg_isready -U {username} -d {database}` inside the container unless you supply your own with `WithWaitStrategy(...)`. Call `GetConnectionString()` after `StartAsync()`. ## Documentation -- [Modules](https://github.com/purview-dev/wsl-containers/blob/main/docs/wiki/Modules.md) — the module contract and readiness choices. -- [Wait Strategies](https://github.com/purview-dev/wsl-containers/blob/main/docs/wiki/Wait-Strategies.md) — overriding readiness checks. +- [Backends: WSLC or Docker](https://github.com/purview-dev/containers/blob/main/docs/wiki/Backends.md) — choosing and configuring the runtime. +- [Modules](https://github.com/purview-dev/containers/blob/main/docs/wiki/Modules.md) — the module contract and readiness choices. +- [Wait Strategies](https://github.com/purview-dev/containers/blob/main/docs/wiki/Wait-Strategies.md) — overriding readiness checks. diff --git a/src/src/RabbitMq/RabbitMq.csproj b/src/src/RabbitMq/RabbitMq.csproj index e18f387..b1adda6 100644 --- a/src/src/RabbitMq/RabbitMq.csproj +++ b/src/src/RabbitMq/RabbitMq.csproj @@ -1,13 +1,13 @@ true - RabbitMQ module for Purview.WslContainers: throwaway RabbitMQ brokers on WSL Containers. - wsl;containers;rabbitmq;amqp;testing;integration + RabbitMQ module for Purview.Containers: throwaway RabbitMQ brokers on WSL Containers or Docker. + wsl;containers;docker;rabbitmq;amqp;testing;integration MIT Purview - + diff --git a/src/src/RabbitMq/RabbitMqBuilder.cs b/src/src/RabbitMq/RabbitMqBuilder.cs index 8d257aa..507ea65 100644 --- a/src/src/RabbitMq/RabbitMqBuilder.cs +++ b/src/src/RabbitMq/RabbitMqBuilder.cs @@ -1,8 +1,8 @@ -using Purview.WslContainers.Diagnostics; -using Purview.WslContainers.Runtime; -using Purview.WslContainers.Waiting; +using Purview.Containers.Diagnostics; +using Purview.Containers.Runtime; +using Purview.Containers.Waiting; -namespace Purview.WslContainers.RabbitMq; +namespace Purview.Containers.RabbitMq; /// Fluent builder for a RabbitMQ test container. public class RabbitMqBuilder : ContainerBuilder @@ -33,8 +33,8 @@ public RabbitMqBuilder(string image) } /// Creates a builder using an explicit runtime. - public RabbitMqBuilder(IContainerRuntime runtime) - : base(runtime) + public RabbitMqBuilder(IContainerBackend backend) + : base(backend) { WithImage(RabbitMqImage) .WithPortBinding(AmqpPort, assignRandomHostPort: true) @@ -98,23 +98,23 @@ protected override void Validate(ContainerConfiguration configuration) base.Validate(configuration); if (string.IsNullOrWhiteSpace(_username)) { - throw new WslContainerConfigurationException("RabbitMQ username cannot be empty."); + throw new ContainerConfigurationException("RabbitMQ username cannot be empty."); } if (string.IsNullOrEmpty(_password.Value)) { - throw new WslContainerConfigurationException("RabbitMQ password cannot be empty."); + throw new ContainerConfigurationException("RabbitMQ password cannot be empty."); } if (string.IsNullOrEmpty(_virtualHost)) { - throw new WslContainerConfigurationException("RabbitMQ virtual host cannot be empty."); + throw new ContainerConfigurationException("RabbitMQ virtual host cannot be empty."); } } /// protected override RabbitMqContainer CreateContainer(RabbitMqConfiguration configuration) { - return new RabbitMqContainer(configuration, Runtime ?? WslContainerRuntime.Instance); + return new RabbitMqContainer(configuration, Backend); } } diff --git a/src/src/RabbitMq/RabbitMqConfiguration.cs b/src/src/RabbitMq/RabbitMqConfiguration.cs index 7df0cf8..f8011ff 100644 --- a/src/src/RabbitMq/RabbitMqConfiguration.cs +++ b/src/src/RabbitMq/RabbitMqConfiguration.cs @@ -1,6 +1,6 @@ -using Purview.WslContainers.Diagnostics; +using Purview.Containers.Diagnostics; -namespace Purview.WslContainers.RabbitMq; +namespace Purview.Containers.RabbitMq; /// Immutable configuration for a RabbitMQ test container. public sealed record RabbitMqConfiguration : ContainerConfiguration diff --git a/src/src/RabbitMq/RabbitMqContainer.cs b/src/src/RabbitMq/RabbitMqContainer.cs index 78b8283..9bec76f 100644 --- a/src/src/RabbitMq/RabbitMqContainer.cs +++ b/src/src/RabbitMq/RabbitMqContainer.cs @@ -1,12 +1,12 @@ -namespace Purview.WslContainers.RabbitMq; +namespace Purview.Containers.RabbitMq; /// A throwaway RabbitMQ broker running on WSL Containers. -public sealed class RabbitMqContainer : WslContainer +public sealed class RabbitMqContainer : ContainerBase { readonly RabbitMqConfiguration _configuration; - internal RabbitMqContainer(RabbitMqConfiguration configuration, IContainerRuntime runtime) - : base(configuration, runtime) + internal RabbitMqContainer(RabbitMqConfiguration configuration, IContainerBackend? backend) + : base(configuration, backend) { _configuration = configuration; } diff --git a/src/src/RabbitMq/Sdk/README.md b/src/src/RabbitMq/Sdk/README.md index de3ea32..dfab0fa 100644 --- a/src/src/RabbitMq/Sdk/README.md +++ b/src/src/RabbitMq/Sdk/README.md @@ -1,29 +1,33 @@ -# Purview.WslContainers.RabbitMq +# Purview.Containers.RabbitMq -Throwaway RabbitMQ brokers for .NET integration testing, running as WSLC containers on -**Microsoft WSL Containers** — no Docker installation. +Throwaway RabbitMQ brokers for .NET integration testing on **WSL Containers (WSLC)** or **Docker**. ```bash -dotnet add package Purview.WslContainers.RabbitMq +dotnet add package Purview.Containers.RabbitMq ``` -Depends on `Purview.WslContainers` (the core runtime). `RabbitMQ.Client` is not referenced by this package — +Backend-neutral: depends on `Purview.Containers.Core` and needs a backend package (`Purview.Containers.Wsl` or `Purview.Containers.Docker`). `RabbitMQ.Client` is not referenced by this package — bring your own client. ## Requirements -- Windows 10/11 with **WSL Containers** (`wsl --install --no-distribution`). -- **A .NET 11 project targeting Windows specifically** (`net11.0-windows10.0.19041.0`, x64 or arm64). - `Purview.WslContainers` supplies `buildTransitive` defaults for `WindowsSdkPackageVersion` and - `PlatformTarget`, and rejects an unsupported consumer with `PWC0001`/`PWC0002` — see the - [consumer requirements](https://github.com/purview-dev/wsl-containers/blob/main/docs/wiki/Consumer-Requirements.md). +- **WSL Containers backend:** Windows 10/11 with WSL Containers (`wsl --install --no-distribution`), and a + `.NET 10` or later project. A platform-neutral `net10.0` project binds the portable facade and gets + automatic WSLC-or-Docker selection; a Windows target framework + (`net10.0-windows10.0.19041.0`, x64 or arm64) binds the implementation directly. The + `Purview.Containers.Wsl` package supplies `buildTransitive` defaults for + `WindowsSdkPackageVersion`/`PlatformTarget` and (for a platform-neutral consumer on a Windows build + host) the implementation payload, rejecting an unsupported consumer with `PCC0001`/`PCC0002` — see the + [consumer requirements](https://github.com/purview-dev/containers/blob/main/docs/wiki/Consumer-Requirements.md). +- **Docker backend:** any reachable Docker daemon (`docker info`), with a `net10.0` or later project on any + platform. No Windows target framework and no `PCC` guards apply. - **Experimental:** the API, defaults and packaging can change between prereleases; there is no production support guarantee. ## Quick start ```csharp -using Purview.WslContainers.RabbitMq; +using Purview.Containers.RabbitMq; using RabbitMQ.Client; await using var rabbitMq = new RabbitMqBuilder() @@ -50,7 +54,7 @@ await using var connection = await factory.CreateConnectionAsync(); | `RabbitMqContainer.GetConnectionString()` | The same AMQP endpoint as a string. | | `RabbitMqContainer.GetManagementEndpoint()` | Management web UI endpoint (`http://localhost:{mappedPort}`). | -`Build()` rejects an empty username, password or virtual host with `WslContainerConfigurationException`. +`Build()` rejects an empty username, password or virtual host with `ContainerConfigurationException`. Call the endpoint accessors after `StartAsync()`. ## Readiness @@ -58,9 +62,10 @@ Call the endpoint accessors after `StartAsync()`. The default wait strategy matches the canonical `Server startup complete` log line rather than running `rabbitmq-diagnostics ping`: under WSLC an exec-based probe races the Erlang cookie setup and can trigger a startup failure (`eacces` reading `.erlang.cookie`). See -[Modules](https://github.com/purview-dev/wsl-containers/blob/main/docs/wiki/Modules.md) for the full note. +[Modules](https://github.com/purview-dev/containers/blob/main/docs/wiki/Modules.md) for the full note. ## Documentation -- [Modules](https://github.com/purview-dev/wsl-containers/blob/main/docs/wiki/Modules.md) — the module contract and readiness choices. -- [Wait Strategies](https://github.com/purview-dev/wsl-containers/blob/main/docs/wiki/Wait-Strategies.md) — overriding readiness checks. +- [Backends: WSLC or Docker](https://github.com/purview-dev/containers/blob/main/docs/wiki/Backends.md) — choosing and configuring the runtime. +- [Modules](https://github.com/purview-dev/containers/blob/main/docs/wiki/Modules.md) — the module contract and readiness choices. +- [Wait Strategies](https://github.com/purview-dev/containers/blob/main/docs/wiki/Wait-Strategies.md) — overriding readiness checks. diff --git a/src/src/Redis/Redis.csproj b/src/src/Redis/Redis.csproj index 0ce04f1..db6d562 100644 --- a/src/src/Redis/Redis.csproj +++ b/src/src/Redis/Redis.csproj @@ -1,13 +1,13 @@ true - Redis module for Purview.WslContainers: throwaway Redis instances on WSL Containers. - wsl;containers;redis;testing;integration + Redis module for Purview.Containers: throwaway Redis instances on WSL Containers or Docker. + wsl;containers;docker;redis;testing;integration MIT Purview - + diff --git a/src/src/Redis/RedisBuilder.cs b/src/src/Redis/RedisBuilder.cs index 3260466..4ea7d5f 100644 --- a/src/src/Redis/RedisBuilder.cs +++ b/src/src/Redis/RedisBuilder.cs @@ -1,6 +1,6 @@ -using Purview.WslContainers.Waiting; +using Purview.Containers.Waiting; -namespace Purview.WslContainers.Redis; +namespace Purview.Containers.Redis; /// /// Fluent builder for a Redis-compatible test container. Works with Redis and Redis-compatible images @@ -25,8 +25,8 @@ public RedisBuilder(string image) } /// Creates a builder using an explicit runtime. - public RedisBuilder(IContainerRuntime runtime) - : base(runtime) + public RedisBuilder(IContainerBackend backend) + : base(backend) { WithImage(RedisImage).WithPortBinding(RedisPort, assignRandomHostPort: true); } @@ -48,6 +48,6 @@ protected override RedisConfiguration BuildConfiguration() /// protected override RedisContainer CreateContainer(RedisConfiguration configuration) { - return new RedisContainer(configuration, Runtime ?? WslContainerRuntime.Instance); + return new RedisContainer(configuration, Backend); } } diff --git a/src/src/Redis/RedisConfiguration.cs b/src/src/Redis/RedisConfiguration.cs index 40fdf75..dfe4c16 100644 --- a/src/src/Redis/RedisConfiguration.cs +++ b/src/src/Redis/RedisConfiguration.cs @@ -1,4 +1,4 @@ -namespace Purview.WslContainers.Redis; +namespace Purview.Containers.Redis; /// Immutable configuration for a Redis-compatible test container. public sealed record RedisConfiguration : ContainerConfiguration { } diff --git a/src/src/Redis/RedisContainer.cs b/src/src/Redis/RedisContainer.cs index f9ff66d..5997c64 100644 --- a/src/src/Redis/RedisContainer.cs +++ b/src/src/Redis/RedisContainer.cs @@ -1,10 +1,10 @@ -namespace Purview.WslContainers.Redis; +namespace Purview.Containers.Redis; /// A throwaway Redis-compatible instance running on WSL Containers. -public sealed class RedisContainer : WslContainer +public sealed class RedisContainer : ContainerBase { - internal RedisContainer(RedisConfiguration configuration, IContainerRuntime runtime) - : base(configuration, runtime) { } + internal RedisContainer(RedisConfiguration configuration, IContainerBackend? backend) + : base(configuration, backend) { } /// Connection string pointing at the mapped host port. Safe to call after . public string GetConnectionString() diff --git a/src/src/Redis/Sdk/README.md b/src/src/Redis/Sdk/README.md index 6eacbff..935d064 100644 --- a/src/src/Redis/Sdk/README.md +++ b/src/src/Redis/Sdk/README.md @@ -1,29 +1,33 @@ -# Purview.WslContainers.Redis +# Purview.Containers.Redis -Throwaway Redis-compatible instances for .NET integration testing, running as WSLC containers on -**Microsoft WSL Containers** — no Docker installation. +Throwaway Redis-compatible instances for .NET integration testing on **WSL Containers (WSLC)** or **Docker**. ```bash -dotnet add package Purview.WslContainers.Redis +dotnet add package Purview.Containers.Redis ``` -Depends on `Purview.WslContainers` (the core runtime). `StackExchange.Redis` is not referenced by this +Backend-neutral: depends on `Purview.Containers.Core` and needs a backend package (`Purview.Containers.Wsl` or `Purview.Containers.Docker`). `StackExchange.Redis` is not referenced by this package — bring your own client. ## Requirements -- Windows 10/11 with **WSL Containers** (`wsl --install --no-distribution`). -- **A .NET 11 project targeting Windows specifically** (`net11.0-windows10.0.19041.0`, x64 or arm64). - `Purview.WslContainers` supplies `buildTransitive` defaults for `WindowsSdkPackageVersion` and - `PlatformTarget`, and rejects an unsupported consumer with `PWC0001`/`PWC0002` — see the - [consumer requirements](https://github.com/purview-dev/wsl-containers/blob/main/docs/wiki/Consumer-Requirements.md). +- **WSL Containers backend:** Windows 10/11 with WSL Containers (`wsl --install --no-distribution`), and a + `.NET 10` or later project. A platform-neutral `net10.0` project binds the portable facade and gets + automatic WSLC-or-Docker selection; a Windows target framework + (`net10.0-windows10.0.19041.0`, x64 or arm64) binds the implementation directly. The + `Purview.Containers.Wsl` package supplies `buildTransitive` defaults for + `WindowsSdkPackageVersion`/`PlatformTarget` and (for a platform-neutral consumer on a Windows build + host) the implementation payload, rejecting an unsupported consumer with `PCC0001`/`PCC0002` — see the + [consumer requirements](https://github.com/purview-dev/containers/blob/main/docs/wiki/Consumer-Requirements.md). +- **Docker backend:** any reachable Docker daemon (`docker info`), with a `net10.0` or later project on any + platform. No Windows target framework and no `PCC` guards apply. - **Experimental:** the API, defaults and packaging can change between prereleases; there is no production support guarantee. ## Quick start ```csharp -using Purview.WslContainers.Redis; +using Purview.Containers.Redis; using StackExchange.Redis; await using var redis = new RedisBuilder().Build(); @@ -57,5 +61,6 @@ await using var garnet = new RedisBuilder("ghcr.io/microsoft/garnet:latest").Bui ## Documentation -- [Modules](https://github.com/purview-dev/wsl-containers/blob/main/docs/wiki/Modules.md) — the module contract and readiness choices. -- [Wait Strategies](https://github.com/purview-dev/wsl-containers/blob/main/docs/wiki/Wait-Strategies.md) — overriding readiness checks. +- [Backends: WSLC or Docker](https://github.com/purview-dev/containers/blob/main/docs/wiki/Backends.md) — choosing and configuring the runtime. +- [Modules](https://github.com/purview-dev/containers/blob/main/docs/wiki/Modules.md) — the module contract and readiness choices. +- [Wait Strategies](https://github.com/purview-dev/containers/blob/main/docs/wiki/Wait-Strategies.md) — overriding readiness checks. diff --git a/src/src/Wsl/Diagnostics/WslContainerActivity.cs b/src/src/Wsl/Diagnostics/WslContainerActivity.cs new file mode 100644 index 0000000..4909782 --- /dev/null +++ b/src/src/Wsl/Diagnostics/WslContainerActivity.cs @@ -0,0 +1,10 @@ +using System.Diagnostics; + +namespace Purview.Containers.Wsl.Diagnostics; + +/// Activity source for container operations. Listener names: Purview.Containers. +static class WslContainerActivity +{ + /// Activities: session.start, image.pull, container.create/start/stop/delete, exec.create, wait. + public static readonly ActivitySource Source = new("Purview.Containers"); +} diff --git a/src/src/WslContainers/IContainerRuntime.cs b/src/src/Wsl/IContainerRuntime.cs similarity index 93% rename from src/src/WslContainers/IContainerRuntime.cs rename to src/src/Wsl/IContainerRuntime.cs index 3383028..5fbbb7a 100644 --- a/src/src/WslContainers/IContainerRuntime.cs +++ b/src/src/Wsl/IContainerRuntime.cs @@ -1,4 +1,4 @@ -namespace Purview.WslContainers; +namespace Purview.Containers.Wsl; /// The process-wide WSL Containers runtime. Owns the shared WSLC session. public interface IContainerRuntime : IAsyncDisposable diff --git a/src/src/WslContainers/IContainerSession.cs b/src/src/Wsl/IContainerSession.cs similarity index 92% rename from src/src/WslContainers/IContainerSession.cs rename to src/src/Wsl/IContainerSession.cs index b59cc70..8b43394 100644 --- a/src/src/WslContainers/IContainerSession.cs +++ b/src/src/Wsl/IContainerSession.cs @@ -1,6 +1,6 @@ -using Purview.WslContainers.Images; +using Purview.Containers.Images; -namespace Purview.WslContainers; +namespace Purview.Containers.Wsl; /// A WSLC session: the shared host for images and containers. public interface IContainerSession : IAsyncDisposable diff --git a/src/src/WslContainers/LogBuffer.cs b/src/src/Wsl/LogBuffer.cs similarity index 97% rename from src/src/WslContainers/LogBuffer.cs rename to src/src/Wsl/LogBuffer.cs index ded2dbd..3248de8 100644 --- a/src/src/WslContainers/LogBuffer.cs +++ b/src/src/Wsl/LogBuffer.cs @@ -1,9 +1,8 @@ using System.Text; using System.Threading.Channels; using Microsoft.WSL.Containers; -using Purview.WslContainers.Containers; -namespace Purview.WslContainers; +namespace Purview.Containers.Wsl; /// /// Bounded buffer for init-process output. Accumulates decoded lines for diagnostics while publishing diff --git a/src/src/WslContainers/Sdk/README.md b/src/src/Wsl/Sdk/README.md similarity index 59% rename from src/src/WslContainers/Sdk/README.md rename to src/src/Wsl/Sdk/README.md index a078c38..e250015 100644 --- a/src/src/WslContainers/Sdk/README.md +++ b/src/src/Wsl/Sdk/README.md @@ -1,25 +1,41 @@ -# Purview.WslContainers +# Purview.Containers.Wsl -WSLC-native throwaway Linux containers for .NET integration testing — a Testcontainers-style library built -directly on the `Microsoft.WSL.Containers` managed API, with **no Docker installation** and no -`wslc.exe`/`wsl.exe`/`docker` CLI, Docker.DotNet or Testcontainers dependency. +The **WSL Containers (WSLC) backend** for [`Purview.Containers.Core`](https://www.nuget.org/packages/Purview.Containers.Core): +throwaway Linux containers for .NET integration testing built directly on the `Microsoft.WSL.Containers` +managed API, with **no Docker installation** and no `wslc.exe`/`wsl.exe`/`docker` CLI, Docker.DotNet or +Testcontainers dependency. ```bash -dotnet add package Purview.WslContainers +dotnet add package Purview.Containers.Wsl ``` +Reference this package (or a service module) and containers run on WSLC. The package is +**multi-target**: a `net10.0` build (a portable facade) and a `net10.0-windows10.0.19041.0` build (the +implementation). A Windows-targeting project binds the implementation; a `net10.0` project binds the +facade, which loads the implementation at run time on a Windows host and reports `wsl` as unavailable +elsewhere — so the **same** test project runs on WSLC on your Windows machine and on +[`Purview.Containers.Docker`](https://www.nuget.org/packages/Purview.Containers.Docker) in Linux CI +with no configuration change. It registers itself as the `wsl` backend in the consuming assembly. + +> **Running in CI, or on a machine without WSL Containers?** With `auto` (the default) the backend is +> reported unavailable and selection falls through to Docker. Pin instead with +> `PURVIEW_CONTAINERS_BACKEND=wsl|docker` or `ContainerBackends.Use(...)` — see +> [Backends: WSLC or Docker](https://github.com/purview-dev/containers/blob/main/docs/wiki/Backends.md). + ## Requirements - Windows 10/11 with **WSL Containers** (`wsl --install --no-distribution`), verified against WSL 3.0.1.0. -- **A .NET 11 project targeting Windows specifically** — `net11.0-windows10.0.19041.0`, x64 or arm64. - A consuming project that targets anything else fails the build with `PWC0001` (target framework) or - `PWC0002` (platform), and without the packages' MSBuild defaults a stale +- **A .NET 10 project.** The recommended target framework is platform-neutral (`net10.0`): it binds the + facade and gives you automatic WSLC-or-Docker selection. A Windows target framework + (`net10.0-windows10.0.19041.0`, x64 or arm64) binds the implementation directly. Anything older than + .NET 10, or a Windows TFM below Windows 10.0.19041.0, fails the build with `PCC0001`; a 32-bit + Windows consumer fails with `PCC0002`; and without the packages' MSBuild defaults a stale `WindowsSdkPackageVersion` fails with `CS1705`. - This package supplies the `buildTransitive` defaults for `WindowsSdkPackageVersion` and - `PlatformTarget` that every module package inherits, so a consumer usually only chooses a target - framework. The full contract, the error reference and the `EnableWindowsTargeting` workaround for - non-Windows CI agents are in the - [consumer requirements](https://github.com/purview-dev/wsl-containers/blob/main/docs/wiki/Consumer-Requirements.md). + `PlatformTarget` that every module package inherits, and (for a platform-neutral consumer on a Windows + build host) copies the Windows implementation payload next to the output. The full contract, the error + reference and the `EnableWindowsTargeting` workaround for non-Windows CI agents are in the + [consumer requirements](https://github.com/purview-dev/containers/blob/main/docs/wiki/Consumer-Requirements.md). - Verify the host with `wsl --version` and `wslc version`. The library never installs or updates WSL itself; `WslContainerRuntime.GetInfoAsync()` reports what is missing. - **Experimental:** this is an experiment in driving WSL Containers. The public API, defaults and @@ -28,8 +44,8 @@ dotnet add package Purview.WslContainers ## Quick start ```csharp -using Purview.WslContainers; -using Purview.WslContainers.Waiting; +using Purview.Containers.Wsl; +using Purview.Containers.Waiting; await using var container = new ContainerBuilder() .WithImage("docker.io/library/redis:latest") @@ -80,7 +96,7 @@ invalid images, ports and mounts fail during configuration rather than at pull t | `Wait.ForAll(...)` / `Wait.ForAny(...)` | All / any contained strategy succeeds. | Every strategy supports `.WithTimeout(...)`, `.WithInterval(...)` and `.WithRetries(...)`. The default -timeout is the container's `StartupTimeout` (5 minutes). Timeouts throw `WslContainerTimeoutException` with +timeout is the container's `StartupTimeout` (5 minutes). Timeouts throw `ContainerTimeoutException` with a diagnostic naming the container, image, state, mapped ports, strategy and a bounded, secret-redacted log tail. ## Runtime, sessions and storage @@ -91,24 +107,24 @@ a diagnostic naming the container, image, state, mapped ports, strategy and a bo A session exclusively locks its `storage.vhdx`; when a concurrent process holds the default shared store the runtime verifies the store once and transparently falls back to an isolated per-process store (removed when that session terminates). Configure `WslContainerRuntimeOptions` for CPU, memory, GPU, session name, -`StoragePath` (or the `WSL_CONTAINERS_STORAGE_PATH` environment variable) and `StorageMode.PerSession`. +`StoragePath` (or the `PURVIEW_CONTAINERS_STORAGE_PATH` environment variable) and `StorageMode.PerSession`. The Microsoft types (`Session`, `Container`, `Process`, …) stay behind the public interfaces; the only escape hatch is the opt-in accessor for `Inspect()` and raw handles. ## Diagnostics -`System.Diagnostics.ActivitySource("Purview.WslContainers")` emits session, pull, container lifecycle, exec +`System.Diagnostics.ActivitySource("Purview.Containers")` emits session, pull, container lifecycle, exec and wait spans. Credentials and other sensitive values are wrapped in `Secret` and redacted from `ToString()` and diagnostics. ## Documentation -See the [project wiki](https://github.com/purview-dev/wsl-containers/blob/main/docs/wiki/Home.md): -[Getting Started](https://github.com/purview-dev/wsl-containers/blob/main/docs/wiki/Getting-Started.md), -[Architecture](https://github.com/purview-dev/wsl-containers/blob/main/docs/wiki/Architecture.md), -[Lifecycle](https://github.com/purview-dev/wsl-containers/blob/main/docs/wiki/Lifecycle.md), -[Networking](https://github.com/purview-dev/wsl-containers/blob/main/docs/wiki/Networking.md) and -[Wait Strategies](https://github.com/purview-dev/wsl-containers/blob/main/docs/wiki/Wait-Strategies.md). -Ready-made service modules ship as `Purview.WslContainers.PostgreSql`, `Redis`, `MsSql`, `RabbitMq`, +See the [project wiki](https://github.com/purview-dev/containers/blob/main/docs/wiki/Home.md): +[Getting Started](https://github.com/purview-dev/containers/blob/main/docs/wiki/Getting-Started.md), +[Architecture](https://github.com/purview-dev/containers/blob/main/docs/wiki/Architecture.md), +[Lifecycle](https://github.com/purview-dev/containers/blob/main/docs/wiki/Lifecycle.md), +[Networking](https://github.com/purview-dev/containers/blob/main/docs/wiki/Networking.md) and +[Wait Strategies](https://github.com/purview-dev/containers/blob/main/docs/wiki/Wait-Strategies.md). +Ready-made service modules ship as `Purview.Containers.PostgreSql`, `Redis`, `MsSql`, `RabbitMq`, `Azurite`, `Nats` and `MySql`. diff --git a/src/src/WslContainers/Sdk/buildTransitive/Purview.WslContainers.props b/src/src/Wsl/Sdk/buildTransitive/Purview.Containers.Wsl.props similarity index 55% rename from src/src/WslContainers/Sdk/buildTransitive/Purview.WslContainers.props rename to src/src/Wsl/Sdk/buildTransitive/Purview.Containers.Wsl.props index d758601..4829422 100644 --- a/src/src/WslContainers/Sdk/buildTransitive/Purview.WslContainers.props +++ b/src/src/Wsl/Sdk/buildTransitive/Purview.Containers.Wsl.props @@ -1,21 +1,27 @@ 10.0.26100.80 + + + $(PurviewContainersBackends);Purview.Containers.Wsl.WslContainerBackend diff --git a/src/src/Wsl/Sdk/buildTransitive/Purview.Containers.Wsl.targets b/src/src/Wsl/Sdk/buildTransitive/Purview.Containers.Wsl.targets new file mode 100644 index 0000000..394f45a --- /dev/null +++ b/src/src/Wsl/Sdk/buildTransitive/Purview.Containers.Wsl.targets @@ -0,0 +1,129 @@ + + + + + + x64 + + + + + + <_PurviewContainersWslIsWindows Condition="'$(TargetPlatformIdentifier)' == 'Windows'" + >true + <_PurviewContainersWslWindowsPlatformOk + Condition="'$(TargetPlatformVersion)' != '' AND $([MSBuild]::VersionGreaterThanOrEquals($(TargetPlatformVersion), '10.0.19041.0'))" + >true + <_PurviewContainersWslWindowsOk + Condition="'$(_PurviewContainersWslIsWindows)' == 'true' AND '$(TargetFrameworkIdentifier)' == '.NETCoreApp' AND $([MSBuild]::VersionGreaterThanOrEquals($(TargetFrameworkVersion), '10.0')) AND '$(_PurviewContainersWslWindowsPlatformOk)' == 'true'" + >true + <_PurviewContainersWslPortableOk + Condition="'$(_PurviewContainersWslIsWindows)' != 'true' AND '$(TargetFrameworkIdentifier)' == '.NETCoreApp' AND $([MSBuild]::VersionGreaterThanOrEquals($(TargetFrameworkVersion), '10.0'))" + >true + <_PurviewContainersWslSupported + Condition="'$(_PurviewContainersWslWindowsOk)' == 'true' OR '$(_PurviewContainersWslPortableOk)' == 'true'" + >true + + + + + + + + <_PurviewContainersWslIsWindows Condition="'$(TargetPlatformIdentifier)' == 'Windows'" + >true + <_PurviewContainersWslPlatformTargetSupported + Condition="'$(_PurviewContainersWslIsWindows)' != 'true' OR '$(PlatformTarget)' == 'x64' OR '$(PlatformTarget)' == 'arm64' OR ('$(PlatformTarget)' == '' AND '$(RuntimeIdentifier)' != '')" + >true + + + + + + + + + <_PurviewContainersWslPayloadRid + Condition="'$(RuntimeIdentifier)' == 'win-arm64' OR '$(PlatformTarget)' == 'arm64'" + >win-arm64 + <_PurviewContainersWslPayloadRid Condition="'$(_PurviewContainersWslPayloadRid)' == ''" + >win-x64 + <_PurviewContainersWslPayloadSource>$(MSBuildThisFileDirectory)..\payload\$(_PurviewContainersWslPayloadRid)\ + + + + <_PurviewContainersWslPayload Include="$(_PurviewContainersWslPayloadSource)*.*" /> + + + + + diff --git a/src/src/WslContainers/StorageMode.cs b/src/src/Wsl/StorageMode.cs similarity index 93% rename from src/src/WslContainers/StorageMode.cs rename to src/src/Wsl/StorageMode.cs index 9df4d97..1338ead 100644 --- a/src/src/WslContainers/StorageMode.cs +++ b/src/src/Wsl/StorageMode.cs @@ -1,4 +1,4 @@ -namespace Purview.WslContainers; +namespace Purview.Containers.Wsl; /// How the session storage path (and therefore the image store) is scoped. public enum StorageMode diff --git a/src/src/Wsl/Wsl.csproj b/src/src/Wsl/Wsl.csproj new file mode 100644 index 0000000..40e8610 --- /dev/null +++ b/src/src/Wsl/Wsl.csproj @@ -0,0 +1,140 @@ + + + true + + + net10.0;net10.0-windows10.0.19041.0 + WSL Containers (WSLC) backend for Purview.Containers: run WSLC-native throwaway Linux containers from any .NET 10 project, with automatic Docker fallback. + wsl;wslc;containers;linux;windows;testing;integration;testcontainers + MIT + Purview + + $(NoWarn);NU5100 + + + + + x64 + 10.0.26100.80 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + $(TargetsForTfmSpecificContentInPackage);PurviewContainersWslAddPayload + + + + + <_WslWindowsBinDir>$(MSBuildThisFileDirectory)bin/$(Configuration)/net10.0-windows10.0.19041.0/ + <_WslcPayloadSource>$(NuGetPackageRoot)microsoft.wsl.containers/3.0.1 + <_WindowsSdkPayloadSource>$(NuGetPackageRoot)microsoft.windows.sdk.net.ref/10.0.26100.80 + + + + + + + + + + + + + + + + + diff --git a/src/src/WslContainers/WslContainer.cs b/src/src/Wsl/WslContainer.cs similarity index 96% rename from src/src/WslContainers/WslContainer.cs rename to src/src/Wsl/WslContainer.cs index bd51988..a0137c7 100644 --- a/src/src/WslContainers/WslContainer.cs +++ b/src/src/Wsl/WslContainer.cs @@ -2,15 +2,14 @@ using System.Runtime.CompilerServices; using System.Text; using Microsoft.WSL.Containers; -using Purview.WslContainers.Containers; -using Purview.WslContainers.Runtime; -using Purview.WslContainers.Waiting; +using Purview.Containers.Waiting; +using MsContainer = Microsoft.WSL.Containers.Container; using MsContainerState = Microsoft.WSL.Containers.ContainerState; using MsProcess = Microsoft.WSL.Containers.Process; using MsProcessState = Microsoft.WSL.Containers.ProcessState; using MsSignal = Microsoft.WSL.Containers.Signal; -namespace Purview.WslContainers; +namespace Purview.Containers.Wsl; /// A WSLC-backed throwaway container. [SuppressMessage( @@ -22,7 +21,7 @@ public class WslContainer : IContainer { readonly LogBuffer _logBuffer = new(); WslContainerSession? _session; - Container? _handle; + MsContainer? _handle; IReadOnlyDictionary _portMappings = new Dictionary(); string? _networkIp; int _started; @@ -51,7 +50,7 @@ public WslContainer(ContainerConfiguration configuration, IContainerRuntime runt public string Id => _handle?.Id ?? string.Empty; /// - public Containers.ContainerState State { get; private set; } + public ContainerState State { get; private set; } /// public string Image => Configuration.Image; @@ -347,9 +346,9 @@ void EnsureStarted() } } - static Containers.ContainerState MapState(MsContainerState state) + static ContainerState MapState(MsContainerState state) { - return (Containers.ContainerState)state; + return (ContainerState)state; } static string GenerateName(ContainerConfiguration configuration) diff --git a/src/src/Wsl/WslContainerBackend.Facade.cs b/src/src/Wsl/WslContainerBackend.Facade.cs new file mode 100644 index 0000000..ca278c3 --- /dev/null +++ b/src/src/Wsl/WslContainerBackend.Facade.cs @@ -0,0 +1,65 @@ +namespace Purview.Containers.Wsl; + +/// +/// The WSL Containers (WSLC) backend as seen from a platform-neutral (net10.0) consumer. +/// +/// +/// +/// On a Windows host this facade loads the Windows build of the backend from the local payload and +/// delegates to it; the container it returns is a real WSLC-backed . On any +/// other host - or when the payload is absent - it reports wsl as unavailable, so +/// auto-selection moves on to the next registered backend (Docker). +/// +/// +/// A Windows-targeting consumer never sees this type: it binds the Windows build of the assembly, +/// where WslContainerBackend is the implementation itself. +/// +/// +public sealed class WslContainerBackend : IContainerBackend, IContainerBackendPreference +{ + /// Stable backend identifier. + public string Name => "wsl"; + + /// + /// Automatic selection preference: WSLC is preferred over Docker on a machine that can run both. + /// + public int AutoPriority => 0; + + /// Factory used by the generated backend registration. + public static WslContainerBackend Create() => new(); + + /// + public IContainer CreateContainer(IContainerConfiguration configuration) + { + ArgumentNullException.ThrowIfNull(configuration); + return Resolve().CreateContainer(configuration); + } + + /// + public async Task GetInfoAsync(CancellationToken cancellationToken = default) + { + var backend = WslPayload.TryCreateBackend(); + if (backend is null) + { + return Unavailable(WslPayload.FailureReason); + } + + try + { + return await backend.GetInfoAsync(cancellationToken).ConfigureAwait(false); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + return Unavailable($"{exception.GetType().Name}: {exception.Message}"); + } + } + + static IContainerBackend Resolve() => + WslPayload.TryCreateBackend() + ?? throw new WslContainerPrerequisiteException( + $"The WSL Containers backend cannot run here. {WslPayload.FailureReason}" + ); + + static ContainerBackendInfo Unavailable(string reason) => + new("wsl", IsAvailable: false, IsCompatible: false, Version: string.Empty, MissingComponents: [reason]); +} diff --git a/src/src/Wsl/WslContainerBackend.cs b/src/src/Wsl/WslContainerBackend.cs new file mode 100644 index 0000000..4a387f9 --- /dev/null +++ b/src/src/Wsl/WslContainerBackend.cs @@ -0,0 +1,71 @@ +using Purview.Containers.Runtime; + +namespace Purview.Containers.Wsl; + +/// +/// The WSL Containers (WSLC) backend: it creates containers on the process-wide WSLC session and reports +/// the installed WSL Containers components. No Docker installation is involved. +/// +public sealed class WslContainerBackend : IContainerBackend, IContainerBackendPreference +{ + /// Creates the backend over the process-wide . + public WslContainerBackend() { } + + /// Creates the backend over an explicit runtime (isolation experiments and tests). + public WslContainerBackend(IContainerRuntime runtime) + { + ArgumentNullException.ThrowIfNull(runtime); + Runtime = runtime; + } + + /// Stable backend identifier. + public string Name => "wsl"; + + /// + /// Automatic selection preference: WSLC is preferred over Docker on a machine that can run both. + /// + public int AutoPriority => 0; + + /// Factory used by the generated backend registration. + public static WslContainerBackend Create() => new(); + + IContainerRuntime Runtime => field ?? WslContainerRuntime.Instance; + + /// + public IContainer CreateContainer(IContainerConfiguration configuration) + { + ArgumentNullException.ThrowIfNull(configuration); + if (configuration is not ContainerConfiguration wslc) + { + throw new ContainerConfigurationException( + "The WSL Containers backend requires a ContainerConfiguration-derived configuration; " + + $"{configuration.GetType().Name} is not supported." + ); + } + + foreach (var binding in configuration.PortBindings) + { + if (binding.Protocol == Networking.PortProtocol.Udp) + { + throw new ContainerNotSupportedException( + "UDP port mappings are not supported by the WSL Containers managed API." + ); + } + } + + return new WslContainer(wslc, Runtime); + } + + /// + public async Task GetInfoAsync(CancellationToken cancellationToken = default) + { + var info = await Runtime.GetInfoAsync(cancellationToken).ConfigureAwait(false); + return new ContainerBackendInfo( + "wsl", + info.IsAvailable, + info.IsCompatible, + info.Version, + info.MissingComponents + ); + } +} diff --git a/src/src/Wsl/WslContainerException.cs b/src/src/Wsl/WslContainerException.cs new file mode 100644 index 0000000..d38e698 --- /dev/null +++ b/src/src/Wsl/WslContainerException.cs @@ -0,0 +1,25 @@ +using Purview.Containers.Runtime; + +namespace Purview.Containers.Wsl; + +#pragma warning disable CA1032 // Implement standard exception constructors + +/// Base exception for errors raised by the WSL Containers (WSLC) backend. +public class WslContainerException : ContainerException +{ + public WslContainerException(string message) + : base(message) { } + + public WslContainerException(string message, Exception innerException) + : base(message, innerException) { } +} + +/// WSL or WSL Containers prerequisites are missing. +public class WslContainerPrerequisiteException(string message) : ContainerPrerequisiteException(message) { } + +/// Starting the container failed inside WSLC (image, OCI runtime, or environment). +public class WslContainerStartupException(string message, Exception innerException) + : ContainerStartupException(message, innerException) { } + +/// The backing WSLC session terminated unexpectedly. +public class WslContainerSessionTerminatedException(string message) : WslContainerException(message) { } diff --git a/src/src/Wsl/WslContainerRuntime.Facade.cs b/src/src/Wsl/WslContainerRuntime.Facade.cs new file mode 100644 index 0000000..03a941e --- /dev/null +++ b/src/src/Wsl/WslContainerRuntime.Facade.cs @@ -0,0 +1,70 @@ +namespace Purview.Containers.Wsl; + +/// +/// The process-wide WSL Containers runtime as seen from a platform-neutral (net10.0) consumer. +/// +/// +/// +/// The facade forwards host diagnostics to the Windows implementation loaded from the payload and +/// reports "unavailable" when this host cannot run WSLC. It exists mainly for the documented host +/// check (WslContainerRuntime.Instance.GetInfoAsync()); container work always goes through the +/// backend-neutral builders. +/// +/// +/// Session-level access () is not exposed from the facade, because the +/// WSLC session handle is a Windows-only type. A Windows-targeting consumer keeps the full runtime. +/// +/// +public sealed class WslContainerRuntime : IContainerRuntime +{ + /// + /// Environment variable that overrides the session storage path for the Windows implementation. + /// + public const string StoragePathEnvironmentVariable = "PURVIEW_CONTAINERS_STORAGE_PATH"; + + /// The pre-rename storage path variable, still honoured as a fallback. + public const string LegacyStoragePathEnvironmentVariable = "WSL_CONTAINERS_STORAGE_PATH"; + + static readonly Lazy InstanceHolder = new(() => new WslContainerRuntime()); + + /// The default process-wide runtime. + public static WslContainerRuntime Instance => InstanceHolder.Value; + + /// Creates a runtime with default options. + public WslContainerRuntime() { } + + /// Creates a runtime with the given options (retained for API parity; applied when WSLC runs). + public WslContainerRuntime(WslContainerRuntimeOptions options) => + Options = options ?? WslContainerRuntimeOptions.Default; + + /// The options this runtime was created with, if any. + public WslContainerRuntimeOptions? Options { get; } + + /// + public async Task GetInfoAsync(CancellationToken cancellationToken = default) + { + var backend = WslPayload.TryCreateBackend(); + if (backend is null) + { + return new WslContainerRuntimeInfo( + Version: string.Empty, + MissingComponents: [WslPayload.FailureReason], + IsAvailable: false, + IsCompatible: false + ); + } + + var info = await backend.GetInfoAsync(cancellationToken).ConfigureAwait(false); + return new WslContainerRuntimeInfo(info.Version, info.MissingComponents, info.IsAvailable, info.IsCompatible); + } + + /// + public Task GetSessionAsync(CancellationToken cancellationToken = default) => + throw new WslContainerPrerequisiteException( + "Direct WSLC session access is only available from a Windows target framework " + + "(net10.0-windows10.0.19041.0 or later). Use the backend-neutral container API instead." + ); + + /// + public ValueTask DisposeAsync() => ValueTask.CompletedTask; +} diff --git a/src/src/WslContainers/WslContainerRuntime.cs b/src/src/Wsl/WslContainerRuntime.cs similarity index 86% rename from src/src/WslContainers/WslContainerRuntime.cs rename to src/src/Wsl/WslContainerRuntime.cs index 0415045..25f6450 100644 --- a/src/src/WslContainers/WslContainerRuntime.cs +++ b/src/src/Wsl/WslContainerRuntime.cs @@ -1,7 +1,7 @@ using System.Diagnostics.CodeAnalysis; using Microsoft.WSL.Containers; -namespace Purview.WslContainers; +namespace Purview.Containers.Wsl; /// /// The process-wide WSL Containers runtime. Owns a single lazily started WSLC session. @@ -18,6 +18,15 @@ public sealed class WslContainerRuntime : IContainerRuntime { const int ErrorSharingViolation = unchecked((int)0x80070020); + /// + /// Environment variable that overrides the session storage path for every runtime that does not set + /// . + /// + public const string StoragePathEnvironmentVariable = "PURVIEW_CONTAINERS_STORAGE_PATH"; + + /// The pre-rename storage path variable, still honoured as a fallback. + public const string LegacyStoragePathEnvironmentVariable = "WSL_CONTAINERS_STORAGE_PATH"; + static readonly Lazy InstanceHolder = new(() => new WslContainerRuntime()); readonly WslContainerRuntimeOptions _options; @@ -180,7 +189,7 @@ string ResolveStoragePath(string sessionName) return _options.StoragePath; } - var envPath = Environment.GetEnvironmentVariable("WSL_CONTAINERS_STORAGE_PATH"); + var envPath = StoragePathFromEnvironment(); if (!string.IsNullOrEmpty(envPath)) { return envPath; @@ -198,10 +207,22 @@ string ResolveStoragePath(string sessionName) bool IsUsingDefaultSharedStore() { return _options.StoragePath is null - && string.IsNullOrEmpty(Environment.GetEnvironmentVariable("WSL_CONTAINERS_STORAGE_PATH")) + && string.IsNullOrEmpty(StoragePathFromEnvironment()) && _options.StorageMode == StorageMode.Shared; } + /// + /// The storage path configured through the environment: + /// first, then the pre-rename . + /// + static string? StoragePathFromEnvironment() + { + var configured = Environment.GetEnvironmentVariable(StoragePathEnvironmentVariable); + return string.IsNullOrEmpty(configured) + ? Environment.GetEnvironmentVariable(LegacyStoragePathEnvironmentVariable) + : configured; + } + static string NextSessionName() { return $"wslc-{Environment.ProcessId}-{Guid.NewGuid().ToString("N")[..8]}"; diff --git a/src/src/WslContainers/WslContainerRuntimeInfo.cs b/src/src/Wsl/WslContainerRuntimeInfo.cs similarity index 86% rename from src/src/WslContainers/WslContainerRuntimeInfo.cs rename to src/src/Wsl/WslContainerRuntimeInfo.cs index aa76ac1..a68ad6a 100644 --- a/src/src/WslContainers/WslContainerRuntimeInfo.cs +++ b/src/src/Wsl/WslContainerRuntimeInfo.cs @@ -1,4 +1,4 @@ -namespace Purview.WslContainers; +namespace Purview.Containers.Wsl; /// Information about the installed WSL Containers runtime. public sealed record WslContainerRuntimeInfo( diff --git a/src/src/WslContainers/WslContainerRuntimeOptions.cs b/src/src/Wsl/WslContainerRuntimeOptions.cs similarity index 83% rename from src/src/WslContainers/WslContainerRuntimeOptions.cs rename to src/src/Wsl/WslContainerRuntimeOptions.cs index 896f76b..7f7c562 100644 --- a/src/src/WslContainers/WslContainerRuntimeOptions.cs +++ b/src/src/Wsl/WslContainerRuntimeOptions.cs @@ -1,4 +1,4 @@ -namespace Purview.WslContainers; +namespace Purview.Containers.Wsl; /// Configuration for the WSLC session owned by a runtime. public sealed record WslContainerRuntimeOptions @@ -20,8 +20,10 @@ public sealed record WslContainerRuntimeOptions /// /// Storage path for the session VHD and image store. When unset and is /// , the default shared image directory is used - /// (%LOCALAPPDATA%\Purview\WslContainers\images). The WSL_CONTAINERS_STORAGE_PATH - /// environment variable overrides the default when this is unset. + /// (%LOCALAPPDATA%\Purview\WslContainers\images). The + /// environment variable overrides the + /// default when this is unset; the pre-rename WSL_CONTAINERS_STORAGE_PATH is still honoured as a + /// fallback. /// public string? StoragePath { get; init; } diff --git a/src/src/WslContainers/WslContainerSession.cs b/src/src/Wsl/WslContainerSession.cs similarity index 89% rename from src/src/WslContainers/WslContainerSession.cs rename to src/src/Wsl/WslContainerSession.cs index 64bcbef..4138570 100644 --- a/src/src/WslContainers/WslContainerSession.cs +++ b/src/src/Wsl/WslContainerSession.cs @@ -1,13 +1,13 @@ using System.Collections.Concurrent; using System.Diagnostics.CodeAnalysis; using Microsoft.WSL.Containers; -using Purview.WslContainers.Diagnostics; -using Purview.WslContainers.Images; -using Purview.WslContainers.Runtime; +using Purview.Containers.Images; +using Purview.Containers.Wsl.Diagnostics; using Windows.Foundation; +using MsContainer = Microsoft.WSL.Containers.Container; using Process = Microsoft.WSL.Containers.Process; -namespace Purview.WslContainers; +namespace Purview.Containers.Wsl; [SuppressMessage( "Design", @@ -52,7 +52,7 @@ public WslContainerSession( public async Task EnsureStartedAsync(CancellationToken cancellationToken) { - using var activity = WslContainersActivity.Source.StartActivity("wslcontainer.session.start"); + using var activity = WslContainerActivity.Source.StartActivity("wslcontainer.session.start"); activity?.SetTag("session.name", Name); ThrowIfTerminated(); if (Volatile.Read(ref _started) == 1) @@ -201,9 +201,12 @@ internal void DisposeSession() _lifecycleGate.Dispose(); } - internal async Task CreateContainerAsync(ContainerSettings settings, CancellationToken cancellationToken) + internal async Task CreateContainerAsync( + ContainerSettings settings, + CancellationToken cancellationToken + ) { - using var activity = WslContainersActivity.Source.StartActivity("wslcontainer.container.create"); + using var activity = WslContainerActivity.Source.StartActivity("wslcontainer.container.create"); activity?.SetTag("container.name", settings.Name); await EnsureStartedAsync(cancellationToken).ConfigureAwait(false); await _lifecycleGate.WaitAsync(cancellationToken).ConfigureAwait(false); @@ -218,9 +221,9 @@ internal async Task CreateContainerAsync(ContainerSettings settings, } } - internal async Task StartContainerAsync(Container container, CancellationToken cancellationToken) + internal async Task StartContainerAsync(MsContainer container, CancellationToken cancellationToken) { - using var activity = WslContainersActivity.Source.StartActivity("wslcontainer.container.start"); + using var activity = WslContainerActivity.Source.StartActivity("wslcontainer.container.start"); activity?.SetTag("container.id", container.Id); await _lifecycleGate.WaitAsync(cancellationToken).ConfigureAwait(false); try @@ -235,13 +238,13 @@ internal async Task StartContainerAsync(Container container, CancellationToken c } internal async Task StopContainerAsync( - Container container, + MsContainer container, Signal signal, TimeSpan timeout, CancellationToken cancellationToken ) { - using var activity = WslContainersActivity.Source.StartActivity("wslcontainer.container.stop"); + using var activity = WslContainerActivity.Source.StartActivity("wslcontainer.container.stop"); activity?.SetTag("container.id", container.Id); await _lifecycleGate.WaitAsync(cancellationToken).ConfigureAwait(false); try @@ -256,12 +259,12 @@ CancellationToken cancellationToken } internal async Task DeleteContainerAsync( - Container container, + MsContainer container, DeleteContainerOption option, CancellationToken cancellationToken ) { - using var activity = WslContainersActivity.Source.StartActivity("wslcontainer.container.delete"); + using var activity = WslContainerActivity.Source.StartActivity("wslcontainer.container.delete"); activity?.SetTag("container.id", container.Id); await _lifecycleGate.WaitAsync(cancellationToken).ConfigureAwait(false); try @@ -276,12 +279,12 @@ CancellationToken cancellationToken } internal async Task CreateProcessAsync( - Container container, + MsContainer container, ProcessSettings settings, CancellationToken cancellationToken ) { - using var activity = WslContainersActivity.Source.StartActivity("wslcontainer.exec.create"); + using var activity = WslContainerActivity.Source.StartActivity("wslcontainer.exec.create"); activity?.SetTag("container.id", container.Id); await _lifecycleGate.WaitAsync(cancellationToken).ConfigureAwait(false); try @@ -354,7 +357,7 @@ async Task PullCoreAsync( CancellationToken cancellationToken ) { - using var activity = WslContainersActivity.Source.StartActivity("wslcontainer.image.pull"); + using var activity = WslContainerActivity.Source.StartActivity("wslcontainer.image.pull"); activity?.SetTag("image", reference.FullReference); PullImageOptions options = new(reference.FullReference); if (credentials is not null) diff --git a/src/src/WslContainers/WslInspectParser.cs b/src/src/Wsl/WslInspectParser.cs similarity index 98% rename from src/src/WslContainers/WslInspectParser.cs rename to src/src/Wsl/WslInspectParser.cs index dc460bf..21b1504 100644 --- a/src/src/WslContainers/WslInspectParser.cs +++ b/src/src/Wsl/WslInspectParser.cs @@ -1,6 +1,6 @@ using System.Text.Json; -namespace Purview.WslContainers; +namespace Purview.Containers.Wsl; /// Parses the Docker-compatible JSON returned by Container.Inspect(). static class WslInspectParser diff --git a/src/src/Wsl/WslPayload.cs b/src/src/Wsl/WslPayload.cs new file mode 100644 index 0000000..f036785 --- /dev/null +++ b/src/src/Wsl/WslPayload.cs @@ -0,0 +1,147 @@ +using System.Reflection; +using System.Runtime.Loader; + +namespace Purview.Containers.Wsl; + +/// +/// Loads the Windows build of Purview.Containers.Wsl at runtime so a platform-neutral +/// (net10.0) process on a Windows host can still run containers on WSL Containers. +/// +/// +/// +/// This is the portable half of the backend. It never references Microsoft.WSL.Containers at +/// compile time; instead it loads the Windows build of this same assembly (shipped as a payload, +/// under wslc/) into a dedicated and hands back its +/// . Only the platform-neutral +/// contract crosses that boundary, so no WSLC type is reflected over. +/// +/// +/// Everywhere else - a non-Windows host, or a missing payload - the probe simply fails, so +/// ContainerBackends auto-selection falls through to Docker. That is what lets the same test +/// project run on WSLC on a developer's Windows machine and on Docker in a Linux CI job without a +/// single configuration change. +/// +/// +static class WslPayload +{ + /// Optional override for the payload folder (advanced hosting scenarios). + const string DirectoryEnvironmentVariable = "PURVIEW_CONTAINERS_WSL_PAYLOAD"; + + // The default payload folder, copied next to the app by the package's buildTransitive targets. + const string DirectoryName = "wslc"; + const string ImplementationFileName = "Purview.Containers.Wsl.dll"; + const string BackendTypeName = "Purview.Containers.Wsl.WslContainerBackend"; + + static readonly Lock Sync = new(); + static bool Attempted; + static IContainerBackend? Resolved; + static string? Failure; + + /// True when this host could possibly run WSL Containers. + internal static bool IsHostSupported => OperatingSystem.IsWindows(); + + /// + /// Returns the Windows implementation's backend, or null when this host cannot run WSLC + /// (non-Windows), the payload is absent, or it failed to load. The reason is available from + /// . + /// + internal static IContainerBackend? TryCreateBackend() + { + if (!IsHostSupported) + { + Failure = "WSL Containers requires a Windows host."; + return null; + } + + lock (Sync) + { + if (!Attempted) + { + Attempted = true; + Resolved = Create(); + } + + return Resolved; + } + } + + /// The reason the last returned null. + internal static string FailureReason => Failure ?? "The WSL Containers implementation is unavailable."; + + static IContainerBackend? Create() + { + foreach (var directory in CandidateDirectories()) + { + if (!Directory.Exists(directory)) + { + continue; + } + + var candidate = Path.Combine(directory, ImplementationFileName); + if (!File.Exists(candidate)) + { + continue; + } + + try + { + PayloadLoadContext context = new(directory); + var assembly = context.LoadFromAssemblyPath(candidate); + var factory = assembly + .GetType(BackendTypeName, throwOnError: false) + ?.GetMethod("Create", BindingFlags.Public | BindingFlags.Static); + if (factory?.Invoke(null, null) is IContainerBackend backend) + { + return backend; + } + + Failure = $"The WSL Containers implementation at '{candidate}' did not expose a backend."; + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + Failure = + $"The WSL Containers implementation at '{candidate}' failed to load: " + + $"{exception.GetType().Name}: {exception.Message}"; + } + } + + Failure ??= + "The WSL Containers implementation payload was not found. Reference 'Purview.Containers.Wsl' " + + $"with the Windows payload deployed (looked in '{string.Join("', '", CandidateDirectories())}')."; + return null; + } + + static IEnumerable CandidateDirectories() + { + var configured = Environment.GetEnvironmentVariable(DirectoryEnvironmentVariable); + if (!string.IsNullOrEmpty(configured)) + { + yield return configured; + } + + yield return Path.Combine(AppContext.BaseDirectory, DirectoryName); + } +} + +/// +/// Isolates the WSLC implementation and its Windows SDK dependencies from the host app. Anything not +/// present in the payload folder (notably Purview.Containers) resolves from the default load +/// context, so the shared contract keeps a single identity. +/// +sealed class PayloadLoadContext(string directory) + : AssemblyLoadContext("Purview.Containers.Wsl.Payload", isCollectible: false) +{ + readonly string _directory = directory; + + protected override Assembly? Load(AssemblyName assemblyName) + { + var candidate = Path.Combine(_directory, $"{assemblyName.Name}.dll"); + return File.Exists(candidate) ? LoadFromAssemblyPath(candidate) : null; + } + + protected override IntPtr LoadUnmanagedDll(string unmanagedDllName) + { + var candidate = Path.Combine(_directory, $"{unmanagedDllName}.dll"); + return File.Exists(candidate) ? LoadUnmanagedDllFromPath(candidate) : IntPtr.Zero; + } +} diff --git a/src/src/WslContainers/WslSettingsMapper.cs b/src/src/Wsl/WslSettingsMapper.cs similarity index 95% rename from src/src/WslContainers/WslSettingsMapper.cs rename to src/src/Wsl/WslSettingsMapper.cs index cd14133..9cc595e 100644 --- a/src/src/WslContainers/WslSettingsMapper.cs +++ b/src/src/Wsl/WslSettingsMapper.cs @@ -1,9 +1,9 @@ using Microsoft.WSL.Containers; -using Purview.WslContainers.Mounts; -using Purview.WslContainers.Networking; +using Purview.Containers.Mounts; +using Purview.Containers.Networking; using Windows.Networking; -namespace Purview.WslContainers; +namespace Purview.Containers.Wsl; /// Maps the public configuration model onto the WSLC settings types. static class WslSettingsMapper diff --git a/src/src/WslContainers/Diagnostics/WslContainersActivity.cs b/src/src/WslContainers/Diagnostics/WslContainersActivity.cs deleted file mode 100644 index 66deb2e..0000000 --- a/src/src/WslContainers/Diagnostics/WslContainersActivity.cs +++ /dev/null @@ -1,10 +0,0 @@ -using System.Diagnostics; - -namespace Purview.WslContainers.Diagnostics; - -/// Activity source for WSL Containers operations. Listener names: Purview.WslContainers. -static class WslContainersActivity -{ - /// Activities: session.start, image.pull, container.create/start/stop/delete, exec.create, wait. - public static readonly ActivitySource Source = new("Purview.WslContainers"); -} diff --git a/src/src/WslContainers/Runtime/WslContainerException.cs b/src/src/WslContainers/Runtime/WslContainerException.cs deleted file mode 100644 index 344d5de..0000000 --- a/src/src/WslContainers/Runtime/WslContainerException.cs +++ /dev/null @@ -1,39 +0,0 @@ -namespace Purview.WslContainers.Runtime; - -#pragma warning disable CA1032 // Implement standard exception constructors - -/// Base exception for all errors raised by the WSL Containers runtime. -public class WslContainerException : Exception -{ - public WslContainerException(string message) - : base(message) { } - - public WslContainerException(string message, Exception innerException) - : base(message, innerException) { } -} - -/// Invalid container configuration detected before any WSLC operation. -public class WslContainerConfigurationException : WslContainerException -{ - public WslContainerConfigurationException(string message) - : base(message) { } - - public WslContainerConfigurationException(string message, Exception innerException) - : base(message, innerException) { } -} - -/// WSL/WSL Containers prerequisites are missing or incompatible. -public class WslContainerPrerequisiteException(string message) : WslContainerException(message) { } - -/// A requested feature is not supported by the current WSLC API. -public class WslContainerNotSupportedException(string message) : WslContainerException(message) { } - -/// Starting the container failed (image, OCI runtime, or environment). -public class WslContainerStartupException(string message, Exception innerException) - : WslContainerException(message, innerException) { } - -/// An operation did not complete within its configured timeout. -public class WslContainerTimeoutException(string message) : WslContainerException(message) { } - -/// The backing WSLC session terminated unexpectedly. -public class WslContainerSessionTerminatedException(string message) : WslContainerException(message) { } diff --git a/src/src/WslContainers/Sdk/buildTransitive/Purview.WslContainers.targets b/src/src/WslContainers/Sdk/buildTransitive/Purview.WslContainers.targets deleted file mode 100644 index 961b94a..0000000 --- a/src/src/WslContainers/Sdk/buildTransitive/Purview.WslContainers.targets +++ /dev/null @@ -1,73 +0,0 @@ - - - - - x64 - - - - - - <_PurviewWslContainersPlatformVersionSupported - Condition="'$(TargetPlatformVersion)' != '' AND $([MSBuild]::VersionGreaterThanOrEquals($(TargetPlatformVersion), '10.0.19041.0'))" - >true - <_PurviewWslContainersTargetFrameworkSupported - Condition="'$(TargetFrameworkIdentifier)' == '.NETCoreApp' AND $([MSBuild]::VersionGreaterThanOrEquals($(TargetFrameworkVersion), '11.0')) AND '$(TargetPlatformIdentifier)' == 'Windows' AND '$(_PurviewWslContainersPlatformVersionSupported)' == 'true'" - >true - - - - - - - - <_PurviewWslContainersPlatformTargetSupported - Condition="'$(PlatformTarget)' == 'x64' OR '$(PlatformTarget)' == 'arm64' OR ('$(PlatformTarget)' == '' AND '$(RuntimeIdentifier)' != '')" - >true - - - - - diff --git a/src/src/WslContainers/Waiting/WaitContext.cs b/src/src/WslContainers/Waiting/WaitContext.cs deleted file mode 100644 index abbe0af..0000000 --- a/src/src/WslContainers/Waiting/WaitContext.cs +++ /dev/null @@ -1,29 +0,0 @@ -namespace Purview.WslContainers.Waiting; - -/// Readiness-check context: the container plus resolved runtime state (ports, IP). -public sealed class WaitContext -{ - readonly IReadOnlyDictionary _portMappings; - - internal WaitContext(IContainer container, IReadOnlyDictionary portMappings, string? networkIp) - { - Container = container; - _portMappings = portMappings; - NetworkIp = networkIp; - } - - /// The container being checked. - public IContainer Container { get; } - - /// The container bridge IP (Bridged networking only), if known. - public string? NetworkIp { get; } - - /// Returns the host port mapped to , or null when not mapped. - public int? GetHostPort(ushort containerPort) - { - return _portMappings.TryGetValue(containerPort, out var hostPort) ? hostPort : null; - } - - /// The first mapped host port, or null when no ports are mapped. - public int? FirstHostPort => _portMappings.Count > 0 ? _portMappings.Values.First() : null; -} diff --git a/src/src/WslContainers/WslContainers.csproj b/src/src/WslContainers/WslContainers.csproj deleted file mode 100644 index abeb3e0..0000000 --- a/src/src/WslContainers/WslContainers.csproj +++ /dev/null @@ -1,13 +0,0 @@ - - - true - WSLC-native throwaway Linux containers for .NET integration testing on Microsoft WSL Containers. - wsl;containers;linux;windows;testing;integration - MIT - Purview - - - - - - diff --git a/src/tests/Azurite.IntegrationTests/AzuriteIntegrationTests.cs b/src/tests/Azurite.IntegrationTests/AzuriteIntegrationTests.cs index 1d7365a..c7d3623 100644 --- a/src/tests/Azurite.IntegrationTests/AzuriteIntegrationTests.cs +++ b/src/tests/Azurite.IntegrationTests/AzuriteIntegrationTests.cs @@ -1,4 +1,4 @@ -namespace Purview.WslContainers.Azurite; +namespace Purview.Containers.Azurite; public class AzuriteIntegrationTests { diff --git a/src/tests/Azurite.UnitTests/AzuriteBuilderTests.cs b/src/tests/Azurite.UnitTests/AzuriteBuilderTests.cs index e2600e3..f1e1997 100644 --- a/src/tests/Azurite.UnitTests/AzuriteBuilderTests.cs +++ b/src/tests/Azurite.UnitTests/AzuriteBuilderTests.cs @@ -1,6 +1,6 @@ -using Purview.WslContainers.Waiting; +using Purview.Containers.Waiting; -namespace Purview.WslContainers.Azurite; +namespace Purview.Containers.Azurite; public class AzuriteBuilderTests { diff --git a/src/tests/Docker.IntegrationTests/Docker.IntegrationTests.csproj b/src/tests/Docker.IntegrationTests/Docker.IntegrationTests.csproj new file mode 100644 index 0000000..b97203f --- /dev/null +++ b/src/tests/Docker.IntegrationTests/Docker.IntegrationTests.csproj @@ -0,0 +1,24 @@ + + + + net10.0 + + + + + + + + + + + diff --git a/src/tests/Docker.IntegrationTests/DockerBackendSelectionTests.cs b/src/tests/Docker.IntegrationTests/DockerBackendSelectionTests.cs new file mode 100644 index 0000000..232c9d3 --- /dev/null +++ b/src/tests/Docker.IntegrationTests/DockerBackendSelectionTests.cs @@ -0,0 +1,62 @@ +namespace Purview.Containers.Docker; + +/// +/// Covers backend selection with a real daemon: the generated registration analogue (explicit +/// registration), a named selection, and a container produced by the generic builder through the +/// registry. The registry is process-wide static state, so this class is not run in parallel. +/// +[NotInParallel] +public class DockerBackendSelectionTests +{ + static Task SkipIfUnavailableAsync() => DockerTest.SkipIfUnavailableAsync(); + + [Test] + public async Task GenericBuilder_WithTheDockerBackendSelected_StartsAContainer() + { + await SkipIfUnavailableAsync(); + ContainerBackends.Reset(); + try + { + ContainerBackends.Register(DockerContainerBackend.Create()); + ContainerBackends.Use(ContainerBackendSelection.Named("docker")); + + // A long-lived command: with a bare `/bin/echo` the container can exit before the state is + // read, making the Running assertion below a race. + await using var container = new ContainerBuilder() + .WithImage("alpine:3.19") + .WithCommand("/bin/sh", "-c", "echo selected && sleep 60") + .Build(); + + await container.StartAsync(); + + var logs = await container.GetLogsAsync(); + + await Assert.That(container.State).IsEqualTo(ContainerState.Running); + await Assert.That(logs).Contains("selected"); + } + finally + { + ContainerBackends.Reset(); + } + } + + [Test] + public async Task ResolveAsync_WithAutomaticDetection_SelectsTheRegisteredDockerBackend() + { + await SkipIfUnavailableAsync(); + ContainerBackends.Reset(); + try + { + var backend = DockerContainerBackend.Create(); + ContainerBackends.Register(backend); + + var resolved = await ContainerBackends.ResolveAsync(); + + await Assert.That(resolved.Name).IsEqualTo("docker"); + } + finally + { + ContainerBackends.Reset(); + } + } +} diff --git a/src/tests/Docker.IntegrationTests/DockerContainerTests.cs b/src/tests/Docker.IntegrationTests/DockerContainerTests.cs new file mode 100644 index 0000000..5dda8e8 --- /dev/null +++ b/src/tests/Docker.IntegrationTests/DockerContainerTests.cs @@ -0,0 +1,105 @@ +using Purview.Containers.Waiting; + +namespace Purview.Containers.Docker; + +/// +/// End-to-end coverage of the Docker backend against a real daemon. These tests are integration tests, so +/// the shared pipeline (filtered to [Category=Unit]) never runs them; they skip themselves when no +/// Docker daemon is reachable. +/// +public class DockerContainerTests +{ + const string Image = "alpine:3.19"; + + static Task SkipIfUnavailableAsync() => DockerTest.SkipIfUnavailableAsync(); + + static Container Build(ushort? mappedPort = null, IWaitStrategy? wait = null) + { + var builder = new ContainerBuilder().WithImage(Image).WithCommand("/bin/sh", "-c", "echo ready && sleep 60"); + + if (mappedPort is ushort port) + { + builder = builder.WithPortBinding(port, assignRandomHostPort: true); + } + + if (wait is not null) + { + builder = builder.WithWaitStrategy(wait); + } + + return builder.Build(); + } + + [Test] + public async Task GetInfoAsync_ReportsTheDockerServerVersion() + { + await SkipIfUnavailableAsync(); + + var info = await DockerContainerBackend.Create().GetInfoAsync(); + + await Assert.That(info.Name).IsEqualTo("docker"); + await Assert.That(info.IsUsable).IsTrue(); + await Assert.That(info.Version).IsNotEmpty(); + } + + [Test] + public async Task Start_ExecAndLogs_RoundTrip() + { + await SkipIfUnavailableAsync(); + + await using var container = Build(); + await container.StartAsync(); + + await Assert.That(container.State).IsEqualTo(ContainerState.Running); + await Assert.That(container.Id).IsNotEmpty(); + + var executed = await container.ExecAsync(["/bin/echo", "hello-from-docker"]); + await Assert.That(executed.IsSuccess).IsTrue(); + await Assert.That(executed.Stdout).Contains("hello-from-docker"); + + var logs = await container.GetLogsAsync(); + await Assert.That(logs).Contains("ready"); + + await container.StopAsync(); + + await Assert.That(container.State).IsEqualTo(ContainerState.Exited); + } + + [Test] + public async Task Start_MapsRandomHostPorts() + { + await SkipIfUnavailableAsync(); + + await using var container = Build(mappedPort: 8080); + await container.StartAsync(); + + var hostPort = container.GetMappedPublicPort(8080); + + await Assert.That(hostPort).IsGreaterThan((ushort)0); + await Assert.That(container.GetMappedPublicPorts()[8080]).IsEqualTo(hostPort); + } + + [Test] + public async Task Start_HonoursTheSharedWaitStrategy() + { + await SkipIfUnavailableAsync(); + + await using var container = Build(wait: Wait.ForLogMessage("ready")); + await container.StartAsync(); + + await Assert.That(container.State).IsEqualTo(ContainerState.Running); + } + + [Test] + public async Task DisposeAsync_IsIdempotent() + { + await SkipIfUnavailableAsync(); + + var container = Build(); + await container.StartAsync(); + await container.DisposeAsync(); + await container.DisposeAsync(); + + await Assert.That(container.State).IsNotEqualTo(ContainerState.Running); + } +} diff --git a/src/tests/Docker.IntegrationTests/DockerTest.cs b/src/tests/Docker.IntegrationTests/DockerTest.cs new file mode 100644 index 0000000..3181527 --- /dev/null +++ b/src/tests/Docker.IntegrationTests/DockerTest.cs @@ -0,0 +1,33 @@ +using TUnit.Core.Exceptions; + +namespace Purview.Containers.Docker; + +/// +/// Shared setup for the Docker integration tests. A package consumer gets backend registration from the +/// generated module initializer in Purview.Containers.Backends.targets; this project references the +/// backend as a project, so it registers explicitly here. Idempotent. +/// +public static class DockerTest +{ + static int BackendRegistered; + + /// Registers the Docker backend with the shared registry. + public static void EnsureBackendRegistered() + { + if (Interlocked.Exchange(ref BackendRegistered, 1) == 0) + { + ContainerBackends.Register(DockerContainerBackend.Create()); + } + } + + /// Skips the current test when no Docker daemon is reachable. + public static async Task SkipIfUnavailableAsync() + { + EnsureBackendRegistered(); + var info = await DockerContainerBackend.Create().GetInfoAsync(); + if (!info.IsUsable) + { + throw new SkipTestException($"Docker is unavailable: {string.Join("; ", info.MissingComponents)}"); + } + } +} diff --git a/src/tests/Docker.UnitTests/Docker.UnitTests.csproj b/src/tests/Docker.UnitTests/Docker.UnitTests.csproj new file mode 100644 index 0000000..f33052a --- /dev/null +++ b/src/tests/Docker.UnitTests/Docker.UnitTests.csproj @@ -0,0 +1,5 @@ + + + + + diff --git a/src/tests/Docker.UnitTests/DockerContainerBackendTests.cs b/src/tests/Docker.UnitTests/DockerContainerBackendTests.cs new file mode 100644 index 0000000..68525ab --- /dev/null +++ b/src/tests/Docker.UnitTests/DockerContainerBackendTests.cs @@ -0,0 +1,42 @@ +namespace Purview.Containers.Docker; + +/// +/// Unit coverage for the Docker backend that needs no daemon: identity, registration metadata and the +/// configuration translation. Anything that talks to a daemon lives in Docker.IntegrationTests. +/// +public class DockerContainerBackendTests +{ + [Test] + public async Task Name_IsDocker() + { + await Assert.That(DockerContainerBackend.Create().Name).IsEqualTo("docker"); + } + + [Test] + public async Task Create_ReturnsANewBackend() + { + await Assert.That(DockerContainerBackend.Create()).IsNotNull(); + } + + [Test] + public async Task CreateContainer_AdaptsTheConfiguredImageAndName() + { + ContainerConfiguration configuration = new() { Image = "alpine:3.19", Name = "purview-unit-test" }; + + var container = new DockerContainerBackend().CreateContainer(configuration); + + await Assert.That(container).IsTypeOf(); + await Assert.That(container.Image).IsEqualTo("alpine:3.19"); + await Assert.That(container.Name).IsEqualTo("purview-unit-test"); + } + + [Test] + public async Task CreateContainer_GeneratesNoName_WhenTheConfigurationDoesNotSetOne() + { + ContainerConfiguration configuration = new() { Image = "alpine:3.19" }; + + var container = new DockerContainerBackend().CreateContainer(configuration); + + await Assert.That(container.Name).IsNotEmpty(); + } +} diff --git a/src/tests/Modules.DockerIntegrationTests/ModuleDockerTests.cs b/src/tests/Modules.DockerIntegrationTests/ModuleDockerTests.cs new file mode 100644 index 0000000..847ef86 --- /dev/null +++ b/src/tests/Modules.DockerIntegrationTests/ModuleDockerTests.cs @@ -0,0 +1,130 @@ +using System.Globalization; +using Purview.Containers.Docker; +using TUnit.Core.Exceptions; + +namespace Purview.Containers.Modules; + +/// +/// Every service module on the Docker backend. The WSLC suites prove the modules against WSL Containers; +/// this suite proves the same module packages produce working containers on Docker, which is what makes a +/// developer machine (WSLC) and a Linux CI runner (Docker) interchangeable. +/// +/// +/// Readiness is the module's own strategy, so a passing test means the service was really up: PostgreSQL +/// waits for pg_isready, Redis for redis-cli ping, SQL Server and MySQL for a real client +/// connection, and RabbitMQ, Azurite and NATS for their startup log lines. Each test skips itself when no +/// Docker daemon is reachable. +/// +public class ModuleDockerTests +{ + static int BackendRegistered; + + static async Task SkipIfUnavailableAsync() + { + if (Interlocked.Exchange(ref BackendRegistered, 1) == 0) + { + ContainerBackends.Register(DockerContainerBackend.Create()); + } + + var info = await DockerContainerBackend.Create().GetInfoAsync(); + if (!info.IsUsable) + { + throw new SkipTestException($"Docker is unavailable: {string.Join("; ", info.MissingComponents)}"); + } + } + + static async Task AssertStartedAsync(IContainer container, ushort port) + { + await container.StartAsync(); + + await Assert.That(container.State).IsEqualTo(ContainerState.Running); + await Assert.That(container.GetMappedPublicPort(port)).IsGreaterThan((ushort)0); + } + + [Test] + public async Task PostgreSql_StartsAndMapsItsPort() + { + await SkipIfUnavailableAsync(); + + await using var postgres = new PostgreSql.PostgreSqlBuilder() + .WithDatabase("docker_tests") + .WithUsername("postgres") + .WithPassword("postgres") + .Build(); + + await AssertStartedAsync(postgres, PostgreSql.PostgreSqlBuilder.PostgreSqlPort); + await Assert.That(postgres.GetConnectionString()).Contains("docker_tests"); + } + + [Test] + public async Task Redis_StartsAndMapsItsPort() + { + await SkipIfUnavailableAsync(); + + await using var redis = new Redis.RedisBuilder().Build(); + + await AssertStartedAsync(redis, Redis.RedisBuilder.RedisPort); + + // The connection string points at the random host port, not the container port. + var hostPort = redis.GetMappedPublicPort(Redis.RedisBuilder.RedisPort); + await Assert.That(redis.GetConnectionString()).Contains(hostPort.ToString(CultureInfo.InvariantCulture)); + } + + [Test] + public async Task MsSql_StartsAndMapsItsPort() + { + await SkipIfUnavailableAsync(); + + await using var sqlServer = new MsSql.MsSqlBuilder() + .WithPassword("SomeStrong!Password1") + .AcceptLicense() + .Build(); + + await AssertStartedAsync(sqlServer, MsSql.MsSqlBuilder.MsSqlPort); + await Assert.That(sqlServer.GetConnectionString()).Contains("Password=SomeStrong!Password1"); + } + + [Test] + public async Task MySql_StartsAndMapsItsPort() + { + await SkipIfUnavailableAsync(); + + await using var mySql = new MySql.MySqlBuilder().WithPassword("SomeStrong!Password1").Build(); + + await AssertStartedAsync(mySql, MySql.MySqlBuilder.MySqlPort); + await Assert.That(mySql.GetConnectionString()).IsNotEmpty(); + } + + [Test] + public async Task RabbitMq_StartsAndMapsItsPorts() + { + await SkipIfUnavailableAsync(); + + await using var rabbitMq = new RabbitMq.RabbitMqBuilder().WithPassword("guest").Build(); + + await AssertStartedAsync(rabbitMq, RabbitMq.RabbitMqBuilder.AmqpPort); + await Assert.That(rabbitMq.GetAmqpEndpoint().Port).IsGreaterThan(0); + } + + [Test] + public async Task Azurite_StartsAndMapsItsPort() + { + await SkipIfUnavailableAsync(); + + await using var azurite = new Azurite.AzuriteBuilder().Build(); + + await AssertStartedAsync(azurite, Azurite.AzuriteBuilder.BlobPort); + await Assert.That(azurite.GetBlobEndpoint().Port).IsGreaterThan(0); + } + + [Test] + public async Task Nats_StartsAndMapsItsPort() + { + await SkipIfUnavailableAsync(); + + await using var nats = new Nats.NatsBuilder().Build(); + + await AssertStartedAsync(nats, Nats.NatsBuilder.ClientPort); + await Assert.That(nats.GetClientEndpoint().Port).IsGreaterThan(0); + } +} diff --git a/src/tests/Modules.DockerIntegrationTests/Modules.DockerIntegrationTests.csproj b/src/tests/Modules.DockerIntegrationTests/Modules.DockerIntegrationTests.csproj new file mode 100644 index 0000000..8cbd4d0 --- /dev/null +++ b/src/tests/Modules.DockerIntegrationTests/Modules.DockerIntegrationTests.csproj @@ -0,0 +1,32 @@ + + + + net10.0 + + + + + + + + + + + + + + + + + + diff --git a/src/tests/MsSql.IntegrationTests/MsSqlIntegrationTests.cs b/src/tests/MsSql.IntegrationTests/MsSqlIntegrationTests.cs index fe21b4f..46a8159 100644 --- a/src/tests/MsSql.IntegrationTests/MsSqlIntegrationTests.cs +++ b/src/tests/MsSql.IntegrationTests/MsSqlIntegrationTests.cs @@ -1,6 +1,6 @@ using Microsoft.Data.SqlClient; -namespace Purview.WslContainers.MsSql; +namespace Purview.Containers.MsSql; public class MsSqlIntegrationTests { diff --git a/src/tests/MsSql.UnitTests/MsSqlBuilderTests.cs b/src/tests/MsSql.UnitTests/MsSqlBuilderTests.cs index 173a10e..463fb2f 100644 --- a/src/tests/MsSql.UnitTests/MsSqlBuilderTests.cs +++ b/src/tests/MsSql.UnitTests/MsSqlBuilderTests.cs @@ -1,6 +1,6 @@ -using Purview.WslContainers.Runtime; +using Purview.Containers.Runtime; -namespace Purview.WslContainers.MsSql; +namespace Purview.Containers.MsSql; public class MsSqlBuilderTests { @@ -9,7 +9,7 @@ public async Task Build_WithoutAcceptLicense_Throws() { var builder = new MsSqlBuilder().WithPassword("SomeStrong!Password1"); - await Assert.That(() => builder.Build()).Throws(); + await Assert.That(() => builder.Build()).Throws(); } [Test] @@ -17,7 +17,7 @@ public async Task Build_WithWeakPassword_Throws() { var builder = new MsSqlBuilder().WithPassword("short").AcceptLicense(); - await Assert.That(() => builder.Build()).Throws(); + await Assert.That(() => builder.Build()).Throws(); } [Test] @@ -27,6 +27,7 @@ public async Task BuildConfig_AppliesDefaults() await Assert.That(configuration.Image).IsEqualTo("mcr.microsoft.com/mssql/server:2022-latest"); await Assert.That(configuration.Password.Value).IsEqualTo("YourStrong!Passw0rd"); + await Assert.That(configuration.Database).IsEqualTo("master"); await Assert.That(configuration.AcceptLicense).IsFalse(); await Assert.That(configuration.PortBindings.Count).IsEqualTo(1); await Assert.That(configuration.PortBindings[0].ContainerPort).IsEqualTo((ushort)1433); @@ -34,6 +35,22 @@ public async Task BuildConfig_AppliesDefaults() await Assert.That(configuration.WaitStrategies.Count).IsEqualTo(1); } + [Test] + public async Task WithDatabase_OverridesTheInitialCatalog() + { + var configuration = new MsSqlBuilder().WithDatabase("app").BuildConfigurationForTesting(); + + await Assert.That(configuration.Database).IsEqualTo("app"); + } + + [Test] + public async Task WithDatabase_WithABlankName_Throws() + { + MsSqlBuilder builder = new(); + + await Assert.That(() => builder.WithDatabase(" ")).Throws(); + } + [Test] public async Task BuildConfig_HonoursModuleSetters() { diff --git a/src/tests/MySql.IntegrationTests/MySqlIntegrationTests.cs b/src/tests/MySql.IntegrationTests/MySqlIntegrationTests.cs index a88f51d..9550be4 100644 --- a/src/tests/MySql.IntegrationTests/MySqlIntegrationTests.cs +++ b/src/tests/MySql.IntegrationTests/MySqlIntegrationTests.cs @@ -1,6 +1,6 @@ using MySqlConnector; -namespace Purview.WslContainers.MySql; +namespace Purview.Containers.MySql; public class MySqlIntegrationTests { diff --git a/src/tests/MySql.UnitTests/MySqlBuilderTests.cs b/src/tests/MySql.UnitTests/MySqlBuilderTests.cs index 8f3e5f0..f629502 100644 --- a/src/tests/MySql.UnitTests/MySqlBuilderTests.cs +++ b/src/tests/MySql.UnitTests/MySqlBuilderTests.cs @@ -1,6 +1,6 @@ -using Purview.WslContainers.Runtime; +using Purview.Containers.Runtime; -namespace Purview.WslContainers.MySql; +namespace Purview.Containers.MySql; public class MySqlBuilderTests { @@ -26,6 +26,6 @@ public async Task BuildConfig_EmptyPassword_Throws() { var builder = new MySqlBuilder().WithPassword(string.Empty); - await Assert.That(() => builder.Build()).Throws(); + await Assert.That(() => builder.Build()).Throws(); } } diff --git a/src/tests/Nats.IntegrationTests/NatsIntegrationTests.cs b/src/tests/Nats.IntegrationTests/NatsIntegrationTests.cs index 3e72d1e..49a2610 100644 --- a/src/tests/Nats.IntegrationTests/NatsIntegrationTests.cs +++ b/src/tests/Nats.IntegrationTests/NatsIntegrationTests.cs @@ -1,6 +1,6 @@ using NATS.Client.Core; -namespace Purview.WslContainers.Nats; +namespace Purview.Containers.Nats; public class NatsIntegrationTests { diff --git a/src/tests/Nats.UnitTests/NatsBuilderTests.cs b/src/tests/Nats.UnitTests/NatsBuilderTests.cs index 96e4ea1..1e4019f 100644 --- a/src/tests/Nats.UnitTests/NatsBuilderTests.cs +++ b/src/tests/Nats.UnitTests/NatsBuilderTests.cs @@ -1,6 +1,6 @@ -using Purview.WslContainers.Waiting; +using Purview.Containers.Waiting; -namespace Purview.WslContainers.Nats; +namespace Purview.Containers.Nats; public class NatsBuilderTests { diff --git a/src/tests/PostgreSql.IntegrationTests/PostgreSqlIntegrationTests.cs b/src/tests/PostgreSql.IntegrationTests/PostgreSqlIntegrationTests.cs index ebefacf..81cda55 100644 --- a/src/tests/PostgreSql.IntegrationTests/PostgreSqlIntegrationTests.cs +++ b/src/tests/PostgreSql.IntegrationTests/PostgreSqlIntegrationTests.cs @@ -1,7 +1,7 @@ using Npgsql; -using PurviewContainerState = Purview.WslContainers.Containers.ContainerState; +using PurviewContainerState = Purview.Containers.ContainerState; -namespace Purview.WslContainers.PostgreSql; +namespace Purview.Containers.PostgreSql; public class PostgreSqlIntegrationTests { diff --git a/src/tests/PostgreSql.UnitTests/PostgreSqlBuilderTests.cs b/src/tests/PostgreSql.UnitTests/PostgreSqlBuilderTests.cs index 12c93dc..7f0039a 100644 --- a/src/tests/PostgreSql.UnitTests/PostgreSqlBuilderTests.cs +++ b/src/tests/PostgreSql.UnitTests/PostgreSqlBuilderTests.cs @@ -1,8 +1,8 @@ -using Purview.WslContainers.Diagnostics; -using Purview.WslContainers.Runtime; -using Purview.WslContainers.Waiting; +using Purview.Containers.Diagnostics; +using Purview.Containers.Runtime; +using Purview.Containers.Waiting; -namespace Purview.WslContainers.PostgreSql; +namespace Purview.Containers.PostgreSql; public class PostgreSqlBuilderTests { @@ -47,7 +47,7 @@ public async Task BuildConfig_EmptyPassword_Throws() { var builder = new PostgreSqlBuilder().WithPassword(string.Empty); - await Assert.That(() => builder.Build()).Throws(); + await Assert.That(() => builder.Build()).Throws(); } [Test] diff --git a/src/tests/RabbitMq.IntegrationTests/RabbitMqIntegrationTests.cs b/src/tests/RabbitMq.IntegrationTests/RabbitMqIntegrationTests.cs index 837944f..e4e3d30 100644 --- a/src/tests/RabbitMq.IntegrationTests/RabbitMqIntegrationTests.cs +++ b/src/tests/RabbitMq.IntegrationTests/RabbitMqIntegrationTests.cs @@ -1,7 +1,7 @@ using System.Text; using RabbitMQ.Client; -namespace Purview.WslContainers.RabbitMq; +namespace Purview.Containers.RabbitMq; public class RabbitMqIntegrationTests { diff --git a/src/tests/RabbitMq.UnitTests/RabbitMqBuilderTests.cs b/src/tests/RabbitMq.UnitTests/RabbitMqBuilderTests.cs index a4524d6..af100f8 100644 --- a/src/tests/RabbitMq.UnitTests/RabbitMqBuilderTests.cs +++ b/src/tests/RabbitMq.UnitTests/RabbitMqBuilderTests.cs @@ -1,7 +1,7 @@ -using Purview.WslContainers.Runtime; -using Purview.WslContainers.Waiting; +using Purview.Containers.Runtime; +using Purview.Containers.Waiting; -namespace Purview.WslContainers.RabbitMq; +namespace Purview.Containers.RabbitMq; public class RabbitMqBuilderTests { @@ -43,6 +43,6 @@ public async Task BuildConfig_EmptyPassword_Throws() { var builder = new RabbitMqBuilder().WithPassword(string.Empty); - await Assert.That(() => builder.Build()).Throws(); + await Assert.That(() => builder.Build()).Throws(); } } diff --git a/src/tests/Redis.IntegrationTests/RedisIntegrationTests.cs b/src/tests/Redis.IntegrationTests/RedisIntegrationTests.cs index 3c47a9d..f322702 100644 --- a/src/tests/Redis.IntegrationTests/RedisIntegrationTests.cs +++ b/src/tests/Redis.IntegrationTests/RedisIntegrationTests.cs @@ -1,6 +1,6 @@ using StackExchange.Redis; -namespace Purview.WslContainers.Redis; +namespace Purview.Containers.Redis; public class RedisIntegrationTests { diff --git a/src/tests/Redis.UnitTests/RedisBuilderTests.cs b/src/tests/Redis.UnitTests/RedisBuilderTests.cs index 4629e9f..09ac167 100644 --- a/src/tests/Redis.UnitTests/RedisBuilderTests.cs +++ b/src/tests/Redis.UnitTests/RedisBuilderTests.cs @@ -1,6 +1,6 @@ -using Purview.WslContainers.Waiting; +using Purview.Containers.Waiting; -namespace Purview.WslContainers.Redis; +namespace Purview.Containers.Redis; public class RedisBuilderTests { diff --git a/src/tests/SharedTestingFramework/SharedTestingFramework.csproj b/src/tests/SharedTestingFramework/SharedTestingFramework.csproj index 1107f7f..47f0c99 100644 --- a/src/tests/SharedTestingFramework/SharedTestingFramework.csproj +++ b/src/tests/SharedTestingFramework/SharedTestingFramework.csproj @@ -1,5 +1,5 @@ - + diff --git a/src/tests/SharedTestingFramework/WslcTest.cs b/src/tests/SharedTestingFramework/WslcTest.cs index c867ef0..f5c3dcb 100644 --- a/src/tests/SharedTestingFramework/WslcTest.cs +++ b/src/tests/SharedTestingFramework/WslcTest.cs @@ -1,13 +1,30 @@ +using Purview.Containers.Wsl; using TUnit.Core.Exceptions; -namespace Purview.WslContainers; +namespace Purview.Containers; /// Shared helpers for integration tests that exercise real WSL Containers. public static class WslcTest { + static int BackendRegistered; + + /// + /// Registers the WSL Containers backend. Package consumers get this from the generated module + /// initializer in Purview.Containers.Backends.targets; these test projects reference the + /// backend as a project, so they register it explicitly here. Idempotent. + /// + public static void EnsureBackendRegistered() + { + if (Interlocked.Exchange(ref BackendRegistered, 1) == 0) + { + ContainerBackends.Register(WslContainerBackend.Create()); + } + } + /// Skips the current test when WSL Containers prerequisites are missing. public static async Task SkipIfUnavailableAsync() { + EnsureBackendRegistered(); var info = await WslContainerRuntime.Instance.GetInfoAsync(); if (!info.IsAvailable || !info.IsCompatible) { diff --git a/src/tests/WslContainers.IntegrationTests/AlpineLifecycleTests.cs b/src/tests/Wsl.IntegrationTests/AlpineLifecycleTests.cs similarity index 96% rename from src/tests/WslContainers.IntegrationTests/AlpineLifecycleTests.cs rename to src/tests/Wsl.IntegrationTests/AlpineLifecycleTests.cs index 5556c5d..3ce61ab 100644 --- a/src/tests/WslContainers.IntegrationTests/AlpineLifecycleTests.cs +++ b/src/tests/Wsl.IntegrationTests/AlpineLifecycleTests.cs @@ -1,6 +1,4 @@ -using Purview.WslContainers.Containers; - -namespace Purview.WslContainers; +namespace Purview.Containers.Wsl; [Explicit] public class AlpineLifecycleTests diff --git a/src/tests/WslContainers.IntegrationTests/CleanupTests.cs b/src/tests/Wsl.IntegrationTests/CleanupTests.cs similarity index 88% rename from src/tests/WslContainers.IntegrationTests/CleanupTests.cs rename to src/tests/Wsl.IntegrationTests/CleanupTests.cs index be011ca..79a0230 100644 --- a/src/tests/WslContainers.IntegrationTests/CleanupTests.cs +++ b/src/tests/Wsl.IntegrationTests/CleanupTests.cs @@ -1,7 +1,7 @@ -using Purview.WslContainers.Runtime; -using Purview.WslContainers.Waiting; +using Purview.Containers.Runtime; +using Purview.Containers.Waiting; -namespace Purview.WslContainers; +namespace Purview.Containers.Wsl; [Explicit] public class CleanupTests @@ -17,7 +17,7 @@ public async Task Dispose_AfterWaitTimeout_IsIdempotent() .WithWaitStrategy(Wait.ForLogMessage("this never appears").WithTimeout(TimeSpan.FromSeconds(3))) .Build(); - await Assert.That(() => container.StartAsync()).Throws(); + await Assert.That(() => container.StartAsync()).Throws(); await container.DisposeAsync(); await container.DisposeAsync(); } diff --git a/src/tests/WslContainers.IntegrationTests/ConcurrencyStressTests.cs b/src/tests/Wsl.IntegrationTests/ConcurrencyStressTests.cs similarity index 89% rename from src/tests/WslContainers.IntegrationTests/ConcurrencyStressTests.cs rename to src/tests/Wsl.IntegrationTests/ConcurrencyStressTests.cs index caf78f4..153e292 100644 --- a/src/tests/WslContainers.IntegrationTests/ConcurrencyStressTests.cs +++ b/src/tests/Wsl.IntegrationTests/ConcurrencyStressTests.cs @@ -1,6 +1,6 @@ -using Purview.WslContainers.Waiting; +using Purview.Containers.Waiting; -namespace Purview.WslContainers; +namespace Purview.Containers.Wsl; [Explicit] public class ConcurrencyStressTests @@ -11,7 +11,7 @@ public async Task ConcurrentContainers_GetDistinctUsablePorts() await WslcTest.SkipIfUnavailableAsync(); const int count = 5; - List containers = [with(count)]; + List containers = [with(count)]; try { for (var i = 0; i < count; i++) diff --git a/src/tests/WslContainers.IntegrationTests/ExecTests.cs b/src/tests/Wsl.IntegrationTests/ExecTests.cs similarity index 96% rename from src/tests/WslContainers.IntegrationTests/ExecTests.cs rename to src/tests/Wsl.IntegrationTests/ExecTests.cs index 405ee13..7e241a8 100644 --- a/src/tests/WslContainers.IntegrationTests/ExecTests.cs +++ b/src/tests/Wsl.IntegrationTests/ExecTests.cs @@ -1,6 +1,4 @@ -using Purview.WslContainers.Containers; - -namespace Purview.WslContainers; +namespace Purview.Containers.Wsl; [Explicit] public class ExecTests diff --git a/src/tests/WslContainers.IntegrationTests/PerformanceTests.cs b/src/tests/Wsl.IntegrationTests/PerformanceTests.cs similarity index 96% rename from src/tests/WslContainers.IntegrationTests/PerformanceTests.cs rename to src/tests/Wsl.IntegrationTests/PerformanceTests.cs index c38891f..74e40ee 100644 --- a/src/tests/WslContainers.IntegrationTests/PerformanceTests.cs +++ b/src/tests/Wsl.IntegrationTests/PerformanceTests.cs @@ -1,6 +1,6 @@ using System.Diagnostics; -namespace Purview.WslContainers; +namespace Purview.Containers.Wsl; [Explicit] public class PerformanceTests diff --git a/src/tests/WslContainers.IntegrationTests/PortMappingTests.cs b/src/tests/Wsl.IntegrationTests/PortMappingTests.cs similarity index 96% rename from src/tests/WslContainers.IntegrationTests/PortMappingTests.cs rename to src/tests/Wsl.IntegrationTests/PortMappingTests.cs index 6ec39f1..23fb4db 100644 --- a/src/tests/WslContainers.IntegrationTests/PortMappingTests.cs +++ b/src/tests/Wsl.IntegrationTests/PortMappingTests.cs @@ -1,8 +1,7 @@ using System.Net; using System.Net.Sockets; -using Purview.WslContainers.Containers; -namespace Purview.WslContainers; +namespace Purview.Containers.Wsl; [Explicit] public class PortMappingTests diff --git a/src/tests/WslContainers.IntegrationTests/RuntimeInfoTests.cs b/src/tests/Wsl.IntegrationTests/RuntimeInfoTests.cs similarity index 91% rename from src/tests/WslContainers.IntegrationTests/RuntimeInfoTests.cs rename to src/tests/Wsl.IntegrationTests/RuntimeInfoTests.cs index 83f625c..b71a4dc 100644 --- a/src/tests/WslContainers.IntegrationTests/RuntimeInfoTests.cs +++ b/src/tests/Wsl.IntegrationTests/RuntimeInfoTests.cs @@ -1,4 +1,4 @@ -namespace Purview.WslContainers; +namespace Purview.Containers.Wsl; [Explicit] public class RuntimeInfoTests diff --git a/src/tests/WslContainers.IntegrationTests/SharedStorageTests.cs b/src/tests/Wsl.IntegrationTests/SharedStorageTests.cs similarity index 91% rename from src/tests/WslContainers.IntegrationTests/SharedStorageTests.cs rename to src/tests/Wsl.IntegrationTests/SharedStorageTests.cs index fb5b134..61631a6 100644 --- a/src/tests/WslContainers.IntegrationTests/SharedStorageTests.cs +++ b/src/tests/Wsl.IntegrationTests/SharedStorageTests.cs @@ -1,6 +1,6 @@ -using Purview.WslContainers.Images; +using Purview.Containers.Images; -namespace Purview.WslContainers; +namespace Purview.Containers.Wsl; [Explicit] public class SharedStorageTests @@ -21,7 +21,7 @@ public async Task SharedStore_IsReusedAcrossSequentialRuntimes() ) ) { - await using var container = new ContainerBuilder(runtime1) + await using var container = new ContainerBuilder(new WslContainerBackend(runtime1)) .WithImage("alpine:latest") .WithCommand("/bin/echo", "one") .Build(); diff --git a/src/tests/WslContainers.IntegrationTests/VolumeTests.cs b/src/tests/Wsl.IntegrationTests/VolumeTests.cs similarity index 98% rename from src/tests/WslContainers.IntegrationTests/VolumeTests.cs rename to src/tests/Wsl.IntegrationTests/VolumeTests.cs index d17d656..c392bd1 100644 --- a/src/tests/WslContainers.IntegrationTests/VolumeTests.cs +++ b/src/tests/Wsl.IntegrationTests/VolumeTests.cs @@ -1,4 +1,4 @@ -namespace Purview.WslContainers; +namespace Purview.Containers.Wsl; [Explicit] public class VolumeTests diff --git a/src/tests/WslContainers.IntegrationTests/WaitStrategyTests.cs b/src/tests/Wsl.IntegrationTests/WaitStrategyTests.cs similarity index 92% rename from src/tests/WslContainers.IntegrationTests/WaitStrategyTests.cs rename to src/tests/Wsl.IntegrationTests/WaitStrategyTests.cs index c57787a..1f042fc 100644 --- a/src/tests/WslContainers.IntegrationTests/WaitStrategyTests.cs +++ b/src/tests/Wsl.IntegrationTests/WaitStrategyTests.cs @@ -1,9 +1,8 @@ using System.Net; -using Purview.WslContainers.Containers; -using Purview.WslContainers.Runtime; -using Purview.WslContainers.Waiting; +using Purview.Containers.Runtime; +using Purview.Containers.Waiting; -namespace Purview.WslContainers; +namespace Purview.Containers.Wsl; [Explicit] public class WaitStrategyTests @@ -105,12 +104,12 @@ public async Task Timeout_ThrowsWithDiagnosticsAndIsDisposable() ) .Build(); - WslContainerTimeoutException? thrown = null; + ContainerTimeoutException? thrown = null; try { await container.StartAsync(); } - catch (WslContainerTimeoutException ex) + catch (ContainerTimeoutException ex) { thrown = ex; } diff --git a/src/tests/WslContainers.IntegrationTests/WslContainers.IntegrationTests.csproj b/src/tests/Wsl.IntegrationTests/Wsl.IntegrationTests.csproj similarity index 100% rename from src/tests/WslContainers.IntegrationTests/WslContainers.IntegrationTests.csproj rename to src/tests/Wsl.IntegrationTests/Wsl.IntegrationTests.csproj diff --git a/src/tests/WslContainers.UnitTests/ConsumerRequirementsTests.cs b/src/tests/Wsl.UnitTests/ConsumerRequirementsTests.cs similarity index 66% rename from src/tests/WslContainers.UnitTests/ConsumerRequirementsTests.cs rename to src/tests/Wsl.UnitTests/ConsumerRequirementsTests.cs index eb7723a..a7ef770 100644 --- a/src/tests/WslContainers.UnitTests/ConsumerRequirementsTests.cs +++ b/src/tests/Wsl.UnitTests/ConsumerRequirementsTests.cs @@ -1,13 +1,15 @@ using System.Reflection; using System.Runtime.Versioning; -namespace Purview.WslContainers; +namespace Purview.Containers.Wsl; /// -/// Guards the consumer contract documented in docs/wiki/Consumer-Requirements.md: every -/// package is a .NET 11 project that targets Windows specifically. A consumer cannot restore the -/// package from anything else, so a drift here has to fail the build rather than quietly -/// invalidate the documentation and the shipped buildTransitive defaults. +/// Guards the consumer contract documented in docs/wiki/Consumer-Requirements.md. The WSL +/// Containers backend is a multi-target package: a Windows build (the implementation, targeting +/// Windows specifically) and a platform-neutral build (the facade). A Windows-targeting consumer +/// binds the Windows build, which is what this project references, so a drift here has to fail the +/// build rather than quietly invalidate the documentation and the shipped buildTransitive +/// defaults. /// public class ConsumerRequirementsTests { @@ -15,12 +17,12 @@ public class ConsumerRequirementsTests static readonly string[] WindowsPlatform = ["Windows10.0.19041.0"]; [Test] - public async Task LibraryTargetsNet11() + public async Task LibraryTargetsNet10() { var targetFramework = Library.GetCustomAttribute(); await Assert.That(targetFramework).IsNotNull(); - await Assert.That(targetFramework!.FrameworkName).IsEqualTo(".NETCoreApp,Version=v11.0"); + await Assert.That(targetFramework!.FrameworkName).IsEqualTo(".NETCoreApp,Version=v10.0"); } [Test] diff --git a/src/tests/Wsl.UnitTests/ContainerBackendsTests.cs b/src/tests/Wsl.UnitTests/ContainerBackendsTests.cs new file mode 100644 index 0000000..091979b --- /dev/null +++ b/src/tests/Wsl.UnitTests/ContainerBackendsTests.cs @@ -0,0 +1,317 @@ +using Purview.Containers.Runtime; + +namespace Purview.Containers.Wsl; + +/// +/// Covers the backend registry, the selection policy and the resolution rules the builders rely on. The +/// registry and the selection are process-wide static state, so the class opts out of parallel execution +/// and every test starts from a clean registry. +/// +[NotInParallel] +public class ContainerBackendsTests +{ + [Test] + public async Task WslBackend_ReportsItsName() + { + await Assert.That(WslContainerBackend.Create().Name).IsEqualTo("wsl"); + } + + [Test] + public async Task WslBackend_CreatesAWslContainerForADerivedConfiguration() + { + var container = new WslContainerBackend().CreateContainer( + new DerivedConfiguration { Image = "alpine:3.19", Extra = "x" } + ); + + await Assert.That(container).IsTypeOf(); + await Assert.That(container.Image).IsEqualTo("alpine:3.19"); + } + + [Test] + public async Task GenericBuilder_Build_DefersTheBackendResolution() + { + using RegistryScope scope = new(); + + // No backend is registered, yet Build() succeeds: the backend is resolved when the container starts. + ContainerBuilder builder = new(); + var container = builder.WithImage("alpine").Build(); + + await Assert.That(container).IsTypeOf(); + await Assert.That(container.Name).StartsWith("alpine-"); + await Assert.That(container.State).IsEqualTo(ContainerState.Created); + } + + [Test] + public async Task ResolveAsync_WithoutARegisteredBackend_ThrowsAnActionableError() + { + using RegistryScope scope = new(); + + var exception = await ThrowsAsync(() => ContainerBackends.ResolveAsync()); + + await Assert.That(exception.Message).Contains("Purview.Containers.Wsl"); + await Assert.That(exception.Message).Contains("Purview.Containers.Docker"); + } + + [Test] + public async Task ResolveAsync_ReturnsTheFirstUsableBackend() + { + using RegistryScope scope = new(); + ContainerBackends.Register(new FakeBackend("one")); + ContainerBackends.Register(new FakeBackend("two")); + + var resolved = await ContainerBackends.ResolveAsync(); + + await Assert.That(resolved.Name).IsEqualTo("one"); + } + + [Test] + public async Task ResolveAsync_PrefersTheLowestAutoPriority() + { + using RegistryScope scope = new(); + ContainerBackends.Register(new FakeBackend("deprioritized", autoPriority: 100)); + ContainerBackends.Register(new FakeBackend("preferred", autoPriority: 0)); + + var resolved = await ContainerBackends.ResolveAsync(); + + await Assert.That(resolved.Name).IsEqualTo("preferred"); + } + + [Test] + public async Task ResolveAsync_TreatsABackendWithoutAPreferenceAsNeutral() + { + using RegistryScope scope = new(); + ContainerBackends.Register(new FakeBackend("deprioritized", autoPriority: 100)); + ContainerBackends.Register(new PlainBackend("neutral")); + + var resolved = await ContainerBackends.ResolveAsync(); + + await Assert.That(resolved.Name).IsEqualTo("neutral"); + } + + [Test] + public async Task ResolveAsync_NamedSelectionIgnoresAutoPriority() + { + using RegistryScope scope = new(); + ContainerBackends.Register(new FakeBackend("preferred", autoPriority: 0)); + ContainerBackends.Register(new FakeBackend("deprioritized", autoPriority: 100)); + + ContainerBackends.Use(ContainerBackendSelection.Named("deprioritized")); + var resolved = await ContainerBackends.ResolveAsync(); + + await Assert.That(resolved.Name).IsEqualTo("deprioritized"); + } + + [Test] + public async Task ResolveAsync_SkipsAnUnusableBackend() + { + using RegistryScope scope = new(); + ContainerBackends.Register(new FakeBackend("one", isAvailable: false)); + ContainerBackends.Register(new FakeBackend("two")); + + var resolved = await ContainerBackends.ResolveAsync(); + + await Assert.That(resolved.Name).IsEqualTo("two"); + } + + [Test] + public async Task ResolveAsync_WithoutAUsableBackend_ReportsEveryBackend() + { + using RegistryScope scope = new(); + ContainerBackends.Register(new FakeBackend("one", isAvailable: false)); + ContainerBackends.Register(new ThrowingBackend("two")); + + var exception = await ThrowsAsync(() => ContainerBackends.ResolveAsync()); + + await Assert.That(exception.Message).Contains("one: unavailable"); + await Assert.That(exception.Message).Contains("two: unavailable"); + await Assert.That(exception.Message).Contains(ContainerBackends.SelectionEnvironmentVariable); + } + + [Test] + public async Task ResolveAsync_CachesTheSuccessfulResolution() + { + using RegistryScope scope = new(); + FakeBackend backend = new("one"); + ContainerBackends.Register(backend); + + await ContainerBackends.ResolveAsync(); + await ContainerBackends.ResolveAsync(); + + await Assert.That(backend.Probes).IsEqualTo(1); + } + + [Test] + public async Task ResolveAsync_WithANamedBackend_PrefersItOverTheRegisteredOrder() + { + using RegistryScope scope = new(); + ContainerBackends.Register(new FakeBackend("one")); + ContainerBackends.Register(new FakeBackend("two")); + ContainerBackends.Use(ContainerBackendSelection.Named("two")); + + var resolved = await ContainerBackends.ResolveAsync(); + + await Assert.That(resolved.Name).IsEqualTo("two"); + } + + [Test] + public async Task ResolveAsync_WithTheEnvironmentVariable_SelectsThatBackend() + { + using RegistryScope scope = new(); + ContainerBackends.Register(new FakeBackend("one")); + ContainerBackends.Register(new FakeBackend("two")); + using EnvironmentScope environment = new(ContainerBackends.SelectionEnvironmentVariable, "two"); + + var resolved = await ContainerBackends.ResolveAsync(); + + await Assert.That(resolved.Name).IsEqualTo("two"); + } + + [Test] + public async Task ResolveAsync_WithANamedBackendThatIsNotRegistered_ListsTheRegisteredOnes() + { + using RegistryScope scope = new(); + ContainerBackends.Register(new FakeBackend("one")); + ContainerBackends.Use(ContainerBackendSelection.Named("docker")); + + var exception = await ThrowsAsync(() => ContainerBackends.ResolveAsync()); + + await Assert.That(exception.Message).Contains("'docker' is not registered"); + await Assert.That(exception.Message).Contains("one"); + } + + [Test] + public async Task ResolveAsync_WithANamedUnusableBackend_DoesNotFallBack() + { + using RegistryScope scope = new(); + ContainerBackends.Register(new FakeBackend("one")); + ContainerBackends.Register(new FakeBackend("two", isAvailable: false)); + ContainerBackends.Use(ContainerBackendSelection.Named("two")); + + var exception = await ThrowsAsync(() => ContainerBackends.ResolveAsync()); + + await Assert.That(exception.Message).Contains("'two' is not usable"); + } + + [Test] + public async Task Use_PinsABackendInstanceOverEverythingElse() + { + using RegistryScope scope = new(); + ContainerBackends.Register(new FakeBackend("registered")); + ContainerBackends.Use(new FakeBackend("pinned")); + + var resolved = await ContainerBackends.ResolveAsync(); + + await Assert.That(resolved.Name).IsEqualTo("pinned"); + } + + [Test] + public async Task Use_WithAPinnedUnusableBackend_ReportsIt() + { + using RegistryScope scope = new(); + ContainerBackends.Register(new FakeBackend("registered")); + ContainerBackends.Use(new FakeBackend("pinned", isCompatible: false)); + + var exception = await ThrowsAsync(() => ContainerBackends.ResolveAsync()); + + await Assert.That(exception.Message).Contains("The pinned container backend 'pinned' is not usable"); + } + + [Test] + public async Task ProbeAllAsync_ReportsEveryRegisteredBackend() + { + using RegistryScope scope = new(); + ContainerBackends.Register(new FakeBackend("one")); + ContainerBackends.Register(new FakeBackend("two", isAvailable: false)); + + var probes = await ContainerBackends.ProbeAllAsync(); + + await Assert.That(probes.Count).IsEqualTo(2); + await Assert.That(probes[0].IsUsable).IsTrue(); + await Assert.That(probes[1].IsUsable).IsFalse(); + } + + static async Task ThrowsAsync(Func action) + where TException : Exception + { + var exception = await Assert.ThrowsAsync(action); + ArgumentNullException.ThrowIfNull(exception); + return exception; + } + + sealed record DerivedConfiguration : ContainerConfiguration + { + public string Extra { get; init; } = string.Empty; + } + + sealed class FakeBackend(string name, bool isAvailable = true, bool isCompatible = true, int autoPriority = 0) + : IContainerBackend, + IContainerBackendPreference + { + public string Name { get; } = name; + + public int AutoPriority { get; } = autoPriority; + + public int Probes { get; private set; } + + public IContainer CreateContainer(IContainerConfiguration configuration) => throw new NotSupportedException(); + + public Task GetInfoAsync(CancellationToken cancellationToken = default) + { + Probes++; + return Task.FromResult( + new ContainerBackendInfo( + Name, + isAvailable, + isCompatible, + "1.0", + isAvailable && isCompatible ? [] : ["missing"] + ) + ); + } + } + + /// A backend that does not implement . + sealed class PlainBackend(string name) : IContainerBackend + { + public string Name { get; } = name; + + public IContainer CreateContainer(IContainerConfiguration configuration) => throw new NotSupportedException(); + + public Task GetInfoAsync(CancellationToken cancellationToken = default) => + Task.FromResult(new ContainerBackendInfo(Name, IsAvailable: true, IsCompatible: true, "1.0", [])); + } + + sealed class ThrowingBackend(string name) : IContainerBackend + { + public string Name { get; } = name; + + public IContainer CreateContainer(IContainerConfiguration configuration) => throw new NotSupportedException(); + + public Task GetInfoAsync(CancellationToken cancellationToken = default) => + throw new InvalidOperationException("probe failed"); + } + + /// Clears the backend registry for the duration of a test and resets it on dispose. + sealed class RegistryScope : IDisposable + { + public RegistryScope() => ContainerBackends.Reset(); + + public void Dispose() => ContainerBackends.Reset(); + } + + /// Sets an environment variable for the duration of a test and restores it on dispose. + sealed class EnvironmentScope : IDisposable + { + readonly string _name; + readonly string? _previous; + + public EnvironmentScope(string name, string? value) + { + _name = name; + _previous = Environment.GetEnvironmentVariable(name); + Environment.SetEnvironmentVariable(name, value); + } + + public void Dispose() => Environment.SetEnvironmentVariable(_name, _previous); + } +} diff --git a/src/tests/WslContainers.UnitTests/ContainerBuilderTests.cs b/src/tests/Wsl.UnitTests/ContainerBuilderTests.cs similarity index 72% rename from src/tests/WslContainers.UnitTests/ContainerBuilderTests.cs rename to src/tests/Wsl.UnitTests/ContainerBuilderTests.cs index 2ef605e2..1ea9846 100644 --- a/src/tests/WslContainers.UnitTests/ContainerBuilderTests.cs +++ b/src/tests/Wsl.UnitTests/ContainerBuilderTests.cs @@ -1,8 +1,8 @@ -using Purview.WslContainers.Images; -using Purview.WslContainers.Networking; -using Purview.WslContainers.Runtime; +using Purview.Containers.Images; +using Purview.Containers.Networking; +using Purview.Containers.Runtime; -namespace Purview.WslContainers; +namespace Purview.Containers.Wsl; public class ContainerBuilderTests { @@ -56,49 +56,57 @@ public async Task BuildConfiguration_PopulatesAllCommonFields() [Test] public async Task Build_WithoutImage_Throws() { - await Assert.That(() => new TestBuilder().Build()).Throws(); + await Assert.That(() => new TestBuilder().Build()).Throws(); } [Test] - public async Task Build_WithUdpPort_ThrowsNotSupported() + public async Task CreateContainer_WithUdpPort_ThrowsNotSupported() { - var builder = new TestBuilder().WithImage("alpine").WithPortBinding(53, 53, PortProtocol.Udp); - await Assert.That(() => builder.Build()).Throws(); + // UDP is a backend capability, so the neutral builder accepts the configuration and the WSL + // Containers backend rejects it (the Docker backend supports UDP). + var configuration = new TestBuilder() + .WithImage("alpine") + .WithPortBinding(53, 53, PortProtocol.Udp) + .BuildConfig(); + + await Assert + .That(() => new WslContainerBackend().CreateContainer(configuration)) + .Throws(); } [Test] public async Task Build_WithContainerPortZero_Throws() { var builder = new TestBuilder().WithImage("alpine").WithPortBinding(0, assignRandomHostPort: true); - await Assert.That(() => builder.Build()).Throws(); + await Assert.That(() => builder.Build()).Throws(); } [Test] public async Task Build_WithHostPortZero_Throws() { var builder = new TestBuilder().WithImage("alpine").WithPortBinding(80, 0); - await Assert.That(() => builder.Build()).Throws(); + await Assert.That(() => builder.Build()).Throws(); } [Test] public async Task Build_WithEmptyBindMount_Throws() { var builder = new TestBuilder().WithImage("alpine").WithBindMount(string.Empty, "/data"); - await Assert.That(() => builder.Build()).Throws(); + await Assert.That(() => builder.Build()).Throws(); } [Test] public async Task Build_WithZeroStartupTimeout_Throws() { var builder = new TestBuilder().WithImage("alpine").WithStartupTimeout(TimeSpan.Zero); - await Assert.That(() => builder.Build()).Throws(); + await Assert.That(() => builder.Build()).Throws(); } [Test] public async Task GenericContainerBuilder_GeneratesUniqueName() { - var first = new ContainerBuilder().WithImage("alpine").Build(); - var second = new ContainerBuilder().WithImage("alpine").Build(); + var first = new ContainerBuilder(new WslContainerBackend()).WithImage("alpine").Build(); + var second = new ContainerBuilder(new WslContainerBackend()).WithImage("alpine").Build(); await Assert.That(first.Name).IsNotEqualTo(second.Name); await Assert.That(first.Image).IsEqualTo("docker.io/library/alpine:latest"); @@ -109,10 +117,10 @@ public async Task WithImage_InvalidReference_ThrowsImmediately() { await Assert .That(() => new ContainerBuilder().WithImage("bad image name")) - .Throws(); + .Throws(); await Assert .That(() => new ContainerBuilder().WithImage(string.Empty)) - .Throws(); + .Throws(); } [Test] @@ -126,7 +134,7 @@ public async Task WithTag_AppliesTagToConfiguredImage() [Test] public async Task WithTag_WithoutImage_Throws() { - await Assert.That(() => new ContainerBuilder().WithTag("7.4")).Throws(); + await Assert.That(() => new ContainerBuilder().WithTag("7.4")).Throws(); } [Test] @@ -134,6 +142,6 @@ public async Task WithTag_InvalidTag_Throws() { await Assert .That(() => new ContainerBuilder().WithImage("redis").WithTag("bad tag")) - .Throws(); + .Throws(); } } diff --git a/src/tests/WslContainers.UnitTests/FakeContainer.cs b/src/tests/Wsl.UnitTests/FakeContainer.cs similarity index 95% rename from src/tests/WslContainers.UnitTests/FakeContainer.cs rename to src/tests/Wsl.UnitTests/FakeContainer.cs index 2651bb7..564731a 100644 --- a/src/tests/WslContainers.UnitTests/FakeContainer.cs +++ b/src/tests/Wsl.UnitTests/FakeContainer.cs @@ -1,6 +1,4 @@ -using Purview.WslContainers.Containers; - -namespace Purview.WslContainers; +namespace Purview.Containers.Wsl; public sealed class FakeContainer : IContainer { diff --git a/src/tests/WslContainers.UnitTests/HttpWaitStrategyTests.cs b/src/tests/Wsl.UnitTests/HttpWaitStrategyTests.cs similarity index 98% rename from src/tests/WslContainers.UnitTests/HttpWaitStrategyTests.cs rename to src/tests/Wsl.UnitTests/HttpWaitStrategyTests.cs index 2cd6749..f8aaaa8 100644 --- a/src/tests/WslContainers.UnitTests/HttpWaitStrategyTests.cs +++ b/src/tests/Wsl.UnitTests/HttpWaitStrategyTests.cs @@ -1,9 +1,9 @@ using System.Net; using System.Net.Sockets; using System.Text; -using Purview.WslContainers.Waiting; +using Purview.Containers.Waiting; -namespace Purview.WslContainers; +namespace Purview.Containers.Wsl; public class HttpWaitStrategyTests { diff --git a/src/tests/WslContainers.UnitTests/ImageTests.cs b/src/tests/Wsl.UnitTests/ImageTests.cs similarity index 97% rename from src/tests/WslContainers.UnitTests/ImageTests.cs rename to src/tests/Wsl.UnitTests/ImageTests.cs index ae97d1a..36eaf5f 100644 --- a/src/tests/WslContainers.UnitTests/ImageTests.cs +++ b/src/tests/Wsl.UnitTests/ImageTests.cs @@ -1,6 +1,6 @@ -using Purview.WslContainers.Images; +using Purview.Containers.Images; -namespace Purview.WslContainers; +namespace Purview.Containers.Wsl; public class ImageTests { diff --git a/src/tests/WslContainers.UnitTests/SecretTests.cs b/src/tests/Wsl.UnitTests/SecretTests.cs similarity index 88% rename from src/tests/WslContainers.UnitTests/SecretTests.cs rename to src/tests/Wsl.UnitTests/SecretTests.cs index 6d15f67..d92f162 100644 --- a/src/tests/WslContainers.UnitTests/SecretTests.cs +++ b/src/tests/Wsl.UnitTests/SecretTests.cs @@ -1,6 +1,6 @@ -using Purview.WslContainers.Diagnostics; +using Purview.Containers.Diagnostics; -namespace Purview.WslContainers; +namespace Purview.Containers.Wsl; public class SecretTests { diff --git a/src/tests/WslContainers.UnitTests/StorageModeTests.cs b/src/tests/Wsl.UnitTests/StorageModeTests.cs similarity index 95% rename from src/tests/WslContainers.UnitTests/StorageModeTests.cs rename to src/tests/Wsl.UnitTests/StorageModeTests.cs index f980eb2..14f7330 100644 --- a/src/tests/WslContainers.UnitTests/StorageModeTests.cs +++ b/src/tests/Wsl.UnitTests/StorageModeTests.cs @@ -1,4 +1,4 @@ -namespace Purview.WslContainers; +namespace Purview.Containers.Wsl; public class StorageModeTests { diff --git a/src/tests/WslContainers.UnitTests/WaitContextTests.cs b/src/tests/Wsl.UnitTests/WaitContextTests.cs similarity index 94% rename from src/tests/WslContainers.UnitTests/WaitContextTests.cs rename to src/tests/Wsl.UnitTests/WaitContextTests.cs index 9bf29f3..ac8aacc 100644 --- a/src/tests/WslContainers.UnitTests/WaitContextTests.cs +++ b/src/tests/Wsl.UnitTests/WaitContextTests.cs @@ -1,6 +1,6 @@ -using Purview.WslContainers.Waiting; +using Purview.Containers.Waiting; -namespace Purview.WslContainers; +namespace Purview.Containers.Wsl; public class WaitContextTests { diff --git a/src/tests/WslContainers.UnitTests/WaitStrategyRunnerTests.cs b/src/tests/Wsl.UnitTests/WaitStrategyRunnerTests.cs similarity index 91% rename from src/tests/WslContainers.UnitTests/WaitStrategyRunnerTests.cs rename to src/tests/Wsl.UnitTests/WaitStrategyRunnerTests.cs index ad95ae0..32a1601 100644 --- a/src/tests/WslContainers.UnitTests/WaitStrategyRunnerTests.cs +++ b/src/tests/Wsl.UnitTests/WaitStrategyRunnerTests.cs @@ -1,7 +1,7 @@ -using Purview.WslContainers.Runtime; -using Purview.WslContainers.Waiting; +using Purview.Containers.Runtime; +using Purview.Containers.Waiting; -namespace Purview.WslContainers; +namespace Purview.Containers.Wsl; public class WaitStrategyRunnerTests { @@ -38,7 +38,7 @@ public async Task RunAsync_TimesOut_ThrowsWithDiagnostics(CancellationToken canc { await WaitStrategyRunner.RunAsync(context, new[] { strategy }, TimeSpan.FromSeconds(5), cancellationToken); } - catch (WslContainerTimeoutException ex) + catch (ContainerTimeoutException ex) { thrown = ex; } diff --git a/src/tests/WslContainers.UnitTests/WaitStrategyTests.cs b/src/tests/Wsl.UnitTests/WaitStrategyTests.cs similarity index 97% rename from src/tests/WslContainers.UnitTests/WaitStrategyTests.cs rename to src/tests/Wsl.UnitTests/WaitStrategyTests.cs index e6b2498..abe576f 100644 --- a/src/tests/WslContainers.UnitTests/WaitStrategyTests.cs +++ b/src/tests/Wsl.UnitTests/WaitStrategyTests.cs @@ -1,8 +1,7 @@ using System.Text.RegularExpressions; -using Purview.WslContainers.Containers; -using Purview.WslContainers.Waiting; +using Purview.Containers.Waiting; -namespace Purview.WslContainers; +namespace Purview.Containers.Wsl; public class WaitStrategyTests { diff --git a/src/tests/WslContainers.UnitTests/WslContainers.UnitTests.csproj b/src/tests/Wsl.UnitTests/Wsl.UnitTests.csproj similarity index 100% rename from src/tests/WslContainers.UnitTests/WslContainers.UnitTests.csproj rename to src/tests/Wsl.UnitTests/Wsl.UnitTests.csproj diff --git a/src/tests/WslContainers.UnitTests/WslContainerRuntimeTests.cs b/src/tests/Wsl.UnitTests/WslContainerRuntimeTests.cs similarity index 97% rename from src/tests/WslContainers.UnitTests/WslContainerRuntimeTests.cs rename to src/tests/Wsl.UnitTests/WslContainerRuntimeTests.cs index f716143..974f99b 100644 --- a/src/tests/WslContainers.UnitTests/WslContainerRuntimeTests.cs +++ b/src/tests/Wsl.UnitTests/WslContainerRuntimeTests.cs @@ -1,6 +1,6 @@ using System.Runtime.InteropServices; -namespace Purview.WslContainers; +namespace Purview.Containers.Wsl; public class WslContainerRuntimeTests { diff --git a/src/tests/WslContainers.UnitTests/WslInspectParserTests.cs b/src/tests/Wsl.UnitTests/WslInspectParserTests.cs similarity index 97% rename from src/tests/WslContainers.UnitTests/WslInspectParserTests.cs rename to src/tests/Wsl.UnitTests/WslInspectParserTests.cs index 87d12c0..e845d91 100644 --- a/src/tests/WslContainers.UnitTests/WslInspectParserTests.cs +++ b/src/tests/Wsl.UnitTests/WslInspectParserTests.cs @@ -1,4 +1,4 @@ -namespace Purview.WslContainers; +namespace Purview.Containers.Wsl; public class WslInspectParserTests {