Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ The WSLC backend is built directly against the `Microsoft.WSL.Containers` NuGet
> modules** (PostgreSQL, Redis, SQL Server, RabbitMQ, Azurite, NATS, MySQL). On WSLC, **images are shared
> by default** (`StorageMode.Shared`): sessions reuse a stable image store
> (`%LOCALAPPDATA%\Purview\WslContainers\images`) so images are pulled once, not per session;
> `StorageMode.PerSession` provides isolation. Runnable samples live in `samples/getting-started`.
> `StorageMode.PerSession` provides isolation. Move the store with `PURVIEW_CONTAINERS_STORAGE_PATH` or
> `WslContainerRuntimeOptions.StoragePath`. Runnable samples live in `samples/getting-started`.

## Prerequisites

Expand Down
15 changes: 9 additions & 6 deletions docs/wiki/Architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ cache. Package consumers get registration from the generated module initializer

Design rules:

1. **One lazily-started session per process** with a deterministic name `wslc-{processId}-{8 hex}` (unique per machine; session names are reserved until the session is disposed) and a **stable storage path** (default `%LOCALAPPDATA%\Purview.WslContainers\sessions\{name}\`), configurable.
1. **One lazily-started session per process** with a deterministic name `wslc-{processId}-{8 hex}` (unique per machine; session names are reserved until the session is disposed) and a **stable storage path** (default `%LOCALAPPDATA%\Purview\WslContainers\images`), configurable via `WslContainerRuntimeOptions.StoragePath` or `PURVIEW_CONTAINERS_STORAGE_PATH`.
2. The session VM is capped at **4096 MB by default** (`WslContainerRuntimeOptions.Default`). This is required for SQL Server (which refuses to start below 2000 MB — `sqlservr: This program requires a machine with at least 2000 megabytes of memory`) and harmless for lighter containers. Override via `WslContainerRuntimeOptions.MemorySizeInMB`.
2. `IContainerBackend` is the seam for backends: a backend package (starting with `Purview.Containers.Wsl`) supplies `IContainer` instances, and `ContainerBackends` resolves which one runs. `IContainerRuntime` remains the WSLC-internal seam so advanced users/tests can substitute a per-container-session runtime for isolation experiments.
3. Container `DisposeAsync` never terminates the shared session; it deletes the container only.
Expand All @@ -85,9 +85,11 @@ Design rules:

- Name: `wslc-{pid}-{random8}`. Never place secrets/credentials in names, paths, or logs.
- **Storage is shared by default** (`StorageMode.Shared`): all sessions use
`%LOCALAPPDATA%\Purview.WslContainers\images`, so the image store is pulled once and reused across
process runs. Set `StorageMode.PerSession` (or an explicit `StoragePath` / `PURVIEW_CONTAINERS_STORAGE_PATH`)
for isolation. Session names stay unique per process; only the path is shared.
`%LOCALAPPDATA%\Purview\WslContainers\images`, so the image store is pulled once and reused across
process runs. Set `StorageMode.PerSession` (or an explicit `StoragePath` /
`PURVIEW_CONTAINERS_STORAGE_PATH`) for isolation; in code, configure the backend with
`new WslContainerBackend(new WslContainerRuntimeOptions { StoragePath = … })`. 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`). The lock is taken lazily on the
first store access, so contention can surface on `GetImages()` rather than at session start; the runtime
Expand Down Expand Up @@ -123,8 +125,9 @@ on the same path can start its session successfully and only fail later on its f
(`GetImages()`) with `0x80070020`. The runtime therefore verifies the store once, under a gate, on the
first `GetSessionAsync`; when a concurrent process holds the default shared store, that session is
discarded and the runtime transparently switches to an isolated per-process store. Explicit
`StoragePath`/`PURVIEW_CONTAINERS_STORAGE_PATH`/`StorageMode.PerSession` configuration opts out of the
fallback. Isolated stores are transient and are removed when their session terminates.
`StoragePath`/`PURVIEW_CONTAINERS_STORAGE_PATH`/`StorageMode.PerSession` configuration (or
`new WslContainerBackend(new WslContainerRuntimeOptions { StoragePath = … })`) opts out of the fallback.
Isolated stores are transient and are removed when their session terminates.

## Cleanup & reaper decision

