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
5 changes: 3 additions & 2 deletions docs/wiki/Architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down
68 changes: 68 additions & 0 deletions docs/wiki/Connection-Strings.md
Original file line number Diff line number Diff line change
@@ -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<PostgreSqlContainer, PostgreSqlConfiguration>
{
protected override string GetHostConnectionString() => Container.GetConnectionString();
}
```

`ContainerConnectionStringProvider<TContainer, TConfiguration>` 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<PostgreSqlContainer, PostgreSqlConfiguration>
{
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.
14 changes: 13 additions & 1 deletion docs/wiki/Contributing-Modules.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,19 @@ public class MyServiceBuilder : ContainerBuilder<MyServiceBuilder, MyServiceCont
}
```

4. **Container** — expose `GetConnectionString()` / endpoints using `GetMappedPublicPort`, built from runtime state (never cached before start).
4. **Container** — expose `GetConnectionString()` / endpoints using `GetMappedPublicPort`, built from runtime state (never cached before start). Add a provider so the polymorphic `IContainer.GetConnectionString()` works, and wire it in the builder constructor:

```csharp
sealed class MyServiceConnectionStringProvider : ContainerConnectionStringProvider<MyServiceContainer, MyServiceConfiguration>
{
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.
Expand Down
5 changes: 5 additions & 0 deletions docs/wiki/Modules.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
1 change: 1 addition & 0 deletions docs/wiki/_Sidebar.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
6 changes: 4 additions & 2 deletions src/src/Azurite/AzuriteBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}

/// <summary>Creates a builder using an explicit runtime.</summary>
Expand All @@ -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());
}

/// <summary>Builds the immutable configuration (internal; used by the module's own tests).</summary>
Expand Down
8 changes: 8 additions & 0 deletions src/src/Azurite/AzuriteConnectionStringProvider.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace Purview.Containers.Azurite;

/// <summary>Provides the Azurite connection string.</summary>
sealed class AzuriteConnectionStringProvider : ContainerConnectionStringProvider<AzuriteContainer, AzuriteConfiguration>
{
/// <inheritdoc />
protected override string GetHostConnectionString() => Container.GetConnectionString();
}
11 changes: 11 additions & 0 deletions src/src/Core/ConnectionMode.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
namespace Purview.Containers;

/// <summary>How a connection string targets the container: from the test host or from another container.</summary>
public enum ConnectionMode
{
/// <summary>Test host to container (the mapped host port).</summary>
Host = 0,

/// <summary>Container to container (the container network).</summary>
Container = 1,
}
28 changes: 28 additions & 0 deletions src/src/Core/ConnectionStringExceptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
namespace Purview.Containers;

#pragma warning disable CA1032 // Implement standard exception constructors

/// <summary>Thrown when a connection string provider has not been configured before use.</summary>
public sealed class ConnectionStringProviderNotConfiguredException : Exception
{
public ConnectionStringProviderNotConfiguredException()
: base("No connection string provider is configured for this container.") { }
}

/// <summary>Thrown when a connection string provider cannot produce a connection string for a mode.</summary>
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}'."
) { }

/// <summary>Thrown when a provider does not support a requested connection mode.</summary>
public sealed class ConnectionStringModeNotSupportedException(ConnectionMode connectionMode, Type providerType)
: InvalidOperationException(
$"The connection string provider '{providerType.FullName}' does not support connection mode '{connectionMode}'."
) { }

/// <summary>Thrown when a provider does not support a requested named connection string.</summary>
public sealed class ConnectionStringNameNotSupportedException(Type providerType, string name)
: InvalidOperationException(
$"The connection string provider '{providerType.FullName}' does not support the named connection string '{name}'."
) { }
57 changes: 57 additions & 0 deletions src/src/Core/ContainerBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ namespace Purview.Containers;
public abstract class ContainerBase : IContainer
{
IContainer? _container;
IConnectionStringProvider? _connectionStringProvider;
Action? _configureConnectionStringProvider;
int _started;
int _disposed;

Expand Down Expand Up @@ -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();
}

/// <inheritdoc />
Expand Down Expand Up @@ -92,6 +95,46 @@ public virtual IReadOnlyDictionary<ushort, ushort> GetMappedPublicPorts()
return Container.GetMappedPublicPorts();
}

/// <inheritdoc />
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),
};
}

/// <inheritdoc />
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}";
}

/// <inheritdoc />
public virtual Task<string> GetLogsAsync(LogOutput? stream = null, CancellationToken cancellationToken = default)
{
Expand Down Expand Up @@ -128,4 +171,18 @@ IContainerConfiguration WithResolvedName()
{
return Configuration is ContainerConfiguration concrete ? concrete with { Name = Name } : Configuration;
}

/// <summary>Wires an explicit connection string provider, configured once the container has started.</summary>
internal void SetConnectionStringProvider<TContainer, TConfiguration>(
IConnectionStringProvider<TContainer, TConfiguration> provider,
TContainer container,
TConfiguration configuration
)
where TContainer : IContainer
where TConfiguration : IContainerConfiguration
{
ArgumentNullException.ThrowIfNull(provider);
_connectionStringProvider = provider;
_configureConnectionStringProvider = () => provider.Configure(container, configuration);
}
}
17 changes: 16 additions & 1 deletion src/src/Core/ContainerBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ public abstract class ContainerBuilder<TBuilder, TContainer, TConfiguration>
TimeSpan _startupTimeout = TimeSpan.FromMinutes(5);
readonly List<IWaitStrategy> _waitStrategies = [];
RegistryCredentials? _registryCredentials;
IConnectionStringProvider<TContainer, TConfiguration>? _connectionStringProvider;

/// <summary>The backend the built container will use. When <c>null</c> it is resolved at start time via <see cref="ContainerBackends.ResolveAsync" />.</summary>
protected IContainerBackend? Backend { get; private set; }
Expand Down Expand Up @@ -74,6 +75,14 @@ public TBuilder WithImage(Image image)
return (TBuilder)this;
}

/// <summary>Overrides the connection string provider the built container exposes via <see cref="IContainer" />.</summary>
public TBuilder WithConnectionStringProvider(IConnectionStringProvider<TContainer, TConfiguration> provider)
{
ArgumentNullException.ThrowIfNull(provider);
_connectionStringProvider = provider;
return (TBuilder)this;
}

/// <summary>Applies a tag to the configured image. Requires <see cref="WithImage(string)" /> to have been called.</summary>
public TBuilder WithTag(string tag)
{
Expand Down Expand Up @@ -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;
}

/// <summary>Builds the immutable configuration from the accumulated builder state.</summary>
Expand Down
Loading
Loading