From 1296585463659476a2b1b56a8549fa19339abaa1 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Fri, 2 Oct 2026 10:06:43 +0100 Subject: [PATCH] feat: added connection string support --- docs/wiki/Architecture.md | 5 +- docs/wiki/Connection-Strings.md | 68 +++++++++++ docs/wiki/Contributing-Modules.md | 14 ++- docs/wiki/Modules.md | 5 + docs/wiki/_Sidebar.md | 1 + package.json | 2 +- src/src/Azurite/AzuriteBuilder.cs | 6 +- .../AzuriteConnectionStringProvider.cs | 8 ++ src/src/Core/ConnectionMode.cs | 11 ++ src/src/Core/ConnectionStringExceptions.cs | 28 +++++ src/src/Core/ContainerBase.cs | 57 +++++++++ src/src/Core/ContainerBuilder.cs | 17 ++- .../Core/ContainerConnectionStringProvider.cs | 76 ++++++++++++ src/src/Core/IConnectionStringProvider.cs | 26 +++++ src/src/Core/IContainer.cs | 2 +- src/src/Core/Sdk/README.md | 1 + src/src/Docker/DockerContainer.cs | 23 ++++ src/src/MsSql/MsSqlBuilder.cs | 8 +- .../MsSql/MsSqlConnectionStringProvider.cs | 8 ++ src/src/MySql/MySqlBuilder.cs | 8 +- .../MySql/MySqlConnectionStringProvider.cs | 8 ++ src/src/Nats/NatsBuilder.cs | 6 +- src/src/Nats/NatsConnectionStringProvider.cs | 8 ++ src/src/PostgreSql/PostgreSqlBuilder.cs | 8 +- .../PostgreSqlConnectionStringProvider.cs | 9 ++ src/src/RabbitMq/RabbitMqBuilder.cs | 6 +- .../RabbitMqConnectionStringProvider.cs | 9 ++ src/src/Redis/RedisBuilder.cs | 8 +- .../Redis/RedisConnectionStringProvider.cs | 8 ++ src/src/Wsl/WslContainer.cs | 25 ++++ .../ConnectionStringProviderTests.cs | 108 ++++++++++++++++++ src/tests/Wsl.UnitTests/FakeContainer.cs | 21 ++++ 32 files changed, 578 insertions(+), 20 deletions(-) create mode 100644 docs/wiki/Connection-Strings.md create mode 100644 src/src/Azurite/AzuriteConnectionStringProvider.cs create mode 100644 src/src/Core/ConnectionMode.cs create mode 100644 src/src/Core/ConnectionStringExceptions.cs create mode 100644 src/src/Core/ContainerConnectionStringProvider.cs create mode 100644 src/src/Core/IConnectionStringProvider.cs create mode 100644 src/src/MsSql/MsSqlConnectionStringProvider.cs create mode 100644 src/src/MySql/MySqlConnectionStringProvider.cs create mode 100644 src/src/Nats/NatsConnectionStringProvider.cs create mode 100644 src/src/PostgreSql/PostgreSqlConnectionStringProvider.cs create mode 100644 src/src/RabbitMq/RabbitMqConnectionStringProvider.cs create mode 100644 src/src/Redis/RedisConnectionStringProvider.cs create mode 100644 src/tests/Wsl.UnitTests/ConnectionStringProviderTests.cs diff --git a/docs/wiki/Architecture.md b/docs/wiki/Architecture.md index 8174762..6bcc469 100644 --- a/docs/wiki/Architecture.md +++ b/docs/wiki/Architecture.md @@ -14,6 +14,7 @@ Purview.Containers (net10.0) umbrella: references Core, ├─ ContainerBase typed module container (delegates to the backend) ├─ ContainerBackends + IContainerBackend backend registry and selection ├─ Waiting / Images / Mounts / Networking / Diagnostics readiness, model, secrets + ├─ IConnectionStringProvider / ConnectionMode / ContainerConnectionStringProvider connection strings └─ Runtime/ContainerException neutral error taxonomy Purview.Containers.Wsl (net10.0 facade + net10.0-windows10.0.19041.0 implementation) @@ -27,9 +28,9 @@ Purview.Containers.Wsl (net10.0 facade + net10.0-windows10.0.1 ├─ WslContainer : IContainer WSLC-backed container └─ WslContainerSession : IContainerSession image pull + container create/start/stop/delete/exec -IContainer : IAsyncDisposable +IContainer : IConnectionStringProvider, IAsyncDisposable StartAsync / StopAsync / DisposeAsync / ExecAsync / GetMappedPublicPort / - GetLogsAsync / tailing IAsyncEnumerable + GetConnectionString / GetLogsAsync / tailing IAsyncEnumerable ``` A module (`Purview.Containers.PostgreSql`, `Purview.Containers.Redis`, …) derives its container from diff --git a/docs/wiki/Connection-Strings.md b/docs/wiki/Connection-Strings.md new file mode 100644 index 0000000..e776ead --- /dev/null +++ b/docs/wiki/Connection-Strings.md @@ -0,0 +1,68 @@ +# Connection strings + +Every container implements `IConnectionStringProvider`, so you can ask any container for its connection +string without knowing the module type: + +```csharp +IContainer container = new PostgreSqlBuilder().Build(); +await container.StartAsync(); + +string connectionString = container.GetConnectionString(); // ConnectionMode.Host +string same = container.GetConnectionString(ConnectionMode.Host); // explicit +``` + +## Connection modes + +`ConnectionMode` describes how the connection string targets the container: + +| Mode | Meaning | Support | +| --- | --- | --- | +| `Host` | Test host → container, using the mapped host port (`127.0.0.1:{port}`). | ✅ both backends | +| `Container` | Container → container, using the container network. | ❌ not supported yet | + +`ConnectionMode.Container` throws `ConnectionStringModeNotSupportedException`. Neither backend supports +container-to-container networking today: WSLC has no managed networks or inter-container DNS, and the +Docker backend does not attach containers to a shared network. Multi-container wiring is deliberately +deferred (see [Networking](Networking.md)). + +## How it works + +A module's builder registers a connection string provider that delegates to the module's own +`GetConnectionString()`: + +```csharp +sealed class PostgreSqlConnectionStringProvider + : ContainerConnectionStringProvider +{ + protected override string GetHostConnectionString() => Container.GetConnectionString(); +} +``` + +`ContainerConnectionStringProvider` is the base class: `Configure` runs once, +after the container has started (so runtime-assigned ports are available), and dispatches +`GetConnectionString(ConnectionMode)` to `GetHostConnectionString()` / +`GetContainerConnectionString()`. The named overload (`GetConnectionString(name, mode)`) exists for modules +with several endpoints; the base throws `ConnectionStringNameNotSupportedException` unless a provider +overrides it. + +## Custom provider + +Override the connection string a container exposes with `WithConnectionStringProvider`: + +```csharp +sealed class ReadOnlyPostgreSql : ContainerConnectionStringProvider +{ + protected override string GetHostConnectionString() => $"{Container.GetConnectionString()};ApplicationName=readonly"; +} + +var postgres = new PostgreSqlBuilder() + .WithConnectionStringProvider(new ReadOnlyPostgreSql()) + .Build(); +``` + +Providers that produce an empty connection string throw `ConnectionStringNotAvailableException`, and a +provider used before `Configure` throws `ConnectionStringProviderNotConfiguredException`. + +## Module reference + +See [Modules](Modules.md) for each module's default connection string and any extra endpoint accessors. diff --git a/docs/wiki/Contributing-Modules.md b/docs/wiki/Contributing-Modules.md index ab7e901..626f5f7 100644 --- a/docs/wiki/Contributing-Modules.md +++ b/docs/wiki/Contributing-Modules.md @@ -64,7 +64,19 @@ public class MyServiceBuilder : ContainerBuilder + { + protected override string GetHostConnectionString() => Container.GetConnectionString(); + } + + // in the builder constructor: + WithImage("myservice:latest") + .WithPortBinding(DefaultPort, assignRandomHostPort: true) + .WithConnectionStringProvider(new MyServiceConnectionStringProvider()); + ``` 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. diff --git a/docs/wiki/Modules.md b/docs/wiki/Modules.md index efce69d..df4ac4f 100644 --- a/docs/wiki/Modules.md +++ b/docs/wiki/Modules.md @@ -122,6 +122,11 @@ public sealed class PostgreSqlContainer : ContainerBase - Prefer client connection-string builders: `NpgsqlConnectionStringBuilder`, `SqlConnectionStringBuilder`, `UriBuilder`, etc. Avoid handcrafted escaping. - Credentials are stored as `Secret` in the module configuration; diagnostics and `ToString()` never reveal them. +Every module builder also registers a connection string provider, so the polymorphic +`IContainer.GetConnectionString()` returns the same value as the module's own `GetConnectionString()`. +See [Connection Strings](Connection-Strings.md). `ConnectionMode.Container` (container-to-container) is +not supported yet. + Every module exposes `GetConnectionString()` with the same shape it has in Testcontainers, so test code that leans on the Testcontainers modules ports across unchanged: diff --git a/docs/wiki/_Sidebar.md b/docs/wiki/_Sidebar.md index 40e77a4..f50afe1 100644 --- a/docs/wiki/_Sidebar.md +++ b/docs/wiki/_Sidebar.md @@ -7,6 +7,7 @@ - [Lifecycle](Lifecycle.md) - [Networking](Networking.md) - [Wait Strategies](Wait-Strategies.md) +- [Connection Strings](Connection-Strings.md) - [Modules](Modules.md) - [Testing](Testing.md) - [Packaging](Packaging.md) diff --git a/package.json b/package.json index aef2255..48a9192 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "purview-containers", - "version": "1.0.0-prerelease.3", + "version": "1.0.0-prerelease.4", "license": "MIT", "author": { "name": "Kieron Lanning", diff --git a/src/src/Azurite/AzuriteBuilder.cs b/src/src/Azurite/AzuriteBuilder.cs index ce2dd7a..1de5696 100644 --- a/src/src/Azurite/AzuriteBuilder.cs +++ b/src/src/Azurite/AzuriteBuilder.cs @@ -28,7 +28,8 @@ public AzuriteBuilder(string image) .WithCommand("azurite", "--blobHost", "0.0.0.0", "--queueHost", "0.0.0.0", "--tableHost", "0.0.0.0") .WithPortBinding(BlobPort, assignRandomHostPort: true) .WithPortBinding(QueuePort, assignRandomHostPort: true) - .WithPortBinding(TablePort, assignRandomHostPort: true); + .WithPortBinding(TablePort, assignRandomHostPort: true) + .WithConnectionStringProvider(new AzuriteConnectionStringProvider()); } /// Creates a builder using an explicit runtime. @@ -39,7 +40,8 @@ public AzuriteBuilder(IContainerBackend backend) .WithCommand("azurite", "--blobHost", "0.0.0.0", "--queueHost", "0.0.0.0", "--tableHost", "0.0.0.0") .WithPortBinding(BlobPort, assignRandomHostPort: true) .WithPortBinding(QueuePort, assignRandomHostPort: true) - .WithPortBinding(TablePort, assignRandomHostPort: true); + .WithPortBinding(TablePort, assignRandomHostPort: true) + .WithConnectionStringProvider(new AzuriteConnectionStringProvider()); } /// Builds the immutable configuration (internal; used by the module's own tests). diff --git a/src/src/Azurite/AzuriteConnectionStringProvider.cs b/src/src/Azurite/AzuriteConnectionStringProvider.cs new file mode 100644 index 0000000..b207486 --- /dev/null +++ b/src/src/Azurite/AzuriteConnectionStringProvider.cs @@ -0,0 +1,8 @@ +namespace Purview.Containers.Azurite; + +/// Provides the Azurite connection string. +sealed class AzuriteConnectionStringProvider : ContainerConnectionStringProvider +{ + /// + protected override string GetHostConnectionString() => Container.GetConnectionString(); +} diff --git a/src/src/Core/ConnectionMode.cs b/src/src/Core/ConnectionMode.cs new file mode 100644 index 0000000..2e21b23 --- /dev/null +++ b/src/src/Core/ConnectionMode.cs @@ -0,0 +1,11 @@ +namespace Purview.Containers; + +/// How a connection string targets the container: from the test host or from another container. +public enum ConnectionMode +{ + /// Test host to container (the mapped host port). + Host = 0, + + /// Container to container (the container network). + Container = 1, +} diff --git a/src/src/Core/ConnectionStringExceptions.cs b/src/src/Core/ConnectionStringExceptions.cs new file mode 100644 index 0000000..5cc2159 --- /dev/null +++ b/src/src/Core/ConnectionStringExceptions.cs @@ -0,0 +1,28 @@ +namespace Purview.Containers; + +#pragma warning disable CA1032 // Implement standard exception constructors + +/// Thrown when a connection string provider has not been configured before use. +public sealed class ConnectionStringProviderNotConfiguredException : Exception +{ + public ConnectionStringProviderNotConfiguredException() + : base("No connection string provider is configured for this container.") { } +} + +/// Thrown when a connection string provider cannot produce a connection string for a mode. +public sealed class ConnectionStringNotAvailableException(ConnectionMode connectionMode, Type providerType) + : InvalidOperationException( + $"The connection string provider '{providerType.FullName}' did not return a connection string for connection mode '{connectionMode}'." + ) { } + +/// Thrown when a provider does not support a requested connection mode. +public sealed class ConnectionStringModeNotSupportedException(ConnectionMode connectionMode, Type providerType) + : InvalidOperationException( + $"The connection string provider '{providerType.FullName}' does not support connection mode '{connectionMode}'." + ) { } + +/// Thrown when a provider does not support a requested named connection string. +public sealed class ConnectionStringNameNotSupportedException(Type providerType, string name) + : InvalidOperationException( + $"The connection string provider '{providerType.FullName}' does not support the named connection string '{name}'." + ) { } diff --git a/src/src/Core/ContainerBase.cs b/src/src/Core/ContainerBase.cs index 84f37f2..ab80a21 100644 --- a/src/src/Core/ContainerBase.cs +++ b/src/src/Core/ContainerBase.cs @@ -8,6 +8,8 @@ namespace Purview.Containers; public abstract class ContainerBase : IContainer { IContainer? _container; + IConnectionStringProvider? _connectionStringProvider; + Action? _configureConnectionStringProvider; int _started; int _disposed; @@ -62,6 +64,7 @@ public virtual async Task StartAsync(CancellationToken cancellationToken = defau var backend = Backend ?? await ContainerBackends.ResolveAsync(cancellationToken).ConfigureAwait(false); _container = backend.CreateContainer(WithResolvedName()); await _container.StartAsync(cancellationToken).ConfigureAwait(false); + _configureConnectionStringProvider?.Invoke(); } /// @@ -92,6 +95,46 @@ public virtual IReadOnlyDictionary GetMappedPublicPorts() return Container.GetMappedPublicPorts(); } + /// + public virtual string GetConnectionString(ConnectionMode connectionMode = ConnectionMode.Host) + { + _ = Container; // throws when the container has not been started + if (_connectionStringProvider is { } provider) + { + return provider.GetConnectionString(connectionMode); + } + + return connectionMode switch + { + ConnectionMode.Host => GetDefaultHostConnectionString(), + ConnectionMode.Container => throw new ConnectionStringModeNotSupportedException(connectionMode, GetType()), + _ => throw new ArgumentOutOfRangeException(nameof(connectionMode), connectionMode, null), + }; + } + + /// + public virtual string GetConnectionString(string name, ConnectionMode connectionMode = ConnectionMode.Host) + { + _ = Container; // throws when the container has not been started + if (_connectionStringProvider is { } provider) + { + return provider.GetConnectionString(name, connectionMode); + } + + throw new ConnectionStringNameNotSupportedException(GetType(), name); + } + + string GetDefaultHostConnectionString() + { + var first = GetMappedPublicPorts().FirstOrDefault(); + if (first.Key == 0 && first.Value == 0) + { + throw new ConnectionStringNotAvailableException(ConnectionMode.Host, GetType()); + } + + return $"127.0.0.1:{first.Value}"; + } + /// public virtual Task GetLogsAsync(LogOutput? stream = null, CancellationToken cancellationToken = default) { @@ -128,4 +171,18 @@ IContainerConfiguration WithResolvedName() { return Configuration is ContainerConfiguration concrete ? concrete with { Name = Name } : Configuration; } + + /// Wires an explicit connection string provider, configured once the container has started. + internal void SetConnectionStringProvider( + IConnectionStringProvider provider, + TContainer container, + TConfiguration configuration + ) + where TContainer : IContainer + where TConfiguration : IContainerConfiguration + { + ArgumentNullException.ThrowIfNull(provider); + _connectionStringProvider = provider; + _configureConnectionStringProvider = () => provider.Configure(container, configuration); + } } diff --git a/src/src/Core/ContainerBuilder.cs b/src/src/Core/ContainerBuilder.cs index 868649b..a447dcb 100644 --- a/src/src/Core/ContainerBuilder.cs +++ b/src/src/Core/ContainerBuilder.cs @@ -36,6 +36,7 @@ public abstract class ContainerBuilder TimeSpan _startupTimeout = TimeSpan.FromMinutes(5); readonly List _waitStrategies = []; RegistryCredentials? _registryCredentials; + IConnectionStringProvider? _connectionStringProvider; /// The backend the built container will use. When null it is resolved at start time via . protected IContainerBackend? Backend { get; private set; } @@ -74,6 +75,14 @@ public TBuilder WithImage(Image image) return (TBuilder)this; } + /// Overrides the connection string provider the built container exposes via . + public TBuilder WithConnectionStringProvider(IConnectionStringProvider provider) + { + ArgumentNullException.ThrowIfNull(provider); + _connectionStringProvider = provider; + return (TBuilder)this; + } + /// Applies a tag to the configured image. Requires to have been called. public TBuilder WithTag(string tag) { @@ -286,7 +295,13 @@ public virtual TContainer Build() { var configuration = BuildConfiguration(); Validate(configuration); - return CreateContainer(configuration); + var container = CreateContainer(configuration); + if (_connectionStringProvider is { } provider && container is ContainerBase baseContainer) + { + baseContainer.SetConnectionStringProvider(provider, container, configuration); + } + + return container; } /// Builds the immutable configuration from the accumulated builder state. diff --git a/src/src/Core/ContainerConnectionStringProvider.cs b/src/src/Core/ContainerConnectionStringProvider.cs new file mode 100644 index 0000000..da1116d --- /dev/null +++ b/src/src/Core/ContainerConnectionStringProvider.cs @@ -0,0 +1,76 @@ +namespace Purview.Containers; + +/// +/// Base implementation for container connection string providers. Subclasses supply the host connection +/// string (and, when supported, a container-to-container one); this type wires configuration, mode dispatch +/// and the guard errors. +/// +public abstract class ContainerConnectionStringProvider + : IConnectionStringProvider + where TContainer : IContainer + where TConfiguration : IContainerConfiguration +{ + /// The started container, available after . + protected TContainer Container { get; private set; } = default!; + + /// The container configuration, available after . + protected TConfiguration Configuration { get; private set; } = default!; + + /// + public virtual void Configure(TContainer container, TConfiguration configuration) + { + ArgumentNullException.ThrowIfNull(container); + ArgumentNullException.ThrowIfNull(configuration); + Container = container; + Configuration = configuration; + } + + /// + public virtual string GetConnectionString(ConnectionMode connectionMode = ConnectionMode.Host) + { + EnsureConfigured(); + + var connectionString = connectionMode switch + { + ConnectionMode.Host => GetHostConnectionString(), + ConnectionMode.Container => GetContainerConnectionString(), + _ => throw new ArgumentOutOfRangeException(nameof(connectionMode), connectionMode, null), + }; + + if (string.IsNullOrWhiteSpace(connectionString)) + { + throw new ConnectionStringNotAvailableException(connectionMode, GetType()); + } + + return connectionString; + } + + /// + public virtual string GetConnectionString(string name, ConnectionMode connectionMode = ConnectionMode.Host) + { + ArgumentNullException.ThrowIfNull(name); + EnsureConfigured(); + throw new ConnectionStringNameNotSupportedException(GetType(), name); + } + + /// Builds the host connection string. Called for . + protected abstract string GetHostConnectionString(); + + /// + /// Builds the container-to-container connection string. The default throws + /// because neither backend supports + /// container-to-container networking yet. + /// + protected virtual string GetContainerConnectionString() + { + throw new ConnectionStringModeNotSupportedException(ConnectionMode.Container, GetType()); + } + + void EnsureConfigured() + { + if (Container is null || Configuration is null) + { + throw new ConnectionStringProviderNotConfiguredException(); + } + } +} diff --git a/src/src/Core/IConnectionStringProvider.cs b/src/src/Core/IConnectionStringProvider.cs new file mode 100644 index 0000000..80aee7a --- /dev/null +++ b/src/src/Core/IConnectionStringProvider.cs @@ -0,0 +1,26 @@ +namespace Purview.Containers; + +/// +/// Produces connection strings (or endpoints) for a container, uniformly across modules. Surfaced through +/// . +/// +public interface IConnectionStringProvider +{ + /// Returns the connection string for the given connection mode. + string GetConnectionString(ConnectionMode connectionMode = ConnectionMode.Host); + + /// Returns a named connection string (for modules with multiple endpoints, e.g. Azurite blob/queue/table). + string GetConnectionString(string name, ConnectionMode connectionMode = ConnectionMode.Host); +} + +/// +/// A connection string provider bound to a specific container and configuration. +/// is invoked once, after the container has started, so runtime-assigned ports are available. +/// +public interface IConnectionStringProvider : IConnectionStringProvider + where TContainer : IContainer + where TConfiguration : IContainerConfiguration +{ + /// Initializes the provider with the started container and its configuration. + void Configure(TContainer container, TConfiguration configuration); +} diff --git a/src/src/Core/IContainer.cs b/src/src/Core/IContainer.cs index e7b2a46..a88dee5 100644 --- a/src/src/Core/IContainer.cs +++ b/src/src/Core/IContainer.cs @@ -1,7 +1,7 @@ namespace Purview.Containers; /// A throwaway Linux container running on WSL Containers. -public interface IContainer : IAsyncDisposable +public interface IContainer : IConnectionStringProvider, IAsyncDisposable { /// Container identifier, available after start. string Id { get; } diff --git a/src/src/Core/Sdk/README.md b/src/src/Core/Sdk/README.md index 883dcc5..121fc79 100644 --- a/src/src/Core/Sdk/README.md +++ b/src/src/Core/Sdk/README.md @@ -19,6 +19,7 @@ separate package. | Area | Types | | --- | --- | | Container contract | `IContainer`, `IContainerConfiguration`, `ContainerConfiguration`, `ContainerBuilder`, `ContainerBase` | +| Connection strings | `IConnectionStringProvider`, `IConnectionStringProvider`, `ContainerConnectionStringProvider`, `ConnectionMode` | | Backends | `IContainerBackend`, `ContainerBackendInfo`, `ContainerBackends`, `ContainerBackendUnavailableException` | | Readiness | `Purview.Containers.Waiting`: `Wait`, `IWaitStrategy`, TCP/HTTP/command/log strategies | | Configuration model | `PortBinding`, `BindMount`, `NamedVolume`, `RegistryCredentials`, `Image`, `PullPolicy` | diff --git a/src/src/Docker/DockerContainer.cs b/src/src/Docker/DockerContainer.cs index 8e085a5..1888a1b 100644 --- a/src/src/Docker/DockerContainer.cs +++ b/src/src/Docker/DockerContainer.cs @@ -120,6 +120,29 @@ public IReadOnlyDictionary GetMappedPublicPorts() return mappings; } + /// + public string GetConnectionString(ConnectionMode connectionMode = ConnectionMode.Host) + { + if (connectionMode != ConnectionMode.Host) + { + throw new ConnectionStringModeNotSupportedException(connectionMode, GetType()); + } + + var first = GetMappedPublicPorts().FirstOrDefault(); + if (first.Key == 0 && first.Value == 0) + { + throw new ConnectionStringNotAvailableException(connectionMode, GetType()); + } + + return $"127.0.0.1:{first.Value}"; + } + + /// + public string GetConnectionString(string name, ConnectionMode connectionMode = ConnectionMode.Host) + { + throw new ConnectionStringNameNotSupportedException(GetType(), name); + } + /// public async Task GetLogsAsync(LogOutput? stream = null, CancellationToken cancellationToken = default) { diff --git a/src/src/MsSql/MsSqlBuilder.cs b/src/src/MsSql/MsSqlBuilder.cs index d65c59e..91acfaf 100644 --- a/src/src/MsSql/MsSqlBuilder.cs +++ b/src/src/MsSql/MsSqlBuilder.cs @@ -25,14 +25,18 @@ public MsSqlBuilder() /// Creates a builder with a custom image. public MsSqlBuilder(string image) { - WithImage(image).WithPortBinding(MsSqlPort, assignRandomHostPort: true); + WithImage(image) + .WithPortBinding(MsSqlPort, assignRandomHostPort: true) + .WithConnectionStringProvider(new MsSqlConnectionStringProvider()); } /// Creates a builder using an explicit runtime. public MsSqlBuilder(IContainerBackend backend) : base(backend) { - WithImage(MsSqlImage).WithPortBinding(MsSqlPort, assignRandomHostPort: true); + WithImage(MsSqlImage) + .WithPortBinding(MsSqlPort, assignRandomHostPort: true) + .WithConnectionStringProvider(new MsSqlConnectionStringProvider()); } /// Sets the SA password. Must satisfy SQL Server password complexity (at least 8 characters). diff --git a/src/src/MsSql/MsSqlConnectionStringProvider.cs b/src/src/MsSql/MsSqlConnectionStringProvider.cs new file mode 100644 index 0000000..c1841e8 --- /dev/null +++ b/src/src/MsSql/MsSqlConnectionStringProvider.cs @@ -0,0 +1,8 @@ +namespace Purview.Containers.MsSql; + +/// Provides the SQL Server connection string. +sealed class MsSqlConnectionStringProvider : ContainerConnectionStringProvider +{ + /// + protected override string GetHostConnectionString() => Container.GetConnectionString(); +} diff --git a/src/src/MySql/MySqlBuilder.cs b/src/src/MySql/MySqlBuilder.cs index 41b9793..5b21d33 100644 --- a/src/src/MySql/MySqlBuilder.cs +++ b/src/src/MySql/MySqlBuilder.cs @@ -26,14 +26,18 @@ public MySqlBuilder() /// Creates a builder with a custom image. public MySqlBuilder(string image) { - WithImage(image).WithPortBinding(MySqlPort, assignRandomHostPort: true); + WithImage(image) + .WithPortBinding(MySqlPort, assignRandomHostPort: true) + .WithConnectionStringProvider(new MySqlConnectionStringProvider()); } /// Creates a builder using an explicit runtime. public MySqlBuilder(IContainerBackend backend) : base(backend) { - WithImage(MySqlImage).WithPortBinding(MySqlPort, assignRandomHostPort: true); + WithImage(MySqlImage) + .WithPortBinding(MySqlPort, assignRandomHostPort: true) + .WithConnectionStringProvider(new MySqlConnectionStringProvider()); } /// Sets the database name. diff --git a/src/src/MySql/MySqlConnectionStringProvider.cs b/src/src/MySql/MySqlConnectionStringProvider.cs new file mode 100644 index 0000000..05c29b2 --- /dev/null +++ b/src/src/MySql/MySqlConnectionStringProvider.cs @@ -0,0 +1,8 @@ +namespace Purview.Containers.MySql; + +/// Provides the MySQL connection string. +sealed class MySqlConnectionStringProvider : ContainerConnectionStringProvider +{ + /// + protected override string GetHostConnectionString() => Container.GetConnectionString(); +} diff --git a/src/src/Nats/NatsBuilder.cs b/src/src/Nats/NatsBuilder.cs index 8fdbafd..17773fe 100644 --- a/src/src/Nats/NatsBuilder.cs +++ b/src/src/Nats/NatsBuilder.cs @@ -23,7 +23,8 @@ public NatsBuilder(string image) { WithImage(image) .WithPortBinding(ClientPort, assignRandomHostPort: true) - .WithPortBinding(MonitoringPort, assignRandomHostPort: true); + .WithPortBinding(MonitoringPort, assignRandomHostPort: true) + .WithConnectionStringProvider(new NatsConnectionStringProvider()); } /// Creates a builder using an explicit runtime. @@ -32,7 +33,8 @@ public NatsBuilder(IContainerBackend backend) { WithImage(NatsImage) .WithPortBinding(ClientPort, assignRandomHostPort: true) - .WithPortBinding(MonitoringPort, assignRandomHostPort: true); + .WithPortBinding(MonitoringPort, assignRandomHostPort: true) + .WithConnectionStringProvider(new NatsConnectionStringProvider()); } /// Builds the immutable configuration (internal; used by the module's own tests). diff --git a/src/src/Nats/NatsConnectionStringProvider.cs b/src/src/Nats/NatsConnectionStringProvider.cs new file mode 100644 index 0000000..5e41d10 --- /dev/null +++ b/src/src/Nats/NatsConnectionStringProvider.cs @@ -0,0 +1,8 @@ +namespace Purview.Containers.Nats; + +/// Provides the NATS connection string. +sealed class NatsConnectionStringProvider : ContainerConnectionStringProvider +{ + /// + protected override string GetHostConnectionString() => Container.GetConnectionString(); +} diff --git a/src/src/PostgreSql/PostgreSqlBuilder.cs b/src/src/PostgreSql/PostgreSqlBuilder.cs index cfc50f3..2e2b51a 100644 --- a/src/src/PostgreSql/PostgreSqlBuilder.cs +++ b/src/src/PostgreSql/PostgreSqlBuilder.cs @@ -24,14 +24,18 @@ public PostgreSqlBuilder() /// Creates a builder with a custom image. public PostgreSqlBuilder(string image) { - WithImage(image).WithPortBinding(PostgreSqlPort, assignRandomHostPort: true); + WithImage(image) + .WithPortBinding(PostgreSqlPort, assignRandomHostPort: true) + .WithConnectionStringProvider(new PostgreSqlConnectionStringProvider()); } /// Creates a builder using an explicit runtime. public PostgreSqlBuilder(IContainerBackend backend) : base(backend) { - WithImage(PostgreSqlImage).WithPortBinding(PostgreSqlPort, assignRandomHostPort: true); + WithImage(PostgreSqlImage) + .WithPortBinding(PostgreSqlPort, assignRandomHostPort: true) + .WithConnectionStringProvider(new PostgreSqlConnectionStringProvider()); } /// Sets the database name. diff --git a/src/src/PostgreSql/PostgreSqlConnectionStringProvider.cs b/src/src/PostgreSql/PostgreSqlConnectionStringProvider.cs new file mode 100644 index 0000000..9ebbdf8 --- /dev/null +++ b/src/src/PostgreSql/PostgreSqlConnectionStringProvider.cs @@ -0,0 +1,9 @@ +namespace Purview.Containers.PostgreSql; + +/// Provides the PostgreSQL connection string. +sealed class PostgreSqlConnectionStringProvider + : ContainerConnectionStringProvider +{ + /// + protected override string GetHostConnectionString() => Container.GetConnectionString(); +} diff --git a/src/src/RabbitMq/RabbitMqBuilder.cs b/src/src/RabbitMq/RabbitMqBuilder.cs index 507ea65..cee081c 100644 --- a/src/src/RabbitMq/RabbitMqBuilder.cs +++ b/src/src/RabbitMq/RabbitMqBuilder.cs @@ -29,7 +29,8 @@ public RabbitMqBuilder(string image) { WithImage(image) .WithPortBinding(AmqpPort, assignRandomHostPort: true) - .WithPortBinding(ManagementPort, assignRandomHostPort: true); + .WithPortBinding(ManagementPort, assignRandomHostPort: true) + .WithConnectionStringProvider(new RabbitMqConnectionStringProvider()); } /// Creates a builder using an explicit runtime. @@ -38,7 +39,8 @@ public RabbitMqBuilder(IContainerBackend backend) { WithImage(RabbitMqImage) .WithPortBinding(AmqpPort, assignRandomHostPort: true) - .WithPortBinding(ManagementPort, assignRandomHostPort: true); + .WithPortBinding(ManagementPort, assignRandomHostPort: true) + .WithConnectionStringProvider(new RabbitMqConnectionStringProvider()); } /// Sets the AMQP username. diff --git a/src/src/RabbitMq/RabbitMqConnectionStringProvider.cs b/src/src/RabbitMq/RabbitMqConnectionStringProvider.cs new file mode 100644 index 0000000..0fb2672 --- /dev/null +++ b/src/src/RabbitMq/RabbitMqConnectionStringProvider.cs @@ -0,0 +1,9 @@ +namespace Purview.Containers.RabbitMq; + +/// Provides the RabbitMQ connection string. +sealed class RabbitMqConnectionStringProvider + : ContainerConnectionStringProvider +{ + /// + protected override string GetHostConnectionString() => Container.GetConnectionString(); +} diff --git a/src/src/Redis/RedisBuilder.cs b/src/src/Redis/RedisBuilder.cs index 4ea7d5f..691ff11 100644 --- a/src/src/Redis/RedisBuilder.cs +++ b/src/src/Redis/RedisBuilder.cs @@ -21,14 +21,18 @@ public RedisBuilder() /// Creates a builder with a custom image (e.g. valkey/valkey:7). public RedisBuilder(string image) { - WithImage(image).WithPortBinding(RedisPort, assignRandomHostPort: true); + WithImage(image) + .WithPortBinding(RedisPort, assignRandomHostPort: true) + .WithConnectionStringProvider(new RedisConnectionStringProvider()); } /// Creates a builder using an explicit runtime. public RedisBuilder(IContainerBackend backend) : base(backend) { - WithImage(RedisImage).WithPortBinding(RedisPort, assignRandomHostPort: true); + WithImage(RedisImage) + .WithPortBinding(RedisPort, assignRandomHostPort: true) + .WithConnectionStringProvider(new RedisConnectionStringProvider()); } /// Builds the immutable configuration (internal; used by the module's own tests). diff --git a/src/src/Redis/RedisConnectionStringProvider.cs b/src/src/Redis/RedisConnectionStringProvider.cs new file mode 100644 index 0000000..57433e0 --- /dev/null +++ b/src/src/Redis/RedisConnectionStringProvider.cs @@ -0,0 +1,8 @@ +namespace Purview.Containers.Redis; + +/// Provides the Redis connection string. +sealed class RedisConnectionStringProvider : ContainerConnectionStringProvider +{ + /// + protected override string GetHostConnectionString() => Container.GetConnectionString(); +} diff --git a/src/src/Wsl/WslContainer.cs b/src/src/Wsl/WslContainer.cs index a0137c7..893e57c 100644 --- a/src/src/Wsl/WslContainer.cs +++ b/src/src/Wsl/WslContainer.cs @@ -262,6 +262,31 @@ public virtual IReadOnlyDictionary GetMappedPublicPorts() return _portMappings; } + /// + public string GetConnectionString(ConnectionMode connectionMode = ConnectionMode.Host) + { + EnsureStarted(); + if (connectionMode != ConnectionMode.Host) + { + throw new ConnectionStringModeNotSupportedException(connectionMode, GetType()); + } + + var first = _portMappings.FirstOrDefault(); + if (first.Key == 0 && first.Value == 0) + { + throw new ConnectionStringNotAvailableException(connectionMode, GetType()); + } + + return $"127.0.0.1:{first.Value}"; + } + + /// + public string GetConnectionString(string name, ConnectionMode connectionMode = ConnectionMode.Host) + { + EnsureStarted(); + throw new ConnectionStringNameNotSupportedException(GetType(), name); + } + /// public virtual Task GetLogsAsync(LogOutput? stream = null, CancellationToken cancellationToken = default) { diff --git a/src/tests/Wsl.UnitTests/ConnectionStringProviderTests.cs b/src/tests/Wsl.UnitTests/ConnectionStringProviderTests.cs new file mode 100644 index 0000000..b0b6e86 --- /dev/null +++ b/src/tests/Wsl.UnitTests/ConnectionStringProviderTests.cs @@ -0,0 +1,108 @@ +namespace Purview.Containers.Wsl; + +public class ConnectionStringProviderTests +{ + sealed class HostOnlyProvider : ContainerConnectionStringProvider + { + readonly string _host; + + public HostOnlyProvider(string host) => _host = host; + + /// + protected override string GetHostConnectionString() => _host; + } + + sealed class HostAndContainerProvider : 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 GetContainerConnectionString() => _container; + } + + [Test] + public async Task GetConnectionString_DefaultsToHostMode() + { + await using FakeContainer container = new(); + HostOnlyProvider provider = new("host-connection"); + provider.Configure(container, new ContainerConfiguration()); + + await Assert.That(provider.GetConnectionString()).IsEqualTo("host-connection"); + await Assert.That(provider.GetConnectionString(ConnectionMode.Host)).IsEqualTo("host-connection"); + } + + [Test] + public async Task GetConnectionString_ContainerModeThrowsWhenNotOverridden() + { + await using FakeContainer container = new(); + HostOnlyProvider provider = new("host-connection"); + provider.Configure(container, new ContainerConfiguration()); + + await Assert + .That(() => provider.GetConnectionString(ConnectionMode.Container)) + .Throws(); + } + + [Test] + public async Task GetConnectionString_ContainerModeIsSupportedWhenOverridden() + { + await using FakeContainer container = new(); + HostAndContainerProvider provider = new("host-connection", "container-connection"); + provider.Configure(container, new ContainerConfiguration()); + + await Assert.That(provider.GetConnectionString(ConnectionMode.Container)).IsEqualTo("container-connection"); + } + + [Test] + public async Task GetConnectionString_ThrowsWhenNotConfigured() + { + HostOnlyProvider provider = new("host-connection"); + + await Assert + .That(() => provider.GetConnectionString()) + .Throws(); + } + + [Test] + public async Task GetConnectionString_ThrowsWhenHostStringIsEmpty() + { + await using FakeContainer container = new(); + HostOnlyProvider provider = new(string.Empty); + provider.Configure(container, new ContainerConfiguration()); + + await Assert.That(() => provider.GetConnectionString()).Throws(); + } + + [Test] + public async Task GetConnectionString_NamedThrowsByDefault() + { + await using FakeContainer container = new(); + HostOnlyProvider provider = new("host-connection"); + provider.Configure(container, new ContainerConfiguration()); + + await Assert + .That(() => provider.GetConnectionString("primary")) + .Throws(); + } + + [Test] + public async Task FakeContainer_ReturnsHostConnectionStringFromFirstMapping() + { + await using FakeContainer container = new() + { + PortMappings = new Dictionary { [6379] = 32768 }, + }; + + await Assert.That(container.GetConnectionString()).IsEqualTo("127.0.0.1:32768"); + } +} diff --git a/src/tests/Wsl.UnitTests/FakeContainer.cs b/src/tests/Wsl.UnitTests/FakeContainer.cs index 564731a..e04ae88 100644 --- a/src/tests/Wsl.UnitTests/FakeContainer.cs +++ b/src/tests/Wsl.UnitTests/FakeContainer.cs @@ -42,6 +42,27 @@ public ushort GetMappedPublicPort(ushort containerPort) public IReadOnlyDictionary GetMappedPublicPorts() => PortMappings; + public string GetConnectionString(ConnectionMode connectionMode = ConnectionMode.Host) + { + if (connectionMode != ConnectionMode.Host) + { + throw new ConnectionStringModeNotSupportedException(connectionMode, GetType()); + } + + var first = PortMappings.FirstOrDefault(); + if (first.Key == 0 && first.Value == 0) + { + throw new ConnectionStringNotAvailableException(connectionMode, GetType()); + } + + return $"127.0.0.1:{first.Value}"; + } + + public string GetConnectionString(string name, ConnectionMode connectionMode = ConnectionMode.Host) + { + throw new ConnectionStringNameNotSupportedException(GetType(), name); + } + public Task GetLogsAsync(LogOutput? stream = null, CancellationToken cancellationToken = default) { return Task.FromResult(stream is LogOutput.Stderr ? string.Empty : Logs);