Expand Down
44 changes: 42 additions & 2 deletions docs/wiki/Backends.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,52 @@ Service modules (`Purview.Containers.PostgreSql`, `Redis`, …) are backend-neut
| **Host OS** | Windows only | Windows, Linux, macOS |
| **Project target framework** | `net10.0` or later, any platform (portable facade), or `net10.0-windows10.0.19041.0`, x64 or arm64 (implementation bound directly) | `net10.0` or later, any platform |
| **How containers run** | the `Microsoft.WSL.Containers` managed API (daemonless) | the Docker Engine API via Testcontainers |
| **Images** | a shared store (`%LOCALAPPDATA%\Purview\WslContainers\images`) reused across runs | the daemon's own image store |
| **Images** | a shared store (`%LOCALAPPDATA%\Purview\WslContainers\images`) reused across runs; the location is configurable | the daemon's own image store |
| **Leak protection** | session disposal plus a process-exit hook | the Testcontainers resource reaper (Ryuk) |
| **Check the host** | `wsl --version`, `wslc version` | `docker info` |
| **Typical fit** | local Windows development without Docker Desktop, fastest cold start | CI runners, non-Windows hosts, teams already running Docker |

Switch between them without touching test code:
## Image store location (WSLC)

By default WSLC images are pulled once into a shared store under the local profile:

```text
%LOCALAPPDATA%\Purview\WslContainers\images
```

To put the image store somewhere else — a different drive, a project-local cache, or a per-run folder —
set the process-wide environment variable (works for every consumer shape, including a platform-neutral
`net10.0` facade):

```powershell
$env:PURVIEW_CONTAINERS_STORAGE_PATH = 'D:\wslc-images' # PowerShell
```

```bash
export PURVIEW_CONTAINERS_STORAGE_PATH=/mnt/d/wslc-images # bash / WSL / CI
```

Or configure the backend in code:

```csharp
using Purview.Containers;
using Purview.Containers.Wsl;

ContainerBackends.Use(new WslContainerBackend(WslContainerRuntimeOptions.Default with
{
StoragePath = @"D:\wslc-images",
StorageMode = StorageMode.Shared,
}));
```

`StorageMode.Shared` (the default) keeps a stable, warm image store at `StoragePath` (or the default above
when it is unset); `StorageMode.PerSession` gives each session a throwaway store under
`%LOCALAPPDATA%\Purview\WslContainers\sessions\{name}`. The full `WslContainerRuntimeOptions` set (CPU,
memory, GPU, session name, `StoragePath`, `StorageMode`, timeout) is honoured on both a platform-neutral
`net10.0` consumer — where the facade forwards it to the Windows build at run time — and a Windows target
framework. The pre-rename `WSL_CONTAINERS_STORAGE_PATH` is still honoured as a fallback.

## Switch between them without touching test code:

```bash
# auto (the default) | wsl | docker | <any registered backend name>
Expand Down
12 changes: 6 additions & 6 deletions docs/wiki/Contributing-Modules.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,18 @@ How to add a new service module to `Purview.Containers`.
## Files

```
src/MyService/
src/src/MyService/
MyService.csproj -> PackageId Purview.Containers.MyService
MyServiceConfiguration.cs -> immutable record, module fields
MyServiceBuilder.cs -> fluent builder
MyServiceContainer.cs -> container, connection string / endpoints
tests/MyService.UnitTests/
tests/MyService.IntegrationTests/
src/tests/MyService.UnitTests/
src/tests/MyService.IntegrationTests/
```

## Steps

1. **Reference the core**: `<ProjectReference Include="../Wsl/Wsl.csproj" />` (the Purview SDK adds the right `InternalsVisibleTo`/pack defaults).
1. **Reference the abstractions**: `<ProjectReference Include="../Core/Core.csproj" />`. A module never references a backend package; the Purview SDK supplies the pack defaults and the `InternalsVisibleTo` entries for the module's test projects.
2. **Configuration record** — derive from `ContainerConfiguration`, add module fields; credentials as `Secret`:

```csharp
Expand Down Expand Up @@ -79,12 +79,12 @@ public class MyServiceBuilder : ContainerBuilder<MyServiceBuilder, MyServiceCont
```
5. **Wait strategy** — prefer verifying the service itself (exec a readiness command or a host client connection), not merely that a TCP port is open. See [Wait Strategies](Wait-Strategies.md). Default waits are applied in `BuildConfiguration()` unless the caller supplied their own.
6. **Secrets** — passwords/usernames go into a `Secret`-typed field; configuration `ToString()` redacts sensitive values automatically.
7. **Tests** — unit tests use `BuildConfigurationForTesting()` (internal test hook on the module builder); integration tests use TUnit, the shared `WslcTest.SkipIfUnavailableAsync()` helper from `tests/SharedTestingFramework`, and the real client.
7. **Tests** — unit tests use `BuildConfigurationForTesting()` (internal test hook on the module builder); integration tests use TUnit, the shared `WslcTest.SkipIfUnavailableAsync()` helper from `src/tests/SharedTestingFramework`, and the real client.

## Conventions

- Package ID `Purview.Containers.MyService` (namespace prefix `Purview`).
- Module is thin: no session management, no port allocation logic, no output buffering.
- Default networking is `Bridged` (from the core defaults); ports use native random allocation unless a fixed host port is explicitly requested.
- The module is **backend-neutral** (`net10.0`, references `Purview.Containers` only) and therefore does **not** bring a backend or inherit its consumer requirements. A consumer references the module *and* a backend package (`Purview.Containers.Wsl` for WSLC, `Purview.Containers.Docker` for Docker). See [Consumer Requirements](Consumer-Requirements.md).
- The module is **backend-neutral** (`net10.0`, references `Purview.Containers.Core` only) and therefore does **not** bring a backend or inherit its consumer requirements. A consumer references the module *and* a backend package (`Purview.Containers.Wsl` for WSLC, `Purview.Containers.Docker` for Docker). See [Consumer Requirements](Consumer-Requirements.md).
- If a Testcontainers capability has no WSLC equivalent (e.g. UDP, TTY, `--user`), throw `ContainerNotSupportedException` at build/validation rather than silently ignoring it.
4 changes: 3 additions & 1 deletion docs/wiki/Home.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,9 @@ This wiki is the project documentation hub. The packages are published under the

- **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.
concurrent process holds the shared store VHD. The image store defaults to
`%LOCALAPPDATA%\Purview\WslContainers\images` and can be moved with `PURVIEW_CONTAINERS_STORAGE_PATH` or
`WslContainerRuntimeOptions.StoragePath`.
- **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`),
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "purview-containers",
"version": "1.0.0-prerelease.4",
"version": "1.0.0-prerelease.5",
"license": "MIT",
"author": {
"name": "Kieron Lanning",
Expand All @@ -14,4 +14,4 @@
"type": "git",
"url": "git+https://github.com/purview-dev/containers.git"
}
}
}
3 changes: 3 additions & 0 deletions src/src/Core/ContainerBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ public virtual string GetConnectionString(ConnectionMode connectionMode = Connec
return provider.GetConnectionString(connectionMode);
}

