diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json
index e57a0aa..f99a073 100644
--- a/.config/dotnet-tools.json
+++ b/.config/dotnet-tools.json
@@ -17,7 +17,7 @@
"rollForward": false
},
"purview.build": {
- "version": "0.5.4",
+ "version": "0.7.1",
"commands": [
"purview-build"
],
diff --git a/.config/purview-build.json b/.config/purview-build.json
index 3207fab..7becf17 100644
--- a/.config/purview-build.json
+++ b/.config/purview-build.json
@@ -2,9 +2,15 @@
"Build": {
"Solution": "src/Containers.slnx",
"TestRoot": "src/tests",
+ // Discovery stays unit-only so the shared Ubuntu pipeline never launches a Windows-targeted WSLC
+ // test app. `just test` is the all-categories path; the TestFilter stays /*/*/*/* so widening
+ // discovery runs every category.
"TestPatterns": "*UnitTests.csproj",
"TestProjects": "*Tests.csproj",
- "TestFilter": "/*/*/*/*[Category=Unit]"
+ // Test projects run one at a time whenever discovery is widened: a WSLC session exclusively
+ // locks its image-store VHD, and a parallel second process falls back to a cold isolated store.
+ "MaxTestParallelism": 1,
+ "TestFilter": "/*/*/*/*"
},
"PackValidation": {
// Explicit because Purview.Build 0.5.3 defaults RequiredCompilerFlags to empty. Earlier
diff --git a/.editorconfig b/.editorconfig
index 120d509..b511789 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -1,3 +1,23 @@
+# Purview.BuildSdk 1.1.0 - managed file, do not edit
+#
+# This file is written by Purview.BuildSdk and is replaced whenever the SDK version changes. The version
+# on the first line is how the SDK recognises a file it owns: a .editorconfig without that marker is
+# yours, and is never touched.
+#
+# To add your own rules, create a second .editorconfig in a subdirectory - `src/.editorconfig` is the
+# usual choice - without `root = true`. EditorConfig applies the nearest file last, so your settings win
+# over these, and nothing you write is lost when this file is updated.
+#
+# To stop the SDK updating this file, set one of:
+# false keep this file as it is
+# Never opt out of all bootstrapping
+# false
+#
+# Deleting the first line also works: without the marker the SDK treats the file as yours. You then own
+# every rule in it, including the ones a future SDK version would have fixed.
+#
+# See https://github.com/purview-dev/build-sdk#editorconfig for the full description.
+
root = true
# Base Configuration - General Settings, File Formatting, and Naming Conventions
@@ -360,12 +380,8 @@ dotnet_style_require_accessibility_modifiers = omit_if_default:warning
# Code-block preferences
csharp_prefer_braces = when_multiline:suggestion
csharp_prefer_simple_using_statement = false:silent
-csharp_prefer_system_threading_lock = true
dotnet_style_prefer_collection_expression = true:suggestion
-# Enable foreach explicit cast preference
-dotnet_style_prefer_foreach_explicit_cast_in_source = when_strongly_typed:warning
-
# Prefer System.Threading.Lock
csharp_prefer_system_threading_lock = true:warning
@@ -497,7 +513,7 @@ dotnet_style_prefer_auto_properties = true:warning
dotnet_style_prefer_compound_assignment = true:warning
dotnet_style_prefer_conditional_expression_over_assignment = true:suggestion
dotnet_style_prefer_conditional_expression_over_return = true:suggestion
-dotnet_style_prefer_foreach_explicit_cast_in_source = when_strongly_typed
+dotnet_style_prefer_foreach_explicit_cast_in_source = when_strongly_typed:warning
dotnet_style_prefer_inferred_anonymous_type_member_names = true:warning
dotnet_style_prefer_inferred_tuple_names = true:warning
dotnet_style_prefer_simplified_boolean_expressions = false:silent
diff --git a/.github/workflows/integration-wsl.yml b/.github/workflows/integration-wsl.yml
index 46984b4..0a63977 100644
--- a/.github/workflows/integration-wsl.yml
+++ b/.github/workflows/integration-wsl.yml
@@ -1,11 +1,13 @@
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.
+# The WSLC suites need Windows Server 2025 with nested virtualisation, which GitHub's `windows-2025`
+# hosted runners provide. The pre-release WSL channel carries wslc and its directory is not on PATH.
+#
+# Coverage:
+# - every pull request: the WSLC contract suite, from pr.yml's `integration-wsl` job (fast)
+# - nightly: the full hosted matrix (contract + every service module), from the schedule below
+# - manually: either the hosted matrix or a self-hosted Windows runner carrying the custom `wslc`
+# label (useful when a warm image store matters), selected with the `runner` input
#
# Runner prerequisites are documented in docs/wiki/Testing.md.
on:
@@ -19,6 +21,20 @@ on:
description: TUnit treenode filter. Defaults to every test.
required: false
default: "/*/*/*/*"
+ runner:
+ description: Runner to use for the full matrix.
+ type: choice
+ options: [hosted, self-hosted]
+ default: hosted
+ scope:
+ description: Test scope.
+ type: choice
+ options: [full, contract]
+ default: full
+ schedule:
+ # Nightly full matrix on a hosted windows-2025 runner (UTC). The contract suite already runs on
+ # every pull request; this is the standing proof that every service module works on WSLC.
+ - cron: "0 3 * * *"
concurrency:
# Never cancel a WSLC run in flight: it owns a session and its image store.
@@ -26,11 +42,98 @@ concurrency:
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.
+ integration-wsl-hosted:
+ name: Integration (WSL Containers, hosted)
+ # On a schedule there are no inputs, so default to the hosted full matrix. On manual dispatch the
+ # `runner` input decides between this job and the self-hosted one.
+ if: github.event_name == 'schedule' || inputs.runner != 'self-hosted'
+ runs-on: windows-2025
+ timeout-minutes: 90
+ env:
+ PURVIEW_CONTAINERS_BACKEND: wsl
+ PURVIEW_CONTAINERS_REQUIRE_BACKEND: wsl
+ WSL_UTF8: "1"
+ 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.2.26504.105"
+
+ - name: Install WSL Containers
+ shell: pwsh
+ run: |
+ wsl --update --pre-release
+ $wslc = 'C:\Program Files\WSL\wslc.exe'
+ if (-not (Test-Path $wslc)) {
+ # The feature reached GA in some channels; fall back to the stable update before failing.
+ wsl --update
+ }
+ wsl --version
+ if (-not (Test-Path $wslc)) {
+ throw "wslc.exe was not installed at $wslc; the hosted runner did not get a WSL build with containers."
+ }
+ $env:PATH = "C:\Program Files\WSL;$env:PATH"
+ & $wslc version
+
+ - name: Restore
+ run: dotnet restore src/Containers.slnx
+
+ - name: Build
+ run: dotnet build src/Containers.slnx --configuration Release --no-restore
+
+ - name: WSLC backend contract
+ # Real containers on a shared WSLC session; the suite caps its own parallelism.
+ run: dotnet test src/tests/Wsl.IntegrationTests/Wsl.IntegrationTests.csproj --configuration Release --no-build --treenode-filter '${{ inputs.filter || '/*/*/*/*' }}' --retry-failed-tests 2 --retry-failed-tests-delay 5s --retry-failed-tests-max-percentage 50
+
+ - name: Service modules on WSLC
+ if: inputs.scope != 'contract'
+ # 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/AzureKeyVaultEmulator.IntegrationTests/AzureKeyVaultEmulator.IntegrationTests.csproj',
+ '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'
+ )
+
+ $filter = '${{ inputs.filter || '/*/*/*/*' }}'
+ $failed = @()
+ foreach ($project in $projects) {
+ Write-Host "::group::$project"
+ dotnet test $project --configuration Release --no-build --treenode-filter $filter --retry-failed-tests 2 --retry-failed-tests-delay 5s --retry-failed-tests-max-percentage 50
+ if ($LASTEXITCODE -ne 0) {
+ $failed += $project
+ }
+ Write-Host "::endgroup::"
+ }
+
+ if ($failed.Count -gt 0) {
+ throw "WSLC integration suites failed: $($failed -join ', ')"
+ }
+
+ integration-wsl-self-hosted:
+ name: Integration (WSL Containers, self-hosted)
+ if: inputs.runner == 'self-hosted'
+ # A self-hosted Windows x64 runner with WSL Containers installed and the custom `wslc` label. It is
+ # only used on manual dispatch; a runner with a warm image store makes the full matrix much faster.
runs-on: [self-hosted, wslc]
timeout-minutes: 60
+ env:
+ PURVIEW_CONTAINERS_BACKEND: wsl
+ PURVIEW_CONTAINERS_REQUIRE_BACKEND: wsl
+ WSL_UTF8: "1"
steps:
- uses: actions/checkout@v4
with:
@@ -46,7 +149,8 @@ jobs:
shell: pwsh
run: |
wsl --version
- wslc version
+ $env:PATH = "C:\Program Files\WSL;$env:PATH"
+ & 'C:\Program Files\WSL\wslc.exe' version
- name: Restore
run: dotnet restore src/Containers.slnx
@@ -56,9 +160,10 @@ jobs:
- 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 }}'
+ run: dotnet test src/tests/Wsl.IntegrationTests/Wsl.IntegrationTests.csproj --configuration Release --no-build --treenode-filter '${{ inputs.filter || '/*/*/*/*' }}' --retry-failed-tests 2 --retry-failed-tests-delay 5s --retry-failed-tests-max-percentage 50
- name: Service modules on WSLC
+ if: inputs.scope != 'contract'
# 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.
@@ -75,10 +180,11 @@ jobs:
'src/tests/Nats.IntegrationTests/Nats.IntegrationTests.csproj'
)
+ $filter = '${{ inputs.filter || '/*/*/*/*' }}'
$failed = @()
foreach ($project in $projects) {
Write-Host "::group::$project"
- dotnet test $project --configuration Release --no-build --treenode-filter '${{ inputs.filter }}'
+ dotnet test $project --configuration Release --no-build --treenode-filter $filter --retry-failed-tests 2 --retry-failed-tests-delay 5s --retry-failed-tests-max-percentage 50
if ($LASTEXITCODE -ne 0) {
$failed += $project
}
diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml
index 5a9245f..616843c 100644
--- a/.github/workflows/pr.yml
+++ b/.github/workflows/pr.yml
@@ -21,8 +21,10 @@ jobs:
# ("A compatible .NET SDK was not found") and every step fails.
# Keep in sync with `sdk.version` in global.json.
dotnet-version: "11.0.100-rc.2.26504.105"
- # Mirrors Build:TestFilter in purview-build.json: the SDK categorises unit test projects as Unit,
- # so the WSLC integration suites are never selected and the runner needs no WSLC host.
+ # The repository default filter is /*/*/*/*, but test discovery stays unit-only and the shared
+ # pipeline runs on ubuntu-latest, so it has no WSLC host. This job narrows the filter to Unit
+ # anyway. The Docker suites run in the explicit job below; the WSLC contract runs on
+ # windows-2025 in the integration-wsl job.
test-filter: "/*/*/*/*[Category=Unit]"
# All library projects are packable, so pack and validate the produced packages.
run-pack: true
@@ -32,11 +34,14 @@ jobs:
# 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.
+ # Windows/WSL. The job requires the daemon, so a runner without one fails instead of skipping.
integration-docker:
name: Integration (Docker)
runs-on: ubuntu-latest
+ env:
+ # Pin the backend and require it: a runner without a daemon must fail, not silently skip every test.
+ PURVIEW_CONTAINERS_BACKEND: docker
+ PURVIEW_CONTAINERS_REQUIRE_BACKEND: docker
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
@@ -44,6 +49,47 @@ jobs:
# Keep in sync with `sdk.version` in global.json.
dotnet-version: "11.0.100-rc.2.26504.105"
- name: Docker backend
- run: dotnet test src/tests/Docker.IntegrationTests/Docker.IntegrationTests.csproj --configuration Release --treenode-filter '/*/*/*/*'
+ run: dotnet test src/tests/Docker.IntegrationTests/Docker.IntegrationTests.csproj --configuration Release --treenode-filter '/*/*/*/*' --retry-failed-tests 2 --retry-failed-tests-delay 5s --retry-failed-tests-max-percentage 50
- name: Service modules on Docker
- run: dotnet test src/tests/Modules.Docker.IntegrationTests/Modules.Docker.IntegrationTests.csproj --configuration Release --treenode-filter '/*/*/*/*'
+ run: dotnet test src/tests/Modules.Docker.IntegrationTests/Modules.Docker.IntegrationTests.csproj --configuration Release --treenode-filter '/*/*/*/*' --retry-failed-tests 2 --retry-failed-tests-delay 5s --retry-failed-tests-max-percentage 50
+
+ # WSLC needs a Windows host with nested virtualisation. windows-2025 provides it (Windows Server
+ # 2025); wslc ships in the pre-release WSL channel and its directory is not on PATH. This is the
+ # contract suite only: the full module matrix is too slow for every pull request and runs in the
+ # nightly integration-wsl workflow instead.
+ integration-wsl:
+ name: Integration (WSLC contract)
+ runs-on: windows-2025
+ timeout-minutes: 30
+ env:
+ PURVIEW_CONTAINERS_BACKEND: wsl
+ PURVIEW_CONTAINERS_REQUIRE_BACKEND: wsl
+ WSL_UTF8: "1"
+ 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.2.26504.105"
+ - name: Install WSL Containers
+ shell: pwsh
+ run: |
+ wsl --update --pre-release
+ $wslc = 'C:\Program Files\WSL\wslc.exe'
+ if (-not (Test-Path $wslc)) {
+ # The feature reached GA in some channels; fall back to the stable update before failing.
+ wsl --update
+ }
+ wsl --version
+ if (-not (Test-Path $wslc)) {
+ throw "wslc.exe was not installed at $wslc; the hosted runner did not get a WSL build with containers."
+ }
+ $env:PATH = "C:\Program Files\WSL;$env:PATH"
+ & $wslc version
+ - name: Restore
+ run: dotnet restore src/Containers.slnx
+ - name: Build
+ run: dotnet build src/Containers.slnx --configuration Release --no-restore
+ - name: WSLC backend contract
+ # Real containers on the shared WSLC session; the suite caps its own parallelism.
+ run: dotnet test src/tests/Wsl.IntegrationTests/Wsl.IntegrationTests.csproj --configuration Release --no-build --treenode-filter '/*/*/*/*' --retry-failed-tests 2 --retry-failed-tests-delay 5s --retry-failed-tests-max-percentage 50
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 7f071b2..8ee32ab 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -5,6 +5,13 @@ on:
branches: ["release/**"]
workflow_dispatch:
+permissions:
+ contents: write
+ # The shared release workflow pushes the same packages to nuget.org and, when
+ # PURVIEW_PUBLISH_GITHUB_PACKAGES=true, to GitHub Packages. A reusable workflow cannot grant
+ # more than its caller, so the permission is declared here.
+ packages: write
+
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
diff --git a/AGENTS.md b/AGENTS.md
index 8d94a75..058c2a6 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -34,7 +34,7 @@ whenever a target framework, a backend package or the `buildTransitive` assets c
| `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 |
+| `.config/purview-build.json` | Shared `Purview.Build` pipeline configuration: solution, test discovery/filter and the **exhaustive** pack-validation manifest |
| `Justfile` | Supported local workflow commands |
| `skills` | The agent skills this repository authors and publishes |
| `.agents/skills` | Skills installed from other repositories by the skills CLI (gitignored; `skills-lock.json` is the tracked record) |
@@ -132,7 +132,7 @@ whenever a target framework, a backend package or the `buildTransitive` assets c
`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
+- `PackValidation.RequireExplicitContent` defaults to `true`, so `.config/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.
- Validate with `just pack` and `just pipeline-pack-validate`.
@@ -140,14 +140,24 @@ whenever a target framework, a backend package or the `buildTransitive` assets c
## Testing
- Test assemblies are categorised by the SDK: `*.UnitTests` → `Unit`, `*.IntegrationTests` → `Integration`.
-- `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`.
+- `.config/purview-build.json` keeps test discovery to `*UnitTests.csproj`, so the shared Ubuntu pipeline
+ never launches a Windows-targeted WSLC test app. `TestFilter` remains `/*/*/*/*` so widening discovery
+ runs every category; `just test` is the all-tests command. The
+ **Docker** integration suites (`Docker.IntegrationTests`, `Modules.Docker.IntegrationTests`) run in the
+ `.github/workflows/pr.yml` `integration-docker` job on `ubuntu-latest`. The **WSLC** contract suite runs
+ on a hosted `windows-2025` runner in the same workflow, and the full WSLC module matrix runs nightly in
+ `.github/workflows/integration-wsl.yml`. The suites' skip helpers honour
+ `PURVIEW_CONTAINERS_REQUIRE_BACKEND`, so a missing or incompatible runtime fails the job instead of
+ silently skipping every test.
+- Test projects run **serially** (`.config/purview-build.json` sets `Build:MaxTestParallelism` to `1`;
+ `just test` passes `--max-parallel-test-modules 1`): a session exclusively locks its image-store VHD, so
+ parallel assemblies fail with `0x80070020`. Within a project, container suites cap their own parallelism
+ (`[NotInParallel]` for the service modules and `ModuleDockerTests`, a `ParallelLimiter` of two for
+ `Wsl.IntegrationTests`) to avoid stampeding the Docker daemon and the WSLC session VM on a cold run.
+- Integration retries are **project-level run-time options**, not assembly attributes: the test projects
+ reference `Microsoft.Testing.Extensions.Retry` and the integration commands pass
+ `--retry-failed-tests 2 --retry-failed-tests-delay 5s`. A retry restarts the test host (fresh WSLC session); do
+ not reintroduce a TUnit `[Retry]` assembly attribute.
- 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.
@@ -180,12 +190,14 @@ 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`.
-- `.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`.
+- `.github/workflows/integration-wsl.yml` runs the WSLC integration suites. The fast contract suite runs on
+ a hosted `windows-2025` runner on every pull request from `pr.yml`; the full service-module matrix runs
+ nightly on `windows-2025` and can be dispatched manually against either the hosted runner or a
+ self-hosted Windows runner with the custom `wslc` label (documented in [Testing](docs/wiki/Testing.md)).
+ `windows-2022` and Windows 10 do not support WSL Containers. `.github/actionlint.yaml` declares the
+ custom label.
- The workflows pin `dotnet-version` to `global.json`'s `sdk.version`; keep them in sync, and keep
- `purview-build.json` pointing at `src/Containers.slnx`. The shared workflow runs on
+ `.config/purview-build.json` pointing at `src/Containers.slnx`. The shared workflow runs on
`ubuntu-latest`, which is why `EnableWindowsTargeting=true` must stay in `src/Directory.Build.props`.
## Completion checklist
@@ -195,7 +207,7 @@ Before handing work back:
- Confirm the requested behaviour and scope are satisfied, and only intended files changed.
- Review public API, package-content and dependency-direction implications.
- Update the affected package `Sdk/README.md`, the root `README.md`, `docs/wiki` (plus `_Sidebar.md` and
- `mkdocs.yml` for new pages), `AGENTS.md` and `purview-build.json` when the change affects them.
+ `mkdocs.yml` for new pages), `AGENTS.md` and `.config/purview-build.json` when the change affects them.
`docs/wiki/Consumer-Requirements.md` is the contract every consumer-facing change has to keep
accurate, and `just verify-consumers` is the check that proves it.
- Watch for the packaging traps: a new packable project missing `true` or a
diff --git a/Directory.Packages.props b/Directory.Packages.props
index dac759a..64bca29 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -1,5 +1,10 @@
+
true
true
@@ -24,6 +29,7 @@
3.0.3
10.0.26100.80
+ 1.76.0
@@ -34,11 +40,19 @@
-
-
-
-
-
+
+
+
+
+
+
+
diff --git a/Justfile b/Justfile
index f2e21d0..ce7ac26 100644
--- a/Justfile
+++ b/Justfile
@@ -90,10 +90,14 @@ restore *args:
# test assemblies contend for the shared store and fail with 0x80070020 ("file is being used by another
# process"). Override by appending the flag, e.g.
# just test '/*/*/*/*/' --max-parallel-test-modules 4
+#
+# Failures are retried at the test-project level by Microsoft.Testing.Platform's retry extension. It
+# restarts the test host for each attempt, which also gives WSLC a fresh session; the delay lets the
+# previous host release the session VHD.
[group('Build and Test')]
test filter="/*/*/*/*/" *args:
echo "Running tests for {{ BLUE }}{{ test_solution }}{{ NORMAL }} with {{ YELLOW }}{{ build_configuration }}{{ NORMAL }}..."
- dotnet test --solution "{{ test_solution }}" --configuration "{{ build_configuration }}" --max-parallel-test-modules 1 --treenode-filter={{ filter }} {{ args }}
+ dotnet test --solution "{{ test_solution }}" --configuration "{{ build_configuration }}" --max-parallel-test-modules 1 --treenode-filter={{ filter }} --retry-failed-tests 2 --retry-failed-tests-delay 5s --retry-failed-tests-max-percentage 50 {{ args }}
# Cleans the solution with the specified configuration (default: Debug)
[group('Build and Test')]
diff --git a/README.md b/README.md
index 6abebb7..f542604 100644
--- a/README.md
+++ b/README.md
@@ -43,7 +43,8 @@ want to fix the runtime.
**WSL Containers backend**
-- Windows 10/11 with **WSL Containers**, installed via `wsl --install --no-distribution` (verified against
+- Windows 11 (22H2 or later) or Windows Server 2025 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
@@ -255,17 +256,26 @@ in parallel makes the losers fail with `0x80070020`:
The process cannot access the file because it is being used by another process.
```
-The runtime now verifies the store on first use and transparently falls back to an isolated
+The runtime verifies the store on first use and transparently falls back to an isolated
per-process store (removed when that process's session terminates), but running test modules serially
-keeps the warm shared image cache (no per-process re-pull) and is the fastest option:
+keeps the warm shared image cache (no per-process re-pull) and is the fastest option. Test projects run
+one at a time (`.config/purview-build.json` sets `Build:MaxTestParallelism` to `1`); inside a project the
+container suites also cap their own parallelism so they do not stampede Testcontainers' resource reaper
+or the WSLC session VM.
```powershell
-just test # dotnet test, one test module at a time
+just test # dotnet test, one test module at a time, retrying failed tests twice
# Visual Studio: Test > Options > untick "Run Tests in Parallel" (or set
# "Maximum Parallel Test Projects" to 1) before running the WSLC integration tests.
```
+Failures are retried at the **test-project** level by Microsoft.Testing.Platform's retry extension: a retry
+restarts the test host, so WSLC gets a fresh session. There is no per-assembly retry attribute. In CI, the
+Docker suites run on every pull request, the WSLC contract suite runs on a hosted `windows-2025` runner, and
+the full WSLC service-module matrix runs nightly. Set `PURVIEW_CONTAINERS_REQUIRE_BACKEND=wsl|docker` to make
+a missing runtime fail instead of skip.
+
To watch a container start end to end, run a sample — the same code, on either backend:
```powershell
diff --git a/docs/wiki/Backends.md b/docs/wiki/Backends.md
index e693feb..1a983d5 100644
--- a/docs/wiki/Backends.md
+++ b/docs/wiki/Backends.md
@@ -32,7 +32,7 @@ Service modules (`Purview.Containers.PostgreSql`, `Redis`, …) are backend-neut
| | 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) |
+| **Prerequisite** | Windows 11 (22H2 or later) or Windows Server 2025 with WSL Containers (`wsl --install --no-distribution`) | any reachable Docker daemon (Docker Desktop, Docker Engine in WSL2, a VM, or a CI runner) |
| **Host OS** | Windows only | Windows, Linux, macOS |
| **Project target framework** | `net10.0` or later, any platform (portable facade), or `net10.0-windows10.0.19041.0`, x64 or arm64 (implementation bound directly) | `net10.0` or later, any platform |
| **How containers run** | the `Microsoft.WSL.Containers` managed API (daemonless) | the Docker Engine API via Testcontainers |
@@ -372,23 +372,50 @@ jobs:
- 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`:
+For a **Windows** job that should exercise WSLC, use a `windows-2025` runner (Windows Server 2025 with
+nested virtualisation). The WSL build with `wslc` is in the pre-release channel and its directory is not on
+`PATH`:
```yaml
integration-windows:
name: Integration tests (WSL Containers)
- runs-on: windows-latest
+ runs-on: windows-2025
+ timeout-minutes: 30
+ env:
+ PURVIEW_CONTAINERS_BACKEND: wsl
+ PURVIEW_CONTAINERS_REQUIRE_BACKEND: wsl
+ WSL_UTF8: "1"
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: 11.0.x
- - run: dotnet test --configuration Release
+ - name: Install WSL Containers
+ shell: pwsh
+ run: |
+ wsl --update --pre-release
+ wsl --version
+ $env:PATH = "C:\Program Files\WSL;$env:PATH"
+ & 'C:\Program Files\WSL\wslc.exe' version
+ - run: dotnet restore
+ - run: dotnet build --configuration Release --no-restore
+ - run: dotnet test --configuration Release --no-build --retry-failed-tests 2 --retry-failed-tests-delay 5s
```
-> 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.
+> `windows-2025` is the only hosted runner that supports WSL Containers: Windows Server 2022 and Windows 10
+> are not supported. Windows runners bill at 2×, and a cold runner has to pull every image, so run the fast
+> contract suite on pull requests and the full service-module matrix nightly or on demand. If you run on a
+> self-hosted Windows runner instead, keep `PURVIEW_CONTAINERS_REQUIRE_BACKEND=wsl` so a host without WSLC
+> fails rather than silently skipping every test.
+
+### Retrying transient integration failures
+
+Container integration tests depend on the host's runtime. Retry at the test-project level with
+Microsoft.Testing.Platform's retry extension rather than with an assembly-level attribute: install
+`Microsoft.Testing.Extensions.Retry` in the integration test project and pass
+`--retry-failed-tests 2 --retry-failed-tests-delay 5s`. A retry restarts the test host, so WSLC gets a fresh
+session and Testcontainers gets a fresh resource reaper, and the delay gives the previous host time to
+release its image-store VHD.
## What differs in practice
diff --git a/docs/wiki/Consumer-Requirements.md b/docs/wiki/Consumer-Requirements.md
index dd09df5..14e0588 100644
--- a/docs/wiki/Consumer-Requirements.md
+++ b/docs/wiki/Consumer-Requirements.md
@@ -56,7 +56,7 @@ file, and because the defaults only fill in a value when you have not chosen one
| 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. |
+| 5 | **A Windows 11 (22H2 or later) or Windows Server 2025 host with WSL Containers** to actually run containers | The library drives WSLC; it never installs or updates WSL for you. Windows 10 and Windows Server 2022 are not supported by WSL Containers. |
### 1. .NET 10 or later
@@ -113,8 +113,8 @@ for you, so you only need it if you want to override the value deliberately.
### 5. Host prerequisites
-The build requirements above are host-independent. Running containers needs a Windows 10/11 host
-with WSL Containers installed (`wsl --install --no-distribution`) — see
+The build requirements above are host-independent. Running containers needs a Windows 11 (22H2 or later)
+or Windows Server 2025 host with WSL Containers installed (`wsl --install --no-distribution`) — see
[Getting Started](Getting-Started.md).
## What the packages do for you
diff --git a/docs/wiki/Getting-Started.md b/docs/wiki/Getting-Started.md
index 7073c35..63ad899 100644
--- a/docs/wiki/Getting-Started.md
+++ b/docs/wiki/Getting-Started.md
@@ -8,7 +8,7 @@ points you at the test workflow.
Everything runs on one of two backends; pick the one that matches your machine — see
[Backends: WSLC or Docker](Backends.md) for the full comparison.
-- **WSL Containers backend:** Windows 10/11 with **WSL Containers** (`wsl --install --no-distribution`),
+- **WSL Containers backend:** Windows 11 (22H2 or later) or Windows Server 2025 with **WSL Containers** (`wsl --install --no-distribution`),
verified against WSL **3.0.1.0**. A consuming project must be **.NET 10 or later**: a platform-neutral
`net10.0` project binds the portable facade and gets automatic WSLC-or-Docker selection, while a Windows
target framework (`net10.0-windows10.0.19041.0`, x64 or arm64) binds the implementation directly. The
diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md
index fa566aa..0cba0c9 100644
--- a/docs/wiki/Home.md
+++ b/docs/wiki/Home.md
@@ -62,7 +62,7 @@ This wiki is the project documentation hub. The packages are published under the
## Requirements
-- **WSL Containers backend:** Windows 10/11 with **WSL Containers**, installed via
+- **WSL Containers backend:** Windows 11 (22H2 or later) or Windows Server 2025 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).
diff --git a/docs/wiki/Testing.md b/docs/wiki/Testing.md
index 3c75277..7b9e5c5 100644
--- a/docs/wiki/Testing.md
+++ b/docs/wiki/Testing.md
@@ -10,14 +10,25 @@ Test projects are discovered under `src/tests` and the SDK stamps every test ass
| --- | --- | --- |
| `*.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.
+| `Docker.IntegrationTests`, `Modules.Docker.IntegrationTests` | `Integration` | any **Docker daemon** — these target `net10.0`, so they also run on Linux. |
+
+`.config/purview-build.json` keeps test discovery to unit projects (`TestPatterns` is
+`*UnitTests.csproj`), so the shared Ubuntu pipeline never launches a Windows-targeted WSLC test app. A
+local pipeline run is therefore runtime-free too. Container coverage is explicit instead:
+
+- **Docker** — `integration-docker` in `pr.yml` runs the Docker contract and module suites on
+ `ubuntu-latest` on every pull request.
+- **WSLC contract** — `integration-wsl` in `pr.yml` runs `Wsl.IntegrationTests` on a `windows-2025`
+ runner on every pull request.
+- **Full WSLC matrix** — `.github/workflows/integration-wsl.yml` runs the contract suite and every
+ service module on a `windows-2025` runner nightly (and on demand).
+
+The suites retry transient failures at the **test-project** level through Microsoft.Testing.Platform's
+retry extension (`--retry-failed-tests`). A retry restarts the test host, which gives WSLC a fresh session
+and Testcontainers a fresh process; `--retry-failed-tests-delay 5s` lets the previous host release the
+session VHD. Runner failures are still real failures: every integration job pins
+`PURVIEW_CONTAINERS_BACKEND` and sets `PURVIEW_CONTAINERS_REQUIRE_BACKEND`, so a missing or incompatible
+runtime fails the job instead of skipping every test.
> 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
@@ -26,7 +37,7 @@ suites still run only on a WSLC host.
> cover.
```powershell
-just test # every discovered test project
+just test # every discovered test project, retrying failed tests twice
just test '/*/*/*/*[Category=Unit]' # unit tests only
just test '/*/*/*/*[Category=Integration]' --max-parallel-test-modules 1
```
@@ -34,14 +45,15 @@ 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 eight
+(`Docker.IntegrationTests` for the container contract, `Modules.Docker.IntegrationTests` for the eight
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.
+host lacks the WSL Containers components. Set `PURVIEW_CONTAINERS_REQUIRE_BACKEND=wsl|docker` to turn that
+skip into a failure — the CI integration jobs do. 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 eight modules on Docker
+just test src/tests/Modules.Docker.IntegrationTests/Modules.Docker.IntegrationTests.csproj # the eight modules on Docker
```
## Why test modules run serially
@@ -54,44 +66,62 @@ access rather than at session start. Running test assemblies in parallel therefo
The process cannot access the file because it is being used by another process.
```
-`just test` passes `--max-parallel-test-modules 1` for that reason. The runtime verifies the store once and
-transparently falls back to an isolated per-process store, but running modules serially keeps the warm shared
-image cache (no per-process re-pull) and is the fastest option.
+`just test` passes `--max-parallel-test-modules 1` for that reason, and `.config/purview-build.json` sets
+`Build:MaxTestParallelism` to `1` so the pipeline does not run test projects in parallel either. The runtime
+verifies the store once and transparently falls back to an isolated per-process store, but running modules
+serially keeps the warm shared image cache (no per-process re-pull) and is the fastest option.
+
+Within a project, TUnit runs tests in parallel by default. The container suites cap that themselves:
+`ModuleDockerTests` and each service-module suite run one test at a time (`[NotInParallel]`), and
+`Wsl.IntegrationTests` uses a `ParallelLimiter` of two. That is what keeps Testcontainers' single resource
+reaper and the 4 GB WSLC session VM from being stampeded on a cold run.
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)
+## Running the WSLC suites in CI
+
+The WSLC half used to be manual-only because no hosted runner was assumed to have WSL Containers. That is
+no longer true: GitHub's `windows-2025` runner is Windows Server 2025 with hardware nested virtualisation,
+and `wsl --update --pre-release` installs a WSL build that includes `wslc`. The binary is not on `PATH`, so
+the workflow adds `C:\Program Files\WSL` and calls it by absolute path.
-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.
+Coverage is split for cost:
-Trigger it from **Actions → Integration (WSL Containers) → Run workflow**, or:
+- **Every pull request**: `pr.yml` runs `Wsl.IntegrationTests` on `windows-2025` (the contract suite).
+- **Nightly**: `.github/workflows/integration-wsl.yml` runs the contract suite and the eight service-module
+ suites on `windows-2025`, one project at a time.
+- **On demand**: the same workflow accepts `runner: self-hosted` to use a warm self-hosted Windows runner
+ with the custom `wslc` label, and `scope: contract` to run only the contract suite.
+
+Trigger a manual run 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 eight service-module suites (`AzureKeyVaultEmulator`,
-`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).
+### Hosted runner notes
+
+Windows runners bill at 2× and the full module matrix pulls several GB of images on a cold runner, so the
+full matrix is nightly rather than per-PR. `windows-2022` and Windows 10 do not support WSL Containers;
+`windows-2025` is the only hosted option. The WSLC API surface is pinned by the
+`Microsoft.WSL.Containers` package, so a hosted runner with a newer pre-release WSL may report the runtime
+as incompatible. The workflow sets `PURVIEW_CONTAINERS_REQUIRE_BACKEND=wsl`, which turns that into a failed
+job rather than a green run with every test skipped.
### 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`).
+- Windows 11 (22H2 or later) or Windows Server 2025 with **WSL Containers**:
+ `wsl --install --no-distribution`, verified with `wsl --version` and `wslc version`. Windows 10 and
+ Windows Server 2022 do not support WSL Containers.
+- The **.NET 11 SDK** the repository pins in `global.json` (`11.0.100-rc.2.26504.105`).
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
+immediately instead of surfacing as container timeouts. `actionlint` is told about the custom label in
`.github/actionlint.yaml`.
## Expectations
@@ -99,7 +129,7 @@ real coverage — the host check is what makes that visible. `actionlint` is tol
- 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.
+ without WSLC can still run the unit suites. Set `PURVIEW_CONTAINERS_REQUIRE_BACKEND` to fail instead.
## Verifying the consumer contract
diff --git a/global.json b/global.json
index c394a96..8b388e3 100644
--- a/global.json
+++ b/global.json
@@ -1,10 +1,11 @@
{
"sdk": {
"version": "11.0.100-rc.2.26504.105",
+ "rollForward": "latestMajor",
"allowPrerelease": true
},
"msbuild-sdks": {
- "Purview.BuildSdk": "1.0.7"
+ "Purview.BuildSdk": "1.1.0"
},
"test": {
"runner": "Microsoft.Testing.Platform"
diff --git a/package.json b/package.json
index 95d5913..4f7f793 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "containers",
- "version": "1.0.0-prerelease.7",
+ "version": "1.0.0-prerelease.8",
"description": "Testcontainers-style library for .NET that runs throwaway Linux containers for integration testing, on Microsoft WSL Containers or Docker.",
"license": "MIT",
"author": {
@@ -15,4 +15,4 @@
"type": "git",
"url": "git+https://github.com/purview-dev/containers.git"
}
-}
+}
\ No newline at end of file
diff --git a/src/Directory.Build.props b/src/Directory.Build.props
index cf18964..6e52685 100644
--- a/src/Directory.Build.props
+++ b/src/Directory.Build.props
@@ -29,4 +29,16 @@
Link="Sdk/purview-logo-light.png"
/>
+
+
+
+
+
diff --git a/src/src/AzureKeyVaultEmulator/AzureKeyVaultEmulatorBuilder.cs b/src/src/AzureKeyVaultEmulator/AzureKeyVaultEmulatorBuilder.cs
index abd0dce..8cff5b1 100644
--- a/src/src/AzureKeyVaultEmulator/AzureKeyVaultEmulatorBuilder.cs
+++ b/src/src/AzureKeyVaultEmulator/AzureKeyVaultEmulatorBuilder.cs
@@ -207,7 +207,7 @@ protected override AzureKeyVaultEmulatorContainer CreateContainer(
AzureKeyVaultEmulatorConfiguration configuration
) => new(configuration, Backend);
- WaitStrategy BuildDefaultWaitStrategy()
+ static WaitStrategy BuildDefaultWaitStrategy()
{
// GET / is served unauthenticated once Kestrel is listening; any HTTP response proves the HTTPS
// stack is up. AllowInsecureTls keeps the probe independent of the host trust store.
diff --git a/src/src/AzureKeyVaultEmulator/AzureKeyVaultEmulatorCertificates.cs b/src/src/AzureKeyVaultEmulator/AzureKeyVaultEmulatorCertificates.cs
index a724502..cccacf6 100644
--- a/src/src/AzureKeyVaultEmulator/AzureKeyVaultEmulatorCertificates.cs
+++ b/src/src/AzureKeyVaultEmulator/AzureKeyVaultEmulatorCertificates.cs
@@ -1,3 +1,4 @@
+using System.Collections.Concurrent;
using System.Diagnostics;
using System.Net;
using System.Security.Cryptography;
@@ -22,6 +23,12 @@ static class AzureKeyVaultEmulatorCertificates
// stay out of a runner's user profile.
static readonly string[] ContinuousIntegrationVariables = ["BUILD_BUILDID", "CI", "GITHUB_ACTIONS", "TF_BUILD"];
+ // Tests and parallel consumers can point several emulators at the same default directory. The
+ // first-run generation is a read-check-write sequence, so without a lock two callers can both
+ // write the pair and one can observe a half-written PFX. The lock is per directory and only
+ // spans this process; CI and the integration suites run test projects one at a time.
+ static readonly ConcurrentDictionary DirectoryLocks = new(StringComparer.OrdinalIgnoreCase);
+
internal static string DefaultDirectory { get; } = ResolveDefaultDirectory();
///
@@ -40,23 +47,32 @@ AzureKeyVaultEmulatorConfiguration configuration
var pfxPath = Path.Combine(configuration.CertificateDirectory, PfxFileName);
var crtPath = Path.Combine(configuration.CertificateDirectory, CrtFileName);
- if (File.Exists(pfxPath) && File.Exists(crtPath))
- {
- return (LoadPfx(pfxPath), Generated: false);
- }
-
- if (!configuration.GenerateCertificates)
+ lock (LockFor(configuration.CertificateDirectory))
{
- throw new ContainerConfigurationException(
- $"No certificate pair was found in '{configuration.CertificateDirectory}' and certificate generation is disabled. Provide '{PfxFileName}' (password '{AzureKeyVaultEmulatorBuilder.CertificatePassword}') and '{CrtFileName}', or re-enable generated certificates."
- );
+ if (File.Exists(pfxPath) && File.Exists(crtPath))
+ {
+ return (LoadPfx(pfxPath), Generated: false);
+ }
+
+ if (!configuration.GenerateCertificates)
+ {
+ throw new ContainerConfigurationException(
+ $"No certificate pair was found in '{configuration.CertificateDirectory}' and certificate generation is disabled. Provide '{PfxFileName}' (password '{AzureKeyVaultEmulatorBuilder.CertificatePassword}') and '{CrtFileName}', or re-enable generated certificates."
+ );
+ }
+
+ // Half of the pair has gone missing: remove both so the regenerated pair is consistent.
+ TryDelete(pfxPath);
+ TryDelete(crtPath);
+
+ return (GenerateAndExport(pfxPath, crtPath), Generated: true);
}
+ }
- // Half of the pair has gone missing: remove both so the regenerated pair is consistent.
- TryDelete(pfxPath);
- TryDelete(crtPath);
-
- return (GenerateAndExport(pfxPath, crtPath), Generated: true);
+ static object LockFor(string certificateDirectory)
+ {
+ var fullPath = Path.TrimEndingDirectorySeparator(Path.GetFullPath(certificateDirectory));
+ return DirectoryLocks.GetOrAdd(fullPath, static _ => new object());
}
/// Installs the certificate into the host trust store so unmodified Azure SDK clients connect.
@@ -188,11 +204,25 @@ static X509Certificate2 GenerateAndExport(string pfxPath, string crtPath)
certificate.FriendlyName = "Azure Key Vault Emulator";
}
- File.WriteAllBytes(
- pfxPath,
- certificate.Export(X509ContentType.Pfx, AzureKeyVaultEmulatorBuilder.CertificatePassword)
- );
- File.WriteAllText(crtPath, ExportToPem(certificate));
+ // Write both files under temporary names and move them into place, so a concurrent reader never
+ // sees a partially written PFX/CRT pair.
+ var pfxTemporaryPath = $"{pfxPath}.{Guid.NewGuid():N}.tmp";
+ var crtTemporaryPath = $"{crtPath}.{Guid.NewGuid():N}.tmp";
+ try
+ {
+ File.WriteAllBytes(
+ pfxTemporaryPath,
+ certificate.Export(X509ContentType.Pfx, AzureKeyVaultEmulatorBuilder.CertificatePassword)
+ );
+ File.WriteAllText(crtTemporaryPath, ExportToPem(certificate));
+ File.Move(pfxTemporaryPath, pfxPath, overwrite: true);
+ File.Move(crtTemporaryPath, crtPath, overwrite: true);
+ }
+ finally
+ {
+ TryDelete(pfxTemporaryPath);
+ TryDelete(crtTemporaryPath);
+ }
return certificate;
}
diff --git a/src/src/AzureKeyVaultEmulator/AzureKeyVaultEmulatorContainer.cs b/src/src/AzureKeyVaultEmulator/AzureKeyVaultEmulatorContainer.cs
index 7f1b197..4139f92 100644
--- a/src/src/AzureKeyVaultEmulator/AzureKeyVaultEmulatorContainer.cs
+++ b/src/src/AzureKeyVaultEmulator/AzureKeyVaultEmulatorContainer.cs
@@ -47,6 +47,7 @@ public Uri GetVaultUri()
public string GetConnectionString() => GetVaultUri().ToString();
/// The certificate the emulator serves. Safe to call after .
+ [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1024:Use properties where appropriate")]
public X509Certificate2 GetCertificate() =>
_certificate ?? throw new InvalidOperationException("Container has not been started. Call StartAsync() first.");
diff --git a/src/src/Azurite/AzuriteBuilder.cs b/src/src/Azurite/AzuriteBuilder.cs
index 1de5696..719790d 100644
--- a/src/src/Azurite/AzuriteBuilder.cs
+++ b/src/src/Azurite/AzuriteBuilder.cs
@@ -54,7 +54,16 @@ protected override AzuriteConfiguration BuildConfiguration()
var waitStrategies =
configuration.WaitStrategies.Count > 0
? configuration.WaitStrategies
- : new[] { (IWaitStrategy)Wait.ForLogMessage("successfully listening") };
+ : new[]
+ {
+ (IWaitStrategy)
+ Wait.ForAll(
+ Wait.ForLogMessage("successfully listening"),
+ Wait.ForTcpPort(BlobPort),
+ Wait.ForTcpPort(QueuePort),
+ Wait.ForTcpPort(TablePort)
+ ),
+ };
return configuration with { WaitStrategies = waitStrategies };
}
diff --git a/src/src/Core/Waiting/HttpWaitStrategy.cs b/src/src/Core/Waiting/HttpWaitStrategy.cs
index 8da012f..2c35b5b 100644
--- a/src/src/Core/Waiting/HttpWaitStrategy.cs
+++ b/src/src/Core/Waiting/HttpWaitStrategy.cs
@@ -84,6 +84,10 @@ public override async Task UntilAsync(WaitContext context, CancellationTok
{
ConnectTimeout = _requestTimeout,
PooledConnectionLifetime = TimeSpan.Zero,
+ // A readiness probe targets the container's mapped loopback port. Honouring a host
+ // HTTP(S)_PROXY would send it to a proxy that cannot reach the container, turning a
+ // healthy service into a five-minute timeout.
+ UseProxy = false,
};
if (_allowInsecureTls)
{
diff --git a/src/src/Nats/NatsBuilder.cs b/src/src/Nats/NatsBuilder.cs
index 17773fe..71b41d1 100644
--- a/src/src/Nats/NatsBuilder.cs
+++ b/src/src/Nats/NatsBuilder.cs
@@ -47,7 +47,14 @@ protected override NatsConfiguration BuildConfiguration()
var waitStrategies =
configuration.WaitStrategies.Count > 0
? configuration.WaitStrategies
- : new[] { (IWaitStrategy)Wait.ForLogMessage("Listening for client connections") };
+ : new[]
+ {
+ (IWaitStrategy)
+ Wait.ForAll(
+ Wait.ForLogMessage("Listening for client connections"),
+ Wait.ForTcpPort(ClientPort)
+ ),
+ };
return configuration with { WaitStrategies = waitStrategies };
}
diff --git a/src/src/RabbitMq/RabbitMqBuilder.cs b/src/src/RabbitMq/RabbitMqBuilder.cs
index cee081c..b2ea5cd 100644
--- a/src/src/RabbitMq/RabbitMqBuilder.cs
+++ b/src/src/RabbitMq/RabbitMqBuilder.cs
@@ -83,7 +83,11 @@ protected override RabbitMqConfiguration BuildConfiguration()
var waitStrategies =
configuration.WaitStrategies.Count > 0
? configuration.WaitStrategies
- : new[] { (IWaitStrategy)Wait.ForLogMessage("Server startup complete") };
+ : new[]
+ {
+ (IWaitStrategy)
+ Wait.ForAll(Wait.ForLogMessage("Server startup complete"), Wait.ForTcpPort(AmqpPort)),
+ };
return configuration with
{
Username = _username,
diff --git a/src/src/Wsl/WslContainerRuntime.cs b/src/src/Wsl/WslContainerRuntime.cs
index 25f6450..dc961df 100644
--- a/src/src/Wsl/WslContainerRuntime.cs
+++ b/src/src/Wsl/WslContainerRuntime.cs
@@ -57,15 +57,30 @@ public WslContainerRuntime(WslContainerRuntimeOptions options)
public async Task GetInfoAsync(CancellationToken cancellationToken = default)
{
await Task.Yield();
- var version = WslcService.GetVersion();
- var missing = WslcService.GetMissingComponents();
- var compatible = !missing.Contains(Component.SdkNeedsUpdate);
- return new WslContainerRuntimeInfo(
- $"{version.Major}.{version.Minor}.{version.Revision}",
- missing.Select(component => component.ToString()).ToArray(),
- missing.Count == 0,
- compatible
- );
+ try
+ {
+ var version = WslcService.GetVersion();
+ var missing = WslcService.GetMissingComponents();
+ var compatible = !missing.Contains(Component.SdkNeedsUpdate);
+ return new WslContainerRuntimeInfo(
+ $"{version.Major}.{version.Minor}.{version.Revision}",
+ missing.Select(component => component.ToString()).ToArray(),
+ missing.Count == 0,
+ compatible
+ );
+ }
+ catch (Exception exception) when (exception is not OperationCanceledException)
+ {
+ // The SDK projection or its native component may be absent even on Windows (for example a
+ // host with Docker but no WSL Containers). "Unavailable" is a result, not an exception:
+ // callers use this probe to decide whether to skip or select another backend.
+ return new WslContainerRuntimeInfo(
+ Version: string.Empty,
+ MissingComponents: [$"{exception.GetType().Name}: {exception.Message}"],
+ IsAvailable: false,
+ IsCompatible: false
+ );
+ }
}
///
diff --git a/src/src/Wsl/WslContainerSession.cs b/src/src/Wsl/WslContainerSession.cs
index 4138570..41bfc51 100644
--- a/src/src/Wsl/WslContainerSession.cs
+++ b/src/src/Wsl/WslContainerSession.cs
@@ -18,7 +18,7 @@ sealed class WslContainerSession : IContainerSession
{
readonly Session _session;
readonly SemaphoreSlim _lifecycleGate = new(1, 1);
- readonly ConcurrentDictionary _activePulls = new(StringComparer.Ordinal);
+ readonly ConcurrentDictionary> _activePulls = new(StringComparer.Ordinal);
readonly string _storagePath;
readonly bool _deleteStorageOnTerminate;
int _started;
@@ -324,30 +324,38 @@ async Task ContainsImageAsync(Image reference, CancellationToken cancellat
return images.Any(image => reference.MatchesStoredName(image.Name));
}
- Task PullDeduplicatedAsync(
+ async Task PullDeduplicatedAsync(
Image reference,
IProgress? progress,
RegistryCredentials? credentials,
CancellationToken cancellationToken
)
{
- return _activePulls.GetOrAdd(
+ cancellationToken.ThrowIfCancellationRequested();
+
+ // The pull is shared by every caller waiting on the same image, so it must not inherit one
+ // caller's cancellation token: a test timing out would otherwise cancel the pull for every
+ // other test waiting on it. Callers stop waiting on their own token; the pull itself runs to
+ // completion (or fails) once.
+ var lazy = _activePulls.GetOrAdd(
reference.FullReference,
- static (_, state) =>
- state.Session.PullCoreAsync(
- state.Reference,
- state.Progress,
- state.Credentials,
- state.CancellationToken
- ),
- (
- Session: this,
- Reference: reference,
- Progress: progress,
- Credentials: credentials,
- CancellationToken: cancellationToken
+ _ => new Lazy(
+ () => PullCoreAsync(reference, progress, credentials, CancellationToken.None),
+ LazyThreadSafetyMode.ExecutionAndPublication
)
);
+ var pull = lazy.Value;
+
+ // Observe a fault even if every waiter cancelled, so it is not raised as an unobserved
+ // task exception at finalization.
+ _ = pull.ContinueWith(
+ static task => _ = task.Exception,
+ CancellationToken.None,
+ TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously,
+ TaskScheduler.Default
+ );
+
+ await pull.WaitAsync(cancellationToken).ConfigureAwait(false);
}
async Task PullCoreAsync(
diff --git a/src/tests/AzureKeyVaultEmulator.IntegrationTests/AzureKeyVaultEmulatorIntegrationTests.cs b/src/tests/AzureKeyVaultEmulator.IntegrationTests/AzureKeyVaultEmulatorIntegrationTests.cs
index db5e559..768c10d 100644
--- a/src/tests/AzureKeyVaultEmulator.IntegrationTests/AzureKeyVaultEmulatorIntegrationTests.cs
+++ b/src/tests/AzureKeyVaultEmulator.IntegrationTests/AzureKeyVaultEmulatorIntegrationTests.cs
@@ -4,6 +4,7 @@ namespace Purview.Containers.AzureKeyVaultEmulator;
// Trust-store installation is disabled: the module's own clients pin the emulator certificate, so the
// tests never modify the host trust store.
+[NotInParallel]
public class AzureKeyVaultEmulatorIntegrationTests
{
[Test]
diff --git a/src/tests/Azurite.IntegrationTests/AzuriteIntegrationTests.cs b/src/tests/Azurite.IntegrationTests/AzuriteIntegrationTests.cs
index c7d3623..4c07c9c 100644
--- a/src/tests/Azurite.IntegrationTests/AzuriteIntegrationTests.cs
+++ b/src/tests/Azurite.IntegrationTests/AzuriteIntegrationTests.cs
@@ -1,5 +1,6 @@
namespace Purview.Containers.Azurite;
+[NotInParallel]
public class AzuriteIntegrationTests
{
[Test]
diff --git a/src/tests/Azurite.UnitTests/AzuriteBuilderTests.cs b/src/tests/Azurite.UnitTests/AzuriteBuilderTests.cs
index f1e1997..5db9e7f 100644
--- a/src/tests/Azurite.UnitTests/AzuriteBuilderTests.cs
+++ b/src/tests/Azurite.UnitTests/AzuriteBuilderTests.cs
@@ -27,7 +27,7 @@ public async Task BuildConfig_AppliesDefaults()
await Assert.That(configuration.PortBindings).Contains(p => p.ContainerPort == 10001 && p.AssignRandomHostPort);
await Assert.That(configuration.PortBindings).Contains(p => p.ContainerPort == 10002 && p.AssignRandomHostPort);
await Assert.That(configuration.WaitStrategies.Count).IsEqualTo(1);
- await Assert.That(configuration.WaitStrategies[0]).IsTypeOf();
+ await Assert.That(configuration.WaitStrategies[0]).IsTypeOf();
}
[Test]
diff --git a/src/tests/Docker.IntegrationTests/DockerIntegrationTestLimits.cs b/src/tests/Docker.IntegrationTests/DockerIntegrationTestLimits.cs
new file mode 100644
index 0000000..b06cee5
--- /dev/null
+++ b/src/tests/Docker.IntegrationTests/DockerIntegrationTestLimits.cs
@@ -0,0 +1,17 @@
+using TUnit.Core.Interfaces;
+
+[assembly: ParallelLimiter]
+
+namespace Purview.Containers.Docker;
+
+///
+/// Caps how many Docker integration tests run at once. Testcontainers starts one process-wide
+/// resource reaper (Ryuk); concurrent first starts can exceed its one-minute init timeout even when
+/// only two tests race, and a 2-core CI runner tolerates very little burst. The suite is fast, so run
+/// one test at a time and keep the reaper initialisation uncontended.
+///
+public sealed record DockerIntegrationParallelLimit : IParallelLimit
+{
+ ///
+ public int Limit => 1;
+}
diff --git a/src/tests/Docker.IntegrationTests/DockerTest.cs b/src/tests/Docker.IntegrationTests/DockerTest.cs
index 3181527..3ef66c3 100644
--- a/src/tests/Docker.IntegrationTests/DockerTest.cs
+++ b/src/tests/Docker.IntegrationTests/DockerTest.cs
@@ -9,6 +9,12 @@ namespace Purview.Containers.Docker;
///
public static class DockerTest
{
+ ///
+ /// Set to docker to make an unavailable daemon fail the test run instead of skipping it. CI
+ /// sets this so a runner without Docker cannot go green with zero coverage.
+ ///
+ public const string RequireBackendEnvironmentVariable = "PURVIEW_CONTAINERS_REQUIRE_BACKEND";
+
static int BackendRegistered;
/// Registers the Docker backend with the shared registry.
@@ -27,7 +33,22 @@ public static async Task SkipIfUnavailableAsync()
var info = await DockerContainerBackend.Create().GetInfoAsync();
if (!info.IsUsable)
{
- throw new SkipTestException($"Docker is unavailable: {string.Join("; ", info.MissingComponents)}");
+ var reason = $"Docker is unavailable: {string.Join("; ", info.MissingComponents)}";
+ if (IsBackendRequired())
+ {
+ throw new InvalidOperationException(
+ $"{reason} {RequireBackendEnvironmentVariable}=docker requires a reachable daemon."
+ );
+ }
+
+ throw new SkipTestException(reason);
}
}
+
+ static bool IsBackendRequired() =>
+ string.Equals(
+ Environment.GetEnvironmentVariable(RequireBackendEnvironmentVariable),
+ "docker",
+ StringComparison.OrdinalIgnoreCase
+ );
}
diff --git a/src/tests/Docker.IntegrationTests/DockerTestEnvironment.cs b/src/tests/Docker.IntegrationTests/DockerTestEnvironment.cs
new file mode 100644
index 0000000..2878726
--- /dev/null
+++ b/src/tests/Docker.IntegrationTests/DockerTestEnvironment.cs
@@ -0,0 +1,27 @@
+using System.Runtime.CompilerServices;
+
+namespace Purview.Containers.Docker;
+
+///
+/// Test-process defaults for Testcontainers' resource reaper.
+///
+///
+/// Ryuk is valuable for a consumer process that may crash, but in these tests every container is
+/// disposed explicitly, and Ryuk's own random public host port is a real source of flakiness on a
+/// Windows host: WSL's relay can already own the loopback port that Ryuk picks, so the container starts
+/// but the reaper cannot be reached and Testcontainers fails the first start with
+/// ResourceReaperException: Initialization has been cancelled after a minute. Disabling the
+/// reaper for this test process keeps the suite deterministic; set
+/// TESTCONTAINERS_RYUK_DISABLED=false to opt back in.
+///
+static class DockerTestEnvironment
+{
+ [ModuleInitializer]
+ internal static void Initialize()
+ {
+ if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("TESTCONTAINERS_RYUK_DISABLED")))
+ {
+ Environment.SetEnvironmentVariable("TESTCONTAINERS_RYUK_DISABLED", "true");
+ }
+ }
+}
diff --git a/src/tests/Modules.Docker.IntegrationTests/DockerTestEnvironment.cs b/src/tests/Modules.Docker.IntegrationTests/DockerTestEnvironment.cs
new file mode 100644
index 0000000..2ce3945
--- /dev/null
+++ b/src/tests/Modules.Docker.IntegrationTests/DockerTestEnvironment.cs
@@ -0,0 +1,20 @@
+using System.Runtime.CompilerServices;
+
+namespace Purview.Containers.Modules.Docker;
+
+///
+/// Test-process defaults for Testcontainers' resource reaper. See the Docker contract suite's
+/// DockerTestEnvironment: every container is disposed explicitly, and Ryuk's random public port
+/// collides with WSL relays often enough on Windows to fail the first start after a minute.
+///
+static class DockerTestEnvironment
+{
+ [ModuleInitializer]
+ internal static void Initialize()
+ {
+ if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("TESTCONTAINERS_RYUK_DISABLED")))
+ {
+ Environment.SetEnvironmentVariable("TESTCONTAINERS_RYUK_DISABLED", "true");
+ }
+ }
+}
diff --git a/src/tests/Modules.Docker.IntegrationTests/ModuleDockerTests.cs b/src/tests/Modules.Docker.IntegrationTests/ModuleDockerTests.cs
index deafdb4..62977c8 100644
--- a/src/tests/Modules.Docker.IntegrationTests/ModuleDockerTests.cs
+++ b/src/tests/Modules.Docker.IntegrationTests/ModuleDockerTests.cs
@@ -15,8 +15,11 @@ namespace Purview.Containers.Modules.Docker;
/// connection, and RabbitMQ, Azurite and NATS for their startup log lines. Each test skips itself when no
/// Docker daemon is reachable.
///
+[NotInParallel]
public class ModuleDockerTests
{
+ const string RequireBackendEnvironmentVariable = "PURVIEW_CONTAINERS_REQUIRE_BACKEND";
+
static int BackendRegistered;
static async Task SkipIfUnavailableAsync()
@@ -29,7 +32,21 @@ static async Task SkipIfUnavailableAsync()
var info = await DockerContainerBackend.Create().GetInfoAsync();
if (!info.IsUsable)
{
- throw new SkipTestException($"Docker is unavailable: {string.Join("; ", info.MissingComponents)}");
+ var reason = $"Docker is unavailable: {string.Join("; ", info.MissingComponents)}";
+ if (
+ string.Equals(
+ Environment.GetEnvironmentVariable(RequireBackendEnvironmentVariable),
+ "docker",
+ StringComparison.OrdinalIgnoreCase
+ )
+ )
+ {
+ throw new InvalidOperationException(
+ $"{reason} {RequireBackendEnvironmentVariable}=docker requires a reachable daemon."
+ );
+ }
+
+ throw new SkipTestException(reason);
}
}
diff --git a/src/tests/MsSql.IntegrationTests/MsSqlIntegrationTests.cs b/src/tests/MsSql.IntegrationTests/MsSqlIntegrationTests.cs
index 46a8159..43b6ff5 100644
--- a/src/tests/MsSql.IntegrationTests/MsSqlIntegrationTests.cs
+++ b/src/tests/MsSql.IntegrationTests/MsSqlIntegrationTests.cs
@@ -2,6 +2,7 @@
namespace Purview.Containers.MsSql;
+[NotInParallel]
public class MsSqlIntegrationTests
{
[Test]
diff --git a/src/tests/MySql.IntegrationTests/MySqlIntegrationTests.cs b/src/tests/MySql.IntegrationTests/MySqlIntegrationTests.cs
index 9550be4..6f032fb 100644
--- a/src/tests/MySql.IntegrationTests/MySqlIntegrationTests.cs
+++ b/src/tests/MySql.IntegrationTests/MySqlIntegrationTests.cs
@@ -2,6 +2,7 @@
namespace Purview.Containers.MySql;
+[NotInParallel]
public class MySqlIntegrationTests
{
[Test]
diff --git a/src/tests/Nats.IntegrationTests/NatsIntegrationTests.cs b/src/tests/Nats.IntegrationTests/NatsIntegrationTests.cs
index 49a2610..58b1b13 100644
--- a/src/tests/Nats.IntegrationTests/NatsIntegrationTests.cs
+++ b/src/tests/Nats.IntegrationTests/NatsIntegrationTests.cs
@@ -2,6 +2,7 @@
namespace Purview.Containers.Nats;
+[NotInParallel]
public class NatsIntegrationTests
{
[Test]
diff --git a/src/tests/Nats.UnitTests/NatsBuilderTests.cs b/src/tests/Nats.UnitTests/NatsBuilderTests.cs
index 1e4019f..d1357b3 100644
--- a/src/tests/Nats.UnitTests/NatsBuilderTests.cs
+++ b/src/tests/Nats.UnitTests/NatsBuilderTests.cs
@@ -14,6 +14,6 @@ public async Task BuildConfig_AppliesDefaults()
await Assert.That(configuration.PortBindings).Contains(p => p.ContainerPort == 4222 && p.AssignRandomHostPort);
await Assert.That(configuration.PortBindings).Contains(p => p.ContainerPort == 8222 && p.AssignRandomHostPort);
await Assert.That(configuration.WaitStrategies.Count).IsEqualTo(1);
- await Assert.That(configuration.WaitStrategies[0]).IsTypeOf();
+ await Assert.That(configuration.WaitStrategies[0]).IsTypeOf();
}
}
diff --git a/src/tests/PostgreSql.IntegrationTests/PostgreSqlIntegrationTests.cs b/src/tests/PostgreSql.IntegrationTests/PostgreSqlIntegrationTests.cs
index 81cda55..ef8b1ac 100644
--- a/src/tests/PostgreSql.IntegrationTests/PostgreSqlIntegrationTests.cs
+++ b/src/tests/PostgreSql.IntegrationTests/PostgreSqlIntegrationTests.cs
@@ -3,6 +3,7 @@
namespace Purview.Containers.PostgreSql;
+[NotInParallel]
public class PostgreSqlIntegrationTests
{
[Test]
diff --git a/src/tests/RabbitMq.IntegrationTests/RabbitMqIntegrationTests.cs b/src/tests/RabbitMq.IntegrationTests/RabbitMqIntegrationTests.cs
index e4e3d30..d30bf35 100644
--- a/src/tests/RabbitMq.IntegrationTests/RabbitMqIntegrationTests.cs
+++ b/src/tests/RabbitMq.IntegrationTests/RabbitMqIntegrationTests.cs
@@ -3,6 +3,7 @@
namespace Purview.Containers.RabbitMq;
+[NotInParallel]
public class RabbitMqIntegrationTests
{
[Test]
diff --git a/src/tests/RabbitMq.UnitTests/RabbitMqBuilderTests.cs b/src/tests/RabbitMq.UnitTests/RabbitMqBuilderTests.cs
index af100f8..97ab840 100644
--- a/src/tests/RabbitMq.UnitTests/RabbitMqBuilderTests.cs
+++ b/src/tests/RabbitMq.UnitTests/RabbitMqBuilderTests.cs
@@ -18,7 +18,7 @@ public async Task BuildConfig_AppliesDefaults()
await Assert.That(configuration.PortBindings).Contains(p => p.ContainerPort == 5672);
await Assert.That(configuration.PortBindings).Contains(p => p.ContainerPort == 15672);
await Assert.That(configuration.WaitStrategies.Count).IsEqualTo(1);
- await Assert.That(configuration.WaitStrategies[0]).IsTypeOf();
+ await Assert.That(configuration.WaitStrategies[0]).IsTypeOf();
}
[Test]
diff --git a/src/tests/Redis.IntegrationTests/RedisIntegrationTests.cs b/src/tests/Redis.IntegrationTests/RedisIntegrationTests.cs
index f322702..29ff196 100644
--- a/src/tests/Redis.IntegrationTests/RedisIntegrationTests.cs
+++ b/src/tests/Redis.IntegrationTests/RedisIntegrationTests.cs
@@ -2,6 +2,7 @@
namespace Purview.Containers.Redis;
+[NotInParallel]
public class RedisIntegrationTests
{
[Test]
diff --git a/src/tests/SharedTestingFramework/WslcTest.cs b/src/tests/SharedTestingFramework/WslcTest.cs
index f5c3dcb..b900e80 100644
--- a/src/tests/SharedTestingFramework/WslcTest.cs
+++ b/src/tests/SharedTestingFramework/WslcTest.cs
@@ -6,6 +6,13 @@ namespace Purview.Containers;
/// Shared helpers for integration tests that exercise real WSL Containers.
public static class WslcTest
{
+ ///
+ /// Set to wsl to make an unavailable or incompatible WSLC host fail the test run instead of
+ /// skipping it. CI sets this for the WSLC jobs so a mis-provisioned runner cannot go green with
+ /// zero coverage.
+ ///
+ public const string RequireBackendEnvironmentVariable = "PURVIEW_CONTAINERS_REQUIRE_BACKEND";
+
static int BackendRegistered;
///
@@ -28,17 +35,57 @@ public static async Task SkipIfUnavailableAsync()
var info = await WslContainerRuntime.Instance.GetInfoAsync();
if (!info.IsAvailable || !info.IsCompatible)
{
- throw new SkipTestException(
- $"WSL Containers unavailable. Missing: {string.Join(", ", info.MissingComponents)}"
- );
+ var reason = $"WSL Containers unavailable. Missing: {string.Join(", ", info.MissingComponents)}";
+ if (IsBackendRequired())
+ {
+ throw new InvalidOperationException(
+ $"{reason} {RequireBackendEnvironmentVariable}=wsl requires a usable WSLC host."
+ );
+ }
+
+ throw new SkipTestException(reason);
}
}
- /// Connects to a host TCP port, throwing when the timeout elapses.
+ ///
+ /// Connects to a host TCP port, retrying until elapses. A refused
+ /// connection fails immediately, so a single ConnectAsync is not a wait.
+ ///
public static async Task WaitForTcpAsync(int port, TimeSpan timeout)
{
- using System.Net.Sockets.TcpClient client = new();
- using CancellationTokenSource timeoutSource = new(timeout);
- await client.ConnectAsync(System.Net.IPAddress.Loopback, port, timeoutSource.Token);
+ var deadline = DateTime.UtcNow + timeout;
+ Exception? lastError = null;
+ while (DateTime.UtcNow < deadline)
+ {
+ try
+ {
+ using System.Net.Sockets.TcpClient client = new();
+ using CancellationTokenSource attemptSource = new(TimeSpan.FromSeconds(2));
+ await client.ConnectAsync(System.Net.IPAddress.Loopback, port, attemptSource.Token);
+ return;
+ }
+ catch (Exception exception) when (exception is not OperationCanceledException)
+ {
+ lastError = exception;
+ }
+ catch (OperationCanceledException)
+ {
+ // A per-attempt connect timeout is just another not-yet-listening result.
+ }
+
+ await Task.Delay(TimeSpan.FromMilliseconds(250));
+ }
+
+ throw new System.TimeoutException(
+ $"TCP port {port} did not become reachable within {timeout}. Last error: "
+ + $"{lastError?.GetType().Name}: {lastError?.Message}"
+ );
}
+
+ static bool IsBackendRequired() =>
+ string.Equals(
+ Environment.GetEnvironmentVariable(RequireBackendEnvironmentVariable),
+ "wsl",
+ StringComparison.OrdinalIgnoreCase
+ );
}
diff --git a/src/tests/Wsl.IntegrationTests/AlpineLifecycleTests.cs b/src/tests/Wsl.IntegrationTests/AlpineLifecycleTests.cs
index 3ce61ab..b2feb9f 100644
--- a/src/tests/Wsl.IntegrationTests/AlpineLifecycleTests.cs
+++ b/src/tests/Wsl.IntegrationTests/AlpineLifecycleTests.cs
@@ -1,3 +1,5 @@
+using Purview.Containers.Waiting;
+
namespace Purview.Containers.Wsl;
[Explicit]
@@ -11,6 +13,7 @@ public async Task EchoContainer_ProducesOutputAndCleansUp()
await using var container = new ContainerBuilder()
.WithImage("alpine:latest")
.WithCommand("/bin/echo", "hello-from-alpine")
+ .WithWaitStrategy(Wait.ForLogMessage("hello-from-alpine"))
.Build();
await container.StartAsync();
@@ -29,6 +32,7 @@ public async Task EnvironmentVariable_IsPassedToInitProcess()
.WithImage("alpine:latest")
.WithEnvironment("GREETING", "hello-env")
.WithCommand("/bin/sh", "-c", "echo $GREETING")
+ .WithWaitStrategy(Wait.ForLogMessage("hello-env"))
.Build();
await container.StartAsync();
diff --git a/src/tests/Wsl.IntegrationTests/PortMappingTests.cs b/src/tests/Wsl.IntegrationTests/PortMappingTests.cs
index 23fb4db..4bdff70 100644
--- a/src/tests/Wsl.IntegrationTests/PortMappingTests.cs
+++ b/src/tests/Wsl.IntegrationTests/PortMappingTests.cs
@@ -31,17 +31,48 @@ public async Task ExplicitHostPort_IsUsed()
{
await WslcTest.SkipIfUnavailableAsync();
- var hostPort = GetFreeTcpPort();
- await using var container = new ContainerBuilder()
- .WithImage("python:3-alpine")
- .WithPortBinding(8080, hostPort)
- .WithCommand("/bin/sh", "-c", "python3 -m http.server 8080")
- .Build();
+ // The port is chosen by probing and then released before the container binds it, so another
+ // process can take it in between. Retry a couple of times rather than failing the suite on a
+ // lost race; the point of the test is that an explicit mapping is honoured, not the probe.
+ Container? container = null;
+ ushort hostPort = 0;
+ try
+ {
+ for (var attempt = 0; attempt < 3 && container is null; attempt++)
+ {
+ var candidatePort = GetFreeTcpPort();
+ var candidate = new ContainerBuilder()
+ .WithImage("python:3-alpine")
+ .WithPortBinding(8080, candidatePort)
+ .WithCommand("/bin/sh", "-c", "python3 -m http.server 8080")
+ .Build();
+ try
+ {
+ await candidate.StartAsync();
+ container = candidate;
+ hostPort = candidatePort;
+ }
+ catch
+ {
+ await candidate.DisposeAsync();
+ if (attempt == 2)
+ {
+ throw;
+ }
+ }
+ }
- await container.StartAsync();
-
- await Assert.That(container.GetMappedPublicPort(8080)).IsEqualTo(hostPort);
- await WslcTest.WaitForTcpAsync(hostPort, TimeSpan.FromSeconds(30));
+ await Assert.That(container).IsNotNull();
+ await Assert.That(container!.GetMappedPublicPort(8080)).IsEqualTo(hostPort);
+ await WslcTest.WaitForTcpAsync(hostPort, TimeSpan.FromSeconds(30));
+ }
+ finally
+ {
+ if (container is not null)
+ {
+ await container.DisposeAsync();
+ }
+ }
}
[Test]
diff --git a/src/tests/Wsl.IntegrationTests/VolumeTests.cs b/src/tests/Wsl.IntegrationTests/VolumeTests.cs
index c392bd1..5857f2f 100644
--- a/src/tests/Wsl.IntegrationTests/VolumeTests.cs
+++ b/src/tests/Wsl.IntegrationTests/VolumeTests.cs
@@ -1,3 +1,5 @@
+using Purview.Containers.Waiting;
+
namespace Purview.Containers.Wsl;
[Explicit]
@@ -49,9 +51,9 @@ public async Task NamedVolume_IsSharedBetweenContainers(CancellationToken cancel
.WithImage("alpine:latest")
.WithVolumeMount(volume, "/data")
.WithCommand("/bin/sh", "-c", "echo shared-data > /data/file.txt; sleep 300")
+ .WithWaitStrategy(Wait.ForCommand("/bin/sh", "-c", "test -f /data/file.txt"))
.Build();
await writer.StartAsync(cancellationToken);
- await Task.Delay(1500, cancellationToken);
await using var reader = new ContainerBuilder()
.WithImage("alpine:latest")
diff --git a/src/tests/Wsl.IntegrationTests/WslIntegrationTestLimits.cs b/src/tests/Wsl.IntegrationTests/WslIntegrationTestLimits.cs
new file mode 100644
index 0000000..183387c
--- /dev/null
+++ b/src/tests/Wsl.IntegrationTests/WslIntegrationTestLimits.cs
@@ -0,0 +1,17 @@
+using TUnit.Core.Interfaces;
+
+[assembly: ParallelLimiter]
+
+namespace Purview.Containers.Wsl;
+
+///
+/// Caps how many WSLC integration tests run at once. Every test in this assembly starts real
+/// containers in the one process-wide session, and WSLC serialises the session operations anyway, so
+/// letting all 27 tests start containers together only adds image-pull and memory contention to the
+/// 4 GB session VM. Two keeps some overlap without making readiness timeouts a lottery.
+///
+public sealed record WslIntegrationParallelLimit : IParallelLimit
+{
+ ///
+ public int Limit => 2;
+}