From 925260fe4db2d98df47af0b6ff87c20aeb79a9ac Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Fri, 2 Oct 2026 14:11:32 +0100 Subject: [PATCH] feat: added wslc storage path pass through and docs --- README.md | 3 +- docs/wiki/Architecture.md | 15 ++-- docs/wiki/Backends.md | 44 ++++++++++- docs/wiki/Contributing-Modules.md | 12 +-- docs/wiki/Home.md | 4 +- package.json | 4 +- src/src/Core/ContainerBase.cs | 3 + .../Core/ContainerConnectionStringProvider.cs | 1 + src/src/Docker/DockerContainer.cs | 1 + src/src/Wsl/Sdk/README.md | 19 ++++- src/src/Wsl/WslContainer.cs | 1 + src/src/Wsl/WslContainerBackend.Facade.cs | 18 ++++- src/src/Wsl/WslContainerBackend.cs | 40 ++++++++++ src/src/Wsl/WslContainerRuntime.Facade.cs | 8 +- src/src/Wsl/WslPayload.cs | 75 ++++++++++++++++--- .../ConnectionStringProviderTests.cs | 24 ++---- src/tests/Wsl.UnitTests/FakeContainer.cs | 1 + src/tests/Wsl.UnitTests/StorageModeTests.cs | 10 +++ 18 files changed, 229 insertions(+), 54 deletions(-) diff --git a/README.md b/README.md index 0492073..aacf2e4 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/wiki/Architecture.md b/docs/wiki/Architecture.md index 6bcc469..6956d8d 100644 --- a/docs/wiki/Architecture.md +++ b/docs/wiki/Architecture.md @@ -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. @@ -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 @@ -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 diff --git a/docs/wiki/Backends.md b/docs/wiki/Backends.md index 328029b..54672eb 100644 --- a/docs/wiki/Backends.md +++ b/docs/wiki/Backends.md @@ -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 | diff --git a/docs/wiki/Contributing-Modules.md b/docs/wiki/Contributing-Modules.md index 626f5f7..72a9c78 100644 --- a/docs/wiki/Contributing-Modules.md +++ b/docs/wiki/Contributing-Modules.md @@ -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**: `` (the Purview SDK adds the right `InternalsVisibleTo`/pack defaults). +1. **Reference the abstractions**: ``. 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 @@ -79,12 +79,12 @@ public class MyServiceBuilder : ContainerBuilder GetDefaultHostConnectionString(), @@ -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); } @@ -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}"; } diff --git a/src/src/Core/ContainerConnectionStringProvider.cs b/src/src/Core/ContainerConnectionStringProvider.cs index da1116d..b8f88ba 100644 --- a/src/src/Core/ContainerConnectionStringProvider.cs +++ b/src/src/Core/ContainerConnectionStringProvider.cs @@ -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; } diff --git a/src/src/Docker/DockerContainer.cs b/src/src/Docker/DockerContainer.cs index 1888a1b..211822e 100644 --- a/src/src/Docker/DockerContainer.cs +++ b/src/src/Docker/DockerContainer.cs @@ -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}"; } diff --git a/src/src/Wsl/Sdk/README.md b/src/src/Wsl/Sdk/README.md index 4685b2e..a525791 100644 --- a/src/src/Wsl/Sdk/README.md +++ b/src/src/Wsl/Sdk/README.md @@ -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. diff --git a/src/src/Wsl/WslContainer.cs b/src/src/Wsl/WslContainer.cs index 893e57c..1236a3b 100644 --- a/src/src/Wsl/WslContainer.cs +++ b/src/src/Wsl/WslContainer.cs @@ -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}"; } diff --git a/src/src/Wsl/WslContainerBackend.Facade.cs b/src/src/Wsl/WslContainerBackend.Facade.cs index ca278c3..0fe458d 100644 --- a/src/src/Wsl/WslContainerBackend.Facade.cs +++ b/src/src/Wsl/WslContainerBackend.Facade.cs @@ -17,6 +17,20 @@ namespace Purview.Containers.Wsl; /// public sealed class WslContainerBackend : IContainerBackend, IContainerBackendPreference { + readonly WslContainerRuntimeOptions? _options; + + /// Creates the backend over the default process-wide runtime. + public WslContainerBackend() { } + + /// + /// 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. + /// + public WslContainerBackend(WslContainerRuntimeOptions options) + { + _options = options ?? WslContainerRuntimeOptions.Default; + } + /// Stable backend identifier. public string Name => "wsl"; @@ -54,8 +68,8 @@ public async Task 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}" ); diff --git a/src/src/Wsl/WslContainerBackend.cs b/src/src/Wsl/WslContainerBackend.cs index 4a387f9..9f1cb6b 100644 --- a/src/src/Wsl/WslContainerBackend.cs +++ b/src/src/Wsl/WslContainerBackend.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using Purview.Containers.Runtime; namespace Purview.Containers.Wsl; @@ -18,6 +19,15 @@ public WslContainerBackend(IContainerRuntime runtime) Runtime = runtime; } + /// Creates the backend over a runtime configured with the given options. + [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)) { } + /// Stable backend identifier. public string Name => "wsl"; @@ -29,6 +39,36 @@ public WslContainerBackend(IContainerRuntime runtime) /// Factory used by the generated backend registration. public static WslContainerBackend Create() => new(); + /// + /// Creates a backend from the runtime options forwarded by the portable facade. The facade cannot pass + /// across the payload boundary directly (each build has its own + /// copy of the type), so it forwards the individual primitive values instead. + /// + 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; /// diff --git a/src/src/Wsl/WslContainerRuntime.Facade.cs b/src/src/Wsl/WslContainerRuntime.Facade.cs index 03a941e..a7c9b0b 100644 --- a/src/src/Wsl/WslContainerRuntime.Facade.cs +++ b/src/src/Wsl/WslContainerRuntime.Facade.cs @@ -33,11 +33,15 @@ public sealed class WslContainerRuntime : IContainerRuntime /// Creates a runtime with default options. public WslContainerRuntime() { } - /// Creates a runtime with the given options (retained for API parity; applied when WSLC runs). + /// + /// Creates a runtime with the given options. On a platform-neutral target this facade is diagnostics-only: + /// configure the runtime via (all options) or just the storage path via + /// the PURVIEW_CONTAINERS_STORAGE_PATH environment variable. + /// public WslContainerRuntime(WslContainerRuntimeOptions options) => Options = options ?? WslContainerRuntimeOptions.Default; - /// The options this runtime was created with, if any. + /// The options this runtime was created with (diagnostics-only on a platform-neutral target). public WslContainerRuntimeOptions? Options { get; } /// diff --git a/src/src/Wsl/WslPayload.cs b/src/src/Wsl/WslPayload.cs index f036785..59c77e0 100644 --- a/src/src/Wsl/WslPayload.cs +++ b/src/src/Wsl/WslPayload.cs @@ -33,8 +33,20 @@ static class WslPayload const string BackendTypeName = "Purview.Containers.Wsl.WslContainerBackend"; static readonly Lock Sync = new(); - static bool Attempted; - static IContainerBackend? Resolved; + static readonly Dictionary ConfiguredBackends = []; + static readonly Type[] OptionsFactoryParameterTypes = + [ + typeof(uint?), + typeof(uint?), + typeof(bool), + typeof(string), + typeof(string), + typeof(int), + typeof(long?), + typeof(bool), + ]; + static bool DefaultAttempted; + static IContainerBackend? DefaultBackend; static string? Failure; /// True when this host could possibly run WSL Containers. @@ -45,7 +57,7 @@ static class WslPayload /// (non-Windows), the payload is absent, or it failed to load. The reason is available from /// . /// - internal static IContainerBackend? TryCreateBackend() + internal static IContainerBackend? TryCreateBackend(WslContainerRuntimeOptions? options = null) { if (!IsHostSupported) { @@ -55,20 +67,31 @@ static class WslPayload lock (Sync) { - if (!Attempted) + if (options is null) { - Attempted = true; - Resolved = Create(); + if (!DefaultAttempted) + { + DefaultAttempted = true; + DefaultBackend = Create(options: null); + } + + return DefaultBackend; + } + + if (!ConfiguredBackends.TryGetValue(options, out var backend)) + { + backend = Create(options); + ConfiguredBackends[options] = backend; } - return Resolved; + return backend; } } /// The reason the last returned null. internal static string FailureReason => Failure ?? "The WSL Containers implementation is unavailable."; - static IContainerBackend? Create() + static IContainerBackend? Create(WslContainerRuntimeOptions? options) { foreach (var directory in CandidateDirectories()) { @@ -87,10 +110,38 @@ static class WslPayload { PayloadLoadContext context = new(directory); var assembly = context.LoadFromAssemblyPath(candidate); - var factory = assembly - .GetType(BackendTypeName, throwOnError: false) - ?.GetMethod("Create", BindingFlags.Public | BindingFlags.Static); - if (factory?.Invoke(null, null) is IContainerBackend backend) + var backendType = assembly.GetType(BackendTypeName, throwOnError: false); + var factory = options is null + ? backendType?.GetMethod( + "Create", + BindingFlags.Public | BindingFlags.Static, + binder: null, + Type.EmptyTypes, + modifiers: null + ) + : backendType?.GetMethod( + "Create", + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static, + binder: null, + OptionsFactoryParameterTypes, + modifiers: null + ); + + object?[]? arguments = options is null + ? null + : + [ + options.CPUCount, + options.MemorySizeInMB, + options.EnableGPU, + options.SessionName, + options.StoragePath, + (int)options.StorageMode, + options.SessionTimeout?.Ticks, + options.DisableProcessExitCleanup, + ]; + + if (factory?.Invoke(null, arguments) is IContainerBackend backend) { return backend; } diff --git a/src/tests/Wsl.UnitTests/ConnectionStringProviderTests.cs b/src/tests/Wsl.UnitTests/ConnectionStringProviderTests.cs index b0b6e86..fb5cdb5 100644 --- a/src/tests/Wsl.UnitTests/ConnectionStringProviderTests.cs +++ b/src/tests/Wsl.UnitTests/ConnectionStringProviderTests.cs @@ -2,32 +2,20 @@ namespace Purview.Containers.Wsl; public class ConnectionStringProviderTests { - sealed class HostOnlyProvider : ContainerConnectionStringProvider + sealed class HostOnlyProvider(string host) : ContainerConnectionStringProvider { - readonly string _host; - - public HostOnlyProvider(string host) => _host = host; - /// - protected override string GetHostConnectionString() => _host; + protected override string GetHostConnectionString() => host; } - sealed class HostAndContainerProvider : ContainerConnectionStringProvider + sealed class HostAndContainerProvider(string host, string container) + : ContainerConnectionStringProvider { - readonly string _host; - readonly string _container; - - public HostAndContainerProvider(string host, string container) - { - _host = host; - _container = container; - } - /// - protected override string GetHostConnectionString() => _host; + protected override string GetHostConnectionString() => host; /// - protected override string GetContainerConnectionString() => _container; + protected override string GetContainerConnectionString() => container; } [Test] diff --git a/src/tests/Wsl.UnitTests/FakeContainer.cs b/src/tests/Wsl.UnitTests/FakeContainer.cs index e04ae88..ceabdbd 100644 --- a/src/tests/Wsl.UnitTests/FakeContainer.cs +++ b/src/tests/Wsl.UnitTests/FakeContainer.cs @@ -55,6 +55,7 @@ public string GetConnectionString(ConnectionMode connectionMode = ConnectionMode throw new ConnectionStringNotAvailableException(connectionMode, GetType()); } + // Return the connection string in the format " return $"127.0.0.1:{first.Value}"; } diff --git a/src/tests/Wsl.UnitTests/StorageModeTests.cs b/src/tests/Wsl.UnitTests/StorageModeTests.cs index 14f7330..62812d8 100644 --- a/src/tests/Wsl.UnitTests/StorageModeTests.cs +++ b/src/tests/Wsl.UnitTests/StorageModeTests.cs @@ -9,6 +9,16 @@ public async Task DefaultOptions_UseSharedStorage() await Assert.That(options.StorageMode).IsEqualTo(StorageMode.Shared); await Assert.That(options.StoragePath).IsNull(); + await Assert.That(options.MemorySizeInMB).IsEqualTo((uint)4096); + } + + [Test] + public async Task DefaultOptions_WithStoragePath_KeepDefaultMemory() + { + var options = WslContainerRuntimeOptions.Default with { StoragePath = @"C:\temp\store" }; + + await Assert.That(options.StoragePath).IsEqualTo(@"C:\temp\store"); + await Assert.That(options.MemorySizeInMB).IsEqualTo((uint)4096); } [Test]