// If the container has started but no connection string provider was configured, we can only support the default host connection string.
return connectionMode switch
{
ConnectionMode.Host => GetDefaultHostConnectionString(),
Expand All @@ -121,6 +122,7 @@ public virtual string GetConnectionString(string name, ConnectionMode connection
return provider.GetConnectionString(name, connectionMode);
}

// If the container has started but no connection string provider was configured, we can only support the default host connection string.
throw new ConnectionStringNameNotSupportedException(GetType(), name);
}

Expand All @@ -132,6 +134,7 @@ string GetDefaultHostConnectionString()
throw new ConnectionStringNotAvailableException(ConnectionMode.Host, GetType());
}

// The default host connection string is always
return $"127.0.0.1:{first.Value}";
}

Expand Down
1 change: 1 addition & 0 deletions src/src/Core/ContainerConnectionStringProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ public virtual string GetConnectionString(ConnectionMode connectionMode = Connec
throw new ConnectionStringNotAvailableException(connectionMode, GetType());
}

// The connection string is non-empty, so return it.
return connectionString;
}

Expand Down
1 change: 1 addition & 0 deletions src/src/Docker/DockerContainer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ public string GetConnectionString(ConnectionMode connectionMode = ConnectionMode
throw new ConnectionStringNotAvailableException(connectionMode, GetType());
}

// The Docker backend is always local, so the connection string is always localhost with the first mapped port.
return $"127.0.0.1:{first.Value}";
}

