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; +}