diff --git a/.editorconfig b/.editorconfig index 27dd15d..120d509 100644 --- a/.editorconfig +++ b/.editorconfig @@ -27,8 +27,10 @@ dotnet_search_reference_assemblies = true # Nullability settings dotnet_build_property.Nullable = enable -# Enable or disable the analyzers -dotnet_analyzer_diagnostic.severity = warning +# Per-rule severity is authoritative in this file. The SDK sets AnalysisMode/AnalysisLevel as +# MSBuild properties, and the .NET SDK ignores bulk 'dotnet_analyzer_diagnostic.*' severity +# configuration whenever those properties are present. Do not re-add a bulk entry here: it +# silently does nothing and hides which severities are actually enforced. # Visual Studio XML Project Files [*.{csproj,vbproj,vcxproj,vcxproj.filters,proj,projitems,shproj}] @@ -76,23 +78,20 @@ indent_style = tab dotnet_naming_rule.non_private_static_fields_should_be_pascal_case.severity = warning dotnet_naming_rule.non_private_static_fields_should_be_pascal_case.style = non_private_static_field_style dotnet_naming_rule.non_private_static_fields_should_be_pascal_case.symbols = non_private_static_fields -dotnet_naming_rule.private_fields.severity = warning -dotnet_naming_rule.private_fields.style = camel_case_underscore -dotnet_naming_rule.private_fields.symbols = private_fields -dotnet_naming_rule.private_fields_style.severity = warning -dotnet_naming_rule.private_fields_style.style = camel_case -dotnet_naming_rule.private_fields_style.symbols = private_fields dotnet_naming_style.non_private_static_field_style.capitalization = pascal_case +# NOTE: the private-field rules that used to sit here referenced a style ('camel_case_underscore') +# and a symbol group ('private_fields') that were never defined, so they never applied. The real +# private-field rules are declared in the "StyleCop Field Naming Rules" section below. dotnet_naming_symbols.non_private_static_fields.applicable_accessibilities = public, protected, internal, protected_internal, private_protected dotnet_naming_symbols.non_private_static_fields.applicable_kinds = field dotnet_naming_symbols.non_private_static_fields.required_modifiers = static -# Constants are PascalCase +# Constants are PascalCase (field constants; local constants are locals and stay camelCase) dotnet_naming_rule.constants_should_be_pascal_case.severity = warning dotnet_naming_rule.constants_should_be_pascal_case.style = non_private_static_field_style dotnet_naming_rule.constants_should_be_pascal_case.symbols = constants dotnet_naming_style.constant_style.capitalization = pascal_case -dotnet_naming_symbols.constants.applicable_kinds = field, local +dotnet_naming_symbols.constants.applicable_kinds = field dotnet_naming_symbols.constants.required_modifiers = const # Locals and parameters are camelCase @@ -102,9 +101,7 @@ dotnet_naming_rule.locals_should_be_camel_case.severity = warning # camel_case_style - Define the camelCase style dotnet_naming_style.camel_case_style.capitalization = camel_case -dotnet_naming_style.static_field_style.required_prefix = s_ dotnet_naming_symbols.locals_and_parameters.applicable_kinds = parameter, local -dotnet_naming_symbols.static_fields.required_modifiers = static # first_upper_style - The first character must start with an upper-case character dotnet_naming_style.first_upper_style.capitalization = first_word_upper @@ -152,22 +149,22 @@ dotnet_naming_symbols.other_public_protected_fields_group.applicable_kinds = fie # StyleCop Field Naming Rules -# All constant fields must be PascalCase -dotnet_naming_rule.private_or_internal_field_should_be__fieldname.severity = warning -dotnet_naming_rule.private_or_internal_field_should_be__fieldname.style = _fieldname -dotnet_naming_rule.private_or_internal_field_should_be__fieldname.symbols = private_or_internal_field +# All non-private constant fields must be PascalCase. Private constants are owned by +# 'private_static_fields_group' below - a field must be covered by exactly one rule, otherwise the +# same violation is reported once per matching rule. dotnet_naming_rule.stylecop_constant_fields_must_be_pascal_case_rule.severity = warning dotnet_naming_rule.stylecop_constant_fields_must_be_pascal_case_rule.style = non_private_static_field_style dotnet_naming_rule.stylecop_constant_fields_must_be_pascal_case_rule.symbols = stylecop_constant_fields_group -dotnet_naming_symbols.stylecop_constant_fields_group.applicable_accessibilities = public, internal, protected_internal, protected, private_protected, private +dotnet_naming_symbols.stylecop_constant_fields_group.applicable_accessibilities = public, internal, protected_internal, protected, private_protected dotnet_naming_symbols.stylecop_constant_fields_group.applicable_kinds = field dotnet_naming_symbols.stylecop_constant_fields_group.required_modifiers = const -# All static readonly fields must be PascalCase +# All non-private static readonly fields must be PascalCase. Private static readonly fields are +# owned by 'private_static_fields_group' below (see the note on the constant rule above). dotnet_naming_rule.stylecop_static_readonly_fields_must_be_pascal_case_rule.severity = warning dotnet_naming_rule.stylecop_static_readonly_fields_must_be_pascal_case_rule.style = non_private_static_field_style dotnet_naming_rule.stylecop_static_readonly_fields_must_be_pascal_case_rule.symbols = stylecop_static_readonly_fields_group -dotnet_naming_symbols.stylecop_static_readonly_fields_group.applicable_accessibilities = public, internal, protected_internal, protected, private_protected, private +dotnet_naming_symbols.stylecop_static_readonly_fields_group.applicable_accessibilities = public, internal, protected_internal, protected, private_protected dotnet_naming_symbols.stylecop_static_readonly_fields_group.applicable_kinds = field dotnet_naming_symbols.stylecop_static_readonly_fields_group.required_modifiers = static, readonly @@ -178,12 +175,22 @@ dotnet_naming_rule.stylecop_instance_fields_must_be_private_rule.symbols = style dotnet_naming_symbols.stylecop_fields_must_be_private_group.applicable_accessibilities = public, internal, protected_internal, protected, private_protected dotnet_naming_symbols.stylecop_fields_must_be_private_group.applicable_kinds = field -# Private fields must be camelCase -dotnet_naming_rule.stylecop_private_fields_must_be_camel_case_rule.severity = warning -dotnet_naming_rule.stylecop_private_fields_must_be_camel_case_rule.style = camel_case_style -dotnet_naming_rule.stylecop_private_fields_must_be_camel_case_rule.symbols = stylecop_private_fields_group -dotnet_naming_symbols.stylecop_private_fields_group.applicable_accessibilities = private -dotnet_naming_symbols.stylecop_private_fields_group.applicable_kinds = field +# Private static fields are PascalCase: they are type-level state and are never qualified with 'this.' +dotnet_naming_rule.private_static_fields_must_be_pascal_case_rule.severity = warning +dotnet_naming_rule.private_static_fields_must_be_pascal_case_rule.style = non_private_static_field_style +dotnet_naming_rule.private_static_fields_must_be_pascal_case_rule.symbols = private_static_fields_group +dotnet_naming_symbols.private_static_fields_group.applicable_accessibilities = private +dotnet_naming_symbols.private_static_fields_group.applicable_kinds = field +dotnet_naming_symbols.private_static_fields_group.required_modifiers = static + +# Private instance fields must be camelCase with a leading underscore: '_name', never 'name'. The +# prefix keeps field access unambiguous (so the noisy 'this.' qualifier is never needed) and keeps +# fields distinguishable from locals and parameters. +dotnet_naming_rule.private_instance_fields_must_be_camel_case_with_underscore_prefix.severity = warning +dotnet_naming_rule.private_instance_fields_must_be_camel_case_with_underscore_prefix.style = camel_case_underscore_style +dotnet_naming_rule.private_instance_fields_must_be_camel_case_with_underscore_prefix.symbols = private_instance_fields_group +dotnet_naming_symbols.private_instance_fields_group.applicable_accessibilities = private +dotnet_naming_symbols.private_instance_fields_group.applicable_kinds = field # Local variables must be camelCase dotnet_naming_rule.stylecop_local_fields_must_be_camel_case_rule.severity = silent @@ -232,25 +239,14 @@ dotnet_naming_style.type_parameter_style.required_prefix = T dotnet_naming_symbols.type_parameter_symbol.applicable_accessibilities = * dotnet_naming_symbols.type_parameter_symbol.applicable_kinds = type_parameter -# Instance fields are camelCase and start with _ -dotnet_naming_rule.camel_case_for_private_internal_fields.severity = suggestion -dotnet_naming_rule.camel_case_for_private_internal_fields.style = camel_case_underscore_style -dotnet_naming_rule.camel_case_for_private_internal_fields.symbols = private_internal_fields -dotnet_naming_rule.instance_fields_should_be_camel_case.severity = suggestion -dotnet_naming_rule.instance_fields_should_be_camel_case.style = camel_case_underscore_style -dotnet_naming_rule.instance_fields_should_be_camel_case.symbols = instance_fields +# Naming style shared by the private-field rules: camelCase with a required '_' prefix. dotnet_naming_style.camel_case_underscore_style.capitalization = camel_case dotnet_naming_style.camel_case_underscore_style.required_prefix = _ -dotnet_naming_style.instance_field_style.capitalization = camel_case -dotnet_naming_style.instance_field_style.required_prefix = _ -dotnet_naming_symbols.instance_fields.applicable_kinds = field -dotnet_naming_symbols.private_internal_fields.applicable_accessibilities = private, internal -dotnet_naming_symbols.private_internal_fields.applicable_kinds = field # Local functions are PascalCase dotnet_naming_rule.local_functions_should_be_pascal_case.severity = warning -dotnet_naming_rule.local_functions_should_be_pascal_case.style = non_private_static_field_style -dotnet_naming_rule.local_functions_should_be_pascal_case.symbols = all_members +dotnet_naming_rule.local_functions_should_be_pascal_case.style = local_function_style +dotnet_naming_rule.local_functions_should_be_pascal_case.symbols = local_functions dotnet_naming_style.local_function_style.capitalization = pascal_case dotnet_naming_symbols.local_functions.applicable_kinds = local_function @@ -264,12 +260,10 @@ dotnet_style_qualification_for_property = false:silent dotnet_style_operator_placement_when_wrapping = end_of_line # Naming styles -dotnet_naming_rule.interface_should_be_begins_with_i.severity = warning -dotnet_naming_rule.interface_should_be_begins_with_i.style = prefix_interface_with_i_style -dotnet_naming_rule.interface_should_be_begins_with_i.symbols = interface -dotnet_naming_rule.types_should_be_pascal_case.severity = warning -dotnet_naming_rule.types_should_be_pascal_case.style = non_private_static_field_style -dotnet_naming_rule.types_should_be_pascal_case.symbols = types +# NOTE: a 'types_should_be_pascal_case' rule (symbol group 'types') and an +# 'interface_should_be_begins_with_i' rule (symbol group 'interface') used to sit here, but neither +# symbol group was ever defined, so neither rule applied. Types are covered by 'element_rule' and +# interfaces by 'interface_rule' above. # By default, name items with PascalCase dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = warning @@ -278,7 +272,6 @@ dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_m # pascal_case_style - Define the PascalCase style dotnet_naming_style.pascal_case_style.capitalization = pascal_case -dotnet_naming_symbols.all_members.applicable_kinds = * # Symbol specifications dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected @@ -286,7 +279,6 @@ dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, meth dotnet_naming_symbols.non_field_members.required_modifiers = * # Naming styles -dotnet_naming_style._fieldname.capitalization = camel_case dotnet_naming_style.begins_with_i.capitalization = pascal_case dotnet_naming_style.begins_with_i.required_prefix = I dotnet_naming_style.begins_with_i.required_suffix = @@ -314,9 +306,11 @@ dotnet_code_quality.prefer_const = true dotnet_code_quality.prefer_inferred_anonymous_type_member_names = true dotnet_code_quality.prefer_inferred_tuple_names = true dotnet_code_quality.prefer_readonly = true -dotnet_code_quality.require_accessibility_modifiers = true +# The real modifier policy is 'dotnet_style_require_accessibility_modifiers' (declared under +# "Modifier preferences" below). The 'require_accessibility_modifiers' and +# 'require_explicit_visibility' entries that used to live here are not .NET analyzer options: +# declaring them only pretended to enforce a policy that was never applied, so they were removed. dotnet_code_quality.require_explicit_type_arguments = true -dotnet_code_quality.require_explicit_visibility = true dotnet_code_quality.require_variable_declaration_for_explicit_type = false dotnet_code_quality_unused_parameters = all:warning dotnet_enable_roslyn_analyzers = true @@ -587,10 +581,14 @@ dotnet_diagnostic.CA1030.severity = suggestion dotnet_diagnostic.CA1031.severity = error # Implement standard exception constructors -dotnet_diagnostic.CA1032.severity = suggestion +# Raised to warning: the public API surface of exceptions is part of the accessibility policy and +# must not be invisible to command-line builds. +dotnet_diagnostic.CA1032.severity = warning # Interface methods should be callable by child types -dotnet_diagnostic.CA1033.severity = suggestion +# Raised to warning: explicit interface implementations that hide the member from derived types are +# an accessibility problem, not a style preference. +dotnet_diagnostic.CA1033.severity = warning # Nested types should not be visible dotnet_diagnostic.CA1034.severity = error @@ -599,7 +597,9 @@ dotnet_diagnostic.CA1034.severity = error dotnet_diagnostic.CA1036.severity = suggestion # Avoid empty interfaces -dotnet_diagnostic.CA1040.severity = suggestion +# Raised to warning: empty interfaces are unenforceable contracts and were previously invisible to +# command-line builds. +dotnet_diagnostic.CA1040.severity = warning # Provide ObsoleteAttribute message dotnet_diagnostic.CA1041.severity = warning @@ -794,7 +794,9 @@ dotnet_diagnostic.CA1513.severity = warning dotnet_diagnostic.CA1514.severity = warning # Consider making public types internal -dotnet_diagnostic.CA1515.severity = suggestion +# Reported as a real build warning (not a suggestion) so accessibility hygiene cannot be ignored: +# a public type in an application or test assembly should be made internal rather than suppressed. +dotnet_diagnostic.CA1515.severity = warning # Naming Rules (CA1700-CA1727 and IDE0130) # Naming rules support adherence to the naming conventions of the .NET design guidelines @@ -973,8 +975,9 @@ dotnet_diagnostic.CA1845.severity = error # Prefer AsSpan over Substring dotnet_diagnostic.CA1846.severity = error -dotnet_diagnostic.ignore_internalsvisibleto = true -dotnet_diagnostic.CA1852.ignore_internalsvisibleto = true +# CA1852 (seal internal types) is intentionally not suppressed for internal types that are exposed +# to other assemblies through InternalsVisibleTo: those types must still be sealed. The blanket +# suppression that used to sit here relied on a malformed key, so it never even applied. # Unsafe DataSet or DataTable in serializable type can be vulnerable to remote code execution attacks dotnet_diagnostic.CA2352.severity = error @@ -1319,8 +1322,9 @@ dotnet_diagnostic.IDE0038.severity = warning # Use local function instead of lambda dotnet_diagnostic.IDE0039.severity = suggestion -# Add accessibility modifiers -dotnet_diagnostic.IDE0040.severity = error +# IDE0040 is declared exactly once, in the "Accessibility modifiers" section above. A second entry +# here used to override that value (later entries win), which made the effective modifier policy +# ambiguous and let repos believe the rule had been turned off. # Use is null check dotnet_diagnostic.IDE0041.severity = suggestion @@ -1557,8 +1561,9 @@ dotnet_diagnostic.IDE0380.severity = warning # Remove unnecessary suppression (null-forgiving operator) dotnet_diagnostic.IDE0370.severity = warning -# Naming rule violation -dotnet_diagnostic.IDE1006.severity = silent +# Naming rule violation - must stay visible: the field-naming policy is only enforced when this +# diagnostic is reported, because naming rules without their own severity inherit this one. +dotnet_diagnostic.IDE1006.severity = warning # Embedded statements must be on their own line dotnet_diagnostic.IDE2001.severity = warning @@ -2671,17 +2676,14 @@ dotnet_diagnostic.PDS0004.severity = none [**/Extensions/**.{cs,vb}] # Files under the Extensions/ namespace-reset convention must not force developers to add pragmas; -# suppress the namespace-conflict diagnostics that arise from declaring framework-style namespaces. +# suppress only the namespace-conflict diagnostics that arise from declaring framework-style +# namespaces. Accessibility/design rules (for example CA1034) are deliberately not suppressed here. dotnet_diagnostic.IDE0130.severity = none -dotnet_diagnostic.CA1034.severity = none dotnet_diagnostic.CA1724.severity = none dotnet_diagnostic.CS0436.severity = none dotnet_diagnostic.CS1591.severity = none dotnet_diagnostic.IDE0005.severity = none -[**/{Extension,Extensions}.{cs,vb}] -dotnet_diagnostic.CA1034.severity = none - [**/Generated/**/*.{cs,vb}] generated_code = true dotnet_diagnostic.CS8602.severity = none @@ -2694,9 +2696,3 @@ generated_code = true [**/bin/**/*.cs] # Compiler/build output (generated AssemblyInfo, GlobalUsings, etc.) generated_code = true - -# Repo convention: standard accessibility modifiers are always written explicitly -# (overrides the SDK default of omit_if_default, which is uncommon for .NET libraries). -[*.cs] -dotnet_style_require_accessibility_modifiers = always -dotnet_diagnostic.IDE0040.severity = none diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..ea2e258 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,25 @@ +# GitHub Copilot Instructions + +## Primary instruction source + +Use the repository root [`AGENTS.md`](../AGENTS.md) as the **primary** source of truth for behavior, +architecture context, testing standards, and completion criteria. + +If this file and `AGENTS.md` appear to conflict, prefer `AGENTS.md` unless this file explicitly states a +GitHub Copilot-only exception. + +## Copilot-specific guidance + +This file should only contain **GitHub Copilot-specific** instruction details. +Keep product, architecture, and general engineering standards centralized in `AGENTS.md`. + +## Operating expectations for Copilot + +- Apply the `AGENTS.md` testing bar strictly: TUnit tests, `*.UnitTests`/`*.IntegrationTests` project + placement, and the rule that WSLC integration modules run serially. +- Treat work as incomplete until the relevant tests pass; unit tests need no WSLC host, integration tests do. +- Never weaken the WSLC session/store invariants (single shared session, serialised session mutations, the + shared-store fallback, `Secret` redaction) to make a test pass. +- Consult the repository `.agents/` folder for additional skills/workflows that may improve execution + quality. Skill content is delivered by NuGet packages and is read-only here. +- Keep edits minimal, focused, and aligned with existing SDK and repository conventions. diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml new file mode 100644 index 0000000..3959ea1 --- /dev/null +++ b/.github/workflows/pr.yml @@ -0,0 +1,30 @@ +name: PR + +on: + pull_request: + branches: [main] + # `ready_for_review` is not one of the default activity types, so a draft PR would never + # re-evaluate the required build gate once it is marked ready for review. + types: [opened, synchronize, reopened, ready_for_review] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + 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 + # ("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.1.26425.128" + # Mirrors Build:TestFilter in purview-build.json: the SDK categorises unit test projects as Unit, + # so the WSLC integration suites are never selected and the runner needs no WSLC host. + test-filter: "/*/*/*/*[Category=Unit]" + # All eight library projects are packable, so pack and validate the produced packages. + run-pack: true + validate-pack: true + secrets: inherit diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..655aa10 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,22 @@ +name: Release + +on: + push: + branches: [main] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + release: + name: Release packages + uses: purview-dev/build/.github/workflows/purview-release.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 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.1.26425.128" + release-mode: NuGet + release-branch: main + secrets: inherit diff --git a/.gitignore b/.gitignore index 22c0210..35511eb 100644 --- a/.gitignore +++ b/.gitignore @@ -657,4 +657,4 @@ sketch !scripts/* -./.tools/purview-build/**/* \ No newline at end of file +.tools/purview-build/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..da4a905 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,134 @@ +# Agent Instructions + +## Purpose and authority + +This repository contains `Purview.WslContainers`, a **WSLC-native** Testcontainers-style library for .NET: +throwaway Linux containers for integration testing on **Microsoft WSL Containers (WSLC)**, with no Docker +installation, plus the service modules (`PostgreSql`, `Redis`, `MsSql`, `RabbitMq`, `Azurite`, `Nats`, +`MySql`). + +- This file is the repository-wide source of truth for AI agents. A more-specific `AGENTS.md` in a subtree, if + one is ever added, takes precedence for that subtree. +- `.github/copilot-instructions.md` defers to this file. +- Follow explicit user instructions first, then the nearest applicable repository instructions, then + established code patterns. +- Operate only in this repository unless the user explicitly expands the scope. +- Never read, copy, log, or commit secrets from excluded files, environment variables, user profiles, local + configuration, test output, or registry credentials. + +## Source of truth and layout + +| Path | Purpose | +| --- | --- | +| `src/WSLTestContainers.slnx` | Canonical solution for restore, build, test and pack | +| `src/src/WslContainers` | Core runtime (`Purview.WslContainers`): `ContainerBuilder`, `WslContainer`, runtime, sessions, images, networking, mounts, wait strategies, diagnostics | +| `src/src/` | Service modules; each is a thin layer over the core and carries a bespoke `Sdk/README.md` | +| `src/src//Sdk` | Package-only assets. `Sdk/README.md` is packed as the package README (suppressing the repo-root README); `Sdk/buildTransitive/**` would ship MSBuild assets | +| `src/tests` | TUnit unit (`*.UnitTests`) and WSLC integration (`*.IntegrationTests`) projects | +| `spikes/WslcSpikes` | Phase 0 investigation harness (`s1`..`s14` probes); not part of the test run | +| `docs/wiki` | User-facing documentation wiki, aggregated by the purview-dev website | +| `mkdocs.yml` | Wiki site configuration (`docs_dir: docs/wiki`) | +| `src/Directory.Build.props` / `src/Directory.Build.targets` | Solution-wide SDK import, package metadata and the shared package icon | +| `purview-build.json` | Shared `Purview.Build` pipeline configuration: solution, test discovery/filter and the **exhaustive** pack-validation manifest | +| `Justfile` | Supported local workflow commands | +| `.agents` | Package-delivered skills, agents and prompts (read-only here) | +| `.purview/agent-sync.cache` | SDK-managed manifest recording which `.agents` files are already mirrored | + +## Standard workflow + +1. Read this file, inspect the working tree, and locate the implementation, tests, documentation and existing + patterns relevant to the task. +2. Confirm behaviour from code and tests rather than relying on memory or documentation alone. +3. Make the smallest coherent change. Preserve public behaviour unless the task explicitly changes it. +4. Update tests for fixes and behaviour changes. Update documentation when public behaviour changes. +5. Run the narrowest meaningful validation first, then broader validation in proportion to risk. +6. Review the diff for unrelated edits, generated noise, compatibility risks, and missing docs or tests. + +## Runtime invariants + +- **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 + sessions. +- **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. +- **All session-mutating operations are serialised** through the runtime's gate (concurrent `Start` can fail + with `0x8000FFFF`). Keep new lifecycle paths inside it. +- **Random host ports are native** (`windowsPort=0`) and read back from the container's mapped ports — never + probe for a free port. +- **Default networking is `Bridged`**; only IPv4 loopback is mapped, and UDP is unsupported + (`WslContainerNotSupportedException`). +- **`DisposeAsync` is idempotent** and never terminates the shared session; a process-exit hook disposes the + session so its name is released. +- **Secrets use `Secret`** and must never reach logs, names, `ToString()` or diagnostics (they are redacted). + +## Module rules + +- A module supplies only defaults: image, ports, environment, module configuration (`WithXxx`), a readiness + strategy, and connection-string/endpoint accessors. It must not duplicate runtime infrastructure. +- New modules follow the shape in [Contributing Modules](docs/wiki/Contributing-Modules.md): a + `ContainerConfiguration`-derived record, a `ContainerBuilder` + subclass, and a container exposing `GetConnectionString()`/endpoints built from `GetMappedPublicPort`. +- 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. + +## Packaging and validation + +- Every project under `src/src` sets `true` **literally** in the `.csproj`: the SDK + resolves `IsPackable` by scanning the project file, and the package metadata/icon conditions in + `src/Directory.Build.props` depend on it. +- Each package ships `lib/$(TFM)/.{dll,xml}`, `README.md` (from `Sdk/README.md`) and + `purview-logo-light.png`. PDBs ship only in the `.snupkg`. +- `PackValidation.RequireExplicitContent` defaults to `true`, so `purview-build.json`'s `RequiredContent` is + the **exhaustive** manifest: a produced package with no rule, or a packed entry matched by no glob, fails + validation. Adding or removing packaged content means updating that map. +- Validate with `just pack` and `just pipeline-pack-validate`. + +## Testing + +- Test assemblies are categorised by the SDK: `*.UnitTests` → `Unit`, `*.IntegrationTests` → `Integration`. +- `purview-build.json` filters pipeline runs to `[Category=Unit]`, so the shared pipeline never needs a WSLC + host; keep it that way unless the task requires real containers. +- WSLC integration tests run **serially** (`--max-parallel-test-modules 1` in the `Justfile`): a session + exclusively locks its image-store VHD, so parallel modules fail with `0x80070020`. +- Use the shared `WslcTest` helper in `src/tests/SharedTestingFramework` for integration skip/availability + checks. Unit tests may use the modules' `BuildConfigurationForTesting()` internal hook. + +## Local commands + +```text +just build # dotnet build (Debug) +just test '/*/*/*/*[Category=Unit]' # unit tests only +just lint-check / just lint-fix # CSharpier check / format +just pack # build + dotnet pack into ./artifacts +just pipeline-pack-validate # restore, build, lint, test, pack, validate +just scrub # reset bin/obj, clean, forced restore, build-server shutdown +``` + +Commit messages follow Conventional Commits enforced by the `commit-msg` lefthook +(`.config/lefthook.yml` → `npx commitlint`); allowed types are in `commitlint.config.mts`. + +## Versioning and releases + +- `package.json` is the authoritative release and package version. Do not diverge project versions by hand. +- `.github/workflows/pr.yml` builds and tests pull requests through the shared + `purview-dev/build/.github/workflows/purview-build.yml`, with pack and validation enabled. +- `.github/workflows/release.yml` runs the shared release pipeline with `release-mode: NuGet` on a push to + `main`. +- Both workflows pin `dotnet-version` to `global.json`'s `sdk.version`; keep them in sync, and keep + `purview-build.json` pointing at `src/WSLTestContainers.slnx`. + +## Completion checklist + +Before handing work back: + +- Confirm the requested behaviour and scope are satisfied, and only intended files changed. +- Review public API, package-content and dependency-direction implications. +- Update the affected package `Sdk/README.md`, the root `README.md`, `docs/wiki` (plus `_Sidebar.md` and + `mkdocs.yml` for new pages), `AGENTS.md` and `purview-build.json` when the change affects them. +- Watch for the packaging traps: a new packable project missing `true` or a + `RequiredContent` entry, and a new `Sdk/README.md` that does not describe its own package. +- Run the appropriate build, test, formatting and pack checks in proportion to risk. +- State exactly what validation ran. If a check was skipped or blocked, give the concrete reason and the + remaining risk. diff --git a/Directory.Build.props b/Directory.Build.props deleted file mode 100644 index 4ddf908..0000000 --- a/Directory.Build.props +++ /dev/null @@ -1,14 +0,0 @@ - - - Purview - $(MSBuildThisFileDirectory)package.json - net11.0-windows10.0.19041.0 - x64 - 10.0.26100.80 - - true - true - - - - diff --git a/Justfile b/Justfile index 1dc4535..0486d93 100644 --- a/Justfile +++ b/Justfile @@ -1,5 +1,10 @@ set quiet +export TESTINGPLATFORM_EXITCODE_IGNORE := "8" +export DOTNET_CLI_TELEMETRY_OPTOUT := "1" +export DOTNET_SKIP_FIRST_TIME_EXPERIENCE := "1" +export DO_NOT_TRACK := "1" + root_folder := "./src/" solution_file := root_folder + "WSLTestContainers.slnx" test_solution := solution_file @@ -84,10 +89,14 @@ restore *args: dotnet restore {{ solution_file }} {{ args }} # Runs tests for the solution with the specified configuration (default: Debug) +# Test modules run serially by default: a WSLC session exclusively locks its image-store VHD, so parallel +# test assemblies contend for the shared store and fail with 0x80070020 ("file is being used by another +# process"). Override by appending the flag, e.g. +# just test '/*/*/*/*/' --max-parallel-test-modules 4 [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 }}" --treenode-filter={{ filter }} {{ args }} + dotnet test --solution "{{ test_solution }}" --configuration "{{ build_configuration }}" --max-parallel-test-modules 1 --treenode-filter={{ filter }} {{ args }} # Cleans the solution with the specified configuration (default: Debug) [group('Build and Test')] diff --git a/README.md b/README.md index 2bbfe49..38a2862 100644 --- a/README.md +++ b/README.md @@ -5,22 +5,22 @@ A **WSLC-native** Testcontainers-style library for .NET that runs throwaway Linu Built directly against the `Microsoft.WSL.Containers` NuGet package (the WSLC managed C# API). No `wslc.exe`/`wsl.exe`/`docker` CLI, no Docker.DotNet, no Testcontainers internally. > **Status: Phase 8 in progress.** Core runtime, wait strategies, the `Image`/`Tag` parser, registry auth, -> observability, hardening, and **eight service modules** (PostgreSQL, Redis, SQL Server, RabbitMQ, Azurite, -> NATS, MySQL, MinIO). **Images are shared by default** (`StorageMode.Shared`): sessions reuse a stable +> observability, hardening, and **seven service modules** (PostgreSQL, Redis, SQL Server, RabbitMQ, Azurite, +> NATS, MySQL). **Images are shared by default** (`StorageMode.Shared`): sessions reuse a stable > image store (`%LOCALAPPDATA%\Purview\WslContainers\images`) so images are pulled once, not per session; > `StorageMode.PerSession` provides isolation. 69+ unit tests pass across all modules. ## Prerequisites - Windows 10/11 -- **WSL ≥ 2.9.3** with WSL Containers, installed via `wsl --install --no-distribution` +- **WSL ≥ 3.0.1.0** with WSL Containers, installed via `wsl --install --no-distribution` - .NET SDK 11 (the repo pins `11.0.100-rc.1`) Verify: ```powershell -wsl --version # needs 2.9.3+ -wslc version # prints e.g. 3.0.1.0 +wsl --version # needs 3.0.1.0+ +wslc version # needs 3.0.1.0+ ``` The library reports missing prerequisites via `WslContainerRuntime.GetInfoAsync()`; it never installs or updates WSL on its own. @@ -77,7 +77,7 @@ string amqp = rabbitMq.GetConnectionString(); ## Key design decisions (verified by spikes) | Decision | Evidence | -|---|---| +| --- | --- | | One shared process-wide session, shared storage path | image store is keyed by storage path; session start ~20 ms (S2); concurrent sessions can't share the VHD (S18) → auto-fallback to isolated store | | Unique session names `wslc-{pid}-{rand}` | session names are machine-reserved (S1, S13) | | Default `NetworkingMode = Bridged` | port mappings require Bridged; default is `none` (S4) | @@ -99,7 +99,6 @@ src/ WslContainers.Azurite/ Azurite module (builder, container, blob/queue/table endpoints) WslContainers.Nats/ NATS module (builder, container, client + monitoring endpoints) WslContainers.MySql/ MySQL module (builder, container, MySqlConnector connection string) - WslContainers.MinIo/ MinIO module (builder, container, S3 API + console endpoints) tests/ SharedTestingFramework/ shared WSLC skip/helper for integration tests WslContainers.UnitTests/ @@ -117,11 +116,29 @@ tests/ spikes/ WslcSpikes/ Phase 0 investigation harness (run: see below) docs/ - architecture.md wslc-api-investigation.md wslc-capability-matrix.md - lifecycle.md networking.md wait-strategies.md modules.md - contributing-modules.md + wiki/ project wiki (mkdocs.yml -> docs_dir: docs/wiki) + index.md Home.md _Sidebar.md Getting-Started.md Testing.md + Architecture.md Lifecycle.md Networking.md Wait-Strategies.md Modules.md + Packaging.md Release-Flow.md Contributing.md Contributing-Modules.md + Wslc-Api-Investigation.md Wslc-Capability-Matrix.md ``` +## Documentation + +The project documentation lives in [`docs/wiki`](docs/wiki/Home.md) and is published as a MkDocs site +(`mkdocs.yml`, `docs_dir: docs/wiki`, aggregated by the purview-dev website): + +- [Getting Started](docs/wiki/Getting-Started.md) — prerequisites, first container, first module. +- [Architecture](docs/wiki/Architecture.md) — the shared session model, concurrency and cleanup decisions. +- [Lifecycle](docs/wiki/Lifecycle.md), [Networking](docs/wiki/Networking.md), [Wait Strategies](docs/wiki/Wait-Strategies.md). +- [Modules](docs/wiki/Modules.md) — the module contract and every shipped module. +- [Testing](docs/wiki/Testing.md) — unit vs integration categories and the serial WSLC test rule. +- [Packaging](docs/wiki/Packaging.md) and [Release Flow](docs/wiki/Release-Flow.md) — what ships and how it is released. +- [Contributing](docs/wiki/Contributing.md) and [Contributing Modules](docs/wiki/Contributing-Modules.md). + +Every package also ships its own `README.md` (from `src/src//Sdk/README.md`), so +`dotnet add package Purview.WslContainers.` brings documentation specific to that package. + The PostgreSQL, Redis, SQL Server and RabbitMQ modules work today: ```csharp @@ -180,6 +197,31 @@ string connectionString = azurite.GetConnectionString(); Uri blob = azurite.GetBlobEndpoint(); ``` +## Running the tests + +Every test process owns a single shared WSLC session and, by default, uses the shared image store +(`%LOCALAPPDATA%\Purview\WslContainers\images`). A WSLC session **exclusively locks its +`storage.vhdx`**, and the lock is taken lazily on the first store access — so running test assemblies +in parallel makes the losers fail with `0x80070020`: + +``` +The process cannot access the file because it is being used by another process. +``` + +The runtime now verifies the store on first use and transparently falls back to an isolated +per-process store (removed when that process's session terminates), but running test modules serially +keeps the warm shared image cache (no per-process re-pull) and is the fastest option: + +```powershell +just test # dotnet test, one test module at a time + +# Visual Studio: Test > Options > untick "Run Tests in Parallel" (or set +# "Maximum Parallel Test Projects" to 1) before running the WSLC integration tests. +``` + +> The `WslContainers.IntegrationTests` module runs 27 real containers in one session and takes ~4 +> minutes because WSLC serialises container operations; expect slow-test warnings while it runs. + ## Running the Phase 0 spikes ```powershell @@ -190,4 +232,4 @@ dotnet run --project spikes/WslcSpikes -- sfull ## License -MIT. This project is not affiliated with, or endorsed by, the Testcontainers project or Microsoft. \ No newline at end of file +MIT. This project is not affiliated with, or endorsed by, the Testcontainers project or Microsoft. diff --git a/docs/architecture.md b/docs/wiki/Architecture.md similarity index 80% rename from docs/architecture.md rename to docs/wiki/Architecture.md index 3c34ccb..572d7fe 100644 --- a/docs/architecture.md +++ b/docs/wiki/Architecture.md @@ -49,8 +49,9 @@ Design rules: process runs. Set `StorageMode.PerSession` (or an explicit `StoragePath` / `WSL_CONTAINERS_STORAGE_PATH`) for isolation. Session names stay unique per process; only the path is shared. - **Concurrent sharing is not possible**: a running session exclusively locks its `storage.vhdx` - (a second session on the same path fails with `0x80070020` — EXP S18). When the default shared store is - contended by a concurrent process, the runtime automatically falls back to an isolated per-process store. + (a second session on the same path fails with `0x80070020` — EXP S18). The lock is taken lazily on the + first store access, so contention can surface on `GetImages()` rather than at session start; the runtime + detects it there too and falls back to an isolated per-process store. Sequential reuse works (EXP S2/S13): after a session ends, a new session on the same path sees its images. - Cleanup: normal shutdown terminates+disposes the session (frees the name, EXP S14). Orphaned sessions from crashed processes block only their own name (EXP S13); they do not block other sessions or storage-path reuse. @@ -72,9 +73,19 @@ Design rules: ## Concurrency - All session-mutating operations (`Pull`, `CreateContainer`, `Start`, `Stop`, `Delete`, `Tag`, `DeleteImage`, VHD ops) go through a per-session `SemaphoreSlim`. This avoids the racy `0x8000FFFF` observed with concurrent `Start`. -- Read-only ops (`Inspect`, `GetImages`, `Exec`?) are allowed concurrently once the container is running; `Exec` still funnels through the lock because it creates a process on the container. +- `GetImages()` reads (and locks) `storage.vhdx`, so it is **not** side-effect-free and goes through the same gate. Container-handle reads (`Inspect`) are allowed concurrently once the container is running; `Exec` also funnels through the lock because it creates a process on the container. - Bounded per-container output buffers prevent runaway memory. +### Shared-store contention (cross-process) + +`storage.vhdx` is opened lazily on the **first store access**, not at session start, so a second process +on the same path can start its session successfully and only fail later on its first read +(`GetImages()`) with `0x80070020`. The runtime therefore verifies the store once, under a gate, on the +first `GetSessionAsync`; when a concurrent process holds the default shared store, that session is +discarded and the runtime transparently switches to an isolated per-process store. Explicit +`StoragePath`/`WSL_CONTAINERS_STORAGE_PATH`/`StorageMode.PerSession` configuration opts out of the +fallback. Isolated stores are transient and are removed when their session terminates. + ## Cleanup & reaper decision **No Ryuk-style sidecar process is needed.** @@ -87,10 +98,11 @@ Design rules: ## Observability - `System.Diagnostics.ActivitySource("Purview.WslContainers")` emits `wslcontainer.session.start`, `wslcontainer.image.pull`, `wslcontainer.container.create/start/stop/delete`, `wslcontainer.exec.create`, and `wslcontainer.wait`. Tags carry container name/id/image and strategy — never credentials. +- Tailing `GetLogsAsync(CancellationToken)` ends when the container's init process exits: the log buffer is flushed and its channel completed on process exit (and on container disposal), so an `await foreach` over the stream terminates instead of waiting forever. The string overload (`GetLogsAsync(stream, ct)`) reads the accumulated buffer only. - `Microsoft.Extensions.Logging` integration is optional/future; the core works without a host or DI. ## Extensibility - Public surface deliberately small; WSLC types internal. - One opt-in accessor (`IWslContainerAccessor`) exposes `Inspect()` / raw handle for advanced users. -- Third-party modules subclass the generic builder (see `docs/modules.md`). \ No newline at end of file +- Third-party modules subclass the generic builder (see [Modules](Modules.md)). \ No newline at end of file diff --git a/docs/contributing-modules.md b/docs/wiki/Contributing-Modules.md similarity index 95% rename from docs/contributing-modules.md rename to docs/wiki/Contributing-Modules.md index bfeb6a4..fbf77a3 100644 --- a/docs/contributing-modules.md +++ b/docs/wiki/Contributing-Modules.md @@ -65,7 +65,7 @@ public class MyServiceBuilder : ContainerBuilder/Sdk/README.md`** — the package's own README, shipped inside the `.nupkg`. Update it + whenever a module's public API, defaults, readiness or endpoints change. +- **`docs/wiki`** — the user-facing wiki aggregated by the purview-dev website. Update the topic page + ([Architecture](Architecture.md), [Lifecycle](Lifecycle.md), [Networking](Networking.md), + [Wait Strategies](Wait-Strategies.md), [Modules](Modules.md), [Testing](Testing.md), + [Packaging](Packaging.md), [Release Flow](Release-Flow.md)) and `_Sidebar.md` when adding a page. +- **`purview-build.json`** — the exhaustive `PackValidation.RequiredContent` manifest has to keep matching + what the packages actually contain; see [Packaging](Packaging.md). +- **`README.md`** — the repository front page, when the shape of the project changes. + +## Before you raise a PR + +- Confirm the requested behaviour and scope are satisfied and that only intended files changed. +- Review public API, package-content and dependency-direction implications. +- Run `just lint-check`, the relevant tests, and `just pipeline-pack-validate` when packaging, dependencies or + package assets changed. +- State exactly what validation ran; if something could not run, say why and what risk remains. + +## Related + +- [Contributing Modules](Contributing-Modules.md) — the module contract for new service packages. +- [Packaging](Packaging.md) — package contents and validation rules. +- [Release Flow](Release-Flow.md) — versioning and what CI does on a PR and on release. diff --git a/docs/wiki/Getting-Started.md b/docs/wiki/Getting-Started.md new file mode 100644 index 0000000..6268106 --- /dev/null +++ b/docs/wiki/Getting-Started.md @@ -0,0 +1,95 @@ +# Getting Started + +This guide installs the packages, runs a first generic container, switches to a typed service module, and +points you at the test workflow. + +## Requirements + +- Windows 10/11 with **WSL ≥ 2.9.3** including WSL Containers (`wsl --install --no-distribution`). +- .NET SDK 11 or later; the packages target `net11.0-windows10.0.19041.0` (x64). +- Verify the host with `wsl --version` (needs 2.9.3+) and `wslc version`. The library never installs or + updates WSL for you — call `WslContainerRuntime.GetInfoAsync()` to report what is missing. + +## 1. Reference a package + +Reference the core runtime directly for a generic container: + +```bash +dotnet add package Purview.WslContainers +``` + +or a service module, which depends on the core package: + +```bash +dotnet add package Purview.WslContainers.PostgreSql +``` + +## 2. Run a generic container + +```csharp +using Purview.WslContainers; +using Purview.WslContainers.Waiting; + +await using var container = new ContainerBuilder() + .WithImage("docker.io/library/redis:latest") + .WithPortBinding(6379, assignRandomHostPort: true) + .WithWaitStrategy(Wait.ForTcpPort(6379)) + .Build(); + +await container.StartAsync(); + +ushort port = container.GetMappedPublicPort(6379); +``` + +`Build()` validates the accumulated configuration; `StartAsync()` ensures the session and image, creates the +container, starts it, and only returns once every configured wait strategy is satisfied. `DisposeAsync()` +stops and deletes the container (and never terminates the shared session). + +## 3. Use a typed module + +```csharp +using Npgsql; +using Purview.WslContainers.PostgreSql; + +await using var postgres = new PostgreSqlBuilder() + .WithDatabase("tests") + .WithUsername("postgres") + .WithPassword("postgres") + .Build(); + +await postgres.StartAsync(); // waits for pg_isready + +await using var connection = new NpgsqlConnection(postgres.GetConnectionString()); +await connection.OpenAsync(); +``` + +Each module ships a bespoke README inside the package (`Purview.WslContainers.`) and a page in the +[Modules](Modules.md) reference. + +## 4. Run the tests + +```powershell +just test # dotnet test, one test module at a time +just test '/*/*/*/*[Category=Unit]' # unit tests only (no WSLC required) +``` + +Integration tests need a working WSLC installation. A WSLC session exclusively locks its image-store VHD, so +test modules run serially by default — see [Testing](Testing.md) for the details and how to run a subset. + +## 5. Build and pack locally + +```powershell +just build # dotnet build of src/WSLTestContainers.slnx (Debug) +just pack # build + dotnet pack into ./artifacts +just pipeline-pack-validate # shared pipeline: restore, build, lint, test, pack, validate +``` + +See [Contributing](Contributing.md) for the full local workflow and [Release Flow](Release-Flow.md) for +versioning and publishing. + +## Next steps + +- [Architecture](Architecture.md) — the session model, concurrency rules and cleanup decisions. +- [Wait Strategies](Wait-Strategies.md) — started vs ready, and composing your own checks. +- [Networking](Networking.md) — port mapping behaviour under WSLC. +- [Packaging](Packaging.md) — what lands in each package. diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md new file mode 100644 index 0000000..21cf11d --- /dev/null +++ b/docs/wiki/Home.md @@ -0,0 +1,83 @@ +# Purview.WslContainers Wiki + +Purview.WslContainers is a **WSLC-native** Testcontainers-style library for .NET: it runs throwaway Linux +containers for integration testing on **Microsoft WSL Containers (WSLC)** with **no Docker installation**. +It is built directly against the `Microsoft.WSL.Containers` managed package — no `wslc.exe`/`wsl.exe`/ +`docker` CLI, no Docker.DotNet, and no Testcontainers dependency. + +This wiki is the project documentation hub. The packages are published under the +`Purview.WslContainers.*` package IDs. + +## Start here + +- [Getting Started](Getting-Started.md) +- [Architecture](Architecture.md) +- [Lifecycle](Lifecycle.md) +- [Networking](Networking.md) +- [Wait Strategies](Wait-Strategies.md) +- [Modules](Modules.md) + +## Packages + +| Package | Purpose | +| --- | --- | +| `Purview.WslContainers` | Core runtime: `ContainerBuilder`, sessions, images, ports, mounts, wait strategies, logs/exec, diagnostics. | +| `Purview.WslContainers.PostgreSql` | PostgreSQL container (`postgres:17`), `pg_isready` readiness, Npgsql connection string. | +| `Purview.WslContainers.Redis` | Redis-compatible container (`redis:7`), `redis-cli ping` readiness; also usable with Valkey/Garnet. | +| `Purview.WslContainers.MsSql` | SQL Server container (`mssql/server:2022-latest`), host-side `SqlClient` readiness, explicit EULA acceptance. | +| `Purview.WslContainers.RabbitMq` | RabbitMQ container (`rabbitmq:3-management`), AMQP + management endpoints. | +| `Purview.WslContainers.Azurite` | Azure Storage emulator (`azure-storage/azurite`), blob/queue/table endpoints. | +| `Purview.WslContainers.Nats` | NATS broker (`nats:2`), client + monitoring endpoints. | +| `Purview.WslContainers.MySql` | MySQL container (`mysql:8`), host-side `MySqlConnector` readiness. | + +## Feature highlights + +- **One shared, process-wide session** — a stable storage path gives a warm image cache + (`StorageMode.Shared`), and the runtime transparently falls back to an isolated per-process store when a + concurrent process holds the shared store VHD. +- **Race-free random host ports** — native `windowsPort=0` allocation, read back from the container's mapped + ports, instead of probing for a free port first. +- **Fail-fast configuration** — invalid images and tags are rejected at configuration time (`Image.Parse`), + UDP mappings and port `0` are rejected at build time, and module invariants (licences, passwords, required + fields) throw `WslContainerConfigurationException` before a pull is attempted. +- **Composable readiness** — TCP, HTTP(S), log-message, exec and custom wait strategies, with timeouts, + intervals, retries, and `ForAll`/`ForAny` composition. +- **Module model without duplication** — modules supply defaults (image, ports, environment, readiness, + connection strings) on top of the core runtime; they never re-implement container infrastructure. +- **Observability and secrecy** — an `ActivitySource` spans session, pull, lifecycle, exec and wait; + credentials live in a `Secret` wrapper and never reach logs or `ToString()` output. + +## Requirements + +- Windows 10/11. +- **WSL ≥ 2.9.3** with WSL Containers, installed via `wsl --install --no-distribution`. +- .NET SDK 11 (the repository pins `11.0.100-rc.1.26425.128`). + +```powershell +wsl --version # needs 2.9.3+ +wslc version # prints e.g. 3.0.1.0 +``` + +The library reports missing prerequisites through `WslContainerRuntime.GetInfoAsync()`; it never installs or +updates WSL on its own. + +## Repository layout + +| Path | Purpose | +| --- | --- | +| `src/WSLTestContainers.slnx` | Canonical solution for restore, build, test and pack. | +| `src/src/WslContainers` | Core runtime (`Purview.WslContainers`): containers, images, runtime, networking, mounts, diagnostics. | +| `src/src/` | Service modules; each carries a bespoke `Sdk/README.md` that ships as the package README. | +| `src/tests` | TUnit unit and integration test projects. | +| `spikes/WslcSpikes` | Phase 0 investigation harness (`s1`..`s14` behaviour probes). | +| `docs/wiki` | This wiki, aggregated by the purview-dev website. | +| `purview-build.json` | Shared `Purview.Build` pipeline configuration, including the exhaustive pack manifest. | +| `.agents` | Package-delivered agent skills, agents and prompts. | + +## Workflow + +- [Testing](Testing.md) — unit vs integration categories, session locking, and the serial test rule. +- [Packaging](Packaging.md) — what each package ships and how the pack manifest is validated. +- [Release Flow](Release-Flow.md) — versioning, pipeline commands and the release gate. +- [Contributing](Contributing.md) — build, lint, test and pack locally. +- [Contributing Modules](Contributing-Modules.md) — adding a new service module. diff --git a/docs/lifecycle.md b/docs/wiki/Lifecycle.md similarity index 100% rename from docs/lifecycle.md rename to docs/wiki/Lifecycle.md diff --git a/docs/modules.md b/docs/wiki/Modules.md similarity index 96% rename from docs/modules.md rename to docs/wiki/Modules.md index 4099100..ee1b3bc 100644 --- a/docs/modules.md +++ b/docs/wiki/Modules.md @@ -129,10 +129,9 @@ public sealed class PostgreSqlContainer : WslContainer | Azurite | `mcr.microsoft.com/azure-storage/azurite` | log `"successfully listening"` | Azure.Storage.* | ✅ implemented — blob/queue/table endpoints; the well-known `devstoreaccount1` key is a placeholder in `AzuriteAccount.Key` until the consuming repo supplies it | | NATS | `nats:2` | log `"Listening for client connections"` | NATS.Client.Core | ✅ implemented — client + monitoring endpoints | | MySQL | `mysql:8` | host `MySqlConnector` connection | MySqlConnector | ✅ implemented — uses a real connection poll (the image logs `"ready for connections"` during its temporary init server) | -| MinIO | `quay.io/minio/minio` | log `"Console:"` | S3 SDK | ✅ implemented — S3 API + console endpoints; note MinIO's official image is on Quay.io (not Docker Hub) | Note on RabbitMQ readiness: the default wait uses the canonical **`Server startup complete`** log signal rather than `rabbitmq-diagnostics ping` — under WSLC the exec-based probe races with the Erlang cookie setup and can trigger a startup failure (`eacces` reading `.erlang.cookie`). WSLC auto-provisions image `VOLUME` declarations on an ext4 device by default; bind-mounting a Windows directory onto one replaces it with drvfs, which ignores Unix `chown` and breaks permission-sensitive images — do not bind-mount onto image volumes unless you intend that. ## Custom modules (third-party) -See `docs/contributing-modules.md`. \ No newline at end of file +See [Contributing Modules](Contributing-Modules.md). \ No newline at end of file diff --git a/docs/networking.md b/docs/wiki/Networking.md similarity index 100% rename from docs/networking.md rename to docs/wiki/Networking.md diff --git a/docs/wiki/Packaging.md b/docs/wiki/Packaging.md new file mode 100644 index 0000000..fd44694 --- /dev/null +++ b/docs/wiki/Packaging.md @@ -0,0 +1,68 @@ +# Packaging + +Every project under `src/src` is packable and ships as `Purview.WslContainers.*`; test and spike projects +never pack. Packages are produced into `./artifacts` with a matching `.snupkg`. + +## What a package contains + +Each package ships exactly: + +| Entry | Source | +| --- | --- | +| `lib/$(TFM)/Purview.WslContainers[.].dll` | The built assembly (`net11.0-windows10.0.19041`). | +| `lib/$(TFM)/Purview.WslContainers[.].xml` | XML documentation, generated because `GenerateDocumentationFile` is on for packable projects. | +| `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`). | + +Portable PDBs are delivered through the `.snupkg`, never inside the `.nupkg`. + +## The `Sdk/` folder convention + +`Purview.BuildSdk` packs a packable project's `Sdk/` folder automatically (`PurviewAutoSdkPack`): + +| Source path | Package path | +| --- | --- | +| `Sdk/*.md`, `Sdk/*.png`, `Sdk/*.svg`, … | package root | +| `Sdk/build/**`, `Sdk/buildTransitive/**`, `Sdk/buildMultiTargeting/**` | the matching package folder | +| `Sdk/**` (anything else) | `Sdk/**` | + +Two consequences matter for this repository: + +1. **Each packable project owns its documentation** in `src/src//Sdk/README.md`. Because a + README-named file is already being packed, the SDK leaves the repository-root `README.md` out of the + package, so every package ships documentation specific to itself. +2. **The icon is linked, not copied.** `src/Directory.Build.props` adds the shared logo as a `None` item + with `Link="Sdk/purview-logo-light.png"`, and the SDK packs linked `Sdk/` files at the package root and + declares ``/``. Projects must therefore keep + `true` literally in the `.csproj`: the SDK resolves `IsPackable` by scanning the + project file, and both property groups are conditioned on it. + +## Pack validation + +`just pipeline-pack-validate` runs the shared `Purview.Build` pipeline with `Build:RunPack=true`, +`Build:ValidatePack=true` and `Release:Mode=None`. `purview-build.json` configures the validation: + +- `RequireSymbolPackage` / `RequireSymbolFiles` — every `.nupkg` needs a matching `.snupkg`, and every + `.snupkg` must contain at least one `.pdb`. +- `RequiredContent` — because `RequireExplicitContent` defaults to `true`, this map is the **exhaustive** + declaration of every package's contents: a produced package with no matching rule fails, and any packed + entry not matched by one of that package's globs fails. `$(TFM)` expands to each target framework the + package actually ships, so the rules are satisfied per framework. +- Additional checks always run: file names must match the nuspec id/version, and `.nupkg` files must not + contain PDBs outside `tools/`/`analyzers/`. + +Adding a packable project therefore means adding a `RequiredContent` entry keyed by its lowercase package id +alongside the `Sdk/README.md`. Removing content from a package means removing the corresponding glob. + +## Adding a package + +1. Create `src/src//.csproj` with `true`, an assembly/package id, a + description and tags, and add it to `src/WSLTestContainers.slnx`. +2. Add `Sdk/README.md` documenting the package (see the existing modules for the shape). +3. Add the package to `RequiredContent` in `purview-build.json`. +4. Prove it with `just pack` and `just pipeline-pack-validate`. + +## Related + +- [Release Flow](Release-Flow.md) — how packages are versioned and published. +- [Contributing Modules](Contributing-Modules.md) — the module contract a new package follows. diff --git a/docs/wiki/Release-Flow.md b/docs/wiki/Release-Flow.md new file mode 100644 index 0000000..815d8fd --- /dev/null +++ b/docs/wiki/Release-Flow.md @@ -0,0 +1,57 @@ +# Release Flow + +How this repository builds, versions, validates and publishes `Purview.WslContainers.*`. + +## Versioning + +`package.json` at the repository root is the authoritative version (`1.0.0-prerelease.1` today). The SDK +applies that value to `Version` and `PackageVersion` for every project, so a release is a `package.json` +bump — never a manual project-file edit. `package.json` also carries the repository, homepage and issue URLs +that end up in each nuspec. + +## Local commands + +The `Justfile` wraps the common steps: + +| Command | What it does | +| --- | --- | +| `just build` | `dotnet build src/WSLTestContainers.slnx` (Debug). | +| `just test` | `dotnet test` across the solution, one test module at a time (see [Testing](Testing.md)). | +| `just lint-check` / `just lint-fix` | CSharpier check / format over the repository root. | +| `just pack` | Build (Debug) and `dotnet pack` into `./artifacts`. | +| `just scrub` | Delete `bin`/`obj`, clean, re-restore with `--force-evaluate`, and shut down the build server. | +| `just pipeline-pack-validate` | Shared pipeline: restore, build, lint, test, pack and **validate** the packages, without publishing. | + +## The shared pipeline + +All pipeline recipes first install the `Purview.Build` tool into `.tools/purview-build` and then run it +against `purview-build.json`: + +| Recipe | Pipeline arguments | +| --- | --- | +| `just pipeline-pr` | default (full PR pipeline) | +| `just pipeline-build` | `--Build:RunTests=false --Release:Mode=None` | +| `just pipeline-tests` | `--Build:RunTests=true --Release:Mode=None` | +| `just pipeline-pack-validate` | `--Build:RunPack=true --Build:ValidatePack=true --Release:Mode=None` | +| `just pipeline-local-release` | `--Release:Mode=LocalNuGet` | +| `just pipeline-release` | `--Release:Mode=NuGet` | + +The pipeline cleans `artifacts/` before it runs, builds in **Release**, lints with CSharpier, runs the +test filter from `purview-build.json` (`[Category=Unit]`, so no WSLC host is needed), packs, and then +validates the produced packages against the exhaustive `RequiredContent` manifest. Any module failure fails +the run with the failing module's output. + +## GitHub Actions + +- `.github/workflows/pr.yml` — pull requests build and test via the shared + `purview-dev/build/.github/workflows/purview-build.yml`, with pack and validation enabled. +- `.github/workflows/release.yml` — a push to `main` calls `purview-dev/build/.github/workflows/purview-release.yml` + with `release-mode: NuGet`, which packs, publishes to NuGet and creates the `v` GitHub release. + +Both workflows pin `dotnet-version` to the SDK in `global.json` (`11.0.100-rc.1.26425.128`); keep them in +sync when the SDK is bumped, and keep `purview-build.json` pointing at `src/WSLTestContainers.slnx`. + +## Related + +- [Packaging](Packaging.md) — the package contents and the validation manifest. +- [Contributing](Contributing.md) — the local workflow before raising a PR. diff --git a/docs/wiki/Testing.md b/docs/wiki/Testing.md new file mode 100644 index 0000000..fb585da --- /dev/null +++ b/docs/wiki/Testing.md @@ -0,0 +1,52 @@ +# Testing + +How the test suite is organised, why it runs serially, and how to run a subset. + +## Test categories + +Test projects are discovered under `src/tests` and the SDK stamps every test assembly with a TUnit category: + +| Project suffix | Category | Needs WSLC | +| --- | --- | --- | +| `*.UnitTests` | `Unit` | No — pure logic, parsing, configuration and wait-strategy units, some with in-process fakes. | +| `*.IntegrationTests` | `Integration` | Yes — real containers on a shared WSLC session. | + +`purview-build.json` filters the pipeline run to `/*/*/*/*[Category=Unit]`, so the shared pipeline never +starts containers. Run integration projects explicitly when you have a WSLC host. + +```powershell +just test # every discovered test project +just test '/*/*/*/*[Category=Unit]' # unit tests only +just test '/*/*/*/*[Category=Integration]' --max-parallel-test-modules 1 +``` + +## Why test modules run serially + +A WSLC session **exclusively locks its `storage.vhdx`**, and the lock is taken lazily on the first store +access rather than at session start. Running test assemblies in parallel therefore makes the losers fail with +`0x80070020`: + +```text +The process cannot access the file because it is being used by another process. +``` + +`just test` passes `--max-parallel-test-modules 1` for that reason. The runtime verifies the store once and +transparently falls back to an isolated per-process store, but running modules serially keeps the warm shared +image cache (no per-process re-pull) and is the fastest option. + +In Visual Studio, untick **Run Tests in Parallel** (or set *Maximum Parallel Test Projects* to 1) before +running the WSLC integration suites. + +## Expectations + +- The `WslContainers.IntegrationTests` module runs many real containers in one session and can take several + minutes, because WSLC serialises container operations. Slow-test warnings while it runs are expected. +- Integration tests skip themselves when the host lacks the required WSL/WSLC components, so a machine + without WSLC can still run the unit suites. +- `spikes/WslcSpikes` is a manual investigation harness (`dotnet run --project spikes/WslcSpikes -- sfull`, + or `s1`..`s14` for individual behaviour probes); it is not part of the test run. + +## Related + +- [Architecture](Architecture.md) — session lifetime, the concurrency gate and the shared-store fallback. +- [Getting Started](Getting-Started.md) — running the tests locally. diff --git a/docs/wait-strategies.md b/docs/wiki/Wait-Strategies.md similarity index 100% rename from docs/wait-strategies.md rename to docs/wiki/Wait-Strategies.md diff --git a/docs/wslc-api-investigation.md b/docs/wiki/Wslc-Api-Investigation.md similarity index 96% rename from docs/wslc-api-investigation.md rename to docs/wiki/Wslc-Api-Investigation.md index a6882e1..ed727a3 100644 --- a/docs/wslc-api-investigation.md +++ b/docs/wiki/Wslc-Api-Investigation.md @@ -100,6 +100,7 @@ Enums (SRC): `ContainerState{Invalid=0,Created,Running,Exited,Deleted}` · `Proc - `Session.Dispose()` alone removes the session from the session manager and frees the name while the process is still alive. `EXP (S14)` - **If a process exits without disposing its session, the session is orphaned** — it remains in `wslc info` with a dead `Creator PID` and the name stays reserved. It does **not** block other session names, nor reuse of the same storage path. `EXP (S10, S13)` - `Container.Stop(signal, timeout)` is **idempotent** (stopping an already-stopped container succeeds). `EXP` +- `Container.Stop(signal, timeout)` can **block far longer than the `timeout` argument** (observed: minutes with `SIGTERM`/10 s while the session held several containers). Because the library serialises session operations, a single slow `Stop` stalls every other container operation in that process. `EXP` - `Container.Delete(...)` on an already-deleted container throws `COMException 0x80010108` (`RPC_E_DISCONNECTED`). `EXP` - `OpenContainer` on a missing container throws `COMException` with `HResult == (int)Error.ContainerNotFound` (`0x80040603`, i.e. `-2147219965`). `EXP` — matches docs. - `Delete(DeleteContainerOption.Force)` works on a running container (state → `Deleted`). `EXP` @@ -178,7 +179,7 @@ The library parses this for `GetMappedPublicPort` and container IP. ### A session exclusively locks its storage VHD (EXP, S18) -- Each session's image store lives in `{storagePath}\storage.vhdx`. A running session locks that VHD; a **concurrent** session on the same path fails reads with `0x80070020` (`ERROR_SHARING_VIOLATION`, "file is being used by another process"). +- Each session's image store lives in `{storagePath}\storage.vhdx`. A running session locks that VHD; a **concurrent** session on the same path fails reads with `0x80070020` (`ERROR_SHARING_VIOLATION`, "file is being used by another process"). The VHD is opened **lazily on the first store access** (e.g. the first `GetImages()` call), not at session creation or `Session.Start()`, so the violation can surface well after `Session.Start()` succeeded. `EXP` - **Sequential** reuse works: after a session ends, a new session on the same path sees the previous session's images (EXP S2/S13). There is no global/shared image cache in WSLC, so sharing images across sessions requires a shared storage path — safe only when sessions do not overlap. The library defaults to a shared store and falls back to an isolated per-process store on contention. ## Known gaps in the C# projection (DOC) diff --git a/docs/wslc-capability-matrix.md b/docs/wiki/Wslc-Capability-Matrix.md similarity index 100% rename from docs/wslc-capability-matrix.md rename to docs/wiki/Wslc-Capability-Matrix.md diff --git a/docs/wiki/_Sidebar.md b/docs/wiki/_Sidebar.md new file mode 100644 index 0000000..d575768 --- /dev/null +++ b/docs/wiki/_Sidebar.md @@ -0,0 +1,14 @@ +- [Home](Home.md) +- [Getting Started](Getting-Started.md) +- [Architecture](Architecture.md) +- [Lifecycle](Lifecycle.md) +- [Networking](Networking.md) +- [Wait Strategies](Wait-Strategies.md) +- [Modules](Modules.md) +- [Testing](Testing.md) +- [Packaging](Packaging.md) +- [Release Flow](Release-Flow.md) +- [Contributing Modules](Contributing-Modules.md) +- [Contributing](Contributing.md) +- [WSLC API Investigation](Wslc-Api-Investigation.md) +- [WSLC Capability Matrix](Wslc-Capability-Matrix.md) diff --git a/docs/wiki/index.md b/docs/wiki/index.md new file mode 100644 index 0000000..f02968e --- /dev/null +++ b/docs/wiki/index.md @@ -0,0 +1,20 @@ +# Purview WSL Test Containers + +WSLC-native throwaway Linux containers for .NET integration testing — Testcontainers-style APIs built +directly on **Microsoft WSL Containers**, with no Docker installation. + +[Get started](Getting-Started.md){ .md-button .md-button--primary } +[Documentation overview](Home.md){ .md-button } + +## Guides + +- [Architecture](Architecture.md) +- [Lifecycle](Lifecycle.md) +- [Networking](Networking.md) +- [Wait strategies](Wait-Strategies.md) +- [Modules](Modules.md) +- [Testing](Testing.md) +- [Packaging](Packaging.md) +- [Release flow](Release-Flow.md) +- [Contributing modules](Contributing-Modules.md) +- [Contributing](Contributing.md) diff --git a/global.json b/global.json index d68cc15..186257a 100644 --- a/global.json +++ b/global.json @@ -4,7 +4,7 @@ "allowPrerelease": true }, "msbuild-sdks": { - "Purview.BuildSdk": "1.0.0" + "Purview.BuildSdk": "1.0.2.0" }, "test": { "runner": "Microsoft.Testing.Platform" diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..c487fe2 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,21 @@ +site_name: Purview WSL Test Containers +site_description: Developer documentation for Purview WSL Test Containers +repo_url: https://github.com/purview-dev/wsl-testcontainers +edit_uri: edit/main/docs/wiki/ +docs_dir: docs/wiki + +theme: + name: material + palette: + - media: "(prefers-color-scheme: light)" + scheme: default + primary: indigo + accent: cyan + - media: "(prefers-color-scheme: dark)" + scheme: slate + primary: indigo + accent: cyan + features: [navigation.instant, navigation.sections, navigation.top, search.highlight, search.suggest, content.code.copy] + +plugins: [search, techdocs-core] +markdown_extensions: [admonition, attr_list, md_in_html, pymdownx.details, pymdownx.superfences] diff --git a/purview-build.json b/purview-build.json new file mode 100644 index 0000000..b5876a0 --- /dev/null +++ b/purview-build.json @@ -0,0 +1,71 @@ +{ + "Build": { + "Solution": "src/WSLTestContainers.slnx", + "TestRoot": "src/tests", + "TestPatterns": "*UnitTests.csproj", + "TestProjects": "*Tests.csproj", + "TestFilter": "/*/*/*/*[Category=Unit]" + }, + "PackValidation": { + "RequireSymbolPackage": true, + "RequireSymbolFiles": true, + // RequireExplicitContent defaults to true, so this is the exhaustive declaration of what each + // package ships: the library assembly and its XML docs, the bespoke package README + // (src/src//Sdk/README.md) and the shared Purview logo + // (assets/images/purview-logo-light.png, linked as Sdk/purview-logo-light.png). A package with + // no rule here, or an entry matching none of its globs, fails validation. + "RequiredContent": { + "purview.wslcontainers": [ + "lib/$(TFM)/Purview.WslContainers.dll", + "lib/$(TFM)/Purview.WslContainers.xml", + "README.md", + "purview-logo-light.png" + ], + "purview.wslcontainers.azurite": [ + "lib/$(TFM)/Purview.WslContainers.Azurite.dll", + "lib/$(TFM)/Purview.WslContainers.Azurite.xml", + "README.md", + "purview-logo-light.png" + ], + "purview.wslcontainers.mssql": [ + "lib/$(TFM)/Purview.WslContainers.MsSql.dll", + "lib/$(TFM)/Purview.WslContainers.MsSql.xml", + "README.md", + "purview-logo-light.png" + ], + "purview.wslcontainers.mysql": [ + "lib/$(TFM)/Purview.WslContainers.MySql.dll", + "lib/$(TFM)/Purview.WslContainers.MySql.xml", + "README.md", + "purview-logo-light.png" + ], + "purview.wslcontainers.nats": [ + "lib/$(TFM)/Purview.WslContainers.Nats.dll", + "lib/$(TFM)/Purview.WslContainers.Nats.xml", + "README.md", + "purview-logo-light.png" + ], + "purview.wslcontainers.postgresql": [ + "lib/$(TFM)/Purview.WslContainers.PostgreSql.dll", + "lib/$(TFM)/Purview.WslContainers.PostgreSql.xml", + "README.md", + "purview-logo-light.png" + ], + "purview.wslcontainers.rabbitmq": [ + "lib/$(TFM)/Purview.WslContainers.RabbitMq.dll", + "lib/$(TFM)/Purview.WslContainers.RabbitMq.xml", + "README.md", + "purview-logo-light.png" + ], + "purview.wslcontainers.redis": [ + "lib/$(TFM)/Purview.WslContainers.Redis.dll", + "lib/$(TFM)/Purview.WslContainers.Redis.xml", + "README.md", + "purview-logo-light.png" + ] + } + }, + "Release": { + "Mode": "None" + } +} \ No newline at end of file diff --git a/spikes/WslcSpikes/Program.cs b/spikes/WslcSpikes/Program.cs index 8019dfb..237cbe7 100644 --- a/spikes/WslcSpikes/Program.cs +++ b/spikes/WslcSpikes/Program.cs @@ -5,7 +5,9 @@ if (args.Length == 0) { - Console.Error.WriteLine("No spike specified. Available: s1 s2 s3 s4 s5 s6 s7 s8 s9 s10 s10child s10child-norelease s11 s12 s13 s14 s18 s19 sfull"); + Console.Error.WriteLine( + "No spike specified. Available: s1 s2 s3 s4 s5 s6 s7 s8 s9 s10 s10child s10child-norelease s11 s12 s13 s14 s18 s19 sfull" + ); return 1; } diff --git a/spikes/WslcSpikes/Spikes/ConcurrentExecProcesses.cs b/spikes/WslcSpikes/Spikes/ConcurrentExecProcesses.cs index 03c8594..7bdb322 100644 --- a/spikes/WslcSpikes/Spikes/ConcurrentExecProcesses.cs +++ b/spikes/WslcSpikes/Spikes/ConcurrentExecProcesses.cs @@ -36,7 +36,12 @@ public static async Task RunAsync() await Task.Delay(500); for (int i = 0; i < containerCount; i++) { - Console.WriteLine("[s19] container s19-{0} state={1} init={2}", i, containers[i].State, containers[i].InitProcess.State); + Console.WriteLine( + "[s19] container s19-{0} state={1} init={2}", + i, + containers[i].State, + containers[i].InitProcess.State + ); } int failures = 0; @@ -52,13 +57,14 @@ public static async Task RunAsync() int ei = e; execs[index++] = Task.Run(async () => { - Process process = containers[ci].CreateProcess( - new ProcessSettings - { - CommandLine = new List { "/bin/echo", "hi" }, - OutputMode = ProcessOutputMode.Event, - } - ); + Process process = containers[ci] + .CreateProcess( + new ProcessSettings + { + CommandLine = new List { "/bin/echo", "hi" }, + OutputMode = ProcessOutputMode.Event, + } + ); ProcessCapture capture = SpikeSupport.Capture(process); try { @@ -110,4 +116,4 @@ public static async Task RunAsync() Console.WriteLine("[s19] S19 complete."); return 0; } -} \ No newline at end of file +} diff --git a/spikes/WslcSpikes/Spikes/ConcurrentSharedPath.cs b/spikes/WslcSpikes/Spikes/ConcurrentSharedPath.cs index c79d02f..1c67b2d 100644 --- a/spikes/WslcSpikes/Spikes/ConcurrentSharedPath.cs +++ b/spikes/WslcSpikes/Spikes/ConcurrentSharedPath.cs @@ -34,7 +34,9 @@ public static async Task RunAsync() } catch (Exception ex) { - Console.WriteLine("[s18] RESULT: session B FAILED to start on the shared path (concurrent sharing unsafe):"); + Console.WriteLine( + "[s18] RESULT: session B FAILED to start on the shared path (concurrent sharing unsafe):" + ); SpikeSupport.Dump(ex); return 0; } @@ -57,8 +59,18 @@ public static async Task RunAsync() Console.WriteLine("[s18] starting containers in A and B concurrently..."); try { - Container ca = SpikeSupport.CreateContainer(a, "docker.io/library/alpine:latest", "s18-a", new[] { "/bin/echo", "from-a" }); - Container cb = SpikeSupport.CreateContainer(b, "docker.io/library/alpine:latest", "s18-b", new[] { "/bin/echo", "from-b" }); + Container ca = SpikeSupport.CreateContainer( + a, + "docker.io/library/alpine:latest", + "s18-a", + new[] { "/bin/echo", "from-a" } + ); + Container cb = SpikeSupport.CreateContainer( + b, + "docker.io/library/alpine:latest", + "s18-b", + new[] { "/bin/echo", "from-b" } + ); await Task.WhenAll(Task.Run(() => ca.Start()), Task.Run(() => cb.Start())); await Task.Delay(1500); Console.WriteLine("[s18] A container state={0}, B container state={1}", ca.State, cb.State); @@ -89,4 +101,4 @@ public static async Task RunAsync() Console.WriteLine("[s18] S18 complete."); return 0; } -} \ No newline at end of file +} diff --git a/src/Directory.Build.props b/src/Directory.Build.props new file mode 100644 index 0000000..099810b --- /dev/null +++ b/src/Directory.Build.props @@ -0,0 +1,28 @@ + + + Purview.WslContainers + net11.0-windows10.0.19041.0 + x64 + 10.0.26100.80 + true + + + + + + + purview-logo-light.png + README.md + + + + + + diff --git a/Directory.Build.targets b/src/Directory.Build.targets similarity index 100% rename from Directory.Build.targets rename to src/Directory.Build.targets diff --git a/src/WSLTestContainers.slnx b/src/WSLTestContainers.slnx index 25962f4..cf4e852 100644 --- a/src/WSLTestContainers.slnx +++ b/src/WSLTestContainers.slnx @@ -1,35 +1,42 @@ + + + + + + + + + + - - - - - - - - - + + + + + + + + - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + diff --git a/src/WslContainers.MinIo/MinIoBuilder.cs b/src/WslContainers.MinIo/MinIoBuilder.cs deleted file mode 100644 index e0aeaba..0000000 --- a/src/WslContainers.MinIo/MinIoBuilder.cs +++ /dev/null @@ -1,100 +0,0 @@ -namespace Purview.WslContainers.MinIo; - -/// Fluent builder for a MinIO (S3-compatible object storage) test container. -public class MinIoBuilder : ContainerBuilder -{ - /// Default S3 API port. - public const ushort ApiPort = 9000; - - /// Default console port. - public const ushort ConsolePort = 9001; - - /// Default image (MinIO's official registry is Quay.io). - public const string MinIoImage = "quay.io/minio/minio:latest"; - - private string accessKey = "minioadmin"; - private Secret secretKey = Secret.From("minioadmin"); - - /// Creates a builder with the default image. - public MinIoBuilder() - : this(MinIoImage) { } - - /// Creates a builder with a custom image. - public MinIoBuilder(string image) - { - WithImage(image) - .WithCommand("minio", "server", "/data", "--console-address", ":9001") - .WithPortBinding(ApiPort, assignRandomHostPort: true) - .WithPortBinding(ConsolePort, assignRandomHostPort: true); - } - - /// Creates a builder using an explicit runtime. - public MinIoBuilder(IContainerRuntime runtime) - : base(runtime) - { - WithImage(MinIoImage) - .WithCommand("minio", "server", "/data", "--console-address", ":9001") - .WithPortBinding(ApiPort, assignRandomHostPort: true) - .WithPortBinding(ConsolePort, assignRandomHostPort: true); - } - - /// Sets the root access key. - public MinIoBuilder WithAccessKey(string accessKey) - { - this.accessKey = accessKey; - WithEnvironment("MINIO_ROOT_USER", accessKey); - return this; - } - - /// Sets the root secret key. - public MinIoBuilder WithSecretKey(string secretKey) - { - this.secretKey = Secret.From(secretKey); - WithEnvironment("MINIO_ROOT_PASSWORD", secretKey); - return this; - } - - /// Builds the immutable configuration (internal; used by the module's own tests). - internal MinIoConfiguration BuildConfigurationForTesting() => BuildConfiguration(); - - /// - protected override MinIoConfiguration BuildConfiguration() - { - MinIoConfiguration configuration = base.BuildConfiguration(); - Dictionary environment = new Dictionary( - configuration.Environment, - StringComparer.Ordinal - ) - { - ["MINIO_ROOT_USER"] = accessKey, - ["MINIO_ROOT_PASSWORD"] = secretKey.Value, - }; - IReadOnlyList waitStrategies = - configuration.WaitStrategies.Count > 0 - ? configuration.WaitStrategies - : new[] { (IWaitStrategy)Wait.ForLogMessage("Console:") }; - return configuration with - { - AccessKey = accessKey, - SecretKey = secretKey, - Environment = environment, - WaitStrategies = waitStrategies, - }; - } - - /// - protected override void Validate(ContainerConfiguration configuration) - { - base.Validate(configuration); - if (string.IsNullOrWhiteSpace(accessKey) || string.IsNullOrEmpty(secretKey.Value)) - { - throw new WslContainerConfigurationException("MinIO access key and secret key cannot be empty."); - } - } - - /// - protected override MinIoContainer CreateContainer(MinIoConfiguration configuration) - { - return new MinIoContainer(configuration, Runtime ?? WslContainerRuntime.Instance); - } -} diff --git a/src/WslContainers.MinIo/MinIoConfiguration.cs b/src/WslContainers.MinIo/MinIoConfiguration.cs deleted file mode 100644 index 6f42dca..0000000 --- a/src/WslContainers.MinIo/MinIoConfiguration.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace Purview.WslContainers.MinIo; - -/// Immutable configuration for a MinIO test container. -public sealed record MinIoConfiguration : ContainerConfiguration -{ - /// Root access key (MINIO_ROOT_USER). - public string AccessKey { get; init; } = "minioadmin"; - - /// Root secret key (MINIO_ROOT_PASSWORD). Rendered as redacted. - public Secret SecretKey { get; init; } = Secret.From("minioadmin"); -} diff --git a/src/WslContainers.MinIo/MinIoContainer.cs b/src/WslContainers.MinIo/MinIoContainer.cs deleted file mode 100644 index 84872cb..0000000 --- a/src/WslContainers.MinIo/MinIoContainer.cs +++ /dev/null @@ -1,31 +0,0 @@ -namespace Purview.WslContainers.MinIo; - -/// A throwaway MinIO (S3-compatible object storage) instance running on WSL Containers. -public sealed class MinIoContainer : WslContainer -{ - private readonly MinIoConfiguration configuration; - - internal MinIoContainer(MinIoConfiguration configuration, IContainerRuntime runtime) - : base(configuration, runtime) - { - this.configuration = configuration; - } - - /// S3 API endpoint. Safe to call after . - public Uri GetApiEndpoint() - { - return new Uri($"http://127.0.0.1:{GetMappedPublicPort(MinIoBuilder.ApiPort)}"); - } - - /// Web console endpoint. - public Uri GetConsoleEndpoint() - { - return new Uri($"http://127.0.0.1:{GetMappedPublicPort(MinIoBuilder.ConsolePort)}"); - } - - /// Root access key. - public string GetAccessKey() => configuration.AccessKey; - - /// Root secret key. Handle as a secret. - public Secret GetSecretKey() => configuration.SecretKey; -} diff --git a/src/WslContainers.MinIo/WslContainers.MinIo.csproj b/src/WslContainers.MinIo/WslContainers.MinIo.csproj deleted file mode 100644 index e02446b..0000000 --- a/src/WslContainers.MinIo/WslContainers.MinIo.csproj +++ /dev/null @@ -1,13 +0,0 @@ - - - true - MinIO module for Purview.WslContainers: throwaway S3-compatible object storage on WSL Containers. - wsl;containers;minio;s3;object;storage;testing;integration - MIT - Purview - - - - - - diff --git a/src/WslContainers/LogBuffer.cs b/src/WslContainers/LogBuffer.cs deleted file mode 100644 index 7e12e52..0000000 --- a/src/WslContainers/LogBuffer.cs +++ /dev/null @@ -1,137 +0,0 @@ -using System.Text; -using System.Threading.Channels; -using Microsoft.WSL.Containers; - -namespace Purview.WslContainers; - -/// -/// Bounded buffer for init-process output. Accumulates decoded lines for diagnostics while publishing -/// every entry to a channel so wait strategies and consumers can tail live output. -/// -internal sealed class LogBuffer -{ - private const int MaxEntries = 4096; - private readonly object sync = new(); - private readonly List entries = new(MaxEntries); - private readonly Channel channel = Channel.CreateBounded( - new BoundedChannelOptions(1024) - { - FullMode = BoundedChannelFullMode.DropOldest, - SingleReader = true, - SingleWriter = false, - } - ); - private readonly Decoder stdoutDecoder = Encoding.UTF8.GetDecoder(); - private readonly Decoder stderrDecoder = Encoding.UTF8.GetDecoder(); - private readonly StringBuilder stdoutPending = new(); - private readonly StringBuilder stderrPending = new(); - private int attached; - - public void Attach(Process process) - { - if (Interlocked.Exchange(ref attached, 1) == 1) - { - return; - } - - process.OutputReceived += OnStdout; - process.ErrorReceived += OnStderr; - process.Exited += _ => FlushRemaining(); - } - - public IReadOnlyList Snapshot() - { - lock (sync) - { - return entries.ToArray(); - } - } - - public string GetLogs(LogOutput? stream) - { - lock (sync) - { - IEnumerable source = stream is LogOutput s - ? entries.Where(entry => entry.Stream == s) - : entries; - return string.Join('\n', source.Select(entry => entry.Text)); - } - } - - public ChannelReader Reader => channel.Reader; - - private void OnStdout(byte[] data) - { - Append(LogOutput.Stdout, stdoutDecoder, stdoutPending, data); - } - - private void OnStderr(byte[] data) - { - Append(LogOutput.Stderr, stderrDecoder, stderrPending, data); - } - - private void Append(LogOutput stream, Decoder decoder, StringBuilder pending, byte[] data) - { - if (data.Length == 0) - { - return; - } - - char[] chars = new char[decoder.GetCharCount(data, 0, data.Length, flush: false)]; - int count = decoder.GetChars(data, 0, data.Length, chars, 0, flush: false); - lock (sync) - { - pending.Append(chars, 0, count); - FlushLines(stream, pending); - } - } - - private void FlushLines(LogOutput stream, StringBuilder pending) - { - string text = pending.ToString(); - pending.Clear(); - int start = 0; - for (int i = 0; i < text.Length; i++) - { - if (text[i] == '\n') - { - Add(new ContainerLogEntry(DateTimeOffset.UtcNow, stream, text[start..i].TrimEnd('\r'))); - start = i + 1; - } - } - - if (start < text.Length) - { - pending.Append(text[start..]); - } - } - - private void FlushRemaining() - { - lock (sync) - { - if (stdoutPending.Length > 0) - { - Add(new ContainerLogEntry(DateTimeOffset.UtcNow, LogOutput.Stdout, stdoutPending.ToString())); - stdoutPending.Clear(); - } - - if (stderrPending.Length > 0) - { - Add(new ContainerLogEntry(DateTimeOffset.UtcNow, LogOutput.Stderr, stderrPending.ToString())); - stderrPending.Clear(); - } - } - } - - private void Add(ContainerLogEntry entry) - { - entries.Add(entry); - while (entries.Count > MaxEntries) - { - entries.RemoveAt(0); - } - - channel.Writer.TryWrite(entry); - } -} diff --git a/src/WslContainers/Waiting/TcpPortWaitStrategy.cs b/src/WslContainers/Waiting/TcpPortWaitStrategy.cs deleted file mode 100644 index 19b2725..0000000 --- a/src/WslContainers/Waiting/TcpPortWaitStrategy.cs +++ /dev/null @@ -1,47 +0,0 @@ -using System.Net; -using System.Net.Sockets; - -namespace Purview.WslContainers; - -/// Waits until a TCP connection to the mapped host port succeeds (IPv4 loopback). -public sealed class TcpPortWaitStrategy : WaitStrategy -{ - private readonly ushort containerPort; - private readonly TimeSpan connectTimeout; - - public TcpPortWaitStrategy(ushort containerPort, TimeSpan connectTimeout) - { - this.containerPort = containerPort; - this.connectTimeout = connectTimeout; - } - - /// - public override async Task UntilAsync(WaitContext context, CancellationToken cancellationToken) - { - int? hostPort = context.GetHostPort(containerPort); - if (hostPort is null) - { - return false; - } - - using CancellationTokenSource timeoutSource = new CancellationTokenSource(connectTimeout); - using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource( - cancellationToken, - timeoutSource.Token - ); - try - { - using TcpClient client = new TcpClient(); - await client.ConnectAsync(IPAddress.Loopback, hostPort.Value, linked.Token).ConfigureAwait(false); - return true; - } - catch (OperationCanceledException) when (timeoutSource.IsCancellationRequested) - { - return false; - } - catch (SocketException) - { - return false; - } - } -} diff --git a/src/WslContainers/WslContainerRuntime.cs b/src/WslContainers/WslContainerRuntime.cs deleted file mode 100644 index 27f99d7..0000000 --- a/src/WslContainers/WslContainerRuntime.cs +++ /dev/null @@ -1,190 +0,0 @@ -using System.Diagnostics.CodeAnalysis; -using Microsoft.WSL.Containers; - -namespace Purview.WslContainers; - -/// -/// The process-wide WSL Containers runtime. Owns a single lazily started WSLC session. -/// Images are shared by default: sessions use a stable shared storage directory so the image store is -/// pulled once and reused across process runs. When a concurrent process holds that store, the runtime -/// falls back to an isolated per-process store automatically. -/// -[SuppressMessage( - "Design", - "CA2213:Disposable fields should be disposed", - Justification = "The session is released asynchronously in DisposeAsync/TerminateAsync; CA2213 cannot see the async path." -)] -public sealed class WslContainerRuntime : IContainerRuntime -{ - private const int ErrorSharingViolation = unchecked((int)0x80070020); - - private static readonly Lazy InstanceHolder = new(() => new WslContainerRuntime()); - - private readonly WslContainerRuntimeOptions options; - private readonly object sync = new(); - private WslContainerSession? session; - private int disposed; - - /// Creates a runtime with default options. Registers a process-exit cleanup hook. - public WslContainerRuntime() - : this(WslContainerRuntimeOptions.Default) { } - - /// Creates a runtime with the given options. - public WslContainerRuntime(WslContainerRuntimeOptions options) - { - this.options = options ?? WslContainerRuntimeOptions.Default; - if (!this.options.DisableProcessExitCleanup) - { - AppDomain.CurrentDomain.ProcessExit += OnProcessExit; - } - } - - /// The default process-wide runtime. - public static WslContainerRuntime Instance => InstanceHolder.Value; - - /// - public async Task GetInfoAsync(CancellationToken cancellationToken = default) - { - await Task.Yield(); - ServiceVersion version = WslcService.GetVersion(); - IReadOnlyList missing = WslcService.GetMissingComponents(); - bool compatible = !missing.Contains(Component.SdkNeedsUpdate); - return new WslContainerRuntimeInfo( - $"{version.Major}.{version.Minor}.{version.Revision}", - missing.Select(component => component.ToString()).ToArray(), - missing.Count == 0, - compatible - ); - } - - /// - public async Task GetSessionAsync(CancellationToken cancellationToken = default) - { - ObjectDisposedException.ThrowIf(Volatile.Read(ref disposed) == 1, this); - WslContainerSession local = GetOrCreateSession(); - try - { - await local.EnsureStartedAsync(cancellationToken).ConfigureAwait(false); - } - catch (Exception ex) when (IsSharingViolation(ex) && IsUsingDefaultSharedStore()) - { - // A concurrent process holds the shared image-store VHD. Isolate this process instead of failing. - local.DisposeSession(); - local = CreateIsolatedSession(); - session = local; - await local.EnsureStartedAsync(cancellationToken).ConfigureAwait(false); - } - - return local; - } - - /// - public async ValueTask DisposeAsync() - { - if (Interlocked.Exchange(ref disposed, 1) == 1) - { - return; - } - - WslContainerSession? local = session; - if (local is not null) - { - await local.TerminateAsync().ConfigureAwait(false); - } - - GC.SuppressFinalize(this); - } - - private WslContainerSession GetOrCreateSession() - { - WslContainerSession? local = session; - if (local is not null) - { - return local; - } - - lock (sync) - { - local = session; - if (local is null) - { - local = CreateSession(); - session = local; - } - - return local; - } - } - - private WslContainerSession CreateSession() - { - string name = NextSessionName(); - string storagePath = ResolveStoragePath(name); - return new WslContainerSession(name, storagePath, options); - } - - private WslContainerSession CreateIsolatedSession() - { - string name = NextSessionName(); - string storagePath = Path.Combine(PerSessionRoot, name); - return new WslContainerSession(name, storagePath, options); - } - - private string ResolveStoragePath(string sessionName) - { - if (options.StoragePath is not null) - { - return options.StoragePath; - } - - string? envPath = Environment.GetEnvironmentVariable("WSL_CONTAINERS_STORAGE_PATH"); - if (!string.IsNullOrEmpty(envPath)) - { - return envPath; - } - - if (options.StorageMode == StorageMode.PerSession) - { - return Path.Combine(PerSessionRoot, sessionName); - } - - return SharedImagesRoot; - } - - private bool IsUsingDefaultSharedStore() - { - return options.StoragePath is null - && string.IsNullOrEmpty(Environment.GetEnvironmentVariable("WSL_CONTAINERS_STORAGE_PATH")) - && options.StorageMode == StorageMode.Shared; - } - - private static string NextSessionName() - { - return $"wslc-{Environment.ProcessId}-{Guid.NewGuid().ToString("N")[..8]}"; - } - - private static string PerSessionRoot => Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - "Purview", - "WslContainers", - "sessions" - ); - - private static string SharedImagesRoot => Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - "Purview", - "WslContainers", - "images" - ); - - private static bool IsSharingViolation(Exception exception) - { - return exception.HResult == ErrorSharingViolation; - } - - private void OnProcessExit(object? sender, EventArgs e) - { - WslContainerSession? local = session; - local?.TerminateBestEffort(); - } -} \ No newline at end of file diff --git a/src/WslContainers/WslInspectParser.cs b/src/WslContainers/WslInspectParser.cs deleted file mode 100644 index 559211f..0000000 --- a/src/WslContainers/WslInspectParser.cs +++ /dev/null @@ -1,79 +0,0 @@ -using System.Text.Json; - -namespace Purview.WslContainers; - -/// Parses the Docker-compatible JSON returned by Container.Inspect(). -internal static class WslInspectParser -{ - public static IReadOnlyDictionary ParseHostPorts(string inspectJson) - { - Dictionary result = new(); - try - { - using JsonDocument document = JsonDocument.Parse(inspectJson); - if ( - !document.RootElement.TryGetProperty("Ports", out JsonElement ports) - || ports.ValueKind != JsonValueKind.Object - ) - { - return result; - } - - foreach (JsonProperty property in ports.EnumerateObject()) - { - // Key format: "8080/tcp" - string key = property.Name; - int slash = key.IndexOf('/', StringComparison.Ordinal); - if ( - slash <= 0 - || !ushort.TryParse(key.AsSpan(0, slash), out ushort containerPort) - || property.Value.ValueKind != JsonValueKind.Array - ) - { - continue; - } - - foreach (JsonElement binding in property.Value.EnumerateArray()) - { - if ( - binding.TryGetProperty("HostPort", out JsonElement hostPort) - && hostPort.ValueKind == JsonValueKind.String - && ushort.TryParse(hostPort.GetString(), out ushort host) - ) - { - result[containerPort] = host; - break; - } - } - } - } - catch (JsonException) { } - - return result; - } - - public static string? TryGetNetworkIp(string inspectJson, string network = "bridge") - { - try - { - using JsonDocument document = JsonDocument.Parse(inspectJson); - if ( - document.RootElement.TryGetProperty("NetworkSettings", out JsonElement networkSettings) - && networkSettings.TryGetProperty("Networks", out JsonElement networks) - && networks.TryGetProperty(network, out JsonElement net) - && net.TryGetProperty("IPAddress", out JsonElement ip) - && ip.ValueKind == JsonValueKind.String - ) - { - string value = ip.GetString() ?? string.Empty; - return string.IsNullOrEmpty(value) ? null : value; - } - - return null; - } - catch (JsonException) - { - return null; - } - } -} diff --git a/src/WslContainers.Azurite/WslContainers.Azurite.csproj b/src/src/Azurite/Azurite.csproj similarity index 100% rename from src/WslContainers.Azurite/WslContainers.Azurite.csproj rename to src/src/Azurite/Azurite.csproj diff --git a/src/WslContainers.Azurite/AzuriteAccount.cs b/src/src/Azurite/AzuriteAccount.cs similarity index 100% rename from src/WslContainers.Azurite/AzuriteAccount.cs rename to src/src/Azurite/AzuriteAccount.cs diff --git a/src/WslContainers.Azurite/AzuriteBuilder.cs b/src/src/Azurite/AzuriteBuilder.cs similarity index 90% rename from src/WslContainers.Azurite/AzuriteBuilder.cs rename to src/src/Azurite/AzuriteBuilder.cs index 0f18999..5990692 100644 --- a/src/WslContainers.Azurite/AzuriteBuilder.cs +++ b/src/src/Azurite/AzuriteBuilder.cs @@ -1,3 +1,5 @@ +using Purview.WslContainers.Waiting; + namespace Purview.WslContainers.Azurite; /// Fluent builder for an Azurite (Azure Storage emulator) test container. @@ -46,8 +48,8 @@ public AzuriteBuilder(IContainerRuntime runtime) /// protected override AzuriteConfiguration BuildConfiguration() { - AzuriteConfiguration configuration = base.BuildConfiguration(); - IReadOnlyList waitStrategies = + var configuration = base.BuildConfiguration(); + var waitStrategies = configuration.WaitStrategies.Count > 0 ? configuration.WaitStrategies : new[] { (IWaitStrategy)Wait.ForLogMessage("successfully listening") }; @@ -55,8 +57,6 @@ protected override AzuriteConfiguration BuildConfiguration() } /// - protected override AzuriteContainer CreateContainer(AzuriteConfiguration configuration) - { - return new AzuriteContainer(configuration, Runtime ?? WslContainerRuntime.Instance); - } + protected override AzuriteContainer CreateContainer(AzuriteConfiguration configuration) => + new(configuration, Runtime ?? WslContainerRuntime.Instance); } diff --git a/src/WslContainers.Azurite/AzuriteConfiguration.cs b/src/src/Azurite/AzuriteConfiguration.cs similarity index 100% rename from src/WslContainers.Azurite/AzuriteConfiguration.cs rename to src/src/Azurite/AzuriteConfiguration.cs diff --git a/src/WslContainers.Azurite/AzuriteContainer.cs b/src/src/Azurite/AzuriteContainer.cs similarity index 97% rename from src/WslContainers.Azurite/AzuriteContainer.cs rename to src/src/Azurite/AzuriteContainer.cs index 4a6c865..973d5de 100644 --- a/src/WslContainers.Azurite/AzuriteContainer.cs +++ b/src/src/Azurite/AzuriteContainer.cs @@ -31,7 +31,7 @@ public Uri GetTableEndpoint() /// (a placeholder until the consuming repository supplies it). public string GetConnectionString() { - StringBuilder builder = new StringBuilder(); + StringBuilder builder = new(); builder.Append("DefaultEndpointsProtocol=http;"); builder.Append(CultureInfo.InvariantCulture, $"AccountName={AzuriteAccount.Name};"); builder.Append(CultureInfo.InvariantCulture, $"AccountKey={AzuriteAccount.Key};"); diff --git a/src/src/Azurite/Sdk/README.md b/src/src/Azurite/Sdk/README.md new file mode 100644 index 0000000..07203df --- /dev/null +++ b/src/src/Azurite/Sdk/README.md @@ -0,0 +1,46 @@ +# Purview.WslContainers.Azurite + +Throwaway [Azurite](https://github.com/Azure/Azurite) (Azure Storage emulator) instances for .NET +integration testing, running as WSLC containers on **Microsoft WSL Containers** — no Docker installation. + +```bash +dotnet add package Purview.WslContainers.Azurite +``` + +Depends on `Purview.WslContainers` (the core runtime) and needs Windows with WSL ≥ 2.9.3 (WSL Containers). +See the [Getting Started guide](https://github.com/purview-dev/wsl-testcontainers/blob/main/docs/wiki/Getting-Started.md). + +## Quick start + +```csharp +using Purview.WslContainers.Azurite; + +await using var azurite = new AzuriteBuilder().Build(); + +await azurite.StartAsync(); // waits for the blob/queue/table listeners + +Uri blob = azurite.GetBlobEndpoint(); +string connectionString = azurite.GetConnectionString(); +``` + +## API + +| Member | Purpose | +| -- | -- | +| `AzuriteBuilder()` / `AzuriteBuilder(string image)` | Default image `mcr.microsoft.com/azure-storage/azurite`, or a custom image. | +| `AzuriteBuilder.BlobPort` (10000), `QueuePort` (10001), `TablePort` (10002) | Container service ports; all three are mapped to random host ports. | +| `AzuriteContainer.GetBlobEndpoint()`, `GetQueueEndpoint()`, `GetTableEndpoint()` | Service endpoints (`http://127.0.0.1:{mappedPort}/devstoreaccount1`). | +| `AzuriteContainer.GetConnectionString()` | Azure Storage connection string for the emulator. | +| `AzuriteAccount.Name` | The well-known account name `devstoreaccount1`. | + +The container starts Azurite with `--blobHost 0.0.0.0 --queueHost 0.0.0.0 --tableHost 0.0.0.0` and waits +for the `successfully listening` log signal. Endpoints and the connection string are only meaningful after +`StartAsync()` because they resolve the mapped host ports. + +## Account key + +The well-known `devstoreaccount1` key is a published constant of the emulator, but this library does not +embed it: `AzuriteAccount.Key` holds a placeholder. Supply the real key in your test infrastructure before +exercising authenticated operations (anonymous/local development paths are unaffected when the client does +not require the key). See [Modules](https://github.com/purview-dev/wsl-testcontainers/blob/main/docs/wiki/Modules.md) +for the module contract. diff --git a/src/WslContainers.MsSql/WslContainers.MsSql.csproj b/src/src/MsSql/MsSql.csproj similarity index 100% rename from src/WslContainers.MsSql/WslContainers.MsSql.csproj rename to src/src/MsSql/MsSql.csproj diff --git a/src/WslContainers.MsSql/MsSqlBuilder.cs b/src/src/MsSql/MsSqlBuilder.cs similarity index 80% rename from src/WslContainers.MsSql/MsSqlBuilder.cs rename to src/src/MsSql/MsSqlBuilder.cs index 3a9bec3..1431fb2 100644 --- a/src/WslContainers.MsSql/MsSqlBuilder.cs +++ b/src/src/MsSql/MsSqlBuilder.cs @@ -1,4 +1,7 @@ using Microsoft.Data.SqlClient; +using Purview.WslContainers.Diagnostics; +using Purview.WslContainers.Runtime; +using Purview.WslContainers.Waiting; namespace Purview.WslContainers.MsSql; @@ -11,8 +14,8 @@ public class MsSqlBuilder : ContainerBuilderDefault image. public const string MsSqlImage = "mcr.microsoft.com/mssql/server:2022-latest"; - private Secret password = Secret.From("YourStrong!Passw0rd"); - private bool acceptLicense; + Secret _password = Secret.From("YourStrong!Passw0rd"); + bool _acceptLicense; /// Creates a builder with the default image. public MsSqlBuilder() @@ -34,7 +37,7 @@ public MsSqlBuilder(IContainerRuntime runtime) /// Sets the SA password. Must satisfy SQL Server password complexity (at least 8 characters). public MsSqlBuilder WithPassword(string password) { - this.password = Secret.From(password); + _password = Secret.From(password); WithEnvironment("MSSQL_SA_PASSWORD", password); return this; } @@ -45,7 +48,7 @@ public MsSqlBuilder WithPassword(string password) /// public MsSqlBuilder AcceptLicense() { - acceptLicense = true; + _acceptLicense = true; WithEnvironment("ACCEPT_EULA", "Y"); return this; } @@ -56,26 +59,23 @@ public MsSqlBuilder AcceptLicense() /// protected override MsSqlConfiguration BuildConfiguration() { - MsSqlConfiguration configuration = base.BuildConfiguration(); - Dictionary environment = new Dictionary( - configuration.Environment, - StringComparer.Ordinal - ) + var configuration = base.BuildConfiguration(); + Dictionary environment = new(configuration.Environment, StringComparer.Ordinal) { - ["MSSQL_SA_PASSWORD"] = password.Value, + ["MSSQL_SA_PASSWORD"] = _password.Value, ["MSSQL_PID"] = "Developer", }; - if (acceptLicense) + if (_acceptLicense) { environment["ACCEPT_EULA"] = "Y"; } - IReadOnlyList waitStrategies = + var waitStrategies = configuration.WaitStrategies.Count > 0 ? configuration.WaitStrategies : new[] { BuildReadinessWait() }; return configuration with { - Password = password, - AcceptLicense = acceptLicense, + Password = _password, + AcceptLicense = _acceptLicense, Environment = environment, WaitStrategies = waitStrategies, }; @@ -85,14 +85,14 @@ protected override MsSqlConfiguration BuildConfiguration() protected override void Validate(ContainerConfiguration configuration) { base.Validate(configuration); - if (!acceptLicense) + if (!_acceptLicense) { throw new WslContainerConfigurationException( "The SQL Server image requires accepting the EULA. Call AcceptLicense() explicitly before Build()." ); } - if (password.Value.Length < 8) + if (_password.Value.Length < 8) { throw new WslContainerConfigurationException( "The SQL Server SA password must be at least 8 characters long." @@ -106,30 +106,30 @@ protected override MsSqlContainer CreateContainer(MsSqlConfiguration configurati return new MsSqlContainer(configuration, Runtime ?? WslContainerRuntime.Instance); } - private IWaitStrategy BuildReadinessWait() + WaitStrategy BuildReadinessWait() { // SQL Server is ready only when it accepts a real connection; a listening TCP port is not enough. return Wait.ForCustom( async (context, cancellationToken) => { - int? hostPort = context.GetHostPort(MsSqlPort); + var hostPort = context.GetHostPort(MsSqlPort); if (hostPort is null) { return false; } - SqlConnectionStringBuilder builder = new SqlConnectionStringBuilder + SqlConnectionStringBuilder builder = new() { // 127.0.0.1 is required: WSLC maps IPv4 loopback only, and Microsoft.Data.SqlClient // hangs on the IPv6 ::1 address that 'localhost' resolves to. DataSource = $"127.0.0.1,{hostPort}", UserID = "sa", - Password = password.Value, + Password = _password.Value, TrustServerCertificate = true, }; try { - await using SqlConnection connection = new SqlConnection(builder.ConnectionString); + await using SqlConnection connection = new(builder.ConnectionString); await connection.OpenAsync(cancellationToken); return connection.State == System.Data.ConnectionState.Open; } diff --git a/src/WslContainers.MsSql/MsSqlConfiguration.cs b/src/src/MsSql/MsSqlConfiguration.cs similarity index 91% rename from src/WslContainers.MsSql/MsSqlConfiguration.cs rename to src/src/MsSql/MsSqlConfiguration.cs index 4c76385..de960c4 100644 --- a/src/WslContainers.MsSql/MsSqlConfiguration.cs +++ b/src/src/MsSql/MsSqlConfiguration.cs @@ -1,3 +1,5 @@ +using Purview.WslContainers.Diagnostics; + namespace Purview.WslContainers.MsSql; /// Immutable configuration for a Microsoft SQL Server test container. diff --git a/src/WslContainers.MsSql/MsSqlContainer.cs b/src/src/MsSql/MsSqlContainer.cs similarity index 80% rename from src/WslContainers.MsSql/MsSqlContainer.cs rename to src/src/MsSql/MsSqlContainer.cs index c96f0c6..eacce1e 100644 --- a/src/WslContainers.MsSql/MsSqlContainer.cs +++ b/src/src/MsSql/MsSqlContainer.cs @@ -5,24 +5,24 @@ namespace Purview.WslContainers.MsSql; /// A throwaway Microsoft SQL Server instance running on WSL Containers. public sealed class MsSqlContainer : WslContainer { - private readonly MsSqlConfiguration configuration; + readonly MsSqlConfiguration _configuration; internal MsSqlContainer(MsSqlConfiguration configuration, IContainerRuntime runtime) : base(configuration, runtime) { - this.configuration = configuration; + _configuration = configuration; } /// Connection string pointing at the mapped host port. Safe to call after . public string GetConnectionString() { - SqlConnectionStringBuilder builder = new SqlConnectionStringBuilder + SqlConnectionStringBuilder builder = new() { // 127.0.0.1 is required: WSLC maps IPv4 loopback only, and Microsoft.Data.SqlClient // hangs on the IPv6 ::1 address that 'localhost' resolves to. DataSource = $"127.0.0.1,{GetMappedPublicPort(MsSqlBuilder.MsSqlPort)}", UserID = "sa", - Password = configuration.Password.Value, + Password = _configuration.Password.Value, TrustServerCertificate = true, }; return builder.ConnectionString; diff --git a/src/src/MsSql/Sdk/README.md b/src/src/MsSql/Sdk/README.md new file mode 100644 index 0000000..db20410 --- /dev/null +++ b/src/src/MsSql/Sdk/README.md @@ -0,0 +1,55 @@ +# Purview.WslContainers.MsSql + +Throwaway Microsoft SQL Server databases for .NET integration testing, running as WSLC containers on +**Microsoft WSL Containers** — no Docker installation. + +```bash +dotnet add package Purview.WslContainers.MsSql +``` + +Depends on `Purview.WslContainers` (the core runtime) and brings `Microsoft.Data.SqlClient` for +connection-string generation and readiness probing. + +## Quick start + +```csharp +using Microsoft.Data.SqlClient; +using Purview.WslContainers.MsSql; + +await using var sqlServer = new MsSqlBuilder() + .WithPassword("SomeStrong!Password1") + .AcceptLicense() // explicit EULA acceptance is required + .Build(); + +await sqlServer.StartAsync(); // waits for a real SQL Server connection + +await using var connection = new SqlConnection(sqlServer.GetConnectionString()); +await connection.OpenAsync(); +``` + +## API + +| Member | Purpose | +| -- | -- | +| `MsSqlBuilder()` / `MsSqlBuilder(string image)` | Default image `mcr.microsoft.com/mssql/server:2022-latest`, or a custom image. | +| `MsSqlBuilder.MsSqlPort` (1433) | Container port, mapped to a random host port. | +| `WithPassword(string)` | Sets `MSSQL_SA_PASSWORD` (default `YourStrong!Passw0rd`); stored as a redacted `Secret`. | +| `AcceptLicense()` | Sets `ACCEPT_EULA=Y`. Required — the library never accepts licensing terms on your behalf. | +| `MsSqlContainer.GetConnectionString()` | `SqlConnectionStringBuilder` connection string for the mapped host port. | + +## Requirements and behaviour + +- **Memory:** SQL Server refuses to start below 2000 MB. The default session VM is capped at 4096 MB + (`WslContainerRuntimeOptions.Default`), so it works out of the box; override with + `MemorySizeInMB` if you configure your own runtime. +- **Licence:** `Build()` throws `WslContainerConfigurationException` unless `AcceptLicense()` was called. + The SA password must be at least 8 characters. +- **Readiness:** a listening TCP port is not enough — the image reports readiness before it can serve + queries, so the default strategy opens a host-side `SqlClient` connection every 2 seconds. The + connection string targets `127.0.0.1`, because WSLC maps IPv4 loopback only and `Microsoft.Data.SqlClient` + hangs on the IPv6 `::1` address that `localhost` can resolve to. + +## Documentation + +- [Modules](https://github.com/purview-dev/wsl-testcontainers/blob/main/docs/wiki/Modules.md) — the module contract and readiness choices. +- [Getting Started](https://github.com/purview-dev/wsl-testcontainers/blob/main/docs/wiki/Getting-Started.md) — prerequisites and first-container walkthrough. diff --git a/src/WslContainers.MySql/WslContainers.MySql.csproj b/src/src/MySql/MySql.csproj similarity index 100% rename from src/WslContainers.MySql/WslContainers.MySql.csproj rename to src/src/MySql/MySql.csproj diff --git a/src/WslContainers.MySql/MySqlBuilder.cs b/src/src/MySql/MySqlBuilder.cs similarity index 72% rename from src/WslContainers.MySql/MySqlBuilder.cs rename to src/src/MySql/MySqlBuilder.cs index c902847..908cd6e 100644 --- a/src/WslContainers.MySql/MySqlBuilder.cs +++ b/src/src/MySql/MySqlBuilder.cs @@ -1,4 +1,7 @@ using MySqlConnector; +using Purview.WslContainers.Diagnostics; +using Purview.WslContainers.Runtime; +using Purview.WslContainers.Waiting; namespace Purview.WslContainers.MySql; @@ -11,10 +14,10 @@ public class MySqlBuilder : ContainerBuilderDefault image. public const string MySqlImage = "mysql:8"; - private string database = "test"; - private string username = "test"; - private Secret password = Secret.From("test"); - private Secret rootPassword = Secret.From("test"); + string _database = "test"; + string _username = "test"; + Secret _password = Secret.From("test"); + Secret _rootPassword = Secret.From("test"); /// Creates a builder with the default image. public MySqlBuilder() @@ -36,7 +39,7 @@ public MySqlBuilder(IContainerRuntime runtime) /// Sets the database name. public MySqlBuilder WithDatabase(string database) { - this.database = database; + _database = database; WithEnvironment("MYSQL_DATABASE", database); return this; } @@ -44,7 +47,7 @@ public MySqlBuilder WithDatabase(string database) /// Sets the application user name. public MySqlBuilder WithUsername(string username) { - this.username = username; + _username = username; WithEnvironment("MYSQL_USER", username); return this; } @@ -52,7 +55,7 @@ public MySqlBuilder WithUsername(string username) /// Sets the application user password. public MySqlBuilder WithPassword(string password) { - this.password = Secret.From(password); + _password = Secret.From(password); WithEnvironment("MYSQL_PASSWORD", password); return this; } @@ -60,7 +63,7 @@ public MySqlBuilder WithPassword(string password) /// Sets the root password. public MySqlBuilder WithRootPassword(string rootPassword) { - this.rootPassword = Secret.From(rootPassword); + _rootPassword = Secret.From(rootPassword); WithEnvironment("MYSQL_ROOT_PASSWORD", rootPassword); return this; } @@ -71,25 +74,22 @@ public MySqlBuilder WithRootPassword(string rootPassword) /// protected override MySqlConfiguration BuildConfiguration() { - MySqlConfiguration configuration = base.BuildConfiguration(); - Dictionary environment = new Dictionary( - configuration.Environment, - StringComparer.Ordinal - ) + var configuration = base.BuildConfiguration(); + Dictionary environment = new(configuration.Environment, StringComparer.Ordinal) { - ["MYSQL_DATABASE"] = database, - ["MYSQL_USER"] = username, - ["MYSQL_PASSWORD"] = password.Value, - ["MYSQL_ROOT_PASSWORD"] = rootPassword.Value, + ["MYSQL_DATABASE"] = _database, + ["MYSQL_USER"] = _username, + ["MYSQL_PASSWORD"] = _password.Value, + ["MYSQL_ROOT_PASSWORD"] = _rootPassword.Value, }; - IReadOnlyList waitStrategies = + var waitStrategies = configuration.WaitStrategies.Count > 0 ? configuration.WaitStrategies : new[] { BuildReadinessWait() }; return configuration with { - Database = database, - Username = username, - Password = password, - RootPassword = rootPassword, + Database = _database, + Username = _username, + Password = _password, + RootPassword = _rootPassword, Environment = environment, WaitStrategies = waitStrategies, }; @@ -99,12 +99,12 @@ protected override MySqlConfiguration BuildConfiguration() protected override void Validate(ContainerConfiguration configuration) { base.Validate(configuration); - if (string.IsNullOrWhiteSpace(database)) + if (string.IsNullOrWhiteSpace(_database)) { throw new WslContainerConfigurationException("MySQL database cannot be empty."); } - if (string.IsNullOrEmpty(password.Value)) + if (string.IsNullOrEmpty(_password.Value)) { throw new WslContainerConfigurationException("MySQL password cannot be empty."); } @@ -116,30 +116,30 @@ protected override MySqlContainer CreateContainer(MySqlConfiguration configurati return new MySqlContainer(configuration, Runtime ?? WslContainerRuntime.Instance); } - private IWaitStrategy BuildReadinessWait() + WaitStrategy BuildReadinessWait() { // The image logs "ready for connections" during its temporary init server, so the readiness // check must be a real host-side connection rather than a log match. return Wait.ForCustom( async (context, cancellationToken) => { - int? hostPort = context.GetHostPort(MySqlPort); + var hostPort = context.GetHostPort(MySqlPort); if (hostPort is null) { return false; } - MySqlConnectionStringBuilder builder = new MySqlConnectionStringBuilder + MySqlConnectionStringBuilder builder = new() { Server = "127.0.0.1", Port = (uint)hostPort.Value, - Database = database, - UserID = username, - Password = password.Value, + Database = _database, + UserID = _username, + Password = _password.Value, }; try { - await using MySqlConnection connection = new MySqlConnection(builder.ConnectionString); + await using MySqlConnection connection = new(builder.ConnectionString); await connection.OpenAsync(cancellationToken); return connection.State == System.Data.ConnectionState.Open; } diff --git a/src/WslContainers.MySql/MySqlConfiguration.cs b/src/src/MySql/MySqlConfiguration.cs similarity index 94% rename from src/WslContainers.MySql/MySqlConfiguration.cs rename to src/src/MySql/MySqlConfiguration.cs index 03813a8..299841f 100644 --- a/src/WslContainers.MySql/MySqlConfiguration.cs +++ b/src/src/MySql/MySqlConfiguration.cs @@ -1,3 +1,5 @@ +using Purview.WslContainers.Diagnostics; + namespace Purview.WslContainers.MySql; /// Immutable configuration for a MySQL test container. diff --git a/src/WslContainers.MySql/MySqlContainer.cs b/src/src/MySql/MySqlContainer.cs similarity index 68% rename from src/WslContainers.MySql/MySqlContainer.cs rename to src/src/MySql/MySqlContainer.cs index 0933d19..8d78fb5 100644 --- a/src/WslContainers.MySql/MySqlContainer.cs +++ b/src/src/MySql/MySqlContainer.cs @@ -5,24 +5,24 @@ namespace Purview.WslContainers.MySql; /// A throwaway MySQL database running on WSL Containers. public sealed class MySqlContainer : WslContainer { - private readonly MySqlConfiguration configuration; + readonly MySqlConfiguration _configuration; internal MySqlContainer(MySqlConfiguration configuration, IContainerRuntime runtime) : base(configuration, runtime) { - this.configuration = configuration; + _configuration = configuration; } /// Connection string pointing at the mapped host port. Safe to call after . public string GetConnectionString() { - MySqlConnectionStringBuilder builder = new MySqlConnectionStringBuilder + MySqlConnectionStringBuilder builder = new() { Server = "127.0.0.1", Port = GetMappedPublicPort(MySqlBuilder.MySqlPort), - Database = configuration.Database, - UserID = configuration.Username, - Password = configuration.Password.Value, + Database = _configuration.Database, + UserID = _configuration.Username, + Password = _configuration.Password.Value, }; return builder.ConnectionString; } diff --git a/src/src/MySql/Sdk/README.md b/src/src/MySql/Sdk/README.md new file mode 100644 index 0000000..917de3b --- /dev/null +++ b/src/src/MySql/Sdk/README.md @@ -0,0 +1,55 @@ +# Purview.WslContainers.MySql + +Throwaway MySQL databases for .NET integration testing, running as WSLC containers on +**Microsoft WSL Containers** — no Docker installation. + +```bash +dotnet add package Purview.WslContainers.MySql +``` + +Depends on `Purview.WslContainers` (the core runtime) and brings `MySqlConnector` for readiness probing and +connection-string generation. + +## Quick start + +```csharp +using MySqlConnector; +using Purview.WslContainers.MySql; + +await using var mysql = new MySqlBuilder() + .WithDatabase("tests") + .WithUsername("test") + .WithPassword("test") + .Build(); + +await mysql.StartAsync(); // waits for a real host-side connection + +await using var connection = new MySqlConnection(mysql.GetConnectionString()); +await connection.OpenAsync(); +``` + +## API + +| Member | Purpose | +| -- | -- | +| `MySqlBuilder()` / `MySqlBuilder(string image)` | Default image `mysql:8`, or a custom image. | +| `MySqlBuilder.MySqlPort` (3306) | Container port, mapped to a random host port. | +| `WithDatabase(string)` | Sets `MYSQL_DATABASE` (default `test`). | +| `WithUsername(string)` | Sets `MYSQL_USER` (default `test`). | +| `WithPassword(string)` | Sets `MYSQL_PASSWORD` (default `test`); stored as a redacted `Secret`. | +| `WithRootPassword(string)` | Sets `MYSQL_ROOT_PASSWORD` (default `test`); stored as a redacted `Secret`. | +| `MySqlContainer.GetConnectionString()` | `MySqlConnectionStringBuilder` connection string for the mapped host port. | + +`Build()` rejects an empty database or password with `WslContainerConfigurationException`. + +## Readiness + +The image logs `ready for connections` while its temporary initialisation server is still running, so a log +match or a bare TCP check would report readiness too early. The default wait strategy therefore opens a real +host-side `MySqlConnector` connection (every second) and only returns once it succeeds. Supply your own +strategy with `WithWaitStrategy(...)` if you need different behaviour. + +## Documentation + +- [Modules](https://github.com/purview-dev/wsl-testcontainers/blob/main/docs/wiki/Modules.md) — the module contract and readiness choices. +- [Wait Strategies](https://github.com/purview-dev/wsl-testcontainers/blob/main/docs/wiki/Wait-Strategies.md) — overriding readiness checks. diff --git a/src/WslContainers.Nats/WslContainers.Nats.csproj b/src/src/Nats/Nats.csproj similarity index 100% rename from src/WslContainers.Nats/WslContainers.Nats.csproj rename to src/src/Nats/Nats.csproj diff --git a/src/WslContainers.Nats/NatsBuilder.cs b/src/src/Nats/NatsBuilder.cs similarity index 94% rename from src/WslContainers.Nats/NatsBuilder.cs rename to src/src/Nats/NatsBuilder.cs index 9d6457d..52d5872 100644 --- a/src/WslContainers.Nats/NatsBuilder.cs +++ b/src/src/Nats/NatsBuilder.cs @@ -1,3 +1,5 @@ +using Purview.WslContainers.Waiting; + namespace Purview.WslContainers.Nats; /// Fluent builder for a NATS test container. @@ -39,8 +41,8 @@ public NatsBuilder(IContainerRuntime runtime) /// protected override NatsConfiguration BuildConfiguration() { - NatsConfiguration configuration = base.BuildConfiguration(); - IReadOnlyList waitStrategies = + var configuration = base.BuildConfiguration(); + var waitStrategies = configuration.WaitStrategies.Count > 0 ? configuration.WaitStrategies : new[] { (IWaitStrategy)Wait.ForLogMessage("Listening for client connections") }; diff --git a/src/WslContainers.Nats/NatsConfiguration.cs b/src/src/Nats/NatsConfiguration.cs similarity index 100% rename from src/WslContainers.Nats/NatsConfiguration.cs rename to src/src/Nats/NatsConfiguration.cs diff --git a/src/WslContainers.Nats/NatsContainer.cs b/src/src/Nats/NatsContainer.cs similarity index 100% rename from src/WslContainers.Nats/NatsContainer.cs rename to src/src/Nats/NatsContainer.cs diff --git a/src/src/Nats/Sdk/README.md b/src/src/Nats/Sdk/README.md new file mode 100644 index 0000000..be41e12 --- /dev/null +++ b/src/src/Nats/Sdk/README.md @@ -0,0 +1,44 @@ +# Purview.WslContainers.Nats + +Throwaway NATS brokers for .NET integration testing, running as WSLC containers on +**Microsoft WSL Containers** — no Docker installation. + +```bash +dotnet add package Purview.WslContainers.Nats +``` + +Depends on `Purview.WslContainers` (the core runtime). `NATS.Client.Core` is not referenced by this +package — bring your own client. + +## Quick start + +```csharp +using Purview.WslContainers.Nats; +using NATS.Client.Core; + +await using var nats = new NatsBuilder().Build(); + +await nats.StartAsync(); // waits for 'Listening for client connections' + +await using var client = new NatsConnection(new NatsOpts { Url = nats.GetConnectionString() }); +await client.PublishAsync("orders.created", "42"); +``` + +## API + +| Member | Purpose | +| -- | -- | +| `NatsBuilder()` / `NatsBuilder(string image)` | Default image `nats:2`, or a custom image. | +| `NatsBuilder.ClientPort` (4222), `MonitoringPort` (8222) | Container ports, both mapped to random host ports. | +| `NatsContainer.GetClientEndpoint()` | `nats://127.0.0.1:{mappedPort}` as a `Uri`. | +| `NatsContainer.GetConnectionString()` | The client endpoint as a string. | +| `NatsContainer.GetMonitoringEndpoint()` | Monitoring HTTP endpoint (`http://127.0.0.1:{mappedPort}`), e.g. `/varz`. | + +The default wait strategy matches the `Listening for client connections` log line unless you supply your own +with `WithWaitStrategy(...)`. Endpoint accessors resolve the mapped host ports, so call them after +`StartAsync()`. + +## Documentation + +- [Modules](https://github.com/purview-dev/wsl-testcontainers/blob/main/docs/wiki/Modules.md) — the module contract and readiness choices. +- [Wait Strategies](https://github.com/purview-dev/wsl-testcontainers/blob/main/docs/wiki/Wait-Strategies.md) — overriding readiness checks. diff --git a/src/WslContainers.PostgreSql/WslContainers.PostgreSql.csproj b/src/src/PostgreSql/PostgreSql.csproj similarity index 100% rename from src/WslContainers.PostgreSql/WslContainers.PostgreSql.csproj rename to src/src/PostgreSql/PostgreSql.csproj diff --git a/src/WslContainers.PostgreSql/PostgreSqlBuilder.cs b/src/src/PostgreSql/PostgreSqlBuilder.cs similarity index 76% rename from src/WslContainers.PostgreSql/PostgreSqlBuilder.cs rename to src/src/PostgreSql/PostgreSqlBuilder.cs index afdf4fb..0a6ca1a 100644 --- a/src/WslContainers.PostgreSql/PostgreSqlBuilder.cs +++ b/src/src/PostgreSql/PostgreSqlBuilder.cs @@ -1,3 +1,7 @@ +using Purview.WslContainers.Diagnostics; +using Purview.WslContainers.Runtime; +using Purview.WslContainers.Waiting; + namespace Purview.WslContainers.PostgreSql; /// Fluent builder for a PostgreSQL test container. @@ -9,9 +13,9 @@ public class PostgreSqlBuilder : ContainerBuilderDefault image. public const string PostgreSqlImage = "postgres:17"; - private string database = "postgres"; - private string username = "postgres"; - private Secret password = Secret.From("postgres"); + string _database = "postgres"; + string _username = "postgres"; + Secret _password = Secret.From("postgres"); /// Creates a builder with the default image. public PostgreSqlBuilder() @@ -33,7 +37,7 @@ public PostgreSqlBuilder(IContainerRuntime runtime) /// Sets the database name. public PostgreSqlBuilder WithDatabase(string database) { - this.database = database; + _database = database; WithEnvironment("POSTGRES_DB", database); return this; } @@ -41,7 +45,7 @@ public PostgreSqlBuilder WithDatabase(string database) /// Sets the superuser name. public PostgreSqlBuilder WithUsername(string username) { - this.username = username; + _username = username; WithEnvironment("POSTGRES_USER", username); return this; } @@ -49,7 +53,7 @@ public PostgreSqlBuilder WithUsername(string username) /// Sets the superuser password. public PostgreSqlBuilder WithPassword(string password) { - this.password = Secret.From(password); + _password = Secret.From(password); WithEnvironment("POSTGRES_PASSWORD", password); return this; } @@ -60,25 +64,22 @@ public PostgreSqlBuilder WithPassword(string password) /// protected override PostgreSqlConfiguration BuildConfiguration() { - PostgreSqlConfiguration configuration = base.BuildConfiguration(); - Dictionary environment = new Dictionary( - configuration.Environment, - StringComparer.Ordinal - ) + var configuration = base.BuildConfiguration(); + Dictionary environment = new(configuration.Environment, StringComparer.Ordinal) { - ["POSTGRES_DB"] = database, - ["POSTGRES_USER"] = username, - ["POSTGRES_PASSWORD"] = password.Value, + ["POSTGRES_DB"] = _database, + ["POSTGRES_USER"] = _username, + ["POSTGRES_PASSWORD"] = _password.Value, }; - IReadOnlyList waitStrategies = + var waitStrategies = configuration.WaitStrategies.Count > 0 ? configuration.WaitStrategies - : new[] { (IWaitStrategy)Wait.ForCommand("pg_isready", "-U", username, "-d", database) }; + : new[] { (IWaitStrategy)Wait.ForCommand("pg_isready", "-U", _username, "-d", _database) }; return configuration with { - Database = database, - Username = username, - Password = password, + Database = _database, + Username = _username, + Password = _password, Environment = environment, WaitStrategies = waitStrategies, }; @@ -88,17 +89,17 @@ protected override PostgreSqlConfiguration BuildConfiguration() protected override void Validate(ContainerConfiguration configuration) { base.Validate(configuration); - if (string.IsNullOrWhiteSpace(database)) + if (string.IsNullOrWhiteSpace(_database)) { throw new WslContainerConfigurationException("PostgreSQL database cannot be empty."); } - if (string.IsNullOrWhiteSpace(username)) + if (string.IsNullOrWhiteSpace(_username)) { throw new WslContainerConfigurationException("PostgreSQL username cannot be empty."); } - if (string.IsNullOrEmpty(password.Value)) + if (string.IsNullOrEmpty(_password.Value)) { throw new WslContainerConfigurationException("PostgreSQL password cannot be empty."); } diff --git a/src/WslContainers.PostgreSql/PostgreSqlConfiguration.cs b/src/src/PostgreSql/PostgreSqlConfiguration.cs similarity index 93% rename from src/WslContainers.PostgreSql/PostgreSqlConfiguration.cs rename to src/src/PostgreSql/PostgreSqlConfiguration.cs index a2f6302..a24f585 100644 --- a/src/WslContainers.PostgreSql/PostgreSqlConfiguration.cs +++ b/src/src/PostgreSql/PostgreSqlConfiguration.cs @@ -1,3 +1,5 @@ +using Purview.WslContainers.Diagnostics; + namespace Purview.WslContainers.PostgreSql; /// Immutable configuration for a PostgreSQL test container. diff --git a/src/WslContainers.PostgreSql/PostgreSqlContainer.cs b/src/src/PostgreSql/PostgreSqlContainer.cs similarity index 68% rename from src/WslContainers.PostgreSql/PostgreSqlContainer.cs rename to src/src/PostgreSql/PostgreSqlContainer.cs index 95f3ca5..77a7a0d 100644 --- a/src/WslContainers.PostgreSql/PostgreSqlContainer.cs +++ b/src/src/PostgreSql/PostgreSqlContainer.cs @@ -5,24 +5,24 @@ namespace Purview.WslContainers.PostgreSql; /// A throwaway PostgreSQL database running on WSL Containers. public sealed class PostgreSqlContainer : WslContainer { - private readonly PostgreSqlConfiguration configuration; + readonly PostgreSqlConfiguration _configuration; internal PostgreSqlContainer(PostgreSqlConfiguration configuration, IContainerRuntime runtime) : base(configuration, runtime) { - this.configuration = configuration; + _configuration = configuration; } /// Connection string pointing at the mapped host port. Safe to call after . public string GetConnectionString() { - NpgsqlConnectionStringBuilder builder = new NpgsqlConnectionStringBuilder + NpgsqlConnectionStringBuilder builder = new() { Host = "localhost", Port = GetMappedPublicPort(PostgreSqlBuilder.PostgreSqlPort), - Database = configuration.Database, - Username = configuration.Username, - Password = configuration.Password.Value, + Database = _configuration.Database, + Username = _configuration.Username, + Password = _configuration.Password.Value, }; return builder.ConnectionString; } diff --git a/src/src/PostgreSql/Sdk/README.md b/src/src/PostgreSql/Sdk/README.md new file mode 100644 index 0000000..6b58cb2 --- /dev/null +++ b/src/src/PostgreSql/Sdk/README.md @@ -0,0 +1,49 @@ +# Purview.WslContainers.PostgreSql + +Throwaway PostgreSQL databases for .NET integration testing, running as WSLC containers on +**Microsoft WSL Containers** — no Docker installation. + +```bash +dotnet add package Purview.WslContainers.PostgreSql +``` + +Depends on `Purview.WslContainers` (the core runtime) and brings `Npgsql` for connection-string generation. +See the [Getting Started guide](https://github.com/purview-dev/wsl-testcontainers/blob/main/docs/wiki/Getting-Started.md). + +## Quick start + +```csharp +using Npgsql; +using Purview.WslContainers.PostgreSql; + +await using var postgres = new PostgreSqlBuilder() + .WithDatabase("tests") + .WithUsername("postgres") + .WithPassword("postgres") + .Build(); + +await postgres.StartAsync(); // waits for pg_isready + +await using var connection = new NpgsqlConnection(postgres.GetConnectionString()); +await connection.OpenAsync(); +``` + +## API + +| Member | Purpose | +| -- | -- | +| `PostgreSqlBuilder()` / `PostgreSqlBuilder(string image)` | Default image `postgres:17`, or a custom image. | +| `PostgreSqlBuilder.PostgreSqlPort` (5432) | Container port, mapped to a random host port. | +| `WithDatabase(string)` | Sets `POSTGRES_DB` (default `postgres`). | +| `WithUsername(string)` | Sets `POSTGRES_USER` (default `postgres`). | +| `WithPassword(string)` | Sets `POSTGRES_PASSWORD` (default `postgres`); stored as a redacted `Secret`. | +| `PostgreSqlContainer.GetConnectionString()` | `NpgsqlConnectionStringBuilder` connection string for the mapped host port. | + +`Build()` rejects an empty database, username or password with `WslContainerConfigurationException`. The +default wait strategy runs `pg_isready -U {username} -d {database}` inside the container unless you supply +your own with `WithWaitStrategy(...)`. Call `GetConnectionString()` after `StartAsync()`. + +## Documentation + +- [Modules](https://github.com/purview-dev/wsl-testcontainers/blob/main/docs/wiki/Modules.md) — the module contract and readiness choices. +- [Wait Strategies](https://github.com/purview-dev/wsl-testcontainers/blob/main/docs/wiki/Wait-Strategies.md) — overriding readiness checks. diff --git a/src/WslContainers.RabbitMq/WslContainers.RabbitMq.csproj b/src/src/RabbitMq/RabbitMq.csproj similarity index 100% rename from src/WslContainers.RabbitMq/WslContainers.RabbitMq.csproj rename to src/src/RabbitMq/RabbitMq.csproj diff --git a/src/WslContainers.RabbitMq/RabbitMqBuilder.cs b/src/src/RabbitMq/RabbitMqBuilder.cs similarity index 78% rename from src/WslContainers.RabbitMq/RabbitMqBuilder.cs rename to src/src/RabbitMq/RabbitMqBuilder.cs index 8f31fd3..8d257aa 100644 --- a/src/WslContainers.RabbitMq/RabbitMqBuilder.cs +++ b/src/src/RabbitMq/RabbitMqBuilder.cs @@ -1,3 +1,7 @@ +using Purview.WslContainers.Diagnostics; +using Purview.WslContainers.Runtime; +using Purview.WslContainers.Waiting; + namespace Purview.WslContainers.RabbitMq; /// Fluent builder for a RabbitMQ test container. @@ -12,9 +16,9 @@ public class RabbitMqBuilder : ContainerBuilderDefault image (includes the management plugin). public const string RabbitMqImage = "rabbitmq:3-management"; - private string username = "guest"; - private Secret password = Secret.From("guest"); - private string virtualHost = "/"; + string _username = "guest"; + Secret _password = Secret.From("guest"); + string _virtualHost = "/"; /// Creates a builder with the default image. public RabbitMqBuilder() @@ -40,7 +44,7 @@ public RabbitMqBuilder(IContainerRuntime runtime) /// Sets the AMQP username. public RabbitMqBuilder WithUsername(string username) { - this.username = username; + _username = username; WithEnvironment("RABBITMQ_DEFAULT_USER", username); return this; } @@ -48,7 +52,7 @@ public RabbitMqBuilder WithUsername(string username) /// Sets the AMQP password. public RabbitMqBuilder WithPassword(string password) { - this.password = Secret.From(password); + _password = Secret.From(password); WithEnvironment("RABBITMQ_DEFAULT_PASS", password); return this; } @@ -56,7 +60,7 @@ public RabbitMqBuilder WithPassword(string password) /// Sets the default virtual host. public RabbitMqBuilder WithVirtualHost(string virtualHost) { - this.virtualHost = virtualHost; + _virtualHost = virtualHost; WithEnvironment("RABBITMQ_DEFAULT_VHOST", virtualHost); return this; } @@ -67,25 +71,22 @@ public RabbitMqBuilder WithVirtualHost(string virtualHost) /// protected override RabbitMqConfiguration BuildConfiguration() { - RabbitMqConfiguration configuration = base.BuildConfiguration(); - Dictionary environment = new Dictionary( - configuration.Environment, - StringComparer.Ordinal - ) + var configuration = base.BuildConfiguration(); + Dictionary environment = new(configuration.Environment, StringComparer.Ordinal) { - ["RABBITMQ_DEFAULT_USER"] = username, - ["RABBITMQ_DEFAULT_PASS"] = password.Value, - ["RABBITMQ_DEFAULT_VHOST"] = virtualHost, + ["RABBITMQ_DEFAULT_USER"] = _username, + ["RABBITMQ_DEFAULT_PASS"] = _password.Value, + ["RABBITMQ_DEFAULT_VHOST"] = _virtualHost, }; - IReadOnlyList waitStrategies = + var waitStrategies = configuration.WaitStrategies.Count > 0 ? configuration.WaitStrategies : new[] { (IWaitStrategy)Wait.ForLogMessage("Server startup complete") }; return configuration with { - Username = username, - Password = password, - VirtualHost = virtualHost, + Username = _username, + Password = _password, + VirtualHost = _virtualHost, Environment = environment, WaitStrategies = waitStrategies, }; @@ -95,17 +96,17 @@ protected override RabbitMqConfiguration BuildConfiguration() protected override void Validate(ContainerConfiguration configuration) { base.Validate(configuration); - if (string.IsNullOrWhiteSpace(username)) + if (string.IsNullOrWhiteSpace(_username)) { throw new WslContainerConfigurationException("RabbitMQ username cannot be empty."); } - if (string.IsNullOrEmpty(password.Value)) + if (string.IsNullOrEmpty(_password.Value)) { throw new WslContainerConfigurationException("RabbitMQ password cannot be empty."); } - if (string.IsNullOrEmpty(virtualHost)) + if (string.IsNullOrEmpty(_virtualHost)) { throw new WslContainerConfigurationException("RabbitMQ virtual host cannot be empty."); } diff --git a/src/WslContainers.RabbitMq/RabbitMqConfiguration.cs b/src/src/RabbitMq/RabbitMqConfiguration.cs similarity index 93% rename from src/WslContainers.RabbitMq/RabbitMqConfiguration.cs rename to src/src/RabbitMq/RabbitMqConfiguration.cs index 5a29445..7df0cf8 100644 --- a/src/WslContainers.RabbitMq/RabbitMqConfiguration.cs +++ b/src/src/RabbitMq/RabbitMqConfiguration.cs @@ -1,3 +1,5 @@ +using Purview.WslContainers.Diagnostics; + namespace Purview.WslContainers.RabbitMq; /// Immutable configuration for a RabbitMQ test container. diff --git a/src/WslContainers.RabbitMq/RabbitMqContainer.cs b/src/src/RabbitMq/RabbitMqContainer.cs similarity index 76% rename from src/WslContainers.RabbitMq/RabbitMqContainer.cs rename to src/src/RabbitMq/RabbitMqContainer.cs index c1417de..78b8283 100644 --- a/src/WslContainers.RabbitMq/RabbitMqContainer.cs +++ b/src/src/RabbitMq/RabbitMqContainer.cs @@ -3,12 +3,12 @@ namespace Purview.WslContainers.RabbitMq; /// A throwaway RabbitMQ broker running on WSL Containers. public sealed class RabbitMqContainer : WslContainer { - private readonly RabbitMqConfiguration configuration; + readonly RabbitMqConfiguration _configuration; internal RabbitMqContainer(RabbitMqConfiguration configuration, IContainerRuntime runtime) : base(configuration, runtime) { - this.configuration = configuration; + _configuration = configuration; } /// @@ -23,15 +23,15 @@ public string GetConnectionString() /// AMQP endpoint (RabbitMQ.Client ConnectionFactory.Uri). public Uri GetAmqpEndpoint() { - UriBuilder builder = new UriBuilder + UriBuilder builder = new() { Scheme = "amqp", Host = "localhost", Port = GetMappedPublicPort(RabbitMqBuilder.AmqpPort), - UserName = configuration.Username, - Password = configuration.Password.Value, + UserName = _configuration.Username, + Password = _configuration.Password.Value, + Path = _configuration.VirtualHost is "/" or "" ? "/" : "/" + _configuration.VirtualHost.TrimStart('/'), }; - builder.Path = configuration.VirtualHost is "/" or "" ? "/" : "/" + configuration.VirtualHost.TrimStart('/'); return builder.Uri; } diff --git a/src/src/RabbitMq/Sdk/README.md b/src/src/RabbitMq/Sdk/README.md new file mode 100644 index 0000000..ab497ed --- /dev/null +++ b/src/src/RabbitMq/Sdk/README.md @@ -0,0 +1,56 @@ +# Purview.WslContainers.RabbitMq + +Throwaway RabbitMQ brokers for .NET integration testing, running as WSLC containers on +**Microsoft WSL Containers** — no Docker installation. + +```bash +dotnet add package Purview.WslContainers.RabbitMq +``` + +Depends on `Purview.WslContainers` (the core runtime). `RabbitMQ.Client` is not referenced by this package — +bring your own client. + +## Quick start + +```csharp +using Purview.WslContainers.RabbitMq; +using RabbitMQ.Client; + +await using var rabbitMq = new RabbitMqBuilder() + .WithUsername("guest") + .WithPassword("guest") + .Build(); + +await rabbitMq.StartAsync(); // waits for 'Server startup complete' + +var factory = new ConnectionFactory { Uri = rabbitMq.GetAmqpEndpoint() }; +await using var connection = await factory.CreateConnectionAsync(); +``` + +## API + +| Member | Purpose | +| -- | -- | +| `RabbitMqBuilder()` / `RabbitMqBuilder(string image)` | Default image `rabbitmq:3-management` (management plugin included), or a custom image. | +| `RabbitMqBuilder.AmqpPort` (5672), `ManagementPort` (15672) | Container ports, both mapped to random host ports. | +| `WithUsername(string)` | Sets `RABBITMQ_DEFAULT_USER` (default `guest`). | +| `WithPassword(string)` | Sets `RABBITMQ_DEFAULT_PASS` (default `guest`); stored as a redacted `Secret`. | +| `WithVirtualHost(string)` | Sets `RABBITMQ_DEFAULT_VHOST` (default `/`). | +| `RabbitMqContainer.GetAmqpEndpoint()` | `amqp://user:pass@localhost:{port}/{vhost}` as a `Uri` for `ConnectionFactory.Uri`. | +| `RabbitMqContainer.GetConnectionString()` | The same AMQP endpoint as a string. | +| `RabbitMqContainer.GetManagementEndpoint()` | Management web UI endpoint (`http://localhost:{mappedPort}`). | + +`Build()` rejects an empty username, password or virtual host with `WslContainerConfigurationException`. +Call the endpoint accessors after `StartAsync()`. + +## Readiness + +The default wait strategy matches the canonical `Server startup complete` log line rather than running +`rabbitmq-diagnostics ping`: under WSLC an exec-based probe races the Erlang cookie setup and can trigger a +startup failure (`eacces` reading `.erlang.cookie`). See +[Modules](https://github.com/purview-dev/wsl-testcontainers/blob/main/docs/wiki/Modules.md) for the full note. + +## Documentation + +- [Modules](https://github.com/purview-dev/wsl-testcontainers/blob/main/docs/wiki/Modules.md) — the module contract and readiness choices. +- [Wait Strategies](https://github.com/purview-dev/wsl-testcontainers/blob/main/docs/wiki/Wait-Strategies.md) — overriding readiness checks. diff --git a/src/WslContainers.Redis/WslContainers.Redis.csproj b/src/src/Redis/Redis.csproj similarity index 100% rename from src/WslContainers.Redis/WslContainers.Redis.csproj rename to src/src/Redis/Redis.csproj diff --git a/src/WslContainers.Redis/RedisBuilder.cs b/src/src/Redis/RedisBuilder.cs similarity index 93% rename from src/WslContainers.Redis/RedisBuilder.cs rename to src/src/Redis/RedisBuilder.cs index c374551..3260466 100644 --- a/src/WslContainers.Redis/RedisBuilder.cs +++ b/src/src/Redis/RedisBuilder.cs @@ -1,3 +1,5 @@ +using Purview.WslContainers.Waiting; + namespace Purview.WslContainers.Redis; /// @@ -35,8 +37,8 @@ public RedisBuilder(IContainerRuntime runtime) /// protected override RedisConfiguration BuildConfiguration() { - RedisConfiguration configuration = base.BuildConfiguration(); - IReadOnlyList waitStrategies = + var configuration = base.BuildConfiguration(); + var waitStrategies = configuration.WaitStrategies.Count > 0 ? configuration.WaitStrategies : new[] { (IWaitStrategy)Wait.ForCommand("redis-cli", "ping") }; diff --git a/src/WslContainers.Redis/RedisConfiguration.cs b/src/src/Redis/RedisConfiguration.cs similarity index 100% rename from src/WslContainers.Redis/RedisConfiguration.cs rename to src/src/Redis/RedisConfiguration.cs diff --git a/src/WslContainers.Redis/RedisContainer.cs b/src/src/Redis/RedisContainer.cs similarity index 100% rename from src/WslContainers.Redis/RedisContainer.cs rename to src/src/Redis/RedisContainer.cs diff --git a/src/src/Redis/Sdk/README.md b/src/src/Redis/Sdk/README.md new file mode 100644 index 0000000..05a9305 --- /dev/null +++ b/src/src/Redis/Sdk/README.md @@ -0,0 +1,51 @@ +# Purview.WslContainers.Redis + +Throwaway Redis-compatible instances for .NET integration testing, running as WSLC containers on +**Microsoft WSL Containers** — no Docker installation. + +```bash +dotnet add package Purview.WslContainers.Redis +``` + +Depends on `Purview.WslContainers` (the core runtime). `StackExchange.Redis` is not referenced by this +package — bring your own client. + +## Quick start + +```csharp +using Purview.WslContainers.Redis; +using StackExchange.Redis; + +await using var redis = new RedisBuilder().Build(); + +await redis.StartAsync(); // waits for redis-cli ping + +using var connection = await ConnectionMultiplexer.ConnectAsync(redis.GetConnectionString()); +await connection.GetDatabase().PingAsync(); +``` + +## API + +| Member | Purpose | +| -- | -- | +| `RedisBuilder()` / `RedisBuilder(string image)` | Default image `redis:7`, or a custom image. | +| `RedisBuilder.RedisPort` (6379) | Container port, mapped to a random host port. | +| `RedisContainer.GetConnectionString()` | `localhost:{mappedPort}` for `ConnectionMultiplexer.ConnectAsync`. | + +The default wait strategy runs `redis-cli ping` inside the container unless you supply your own with +`WithWaitStrategy(...)`. Call `GetConnectionString()` after `StartAsync()`. + +## Redis-compatible images + +The module is a thin Redis client-neutral wrapper, so other Redis-compatible servers work through the image +constructor: + +```csharp +await using var valkey = new RedisBuilder("valkey/valkey:7").Build(); +await using var garnet = new RedisBuilder("ghcr.io/microsoft/garnet:latest").Build(); +``` + +## Documentation + +- [Modules](https://github.com/purview-dev/wsl-testcontainers/blob/main/docs/wiki/Modules.md) — the module contract and readiness choices. +- [Wait Strategies](https://github.com/purview-dev/wsl-testcontainers/blob/main/docs/wiki/Wait-Strategies.md) — overriding readiness checks. diff --git a/src/WslContainers/ContainerBuilder.cs b/src/src/WslContainers/ContainerBuilder.cs similarity index 88% rename from src/WslContainers/ContainerBuilder.cs rename to src/src/WslContainers/ContainerBuilder.cs index 376e145..fc83484 100644 --- a/src/WslContainers/ContainerBuilder.cs +++ b/src/src/WslContainers/ContainerBuilder.cs @@ -1,4 +1,8 @@ using Purview.WslContainers.Images; +using Purview.WslContainers.Mounts; +using Purview.WslContainers.Networking; +using Purview.WslContainers.Runtime; +using Purview.WslContainers.Waiting; namespace Purview.WslContainers; @@ -7,34 +11,34 @@ namespace Purview.WslContainers; /// Builder instances accumulate configuration; produces an immutable configuration /// and a container bound to a runtime. /// +[System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1005:Avoid excessive parameters on generic types")] public abstract class ContainerBuilder where TBuilder : ContainerBuilder where TContainer : WslContainer where TConfiguration : ContainerConfiguration, new() { - private string? _image; - private Image? _parsedImage; - private string? _name; - private string? _hostname; - private string? _domainName; - private readonly Dictionary _environment = new(StringComparer.Ordinal); - private readonly List _command = new(); - private string? _workingDirectory; - private readonly List _portBindings = new(); - private readonly List _bindMounts = new(); - private readonly List _namedVolumes = new(); - private ContainerNetworkingMode _networkingMode = ContainerNetworkingMode.Bridged; - private bool _privileged; - private bool _enableGpu; - private bool _enableAutoRemove; - private PullPolicy _pullPolicy = PullPolicy.Missing; - private TimeSpan _startupTimeout = TimeSpan.FromMinutes(5); - private readonly List _waitStrategies = new(); - private RegistryCredentials? _registryCredentials; - private IContainerRuntime? _runtime; + string? _image; + Image? _parsedImage; + string? _name; + string? _hostname; + string? _domainName; + readonly Dictionary _environment = [with(StringComparer.Ordinal)]; + readonly List _command = []; + string? _workingDirectory; + readonly List _portBindings = []; + readonly List _bindMounts = []; + readonly List _namedVolumes = []; + ContainerNetworkingMode _networkingMode = ContainerNetworkingMode.Bridged; + bool _privileged; + bool _enableGPU; + bool _enableAutoRemove; + PullPolicy _pullPolicy = PullPolicy.Missing; + TimeSpan _startupTimeout = TimeSpan.FromMinutes(5); + readonly List _waitStrategies = []; + RegistryCredentials? _registryCredentials; /// The runtime the built container will use. Defaults to the process-wide . - protected IContainerRuntime? Runtime => _runtime; + protected IContainerRuntime? Runtime { get; private set; } /// Creates a builder using the default process-wide runtime. protected ContainerBuilder() { } @@ -43,7 +47,7 @@ protected ContainerBuilder() { } protected ContainerBuilder(IContainerRuntime runtime) { ArgumentNullException.ThrowIfNull(runtime); - _runtime = runtime; + Runtime = runtime; } /// Sets the container image. Invalid references fail fast (at configuration time). @@ -123,7 +127,7 @@ public TBuilder WithEnvironment(string key, string value) public TBuilder WithEnvironment(IReadOnlyDictionary environment) { ArgumentNullException.ThrowIfNull(environment); - foreach ((string key, string value) in environment) + foreach ((var key, var value) in environment) { _environment[key] = value; } @@ -215,9 +219,9 @@ public TBuilder WithPrivileged(bool privileged = true) } /// Exposes GPU devices to the container. - public TBuilder WithGpu(bool enable = true) + public TBuilder WithGPU(bool enable = true) { - _enableGpu = enable; + _enableGPU = enable; return (TBuilder)this; } @@ -273,14 +277,14 @@ public TBuilder WithRegistryCredentials(Uri serverAddress, string username, stri public TBuilder WithRuntime(IContainerRuntime runtime) { ArgumentNullException.ThrowIfNull(runtime); - _runtime = runtime; + Runtime = runtime; return (TBuilder)this; } /// Validates the configuration and builds the container. public virtual TContainer Build() { - TConfiguration configuration = BuildConfiguration(); + var configuration = BuildConfiguration(); Validate(configuration); return CreateContainer(configuration); } @@ -306,7 +310,7 @@ protected virtual TConfiguration BuildConfiguration() NamedVolumes = _namedVolumes, NetworkingMode = _networkingMode, Privileged = _privileged, - EnableGpu = _enableGpu, + EnableGPU = _enableGPU, EnableAutoRemove = _enableAutoRemove, PullPolicy = _pullPolicy, StartupTimeout = _startupTimeout, @@ -332,7 +336,7 @@ protected virtual void Validate(ContainerConfiguration configuration) ); } - foreach (PortBinding portBinding in configuration.PortBindings) + foreach (var portBinding in configuration.PortBindings) { if (portBinding.Protocol == PortProtocol.Udp) { @@ -352,7 +356,7 @@ protected virtual void Validate(ContainerConfiguration configuration) } } - foreach (BindMount bindMount in configuration.BindMounts) + foreach (var bindMount in configuration.BindMounts) { if (string.IsNullOrWhiteSpace(bindMount.HostPath) || string.IsNullOrWhiteSpace(bindMount.ContainerPath)) { @@ -360,7 +364,7 @@ protected virtual void Validate(ContainerConfiguration configuration) } } - foreach (NamedVolume namedVolume in configuration.NamedVolumes) + foreach (var namedVolume in configuration.NamedVolumes) { if (string.IsNullOrWhiteSpace(namedVolume.Name) || string.IsNullOrWhiteSpace(namedVolume.ContainerPath)) { @@ -388,7 +392,7 @@ public ContainerBuilder(IContainerRuntime runtime) /// protected override WslContainer CreateContainer(ContainerConfiguration configuration) { - IContainerRuntime runtime = Runtime ?? WslContainerRuntime.Instance; + var runtime = Runtime ?? WslContainerRuntime.Instance; return new WslContainer(configuration, runtime); } } diff --git a/src/WslContainers/ContainerConfiguration.cs b/src/src/WslContainers/ContainerConfiguration.cs similarity index 91% rename from src/WslContainers/ContainerConfiguration.cs rename to src/src/WslContainers/ContainerConfiguration.cs index de08360..020b91b 100644 --- a/src/WslContainers/ContainerConfiguration.cs +++ b/src/src/WslContainers/ContainerConfiguration.cs @@ -1,4 +1,9 @@ using System.Text; +using Purview.WslContainers.Diagnostics; +using Purview.WslContainers.Images; +using Purview.WslContainers.Mounts; +using Purview.WslContainers.Networking; +using Purview.WslContainers.Waiting; namespace Purview.WslContainers; @@ -43,7 +48,7 @@ public record ContainerConfiguration : IContainerConfiguration public bool Privileged { get; init; } /// - public bool EnableGpu { get; init; } + public bool EnableGPU { get; init; } /// public bool EnableAutoRemove { get; init; } @@ -84,7 +89,7 @@ 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(EnableGpu)).Append(" = ").Append(EnableGpu); + builder.Append(", ").Append(nameof(EnableGPU)).Append(" = ").Append(EnableGPU); builder.Append(", ").Append(nameof(EnableAutoRemove)).Append(" = ").Append(EnableAutoRemove); builder.Append(", ").Append(nameof(PullPolicy)).Append(" = ").Append(PullPolicy); builder.Append(", ").Append(nameof(StartupTimeout)).Append(" = ").Append(StartupTimeout); diff --git a/src/WslContainers/Containers/ContainerLogEntry.cs b/src/src/WslContainers/Containers/ContainerLogEntry.cs similarity index 79% rename from src/WslContainers/Containers/ContainerLogEntry.cs rename to src/src/WslContainers/Containers/ContainerLogEntry.cs index 95fc0f7..7f8e6c1 100644 --- a/src/WslContainers/Containers/ContainerLogEntry.cs +++ b/src/src/WslContainers/Containers/ContainerLogEntry.cs @@ -1,4 +1,4 @@ -namespace Purview.WslContainers; +namespace Purview.WslContainers.Containers; /// A single line of container init-process output. public sealed record ContainerLogEntry(DateTimeOffset Timestamp, LogOutput Stream, string Text); diff --git a/src/WslContainers/Containers/ContainerState.cs b/src/src/WslContainers/Containers/ContainerState.cs similarity index 91% rename from src/WslContainers/Containers/ContainerState.cs rename to src/src/WslContainers/Containers/ContainerState.cs index 4b8c099..09df5b4 100644 --- a/src/WslContainers/Containers/ContainerState.cs +++ b/src/src/WslContainers/Containers/ContainerState.cs @@ -1,4 +1,4 @@ -namespace Purview.WslContainers; +namespace Purview.WslContainers.Containers; /// Lifecycle state of a WSLC container. public enum ContainerState diff --git a/src/WslContainers/Containers/ExecOptions.cs b/src/src/WslContainers/Containers/ExecOptions.cs similarity index 94% rename from src/WslContainers/Containers/ExecOptions.cs rename to src/src/WslContainers/Containers/ExecOptions.cs index 8ff1428..ccf2980 100644 --- a/src/WslContainers/Containers/ExecOptions.cs +++ b/src/src/WslContainers/Containers/ExecOptions.cs @@ -1,4 +1,4 @@ -namespace Purview.WslContainers; +namespace Purview.WslContainers.Containers; /// Options for . public sealed record ExecOptions diff --git a/src/WslContainers/Containers/ExecResult.cs b/src/src/WslContainers/Containers/ExecResult.cs similarity index 85% rename from src/WslContainers/Containers/ExecResult.cs rename to src/src/WslContainers/Containers/ExecResult.cs index 038f076..4ef5489 100644 --- a/src/WslContainers/Containers/ExecResult.cs +++ b/src/src/WslContainers/Containers/ExecResult.cs @@ -1,4 +1,4 @@ -namespace Purview.WslContainers; +namespace Purview.WslContainers.Containers; /// Result of executing a command inside a container. public sealed record ExecResult(long ExitCode, string Stdout, string Stderr) diff --git a/src/WslContainers/Containers/LogStream.cs b/src/src/WslContainers/Containers/LogStream.cs similarity index 82% rename from src/WslContainers/Containers/LogStream.cs rename to src/src/WslContainers/Containers/LogStream.cs index fef2d2f..27e7268 100644 --- a/src/WslContainers/Containers/LogStream.cs +++ b/src/src/WslContainers/Containers/LogStream.cs @@ -1,4 +1,4 @@ -namespace Purview.WslContainers; +namespace Purview.WslContainers.Containers; /// Standard output stream of a container or exec process. public enum LogOutput diff --git a/src/WslContainers/Diagnostics/Secret.cs b/src/src/WslContainers/Diagnostics/Secret.cs similarity index 78% rename from src/WslContainers/Diagnostics/Secret.cs rename to src/src/WslContainers/Diagnostics/Secret.cs index 4082587..bc8ee2c 100644 --- a/src/WslContainers/Diagnostics/Secret.cs +++ b/src/src/WslContainers/Diagnostics/Secret.cs @@ -1,4 +1,4 @@ -namespace Purview.WslContainers; +namespace Purview.WslContainers.Diagnostics; /// /// Wraps a sensitive value so it is never printed by default diagnostics. @@ -6,11 +6,9 @@ namespace Purview.WslContainers; /// public readonly record struct Secret { - private readonly string _value; - - private Secret(string value) + Secret(string value) { - _value = value; + Value = value; } /// Creates a secret from a plain value. @@ -21,7 +19,7 @@ public static Secret From(string value) } /// The underlying value. Handle with care. - public string Value => _value; + public string Value { get; } /// public override string ToString() => ""; diff --git a/src/WslContainers/Diagnostics/SecretRedactor.cs b/src/src/WslContainers/Diagnostics/SecretRedactor.cs similarity index 58% rename from src/WslContainers/Diagnostics/SecretRedactor.cs rename to src/src/WslContainers/Diagnostics/SecretRedactor.cs index b5ebc03..08e7e66 100644 --- a/src/WslContainers/Diagnostics/SecretRedactor.cs +++ b/src/src/WslContainers/Diagnostics/SecretRedactor.cs @@ -1,18 +1,9 @@ -namespace Purview.WslContainers; +namespace Purview.WslContainers.Diagnostics; /// Redacts sensitive values from diagnostics and configuration rendering. -internal static class SecretRedactor +static class SecretRedactor { - private static readonly string[] SensitiveKeyMarkers = - { - "PASSWORD", - "PASS", - "TOKEN", - "SECRET", - "PWD", - "CREDENTIAL", - "KEY", - }; + static readonly string[] SensitiveKeyMarkers = ["PASSWORD", "PASS", "TOKEN", "SECRET", "PWD", "CREDENTIAL", "KEY"]; public static string RedactEnvironment(IReadOnlyDictionary environment) { @@ -21,15 +12,15 @@ public static string RedactEnvironment(IReadOnlyDictionary envir return "{}"; } - IEnumerable parts = environment.Select(pair => + var parts = environment.Select(pair => IsSensitive(pair.Key) ? $"{pair.Key} = " : $"{pair.Key} = {pair.Value}" ); return "{ " + string.Join(", ", parts) + " }"; } - private static bool IsSensitive(string key) + static bool IsSensitive(string key) { - foreach (string marker in SensitiveKeyMarkers) + foreach (var marker in SensitiveKeyMarkers) { if (key.Contains(marker, StringComparison.OrdinalIgnoreCase)) { diff --git a/src/WslContainers/Diagnostics/WslContainersActivity.cs b/src/src/WslContainers/Diagnostics/WslContainersActivity.cs similarity index 61% rename from src/WslContainers/Diagnostics/WslContainersActivity.cs rename to src/src/WslContainers/Diagnostics/WslContainersActivity.cs index c49cf31..66deb2e 100644 --- a/src/WslContainers/Diagnostics/WslContainersActivity.cs +++ b/src/src/WslContainers/Diagnostics/WslContainersActivity.cs @@ -1,10 +1,10 @@ using System.Diagnostics; -namespace Purview.WslContainers; +namespace Purview.WslContainers.Diagnostics; /// Activity source for WSL Containers operations. Listener names: Purview.WslContainers. -internal static class WslContainersActivity +static class WslContainersActivity { /// Activities: session.start, image.pull, container.create/start/stop/delete, exec.create, wait. - public static readonly ActivitySource Source = new ActivitySource("Purview.WslContainers"); + public static readonly ActivitySource Source = new("Purview.WslContainers"); } diff --git a/src/WslContainers/IContainer.cs b/src/src/WslContainers/IContainer.cs similarity index 97% rename from src/WslContainers/IContainer.cs rename to src/src/WslContainers/IContainer.cs index 3161e72..2db4bbc 100644 --- a/src/WslContainers/IContainer.cs +++ b/src/src/WslContainers/IContainer.cs @@ -1,3 +1,5 @@ +using Purview.WslContainers.Containers; + namespace Purview.WslContainers; /// A throwaway Linux container running on WSL Containers. diff --git a/src/WslContainers/IContainerBuilder.cs b/src/src/WslContainers/IContainerBuilder.cs similarity index 100% rename from src/WslContainers/IContainerBuilder.cs rename to src/src/WslContainers/IContainerBuilder.cs diff --git a/src/WslContainers/IContainerConfiguration.cs b/src/src/WslContainers/IContainerConfiguration.cs similarity index 93% rename from src/WslContainers/IContainerConfiguration.cs rename to src/src/WslContainers/IContainerConfiguration.cs index 1e6d9c3..b9d5631 100644 --- a/src/WslContainers/IContainerConfiguration.cs +++ b/src/src/WslContainers/IContainerConfiguration.cs @@ -1,4 +1,7 @@ using Purview.WslContainers.Images; +using Purview.WslContainers.Mounts; +using Purview.WslContainers.Networking; +using Purview.WslContainers.Waiting; namespace Purview.WslContainers; @@ -42,7 +45,7 @@ public interface IContainerConfiguration bool Privileged { get; } /// Expose GPU devices to the container. - bool EnableGpu { get; } + bool EnableGPU { get; } /// Automatically remove the container when it stops. bool EnableAutoRemove { get; } diff --git a/src/WslContainers/IContainerRuntime.cs b/src/src/WslContainers/IContainerRuntime.cs similarity index 100% rename from src/WslContainers/IContainerRuntime.cs rename to src/src/WslContainers/IContainerRuntime.cs diff --git a/src/WslContainers/IContainerSession.cs b/src/src/WslContainers/IContainerSession.cs similarity index 100% rename from src/WslContainers/IContainerSession.cs rename to src/src/WslContainers/IContainerSession.cs diff --git a/src/WslContainers/Images/Image.cs b/src/src/WslContainers/Images/Image.cs similarity index 84% rename from src/WslContainers/Images/Image.cs rename to src/src/WslContainers/Images/Image.cs index 5685fa2..be23cb4 100644 --- a/src/WslContainers/Images/Image.cs +++ b/src/src/WslContainers/Images/Image.cs @@ -6,10 +6,10 @@ namespace Purview.WslContainers.Images; /// public readonly record struct Image { - private const string DefaultRegistry = "docker.io"; - private const string DefaultTag = "latest"; + const string DefaultRegistry = "docker.io"; + const string DefaultTag = "latest"; - private Image(string registry, string repository, string tag) + Image(string registry, string repository, string tag) { Registry = registry; Repository = repository; @@ -44,9 +44,9 @@ public static Image Parse(string reference) throw new ArgumentException("Image reference cannot be empty.", nameof(reference)); } - string tag = DefaultTag; - int lastSlash = reference.LastIndexOf('/', StringComparison.Ordinal); - int lastColon = reference.LastIndexOf(':', StringComparison.Ordinal); + var tag = DefaultTag; + var lastSlash = reference.LastIndexOf('/', StringComparison.Ordinal); + var lastColon = reference.LastIndexOf(':', StringComparison.Ordinal); if (lastColon > lastSlash) { tag = NormalizeTag(reference[(lastColon + 1)..]); @@ -55,7 +55,7 @@ public static Image Parse(string reference) string registry; string repository; - int firstSlash = reference.IndexOf('/', StringComparison.Ordinal); + var firstSlash = reference.IndexOf('/', StringComparison.Ordinal); if (firstSlash < 0) { registry = DefaultRegistry; @@ -63,7 +63,7 @@ public static Image Parse(string reference) } else { - string firstSegment = reference[..firstSlash]; + var firstSegment = reference[..firstSlash]; if ( firstSegment.Contains('.', StringComparison.Ordinal) || firstSegment.Contains(':', StringComparison.Ordinal) @@ -80,8 +80,11 @@ public static Image Parse(string reference) } } +#pragma warning disable CA1308 // Normalize strings to uppercase registry = registry.ToLowerInvariant(); repository = repository.ToLowerInvariant(); +#pragma warning restore CA1308 // Normalize strings to uppercase + if (registry == DefaultRegistry && repository.IndexOf('/', StringComparison.Ordinal) < 0) { repository = $"library/{repository}"; @@ -110,7 +113,7 @@ public bool MatchesStoredName(string storedName) try { - Image other = Parse(storedName); + var other = Parse(storedName); return string.Equals(other.Tag, Tag, StringComparison.OrdinalIgnoreCase) && string.Equals(other.ShortName, ShortName, StringComparison.OrdinalIgnoreCase); } @@ -123,7 +126,7 @@ public bool MatchesStoredName(string storedName) /// public override string ToString() => FullReference; - private static string NormalizeTag(string tag) + static string NormalizeTag(string tag) { if (string.IsNullOrWhiteSpace(tag)) { @@ -140,10 +143,11 @@ private static string NormalizeTag(string tag) throw new ArgumentException($"Image tag '{tag}' is invalid.", nameof(tag)); } + // Tags are case-sensitive, but we normalize to lowercase for consistency with repository names. return tag; } - private static void ValidateRepository(string reference, string repository) + static void ValidateRepository(string reference, string repository) { if (repository.Length == 0) { diff --git a/src/WslContainers/Images/ImagePullProgress.cs b/src/src/WslContainers/Images/ImagePullProgress.cs similarity index 100% rename from src/WslContainers/Images/ImagePullProgress.cs rename to src/src/WslContainers/Images/ImagePullProgress.cs diff --git a/src/WslContainers/Images/ImageSummary.cs b/src/src/WslContainers/Images/ImageSummary.cs similarity index 100% rename from src/WslContainers/Images/ImageSummary.cs rename to src/src/WslContainers/Images/ImageSummary.cs diff --git a/src/WslContainers/Images/PullPolicy.cs b/src/src/WslContainers/Images/PullPolicy.cs similarity index 89% rename from src/WslContainers/Images/PullPolicy.cs rename to src/src/WslContainers/Images/PullPolicy.cs index 322de58..2c99788 100644 --- a/src/WslContainers/Images/PullPolicy.cs +++ b/src/src/WslContainers/Images/PullPolicy.cs @@ -1,4 +1,4 @@ -namespace Purview.WslContainers; +namespace Purview.WslContainers.Images; /// Controls when an image is pulled. public enum PullPolicy diff --git a/src/src/WslContainers/LogBuffer.cs b/src/src/WslContainers/LogBuffer.cs new file mode 100644 index 0000000..ded2dbd --- /dev/null +++ b/src/src/WslContainers/LogBuffer.cs @@ -0,0 +1,149 @@ +using System.Text; +using System.Threading.Channels; +using Microsoft.WSL.Containers; +using Purview.WslContainers.Containers; + +namespace Purview.WslContainers; + +/// +/// Bounded buffer for init-process output. Accumulates decoded lines for diagnostics while publishing +/// every entry to a channel so wait strategies and consumers can tail live output. +/// +sealed class LogBuffer +{ + const int MaxEntries = 4096; + readonly Lock _sync = new(); + readonly List _entries = [with(MaxEntries)]; + readonly Channel _channel = Channel.CreateBounded( + new BoundedChannelOptions(1024) + { + FullMode = BoundedChannelFullMode.DropOldest, + SingleReader = true, + SingleWriter = false, + } + ); + readonly Decoder _stdoutDecoder = Encoding.UTF8.GetDecoder(); + readonly Decoder _stderrDecoder = Encoding.UTF8.GetDecoder(); + readonly StringBuilder _stdoutPending = new(); + readonly StringBuilder _stderrPending = new(); + int _attached; + + public void Attach(Process process) + { + if (Interlocked.Exchange(ref _attached, 1) == 1) + { + return; + } + + process.OutputReceived += OnStdout; + process.ErrorReceived += OnStderr; + process.Exited += _ => + { + FlushRemaining(); + Complete(); + }; + } + + public IReadOnlyList Snapshot() + { + lock (_sync) + { + return _entries.ToArray(); + } + } + + public string GetLogs(LogOutput? stream) + { + lock (_sync) + { + var source = stream is LogOutput s ? _entries.Where(entry => entry.Stream == s) : _entries; + return string.Join('\n', source.Select(entry => entry.Text)); + } + } + + public ChannelReader Reader => _channel.Reader; + + /// + /// Completes the live stream. Call once no further output can arrive (init process exit or container + /// disposal) so consumers tailing finish instead of waiting forever. + /// + public void Complete() + { + _channel.Writer.TryComplete(); + } + + void OnStdout(byte[] data) + { + Append(LogOutput.Stdout, _stdoutDecoder, _stdoutPending, data); + } + + void OnStderr(byte[] data) + { + Append(LogOutput.Stderr, _stderrDecoder, _stderrPending, data); + } + + void Append(LogOutput stream, Decoder decoder, StringBuilder pending, byte[] data) + { + if (data.Length == 0) + { + return; + } + + var chars = new char[decoder.GetCharCount(data, 0, data.Length, flush: false)]; + var count = decoder.GetChars(data, 0, data.Length, chars, 0, flush: false); + lock (_sync) + { + pending.Append(chars, 0, count); + FlushLines(stream, pending); + } + } + + void FlushLines(LogOutput stream, StringBuilder pending) + { + var text = pending.ToString(); + pending.Clear(); + var start = 0; + for (var i = 0; i < text.Length; i++) + { + if (text[i] == '\n') + { + Add(new ContainerLogEntry(DateTimeOffset.UtcNow, stream, text[start..i].TrimEnd('\r'))); + start = i + 1; + } + } + + if (start < text.Length) + { + pending.Append(text[start..]); + } + } + + void FlushRemaining() + { + lock (_sync) + { + if (_stdoutPending.Length > 0) + { + Add(new ContainerLogEntry(DateTimeOffset.UtcNow, LogOutput.Stdout, _stdoutPending.ToString())); + _stdoutPending.Clear(); + } + + if (_stderrPending.Length > 0) + { + Add(new ContainerLogEntry(DateTimeOffset.UtcNow, LogOutput.Stderr, _stderrPending.ToString())); + _stderrPending.Clear(); + } + } + } + + void Add(ContainerLogEntry entry) + { + _entries.Add(entry); + while (_entries.Count > MaxEntries) + { + _entries.RemoveAt(0); + } + + _channel.Writer.TryWrite(entry); + } +} diff --git a/src/WslContainers/Mounts/BindMount.cs b/src/src/WslContainers/Mounts/BindMount.cs similarity index 81% rename from src/WslContainers/Mounts/BindMount.cs rename to src/src/WslContainers/Mounts/BindMount.cs index ddf19cb..30f4eda 100644 --- a/src/WslContainers/Mounts/BindMount.cs +++ b/src/src/WslContainers/Mounts/BindMount.cs @@ -1,4 +1,4 @@ -namespace Purview.WslContainers; +namespace Purview.WslContainers.Mounts; /// A bind mount of a Windows directory into the container. public sealed record BindMount(string HostPath, string ContainerPath, bool ReadOnly = false); diff --git a/src/WslContainers/Mounts/NamedVolume.cs b/src/src/WslContainers/Mounts/NamedVolume.cs similarity index 83% rename from src/WslContainers/Mounts/NamedVolume.cs rename to src/src/WslContainers/Mounts/NamedVolume.cs index 95e4905..bff93df 100644 --- a/src/WslContainers/Mounts/NamedVolume.cs +++ b/src/src/WslContainers/Mounts/NamedVolume.cs @@ -1,4 +1,4 @@ -namespace Purview.WslContainers; +namespace Purview.WslContainers.Mounts; /// A named volume (session VHD) mounted into the container. Auto-provisioned on first use. public sealed record NamedVolume(string Name, string ContainerPath, bool ReadOnly = false); diff --git a/src/WslContainers/Networking/ContainerNetworkingMode.cs b/src/src/WslContainers/Networking/ContainerNetworkingMode.cs similarity index 89% rename from src/WslContainers/Networking/ContainerNetworkingMode.cs rename to src/src/WslContainers/Networking/ContainerNetworkingMode.cs index 3b09068..d139b58 100644 --- a/src/WslContainers/Networking/ContainerNetworkingMode.cs +++ b/src/src/WslContainers/Networking/ContainerNetworkingMode.cs @@ -1,4 +1,4 @@ -namespace Purview.WslContainers; +namespace Purview.WslContainers.Networking; /// Container networking mode. WSLC only supports none and bridged. public enum ContainerNetworkingMode diff --git a/src/WslContainers/Networking/PortBinding.cs b/src/src/WslContainers/Networking/PortBinding.cs similarity index 94% rename from src/WslContainers/Networking/PortBinding.cs rename to src/src/WslContainers/Networking/PortBinding.cs index b69a130..e7dc7f4 100644 --- a/src/WslContainers/Networking/PortBinding.cs +++ b/src/src/WslContainers/Networking/PortBinding.cs @@ -1,4 +1,4 @@ -namespace Purview.WslContainers; +namespace Purview.WslContainers.Networking; /// A host-to-container port mapping. /// The port inside the container. diff --git a/src/WslContainers/Networking/PortProtocol.cs b/src/src/WslContainers/Networking/PortProtocol.cs similarity index 81% rename from src/WslContainers/Networking/PortProtocol.cs rename to src/src/WslContainers/Networking/PortProtocol.cs index a0b18b6..1d1d7aa 100644 --- a/src/WslContainers/Networking/PortProtocol.cs +++ b/src/src/WslContainers/Networking/PortProtocol.cs @@ -1,4 +1,4 @@ -namespace Purview.WslContainers; +namespace Purview.WslContainers.Networking; /// Port transport protocol. public enum PortProtocol diff --git a/src/WslContainers/RegistryCredentials.cs b/src/src/WslContainers/RegistryCredentials.cs similarity index 94% rename from src/WslContainers/RegistryCredentials.cs rename to src/src/WslContainers/RegistryCredentials.cs index d56ba69..6998c9c 100644 --- a/src/WslContainers/RegistryCredentials.cs +++ b/src/src/WslContainers/RegistryCredentials.cs @@ -1,3 +1,5 @@ +using Purview.WslContainers.Diagnostics; + namespace Purview.WslContainers; /// Credentials for authenticating to a private container registry. diff --git a/src/WslContainers/Runtime/WslContainerException.cs b/src/src/WslContainers/Runtime/WslContainerException.cs similarity index 57% rename from src/WslContainers/Runtime/WslContainerException.cs rename to src/src/WslContainers/Runtime/WslContainerException.cs index b2cffd8..344d5de 100644 --- a/src/WslContainers/Runtime/WslContainerException.cs +++ b/src/src/WslContainers/Runtime/WslContainerException.cs @@ -1,4 +1,6 @@ -namespace Purview.WslContainers; +namespace Purview.WslContainers.Runtime; + +#pragma warning disable CA1032 // Implement standard exception constructors /// Base exception for all errors raised by the WSL Containers runtime. public class WslContainerException : Exception @@ -21,36 +23,17 @@ public WslContainerConfigurationException(string message, Exception innerExcepti } /// WSL/WSL Containers prerequisites are missing or incompatible. -public class WslContainerPrerequisiteException : WslContainerException -{ - public WslContainerPrerequisiteException(string message) - : base(message) { } -} +public class WslContainerPrerequisiteException(string message) : WslContainerException(message) { } /// A requested feature is not supported by the current WSLC API. -public class WslContainerNotSupportedException : WslContainerException -{ - public WslContainerNotSupportedException(string message) - : base(message) { } -} +public class WslContainerNotSupportedException(string message) : WslContainerException(message) { } /// Starting the container failed (image, OCI runtime, or environment). -public class WslContainerStartupException : WslContainerException -{ - public WslContainerStartupException(string message, Exception innerException) - : base(message, innerException) { } -} +public class WslContainerStartupException(string message, Exception innerException) + : WslContainerException(message, innerException) { } /// An operation did not complete within its configured timeout. -public class WslContainerTimeoutException : WslContainerException -{ - public WslContainerTimeoutException(string message) - : base(message) { } -} +public class WslContainerTimeoutException(string message) : WslContainerException(message) { } /// The backing WSLC session terminated unexpectedly. -public class WslContainerSessionTerminatedException : WslContainerException -{ - public WslContainerSessionTerminatedException(string message) - : base(message) { } -} +public class WslContainerSessionTerminatedException(string message) : WslContainerException(message) { } diff --git a/src/src/WslContainers/Sdk/README.md b/src/src/WslContainers/Sdk/README.md new file mode 100644 index 0000000..5e464ba --- /dev/null +++ b/src/src/WslContainers/Sdk/README.md @@ -0,0 +1,104 @@ +# Purview.WslContainers + +WSLC-native throwaway Linux containers for .NET integration testing — a Testcontainers-style library built +directly on the `Microsoft.WSL.Containers` managed API, with **no Docker installation** and no +`wslc.exe`/`wsl.exe`/`docker` CLI, Docker.DotNet or Testcontainers dependency. + +```bash +dotnet add package Purview.WslContainers +``` + +## Requirements + +- Windows 10/11 with **WSL ≥ 2.9.3** including WSL Containers (`wsl --install --no-distribution`). +- .NET 11 SDK or later (`net11.0-windows10.0.19041.0`). +- Verify with `wsl --version` (needs 2.9.3+) and `wslc version`. The library never installs or updates WSL + itself; `WslContainerRuntime.GetInfoAsync()` reports what is missing. + +## Quick start + +```csharp +using Purview.WslContainers; +using Purview.WslContainers.Waiting; + +await using var container = new ContainerBuilder() + .WithImage("docker.io/library/redis:latest") + .WithPortBinding(6379, assignRandomHostPort: true) + .WithWaitStrategy(Wait.ForTcpPort(6379)) + .Build(); + +await container.StartAsync(); + +ushort port = container.GetMappedPublicPort(6379); +``` + +`Build()` snapshots the accumulated builder state into an immutable configuration and validates it, so +invalid images, ports and mounts fail during configuration rather than at pull time. + +## Builder surface + +| Member | Purpose | +| -- | -- | +| `WithImage(string)`, `WithImage(Image)`, `WithTag(string)` | Image reference; parsed and validated eagerly (`Image.Parse`). | +| `WithName`, `WithHostname`, `WithDomainName` | Container identity. Names are generated uniquely when unset. | +| `WithEnvironment(string, string)`, `WithEnvironment(IReadOnlyDictionary)` | Init-process environment. | +| `WithCommand(params string[])`, `WithWorkingDirectory` | Init-process argv (no shell) and working directory. | +| `WithPortBinding(ushort, bool assignRandomHostPort)`, `WithPortBinding(ushort, ushort hostPort, ...)`, `WithPortBinding(PortBinding)` | Host port mappings; random host ports use native `windowsPort=0` allocation. | +| `WithBindMount(string hostPath, string containerPath, bool readOnly = false)` | Bind-mount a Windows directory. | +| `WithVolumeMount(string name, string containerPath, bool readOnly = false)` | Named volume on the session VHD. | +| `WithNetworkMode`, `WithPrivileged`, `WithGPU`, `WithAutoRemove`, `WithPullPolicy`, `WithStartupTimeout`, `WithWaitStrategy`, `WithRegistryCredentials`, `WithRuntime` | Runtime behaviour and readiness. | + +## Container API + +`IContainer` (and `WslContainer`) exposes `StartAsync`, `StopAsync`, `ExecAsync`, `GetMappedPublicPort`, +`GetMappedPublicPorts`, `GetLogsAsync(LogOutput?)` and a tailing `IAsyncEnumerable` +`GetLogsAsync(CancellationToken)`. `Id`, `Name`, `State` and `Image` describe the running container. +`DisposeAsync` stops and deletes the container and is idempotent; it never terminates the shared session. + +## Wait strategies + +`Wait` composes readiness checks that run inside `StartAsync`: + +| Factory | Ready when | +| -- | -- | +| `Wait.ForContainerRunning()` | The init process is running (started, not necessarily a ready service). | +| `Wait.ForTcpPort(port)` | A TCP connection to the mapped host port succeeds. | +| `Wait.ForHttp(path)` | An HTTP(S) request succeeds; configure `ForPort`, `ForStatusCode`, `ForHeader`, `AllowInsecureTls`. | +| `Wait.ForLogMessage(string)` / `Wait.ForLogMessage(Regex)` | Accumulated init-process output matches. | +| `Wait.ForCommand(params string[])` | A command executed in the container exits with the expected code. | +| `Wait.ForCustom(predicate)` | An arbitrary predicate returns `true`. | +| `Wait.ForAll(...)` / `Wait.ForAny(...)` | All / any contained strategy succeeds. | + +Every strategy supports `.WithTimeout(...)`, `.WithInterval(...)` and `.WithRetries(...)`. The default +timeout is the container's `StartupTimeout` (5 minutes). Timeouts throw `WslContainerTimeoutException` with +a diagnostic naming the container, image, state, mapped ports, strategy and a bounded, secret-redacted log tail. + +## Runtime, sessions and storage + +`WslContainerRuntime.Instance` owns a single lazily started, process-wide WSLC session named +`wslc-{pid}-{random8}`. Images are **shared by default** (`StorageMode.Shared`): every session uses +`%LOCALAPPDATA%\Purview\WslContainers\images`, so images are pulled once and reused across process runs. +A session exclusively locks its `storage.vhdx`; when a concurrent process holds the default shared store the +runtime verifies the store once and transparently falls back to an isolated per-process store (removed when +that session terminates). Configure `WslContainerRuntimeOptions` for CPU, memory, GPU, session name, +`StoragePath` (or the `WSL_CONTAINERS_STORAGE_PATH` environment variable) and `StorageMode.PerSession`. + +The Microsoft types (`Session`, `Container`, `Process`, …) stay behind the public interfaces; the only escape +hatch is the opt-in accessor for `Inspect()` and raw handles. + +## Diagnostics + +`System.Diagnostics.ActivitySource("Purview.WslContainers")` emits session, pull, container lifecycle, exec +and wait spans. Credentials and other sensitive values are wrapped in `Secret` and redacted from `ToString()` +and diagnostics. + +## Documentation + +See the [project wiki](https://github.com/purview-dev/wsl-testcontainers/blob/main/docs/wiki/Home.md): +[Getting Started](https://github.com/purview-dev/wsl-testcontainers/blob/main/docs/wiki/Getting-Started.md), +[Architecture](https://github.com/purview-dev/wsl-testcontainers/blob/main/docs/wiki/Architecture.md), +[Lifecycle](https://github.com/purview-dev/wsl-testcontainers/blob/main/docs/wiki/Lifecycle.md), +[Networking](https://github.com/purview-dev/wsl-testcontainers/blob/main/docs/wiki/Networking.md) and +[Wait Strategies](https://github.com/purview-dev/wsl-testcontainers/blob/main/docs/wiki/Wait-Strategies.md). +Ready-made service modules ship as `Purview.WslContainers.PostgreSql`, `Redis`, `MsSql`, `RabbitMq`, +`Azurite`, `Nats` and `MySql`. diff --git a/src/WslContainers/StorageMode.cs b/src/src/WslContainers/StorageMode.cs similarity index 99% rename from src/WslContainers/StorageMode.cs rename to src/src/WslContainers/StorageMode.cs index f21b794..9df4d97 100644 --- a/src/WslContainers/StorageMode.cs +++ b/src/src/WslContainers/StorageMode.cs @@ -11,4 +11,4 @@ public enum StorageMode /// Each session gets a unique, isolated storage directory (fresh image store per process). PerSession = 1, -} \ No newline at end of file +} diff --git a/src/WslContainers/Waiting/AllWaitStrategy.cs b/src/src/WslContainers/Waiting/AllWaitStrategy.cs similarity index 76% rename from src/WslContainers/Waiting/AllWaitStrategy.cs rename to src/src/WslContainers/Waiting/AllWaitStrategy.cs index fa4847d..2c8170c 100644 --- a/src/WslContainers/Waiting/AllWaitStrategy.cs +++ b/src/src/WslContainers/Waiting/AllWaitStrategy.cs @@ -1,20 +1,20 @@ -namespace Purview.WslContainers; +namespace Purview.WslContainers.Waiting; /// Composes strategies; ready only when all contained strategies are ready on the same check. public sealed class AllWaitStrategy : WaitStrategy { - private readonly IReadOnlyList strategies; + readonly IReadOnlyList _strategies; public AllWaitStrategy(IReadOnlyList strategies) { ArgumentNullException.ThrowIfNull(strategies); - this.strategies = strategies; + _strategies = strategies; } /// public override async Task UntilAsync(WaitContext context, CancellationToken cancellationToken) { - foreach (IWaitStrategy strategy in strategies) + foreach (var strategy in _strategies) { if (!await strategy.UntilAsync(context, cancellationToken).ConfigureAwait(false)) { diff --git a/src/WslContainers/Waiting/AnyWaitStrategy.cs b/src/src/WslContainers/Waiting/AnyWaitStrategy.cs similarity index 76% rename from src/WslContainers/Waiting/AnyWaitStrategy.cs rename to src/src/WslContainers/Waiting/AnyWaitStrategy.cs index d90e38a..1c698bf 100644 --- a/src/WslContainers/Waiting/AnyWaitStrategy.cs +++ b/src/src/WslContainers/Waiting/AnyWaitStrategy.cs @@ -1,20 +1,20 @@ -namespace Purview.WslContainers; +namespace Purview.WslContainers.Waiting; /// Composes strategies; ready when any contained strategy is ready on the same check. public sealed class AnyWaitStrategy : WaitStrategy { - private readonly IReadOnlyList strategies; + readonly IReadOnlyList _strategies; public AnyWaitStrategy(IReadOnlyList strategies) { ArgumentNullException.ThrowIfNull(strategies); - this.strategies = strategies; + _strategies = strategies; } /// public override async Task UntilAsync(WaitContext context, CancellationToken cancellationToken) { - foreach (IWaitStrategy strategy in strategies) + foreach (var strategy in _strategies) { if (await strategy.UntilAsync(context, cancellationToken).ConfigureAwait(false)) { diff --git a/src/WslContainers/Waiting/CommandWaitStrategy.cs b/src/src/WslContainers/Waiting/CommandWaitStrategy.cs similarity index 72% rename from src/WslContainers/Waiting/CommandWaitStrategy.cs rename to src/src/WslContainers/Waiting/CommandWaitStrategy.cs index 0432399..ae04827 100644 --- a/src/WslContainers/Waiting/CommandWaitStrategy.cs +++ b/src/src/WslContainers/Waiting/CommandWaitStrategy.cs @@ -1,6 +1,6 @@ using System.Diagnostics.CodeAnalysis; -namespace Purview.WslContainers; +namespace Purview.WslContainers.Waiting; /// Waits until a command executed inside the container exits with the expected code (default 0). [SuppressMessage( @@ -10,20 +10,20 @@ namespace Purview.WslContainers; )] public sealed class CommandWaitStrategy : WaitStrategy { - private readonly string[] command; - private int expectedExitCode; + readonly string[] _command; + int _expectedExitCode; public CommandWaitStrategy(string[] command, int expectedExitCode = 0) { ArgumentNullException.ThrowIfNull(command); - this.command = command; - this.expectedExitCode = expectedExitCode; + _command = command; + _expectedExitCode = expectedExitCode; } /// Sets the expected exit code. Defaults to 0. public CommandWaitStrategy ForExitCode(int exitCode) { - expectedExitCode = exitCode; + _expectedExitCode = exitCode; return this; } @@ -32,10 +32,10 @@ public override async Task UntilAsync(WaitContext context, CancellationTok { try { - ExecResult result = await context - .Container.ExecAsync(command, options: null, cancellationToken) + var result = await context + .Container.ExecAsync(_command, options: null, cancellationToken) .ConfigureAwait(false); - return result.ExitCode == expectedExitCode; + return result.ExitCode == _expectedExitCode; } catch (OperationCanceledException) { diff --git a/src/WslContainers/Waiting/ContainerRunningWaitStrategy.cs b/src/src/WslContainers/Waiting/ContainerRunningWaitStrategy.cs similarity index 81% rename from src/WslContainers/Waiting/ContainerRunningWaitStrategy.cs rename to src/src/WslContainers/Waiting/ContainerRunningWaitStrategy.cs index 06d8bc1..1841ba2 100644 --- a/src/WslContainers/Waiting/ContainerRunningWaitStrategy.cs +++ b/src/src/WslContainers/Waiting/ContainerRunningWaitStrategy.cs @@ -1,4 +1,6 @@ -namespace Purview.WslContainers; +using Purview.WslContainers.Containers; + +namespace Purview.WslContainers.Waiting; /// Waits until the container init process is running. public sealed class ContainerRunningWaitStrategy : WaitStrategy diff --git a/src/WslContainers/Waiting/CustomWaitStrategy.cs b/src/src/WslContainers/Waiting/CustomWaitStrategy.cs similarity index 67% rename from src/WslContainers/Waiting/CustomWaitStrategy.cs rename to src/src/WslContainers/Waiting/CustomWaitStrategy.cs index 18fd6b6..3479195 100644 --- a/src/WslContainers/Waiting/CustomWaitStrategy.cs +++ b/src/src/WslContainers/Waiting/CustomWaitStrategy.cs @@ -1,19 +1,19 @@ -namespace Purview.WslContainers; +namespace Purview.WslContainers.Waiting; /// Waits until a custom predicate returns true. public sealed class CustomWaitStrategy : WaitStrategy { - private readonly Func> predicate; + readonly Func> _predicate; public CustomWaitStrategy(Func> predicate) { ArgumentNullException.ThrowIfNull(predicate); - this.predicate = predicate; + _predicate = predicate; } /// public override Task UntilAsync(WaitContext context, CancellationToken cancellationToken) { - return predicate(context, cancellationToken); + return _predicate(context, cancellationToken); } } diff --git a/src/WslContainers/Waiting/HttpWaitStrategy.cs b/src/src/WslContainers/Waiting/HttpWaitStrategy.cs similarity index 54% rename from src/WslContainers/Waiting/HttpWaitStrategy.cs rename to src/src/WslContainers/Waiting/HttpWaitStrategy.cs index f6e2b71..17ca9f0 100644 --- a/src/WslContainers/Waiting/HttpWaitStrategy.cs +++ b/src/src/WslContainers/Waiting/HttpWaitStrategy.cs @@ -1,8 +1,7 @@ using System.Diagnostics.CodeAnalysis; using System.Net; -using System.Net.Http; -namespace Purview.WslContainers; +namespace Purview.WslContainers.Waiting; /// Waits until an HTTP(S) request against the mapped host port succeeds. [SuppressMessage( @@ -10,56 +9,51 @@ namespace Purview.WslContainers; "CA1031:Do not catch general exception types", Justification = "Transient network failures during startup are treated as not-ready." )] -public sealed class HttpWaitStrategy : WaitStrategy +public sealed class HttpWaitStrategy(string path = "/") : WaitStrategy { - private readonly string path; - private ushort? containerPort; - private string scheme = "http"; - private HttpStatusCode? expectedStatusCode = HttpStatusCode.OK; - private Func? statusPredicate; - private readonly Dictionary headers = new(StringComparer.OrdinalIgnoreCase); - private bool allowInsecureTls; - private TimeSpan requestTimeout = TimeSpan.FromSeconds(2); - - public HttpWaitStrategy(string path = "/") - { - this.path = string.IsNullOrEmpty(path) ? "/" : path; - } + readonly string _path = string.IsNullOrEmpty(path) ? "/" : path; + ushort? _containerPort; + string _scheme = "http"; + HttpStatusCode? _expectedStatusCode = HttpStatusCode.OK; + Func? _statusPredicate; + readonly Dictionary _headers = [with(StringComparer.OrdinalIgnoreCase)]; + bool _allowInsecureTls; + readonly TimeSpan _requestTimeout = TimeSpan.FromSeconds(2); /// The container port to connect to. When unset, the first mapped port is used. public HttpWaitStrategy ForPort(ushort containerPort) { - this.containerPort = containerPort; + _containerPort = containerPort; return this; } /// The URL scheme: http or https. public HttpWaitStrategy ForScheme(string scheme) { - this.scheme = scheme; + _scheme = scheme; return this; } /// The expected HTTP status code. public HttpWaitStrategy ForStatusCode(HttpStatusCode statusCode) { - expectedStatusCode = statusCode; - statusPredicate = null; + _expectedStatusCode = statusCode; + _statusPredicate = null; return this; } /// A predicate over the response status code. public HttpWaitStrategy ForStatusPredicate(Func predicate) { - statusPredicate = predicate; - expectedStatusCode = null; + _statusPredicate = predicate; + _expectedStatusCode = null; return this; } /// Adds a request header. public HttpWaitStrategy ForHeader(string name, string value) { - headers[name] = value; + _headers[name] = value; return this; } @@ -68,14 +62,14 @@ public HttpWaitStrategy ForHeader(string name, string value) /// public HttpWaitStrategy AllowInsecureTls() { - allowInsecureTls = true; + _allowInsecureTls = true; return this; } /// public override async Task UntilAsync(WaitContext context, CancellationToken cancellationToken) { - int? hostPort = containerPort is ushort containerPortValue + var hostPort = _containerPort is ushort containerPortValue ? context.GetHostPort(containerPortValue) : context.FirstHostPort; if (hostPort is null) @@ -83,32 +77,32 @@ public override async Task UntilAsync(WaitContext context, CancellationTok return false; } - string url = $"{scheme}://127.0.0.1:{hostPort}{path}"; + var url = $"{_scheme}://127.0.0.1:{hostPort}{_path}"; try { - using SocketsHttpHandler handler = new SocketsHttpHandler + using SocketsHttpHandler handler = new() { - ConnectTimeout = requestTimeout, + ConnectTimeout = _requestTimeout, PooledConnectionLifetime = TimeSpan.Zero, }; - if (allowInsecureTls) + if (_allowInsecureTls) { +#pragma warning disable CA5359 // Do Not Disable Certificate Validation handler.SslOptions.RemoteCertificateValidationCallback = static (_, _, _, _) => true; +#pragma warning restore CA5359 // Do Not Disable Certificate Validation } - using HttpClient client = new HttpClient(handler) { Timeout = requestTimeout }; - using HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Get, url); - foreach ((string name, string value) in headers) + using HttpClient client = new(handler) { Timeout = _requestTimeout }; + using HttpRequestMessage request = new(HttpMethod.Get, url); + foreach ((var name, var value) in _headers) { request.Headers.TryAddWithoutValidation(name, value); } - using HttpResponseMessage response = await client - .SendAsync(request, cancellationToken) - .ConfigureAwait(false); - int status = (int)response.StatusCode; - return statusPredicate?.Invoke(status) - ?? (expectedStatusCode is HttpStatusCode expected && status == (int)expected); + using var response = await client.SendAsync(request, cancellationToken).ConfigureAwait(false); + var status = (int)response.StatusCode; + return _statusPredicate?.Invoke(status) + ?? (_expectedStatusCode is HttpStatusCode expected && status == (int)expected); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { diff --git a/src/WslContainers/Waiting/IWaitStrategy.cs b/src/src/WslContainers/Waiting/IWaitStrategy.cs similarity index 94% rename from src/WslContainers/Waiting/IWaitStrategy.cs rename to src/src/WslContainers/Waiting/IWaitStrategy.cs index 6f6b442..eb638b8 100644 --- a/src/WslContainers/Waiting/IWaitStrategy.cs +++ b/src/src/WslContainers/Waiting/IWaitStrategy.cs @@ -1,4 +1,4 @@ -namespace Purview.WslContainers; +namespace Purview.WslContainers.Waiting; /// A composable readiness check evaluated until the container is ready or the timeout expires. public interface IWaitStrategy diff --git a/src/WslContainers/Waiting/LogMessageWaitStrategy.cs b/src/src/WslContainers/Waiting/LogMessageWaitStrategy.cs similarity index 61% rename from src/WslContainers/Waiting/LogMessageWaitStrategy.cs rename to src/src/WslContainers/Waiting/LogMessageWaitStrategy.cs index 38ed023..d23acb7 100644 --- a/src/WslContainers/Waiting/LogMessageWaitStrategy.cs +++ b/src/src/WslContainers/Waiting/LogMessageWaitStrategy.cs @@ -1,28 +1,28 @@ using System.Text.RegularExpressions; -namespace Purview.WslContainers; +namespace Purview.WslContainers.Waiting; /// Waits until the container's accumulated init-process output matches a message or regex. public sealed class LogMessageWaitStrategy : WaitStrategy { - private readonly Regex pattern; + readonly Regex _pattern; public LogMessageWaitStrategy(string message) { ArgumentNullException.ThrowIfNull(message); - pattern = new Regex(Regex.Escape(message), RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + _pattern = new Regex(Regex.Escape(message), RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); } public LogMessageWaitStrategy(Regex pattern) { ArgumentNullException.ThrowIfNull(pattern); - this.pattern = pattern; + _pattern = pattern; } /// public override async Task UntilAsync(WaitContext context, CancellationToken cancellationToken) { - string logs = await context.Container.GetLogsAsync(stream: null, cancellationToken).ConfigureAwait(false); - return pattern.IsMatch(logs); + var logs = await context.Container.GetLogsAsync(stream: null, cancellationToken).ConfigureAwait(false); + return _pattern.IsMatch(logs); } } diff --git a/src/src/WslContainers/Waiting/TcpPortWaitStrategy.cs b/src/src/WslContainers/Waiting/TcpPortWaitStrategy.cs new file mode 100644 index 0000000..f3f2b55 --- /dev/null +++ b/src/src/WslContainers/Waiting/TcpPortWaitStrategy.cs @@ -0,0 +1,35 @@ +using System.Net; +using System.Net.Sockets; + +namespace Purview.WslContainers.Waiting; + +/// Waits until a TCP connection to the mapped host port succeeds (IPv4 loopback). +public sealed class TcpPortWaitStrategy(ushort containerPort, TimeSpan connectTimeout) : WaitStrategy +{ + /// + public override async Task UntilAsync(WaitContext context, CancellationToken cancellationToken) + { + var hostPort = context.GetHostPort(containerPort); + if (hostPort is null) + { + return false; + } + + using CancellationTokenSource timeoutSource = new(connectTimeout); + using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutSource.Token); + try + { + using TcpClient client = new(); + await client.ConnectAsync(IPAddress.Loopback, hostPort.Value, linked.Token).ConfigureAwait(false); + return true; + } + catch (OperationCanceledException) when (timeoutSource.IsCancellationRequested) + { + return false; + } + catch (SocketException) + { + return false; + } + } +} diff --git a/src/WslContainers/Waiting/Wait.cs b/src/src/WslContainers/Waiting/Wait.cs similarity index 98% rename from src/WslContainers/Waiting/Wait.cs rename to src/src/WslContainers/Waiting/Wait.cs index e61c653..1dbe2d8 100644 --- a/src/WslContainers/Waiting/Wait.cs +++ b/src/src/WslContainers/Waiting/Wait.cs @@ -1,6 +1,6 @@ using System.Text.RegularExpressions; -namespace Purview.WslContainers; +namespace Purview.WslContainers.Waiting; /// Factory for composable wait strategies. public static class Wait diff --git a/src/WslContainers/Waiting/WaitContext.cs b/src/src/WslContainers/Waiting/WaitContext.cs similarity index 71% rename from src/WslContainers/Waiting/WaitContext.cs rename to src/src/WslContainers/Waiting/WaitContext.cs index 84c7bdf..abbe0af 100644 --- a/src/WslContainers/Waiting/WaitContext.cs +++ b/src/src/WslContainers/Waiting/WaitContext.cs @@ -1,14 +1,14 @@ -namespace Purview.WslContainers; +namespace Purview.WslContainers.Waiting; /// Readiness-check context: the container plus resolved runtime state (ports, IP). public sealed class WaitContext { - private readonly IReadOnlyDictionary portMappings; + readonly IReadOnlyDictionary _portMappings; internal WaitContext(IContainer container, IReadOnlyDictionary portMappings, string? networkIp) { Container = container; - this.portMappings = portMappings; + _portMappings = portMappings; NetworkIp = networkIp; } @@ -21,9 +21,9 @@ internal WaitContext(IContainer container, IReadOnlyDictionary p /// Returns the host port mapped to , or null when not mapped. public int? GetHostPort(ushort containerPort) { - return portMappings.TryGetValue(containerPort, out ushort hostPort) ? hostPort : null; + return _portMappings.TryGetValue(containerPort, out var hostPort) ? hostPort : null; } /// The first mapped host port, or null when no ports are mapped. - public int? FirstHostPort => portMappings.Count > 0 ? portMappings.Values.First() : null; + public int? FirstHostPort => _portMappings.Count > 0 ? _portMappings.Values.First() : null; } diff --git a/src/WslContainers/Waiting/WaitStrategy.cs b/src/src/WslContainers/Waiting/WaitStrategy.cs similarity index 96% rename from src/WslContainers/Waiting/WaitStrategy.cs rename to src/src/WslContainers/Waiting/WaitStrategy.cs index 255a0db..cdadd2a 100644 --- a/src/WslContainers/Waiting/WaitStrategy.cs +++ b/src/src/WslContainers/Waiting/WaitStrategy.cs @@ -1,4 +1,4 @@ -namespace Purview.WslContainers; +namespace Purview.WslContainers.Waiting; /// Base class for wait strategies with shared polling configuration. public abstract class WaitStrategy : IWaitStrategy diff --git a/src/WslContainers/Waiting/WaitStrategyRunner.cs b/src/src/WslContainers/Waiting/WaitStrategyRunner.cs similarity index 66% rename from src/WslContainers/Waiting/WaitStrategyRunner.cs rename to src/src/WslContainers/Waiting/WaitStrategyRunner.cs index d43da59..21b44d6 100644 --- a/src/WslContainers/Waiting/WaitStrategyRunner.cs +++ b/src/src/WslContainers/Waiting/WaitStrategyRunner.cs @@ -1,9 +1,10 @@ -using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Text; +using Purview.WslContainers.Diagnostics; +using Purview.WslContainers.Runtime; -namespace Purview.WslContainers; +namespace Purview.WslContainers.Waiting; /// Runs wait strategies with timeout, interval and retries, producing actionable diagnostics on failure. [SuppressMessage( @@ -11,7 +12,7 @@ namespace Purview.WslContainers; "CA1031:Do not catch general exception types", Justification = "Wait checks observe transient errors; diagnostics collection is best-effort." )] -internal static class WaitStrategyRunner +static class WaitStrategyRunner { public static async Task RunAsync( WaitContext context, @@ -20,32 +21,30 @@ public static async Task RunAsync( CancellationToken cancellationToken ) { - foreach (IWaitStrategy strategy in strategies) + foreach (var strategy in strategies) { await RunSingleAsync(context, strategy, defaultTimeout, cancellationToken).ConfigureAwait(false); } } - private static async Task RunSingleAsync( + static async Task RunSingleAsync( WaitContext context, IWaitStrategy strategy, TimeSpan defaultTimeout, CancellationToken cancellationToken ) { - using Activity? activity = WslContainersActivity.Source.StartActivity("wslcontainer.wait"); + using var activity = WslContainersActivity.Source.StartActivity("wslcontainer.wait"); activity?.SetTag("container.name", context.Container.Name); activity?.SetTag("strategy", strategy.GetType().Name); - TimeSpan timeout = strategy.Timeout ?? defaultTimeout; - TimeSpan interval = strategy.Interval > TimeSpan.Zero ? strategy.Interval : TimeSpan.FromMilliseconds(250); - using CancellationTokenSource timeoutSource = CancellationTokenSource.CreateLinkedTokenSource( - cancellationToken - ); + var timeout = strategy.Timeout ?? defaultTimeout; + var interval = strategy.Interval > TimeSpan.Zero ? strategy.Interval : TimeSpan.FromMilliseconds(250); + using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); timeoutSource.CancelAfter(timeout); - CancellationToken token = timeoutSource.Token; + var token = timeoutSource.Token; Exception? lastError = null; - int failedChecks = 0; + var failedChecks = 0; while (!token.IsCancellationRequested) { bool ready; @@ -82,21 +81,23 @@ CancellationToken cancellationToken } catch (OperationCanceledException) { } } + cancellationToken.ThrowIfCancellationRequested(); - if (cancellationToken.IsCancellationRequested) - { - throw new OperationCanceledException(cancellationToken); - } - - string diagnostics = await BuildDiagnosticsAsync(context, timeout, lastError).ConfigureAwait(false); + var diagnostics = await BuildDiagnosticsAsync(context, timeout, lastError, cancellationToken) + .ConfigureAwait(false); throw new WslContainerTimeoutException( $"Container '{context.Container.Name}' did not become ready within {timeout} for strategy {strategy.GetType().Name}. {diagnostics}" ); } - private static async Task BuildDiagnosticsAsync(WaitContext context, TimeSpan timeout, Exception? lastError) + static async Task BuildDiagnosticsAsync( + WaitContext context, + TimeSpan timeout, + Exception? lastError, + CancellationToken cancellationToken + ) { - StringBuilder builder = new StringBuilder(); + StringBuilder builder = new(); builder.Append( CultureInfo.InvariantCulture, $"State: {context.Container.State}. Image: {context.Container.Image}. Startup timeout: {timeout}." @@ -104,7 +105,7 @@ private static async Task BuildDiagnosticsAsync(WaitContext context, Tim try { - IReadOnlyDictionary ports = context.Container.GetMappedPublicPorts(); + var ports = context.Container.GetMappedPublicPorts(); if (ports.Count > 0) { builder.Append( @@ -125,10 +126,8 @@ private static async Task BuildDiagnosticsAsync(WaitContext context, Tim try { - string logs = await context - .Container.GetLogsAsync(stream: null, CancellationToken.None) - .ConfigureAwait(false); - string tail = logs.Length > 2000 ? logs[^2000..] : logs; + var logs = await context.Container.GetLogsAsync(stream: null, cancellationToken).ConfigureAwait(false); + var tail = logs.Length > 2000 ? logs[^2000..] : logs; if (tail.Length > 0) { builder.Append(CultureInfo.InvariantCulture, $" Recent output: {tail}"); diff --git a/src/WslContainers/WslContainer.cs b/src/src/WslContainers/WslContainer.cs similarity index 60% rename from src/WslContainers/WslContainer.cs rename to src/src/WslContainers/WslContainer.cs index 4021484..bd51988 100644 --- a/src/WslContainers/WslContainer.cs +++ b/src/src/WslContainers/WslContainer.cs @@ -1,9 +1,10 @@ using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; using System.Text; -using System.Threading.Channels; using Microsoft.WSL.Containers; -using Purview.WslContainers.Images; +using Purview.WslContainers.Containers; +using Purview.WslContainers.Runtime; +using Purview.WslContainers.Waiting; using MsContainerState = Microsoft.WSL.Containers.ContainerState; using MsProcess = Microsoft.WSL.Containers.Process; using MsProcessState = Microsoft.WSL.Containers.ProcessState; @@ -19,14 +20,13 @@ namespace Purview.WslContainers; )] public class WslContainer : IContainer { - private readonly IContainerRuntime runtime; - private readonly LogBuffer logBuffer = new(); - private WslContainerSession? session; - private Microsoft.WSL.Containers.Container? handle; - private IReadOnlyDictionary portMappings = new Dictionary(); - private string? networkIp; - private int started; - private int disposed; + readonly LogBuffer _logBuffer = new(); + WslContainerSession? _session; + Container? _handle; + IReadOnlyDictionary _portMappings = new Dictionary(); + string? _networkIp; + int _started; + int _disposed; /// Creates a container bound to a runtime. Prefer building via a . public WslContainer(ContainerConfiguration configuration, IContainerRuntime runtime) @@ -34,7 +34,7 @@ public WslContainer(ContainerConfiguration configuration, IContainerRuntime runt ArgumentNullException.ThrowIfNull(configuration); ArgumentNullException.ThrowIfNull(runtime); Configuration = configuration; - this.runtime = runtime; + Runtime = runtime; Name = configuration.Name ?? GenerateName(configuration); } @@ -42,16 +42,16 @@ public WslContainer(ContainerConfiguration configuration, IContainerRuntime runt protected ContainerConfiguration Configuration { get; } /// The runtime this container is bound to. - protected IContainerRuntime Runtime => runtime; + protected IContainerRuntime Runtime { get; } /// public string Name { get; } /// - public string Id => handle?.Id ?? string.Empty; + public string Id => _handle?.Id ?? string.Empty; /// - public ContainerState State { get; private set; } + public Containers.ContainerState State { get; private set; } /// public string Image => Configuration.Image; @@ -59,16 +59,15 @@ public WslContainer(ContainerConfiguration configuration, IContainerRuntime runt /// public virtual async Task StartAsync(CancellationToken cancellationToken = default) { - ObjectDisposedException.ThrowIf(Volatile.Read(ref disposed) == 1, this); - if (Volatile.Read(ref started) == 1) + ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) == 1, this); + if (Volatile.Read(ref _started) == 1) { return; } - WslContainerSession sessionLocal = (WslContainerSession) - await runtime.GetSessionAsync(cancellationToken).ConfigureAwait(false); - session = sessionLocal; - await session + var sessionLocal = (WslContainerSession)await Runtime.GetSessionAsync(cancellationToken).ConfigureAwait(false); + _session = sessionLocal; + await _session .EnsureImageAsync( Configuration.Image, Configuration.PullPolicy, @@ -78,22 +77,20 @@ await session ) .ConfigureAwait(false); - ContainerSettings settings = WslSettingsMapper.ToContainerSettings(Configuration, Name); - Microsoft.WSL.Containers.Container container = await session - .CreateContainerAsync(settings, cancellationToken) - .ConfigureAwait(false); - handle = container; - logBuffer.Attach(container.InitProcess); - await session.StartContainerAsync(container, cancellationToken).ConfigureAwait(false); + var settings = WslSettingsMapper.ToContainerSettings(Configuration, Name); + var container = await _session.CreateContainerAsync(settings, cancellationToken).ConfigureAwait(false); + _handle = container; + _logBuffer.Attach(container.InitProcess); + await _session.StartContainerAsync(container, cancellationToken).ConfigureAwait(false); State = MapState(container.State); - portMappings = WslInspectParser.ParseHostPorts(container.Inspect()); - networkIp = WslInspectParser.TryGetNetworkIp(container.Inspect()); - Volatile.Write(ref started, 1); + _portMappings = WslInspectParser.ParseHostPorts(container.Inspect()); + _networkIp = WslInspectParser.TryGetNetworkIp(container.Inspect()); + Volatile.Write(ref _started, 1); if (Configuration.WaitStrategies.Count > 0) { - WaitContext waitContext = new(this, portMappings, networkIp); + WaitContext waitContext = new(this, _portMappings, _networkIp); await WaitStrategyRunner .RunAsync(waitContext, Configuration.WaitStrategies, Configuration.StartupTimeout, cancellationToken) .ConfigureAwait(false); @@ -104,15 +101,15 @@ await WaitStrategyRunner public virtual async Task StopAsync(CancellationToken cancellationToken = default) { EnsureStarted(); - if (handle is null || session is null) + if (_handle is null || _session is null) { return; } try { - await session - .StopContainerAsync(handle, MsSignal.SIGTERM, TimeSpan.FromSeconds(10), cancellationToken) + await _session + .StopContainerAsync(_handle, MsSignal.SIGTERM, TimeSpan.FromSeconds(10), cancellationToken) .ConfigureAwait(false); } catch (WslContainerSessionTerminatedException) @@ -124,7 +121,7 @@ await session // already stopped; idempotent by design } - State = MapState(handle.State); + State = MapState(_handle.State); } /// @@ -141,14 +138,14 @@ public virtual async Task ExecAsync( } EnsureStarted(); - if (handle is null || session is null) + if (_handle is null || _session is null) { throw new InvalidOperationException("Container is not started."); } - ProcessSettings settings = new ProcessSettings + ProcessSettings settings = new() { - CommandLine = new List(command), + CommandLine = [.. command], OutputMode = ProcessOutputMode.Event, WorkingDirectory = options?.WorkingDirectory, }; @@ -157,8 +154,8 @@ public virtual async Task ExecAsync( settings.EnvironmentVariables = new Dictionary(options.Environment); } - MsProcess process = await session.CreateProcessAsync(handle, settings, cancellationToken).ConfigureAwait(false); - using CancellationTokenRegistration cancelRegistration = cancellationToken.Register( + var process = await _session.CreateProcessAsync(_handle, settings, cancellationToken).ConfigureAwait(false); + using var cancelRegistration = cancellationToken.Register( static state => { if (state is MsProcess processToSignal) @@ -173,23 +170,20 @@ public virtual async Task ExecAsync( process ); - StringBuilder stdout = new StringBuilder(); - StringBuilder stderr = new StringBuilder(); - TaskCompletionSource exitSource = new TaskCompletionSource( - TaskCreationOptions.RunContinuationsAsynchronously - ); + StringBuilder stdout = new(); + StringBuilder stderr = new(); + TaskCompletionSource exitSource = new(TaskCreationOptions.RunContinuationsAsynchronously); process.OutputReceived += data => stdout.Append(Encoding.UTF8.GetString(data)); process.ErrorReceived += data => stderr.Append(Encoding.UTF8.GetString(data)); process.Exited += code => exitSource.TrySetResult(code); - await session.StartProcessAsync(process, cancellationToken).ConfigureAwait(false); + await _session.StartProcessAsync(process, cancellationToken).ConfigureAwait(false); - Task exitTask = exitSource.Task; + var exitTask = exitSource.Task; int exitCode; if (options?.Timeout is TimeSpan timeout) { - Task completed = await Task.WhenAny(exitTask, Task.Delay(timeout, CancellationToken.None)) - .ConfigureAwait(false); + var completed = await Task.WhenAny(exitTask, Task.Delay(timeout, cancellationToken)).ConfigureAwait(false); if (completed == exitTask) { exitCode = await exitTask.ConfigureAwait(false); @@ -217,7 +211,7 @@ public virtual async Task ExecAsync( return new ExecResult(exitCode, stdout.ToString(), stderr.ToString()); } - private static void TryKill(MsProcess process) + static void TryKill(MsProcess process) { try { @@ -227,9 +221,9 @@ private static void TryKill(MsProcess process) } // WSLC does not reliably raise Process.Exited after Signal(SIGKILL); poll the process state instead. - private static async Task PollForExitAsync(MsProcess process, Task exitTask) + static async Task PollForExitAsync(MsProcess process, Task exitTask) { - DateTime deadline = DateTime.UtcNow.AddSeconds(15); + var deadline = DateTime.UtcNow.AddSeconds(15); while (DateTime.UtcNow < deadline) { if (exitTask.IsCompleted) @@ -237,7 +231,7 @@ private static async Task PollForExitAsync(MsProcess process, Task exi return await exitTask.ConfigureAwait(false); } - MsProcessState state = process.State; + var state = process.State; if (state is MsProcessState.Exited or MsProcessState.Signalled) { return process.ExitCode; @@ -253,11 +247,12 @@ private static async Task PollForExitAsync(MsProcess process, Task exi public virtual ushort GetMappedPublicPort(ushort containerPort) { EnsureStarted(); - if (!portMappings.TryGetValue(containerPort, out ushort hostPort)) + if (!_portMappings.TryGetValue(containerPort, out var hostPort)) { throw new WslContainerException($"No host port mapping found for container port {containerPort}."); } + // WSLC does not support dynamic host port assignment; the host port is always the same as the container port. return hostPort; } @@ -265,14 +260,14 @@ public virtual ushort GetMappedPublicPort(ushort containerPort) public virtual IReadOnlyDictionary GetMappedPublicPorts() { EnsureStarted(); - return portMappings; + return _portMappings; } /// public virtual Task GetLogsAsync(LogOutput? stream = null, CancellationToken cancellationToken = default) { - ObjectDisposedException.ThrowIf(Volatile.Read(ref disposed) == 1, this); - return Task.FromResult(logBuffer.GetLogs(stream)); + ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) == 1, this); + return Task.FromResult(_logBuffer.GetLogs(stream)); } /// @@ -280,14 +275,14 @@ public virtual async IAsyncEnumerable GetLogsAsync( [EnumeratorCancellation] CancellationToken cancellationToken ) { - ObjectDisposedException.ThrowIf(Volatile.Read(ref disposed) == 1, this); - foreach (ContainerLogEntry entry in logBuffer.Snapshot()) + ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) == 1, this); + foreach (var entry in _logBuffer.Snapshot()) { yield return entry; } - ChannelReader reader = logBuffer.Reader; - await foreach (ContainerLogEntry entry in reader.ReadAllAsync(cancellationToken).ConfigureAwait(false)) + var reader = _logBuffer.Reader; + await foreach (var entry in reader.ReadAllAsync(cancellationToken).ConfigureAwait(false)) { yield return entry; } @@ -296,22 +291,22 @@ [EnumeratorCancellation] CancellationToken cancellationToken /// public virtual async ValueTask DisposeAsync() { - if (Interlocked.Exchange(ref disposed, 1) == 1) + if (Interlocked.Exchange(ref _disposed, 1) == 1) { return; } try { - if (handle is not null && session is not null) + if (_handle is not null && _session is not null) { try { - if (handle.State == MsContainerState.Running) + if (_handle.State == MsContainerState.Running) { - await session + await _session .StopContainerAsync( - handle, + _handle, MsSignal.SIGTERM, TimeSpan.FromSeconds(10), CancellationToken.None @@ -323,40 +318,41 @@ await session try { - await session - .DeleteContainerAsync(handle, DeleteContainerOption.Force, CancellationToken.None) + await _session + .DeleteContainerAsync(_handle, DeleteContainerOption.Force, CancellationToken.None) .ConfigureAwait(false); } catch { } try { - handle.Dispose(); + _handle.Dispose(); } catch { } } } finally { - handle = null; + _logBuffer.Complete(); + _handle = null; GC.SuppressFinalize(this); } } - private void EnsureStarted() + void EnsureStarted() { - if (Volatile.Read(ref started) == 0) + if (Volatile.Read(ref _started) == 0) { throw new InvalidOperationException("Container has not been started. Call StartAsync() first."); } } - private static ContainerState MapState(MsContainerState state) + static Containers.ContainerState MapState(MsContainerState state) { - return (ContainerState)state; + return (Containers.ContainerState)state; } - private static string GenerateName(ContainerConfiguration configuration) + static string GenerateName(ContainerConfiguration configuration) { string shortName; try @@ -368,11 +364,11 @@ private static string GenerateName(ContainerConfiguration configuration) shortName = "container"; } - string safe = new string( - shortName - .Select(character => char.IsAsciiLetterOrDigit(character) || character is '-' or '_' ? character : '-') - .ToArray() - ); + string safe = new([ + .. shortName.Select(character => + char.IsAsciiLetterOrDigit(character) || character is '-' or '_' ? character : '-' + ), + ]); return $"{safe}-{Environment.ProcessId}-{Guid.NewGuid().ToString("N")[..6]}"; } } diff --git a/src/src/WslContainers/WslContainerRuntime.cs b/src/src/WslContainers/WslContainerRuntime.cs new file mode 100644 index 0000000..0415045 --- /dev/null +++ b/src/src/WslContainers/WslContainerRuntime.cs @@ -0,0 +1,246 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.WSL.Containers; + +namespace Purview.WslContainers; + +/// +/// The process-wide WSL Containers runtime. Owns a single lazily started WSLC session. +/// Images are shared by default: sessions use a stable shared storage directory so the image store is +/// pulled once and reused across process runs. When a concurrent process holds that store, the runtime +/// falls back to an isolated per-process store automatically. +/// +[SuppressMessage( + "Design", + "CA2213:Disposable fields should be disposed", + Justification = "The session is released asynchronously in DisposeAsync/TerminateAsync; CA2213 cannot see the async path." +)] +public sealed class WslContainerRuntime : IContainerRuntime +{ + const int ErrorSharingViolation = unchecked((int)0x80070020); + + static readonly Lazy InstanceHolder = new(() => new WslContainerRuntime()); + + readonly WslContainerRuntimeOptions _options; + readonly Lock _sync = new(); + readonly SemaphoreSlim _sessionGate = new(1, 1); + WslContainerSession? _session; + int _disposed; + int _storeVerified; + + /// Creates a runtime with default options. Registers a process-exit cleanup hook. + public WslContainerRuntime() + : this(WslContainerRuntimeOptions.Default) { } + + /// Creates a runtime with the given options. + public WslContainerRuntime(WslContainerRuntimeOptions options) + { + _options = options ?? WslContainerRuntimeOptions.Default; + if (!_options.DisableProcessExitCleanup) + { + AppDomain.CurrentDomain.ProcessExit += OnProcessExit; + } + } + + /// The default process-wide runtime. + public static WslContainerRuntime Instance => InstanceHolder.Value; + + /// + public async Task GetInfoAsync(CancellationToken cancellationToken = default) + { + await Task.Yield(); + var version = WslcService.GetVersion(); + var missing = WslcService.GetMissingComponents(); + var compatible = !missing.Contains(Component.SdkNeedsUpdate); + return new WslContainerRuntimeInfo( + $"{version.Major}.{version.Minor}.{version.Revision}", + missing.Select(component => component.ToString()).ToArray(), + missing.Count == 0, + compatible + ); + } + + /// + public async Task GetSessionAsync(CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) == 1, this); + var local = GetOrCreateSession(); + if (Volatile.Read(ref _storeVerified) == 1) + { + await local.EnsureStartedAsync(cancellationToken).ConfigureAwait(false); + return local; + } + + // A session's image store lives in storage.vhdx and is opened lazily on first access rather than + // at session start, so contention from a concurrent process can surface on the first store read + // instead of during start. Verify the store once, under a gate, and fall back to an isolated + // per-process store when another process holds it. + await _sessionGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) == 1, this); + local = GetOrCreateSession(); + if (Volatile.Read(ref _storeVerified) == 0) + { + local = await EnsureStoreAsync(local, cancellationToken).ConfigureAwait(false); + Volatile.Write(ref _storeVerified, 1); + } + else + { + await local.EnsureStartedAsync(cancellationToken).ConfigureAwait(false); + } + + return local; + } + finally + { + _sessionGate.Release(); + } + } + + async Task EnsureStoreAsync(WslContainerSession session, CancellationToken cancellationToken) + { + try + { + await session.EnsureStartedAsync(cancellationToken).ConfigureAwait(false); + // Opens (and therefore locks) storage.vhdx; throws 0x80070020 when another process holds it. + await session.ListImagesAsync(cancellationToken).ConfigureAwait(false); + return session; + } + catch (Exception ex) when (ShouldFallBackToIsolatedStore(ex, IsUsingDefaultSharedStore())) + { + // A concurrent process holds the shared image-store VHD. Isolate this process instead of failing. + session.DisposeSession(); + var isolated = CreateIsolatedSession(); + _session = isolated; + await isolated.EnsureStartedAsync(cancellationToken).ConfigureAwait(false); + await isolated.ListImagesAsync(cancellationToken).ConfigureAwait(false); + return isolated; + } + } + + /// + public async ValueTask DisposeAsync() + { + if (Interlocked.Exchange(ref _disposed, 1) == 1) + { + return; + } + + var local = _session; + if (local is not null) + { + await local.TerminateAsync().ConfigureAwait(false); + } + + _sessionGate.Dispose(); + GC.SuppressFinalize(this); + } + + WslContainerSession GetOrCreateSession() + { + var local = _session; + if (local is not null) + { + return local; + } + + lock (_sync) + { + local = _session; + if (local is null) + { + local = CreateSession(); + _session = local; + } + + return local; + } + } + + WslContainerSession CreateSession() + { + var name = NextSessionName(); + var storagePath = ResolveStoragePath(name); + return new WslContainerSession(name, storagePath, _options); + } + + WslContainerSession CreateIsolatedSession() + { + var name = NextSessionName(); + var storagePath = Path.Combine(PerSessionRoot, name); + // An isolated fallback store is a throwaway: it is removed when the session terminates so + // contended runs do not accumulate per-process image stores. + return new WslContainerSession(name, storagePath, _options, deleteStorageOnTerminate: true); + } + + string ResolveStoragePath(string sessionName) + { + if (_options.StoragePath is not null) + { + return _options.StoragePath; + } + + var envPath = Environment.GetEnvironmentVariable("WSL_CONTAINERS_STORAGE_PATH"); + if (!string.IsNullOrEmpty(envPath)) + { + return envPath; + } + + if (_options.StorageMode == StorageMode.PerSession) + { + return Path.Combine(PerSessionRoot, sessionName); + } + + // Default to a shared store in the user's local application data directory. + return SharedImagesRoot; + } + + bool IsUsingDefaultSharedStore() + { + return _options.StoragePath is null + && string.IsNullOrEmpty(Environment.GetEnvironmentVariable("WSL_CONTAINERS_STORAGE_PATH")) + && _options.StorageMode == StorageMode.Shared; + } + + static string NextSessionName() + { + return $"wslc-{Environment.ProcessId}-{Guid.NewGuid().ToString("N")[..8]}"; + } + + static string PerSessionRoot => + Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "Purview", + "WslContainers", + "sessions" + ); + + static string SharedImagesRoot => + Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "Purview", + "WslContainers", + "images" + ); + + static bool IsSharingViolation(Exception exception) + { + return exception.HResult == ErrorSharingViolation; + } + + /// + /// True when is a Windows sharing violation (0x80070020) and the + /// runtime is using the default shared image store, meaning a concurrent process holds the store VHD + /// and this process should fall back to an isolated per-process store. + /// + internal static bool ShouldFallBackToIsolatedStore(Exception exception, bool isUsingDefaultSharedStore) + { + return isUsingDefaultSharedStore && IsSharingViolation(exception); + } + + void OnProcessExit(object? sender, EventArgs e) + { + var local = _session; + local?.TerminateBestEffort(); + } +} diff --git a/src/WslContainers/WslContainerRuntimeInfo.cs b/src/src/WslContainers/WslContainerRuntimeInfo.cs similarity index 100% rename from src/WslContainers/WslContainerRuntimeInfo.cs rename to src/src/WslContainers/WslContainerRuntimeInfo.cs diff --git a/src/WslContainers/WslContainerRuntimeOptions.cs b/src/src/WslContainers/WslContainerRuntimeOptions.cs similarity index 95% rename from src/WslContainers/WslContainerRuntimeOptions.cs rename to src/src/WslContainers/WslContainerRuntimeOptions.cs index 0184b7a..896f76b 100644 --- a/src/WslContainers/WslContainerRuntimeOptions.cs +++ b/src/src/WslContainers/WslContainerRuntimeOptions.cs @@ -4,13 +4,13 @@ namespace Purview.WslContainers; public sealed record WslContainerRuntimeOptions { /// CPU count for the session VM. - public uint? CpuCount { get; init; } + public uint? CPUCount { get; init; } /// Memory (MB) for the session VM. public uint? MemorySizeInMB { get; init; } /// Expose GPU devices in the session. - public bool EnableGpu { get; init; } + public bool EnableGPU { get; init; } /// /// Session name. Defaults to wslc-{processId}-{random}. Session names are machine-unique. diff --git a/src/WslContainers/WslContainerSession.cs b/src/src/WslContainers/WslContainerSession.cs similarity index 55% rename from src/WslContainers/WslContainerSession.cs rename to src/src/WslContainers/WslContainerSession.cs index 1676960..64bcbef 100644 --- a/src/WslContainers/WslContainerSession.cs +++ b/src/src/WslContainers/WslContainerSession.cs @@ -1,8 +1,9 @@ using System.Collections.Concurrent; -using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using Microsoft.WSL.Containers; +using Purview.WslContainers.Diagnostics; using Purview.WslContainers.Images; +using Purview.WslContainers.Runtime; using Windows.Foundation; using Process = Microsoft.WSL.Containers.Process; @@ -13,57 +14,65 @@ namespace Purview.WslContainers; "CA1031:Do not catch general exception types", Justification = "Session termination and pull cleanup are best-effort; WSLC surfaces a wide range of COMException variants." )] -internal sealed class WslContainerSession : IContainerSession +sealed class WslContainerSession : IContainerSession { - private readonly Session session; - private readonly string name; - private readonly SemaphoreSlim lifecycleGate = new(1, 1); - private readonly ConcurrentDictionary activePulls = new(StringComparer.Ordinal); - private int started; - private int terminated; - - public WslContainerSession(string name, string storagePath, WslContainerRuntimeOptions options) + readonly Session _session; + readonly SemaphoreSlim _lifecycleGate = new(1, 1); + readonly ConcurrentDictionary _activePulls = new(StringComparer.Ordinal); + readonly string _storagePath; + readonly bool _deleteStorageOnTerminate; + int _started; + int _terminated; + + public WslContainerSession( + string name, + string storagePath, + WslContainerRuntimeOptions options, + bool deleteStorageOnTerminate = false + ) { - this.name = name; - SessionSettings settings = new SessionSettings(name, storagePath) + Name = name; + _storagePath = storagePath; + _deleteStorageOnTerminate = deleteStorageOnTerminate; + SessionSettings settings = new(name, storagePath) { - CpuCount = options.CpuCount, + CpuCount = options.CPUCount, MemorySizeInMB = options.MemorySizeInMB, - EnableGpu = options.EnableGpu, + EnableGpu = options.EnableGPU, Timeout = options.SessionTimeout, }; - session = new Session(settings); - session.Terminated += reason => Terminated?.Invoke(reason); + _session = new Session(settings); + _session.Terminated += reason => Terminated?.Invoke(reason); } /// Raised when the underlying session terminates. internal event Action? Terminated; - public string Name => name; + public string Name { get; } public async Task EnsureStartedAsync(CancellationToken cancellationToken) { - using Activity? activity = WslContainersActivity.Source.StartActivity("wslcontainer.session.start"); - activity?.SetTag("session.name", name); + using var activity = WslContainersActivity.Source.StartActivity("wslcontainer.session.start"); + activity?.SetTag("session.name", Name); ThrowIfTerminated(); - if (Volatile.Read(ref started) == 1) + if (Volatile.Read(ref _started) == 1) { return; } - await lifecycleGate.WaitAsync(cancellationToken).ConfigureAwait(false); + await _lifecycleGate.WaitAsync(cancellationToken).ConfigureAwait(false); try { ThrowIfTerminated(); - if (Volatile.Read(ref started) == 0) + if (Volatile.Read(ref _started) == 0) { - session.Start(); - Volatile.Write(ref started, 1); + _session.Start(); + Volatile.Write(ref _started, 1); } } finally { - lifecycleGate.Release(); + _lifecycleGate.Release(); } } @@ -76,7 +85,7 @@ CancellationToken cancellationToken ) { await EnsureStartedAsync(cancellationToken).ConfigureAwait(false); - Image reference = Image.Parse(image); + var reference = Image.Parse(image); switch (policy) { case PullPolicy.Never: @@ -105,97 +114,115 @@ CancellationToken cancellationToken public async Task> ListImagesAsync(CancellationToken cancellationToken) { await EnsureStartedAsync(cancellationToken).ConfigureAwait(false); - return session - .GetImages() - .Select(image => new ImageSummary(image.Name, image.Size, image.CreatedTimestamp)) - .ToArray(); + return await ReadImagesAsync(cancellationToken).ConfigureAwait(false); + } + + // GetImages() reads (and locks) the session's storage VHD, so it is gated with the other + // session-mutating operations rather than treated as a side-effect-free read. + async Task> ReadImagesAsync(CancellationToken cancellationToken) + { + await _lifecycleGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + ThrowIfTerminated(); + return _session + .GetImages() + .Select(image => new ImageSummary(image.Name, image.Size, image.CreatedTimestamp)) + .ToArray(); + } + finally + { + _lifecycleGate.Release(); + } } public async Task TerminateAsync(CancellationToken cancellationToken = default) { - if (Interlocked.Exchange(ref terminated, 1) == 1) + if (Interlocked.Exchange(ref _terminated, 1) == 1) { return; } try { - session.Terminate(); + _session.Terminate(); } catch { } try { - session.Dispose(); + _session.Dispose(); } catch { } - lifecycleGate.Dispose(); + _lifecycleGate.Dispose(); + TryDeleteStorage(); await Task.CompletedTask; } /// Synchronous best-effort terminate for the process-exit hook. internal void TerminateBestEffort() { - if (Interlocked.Exchange(ref terminated, 1) == 1) + if (Interlocked.Exchange(ref _terminated, 1) == 1) { return; } try { - session.Terminate(); + _session.Terminate(); } catch { } try { - session.Dispose(); + _session.Dispose(); } catch { } - lifecycleGate.Dispose(); + _lifecycleGate.Dispose(); + TryDeleteStorage(); } /// Releases a session that failed to start (e.g. a shared-store contention fallback). internal void DisposeSession() { - if (Interlocked.Exchange(ref terminated, 1) == 1) + if (Interlocked.Exchange(ref _terminated, 1) == 1) { return; } try { - session.Dispose(); + _session.Dispose(); } catch { } - lifecycleGate.Dispose(); + _lifecycleGate.Dispose(); } internal async Task CreateContainerAsync(ContainerSettings settings, CancellationToken cancellationToken) { - using Activity? activity = WslContainersActivity.Source.StartActivity("wslcontainer.container.create"); + using var activity = WslContainersActivity.Source.StartActivity("wslcontainer.container.create"); activity?.SetTag("container.name", settings.Name); await EnsureStartedAsync(cancellationToken).ConfigureAwait(false); - await lifecycleGate.WaitAsync(cancellationToken).ConfigureAwait(false); + await _lifecycleGate.WaitAsync(cancellationToken).ConfigureAwait(false); try { ThrowIfTerminated(); - return session.CreateContainer(settings); + return _session.CreateContainer(settings); } finally { - lifecycleGate.Release(); + _lifecycleGate.Release(); } } internal async Task StartContainerAsync(Container container, CancellationToken cancellationToken) { - using Activity? activity = WslContainersActivity.Source.StartActivity("wslcontainer.container.start"); + using var activity = WslContainersActivity.Source.StartActivity("wslcontainer.container.start"); activity?.SetTag("container.id", container.Id); - await lifecycleGate.WaitAsync(cancellationToken).ConfigureAwait(false); + await _lifecycleGate.WaitAsync(cancellationToken).ConfigureAwait(false); try { ThrowIfTerminated(); @@ -203,7 +230,7 @@ internal async Task StartContainerAsync(Container container, CancellationToken c } finally { - lifecycleGate.Release(); + _lifecycleGate.Release(); } } @@ -214,9 +241,9 @@ internal async Task StopContainerAsync( CancellationToken cancellationToken ) { - using Activity? activity = WslContainersActivity.Source.StartActivity("wslcontainer.container.stop"); + using var activity = WslContainersActivity.Source.StartActivity("wslcontainer.container.stop"); activity?.SetTag("container.id", container.Id); - await lifecycleGate.WaitAsync(cancellationToken).ConfigureAwait(false); + await _lifecycleGate.WaitAsync(cancellationToken).ConfigureAwait(false); try { ThrowIfTerminated(); @@ -224,7 +251,7 @@ CancellationToken cancellationToken } finally { - lifecycleGate.Release(); + _lifecycleGate.Release(); } } @@ -234,9 +261,9 @@ internal async Task DeleteContainerAsync( CancellationToken cancellationToken ) { - using Activity? activity = WslContainersActivity.Source.StartActivity("wslcontainer.container.delete"); + using var activity = WslContainersActivity.Source.StartActivity("wslcontainer.container.delete"); activity?.SetTag("container.id", container.Id); - await lifecycleGate.WaitAsync(cancellationToken).ConfigureAwait(false); + await _lifecycleGate.WaitAsync(cancellationToken).ConfigureAwait(false); try { ThrowIfTerminated(); @@ -244,7 +271,7 @@ CancellationToken cancellationToken } finally { - lifecycleGate.Release(); + _lifecycleGate.Release(); } } @@ -254,9 +281,9 @@ internal async Task CreateProcessAsync( CancellationToken cancellationToken ) { - using Activity? activity = WslContainersActivity.Source.StartActivity("wslcontainer.exec.create"); + using var activity = WslContainersActivity.Source.StartActivity("wslcontainer.exec.create"); activity?.SetTag("container.id", container.Id); - await lifecycleGate.WaitAsync(cancellationToken).ConfigureAwait(false); + await _lifecycleGate.WaitAsync(cancellationToken).ConfigureAwait(false); try { ThrowIfTerminated(); @@ -264,13 +291,13 @@ CancellationToken cancellationToken } finally { - lifecycleGate.Release(); + _lifecycleGate.Release(); } } internal async Task StartProcessAsync(Process process, CancellationToken cancellationToken) { - await lifecycleGate.WaitAsync(cancellationToken).ConfigureAwait(false); + await _lifecycleGate.WaitAsync(cancellationToken).ConfigureAwait(false); try { ThrowIfTerminated(); @@ -278,7 +305,7 @@ internal async Task StartProcessAsync(Process process, CancellationToken cancell } finally { - lifecycleGate.Release(); + _lifecycleGate.Release(); } } @@ -287,20 +314,21 @@ public async ValueTask DisposeAsync() await TerminateAsync().ConfigureAwait(false); } - private async Task ContainsImageAsync(Image reference, CancellationToken cancellationToken) + async Task ContainsImageAsync(Image reference, CancellationToken cancellationToken) { await EnsureStartedAsync(cancellationToken).ConfigureAwait(false); - return session.GetImages().Any(image => reference.MatchesStoredName(image.Name)); + var images = await ReadImagesAsync(cancellationToken).ConfigureAwait(false); + return images.Any(image => reference.MatchesStoredName(image.Name)); } - private Task PullDeduplicatedAsync( + Task PullDeduplicatedAsync( Image reference, IProgress? progress, RegistryCredentials? credentials, CancellationToken cancellationToken ) { - return activePulls.GetOrAdd( + return _activePulls.GetOrAdd( reference.FullReference, static (_, state) => state.Session.PullCoreAsync( @@ -319,19 +347,19 @@ CancellationToken cancellationToken ); } - private async Task PullCoreAsync( + async Task PullCoreAsync( Image reference, IProgress? progress, RegistryCredentials? credentials, CancellationToken cancellationToken ) { - using Activity? activity = WslContainersActivity.Source.StartActivity("wslcontainer.image.pull"); + using var activity = WslContainersActivity.Source.StartActivity("wslcontainer.image.pull"); activity?.SetTag("image", reference.FullReference); - PullImageOptions options = new PullImageOptions(reference.FullReference); + PullImageOptions options = new(reference.FullReference); if (credentials is not null) { - AuthenticateResult authentication = session.Authenticate( + var authentication = _session.Authenticate( credentials.ServerAddress, credentials.Username, credentials.Password.Value @@ -339,8 +367,8 @@ CancellationToken cancellationToken options.RegistryAuth = authentication.IdentityToken; } - IAsyncActionWithProgress operation = session.PullImageAsync(options); - using CancellationTokenRegistration registration = cancellationToken.Register( + var operation = _session.PullImageAsync(options); + using var registration = cancellationToken.Register( static state => { if (state is IAsyncActionWithProgress op) @@ -366,15 +394,39 @@ CancellationToken cancellationToken } finally { - activePulls.TryRemove(reference.FullReference, out _); + _activePulls.TryRemove(reference.FullReference, out _); + } + } + + void TryDeleteStorage() + { + if (!_deleteStorageOnTerminate) + { + return; + } + + try + { + if (Directory.Exists(_storagePath)) + { + Directory.Delete(_storagePath, recursive: true); + } + } + catch (IOException) + { + // The session manager may still hold the store VHD briefly; the store is transient. + } + catch (UnauthorizedAccessException) + { + // Same. } } - private void ThrowIfTerminated() + void ThrowIfTerminated() { - if (Volatile.Read(ref terminated) == 1) + if (Volatile.Read(ref _terminated) == 1) { - throw new WslContainerSessionTerminatedException($"Session '{name}' has been terminated."); + throw new WslContainerSessionTerminatedException($"Session '{Name}' has been terminated."); } } } diff --git a/src/WslContainers/WslContainers.csproj b/src/src/WslContainers/WslContainers.csproj similarity index 100% rename from src/WslContainers/WslContainers.csproj rename to src/src/WslContainers/WslContainers.csproj diff --git a/src/src/WslContainers/WslInspectParser.cs b/src/src/WslContainers/WslInspectParser.cs new file mode 100644 index 0000000..dc460bf --- /dev/null +++ b/src/src/WslContainers/WslInspectParser.cs @@ -0,0 +1,76 @@ +using System.Text.Json; + +namespace Purview.WslContainers; + +/// Parses the Docker-compatible JSON returned by Container.Inspect(). +static class WslInspectParser +{ + public static IReadOnlyDictionary ParseHostPorts(string inspectJson) + { + Dictionary result = []; + try + { + using var document = JsonDocument.Parse(inspectJson); + if (!document.RootElement.TryGetProperty("Ports", out var ports) || ports.ValueKind != JsonValueKind.Object) + { + return result; + } + + foreach (var property in ports.EnumerateObject()) + { + // Key format: "8080/tcp" + var key = property.Name; + var slash = key.IndexOf('/', StringComparison.Ordinal); + if ( + slash <= 0 + || !ushort.TryParse(key.AsSpan(0, slash), out var containerPort) + || property.Value.ValueKind != JsonValueKind.Array + ) + { + continue; + } + + foreach (var binding in property.Value.EnumerateArray()) + { + if ( + binding.TryGetProperty("HostPort", out var hostPort) + && hostPort.ValueKind == JsonValueKind.String + && ushort.TryParse(hostPort.GetString(), out var host) + ) + { + result[containerPort] = host; + break; + } + } + } + } + catch (JsonException) { } + + return result; + } + + public static string? TryGetNetworkIp(string inspectJson, string network = "bridge") + { + try + { + using var document = JsonDocument.Parse(inspectJson); + if ( + document.RootElement.TryGetProperty("NetworkSettings", out var networkSettings) + && networkSettings.TryGetProperty("Networks", out var networks) + && networks.TryGetProperty(network, out var net) + && net.TryGetProperty("IPAddress", out var ip) + && ip.ValueKind == JsonValueKind.String + ) + { + var value = ip.GetString() ?? string.Empty; + return string.IsNullOrEmpty(value) ? null : value; + } + + return null; + } + catch (JsonException) + { + return null; + } + } +} diff --git a/src/WslContainers/WslSettingsMapper.cs b/src/src/WslContainers/WslSettingsMapper.cs similarity index 64% rename from src/WslContainers/WslSettingsMapper.cs rename to src/src/WslContainers/WslSettingsMapper.cs index 6c071c6..cd14133 100644 --- a/src/WslContainers/WslSettingsMapper.cs +++ b/src/src/WslContainers/WslSettingsMapper.cs @@ -1,16 +1,18 @@ using Microsoft.WSL.Containers; +using Purview.WslContainers.Mounts; +using Purview.WslContainers.Networking; using Windows.Networking; namespace Purview.WslContainers; /// Maps the public configuration model onto the WSLC settings types. -internal static class WslSettingsMapper +static class WslSettingsMapper { public static ContainerSettings ToContainerSettings(IContainerConfiguration configuration, string containerName) { - ProcessSettings initProcess = new ProcessSettings + ProcessSettings initProcess = new() { - CommandLine = new List(configuration.Command), + CommandLine = [.. configuration.Command], OutputMode = ProcessOutputMode.Event, WorkingDirectory = configuration.WorkingDirectory, }; @@ -19,7 +21,7 @@ public static ContainerSettings ToContainerSettings(IContainerConfiguration conf initProcess.EnvironmentVariables = new Dictionary(configuration.Environment); } - ContainerSettings settings = new ContainerSettings(configuration.Image) + ContainerSettings settings = new(configuration.Image) { Name = containerName, HostName = configuration.Hostname, @@ -27,31 +29,31 @@ public static ContainerSettings ToContainerSettings(IContainerConfiguration conf InitProcess = initProcess, NetworkingMode = (Microsoft.WSL.Containers.ContainerNetworkingMode)configuration.NetworkingMode, Privileged = configuration.Privileged, - EnableGpu = configuration.EnableGpu, + EnableGpu = configuration.EnableGPU, EnableAutoRemove = configuration.EnableAutoRemove, }; if (configuration.PortBindings.Count > 0) { - settings.PortMappings = new List(configuration.PortBindings.Select(ToPortMapping)); + settings.PortMappings = [.. configuration.PortBindings.Select(ToPortMapping)]; } if (configuration.BindMounts.Count > 0) { - settings.Volumes = new List(configuration.BindMounts.Select(ToVolume)); + settings.Volumes = [.. configuration.BindMounts.Select(ToVolume)]; } if (configuration.NamedVolumes.Count > 0) { - settings.NamedVolumes = new List(configuration.NamedVolumes.Select(ToNamedVolume)); + settings.NamedVolumes = [.. configuration.NamedVolumes.Select(ToNamedVolume)]; } return settings; } - private static ContainerPortMapping ToPortMapping(PortBinding binding) + static ContainerPortMapping ToPortMapping(PortBinding binding) { - ContainerPortMapping mapping = new ContainerPortMapping( + ContainerPortMapping mapping = new( binding.HostPort ?? 0, binding.ContainerPort, (Microsoft.WSL.Containers.PortProtocol)binding.Protocol @@ -64,12 +66,12 @@ private static ContainerPortMapping ToPortMapping(PortBinding binding) return mapping; } - private static ContainerVolume ToVolume(BindMount bindMount) + static ContainerVolume ToVolume(BindMount bindMount) { return new ContainerVolume(bindMount.HostPath, bindMount.ContainerPath, bindMount.ReadOnly); } - private static ContainerNamedVolume ToNamedVolume(NamedVolume namedVolume) + static ContainerNamedVolume ToNamedVolume(NamedVolume namedVolume) { return new ContainerNamedVolume(namedVolume.Name, namedVolume.ContainerPath, namedVolume.ReadOnly); } diff --git a/tests/WslContainers.Azurite.IntegrationTests/WslContainers.Azurite.IntegrationTests.csproj b/src/tests/Azurite.IntegrationTests/Azurite.IntegrationTests.csproj similarity index 100% rename from tests/WslContainers.Azurite.IntegrationTests/WslContainers.Azurite.IntegrationTests.csproj rename to src/tests/Azurite.IntegrationTests/Azurite.IntegrationTests.csproj diff --git a/tests/WslContainers.Azurite.IntegrationTests/AzuriteIntegrationTests.cs b/src/tests/Azurite.IntegrationTests/AzuriteIntegrationTests.cs similarity index 71% rename from tests/WslContainers.Azurite.IntegrationTests/AzuriteIntegrationTests.cs rename to src/tests/Azurite.IntegrationTests/AzuriteIntegrationTests.cs index 27d38a2..1d7365a 100644 --- a/tests/WslContainers.Azurite.IntegrationTests/AzuriteIntegrationTests.cs +++ b/src/tests/Azurite.IntegrationTests/AzuriteIntegrationTests.cs @@ -1,9 +1,4 @@ -using Purview.WslContainers.Azurite; -using Purview.WslContainers.Testing; -using TUnit.Assertions; -using TUnit.Assertions.Extensions; - -namespace WslContainers.Azurite.IntegrationTests; +namespace Purview.WslContainers.Azurite; public class AzuriteIntegrationTests { @@ -12,7 +7,7 @@ public async Task Azurite_StartsAndExposesEndpoints() { await WslcTest.SkipIfUnavailableAsync(); - await using AzuriteContainer azurite = new AzuriteBuilder().Build(); + await using var azurite = new AzuriteBuilder().Build(); await azurite.StartAsync(); @@ -26,14 +21,14 @@ public async Task ConnectionString_UsesMappedPortsAndAccount() { await WslcTest.SkipIfUnavailableAsync(); - await using AzuriteContainer azurite = new AzuriteBuilder().Build(); + await using var azurite = new AzuriteBuilder().Build(); await azurite.StartAsync(); - ushort blobPort = azurite.GetMappedPublicPort(AzuriteBuilder.BlobPort); - ushort queuePort = azurite.GetMappedPublicPort(AzuriteBuilder.QueuePort); - ushort tablePort = azurite.GetMappedPublicPort(AzuriteBuilder.TablePort); - string connectionString = azurite.GetConnectionString(); + var blobPort = azurite.GetMappedPublicPort(AzuriteBuilder.BlobPort); + var queuePort = azurite.GetMappedPublicPort(AzuriteBuilder.QueuePort); + var tablePort = azurite.GetMappedPublicPort(AzuriteBuilder.TablePort); + var connectionString = azurite.GetConnectionString(); await Assert.That(connectionString).Contains("DefaultEndpointsProtocol=http"); await Assert.That(connectionString).Contains("AccountName=devstoreaccount1"); diff --git a/tests/WslContainers.Azurite.UnitTests/WslContainers.Azurite.UnitTests.csproj b/src/tests/Azurite.UnitTests/Azurite.UnitTests.csproj similarity index 100% rename from tests/WslContainers.Azurite.UnitTests/WslContainers.Azurite.UnitTests.csproj rename to src/tests/Azurite.UnitTests/Azurite.UnitTests.csproj diff --git a/tests/WslContainers.Azurite.UnitTests/AzuriteBuilderTests.cs b/src/tests/Azurite.UnitTests/AzuriteBuilderTests.cs similarity index 53% rename from tests/WslContainers.Azurite.UnitTests/AzuriteBuilderTests.cs rename to src/tests/Azurite.UnitTests/AzuriteBuilderTests.cs index 539cdec..e2600e3 100644 --- a/tests/WslContainers.Azurite.UnitTests/AzuriteBuilderTests.cs +++ b/src/tests/Azurite.UnitTests/AzuriteBuilderTests.cs @@ -1,35 +1,39 @@ -using Purview.WslContainers; -using Purview.WslContainers.Azurite; -using TUnit.Assertions; -using TUnit.Assertions.Extensions; +using Purview.WslContainers.Waiting; -namespace WslContainers.Azurite.UnitTests; +namespace Purview.WslContainers.Azurite; public class AzuriteBuilderTests { + static readonly string[] Expected = + [ + "azurite", + "--blobHost", + "0.0.0.0", + "--queueHost", + "0.0.0.0", + "--tableHost", + "0.0.0.0", + ]; + [Test] public async Task BuildConfig_AppliesDefaults() { - AzuriteConfiguration configuration = new AzuriteBuilder().BuildConfigurationForTesting(); + var configuration = new AzuriteBuilder().BuildConfigurationForTesting(); await Assert.That(configuration.Image).IsEqualTo("mcr.microsoft.com/azure-storage/azurite:latest"); - await Assert - .That(configuration.Command) - .IsEquivalentTo( - new[] { "azurite", "--blobHost", "0.0.0.0", "--queueHost", "0.0.0.0", "--tableHost", "0.0.0.0" } - ); - await Assert.That(configuration.PortBindings).HasCount().EqualTo(3); + await Assert.That(configuration.Command).IsEquivalentTo(Expected); + await Assert.That(configuration.PortBindings.Count).IsEqualTo(3); await Assert.That(configuration.PortBindings).Contains(p => p.ContainerPort == 10000 && p.AssignRandomHostPort); await Assert.That(configuration.PortBindings).Contains(p => p.ContainerPort == 10001 && p.AssignRandomHostPort); await Assert.That(configuration.PortBindings).Contains(p => p.ContainerPort == 10002 && p.AssignRandomHostPort); - await Assert.That(configuration.WaitStrategies).HasCount().EqualTo(1); + await Assert.That(configuration.WaitStrategies.Count).IsEqualTo(1); await Assert.That(configuration.WaitStrategies[0]).IsTypeOf(); } [Test] public async Task BuildConfig_SupportsCustomImage() { - AzuriteConfiguration configuration = new AzuriteBuilder("custom/azurite:1").BuildConfigurationForTesting(); + var configuration = new AzuriteBuilder("custom/azurite:1").BuildConfigurationForTesting(); await Assert.That(configuration.Image).IsEqualTo("docker.io/custom/azurite:1"); } diff --git a/tests/WslContainers.IntegrationTests/WslContainers.IntegrationTests.csproj b/src/tests/MsSql.IntegrationTests/MsSql.IntegrationTests.csproj similarity index 100% rename from tests/WslContainers.IntegrationTests/WslContainers.IntegrationTests.csproj rename to src/tests/MsSql.IntegrationTests/MsSql.IntegrationTests.csproj diff --git a/src/tests/MsSql.IntegrationTests/MsSqlIntegrationTests.cs b/src/tests/MsSql.IntegrationTests/MsSqlIntegrationTests.cs new file mode 100644 index 0000000..fe21b4f --- /dev/null +++ b/src/tests/MsSql.IntegrationTests/MsSqlIntegrationTests.cs @@ -0,0 +1,40 @@ +using Microsoft.Data.SqlClient; + +namespace Purview.WslContainers.MsSql; + +public class MsSqlIntegrationTests +{ + [Test] + public async Task MsSql_ConnectsAndRunsQuery() + { + await WslcTest.SkipIfUnavailableAsync(); + + await using var sqlServer = new MsSqlBuilder().WithPassword("SomeStrong!Password1").AcceptLicense().Build(); + + await sqlServer.StartAsync(); + + await using SqlConnection connection = new(sqlServer.GetConnectionString()); + await connection.OpenAsync(); + await using SqlCommand command = new("SELECT 1", connection); + var result = await command.ExecuteScalarAsync(); + + await Assert.That(Convert.ToInt32(result, System.Globalization.CultureInfo.InvariantCulture)).IsEqualTo(1); + } + + [Test] + public async Task ConnectionString_UsesMappedRandomPort() + { + await WslcTest.SkipIfUnavailableAsync(); + + await using var sqlServer = new MsSqlBuilder().WithPassword("SomeStrong!Password1").AcceptLicense().Build(); + + await sqlServer.StartAsync(); + + SqlConnectionStringBuilder builder = [with(sqlServer.GetConnectionString())]; + var port = sqlServer.GetMappedPublicPort(MsSqlBuilder.MsSqlPort); + + await Assert.That(builder.DataSource).IsEqualTo($"127.0.0.1,{port}"); + await Assert.That(port).IsGreaterThan((ushort)0); + await Assert.That(builder.UserID).IsEqualTo("sa"); + } +} diff --git a/tests/WslContainers.MinIo.IntegrationTests/WslContainers.MinIo.IntegrationTests.csproj b/src/tests/MsSql.UnitTests/MsSql.UnitTests.csproj similarity index 100% rename from tests/WslContainers.MinIo.IntegrationTests/WslContainers.MinIo.IntegrationTests.csproj rename to src/tests/MsSql.UnitTests/MsSql.UnitTests.csproj diff --git a/tests/WslContainers.MsSql.UnitTests/MsSqlBuilderTests.cs b/src/tests/MsSql.UnitTests/MsSqlBuilderTests.cs similarity index 69% rename from tests/WslContainers.MsSql.UnitTests/MsSqlBuilderTests.cs rename to src/tests/MsSql.UnitTests/MsSqlBuilderTests.cs index cfb2646..173a10e 100644 --- a/tests/WslContainers.MsSql.UnitTests/MsSqlBuilderTests.cs +++ b/src/tests/MsSql.UnitTests/MsSqlBuilderTests.cs @@ -1,16 +1,13 @@ -using Purview.WslContainers; -using Purview.WslContainers.MsSql; -using TUnit.Assertions; -using TUnit.Assertions.Extensions; +using Purview.WslContainers.Runtime; -namespace WslContainers.MsSql.UnitTests; +namespace Purview.WslContainers.MsSql; public class MsSqlBuilderTests { [Test] public async Task Build_WithoutAcceptLicense_Throws() { - MsSqlBuilder builder = new MsSqlBuilder().WithPassword("SomeStrong!Password1"); + var builder = new MsSqlBuilder().WithPassword("SomeStrong!Password1"); await Assert.That(() => builder.Build()).Throws(); } @@ -18,7 +15,7 @@ public async Task Build_WithoutAcceptLicense_Throws() [Test] public async Task Build_WithWeakPassword_Throws() { - MsSqlBuilder builder = new MsSqlBuilder().WithPassword("short").AcceptLicense(); + var builder = new MsSqlBuilder().WithPassword("short").AcceptLicense(); await Assert.That(() => builder.Build()).Throws(); } @@ -26,21 +23,21 @@ public async Task Build_WithWeakPassword_Throws() [Test] public async Task BuildConfig_AppliesDefaults() { - MsSqlConfiguration configuration = new MsSqlBuilder().BuildConfigurationForTesting(); + var configuration = new MsSqlBuilder().BuildConfigurationForTesting(); await Assert.That(configuration.Image).IsEqualTo("mcr.microsoft.com/mssql/server:2022-latest"); await Assert.That(configuration.Password.Value).IsEqualTo("YourStrong!Passw0rd"); await Assert.That(configuration.AcceptLicense).IsFalse(); - await Assert.That(configuration.PortBindings).HasCount().EqualTo(1); + await Assert.That(configuration.PortBindings.Count).IsEqualTo(1); await Assert.That(configuration.PortBindings[0].ContainerPort).IsEqualTo((ushort)1433); await Assert.That(configuration.PortBindings[0].AssignRandomHostPort).IsTrue(); - await Assert.That(configuration.WaitStrategies).HasCount().EqualTo(1); + await Assert.That(configuration.WaitStrategies.Count).IsEqualTo(1); } [Test] public async Task BuildConfig_HonoursModuleSetters() { - MsSqlConfiguration configuration = new MsSqlBuilder() + var configuration = new MsSqlBuilder() .WithPassword("SomeStrong!Password1") .AcceptLicense() .BuildConfigurationForTesting(); diff --git a/tests/WslContainers.MySql.IntegrationTests/WslContainers.MySql.IntegrationTests.csproj b/src/tests/MySql.IntegrationTests/MySql.IntegrationTests.csproj similarity index 100% rename from tests/WslContainers.MySql.IntegrationTests/WslContainers.MySql.IntegrationTests.csproj rename to src/tests/MySql.IntegrationTests/MySql.IntegrationTests.csproj diff --git a/tests/WslContainers.MySql.IntegrationTests/MySqlIntegrationTests.cs b/src/tests/MySql.IntegrationTests/MySqlIntegrationTests.cs similarity index 51% rename from tests/WslContainers.MySql.IntegrationTests/MySqlIntegrationTests.cs rename to src/tests/MySql.IntegrationTests/MySqlIntegrationTests.cs index 55d9589..a88f51d 100644 --- a/tests/WslContainers.MySql.IntegrationTests/MySqlIntegrationTests.cs +++ b/src/tests/MySql.IntegrationTests/MySqlIntegrationTests.cs @@ -1,10 +1,6 @@ using MySqlConnector; -using Purview.WslContainers.MySql; -using Purview.WslContainers.Testing; -using TUnit.Assertions; -using TUnit.Assertions.Extensions; -namespace WslContainers.MySql.IntegrationTests; +namespace Purview.WslContainers.MySql; public class MySqlIntegrationTests { @@ -13,7 +9,7 @@ public async Task MySql_ConnectsAndRunsQuery() { await WslcTest.SkipIfUnavailableAsync(); - await using MySqlContainer mySql = new MySqlBuilder() + await using var mySql = new MySqlBuilder() .WithDatabase("integration") .WithUsername("test") .WithPassword("test") @@ -21,10 +17,10 @@ public async Task MySql_ConnectsAndRunsQuery() await mySql.StartAsync(); - await using MySqlConnection connection = new MySqlConnection(mySql.GetConnectionString()); + await using MySqlConnection connection = new(mySql.GetConnectionString()); await connection.OpenAsync(); - await using MySqlCommand command = new MySqlCommand("SELECT 1", connection); - object result = await command.ExecuteScalarAsync(); + await using MySqlCommand command = new("SELECT 1", connection); + var result = await command.ExecuteScalarAsync(); await Assert.That(Convert.ToInt32(result, System.Globalization.CultureInfo.InvariantCulture)).IsEqualTo(1); } @@ -34,12 +30,12 @@ public async Task ConnectionString_UsesMappedRandomPort() { await WslcTest.SkipIfUnavailableAsync(); - await using MySqlContainer mySql = new MySqlBuilder().Build(); + await using var mySql = new MySqlBuilder().Build(); await mySql.StartAsync(); - MySqlConnectionStringBuilder builder = new MySqlConnectionStringBuilder(mySql.GetConnectionString()); - ushort port = mySql.GetMappedPublicPort(MySqlBuilder.MySqlPort); + MySqlConnectionStringBuilder builder = [with(mySql.GetConnectionString())]; + var port = mySql.GetMappedPublicPort(MySqlBuilder.MySqlPort); await Assert.That(builder.Server).IsEqualTo("127.0.0.1"); await Assert.That(builder.Port).IsEqualTo(port); diff --git a/tests/WslContainers.MinIo.UnitTests/WslContainers.MinIo.UnitTests.csproj b/src/tests/MySql.UnitTests/MySql.UnitTests.csproj similarity index 100% rename from tests/WslContainers.MinIo.UnitTests/WslContainers.MinIo.UnitTests.csproj rename to src/tests/MySql.UnitTests/MySql.UnitTests.csproj diff --git a/tests/WslContainers.MySql.UnitTests/MySqlBuilderTests.cs b/src/tests/MySql.UnitTests/MySqlBuilderTests.cs similarity index 65% rename from tests/WslContainers.MySql.UnitTests/MySqlBuilderTests.cs rename to src/tests/MySql.UnitTests/MySqlBuilderTests.cs index fb4ab81..8f3e5f0 100644 --- a/tests/WslContainers.MySql.UnitTests/MySqlBuilderTests.cs +++ b/src/tests/MySql.UnitTests/MySqlBuilderTests.cs @@ -1,16 +1,13 @@ -using Purview.WslContainers; -using Purview.WslContainers.MySql; -using TUnit.Assertions; -using TUnit.Assertions.Extensions; +using Purview.WslContainers.Runtime; -namespace WslContainers.MySql.UnitTests; +namespace Purview.WslContainers.MySql; public class MySqlBuilderTests { [Test] public async Task BuildConfig_AppliesDefaults() { - MySqlConfiguration configuration = new MySqlBuilder().BuildConfigurationForTesting(); + var configuration = new MySqlBuilder().BuildConfigurationForTesting(); await Assert.That(configuration.Image).IsEqualTo("docker.io/library/mysql:8"); await Assert.That(configuration.Database).IsEqualTo("test"); @@ -19,15 +16,15 @@ public async Task BuildConfig_AppliesDefaults() await Assert.That(configuration.Environment["MYSQL_DATABASE"]).IsEqualTo("test"); await Assert.That(configuration.Environment["MYSQL_USER"]).IsEqualTo("test"); await Assert.That(configuration.Environment["MYSQL_PASSWORD"]).IsEqualTo("test"); - await Assert.That(configuration.PortBindings).HasCount().EqualTo(1); + await Assert.That(configuration.PortBindings.Count).IsEqualTo(1); await Assert.That(configuration.PortBindings[0].ContainerPort).IsEqualTo((ushort)3306); - await Assert.That(configuration.WaitStrategies).HasCount().EqualTo(1); + await Assert.That(configuration.WaitStrategies.Count).IsEqualTo(1); } [Test] public async Task BuildConfig_EmptyPassword_Throws() { - MySqlBuilder builder = new MySqlBuilder().WithPassword(string.Empty); + var builder = new MySqlBuilder().WithPassword(string.Empty); await Assert.That(() => builder.Build()).Throws(); } diff --git a/tests/WslContainers.Nats.IntegrationTests/WslContainers.Nats.IntegrationTests.csproj b/src/tests/Nats.IntegrationTests/Nats.IntegrationTests.csproj similarity index 100% rename from tests/WslContainers.Nats.IntegrationTests/WslContainers.Nats.IntegrationTests.csproj rename to src/tests/Nats.IntegrationTests/Nats.IntegrationTests.csproj diff --git a/tests/WslContainers.Nats.IntegrationTests/NatsIntegrationTests.cs b/src/tests/Nats.IntegrationTests/NatsIntegrationTests.cs similarity index 57% rename from tests/WslContainers.Nats.IntegrationTests/NatsIntegrationTests.cs rename to src/tests/Nats.IntegrationTests/NatsIntegrationTests.cs index d48f53c..3e72d1e 100644 --- a/tests/WslContainers.Nats.IntegrationTests/NatsIntegrationTests.cs +++ b/src/tests/Nats.IntegrationTests/NatsIntegrationTests.cs @@ -1,10 +1,6 @@ using NATS.Client.Core; -using Purview.WslContainers.Nats; -using Purview.WslContainers.Testing; -using TUnit.Assertions; -using TUnit.Assertions.Extensions; -namespace WslContainers.Nats.IntegrationTests; +namespace Purview.WslContainers.Nats; public class NatsIntegrationTests { @@ -13,13 +9,13 @@ public async Task Nats_RespondsToPing() { await WslcTest.SkipIfUnavailableAsync(); - await using NatsContainer nats = new NatsBuilder().Build(); + await using var nats = new NatsBuilder().Build(); await nats.StartAsync(); await WslcTest.WaitForTcpAsync(nats.GetMappedPublicPort(NatsBuilder.ClientPort), TimeSpan.FromSeconds(30)); - await using NatsConnection client = new NatsConnection(new NatsOpts { Url = nats.GetConnectionString() }); - TimeSpan roundTrip = await client.PingAsync(); + await using NatsConnection client = new(new NatsOpts { Url = nats.GetConnectionString() }); + var roundTrip = await client.PingAsync(); await Assert.That(roundTrip).IsGreaterThan(TimeSpan.Zero); } @@ -29,12 +25,12 @@ public async Task Endpoints_UseMappedRandomPorts() { await WslcTest.SkipIfUnavailableAsync(); - await using NatsContainer nats = new NatsBuilder().Build(); + await using var nats = new NatsBuilder().Build(); await nats.StartAsync(); - ushort clientPort = nats.GetMappedPublicPort(NatsBuilder.ClientPort); - ushort monitoringPort = nats.GetMappedPublicPort(NatsBuilder.MonitoringPort); + var clientPort = nats.GetMappedPublicPort(NatsBuilder.ClientPort); + var monitoringPort = nats.GetMappedPublicPort(NatsBuilder.MonitoringPort); await Assert.That(nats.GetClientEndpoint().Host).IsEqualTo("127.0.0.1"); await Assert.That(nats.GetClientEndpoint().Port).IsEqualTo(clientPort); diff --git a/tests/WslContainers.MsSql.IntegrationTests/WslContainers.MsSql.IntegrationTests.csproj b/src/tests/Nats.UnitTests/Nats.UnitTests.csproj similarity index 100% rename from tests/WslContainers.MsSql.IntegrationTests/WslContainers.MsSql.IntegrationTests.csproj rename to src/tests/Nats.UnitTests/Nats.UnitTests.csproj diff --git a/tests/WslContainers.Nats.UnitTests/NatsBuilderTests.cs b/src/tests/Nats.UnitTests/NatsBuilderTests.cs similarity index 55% rename from tests/WslContainers.Nats.UnitTests/NatsBuilderTests.cs rename to src/tests/Nats.UnitTests/NatsBuilderTests.cs index 722a2be..96e4ea1 100644 --- a/tests/WslContainers.Nats.UnitTests/NatsBuilderTests.cs +++ b/src/tests/Nats.UnitTests/NatsBuilderTests.cs @@ -1,22 +1,19 @@ -using Purview.WslContainers; -using Purview.WslContainers.Nats; -using TUnit.Assertions; -using TUnit.Assertions.Extensions; +using Purview.WslContainers.Waiting; -namespace WslContainers.Nats.UnitTests; +namespace Purview.WslContainers.Nats; public class NatsBuilderTests { [Test] public async Task BuildConfig_AppliesDefaults() { - NatsConfiguration configuration = new NatsBuilder().BuildConfigurationForTesting(); + var configuration = new NatsBuilder().BuildConfigurationForTesting(); await Assert.That(configuration.Image).IsEqualTo("docker.io/library/nats:2"); - await Assert.That(configuration.PortBindings).HasCount().EqualTo(2); + await Assert.That(configuration.PortBindings.Count).IsEqualTo(2); await Assert.That(configuration.PortBindings).Contains(p => p.ContainerPort == 4222 && p.AssignRandomHostPort); await Assert.That(configuration.PortBindings).Contains(p => p.ContainerPort == 8222 && p.AssignRandomHostPort); - await Assert.That(configuration.WaitStrategies).HasCount().EqualTo(1); + await Assert.That(configuration.WaitStrategies.Count).IsEqualTo(1); await Assert.That(configuration.WaitStrategies[0]).IsTypeOf(); } } diff --git a/tests/WslContainers.MsSql.UnitTests/WslContainers.MsSql.UnitTests.csproj b/src/tests/PostgreSql.IntegrationTests/PostgreSql.IntegrationTests.csproj similarity index 100% rename from tests/WslContainers.MsSql.UnitTests/WslContainers.MsSql.UnitTests.csproj rename to src/tests/PostgreSql.IntegrationTests/PostgreSql.IntegrationTests.csproj diff --git a/tests/WslContainers.PostgreSql.IntegrationTests/PostgreSqlIntegrationTests.cs b/src/tests/PostgreSql.IntegrationTests/PostgreSqlIntegrationTests.cs similarity index 57% rename from tests/WslContainers.PostgreSql.IntegrationTests/PostgreSqlIntegrationTests.cs rename to src/tests/PostgreSql.IntegrationTests/PostgreSqlIntegrationTests.cs index 456a13e..ebefacf 100644 --- a/tests/WslContainers.PostgreSql.IntegrationTests/PostgreSqlIntegrationTests.cs +++ b/src/tests/PostgreSql.IntegrationTests/PostgreSqlIntegrationTests.cs @@ -1,11 +1,7 @@ using Npgsql; -using Purview.WslContainers; -using Purview.WslContainers.PostgreSql; -using Purview.WslContainers.Testing; -using TUnit.Assertions; -using TUnit.Assertions.Extensions; +using PurviewContainerState = Purview.WslContainers.Containers.ContainerState; -namespace WslContainers.PostgreSql.IntegrationTests; +namespace Purview.WslContainers.PostgreSql; public class PostgreSqlIntegrationTests { @@ -14,7 +10,7 @@ public async Task PostgreSql_ConnectsAndRunsQuery() { await WslcTest.SkipIfUnavailableAsync(); - await using PostgreSqlContainer postgres = new PostgreSqlBuilder() + await using var postgres = new PostgreSqlBuilder() .WithDatabase("integration") .WithUsername("postgres") .WithPassword("postgres") @@ -22,10 +18,10 @@ public async Task PostgreSql_ConnectsAndRunsQuery() await postgres.StartAsync(); - await using NpgsqlConnection connection = new NpgsqlConnection(postgres.GetConnectionString()); + await using NpgsqlConnection connection = new(postgres.GetConnectionString()); await connection.OpenAsync(); - await using NpgsqlCommand command = new NpgsqlCommand("SELECT 1", connection); - object result = await command.ExecuteScalarAsync(); + await using NpgsqlCommand command = new("SELECT 1", connection); + var result = await command.ExecuteScalarAsync(); await Assert.That(Convert.ToInt32(result, System.Globalization.CultureInfo.InvariantCulture)).IsEqualTo(1); } @@ -35,7 +31,7 @@ public async Task ConnectionString_UsesMappedRandomPort() { await WslcTest.SkipIfUnavailableAsync(); - await using PostgreSqlContainer postgres = new PostgreSqlBuilder() + await using var postgres = new PostgreSqlBuilder() .WithDatabase("integration") .WithUsername("postgres") .WithPassword("postgres") @@ -43,7 +39,7 @@ public async Task ConnectionString_UsesMappedRandomPort() await postgres.StartAsync(); - NpgsqlConnectionStringBuilder builder = new NpgsqlConnectionStringBuilder(postgres.GetConnectionString()); + NpgsqlConnectionStringBuilder builder = [with(postgres.GetConnectionString())]; await Assert.That(builder.Host).IsEqualTo("localhost"); await Assert.That(builder.Database).IsEqualTo("integration"); @@ -57,10 +53,10 @@ public async Task StartAsync_WaitsForPostgresReadiness() { await WslcTest.SkipIfUnavailableAsync(); - await using PostgreSqlContainer postgres = new PostgreSqlBuilder().Build(); + await using var postgres = new PostgreSqlBuilder().Build(); await postgres.StartAsync(); - await Assert.That(postgres.State).IsEqualTo(ContainerState.Running); + await Assert.That(postgres.State).IsEqualTo(PurviewContainerState.Running); } } diff --git a/tests/WslContainers.MySql.UnitTests/WslContainers.MySql.UnitTests.csproj b/src/tests/PostgreSql.UnitTests/PostgreSql.UnitTests.csproj similarity index 100% rename from tests/WslContainers.MySql.UnitTests/WslContainers.MySql.UnitTests.csproj rename to src/tests/PostgreSql.UnitTests/PostgreSql.UnitTests.csproj diff --git a/tests/WslContainers.PostgreSql.UnitTests/PostgreSqlBuilderTests.cs b/src/tests/PostgreSql.UnitTests/PostgreSqlBuilderTests.cs similarity index 76% rename from tests/WslContainers.PostgreSql.UnitTests/PostgreSqlBuilderTests.cs rename to src/tests/PostgreSql.UnitTests/PostgreSqlBuilderTests.cs index 313d1cc..12c93dc 100644 --- a/tests/WslContainers.PostgreSql.UnitTests/PostgreSqlBuilderTests.cs +++ b/src/tests/PostgreSql.UnitTests/PostgreSqlBuilderTests.cs @@ -1,16 +1,15 @@ -using Purview.WslContainers; -using Purview.WslContainers.PostgreSql; -using TUnit.Assertions; -using TUnit.Assertions.Extensions; +using Purview.WslContainers.Diagnostics; +using Purview.WslContainers.Runtime; +using Purview.WslContainers.Waiting; -namespace WslContainers.PostgreSql.UnitTests; +namespace Purview.WslContainers.PostgreSql; public class PostgreSqlBuilderTests { [Test] public async Task BuildConfig_AppliesDefaults() { - PostgreSqlConfiguration configuration = new PostgreSqlBuilder().BuildConfigurationForTesting(); + var configuration = new PostgreSqlBuilder().BuildConfigurationForTesting(); await Assert.That(configuration.Image).IsEqualTo("docker.io/library/postgres:17"); await Assert.That(configuration.Database).IsEqualTo("postgres"); @@ -19,17 +18,17 @@ public async Task BuildConfig_AppliesDefaults() await Assert.That(configuration.Environment["POSTGRES_DB"]).IsEqualTo("postgres"); await Assert.That(configuration.Environment["POSTGRES_USER"]).IsEqualTo("postgres"); await Assert.That(configuration.Environment["POSTGRES_PASSWORD"]).IsEqualTo("postgres"); - await Assert.That(configuration.PortBindings).HasCount().EqualTo(1); + await Assert.That(configuration.PortBindings.Count).IsEqualTo(1); await Assert.That(configuration.PortBindings[0].ContainerPort).IsEqualTo((ushort)5432); await Assert.That(configuration.PortBindings[0].AssignRandomHostPort).IsTrue(); - await Assert.That(configuration.WaitStrategies).HasCount().EqualTo(1); + await Assert.That(configuration.WaitStrategies.Count).IsEqualTo(1); await Assert.That(configuration.WaitStrategies[0]).IsTypeOf(); } [Test] public async Task BuildConfig_HonoursModuleSetters() { - PostgreSqlConfiguration configuration = new PostgreSqlBuilder() + var configuration = new PostgreSqlBuilder() .WithDatabase("integration") .WithUsername("app") .WithPassword("s3cret") @@ -46,7 +45,7 @@ public async Task BuildConfig_HonoursModuleSetters() [Test] public async Task BuildConfig_EmptyPassword_Throws() { - PostgreSqlBuilder builder = new PostgreSqlBuilder().WithPassword(string.Empty); + var builder = new PostgreSqlBuilder().WithPassword(string.Empty); await Assert.That(() => builder.Build()).Throws(); } @@ -54,7 +53,7 @@ public async Task BuildConfig_EmptyPassword_Throws() [Test] public async Task ConfigurationToString_RedactsPasswordAndEnvironment() { - PostgreSqlConfiguration configuration = new PostgreSqlConfiguration + PostgreSqlConfiguration configuration = new() { Image = "postgres:17", Environment = new Dictionary @@ -65,7 +64,7 @@ public async Task ConfigurationToString_RedactsPasswordAndEnvironment() Password = Secret.From("s3cret-password"), }; - string rendered = configuration.ToString(); + var rendered = configuration.ToString(); await Assert.That(rendered).DoesNotContain("s3cret-password"); await Assert.That(rendered).Contains("postgres"); diff --git a/tests/WslContainers.RabbitMq.IntegrationTests/WslContainers.RabbitMq.IntegrationTests.csproj b/src/tests/RabbitMq.IntegrationTests/RabbitMq.IntegrationTests.csproj similarity index 100% rename from tests/WslContainers.RabbitMq.IntegrationTests/WslContainers.RabbitMq.IntegrationTests.csproj rename to src/tests/RabbitMq.IntegrationTests/RabbitMq.IntegrationTests.csproj diff --git a/tests/WslContainers.RabbitMq.IntegrationTests/RabbitMqIntegrationTests.cs b/src/tests/RabbitMq.IntegrationTests/RabbitMqIntegrationTests.cs similarity index 55% rename from tests/WslContainers.RabbitMq.IntegrationTests/RabbitMqIntegrationTests.cs rename to src/tests/RabbitMq.IntegrationTests/RabbitMqIntegrationTests.cs index bb638bb..837944f 100644 --- a/tests/WslContainers.RabbitMq.IntegrationTests/RabbitMqIntegrationTests.cs +++ b/src/tests/RabbitMq.IntegrationTests/RabbitMqIntegrationTests.cs @@ -1,11 +1,7 @@ using System.Text; -using Purview.WslContainers.RabbitMq; -using Purview.WslContainers.Testing; using RabbitMQ.Client; -using TUnit.Assertions; -using TUnit.Assertions.Extensions; -namespace WslContainers.RabbitMq.IntegrationTests; +namespace Purview.WslContainers.RabbitMq; public class RabbitMqIntegrationTests { @@ -14,16 +10,13 @@ public async Task RabbitMq_PublishesAndConsumes() { await WslcTest.SkipIfUnavailableAsync(); - await using RabbitMqContainer rabbit = new RabbitMqBuilder() - .WithUsername("guest") - .WithPassword("guest") - .Build(); + await using var rabbit = new RabbitMqBuilder().WithUsername("guest").WithPassword("guest").Build(); await rabbit.StartAsync(); - ConnectionFactory factory = new ConnectionFactory { Uri = rabbit.GetAmqpEndpoint() }; - using IConnection connection = await factory.CreateConnectionAsync(); - using IChannel channel = await connection.CreateChannelAsync(); + ConnectionFactory factory = new() { Uri = rabbit.GetAmqpEndpoint() }; + using var connection = await factory.CreateConnectionAsync(); + using var channel = await connection.CreateChannelAsync(); const string queue = "wslc-test-queue"; await channel.QueueDeclareAsync(queue, durable: false, exclusive: false, autoDelete: true); @@ -33,7 +26,7 @@ await channel.BasicPublishAsync( body: Encoding.UTF8.GetBytes("hello-rabbitmq") ); - BasicGetResult? result = await channel.BasicGetAsync(queue, autoAck: true); + var result = await channel.BasicGetAsync(queue, autoAck: true); await Assert.That(result).IsNotNull(); await Assert.That(Encoding.UTF8.GetString(result!.Body.ToArray())).IsEqualTo("hello-rabbitmq"); @@ -44,12 +37,12 @@ public async Task Endpoints_UseMappedRandomPorts() { await WslcTest.SkipIfUnavailableAsync(); - await using RabbitMqContainer rabbit = new RabbitMqBuilder().Build(); + await using var rabbit = new RabbitMqBuilder().Build(); await rabbit.StartAsync(); - ushort amqpPort = rabbit.GetMappedPublicPort(RabbitMqBuilder.AmqpPort); - ushort managementPort = rabbit.GetMappedPublicPort(RabbitMqBuilder.ManagementPort); + var amqpPort = rabbit.GetMappedPublicPort(RabbitMqBuilder.AmqpPort); + var managementPort = rabbit.GetMappedPublicPort(RabbitMqBuilder.ManagementPort); await Assert.That(rabbit.GetAmqpEndpoint().ToString()).IsEqualTo($"amqp://guest:guest@localhost:{amqpPort}/"); await Assert.That(rabbit.GetManagementEndpoint().ToString()).IsEqualTo($"http://localhost:{managementPort}/"); diff --git a/tests/WslContainers.Nats.UnitTests/WslContainers.Nats.UnitTests.csproj b/src/tests/RabbitMq.UnitTests/RabbitMq.UnitTests.csproj similarity index 100% rename from tests/WslContainers.Nats.UnitTests/WslContainers.Nats.UnitTests.csproj rename to src/tests/RabbitMq.UnitTests/RabbitMq.UnitTests.csproj diff --git a/tests/WslContainers.RabbitMq.UnitTests/RabbitMqBuilderTests.cs b/src/tests/RabbitMq.UnitTests/RabbitMqBuilderTests.cs similarity index 73% rename from tests/WslContainers.RabbitMq.UnitTests/RabbitMqBuilderTests.cs rename to src/tests/RabbitMq.UnitTests/RabbitMqBuilderTests.cs index 2f98668..a4524d6 100644 --- a/tests/WslContainers.RabbitMq.UnitTests/RabbitMqBuilderTests.cs +++ b/src/tests/RabbitMq.UnitTests/RabbitMqBuilderTests.cs @@ -1,32 +1,30 @@ -using Purview.WslContainers; -using Purview.WslContainers.RabbitMq; -using TUnit.Assertions; -using TUnit.Assertions.Extensions; +using Purview.WslContainers.Runtime; +using Purview.WslContainers.Waiting; -namespace WslContainers.RabbitMq.UnitTests; +namespace Purview.WslContainers.RabbitMq; public class RabbitMqBuilderTests { [Test] public async Task BuildConfig_AppliesDefaults() { - RabbitMqConfiguration configuration = new RabbitMqBuilder().BuildConfigurationForTesting(); + var configuration = new RabbitMqBuilder().BuildConfigurationForTesting(); await Assert.That(configuration.Image).IsEqualTo("docker.io/library/rabbitmq:3-management"); await Assert.That(configuration.Username).IsEqualTo("guest"); await Assert.That(configuration.Password.Value).IsEqualTo("guest"); await Assert.That(configuration.VirtualHost).IsEqualTo("/"); - await Assert.That(configuration.PortBindings).HasCount().EqualTo(2); + await Assert.That(configuration.PortBindings.Count).IsEqualTo(2); await Assert.That(configuration.PortBindings).Contains(p => p.ContainerPort == 5672); await Assert.That(configuration.PortBindings).Contains(p => p.ContainerPort == 15672); - await Assert.That(configuration.WaitStrategies).HasCount().EqualTo(1); + await Assert.That(configuration.WaitStrategies.Count).IsEqualTo(1); await Assert.That(configuration.WaitStrategies[0]).IsTypeOf(); } [Test] public async Task BuildConfig_HonoursModuleSetters() { - RabbitMqConfiguration configuration = new RabbitMqBuilder() + var configuration = new RabbitMqBuilder() .WithUsername("app") .WithPassword("s3cret") .WithVirtualHost("myvhost") @@ -43,7 +41,7 @@ public async Task BuildConfig_HonoursModuleSetters() [Test] public async Task BuildConfig_EmptyPassword_Throws() { - RabbitMqBuilder builder = new RabbitMqBuilder().WithPassword(string.Empty); + var builder = new RabbitMqBuilder().WithPassword(string.Empty); await Assert.That(() => builder.Build()).Throws(); } diff --git a/tests/WslContainers.Redis.IntegrationTests/WslContainers.Redis.IntegrationTests.csproj b/src/tests/Redis.IntegrationTests/Redis.IntegrationTests.csproj similarity index 100% rename from tests/WslContainers.Redis.IntegrationTests/WslContainers.Redis.IntegrationTests.csproj rename to src/tests/Redis.IntegrationTests/Redis.IntegrationTests.csproj diff --git a/src/tests/Redis.IntegrationTests/RedisIntegrationTests.cs b/src/tests/Redis.IntegrationTests/RedisIntegrationTests.cs new file mode 100644 index 0000000..3c47a9d --- /dev/null +++ b/src/tests/Redis.IntegrationTests/RedisIntegrationTests.cs @@ -0,0 +1,38 @@ +using StackExchange.Redis; + +namespace Purview.WslContainers.Redis; + +public class RedisIntegrationTests +{ + [Test] + public async Task Redis_RespondsToPing() + { + await WslcTest.SkipIfUnavailableAsync(); + + await using var redis = new RedisBuilder().Build(); + + await redis.StartAsync(); + + await using var connection = await ConnectionMultiplexer.ConnectAsync(redis.GetConnectionString()); + var db = connection.GetDatabase(); + var pong = await db.ExecuteAsync("PING"); + + await Assert.That(pong.ToString()).IsEqualTo("PONG"); + } + + [Test] + public async Task ConnectionString_UsesMappedRandomPort() + { + await WslcTest.SkipIfUnavailableAsync(); + + await using var redis = new RedisBuilder().Build(); + + await redis.StartAsync(); + + var connectionString = redis.GetConnectionString(); + var port = redis.GetMappedPublicPort(RedisBuilder.RedisPort); + + await Assert.That(connectionString).IsEqualTo($"localhost:{port}"); + await Assert.That(port).IsNotEqualTo(RedisBuilder.RedisPort); + } +} diff --git a/tests/WslContainers.PostgreSql.IntegrationTests/WslContainers.PostgreSql.IntegrationTests.csproj b/src/tests/Redis.UnitTests/Redis.UnitTests.csproj similarity index 100% rename from tests/WslContainers.PostgreSql.IntegrationTests/WslContainers.PostgreSql.IntegrationTests.csproj rename to src/tests/Redis.UnitTests/Redis.UnitTests.csproj diff --git a/tests/WslContainers.Redis.UnitTests/RedisBuilderTests.cs b/src/tests/Redis.UnitTests/RedisBuilderTests.cs similarity index 51% rename from tests/WslContainers.Redis.UnitTests/RedisBuilderTests.cs rename to src/tests/Redis.UnitTests/RedisBuilderTests.cs index fa14446..4629e9f 100644 --- a/tests/WslContainers.Redis.UnitTests/RedisBuilderTests.cs +++ b/src/tests/Redis.UnitTests/RedisBuilderTests.cs @@ -1,29 +1,26 @@ -using Purview.WslContainers; -using Purview.WslContainers.Redis; -using TUnit.Assertions; -using TUnit.Assertions.Extensions; +using Purview.WslContainers.Waiting; -namespace WslContainers.Redis.UnitTests; +namespace Purview.WslContainers.Redis; public class RedisBuilderTests { [Test] public async Task BuildConfig_AppliesDefaults() { - RedisConfiguration configuration = new RedisBuilder().BuildConfigurationForTesting(); + var configuration = new RedisBuilder().BuildConfigurationForTesting(); await Assert.That(configuration.Image).IsEqualTo("docker.io/library/redis:7"); - await Assert.That(configuration.PortBindings).HasCount().EqualTo(1); + await Assert.That(configuration.PortBindings.Count).IsEqualTo(1); await Assert.That(configuration.PortBindings[0].ContainerPort).IsEqualTo((ushort)6379); await Assert.That(configuration.PortBindings[0].AssignRandomHostPort).IsTrue(); - await Assert.That(configuration.WaitStrategies).HasCount().EqualTo(1); + await Assert.That(configuration.WaitStrategies.Count).IsEqualTo(1); await Assert.That(configuration.WaitStrategies[0]).IsTypeOf(); } [Test] public async Task BuildConfig_SupportsCompatibleImages() { - RedisConfiguration configuration = new RedisBuilder("valkey/valkey:7").BuildConfigurationForTesting(); + var configuration = new RedisBuilder("valkey/valkey:7").BuildConfigurationForTesting(); await Assert.That(configuration.Image).IsEqualTo("docker.io/valkey/valkey:7"); } @@ -31,11 +28,9 @@ public async Task BuildConfig_SupportsCompatibleImages() [Test] public async Task BuildConfig_PreservesCustomWaitStrategy() { - RedisConfiguration configuration = new RedisBuilder() - .WithWaitStrategy(Wait.ForTcpPort(6379)) - .BuildConfigurationForTesting(); + var configuration = new RedisBuilder().WithWaitStrategy(Wait.ForTcpPort(6379)).BuildConfigurationForTesting(); - await Assert.That(configuration.WaitStrategies).HasCount().EqualTo(1); + await Assert.That(configuration.WaitStrategies.Count).IsEqualTo(1); await Assert.That(configuration.WaitStrategies[0]).IsTypeOf(); } } diff --git a/tests/SharedTestingFramework/SharedTestingFramework.csproj b/src/tests/SharedTestingFramework/SharedTestingFramework.csproj similarity index 100% rename from tests/SharedTestingFramework/SharedTestingFramework.csproj rename to src/tests/SharedTestingFramework/SharedTestingFramework.csproj diff --git a/tests/SharedTestingFramework/WslcTest.cs b/src/tests/SharedTestingFramework/WslcTest.cs similarity index 67% rename from tests/SharedTestingFramework/WslcTest.cs rename to src/tests/SharedTestingFramework/WslcTest.cs index b7e40df..c867ef0 100644 --- a/tests/SharedTestingFramework/WslcTest.cs +++ b/src/tests/SharedTestingFramework/WslcTest.cs @@ -1,7 +1,6 @@ -using Purview.WslContainers; using TUnit.Core.Exceptions; -namespace Purview.WslContainers.Testing; +namespace Purview.WslContainers; /// Shared helpers for integration tests that exercise real WSL Containers. public static class WslcTest @@ -9,7 +8,7 @@ public static class WslcTest /// Skips the current test when WSL Containers prerequisites are missing. public static async Task SkipIfUnavailableAsync() { - WslContainerRuntimeInfo info = await WslContainerRuntime.Instance.GetInfoAsync(); + var info = await WslContainerRuntime.Instance.GetInfoAsync(); if (!info.IsAvailable || !info.IsCompatible) { throw new SkipTestException( @@ -21,10 +20,8 @@ public static async Task SkipIfUnavailableAsync() /// Connects to a host TCP port, throwing when the timeout elapses. public static async Task WaitForTcpAsync(int port, TimeSpan timeout) { - using System.Net.Sockets.TcpClient client = new System.Net.Sockets.TcpClient(); - using System.Threading.CancellationTokenSource timeoutSource = new System.Threading.CancellationTokenSource( - timeout - ); + using System.Net.Sockets.TcpClient client = new(); + using CancellationTokenSource timeoutSource = new(timeout); await client.ConnectAsync(System.Net.IPAddress.Loopback, port, timeoutSource.Token); } } diff --git a/tests/WslContainers.IntegrationTests/AlpineLifecycleTests.cs b/src/tests/WslContainers.IntegrationTests/AlpineLifecycleTests.cs similarity index 72% rename from tests/WslContainers.IntegrationTests/AlpineLifecycleTests.cs rename to src/tests/WslContainers.IntegrationTests/AlpineLifecycleTests.cs index abb7630..5556c5d 100644 --- a/tests/WslContainers.IntegrationTests/AlpineLifecycleTests.cs +++ b/src/tests/WslContainers.IntegrationTests/AlpineLifecycleTests.cs @@ -1,10 +1,8 @@ -using Purview.WslContainers; -using Purview.WslContainers.Testing; -using TUnit.Assertions; -using TUnit.Assertions.Extensions; +using Purview.WslContainers.Containers; -namespace WslContainers.IntegrationTests; +namespace Purview.WslContainers; +[Explicit] public class AlpineLifecycleTests { [Test] @@ -12,7 +10,7 @@ public async Task EchoContainer_ProducesOutputAndCleansUp() { await WslcTest.SkipIfUnavailableAsync(); - await using WslContainer container = new ContainerBuilder() + await using var container = new ContainerBuilder() .WithImage("alpine:latest") .WithCommand("/bin/echo", "hello-from-alpine") .Build(); @@ -20,7 +18,7 @@ public async Task EchoContainer_ProducesOutputAndCleansUp() await container.StartAsync(); await Assert.That(container.State).IsNotEqualTo(ContainerState.Invalid); - string logs = await container.GetLogsAsync(); + var logs = await container.GetLogsAsync(); await Assert.That(logs).Contains("hello-from-alpine"); } @@ -29,7 +27,7 @@ public async Task EnvironmentVariable_IsPassedToInitProcess() { await WslcTest.SkipIfUnavailableAsync(); - await using WslContainer container = new ContainerBuilder() + await using var container = new ContainerBuilder() .WithImage("alpine:latest") .WithEnvironment("GREETING", "hello-env") .WithCommand("/bin/sh", "-c", "echo $GREETING") @@ -37,7 +35,7 @@ public async Task EnvironmentVariable_IsPassedToInitProcess() await container.StartAsync(); - string logs = await container.GetLogsAsync(); + var logs = await container.GetLogsAsync(); await Assert.That(logs).Contains("hello-env"); } @@ -46,10 +44,7 @@ public async Task StopAsync_ThenDisposeAsync_IsIdempotent() { await WslcTest.SkipIfUnavailableAsync(); - WslContainer container = new ContainerBuilder() - .WithImage("alpine:latest") - .WithCommand("/bin/sleep", "300") - .Build(); + var container = new ContainerBuilder().WithImage("alpine:latest").WithCommand("/bin/sleep", "300").Build(); await container.StartAsync(); await Assert.That(container.State).IsEqualTo(ContainerState.Running); @@ -67,7 +62,7 @@ public async Task GetMappedPublicPort_BeforeStart_Throws() { await WslcTest.SkipIfUnavailableAsync(); - await using WslContainer container = new ContainerBuilder() + await using var container = new ContainerBuilder() .WithImage("alpine:latest") .WithCommand("/bin/echo", "hi") .Build(); diff --git a/tests/WslContainers.IntegrationTests/CleanupTests.cs b/src/tests/WslContainers.IntegrationTests/CleanupTests.cs similarity index 74% rename from tests/WslContainers.IntegrationTests/CleanupTests.cs rename to src/tests/WslContainers.IntegrationTests/CleanupTests.cs index 983b36b..be011ca 100644 --- a/tests/WslContainers.IntegrationTests/CleanupTests.cs +++ b/src/tests/WslContainers.IntegrationTests/CleanupTests.cs @@ -1,10 +1,9 @@ -using Purview.WslContainers; -using Purview.WslContainers.Testing; -using TUnit.Assertions; -using TUnit.Assertions.Extensions; +using Purview.WslContainers.Runtime; +using Purview.WslContainers.Waiting; -namespace WslContainers.IntegrationTests; +namespace Purview.WslContainers; +[Explicit] public class CleanupTests { [Test] @@ -12,7 +11,7 @@ public async Task Dispose_AfterWaitTimeout_IsIdempotent() { await WslcTest.SkipIfUnavailableAsync(); - WslContainer container = new ContainerBuilder() + var container = new ContainerBuilder() .WithImage("alpine:latest") .WithCommand("/bin/sh", "-c", "echo booting; sleep 300") .WithWaitStrategy(Wait.ForLogMessage("this never appears").WithTimeout(TimeSpan.FromSeconds(3))) @@ -28,7 +27,7 @@ public async Task Dispose_AfterInvalidImage_DoesNotThrow() { await WslcTest.SkipIfUnavailableAsync(); - WslContainer container = new ContainerBuilder() + var container = new ContainerBuilder() .WithImage("docker.io/library/definitely-not-a-real-image-xyz:latest") .Build(); @@ -41,10 +40,7 @@ public async Task Stop_ThenDispose_IsIdempotent() { await WslcTest.SkipIfUnavailableAsync(); - WslContainer container = new ContainerBuilder() - .WithImage("alpine:latest") - .WithCommand("/bin/sleep", "300") - .Build(); + var container = new ContainerBuilder().WithImage("alpine:latest").WithCommand("/bin/sleep", "300").Build(); await container.StartAsync(); await container.StopAsync(); @@ -57,7 +53,7 @@ public async Task ImmediateExit_ThenDispose_Works() { await WslcTest.SkipIfUnavailableAsync(); - await using WslContainer container = new ContainerBuilder() + await using var container = new ContainerBuilder() .WithImage("alpine:latest") .WithCommand("/bin/echo", "bye") .Build(); diff --git a/tests/WslContainers.IntegrationTests/ConcurrencyStressTests.cs b/src/tests/WslContainers.IntegrationTests/ConcurrencyStressTests.cs similarity index 74% rename from tests/WslContainers.IntegrationTests/ConcurrencyStressTests.cs rename to src/tests/WslContainers.IntegrationTests/ConcurrencyStressTests.cs index 51dcb1b..caf78f4 100644 --- a/tests/WslContainers.IntegrationTests/ConcurrencyStressTests.cs +++ b/src/tests/WslContainers.IntegrationTests/ConcurrencyStressTests.cs @@ -1,10 +1,8 @@ -using Purview.WslContainers; -using Purview.WslContainers.Testing; -using TUnit.Assertions; -using TUnit.Assertions.Extensions; +using Purview.WslContainers.Waiting; -namespace WslContainers.IntegrationTests; +namespace Purview.WslContainers; +[Explicit] public class ConcurrencyStressTests { [Test] @@ -13,10 +11,10 @@ public async Task ConcurrentContainers_GetDistinctUsablePorts() await WslcTest.SkipIfUnavailableAsync(); const int count = 5; - List containers = new List(count); + List containers = [with(count)]; try { - for (int i = 0; i < count; i++) + for (var i = 0; i < count; i++) { containers.Add( new ContainerBuilder() @@ -39,7 +37,7 @@ public async Task ConcurrentContainers_GetDistinctUsablePorts() } finally { - foreach (WslContainer container in containers) + foreach (var container in containers) { await container.DisposeAsync(); } diff --git a/tests/WslContainers.IntegrationTests/ExecTests.cs b/src/tests/WslContainers.IntegrationTests/ExecTests.cs similarity index 67% rename from tests/WslContainers.IntegrationTests/ExecTests.cs rename to src/tests/WslContainers.IntegrationTests/ExecTests.cs index 458cb4b..405ee13 100644 --- a/tests/WslContainers.IntegrationTests/ExecTests.cs +++ b/src/tests/WslContainers.IntegrationTests/ExecTests.cs @@ -1,10 +1,8 @@ -using Purview.WslContainers; -using Purview.WslContainers.Testing; -using TUnit.Assertions; -using TUnit.Assertions.Extensions; +using Purview.WslContainers.Containers; -namespace WslContainers.IntegrationTests; +namespace Purview.WslContainers; +[Explicit] public class ExecTests { [Test] @@ -12,13 +10,13 @@ public async Task Exec_ReturnsStdoutAndExitCode() { await WslcTest.SkipIfUnavailableAsync(); - await using WslContainer container = new ContainerBuilder() + await using var container = new ContainerBuilder() .WithImage("alpine:latest") .WithCommand("/bin/sleep", "300") .Build(); await container.StartAsync(); - ExecResult result = await container.ExecAsync(new[] { "/bin/echo", "hello-exec" }); + var result = await container.ExecAsync(["/bin/echo", "hello-exec"]); await Assert.That(result.ExitCode).IsEqualTo(0); await Assert.That(result.Stdout.Trim()).IsEqualTo("hello-exec"); @@ -30,14 +28,14 @@ public async Task Exec_PropagatesEnvironmentAndWorkingDirectory() { await WslcTest.SkipIfUnavailableAsync(); - await using WslContainer container = new ContainerBuilder() + await using var container = new ContainerBuilder() .WithImage("alpine:latest") .WithCommand("/bin/sleep", "300") .Build(); await container.StartAsync(); - ExecResult result = await container.ExecAsync( - new[] { "/bin/sh", "-c", "echo $FOO; pwd" }, + var result = await container.ExecAsync( + ["/bin/sh", "-c", "echo $FOO; pwd"], new ExecOptions { Environment = new Dictionary { ["FOO"] = "bar" }, @@ -54,13 +52,13 @@ public async Task Exec_ReturnsNonZeroExitCodeAndStderr() { await WslcTest.SkipIfUnavailableAsync(); - await using WslContainer container = new ContainerBuilder() + await using var container = new ContainerBuilder() .WithImage("alpine:latest") .WithCommand("/bin/sleep", "300") .Build(); await container.StartAsync(); - ExecResult result = await container.ExecAsync(new[] { "/bin/sh", "-c", "echo boom >&2; exit 9" }); + var result = await container.ExecAsync(["/bin/sh", "-c", "echo boom >&2; exit 9"]); await Assert.That(result.ExitCode).IsEqualTo(9); await Assert.That(result.Stderr.Trim()).IsEqualTo("boom"); @@ -71,14 +69,14 @@ public async Task Exec_TimesOut_AndKillsProcess() { await WslcTest.SkipIfUnavailableAsync(); - await using WslContainer container = new ContainerBuilder() + await using var container = new ContainerBuilder() .WithImage("alpine:latest") .WithCommand("/bin/sleep", "300") .Build(); await container.StartAsync(); - ExecResult result = await container.ExecAsync( - new[] { "/bin/sleep", "60" }, + var result = await container.ExecAsync( + ["/bin/sleep", "60"], new ExecOptions { Timeout = TimeSpan.FromSeconds(2) } ); diff --git a/src/tests/WslContainers.IntegrationTests/PerformanceTests.cs b/src/tests/WslContainers.IntegrationTests/PerformanceTests.cs new file mode 100644 index 0000000..c38891f --- /dev/null +++ b/src/tests/WslContainers.IntegrationTests/PerformanceTests.cs @@ -0,0 +1,36 @@ +using System.Diagnostics; + +namespace Purview.WslContainers; + +[Explicit] +public class PerformanceTests +{ + [Test] + [NotInParallel] + public async Task WarmContainerStart_IsFast(CancellationToken cancellationToken) + { + await WslcTest.SkipIfUnavailableAsync(); + + // Warm the image into the shared session store. + await using ( + var warm = new ContainerBuilder().WithImage("alpine:latest").WithCommand("/bin/echo", "warm").Build() + ) + { + await warm.StartAsync(cancellationToken); + } + + var stopwatch = Stopwatch.StartNew(); + await using var container = new ContainerBuilder() + .WithImage("alpine:latest") + .WithCommand("/bin/echo", "hi") + .Build(); + await container.StartAsync(cancellationToken); + stopwatch.Stop(); + + await TestContext.Current!.OutputWriter.WriteLineAsync( + $"warm container start: {stopwatch.Elapsed}", + cancellationToken + ); + await Assert.That(stopwatch.Elapsed).IsLessThan(TimeSpan.FromSeconds(60)); + } +} diff --git a/tests/WslContainers.IntegrationTests/PortMappingTests.cs b/src/tests/WslContainers.IntegrationTests/PortMappingTests.cs similarity index 69% rename from tests/WslContainers.IntegrationTests/PortMappingTests.cs rename to src/tests/WslContainers.IntegrationTests/PortMappingTests.cs index 3aadd8e..6ec39f1 100644 --- a/tests/WslContainers.IntegrationTests/PortMappingTests.cs +++ b/src/tests/WslContainers.IntegrationTests/PortMappingTests.cs @@ -1,13 +1,10 @@ using System.Net; -using System.Net.Http; using System.Net.Sockets; -using Purview.WslContainers; -using Purview.WslContainers.Testing; -using TUnit.Assertions; -using TUnit.Assertions.Extensions; +using Purview.WslContainers.Containers; -namespace WslContainers.IntegrationTests; +namespace Purview.WslContainers; +[Explicit] public class PortMappingTests { [Test] @@ -15,7 +12,7 @@ public async Task RandomHostPort_IsAssignedAndReachable() { await WslcTest.SkipIfUnavailableAsync(); - await using WslContainer container = new ContainerBuilder() + await using var container = new ContainerBuilder() .WithImage("python:3-alpine") .WithPortBinding(8080, assignRandomHostPort: true) .WithCommand("/bin/sh", "-c", "python3 -m http.server 8080") @@ -24,7 +21,7 @@ public async Task RandomHostPort_IsAssignedAndReachable() await container.StartAsync(); await Assert.That(container.State).IsEqualTo(ContainerState.Running); - ushort hostPort = container.GetMappedPublicPort(8080); + var hostPort = container.GetMappedPublicPort(8080); await Assert.That(hostPort).IsGreaterThan((ushort)0); await WslcTest.WaitForTcpAsync(hostPort, TimeSpan.FromSeconds(30)); @@ -35,8 +32,8 @@ public async Task ExplicitHostPort_IsUsed() { await WslcTest.SkipIfUnavailableAsync(); - ushort hostPort = GetFreeTcpPort(); - await using WslContainer container = new ContainerBuilder() + var hostPort = GetFreeTcpPort(); + await using var container = new ContainerBuilder() .WithImage("python:3-alpine") .WithPortBinding(8080, hostPort) .WithCommand("/bin/sh", "-c", "python3 -m http.server 8080") @@ -53,12 +50,12 @@ public async Task MultipleContainers_ReceiveDistinctRandomPorts() { await WslcTest.SkipIfUnavailableAsync(); - await using WslContainer first = new ContainerBuilder() + await using var first = new ContainerBuilder() .WithImage("python:3-alpine") .WithPortBinding(8080, assignRandomHostPort: true) .WithCommand("/bin/sh", "-c", "python3 -m http.server 8080") .Build(); - await using WslContainer second = new ContainerBuilder() + await using var second = new ContainerBuilder() .WithImage("python:3-alpine") .WithPortBinding(8080, assignRandomHostPort: true) .WithCommand("/bin/sh", "-c", "python3 -m http.server 8080") @@ -66,14 +63,14 @@ public async Task MultipleContainers_ReceiveDistinctRandomPorts() await Task.WhenAll(first.StartAsync(), second.StartAsync()); - ushort firstPort = first.GetMappedPublicPort(8080); - ushort secondPort = second.GetMappedPublicPort(8080); + var firstPort = first.GetMappedPublicPort(8080); + var secondPort = second.GetMappedPublicPort(8080); await Assert.That(firstPort).IsNotEqualTo(secondPort); } - private static ushort GetFreeTcpPort() + static ushort GetFreeTcpPort() { - using TcpListener listener = new TcpListener(IPAddress.Loopback, 0); + using TcpListener listener = new(IPAddress.Loopback, 0); listener.Start(); return (ushort)((IPEndPoint)listener.LocalEndpoint).Port; } diff --git a/tests/WslContainers.IntegrationTests/RuntimeInfoTests.cs b/src/tests/WslContainers.IntegrationTests/RuntimeInfoTests.cs similarity index 53% rename from tests/WslContainers.IntegrationTests/RuntimeInfoTests.cs rename to src/tests/WslContainers.IntegrationTests/RuntimeInfoTests.cs index 7bd6652..83f625c 100644 --- a/tests/WslContainers.IntegrationTests/RuntimeInfoTests.cs +++ b/src/tests/WslContainers.IntegrationTests/RuntimeInfoTests.cs @@ -1,10 +1,6 @@ -using Purview.WslContainers; -using Purview.WslContainers.Testing; -using TUnit.Assertions; -using TUnit.Assertions.Extensions; - -namespace WslContainers.IntegrationTests; +namespace Purview.WslContainers; +[Explicit] public class RuntimeInfoTests { [Test] @@ -12,7 +8,7 @@ public async Task GetInfoAsync_ReportsAvailability() { await WslcTest.SkipIfUnavailableAsync(); - WslContainerRuntimeInfo info = await WslContainerRuntime.Instance.GetInfoAsync(); + var info = await WslContainerRuntime.Instance.GetInfoAsync(); await Assert.That(info.IsAvailable).IsTrue(); await Assert.That(info.IsCompatible).IsTrue(); diff --git a/src/tests/WslContainers.IntegrationTests/SharedStorageTests.cs b/src/tests/WslContainers.IntegrationTests/SharedStorageTests.cs new file mode 100644 index 0000000..fb5b134 --- /dev/null +++ b/src/tests/WslContainers.IntegrationTests/SharedStorageTests.cs @@ -0,0 +1,67 @@ +using Purview.WslContainers.Images; + +namespace Purview.WslContainers; + +[Explicit] +public class SharedStorageTests +{ + [Test] + public async Task SharedStore_IsReusedAcrossSequentialRuntimes() + { + await WslcTest.SkipIfUnavailableAsync(); + + var path = Path.Combine(Path.GetTempPath(), "wslc-shared-" + Guid.NewGuid().ToString("N")[..6]); + Directory.CreateDirectory(path); + try + { + IReadOnlyList firstRun; + await using ( + var runtime1 = new WslContainerRuntime( + new WslContainerRuntimeOptions { StoragePath = path, DisableProcessExitCleanup = true } + ) + ) + { + await using var container = new ContainerBuilder(runtime1) + .WithImage("alpine:latest") + .WithCommand("/bin/echo", "one") + .Build(); + await container.StartAsync(); + var session1 = await runtime1.GetSessionAsync(); + firstRun = await session1.ListImagesAsync(); + } + + IReadOnlyList secondRun; + await using ( + var runtime2 = new WslContainerRuntime( + new WslContainerRuntimeOptions { StoragePath = path, DisableProcessExitCleanup = true } + ) + ) + { + var session2 = await runtime2.GetSessionAsync(); + secondRun = await session2.ListImagesAsync(); + } + + await Assert + .That(firstRun.Any(image => image.Name.Contains("alpine", StringComparison.OrdinalIgnoreCase))) + .IsTrue(); + await Assert + .That(secondRun.Any(image => image.Name.Contains("alpine", StringComparison.OrdinalIgnoreCase))) + .IsTrue(); + } + finally + { + try + { + Directory.Delete(path, recursive: true); + } + catch (IOException) + { + // VHD may still be held briefly by the session manager. + } + catch (UnauthorizedAccessException) + { + // Same. + } + } + } +} diff --git a/src/tests/WslContainers.IntegrationTests/VolumeTests.cs b/src/tests/WslContainers.IntegrationTests/VolumeTests.cs new file mode 100644 index 0000000..d17d656 --- /dev/null +++ b/src/tests/WslContainers.IntegrationTests/VolumeTests.cs @@ -0,0 +1,75 @@ +namespace Purview.WslContainers; + +[Explicit] +public class VolumeTests +{ + [Test] + public async Task BindMount_ReadsHostFile(CancellationToken cancellationToken) + { + await WslcTest.SkipIfUnavailableAsync(); + + var dir = Path.Combine(Path.GetTempPath(), "wslc-bind-" + Guid.NewGuid().ToString("N")[..6]); + Directory.CreateDirectory(dir); + await File.WriteAllTextAsync(Path.Combine(dir, "hello.txt"), "hello-bind\n", cancellationToken); + try + { + await using var container = new ContainerBuilder() + .WithImage("alpine:latest") + .WithBindMount(dir, "/mnt/data") + .WithCommand("/bin/cat", "/mnt/data/hello.txt") + .Build(); + + await container.StartAsync(cancellationToken); + + // The live log stream completes when the init process exits; bound it so a regression fails + // instead of hanging the suite. + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(TimeSpan.FromSeconds(60)); + List logs = []; + await foreach (var log in container.GetLogsAsync(timeout.Token)) + { + logs.Add(log.Text); + } + + await Assert.That(logs.Any(text => text.Contains("hello-bind", StringComparison.Ordinal))).IsTrue(); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } + + [Test] + public async Task NamedVolume_IsSharedBetweenContainers(CancellationToken cancellationToken) + { + await WslcTest.SkipIfUnavailableAsync(); + + var volume = "wslc-vol-" + Guid.NewGuid().ToString("N")[..6]; + await using var writer = new ContainerBuilder() + .WithImage("alpine:latest") + .WithVolumeMount(volume, "/data") + .WithCommand("/bin/sh", "-c", "echo shared-data > /data/file.txt; sleep 300") + .Build(); + await writer.StartAsync(cancellationToken); + await Task.Delay(1500, cancellationToken); + + await using var reader = new ContainerBuilder() + .WithImage("alpine:latest") + .WithVolumeMount(volume, "/data") + .WithCommand("/bin/cat", "/data/file.txt") + .Build(); + await reader.StartAsync(cancellationToken); + + // The live log stream completes when the init process exits; bound it so a regression fails + // instead of hanging the suite. + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(TimeSpan.FromSeconds(60)); + List logs = []; + await foreach (var log in reader.GetLogsAsync(timeout.Token)) + { + logs.Add(log.Text); + } + + await Assert.That(logs.Any(text => text.Contains("shared-data", StringComparison.Ordinal))).IsTrue(); + } +} diff --git a/tests/WslContainers.IntegrationTests/WaitStrategyTests.cs b/src/tests/WslContainers.IntegrationTests/WaitStrategyTests.cs similarity index 82% rename from tests/WslContainers.IntegrationTests/WaitStrategyTests.cs rename to src/tests/WslContainers.IntegrationTests/WaitStrategyTests.cs index 5fb1ff1..c57787a 100644 --- a/tests/WslContainers.IntegrationTests/WaitStrategyTests.cs +++ b/src/tests/WslContainers.IntegrationTests/WaitStrategyTests.cs @@ -1,11 +1,11 @@ using System.Net; -using Purview.WslContainers; -using Purview.WslContainers.Testing; -using TUnit.Assertions; -using TUnit.Assertions.Extensions; +using Purview.WslContainers.Containers; +using Purview.WslContainers.Runtime; +using Purview.WslContainers.Waiting; -namespace WslContainers.IntegrationTests; +namespace Purview.WslContainers; +[Explicit] public class WaitStrategyTests { [Test] @@ -13,7 +13,7 @@ public async Task LogMessage_WaitsForReadiness() { await WslcTest.SkipIfUnavailableAsync(); - await using WslContainer container = new ContainerBuilder() + await using var container = new ContainerBuilder() .WithImage("alpine:latest") .WithCommand("/bin/sh", "-c", "echo ready; sleep 300") .WithWaitStrategy(Wait.ForLogMessage("ready")) @@ -29,7 +29,7 @@ public async Task TcpPort_WaitsForListener() { await WslcTest.SkipIfUnavailableAsync(); - await using WslContainer container = new ContainerBuilder() + await using var container = new ContainerBuilder() .WithImage("python:3-alpine") .WithPortBinding(8080, assignRandomHostPort: true) .WithCommand("/bin/sh", "-c", "python3 -m http.server 8080") @@ -38,7 +38,7 @@ public async Task TcpPort_WaitsForListener() await container.StartAsync(); - ushort hostPort = container.GetMappedPublicPort(8080); + var hostPort = container.GetMappedPublicPort(8080); await Assert.That(hostPort).IsGreaterThan((ushort)0); } @@ -47,7 +47,7 @@ public async Task Http_WaitsForExpectedStatus() { await WslcTest.SkipIfUnavailableAsync(); - await using WslContainer container = new ContainerBuilder() + await using var container = new ContainerBuilder() .WithImage("python:3-alpine") .WithPortBinding(8080, assignRandomHostPort: true) .WithCommand("/bin/sh", "-c", "python3 -m http.server 8080") @@ -62,7 +62,7 @@ public async Task Command_WaitsForExitCodeZero() { await WslcTest.SkipIfUnavailableAsync(); - await using WslContainer container = new ContainerBuilder() + await using var container = new ContainerBuilder() .WithImage("alpine:latest") .WithCommand("/bin/sleep", "300") .WithWaitStrategy(Wait.ForCommand("/bin/true")) @@ -78,7 +78,7 @@ public async Task Composition_AllStrategiesReady() { await WslcTest.SkipIfUnavailableAsync(); - await using WslContainer container = new ContainerBuilder() + await using var container = new ContainerBuilder() .WithImage("python:3-alpine") .WithPortBinding(8080, assignRandomHostPort: true) .WithCommand("/bin/sh", "-c", "python3 -m http.server 8080") @@ -95,7 +95,7 @@ public async Task Timeout_ThrowsWithDiagnosticsAndIsDisposable() { await WslcTest.SkipIfUnavailableAsync(); - WslContainer container = new ContainerBuilder() + var container = new ContainerBuilder() .WithImage("alpine:latest") .WithCommand("/bin/sh", "-c", "echo booting; sleep 300") .WithWaitStrategy( diff --git a/src/tests/WslContainers.IntegrationTests/WslContainers.IntegrationTests.csproj b/src/tests/WslContainers.IntegrationTests/WslContainers.IntegrationTests.csproj new file mode 100644 index 0000000..6b512ec --- /dev/null +++ b/src/tests/WslContainers.IntegrationTests/WslContainers.IntegrationTests.csproj @@ -0,0 +1 @@ + diff --git a/tests/WslContainers.UnitTests/ContainerBuilderTests.cs b/src/tests/WslContainers.UnitTests/ContainerBuilderTests.cs similarity index 73% rename from tests/WslContainers.UnitTests/ContainerBuilderTests.cs rename to src/tests/WslContainers.UnitTests/ContainerBuilderTests.cs index 578f65e..2ef605e2 100644 --- a/tests/WslContainers.UnitTests/ContainerBuilderTests.cs +++ b/src/tests/WslContainers.UnitTests/ContainerBuilderTests.cs @@ -1,13 +1,12 @@ -using Purview.WslContainers; using Purview.WslContainers.Images; -using TUnit.Assertions; -using TUnit.Assertions.Extensions; +using Purview.WslContainers.Networking; +using Purview.WslContainers.Runtime; -namespace WslContainers.UnitTests; +namespace Purview.WslContainers; public class ContainerBuilderTests { - private sealed class TestBuilder : ContainerBuilder + sealed class TestBuilder : ContainerBuilder { public ContainerConfiguration BuildConfig() => BuildConfiguration(); @@ -19,10 +18,12 @@ protected override WslContainer CreateContainer(ContainerConfiguration configura } } + static readonly string[] Expected = ["sleep", "10"]; + [Test] public async Task BuildConfiguration_PopulatesAllCommonFields() { - ContainerConfiguration configuration = new TestBuilder() + var configuration = new TestBuilder() .WithImage("alpine:3.19") .WithName("unit-test") .WithHostname("unit") @@ -41,13 +42,13 @@ public async Task BuildConfiguration_PopulatesAllCommonFields() await Assert.That(configuration.Hostname).IsEqualTo("unit"); await Assert.That(configuration.Environment["A"]).IsEqualTo("1"); await Assert.That(configuration.Environment["B"]).IsEqualTo("2"); - await Assert.That(configuration.Command).IsEquivalentTo(new[] { "sleep", "10" }); + await Assert.That(configuration.Command).IsEquivalentTo(Expected); await Assert.That(configuration.WorkingDirectory).IsEqualTo("/tmp"); - await Assert.That(configuration.PortBindings).HasCount().EqualTo(2); + await Assert.That(configuration.PortBindings.Count).IsEqualTo(2); await Assert.That(configuration.PortBindings[0].AssignRandomHostPort).IsTrue(); await Assert.That(configuration.PortBindings[1].HostPort).IsEqualTo((ushort)9091); - await Assert.That(configuration.BindMounts).HasCount().EqualTo(1); - await Assert.That(configuration.NamedVolumes).HasCount().EqualTo(1); + await Assert.That(configuration.BindMounts.Count).IsEqualTo(1); + await Assert.That(configuration.NamedVolumes.Count).IsEqualTo(1); await Assert.That(configuration.NetworkingMode).IsEqualTo(ContainerNetworkingMode.Bridged); await Assert.That(configuration.PullPolicy).IsEqualTo(PullPolicy.Missing); } @@ -61,43 +62,43 @@ public async Task Build_WithoutImage_Throws() [Test] public async Task Build_WithUdpPort_ThrowsNotSupported() { - TestBuilder builder = new TestBuilder().WithImage("alpine").WithPortBinding(53, 53, PortProtocol.Udp); + var builder = new TestBuilder().WithImage("alpine").WithPortBinding(53, 53, PortProtocol.Udp); await Assert.That(() => builder.Build()).Throws(); } [Test] public async Task Build_WithContainerPortZero_Throws() { - TestBuilder builder = new TestBuilder().WithImage("alpine").WithPortBinding(0, assignRandomHostPort: true); + var builder = new TestBuilder().WithImage("alpine").WithPortBinding(0, assignRandomHostPort: true); await Assert.That(() => builder.Build()).Throws(); } [Test] public async Task Build_WithHostPortZero_Throws() { - TestBuilder builder = new TestBuilder().WithImage("alpine").WithPortBinding(80, 0); + var builder = new TestBuilder().WithImage("alpine").WithPortBinding(80, 0); await Assert.That(() => builder.Build()).Throws(); } [Test] public async Task Build_WithEmptyBindMount_Throws() { - TestBuilder builder = new TestBuilder().WithImage("alpine").WithBindMount(string.Empty, "/data"); + var builder = new TestBuilder().WithImage("alpine").WithBindMount(string.Empty, "/data"); await Assert.That(() => builder.Build()).Throws(); } [Test] public async Task Build_WithZeroStartupTimeout_Throws() { - TestBuilder builder = new TestBuilder().WithImage("alpine").WithStartupTimeout(TimeSpan.Zero); + var builder = new TestBuilder().WithImage("alpine").WithStartupTimeout(TimeSpan.Zero); await Assert.That(() => builder.Build()).Throws(); } [Test] public async Task GenericContainerBuilder_GeneratesUniqueName() { - WslContainer first = new ContainerBuilder().WithImage("alpine").Build(); - WslContainer second = new ContainerBuilder().WithImage("alpine").Build(); + var first = new ContainerBuilder().WithImage("alpine").Build(); + var second = new ContainerBuilder().WithImage("alpine").Build(); await Assert.That(first.Name).IsNotEqualTo(second.Name); await Assert.That(first.Image).IsEqualTo("docker.io/library/alpine:latest"); @@ -117,7 +118,7 @@ await Assert [Test] public async Task WithTag_AppliesTagToConfiguredImage() { - ContainerConfiguration configuration = new TestBuilder().WithImage("redis").WithTag("7.4").BuildConfig(); + var configuration = new TestBuilder().WithImage("redis").WithTag("7.4").BuildConfig(); await Assert.That(configuration.Image).IsEqualTo("docker.io/library/redis:7.4"); } diff --git a/tests/WslContainers.UnitTests/FakeContainer.cs b/src/tests/WslContainers.UnitTests/FakeContainer.cs similarity index 87% rename from tests/WslContainers.UnitTests/FakeContainer.cs rename to src/tests/WslContainers.UnitTests/FakeContainer.cs index bfa6b1f..2651bb7 100644 --- a/tests/WslContainers.UnitTests/FakeContainer.cs +++ b/src/tests/WslContainers.UnitTests/FakeContainer.cs @@ -1,8 +1,8 @@ -using Purview.WslContainers; +using Purview.WslContainers.Containers; -namespace WslContainers.UnitTests; +namespace Purview.WslContainers; -internal sealed class FakeContainer : IContainer +public sealed class FakeContainer : IContainer { public string Id { get; init; } = "fake-id"; @@ -33,11 +33,12 @@ public Task ExecAsync( public ushort GetMappedPublicPort(ushort containerPort) { - if (!PortMappings.TryGetValue(containerPort, out ushort hostPort)) + if (!PortMappings.TryGetValue(containerPort, out var hostPort)) { throw new InvalidOperationException($"No mapping for {containerPort}."); } + // Return the mapped host port return hostPort; } diff --git a/src/tests/WslContainers.UnitTests/HttpWaitStrategyTests.cs b/src/tests/WslContainers.UnitTests/HttpWaitStrategyTests.cs new file mode 100644 index 0000000..2cd6749 --- /dev/null +++ b/src/tests/WslContainers.UnitTests/HttpWaitStrategyTests.cs @@ -0,0 +1,126 @@ +using System.Net; +using System.Net.Sockets; +using System.Text; +using Purview.WslContainers.Waiting; + +namespace Purview.WslContainers; + +public class HttpWaitStrategyTests +{ + [Test] + public async Task Http_ReturnsTrueOnExpectedStatus(CancellationToken cancellationToken) + { + (var port, var listener, var _) = StartServer(HttpStatusCode.OK, cancellationToken); + try + { + await using FakeContainer container = new() + { + PortMappings = new Dictionary { [80] = (ushort)port }, + }; + WaitContext context = new(container, container.PortMappings, networkIp: null); + var strategy = Wait.ForHttp("/health").ForPort(80).ForStatusCode(HttpStatusCode.OK); + + await Assert.That(await strategy.UntilAsync(context, cancellationToken)).IsTrue(); + } + finally + { + listener.Stop(); + } + } + + [Test] + public async Task Http_ReturnsFalseOnUnexpectedStatus(CancellationToken cancellationToken) + { + (var port, var listener, var _) = StartServer(HttpStatusCode.InternalServerError, cancellationToken); + try + { + await using FakeContainer container = new() + { + PortMappings = new Dictionary { [80] = (ushort)port }, + }; + WaitContext context = new(container, container.PortMappings, networkIp: null); + var strategy = Wait.ForHttp("/health").ForPort(80).ForStatusCode(HttpStatusCode.OK); + + await Assert.That(await strategy.UntilAsync(context, cancellationToken)).IsFalse(); + } + finally + { + listener.Stop(); + } + } + + [Test] + public async Task Http_StatusPredicate_IsUsed(CancellationToken cancellationToken) + { + (var port, var listener, var _) = StartServer(HttpStatusCode.Accepted, cancellationToken); + try + { + await using FakeContainer container = new() + { + PortMappings = new Dictionary { [80] = (ushort)port }, + }; + WaitContext context = new(container, container.PortMappings, networkIp: null); + var strategy = Wait.ForHttp("/").ForPort(80).ForStatusPredicate(status => status is >= 200 and < 300); + + await Assert.That(await strategy.UntilAsync(context, cancellationToken)).IsTrue(); + } + finally + { + listener.Stop(); + } + } + + [Test] + public async Task Http_ReturnsFalseWhenPortNotMapped(CancellationToken cancellationToken) + { + await using FakeContainer container = new(); + WaitContext context = new(container, container.PortMappings, networkIp: null); + var strategy = Wait.ForHttp("/").ForPort(80); + + await Assert.That(await strategy.UntilAsync(context, cancellationToken)).IsFalse(); + } + + static (int Port, HttpListener Listener, Task Handler) StartServer( + HttpStatusCode status, + CancellationToken cancellationToken + ) + { + using TcpListener probe = new(IPAddress.Loopback, 0); + probe.Start(); + var port = ((IPEndPoint)probe.LocalEndpoint).Port; + probe.Stop(); + + HttpListener listener = new(); + listener.Prefixes.Add($"http://127.0.0.1:{port}/"); + listener.Start(); + + var handler = Task.Run( + async () => + { + while (listener.IsListening && !cancellationToken.IsCancellationRequested) + { + try + { + var ctx = await listener.GetContextAsync(); + ctx.Response.StatusCode = (int)status; + var body = Encoding.UTF8.GetBytes("ok"); + ctx.Response.ContentLength64 = body.Length; + await ctx.Response.OutputStream.WriteAsync(body, cancellationToken); + ctx.Response.Close(); + } + catch (HttpListenerException) + { + break; + } + catch (ObjectDisposedException) + { + break; + } + } + }, + cancellationToken + ); + + return (port, listener, handler); + } +} diff --git a/tests/WslContainers.UnitTests/ImageTests.cs b/src/tests/WslContainers.UnitTests/ImageTests.cs similarity index 82% rename from tests/WslContainers.UnitTests/ImageTests.cs rename to src/tests/WslContainers.UnitTests/ImageTests.cs index 4c4da77..ae97d1a 100644 --- a/tests/WslContainers.UnitTests/ImageTests.cs +++ b/src/tests/WslContainers.UnitTests/ImageTests.cs @@ -1,15 +1,13 @@ using Purview.WslContainers.Images; -using TUnit.Assertions; -using TUnit.Assertions.Extensions; -namespace WslContainers.UnitTests; +namespace Purview.WslContainers; public class ImageTests { [Test] public async Task Parse_ShortName_DefaultsToDockerHubLibrary() { - Image reference = Image.Parse("alpine"); + var reference = Image.Parse("alpine"); await Assert.That(reference.Registry).IsEqualTo("docker.io"); await Assert.That(reference.Repository).IsEqualTo("library/alpine"); @@ -20,7 +18,7 @@ public async Task Parse_ShortName_DefaultsToDockerHubLibrary() [Test] public async Task Parse_ExplicitTag_IsPreserved() { - Image reference = Image.Parse("redis:7"); + var reference = Image.Parse("redis:7"); await Assert.That(reference.Registry).IsEqualTo("docker.io"); await Assert.That(reference.Repository).IsEqualTo("library/redis"); @@ -30,7 +28,7 @@ public async Task Parse_ExplicitTag_IsPreserved() [Test] public async Task Parse_FullyQualified_IsNormalized() { - Image reference = Image.Parse("docker.io/library/alpine:latest"); + var reference = Image.Parse("docker.io/library/alpine:latest"); await Assert.That(reference.Registry).IsEqualTo("docker.io"); await Assert.That(reference.Repository).IsEqualTo("library/alpine"); @@ -40,7 +38,7 @@ public async Task Parse_FullyQualified_IsNormalized() [Test] public async Task Parse_PrivateRegistry_IsDetected() { - Image reference = Image.Parse("ghcr.io/acme/app:v1"); + var reference = Image.Parse("ghcr.io/acme/app:v1"); await Assert.That(reference.Registry).IsEqualTo("ghcr.io"); await Assert.That(reference.Repository).IsEqualTo("acme/app"); @@ -50,7 +48,7 @@ public async Task Parse_PrivateRegistry_IsDetected() [Test] public async Task MatchesStoredName_ShortVsQualified_Matches() { - Image reference = Image.Parse("docker.io/library/alpine:latest"); + var reference = Image.Parse("docker.io/library/alpine:latest"); await Assert.That(reference.MatchesStoredName("alpine:latest")).IsTrue(); await Assert.That(reference.MatchesStoredName("redis:latest")).IsFalse(); @@ -60,7 +58,7 @@ public async Task MatchesStoredName_ShortVsQualified_Matches() [Test] public async Task WithTag_ReturnsNewImage() { - Image reference = Image.Parse("redis").WithTag("7.4"); + var reference = Image.Parse("redis").WithTag("7.4"); await Assert.That(reference.Tag).IsEqualTo("7.4"); await Assert.That(reference.FullReference).IsEqualTo("docker.io/library/redis:7.4"); diff --git a/tests/WslContainers.UnitTests/SecretTests.cs b/src/tests/WslContainers.UnitTests/SecretTests.cs similarity index 67% rename from tests/WslContainers.UnitTests/SecretTests.cs rename to src/tests/WslContainers.UnitTests/SecretTests.cs index 7856020..6d15f67 100644 --- a/tests/WslContainers.UnitTests/SecretTests.cs +++ b/src/tests/WslContainers.UnitTests/SecretTests.cs @@ -1,15 +1,13 @@ -using Purview.WslContainers; -using TUnit.Assertions; -using TUnit.Assertions.Extensions; +using Purview.WslContainers.Diagnostics; -namespace WslContainers.UnitTests; +namespace Purview.WslContainers; public class SecretTests { [Test] public async Task ToString_RedactsValue() { - Secret secret = Secret.From("super-secret-password"); + var secret = Secret.From("super-secret-password"); await Assert.That(secret.ToString()).IsEqualTo(""); await Assert.That(secret.ToString()).DoesNotContain("super"); @@ -18,7 +16,7 @@ public async Task ToString_RedactsValue() [Test] public async Task Value_ReturnsUnderlying() { - Secret secret = Secret.From("super-secret-password"); + var secret = Secret.From("super-secret-password"); await Assert.That(secret.Value).IsEqualTo("super-secret-password"); } diff --git a/tests/WslContainers.UnitTests/StorageModeTests.cs b/src/tests/WslContainers.UnitTests/StorageModeTests.cs similarity index 79% rename from tests/WslContainers.UnitTests/StorageModeTests.cs rename to src/tests/WslContainers.UnitTests/StorageModeTests.cs index 0d7c214..f980eb2 100644 --- a/tests/WslContainers.UnitTests/StorageModeTests.cs +++ b/src/tests/WslContainers.UnitTests/StorageModeTests.cs @@ -1,15 +1,11 @@ -using Purview.WslContainers; -using TUnit.Assertions; -using TUnit.Assertions.Extensions; - -namespace WslContainers.UnitTests; +namespace Purview.WslContainers; public class StorageModeTests { [Test] public async Task DefaultOptions_UseSharedStorage() { - WslContainerRuntimeOptions options = WslContainerRuntimeOptions.Default; + var options = WslContainerRuntimeOptions.Default; await Assert.That(options.StorageMode).IsEqualTo(StorageMode.Shared); await Assert.That(options.StoragePath).IsNull(); @@ -31,4 +27,4 @@ public async Task ExplicitStoragePath_OverridesSharedDefault() await Assert.That(options.StoragePath).IsEqualTo(@"C:\temp\store"); await Assert.That(options.StorageMode).IsEqualTo(StorageMode.Shared); } -} \ No newline at end of file +} diff --git a/src/tests/WslContainers.UnitTests/WaitContextTests.cs b/src/tests/WslContainers.UnitTests/WaitContextTests.cs new file mode 100644 index 0000000..9bf29f3 --- /dev/null +++ b/src/tests/WslContainers.UnitTests/WaitContextTests.cs @@ -0,0 +1,45 @@ +using Purview.WslContainers.Waiting; + +namespace Purview.WslContainers; + +public class WaitContextTests +{ + static (WaitContext, IAsyncDisposable) Create(IReadOnlyDictionary mappings) + { + FakeContainer container = new() { PortMappings = mappings }; + return (new WaitContext(container, mappings, "172.17.0.9"), container); + } + + [Test] + public async Task GetHostPort_ReturnsMappedPort() + { + var (context, container) = Create(new Dictionary { [8080] = 54321 }); + + await using (container) + { + await Assert.That(context.GetHostPort(8080)).IsEqualTo(54321); + await Assert.That(context.GetHostPort(80)).IsNull(); + } + } + + [Test] + public async Task FirstHostPort_ReturnsFirstMapping() + { + var (context, container) = Create(new Dictionary { [8080] = 54321, [8081] = 54322 }); + + await using (container) + await Assert.That(context.FirstHostPort).IsEqualTo(54321); + } + + [Test] + public async Task FirstHostPort_EmptyMappings_IsNull() + { + var (context, container) = Create(new Dictionary()); + + await using (container) + { + await Assert.That(context.FirstHostPort).IsNull(); + await Assert.That(context.NetworkIp).IsEqualTo("172.17.0.9"); + } + } +} diff --git a/tests/WslContainers.UnitTests/WaitStrategyRunnerTests.cs b/src/tests/WslContainers.UnitTests/WaitStrategyRunnerTests.cs similarity index 58% rename from tests/WslContainers.UnitTests/WaitStrategyRunnerTests.cs rename to src/tests/WslContainers.UnitTests/WaitStrategyRunnerTests.cs index 0c33b8f..ad95ae0 100644 --- a/tests/WslContainers.UnitTests/WaitStrategyRunnerTests.cs +++ b/src/tests/WslContainers.UnitTests/WaitStrategyRunnerTests.cs @@ -1,35 +1,34 @@ -using Purview.WslContainers; -using TUnit.Assertions; -using TUnit.Assertions.Extensions; +using Purview.WslContainers.Runtime; +using Purview.WslContainers.Waiting; -namespace WslContainers.UnitTests; +namespace Purview.WslContainers; public class WaitStrategyRunnerTests { [Test] - public async Task RunAsync_SucceedsWhenConditionEventuallyTrue() + public async Task RunAsync_SucceedsWhenConditionEventuallyTrue(CancellationToken cancellationToken) { - FakeContainer container = new FakeContainer { Name = "runner-test" }; - WaitContext context = new WaitContext(container, container.PortMappings, networkIp: null); - int calls = 0; + await using FakeContainer container = new() { Name = "runner-test" }; + WaitContext context = new(container, container.PortMappings, networkIp: null); + var calls = 0; IWaitStrategy strategy = Wait.ForCustom((_, _) => Task.FromResult(++calls >= 3)) .WithInterval(TimeSpan.FromMilliseconds(10)); - await WaitStrategyRunner.RunAsync(context, new[] { strategy }, TimeSpan.FromSeconds(5), CancellationToken.None); + await WaitStrategyRunner.RunAsync(context, new[] { strategy }, TimeSpan.FromSeconds(5), cancellationToken); await Assert.That(calls).IsGreaterThanOrEqualTo(3); } [Test] - public async Task RunAsync_TimesOut_ThrowsWithDiagnostics() + public async Task RunAsync_TimesOut_ThrowsWithDiagnostics(CancellationToken cancellationToken) { - FakeContainer container = new FakeContainer + await using FakeContainer container = new() { Name = "runner-test", Logs = "some service output\n", PortMappings = new Dictionary { [80] = 1234 }, }; - WaitContext context = new WaitContext(container, container.PortMappings, networkIp: null); + WaitContext context = new(container, container.PortMappings, networkIp: null); IWaitStrategy strategy = Wait.ForCustom((_, _) => Task.FromResult(false)) .WithInterval(TimeSpan.FromMilliseconds(10)) .WithTimeout(TimeSpan.FromMilliseconds(300)); @@ -37,12 +36,7 @@ public async Task RunAsync_TimesOut_ThrowsWithDiagnostics() Exception? thrown = null; try { - await WaitStrategyRunner.RunAsync( - context, - new[] { strategy }, - TimeSpan.FromSeconds(5), - CancellationToken.None - ); + await WaitStrategyRunner.RunAsync(context, new[] { strategy }, TimeSpan.FromSeconds(5), cancellationToken); } catch (WslContainerTimeoutException ex) { diff --git a/src/tests/WslContainers.UnitTests/WaitStrategyTests.cs b/src/tests/WslContainers.UnitTests/WaitStrategyTests.cs new file mode 100644 index 0000000..e6b2498 --- /dev/null +++ b/src/tests/WslContainers.UnitTests/WaitStrategyTests.cs @@ -0,0 +1,103 @@ +using System.Text.RegularExpressions; +using Purview.WslContainers.Containers; +using Purview.WslContainers.Waiting; + +namespace Purview.WslContainers; + +public class WaitStrategyTests +{ + static WaitContext Context(FakeContainer container) + { + return new WaitContext(container, container.PortMappings, networkIp: null); + } + + [Test] + public async Task LogMessage_MatchesAccumulatedOutput(CancellationToken cancellationToken) + { + await using FakeContainer container = new() + { + Logs = "2026-01-01 database system is ready to accept connections\n", + }; + var strategy = Wait.ForLogMessage("database system is ready"); + + await Assert.That(await strategy.UntilAsync(Context(container), cancellationToken)).IsTrue(); + } + + [Test] + public async Task LogMessage_DoesNotMatchAbsentMessage(CancellationToken cancellationToken) + { + await using FakeContainer container = new() { Logs = "starting up\n" }; +#pragma warning disable SYSLIB1045 // Convert to 'GeneratedRegexAttribute'. + var strategy = Wait.ForLogMessage(new Regex("ready to accept")); +#pragma warning restore SYSLIB1045 // Convert to 'GeneratedRegexAttribute'. + + await Assert.That(await strategy.UntilAsync(Context(container), cancellationToken)).IsFalse(); + } + + [Test] + public async Task Command_ReturnsTrueOnExitCodeZero(CancellationToken cancellationToken) + { + await using FakeContainer container = new() { Exec = _ => new ExecResult(0, "ok", string.Empty) }; + var strategy = Wait.ForCommand("/bin/true"); + + await Assert.That(await strategy.UntilAsync(Context(container), cancellationToken)).IsTrue(); + } + + [Test] + public async Task Command_ReturnsFalseOnNonZeroExit(CancellationToken cancellationToken) + { + await using FakeContainer container = new() { Exec = _ => new ExecResult(1, string.Empty, "boom") }; + var strategy = Wait.ForCommand("/bin/false"); + + await Assert.That(await strategy.UntilAsync(Context(container), cancellationToken)).IsFalse(); + } + + [Test] + public async Task Command_ReturnsFalseWhenExecThrows(CancellationToken cancellationToken) + { + await using FakeContainer container = new() + { + Exec = _ => throw new InvalidOperationException("container not ready"), + }; + var strategy = Wait.ForCommand("/bin/true"); + + await Assert.That(await strategy.UntilAsync(Context(container), cancellationToken)).IsFalse(); + } + + [Test] + public async Task ForAll_RequiresEveryStrategy(CancellationToken cancellationToken) + { + await using FakeContainer container = new(); + var all = Wait.ForAll( + Wait.ForCustom((_, _) => Task.FromResult(true)), + Wait.ForCustom((_, _) => Task.FromResult(false)) + ); + var any = Wait.ForAny( + Wait.ForCustom((_, _) => Task.FromResult(true)), + Wait.ForCustom((_, _) => Task.FromResult(false)) + ); + + await Assert.That(await all.UntilAsync(Context(container), cancellationToken)).IsFalse(); + await Assert.That(await any.UntilAsync(Context(container), cancellationToken)).IsTrue(); + } + + [Test] + public async Task ContainerRunning_ReflectsState(CancellationToken cancellationToken) + { + await using FakeContainer running = new() { State = ContainerState.Running }; + await using FakeContainer exited = new() { State = ContainerState.Exited }; + var strategy = Wait.ForContainerRunning(); + + await Assert.That(await strategy.UntilAsync(Context(running), cancellationToken)).IsTrue(); + await Assert.That(await strategy.UntilAsync(Context(exited), cancellationToken)).IsFalse(); + } + + [Test] + public async Task TcpPort_ReturnsFalseWhenNoMapping(CancellationToken cancellationToken) + { + await using FakeContainer container = new(); + var strategy = Wait.ForTcpPort(8080); + + await Assert.That(await strategy.UntilAsync(Context(container), cancellationToken)).IsFalse(); + } +} diff --git a/src/tests/WslContainers.UnitTests/WslContainerRuntimeTests.cs b/src/tests/WslContainers.UnitTests/WslContainerRuntimeTests.cs new file mode 100644 index 0000000..f716143 --- /dev/null +++ b/src/tests/WslContainers.UnitTests/WslContainerRuntimeTests.cs @@ -0,0 +1,53 @@ +using System.Runtime.InteropServices; + +namespace Purview.WslContainers; + +public class WslContainerRuntimeTests +{ + const int ErrorSharingViolation = unchecked((int)0x80070020); + + [Test] + public async Task SharingViolation_OnDefaultSharedStore_FallsBackToIsolatedStore() + { + COMException exception = new( + "The process cannot access the file because it is being used by another process.", + ErrorSharingViolation + ); + + var shouldFallBack = WslContainerRuntime.ShouldFallBackToIsolatedStore( + exception, + isUsingDefaultSharedStore: true + ); + + await Assert.That(shouldFallBack).IsTrue(); + } + + [Test] + public async Task SharingViolation_OnExplicitStore_DoesNotFallBack() + { + COMException exception = new( + "The process cannot access the file because it is being used by another process.", + ErrorSharingViolation + ); + + var shouldFallBack = WslContainerRuntime.ShouldFallBackToIsolatedStore( + exception, + isUsingDefaultSharedStore: false + ); + + await Assert.That(shouldFallBack).IsFalse(); + } + + [Test] + public async Task NonSharingViolation_DoesNotFallBack() + { + COMException exception = new("Unexpected failure", unchecked((int)0x8000FFFF)); + + var shouldFallBack = WslContainerRuntime.ShouldFallBackToIsolatedStore( + exception, + isUsingDefaultSharedStore: true + ); + + await Assert.That(shouldFallBack).IsFalse(); + } +} diff --git a/tests/WslContainers.UnitTests/WslContainers.UnitTests.csproj b/src/tests/WslContainers.UnitTests/WslContainers.UnitTests.csproj similarity index 100% rename from tests/WslContainers.UnitTests/WslContainers.UnitTests.csproj rename to src/tests/WslContainers.UnitTests/WslContainers.UnitTests.csproj diff --git a/tests/WslContainers.UnitTests/WslInspectParserTests.cs b/src/tests/WslContainers.UnitTests/WslInspectParserTests.cs similarity index 64% rename from tests/WslContainers.UnitTests/WslInspectParserTests.cs rename to src/tests/WslContainers.UnitTests/WslInspectParserTests.cs index 7f015a4..87d12c0 100644 --- a/tests/WslContainers.UnitTests/WslInspectParserTests.cs +++ b/src/tests/WslContainers.UnitTests/WslInspectParserTests.cs @@ -1,12 +1,9 @@ -using Purview.WslContainers; -using TUnit.Assertions; -using TUnit.Assertions.Extensions; - -namespace WslContainers.UnitTests; +namespace Purview.WslContainers; public class WslInspectParserTests { - private const string SampleInspect = """ + const string SampleInspect = /*lang=json,strict*/ + """ { "HostConfig": { "NetworkMode": "bridge" }, "NetworkSettings": { @@ -23,7 +20,7 @@ public class WslInspectParserTests [Test] public async Task ParseHostPorts_ReadsMappedPorts() { - IReadOnlyDictionary ports = WslInspectParser.ParseHostPorts(SampleInspect); + var ports = WslInspectParser.ParseHostPorts(SampleInspect); await Assert.That(ports.Count).IsEqualTo(1); await Assert.That(ports[8080]).IsEqualTo((ushort)61332); @@ -32,7 +29,7 @@ public async Task ParseHostPorts_ReadsMappedPorts() [Test] public async Task ParseHostPorts_EmptyJson_ReturnsEmpty() { - IReadOnlyDictionary ports = WslInspectParser.ParseHostPorts("{}"); + var ports = WslInspectParser.ParseHostPorts("{}"); await Assert.That(ports.Count).IsEqualTo(0); } @@ -40,7 +37,7 @@ public async Task ParseHostPorts_EmptyJson_ReturnsEmpty() [Test] public async Task TryGetNetworkIp_ReadsBridgeIp() { - string? ip = WslInspectParser.TryGetNetworkIp(SampleInspect); + var ip = WslInspectParser.TryGetNetworkIp(SampleInspect); await Assert.That(ip).IsEqualTo("172.17.0.3"); } @@ -48,7 +45,7 @@ public async Task TryGetNetworkIp_ReadsBridgeIp() [Test] public async Task TryGetNetworkIp_MissingNetwork_ReturnsNull() { - string? ip = WslInspectParser.TryGetNetworkIp("{}"); + var ip = WslInspectParser.TryGetNetworkIp("{}"); await Assert.That(ip).IsNull(); } diff --git a/tests/WslContainers.IntegrationTests/PerformanceTests.cs b/tests/WslContainers.IntegrationTests/PerformanceTests.cs deleted file mode 100644 index ef1fd9c..0000000 --- a/tests/WslContainers.IntegrationTests/PerformanceTests.cs +++ /dev/null @@ -1,39 +0,0 @@ -using System.Diagnostics; -using Purview.WslContainers; -using Purview.WslContainers.Testing; -using TUnit.Assertions; -using TUnit.Assertions.Extensions; - -namespace WslContainers.IntegrationTests; - -public class PerformanceTests -{ - [Test] - [NotInParallel] - public async Task WarmContainerStart_IsFast() - { - await WslcTest.SkipIfUnavailableAsync(); - - // Warm the image into the shared session store. - await using ( - WslContainer warm = new ContainerBuilder() - .WithImage("alpine:latest") - .WithCommand("/bin/echo", "warm") - .Build() - ) - { - await warm.StartAsync(); - } - - Stopwatch stopwatch = Stopwatch.StartNew(); - await using WslContainer container = new ContainerBuilder() - .WithImage("alpine:latest") - .WithCommand("/bin/echo", "hi") - .Build(); - await container.StartAsync(); - stopwatch.Stop(); - - TestContext.Current!.OutputWriter.WriteLine($"warm container start: {stopwatch.Elapsed}"); - await Assert.That(stopwatch.Elapsed).IsLessThan(TimeSpan.FromSeconds(60)); - } -} diff --git a/tests/WslContainers.IntegrationTests/SharedStorageTests.cs b/tests/WslContainers.IntegrationTests/SharedStorageTests.cs deleted file mode 100644 index 39da654..0000000 --- a/tests/WslContainers.IntegrationTests/SharedStorageTests.cs +++ /dev/null @@ -1,66 +0,0 @@ -using Purview.WslContainers; -using Purview.WslContainers.Images; -using Purview.WslContainers.Testing; -using TUnit.Assertions; -using TUnit.Assertions.Extensions; - -namespace WslContainers.IntegrationTests; - -public class SharedStorageTests -{ - [Test] - public async Task SharedStore_IsReusedAcrossSequentialRuntimes() - { - await WslcTest.SkipIfUnavailableAsync(); - - string path = Path.Combine(Path.GetTempPath(), "wslc-shared-" + Guid.NewGuid().ToString("N")[..6]); - Directory.CreateDirectory(path); - try - { - IReadOnlyList firstRun; - await using (WslContainerRuntime runtime1 = new WslContainerRuntime(new WslContainerRuntimeOptions - { - StoragePath = path, - DisableProcessExitCleanup = true, - })) - { - await using WslContainer container = new ContainerBuilder(runtime1) - .WithImage("alpine:latest") - .WithCommand("/bin/echo", "one") - .Build(); - await container.StartAsync(); - IContainerSession session1 = await runtime1.GetSessionAsync(); - firstRun = await session1.ListImagesAsync(); - } - - IReadOnlyList secondRun; - await using (WslContainerRuntime runtime2 = new WslContainerRuntime(new WslContainerRuntimeOptions - { - StoragePath = path, - DisableProcessExitCleanup = true, - })) - { - IContainerSession session2 = await runtime2.GetSessionAsync(); - secondRun = await session2.ListImagesAsync(); - } - - await Assert.That(firstRun.Any(image => image.Name.Contains("alpine", StringComparison.OrdinalIgnoreCase))).IsTrue(); - await Assert.That(secondRun.Any(image => image.Name.Contains("alpine", StringComparison.OrdinalIgnoreCase))).IsTrue(); - } - finally - { - try - { - Directory.Delete(path, recursive: true); - } - catch (IOException) - { - // VHD may still be held briefly by the session manager. - } - catch (UnauthorizedAccessException) - { - // Same. - } - } - } -} \ No newline at end of file diff --git a/tests/WslContainers.IntegrationTests/VolumeTests.cs b/tests/WslContainers.IntegrationTests/VolumeTests.cs deleted file mode 100644 index 76a0dde..0000000 --- a/tests/WslContainers.IntegrationTests/VolumeTests.cs +++ /dev/null @@ -1,59 +0,0 @@ -using Purview.WslContainers; -using Purview.WslContainers.Testing; -using TUnit.Assertions; -using TUnit.Assertions.Extensions; - -namespace WslContainers.IntegrationTests; - -public class VolumeTests -{ - [Test] - public async Task BindMount_ReadsHostFile() - { - await WslcTest.SkipIfUnavailableAsync(); - - string dir = Path.Combine(Path.GetTempPath(), "wslc-bind-" + Guid.NewGuid().ToString("N")[..6]); - Directory.CreateDirectory(dir); - File.WriteAllText(Path.Combine(dir, "hello.txt"), "hello-bind\n"); - try - { - await using WslContainer container = new ContainerBuilder() - .WithImage("alpine:latest") - .WithBindMount(dir, "/mnt/data") - .WithCommand("/bin/cat", "/mnt/data/hello.txt") - .Build(); - - await container.StartAsync(); - string logs = await container.GetLogsAsync(); - await Assert.That(logs).Contains("hello-bind"); - } - finally - { - Directory.Delete(dir, recursive: true); - } - } - - [Test] - public async Task NamedVolume_IsSharedBetweenContainers() - { - await WslcTest.SkipIfUnavailableAsync(); - - string volume = "wslc-vol-" + Guid.NewGuid().ToString("N")[..6]; - await using WslContainer writer = new ContainerBuilder() - .WithImage("alpine:latest") - .WithVolumeMount(volume, "/data") - .WithCommand("/bin/sh", "-c", "echo shared-data > /data/file.txt; sleep 300") - .Build(); - await writer.StartAsync(); - await Task.Delay(1500); - - await using WslContainer reader = new ContainerBuilder() - .WithImage("alpine:latest") - .WithVolumeMount(volume, "/data") - .WithCommand("/bin/cat", "/data/file.txt") - .Build(); - await reader.StartAsync(); - string logs = await reader.GetLogsAsync(); - await Assert.That(logs).Contains("shared-data"); - } -} diff --git a/tests/WslContainers.MinIo.IntegrationTests/MinIoIntegrationTests.cs b/tests/WslContainers.MinIo.IntegrationTests/MinIoIntegrationTests.cs deleted file mode 100644 index bac00e4..0000000 --- a/tests/WslContainers.MinIo.IntegrationTests/MinIoIntegrationTests.cs +++ /dev/null @@ -1,66 +0,0 @@ -using Purview.WslContainers.MinIo; -using Purview.WslContainers.Testing; -using TUnit.Assertions; -using TUnit.Assertions.Extensions; -using TUnit.Core.Exceptions; - -namespace WslContainers.MinIo.IntegrationTests; - -public class MinIoIntegrationTests -{ - [Test] - public async Task MinIo_ExposesApiAndConsole() - { - await using MinIoContainer minio = await StartOrSkipAsync(); - - await WslcTest.WaitForTcpAsync(minio.GetMappedPublicPort(MinIoBuilder.ApiPort), TimeSpan.FromSeconds(30)); - await WslcTest.WaitForTcpAsync(minio.GetMappedPublicPort(MinIoBuilder.ConsolePort), TimeSpan.FromSeconds(30)); - - using HttpClient client = new HttpClient(); - using HttpResponseMessage response = await client.GetAsync(minio.GetConsoleEndpoint()); - await Assert.That((int)response.StatusCode).IsEqualTo(200); - } - - [Test] - public async Task Endpoints_UseMappedRandomPorts() - { - await using MinIoContainer minio = await StartOrSkipAsync(); - - ushort apiPort = minio.GetMappedPublicPort(MinIoBuilder.ApiPort); - ushort consolePort = minio.GetMappedPublicPort(MinIoBuilder.ConsolePort); - - await Assert.That(minio.GetApiEndpoint().ToString()).IsEqualTo($"http://127.0.0.1:{apiPort}"); - await Assert.That(minio.GetConsoleEndpoint().ToString()).IsEqualTo($"http://127.0.0.1:{consolePort}"); - await Assert.That(minio.GetAccessKey()).IsEqualTo("minioadmin"); - } - - // MinIO's official image lives on quay.io, which some networks/WSL sessions cannot reach - // (e.g. IPv6-only resolution with no route). Skip gracefully rather than fail the suite. - private static async Task StartOrSkipAsync() - { - await WslcTest.SkipIfUnavailableAsync(); - - MinIoContainer minio = new MinIoBuilder().Build(); - try - { - await minio.StartAsync(); - return minio; - } - catch (Exception ex) - { - await minio.DisposeAsync(); - string message = ex.ToString(); - if ( - message.Contains("network is unreachable", StringComparison.OrdinalIgnoreCase) - || message.Contains("unreachable", StringComparison.OrdinalIgnoreCase) - || message.Contains("unauthorized", StringComparison.OrdinalIgnoreCase) - || message.Contains("does not exist", StringComparison.OrdinalIgnoreCase) - ) - { - throw new SkipTestException($"MinIO image could not be pulled from its registry: {ex.Message}"); - } - - throw; - } - } -} diff --git a/tests/WslContainers.MinIo.UnitTests/MinIoBuilderTests.cs b/tests/WslContainers.MinIo.UnitTests/MinIoBuilderTests.cs deleted file mode 100644 index c6132cc..0000000 --- a/tests/WslContainers.MinIo.UnitTests/MinIoBuilderTests.cs +++ /dev/null @@ -1,35 +0,0 @@ -using Purview.WslContainers; -using Purview.WslContainers.MinIo; -using TUnit.Assertions; -using TUnit.Assertions.Extensions; - -namespace WslContainers.MinIo.UnitTests; - -public class MinIoBuilderTests -{ - [Test] - public async Task BuildConfig_AppliesDefaults() - { - MinIoConfiguration configuration = new MinIoBuilder().BuildConfigurationForTesting(); - - await Assert.That(configuration.Image).IsEqualTo("quay.io/minio/minio:latest"); - await Assert.That(configuration.AccessKey).IsEqualTo("minioadmin"); - await Assert.That(configuration.SecretKey.Value).IsEqualTo("minioadmin"); - await Assert.That(configuration.Environment["MINIO_ROOT_USER"]).IsEqualTo("minioadmin"); - await Assert.That(configuration.Environment["MINIO_ROOT_PASSWORD"]).IsEqualTo("minioadmin"); - await Assert - .That(configuration.Command) - .IsEquivalentTo(new[] { "minio", "server", "/data", "--console-address", ":9001" }); - await Assert.That(configuration.PortBindings).HasCount().EqualTo(2); - await Assert.That(configuration.PortBindings).Contains(p => p.ContainerPort == 9000 && p.AssignRandomHostPort); - await Assert.That(configuration.PortBindings).Contains(p => p.ContainerPort == 9001 && p.AssignRandomHostPort); - } - - [Test] - public async Task BuildConfig_EmptySecret_Throws() - { - MinIoBuilder builder = new MinIoBuilder().WithSecretKey(string.Empty); - - await Assert.That(() => builder.Build()).Throws(); - } -} diff --git a/tests/WslContainers.MsSql.IntegrationTests/MsSqlIntegrationTests.cs b/tests/WslContainers.MsSql.IntegrationTests/MsSqlIntegrationTests.cs deleted file mode 100644 index 5d24396..0000000 --- a/tests/WslContainers.MsSql.IntegrationTests/MsSqlIntegrationTests.cs +++ /dev/null @@ -1,50 +0,0 @@ -using Microsoft.Data.SqlClient; -using Purview.WslContainers.MsSql; -using Purview.WslContainers.Testing; -using TUnit.Assertions; -using TUnit.Assertions.Extensions; - -namespace WslContainers.MsSql.IntegrationTests; - -public class MsSqlIntegrationTests -{ - [Test] - public async Task MsSql_ConnectsAndRunsQuery() - { - await WslcTest.SkipIfUnavailableAsync(); - - await using MsSqlContainer sqlServer = new MsSqlBuilder() - .WithPassword("SomeStrong!Password1") - .AcceptLicense() - .Build(); - - await sqlServer.StartAsync(); - - await using SqlConnection connection = new SqlConnection(sqlServer.GetConnectionString()); - await connection.OpenAsync(); - await using SqlCommand command = new SqlCommand("SELECT 1", connection); - object result = await command.ExecuteScalarAsync(); - - await Assert.That(Convert.ToInt32(result, System.Globalization.CultureInfo.InvariantCulture)).IsEqualTo(1); - } - - [Test] - public async Task ConnectionString_UsesMappedRandomPort() - { - await WslcTest.SkipIfUnavailableAsync(); - - await using MsSqlContainer sqlServer = new MsSqlBuilder() - .WithPassword("SomeStrong!Password1") - .AcceptLicense() - .Build(); - - await sqlServer.StartAsync(); - - SqlConnectionStringBuilder builder = new SqlConnectionStringBuilder(sqlServer.GetConnectionString()); - ushort port = sqlServer.GetMappedPublicPort(MsSqlBuilder.MsSqlPort); - - await Assert.That(builder.DataSource).IsEqualTo($"127.0.0.1,{port}"); - await Assert.That(port).IsGreaterThan((ushort)0); - await Assert.That(builder.UserID).IsEqualTo("sa"); - } -} diff --git a/tests/WslContainers.PostgreSql.UnitTests/WslContainers.PostgreSql.UnitTests.csproj b/tests/WslContainers.PostgreSql.UnitTests/WslContainers.PostgreSql.UnitTests.csproj deleted file mode 100644 index e6c35ec..0000000 --- a/tests/WslContainers.PostgreSql.UnitTests/WslContainers.PostgreSql.UnitTests.csproj +++ /dev/null @@ -1 +0,0 @@ - diff --git a/tests/WslContainers.RabbitMq.UnitTests/WslContainers.RabbitMq.UnitTests.csproj b/tests/WslContainers.RabbitMq.UnitTests/WslContainers.RabbitMq.UnitTests.csproj deleted file mode 100644 index e6c35ec..0000000 --- a/tests/WslContainers.RabbitMq.UnitTests/WslContainers.RabbitMq.UnitTests.csproj +++ /dev/null @@ -1 +0,0 @@ - diff --git a/tests/WslContainers.Redis.IntegrationTests/RedisIntegrationTests.cs b/tests/WslContainers.Redis.IntegrationTests/RedisIntegrationTests.cs deleted file mode 100644 index 77b4d92..0000000 --- a/tests/WslContainers.Redis.IntegrationTests/RedisIntegrationTests.cs +++ /dev/null @@ -1,43 +0,0 @@ -using Purview.WslContainers.Redis; -using StackExchange.Redis; -using TUnit.Assertions; -using TUnit.Assertions.Extensions; - -namespace WslContainers.Redis.IntegrationTests; - -public class RedisIntegrationTests -{ - [Test] - public async Task Redis_RespondsToPing() - { - await Purview.WslContainers.Testing.WslcTest.SkipIfUnavailableAsync(); - - await using RedisContainer redis = new RedisBuilder().Build(); - - await redis.StartAsync(); - - await using ConnectionMultiplexer connection = await ConnectionMultiplexer.ConnectAsync( - redis.GetConnectionString() - ); - IDatabase db = connection.GetDatabase(); - RedisResult pong = await db.ExecuteAsync("PING"); - - await Assert.That(pong.ToString()).IsEqualTo("PONG"); - } - - [Test] - public async Task ConnectionString_UsesMappedRandomPort() - { - await Purview.WslContainers.Testing.WslcTest.SkipIfUnavailableAsync(); - - await using RedisContainer redis = new RedisBuilder().Build(); - - await redis.StartAsync(); - - string connectionString = redis.GetConnectionString(); - ushort port = redis.GetMappedPublicPort(RedisBuilder.RedisPort); - - await Assert.That(connectionString).IsEqualTo($"localhost:{port}"); - await Assert.That(port).IsNotEqualTo(RedisBuilder.RedisPort); - } -} diff --git a/tests/WslContainers.Redis.UnitTests/WslContainers.Redis.UnitTests.csproj b/tests/WslContainers.Redis.UnitTests/WslContainers.Redis.UnitTests.csproj deleted file mode 100644 index e6c35ec..0000000 --- a/tests/WslContainers.Redis.UnitTests/WslContainers.Redis.UnitTests.csproj +++ /dev/null @@ -1 +0,0 @@ - diff --git a/tests/WslContainers.UnitTests/HttpWaitStrategyTests.cs b/tests/WslContainers.UnitTests/HttpWaitStrategyTests.cs deleted file mode 100644 index cba8feb..0000000 --- a/tests/WslContainers.UnitTests/HttpWaitStrategyTests.cs +++ /dev/null @@ -1,124 +0,0 @@ -using System.Net; -using System.Net.Sockets; -using System.Text; -using Purview.WslContainers; -using TUnit.Assertions; -using TUnit.Assertions.Extensions; - -namespace WslContainers.UnitTests; - -public class HttpWaitStrategyTests -{ - [Test] - public async Task Http_ReturnsTrueOnExpectedStatus() - { - (int port, HttpListener listener, Task _) = StartServer(HttpStatusCode.OK); - try - { - FakeContainer container = new FakeContainer - { - PortMappings = new Dictionary { [80] = (ushort)port }, - }; - WaitContext context = new WaitContext(container, container.PortMappings, networkIp: null); - HttpWaitStrategy strategy = Wait.ForHttp("/health").ForPort(80).ForStatusCode(HttpStatusCode.OK); - - await Assert.That(await strategy.UntilAsync(context, CancellationToken.None)).IsTrue(); - } - finally - { - listener.Stop(); - } - } - - [Test] - public async Task Http_ReturnsFalseOnUnexpectedStatus() - { - (int port, HttpListener listener, Task _) = StartServer(HttpStatusCode.InternalServerError); - try - { - FakeContainer container = new FakeContainer - { - PortMappings = new Dictionary { [80] = (ushort)port }, - }; - WaitContext context = new WaitContext(container, container.PortMappings, networkIp: null); - HttpWaitStrategy strategy = Wait.ForHttp("/health").ForPort(80).ForStatusCode(HttpStatusCode.OK); - - await Assert.That(await strategy.UntilAsync(context, CancellationToken.None)).IsFalse(); - } - finally - { - listener.Stop(); - } - } - - [Test] - public async Task Http_StatusPredicate_IsUsed() - { - (int port, HttpListener listener, Task _) = StartServer(HttpStatusCode.Accepted); - try - { - FakeContainer container = new FakeContainer - { - PortMappings = new Dictionary { [80] = (ushort)port }, - }; - WaitContext context = new WaitContext(container, container.PortMappings, networkIp: null); - HttpWaitStrategy strategy = Wait.ForHttp("/") - .ForPort(80) - .ForStatusPredicate(status => status >= 200 && status < 300); - - await Assert.That(await strategy.UntilAsync(context, CancellationToken.None)).IsTrue(); - } - finally - { - listener.Stop(); - } - } - - [Test] - public async Task Http_ReturnsFalseWhenPortNotMapped() - { - FakeContainer container = new FakeContainer(); - WaitContext context = new WaitContext(container, container.PortMappings, networkIp: null); - HttpWaitStrategy strategy = Wait.ForHttp("/").ForPort(80); - - await Assert.That(await strategy.UntilAsync(context, CancellationToken.None)).IsFalse(); - } - - private static (int Port, HttpListener Listener, Task Handler) StartServer(HttpStatusCode status) - { - using TcpListener probe = new TcpListener(IPAddress.Loopback, 0); - probe.Start(); - int port = ((IPEndPoint)probe.LocalEndpoint).Port; - probe.Stop(); - - HttpListener listener = new HttpListener(); - listener.Prefixes.Add($"http://127.0.0.1:{port}/"); - listener.Start(); - - Task handler = Task.Run(async () => - { - while (listener.IsListening) - { - try - { - HttpListenerContext ctx = await listener.GetContextAsync(); - ctx.Response.StatusCode = (int)status; - byte[] body = Encoding.UTF8.GetBytes("ok"); - ctx.Response.ContentLength64 = body.Length; - await ctx.Response.OutputStream.WriteAsync(body); - ctx.Response.Close(); - } - catch (HttpListenerException) - { - break; - } - catch (ObjectDisposedException) - { - break; - } - } - }); - - return (port, listener, handler); - } -} diff --git a/tests/WslContainers.UnitTests/WaitContextTests.cs b/tests/WslContainers.UnitTests/WaitContextTests.cs deleted file mode 100644 index 788742f..0000000 --- a/tests/WslContainers.UnitTests/WaitContextTests.cs +++ /dev/null @@ -1,40 +0,0 @@ -using Purview.WslContainers; -using TUnit.Assertions; -using TUnit.Assertions.Extensions; - -namespace WslContainers.UnitTests; - -public class WaitContextTests -{ - private static WaitContext Create(IReadOnlyDictionary mappings) - { - FakeContainer container = new FakeContainer { PortMappings = mappings }; - return new WaitContext(container, mappings, "172.17.0.9"); - } - - [Test] - public async Task GetHostPort_ReturnsMappedPort() - { - WaitContext context = Create(new Dictionary { [8080] = 54321 }); - - await Assert.That(context.GetHostPort(8080)).IsEqualTo(54321); - await Assert.That(context.GetHostPort(80)).IsNull(); - } - - [Test] - public async Task FirstHostPort_ReturnsFirstMapping() - { - WaitContext context = Create(new Dictionary { [8080] = 54321, [8081] = 54322 }); - - await Assert.That(context.FirstHostPort).IsEqualTo(54321); - } - - [Test] - public async Task FirstHostPort_EmptyMappings_IsNull() - { - WaitContext context = Create(new Dictionary()); - - await Assert.That(context.FirstHostPort).IsNull(); - await Assert.That(context.NetworkIp).IsEqualTo("172.17.0.9"); - } -} diff --git a/tests/WslContainers.UnitTests/WaitStrategyTests.cs b/tests/WslContainers.UnitTests/WaitStrategyTests.cs deleted file mode 100644 index d15595d..0000000 --- a/tests/WslContainers.UnitTests/WaitStrategyTests.cs +++ /dev/null @@ -1,102 +0,0 @@ -using System.Text.RegularExpressions; -using Purview.WslContainers; -using TUnit.Assertions; -using TUnit.Assertions.Extensions; - -namespace WslContainers.UnitTests; - -public class WaitStrategyTests -{ - private static WaitContext Context(FakeContainer container) - { - return new WaitContext(container, container.PortMappings, networkIp: null); - } - - [Test] - public async Task LogMessage_MatchesAccumulatedOutput() - { - FakeContainer container = new FakeContainer - { - Logs = "2026-01-01 database system is ready to accept connections\n", - }; - IWaitStrategy strategy = Wait.ForLogMessage("database system is ready"); - - await Assert.That(await strategy.UntilAsync(Context(container), CancellationToken.None)).IsTrue(); - } - - [Test] - public async Task LogMessage_DoesNotMatchAbsentMessage() - { - FakeContainer container = new FakeContainer { Logs = "starting up\n" }; - IWaitStrategy strategy = Wait.ForLogMessage(new Regex("ready to accept")); - - await Assert.That(await strategy.UntilAsync(Context(container), CancellationToken.None)).IsFalse(); - } - - [Test] - public async Task Command_ReturnsTrueOnExitCodeZero() - { - FakeContainer container = new FakeContainer { Exec = _ => new ExecResult(0, "ok", string.Empty) }; - IWaitStrategy strategy = Wait.ForCommand("/bin/true"); - - await Assert.That(await strategy.UntilAsync(Context(container), CancellationToken.None)).IsTrue(); - } - - [Test] - public async Task Command_ReturnsFalseOnNonZeroExit() - { - FakeContainer container = new FakeContainer { Exec = _ => new ExecResult(1, string.Empty, "boom") }; - IWaitStrategy strategy = Wait.ForCommand("/bin/false"); - - await Assert.That(await strategy.UntilAsync(Context(container), CancellationToken.None)).IsFalse(); - } - - [Test] - public async Task Command_ReturnsFalseWhenExecThrows() - { - FakeContainer container = new FakeContainer - { - Exec = _ => throw new InvalidOperationException("container not ready"), - }; - IWaitStrategy strategy = Wait.ForCommand("/bin/true"); - - await Assert.That(await strategy.UntilAsync(Context(container), CancellationToken.None)).IsFalse(); - } - - [Test] - public async Task ForAll_RequiresEveryStrategy() - { - FakeContainer container = new FakeContainer(); - IWaitStrategy all = Wait.ForAll( - Wait.ForCustom((_, _) => Task.FromResult(true)), - Wait.ForCustom((_, _) => Task.FromResult(false)) - ); - IWaitStrategy any = Wait.ForAny( - Wait.ForCustom((_, _) => Task.FromResult(true)), - Wait.ForCustom((_, _) => Task.FromResult(false)) - ); - - await Assert.That(await all.UntilAsync(Context(container), CancellationToken.None)).IsFalse(); - await Assert.That(await any.UntilAsync(Context(container), CancellationToken.None)).IsTrue(); - } - - [Test] - public async Task ContainerRunning_ReflectsState() - { - FakeContainer running = new FakeContainer { State = ContainerState.Running }; - FakeContainer exited = new FakeContainer { State = ContainerState.Exited }; - IWaitStrategy strategy = Wait.ForContainerRunning(); - - await Assert.That(await strategy.UntilAsync(Context(running), CancellationToken.None)).IsTrue(); - await Assert.That(await strategy.UntilAsync(Context(exited), CancellationToken.None)).IsFalse(); - } - - [Test] - public async Task TcpPort_ReturnsFalseWhenNoMapping() - { - FakeContainer container = new FakeContainer(); - IWaitStrategy strategy = Wait.ForTcpPort(8080); - - await Assert.That(await strategy.UntilAsync(Context(container), CancellationToken.None)).IsFalse(); - } -}