From 8022b45b96a54bf658cfaa3b0dd7e597685295ce Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 11 Nov 2026 14:53:17 +0000 Subject: [PATCH] fix: wsl session cleanup --- .../purview-architecture-testing/SKILL.md | 5 +- .agents/skills/purview-architecture/SKILL.md | 43 +- .../references/contract-strategy.md | 236 +++++++++ .../references/project-responsibilities.md | 22 +- .../purview-component-compatibility/SKILL.md | 116 +++++ .../references/lowest-compatible.md | 93 ++++ .../references/purview-components.md | 60 +++ .../scripts/dependencies.mjs | 239 +++++++++ .../purview-engineering-principles/SKILL.md | 39 +- .agents/skills/purview-org-harvest/SKILL.md | 197 ++++++++ .../references/best-in-class.md | 77 +++ .../references/harvest-commands.md | 121 +++++ .../purview-org-harvest/scripts/inventory.mjs | 466 ++++++++++++++++++ .agents/skills/purview-persistence/SKILL.md | 65 ++- .../references/ef-core-integration.md | 83 ++++ .../references/guid-generation.md | 26 +- .../references/postgresql.md | 44 ++ .../references/sqlserver.md | 49 +- .../references/well-known-identifiers.md | 93 ++++ .agents/skills/purview-telemetry/SKILL.md | 3 +- .config/dotnet-tools.json | 6 +- .config/purview-build.json | 14 +- .editorconfig | 2 +- .github/workflows/integration-wsl.yml | 12 +- .github/workflows/pr.yml | 18 +- .github/workflows/release.yml | 6 +- AGENTS.md | 68 ++- Directory.Packages.props | 18 +- Justfile | 6 +- README.md | 55 ++- docs/wiki/Architecture.md | 46 +- docs/wiki/Backend-Capabilities.md | 16 +- docs/wiki/Backends.md | 26 +- docs/wiki/Consumer-Requirements.md | 107 ++-- docs/wiki/Contributing-Modules.md | 7 +- docs/wiki/Contributing.md | 19 +- docs/wiki/Getting-Started.md | 13 +- docs/wiki/Home.md | 25 +- docs/wiki/Lifecycle.md | 4 +- docs/wiki/Modules.md | 4 +- docs/wiki/Networking.md | 31 +- docs/wiki/Packaging.md | 17 +- docs/wiki/Release-Flow.md | 5 +- docs/wiki/Testing.md | 6 +- docs/wiki/Using-in-Your-Tests.md | 10 +- global.json | 10 +- package.json | 2 +- samples/getting-started/README.md | 4 + scripts/verify-consumers.ps1 | 20 +- skills-lock.json | 52 +- .../purview-containers-capabilities/SKILL.md | 8 +- .../SKILL.md | 35 +- skills/purview-containers-lifecycle/SKILL.md | 11 +- skills/purview-containers-quickstart/SKILL.md | 9 +- skills/purview-containers-testing/SKILL.md | 4 +- src/Directory.Build.props | 13 +- .../AzureKeyVaultEmulatorCertificates.cs | 3 +- src/src/AzureKeyVaultEmulator/Sdk/README.md | 6 +- src/src/AzureServiceBusEmulator/Sdk/README.md | 6 +- src/src/Azurite/Sdk/README.md | 6 +- src/src/Containers/Sdk/README.md | 8 +- src/src/Core/ContainerBackends.cs | 7 +- src/src/Core/ContainerBuilder.cs | 41 +- src/src/Core/ContainerConfiguration.cs | 8 + src/src/Core/ContainerResourceArchive.cs | 24 +- src/src/Core/ContainerResourceMapping.cs | 9 +- src/src/Core/IContainerConfiguration.cs | 9 + src/src/Core/Images/Image.cs | 8 +- src/src/Core/Sdk/README.md | 2 +- src/src/Directory.Build.props | 12 +- src/src/Docker/DockerContainer.cs | 2 +- src/src/Docker/DockerContainerBackend.cs | 6 +- src/src/Docker/DockerFileCopy.cs | 10 +- src/src/Docker/Sdk/README.md | 5 +- src/src/Docker/WslDockerHost.cs | 2 +- .../ContainerEnvironmentResolver.cs | 38 +- src/src/Environment/ContainerProcess.cs | 3 +- src/src/MsSql/MsSqlBuilder.cs | 7 + src/src/MsSql/Sdk/README.md | 11 +- src/src/MySql/Sdk/README.md | 6 +- src/src/Nats/Sdk/README.md | 6 +- src/src/PostgreSql/Sdk/README.md | 6 +- src/src/RabbitMq/RabbitMqBuilder.cs | 27 + src/src/RabbitMq/Sdk/README.md | 6 +- src/src/Redis/Sdk/README.md | 6 +- src/src/Wsl/Sdk/README.md | 42 +- .../Purview.Containers.Wsl.props | 6 +- .../Purview.Containers.Wsl.targets | 9 +- src/src/Wsl/Wsl.csproj | 79 +-- src/src/Wsl/WslCommand.cs | 13 +- src/src/Wsl/WslContainer.cs | 7 +- src/src/Wsl/WslContainerBackend.Facade.cs | 2 +- src/src/Wsl/WslContainerBackend.cs | 8 +- src/src/Wsl/WslContainerResidue.cs | 110 +++++ src/src/Wsl/WslContainerRuntime.Facade.cs | 2 +- src/src/Wsl/WslContainerRuntime.cs | 105 +++- src/src/Wsl/WslContainerRuntimeOptions.cs | 12 +- src/src/Wsl/WslContainerSession.cs | 67 ++- src/src/Wsl/WslCreateArguments.cs | 6 + src/src/Wsl/WslFileCopy.cs | 3 +- src/src/Wsl/WslPayload.cs | 4 +- src/src/Wsl/WslResource.cs | 4 +- src/src/Wsl/WslSessionSweeper.cs | 271 ++++++++++ src/src/Wsl/WslSettingsMapper.cs | 30 +- src/tests/BackendContractTests.cs | 106 ++-- .../Wsl.IntegrationTests/NetworkTests.cs | 67 +++ .../Wsl.IntegrationTests/SessionSweepTests.cs | 27 + src/tests/Wsl.UnitTests/BuildFeatureTests.cs | 24 + .../ConsumerRequirementsTests.cs | 7 +- .../Wsl.UnitTests/ContainerBuilderTests.cs | 24 + .../Wsl.UnitTests/WslContainerResidueTests.cs | 26 + .../Wsl.UnitTests/WslSessionSweeperTests.cs | 81 +++ 112 files changed, 3680 insertions(+), 538 deletions(-) create mode 100644 .agents/skills/purview-architecture/references/contract-strategy.md create mode 100644 .agents/skills/purview-component-compatibility/SKILL.md create mode 100644 .agents/skills/purview-component-compatibility/references/lowest-compatible.md create mode 100644 .agents/skills/purview-component-compatibility/references/purview-components.md create mode 100644 .agents/skills/purview-component-compatibility/scripts/dependencies.mjs create mode 100644 .agents/skills/purview-org-harvest/SKILL.md create mode 100644 .agents/skills/purview-org-harvest/references/best-in-class.md create mode 100644 .agents/skills/purview-org-harvest/references/harvest-commands.md create mode 100644 .agents/skills/purview-org-harvest/scripts/inventory.mjs create mode 100644 .agents/skills/purview-persistence/references/ef-core-integration.md create mode 100644 .agents/skills/purview-persistence/references/well-known-identifiers.md create mode 100644 src/src/Wsl/WslContainerResidue.cs create mode 100644 src/src/Wsl/WslSessionSweeper.cs create mode 100644 src/tests/Wsl.IntegrationTests/NetworkTests.cs create mode 100644 src/tests/Wsl.IntegrationTests/SessionSweepTests.cs create mode 100644 src/tests/Wsl.UnitTests/BuildFeatureTests.cs create mode 100644 src/tests/Wsl.UnitTests/WslContainerResidueTests.cs create mode 100644 src/tests/Wsl.UnitTests/WslSessionSweeperTests.cs diff --git a/.agents/skills/purview-architecture-testing/SKILL.md b/.agents/skills/purview-architecture-testing/SKILL.md index 0619225..55d0ca8 100644 --- a/.agents/skills/purview-architecture-testing/SKILL.md +++ b/.agents/skills/purview-architecture-testing/SKILL.md @@ -9,6 +9,7 @@ license: MIT metadata: author: Purview-Dev version: "2.0" + repository: purview-dev/engineering-playbook --- # Purview architecture testing @@ -83,7 +84,7 @@ debugging the package. Then add a test that the documentation matches the policy, so the table in the wiki is falsifiable too. Full worked example: -[Project Responsibilities](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/Project-Responsibilities.md). +[Project Responsibilities](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/foundations/Project-Responsibilities.md). ## 3. Read project files, and assemblies @@ -180,4 +181,4 @@ project classified so every dependency rule still applies to it. Prefer a waiver - `purview-architecture` — what the projects are for, and how they are named - `purview-buildsdk-architecture-tests` — the package's own skill, authoritative on its API -- [Architecture as Tests](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/Architecture-As-Tests.md) +- [Architecture as Tests](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/foundations/Architecture-As-Tests.md) diff --git a/.agents/skills/purview-architecture/SKILL.md b/.agents/skills/purview-architecture/SKILL.md index 8d4b5a8..0c43596 100644 --- a/.agents/skills/purview-architecture/SKILL.md +++ b/.agents/skills/purview-architecture/SKILL.md @@ -2,13 +2,15 @@ name: purview-architecture description: >- Use when deciding the shape of a .NET solution that uses Purview libraries — how many projects, - whether to add a repository or CQRS layer, where validation lives — or before generating substantial - implementation code. Establishes project context first, then applies DRY, KISS and architecture - proportional to the actual requirements rather than to an enterprise reference diagram. + whether to add a repository or CQRS layer, where validation lives, how an API and its clients share + a contract (Contracts, OpenAPI or Hybrid) and when a BFF is earned — or before generating + substantial implementation code. Establishes project context first, then applies DRY, KISS and + architecture proportional to the actual requirements rather than to an enterprise reference diagram. license: MIT metadata: author: Purview-Dev version: "1.0" + repository: purview-dev/engineering-playbook --- # Purview architecture @@ -110,7 +112,34 @@ The rules that matter most: When a boundary is worth having, make it fail the build rather than the review — see `purview-architecture-testing`. -## 5. DRY, applied where it pays +## 5. Choose how an API and its clients share a contract + +When there is an HTTP API and at least one client, choose **Contracts**, **OpenAPI** or **Hybrid** +from the clients that **exist today**. A planned client is noted; it does not count. + +| Clients today | Strategy | Failure it prevents | +| -- | -- | -- | +| All .NET, same solution, shipped in lockstep | **Contracts** — a library of request/response types on the domain's value objects, referenced by the server and every .NET client | Rules drifting, and a weaker second copy of them in the UI | +| At least one non-.NET client, or one that ships independently | **Hybrid** — Contracts is the source; the OpenAPI document and its schemas are generated from it | Non-.NET clients hand-writing shapes, and .NET clients losing their value objects to a generated client | +| No .NET client — partners or the public only | **OpenAPI**, generated from the server's types; no Contracts project | A project with no consumer but the server | + +The rules that do not bend: + +- **OpenAPI is an output, never an input**, while Contracts exists. Never regenerate a C# client from + your own API's document when that client is in the same solution. +- **Never hand-maintain a parallel set of models**, and **the server always validates** — a shared + validator is for user experience, not trust. +- **Contracts is browser-safe** when a WebAssembly client references it: architecture-policy bans, + the trim and AOT analyzers and the browser platform analyzer check it, not a reviewer. +- **A BFF is earned by keeping tokens out of the browser.** It holds the tokens, gives the browser an + `HttpOnly` cookie, forwards with the user's token, and requires an anti-forgery header on every + forwarded call. In Blazor it is the host that serves the WebAssembly client, and a BFF that only + forwards does not reference Contracts. The policy does not define a BFF yet, so these are review + obligations until a future Build SDK release adds Blazor and BFF support. + +Full procedure, version-skew rules, BFF guidance and worked examples: `references/contract-strategy.md`. + +## 6. DRY, applied where it pays Reuse the Purview libraries when they remove a real source of truth, not for coverage: @@ -126,7 +155,7 @@ Duplication that matters: validation repeated per entry point, a domain rule res configuration bound in two places, two types that both claim to be the truth. Duplication that does not: two similar-looking methods that answer to different owners. -## 6. Telemetry is not an advanced topic +## 7. Telemetry is not an advanced topic Recommend instrumentation from the first commit, at every project size. A small application gets small telemetry — it does not get none. See `purview-telemetry`. @@ -136,6 +165,8 @@ small telemetry — it does not get none. See `purview-telemetry`. - Did you read the four context dimensions rather than ask for them? - Can you name, for each project and each abstraction you are proposing, the failure it prevents here? - Is anything in your proposal there only because a reference architecture has it? +- If there is an API with clients, did you choose its contract strategy from the clients that exist + today? - Did you include telemetry? ## See also @@ -143,4 +174,4 @@ small telemetry — it does not get none. See `purview-telemetry`. - `purview-telemetry` — instrumentation at every scale - `purview-persistence` — storage and GUID-key guidance, only when persistence is real - [Engineering Principles](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/Engineering-Principles.md) -- [Adopting This At Your Scale](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/Adopting-This-At-Your-Scale.md) +- [Adopting This At Your Scale](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/governance/Adopting-This-At-Your-Scale.md) diff --git a/.agents/skills/purview-architecture/references/contract-strategy.md b/.agents/skills/purview-architecture/references/contract-strategy.md new file mode 100644 index 0000000..62b5a17 --- /dev/null +++ b/.agents/skills/purview-architecture/references/contract-strategy.md @@ -0,0 +1,236 @@ +# Contract strategy: Contracts, OpenAPI or Hybrid + +When a solution has an HTTP API and one or more clients, something has to define the shapes that cross +between them. There are three ways to do it, and the wrong one fails in one of two directions: + +- **Rules drift.** The client holds a second, weaker copy of the domain rules, and a request the UI + accepted is rejected by the server — or a break only surfaces at runtime. +- **Decoupling bought in advance.** A .NET client in the same solution regenerates its models from + the API's own OpenAPI document, and throws away every value object and rule to buy an independence + the solution does not have. + +The strategy is chosen from the clients that **exist today**. A planned client is noted; it does not +count. "Later" and "in case" do not justify a strategy any more than they justify a project — see +`proportional-architecture.md`. + +## The three strategies + +### Contracts + +A .NET class library holding the request and response types, built on the domain's value objects and +carrying `[ZodSchema]`. The server references it, and so does every .NET client — including a Blazor +WebAssembly client. + +There is one copy of every rule, error code and normalisation, and both sides run the same generated +validators. + +### OpenAPI + +The server publishes an OpenAPI document, and clients generate their models and calls from it. It is +language-neutral and versions independently. + +Generated models are primitives. Value objects, custom ZodSharp rules, error codes, and trimming and +casing are lost; only what JSON Schema can express survives. + +### Hybrid + +Contracts is the **single source of truth**. The OpenAPI document is **generated from it**, with its +component schemas taken from ZodSharp's export — the generated `{T}Schema.BuildJsonSchema()` is built +from the same metadata as the validator, so the document and the validators cannot drift. + +.NET clients reference Contracts. Non-.NET clients generate from the document and take their +validation rules from the exported JSON Schema — for TypeScript, `z.fromJSONSchema` (Zod 4) reads it +back as Zod schemas. The `purview-zodsharp-json` and `purview-zodsharp-source-generator` skills are +authoritative on the export. + +Three facts about the export that a Hybrid design has to plan for: + +- **ZodSharp ships no OpenAPI integration.** It produces JSON Schema; putting that into the OpenAPI + document — for example with a schema transformer in `Microsoft.AspNetCore.OpenApi` — is the API + host's job. Without it, ASP.NET Core describes a value object from its CLR shape, not from its rules, + and the document and the validators drift after all. +- **A non-.NET client still gets only what JSON Schema can express.** A value object exports as its + underlying value, and a rule the exporter cannot represent is listed under `x-zodsharp-unsupported` + rather than dropped. The contract test should fail when that list grows. +- **Exported objects set `additionalProperties: false`.** A client that validates a *response* against + that schema rejects a field the server has added — so "additive changes only" is not safe until + clients tolerate unknown response fields. Validate requests strictly and responses leniently, or the + versioning rule below protects nobody. (The reverse also holds: System.Text.Json ignores unknown + members, so the server accepts a request with an extra key that the schema rejects.) + +## Decision procedure + +1. **List the clients that exist today.** For each, record: + - its language and runtime; + - who owns it; + - whether it lives in the same repository and solution; + - whether it ships **in lockstep** with the server or on its own cadence. + + A planned client is noted, but it does not count yet. +2. **Every client is .NET, in the same solution, and ships in lockstep → Contracts.** This prevents: + - rules drifting between client and server; + - a second, weaker copy of the domain rules in the UI; + - contract breaks that only surface at runtime. + + Publish OpenAPI anyway if documentation or tooling wants it, but generate it from Contracts. It is + an output, never an input. +3. **At least one client is not .NET, or ships independently → Hybrid.** This prevents: + - non-.NET clients hand-writing shapes and rules; + - the .NET clients losing their value objects and rules to a generated client. + + It requires the independent-versioning rules under [Version skew](#version-skew). +4. **No .NET client exists, or the API is a surface whose clients the team does not own (public or + partner) → OpenAPI**, still generated from the server's types. A separate Contracts project is not + earned when the server is its only consumer. +5. **A .NET client in another repository with its own release cadence** prefers Contracts shipped as + a package, if that team accepts the dependency. Otherwise treat it as step 3. + +```text +flowchart TD + A["List the clients that exist today"] --> C{"Every client .NET,
same solution, lockstep?"} + C -- yes --> K["Contracts
OpenAPI only as an output"] + C -- no --> B{"Any .NET client
at all?"} + B -- no --> O["OpenAPI
generated from the server's types"] + B -- yes --> D{"The only other clients are .NET
in another repository,
and they accept a package?"} + D -- yes --> P["Contracts, shipped as a package"] + D -- no --> H["Hybrid
Contracts is the source, OpenAPI generated from it"] +``` + +Steps 3 and 4 do not overlap: a .NET client in the solution alongside a partner or non-.NET client is +step 3, because the .NET client still has value objects and rules to lose. + +## Rules that follow + +- **Never regenerate a C# client from your own API's OpenAPI document when that client is in the same + solution.** It throws away value objects and rules to buy decoupling the solution does not have. +- **Never hand-maintain a parallel set of models** on either side. Two definitions of one wire shape + disagree eventually, and nothing fails when they start to. +- **The server always validates.** Sharing validators with a client is for user experience and + consistency. It is never a substitute for validating on the server, because the client is untrusted. +- **Moving from Contracts to Hybrid must be additive.** Because Contracts is already the source, adding + a non-.NET client means turning on generation of the OpenAPI document and a contract test. Nothing in + Contracts changes. Design for that, but do not build it until the client exists. + +## Contracts project constraints + +- **References only the domain library (value objects) and the runtime Purview packages** — + `Purview.ValueObjects`, `Purview.ZodSharp`, `Purview.Results`. Never the server, persistence, HTTP + clients or hosting, and never an ASP.NET Core bridge such as `Purview.ZodSharp.AspNetCore`; those + belong to the API host. A Contracts project that references the server is a server that every client + now ships. +- **Browser-safe when a WebAssembly client references it.** No file, network or process I/O, safe to + trim, and no reflection-dependent behaviour. The same applies transitively to the domain library, + because the client downloads it too. Make the build enforce it, not a reviewer — three mechanisms, + each catching what the others cannot: + - **Your repository's architecture policy** forbids `Microsoft.AspNetCore` packages + (`forbiddenPackagePrefixes`) and the `Microsoft.AspNetCore.App` framework reference + (`forbiddenFrameworkReferences`) in `Contracts` and `Core`. The playbook's own policy forbids the + packages in `Core` only; the rest arrives with the Build SDK's planned Blazor support, so add it + yourself until then. See `purview-architecture-testing`. + - **`true`** on both projects turns on the trim and AOT analyzers, + so reflection a trimmed client would break on is a warning at build time. `Purview.ValueObjects` + and `Purview.ZodSharp` already set it. + - **``** makes the platform-compatibility analyzer (`CA1416`) + flag an API that is unsupported in the browser, such as starting a process. + + The policy rules fail a test run. The two analyzers produce warnings, and the SDK does not turn on + `TreatWarningsAsErrors` for an ordinary project — set it on `Contracts` and `Core`, or the "clean + build has no warnings" rule is the only thing standing behind them. No analyzer proves the absence of + every kind of I/O; what they miss is a review obligation, and the client's own tests running in the + browser are the last check. +- **Deserialisation mode is a deliberate choice per direction.** The server reads requests through + `Create` (strict), because the client is untrusted. Record what the client uses for responses, and + why — strict catches a server that has drifted, and `Hydrate` trusts a server the client cannot + verify. + +## Version skew + +| Shipping | Strategy | Failure it prevents | What to do | +| -- | -- | -- | -- | +| Lockstep | Contracts, internal apps | A silent failure that looks like a bug, when an old cached client talks to a new server | The server stamps its build on responses; the client prompts the user to reload when the stamps differ | +| Independent | Hybrid, OpenAPI | A deployed client breaking without warning | Additive changes only; a breaking change gets a new route version; a contract test compares the generated document against the last published one and fails on a breaking difference | + +## Backend-for-Frontend + +A BFF is a host, not a layer, and it is held to the same justification test as anything else. +Microsoft's [Blazor Web App with OIDC and the BFF pattern](https://learn.microsoft.com/aspnet/core/blazor/security/blazor-web-app-with-oidc?pivots=with-bff-pattern) +is the authority on the mechanics; what follows is the rules and the failures behind them. + +### What earns it + +- **Keeping tokens out of the browser.** The BFF signs the user in (OpenID Connect), holds the access + and refresh tokens, and gives the browser only an authentication cookie. Script in the page — a + compromised dependency, an XSS hole — cannot read a token that never reached it. This prevents tokens + being stolen from browser storage. +- **It forwards with the user's access token, never its own identity.** The API validates that token + exactly as it would any other, so authorisation and the audit trail stay per-user. A BFF calling with + a client-credentials token turns every action into the BFF's and moves authorisation into the BFF. + +### In a Blazor solution, the BFF is the host + +The project that serves the WebAssembly client is the natural BFF: it already runs on the server, at +the client's origin. Do not add a separate BFF in front of it. + +- **It references the client project** (`Bff → WebApp`) because it serves the client's assets. The + client never references it; they talk over HTTP with the cookie. +- **Same origin is the point.** The client calls the BFF's own routes, the BFF forwards them to the API + (with YARP's `MapForwarder`, for example), and there is no CORS configuration and no third-party + cookie. +- **The client shares Contracts with the API directly.** The WebAssembly client references Contracts, + sends Contracts types, and runs the generated validators. The BFF in the middle passes the bytes + through untouched. + +### Rules that come with a cookie + +Moving the credential into a cookie trades token theft for a different attack, and the BFF has to +close it: + +- **Every forwarded call requires an anti-forgery signal.** The browser attaches the cookie to any + request to the BFF's origin, including one a hostile page triggers. Require a custom header the + client always sends, which a cross-site form cannot set, and reject calls without it — Duende.BFF's + `X-CSRF: 1` is the established form. `SameSite` narrows the attack; it does not replace the check. + This prevents cross-site request forgery. +- **The cookie is `HttpOnly`, `Secure` and `SameSite=Strict` or `Lax`.** `HttpOnly` is what keeps + script from reading it; without it the BFF protects nothing. +- **Data Protection keys are persisted and shared** when the BFF runs on more than one instance or + survives a restart. The cookie is encrypted with them, so per-instance keys sign users out at random. +- **Refresh happens on the server.** The BFF refreshes the access token before it expires and signs + the user out when refresh fails. The client never sees either token. +- **A large session moves to a server-side store.** Tokens kept inside the cookie make it large enough + to be split across several cookies, and the session cannot be revoked server-side. Use a ticket store + when either matters. + +### What it may reference + +- **A BFF that only forwards does not reference Contracts** and never reads payloads. Reading a payload + it does not own makes it a second consumer that has to change in lockstep for no benefit. +- **Prerendering is the common exception.** If the BFF prerenders client components that load data, + it must run those data calls on the server, and it references Contracts to do so. That is a real + reason; turning prerendering off for those components is the alternative, and the choice should be + written down. +- **A BFF that combines several API calls into one response owns its own contract** for those + responses, chosen with this same procedure. That is a separate justification. The forwarding-only + test — "a service layer that forwards" is not earned — still applies to everything else it does. +- **One BFF per client kind, not one per screen.** Web and mobile may each get one when their sign-in + or response needs differ. + +**None of this is enforced yet.** The architecture policy has no BFF responsibility; Blazor and BFF +support is planned for a future Build SDK release. Until then every rule in this section is a review +obligation, and a repository that adds a BFF project records it in its own policy — see +`project-responsibilities.md`. Even once it is in the policy, "Contracts only when it prerenders or +combines" stays a review obligation, because a dependency matrix cannot express a condition. + +## Worked examples + +| Clients today | Strategy | +| -- | -- | +| Blazor WebAssembly app, same solution, lockstep, signed in through a BFF | Contracts, referenced by the API and the WebAssembly client. The BFF is the host that serves the client, holds the tokens and forwards with the user's token; it references Contracts only if it prerenders data. OpenAPI generated for documentation. | +| Blazor WebAssembly app plus a React Native mobile app on its own release cadence | Hybrid. Mobile generates from the document and takes Zod rules from the exported schema. Additive-only versioning. A mobile BFF if sign-in differs. | +| Partner integrations only | OpenAPI, generated from the server's types. No Contracts project. | + +## See also + +- [Contracts and Data Exchange](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/data/Contracts-And-Data-Exchange.md) + — `Create` and `Hydrate`, and the schema as the source of truth for a wire shape +- [zodsharp](https://github.com/purview-dev/zodsharp) — authoritative on `[ZodSchema]` and JSON Schema + export diff --git a/.agents/skills/purview-architecture/references/project-responsibilities.md b/.agents/skills/purview-architecture/references/project-responsibilities.md index 1bf0a1a..dc4e67c 100644 --- a/.agents/skills/purview-architecture/references/project-responsibilities.md +++ b/.agents/skills/purview-architecture/references/project-responsibilities.md @@ -3,7 +3,7 @@ The catalogue in brief. The authoritative definition is [`.config/architecture-policy.json`](https://github.com/purview-dev/engineering-playbook/blob/main/.config/architecture-policy.json), and the full page — with diagrams — is -[Project Responsibilities](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/Project-Responsibilities.md). +[Project Responsibilities](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/foundations/Project-Responsibilities.md). Do not restate either here or in another skill; link to them. ## The catalogue @@ -15,7 +15,7 @@ Do not restate either here or in another skill; link to them. | `Core` | `Domain` | Entities, value objects, invariants, domain events and errors | Shared | | `Abstractions` | — | Cross-boundary capability contracts | Core, Shared | | `Application` | `UseCases` | Use cases, workflows, orchestration | Core, Abstractions, Contracts, Shared | -| `Contracts` | — | Externally exposed request/response types | Core, Shared | +| `Contracts` | — | Externally exposed request/response types; browser-safe when a WebAssembly client references it | Core, Shared | | `Persistence` | — | DbContext, mappings, migration *definitions* | Core, Abstractions, Application, Shared | | `Providers` | `Integrations` | Integration with a *named external platform* | Core, Abstractions, Application, Shared | | `Adapters` | — | An *application-defined capability* via one technology | Core, Abstractions, Application, Shared | @@ -25,13 +25,26 @@ Do not restate either here or in another skill; link to them. | `Migrator` | — | *Executes* migrations and controlled seeding | Persistence, Composition, ServiceDefaults | | `AppHost` | — | Aspire orchestration | The hosts it orchestrates | | `ServiceDefaults` | — | Telemetry, health, resilience, discovery defaults | Shared | -| `WebApp` | — | Frontend | Contracts, ServiceDefaults, Shared | +| `WebApp` | — | Frontend, including a Blazor WebAssembly client that references Contracts | Contracts, ServiceDefaults, Shared | | `Shared` | — | Small domain-independent primitives | *nothing* | **None of these is mandatory.** A small application with `Core` and one host has a complete and correct architecture. The catalogue names responsibilities so you can tell when splitting one out buys something — it is not a list of projects to create. +### Backend-for-Frontend: guidance, not yet policy + +A BFF is a host responsibility that the policy does **not** define yet; Blazor and BFF support is +planned for a future Build SDK release. Until then it is guidance, and every rule below is a review +obligation: + +| Owns | May depend on | Split it out when | Leave it combined when | +| -- | -- | -- | -- | +| Sign-in and token custody for a browser or mobile client; forwards with the user's token. In Blazor, the host that serves the WebAssembly client | The WebAssembly client project, ServiceDefaults, Shared — and Contracts only when it prerenders data or combines responses | Tokens must be kept out of the browser, or several API calls must become one response | It would only forward calls that the client could make with a token it already holds safely | + +A repository that adds a BFF project today needs its own responsibility entry or a waiver in its +policy, or the architecture tests reject the project as unclassified. See `contract-strategy.md`. + ## When to split | Responsibility | Split it out when | Leave it combined when | @@ -39,8 +52,7 @@ buys something — it is not a list of projects to create. | `Core` | A domain model exists | The app is a thin utility | | `Abstractions` | Two implementations, or the consumer must not see the implementation | One implementation — keep the interface beside it | | `Application` | Workflows are reused by more than one host | It would only forward CRUD | -| `Contracts` | A client compiles against it without taking the server | The API host is the only consumer | -| `Persistence` | Persistence exists at all | A single-project utility writing a file | +| `Contracts` | The contract strategy is Contracts or Hybrid — see `contract-strategy.md` | The API host is the only consumer, which is the OpenAPI strategy || `Persistence` | Persistence exists at all | A single-project utility writing a file | | `Providers` | Two platforms with different SDKs or lifecycles | One platform | | `Adapters` | One capability, several technologies | One capability, one technology | | `Composition` | Two hosts compose the same implementations | One host — compose in the host | diff --git a/.agents/skills/purview-component-compatibility/SKILL.md b/.agents/skills/purview-component-compatibility/SKILL.md new file mode 100644 index 0000000..b017681 --- /dev/null +++ b/.agents/skills/purview-component-compatibility/SKILL.md @@ -0,0 +1,116 @@ +--- +name: purview-component-compatibility +description: >- + Use when developing, reviewing or releasing a published component - a NuGet package, or an npm + package - and choosing which dependency version to declare, when a consumer reports a version + conflict or an unexpected upgrade, or when a component's supported target frameworks change. Applies + the golden rule that a published component declares the LOWEST compatible version rather than the + latest, walks the Purview component hierarchy to find each floor, and verifies it by building + against it. Not for applications: they take the latest compatible version instead. +license: MIT +metadata: + author: Purview-Dev + version: "1.0" + repository: purview-dev/engineering-playbook +--- + +# Purview component compatibility + +A published component makes a promise with every version it declares: *this is the least I require of +you*. Raise that number and every consumer is pulled up with you, whether or not they wanted it. + +**The golden rule: declare the lowest version that compiles, passes your tests, and is supported on +every target framework you claim.** The version you happen to have installed is never the answer, and +neither is the latest. + +## Which direction? + +| What you are publishing | Declare | Why | +| -- | -- | -- | +| an application, a test suite, a tool — it runs, and nobody installs it | the **latest** compatible version | you own the runtime, and a version you skip is a fix you did not take | +| a component — a NuGet or npm package somebody else installs | the **lowest** compatible version | dependency resolution takes the highest minimum in the graph, so your floor becomes theirs | + +This skill is about the second row. If the repository only ever runs, stop here and take the latest. + +## Why a floor matters + +NuGet and npm resolve a graph to the **highest** minimum among all constraints. If a package declares +`Testcontainers` 4.16 because 4.16 is what its author had installed, every consumer is forced onto 4.16, +including the one whose own constraint is 4.0 and whose runtime is pinned to it. The consumer cannot +refuse an upgrade the package never needed to ask for, and the symptom appears in *their* build, not +yours. + +## The workflow + +### 1. Name the component and its frameworks + +The floor is per target framework, not per package. Write down every `TargetFramework` the component +publishes; a multi-targeting component has a different floor on each, and the one that matters is the +oldest framework still supported, not the newest one you build against. + +### 2. Read the hierarchy, do not remember it + +The dependencies — and their minimums — are in the published `.nuspec`, not in anyone's memory: + +```bash +node scripts/dependencies.mjs Purview.Containers +node scripts/dependencies.mjs Purview.Architecture.Testing --tfm net11.0 +node scripts/dependencies.mjs Purview.Containers --walk +``` + +A bare version is a **minimum**; a bracketed one (`[1.2.3]`, `[1.0,2.0)`) is a range. The script prints +the distinction, because it is the whole rule: a minimum you raise is a minimum every consumer inherits. + +`references/purview-components.md` records which Purview package depends on which, and which target +frameworks each family supports. Where the two disagree the registry wins — that file is the map, the +`.nuspec` is the territory. + +### 3. Fix the floor, per framework + +For each dependency, find the oldest version that: + +- exposes every API the component calls (the compiler decides this, not the changelog); +- passes the component's own tests and its consumer-verification suite; +- is supported on every framework in that dependency group. + +Then declare that version. Not `latest`, not floating (`1.*`), and never the version that resolved on +your machine by accident. + +### 4. Prove the floor + +A floor nobody builds against is a guess. Add a CI leg that restores the component with every dependency +held at its declared floor and runs the suite — the ordinary leg keeps resolving the latest, which is +what a consumer will actually get. `references/lowest-compatible.md` has the mechanics. + +### 5. Keep the ceiling visible, and the floor written down + +A component's README carries a badge for the current release and a sentence naming the floor it +supports. Badges are generated from the registry, so they cannot go stale; a version written into prose +is wrong within one release. + +## Pitfalls that produce a floor nobody meant + +| Pitfall | What it looks like | The fix | +| -- | -- | -- | +| Declaring what is installed | `dotnet add package X` writes the latest; right for an application, wrong for a component | edit the version down, then prove it in step 4 | +| Transitive uplift | a dependency pulls in a newer transient package than your floor allows | override it deliberately — the persistence sample does this for a security-patched package — and comment why | +| One framework's floor | a `net10.0-windows` group needs a higher floor than `net10.0` | read each group; never average them | +| Prerelease in the graph | a family that ships prereleases depends on them at the same version | pin the family to one property so the floor moves together | +| Analyzer and source-generator packages | compile-time only, so they rarely constrain a consumer | still declare a real floor; they appear in the graph and in transitive audits | +| Floating versions | `1.*` resolves differently on every restore | banned: a floor is a number | + +## Checks before you answer + +- Is each declared version the lowest that passes, or the one that happened to be installed? +- Does every declared target framework have a floor that compiles on that framework? +- Was the floor proved by a build, or only by your machine? +- Does any page state a version number? It should be a badge or a link to the registry. +- If a floor was raised, is that a breaking change the version number admits to? + +## See also + +`references/lowest-compatible.md` for the CI mechanics and the override patterns, and +`references/purview-components.md` for the Purview hierarchy. The rule itself, and the application-side +half of it, is stated in the Engineering Playbook's Dependency Versions page; the +`purview-engineering-principles` skill summarises it as one of the eleven conventions. + diff --git a/.agents/skills/purview-component-compatibility/references/lowest-compatible.md b/.agents/skills/purview-component-compatibility/references/lowest-compatible.md new file mode 100644 index 0000000..ccdb3b6 --- /dev/null +++ b/.agents/skills/purview-component-compatibility/references/lowest-compatible.md @@ -0,0 +1,93 @@ +# Proving the floor + +The rule is "declare the lowest compatible version". This file is the mechanics: how to find the floor, +how to prove it in a build, and the patterns that keep it from silently rising. + +## What "compatible" means, precisely + +A version is a valid floor when all three hold: + +1. **It compiles against every API the component uses.** The compiler decides this, not a changelog. If + you cannot say which member forced the version up, you have not found the floor. +2. **The component's own tests pass against it**, including the consumer-verification suite, which + builds a throwaway consumer rather than the component's own test project. +3. **It is supported on every target framework in that dependency group.** A floor is per group, and a + multi-targeting component can have several. + +## Finding it + +```bash +dotnet list package --outdated # what you declared against what exists +dotnet list package --include-transitive # what actually resolved +dotnet nuget why # which path pulled a version in +``` + +Then work downward: lower the declared version, build, and read the compiler. The first version that +compiles *and* passes is the floor. Do not stop at the first failure and pick the version above it — +that is how a floor ends up one minor above what the code needs. + +## Making the floor a build leg + +Keep one property per family (so a family cannot be bumped halfway) and make each conditional on a floor +switch. Nothing here is NuGet-specific magic: it is MSBuild, and central package management already +gives you the single place to put it. + +```xml + + LATEST + FLOOR + +``` + +```bash +dotnet restore -p:DependencyFloor=true +dotnet build -p:DependencyFloor=true --no-restore +dotnet test -p:DependencyFloor=true --no-build +``` + +`LATEST` and `FLOOR` are literals in the file; the point is that they are two properties, not two files. +Run the ordinary leg at `LATEST` and a second, slower leg at `FLOOR`, and give the floor leg its own CI +job so a failure names the floor rather than the build. + +Where a component multi-targets, add the framework dimension and run the pair per framework: + +```bash +for tfm in net8.0 net9.0 net10.0; do + dotnet build -p:DependencyFloor=true -f "$tfm" +done +``` + +## Transitive pins + +A floor can be raised by something you never declared. Transitive pinning puts those versions where you +can see them: + +```xml + + true + +``` + +Then an override is a deliberate line with a reason beside it — the persistence sample in this +repository does exactly that for a security-patched package the SQL client drags in. An override without +a comment is indistinguishable from an accident, and the next person deletes it. + +## The ceiling, and the API surface + +- The floor leg proves the **bottom**; the ordinary leg proves the **top**. Both, or you know neither. +- An API-compatibility check against the previous release catches the other direction of the same + mistake: a floor can stay put while the public surface grows a member that only the newest dependency + provides. `Microsoft.DotNet.ApiCompat.Task` wires that into the build; `Microsoft.DotNet.ApiCompat.Tool` + runs it from a pipeline. +- A prerelease dependency is a floor with an expiry: it cannot be consumed by anyone who has not opted + into prereleases. Say so in the package's README rather than letting the error message explain it. + +## What not to do + +| Temptation | Why it is wrong | +| -- | -- | +| `dotnet add package X` and commit | it writes the latest; correct for an application, wrong for a component | +| floating versions (`1.*`) | resolves differently on every restore, so the floor is nobody's decision | +| "it restores on my machine" | that proves one graph, on one date, with one SDK | +| raising a floor in a patch release | the consumer's build changes without their version changing | +| one floor leg for all frameworks | the framework with the lowest floor is the one nobody tested | diff --git a/.agents/skills/purview-component-compatibility/references/purview-components.md b/.agents/skills/purview-component-compatibility/references/purview-components.md new file mode 100644 index 0000000..6e13b65 --- /dev/null +++ b/.agents/skills/purview-component-compatibility/references/purview-components.md @@ -0,0 +1,60 @@ +# The Purview component hierarchy + +Which Purview package depends on which, and which target frameworks each family supports, so a harvest +or a floor decision starts from the shape rather than from a memory of it. + +Every row was read from the registry with `scripts/dependencies.mjs`, which reads the published +`.nuspec`. **The registry is the authority; this file is the map.** Re-run the script before acting on +any row — a family that multi-targets changes its floors on every release. + +## The families + +| Family | Packages | Target frameworks | Depends on | +| -- | -- | -- | -- | +| Build SDK | `Purview.BuildSdk` | `netstandard2.0` (an MSBuild SDK) | nothing | +| Test-support rule sets | `Purview.Skills.Testing`, `Purview.Architecture.Testing`, `Purview.Architecture.Testing.Cli` | `net10.0`; `Architecture.Testing` also `net11.0` | `TUnit.Core`, `TUnit.Assertions`, `JsonSchema.Net` | +| Telemetry | `Purview.Telemetry.SourceGenerator` | `netstandard2.0` (an analyzer) | nothing | +| Containers | `Purview.Containers` + `.Core`, `.Docker`, `.Wsl`, `.MsSql`, `.PostgreSql` | `net10.0`; `.Wsl` adds `net10.0-windows10.0.19041` | its own family at the same version, `Microsoft.Extensions.Telemetry.Abstractions`, `Testcontainers` | +| Results | `Purview.Results`, `.AspNetCore`, `.ZodSharp`, `.ZodSharp.AspNetCore` | `net10.0`, `net11.0` | nothing; the adapters depend on the core and on ZodSharp | +| ZodSharp | `Purview.ZodSharp`, `.AspNetCore`, `.SystemTextJson`, `.NewtonsoftJson` | `net9.0`, `net10.0`, `net11.0` | `Microsoft.Extensions.Options`, `Microsoft.Extensions.DependencyInjection.Abstractions` | +| Value Objects | `Purview.ValueObjects` | `net8.0`, `net9.0`, `net10.0`, `net11.0` | nothing | +| Source Generator Framework | `Purview.SourceGeneratorFramework`, `.Testing`, `.Testing.TUnit` | `netstandard2.0` | Roslyn (`Microsoft.CodeAnalysis.CSharp`), `System.Collections.Immutable`, `System.Reflection.Metadata` | +| Aspire ResourceKit | `Purview.Aspire.ResourceKit` | `net8.0`, `net9.0`, `net10.0` | `Aspire.Hosting`, OpenTelemetry exporter and hosting, `Microsoft.Extensions.*` | +| Astroc4 | `@purview-dev/astroc4` (npm, not NuGet) | an Astro/Starlight plugin | `likec4`, `@astrojs/starlight`, `astro` as peers | + +## Reading the shape + +Three patterns are worth internalising, because each one changes what "the floor" means: + +- **A family ships at one version.** `Purview.Containers.Core` is required at the umbrella's own version, + and `Purview.Results.ZodSharp` requires `Purview.Results` and `Purview.ZodSharp` at their current ones. + Declare the family with one property, so its floor moves as a unit. +- **An analyzer has no consumer-side floor worth arguing about.** `Purview.BuildSdk`, + `Purview.Telemetry.SourceGenerator` and `Purview.SourceGeneratorFramework` target `netstandard2.0` and do + their work at compile time. They still appear in a transitive audit, so they still get a real version. +- **A multi-targeting component has a different floor per framework**, and ZodSharp shows it most + clearly: the `net11.0` group declares older `Microsoft.Extensions.*` minimums than the `net10.0` group, + because the lower framework carries the newer requirements. Reading one group and applying it to + another is how a floor is set wrong in the direction that breaks consumers. + +## What the script prints + +```text +Purview.Containers 1.0.0-prerelease.11 + net10.0: Purview.Containers.Core >= 1.0.0-prerelease.11 + net10.0: Purview.Containers.Docker >= 1.0.0-prerelease.11 + net10.0: Purview.Containers.Wsl >= 1.0.0-prerelease.11 + net10.0: Microsoft.Extensions.Telemetry.Abstractions >= 10.11.0 + net10.0: Testcontainers >= 4.16.0 + Purview.Containers.Core 1.0.0-prerelease.11 + net10.0: Microsoft.Extensions.Telemetry.Abstractions >= 10.11.0 +``` + +A bare version is a minimum; brackets would mean a range. The numbers above are what the registry said +when this page was written — the shape is the part that lasts, which is why the command is here and the +versions are not presented as targets. + +## Related components that are not Purview packages + +`AspireC4.Hosting` is published under its own name and versions on its own schedule, so it is not part of +a Purview family floor. Read it the same way, with the same script. diff --git a/.agents/skills/purview-component-compatibility/scripts/dependencies.mjs b/.agents/skills/purview-component-compatibility/scripts/dependencies.mjs new file mode 100644 index 0000000..3069ef6 --- /dev/null +++ b/.agents/skills/purview-component-compatibility/scripts/dependencies.mjs @@ -0,0 +1,239 @@ +#!/usr/bin/env node +/** + * Prints a package's dependency groups exactly as NuGet declares them, so the floor a component + * inherits can be read rather than guessed. + * + * The golden rule of package development is to declare the LOWEST compatible version, and that decision + * needs the real graph: which dependency, at what minimum, on which target framework. This script reads + * it from the registry - the `.nuspec` the published package carries - instead of from a table that + * would be stale by the next release. + * + * Zero dependencies: it uses the platform `fetch`, so it runs anywhere Node 18 or later does. + * + * node dependencies.mjs Purview.Containers + * node dependencies.mjs Purview.Architecture.Testing --tfm net11.0 + * node dependencies.mjs Purview.Containers --walk # recurse into Purview.* dependencies + * node dependencies.mjs Purview.Results --json + * + * An npm package is not a NuGet id; read it with `npm view versions --json`. + * + * Exit codes: 0 on success, 1 when the package, version or network is not available. + */ + +/** The NuGet flat-container root: the same host that serves the package's own `.nuspec`. */ +const REGISTRY = "https://api.nuget.org/v3-flatcontainer"; + +/** How deep `--walk` follows dependencies before it stops. */ +const MAX_DEPTH = 3; + +/** True when a version string carries a prerelease label. */ +function isPrerelease(version) { + return version.includes("-"); +} + +/** Every published version of a package, oldest first. */ +async function versionsOf(id) { + const response = await fetch(`${REGISTRY}/${id.toLowerCase()}/index.json`); + if (!response.ok) { + throw new Error(`${id} is not on nuget.org (HTTP ${response.status}).`); + } + + const body = await response.json(); + return body.versions; +} + +/** The version to describe: the one asked for, else the newest stable, else the newest anything. */ +function chooseVersion(available, requested) { + if (requested) { + const match = available.find((version) => version.toLowerCase() === requested.toLowerCase()); + if (!match) { + throw new Error(`'${requested}' is not a published version. Newest is ${available.at(-1)}.`); + } + + return match; + } + + const stable = available.filter((version) => !isPrerelease(version)); + return stable.at(-1) ?? available.at(-1); +} + +/** The package's `.nuspec`, which is what a consumer's restore actually reads. */ +async function readNuspec(id, version) { + const name = id.toLowerCase(); + const response = await fetch(`${REGISTRY}/${name}/${version.toLowerCase()}/${name}.nuspec`); + if (!response.ok) { + throw new Error(`No .nuspec for ${id} ${version} (HTTP ${response.status}).`); + } + + return response.text(); +} + +/** The value of one XML attribute on a tag, or null. */ +function attribute(tag, name) { + const match = new RegExp(`${name}\\s*=\\s*"([^"]*)"`).exec(tag); + return match ? match[1] : null; +} + +/** + * How a nuspec version string constrains a consumer: a bare version is a MINIMUM, and a bracketed one is + * a range. This is the distinction the whole rule turns on, because a minimum you raise is a minimum + * every consumer inherits. + */ +function classify(range) { + if (!range) { + return "any"; + } + + return range.startsWith("[") || range.startsWith("(") ? "range" : "minimum"; +} + +/** The dependency elements inside a fragment, normalized. */ +function dependenciesIn(fragment) { + const dependencies = []; + const pattern = /]*)\/?>/g; + let match; + + while ((match = pattern.exec(fragment)) !== null) { + const tag = match[1]; + const version = attribute(tag, "version"); + dependencies.push({ id: attribute(tag, "id"), version, constraint: classify(version) }); + } + + return dependencies; +} + +/** The dependency groups a nuspec declares, one per target framework. */ +function dependencyGroups(nuspec) { + const groups = []; + const pattern = /]*)>([\s\S]*?)<\/group>/g; + let match; + + while ((match = pattern.exec(nuspec)) !== null) { + groups.push({ + framework: attribute(match[1], "targetFramework") ?? "(any)", + dependencies: dependenciesIn(match[2]), + }); + } + + if (groups.length === 0) { + const flat = dependenciesIn(nuspec); + if (flat.length > 0) { + groups.push({ framework: "(any)", dependencies: flat }); + } + } + + return groups; +} + +/** A printable name for a target framework, spelled the way the ecosystem usually writes it. */ +function shortFramework(framework) { + return framework + .replace(/^\.NETStandard/i, "netstandard") + .replace(/^\.NETCoreApp/i, "net") + .replace(/^\.NETFramework/i, "netframework") + .toLowerCase(); +} + +/** Prints one package's groups, recursing into Purview components when asked. */ +async function describe(id, requestedVersion, options, depth, seen) { + const version = chooseVersion(await versionsOf(id), requestedVersion); + const groups = dependencyGroups(await readNuspec(id, version)); + const indent = " ".repeat(depth); + + console.log(`${indent}${id} ${version}`); + + for (const group of groups) { + const framework = shortFramework(group.framework); + if (options.tfm && framework !== options.tfm.toLowerCase()) { + continue; + } + + if (group.dependencies.length === 0) { + console.log(`${indent} ${framework}: no dependencies`); + continue; + } + + for (const dependency of group.dependencies) { + const relation = dependency.constraint === "minimum" ? ">=" : dependency.constraint; + console.log( + `${indent} ${framework}: ${dependency.id} ${relation} ${dependency.version ?? "any"}`, + ); + } + + if (options.walk && depth < MAX_DEPTH) { + for (const dependency of group.dependencies) { + const isPurview = /^purview\./i.test(dependency.id ?? ""); + if (!(options.all || isPurview) || seen.has(dependency.id.toLowerCase())) { + continue; + } + + seen.add(dependency.id.toLowerCase()); + await describe(dependency.id, null, options, depth + 1, seen); + } + } + } + + return { id, version, groups }; +} + +/** Parses the command line. An unknown argument is a mistake worth failing on. */ +function parseArguments(argv) { + const options = { packages: [], tfm: null, walk: false, all: false, json: false }; + + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + + if (argument === "--walk" || argument === "-w") { + options.walk = true; + } else if (argument === "--all") { + options.all = true; + } else if (argument === "--json") { + options.json = true; + } else if (argument === "--tfm") { + options.tfm = argv[(index += 1)]; + if (!options.tfm) { + throw new Error("--tfm needs a value, e.g. --tfm net10.0."); + } + } else if (argument.startsWith("-")) { + throw new Error(`Unknown argument '${argument}'.`); + } else { + options.packages.push(argument); + } + } + + if (options.packages.length === 0) { + throw new Error( + "Name at least one package, e.g. `node dependencies.mjs Purview.Containers`.\n" + + "A package may carry a version: `Purview.Containers@1.0.0-prerelease.11`.", + ); + } + + return options; +} + +/** The command. */ +async function main() { + const options = parseArguments(process.argv.slice(2)); + const results = []; + + for (const entry of options.packages) { + const separator = entry.indexOf("@", 1); + const id = separator === -1 ? entry : entry.slice(0, separator); + const version = separator === -1 ? null : entry.slice(separator + 1); + + results.push(await describe(id, version, options, 0, new Set([id.toLowerCase()]))); + + if (!options.walk && !options.json) { + console.log(""); + } + } + + if (options.json) { + console.log(JSON.stringify(results, null, "\t")); + } +} + +main().catch((error) => { + console.error(`error: ${error.message}`); + process.exitCode = 1; +}); diff --git a/.agents/skills/purview-engineering-principles/SKILL.md b/.agents/skills/purview-engineering-principles/SKILL.md index ba4dd9b..24fe707 100644 --- a/.agents/skills/purview-engineering-principles/SKILL.md +++ b/.agents/skills/purview-engineering-principles/SKILL.md @@ -4,8 +4,9 @@ description: >- Use when setting up, reviewing or rationalising a .NET repository against the Purview engineering conventions - convention over configuration, derived identity, architecture as tests, warnings as errors, proving consumer-facing claims, documentation ownership, test data builders, agent - legibility, and tiering verification by risk. Each rule names the failure it prevents and whether a - build or a reviewer enforces it. + legibility, tiering verification by risk, the version policy for applications and for published + components, and the conditional persistence rules. Each rule names the failure it prevents and + whether a build or a reviewer enforces it. metadata: product: Purview engineering playbook repository: purview-dev/engineering-playbook @@ -13,7 +14,7 @@ metadata: # Purview engineering principles -Ten rules, each with the failure it prevents and the mechanism that makes it true. The distinction +Eleven rules, each with the failure it prevents and the mechanism that makes it true. The distinction that matters throughout: **a convention a build can check is checked by the build; a convention only a reviewer can check is labelled a review obligation and never described as enforced.** A rule that claims enforcement it does not have is worse than no rule, because people stop looking. @@ -100,8 +101,9 @@ repository's own tests already are the consumer. ## 7. Documentation is a build artefact of the repository -The wiki lives in the repository it describes, in `docs/wiki`, and the repository owns its -`mkdocs.yml`, its navigation and its `edit_uri`. +The wiki lives in the repository it describes, in `docs/wiki`, as plain Markdown. The repository owns no +site configuration at all: `_Sidebar.md` is the navigation, `Home.md` is the hub, and the site that +publishes them lives in one place outside the repository. Documentation that lives elsewhere is updated elsewhere. A page with no owner and no review path is wrong within one release. @@ -154,6 +156,23 @@ hand-written attribute. *At your scale:* one tier is fine until the slow tests exist. Then split by project name, not by attribute. +## 11. A version is a range, not a number + +An application takes the latest version its runtime supports. A published component declares the +**lowest** version that compiles, passes its tests and satisfies the contract it documents, on each +target framework it claims — because dependency resolution is a set of lower bounds, so the floor a +component declares becomes the floor every consumer inherits. + +A page states no release number. It shows a badge generated from the registry, or links to the +registry, so a version cannot be wrong within a release. + +*Enforced by:* a lowest-compatible CI leg that restores against the declared floor, and an +API-compatibility check per target framework. Nothing enforces the floor of a package that has no +consumer yet — that tier arrives with the first release. + +*At your scale:* with one application there is nothing to earn yet; take the latest and let +`dotnet list package --outdated` report the rest. The golden rule starts at the first published package. + ## Applying these in review Three questions catch most of it: @@ -164,8 +183,16 @@ Three questions catch most of it: 3. **Could an agent — or a new joiner — find this without being told?** If not, it goes in `AGENTS.md` or the wiki. +## The conditional persistence rules + +Three further rules apply only when a repository actually stores data: a value known before storage is +derived with UUIDv5, every other GUID key uses one strategy per store, and EF Core is the persistence +boundary. They are not part of the eleven because a stateless CLI or a pure library must not inherit them. +`purview-persistence` owns them and stays silent when persistence is not real. + ## Related skills `purview-architecture` for project-shape decisions, `purview-architecture-testing` for turning a boundary into a failing build, `purview-telemetry` and `purview-persistence` for the per-concern -guidance. +guidance, and `purview-component-compatibility` for deciding and verifying a published component's +lowest compatible dependency versions. diff --git a/.agents/skills/purview-org-harvest/SKILL.md b/.agents/skills/purview-org-harvest/SKILL.md new file mode 100644 index 0000000..2c05ea1 --- /dev/null +++ b/.agents/skills/purview-org-harvest/SKILL.md @@ -0,0 +1,197 @@ +--- +name: purview-org-harvest +description: >- + Use when a Purview repository's conventions may have fallen behind the organisation - + inventorying every public purview-dev repository at main, deciding which repository is + best-in-class for each concern before adopting anything, and recording the result in a dated + snapshot. Always reads the remote: a local clone is a cache, and it may be stale, renamed or + missing entirely. +license: MIT +metadata: + author: Purview-Dev + version: "1.0" + repository: purview-dev/engineering-playbook +--- + +# Purview organisation harvest + +Seventeen public repositories, each with its own wiki, its own `skills/` folder and its own `AGENTS.md`. +They are not equally up to date, and the one you have cloned is not necessarily the one that has the +answer. Two failures follow, and this skill exists to prevent both. + +**Copying from a stale clone.** A clone is a cache with no expiry. The convention you read there may +be two months behind the one the owner now documents, and nothing about the local file says so. + +**Copying from the wrong owner.** "How do we ship?" is answered by `build`. "How do we keep a boundary +honest?" is answered by `architests`. Asking a library that does neither produces a confident answer +with no evidence, which is worse than no answer. + +So: establish membership, read at `main`, name one owner per concern, write down what you found. + +## 1. Scope + +Name two things before reading anything: + +| Input | Example | +| -- | -- | +| The repository being changed | `containers` | +| The concern in question | "how a test reaches a real database" | + +A harvest without a named concern becomes a tour, and a tour produces a list of differences nobody +acted on. The concern is what makes "best-in-class" a decision rather than a preference. + +## 2. Discover membership + +`gh repo list` is the membership authority. A catalogue, a README table, an `llms.txt` and a local +folder listing are all convenience indexes: they are maintained by hand, so they under-count, and the +omission is silent. + +```bash +gh auth status +gh repo list purview-dev --limit 200 \ + --json name,visibility,isArchived,pushedAt,defaultBranchRef +``` + +Drop `isArchived` and anything that is not `PUBLIC`. **If `gh auth status` fails, stop.** Degrading to +the local clone is the exact failure this skill prevents, and it degrades silently. + +The filter denies by default: a repository must be exactly `PUBLIC` and exactly not archived to be +recorded, so a visibility you have never seen fails closed. `gh repo list` also only lists what your +token can see, which is why the token's visibility and the file's contents are two separate questions: +the section below is about the second one. + +A local clone is admissible only when its `HEAD` equals the SHA the snapshot recorded: + +```bash +git -C ../containers rev-parse HEAD +``` + +That is a speed optimisation, never a substitute. + +## 3. Read it at `main` + +Every repository is read at `main`, and the commit is recorded. The snapshot is what makes the claim +checkable a month later, when nobody remembers which commit the analysis saw. + +```bash +node scripts/inventory.mjs # drift between the organisation and the snapshot +node scripts/inventory.mjs --write # regenerate .config/org-inventory.json +node scripts/inventory.mjs --repo containers +``` + +The script is deterministic and dependency-free: it shells out to `gh`, so it reuses the CLI's own +authentication, and it runs the same on Windows and on a Linux agent — it is Node, not a shell script, +so there is no PowerShell edition and no Bash edition to keep in step. It writes +`.config/org-inventory.json` in the repository you run it from — generated, never hand-edited. + +To inspect a decision without a network call, replay a recorded harvest with `--from` and `--details`. +That is the same script, not a variant, so what you review is what runs. + +## 4. Harvest in two tiers + +Seventeen repositories is not a reading list; it is a budget. Two tiers keep it affordable. + +| Tier | Call | Answers | +| -- | -- | -- | +| 1 — shape | `gh api repos/purview-dev//git/trees/main?recursive=1`, for all seventeen | Which folders exist, how many pages the wiki has, whether `skills/` is authored, whether `AGENTS.md` exists. Cheap enough for everything. | +| 2 — content | `gh api -H 'Accept: application/vnd.github.raw' repos/purview-dev//contents/`, **only for the owner of the concern** | What the convention actually says. | + +Tier 1 tells you where the evidence is. Tier 2 reads it, in the one repository that wrote it down. + +```bash +gh api repos/purview-dev/architests/git/trees/main?recursive=1 --jq '.tree[].path' +gh api -H 'Accept: application/vnd.github.raw' \ + repos/purview-dev/architests/contents/docs/wiki/Architecture-As-Tests.md +``` + +More invocations, and what each one is for: `references/harvest-commands.md`. + +## 5. Classify: one owner per concern + +Pick exactly one owner per concern, from `references/best-in-class.md`. Two signs the classification +is wrong: + +- **Two owners for one concern.** `build-sdk` and this playbook's `purview-engineering-principles` + both answer "review this repository". That overlap is a finding to resolve, not a tie to break by + preferring whichever you read first. +- **An owner that has never run the thing it documents.** Check the tree before trusting the page. + +## 6. Divergence register + +Write the differences down before changing anything, with an adopt-or-reject for each. A divergence +with no decision recorded is the same divergence next quarter. + +| Column | Answers | +| -- | -- | +| Concern | What is being compared | +| Owner | The repository the convention comes from, and the path it is stated at | +| Here | What this repository does now | +| Decision | Adopt, or reject with the reason | +| Mechanism | The build rule, test or review obligation that keeps it true | + +Rejecting is a legitimate outcome and is usually the right one at a smaller scale. What is not +legitimate is adopting a convention whose failure the repository cannot have. + +## 7. Adopt + +A convention arrives as a set of changes that land together, or it is a claim with no mechanism: + +```text +the page states the rule, why, how it is enforced, and what a smaller repository does instead +src/ demonstrates it, or says why it does not +the snapshot records the commit it was read at +the skill points at the owner, and never paraphrases it +``` + +**Link to the owner; do not restate it.** A paraphrase is a second source of truth, and the copy with +no owner is the one that loses. + +## 8. Verify + +```bash +just build # zero warnings +just test # architecture, skill and documentation rules +just lint-check +node scripts/inventory.mjs --print +``` + +If the harvest changed what the snapshot should record, regenerate it and commit the diff. The +snapshot being current is a review obligation rather than a build failure — a build that goes red on +a Tuesday because a clock passed is a build people disable. + +## Private repositories + +A private repository may inform a maintainer's judgement. It is never evidence, and it is never +written down. This is not a preference, and it is not a heuristic an agent gets to weigh against a +good argument. Five statements, in order of how often they are broken: + +1. A committed snapshot contains public, non-archived repositories only. The writer denies by default + and refuses to persist anything else, and a test asserts it. +2. A private repository may be read when `gh` is authenticated for it, and what it teaches may shape + a rule. +3. Nothing published names it: not the snapshot, not the page, not the skill, not a workflow, not a + config, not a commit message. Attribute the rule to "a Purview application repository", or to + nothing. +4. **If a rule cannot be justified without the private source, it is not published.** +5. **If you cannot say where a name came from, do not write the name.** A repository the snapshot + does not list is either private or gone, and both fail the same test. + +The fourth is the one an agent rationalises around, because the evidence is often excellent. A rule +whose only justification is out of reach of the people it is published to cannot be reviewed by them, +which makes it folklore with a citation they cannot follow. + +## Checks before you answer + +- Did you read membership from the organisation rather than from a catalogue or a folder? +- Is every claim about a repository true at the SHA the snapshot recorded, not at your clone? +- Did you name exactly one owner per concern, and resolve any overlap rather than duplicating it? +- Did you reject anything? A harvest with no rejections usually means nothing was understood. +- Does every change you propose have a mechanism, or is it labelled a review obligation? +- Is every repository you named public? + +## See also + +- `references/best-in-class.md` — which repository owns which concern, and where to read it +- `references/harvest-commands.md` — the `gh` invocations, and the trap in each one +- `purview-engineering-principles` — the conventions themselves, once you know which owner to read +- `purview-architecture` — the project-shape decisions a harvest most often touches diff --git a/.agents/skills/purview-org-harvest/references/best-in-class.md b/.agents/skills/purview-org-harvest/references/best-in-class.md new file mode 100644 index 0000000..9ab69cc --- /dev/null +++ b/.agents/skills/purview-org-harvest/references/best-in-class.md @@ -0,0 +1,77 @@ +# Best-in-class, by concern + +One owner per concern. Read the owner, not the nearest repository that happens to have a wiki. + +Every row was read from the organisation with `gh repo list purview-dev` and +`gh api repos//git/trees/main?recursive=1`. The page counts and skill counts are the shape at +the time of writing, not a promise: they are what the snapshot records, and the snapshot is +regenerated rather than trusted. + +## The matrix + +| Concern | Owner | Read it at | +| -- | -- | -- | +| Repository shape, MSBuild defaults, analyzers, skill authoring | `build-sdk` | `docs/wiki`, `templates/`, `skills/purview-buildsdk-*` | +| Pipeline, release, pack validation | `build` | `docs/wiki`, `skills/purview-build-*` | +| Architecture policy: dependency direction, naming rules | `architests` | `docs` (nine pages; `Policy-Reference.md` is the reference, `Extending.md` the how-to), `skills/purview-architests-*` | +| Local dependencies in tests | `containers` | `docs/wiki`, `skills/purview-containers-*` | +| TypeScript contracts generated from C# | `zodsharp` | `docs/wiki`, `skills/purview-zodsharp-*` | +| Logging, tracing, metrics | `telemetry-sourcegenerator` | `docs/wiki`, `skills/purview-telemetry-*` | +| Event streams and event storage | `event-sourcing` | `docs/wiki` — **no skills folder** | +| Operation outcomes without exceptions | `results` | `docs/wiki`, `skills/purview-results-*` | +| Validated identity types | `value-objects` | `docs` — **no `docs/wiki`** | +| Aspire hosting resources | `aspire-resourcekit` | `docs/wiki`, `skills/purview-resourcekit-*` | +| Architecture diagrams generated from source | `aspirec4` | `docs/wiki`, `skills/purview-aspirec4-*` | +| LikeC4 diagrams inside an Astro or Starlight site | `astroc4` | `docs/wiki`, `skills/purview-astroc4-*` — an npm package, not .NET | +| Source generator authoring | `sourcegenerator-framework` | `docs/wiki`, `skills/purview-sourcegenerator-*` | +| Skill-set design, and the dated-snapshot pattern | `pm-skills` | `skills/pm-*` — **no documentation folder at all** | +| OpenAPI generated from XML comments | `openapi-xmlcomments` | `docs/wiki`, `skills/purview-openapi-xmlcomments-*` | +| The reusable conventions themselves | `engineering-playbook` | `docs/wiki`, `skills/purview-*` | +| The public project catalogue | `purview-dev.github.io` | `src/src/data/projects/*.yml` | + +`purview-dev.github.io` is the catalogue, and a catalogue is an index rather than a membership list: +an entry is added by hand, so a repository missing from it is silent. `gh repo list` is the authority. + +## The overlaps worth knowing about + +**`build-sdk` and `engineering-playbook` both answer "review this repository".** They are not +duplicates, and the split is worth stating because a harvest will hit it immediately: + +- `build-sdk` owns *what the tooling does* — project detection, the props and targets, the analyzer + set, the packaged test-support rule sets. +- `engineering-playbook` owns *why a repository is shaped that way, and what it costs* — the rule, + the failure it prevents, and what a smaller repository does instead. + +A question about a property name goes to `build-sdk`. A question about whether a project should exist +goes here. + +**`containers` and `aspire-resourcekit` both look like "local dependencies".** `containers` owns the +test-time resource — a real SQL Server, PostgreSQL or queue — and `aspire-resourcekit` owns the +development-time orchestration. A test that needs a database is `containers`; an AppHost that needs +one is `aspire-resourcekit`. + +**`aspirec4` and `astroc4` both draw LikeC4 diagrams.** `aspirec4` owns the diagram generated from a +running Aspire resource graph; `astroc4` owns putting a `.c4` model inside an Astro or Starlight site — +the fence transform and the LikeC4 syntax highlighting. A question about the .NET host is `aspirec4`; a +question about the documentation site is `astroc4`, which is the one owner here that is not a .NET +package, so it is read for its TypeScript conventions rather than for its MSBuild ones. + +## Reading a repository you have not cloned + +Tier 1 first — the shape, for everything: + +```bash +gh api repos/purview-dev//git/trees/main?recursive=1 --jq '.tree[].path' +``` + +Then Tier 2 — the content, for the one owner: + +```bash +gh api -H 'Accept: application/vnd.github.raw' \ + repos/purview-dev//contents/docs/wiki/_Sidebar.md +``` + +`_Sidebar.md` is the fastest index of a wiki, because the repository's own documentation rule says a +page that is not in it does not exist. Where a repository publishes no wiki, its `AGENTS.md` is the +next best entry point, and where it has neither, read `skills/*/SKILL.md` — the trigger in each +`description` says what the repository thinks it is for. diff --git a/.agents/skills/purview-org-harvest/references/harvest-commands.md b/.agents/skills/purview-org-harvest/references/harvest-commands.md new file mode 100644 index 0000000..f4b4653 --- /dev/null +++ b/.agents/skills/purview-org-harvest/references/harvest-commands.md @@ -0,0 +1,121 @@ +# Harvest commands + +Every invocation here is safe to run read-only, and each one is followed by the trap that cost +somebody time. Both shells are shown because the harvest runs on a developer's Windows machine and on +a Linux agent, and the quoting rules are not the same. + +## Membership + +```bash +gh auth status +gh repo list purview-dev --limit 200 --json name,visibility,isArchived,pushedAt,defaultBranchRef +``` + +```powershell +gh auth status +gh repo list purview-dev --limit 200 --json name,visibility,isArchived,pushedAt,defaultBranchRef | + ConvertFrom-Json | Sort-Object name | + Format-Table name, visibility, isArchived, pushedAt +``` + +**Trap: the default limit is 30.** Without `--limit` the list is silently short, and a short list is +worse than a failed one because the omission never announces itself. + +**Trap: `gh repo list` shows what your token can see.** An organisation member sees private +repositories too; filter to `visibility -eq 'PUBLIC'` and `-not isArchived` before recording anything. + +## The SHA a convention was read at + +```bash +gh api repos/purview-dev//git/ref/heads/main --jq .object.sha +``` + +**Trap: a renamed repository resolves by redirect.** Asking for a repository under its former name +answers `purview-dev/purview-dev.github.io` and exits 0, so a stale name never fails. Compare the +returned `full_name` with the name you asked for. + +## Tier 1: the shape + +```bash +gh api repos/purview-dev//git/trees/main?recursive=1 --jq '.tree[].path' +``` + +**Trap: `truncated`.** GitHub caps a recursive tree, and the response says so with `"truncated": true` +rather than erroring. A truncated tree under-counts skills and pages, so check the flag before +believing a zero. + +**Trap: the tree lists blobs and trees.** Filter to `type == "blob"` when counting files, or a folder +whose name matches your pattern is counted as its contents. + +## Tier 2: the content + +```bash +gh api -H 'Accept: application/vnd.github.raw' \ + repos/purview-dev//contents/docs/wiki/_Sidebar.md +``` + +**Trap: `contents` returns JSON unless you ask for raw.** Without the `Accept` header the response is +a base64 `content` field, and the analysis quietly proceeds on a JSON document. + +**Trap: `contents` caps at 1000 files for a directory listing.** Use the tree API for enumerating and +`contents` for reading one file. + +## Windows PowerShell, specifically + +**Embedded double quotes are stripped from a native command's arguments.** This looks correct and is +not: + +```powershell +gh repo list purview-dev --jq '.[] | [.name, (.pushedAt // "-")] | @tsv' +``` + +`gh` receives `// -` and answers `failed to parse jq expression ... unexpected token`. Either keep the +jq program free of string literals, or do the shaping in PowerShell: + +```powershell +$repos = gh repo list purview-dev --limit 200 --json name,visibility | ConvertFrom-Json +$repos | Sort-Object name | ForEach-Object { '{0}|{1}' -f $_.name, $_.visibility } +``` + +**A filtered result is a scalar when it is one item.** `(... | Where-Object { ... }).Count` returns +nothing rather than `1`, so `.Count` in a summary line reads as empty. Wrap it: `@(...).Count`. + +## The snapshot + +```bash +node skills/purview-org-harvest/scripts/inventory.mjs +node skills/purview-org-harvest/scripts/inventory.mjs --write +node skills/purview-org-harvest/scripts/inventory.mjs --repo containers --write +``` + +It resolves the snapshot path from `git rev-parse --show-toplevel`, so it writes into the repository +you are standing in rather than into the one the script was installed from. That is what lets another +repository adopt it unchanged. + +`--repo` merges on write, so reading one repository does not erase the other sixteen. + +## Replaying a harvest, with no network + +Two flags replace the two `gh` calls. This is how the repository's own tests exercise the script on +every platform without touching GitHub, and how a reviewer reproduces what a harvest decided: + +```bash +node skills/purview-org-harvest/scripts/inventory.mjs \ + --from recorded-membership.json \ + --details recorded-details.json \ + --print +``` + +`--from` takes exactly what `gh repo list --json name,visibility,isArchived,pushedAt,defaultBranchRef` +printed. `--details` takes an object keyed by repository name, each with the `commitSha` a tier-1 +harvest found and the `paths` the tree contained. A repository that is in the membership and missing +from the details is an error, not a silent omission — a replay that quietly skipped a repository would +look like a clean run. + +Two behaviours worth knowing before you trust a run: + +- **A snapshot carrying a byte-order mark is accepted.** It is the one file in this workflow a person + is likely to open and re-save, and a BOM is not a corruption. +- **A snapshot containing anything that is not public and unarchived is refused**, and nothing is + written. This includes an entry a hand edit left behind, which the merge would otherwise carry + forward: delete it and re-run `--write`. diff --git a/.agents/skills/purview-org-harvest/scripts/inventory.mjs b/.agents/skills/purview-org-harvest/scripts/inventory.mjs new file mode 100644 index 0000000..affc46f --- /dev/null +++ b/.agents/skills/purview-org-harvest/scripts/inventory.mjs @@ -0,0 +1,466 @@ +#!/usr/bin/env node +/** + * Snapshots every public, non-archived `purview-dev` repository at `main` into + * `.config/org-inventory.json`, and reports the drift between that snapshot and the organisation. + * + * The snapshot is what makes the harvest rule checkable offline: `OrgInventoryTests` reads the file + * the pipeline has, never the network. This script is the only thing that writes it, so a repository + * cannot appear in the snapshot by being remembered. + * + * Zero dependencies: it shells out to `gh` and so reuses the CLI's own authentication rather than + * asking for a token. + * + * node inventory.mjs # print the drift against the snapshot (default) + * node inventory.mjs --write # rewrite the snapshot from the organisation + * node inventory.mjs --repo containers # narrow to one repository; merges on --write + * node inventory.mjs --out # write somewhere else, e.g. while adopting the skill + * node inventory.mjs --help + * + * Exit codes: 0 on success, including when drift is found - a stale snapshot is a review obligation, + * not a red pull request for whoever happened to push next. 1 when `gh` is missing, unauthenticated, + * or the organisation cannot be read. + */ +import { execFileSync } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; + +/** The organisation this playbook describes. */ +const ORG = "purview-dev"; + +/** The branch a convention is read at. A local clone is a cache; this is the authority. */ +const BRANCH = "main"; + +/** Bumped when a field is added or its meaning changes. */ +const SCHEMA_VERSION = 1; + +/** Past this age the snapshot is reported as stale. Advisory: nothing fails on a clock. */ +const STALE_AFTER_DAYS = 30; + +/** Files a repository may put at its root to guide an agent. Recorded in this order. */ +const AGENT_GUIDANCE_FILES = ["AGENTS.md", "CLAUDE.md", ".github/copilot-instructions.md", "llms.txt"]; + +/** Directories a published documentation site is read from, most specific first. */ +const DOCS_DIRECTORIES = ["docs/wiki", "docs"]; + +/** + * The concern each repository is best-in-class for, so a harvest reads one owner instead of sixteen + * repositories. `references/best-in-class.md` is the long form of this table and is the file to edit + * first; this map only records the answer in the snapshot. + */ +const CONCERNS = new Map([ + ["architests", ["architecture policy", "dependency and naming rules"]], + ["aspire-resourcekit", ["Aspire hosting resources"]], + ["aspirec4", ["architecture diagrams from source"]], + ["astroc4", ["architecture diagrams in an Astro site"]], + ["build", ["pipeline", "release"]], + ["build-sdk", ["repository shape", "analyzers and style", "MSBuild defaults", "skill authoring"]], + ["containers", ["local dependencies in tests"]], + ["engineering-playbook", ["the conventions themselves"]], + ["event-sourcing", ["event streams", "event storage"]], + ["openapi-xmlcomments", ["OpenAPI from XML comments"]], + ["pm-skills", ["skill-set design"]], + ["purview-dev.github.io", ["the public project catalogue"]], + ["results", ["operation outcomes"]], + ["sourcegenerator-framework", ["source generator authoring"]], + ["telemetry-sourcegenerator", ["logging", "tracing", "metrics"]], + ["value-objects", ["validated identity types"]], + ["zodsharp", ["TypeScript contracts from C#"]], +]); + +/** Runs `gh` and returns stdout, turning a CLI failure into an actionable message. */ +function gh(args) { + try { + return execFileSync("gh", args, { + encoding: "utf8", + maxBuffer: 256 * 1024 * 1024, + stdio: ["ignore", "pipe", "pipe"], + }); + } catch (error) { + const detail = (error.stderr ?? "").toString().trim() || error.message; + throw new Error( + `gh ${args.join(" ")} failed.\n${detail}\n\n` + + "This script reads the organisation, not a local clone. Run `gh auth status`, then retry.", + ); + } +} + +/** Calls one GitHub REST endpoint and parses the JSON response. */ +function api(endpoint) { + return JSON.parse(gh(["api", endpoint])); +} + +/** The root of the repository the command was run in, so the skill works from any repository. */ +function repositoryRoot() { + try { + return execFileSync("git", ["rev-parse", "--show-toplevel"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }).trim(); + } catch { + throw new Error("Not inside a git repository. Run this from a clone, or pass --out ."); + } +} + +/** Membership, as the organisation reports it. The catalogue is a convenience index, not the list. */ +function readMembership(source) { + const listed = source + ? JSON.parse(readFileSync(source, "utf8")) + : JSON.parse( + gh([ + "repo", + "list", + ORG, + "--limit", + "200", + "--json", + "name,visibility,isArchived,pushedAt,defaultBranchRef", + ]), + ); + + return ( + listed + // Deny by default. The filter is "exactly public and not archived" rather than "not + // private", so a visibility value this script has never seen fails closed instead of + // falling through into a file that is committed to a public repository. + .filter((repository) => repository.visibility === "PUBLIC" && repository.isArchived === false) + .sort((left, right) => left.name.localeCompare(right.name, "en")) + ); +} + +/** The recorded commit and file list for each repository, when a run replays a previous harvest. */ +function readDetails(source) { + return new Map(Object.entries(JSON.parse(readFileSync(source, "utf8")))); +} + +/** The Markdown pages directly inside a docs directory. */ +function countPages(paths, directory) { + const prefix = `${directory}/`; + return paths.filter((path) => { + if (!path.startsWith(prefix)) { + return false; + } + + const remainder = path.slice(prefix.length); + return remainder.endsWith(".md") && !remainder.includes("/"); + }).length; +} + +/** A skill is a folder under `skills/` that carries a `SKILL.md`. */ +function authoredSkills(paths) { + const names = paths + .filter((path) => /^skills\/[^/]+\/SKILL\.md$/.test(path)) + .map((path) => path.split("/")[1]); + + return [...new Set(names)].sort((left, right) => left.localeCompare(right, "en")); +} + +/** Everything the snapshot records about one repository, read at `BRANCH`. */ +function describeRepository(repository, details) { + // The filter above already excluded these, and this is the second lock on the same door: a + // private repository must never reach the snapshot, so the guard is here as well as there. + if (repository.visibility !== "PUBLIC" || repository.isArchived !== false) { + throw new Error( + `${ORG}/${repository.name} is ${repository.visibility} and archived ${repository.isArchived}. ` + + "The snapshot records public, unarchived repositories only, so nothing was written.", + ); + } + + const defaultBranch = repository.defaultBranchRef?.name ?? BRANCH; + if (defaultBranch !== BRANCH) { + throw new Error( + `${ORG}/${repository.name} has default branch '${defaultBranch}', not '${BRANCH}'. ` + + "The snapshot pins one branch, so either the rule or the repository has to change.", + ); + } + + const recorded = details?.get(repository.name); + if (details && !recorded) { + throw new Error( + `${repository.name} has no recorded detail, so --details cannot describe it. ` + + "Replay a membership the details file was captured with, or run without --details.", + ); + } + + const commitSha = recorded?.commitSha ?? api(`repos/${ORG}/${repository.name}/git/ref/heads/${BRANCH}`).object.sha; + const paths = recorded?.paths ?? readTree(repository.name); + const docsDir = DOCS_DIRECTORIES.find((candidate) => + paths.some((path) => path.startsWith(`${candidate}/`)), + ); + + return { + name: repository.name, + visibility: repository.visibility.toLowerCase(), + archived: repository.isArchived, + commitSha, + pushedAt: repository.pushedAt, + docsDir: docsDir ?? null, + docsPages: docsDir ? countPages(paths, docsDir) : 0, + skills: authoredSkills(paths), + agentGuidance: AGENT_GUIDANCE_FILES.filter((file) => paths.includes(file)), + concerns: CONCERNS.get(repository.name) ?? [], + }; +} + +/** The blob paths of one repository at `BRANCH`. */ +function readTree(name) { + const tree = api(`repos/${ORG}/${name}/git/trees/${BRANCH}?recursive=1`); + + if (tree.truncated) { + process.stderr.write( + `warning: ${name}'s tree is truncated, so its skills and docs are undercounted\n`, + ); + } + + return tree.tree.filter((entry) => entry.type === "blob").map((entry) => entry.path); +} + +/** The snapshot document. */ +function snapshotOf(repositories) { + return { + schemaVersion: SCHEMA_VERSION, + org: ORG, + branch: BRANCH, + fetchedAt: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"), + repositories, + }; +} + +/** The snapshot on disk, or null when the repository has not committed one yet. */ +function readSnapshot(path) { + if (!existsSync(path)) { + return null; + } + + // The snapshot is the one file in this workflow a person is likely to open and save by hand, and + // an editor that writes a byte-order mark produces a file JSON.parse rejects with a message that + // names neither the file nor the cause. + return JSON.parse(readFileSync(path, "utf8").replace(/^\uFEFF/, "")); +} + +/** A field's value in the form the drift report prints. */ +function render(value) { + if (value === null || value === undefined) { + return "none"; + } + + if (Array.isArray(value)) { + return value.length === 0 ? "none" : value.join(", "); + } + + return String(value); +} + +/** A field's value in the form the drift report compares: structured values compare as one string. */ +function fingerprint(value) { + return JSON.stringify(value ?? null); +} + +/** The fields whose change is worth reporting, in the order a reader cares about them. */ +const COMPARED_FIELDS = [ + "commitSha", + "docsDir", + "docsPages", + "skills", + "agentGuidance", + "concerns", + "visibility", + "archived", +]; + +/** The differences between the organisation and the snapshot. */ +function drift(existing, observed) { + const findings = []; + const before = new Map((existing?.repositories ?? []).map((entry) => [entry.name, entry])); + const after = new Map(observed.map((entry) => [entry.name, entry])); + + for (const [name, entry] of after) { + if (!before.has(name)) { + findings.push(`${name}: not in the snapshot`); + continue; + } + + const previous = before.get(name); + for (const field of COMPARED_FIELDS) { + if (fingerprint(previous[field]) === fingerprint(entry[field])) { + continue; + } + + findings.push(`${name}: ${field} ${render(previous[field])} -> ${render(entry[field])}`); + } + } + + for (const name of before.keys()) { + if (!after.has(name)) { + findings.push(`${name}: in the snapshot but no longer a public repository`); + } + } + + return findings; +} + +/** How long ago the snapshot was taken, in whole days, or null when it carries no usable date. */ +function ageInDays(fetchedAt) { + const fetched = Date.parse(fetchedAt ?? ""); + return Number.isNaN(fetched) ? null : Math.floor((Date.now() - fetched) / 86_400_000); +} + +/** Parses the command line. Unknown arguments are a mistake worth failing on. */ +function parseArguments(argv) { + const options = { write: false, repos: [], out: null, from: null, details: null, help: false }; + + for (let index = 0; index < argv.length; index++) { + const argument = argv[index]; + if (argument === "--write") { + options.write = true; + } else if (argument === "--print") { + options.write = false; + } else if (argument === "--help" || argument === "-h") { + options.help = true; + } else if (argument === "--repo") { + const value = argv[++index]; + if (!value) { + throw new Error("--repo needs a repository name."); + } + + options.repos.push(value); + } else if (argument === "--out") { + const value = argv[++index]; + if (!value) { + throw new Error("--out needs a path."); + } + + options.out = value; + } else if (argument === "--from") { + const value = argv[++index]; + if (!value) { + throw new Error("--from needs a path to a recorded `gh repo list` response."); + } + + options.from = value; + } else if (argument === "--details") { + const value = argv[++index]; + if (!value) { + throw new Error("--details needs a path to a recorded detail file."); + } + + options.details = value; + } else { + throw new Error(`Unknown argument '${argument}'. Run with --help.`); + } + } + + return options; +} + +/** The documented usage. */ +const HELP = `Snapshot every public, non-archived ${ORG} repository at ${BRANCH}. + + node inventory.mjs print the drift between the organisation and the snapshot + node inventory.mjs --write rewrite the snapshot from the organisation + node inventory.mjs --repo narrow to one repository; merges when writing + node inventory.mjs --out write somewhere other than .config/org-inventory.json + node inventory.mjs --from replay a recorded "gh repo list --json" response instead of + asking the organisation + node inventory.mjs --details replay recorded commits and file lists, so a run touches no + network at all; pair it with --from + node inventory.mjs --help this text + +The snapshot is generated, never hand-edited. A stale snapshot is a review obligation rather than a +build failure, so drift prints and the exit code stays 0.`; + +/** Runs the command. */ +function main() { + const options = parseArguments(process.argv.slice(2)); + if (options.help) { + console.log(HELP); + return; + } + + const snapshotPath = options.out ?? join(repositoryRoot(), ".config", "org-inventory.json"); + const existing = readSnapshot(snapshotPath); + + let observed = readMembership(options.from); + if (options.repos.length > 0) { + const known = new Set(observed.map((repository) => repository.name)); + for (const name of options.repos) { + if (!known.has(name)) { + throw new Error( + `'${name}' is not a public, non-archived ${ORG} repository. ` + + `Run without --repo to include all ${observed.length}.`, + ); + } + } + + observed = observed.filter((repository) => options.repos.includes(repository.name)); + } + + const details = options.details ? readDetails(options.details) : null; + const described = observed.map((repository) => describeRepository(repository, details)); + + if (options.write) { + const merged = new Map((existing?.repositories ?? []).map((entry) => [entry.name, entry])); + for (const entry of described) { + merged.set(entry.name, entry); + } + + const written = snapshotOf( + [...merged.values()].sort((left, right) => left.name.localeCompare(right.name, "en")), + ); + + // The merge carries forward whatever the file already held, which is how a hand-edited entry + // would survive a regeneration. Refuse to write a snapshot that records anything else. + const notPublic = written.repositories.filter( + (entry) => entry.visibility !== "public" || entry.archived !== false, + ); + if (notPublic.length > 0) { + throw new Error( + "Refusing to write a snapshot that records a repository which is not public and " + + `unarchived: ${notPublic.map((entry) => entry.name).join(", ")}. ` + + "Delete the entries and re-run --write.", + ); + } + + mkdirSync(dirname(snapshotPath), { recursive: true }); + writeFileSync(snapshotPath, `${JSON.stringify(written, null, "\t")}\n`, "utf8"); + console.log( + `wrote ${written.repositories.length} repositories to ${snapshotPath} at ${written.fetchedAt}`, + ); + return; + } + + const findings = drift(existing, described); + const age = ageInDays(existing?.fetchedAt); + + console.log(`${ORG}: ${observed.length} public, non-archived repositories at ${BRANCH}`); + if (existing) { + console.log( + `snapshot: ${existing.repositories?.length ?? 0} repositories, fetched ${existing.fetchedAt}` + + (age === null ? "" : ` (${age} day${age === 1 ? "" : "s"} old)`), + ); + } else { + console.log(`no snapshot at ${snapshotPath} - run with --write to create one`); + } + + if (findings.length === 0) { + console.log("no drift"); + } else { + console.log(`${findings.length} difference${findings.length === 1 ? "" : "s"}:`); + for (const finding of findings) { + console.log(` ${finding}`); + } + } + + if (age !== null && age > STALE_AFTER_DAYS) { + console.log( + `warning: the snapshot is older than ${STALE_AFTER_DAYS} days. ` + + "Re-run with --write and review the diff.", + ); + } +} + +try { + main(); +} catch (error) { + console.error(`error: ${error.message}`); + process.exitCode = 1; +} diff --git a/.agents/skills/purview-persistence/SKILL.md b/.agents/skills/purview-persistence/SKILL.md index 4667fa2..7d6ad91 100644 --- a/.agents/skills/purview-persistence/SKILL.md +++ b/.agents/skills/purview-persistence/SKILL.md @@ -1,14 +1,15 @@ --- name: purview-persistence description: >- - Use when a .NET project actually persists data and you are choosing a storage approach, deciding - whether to abstract EF Core, or selecting a GUID primary-key strategy. Gives provider-specific - guidance — SQL Server and PostgreSQL differ and must not be treated alike — and stays silent for - projects that do not persist anything. + Use when a .NET project actually persists data and you are choosing a storage approach, integrating + or abstracting EF Core, deriving a well-known identifier, or selecting a GUID primary-key strategy. + Gives provider-specific guidance — SQL Server and PostgreSQL differ and must not be treated alike — + and stays silent for projects that do not persist anything. license: MIT metadata: author: Purview-Dev - version: "1.0" + version: "1.2" + repository: purview-dev/engineering-playbook --- # Purview persistence @@ -40,7 +41,39 @@ development and integration tests — including `MsSql`, `PostgreSql`, `MySql`, `RabbitMq`, `Azurite` and an Azure Key Vault emulator. Prefer a real container over an in-memory provider: the in-memory provider does not enforce the constraints that break you in production. -## 3. GUID keys — only if GUIDs are keys +## 3. EF Core integration + +When EF Core is the store, keep the integration in four places: + +- **`DbContext` is the boundary.** Use it directly; do not add `IRepository` or a unit-of-work wrapper + for testability. `Purview.Containers` gives you a real database in tests. +- **Choose the provider where the context is registered.** Carry the choice as context-options data, not + a `Database.ProviderName` branch at model-build time. +- **Map value objects once.** Configure converters and comparers in the model, not per property. A + value-object key needs a converter-aware value generator. +- **Keep design time and runtime in step.** Both paths make the same provider, strategy and + `ReplaceService` choices, or the migration snapshot describes a different model. + +Migrations are code, reviewed and applied by a deliberate step; the API does not migrate at startup in +production. The full rule, composition sample and checks are in `references/ef-core-integration.md`. + +## 4. Identifiers — derived or generated + +Two decisions live here, and they are independent. Ask them in this order. + +### The deterministic-identifier gate + +Derive an identifier only when its **value must be known before storage** — a system tenant, a seeded +reference row, an installation's service principal, or an ingested object whose provider key is stable +and never recycled. Then derive it with UUIDv5 over an installation-scoped namespace and configure +`ValueGeneratedNever()`, so the sequential convention below leaves it alone. A project with random keys +and no seeding has no use for UUIDv5. An outbox row that merely needs an id before `SaveChanges` takes +the generated strategy below, not a derived one. + +The full rule, catalogue, naming rules, migration caveat and tests are in +`references/well-known-identifiers.md`. + +### The generated-key decision Resolve these before recommending anything; most are answered by the repository: @@ -72,11 +105,14 @@ Use the framework or database capability when it is sufficient — `Guid.CreateV generator is a maintenance obligation and a correctness risk; introduce one only against a measured requirement. -**There is no Purview GUID generation package.** Do not reference one, and do not create one without -first establishing a concrete, measured need that the built-in options cannot meet. If such a -capability is added later, this skill is where the guidance for recommending it belongs. +**No NuGet package provides this pattern.** A reviewed generator such as `FastGuid` supplies a +store-ordered implementation, but the strategy witness, options extension, value generator with +value-converter support, and model-finalising convention are repository-local: copy them, do not depend +on a package that claims to own the wiring. Introduce it only for a measured requirement: a second +provider, or an identifier needed before `SaveChanges`. Until the second provider exists, a built-in +capability is the answer. -## 4. Value objects as keys +## 5. Value objects as keys `Purview.ValueObjects` gives strongly-typed identifiers that stop an `OrderId` being passed where a `CustomerId` belongs. They need an EF Core value converter; configure it once in the model rather @@ -85,7 +121,7 @@ than per property. This is about type safety at the boundary, not about the generation strategy — the two decisions are independent. -## 5. Migrations and schema +## 6. Migrations and schema - Migrations are code: reviewed, tested against a real container, and applied by a deliberate step — not by the application at startup in production. @@ -96,7 +132,14 @@ independent. ## Checks before you answer - Have you confirmed persistence is actually required? +- If the project uses EF Core, are you recommending the `DbContext` directly rather than a repository + wrapper added for testability? +- Does the design-time path make the same provider and strategy choices as the runtime path? +- Does every generated value have one owner — application, store or derivation — and no second writer? +- If you are deriving an identifier, is its value genuinely known before storage, and is it keyed + `ValueGeneratedNever()`? - If you are recommending a GUID strategy, do you know the provider *and* its version? +- If you are proposing the strategy/convention, is there a second provider or a pre-save identifier? - Are you proposing an abstraction you can justify by something other than testability? - Have you preferred a built-in capability over a custom generator? diff --git a/.agents/skills/purview-persistence/references/ef-core-integration.md b/.agents/skills/purview-persistence/references/ef-core-integration.md new file mode 100644 index 0000000..77c7573 --- /dev/null +++ b/.agents/skills/purview-persistence/references/ef-core-integration.md @@ -0,0 +1,83 @@ +# EF Core integration + +Read this when the project actually uses EF Core. The identifier decisions are in `guid-generation.md` +and `well-known-identifiers.md`; this page is the rest of the integration. + +## The rule + +1. **`DbContext` is the boundary.** Read and write through it directly, or through a narrow query type + when a query deserves a name. Do not add `IRepository` or a unit-of-work wrapper for testability. + `DbContext` already is both, and a real container is a more faithful test than a substitute. +2. **The provider is chosen where the context is registered.** Carry the choice as context-options data; + never branch on `Database.ProviderName` while the model is being built. The model is built once and + cached, so that branch serves one provider's model to the other. +3. **Map a value object once.** Configure the converter and comparer in the model, not on each property. + The `Purview.ValueObjects` generator emits the converters when EF Core is referenced. A value-object + key also needs a converter-aware value generator, or the property receives a raw `Guid`. +4. **One owner per generated value.** The application supplies it, the store generates it, or it is + derived — never two. A derived identifier is `ValueGeneratedNever()`; a store default or computed + column is left to the store. +5. **Design time and runtime agree.** The design-time factory or host registration makes the same + provider, strategy and `ReplaceService` choices as the application. Otherwise the migration snapshot + describes a model the application does not run. +6. **The model cache key covers every input that varies the model.** Provider, strategy, tenant and + design time. EF's service-provider cache and model cache are separate; both need a key that matches + the decision. +7. **Migrations are code.** Persistence owns the definitions; a deliberate step applies them. The API + does not migrate at startup in production. +8. **Integration tests use a real store.** The in-memory provider does not enforce foreign keys, unique + indexes, computed columns or SQL semantics. Use `Purview.Containers` and a `.IntegrationTests` + project. +9. **The connection string is configuration.** Keep it out of source, out of `appsettings.json` in the + repository, and out of telemetry. A redaction test is worth more than a convention. + +## The composition + +The provider, the strategy and the service replacements belong together, where the context is +registered: + +```csharp +services.AddDbContext>((serviceProvider, options) => +{ + ConfigureStore(options, connectionString); + + options + .UseSequentialGuids() + .ReplaceService() + .ReplaceService>(); +}); + +services.AddSingleton>(); +``` + +- `ConfigureStore` is the only place that names `UseSqlServer` or `UseNpgsql`. +- `UseSequentialGuids` adds an options extension; the model reads it instead of asking about the provider. +- `IModelCacheKeyFactory` is replaced because the service-provider hash alone does not key the model + cache. +- The application-facing factory is registered separately, because EF's internal registration is not + reachable from an outbox writer or a correlation-id generator. + +## Migrations + +- Generation and application are separate: `dotnet ef migrations add` in development, a controlled + `Migrator` step in a deployment. +- A migration and `HasData` are compiled and replayed, so they need a literal GUID. A derived identifier + cannot be computed there; print the constant from the derivation and guard it with a test. +- Expect the schema to be ahead of or behind the code during a rollout, and design the change to be + compatible in both directions. +- Never let the API apply migrations at startup in production: a deploy then mutates the schema without + review and without a clean rollback. + +## Checks to write + +| Check | Shape | +| -- | -- | +| Model covers every GUID key | Each non-foreign-key GUID key is generated on add with the expected factory | +| Foreign keys are not generated | A foreign-key GUID keeps the value copied from its principal | +| Explicit configuration wins | A `ValueGeneratedNever()` key keeps its supplied value through `SaveChanges` | +| Two strategies, one context type | Different strategies produce different service providers and models | +| Design time matches runtime | Both models produce the same migration snapshot | +| Real store semantics | A unique index, a foreign key and a computed column behave as the model says in a container | + +The Engineering Playbook's *EF Core Integration* page states the same rules with the sample as evidence; +the runnable implementation is `samples/Acme.Persistence` in that repository, run with `just samples`. diff --git a/.agents/skills/purview-persistence/references/guid-generation.md b/.agents/skills/purview-persistence/references/guid-generation.md index 432053a..fb644bb 100644 --- a/.agents/skills/purview-persistence/references/guid-generation.md +++ b/.agents/skills/purview-persistence/references/guid-generation.md @@ -62,14 +62,22 @@ Never use `NEWSEQUENTIALID()` output as a secret. | Provider | Needs value before insert | Locality matters | Recommendation | | -- | -- | -- | -- | -| SQL Server | No | Yes | `NEWSEQUENTIALID()` (EF Core's default for `Guid` keys) | -| SQL Server | Yes | Yes | EF Core `SequentialGuidValueGenerator` | +| SQL Server | No | Yes | `NEWSEQUENTIALID()` as a store default, or EF Core's client-side `SequentialGuidValueGenerator` | +| SQL Server | Yes | Yes | EF Core `SequentialGuidValueGenerator`, or a store-ordered `FastGuid` strategy when both providers share a model | | SQL Server | Either | No | `Guid.NewGuid()` | | PostgreSQL | Yes | Yes | `Guid.CreateVersion7()` with `ValueGeneratedNever()` | | PostgreSQL | No | Yes | `uuidv7()` on 18+, otherwise generate v7 in the application | | PostgreSQL | Either | No | `Guid.NewGuid()` or `gen_random_uuid()` | +| SQL Server **and** PostgreSQL, one context type | Either | Yes | A store-specific strategy carried as context-options data. One strategy per provider; see below. | | Any | Either | Either, but must be unpredictable | Random v4 | +The two-provider row is the one that needs code: an `ISequentialGuidProvider` witness, an options +extension and a model-finalising convention. Copy them from the Engineering Playbook's *GUID Key +Strategy* page. Never expect one byte-ordered generator to order for both stores. + +A value whose identity is known before storage does not belong in this table. Derive it with UUIDv5 and +key it `ValueGeneratedNever()`; see `well-known-identifiers.md`. + ## Prefer built-in capability In order of preference: @@ -77,17 +85,19 @@ In order of preference: 1. A framework capability — `Guid.CreateVersion7()`, EF Core's value generators. 2. A database capability — `NEWSEQUENTIALID()`, `uuidv7()`, `gen_random_uuid()`. 3. A reviewed, tested implementation of a published standard, when neither of the above is available - on your target versions. + on your target versions — `FastGuid` is the one the sample uses for store-ordered values. 4. A bespoke generator — only against a measured requirement the first three cannot meet. A hand-written sequential GUID generator is easy to get subtly wrong — endianness, clock regression, counter rollover within a millisecond, and thread safety are all live hazards, and the failure mode is duplicate or badly-ordered keys found in production. -## There is no Purview GUID package +## No NuGet package provides this -No Purview library provides GUID generation today. Do not reference one and do not invent one. +No NuGet package provides the strategy/convention pattern. It is repository-local: copy the strategy +witness, options extension, value generator and model-finalising convention from the Engineering +Playbook's *GUID Key Strategy* page. Do not depend on a package that claims to own it, and do not +create one without a measured requirement the built-in options cannot meet. -If a concrete need emerges that the built-in options cannot meet — established by measurement, not by -anticipation — that is the point at which creating one is worth discussing. Until then this guidance -is the deliverable, and this page is where a recommendation for such a package would be documented. +The abstraction is earned by a second provider, or by an identifier needed before `SaveChanges` on +both — not by anticipation. Until then a built-in capability is the answer. diff --git a/.agents/skills/purview-persistence/references/postgresql.md b/.agents/skills/purview-persistence/references/postgresql.md index cd557ec..603987d 100644 --- a/.agents/skills/purview-persistence/references/postgresql.md +++ b/.agents/skills/purview-persistence/references/postgresql.md @@ -43,6 +43,49 @@ builder.Property(o => o.Id).ValueGeneratedNever(); Without that, EF Core may treat the key as store-generated and skip your value. +### Application-generated, store-ordered strategy + +When one context type serves both PostgreSQL and SQL Server, or the identifier must exist before +`SaveChanges` on both, use a store-specific strategy carried as context-options data. The PostgreSQL +implementation returns `FastGuid.NewPostgreSqlGuid()`; a model-finalising convention applies it to +every GUID key and leaves explicitly keyed derived identifiers alone. + +```csharp +public sealed class PostgreSqlSequentialGuidProvider : ISequentialGuidProvider +{ + public static string Name => "PostgreSql"; + + public static Guid NewGuid() => FastGuid.NewPostgreSqlGuid(); + + public static Guid NewGuid(DateTime timestampUtc) => + FastGuid.NewPostgreSqlGuid(UtcTimestamp.EnsureUtc(timestampUtc)); + + public static DateTime GetTimestamp(Guid value) => FastGuid.PostgreSql.GetTimestamp(value); + + public static Guid MinValueFor(DateTime timestampUtc) => + FastGuid.PostgreSql.MinGuidForTimestamp(UtcTimestamp.EnsureUtc(timestampUtc)); + + public static Guid MaxValueFor(DateTime timestampUtc) => + FastGuid.PostgreSql.MaxGuidForTimestamp(UtcTimestamp.EnsureUtc(timestampUtc)); +} +``` + +- PostgreSQL compares `uuid` in plain big-endian byte order, so both `FastGuid.NewPostgreSqlGuid()` + and `Guid.CreateVersion7()` are genuinely sequential here. `FastGuid` is also correct for SQL Server, + which is the point of the strategy abstraction. +- Set `ValueGeneratedNever()` through the convention; never let EF fetch a store-generated value that + does not exist. + +#### RFC caveat + +`FastGuid.NewPostgreSqlGuid()` values are **not** RFC 9562 UUIDs. They are ordinary GUIDs with a +PostgreSQL-ordered layout, so PostgreSQL's `uuid_extract_timestamp()` will not read them even though +they are valid `uuid` values. Use the provider's `GetTimestamp` (above) instead. If another system +needs a standard timestamp, generate `Guid.CreateVersion7()` there and accept that the layout differs. + +The full sample is the Engineering Playbook's *GUID Key Strategy* page. The abstraction is earned by +the second provider, not by anticipation. + ### Database-generated UUIDv7 — PostgreSQL 18+ PostgreSQL 18 added a native `uuidv7()` function. Earlier versions do not have it; on 13–17 you have @@ -87,6 +130,7 @@ Random and unpredictable. Fine at low to moderate write volume, which covers mos | .NET 8 or earlier, high write volume | A reviewed UUIDv7 implementation, or accept v4 after measuring | | Rows inserted outside the application, PostgreSQL 18+ | `uuidv7()` as a column default | | Rows inserted outside the application, PostgreSQL 13–17 | `gen_random_uuid()` | +| One model serving PostgreSQL and SQL Server | A store-specific `FastGuid` strategy carried as context-options data — never one byte-ordered generator for both | | Key must be unpredictable | `Guid.NewGuid()` or `gen_random_uuid()` — v7 leaks creation time | | Low or moderate volume | v4 — the locality difference will not be your bottleneck | diff --git a/.agents/skills/purview-persistence/references/sqlserver.md b/.agents/skills/purview-persistence/references/sqlserver.md index c728295..2262972 100644 --- a/.agents/skills/purview-persistence/references/sqlserver.md +++ b/.agents/skills/purview-persistence/references/sqlserver.md @@ -27,8 +27,13 @@ ALTER TABLE [Orders] ADD CONSTRAINT [DF_Orders_Id] DEFAULT NEWSEQUENTIALID() FOR [Id]; ``` -EF Core, for a `Guid` key on SQL Server, configures this by default — `ValueGeneratedOnAdd` maps to -`NEWSEQUENTIALID()` through the default value-generation strategy. +EF Core does **not** map `ValueGeneratedOnAdd` to `NEWSEQUENTIALID()` by default. For a `Guid` key on +SQL Server the provider generates the value client-side with `SequentialGuidValueGenerator` (below). +Configure a store default explicitly when the database must own the value: + +```csharp +builder.Property(o => o.Id).HasDefaultValueSql("NEWSEQUENTIALID()"); +``` Trade-offs: @@ -53,6 +58,44 @@ builder.Property(o => o.Id) Use this when the identifier must exist before `SaveChanges`. It keeps most of the index locality while letting the application own the value. +### Application-generated, store-ordered strategy + +When one context type serves both SQL Server and PostgreSQL, or the identifier must exist before +`SaveChanges` on both, a single built-in generator is not enough: the two stores order GUIDs +differently. The answer is a store-specific strategy carried as context-options data — a +`static abstract` witness whose SQL Server implementation returns `FastGuid.NewSqlServerGuid()`, plus a +value generator and a model-finalising convention that applies it to every GUID key. + +```csharp +public sealed class SqlServerSequentialGuidProvider : ISequentialGuidProvider +{ + public static string Name => "SqlServer"; + + public static Guid NewGuid() => FastGuid.NewSqlServerGuid(); + + public static Guid NewGuid(DateTime timestampUtc) => + FastGuid.NewSqlServerGuid(UtcTimestamp.EnsureUtc(timestampUtc)); + + public static DateTime GetTimestamp(Guid value) => FastGuid.SqlServer.GetTimestamp(value); + + public static Guid MinValueFor(DateTime timestampUtc) => + FastGuid.SqlServer.MinGuidForTimestamp(UtcTimestamp.EnsureUtc(timestampUtc)); + + public static Guid MaxValueFor(DateTime timestampUtc) => + FastGuid.SqlServer.MaxGuidForTimestamp(UtcTimestamp.EnsureUtc(timestampUtc)); +} +``` + +- The increasing part must sit where SQL Server sorts it. The trailing six bytes dominate + `uniqueidentifier`; a byte-ordered UUIDv7 does not. +- The generator must hand the value through the property's value converter, or a `readonly record + struct OrderId` key receives a raw `Guid`. +- Apply it at convention precedence, so explicit `ValueGeneratedNever()`, a store default and a + computed column all win. + +The full sample is the Engineering Playbook's *GUID Key Strategy* page. The abstraction is earned by +the second provider; on one SQL Server provider `NEWSEQUENTIALID()` or EF Core's generator is enough. + ### `Guid.NewGuid()` — random Correct, portable and unpredictable; poor index locality. @@ -88,7 +131,7 @@ reaching for a sequential generator. | Situation | Use | | -- | -- | | High insert volume, key may be database-generated | `NEWSEQUENTIALID()` | -| High insert volume, identifier needed before persisting | EF Core `SequentialGuidValueGenerator` | +| High insert volume, identifier needed before persisting | EF Core `SequentialGuidValueGenerator`, or a store-ordered `FastGuid` strategy when both providers share a model | | Key must be unpredictable (public surface, security boundary) | `Guid.NewGuid()`, with a non-clustered PK | | Low or moderate volume | `Guid.NewGuid()` — do not optimise what does not hurt | | You want a sortable timestamp in the key and accept the locality cost | `Guid.CreateVersion7()` | diff --git a/.agents/skills/purview-persistence/references/well-known-identifiers.md b/.agents/skills/purview-persistence/references/well-known-identifiers.md new file mode 100644 index 0000000..372d585 --- /dev/null +++ b/.agents/skills/purview-persistence/references/well-known-identifiers.md @@ -0,0 +1,93 @@ +# Deriving well-known identifiers with UUIDv5 + +Read this only when an identifier's **value must be known before storage**: a system tenant, a seeded +reference row, an installation's service principal, or an ingested object whose provider key is stable +and never recycled. A project with random keys and no seeding has no use for UUIDv5 — say nothing and +move on. An outbox row that merely needs an id before `SaveChanges` takes a generated strategy instead. + +## The rule + +1. Derive the value: `Uuid5.Create(namespace, name)` is RFC 4122 version 5 (SHA-1), byte-identical for + the same namespace and name in every process, language and restart. +2. Scope the namespace to the isolation boundary — one installation or one deployment. Never a global + constant shared by every deployment. +3. Fix the name as a canonical constant of the identity kind. Not a display name, not user data, not a + secret. +4. Once persisted, the value is authoritative. Never recompute it and update the row. +5. Key it explicitly with `ValueGeneratedNever()`, so a sequential-GUID convention never generates over + a value the application already knows. + +## The catalogue + +Write the known identities down in one place. + +| Identity | Namespace | Name | Derived when | Immutable | +| -- | -- | -- | -- | -- | +| System tenant of an installation | installation id | `system-tenant` | first bootstrap | yes | +| System principal of an installation | installation id | `system-service-principal` | first bootstrap | yes | +| Seeded reference row | installation id | `:` | seed step | yes | +| Ingested external object (optional) | installation id | `:` | first ingest | yes | + +Derive the ingested row only when the provider guarantees the external key is stable and never +recycled. If it can be recycled, keep the local key sequential and make the external key a unique +column. + +## Naming rules + +- Lowercase ASCII, hyphen-separated, no whitespace, no locale-dependent casing. +- Never from a display name, email, URL, title, or anything a user can edit. +- Never from a secret, and never as a capability token. A name is guessable. +- Renaming a name creates a new identity. Renaming a persisted one is a data migration. + +## Bootstrap sequence + +Read the persisted identity; if present it wins, and a configured value must match or the operation +fails. If absent, insert it. Derive dependants from the persisted id. Save once. A second run changes +nothing. + +The guard matters as much as the insert: + +```csharp +if (persisted is not null && persisted.Id != configured) +{ + throw new InvalidOperationException( + $"Configured installation id '{configured.Value}' does not match the persisted id '{persisted.Id.Value}'."); +} +``` + +## Migrations cannot call the helper + +`HasData` and migration SQL need a literal GUID. A small script prints ready-to-paste constants from +the same derivation, and a test asserts every printed constant equals `Uuid5.Create(...)`. Changing the +namespace or a name after a migration has shipped is a data migration, not an edit. + +## Checks to write + +| Check | Shape | +| -- | -- | +| RFC 4122 conformance | Fixed vectors for DNS/URL/OID/X500 and the empty name; exact GUIDs | +| Determinism | Same pair twice equal; different names differ | +| Version and variant bits | Byte 6 high nibble `0x5`; byte 8 high bits `10` | +| Cross-language equality | Python `uuid.uuid5` or Node `crypto` reproduces the vectors | +| Catalogue uniqueness | The catalogue's constants are distinct | +| Bootstrap idempotency | Seeding twice changes zero rows; a mismatched configured id throws | +| Explicit keying | The key is `ValueGenerated.Never`, and the sequential convention leaves it alone | + +```text +uuid5(6ba7b810-9dad-11d1-80b4-00c04fd430c8, "example.com") = cfbff0d1-9375-5685-968c-48ce8b15ae17 +uuid5(6ba7b811-9dad-11d1-80b4-00c04fd430c8, "www.example.com") = b63cdfa4-3df9-568e-97ae-006c5b8fd652 +uuid5(6ba7b812-9dad-11d1-80b4-00c04fd430c8, "1.2.3.4") = 67448b45-6d15-536b-bef7-8a78c3d10ac6 +uuid5(6ba7b814-9dad-11d1-80b4-00c04fd430c8, "CN=Bob") = 0840e90d-9ca0-5f14-970e-26294d44cb1d +``` + +The hash path is big-endian. `Guid.ToByteArray()` and `new Guid(byte[])` use a mixed-endian layout and +produce a different hash; that is the most common way a "UUIDv5" implementation is wrong. + +**UUIDv5 is not sequential.** Never use it for a high-write table's primary key. It is for a small, +known set of identities; everything else takes `guid-generation.md`. + +RFC 4122 is the version-5 specification; RFC 9562 is the current UUID specification and retains the +algorithm. The .NET BCL has no UUIDv5 API, so the helper is a small hand-written function. + +The Engineering Playbook's *Well-Known Identifiers* page carries the full helper, catalogue and +bootstrap samples, and their test shapes. diff --git a/.agents/skills/purview-telemetry/SKILL.md b/.agents/skills/purview-telemetry/SKILL.md index 6d83173..d2f42d7 100644 --- a/.agents/skills/purview-telemetry/SKILL.md +++ b/.agents/skills/purview-telemetry/SKILL.md @@ -9,6 +9,7 @@ license: MIT metadata: author: Purview-Dev version: "1.0" + repository: purview-dev/engineering-playbook --- # Purview telemetry @@ -120,4 +121,4 @@ Instrumentation is identical across all three rows. Only the right-hand column g ## See also - `purview-architecture` — establishing project context and scale before deciding -- [Purview.Telemetry.SourceGenerator](https://github.com/purview-dev/purview-telemetry-sourcegenerator) +- [Purview.Telemetry.SourceGenerator](https://github.com/purview-dev/telemetry-sourcegenerator) diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 8080adf..bc9bc27 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -3,21 +3,21 @@ "isRoot": true, "tools": { "csharpier": { - "version": "1.3.0", + "version": "1.4.0", "commands": [ "csharpier" ], "rollForward": false }, "dotnet-inspect": { - "version": "0.26.0", + "version": "0.27.0", "commands": [ "dotnet-inspect" ], "rollForward": false }, "purview.build": { - "version": "0.8.0", + "version": "0.9.0", "commands": [ "purview-build" ], diff --git a/.config/purview-build.json b/.config/purview-build.json index a1c0634..3519bd8 100644 --- a/.config/purview-build.json +++ b/.config/purview-build.json @@ -63,15 +63,21 @@ "purview-logo-light.png", "buildTransitive/Purview.Containers.Docker.props" ], - // Purview.Containers.Wsl is multi-target: lib/net10.0 is the portable facade, and - // lib/net10.0-windows10.0.19041 is the WSLC implementation. The portable consumer loads - // that implementation (plus the WSLC projection, its Windows SDK dependencies and the - // native SDK) from payload// via the buildTransitive targets. + // Purview.Containers.Wsl is multi-target: lib/net10.0 and lib/net11.0 are the portable facades + // (net11.0 is compiled with runtime-async), and the *-windows10.0.19041 builds are the WSLC + // implementations. A portable consumer loads the Windows implementation (plus the WSLC + // projection, its Windows SDK dependencies and the native SDK) from payload// via the + // buildTransitive targets; one payload serves both portable targets because a .NET 11 process + // loads a .NET 10 assembly. "purview.containers.wsl": [ "lib/net10.0/Purview.Containers.Wsl.dll", "lib/net10.0/Purview.Containers.Wsl.xml", "lib/net10.0-windows10.0.19041/Purview.Containers.Wsl.dll", "lib/net10.0-windows10.0.19041/Purview.Containers.Wsl.xml", + "lib/net11.0/Purview.Containers.Wsl.dll", + "lib/net11.0/Purview.Containers.Wsl.xml", + "lib/net11.0-windows10.0.19041/Purview.Containers.Wsl.dll", + "lib/net11.0-windows10.0.19041/Purview.Containers.Wsl.xml", "README.md", "purview-logo-light.png", "buildTransitive/Purview.Containers.Wsl.props", diff --git a/.editorconfig b/.editorconfig index f8b3050..a9b39cb 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,4 +1,4 @@ -# Purview.BuildSdk 1.3.0 - managed file, do not edit +# Purview.BuildSdk 2.0.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 diff --git a/.github/workflows/integration-wsl.yml b/.github/workflows/integration-wsl.yml index 21df9d3..0edcf7d 100644 --- a/.github/workflows/integration-wsl.yml +++ b/.github/workflows/integration-wsl.yml @@ -62,7 +62,7 @@ jobs: 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" + dotnet-version: "11.0.100" - name: Install WSL Containers shell: pwsh @@ -88,7 +88,7 @@ jobs: - 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 + run: dotnet test src/tests/Wsl.IntegrationTests/Wsl.IntegrationTests.csproj --configuration Release --no-build --treenode-filter '${{ inputs.filter || '/*/*/*/*' }}' --timeout 15m --retry-failed-tests 2 --retry-failed-tests-delay 5s --retry-failed-tests-max-percentage 50 - name: Service modules on WSLC if: inputs.scope != 'contract' @@ -113,7 +113,7 @@ jobs: $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 + dotnet test $project --configuration Release --no-build --treenode-filter $filter --timeout 20m --maximum-failed-tests 10 --retry-failed-tests 2 --retry-failed-tests-delay 5s --retry-failed-tests-max-percentage 50 if ($LASTEXITCODE -ne 0) { $failed += $project } @@ -144,7 +144,7 @@ jobs: 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" + dotnet-version: "11.0.100" - name: Verify the WSL Containers host shell: pwsh @@ -161,7 +161,7 @@ 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 || '/*/*/*/*' }}' --retry-failed-tests 2 --retry-failed-tests-delay 5s --retry-failed-tests-max-percentage 50 + run: dotnet test src/tests/Wsl.IntegrationTests/Wsl.IntegrationTests.csproj --configuration Release --no-build --treenode-filter '${{ inputs.filter || '/*/*/*/*' }}' --timeout 15m --retry-failed-tests 2 --retry-failed-tests-delay 5s --retry-failed-tests-max-percentage 50 - name: Service modules on WSLC if: inputs.scope != 'contract' @@ -186,7 +186,7 @@ jobs: $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 + dotnet test $project --configuration Release --no-build --treenode-filter $filter --timeout 20m --maximum-failed-tests 10 --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 616843c..c83922a 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -16,11 +16,11 @@ jobs: name: Build and test uses: purview-dev/build/.github/workflows/purview-build.yml@main with: - # The shared workflow defaults to the 10.0.x SDK, but this repository targets net11.0 and pins - # the .NET 11 SDK in global.json. Without the matching SDK the runner cannot resolve global.json + # The shared workflow defaults to the 10.0.x SDK, but this repository is built with the .NET 11 + # SDK and pins it in global.json. Without the matching SDK the runner cannot resolve global.json # ("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" + dotnet-version: "11.0.100" # 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 @@ -47,11 +47,13 @@ jobs: - uses: actions/setup-dotnet@v4 with: # Keep in sync with `sdk.version` in global.json. - dotnet-version: "11.0.100-rc.2.26504.105" + dotnet-version: "11.0.100" - name: Docker backend - 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 + # --timeout is a .NET 11 run-level policy: a hung container can otherwise hold the job until + # GitHub's timeout kills it, losing the whole run's diagnostics. + run: dotnet test src/tests/Docker.IntegrationTests/Docker.IntegrationTests.csproj --configuration Release --treenode-filter '/*/*/*/*' --timeout 15m --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 '/*/*/*/*' --retry-failed-tests 2 --retry-failed-tests-delay 5s --retry-failed-tests-max-percentage 50 + run: dotnet test src/tests/Modules.Docker.IntegrationTests/Modules.Docker.IntegrationTests.csproj --configuration Release --treenode-filter '/*/*/*/*' --timeout 15m --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 @@ -70,7 +72,7 @@ jobs: - uses: actions/setup-dotnet@v4 with: # Keep in sync with `sdk.version` in global.json. - dotnet-version: "11.0.100-rc.2.26504.105" + dotnet-version: "11.0.100" - name: Install WSL Containers shell: pwsh run: | @@ -92,4 +94,4 @@ jobs: 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 + run: dotnet test src/tests/Wsl.IntegrationTests/Wsl.IntegrationTests.csproj --configuration Release --no-build --treenode-filter '/*/*/*/*' --timeout 15m --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 0ca71a1..6ab891f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -30,10 +30,10 @@ jobs: - uses: purview-dev/build/actions/purview-release@main with: release-mode: NuGet - # The shared action defaults to the 10.0.x SDK, but this repository targets net11.0 and pins - # the .NET 11 SDK in global.json; without it the release job cannot resolve global.json. + # The shared action defaults to the 10.0.x SDK, but this repository is built with the .NET 11 + # SDK and pins it in global.json; without it the release job cannot resolve global.json. # Keep in sync with `sdk.version` in global.json (and with .github/workflows/pr.yml). - dotnet-version: "11.0.100-rc.2.26504.105" + dotnet-version: "11.0.100" trusted-publishing: true # The nuget.org profile name, not the sign-in email. nuget-user: kieronlanning diff --git a/AGENTS.md b/AGENTS.md index d145286..2ee30fc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,13 +24,13 @@ whenever a target framework, a backend package or the `buildTransitive` assets c | Path | Purpose | | --- | --- | | `src/Containers.slnx` | Canonical solution for restore, build, test and pack | -| `src/src/Containers` | Umbrella package (`Purview.Containers`, `net10.0`): references `Core` and both backends; no code of its own | -| `src/src/Core` | Backend-neutral abstractions (`Purview.Containers.Core`, `net10.0`, namespace `Purview.Containers`): `IContainer`/`ContainerConfiguration`, `ContainerBuilder`, `ContainerBase`, `IContainerBackend`/`ContainerBackends`, wait strategies, images, networking, mounts, diagnostics | -| `src/src/Wsl` | WSL Containers backend (`Purview.Containers.Wsl`, multi-target `net10.0` facade + `net10.0-windows10.0.19041.0` implementation): `WslContainerBackend`, the shared session runtime, `WslContainer`, `WslContainerSession`, and the portable facade (`WslPayload`, `*.Facade.cs`) that loads the implementation at run time | -| `src/src/Docker` | Docker backend (`Purview.Containers.Docker`, `net10.0`): `DockerContainerBackend` + `DockerContainer` over Testcontainers | -| `src/src/Environment` | Standalone CLI bind-sharing resolver (`Purview.Containers.Environment`, `net10.0`); BCL-only, no backend or Aspire dependency | +| `src/src/Containers` | Umbrella package (`Purview.Containers`, `net10.0` and `net11.0`): references `Core` and both backends; no code of its own | +| `src/src/Core` | Backend-neutral abstractions (`Purview.Containers.Core`, `net10.0` and `net11.0`, namespace `Purview.Containers`): `IContainer`/`ContainerConfiguration`, `ContainerBuilder`, `ContainerBase`, `IContainerBackend`/`ContainerBackends`, wait strategies, images, networking, mounts, diagnostics | +| `src/src/Wsl` | WSL Containers backend (`Purview.Containers.Wsl`, multi-target `net10.0`/`net11.0` facades + `net10.0`/`net11.0` `-windows10.0.19041.0` implementations): `WslContainerBackend`, the shared session runtime, `WslContainer`, `WslContainerSession`, and the portable facade (`WslPayload`, `*.Facade.cs`) that loads the implementation at run time | +| `src/src/Docker` | Docker backend (`Purview.Containers.Docker`, `net10.0` and `net11.0`): `DockerContainerBackend` + `DockerContainer` over Testcontainers | +| `src/src/Environment` | Standalone CLI bind-sharing resolver (`Purview.Containers.Environment`, `net10.0` and `net11.0`); BCL-only, no backend or Aspire dependency | | `src/src/Aspire` | Optional `Purview.Containers.Aspire` adapter; translates bind sources at `BeforeResourceStartedEvent` without changing endpoints or named volumes | -| `src/src/` | Service modules (`Purview.Containers.`, `net10.0`); each is a thin layer over the abstractions and carries a bespoke `Sdk/README.md` | +| `src/src/` | Service modules (`Purview.Containers.`, `net10.0` and `net11.0`); each is a thin layer over the abstractions and carries a bespoke `Sdk/README.md` | | `src/src//Sdk` | Package-only assets. `Sdk/README.md` is packed as the package README (suppressing the repo-root README); `Sdk/buildTransitive/**` ships MSBuild assets to consumers (the abstractions' backend registration, each backend package's own registration entry, and the WSL backend's consumer defaults and guards) | | `src/tests` | TUnit unit (`*.UnitTests`) and container integration (`*.IntegrationTests`) projects: the WSLC suites need a WSLC host, `Docker.IntegrationTests` and `Modules.DockerIntegrationTests` need a Docker daemon | | `docs/wiki` | User-facing documentation wiki, aggregated by the purview-dev website. `Backends.md` is the consumer guide to choosing and configuring WSLC or Docker | @@ -38,6 +38,7 @@ whenever a target framework, a backend package or the `buildTransitive` assets c | `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 | | `.config/purview-build.json` | Shared `Purview.Build` pipeline configuration: solution, test discovery/filter and the **exhaustive** pack-validation manifest | +| `Directory.Build.rsp` | Purview.BuildSdk-managed MSBuild response file: enables the .NET 11 multithreaded build (`-mt`) and disables node reuse (`-nr:false`). Delete its marker line to take ownership | | `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) | @@ -55,8 +56,10 @@ whenever a target framework, a backend package or the `buildTransitive` assets c ## Runtime invariants - Shared extended APIs cover entrypoint/user/labels, exec overrides, resource copy, one-shot exit waits, - managed bridge networks/aliases, builds and reuse. WSLC must target the SDK-owned session with a - bounded `wslc` subprocess for SDK-missing capabilities; ordinary SDK-only consumers need no CLI. + managed bridge networks/aliases, Linux capabilities, session host loopback, builds and reuse. WSLC uses + the managed `Microsoft.WSL.Containers` 3.0.5 SDK for lifecycle, images, networks, capabilities and host + loopback, and the SDK-owned session with a bounded `wslc` subprocess only for SDK-missing capabilities + (entrypoint/user/labels, copy, builds, reuse); ordinary SDK-only consumers need no CLI. - Never swallow caller cancellation as a successful exec/stop. Startup is a total five-minute managed budget; exec defaults to one minute, stop to 15 seconds and cleanup to 30 seconds. Synchronous WSLC native calls cannot be safely interrupted; never advertise hard zero-hang guarantees. @@ -82,8 +85,18 @@ whenever a target framework, a backend package or the `buildTransitive` assets c directories. Both backends share the archive builder and preserve exact file targets. - **One shared, process-wide session** (`WslContainerRuntime.Instance`), lazily started, named - `wslc-{pid}-{random8}`. Container isolation comes from unique names and unique host ports, not from extra + `purview-{pid}-{random8}`. Container isolation comes from unique names and unique host ports, not from extra sessions. +- **Crash residue is swept before the first session starts.** A process killed by a timeout or CI + cancellation cannot run its exit hook, so its session and per-session storage directory survive. + `WslSessionSweeper` terminates `purview-*` and legacy `wslc-*` sessions whose name-embedded creator PID + is no longer alive, then deletes their `%LOCALAPPDATA%\Purview\WslContainers\sessions` directories. + After the session opens the shared store it also removes stale containers whose generated + `{image}-{pid}-{6 hex}` name embeds a dead PID, and prunes unused anonymous volumes. Live PIDs, the + `wslc-cli-*` session and other tools' sessions are never touched. The sweep is best-effort and + bounded; `WslContainerRuntime.SweepOrphanedSessionsAsync()` exposes the session sweep explicitly and + `CleanUpAsync()` also removes container residue. Do not widen the name match or drop the PID liveness + check. - **Images are shared by default** (`StorageMode.Shared` → `%LOCALAPPDATA%\Purview\WslContainers\images`). A session exclusively locks its `storage.vhdx`; the runtime verifies the store once and falls back to an isolated per-process store on a sharing violation (`0x80070020`). Do not remove that fallback. @@ -114,19 +127,31 @@ whenever a target framework, a backend package or the `buildTransitive` assets c ## Consumer requirements and compatibility -- The packages split by framework: `Purview.Containers.Core` (abstractions) and the service modules target - **`net10.0`** on any platform. `Purview.Containers.Wsl` is **multi-target**: `net10.0` (a portable - facade) and `net10.0-windows10.0.19041.0` (the WSLC implementation). `src/src/Directory.Build.props` - sets the `net10.0` subtree default and `Wsl.csproj` adds the Windows build; the WSLC tests keep the - Windows target because they drive WSLC and therefore bind the implementation, while the Docker - integration projects override to `net10.0` so they run on Linux. The contract, the - failures that enforce it and the CI workarounds are documented in +- The packages split by framework and support **.NET 10 and .NET 11 only** (`.NET 8` and `.NET 9` are + dropped): `Purview.Containers.Core` (abstractions) and the service modules target **`net10.0` and + `net11.0`** on any platform. `Purview.Containers.Wsl` is **multi-target**: `net10.0`/`net11.0` (portable + facades) and `net10.0-windows10.0.19041.0`/`net11.0-windows10.0.19041.0` (the WSLC implementations). + Purview.BuildSdk's `Current` target-framework set supplies the `net10.0;net11.0` list to the library + subtree, while `src/Directory.Build.props` pins that set to `net11.0-windows10.0.19041.0` for + `src/tests` so the WSLC tests bind the implementation; `Wsl.csproj` adds the Windows + builds with a platform-identifier condition (never a literal TFM). The Docker integration projects + declare `net10.0` directly so they run on Linux **and** keep the lowest supported framework under test. + The contract, the failures that enforce it and the CI workarounds are documented in [Consumer Requirements](docs/wiki/Consumer-Requirements.md); update that page whenever a target framework, the `buildTransitive` defaults, the payload layout or the `PCC0001`/`PCC0002` guards change. +- The `net11.0` library builds are compiled with `runtime-async=on` (`PurviewRuntimeAsync=false` opts out): + Purview.BuildSdk applies it to every `net11.0`+ target together with the C# 15 + `updated-memory-safety-rules` tier (`MemorySafetyRules=2`), so the repository does not repeat either + switch. The runtime manages suspension instead of compiler-generated state machines, which lowers async + overhead and makes live stack traces name the real methods. `Wsl.UnitTests/BuildFeatureTests.cs` guards + it. The `net10.0` builds keep the classic state machines. Because `closed` hierarchies require + `System.Runtime.CompilerServices.IsClosedTypeAttribute` and C# unions require `UnionAttribute` (both + .NET 11 runtime types), neither is used: no public hierarchy here is closed anyway — `WaitStrategy`, + `ContainerException` and the backend interfaces are documented extension points. - `Purview.Containers.Wsl` ships `Sdk/buildTransitive/Purview.Containers.Wsl.{props,targets}` so consumers inherit `WindowsSdkPackageVersion`/`PlatformTarget` defaults, a clear error for an unsupported target framework (`PCC0001`) or a 32-bit Windows consumer (`PCC0002`), and — for a platform-neutral consumer - on a Windows build host — a copy of the WSLC implementation payload (`wslc/`) that the `net10.0` facade + on a Windows build host — a copy of the WSLC implementation payload (`wslc/`) that the portable facade loads at run time. Those assets are framework-agnostic, so NuGet never raises `NU1202` at restore time; the guards are the fail-fast path. Keep them, and keep the `RequiredContent` entries (including `payload/**`) that declare them. They belong to the WSL backend package only. @@ -154,6 +179,9 @@ whenever a target framework, a backend package or the `buildTransitive` assets c - Prefer verifying the service for readiness (exec a readiness command or open a host client connection) over a bare TCP check. Where an image reports readiness too early (MySQL), a log match is wrong. - SQL Server requires an explicit `AcceptLicense()` call; never accept licensing terms on the caller's behalf. + `MsSqlBuilder` also enables `WithPrivileged()` by default: the non-root `mssql` user in the 2025 image + cannot create `/.system` on WSLC, and the managed SDK has no per-container user override. Callers can + opt out with `WithPrivileged(false)`. ## Packaging and validation @@ -200,8 +228,10 @@ whenever a target framework, a backend package or the `buildTransitive` assets c skip/availability checks; the Docker suites use their own `DockerTest` helper. Unit tests may use the modules' `BuildConfigurationForTesting()` internal hook. - `Wsl.UnitTests/ConsumerRequirementsTests.cs` guards the published target framework of the Windows - build (`.NETCoreApp,Version=v10.0` plus `Windows10.0.19041.0`), which the test project binds; keep it - in step with [Consumer Requirements](docs/wiki/Consumer-Requirements.md). + build this project binds (`.NETCoreApp,Version=v11.0` plus `Windows10.0.19041.0`), and + `Wsl.UnitTests/BuildFeatureTests.cs` guards that the net11.0 build has no compiler-generated async + state machines; keep both in step with + [Consumer Requirements](docs/wiki/Consumer-Requirements.md). - `just verify-consumers` packs and then builds throwaway consumer projects to assert the documented guard errors and workarounds. It restores from nuget.org, so it stays out of the `[Category=Unit]` filter and is a local/explicit step. diff --git a/Directory.Packages.props b/Directory.Packages.props index fc8aab4..4534c24 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -27,19 +27,29 @@ nothing left to drift. --> - 3.0.3 + 3.0.5 10.0.26100.80 1.76.0 - + + - + - + + diff --git a/Justfile b/Justfile index ce7ac26..e7e96bc 100644 --- a/Justfile +++ b/Justfile @@ -94,10 +94,14 @@ restore *args: # 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. +# +# --results-directory-layout per-module is a .NET 11 run-level option: every test application gets its +# own report folder, so a multi-module run cannot have one module's identically named report overwrite +# another's. [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 }} --retry-failed-tests 2 --retry-failed-tests-delay 5s --retry-failed-tests-max-percentage 50 {{ 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 --results-directory-layout per-module {{ args }} # Cleans the solution with the specified configuration (default: Debug) [group('Build and Test')] diff --git a/README.md b/README.md index a1a72be..20f779c 100644 --- a/README.md +++ b/README.md @@ -8,24 +8,26 @@ A Testcontainers-style library for .NET that runs throwaway Linux containers for > **Two backends, one API — one reference.** Containers are created through the backend-neutral > `Purview.Containers.Core` abstractions, so the same test suite runs on **WSL Containers** > (`Purview.Containers.Wsl`) or **Docker** (`Purview.Containers.Docker`, driven by Testcontainers). Add the -> umbrella package `Purview.Containers` to a plain `net10.0` project — no Windows target framework needed — -> and it brings the abstractions plus both backends, gets WSLC on a Windows developer machine and Docker on -> a Linux CI runner **without changing a line of test code or configuration**, and can be pinned with -> `PURVIEW_CONTAINERS_BACKEND`. The `Purview.Containers.Wsl` package is multi-target: its `net10.0` facade -> loads the WSLC implementation at run time on Windows and reports `wsl` as unavailable everywhere else. +> umbrella package `Purview.Containers` to a plain `net10.0` or `net11.0` project — no Windows target +> framework needed — and it brings the abstractions plus both backends, gets WSLC on a Windows developer +> machine and Docker on a Linux CI runner **without changing a line of test code or configuration**, and can +> be pinned with `PURVIEW_CONTAINERS_BACKEND`. The `Purview.Containers.Wsl` package is multi-target: its +> `net10.0` and `net11.0` facades load the WSLC implementation at run time on Windows and report `wsl` as +> unavailable everywhere else. > > **[Using it in your tests (auto)](docs/wiki/Using-in-Your-Tests.md)** — the copy-paste zero-config shape. > > **[Backends: WSLC or Docker](docs/wiki/Backends.md)** — comparison, side-by-side project setup, CI > example and troubleshooting. -The WSLC backend uses `Microsoft.WSL.Containers` for ordinary lifecycle operations and a bounded -`wslc` CLI fallback for entrypoint/user/labels, managed networks, reuse, resource copy and image builds. -It requires no Docker installation. The Docker backend is built on -[Testcontainers for .NET](https://dotnet.testcontainers.org/). +The WSLC backend uses `Microsoft.WSL.Containers` 3.0.5 for lifecycle, images, named networks and aliases, +Linux capabilities and session host-loopback support, with a bounded `wslc` CLI fallback only for +entrypoint/user/labels, reuse, resource copy and image builds. It requires no Docker installation. The +Docker backend is built on [Testcontainers for .NET](https://dotnet.testcontainers.org/). -Both backends expose file copy/read, one-shot exit waits, managed bridge networks/aliases and -Dockerfile builds through the same contract. Startup has a five-minute total budget; exec defaults +Both backends expose file copy/read, one-shot exit waits, managed bridge networks/aliases, Linux +capabilities (`WithCapabilityAdd`/`WithCapabilityDrop`) and Dockerfile builds through the same contract; +WSLC additionally offers session host loopback. Startup has a five-minute total budget; exec defaults to one minute, stop to 15 seconds and cleanup to 30 seconds, all configurable. Cancellation is propagated; synchronous native WSLC calls cannot be forcibly interrupted. Disposal follows Testcontainers' `WithCleanUp`: containers are removed by default, `.WithCleanUp(false)` retains them @@ -56,7 +58,7 @@ for inspection, and `.WithAutoRemove()` is opted into separately. See referencing the umbrella package, Testcontainers or WSLC. It verifies native paths, running WSL translations and explicit shared-remote mappings against the daemon using two-way file markers. `Purview.Containers.Aspire` adds opt-in `builder.AddContainerEnvironment()` integration for any -Aspire AppHost. Both target `net10.0`; the adapter requires Aspire 13.6.4 or later. +Aspire AppHost. Both target `net10.0` and `net11.0`; the adapter requires Aspire 13.6.5 or later. These are separate from the existing Docker backend's automatic WSL2 path handling. Docker Engine inside a WSL distribution is not Microsoft WSL Containers. See the [environment package guide](src/src/Environment/Sdk/README.md) and @@ -73,26 +75,27 @@ want to fix the runtime. - 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 - (`net10.0-windows10.0.19041.0`, x64 or arm64) binds the implementation directly. The + WSL 3.0.5.0). +- A consuming project that is **.NET 10 or .NET 11** (.NET 8 and .NET 9 are not supported). A + platform-neutral `net10.0` or `net11.0` project binds the portable facade and gets automatic + WSLC-or-Docker selection; a Windows target framework (`net10.0-windows10.0.19041.0` or + `net11.0-windows10.0.19041.0`, x64 or arm64) binds the matching implementation directly. The `Purview.Containers.Wsl` package ships MSBuild defaults for the supporting settings. **Docker backend** -- Any reachable Docker daemon, and a `net10.0` (or later) project on any platform. +- Any reachable Docker daemon, and a `net10.0` or `net11.0` project on any platform. - Windows bind mounts are translated automatically for a verified local Docker Engine inside WSL2; no consumer path setting is needed. See [WSL2 bind mounts](docs/wiki/Backends.md#windows-bind-mounts-with-docker-engine-in-wsl2). -.NET SDK 11 is required to build this repository (it pins `11.0.100-rc.1`); SDK 10 is enough for a Docker -consumer. +.NET SDK 11 is required to build this repository (it pins `11.0.100`); the .NET 10 or .NET 11 SDK is +enough for a consumer. Verify: ```powershell -wsl --version # WSL Containers installed (verified against 3.0.1.0) -wslc version # e.g. 3.0.1.0 +wsl --version # WSL Containers installed (verified against 3.0.5.0) +wslc version # e.g. 3.0.5.0 ``` ```bash @@ -158,12 +161,12 @@ string amqp = rabbitMq.GetConnectionString(); | Decision | Evidence | | --- | --- | | One shared process-wide session, shared storage path | the image store is keyed by storage path; session start is ~20 ms; concurrent sessions cannot share the VHD, so the runtime falls back to an isolated store | -| Unique session names `wslc-{pid}-{rand}` | session names are machine-reserved | +| Unique session names `purview-{pid}-{rand}` | session names are machine-reserved | | Default `NetworkingMode = Bridged` | port mappings require Bridged; the default is `none` | | Random host ports via native `windowsPort=0` | race-free random assignment | | Serialize session/container lifecycle ops | a concurrent `Start` can race (`0x8000FFFF`) | | No Ryuk-style reaper needed | `Session.Dispose()` frees the session; orphans block only their own name | -| No container-name DNS | containers reach each other by IP only | +| No container-name DNS on the default bridge | managed networks provide alias DNS; the default bridge does not | | `Image`/`WithTag` fail fast | invalid images/tags rejected at configuration time, never at pull time | ## Repository layout @@ -333,13 +336,13 @@ just sample-docker # needs a Docker daemon ### Verifying the consumer contract ```powershell -just verify-consumers # pack, then build 23 throwaway consumer projects against ./artifacts +just verify-consumers # pack, then build 25 throwaway consumer projects against ./artifacts just verify-consumers -Keep # same, keeping the generated projects for inspection ``` `just verify-consumers` asserts every claim in [Consumer Requirements](docs/wiki/Consumer-Requirements.md): -the happy path, the shipped `buildTransitive` defaults, the `PCC0001`/`PCC0002` guards, and the -non-.NET-11 escape hatch that deliberately does not work. It needs network access and is therefore a +the happy path on .NET 10 and .NET 11, the shipped `buildTransitive` defaults, the `PCC0001`/`PCC0002` +guards, and the rejection of .NET 8 (the framework floor). It needs network access and is therefore a local step rather than part of the `[Category=Unit]` pipeline filter. ## License diff --git a/docs/wiki/Architecture.md b/docs/wiki/Architecture.md index 3676caf..9d66cfb 100644 --- a/docs/wiki/Architecture.md +++ b/docs/wiki/Architecture.md @@ -7,8 +7,8 @@ Design for a Testcontainers-style .NET library with pluggable container backends The library is split into a backend-neutral abstraction assembly and one assembly per backend: ``` -Purview.Containers (net10.0) umbrella: references Core, Wsl and Docker - └─ Purview.Containers.Core (net10.0, portable) the backend-neutral abstractions +Purview.Containers (net10.0 + net11.0) umbrella: references Core, Wsl and Docker + └─ Purview.Containers.Core (net10.0 + net11.0, portable) the backend-neutral abstractions ├─ IContainer / IContainerConfiguration / ContainerConfiguration the container contract ├─ ContainerBuilder fluent configuration + validation ├─ ContainerBase typed module container (delegates to the backend) @@ -17,7 +17,8 @@ Purview.Containers (net10.0) umbrella: references Core, ├─ IConnectionStringProvider / ConnectionMode / ContainerConnectionStringProvider connection strings └─ Runtime/ContainerException neutral error taxonomy -Purview.Containers.Wsl (net10.0 facade + net10.0-windows10.0.19041.0 implementation) +Purview.Containers.Wsl (net10.0 + net11.0 facades, + net10.0 + net11.0 windows10.0.19041.0 implementations) ├─ WslContainerBackend : IContainerBackend registers itself as "wsl" ├─ WslContainerRuntime : IContainerRuntime process singleton, owns the shared session │ ├─ SessionSettings (name, storagePath, cpu/mem, gpu, timeout) @@ -27,7 +28,7 @@ Purview.Containers.Wsl (net10.0 facade + net10.0-windows10.0.1 │ └─ SessionHandle -> Microsoft.WSL.Containers.Session (internal) ├─ WslContainer : IContainer WSLC-backed container ├─ WslContainerSession : IContainerSession image pull + container create/start/stop/delete/exec - └─ WslCommand / WslResource / WslFileCopy bounded CLI fallback in that same session + └─ WslResource / WslCommand / WslFileCopy owned resources; bounded CLI fallback in that same session IContainer : IConnectionStringProvider, IAsyncDisposable StartAsync / StopAsync / DisposeAsync / ExecAsync / GetMappedPublicPort / @@ -36,9 +37,10 @@ IContainer : IConnectionStringProvider, IAsyncDisposable ``` `IContainerBackend.CreateNetworkAsync` and `BuildImageAsync` return owned `IContainerResource` -instances on either backend. Docker delegates to Testcontainers. WSLC CLI-backed resources target -the SDK-owned session/store, while ordinary lifecycle operations remain SDK-backed. Dispose containers -before networks/images. See [Capabilities and Cancellation](Backend-Capabilities.md). +instances on either backend. Docker delegates to Testcontainers. WSLC networks and their deletion use the +managed SDK; CLI-backed resources (image builds) target the SDK-owned session/store, while lifecycle, +images and network operations remain SDK-backed. Dispose containers before networks/images. See +[Capabilities and Cancellation](Backend-Capabilities.md). A module (`Purview.Containers.PostgreSql`, `Purview.Containers.Redis`, …) derives its container from `ContainerBase` and references `Purview.Containers.Core` only. The backend is resolved at run time through @@ -48,6 +50,28 @@ reference gives a consumer the whole "auto" setup. Microsoft types (`Session`, `Container`, `Process`, `ContainerSettings`, …) are **runtime implementation details** kept behind the public interfaces. They are not exposed through the public API surface (an opt-in accessor is the only escape hatch). +## Target frameworks + +Every package ships `net10.0` and `net11.0` assets. **.NET 8 and .NET 9 are dropped**; the contract is in +[Consumer Requirements](Consumer-Requirements.md). Purview.BuildSdk's `Current` target-framework set +supplies that pair; the repository does not hand-maintain it. + +- `net10.0` is the floor. A consumer that has not moved to .NET 11 keeps the classic compiler-generated + async state machines and the .NET 10 library surface. +- `net11.0` is compiled with `runtime-async=on` and the C# 15 updated memory-safety rules + (`MemorySafetyRules=2`). The runtime manages suspension and resumption instead of compiler-generated + state machines, which lowers async overhead and makes live stack traces (profiler, `new StackTrace()`, + debugger) name the real methods. `PurviewRuntimeAsync=false` opts out for a local experiment; + `Wsl.UnitTests/BuildFeatureTests.cs` guards that the shipped default is on. +- The WSL Containers backend uses the same split for its Windows builds, and a single implementation + payload serves both portable targets because a .NET 11 process loads a .NET 10 assembly. + +C# 15 `closed` hierarchies and `union` types are deliberately **not** used. Both need .NET 11 runtime types +(`IsClosedTypeAttribute`, `UnionAttribute`), so they could only exist in the `net11.0` assets and would +split the public API by target framework. No type here is a closed hierarchy in the language sense either: +the wait strategies, `ContainerException` and the backend interfaces are documented extension points, and +the service modules derive from `ContainerBase` in their own assemblies. + ## Backend selection `ContainerBackends` is the process-wide registry; `ResolveAsync()` chooses the backend with this @@ -82,7 +106,7 @@ cache. Package consumers get registration from the generated module initializer Design rules: -1. **One lazily-started session per process** with a deterministic name `wslc-{processId}-{8 hex}` (unique per machine; session names are reserved until the session is disposed) and a **stable storage path** (default `%LOCALAPPDATA%\Purview\WslContainers\images`), configurable via `WslContainerRuntimeOptions.StoragePath` or `PURVIEW_CONTAINERS_STORAGE_PATH`. +1. **One lazily-started session per process** with a deterministic name `purview-{processId}-{8 hex}` (unique per machine; session names are reserved until the session is disposed) and a **stable storage path** (default `%LOCALAPPDATA%\Purview\WslContainers\images`), configurable via `WslContainerRuntimeOptions.StoragePath` or `PURVIEW_CONTAINERS_STORAGE_PATH`. 2. The session VM is capped at **4096 MB by default** (`WslContainerRuntimeOptions.Default`). This is required for SQL Server (which refuses to start below 2000 MB — `sqlservr: This program requires a machine with at least 2000 megabytes of memory`) and harmless for lighter containers. Override via `WslContainerRuntimeOptions.MemorySizeInMB`. 2. `IContainerBackend` is the seam for backends: a backend package (starting with `Purview.Containers.Wsl`) supplies `IContainer` instances, and `ContainerBackends` resolves which one runs. `IContainerRuntime` remains the WSLC-internal seam so advanced users/tests can substitute a per-container-session runtime for isolation experiments. 3. Container `DisposeAsync` never terminates the shared session; it deletes the container only. @@ -90,7 +114,7 @@ Design rules: ## Session naming and storage -- Name: `wslc-{pid}-{random8}`. Never place secrets/credentials in names, paths, or logs. +- Name: `purview-{pid}-{random8}`. Never place secrets/credentials in names, paths, or logs. - **Storage is shared by default** (`StorageMode.Shared`): all sessions use `%LOCALAPPDATA%\Purview\WslContainers\images`, so the image store is pulled once and reused across process runs. Set `StorageMode.PerSession` (or an explicit `StoragePath` / @@ -102,7 +126,7 @@ Design rules: first store access, so contention can surface on `GetImages()` rather than at session start; the runtime detects it there too and falls back to an isolated per-process store. Sequential reuse works: after a session ends, a new session on the same path sees its images. -- Cleanup: normal shutdown terminates+disposes the session (frees the name). Orphaned sessions from crashed processes block only their own name; they do not block other sessions or storage-path reuse. +- Cleanup: normal shutdown terminates+disposes the session (frees the name). Orphaned sessions from crashed processes block only their own name, not other sessions or storage-path reuse. They are swept automatically before the next session starts (the runtime terminates `purview-*` and legacy `wslc-*` sessions whose creator PID is gone). ## Image management @@ -142,7 +166,7 @@ Isolated stores are transient and are removed when their session terminates. - `Session.Dispose()` removes the session from the manager and frees its name. - The library registers a process-exit handler so the session is always disposed on normal termination. -- Crash residue: an orphaned session from a dead process remains registered with a dead `Creator PID`. It reserves only its own name. It can be swept with `wslc --session system session terminate`; the library documents this as an optional maintenance step and may offer a dev-time sweep utility (CLI-based, clearly isolated from the core runtime). +- Crash residue: an orphaned session from a dead process remains registered with a dead `Creator PID`. The runtime sweeps `purview-*` (and legacy `wslc-*`) sessions whose creator PID is gone before the first session starts, removes the matching per-session storage directories, removes stale containers whose generated name embeds a dead PID, and prunes unused anonymous volumes. `WslContainerRuntime.SweepOrphanedSessionsAsync()` runs the session sweep explicitly and `CleanUpAsync()` also removes container residue; neither touches a live session. - `Container.DisposeAsync` is idempotent: stop (SIGTERM→SIGKILL) then `Delete(Force)`; swallow `RPC_E_DISCONNECTED`/`ContainerNotFound` on double-delete. ## Observability diff --git a/docs/wiki/Backend-Capabilities.md b/docs/wiki/Backend-Capabilities.md index 3fbcd12..5e9b389 100644 --- a/docs/wiki/Backend-Capabilities.md +++ b/docs/wiki/Backend-Capabilities.md @@ -1,8 +1,9 @@ # Backend capabilities and cancellation The Docker backend is implemented with Testcontainers for .NET. WSLC uses -`Microsoft.WSL.Containers` for ordinary lifecycle operations and the `wslc` CLI for capabilities -missing from the managed SDK. Neither backend silently switches runtimes to implement a feature. +`Microsoft.WSL.Containers` 3.0.5 for lifecycle, images, named networks and aliases, Linux capabilities +and session host-loopback support, with the `wslc` CLI only for capabilities missing from the managed +SDK. Neither backend silently switches runtimes to implement a feature. This is a Testcontainers-style API, not a source-compatible replacement for every Testcontainers API. Supply the command explicitly when overriding an entrypoint; Docker-style entrypoint overrides do not preserve an image's default command. @@ -17,7 +18,9 @@ do not preserve an image's default command. | Pre-start files/directories | `WithResourceMapping(source, target)` | CLI tar copy before init starts | | Runtime files/directories | `CopyAsync(source, target)`, `ReadFileAsync(path)` | CLI tar copy; reads return exact bytes | | One-shot containers | `Wait.ForExitCode(expected)`, `WaitForExitAsync(ct)` | Live init-process state/exit code | -| Managed bridge networks and aliases | `CreateNetworkAsync`, `WithNetwork`, `WithNetworkAliases` | CLI network and container creation; alias DNS | +| Managed bridge networks and aliases | `CreateNetworkAsync`, `WithNetwork`, `WithNetworkAliases` | Managed SDK: `Session.CreateNetwork`/`DeleteNetwork` and `ContainerSettings.Networks`; alias DNS | +| Linux capabilities | `WithCapabilityAdd`, `WithCapabilityDrop` | Managed SDK (`ContainerSettings.CapabilityAdditions`/`CapabilityDrops`); the CLI also passes `--cap-add`/`--cap-drop` | +| Host loopback | `WslContainerRuntimeOptions.HostLoopback` | Managed SDK: `SessionSettings.HostLoopback` (session-wide) | | Dockerfile builds | `BuildImageAsync` | CLI build in the owning SDK session/store | | Reuse | `WithName(...).WithReuse()` | Same configuration/name within the owning session | | Disposal cleanup | `WithCleanUp(bool)` (default `true`) | Stop then force-delete; `false` stops and retains the container | @@ -142,12 +145,13 @@ Do not share a reusable container with tests that mutate it concurrently. The WSLC fingerprint checks configuration, not mapped-file contents or mutable image-tag contents. Delete and recreate the container when those inputs change. -WSLC CLI-backed features require `wslc` on `PATH`. They fail with a prerequisite error if absent, -rather than changing the default SDK-only path. CLI creation cannot currently combine privileged +WSLC CLI-backed features require `wslc` on `PATH`: entrypoint/user/labels, resource copy, Dockerfile +builds and reusable containers. They fail with a prerequisite error if absent, rather than changing the +default SDK-only path. CLI creation cannot currently combine privileged mode with CLI-only creation options and requires single-line environment values. Environment values are passed through a temporary env file removed after creation, not rendered in command diagnostics. Labels and names must not contain secrets. CLI errors deliberately omit potentially sensitive output. UDP mapping and interactive stdin remain unsupported by the WSLC public contract; capture-based exec rejects interactive stdin on both backends. Managed networks do not imply support for arbitrary -drivers, dynamic attachment APIs, or every Testcontainers network/build customization. +drivers or dynamic attachment APIs, and the SDK's subnet/gateway options are not surfaced yet. diff --git a/docs/wiki/Backends.md b/docs/wiki/Backends.md index a5d4eba..d04c251 100644 --- a/docs/wiki/Backends.md +++ b/docs/wiki/Backends.md @@ -34,7 +34,7 @@ Service modules (`Purview.Containers.PostgreSql`, `Redis`, …) are backend-neut | **Package** | `Purview.Containers.Wsl` | `Purview.Containers.Docker` | | **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 | +| **Project target framework** | `net10.0` or `net11.0`, any platform (portable facade), or `net10.0-windows10.0.19041.0`/`net11.0-windows10.0.19041.0`, x64 or arm64 (implementation bound directly) | `net10.0` or `net11.0`, any platform | | **How containers run** | the managed SDK, with `wslc` CLI fallback for SDK-missing features | the Docker Engine API via Testcontainers | | **Images** | a shared store (`%LOCALAPPDATA%\Purview\WslContainers\images`) reused across runs; the location is configurable | the daemon's own image store | | **Leak protection** | session disposal plus a process-exit hook | the Testcontainers resource reaper (Ryuk) | @@ -80,7 +80,7 @@ By default WSLC images are pulled once into a shared store under the local profi To put the image store somewhere else — a different drive, a project-local cache, or a per-run folder — set the process-wide environment variable (works for every consumer shape, including a platform-neutral -`net10.0` facade): +`net10.0` or `net11.0` facade): ```powershell $env:PURVIEW_CONTAINERS_STORAGE_PATH = 'D:\wslc-images' # PowerShell @@ -107,7 +107,7 @@ ContainerBackends.Use(new WslContainerBackend(WslContainerRuntimeOptions.Default when it is unset); `StorageMode.PerSession` gives each session a throwaway store under `%LOCALAPPDATA%\Purview\WslContainers\sessions\{name}`. The full `WslContainerRuntimeOptions` set (CPU, memory, GPU, session name, `StoragePath`, `StorageMode`, timeout, `CleanUpVolumesOnTerminate`) is honoured -on both a platform-neutral `net10.0` consumer — where the facade forwards it to the Windows build at run +on both a platform-neutral `net10.0` or `net11.0` consumer — where the facade forwards it to the Windows build at run time — and a Windows target framework. The pre-rename `WSL_CONTAINERS_STORAGE_PATH` is still honoured as a fallback. @@ -229,11 +229,12 @@ dotnet add package Purview.Containers.Wsl ``` -This project runs WSLC on a Windows host. A platform-neutral `net10.0` project binds the portable -facade; a Windows target framework (`net10.0-windows10.0.19041.0`, x64 or arm64) binds the -implementation directly. `WindowsSdkPackageVersion` is supplied by the package. A project older than -.NET 10 fails the build with `PCC0001`, and a 32-bit Windows consumer with `PCC0002`; see -[Consumer Requirements](Consumer-Requirements.md). +This project runs WSLC on a Windows host. A platform-neutral `net10.0` or `net11.0` project binds the +portable facade; a Windows target framework (`net10.0-windows10.0.19041.0` or +`net11.0-windows10.0.19041.0`, x64 or arm64) binds the implementation directly. +`WindowsSdkPackageVersion` is supplied by the package. A project older than +.NET 10 (including .NET 8 and .NET 9) fails the build with `PCC0001`, and a 32-bit Windows consumer with +`PCC0002`; see [Consumer Requirements](Consumer-Requirements.md). > On a non-Windows host this project has **no usable backend** (the facade reports `wsl` unavailable). > To also run on Docker there, add `Purview.Containers.Docker` or use the umbrella — see Option 3. @@ -399,8 +400,8 @@ jobs: - run: dotnet test --configuration Release --no-build ``` -- On a Linux job, target a platform-neutral framework (`net10.0` or later). The WSL Containers package is - portable — its `net10.0` facade loads the WSLC implementation on a Windows host and reports `wsl` as +- On a Linux job, target a platform-neutral framework (`net10.0` or `net11.0`). The WSL Containers package is + portable — its `net10.0` and `net11.0` facades load the WSLC implementation on a Windows host and report `wsl` as unavailable elsewhere — so one test project can reference both backends and `auto` selects WSLC on a Windows developer machine and Docker on the Linux runner. - Keep your integration tests in their own project or category if you want the fast unit tests to stay @@ -466,7 +467,8 @@ release its image-store VHD. | GPU exposure | ✅ (`EnableGPU` session setting) | depends on the daemon and host runtime | | `ExecOptions.WorkingDirectory` / `Timeout` | ✅ | ✅ | | `ExecOptions.Environment` | ✅ | ✅ | -| Entrypoint/user/labels, copy, builds, networks/aliases | ✅ CLI fallback | ✅ Testcontainers | +| Entrypoint/user/labels, copy, builds | ✅ CLI fallback | ✅ Testcontainers | +| Networks/aliases, Linux capabilities, host loopback | ✅ managed SDK (3.0.5) | ✅ Testcontainers | | Reuse | session-scoped, explicit name | Testcontainers reuse | | Log tailing | polled until the container stops | polled until the container stops | | Leak protection | session disposal + process-exit hook | Testcontainers resource reaper (Ryuk) | @@ -508,7 +510,7 @@ using the wrong runtime. backend whose package is not referenced by the project. **`PCC0001` / `PCC0002`** — a build-time guard from the WSL Containers backend package: the consuming project -is neither .NET 10+ (Windows or platform-neutral), or is a 32-bit Windows consumer. See +is neither .NET 10 nor .NET 11 (Windows or platform-neutral), or is a 32-bit Windows consumer. See [Consumer Requirements](Consumer-Requirements.md), or switch the project to the Docker backend. **The same image is pulled twice** — expected when both runtimes are used: WSLC and Docker keep separate diff --git a/docs/wiki/Consumer-Requirements.md b/docs/wiki/Consumer-Requirements.md index 72e12d3..22c2f4a 100644 --- a/docs/wiki/Consumer-Requirements.md +++ b/docs/wiki/Consumer-Requirements.md @@ -5,33 +5,37 @@ > support guarantee. Treat every version as a preview and pin the exact package version you build > against. -The WSL Containers backend package is **multi-target**. It ships a Windows build -(`net10.0-windows10.0.19041.0`, the implementation, built on the `Microsoft.WSL.Containers` -projection) and a platform-neutral build (`net10.0`, a facade). A Windows-targeting project binds the -implementation directly; any other project binds the facade, which builds on **every** platform and, on -a Windows host, loads the implementation at run time. That is what lets a `net10.0` test project that -references the WSL backend (or the umbrella `Purview.Containers`, which also brings Docker) run on WSLC on -a developer's Windows machine and on Docker in a Linux CI job with no configuration change. This page is -the authoritative statement of that contract, of the workarounds -that exist for it, and of how each of them is verified. +The WSL Containers backend package is **multi-target**. It ships Windows builds +(`net10.0-windows10.0.19041.0` and `net11.0-windows10.0.19041.0`, the implementations, built on the +`Microsoft.WSL.Containers` projection) and platform-neutral builds (`net10.0` and `net11.0`, facades). A +Windows-targeting project binds the matching implementation directly; any other project binds the facade, +which builds on **every** platform and, on a Windows host, loads the implementation at run time. That is +what lets a `net10.0` or `net11.0` test project that references the WSL backend (or the umbrella +`Purview.Containers`, which also brings Docker) run on WSLC on a developer's Windows machine and on Docker +in a Linux CI job with no configuration change. This page is the authoritative statement of that contract, +of the workarounds that exist for it, and of how each of them is verified. + +**.NET 8 and .NET 9 are not supported.** The lowest consumer is .NET 10, and a .NET 11 consumer binds +the `net11.0` assets, which are compiled with `runtime-async=on` and can use the .NET 11 library surface. ## Which package requires what -The WSLC SDK-only path keeps its existing prerequisites. Entry-point/user/label overrides, managed -networks, reuse, file copy and Dockerfile builds additionally require the installed `wslc` CLI on PATH. +The WSLC SDK-only path keeps its existing prerequisites. Entry-point/user/label overrides, reuse, file +copy and Dockerfile builds additionally require the installed `wslc` CLI on PATH; named networks, +aliases, Linux capabilities and host loopback use the managed SDK (3.0.5+). The CLI operates in the SDK-owned session/store, not the user's default CLI session. Missing CLI support fails explicitly, without switching backends. See [Capabilities and Cancellation](Backend-Capabilities.md) for operation deadlines, resource ownership and native cancellation limits. | Package | Target framework | Notes | | --- | --- | --- | -| `Purview.Containers` | `net10.0`, any platform | The umbrella: brings `Purview.Containers.Core` plus both backends. One reference; no target-framework requirement beyond .NET 10. | -| `Purview.Containers.Core` | `net10.0`, any platform | Backend-neutral abstractions (namespace `Purview.Containers`). | -| `Purview.Containers.` | `net10.0`, any platform | Service modules. Restore anywhere; needs a backend package to actually run. | -| `Purview.Containers.Wsl` | `net10.0` and `net10.0-windows10.0.19041.0` | The WSL Containers backend: a portable facade plus the Windows implementation. **The requirements on this page are its contract.** | -| `Purview.Containers.Docker` | `net10.0`, any platform | The Docker backend (Testcontainers). Needs a reachable Docker daemon, not a Windows target framework. | -| `Purview.Containers.Environment` | `net10.0`, any platform | Standalone Docker-compatible CLI bind-sharing resolver; no Aspire, Testcontainers or WSLC dependency. | -| `Purview.Containers.Aspire` | `net10.0`, any platform | Optional adapter requiring Aspire 13.6.4+ and the Environment package; no backend dependency. | +| `Purview.Containers` | `net10.0` and `net11.0`, any platform | The umbrella: brings `Purview.Containers.Core` plus both backends. One reference; no target-framework requirement beyond .NET 10. | +| `Purview.Containers.Core` | `net10.0` and `net11.0`, any platform | Backend-neutral abstractions (namespace `Purview.Containers`). | +| `Purview.Containers.` | `net10.0` and `net11.0`, any platform | Service modules. Restore anywhere; needs a backend package to actually run. | +| `Purview.Containers.Wsl` | `net10.0`, `net11.0`, `net10.0-windows10.0.19041.0` and `net11.0-windows10.0.19041.0` | The WSL Containers backend: portable facades plus the Windows implementations. **The requirements on this page are its contract.** | +| `Purview.Containers.Docker` | `net10.0` and `net11.0`, any platform | The Docker backend (Testcontainers). Needs a reachable Docker daemon, not a Windows target framework. | +| `Purview.Containers.Environment` | `net10.0` and `net11.0`, any platform | Standalone Docker-compatible CLI bind-sharing resolver; no Aspire, Testcontainers or WSLC dependency. | +| `Purview.Containers.Aspire` | `net10.0` and `net11.0`, any platform | Optional adapter requiring Aspire 13.6.5+ and the Environment package; no backend dependency. | The standalone resolver requires a reachable Linux-container daemon through its selected CLI, working credential helpers on `PATH`, and client write access to bind-source directories for verification markers. @@ -43,8 +47,8 @@ See the [resolver guide](https://github.com/purview-dev/containers/blob/main/src The portable package contracts are exercised by `just verify-consumers` cases 22 and 23. Everything below describes the **WSL Containers backend**. A consumer of a different backend (for -example `Purview.Containers.Docker`) needs only a portable `net10.0` project: the -abstractions and the service modules are portable `net10.0` assets. For how to use and switch between the +example `Purview.Containers.Docker`) needs only a portable `net10.0` or `net11.0` project: the +abstractions and the service modules are portable assets. For how to use and switch between the backends, see [Backends: WSLC or Docker](Backends.md). For Windows bind mounts targeting a local WSL2 Docker Engine, the Docker backend automatically @@ -55,8 +59,8 @@ the owning distribution's default user must be able to run `docker info` against ## Copy this into your project -Most projects need **nothing at all**: a `net10.0` project binds the facade and gets automatic backend -selection. A Windows-targeting project can be explicit: +Most projects need **nothing at all**: a `net10.0` or `net11.0` project binds the facade and gets automatic +backend selection. A Windows-targeting project can be explicit: ```xml @@ -75,17 +79,19 @@ file, and because the defaults only fill in a value when you have not chosen one | # | Requirement | Why it exists | | --- | --- | --- | -| 1 | **.NET 10 or later** | `Purview.Containers.Wsl` ships `lib/net10.0/` (facade) and `lib/net10.0-windows10.0.19041/` (implementation). `Purview.Containers` and the service modules ship `lib/net10.0/` too. | -| 2 | **A Windows-specific target framework that names the OS version** (`net10.0-windows10.0.19041.0` or later) — *only when you want the implementation bound directly; a platform-neutral `net10.0` project binds the facade and still runs WSLC on a Windows host* | The `Microsoft.WSL.Containers` projection only ships Windows assets. `net10.0-windows` on its own is not enough. | +| 1 | **.NET 10 or .NET 11** | `Purview.Containers.Wsl` ships `lib/net10.0/` and `lib/net11.0/` (facades) and `lib/net10.0-windows10.0.19041/` and `lib/net11.0-windows10.0.19041/` (implementations). `Purview.Containers` and the service modules ship `lib/net10.0/` and `lib/net11.0/` too. Anything older — including .NET 8 and .NET 9 — is unsupported. | +| 2 | **A Windows-specific target framework that names the OS version** (`net10.0-windows10.0.19041.0` or `net11.0-windows10.0.19041.0`) — *only when you want the implementation bound directly; a platform-neutral 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 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 +### 1. .NET 10 or .NET 11 -`Purview.Containers.Wsl` ships a `net10.0` facade and a `net10.0-windows10.0.19041.0` implementation, -so it restores on any .NET 10+ project. The repository's own SDK is .NET 11 (`global.json` pins -`11.0.100-rc.1.26425.128`), but that is a build-time detail, not a consumer requirement. +`Purview.Containers.Wsl` ships `net10.0` and `net11.0` facades and `net10.0-windows10.0.19041.0` and +`net11.0-windows10.0.19041.0` implementations, so it restores on any .NET 10+ project. A .NET 11 consumer +binds the `net11.0` assets, which are compiled with `runtime-async=on`; a .NET 10 consumer keeps the +`net10.0` assets. The repository's own SDK is .NET 11 (`global.json` pins `11.0.100`), but that is a +build-time detail, not a consumer requirement. ### 2. A Windows-specific target framework (optional) @@ -96,12 +102,20 @@ gives you the automatic "WSLC on a Windows machine, Docker everywhere else" beha net10.0 ``` +```xml +net11.0 +``` + A Windows target framework binds the implementation directly (no run-time load): ```xml net10.0-windows10.0.19041.0 ``` +```xml +net11.0-windows10.0.19041.0 +``` + ```xml net10.0-windows ``` @@ -109,7 +123,7 @@ A Windows target framework binds the implementation directly (no run-time load): `net10.0-windows` resolves its platform version to `7.0`, which is older than the `10.0.19041.0` the implementation was built against, so NuGet selects no compile assets for it. Always spell out the version. (The `Microsoft.WSL.Containers` package itself is `net8.0-windows10.0.19041.0`, which a -`net10.0-windows10.0.19041.0` project consumes without issue.) +`net10.0-windows10.0.19041.0` or `net11.0-windows10.0.19041.0` project consumes without issue.) ### 3. 64-bit consumers only @@ -122,9 +136,10 @@ error PCC0002: Purview.Containers.Wsl requires a 64-bit (x64 or arm64) consumer ### 4. Windows SDK targeting pack version -`Microsoft.WSL.Containers` 3.0.3's projection references `Microsoft.Windows.SDK.NET` -`10.0.26100.79`. Targeting a Windows target framework (`net10.0-windows10.0.19041.0`) resolves a lower pack -(`10.0.19041.x`) by default and the compiler rejects the mismatch: +`Microsoft.WSL.Containers` 3.0.5's projection references `Microsoft.Windows.SDK.NET` +`10.0.26100.79`. Targeting a Windows target framework (`net10.0-windows10.0.19041.0` or +`net11.0-windows10.0.19041.0`) resolves a lower pack (`10.0.19041.x`) by default and the compiler rejects +the mismatch: ```text error CS1705: Assembly 'wslcsdkcs' ... uses 'Microsoft.Windows.SDK.NET' which has a higher @@ -183,7 +198,7 @@ always wins over these defaults. | Error | Raised by | Meaning | Fix | | --- | --- | --- | --- | -| `PCC0001` | the shipped guard target | The consumer's target framework is neither .NET 10+ on Windows 10.0.19041.0+ nor a platform-neutral .NET 10+ framework | Retarget as above. | +| `PCC0001` | the shipped guard target | The consumer's target framework is neither .NET 10/.NET 11 on Windows 10.0.19041.0+ nor a platform-neutral .NET 10/.NET 11 framework | Retarget as above. | | `PCC0002` | the shipped guard target | The consumer is not 64-bit | Remove your `PlatformTarget`, set it to `x64`/`arm64`, or choose a matching `RuntimeIdentifier`. | | `CS1705` | the C# compiler | `WindowsSdkPackageVersion` is older than the projection requires | Set `WindowsSdkPackageVersion` to `10.0.26100.80` or later. | | `CS8012` | the C# compiler | A referenced assembly targets a different processor (seen when a consumer forces `x86`) | Use a 64-bit consumer. | @@ -219,7 +234,8 @@ What it does and does not do: **There is none.** A project that targets Windows but not .NET 10 (for example `net8.0-windows10.0.19041.0`) cannot use these packages, and the repository verifies that no -escape hatch exists. +escape hatch exists. .NET 8 and .NET 9 are explicitly dropped: they are not built, tested or +documented, and `PCC0001` rejects them. The historical suggestion is `AssetTargetFallback`, which tells NuGet to consider an additional target framework for the project's package references: @@ -244,26 +260,27 @@ consume the packages from the older project. ## Multi-targeting -Multi-targeting is **no longer required** for the WSL Containers backend: a platform-neutral `net10.0` -target binds the portable facade. If you multi-target for other reasons, reference the package -unconditionally — both inner builds are supported: +Multi-targeting is **no longer required** for the WSL Containers backend: a platform-neutral `net10.0` or +`net11.0` target binds the portable facade. If you multi-target for other reasons, reference the package +unconditionally — every inner build is supported: ```xml - net10.0;net10.0-windows10.0.19041.0 + net10.0;net11.0;net10.0-windows10.0.19041.0 ``` -The Windows inner build binds the implementation; the portable inner build binds the facade. The +The Windows inner build binds the implementation; the portable inner builds bind the facade. The historical pattern — multi-targeting with the reference only on the Windows inner build — still works, -because the package keeps a `net10.0-windows10.0.19041` build, but the condition is no longer needed. +because the package keeps `net10.0-windows10.0.19041` and `net11.0-windows10.0.19041` builds, but the +condition is no longer needed. ## Verifying these requirements -`just verify-consumers` packs the solution and builds twenty-one throwaway consumer projects against the +`just verify-consumers` packs the solution and builds twenty-five throwaway consumer projects against the produced packages, asserting every claim on this page: | Case | Consumer | Expected outcome | @@ -273,8 +290,8 @@ produced packages, asserting every claim on this page: | 03 | …with `PlatformTarget=x86` | `PCC0002` | | 04 | …with no settings at all | builds via the shipped `buildTransitive` defaults | | 05 | a module package plus the WSL Containers backend, with no settings | builds; `wslcsdk.dll` copied (the defaults are transitive through the backend) | -| 06 | `net8.0-windows` + `AssetTargetFallback` | `PCC0001` — the escape hatch does not work | -| 07 | `net8.0-windows` | `PCC0001` | +| 06 | `net8.0-windows` + `AssetTargetFallback` | `PCC0001` — .NET 8 is unsupported and the escape hatch does not work | +| 07 | `net8.0-windows` | `PCC0001` — below the .NET 10 floor | | 08 | plain `net11.0` (platform-neutral facade) | builds; the `wsl` registration is generated | | 09 | …with `PlatformTarget=AnyCPU` | builds (corrected to `x64`) | | 10 | the `net11.0-windows` shorthand (no OS version) | `PCC0001` | @@ -289,6 +306,10 @@ produced packages, asserting every claim on this page: | 19 | a plain `net10.0` consumer of the WSL Containers backend | builds; the `wsl` registration is generated (the portable facade) | | 20 | a `net10.0` consumer of two modules with **both** backends (the auto shape) | builds; both registrations are generated | | 21 | a `net10.0` consumer of modules with only the umbrella `Purview.Containers` | builds; both registrations are generated (one reference) | +| 22 | a `net10.0` consumer of the standalone environment resolver | builds; no backend required | +| 23 | a `net10.0` consumer of the Aspire adapter | builds; no backend required | +| 24 | a plain `net11.0` consumer of the WSL Containers backend | builds; the `wsl` registration is generated (the runtime-async facade) | +| 25 | a `net11.0` consumer of modules with only the umbrella `Purview.Containers` | builds; both registrations are generated (one reference) | The script is `scripts/verify-consumers.ps1` and it only writes to the temp folder. It needs network access (it restores transitive dependencies from nuget.org), so it is a local/CI-explicit step diff --git a/docs/wiki/Contributing-Modules.md b/docs/wiki/Contributing-Modules.md index 7f16eba..5610232 100644 --- a/docs/wiki/Contributing-Modules.md +++ b/docs/wiki/Contributing-Modules.md @@ -86,7 +86,8 @@ public class MyServiceBuilder : ContainerBuilder system session terminate`. +- Test-process termination: a process-exit hook disposes the shared session (frees the name). A crashed process leaves an orphaned session; the runtime sweeps sessions whose creator PID is gone (and their per-session storage directories) before the next session starts, then removes stale containers whose generated name embeds a dead PID and prunes unused anonymous volumes. `WslContainerRuntime.SweepOrphanedSessionsAsync()` runs the session sweep explicitly; `CleanUpAsync()` also removes container residue. ## Secrets & names - Container names are generated uniquely (`{service}-{processId}-{random8}`); never contain credentials. -- Session names are `wslc-{pid}-{random8}`. +- Session names are `purview-{pid}-{random8}`. - `Secret.ToString()` returns ``; CLI errors omit raw output. Container logs can still contain application secrets, so do not print them indiscriminately. diff --git a/docs/wiki/Modules.md b/docs/wiki/Modules.md index 597f0b3..46e0b8e 100644 --- a/docs/wiki/Modules.md +++ b/docs/wiki/Modules.md @@ -18,7 +18,7 @@ Modules are thin packages layered on the backend-neutral abstractions Modules must **not** duplicate container runtime infrastructure, and they must never reference a backend package (`Purview.Containers.Wsl`, …): the container base resolves the backend through `ContainerBackends.ResolveAsync()`, which is what lets the same module package run on WSLC or Docker. Every -module targets `net10.0` and is portable. +module targets `net10.0` and `net11.0` and is portable. ## Builder model @@ -148,7 +148,7 @@ that leans on the Testcontainers modules ports across unchanged: |---|---|---|---|---| | PostgreSQL | `postgres:17` | `pg_isready` | Npgsql | ✅ implemented | | Redis | `redis:7` | `redis-cli ping` | StackExchange.Redis | ✅ implemented — also usable with Valkey/Garnet via `WithImage` | -| SQL Server | `mcr.microsoft.com/mssql/server:2025-latest` | host `SqlClient` connection | Microsoft.Data.SqlClient | ✅ implemented — requires `.AcceptLicense()`; the connection string defaults to `Database=master` (`WithDatabase(...)` to change it); session needs ≥ 2000 MB memory | +| SQL Server | `mcr.microsoft.com/mssql/server:2025-latest` | host `SqlClient` connection | Microsoft.Data.SqlClient | ✅ implemented — requires `.AcceptLicense()`; the connection string defaults to `Database=master` (`WithDatabase(...)` to change it); the builder runs the container privileged because the non-root `mssql` user cannot create `/.system` on WSLC (`WithPrivileged(false)` to opt out); session needs ≥ 2000 MB memory | | RabbitMQ | `rabbitmq:3-management` | log `"Server startup complete"` | RabbitMQ.Client | ✅ implemented — AMQP + management endpoints | | Azurite | `mcr.microsoft.com/azure-storage/azurite` | log `"successfully listening"` | Azure.Storage.* | ✅ implemented — blob/queue/table endpoints; the connection string carries the well-known `devstoreaccount1` key (`AzuriteAccount.Key`) | | NATS | `nats:2` | log `"Listening for client connections"` | NATS.Client.Core | ✅ implemented — client + monitoring endpoints | diff --git a/docs/wiki/Networking.md b/docs/wiki/Networking.md index eed4a2a..8c4e882 100644 --- a/docs/wiki/Networking.md +++ b/docs/wiki/Networking.md @@ -22,10 +22,30 @@ Attach another container using the same backend and `WithNetwork(network.Name)` `cache:6379`. Dispose containers before disposing the network. Names/aliases must not contain secrets. Network aliases require an explicit network; a named network cannot be combined with `None`. -Docker uses Testcontainers networks. WSLC uses the `wslc` CLI in the SDK-owned session because the -managed SDK exposes only bridge/none settings, not network creation. This path requires `wslc` on -PATH; the ordinary default-bridge path remains SDK-only. See -[Capabilities and Cancellation](Backend-Capabilities.md) for timeouts and platform limits. +Docker uses Testcontainers networks. WSLC uses the managed `Microsoft.WSL.Containers` 3.0.5 network API +(`Session.CreateNetwork`/`DeleteNetwork` and `ContainerSettings.Networks`), so creating a managed network +and attaching containers with aliases no longer needs the `wslc` CLI. The ordinary default-bridge path +remains SDK-only as well. See [Capabilities and Cancellation](Backend-Capabilities.md) for timeouts and +platform limits. + +The WSLC default bridge has no container-name DNS; alias resolution is a property of the managed network, +so `WithNetwork(...).WithNetworkAliases(...)` is the supported shape for container-to-container names. + +## Host loopback (WSLC) + +`Microsoft.WSL.Containers` 3.0.5 can expose the Windows host loopback interface to the session under a +hostname you choose. It is session-wide and off by default: + +```csharp +ContainerBackends.Use(new WslContainerBackend(WslContainerRuntimeOptions.Default with +{ + HostLoopback = "host.internal", +})); +``` + +A container can then reach a service listening on the Windows host by that name. Use it only when a +container must call a host process; the alternative — and the normal shape for a test host calling into +a container — is a published port on `127.0.0.1`. ## Host connections and IPs @@ -42,4 +62,5 @@ PATH; the ordinary default-bridge path remains SDK-only. See and service ports to construct such connection strings. UDP mapping remains unsupported by the WSLC public contract. Dynamic network attachment and arbitrary -network drivers are not exposed by the shared API. +network drivers are not exposed by the shared API; a managed network's subnet/gateway options available on +`Microsoft.WSL.Containers` are not surfaced yet. diff --git a/docs/wiki/Packaging.md b/docs/wiki/Packaging.md index 4271fb3..bb9c288 100644 --- a/docs/wiki/Packaging.md +++ b/docs/wiki/Packaging.md @@ -4,8 +4,8 @@ Every project under `src/src` is packable and ships as `Purview.Containers.*`; t never pack. Packages are produced into `./artifacts` with a matching `.snupkg`. The optional `Purview.Containers.Environment` and `Purview.Containers.Aspire` packages also ship -portable `net10.0` assets, their own READMEs and the shared icon. Environment is BCL-only; Aspire -depends only on Environment and `Aspire.Hosting`. Neither is pulled in by the umbrella package. +portable `net10.0` and `net11.0` assets, their own READMEs and the shared icon. Environment is BCL-only; +Aspire depends only on Environment and `Aspire.Hosting`. Neither is pulled in by the umbrella package. ## What a package contains @@ -13,24 +13,25 @@ Each package ships exactly: | Entry | Source | | --- | --- | -| `lib/$(TFM)/Purview.Containers.*.dll` | The built assembly: `net10.0` for `Purview.Containers` (umbrella), `Purview.Containers.Core` and the service modules; `Purview.Containers.Wsl` ships `net10.0` (the portable facade) and `net10.0-windows10.0.19041` (the implementation). | +| `lib/$(TFM)/Purview.Containers.*.dll` | The built assembly: `net10.0` and `net11.0` for `Purview.Containers` (umbrella), `Purview.Containers.Core` and the service modules; `Purview.Containers.Wsl` ships `net10.0` and `net11.0` (the portable facades) plus `net10.0-windows10.0.19041` and `net11.0-windows10.0.19041` (the implementations). | | `lib/$(TFM)/.xml` | XML documentation, generated because `GenerateDocumentationFile` is on for packable projects. | | `README.md` | The package's bespoke `Sdk/README.md` (see below). | | `purview-logo-light.png` | The shared Purview package icon (`assets/images/purview-logo-light.png`). | | `buildTransitive/Purview.Containers.Core.props` / `.targets` | **Core (abstractions) package.** Declares the `PurviewContainersBackends` list and turns it into a generated backend initializer in the consuming assembly. | | `buildTransitive/Purview.Containers.Wsl.props` / `.targets` | **WSL Containers backend only.** Defaults `WindowsSdkPackageVersion`/`PlatformTarget` and raises `PCC0001`/`PCC0002` for unsupported consumers. | | `buildTransitive/Purview.Containers.Docker.props` | **Docker backend only.** Adds the Docker backend to the `PurviewContainersBackends` list. | -| `payload/win-x64/*`, `payload/win-arm64/*` | **WSL Containers backend only.** The Windows implementation, the WSLC projection, its Windows SDK dependencies and the native SDK. The `net10.0` facade loads them at run time on a Windows host; the `buildTransitive` targets copy the matching folder to the consumer output. | +| `payload/win-x64/*`, `payload/win-arm64/*` | **WSL Containers backend only.** The Windows implementation, the WSLC projection, its Windows SDK dependencies and the native SDK. The portable facades load them at run time on a Windows host; the `buildTransitive` targets copy the matching folder to the consumer output. One payload (built from the lowest Windows target) serves both portable targets, because a .NET 11 process loads a .NET 10 assembly. | Portable PDBs are delivered through the `.snupkg`, never inside the `.nupkg`. The umbrella `Purview.Containers` package has no `buildTransitive` of its own: its dependencies' assets flow transitively. `$(TFM)` is expanded per shipped framework: `Purview.Containers`, `Purview.Containers.Core`, -`Purview.Containers.Docker` and the service modules ship `lib/net10.0/`; `Purview.Containers.Wsl` ships -both `lib/net10.0/` (the facade) and `lib/net10.0-windows10.0.19041/` (the implementation), with the -`payload/` folders alongside. Every package therefore restores on a portable `net10.0` project. Consumers -must target .NET 10+; see [Consumer Requirements](Consumer-Requirements.md). +`Purview.Containers.Docker` and the service modules ship `lib/net10.0/` and `lib/net11.0/`; +`Purview.Containers.Wsl` ships `lib/net10.0/` and `lib/net11.0/` (the facades), `lib/net10.0-windows10.0.19041/` +and `lib/net11.0-windows10.0.19041/` (the implementations), with the `payload/` folders alongside. Every +package therefore restores on a portable `net10.0` or `net11.0` project. Consumers must target .NET 10 or +.NET 11; see [Consumer Requirements](Consumer-Requirements.md). ## Package metadata diff --git a/docs/wiki/Release-Flow.md b/docs/wiki/Release-Flow.md index d2a8199..924363c 100644 --- a/docs/wiki/Release-Flow.md +++ b/docs/wiki/Release-Flow.md @@ -92,8 +92,9 @@ nothing and publishes nothing; it reports the verdict and the rule that decided The workflow fails on a `Fail` verdict, logs a notice and publishes nothing on `Skip`, and publishes only on `Release`. -The shared workflow runs on **`ubuntu-latest`**, so the Linux agent builds the portable `net10.0` projects -and the `net11.0-windows10.0.19041.0` test projects. That only works because `src/Directory.Build.props` +The shared workflow runs on **`ubuntu-latest`**, so the Linux agent builds the portable `net10.0` and +`net11.0` projects and the `net11.0-windows10.0.19041.0` test projects. That only works because +`src/Directory.Build.props` sets `EnableWindowsTargeting=true`; removing it fails the pipeline with `NETSDK1100`. The agent has no WSL Containers, which is why the pipeline is filtered to `[Category=Unit]` — see [Consumer Requirements](Consumer-Requirements.md) and [Testing](Testing.md). diff --git a/docs/wiki/Testing.md b/docs/wiki/Testing.md index 93157ab..b136e2f 100644 --- a/docs/wiki/Testing.md +++ b/docs/wiki/Testing.md @@ -10,7 +10,7 @@ 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.Docker.IntegrationTests` | `Integration` | any **Docker daemon** — these target `net10.0`, so they also run on Linux. | +| `Docker.IntegrationTests`, `Modules.Docker.IntegrationTests` | `Integration` | any **Docker daemon** — these target `net10.0`, so they also run on Linux and keep the lowest supported framework under test. | `.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 @@ -31,7 +31,7 @@ session VHD. Runner failures are still real failures: every integration job pins 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 +> `net10.0`/`net11.0` projects and the `net11.0-windows…` test projects on Linux. That works because > `src/Directory.Build.props` sets `EnableWindowsTargeting=true`; without it the SDK reports `NETSDK1100`. > See [Consumer Requirements](Consumer-Requirements.md) for what that workaround does and does not > cover. @@ -139,7 +139,7 @@ Register a **Windows x64** runner for this repository (or organisation) with the - 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 **.NET 11 SDK** the repository pins in `global.json` (`11.0.100`). The job's first step prints `wsl --version` and `wslc version`, so a mis-provisioned runner fails immediately instead of surfacing as container timeouts. `actionlint` is told about the custom label in diff --git a/docs/wiki/Using-in-Your-Tests.md b/docs/wiki/Using-in-Your-Tests.md index 3107d46..ffc4dd4 100644 --- a/docs/wiki/Using-in-Your-Tests.md +++ b/docs/wiki/Using-in-Your-Tests.md @@ -23,10 +23,10 @@ a Mac) — nothing changes between them. Three things make this work, and nothing else is required: -- **A platform-neutral target framework** (`net10.0`). `Purview.Containers.Wsl` is multi-target; a - `net10.0` project binds its portable facade, which runs the WSLC implementation on Windows and reports - `wsl` unavailable everywhere else. (A Windows target framework still works — it binds the - implementation directly — but it cannot run on a Linux CI runner.) +- **A platform-neutral target framework** (`net10.0` or `net11.0`). `Purview.Containers.Wsl` is + multi-target; a platform-neutral project binds its portable facade, which runs the WSLC implementation on + Windows and reports `wsl` unavailable everywhere else. (A Windows target framework still works — it binds + the implementation directly — but it cannot run on a Linux CI runner.) - **The umbrella package** (`Purview.Containers`), which brings `Purview.Containers.Core` (the abstractions) and both backends. WSLC is preferred where it is usable and Docker is the fallback, so the one project runs everywhere. Prefer to be explicit? Reference `Purview.Containers.Core` plus @@ -109,7 +109,7 @@ diagnostics. ## Requirements -- The abstractions and every service module are portable `net10.0`; see +- The abstractions and every service module are portable `net10.0`/`net11.0`; see [Consumer Requirements](Consumer-Requirements.md) for the exact target-framework contract, the `PCC0001`/`PCC0002` guards and the `EnableWindowsTargeting` workaround for non-Windows build agents. - The per-module connection-string shapes (Redis, PostgreSQL, SQL Server, MySQL, RabbitMQ, Azurite, NATS, diff --git a/global.json b/global.json index a952730..a12bdeb 100644 --- a/global.json +++ b/global.json @@ -1,13 +1,13 @@ { "sdk": { - "version": "11.0.100-rc.2.26504.105", - "rollForward": "latestMajor", - "allowPrerelease": true + "version": "11.0.100", + "rollForward": "latestPatch", + "allowPrerelease": false }, "msbuild-sdks": { - "Purview.BuildSdk": "1.3.0" + "Purview.BuildSdk": "2.0.0" }, "test": { "runner": "Microsoft.Testing.Platform" } -} +} \ No newline at end of file diff --git a/package.json b/package.json index d895295..913bebf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "containers", - "version": "1.0.0-prerelease.12", + "version": "1.0.0-prerelease.13", "description": "Testcontainers-style library for .NET that runs throwaway Linux containers for integration testing, on Microsoft WSL Containers or Docker.", "license": "MIT", "author": { diff --git a/samples/getting-started/README.md b/samples/getting-started/README.md index 8617cbb..18351a7 100644 --- a/samples/getting-started/README.md +++ b/samples/getting-started/README.md @@ -13,6 +13,10 @@ runs on WSL Containers on Windows and Docker everywhere else. See | `DockerSample` | `Purview.Containers.Docker` | `net10.0` | a reachable Docker daemon (`docker info`) | | `AutoSample` | umbrella (auto) | `net10.0` | either — WSLC on Windows, Docker elsewhere | +The split is deliberate: `DockerSample` and `AutoSample` stay on `net10.0` (the lowest supported +framework) while `WslSample` targets `net11.0-windows`, so the samples keep both ends of the supported +surface compiling and runnable. + Run them from the repository root: ```powershell diff --git a/scripts/verify-consumers.ps1 b/scripts/verify-consumers.ps1 index 68e9391..668f36b 100644 --- a/scripts/verify-consumers.ps1 +++ b/scripts/verify-consumers.ps1 @@ -17,14 +17,14 @@ 03 ...with PlatformTarget=x86 -> fails PCC0002 04 ...with no settings at all (buildTransitive defaults) -> builds, wslcsdk.dll copied 05 a module package with no settings (defaults are transitive) -> builds - 06 net8.0-windows + AssetTargetFallback escape hatch -> PCC0001 (the hatch does not work) - 07 net8.0-windows without the escape hatch -> PCC0001 + 06 net8.0-windows + AssetTargetFallback escape hatch -> PCC0001 (.NET 8 and 9 are unsupported) + 07 net8.0-windows without the escape hatch -> PCC0001 (below the .NET 10 floor) 08 plain net11.0 (platform-neutral facade) -> builds, wsl registration generated 09 ...with PlatformTarget=AnyCPU (corrected to x64) -> builds, wslcsdk.dll copied 10 the net11.0-windows shorthand TFM (no OS version) -> PCC0001 11 multi-targeting with a conditional PackageReference -> builds 12 multi-targeting with an unconditional PackageReference -> builds (both inner builds supported) - 13 net10.0 + core abstractions -> builds + 13 net10.0 + core abstractions -> builds (lowest supported framework) 14 net10.0 + Docker backend -> builds, backend registration generated 15 net10.0 + service module (no backend package) -> builds 16 net11.0 windows + both backends (auto detection) -> builds, both registrations generated @@ -35,6 +35,8 @@ 21 net10.0 + umbrella Purview.Containers + modules (one ref) -> builds, both registrations generated 22 net10.0 + standalone environment resolver -> builds, no backend required 23 net10.0 + Aspire adapter -> builds, no backend required + 24 plain net11.0 + WSL backend (the runtime-async facade) -> builds, wsl registration generated + 25 net11.0 + umbrella Purview.Containers + modules (one ref) -> builds, both registrations generated .PARAMETER FeedPath Folder holding the packed .nupkg files. Defaults to /artifacts. @@ -486,6 +488,18 @@ public static class Program } } '@ } + + New-ConsumerCase -Id '24' -Name 'plain net11.0 + WSL backend (runtime-async facade)' ` + -Framework 'net11.0' ` + -Sources @{ 'Smoke.Portable.cs' = $portableSource } ` + -RequireText 'WslContainerBackend.Create()' + + New-ConsumerCase -Id '25' -Name 'net11.0 + umbrella Purview.Containers + modules (one reference, auto)' ` + -Framework 'net11.0' ` + -Package 'Purview.Containers' ` + -Items $umbrellaItems ` + -Sources @{ 'Smoke.Auto.cs' = $autoSource } ` + -RequireText @('WslContainerBackend.Create()', 'DockerContainerBackend.Create()') ) diff --git a/skills-lock.json b/skills-lock.json index fb6c4df..2109813 100644 --- a/skills-lock.json +++ b/skills-lock.json @@ -1,119 +1,131 @@ { - "version": 1, + "version": 2, "skills": { "purview-architecture": { "source": "purview-dev/engineering-playbook", "sourceType": "github", "skillPath": "skills/purview-architecture/SKILL.md", - "computedHash": "04dff518cc8553f7ff05a7871737bf8edeea1f92f32fa1b5cff8af94a9818fd4" + "computedHash": "2500e935ff2ccb350f26ebb9be321dc9f99800121aa9412fdc597f95c2c465c8" }, "purview-architecture-testing": { "source": "purview-dev/engineering-playbook", "sourceType": "github", "skillPath": "skills/purview-architecture-testing/SKILL.md", - "computedHash": "5907c6bf28788b755b752041e2f0cd88222604a949988d487c25940b6284fce4" + "computedHash": "23af6d9baeb408e1e3f4034cef6539d7efa3fecb1b4da918cb539bd8dfbd5a00" }, "purview-buildsdk-adoption": { "source": "purview-dev/build-sdk", "sourceType": "github", "skillPath": "skills/purview-buildsdk-adoption/SKILL.md", - "computedHash": "ca2e13fc8074281f8ca2b510b742ae99db28f97d78f5fb36a6f27936a91941fc" + "computedHash": "d90f4aeacaa6aab263d6e6096fec9c49e50e3869798325c0e8b7e0b74fde2987" }, "purview-buildsdk-configuration": { "source": "purview-dev/build-sdk", "sourceType": "github", "skillPath": "skills/purview-buildsdk-configuration/SKILL.md", - "computedHash": "55f96148b07e03f292f06a786a9733ae6df6d971870af48caf32212a3b58e46d" + "computedHash": "c3bb16976c92287262fba1d8c09ecad2c2e3a87ba958c8f3d7b59336c94118f1" }, "purview-buildsdk-project-detection": { "source": "purview-dev/build-sdk", "sourceType": "github", "skillPath": "skills/purview-buildsdk-project-detection/SKILL.md", - "computedHash": "1f251609d5e0c219a9eb627ce27fc38f845e1ab826a2b0bc0985af8ec1a069a2" + "computedHash": "1821fcce1adebd46e56a832988e4c400c73eee3f9f5100c9a1a338666f8d74da" }, "purview-buildsdk-project-layout": { "source": "purview-dev/build-sdk", "sourceType": "github", "skillPath": "skills/purview-buildsdk-project-layout/SKILL.md", - "computedHash": "d88899ee89edf9615af4b8406b50d53ffb9cec63ed2a9a4ace6d9a322aeef059" + "computedHash": "758ce881a2ffe6aee4cb9bacb5feae07b8cc993d1eabe34b5576db2cdef3d1ab" }, "purview-buildsdk-repository-review": { "source": "purview-dev/build-sdk", "sourceType": "github", "skillPath": "skills/purview-buildsdk-repository-review/SKILL.md", - "computedHash": "d366821995ff410caa436ccb996eaa6429f1ba51ba985b210d74ec3963f6c06f" + "computedHash": "b5f83029c4eaa8153db80ca686ea970e6bf5cb5f2d55d9e0971174f6fa2cb188" }, "purview-buildsdk-skill-installation": { "source": "purview-dev/build-sdk", "sourceType": "github", "skillPath": "skills/purview-buildsdk-skill-installation/SKILL.md", - "computedHash": "b61cce9f0f213469c3acab4dd5ebaec450ee2be730680b166c9718c901aedffe" + "computedHash": "9389b31fd8ac05c5fa37155cbf5e52c220422ddd496f7995c46ba5ee590ea111" }, "purview-buildsdk-skill-tests": { "source": "purview-dev/build-sdk", "sourceType": "github", "skillPath": "skills/purview-buildsdk-skill-tests/SKILL.md", - "computedHash": "807b64466de0a871e5f8e678e7686c48a06d8bb9d42a03448f604a35d40d3935" + "computedHash": "27b3520ced5c5a8009cfb47149f0fc1826246275d3ec2991667e1543b011d657" }, "purview-buildsdk-testing": { "source": "purview-dev/build-sdk", "sourceType": "github", "skillPath": "skills/purview-buildsdk-testing/SKILL.md", - "computedHash": "d6a2ac1167faf68e8c27493de81c2e5b0d8183e523de4411aca5f5b9249c8a9c" + "computedHash": "e7e00ede43f5ee8aa4517329e4fdbde00a5a01bd8e98ea42990c851060bcaf9e" + }, + "purview-component-compatibility": { + "source": "purview-dev/engineering-playbook", + "sourceType": "github", + "skillPath": "skills/purview-component-compatibility/SKILL.md", + "computedHash": "070634caa90d032e2319d6799edbee973be0f9380008917474909328e74f143b" }, "purview-engineering-principles": { "source": "purview-dev/engineering-playbook", "sourceType": "github", "skillPath": "skills/purview-engineering-principles/SKILL.md", - "computedHash": "fd38d374b816ecf77964c41486e7c734ea34998c3f2a5538c90a2ba793905994" + "computedHash": "d1b0d2b50e635160f96d03d2790cb725242434bb28b1b47913371bb39d83215b" + }, + "purview-org-harvest": { + "source": "purview-dev/engineering-playbook", + "sourceType": "github", + "skillPath": "skills/purview-org-harvest/SKILL.md", + "computedHash": "040b34d9cd41f23e71dbe1500b0748793377703d1032e1135ed0a3eeeee4a7f8" }, "purview-persistence": { "source": "purview-dev/engineering-playbook", "sourceType": "github", "skillPath": "skills/purview-persistence/SKILL.md", - "computedHash": "0523ecc210bf3571e2fccb859128a499fd80694e9d4089b377a8d39d73c028f4" + "computedHash": "28e92fa8bccc241ca864b6731148bd721f47699baf3a1a2ec07cce42ed650bae" }, "purview-telemetry": { "source": "purview-dev/engineering-playbook", "sourceType": "github", "skillPath": "skills/purview-telemetry/SKILL.md", - "computedHash": "08ba26e1379dfa2bb826ddfd011e95ac84cd583b865f0b89673d7302b2c2c27d" + "computedHash": "f92aab10e3d2be7b24be559316537ba7d0485d0e479c67f4d5c6126f5f2fc652" }, "purview-telemetry-design": { "source": "purview-dev/telemetry-sourcegenerator", "sourceType": "github", "skillPath": "skills/purview-telemetry-design/SKILL.md", - "computedHash": "74c4c4a810713ef34e0179ebd83494c8763d4409e4b3c1289863a8c4a5676cd9" + "computedHash": "5580f584aeee260b51ff1103c4cfebb181ee3025bc2166aa5fae5950c9bdc4e1" }, "purview-telemetry-diagnostics": { "source": "purview-dev/telemetry-sourcegenerator", "sourceType": "github", "skillPath": "skills/purview-telemetry-diagnostics/SKILL.md", - "computedHash": "ae3d02a28143d6e2dbf1af1f89a2394055cfa728864eaee7e2ff05b1398958bc" + "computedHash": "1b9c7119ae9474086489b0a241533fef38d4980a3729d71d18c03c2c2302266a" }, "purview-telemetry-migration": { "source": "purview-dev/telemetry-sourcegenerator", "sourceType": "github", "skillPath": "skills/purview-telemetry-migration/SKILL.md", - "computedHash": "703fe6f5ccbe1e4fe644873fab681ab98caae4bd552edf03dd6d61ff5eeffba2" + "computedHash": "b89e05b97313a019bc88bb47fafbe73cc31559056078e5857efef96979a30e87" }, "purview-telemetry-multi-targeting": { "source": "purview-dev/telemetry-sourcegenerator", "sourceType": "github", "skillPath": "skills/purview-telemetry-multi-targeting/SKILL.md", - "computedHash": "f220ef392d37139a9870a49e9d5be41bf8374e27b42f2808878daa968c666793" + "computedHash": "213bedc6f672b76c3d973dcf6abeb74eb54703ded5beadbdb92f02599a0ec47a" }, "purview-telemetry-quickstart": { "source": "purview-dev/telemetry-sourcegenerator", "sourceType": "github", "skillPath": "skills/purview-telemetry-quickstart/SKILL.md", - "computedHash": "e4ebadfa44536c949ea8600430d6d1b92986958abd25bc6f149d0a00dc9c9b86" + "computedHash": "ffc12991fcd936f7378c8b9bb49f8d0e309073df0460d41df8d598e7806a5e43" }, "purview-testing-conventions": { "source": "purview-dev/engineering-playbook", "sourceType": "github", "skillPath": "skills/purview-testing-conventions/SKILL.md", - "computedHash": "388b0a79b565ed45a8513d77e8ed6e3d4491f96b43328cb09933cec7811c10f6" + "computedHash": "e053513db856852a23aad0e22625e1408fdd63377acb793e6d720fe37b9f7214" } } } diff --git a/skills/purview-containers-capabilities/SKILL.md b/skills/purview-containers-capabilities/SKILL.md index e76053c..5e3c197 100644 --- a/skills/purview-containers-capabilities/SKILL.md +++ b/skills/purview-containers-capabilities/SKILL.md @@ -12,8 +12,9 @@ metadata: # Capabilities on Docker and WSLC -Docker uses Testcontainers for .NET. WSLC ordinarily uses its managed SDK; entrypoint/user/labels, -managed networks, reuse, image builds and copy need the `wslc` CLI on PATH. The CLI targets the same +Docker uses Testcontainers for .NET. WSLC ordinarily uses its managed SDK (3.0.5): lifecycle, images, +named networks and aliases, Linux capabilities and host loopback are SDK-backed; entrypoint/user/labels, +reuse, image builds and copy need the `wslc` CLI on PATH. The CLI targets the same SDK-owned session and image store, not the user's default CLI session. There is no Docker dependency on the WSLC path and no silent runtime fallback when an operation is unsupported. @@ -125,7 +126,8 @@ exec-process kill endpoint. WSLC signals its owned exec process. Dispose the con processes and never assume cancellation rolls back application side effects. CLI-only creation currently rejects privileged mode and multi-line environment values. Ordinary -SDK-backed privileged containers are unaffected. Never put secrets in names or labels; CLI errors +SDK-backed privileged containers are unaffected, and `WithCapabilityAdd`/`WithCapabilityDrop` map to +the managed SDK (or `--cap-add`/`--cap-drop` on the CLI path). Never put secrets in names or labels; CLI errors omit raw command/build output to prevent accidental disclosure. ## Related skills diff --git a/skills/purview-containers-consumer-requirements/SKILL.md b/skills/purview-containers-consumer-requirements/SKILL.md index 543a0cd..977b67f 100644 --- a/skills/purview-containers-consumer-requirements/SKILL.md +++ b/skills/purview-containers-consumer-requirements/SKILL.md @@ -15,16 +15,18 @@ metadata: | Package | Target frameworks | Notes | | --- | --- | --- | -| `Purview.Containers` | `net10.0`, any platform | the umbrella: `Core` plus both backends | -| `Purview.Containers.Core` | `net10.0`, any platform | backend-neutral abstractions | -| `Purview.Containers.` | `net10.0`, any platform | service modules; restore anywhere, need a backend to run | -| `Purview.Containers.Wsl` | `net10.0` **and** `net10.0-windows10.0.19041.0` | a portable facade plus the Windows implementation. **Everything below is its contract.** | -| `Purview.Containers.Docker` | `net10.0`, any platform | needs a reachable Docker daemon, not a Windows target framework | - -A consumer of any backend other than WSL needs only a portable `net10.0` project. - -WSLC features missing from the managed SDK (entrypoint/user/labels, managed networks, reuse, copy -and Dockerfile builds) additionally require `wslc` on PATH. The ordinary SDK-only path is unchanged. +| `Purview.Containers` | `net10.0` and `net11.0`, any platform | the umbrella: `Core` plus both backends | +| `Purview.Containers.Core` | `net10.0` and `net11.0`, any platform | backend-neutral abstractions | +| `Purview.Containers.` | `net10.0` and `net11.0`, any platform | service modules; restore anywhere, need a backend to run | +| `Purview.Containers.Wsl` | `net10.0`, `net11.0`, `net10.0-windows10.0.19041.0` and `net11.0-windows10.0.19041.0` | portable facades plus the Windows implementations. **Everything below is its contract.** | +| `Purview.Containers.Docker` | `net10.0` and `net11.0`, any platform | needs a reachable Docker daemon, not a Windows target framework | + +A consumer of any backend other than WSL needs only a portable `net10.0` or `net11.0` project. .NET 8 and +.NET 9 are unsupported. + +WSLC features missing from the managed SDK (entrypoint/user/labels, reuse, copy +and Dockerfile builds) additionally require `wslc` on PATH; networks/aliases, Linux capabilities and +host loopback use the managed SDK 3.0.5. The ordinary SDK-only path is unchanged. Use `purview-containers-capabilities` for the feature/cancellation boundary. ## Most projects need nothing @@ -55,7 +57,7 @@ value you have not chosen. | # | Requirement | Why | | --- | --- | --- | -| 1 | **.NET 10 or later** | the package ships `lib/net10.0/` (facade) and `lib/net10.0-windows10.0.19041/` (implementation) | +| 1 | **.NET 10 or .NET 11** (.NET 8 and .NET 9 are unsupported) | the package ships `lib/net10.0/` and `lib/net11.0/` (facades) plus `lib/net10.0-windows10.0.19041/` and `lib/net11.0-windows10.0.19041/` (implementations) | | 2 | **A Windows target framework that names the OS version** — *only* if you want the implementation bound directly | the `Microsoft.WSL.Containers` projection ships Windows assets only | | 3 | **64-bit** — `PlatformTarget` `x64`/`arm64`, or a matching `RuntimeIdentifier` | the native `wslcsdk.dll` ships for `win-x64` and `win-arm64` only | | 4 | **`WindowsSdkPackageVersion` ≥ `10.0.26100.80`** | the projection is compiled against `Microsoft.Windows.SDK.NET` `10.0.26100.79`, and the pack the SDK resolves for a `10.0.19041.0` target framework is older | @@ -71,7 +73,7 @@ value you have not chosen. `net10.0-windows` resolves its platform version to **7.0**, older than the `10.0.19041.0` the implementation was built against, so NuGet selects no compile assets at all. (The `Microsoft.WSL.Containers` package is itself `net8.0-windows10.0.19041.0`, which a -`net10.0-windows10.0.19041.0` project consumes without issue.) +`net10.0-windows10.0.19041.0` or `net11.0-windows10.0.19041.0` project consumes without issue.) ## What the package does for you @@ -98,7 +100,7 @@ package, and use `PURVIEW_CONTAINERS_BACKEND` to pin which one runs. | Error | Raised by | Means | Fix | | --- | --- | --- | --- | -| `PCC0001` | the shipped guard target | the target framework is neither .NET 10+ on Windows `10.0.19041.0`+ nor a platform-neutral .NET 10+ framework | retarget as above | +| `PCC0001` | the shipped guard target | the target framework is neither .NET 10 nor .NET 11 on Windows `10.0.19041.0`+ nor a platform-neutral .NET 10/.NET 11 framework | retarget as above | | `PCC0002` | the shipped guard target | the consumer is not 64-bit | remove `PlatformTarget`, set it to `x64`/`arm64`, or choose a matching `RuntimeIdentifier` | | `CS1705` naming `Microsoft.Windows.SDK.NET` | the compiler | the resolved Windows SDK targeting pack is older than the projection's | pin `WindowsSdkPackageVersion` to `10.0.26100.80` | | `CS0234`/`CS0246` for `Microsoft.WSL`/`Purview` types | the compiler | the package contributed no compile assets — an unsupported target framework, or an unverified escape hatch | fix the target framework; `PCC0001` says the same thing more clearly | @@ -115,9 +117,10 @@ Use it to keep one solution building in CI, not as a way to run WSLC off Windows The repository proves every row above rather than asserting it: - `Wsl.UnitTests/ConsumerRequirementsTests.cs` guards the **shape** inside the normal unit run — the - Windows build targets `.NETCoreApp,Version=v10.0`, targets `Windows10.0.19041.0`, and declares only - that OS platform. If the target framework drifts, that test fails, and this page, the package - READMEs and the `buildTransitive` defaults all have to move with it. + Windows build targets `.NETCoreApp,Version=v11.0`, targets `Windows10.0.19041.0`, and declares only + that OS platform. `Wsl.UnitTests/BuildFeatureTests.cs` guards that the net11.0 build has no + compiler-generated async state machines (runtime-async is on). If either drifts, the test fails, and + this page, the package READMEs and the `buildTransitive` defaults all have to move with it. - `just verify-consumers` proves the **behaviour**: it packs, then builds throwaway consumer projects for every documented outcome, including the guard errors (`PlatformTarget=x86` → `PCC0002`; `net8.0-windows` → `PCC0001`; `net11.0-windows` without an OS version → `PCC0001`). It sits outside diff --git a/skills/purview-containers-lifecycle/SKILL.md b/skills/purview-containers-lifecycle/SKILL.md index 7856b45..aef8001 100644 --- a/skills/purview-containers-lifecycle/SKILL.md +++ b/skills/purview-containers-lifecycle/SKILL.md @@ -69,13 +69,16 @@ These are the ones that matter when a test throws: - A **session crash** marks the runtime session dead, and subsequent operations throw `WslContainerSessionTerminatedException` rather than failing obscurely. - **Test-process termination** disposes the shared session through a process-exit hook, freeing the - name. A *crashed* process leaves an orphaned session that only reserves its name; sweep it with - `wslc --session system session terminate`. + name. A *crashed* process leaves an orphaned session; the runtime sweeps `purview-*` (and legacy + `wslc-*`) sessions whose creator PID is gone before the next session starts, deletes their + per-session storage directories, removes stale containers whose generated name embeds a dead PID and + prunes unused anonymous volumes. `WslContainerRuntime.SweepOrphanedSessionsAsync()` does the session + sweep explicitly; `CleanUpAsync()` also removes container residue. ## Secrets and names Container names are generated uniquely as `{service}-{processId}-{random8}` and **never contain -credentials**; session names are `wslc-{pid}-{random8}`. Diagnostics redact secrets through a +credentials**; session names are `purview-{pid}-{random8}`. Diagnostics redact secrets through a `Secret` wrapper whose `ToString()` returns ``. Application output can contain secrets; do not assume log snapshots or timeout diagnostics are safe to publish. CLI failures omit raw command/build output. @@ -145,7 +148,7 @@ Verified WSLC behaviour, and the parts that surprise people: `http://:8080/` fail with "bad address"; `/etc/hosts` holds only localhost and the container's own `IP `. - Use `IContainerBackend.CreateNetworkAsync` and `WithNetwork`/`WithNetworkAliases` for managed - networks with alias DNS on both backends. WSLC uses the CLI in the SDK-owned session. + networks with alias DNS on both backends. WSLC uses the managed SDK network API in the SDK-owned session. How the library models it: `GetMappedPublicPort(containerPort)` reads `Inspect().Ports["/tcp"][0].HostPort`; `IContainer.NetworkIp` is the bridge IP after start diff --git a/skills/purview-containers-quickstart/SKILL.md b/skills/purview-containers-quickstart/SKILL.md index d6f0008..396d895 100644 --- a/skills/purview-containers-quickstart/SKILL.md +++ b/skills/purview-containers-quickstart/SKILL.md @@ -49,10 +49,11 @@ dotnet add package Purview.Containers Three things make it work, and nothing else is required: -- **A platform-neutral target framework** (`net10.0`). `Purview.Containers.Wsl` is multi-targeted; a - `net10.0` project binds its **portable facade**, which runs the WSLC implementation on Windows and - reports `wsl` unavailable everywhere else. A Windows target framework binds the implementation - directly — and then cannot run on a Linux CI runner. +- **A platform-neutral target framework** (`net10.0` or `net11.0`; .NET 8 and .NET 9 are unsupported). + `Purview.Containers.Wsl` is multi-targeted; a platform-neutral project binds its **portable facade**, + which runs the WSLC implementation on Windows and reports `wsl` unavailable everywhere else. A Windows + target framework binds the implementation directly — and then cannot run on a Linux CI runner. The + `net11.0` assets are compiled with `runtime-async=on`. - **The umbrella package**, bringing `Core` and both backends. - **No registration line and no environment variable.** Each backend package ships `buildTransitive` assets that generate a module initializer in your assembly, so the process discovers the registered diff --git a/skills/purview-containers-testing/SKILL.md b/skills/purview-containers-testing/SKILL.md index 1a8e22d..87d122e 100644 --- a/skills/purview-containers-testing/SKILL.md +++ b/skills/purview-containers-testing/SKILL.md @@ -21,7 +21,7 @@ tier is a property of the project rather than of a hand-written attribute: | --- | --- | --- | | `*.UnitTests` | `Unit` | none — logic, parsing, configuration, wait-strategy units, in-process fakes | | `Wsl.*` and module `*.IntegrationTests` | `Integration` | a **WSLC host** | -| `Docker.IntegrationTests`, `Modules.DockerIntegrationTests` | `Integration` | any **Docker daemon**; these target `net10.0`, so they run on Linux too | +| `Docker.IntegrationTests`, `Modules.DockerIntegrationTests` | `Integration` | any **Docker daemon**; these target `net10.0`, so they run on Linux too and keep the lowest supported framework under test | The shared pipeline filters to `/*/*/*/*[Category=Unit]`, so **the standard pipeline never starts a container**. The Docker suites are the exception: the PR workflow adds an `integration-docker` job on @@ -89,7 +89,7 @@ Self-hosted runner prerequisites: a **Windows x64** runner labelled `self-hosted WSL Containers installed (`wsl --install --no-distribution`) and the pinned .NET SDK. Tell `actionlint` about the custom label in `.github/actionlint.yaml`. -> The shared pipeline runs on `ubuntu-latest`, so it builds the portable `net10.0` projects **and** +> The shared pipeline runs on `ubuntu-latest`, so it builds the portable `net10.0`/`net11.0` projects **and** > the Windows-targeted test projects on Linux. That works only because `src/Directory.Build.props` > sets `EnableWindowsTargeting=true`; without it the SDK reports `NETSDK1100`. diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 70028da..f8d7df9 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -2,7 +2,18 @@ Purview.Containers true - net11.0-windows10.0.19041.0 + + <_PurviewIsTestSubtree + Condition="$([System.String]::Copy('$(MSBuildProjectDirectory)').Replace('\', '/').Contains('/src/tests/'))" + >true + net11.0-windows10.0.19041.0 x64 10.0.26100.80 true diff --git a/src/src/AzureKeyVaultEmulator/AzureKeyVaultEmulatorCertificates.cs b/src/src/AzureKeyVaultEmulator/AzureKeyVaultEmulatorCertificates.cs index cccacf6..134727c 100644 --- a/src/src/AzureKeyVaultEmulator/AzureKeyVaultEmulatorCertificates.cs +++ b/src/src/AzureKeyVaultEmulator/AzureKeyVaultEmulatorCertificates.cs @@ -278,8 +278,7 @@ static void RunBash(string command) CreateNoWindow = true, }; - using var process = - Process.Start(startInfo) + using var process = Process.Start(startInfo) ?? throw new ContainerConfigurationException($"Failed to start /bin/bash for: {command}"); var error = process.StandardError.ReadToEnd(); diff --git a/src/src/AzureKeyVaultEmulator/Sdk/README.md b/src/src/AzureKeyVaultEmulator/Sdk/README.md index cbe8b6d..6c92a28 100644 --- a/src/src/AzureKeyVaultEmulator/Sdk/README.md +++ b/src/src/AzureKeyVaultEmulator/Sdk/README.md @@ -15,14 +15,14 @@ container can hand out clients that are already wired for the emulator. ## Requirements - **WSL Containers backend:** Windows 10/11 with WSL Containers (`wsl --install --no-distribution`), and a - `.NET 10` or later project. A platform-neutral `net10.0` project binds the portable facade and gets + `.NET 10` or `.NET 11` project. A platform-neutral `net10.0` or `net11.0` project binds the portable facade and gets automatic WSLC-or-Docker selection; a Windows target framework - (`net10.0-windows10.0.19041.0`, x64 or arm64) binds the implementation directly. The + (`net10.0-windows10.0.19041.0` or `net11.0-windows10.0.19041.0`, x64 or arm64) binds the implementation directly. The `Purview.Containers.Wsl` package supplies `buildTransitive` defaults for `WindowsSdkPackageVersion`/`PlatformTarget` and (for a platform-neutral consumer on a Windows build host) the implementation payload, rejecting an unsupported consumer with `PCC0001`/`PCC0002` — see the [consumer requirements](https://github.com/purview-dev/containers/blob/main/docs/wiki/Consumer-Requirements.md). -- **Docker backend:** any reachable Docker daemon (`docker info`), with a `net10.0` or later project on any +- **Docker backend:** any reachable Docker daemon (`docker info`), with a `net10.0` or `net11.0` project on any platform. No Windows target framework and no `PCC` guards apply. - **Experimental:** the API, defaults and packaging can change between prereleases; there is no production support guarantee. diff --git a/src/src/AzureServiceBusEmulator/Sdk/README.md b/src/src/AzureServiceBusEmulator/Sdk/README.md index 25b3b81..f66688b 100644 --- a/src/src/AzureServiceBusEmulator/Sdk/README.md +++ b/src/src/AzureServiceBusEmulator/Sdk/README.md @@ -15,14 +15,14 @@ its state in SQL Server), and needs a backend package (`Purview.Containers.Wsl` ## Requirements - **WSL Containers backend:** Windows 10/11 with WSL Containers (`wsl --install --no-distribution`), and a - `.NET 10` or later project. A platform-neutral `net10.0` project binds the portable facade and gets + `.NET 10` or `.NET 11` project. A platform-neutral `net10.0` or `net11.0` project binds the portable facade and gets automatic WSLC-or-Docker selection; a Windows target framework - (`net10.0-windows10.0.19041.0`, x64 or arm64) binds the implementation directly. The + (`net10.0-windows10.0.19041.0` or `net11.0-windows10.0.19041.0`, x64 or arm64) binds the implementation directly. The `Purview.Containers.Wsl` package supplies `buildTransitive` defaults for `WindowsSdkPackageVersion`/`PlatformTarget` and (for a platform-neutral consumer on a Windows build host) the implementation payload, rejecting an unsupported consumer with `PCC0001`/`PCC0002` — see the [consumer requirements](https://github.com/purview-dev/containers/blob/main/docs/wiki/Consumer-Requirements.md). -- **Docker backend:** any reachable Docker daemon (`docker info`), with a `net10.0` or later project on any +- **Docker backend:** any reachable Docker daemon (`docker info`), with a `net10.0` or `net11.0` project on any platform. No Windows target framework and no `PCC` guards apply. - **SQL Server sidecar:** the default is SQL Server 2025 (`mcr.microsoft.com/mssql/server:2025-latest`), which is x64 only and needs at least 2000 MB of memory in the WSLC session VM or Docker host. On ARM64, diff --git a/src/src/Azurite/Sdk/README.md b/src/src/Azurite/Sdk/README.md index 3ae026c..4f9e232 100644 --- a/src/src/Azurite/Sdk/README.md +++ b/src/src/Azurite/Sdk/README.md @@ -13,14 +13,14 @@ See the [Getting Started guide](https://github.com/purview-dev/containers/blob/m ## Requirements - **WSL Containers backend:** Windows 10/11 with WSL Containers (`wsl --install --no-distribution`), and a - `.NET 10` or later project. A platform-neutral `net10.0` project binds the portable facade and gets + `.NET 10` or `.NET 11` project. A platform-neutral `net10.0` or `net11.0` project binds the portable facade and gets automatic WSLC-or-Docker selection; a Windows target framework - (`net10.0-windows10.0.19041.0`, x64 or arm64) binds the implementation directly. The + (`net10.0-windows10.0.19041.0` or `net11.0-windows10.0.19041.0`, x64 or arm64) binds the implementation directly. The `Purview.Containers.Wsl` package supplies `buildTransitive` defaults for `WindowsSdkPackageVersion`/`PlatformTarget` and (for a platform-neutral consumer on a Windows build host) the implementation payload, rejecting an unsupported consumer with `PCC0001`/`PCC0002` — see the [consumer requirements](https://github.com/purview-dev/containers/blob/main/docs/wiki/Consumer-Requirements.md). -- **Docker backend:** any reachable Docker daemon (`docker info`), with a `net10.0` or later project on any +- **Docker backend:** any reachable Docker daemon (`docker info`), with a `net10.0` or `net11.0` project on any platform. No Windows target framework and no `PCC` guards apply. - **Experimental:** the API, defaults and packaging can change between prereleases; there is no production support guarantee. diff --git a/src/src/Containers/Sdk/README.md b/src/src/Containers/Sdk/README.md index 8c2795d..b16e3cb 100644 --- a/src/src/Containers/Sdk/README.md +++ b/src/src/Containers/Sdk/README.md @@ -40,8 +40,8 @@ await cache.GetDatabase().PingAsync(); There is no registration line and no `PURVIEW_CONTAINERS_BACKEND`: each backend package ships `buildTransitive` assets that register themselves in your assembly, and automatic selection picks the -first usable backend (WSLC is preferred where it works, Docker otherwise). Use a **`net10.0`** (or later) -project — see [Using it in your tests](https://github.com/purview-dev/containers/blob/main/docs/wiki/Using-in-Your-Tests.md). +first usable backend (WSLC is preferred where it works, Docker otherwise). Use a **`net10.0` or +`net11.0`** project — see [Using it in your tests](https://github.com/purview-dev/containers/blob/main/docs/wiki/Using-in-Your-Tests.md). ## When to use this versus a single backend @@ -56,8 +56,8 @@ not change. ## Requirements -- **.NET 10 or later.** The abstractions and service modules are portable; the WSL Containers backend is - multi-target and stays dormant on non-Windows hosts. See the +- **.NET 10 or .NET 11** (.NET 8 and .NET 9 are not supported). The abstractions and service modules are + portable; the WSL Containers backend is multi-target and stays dormant on non-Windows hosts. See the [consumer requirements](https://github.com/purview-dev/containers/blob/main/docs/wiki/Consumer-Requirements.md). - Either a Windows 10/11 host with **WSL Containers** (`wsl --install --no-distribution`) or a reachable **Docker** daemon. diff --git a/src/src/Core/ContainerBackends.cs b/src/src/Core/ContainerBackends.cs index f2ed25e..ab89040 100644 --- a/src/src/Core/ContainerBackends.cs +++ b/src/src/Core/ContainerBackends.cs @@ -236,10 +236,9 @@ CancellationToken cancellationToken if (!selection.IsAuto) { - var named = - registered.FirstOrDefault(backend => - string.Equals(backend.Name, selection.Name, StringComparison.OrdinalIgnoreCase) - ) + var named = registered.FirstOrDefault(backend => + string.Equals(backend.Name, selection.Name, StringComparison.OrdinalIgnoreCase) + ) ?? throw new ContainerBackendUnavailableException( $"The container backend '{selection.Name}' is not registered. Registered backends: " + $"{string.Join(", ", registered.Select(backend => backend.Name))}." diff --git a/src/src/Core/ContainerBuilder.cs b/src/src/Core/ContainerBuilder.cs index ced4946..d2b5f86 100644 --- a/src/src/Core/ContainerBuilder.cs +++ b/src/src/Core/ContainerBuilder.cs @@ -30,6 +30,8 @@ public abstract class ContainerBuilder readonly List _namedVolumes = []; ContainerNetworkingMode _networkingMode = ContainerNetworkingMode.Bridged; bool _privileged; + string[] _capabilityAdditions = []; + string[] _capabilityDrops = []; bool _enableGPU; bool _enableAutoRemove; bool _enableCleanUp = true; @@ -325,6 +327,40 @@ public TBuilder WithPrivileged(bool privileged = true) return (TBuilder)this; } + /// + /// Adds Linux capabilities beyond the runtime defaults (for example NET_ADMIN). The names are + /// passed to the backend unchanged; privileged mode already grants every capability. + /// + public TBuilder WithCapabilityAdd(params string[] capabilities) + { + ArgumentNullException.ThrowIfNull(capabilities); + _capabilityAdditions = ValidateCapabilities(capabilities); + return (TBuilder)this; + } + + /// Drops Linux capabilities from the container (for example NET_RAW). + public TBuilder WithCapabilityDrop(params string[] capabilities) + { + ArgumentNullException.ThrowIfNull(capabilities); + _capabilityDrops = ValidateCapabilities(capabilities); + return (TBuilder)this; + } + + static string[] ValidateCapabilities(string[] capabilities) + { + foreach (var capability in capabilities) + { + if (string.IsNullOrWhiteSpace(capability)) + throw new ContainerConfigurationException("A Linux capability name cannot be empty."); + if (capability.Any(char.IsWhiteSpace)) + throw new ContainerConfigurationException( + $"Linux capability '{capability}' cannot contain whitespace." + ); + } + + return [.. capabilities.Distinct(StringComparer.Ordinal)]; + } + /// Exposes GPU devices to the container. public TBuilder WithGPU(bool enable = true) { @@ -419,8 +455,7 @@ protected virtual TConfiguration BuildConfiguration() { return new TConfiguration { - Image = - _image + Image = _image ?? throw new ContainerConfigurationException( "An image is required. Call WithImage(...) before Build()." ), @@ -435,6 +470,8 @@ protected virtual TConfiguration BuildConfiguration() NamedVolumes = _namedVolumes.ToArray(), NetworkingMode = _networkingMode, Privileged = _privileged, + CapabilityAdditions = [.. _capabilityAdditions], + CapabilityDrops = [.. _capabilityDrops], EnableGPU = _enableGPU, EnableAutoRemove = _enableAutoRemove, EnableCleanUp = _enableCleanUp, diff --git a/src/src/Core/ContainerConfiguration.cs b/src/src/Core/ContainerConfiguration.cs index f9044ff..799f2bc 100644 --- a/src/src/Core/ContainerConfiguration.cs +++ b/src/src/Core/ContainerConfiguration.cs @@ -47,6 +47,12 @@ public record ContainerConfiguration : IContainerConfiguration /// public bool Privileged { get; init; } + /// + public IReadOnlyList CapabilityAdditions { get; init; } = Array.Empty(); + + /// + public IReadOnlyList CapabilityDrops { get; init; } = Array.Empty(); + /// public bool EnableGPU { get; init; } @@ -126,6 +132,8 @@ protected virtual bool PrintMembers(StringBuilder builder) builder.Append(", ").Append(nameof(NamedVolumes)).Append(" = ").Append(NamedVolumes); builder.Append(", ").Append(nameof(NetworkingMode)).Append(" = ").Append(NetworkingMode); builder.Append(", ").Append(nameof(Privileged)).Append(" = ").Append(Privileged); + builder.Append(", ").Append(nameof(CapabilityAdditions)).Append(" = ").Append(CapabilityAdditions); + builder.Append(", ").Append(nameof(CapabilityDrops)).Append(" = ").Append(CapabilityDrops); builder.Append(", ").Append(nameof(EnableGPU)).Append(" = ").Append(EnableGPU); builder.Append(", ").Append(nameof(EnableAutoRemove)).Append(" = ").Append(EnableAutoRemove); builder.Append(", ").Append(nameof(EnableCleanUp)).Append(" = ").Append(EnableCleanUp); diff --git a/src/src/Core/ContainerResourceArchive.cs b/src/src/Core/ContainerResourceArchive.cs index 2db8437..1b7e181 100644 --- a/src/src/Core/ContainerResourceArchive.cs +++ b/src/src/Core/ContainerResourceArchive.cs @@ -8,13 +8,14 @@ static class ContainerResourceArchive internal static async Task CreateAsync( string source, string target, + UnixFileMode? mode, CancellationToken cancellationToken ) { ArgumentException.ThrowIfNullOrWhiteSpace(source); ArgumentException.ThrowIfNullOrWhiteSpace(target); cancellationToken.ThrowIfCancellationRequested(); - if (!target.StartsWith('/') || target.Split('/').Contains("..", StringComparer.Ordinal)) + if (!target.AsSpan().StartsWith("/") || target.Split('/').Contains("..", StringComparer.Ordinal)) throw new ContainerConfigurationException("Copy targets must be absolute container paths without '..'."); MemoryStream archive = new(); @@ -53,7 +54,26 @@ await writer } } else - await writer.WriteEntryAsync(source, target.Trim('/'), cancellationToken).ConfigureAwait(false); + { + if (mode is { } fileMode) + { + await using var stream = File.OpenRead(source); + await writer + .WriteEntryAsync( + new PaxTarEntry(TarEntryType.RegularFile, target.Trim('/')) + { + Mode = fileMode, + DataStream = stream, + }, + cancellationToken + ) + .ConfigureAwait(false); + } + else + { + await writer.WriteEntryAsync(source, target.Trim('/'), cancellationToken).ConfigureAwait(false); + } + } } archive.Position = 0; return archive; diff --git a/src/src/Core/ContainerResourceMapping.cs b/src/src/Core/ContainerResourceMapping.cs index 7071476..aa766b0 100644 --- a/src/src/Core/ContainerResourceMapping.cs +++ b/src/src/Core/ContainerResourceMapping.cs @@ -1,4 +1,11 @@ namespace Purview.Containers; /// A host file or directory copied to an absolute container path before startup. -public sealed record ContainerResourceMapping(string Source, string Target); +public sealed record ContainerResourceMapping(string Source, string Target) +{ + /// + /// Optional Unix mode for a file source. Most images accept the mode inherited from the host; a + /// credential file such as an Erlang cookie must be owner-read-only (400). + /// + public UnixFileMode? Mode { get; init; } +} diff --git a/src/src/Core/IContainerConfiguration.cs b/src/src/Core/IContainerConfiguration.cs index d8b9ba6..ae61991 100644 --- a/src/src/Core/IContainerConfiguration.cs +++ b/src/src/Core/IContainerConfiguration.cs @@ -44,6 +44,15 @@ public interface IContainerConfiguration /// Run the container in privileged mode. bool Privileged { get; } + /// + /// Linux capabilities added to the container beyond the runtime defaults (for example + /// NET_ADMIN). Empty by default. + /// + IReadOnlyList CapabilityAdditions => Array.Empty(); + + /// Linux capabilities dropped from the container, for example NET_RAW. Empty by default. + IReadOnlyList CapabilityDrops => Array.Empty(); + /// Expose GPU devices to the container. bool EnableGPU { get; } diff --git a/src/src/Core/Images/Image.cs b/src/src/Core/Images/Image.cs index 55dd82f..8bc56db 100644 --- a/src/src/Core/Images/Image.cs +++ b/src/src/Core/Images/Image.cs @@ -29,7 +29,7 @@ public readonly record struct Image public string FullReference => $"{Registry}/{Repository}:{Tag}"; /// Last repository segment, e.g. alpine. - public string ShortName => Repository[(Repository.LastIndexOf('/') + 1)..]; + public string ShortName => Repository.AsSpan()[(Repository.AsSpan().LastIndexOf('/') + 1)..].ToString(); /// /// Parses an image reference. Accepts alpine, alpine:latest, docker.io/library/alpine:latest, @@ -45,8 +45,10 @@ public static Image Parse(string reference) } var tag = DefaultTag; - var lastSlash = reference.LastIndexOf('/'); - var lastColon = reference.LastIndexOf(':'); + // Span comparisons are ordinal by definition and avoid the culture-sensitive string overloads + // that CA1307 flags (the char overloads it recommends only exist from .NET 11). + var lastSlash = reference.AsSpan().LastIndexOf('/'); + var lastColon = reference.AsSpan().LastIndexOf(':'); if (lastColon > lastSlash) { tag = NormalizeTag(reference[(lastColon + 1)..]); diff --git a/src/src/Core/Sdk/README.md b/src/src/Core/Sdk/README.md index 4be0403..c5d9232 100644 --- a/src/src/Core/Sdk/README.md +++ b/src/src/Core/Sdk/README.md @@ -78,7 +78,7 @@ callers that want to apply their own policy. ## Requirements -- **.NET 10 or later**, any platform (this package is portable). +- **.NET 10 or .NET 11**, any platform (this package is portable; both framework builds ship). - A **backend package**: `Purview.Containers.Wsl` (WSLC on a Windows host), `Purview.Containers.Docker` (Docker Engine reachable from the test host), or both — or the umbrella `Purview.Containers`, which brings this package plus both backends for automatic selection. diff --git a/src/src/Directory.Build.props b/src/src/Directory.Build.props index d530b3d..80c534b 100644 --- a/src/src/Directory.Build.props +++ b/src/src/Directory.Build.props @@ -2,10 +2,13 @@ diff --git a/src/src/Docker/DockerContainer.cs b/src/src/Docker/DockerContainer.cs index 57f36ca..f722fb1 100644 --- a/src/src/Docker/DockerContainer.cs +++ b/src/src/Docker/DockerContainer.cs @@ -341,7 +341,7 @@ public async Task CopyAsync(string source, string target, CancellationToken canc ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) == 1, this); using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); deadline.CancelAfter(_configuration.OperationTimeout); - await DockerFileCopy.CopyAsync(Handle.Id, source, target, deadline.Token).ConfigureAwait(false); + await DockerFileCopy.CopyAsync(Handle.Id, source, target, mode: null, deadline.Token).ConfigureAwait(false); } /// diff --git a/src/src/Docker/DockerContainerBackend.cs b/src/src/Docker/DockerContainerBackend.cs index 303f482..54b2192 100644 --- a/src/src/Docker/DockerContainerBackend.cs +++ b/src/src/Docker/DockerContainerBackend.cs @@ -188,7 +188,7 @@ await client { foreach (var mapping in configuration.ResourceMappings) DockerFileCopy - .CopyAsync(container.Id, mapping.Source, mapping.Target, cancellationToken) + .CopyAsync(container.Id, mapping.Source, mapping.Target, mapping.Mode, cancellationToken) .GetAwaiter() .GetResult(); }; @@ -307,6 +307,10 @@ IContainerConfiguration configuration [ new DeviceRequest { Count = -1, Capabilities = [new List { "gpu" }] }, ]; + if (configuration.CapabilityAdditions.Count > 0) + parameters.HostConfig.CapAdd = [.. configuration.CapabilityAdditions]; + if (configuration.CapabilityDrops.Count > 0) + parameters.HostConfig.CapDrop = [.. configuration.CapabilityDrops]; foreach (var binding in configuration.PortBindings) { var key = diff --git a/src/src/Docker/DockerFileCopy.cs b/src/src/Docker/DockerFileCopy.cs index 4e42d41..e92d0f8 100644 --- a/src/src/Docker/DockerFileCopy.cs +++ b/src/src/Docker/DockerFileCopy.cs @@ -4,10 +4,16 @@ namespace Purview.Containers.Docker; static class DockerFileCopy { - internal static async Task CopyAsync(string id, string source, string target, CancellationToken cancellationToken) + internal static async Task CopyAsync( + string id, + string source, + string target, + UnixFileMode? mode, + CancellationToken cancellationToken + ) { using var archive = await ContainerResourceArchive - .CreateAsync(source, target, cancellationToken) + .CreateAsync(source, target, mode, cancellationToken) .ConfigureAwait(false); using var client = TestcontainersSettings .OS.DockerEndpointAuthConfig.GetDockerClientBuilder(Guid.NewGuid()) diff --git a/src/src/Docker/Sdk/README.md b/src/src/Docker/Sdk/README.md index 0e5d18a..878d074 100644 --- a/src/src/Docker/Sdk/README.md +++ b/src/src/Docker/Sdk/README.md @@ -35,7 +35,7 @@ PURVIEW_CONTAINERS_BACKEND=docker # or "auto" (the default) to probe WSLC fi ## Requirements -- **.NET 10 or later**, any platform (this package is portable). +- **.NET 10 or .NET 11**, any platform (this package is portable; both framework builds ship). - A reachable Docker daemon. `DockerContainerBackend.GetInfoAsync()` pings it and reports the server version, or the reason it could not be reached, so a missing daemon is a clear message and not a timeout deep inside a test. @@ -56,7 +56,8 @@ PURVIEW_CONTAINERS_BACKEND=docker # or "auto" (the default) to probe WSLC fi - `ExecAsync` supports environment, working-directory and timeout overrides. Capture-based exec rejects interactive stdin. Cancellation/deadlines propagate as `OperationCanceledException`. - Entrypoint, user, labels, pre-start/runtime file copy, one-shot exit waits, managed networks/aliases, - Dockerfile builds and opt-in reuse are available. Defaults are five minutes for total startup, + Linux capabilities (`WithCapabilityAdd`/`WithCapabilityDrop`), Dockerfile builds and opt-in reuse are + available. Defaults are five minutes for total startup, one minute for exec/copy/read/log snapshots, 15 seconds for stop and 30 seconds for cleanup. Reuse follows Testcontainers semantics and disables normal reaper cleanup; `DeleteAsync` removes retained containers explicitly. See diff --git a/src/src/Docker/WslDockerHost.cs b/src/src/Docker/WslDockerHost.cs index bdd8569..a1fa43b 100644 --- a/src/src/Docker/WslDockerHost.cs +++ b/src/src/Docker/WslDockerHost.cs @@ -29,7 +29,7 @@ internal string Resolve(string path) ]); if ( string.IsNullOrWhiteSpace(translated) - || !translated.StartsWith('/') + || !translated.AsSpan().StartsWith("/") || translated.Contains('\n', StringComparison.Ordinal) ) { diff --git a/src/src/Environment/ContainerEnvironmentResolver.cs b/src/src/Environment/ContainerEnvironmentResolver.cs index 26c022e..118fc66 100644 --- a/src/src/Environment/ContainerEnvironmentResolver.cs +++ b/src/src/Environment/ContainerEnvironmentResolver.cs @@ -77,27 +77,21 @@ Func>> wslCandidates /// CONTAINER_ENVIRONMENT_WSL_DISTRIBUTION and CONTAINER_ENVIRONMENT_PROBE_IMAGE on first use. /// public static ContainerEnvironmentResolver ForRuntime(string runtimeCommand = "docker") => - Environments.GetOrAdd( - runtimeCommand, - command => + Environments.GetOrAdd(runtimeCommand, command => + { + ContainerEnvironmentOptions options = new() { - ContainerEnvironmentOptions options = new() - { - RuntimeCommand = command, - WslDistribution = System.Environment.GetEnvironmentVariable( - "CONTAINER_ENVIRONMENT_WSL_DISTRIBUTION" - ), - ProbeImage = - System.Environment.GetEnvironmentVariable("CONTAINER_ENVIRONMENT_PROBE_IMAGE") ?? "alpine:3.19", - }; - var mappings = System.Environment.GetEnvironmentVariable("CONTAINER_ENVIRONMENT_PATH_MAPPINGS"); - if (!string.IsNullOrWhiteSpace(mappings)) - options.PathMappings = - JsonSerializer.Deserialize>(mappings) - ?? throw new FormatException("CONTAINER_ENVIRONMENT_PATH_MAPPINGS must be a JSON object."); - return new(options); - } - ); + RuntimeCommand = command, + WslDistribution = System.Environment.GetEnvironmentVariable("CONTAINER_ENVIRONMENT_WSL_DISTRIBUTION"), + ProbeImage = System.Environment.GetEnvironmentVariable("CONTAINER_ENVIRONMENT_PROBE_IMAGE") + ?? "alpine:3.19", + }; + var mappings = System.Environment.GetEnvironmentVariable("CONTAINER_ENVIRONMENT_PATH_MAPPINGS"); + if (!string.IsNullOrWhiteSpace(mappings)) + options.PathMappings = JsonSerializer.Deserialize>(mappings) + ?? throw new FormatException("CONTAINER_ENVIRONMENT_PATH_MAPPINGS must be a JSON object."); + return new(options); + }); /// Releases synchronization resources. Do not dispose instances returned by . public void Dispose() => _resolutionLock.Dispose(); @@ -150,7 +144,7 @@ public async Task ResolveBindMountsAsync(ProcessStartInfo startInfo, Cancellatio for (var i = verb + 1; i < arguments.Count; i++) { var argument = arguments[i]; - if (argument == "--" || !argument.StartsWith('-')) + if (argument == "--" || !argument.AsSpan().StartsWith("-")) break; if (argument == "--mount" && i + 1 < arguments.Count) { @@ -344,7 +338,7 @@ async Task> GetWslCandidatesAsync(string directory, Cancel cancellationToken ) .ConfigureAwait(false); - if (result.ExitCode == 0 && result.Output.Trim().StartsWith('/')) + if (result.ExitCode == 0 && result.Output.Trim().AsSpan().StartsWith("/")) candidates.Add(result.Output.Trim()); } return candidates; diff --git a/src/src/Environment/ContainerProcess.cs b/src/src/Environment/ContainerProcess.cs index 7220f69..6294e8f 100644 --- a/src/src/Environment/ContainerProcess.cs +++ b/src/src/Environment/ContainerProcess.cs @@ -29,8 +29,7 @@ internal static async Task RunAsync( foreach (var argument in arguments) startInfo.ArgumentList.Add(argument); - using var process = - Process.Start(startInfo) + using var process = Process.Start(startInfo) ?? throw new InvalidOperationException($"Could not start container environment command '{command}'."); try diff --git a/src/src/MsSql/MsSqlBuilder.cs b/src/src/MsSql/MsSqlBuilder.cs index 4a9d0b3..5aba9fa 100644 --- a/src/src/MsSql/MsSqlBuilder.cs +++ b/src/src/MsSql/MsSqlBuilder.cs @@ -27,6 +27,12 @@ public MsSqlBuilder(string image) { WithImage(image) .WithPortBinding(MsSqlPort, assignRandomHostPort: true) + // SQL Server 2025 (and CU11+ 2022) images run as the non-root 'mssql' user. WSLC does + // not give that user a writable home directory or the capabilities needed to create + // '/.system', so the image exits with 0xC0000022 unless the container is privileged. + // This is the managed-SDK equivalent of '--user root' without requiring the wslc CLI; + // Docker accepts it unchanged. A caller can opt out with WithPrivileged(false). + .WithPrivileged() .WithConnectionStringProvider(new MsSqlConnectionStringProvider()); } @@ -36,6 +42,7 @@ public MsSqlBuilder(IContainerBackend backend) { WithImage(MsSqlImage) .WithPortBinding(MsSqlPort, assignRandomHostPort: true) + .WithPrivileged() .WithConnectionStringProvider(new MsSqlConnectionStringProvider()); } diff --git a/src/src/MsSql/Sdk/README.md b/src/src/MsSql/Sdk/README.md index 7f16f11..f0f4b7d 100644 --- a/src/src/MsSql/Sdk/README.md +++ b/src/src/MsSql/Sdk/README.md @@ -12,14 +12,14 @@ connection-string generation and readiness probing. ## Requirements - **WSL Containers backend:** Windows 10/11 with WSL Containers (`wsl --install --no-distribution`), and a - `.NET 10` or later project. A platform-neutral `net10.0` project binds the portable facade and gets + `.NET 10` or `.NET 11` project. A platform-neutral `net10.0` or `net11.0` project binds the portable facade and gets automatic WSLC-or-Docker selection; a Windows target framework - (`net10.0-windows10.0.19041.0`, x64 or arm64) binds the implementation directly. The + (`net10.0-windows10.0.19041.0` or `net11.0-windows10.0.19041.0`, x64 or arm64) binds the implementation directly. The `Purview.Containers.Wsl` package supplies `buildTransitive` defaults for `WindowsSdkPackageVersion`/`PlatformTarget` and (for a platform-neutral consumer on a Windows build host) the implementation payload, rejecting an unsupported consumer with `PCC0001`/`PCC0002` — see the [consumer requirements](https://github.com/purview-dev/containers/blob/main/docs/wiki/Consumer-Requirements.md). -- **Docker backend:** any reachable Docker daemon (`docker info`), with a `net10.0` or later project on any +- **Docker backend:** any reachable Docker daemon (`docker info`), with a `net10.0` or `net11.0` project on any platform. No Windows target framework and no `PCC` guards apply. - **Experimental:** the API, defaults and packaging can change between prereleases; there is no production support guarantee. @@ -54,6 +54,11 @@ await connection.OpenAsync(); ## Behaviour and constraints +- **Privileged container:** the `2025-latest` image (and CU11+ 2022 images) runs as the non-root + `mssql` user. WSLC does not give that user a writable home directory or the capabilities needed to + create `/.system`, so the image exits with `0xC0000022`. The builder therefore enables + `WithPrivileged()` by default; call `WithPrivileged(false)` for an image that does not need it. Docker + accepts the flag unchanged. - **Memory:** SQL Server refuses to start below 2000 MB. The default session VM is capped at 4096 MB (`WslContainerRuntimeOptions.Default`), so it works out of the box; override with `MemorySizeInMB` if you configure your own runtime. diff --git a/src/src/MySql/Sdk/README.md b/src/src/MySql/Sdk/README.md index 11185b0..bd02535 100644 --- a/src/src/MySql/Sdk/README.md +++ b/src/src/MySql/Sdk/README.md @@ -12,14 +12,14 @@ connection-string generation. ## Requirements - **WSL Containers backend:** Windows 10/11 with WSL Containers (`wsl --install --no-distribution`), and a - `.NET 10` or later project. A platform-neutral `net10.0` project binds the portable facade and gets + `.NET 10` or `.NET 11` project. A platform-neutral `net10.0` or `net11.0` project binds the portable facade and gets automatic WSLC-or-Docker selection; a Windows target framework - (`net10.0-windows10.0.19041.0`, x64 or arm64) binds the implementation directly. The + (`net10.0-windows10.0.19041.0` or `net11.0-windows10.0.19041.0`, x64 or arm64) binds the implementation directly. The `Purview.Containers.Wsl` package supplies `buildTransitive` defaults for `WindowsSdkPackageVersion`/`PlatformTarget` and (for a platform-neutral consumer on a Windows build host) the implementation payload, rejecting an unsupported consumer with `PCC0001`/`PCC0002` — see the [consumer requirements](https://github.com/purview-dev/containers/blob/main/docs/wiki/Consumer-Requirements.md). -- **Docker backend:** any reachable Docker daemon (`docker info`), with a `net10.0` or later project on any +- **Docker backend:** any reachable Docker daemon (`docker info`), with a `net10.0` or `net11.0` project on any platform. No Windows target framework and no `PCC` guards apply. - **Experimental:** the API, defaults and packaging can change between prereleases; there is no production support guarantee. diff --git a/src/src/Nats/Sdk/README.md b/src/src/Nats/Sdk/README.md index 07620e4..eda60d5 100644 --- a/src/src/Nats/Sdk/README.md +++ b/src/src/Nats/Sdk/README.md @@ -12,14 +12,14 @@ package — bring your own client. ## Requirements - **WSL Containers backend:** Windows 10/11 with WSL Containers (`wsl --install --no-distribution`), and a - `.NET 10` or later project. A platform-neutral `net10.0` project binds the portable facade and gets + `.NET 10` or `.NET 11` project. A platform-neutral `net10.0` or `net11.0` project binds the portable facade and gets automatic WSLC-or-Docker selection; a Windows target framework - (`net10.0-windows10.0.19041.0`, x64 or arm64) binds the implementation directly. The + (`net10.0-windows10.0.19041.0` or `net11.0-windows10.0.19041.0`, x64 or arm64) binds the implementation directly. The `Purview.Containers.Wsl` package supplies `buildTransitive` defaults for `WindowsSdkPackageVersion`/`PlatformTarget` and (for a platform-neutral consumer on a Windows build host) the implementation payload, rejecting an unsupported consumer with `PCC0001`/`PCC0002` — see the [consumer requirements](https://github.com/purview-dev/containers/blob/main/docs/wiki/Consumer-Requirements.md). -- **Docker backend:** any reachable Docker daemon (`docker info`), with a `net10.0` or later project on any +- **Docker backend:** any reachable Docker daemon (`docker info`), with a `net10.0` or `net11.0` project on any platform. No Windows target framework and no `PCC` guards apply. - **Experimental:** the API, defaults and packaging can change between prereleases; there is no production support guarantee. diff --git a/src/src/PostgreSql/Sdk/README.md b/src/src/PostgreSql/Sdk/README.md index db8a80c..68af22c 100644 --- a/src/src/PostgreSql/Sdk/README.md +++ b/src/src/PostgreSql/Sdk/README.md @@ -12,14 +12,14 @@ See the [Getting Started guide](https://github.com/purview-dev/containers/blob/m ## Requirements - **WSL Containers backend:** Windows 10/11 with WSL Containers (`wsl --install --no-distribution`), and a - `.NET 10` or later project. A platform-neutral `net10.0` project binds the portable facade and gets + `.NET 10` or `.NET 11` project. A platform-neutral `net10.0` or `net11.0` project binds the portable facade and gets automatic WSLC-or-Docker selection; a Windows target framework - (`net10.0-windows10.0.19041.0`, x64 or arm64) binds the implementation directly. The + (`net10.0-windows10.0.19041.0` or `net11.0-windows10.0.19041.0`, x64 or arm64) binds the implementation directly. The `Purview.Containers.Wsl` package supplies `buildTransitive` defaults for `WindowsSdkPackageVersion`/`PlatformTarget` and (for a platform-neutral consumer on a Windows build host) the implementation payload, rejecting an unsupported consumer with `PCC0001`/`PCC0002` — see the [consumer requirements](https://github.com/purview-dev/containers/blob/main/docs/wiki/Consumer-Requirements.md). -- **Docker backend:** any reachable Docker daemon (`docker info`), with a `net10.0` or later project on any +- **Docker backend:** any reachable Docker daemon (`docker info`), with a `net10.0` or `net11.0` project on any platform. No Windows target framework and no `PCC` guards apply. - **Experimental:** the API, defaults and packaging can change between prereleases; there is no production support guarantee. diff --git a/src/src/RabbitMq/RabbitMqBuilder.cs b/src/src/RabbitMq/RabbitMqBuilder.cs index b2ea5cd..d7871bb 100644 --- a/src/src/RabbitMq/RabbitMqBuilder.cs +++ b/src/src/RabbitMq/RabbitMqBuilder.cs @@ -16,6 +16,13 @@ public class RabbitMqBuilder : ContainerBuilderDefault image (includes the management plugin). public const string RabbitMqImage = "rabbitmq:3-management"; + // WSLC provisions the image's declared /var/lib/rabbitmq volume with an empty .erlang.cookie. + // Erlang treats an existing empty file as a too-short cookie and refuses to boot, so the module + // supplies a valid one before the init process starts (and keeps the env var in step for the CLI). + const string ErlangCookie = "purview-containers-rabbitmq-cookie"; + + static readonly Lazy ErlangCookieFilePath = new(WriteErlangCookieFile); + string _username = "guest"; Secret _password = Secret.From("guest"); string _virtualHost = "/"; @@ -79,6 +86,10 @@ protected override RabbitMqConfiguration BuildConfiguration() ["RABBITMQ_DEFAULT_USER"] = _username, ["RABBITMQ_DEFAULT_PASS"] = _password.Value, ["RABBITMQ_DEFAULT_VHOST"] = _virtualHost, + ["RABBITMQ_ERLANG_COOKIE"] = ErlangCookie, + // The managed SDK does not carry the image's HOME into the init process, and Erlang looks + // for ~/.erlang.cookie relative to it. Without this the server reads no cookie and fails. + ["HOME"] = "/var/lib/rabbitmq", }; var waitStrategies = configuration.WaitStrategies.Count > 0 @@ -94,10 +105,26 @@ protected override RabbitMqConfiguration BuildConfiguration() Password = _password, VirtualHost = _virtualHost, Environment = environment, + ResourceMappings = + [ + .. configuration.ResourceMappings, + new ContainerResourceMapping(ErlangCookieFilePath.Value, "/var/lib/rabbitmq/.erlang.cookie") + { + // Erlang requires the cookie file to be owner-readable only (400). + Mode = UnixFileMode.UserRead, + }, + ], WaitStrategies = waitStrategies, }; } + static string WriteErlangCookieFile() + { + var path = Path.Combine(Path.GetTempPath(), "purview-rabbitmq-erlang-cookie"); + File.WriteAllText(path, ErlangCookie); + return path; + } + /// protected override void Validate(ContainerConfiguration configuration) { diff --git a/src/src/RabbitMq/Sdk/README.md b/src/src/RabbitMq/Sdk/README.md index dfab0fa..e5ec9ed 100644 --- a/src/src/RabbitMq/Sdk/README.md +++ b/src/src/RabbitMq/Sdk/README.md @@ -12,14 +12,14 @@ bring your own client. ## Requirements - **WSL Containers backend:** Windows 10/11 with WSL Containers (`wsl --install --no-distribution`), and a - `.NET 10` or later project. A platform-neutral `net10.0` project binds the portable facade and gets + `.NET 10` or `.NET 11` project. A platform-neutral `net10.0` or `net11.0` project binds the portable facade and gets automatic WSLC-or-Docker selection; a Windows target framework - (`net10.0-windows10.0.19041.0`, x64 or arm64) binds the implementation directly. The + (`net10.0-windows10.0.19041.0` or `net11.0-windows10.0.19041.0`, x64 or arm64) binds the implementation directly. The `Purview.Containers.Wsl` package supplies `buildTransitive` defaults for `WindowsSdkPackageVersion`/`PlatformTarget` and (for a platform-neutral consumer on a Windows build host) the implementation payload, rejecting an unsupported consumer with `PCC0001`/`PCC0002` — see the [consumer requirements](https://github.com/purview-dev/containers/blob/main/docs/wiki/Consumer-Requirements.md). -- **Docker backend:** any reachable Docker daemon (`docker info`), with a `net10.0` or later project on any +- **Docker backend:** any reachable Docker daemon (`docker info`), with a `net10.0` or `net11.0` project on any platform. No Windows target framework and no `PCC` guards apply. - **Experimental:** the API, defaults and packaging can change between prereleases; there is no production support guarantee. diff --git a/src/src/Redis/Sdk/README.md b/src/src/Redis/Sdk/README.md index 935d064..76c9740 100644 --- a/src/src/Redis/Sdk/README.md +++ b/src/src/Redis/Sdk/README.md @@ -12,14 +12,14 @@ package — bring your own client. ## Requirements - **WSL Containers backend:** Windows 10/11 with WSL Containers (`wsl --install --no-distribution`), and a - `.NET 10` or later project. A platform-neutral `net10.0` project binds the portable facade and gets + `.NET 10` or `.NET 11` project. A platform-neutral `net10.0` or `net11.0` project binds the portable facade and gets automatic WSLC-or-Docker selection; a Windows target framework - (`net10.0-windows10.0.19041.0`, x64 or arm64) binds the implementation directly. The + (`net10.0-windows10.0.19041.0` or `net11.0-windows10.0.19041.0`, x64 or arm64) binds the implementation directly. The `Purview.Containers.Wsl` package supplies `buildTransitive` defaults for `WindowsSdkPackageVersion`/`PlatformTarget` and (for a platform-neutral consumer on a Windows build host) the implementation payload, rejecting an unsupported consumer with `PCC0001`/`PCC0002` — see the [consumer requirements](https://github.com/purview-dev/containers/blob/main/docs/wiki/Consumer-Requirements.md). -- **Docker backend:** any reachable Docker daemon (`docker info`), with a `net10.0` or later project on any +- **Docker backend:** any reachable Docker daemon (`docker info`), with a `net10.0` or `net11.0` project on any platform. No Windows target framework and no `PCC` guards apply. - **Experimental:** the API, defaults and packaging can change between prereleases; there is no production support guarantee. diff --git a/src/src/Wsl/Sdk/README.md b/src/src/Wsl/Sdk/README.md index 0168972..52eb8aa 100644 --- a/src/src/Wsl/Sdk/README.md +++ b/src/src/Wsl/Sdk/README.md @@ -2,20 +2,22 @@ The **WSL Containers (WSLC) backend** for [`Purview.Containers.Core`](https://www.nuget.org/packages/Purview.Containers.Core): throwaway Linux containers for .NET integration testing built directly on the `Microsoft.WSL.Containers` -managed API, with **no Docker installation**, Docker.DotNet or Testcontainers dependency. Capabilities -missing from the SDK use a bounded `wslc` CLI fallback in the same session: entrypoint/user/labels, -managed networks/aliases, reuse, file copy and Dockerfile builds require `wslc` on PATH. +3.0.5 managed API, with **no Docker installation**, Docker.DotNet or Testcontainers dependency. +Lifecycle, images, named networks and aliases, Linux capabilities and session host-loopback support all +use the managed SDK. Capabilities still missing from it use a bounded `wslc` CLI fallback in the same +session: entrypoint/user/labels, reuse, file copy and Dockerfile builds require `wslc` on PATH. ```bash dotnet add package Purview.Containers.Wsl ``` Reference this package (or a service module) and containers run on WSLC. The package is -**multi-target**: a `net10.0` build (a portable facade) and a `net10.0-windows10.0.19041.0` build (the -implementation). A Windows-targeting project binds the implementation; a `net10.0` project binds the -facade, which loads the implementation at run time on a Windows host and reports `wsl` as unavailable -elsewhere. It registers itself as the `wsl` backend in the consuming assembly. To also run the same test -code on Docker in Linux CI, reference +**multi-target**: `net10.0` and `net11.0` builds (portable facades) and +`net10.0-windows10.0.19041.0` and `net11.0-windows10.0.19041.0` builds (the implementations). A +Windows-targeting project binds the matching implementation; a platform-neutral project binds the facade, +which loads the implementation at run time on a Windows host and reports `wsl` as unavailable elsewhere. The +`net11.0` builds are compiled with `runtime-async=on`. It registers itself as the `wsl` backend in the +consuming assembly. To also run the same test code on Docker in Linux CI, reference [`Purview.Containers.Docker`](https://www.nuget.org/packages/Purview.Containers.Docker) (or the umbrella [`Purview.Containers`](https://www.nuget.org/packages/Purview.Containers), which brings both backends). @@ -28,12 +30,13 @@ code on Docker in Linux CI, reference ## Requirements - Windows 11 or Windows Server 2025 with **WSL Containers** (`wsl --install --no-distribution`). -- **A .NET 10 project.** The recommended target framework is platform-neutral (`net10.0`): it binds the - facade and gives you automatic WSLC-or-Docker selection. A Windows target framework - (`net10.0-windows10.0.19041.0`, x64 or arm64) binds the implementation directly. Anything older than - .NET 10, or a Windows TFM below Windows 10.0.19041.0, fails the build with `PCC0001`; a 32-bit - Windows consumer fails with `PCC0002`; and without the packages' MSBuild defaults a stale - `WindowsSdkPackageVersion` fails with `CS1705`. +- **A .NET 10 or .NET 11 project** (.NET 8 and .NET 9 are not supported). The recommended target + framework is platform-neutral (`net10.0` or `net11.0`): it binds the facade and gives you automatic + WSLC-or-Docker selection. A Windows target framework + (`net10.0-windows10.0.19041.0` or `net11.0-windows10.0.19041.0`, x64 or arm64) binds the implementation + directly. Anything older than .NET 10, or a Windows TFM below Windows 10.0.19041.0, fails the build with + `PCC0001`; a 32-bit Windows consumer fails with `PCC0002`; and without the packages' MSBuild defaults a + stale `WindowsSdkPackageVersion` fails with `CS1705`. - This package supplies the `buildTransitive` defaults for `WindowsSdkPackageVersion` and `PlatformTarget` that every module package inherits, and (for a platform-neutral consumer on a Windows build host) copies the Windows implementation payload next to the output. The full contract, the error @@ -74,7 +77,8 @@ invalid images, ports and mounts fail during configuration rather than at pull t | `WithEnvironment(string, string)`, `WithEnvironment(IReadOnlyDictionary)` | Init-process environment. | | `WithCommand(params string[])`, `WithWorkingDirectory` | Init-process argv (no shell) and working directory. | | `WithEntrypoint`, `WithUser`, `WithLabel` | Init-process overrides and non-secret metadata (CLI creation). | -| `WithNetwork`, `WithNetworkAliases`, `WithReuse` | Managed network attachment/DNS aliases and explicit session-scoped reuse. | +| `WithNetwork`, `WithNetworkAliases`, `WithReuse` | Managed network attachment/DNS aliases (managed SDK) and explicit session-scoped reuse. | +| `WithCapabilityAdd`, `WithCapabilityDrop` | Linux capabilities added to or dropped from the container; passed unchanged to WSLC or Docker. | | `WithResourceMapping` | Pre-start file/directory copy, including empty directories. | | `WithExecTimeout`, `WithStopTimeout`, `WithCleanupTimeout`, `WithOperationTimeout` | Positive finite managed deadlines. | | `WithPortBinding(ushort, bool assignRandomHostPort)`, `WithPortBinding(ushort, ushort hostPort, ...)`, `WithPortBinding(PortBinding)` | Host port mappings; random host ports use native `windowsPort=0` allocation. | @@ -149,9 +153,11 @@ ContainerBackends.Use(new WslContainerBackend(WslContainerRuntimeOptions.Default ``` The full `WslContainerRuntimeOptions` set (CPU, memory, GPU, session name, `StoragePath`, `StorageMode`, -timeout, `CleanUpVolumesOnTerminate`) is honoured on both a platform-neutral (`net10.0`) consumer — where the -facade forwards it to the Windows build at run time — and a Windows target framework, where the Windows build -applies it directly. +timeout, `CleanUpVolumesOnTerminate`, `HostLoopback`) is honoured on both a platform-neutral +(`net10.0`/`net11.0`) consumer — where the facade forwards it to the Windows build at run time — and a +Windows target framework, where the Windows build applies it directly. `HostLoopback` is the optional +hostname containers use to reach services listening on the Windows host loopback interface; it is +session-wide and requires WSL 3.0.5 or later. Named volumes live on the session and are **retained by default**, matching Docker/Testcontainers. Set `ContainerCleanup.CleanUpVolumesOnExit = true`, or set `PURVIEW_CONTAINERS_CLEAN_VOLUMES=true` before the diff --git a/src/src/Wsl/Sdk/buildTransitive/Purview.Containers.Wsl.props b/src/src/Wsl/Sdk/buildTransitive/Purview.Containers.Wsl.props index 2538baf..6d4bed9 100644 --- a/src/src/Wsl/Sdk/buildTransitive/Purview.Containers.Wsl.props +++ b/src/src/Wsl/Sdk/buildTransitive/Purview.Containers.Wsl.props @@ -7,10 +7,10 @@ copying settings into its own project file. These are defaults only: a consumer that sets its own value always wins. - Why: Microsoft.WSL.Containers 3.0.3 ships a C#/WinRT projection built against + Why: Microsoft.WSL.Containers 3.0.5 ships a C#/WinRT projection built against Microsoft.Windows.SDK.NET 10.0.26100.79. The Windows targeting pack resolved for - a net10.0-windows10.0.19041.0 target is older (10.0.19041.x), which fails to compile with CS1705 - (the projection "uses 'Microsoft.Windows.SDK.NET' which has a higher version than + a net10.0-windows10.0.19041.0 or net11.0-windows10.0.19041.0 target is older (10.0.19041.x), which fails + to compile with CS1705 (the projection "uses 'Microsoft.Windows.SDK.NET' which has a higher version than referenced assembly"). Pinning 10.0.26100.80 (the nearest published version) resolves it. NOTE: NuGet only auto-imports buildTransitive/.props and buildTransitive/.targets. --> diff --git a/src/src/Wsl/Sdk/buildTransitive/Purview.Containers.Wsl.targets b/src/src/Wsl/Sdk/buildTransitive/Purview.Containers.Wsl.targets index 394f45a..908cae5 100644 --- a/src/src/Wsl/Sdk/buildTransitive/Purview.Containers.Wsl.targets +++ b/src/src/Wsl/Sdk/buildTransitive/Purview.Containers.Wsl.targets @@ -35,12 +35,13 @@ - net10.0;net10.0-windows10.0.19041.0 - WSL Containers (WSLC) backend for Purview.Containers: run WSLC-native throwaway Linux containers from any .NET 10 project, with automatic Docker fallback. + net10.0;net10.0-windows10.0.19041.0;net11.0;net11.0-windows10.0.19041.0 + WSL Containers (WSLC) backend for Purview.Containers: run WSLC-native throwaway Linux containers from any .NET 10 or .NET 11 project, with automatic Docker fallback. wsl;wslc;containers;linux;windows;testing;integration;testcontainers MIT Purview @@ -29,9 +32,20 @@ $(NoWarn);NU5100 + + + true + true + + - + Windows builds only (the WSL Containers projection needs the newer SDK pack to avoid CS1705). --> + x64 $(WindowsSdkRefPackageVersion) @@ -40,18 +54,18 @@ - + - + @@ -62,29 +76,34 @@ + + - + $(TargetsForTfmSpecificContentInPackage);PurviewContainersWslAddPayload diff --git a/src/src/Wsl/WslCommand.cs b/src/src/Wsl/WslCommand.cs index 51fb4d0..110a5c4 100644 --- a/src/src/Wsl/WslCommand.cs +++ b/src/src/Wsl/WslCommand.cs @@ -15,7 +15,7 @@ internal sealed record Result(int ExitCode, byte[] Output, string Error); Justification = "Cancellation kills and drains the owned process; bounded cleanup then closes its pipes if the runtime stalls." )] internal static async Task RunAsync( - string session, + string? session, IReadOnlyList arguments, CancellationToken cancellationToken, Stream? input = null, @@ -32,15 +32,18 @@ internal static async Task RunAsync( RedirectStandardError = true, RedirectStandardInput = input is not null, }; - info.ArgumentList.Add("--session"); - info.ArgumentList.Add(session); + if (!string.IsNullOrEmpty(session)) + { + info.ArgumentList.Add("--session"); + info.ArgumentList.Add(session); + } + foreach (var argument in arguments) info.ArgumentList.Add(argument); System.Diagnostics.Process process; try { - process = - System.Diagnostics.Process.Start(info) + process = System.Diagnostics.Process.Start(info) ?? throw new ContainerPrerequisiteException("Could not start wslc."); } catch (Win32Exception) diff --git a/src/src/Wsl/WslContainer.cs b/src/src/Wsl/WslContainer.cs index cd66c6d..66d9724 100644 --- a/src/src/Wsl/WslContainer.cs +++ b/src/src/Wsl/WslContainer.cs @@ -86,11 +86,12 @@ await _session ) .ConfigureAwait(false); + // Microsoft.WSL.Containers 3.0.5 supplies named networks, aliases, capabilities and host + // loopback directly, so only entrypoint/user/labels/reuse still need the CLI. _usesCLI = Configuration.Entrypoint.Count > 0 || Configuration.User is not null || Configuration.Labels.Count > 0 - || Configuration.Network is not null || Configuration.Reuse; var container = _usesCLI ? await _session.CreateConfiguredContainerAsync(Configuration, Name, token).ConfigureAwait(false) @@ -103,7 +104,7 @@ await _session { foreach (var mapping in Configuration.ResourceMappings) await WslFileCopy - .CopyAsync(_session, container.Id, mapping.Source, mapping.Target, token) + .CopyAsync(_session, container.Id, mapping.Source, mapping.Target, mapping.Mode, token) .ConfigureAwait(false); await _session.StartContainerAsync(container, token).ConfigureAwait(false); } @@ -360,7 +361,7 @@ public virtual async Task CopyAsync(string source, string target, CancellationTo EnsureStarted(); using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); deadline.CancelAfter(Configuration.OperationTimeout); - await WslFileCopy.CopyAsync(_session!, Id, source, target, deadline.Token).ConfigureAwait(false); + await WslFileCopy.CopyAsync(_session!, Id, source, target, mode: null, deadline.Token).ConfigureAwait(false); } /// diff --git a/src/src/Wsl/WslContainerBackend.Facade.cs b/src/src/Wsl/WslContainerBackend.Facade.cs index 45e9cfc..3d844ce 100644 --- a/src/src/Wsl/WslContainerBackend.Facade.cs +++ b/src/src/Wsl/WslContainerBackend.Facade.cs @@ -1,7 +1,7 @@ namespace Purview.Containers.Wsl; /// -/// The WSL Containers (WSLC) backend as seen from a platform-neutral (net10.0) consumer. +/// The WSL Containers (WSLC) backend as seen from a platform-neutral (net10.0/net11.0) consumer. /// /// /// diff --git a/src/src/Wsl/WslContainerBackend.cs b/src/src/Wsl/WslContainerBackend.cs index 77c18b6..a6544d1 100644 --- a/src/src/Wsl/WslContainerBackend.cs +++ b/src/src/Wsl/WslContainerBackend.cs @@ -53,7 +53,8 @@ internal static WslContainerBackend Create( int storageMode, long? sessionTimeoutTicks, bool disableProcessExitCleanup, - bool? cleanUpVolumesOnTerminate + bool? cleanUpVolumesOnTerminate, + string? hostLoopback ) { WslContainerRuntimeOptions options = new() @@ -67,6 +68,7 @@ internal static WslContainerBackend Create( SessionTimeout = sessionTimeoutTicks is { } ticks ? TimeSpan.FromTicks(ticks) : null, DisableProcessExitCleanup = disableProcessExitCleanup, CleanUpVolumesOnTerminate = cleanUpVolumesOnTerminate, + HostLoopback = hostLoopback, }; return new WslContainerBackend(options); } @@ -125,9 +127,7 @@ public async Task CreateNetworkAsync( deadline.CancelAfter(configuration.Timeout); var session = (WslContainerSession)await Runtime.GetSessionAsync(deadline.Token).ConfigureAwait(false); var name = configuration.Name ?? $"purview-{Guid.NewGuid():N}"; - await session - .RunCommandAsync(["network", "create", name], configuration.Timeout, deadline.Token) - .ConfigureAwait(false); + await session.CreateNetworkAsync(name, deadline.Token).ConfigureAwait(false); return new WslResource(session, name, network: true, configuration.Timeout); } diff --git a/src/src/Wsl/WslContainerResidue.cs b/src/src/Wsl/WslContainerResidue.cs new file mode 100644 index 0000000..43aa557 --- /dev/null +++ b/src/src/Wsl/WslContainerResidue.cs @@ -0,0 +1,110 @@ +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace Purview.Containers.Wsl; + +/// +/// Removes containers left in the shared store by processes that exited without deleting them. Only a +/// generated name whose embedded creator PID is gone and that carries this library's owner label is +/// eligible; a live process's container is never touched. +/// +static partial class WslContainerResidue +{ + const string GeneratedContainerNamePattern = @"-(?\d+)-(?[0-9a-f]{6})$"; + + [GeneratedRegex(GeneratedContainerNamePattern, RegexOptions.CultureInvariant)] + private static partial Regex GeneratedContainerNameRegex(); + + internal static async Task RemoveAsync(string sessionName, CancellationToken cancellationToken) + { + WslCommand.Result listing; + using (var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken)) + { + deadline.CancelAfter(TimeSpan.FromSeconds(20)); + listing = await WslCommand + .RunAsync(sessionName, ["ps", "-a", "--format", "json"], deadline.Token, allowFailure: true) + .ConfigureAwait(false); + } + + if (listing.ExitCode != 0) + { + return 0; + } + + var removed = 0; + var output = Encoding.UTF8.GetString(listing.Output); + foreach (var line in output.Split('\n')) + { + if (string.IsNullOrWhiteSpace(line)) + continue; + + string? id; + string? name; + try + { + using var document = JsonDocument.Parse(line); + var root = document.RootElement; + id = root.TryGetProperty("ID", out var idElement) ? idElement.GetString() : null; + name = root.TryGetProperty("Names", out var nameElement) ? nameElement.GetString() : null; + } + catch (JsonException) + { + continue; + } + + if (string.IsNullOrEmpty(id) || !IsStaleOwnedContainer(name)) + continue; + + try + { + using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + deadline.CancelAfter(TimeSpan.FromSeconds(10)); + var result = await WslCommand + .RunAsync(sessionName, ["rm", "-f", id], deadline.Token, allowFailure: true) + .ConfigureAwait(false); + if (result.ExitCode == 0) + removed++; + } + catch (Exception exception) + when (exception + is OperationCanceledException + or TimeoutException + or IOException + or InvalidOperationException + ) + { + // Best effort: one stuck container must not stop the sweep. + } + } + + // Only anonymous volumes are pruned; named volumes are retained by design. + using (var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken)) + { + deadline.CancelAfter(TimeSpan.FromSeconds(20)); + _ = await WslCommand + .RunAsync(sessionName, ["volume", "prune", "-f"], deadline.Token, allowFailure: true) + .ConfigureAwait(false); + } + + return removed; + } + + internal static bool IsStaleOwnedContainer(string? name) + { + if (string.IsNullOrEmpty(name)) + return false; + + // SDK-created containers cannot carry labels (the managed API has no Labels setting), so the + // generated name is the marker: {image}-{pid}-{6 hex}. A live process's PID is never removed. + var match = GeneratedContainerNameRegex().Match(name); + if (!match.Success) + return false; + if (!int.TryParse(match.Groups["pid"].Value, out var processId)) + return false; + if (processId <= 0) + return false; + + return processId != Environment.ProcessId && !WslSessionSweeper.IsProcessAlive(processId); + } +} diff --git a/src/src/Wsl/WslContainerRuntime.Facade.cs b/src/src/Wsl/WslContainerRuntime.Facade.cs index 266fd43..dc19a12 100644 --- a/src/src/Wsl/WslContainerRuntime.Facade.cs +++ b/src/src/Wsl/WslContainerRuntime.Facade.cs @@ -1,7 +1,7 @@ namespace Purview.Containers.Wsl; /// -/// The process-wide WSL Containers runtime as seen from a platform-neutral (net10.0) consumer. +/// The process-wide WSL Containers runtime as seen from a platform-neutral (net10.0/net11.0) consumer. /// /// /// diff --git a/src/src/Wsl/WslContainerRuntime.cs b/src/src/Wsl/WslContainerRuntime.cs index a27c37a..06211c9 100644 --- a/src/src/Wsl/WslContainerRuntime.cs +++ b/src/src/Wsl/WslContainerRuntime.cs @@ -1,5 +1,7 @@ using System.Diagnostics.CodeAnalysis; using Microsoft.WSL.Containers; +using Purview.Containers.Runtime; +using Win32Exception = System.ComponentModel.Win32Exception; namespace Purview.Containers.Wsl; @@ -41,6 +43,8 @@ public sealed class WslContainerRuntime : IContainerRuntime WslContainerSession? _session; int _disposed; int _storeVerified; + int _sweepAttempted; + int _containerSweepAttempted; /// Creates a runtime with default options. Registers a process-exit cleanup hook. public WslContainerRuntime() @@ -95,10 +99,12 @@ public async Task GetSessionAsync(CancellationToken cancellat { cancellationToken.ThrowIfCancellationRequested(); ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) == 1, this); + await SweepOrphansOnceAsync(cancellationToken).ConfigureAwait(false); var local = GetOrCreateSession(); if (Volatile.Read(ref _storeVerified) == 1) { await local.EnsureStartedAsync(cancellationToken).ConfigureAwait(false); + await SweepContainerResidueOnceAsync(local, cancellationToken).ConfigureAwait(false); return local; } @@ -121,6 +127,7 @@ public async Task GetSessionAsync(CancellationToken cancellat await local.EnsureStartedAsync(cancellationToken).ConfigureAwait(false); } + await SweepContainerResidueOnceAsync(local, cancellationToken).ConfigureAwait(false); return local; } finally @@ -151,11 +158,99 @@ async Task EnsureStoreAsync(WslContainerSession session, Ca } /// - public Task CleanUpAsync(CancellationToken cancellationToken = default) + public async Task CleanUpAsync(CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); - _session?.CleanUpRetainedResources(force: true); - return Task.CompletedTask; + var session = _session; + if (session is null) + return; + + session.CleanUpRetainedResources(force: true); + try + { + await session.RemoveStaleContainersAsync(cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) + when (exception + is ContainerPrerequisiteException + or Win32Exception + or IOException + or InvalidOperationException + or TimeoutException + or UnauthorizedAccessException + ) + { + // Cleanup is best effort; the session's own resources were already released. + } + } + + /// + /// Sweeps sessions and per-session storage directories left behind by processes that exited without + /// running their process-exit hook (a timed-out test host, a debugger stop or a cancelled CI job). + /// A session or directory whose creator process is still running is never touched, and the sweep + /// ignores sessions created by other tools. Runs automatically once per process before the first + /// session starts; this method exposes the same operation for a maintenance step. + /// + public Task SweepOrphanedSessionsAsync(CancellationToken cancellationToken = default) => + WslSessionSweeper.SweepAsync(cancellationToken); + + async Task SweepOrphansOnceAsync(CancellationToken cancellationToken) + { + if (Interlocked.Exchange(ref _sweepAttempted, 1) == 1) + return; + + try + { + await WslSessionSweeper.SweepAsync(cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) + when (exception + is ContainerPrerequisiteException + or Win32Exception + or IOException + or InvalidOperationException + or TimeoutException + or UnauthorizedAccessException + ) + { + // Best effort: a host without wslc, or a transient CLI failure, must not stop the runtime + // from starting a session through the SDK. + } + } + + async Task SweepContainerResidueOnceAsync(WslContainerSession session, CancellationToken cancellationToken) + { + if (Interlocked.Exchange(ref _containerSweepAttempted, 1) == 1) + return; + + try + { + await session.RemoveStaleContainersAsync(cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) + when (exception + is ContainerPrerequisiteException + or Win32Exception + or IOException + or InvalidOperationException + or TimeoutException + or UnauthorizedAccessException + ) + { + // Best effort: containment residue cleanup never blocks a session that already started. + } } /// @@ -256,10 +351,10 @@ bool IsUsingDefaultSharedStore() static string NextSessionName() { - return $"wslc-{Environment.ProcessId}-{Guid.NewGuid().ToString("N")[..8]}"; + return $"purview-{Environment.ProcessId}-{Guid.NewGuid().ToString("N")[..8]}"; } - static string PerSessionRoot => + internal static string PerSessionRoot => Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Purview", diff --git a/src/src/Wsl/WslContainerRuntimeOptions.cs b/src/src/Wsl/WslContainerRuntimeOptions.cs index 429dfee..6135ec6 100644 --- a/src/src/Wsl/WslContainerRuntimeOptions.cs +++ b/src/src/Wsl/WslContainerRuntimeOptions.cs @@ -13,7 +13,7 @@ public sealed record WslContainerRuntimeOptions public bool EnableGPU { get; init; } /// - /// Session name. Defaults to wslc-{processId}-{random}. Session names are machine-unique. + /// Session name. Defaults to purview-{processId}-{random}. Session names are machine-unique. /// public string? SessionName { get; init; } @@ -49,6 +49,16 @@ public sealed record WslContainerRuntimeOptions /// Disables the process-exit cleanup hook (mainly for testing). public bool DisableProcessExitCleanup { get; init; } + /// + /// Hostname that containers can use to reach services listening on the Windows host loopback + /// interface. null (the default) disables host loopback. Requires WSL 3.0.5 or later. + /// + /// + /// The setting is session-wide. Use it when a container must call a service that runs directly on + /// the Windows host; ordinary host-to-container traffic uses published ports instead. + /// + public string? HostLoopback { get; init; } + /// /// Default options. The session VM is capped at 4096 MB so memory-hungry services such as SQL Server /// (which requires at least 2000 MB) start without extra configuration; lighter containers are unaffected. diff --git a/src/src/Wsl/WslContainerSession.cs b/src/src/Wsl/WslContainerSession.cs index 8d89c36..49d0e66 100644 --- a/src/src/Wsl/WslContainerSession.cs +++ b/src/src/Wsl/WslContainerSession.cs @@ -47,6 +47,7 @@ public WslContainerSession( MemorySizeInMB = options.MemorySizeInMB, EnableGpu = options.EnableGPU, Timeout = options.SessionTimeout, + HostLoopback = options.HostLoopback, }; _session = new Session(settings); _session.Terminated += reason => Terminated?.Invoke(reason); @@ -211,6 +212,61 @@ internal void DisposeSession() _lifecycleGate.Dispose(); } + internal async Task CreateNetworkAsync(string name, CancellationToken cancellationToken) + { + using var activity = WslContainerActivity.Source.StartActivity("wslcontainer.network.create"); + activity?.SetTag("network.name", name); + await EnsureStartedAsync(cancellationToken).ConfigureAwait(false); + await _lifecycleGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + ThrowIfTerminated(); + _ = _session.CreateNetwork(new NetworkSettings(name)); + } + finally + { + _lifecycleGate.Release(); + } + } + + internal async Task DeleteNetworkAsync(string name, TimeSpan timeout, CancellationToken cancellationToken) + { + using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + deadline.CancelAfter(timeout); + await _lifecycleGate.WaitAsync(deadline.Token).ConfigureAwait(false); + try + { + ThrowIfTerminated(); + _session.DeleteNetwork(name); + } + finally + { + _lifecycleGate.Release(); + } + } + + internal async Task DeleteImageAsync(string name, TimeSpan timeout, CancellationToken cancellationToken) + { + using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + deadline.CancelAfter(timeout); + await _lifecycleGate.WaitAsync(deadline.Token).ConfigureAwait(false); + try + { + ThrowIfTerminated(); + _session.DeleteImage(name); + } + finally + { + _lifecycleGate.Release(); + } + } + + /// + /// Removes containers left in the shared store by processes that exited without deleting them. + /// + internal Task RemoveStaleContainersAsync(CancellationToken cancellationToken) => + WslContainerResidue.RemoveAsync(Name, cancellationToken); + internal async Task CreateContainerAsync( ContainerSettings settings, CancellationToken cancellationToken @@ -533,13 +589,10 @@ CancellationToken cancellationToken // 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, - _ => new Lazy( - () => PullWithDeadlineAsync(reference, progress, credentials), - LazyThreadSafetyMode.ExecutionAndPublication - ) - ); + var lazy = _activePulls.GetOrAdd(reference.FullReference, _ => new Lazy( + () => PullWithDeadlineAsync(reference, progress, credentials), + LazyThreadSafetyMode.ExecutionAndPublication + )); var pull = lazy.Value; // Observe a fault even if every waiter cancelled, so it is not raised as an unobserved diff --git a/src/src/Wsl/WslCreateArguments.cs b/src/src/Wsl/WslCreateArguments.cs index 5da956b..526e684 100644 --- a/src/src/Wsl/WslCreateArguments.cs +++ b/src/src/Wsl/WslCreateArguments.cs @@ -28,6 +28,8 @@ internal static string ReuseHash(ContainerConfiguration configuration) => configuration.NamedVolumes, configuration.PortBindings, configuration.Privileged, + configuration.CapabilityAdditions, + configuration.CapabilityDrops, configuration.EnableGPU, configuration.ResourceMappings, } @@ -90,6 +92,10 @@ void Add(string option, string? value) throw new ContainerNotSupportedException( "The WSLC CLI does not expose privileged creation. Do not combine privileged mode with CLI-only features." ); + foreach (var capability in configuration.CapabilityAdditions) + Add("--cap-add", capability); + foreach (var capability in configuration.CapabilityDrops) + Add("--cap-drop", capability); Add("--env-file", environmentFile); foreach (var mount in configuration.BindMounts) Add("--volume", $"{mount.HostPath}:{mount.ContainerPath}{(mount.ReadOnly ? ":ro" : string.Empty)}"); diff --git a/src/src/Wsl/WslFileCopy.cs b/src/src/Wsl/WslFileCopy.cs index 9b79636..8a7405e 100644 --- a/src/src/Wsl/WslFileCopy.cs +++ b/src/src/Wsl/WslFileCopy.cs @@ -9,11 +9,12 @@ internal static async Task CopyAsync( string id, string source, string target, + UnixFileMode? mode, CancellationToken cancellationToken ) { using var archive = await ContainerResourceArchive - .CreateAsync(source, target, cancellationToken) + .CreateAsync(source, target, mode, cancellationToken) .ConfigureAwait(false); await session.RunCopyCommandAsync(["cp", "-", $"{id}:/"], cancellationToken, archive).ConfigureAwait(false); } diff --git a/src/src/Wsl/WslPayload.cs b/src/src/Wsl/WslPayload.cs index 5c3ac9e..84bab01 100644 --- a/src/src/Wsl/WslPayload.cs +++ b/src/src/Wsl/WslPayload.cs @@ -5,7 +5,7 @@ namespace Purview.Containers.Wsl; /// /// Loads the Windows build of Purview.Containers.Wsl at runtime so a platform-neutral -/// (net10.0) process on a Windows host can still run containers on WSL Containers. +/// (net10.0/net11.0) process on a Windows host can still run containers on WSL Containers. /// /// /// @@ -45,6 +45,7 @@ static class WslPayload typeof(long?), typeof(bool), typeof(bool?), + typeof(string), ]; static bool DefaultAttempted; static IContainerBackend? DefaultBackend; @@ -141,6 +142,7 @@ static class WslPayload options.SessionTimeout?.Ticks, options.DisableProcessExitCleanup, options.CleanUpVolumesOnTerminate, + options.HostLoopback, ]; if (factory?.Invoke(null, arguments) is IContainerBackend backend) diff --git a/src/src/Wsl/WslResource.cs b/src/src/Wsl/WslResource.cs index 9dad9fd..614a169 100644 --- a/src/src/Wsl/WslResource.cs +++ b/src/src/Wsl/WslResource.cs @@ -6,7 +6,9 @@ sealed class WslResource(WslContainerSession session, string name, bool network, public string Name => name; public Task DeleteAsync(CancellationToken cancellationToken = default) => - session.RunCommandAsync(network ? ["network", "remove", Name] : ["rmi", Name], timeout, cancellationToken); + network + ? session.DeleteNetworkAsync(Name, timeout, cancellationToken) + : session.DeleteImageAsync(Name, timeout, cancellationToken); public async ValueTask DisposeAsync() { diff --git a/src/src/Wsl/WslSessionSweeper.cs b/src/src/Wsl/WslSessionSweeper.cs new file mode 100644 index 0000000..0a152ba --- /dev/null +++ b/src/src/Wsl/WslSessionSweeper.cs @@ -0,0 +1,271 @@ +using System.ComponentModel; +using System.Diagnostics; +using System.Globalization; +using System.Text; +using System.Text.RegularExpressions; +using Purview.Containers.Runtime; + +namespace Purview.Containers.Wsl; + +/// +/// Removes crash residue left by earlier processes: WSLC sessions whose creator process is gone, and +/// the per-session storage directories those processes left behind. A live session is never touched. +/// +/// +/// The process-exit hook terminates a runtime's own session on a graceful exit. A test host killed by a +/// timeout, a debugger stop or a CI job cancellation cannot run that hook, so its session and (for a +/// per-session store) its storage directory survive. WSLC keeps those sessions registered and their +/// VHDs on disk; across enough runs they fill the drive. The sweep runs once per process before the +/// first session is created and uses the wslc CLI when it is available; a host without the CLI +/// simply skips it. +/// +static partial class WslSessionSweeper +{ + const string SessionNamePattern = @"^(?:wslc|purview)-(?\d+)-(?[0-9a-f]{8})$"; + + [GeneratedRegex(SessionNamePattern, RegexOptions.CultureInvariant)] + private static partial Regex SessionNameRegex(); + + /// Total budget for a sweep: listing sessions plus a bounded terminate per session. + static readonly TimeSpan SweepTimeout = TimeSpan.FromSeconds(30); + + /// Budget for terminating one session. + static readonly TimeSpan TerminateTimeout = TimeSpan.FromSeconds(10); + + internal static async Task SweepAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + deadline.CancelAfter(SweepTimeout); + var token = deadline.Token; + var currentProcessId = Environment.ProcessId; + + WslCommand.Result listing; + try + { + listing = await WslCommand + .RunAsync(session: null, ["system", "session", "list"], token, allowFailure: true) + .ConfigureAwait(false); + } + catch (Exception exception) + when (exception + is ContainerPrerequisiteException + or Win32Exception + or OperationCanceledException + or TimeoutException + or IOException + or InvalidOperationException + ) + { + // A host without wslc, or a cancelled or timed-out listing, is not a session failure: + // the SDK path still works and the next process will try again. + return new( + SessionsExamined: 0, + TerminatedSessions: [], + RemovedStorageDirectories: [], + Failures: [$"Session sweep skipped: {exception.GetType().Name}: {exception.Message}"] + ); + } + + if (listing.ExitCode != 0) + { + return new( + SessionsExamined: 0, + TerminatedSessions: [], + RemovedStorageDirectories: [], + Failures: [$"Session list failed with exit code {listing.ExitCode}."] + ); + } + + var sessions = ParseSessions(Encoding.UTF8.GetString(listing.Output)); + var listedNames = sessions.Select(session => session.Name).ToHashSet(StringComparer.Ordinal); + List terminated = []; + List failures = []; + + foreach (var session in sessions) + { + if (session.CreatorProcessId == currentProcessId || IsProcessAlive(session.CreatorProcessId)) + continue; + + try + { + using var terminateDeadline = CancellationTokenSource.CreateLinkedTokenSource(token); + terminateDeadline.CancelAfter(TerminateTimeout); + var result = await WslCommand + .RunAsync( + session.Name, + ["system", "session", "terminate"], + terminateDeadline.Token, + allowFailure: true + ) + .ConfigureAwait(false); + if (result.ExitCode == 0) + { + terminated.Add(session.Name); + } + else + { + failures.Add($"{session.Name}: terminate exited with code {result.ExitCode}."); + } + } + catch (Exception exception) + when (exception + is OperationCanceledException + or TimeoutException + or IOException + or InvalidOperationException + or Win32Exception + ) + { + failures.Add($"{session.Name}: {exception.GetType().Name}: {exception.Message}"); + } + } + + var removedDirectories = RemoveOrphanedStorageDirectories(currentProcessId, listedNames, terminated); + return new( + SessionsExamined: sessions.Count, + TerminatedSessions: terminated, + RemovedStorageDirectories: removedDirectories, + Failures: failures + ); + } + + /// True when is a session name this library created. + internal static bool TryParseSessionName(string? name, out int creatorProcessId) + { + creatorProcessId = 0; + if (string.IsNullOrEmpty(name)) + return false; + + var match = SessionNameRegex().Match(name); + return match.Success + && int.TryParse( + match.Groups["pid"].Value, + NumberStyles.None, + CultureInfo.InvariantCulture, + out creatorProcessId + ) + && creatorProcessId > 0; + } + + /// + /// Parses wslc system session list output into the sessions this library owns. The CLI table + /// is ID Creator PID Display Name; anything that is not one of our names is ignored, so the + /// default wslc-cli-* session and sessions created by other tools are never considered. + /// purview-* is the current prefix; wslc-* is retained so residue from an earlier + /// release is swept too. + /// + internal static IReadOnlyList ParseSessions(string output) + { + List sessions = []; + foreach (var rawLine in output.Split('\n')) + { + var line = rawLine.Trim(); + if (line.Length == 0) + continue; + if (line.StartsWith("ID", StringComparison.OrdinalIgnoreCase)) + continue; + if (line.StartsWith("[wslc]", StringComparison.OrdinalIgnoreCase)) + continue; + + var columns = line.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); + if (columns.Length < 3) + continue; + if (!int.TryParse(columns[1], NumberStyles.None, CultureInfo.InvariantCulture, out var listedProcessId)) + continue; + + var name = columns[^1]; + if (!TryParseSessionName(name, out var nameProcessId) || nameProcessId != listedProcessId) + continue; + + sessions.Add(new(name, listedProcessId)); + } + + return sessions; + } + + /// True when a process with is still running. + internal static bool IsProcessAlive(int processId) + { + if (processId <= 0) + return false; + + try + { + using var process = Process.GetProcessById(processId); + return !process.HasExited; + } + catch (ArgumentException) + { + return false; + } + catch (InvalidOperationException) + { + return false; + } + catch (Win32Exception) + { + // The process exists but cannot be inspected. Be conservative: never delete a session + // that might still be live. + return true; + } + } + + /// + /// Removes the per-session storage directories left by dead processes. A directory is removed only + /// when its session was terminated by this sweep, or when no such session is registered at all. A + /// directory whose session is still listed (because termination failed) is retained. + /// + internal static IReadOnlyList RemoveOrphanedStorageDirectories( + int currentProcessId, + IReadOnlySet listedSessionNames, + IReadOnlyCollection terminatedSessionNames, + string? root = null + ) + { + root ??= WslContainerRuntime.PerSessionRoot; + if (!Directory.Exists(root)) + return []; + + HashSet terminated = new(terminatedSessionNames, StringComparer.Ordinal); + List removed = []; + foreach (var directory in Directory.EnumerateDirectories(root)) + { + var name = Path.GetFileName(directory); + if (!TryParseSessionName(name, out var processId)) + continue; + if (processId == currentProcessId || IsProcessAlive(processId)) + continue; + if (listedSessionNames.Contains(name) && !terminated.Contains(name)) + continue; + + try + { + Directory.Delete(directory, recursive: true); + removed.Add(name); + } + catch (IOException) { } + catch (UnauthorizedAccessException) { } + } + + return removed; + } + + internal sealed record SessionEntry(string Name, int CreatorProcessId); +} + +/// The result of one orphaned-session sweep. +/// Number of this library's sessions found in the WSLC session list. +/// Sessions terminated because their creator process was gone. +/// Per-session storage directories deleted from disk. +/// Per-session failures; a failure never aborts the rest of the sweep. +public sealed record WslSessionSweepResult( + int SessionsExamined, + IReadOnlyList TerminatedSessions, + IReadOnlyList RemovedStorageDirectories, + IReadOnlyList Failures +) +{ + /// True when at least one orphaned session or directory could not be removed. + public bool HasFailures => Failures.Count > 0; +} diff --git a/src/src/Wsl/WslSettingsMapper.cs b/src/src/Wsl/WslSettingsMapper.cs index 9cc595e..124e70e 100644 --- a/src/src/Wsl/WslSettingsMapper.cs +++ b/src/src/Wsl/WslSettingsMapper.cs @@ -27,12 +27,40 @@ public static ContainerSettings ToContainerSettings(IContainerConfiguration conf HostName = configuration.Hostname, DomainName = configuration.DomainName, InitProcess = initProcess, - NetworkingMode = (Microsoft.WSL.Containers.ContainerNetworkingMode)configuration.NetworkingMode, + // The SDK rejects NetworkingMode combined with Networks, so a named network is expressed + // through ContainerNetwork (which also carries aliases) and the mode is left unset. + NetworkingMode = configuration.Network is null + ? (Microsoft.WSL.Containers.ContainerNetworkingMode)configuration.NetworkingMode + : null, Privileged = configuration.Privileged, EnableGpu = configuration.EnableGPU, EnableAutoRemove = configuration.EnableAutoRemove, }; + // Only assign these when the caller asked for a change. An empty list is not "no change": the + // SDK applies it as the container's capability set, which strips the runtime defaults and + // makes a non-root process (for example SQL Server creating /.system) fail. + if (configuration.CapabilityAdditions.Count > 0) + { + settings.CapabilityAdditions = [.. configuration.CapabilityAdditions]; + } + + if (configuration.CapabilityDrops.Count > 0) + { + settings.CapabilityDrops = [.. configuration.CapabilityDrops]; + } + + if (configuration.Network is { } networkName) + { + ContainerNetwork network = new(networkName); + if (configuration.NetworkAliases.Count > 0) + { + network.Aliases = [.. configuration.NetworkAliases]; + } + + settings.Networks = [network]; + } + if (configuration.PortBindings.Count > 0) { settings.PortMappings = [.. configuration.PortBindings.Select(ToPortMapping)]; diff --git a/src/tests/BackendContractTests.cs b/src/tests/BackendContractTests.cs index 5b813de..cff281d 100644 --- a/src/tests/BackendContractTests.cs +++ b/src/tests/BackendContractTests.cs @@ -35,15 +35,13 @@ public async Task StartAsync_EntrypointUserAndExecOverrides_PreserveArguments(Ca { // Arrange await EnsureAvailableAsync(); - await using var container = CreateContainer( - CreateBackend(), - builder => - builder - .WithEntrypoint("/bin/sh") - .WithCommand("-c", "echo ready; sleep 300") - .WithUser("1000:1000") - .WithLabel("purview.test", "metadata") - .WithWaitStrategy(Wait.ForLogMessage("ready")) + await using var container = CreateContainer(CreateBackend(), builder => + builder + .WithEntrypoint("/bin/sh") + .WithCommand("-c", "echo ready; sleep 300") + .WithUser("1000:1000") + .WithLabel("purview.test", "metadata") + .WithWaitStrategy(Wait.ForLogMessage("ready")) ); await container.StartAsync(cancellationToken); var directory = "/tmp/a'b$HOME"; @@ -78,18 +76,16 @@ public async Task CopyAsync_PreStartAndRuntimeMappings_PreserveFileBytes(Cancell var emptyDirectory = Directory.CreateDirectory(Path.Combine(directory, "empty")).FullName; byte[] content = [0, 1, 2, 255, 13, 10, 128]; await File.WriteAllBytesAsync(source, content, cancellationToken); - await using var container = CreateContainer( - CreateBackend(), - builder => - builder - .WithResourceMapping(source, "/tmp/pre-start.bin") - .WithResourceMapping(directory, "/tmp/pre-start-dir") - .WithCommand( - "/bin/sh", - "-c", - "test -f /tmp/pre-start.bin && test -f /tmp/pre-start-dir/input.bin && test -d /tmp/pre-start-dir/empty && echo ready; sleep 300" - ) - .WithWaitStrategy(Wait.ForLogMessage("ready")) + await using var container = CreateContainer(CreateBackend(), builder => + builder + .WithResourceMapping(source, "/tmp/pre-start.bin") + .WithResourceMapping(directory, "/tmp/pre-start-dir") + .WithCommand( + "/bin/sh", + "-c", + "test -f /tmp/pre-start.bin && test -f /tmp/pre-start-dir/input.bin && test -d /tmp/pre-start-dir/empty && echo ready; sleep 300" + ) + .WithWaitStrategy(Wait.ForLogMessage("ready")) ); // Act @@ -122,13 +118,11 @@ public async Task StartAsync_OneShotExitWait_ReturnsExpectedExitCode(Cancellatio { // Arrange await EnsureAvailableAsync(); - await using var container = CreateContainer( - CreateBackend(), - builder => - builder - .WithEntrypoint("/bin/sh") - .WithCommand("-c", "sleep 1; echo complete; exit 7") - .WithWaitStrategy(Wait.ForExitCode(7)) + await using var container = CreateContainer(CreateBackend(), builder => + builder + .WithEntrypoint("/bin/sh") + .WithCommand("-c", "sleep 1; echo complete; exit 7") + .WithWaitStrategy(Wait.ForExitCode(7)) ); // Act @@ -145,15 +139,10 @@ public async Task GetLogsAsync_PartialLinesAndStderr_AreDrainedOnExit(Cancellati { // Arrange await EnsureAvailableAsync(); - await using var container = CreateContainer( - CreateBackend(), - builder => - builder - .WithEntrypoint("/bin/sh") - .WithCommand( - "-c", - "printf first; sleep 1; printf ' line\\n'; printf 'problem\\n' >&2; printf final" - ) + await using var container = CreateContainer(CreateBackend(), builder => + builder + .WithEntrypoint("/bin/sh") + .WithCommand("-c", "printf first; sleep 1; printf ' line\\n'; printf 'problem\\n' >&2; printf final") ); await container.StartAsync(cancellationToken); using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); @@ -178,10 +167,8 @@ public async Task StartAsync_OneShotUnexpectedExit_FailsWithoutReadinessRetries( { // Arrange await EnsureAvailableAsync(); - await using var container = CreateContainer( - CreateBackend(), - builder => - builder.WithEntrypoint("/bin/sh").WithCommand("-c", "exit 2").WithWaitStrategy(Wait.ForExitCode()) + await using var container = CreateContainer(CreateBackend(), builder => + builder.WithEntrypoint("/bin/sh").WithCommand("-c", "exit 2").WithWaitStrategy(Wait.ForExitCode()) ); // Act @@ -198,9 +185,8 @@ public async Task CreateNetworkAsync_Aliases_ResolveBetweenContainers(Cancellati await EnsureAvailableAsync(); var backend = CreateBackend(); await using var network = await backend.CreateNetworkAsync(cancellationToken: cancellationToken); - await using var server = CreateContainer( - backend, - builder => builder.WithNetwork(network.Name).WithNetworkAliases("peer") + await using var server = CreateContainer(backend, builder => + builder.WithNetwork(network.Name).WithNetworkAliases("peer") ); await using var client = CreateContainer(backend, builder => builder.WithNetwork(network.Name)); await server.StartAsync(cancellationToken); @@ -244,14 +230,12 @@ await File.WriteAllTextAsync( await using var container = CreateContainer(backend, builder => builder.WithImage(image.Name)); await container.StartAsync(cancellationToken); var bytes = await container.ReadFileAsync("/probe", cancellationToken); - await using var job = CreateContainer( - backend, - builder => - builder - .WithImage(image.Name) - .WithEntrypoint("/bin/sh", "-c") - .WithCommand("echo explicit-command") - .WithWaitStrategy(Wait.ForExitCode()) + await using var job = CreateContainer(backend, builder => + builder + .WithImage(image.Name) + .WithEntrypoint("/bin/sh", "-c") + .WithCommand("echo explicit-command") + .WithWaitStrategy(Wait.ForExitCode()) ); await job.StartAsync(cancellationToken); var logs = await job.GetLogsAsync(stream: null, cancellationToken: cancellationToken); @@ -300,14 +284,12 @@ public async Task StartAsync_NameCollision_DoesNotDeleteExistingContainer(Cancel await EnsureAvailableAsync(); var backend = CreateBackend(); var name = $"purview-collision-{Guid.NewGuid():N}"; - await using var first = CreateContainer( - backend, - builder => builder.WithName(name).WithLabel("suite", "collision") + await using var first = CreateContainer(backend, builder => + builder.WithName(name).WithLabel("suite", "collision") ); await first.StartAsync(cancellationToken); - await using var second = CreateContainer( - backend, - builder => builder.WithName(name).WithLabel("suite", "collision") + await using var second = CreateContainer(backend, builder => + builder.WithName(name).WithLabel("suite", "collision") ); // Act @@ -378,12 +360,8 @@ public async Task StartAsync_UncooperativeReadiness_IsBoundedAndCleansUp(Cancell // Arrange await EnsureAvailableAsync(); TaskCompletionSource never = new(TaskCreationOptions.RunContinuationsAsynchronously); - await using var container = CreateContainer( - CreateBackend(), - builder => - builder - .WithStartupTimeout(TimeSpan.FromSeconds(2)) - .WithWaitStrategy(Wait.ForCustom((_, _) => never.Task)) + await using var container = CreateContainer(CreateBackend(), builder => + builder.WithStartupTimeout(TimeSpan.FromSeconds(2)).WithWaitStrategy(Wait.ForCustom((_, _) => never.Task)) ); // Act diff --git a/src/tests/Wsl.IntegrationTests/NetworkTests.cs b/src/tests/Wsl.IntegrationTests/NetworkTests.cs new file mode 100644 index 0000000..0c7c3e1 --- /dev/null +++ b/src/tests/Wsl.IntegrationTests/NetworkTests.cs @@ -0,0 +1,67 @@ +using Purview.Containers.Waiting; + +namespace Purview.Containers.Wsl; + +[Explicit] +public class NetworkTests +{ + [Test] + public async Task ManagedNetwork_ContainerAttachesAndNetworkIsRemoved(CancellationToken cancellationToken) + { + await WslcTest.SkipIfUnavailableAsync(); + + WslContainerBackend backend = new(); + var name = $"purview-net-{Guid.NewGuid():N}"[..28]; + await using ( + var network = await backend.CreateNetworkAsync(new NetworkConfiguration { Name = name }, cancellationToken) + ) + { + await using var container = new ContainerBuilder(backend) + .WithImage("alpine:latest") + .WithNetwork(network.Name) + .WithNetworkAliases("peer") + .WithCommand("/bin/sleep", "300") + .Build(); + + await container.StartAsync(cancellationToken); + + // The managed network replaced the default bridge, so the container reports an address on it. + await Assert.That(container.NetworkIp).IsNotNull().And.IsNotEmpty(); + } + + // Disposing the resource deletes the session network, so the same name can be created again. + await using var recreated = await backend.CreateNetworkAsync( + new NetworkConfiguration { Name = name }, + cancellationToken + ); + await Assert.That(recreated.Name).IsEqualTo(name); + } + + [Test] + public async Task NetworkAliases_ResolveBetweenContainers(CancellationToken cancellationToken) + { + await WslcTest.SkipIfUnavailableAsync(); + + WslContainerBackend backend = new(); + await using var network = await backend.CreateNetworkAsync(cancellationToken: cancellationToken); + + await using var listener = new ContainerBuilder(backend) + .WithImage("alpine:latest") + .WithNetwork(network.Name) + .WithNetworkAliases("peer") + .WithCommand("/bin/sleep", "300") + .Build(); + await listener.StartAsync(cancellationToken); + + await using var client = new ContainerBuilder(backend) + .WithImage("alpine:latest") + .WithNetwork(network.Name) + .WithCommand("/bin/sh", "-c", "ping -c 1 -W 2 peer") + .WithWaitStrategy(Wait.ForExitCode()) + .Build(); + await client.StartAsync(cancellationToken); + + // The exit-code wait fails the start when the alias does not resolve. + await Assert.That(client.State).IsEqualTo(ContainerState.Exited); + } +} diff --git a/src/tests/Wsl.IntegrationTests/SessionSweepTests.cs b/src/tests/Wsl.IntegrationTests/SessionSweepTests.cs new file mode 100644 index 0000000..84cf73c --- /dev/null +++ b/src/tests/Wsl.IntegrationTests/SessionSweepTests.cs @@ -0,0 +1,27 @@ +using Purview.Containers.Waiting; + +namespace Purview.Containers.Wsl; + +[Explicit] +public class SessionSweepTests +{ + [Test] + public async Task SweepOrphanedSessions_LeavesTheCurrentSessionRunning(CancellationToken cancellationToken) + { + await WslcTest.SkipIfUnavailableAsync(); + + var session = await WslContainerRuntime.Instance.GetSessionAsync(cancellationToken); + await Assert.That(session.Name).StartsWith("purview-"); + var result = await WslContainerRuntime.Instance.SweepOrphanedSessionsAsync(cancellationToken); + + await Assert.That(result.TerminatedSessions).DoesNotContain(session.Name); + + await using var container = new ContainerBuilder() + .WithImage("alpine:latest") + .WithCommand("/bin/echo", "sweep-ok") + .WithWaitStrategy(Wait.ForLogMessage("sweep-ok")) + .Build(); + await container.StartAsync(cancellationToken); + await Assert.That(container.State).IsNotEqualTo(ContainerState.Invalid); + } +} diff --git a/src/tests/Wsl.UnitTests/BuildFeatureTests.cs b/src/tests/Wsl.UnitTests/BuildFeatureTests.cs new file mode 100644 index 0000000..761cceb --- /dev/null +++ b/src/tests/Wsl.UnitTests/BuildFeatureTests.cs @@ -0,0 +1,24 @@ +using System.Reflection; +using System.Runtime.CompilerServices; + +namespace Purview.Containers.Wsl; + +/// +/// Guards the build shape of the net11.0 assembly documented in +/// docs/wiki/Consumer-Requirements.md. The net11.0 build is compiled with +/// runtime-async=on (Purview.BuildSdk's PurviewRuntimeAsync default), which removes the +/// compiler-generated state machines: async methods carry no , +/// the runtime tracks suspension instead, and live stack traces name the real methods. +/// +public class BuildFeatureTests +{ + [Test] + public async Task Net11AssemblyUsesRuntimeAsync() + { + var stateMachine = typeof(WslContainer) + .GetMethod(nameof(WslContainer.StartAsync))! + .GetCustomAttribute(); + + await Assert.That(stateMachine).IsNull(); + } +} diff --git a/src/tests/Wsl.UnitTests/ConsumerRequirementsTests.cs b/src/tests/Wsl.UnitTests/ConsumerRequirementsTests.cs index a7ef770..cd4dc2f 100644 --- a/src/tests/Wsl.UnitTests/ConsumerRequirementsTests.cs +++ b/src/tests/Wsl.UnitTests/ConsumerRequirementsTests.cs @@ -17,12 +17,15 @@ public class ConsumerRequirementsTests static readonly string[] WindowsPlatform = ["Windows10.0.19041.0"]; [Test] - public async Task LibraryTargetsNet10() + public async Task LibraryTargetsNet11() { + // The package ships net10.0 and net11.0 assets; this project targets net11.0-windows, so it + // binds the net11.0 Windows implementation. The net10.0 assets are guarded by pack validation + // (.config/purview-build.json), not from inside a net11 test process. var targetFramework = Library.GetCustomAttribute(); await Assert.That(targetFramework).IsNotNull(); - await Assert.That(targetFramework!.FrameworkName).IsEqualTo(".NETCoreApp,Version=v10.0"); + await Assert.That(targetFramework!.FrameworkName).IsEqualTo(".NETCoreApp,Version=v11.0"); } [Test] diff --git a/src/tests/Wsl.UnitTests/ContainerBuilderTests.cs b/src/tests/Wsl.UnitTests/ContainerBuilderTests.cs index ef45a7d..348534d 100644 --- a/src/tests/Wsl.UnitTests/ContainerBuilderTests.cs +++ b/src/tests/Wsl.UnitTests/ContainerBuilderTests.cs @@ -168,6 +168,30 @@ public async Task BuildConfiguration_PopulatesAllCommonFields() await Assert.That(configuration.PullPolicy).IsEqualTo(PullPolicy.Missing); } + [Test] + public async Task WithCapabilities_PopulatesConfigurationAndRemovesDuplicates() + { + var configuration = new TestBuilder() + .WithImage("alpine") + .WithCapabilityAdd("NET_ADMIN", "SYS_PTRACE", "NET_ADMIN") + .WithCapabilityDrop("NET_RAW") + .BuildConfig(); + + await Assert.That(configuration.CapabilityAdditions).IsEquivalentTo(["NET_ADMIN", "SYS_PTRACE"]); + await Assert.That(configuration.CapabilityDrops).IsEquivalentTo(["NET_RAW"]); + } + + [Test] + public async Task WithCapability_InvalidName_Throws() + { + await Assert + .That(() => new TestBuilder().WithImage("alpine").WithCapabilityAdd(string.Empty)) + .Throws(); + await Assert + .That(() => new TestBuilder().WithImage("alpine").WithCapabilityDrop("NET RAW")) + .Throws(); + } + [Test] public async Task Build_WithoutImage_Throws() { diff --git a/src/tests/Wsl.UnitTests/WslContainerResidueTests.cs b/src/tests/Wsl.UnitTests/WslContainerResidueTests.cs new file mode 100644 index 0000000..8cf5269 --- /dev/null +++ b/src/tests/Wsl.UnitTests/WslContainerResidueTests.cs @@ -0,0 +1,26 @@ +namespace Purview.Containers.Wsl; + +public class WslContainerResidueTests +{ + [Test] + [Arguments("server-999999-abcdef", true)] + [Arguments("rabbitmq-999999-0badc0", true)] + [Arguments("server-0-abcdef", false)] + [Arguments("server-999999-ABCDEF", false)] + [Arguments("server-999999-abcdef12", false)] + [Arguments("diag-root", false)] + [Arguments("my-container", false)] + public async Task IsStaleOwnedContainer_OnlyAcceptsGeneratedNamesWithDeadPids(string name, bool expected) + { + var result = WslContainerResidue.IsStaleOwnedContainer(name); + await Assert.That(result).IsEqualTo(expected); + } + + [Test] + public async Task IsStaleOwnedContainer_DoesNotRemoveTheCurrentProcessContainer() + { + var current = Environment.ProcessId; + var result = WslContainerResidue.IsStaleOwnedContainer($"server-{current}-abcdef"); + await Assert.That(result).IsFalse(); + } +} diff --git a/src/tests/Wsl.UnitTests/WslSessionSweeperTests.cs b/src/tests/Wsl.UnitTests/WslSessionSweeperTests.cs new file mode 100644 index 0000000..656f267 --- /dev/null +++ b/src/tests/Wsl.UnitTests/WslSessionSweeperTests.cs @@ -0,0 +1,81 @@ +namespace Purview.Containers.Wsl; + +public class WslSessionSweeperTests +{ + const string SessionList = """ + ID Creator PID Display Name + 1 28116 wslc-cli-Kieron + 21 64548 purview-64548-66f3e104 + 22 64549 wslc-64549-0badc0de + 23 64550 other-session + 24 64551 purview-64551-ABCDEF01 + 25 64552 purview-64552-1234 + """; + + [Test] + [Arguments("purview-123-abcdef12", 123, true)] + [Arguments("wslc-123-abcdef12", 123, true)] + [Arguments("purview-1-00000000", 1, true)] + [Arguments("wslc-cli-Kieron", 0, false)] + [Arguments("purview-123-ABCDEF12", 0, false)] + [Arguments("purview-123-abcdef1", 0, false)] + [Arguments("purview-x-abcdef12", 0, false)] + [Arguments("other-123-abcdef12", 0, false)] + [Arguments("", 0, false)] + public async Task TryParseSessionName_OnlyAcceptsLibraryNames(string name, int expectedProcessId, bool expected) + { + var parsed = WslSessionSweeper.TryParseSessionName(name, out var processId); + + await Assert.That(parsed).IsEqualTo(expected); + await Assert.That(processId).IsEqualTo(expected ? expectedProcessId : 0); + } + + [Test] + public async Task ParseSessions_IgnoresHeaderCLIAndForeignSessions() + { + var sessions = WslSessionSweeper.ParseSessions(SessionList); + + await Assert + .That(sessions.Select(session => session.Name)) + .IsEquivalentTo(["purview-64548-66f3e104", "wslc-64549-0badc0de"]); + await Assert.That(sessions.Select(session => session.CreatorProcessId)).IsEquivalentTo([64548, 64549]); + } + + [Test] + public async Task RemoveOrphanedStorageDirectories_KeepsLiveAndUnrelatedDirectories() + { + var root = Path.Combine(Path.GetTempPath(), "purview-sweep-" + Guid.NewGuid().ToString("N")[..8]); + Directory.CreateDirectory(root); + try + { + var current = Environment.ProcessId; + var dead = Path.Combine(root, "purview-999999-abcdef12"); + var legacyDead = Path.Combine(root, "wslc-999997-abcdef12"); + var live = Path.Combine(root, $"purview-{current}-abcdef12"); + var foreign = Path.Combine(root, "other-session"); + var failed = Path.Combine(root, "purview-999998-abcdef12"); + foreach (var directory in new[] { dead, legacyDead, live, foreign, failed }) + { + Directory.CreateDirectory(directory); + } + + var removed = WslSessionSweeper.RemoveOrphanedStorageDirectories( + current, + listedSessionNames: new HashSet(["purview-999998-abcdef12"], StringComparer.Ordinal), + terminatedSessionNames: [], + root: root + ); + + await Assert.That(removed).IsEquivalentTo(["purview-999999-abcdef12", "wslc-999997-abcdef12"]); + await Assert.That(Directory.Exists(dead)).IsFalse(); + await Assert.That(Directory.Exists(legacyDead)).IsFalse(); + await Assert.That(Directory.Exists(live)).IsTrue(); + await Assert.That(Directory.Exists(foreign)).IsTrue(); + await Assert.That(Directory.Exists(failed)).IsTrue(); + } + finally + { + Directory.Delete(root, recursive: true); + } + } +}