Expand Down
19 changes: 17 additions & 2 deletions src/src/Wsl/Sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,23 @@ a diagnostic naming the container, image, state, mapped ports, strategy and a bo
`%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 `PURVIEW_CONTAINERS_STORAGE_PATH` environment variable) and `StorageMode.PerSession`.
that session terminates).

To place the image store somewhere other than the local profile, set the process-wide override
`PURVIEW_CONTAINERS_STORAGE_PATH` (the pre-rename `WSL_CONTAINERS_STORAGE_PATH` is still honoured as a
fallback), or configure the backend in code:

```csharp
ContainerBackends.Use(new WslContainerBackend(WslContainerRuntimeOptions.Default with
{
StoragePath = @"D:\wslc-images",
StorageMode = StorageMode.Shared,
}));
```

The full `WslContainerRuntimeOptions` set (CPU, memory, GPU, session name, `StoragePath`, `StorageMode`,
timeout) is honoured on both a platform-neutral (`net10.0`) consumer — where the facade forwards it to the
Windows build at run time — and a Windows target framework, where the Windows build applies it directly.

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.
Expand Down
1 change: 1 addition & 0 deletions src/src/Wsl/WslContainer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,7 @@ public string GetConnectionString(ConnectionMode connectionMode = ConnectionMode
throw new ConnectionStringNotAvailableException(connectionMode, GetType());
}

// WSLC does not support dynamic host port assignment; the host port is always the same as the container port.
return $"127.0.0.1:{first.Value}";
}

Expand Down
18 changes: 16 additions & 2 deletions src/src/Wsl/WslContainerBackend.Facade.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,20 @@ namespace Purview.Containers.Wsl;
/// </remarks>
public sealed class WslContainerBackend : IContainerBackend, IContainerBackendPreference
{
readonly WslContainerRuntimeOptions? _options;

/// <summary>Creates the backend over the default process-wide runtime.</summary>
public WslContainerBackend() { }

/// <summary>
/// Creates the backend configured with the given runtime options. On a platform-neutral target the
/// options are forwarded to the Windows implementation when it is loaded.
/// </summary>
public WslContainerBackend(WslContainerRuntimeOptions options)
{
_options = options ?? WslContainerRuntimeOptions.Default;
}

/// <summary>Stable backend identifier.</summary>
public string Name => "wsl";

Expand Down Expand Up @@ -54,8 +68,8 @@ public async Task<ContainerBackendInfo> GetInfoAsync(CancellationToken cancellat
}
}

static IContainerBackend Resolve() =>
WslPayload.TryCreateBackend()
IContainerBackend Resolve() =>
WslPayload.TryCreateBackend(_options)
?? throw new WslContainerPrerequisiteException(
$"The WSL Containers backend cannot run here. {WslPayload.FailureReason}"
);
Expand Down
40 changes: 40 additions & 0 deletions src/src/Wsl/WslContainerBackend.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using Purview.Containers.Runtime;

namespace Purview.Containers.Wsl;
Expand All @@ -18,6 +19,15 @@ public WslContainerBackend(IContainerRuntime runtime)
Runtime = runtime;
}

/// <summary>Creates the backend over a runtime configured with the given options.</summary>
[SuppressMessage(
"Reliability",
"CA2000:Dispose objects before losing scope",
Justification = "The runtime owns the process-wide session and is released by its process-exit hook; the backend is process-lifetime."
)]
public WslContainerBackend(WslContainerRuntimeOptions options)
: this(new WslContainerRuntime(options)) { }

/// <summary>Stable backend identifier.</summary>
public string Name => "wsl";

Expand All @@ -29,6 +39,36 @@ public WslContainerBackend(IContainerRuntime runtime)
/// <summary>Factory used by the generated backend registration.</summary>
public static WslContainerBackend Create() => new();

/// <summary>
/// Creates a backend from the runtime options forwarded by the portable facade. The facade cannot pass
/// <see cref="WslContainerRuntimeOptions" /> across the payload boundary directly (each build has its own
/// copy of the type), so it forwards the individual primitive values instead.
/// </summary>
internal static WslContainerBackend Create(
uint? cpuCount,
uint? memorySizeInMB,
bool enableGpu,
string? sessionName,
string? storagePath,
int storageMode,
long? sessionTimeoutTicks,
bool disableProcessExitCleanup
)
{
WslContainerRuntimeOptions options = new()
{
CPUCount = cpuCount,
MemorySizeInMB = memorySizeInMB,
EnableGPU = enableGpu,
SessionName = sessionName,
StoragePath = storagePath,
StorageMode = (StorageMode)storageMode,
SessionTimeout = sessionTimeoutTicks is { } ticks ? TimeSpan.FromTicks(ticks) : null,
DisableProcessExitCleanup = disableProcessExitCleanup,
};
return new WslContainerBackend(options);
}

IContainerRuntime Runtime => field ?? WslContainerRuntime.Instance;

/// <inheritdoc />
Expand Down
8 changes: 6 additions & 2 deletions src/src/Wsl/WslContainerRuntime.Facade.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,15 @@ public sealed class WslContainerRuntime : IContainerRuntime
/// <summary>Creates a runtime with default options.</summary>
public WslContainerRuntime() { }

/// <summary>Creates a runtime with the given options (retained for API parity; applied when WSLC runs).</summary>
/// <summary>
/// Creates a runtime with the given options. On a platform-neutral target this facade is diagnostics-only:
/// configure the runtime via <see cref="WslContainerBackend" /> (all options) or just the storage path via
/// the <c>PURVIEW_CONTAINERS_STORAGE_PATH</c> environment variable.
/// </summary>
public WslContainerRuntime(WslContainerRuntimeOptions options) =>
Options = options ?? WslContainerRuntimeOptions.Default;

/// <summary>The options this runtime was created with, if any.</summary>
/// <summary>The options this runtime was created with (diagnostics-only on a platform-neutral target).</summary>
public WslContainerRuntimeOptions? Options { get; }

/// <inheritdoc />
Expand Down
Loading
Loading