From cfac91cb0aa6193858fbda41e9d32b3544da95cf Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Tue, 27 Oct 2026 14:23:42 +0000 Subject: [PATCH 1/3] feat: add the Azure Service Bus emulator module and fix the Azurite key Add Purview.Containers.AzureServiceBusEmulator: the emulator plus a SQL Server sidecar on the same backend, reached by bridge IP. AcceptLicense() is required and accepts both EULAs. Readiness is GET /health on the management port; WithConfig/WithConfigFile mount Config.json. The README documents sharing one emulator per test session with a queue per test, and its limitations. Core: add IContainer.NetworkIp (WSLC and Docker) and the ContainerBase.ConfigureForStart hook. This is a breaking change for third-party IContainer implementations. Azurite: AzuriteAccount.Key is now the published devstoreaccount1 key, so GetConnectionString() authenticates as-is. MsSql: default to mcr.microsoft.com/mssql/server:2025-latest. SQL Server 2022 cannot create /.system as the non-root mssql user on WSLC. Bump the version to 1.1.0-prerelease.1. --- .config/purview-build.json | 6 + .github/workflows/integration-wsl.yml | 2 + AGENTS.md | 3 +- Directory.Packages.props | 3 + README.md | 22 +- docs/wiki/Backends.md | 2 +- docs/wiki/Connection-Strings.md | 8 +- docs/wiki/Consumer-Requirements.md | 2 +- docs/wiki/Home.md | 3 +- docs/wiki/Modules.md | 6 +- docs/wiki/Networking.md | 4 +- docs/wiki/Testing.md | 6 +- package.json | 2 +- skills/purview-containers-lifecycle/SKILL.md | 13 +- skills/purview-containers-quickstart/SKILL.md | 4 +- src/Containers.slnx | 3 + .../AzureServiceBusEmulator.csproj | 16 ++ .../AzureServiceBusEmulatorBuilder.cs | 199 +++++++++++++++++ .../AzureServiceBusEmulatorConfiguration.cs | 23 ++ ...viceBusEmulatorConnectionStringProvider.cs | 9 + .../AzureServiceBusEmulatorContainer.cs | 125 +++++++++++ src/src/AzureServiceBusEmulator/Sdk/README.md | 211 ++++++++++++++++++ src/src/Azurite/AzuriteAccount.cs | 11 +- src/src/Azurite/AzuriteContainer.cs | 4 +- src/src/Azurite/Sdk/README.md | 18 +- src/src/Core/ContainerBase.cs | 12 +- src/src/Core/IContainer.cs | 7 + src/src/Docker/DockerContainer.cs | 8 +- src/src/MsSql/MsSqlBuilder.cs | 2 +- src/src/MsSql/Sdk/README.md | 2 +- src/src/Wsl/WslContainer.cs | 3 + ...ServiceBusEmulator.IntegrationTests.csproj | 5 + ...AzureServiceBusEmulatorIntegrationTests.cs | 125 +++++++++++ .../SharedServiceBusEmulator.cs | 89 ++++++++ .../SharedServiceBusEmulatorTests.cs | 50 +++++ .../AzureServiceBusEmulator.UnitTests.csproj | 1 + .../AzureServiceBusEmulatorBuilderTests.cs | 105 +++++++++ .../Azurite.IntegrationTests.csproj | 6 +- .../AzuriteIntegrationTests.cs | 24 ++ .../ModuleDockerTests.cs | 44 ++++ .../Modules.Docker.IntegrationTests.csproj | 6 + .../MsSql.UnitTests/MsSqlBuilderTests.cs | 2 +- src/tests/Wsl.UnitTests/FakeContainer.cs | 2 + 43 files changed, 1152 insertions(+), 46 deletions(-) create mode 100644 src/src/AzureServiceBusEmulator/AzureServiceBusEmulator.csproj create mode 100644 src/src/AzureServiceBusEmulator/AzureServiceBusEmulatorBuilder.cs create mode 100644 src/src/AzureServiceBusEmulator/AzureServiceBusEmulatorConfiguration.cs create mode 100644 src/src/AzureServiceBusEmulator/AzureServiceBusEmulatorConnectionStringProvider.cs create mode 100644 src/src/AzureServiceBusEmulator/AzureServiceBusEmulatorContainer.cs create mode 100644 src/src/AzureServiceBusEmulator/Sdk/README.md create mode 100644 src/tests/AzureServiceBusEmulator.IntegrationTests/AzureServiceBusEmulator.IntegrationTests.csproj create mode 100644 src/tests/AzureServiceBusEmulator.IntegrationTests/AzureServiceBusEmulatorIntegrationTests.cs create mode 100644 src/tests/AzureServiceBusEmulator.IntegrationTests/SharedServiceBusEmulator.cs create mode 100644 src/tests/AzureServiceBusEmulator.IntegrationTests/SharedServiceBusEmulatorTests.cs create mode 100644 src/tests/AzureServiceBusEmulator.UnitTests/AzureServiceBusEmulator.UnitTests.csproj create mode 100644 src/tests/AzureServiceBusEmulator.UnitTests/AzureServiceBusEmulatorBuilderTests.cs diff --git a/.config/purview-build.json b/.config/purview-build.json index 7becf17..d359f0c 100644 --- a/.config/purview-build.json +++ b/.config/purview-build.json @@ -73,6 +73,12 @@ "README.md", "purview-logo-light.png" ], + "purview.containers.azureservicebusemulator": [ + "lib/$(TFM)/Purview.Containers.AzureServiceBusEmulator.dll", + "lib/$(TFM)/Purview.Containers.AzureServiceBusEmulator.xml", + "README.md", + "purview-logo-light.png" + ], "purview.containers.azurite": [ "lib/$(TFM)/Purview.Containers.Azurite.dll", "lib/$(TFM)/Purview.Containers.Azurite.xml", diff --git a/.github/workflows/integration-wsl.yml b/.github/workflows/integration-wsl.yml index 0a63977..21df9d3 100644 --- a/.github/workflows/integration-wsl.yml +++ b/.github/workflows/integration-wsl.yml @@ -99,6 +99,7 @@ jobs: run: | $projects = @( 'src/tests/AzureKeyVaultEmulator.IntegrationTests/AzureKeyVaultEmulator.IntegrationTests.csproj', + 'src/tests/AzureServiceBusEmulator.IntegrationTests/AzureServiceBusEmulator.IntegrationTests.csproj', 'src/tests/PostgreSql.IntegrationTests/PostgreSql.IntegrationTests.csproj', 'src/tests/Redis.IntegrationTests/Redis.IntegrationTests.csproj', 'src/tests/MsSql.IntegrationTests/MsSql.IntegrationTests.csproj', @@ -171,6 +172,7 @@ jobs: run: | $projects = @( 'src/tests/AzureKeyVaultEmulator.IntegrationTests/AzureKeyVaultEmulator.IntegrationTests.csproj', + 'src/tests/AzureServiceBusEmulator.IntegrationTests/AzureServiceBusEmulator.IntegrationTests.csproj', 'src/tests/PostgreSql.IntegrationTests/PostgreSql.IntegrationTests.csproj', 'src/tests/Redis.IntegrationTests/Redis.IntegrationTests.csproj', 'src/tests/MsSql.IntegrationTests/MsSql.IntegrationTests.csproj', diff --git a/AGENTS.md b/AGENTS.md index 058c2a6..6b75c6f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,7 +5,8 @@ This repository contains `Purview.Containers`, a Testcontainers-style library for .NET that runs throwaway Linux containers for integration testing on **Microsoft WSL Containers (WSLC)** — the Windows runtime with no Docker installation — or on **Docker** through Testcontainers, plus the service modules (`PostgreSql`, -`Redis`, `MsSql`, `RabbitMq`, `Azurite`, `Nats`, `MySql`, `AzureKeyVaultEmulator`). +`Redis`, `MsSql`, `RabbitMq`, `Azurite`, `Nats`, `MySql`, `AzureKeyVaultEmulator`, +`AzureServiceBusEmulator`). `docs/wiki/Backends.md` is the consumer guide to choosing and configuring a backend; keep it up to date whenever a target framework, a backend package or the `buildTransitive` assets change. diff --git a/Directory.Packages.props b/Directory.Packages.props index 64bca29..1c97c54 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -58,6 +58,9 @@ + + + diff --git a/README.md b/README.md index f542604..8ee6a3f 100644 --- a/README.md +++ b/README.md @@ -27,8 +27,8 @@ The WSLC backend is built directly against the `Microsoft.WSL.Containers` NuGet > [Consumer Requirements](docs/wiki/Consumer-Requirements.md) before adopting it. > **Status: preview.** Backend-neutral abstractions, two backends (WSL Containers and Docker), wait -> strategies, the `Image`/`Tag` parser, registry auth, observability, hardening, and **eight service -> modules** (PostgreSQL, Redis, SQL Server, RabbitMQ, Azurite, NATS, MySQL, Azure Key Vault Emulator). On WSLC, **images are shared +> strategies, the `Image`/`Tag` parser, registry auth, observability, hardening, and **nine service +> modules** (PostgreSQL, Redis, SQL Server, RabbitMQ, Azurite, NATS, MySQL, Azure Key Vault Emulator, Azure Service Bus Emulator). 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. Move the store with `PURVIEW_CONTAINERS_STORAGE_PATH` or @@ -147,7 +147,8 @@ src/ Docker/ Docker backend (Purview.Containers.Docker) Containers/ umbrella package (Purview.Containers): Core + both backends PostgreSql/ Redis/ MsSql/ MySql/ - RabbitMq/ Azurite/ Nats/ AzureKeyVaultEmulator/ service modules + RabbitMq/ Azurite/ Nats/ AzureKeyVaultEmulator/ + AzureServiceBusEmulator/ service modules tests/ TUnit unit + integration projects (WSLC and Docker suites) samples/ getting-started/ runnable samples (WslSample, DockerSample, AutoSample) @@ -177,7 +178,7 @@ The project documentation lives in [`docs/wiki`](docs/wiki/Home.md) and is publi Every package also ships its own `README.md` (from `src/src//Sdk/README.md`), so `dotnet add package Purview.Containers.` brings documentation specific to that package. -All eight service modules work today: +All nine service modules work today: ```csharp await using var postgres = new PostgreSqlBuilder() @@ -230,7 +231,7 @@ await using var azurite = new AzuriteBuilder().Build(); await azurite.StartAsync(); // waits for the blob/queue/table listeners -// Supply the Azurite devstoreaccount1 key in AzuriteAccount.Key for authenticated operations. +// Carries the well-known devstoreaccount1 key, so the Azure Storage clients authenticate as-is. string connectionString = azurite.GetConnectionString(); Uri blob = azurite.GetBlobEndpoint(); ``` @@ -245,6 +246,17 @@ var secrets = keyVault.GetSecretClient(); await secrets.SetSecretAsync("mySecret", "myValue"); ``` +```csharp +await using var serviceBus = new AzureServiceBusEmulatorBuilder() + .AcceptLicense() // explicit: the emulator and SQL Server EULAs + .Build(); + +await serviceBus.StartAsync(); // starts its SQL Server sidecar, then waits for GET /health + +await using var client = new ServiceBusClient(serviceBus.GetConnectionString()); +await client.CreateSender("queue.1").SendMessageAsync(new ServiceBusMessage("hello")); +``` + ## Running the tests Every test process owns a single shared WSLC session and, by default, uses the shared image store diff --git a/docs/wiki/Backends.md b/docs/wiki/Backends.md index 1a983d5..56b7558 100644 --- a/docs/wiki/Backends.md +++ b/docs/wiki/Backends.md @@ -2,7 +2,7 @@ `Purview.Containers` has **one API and two interchangeable backends**. The same test code, the same service modules (`Purview.Containers.PostgreSql`, `Redis`, `MsSql`, `RabbitMq`, `Azurite`, `Nats`, `MySql`, -`AzureKeyVaultEmulator`), and +`AzureKeyVaultEmulator`, `AzureServiceBusEmulator`), and the same connection-string accessors run on either runtime — you choose which one by the package you reference, and can override it in code or from the environment. diff --git a/docs/wiki/Connection-Strings.md b/docs/wiki/Connection-Strings.md index e776ead..ca58216 100644 --- a/docs/wiki/Connection-Strings.md +++ b/docs/wiki/Connection-Strings.md @@ -20,10 +20,10 @@ string same = container.GetConnectionString(ConnectionMode.Host); // explici | `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)). +`ConnectionMode.Container` throws `ConnectionStringModeNotSupportedException`. WSLC has no managed networks +or inter-container DNS, and the Docker backend does not attach containers to a shared network, so no +module builds a container-to-container connection string. Containers can still reach each other by +bridge IP (`IContainer.NetworkIp`) — see [Networking](Networking.md). ## How it works diff --git a/docs/wiki/Consumer-Requirements.md b/docs/wiki/Consumer-Requirements.md index 14e0588..867c83a 100644 --- a/docs/wiki/Consumer-Requirements.md +++ b/docs/wiki/Consumer-Requirements.md @@ -124,7 +124,7 @@ transitive references**, so a project that references the backend package direct project that does — gets them automatically. > A **service module** (`Purview.Containers.PostgreSql`, `Redis`, `MsSql`, `RabbitMq`, `Azurite`, `Nats`, -> `MySql`, `AzureKeyVaultEmulator`) is backend-neutral and does **not** bring a backend, so reference the module **and** +> `MySql`, `AzureKeyVaultEmulator`, `AzureServiceBusEmulator`) is backend-neutral and does **not** bring a backend, so reference the module **and** > `Purview.Containers.Wsl` (or another backend package) to run it. Use `Purview.Containers.Docker` to run > the same module on Docker, and `PURVIEW_CONTAINERS_BACKEND` to choose between them. diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md index 0cba0c9..16b41ef 100644 --- a/docs/wiki/Home.md +++ b/docs/wiki/Home.md @@ -34,12 +34,13 @@ This wiki is the project documentation hub. The packages are published under the | `Purview.Containers.Docker` | The Docker backend: the same containers on any reachable Docker daemon, driven by Testcontainers. | | `Purview.Containers.PostgreSql` | PostgreSQL container (`postgres:17`), `pg_isready` readiness, Npgsql connection string. | | `Purview.Containers.Redis` | Redis-compatible container (`redis:7`), `redis-cli ping` readiness; also usable with Valkey/Garnet. | -| `Purview.Containers.MsSql` | SQL Server container (`mssql/server:2022-latest`), host-side `SqlClient` readiness, explicit EULA acceptance. | +| `Purview.Containers.MsSql` | SQL Server container (`mssql/server:2025-latest`), host-side `SqlClient` readiness, explicit EULA acceptance. | | `Purview.Containers.RabbitMq` | RabbitMQ container (`rabbitmq:3-management`), AMQP + management endpoints. | | `Purview.Containers.Azurite` | Azure Storage emulator (`azure-storage/azurite`), blob/queue/table endpoints. | | `Purview.Containers.Nats` | NATS broker (`nats:2`), client + monitoring endpoints. | | `Purview.Containers.MySql` | MySQL container (`mysql:8`), host-side `MySqlConnector` readiness. | | `Purview.Containers.AzureKeyVaultEmulator` | Azure Key Vault emulator (`jamesgoulddev/azure-keyvault-emulator`), secrets/keys/certificates endpoints and certificate-pinned Azure SDK clients. | +| `Purview.Containers.AzureServiceBusEmulator` | Azure Service Bus emulator (`azure-messaging/servicebus-emulator`) with its SQL Server sidecar, AMQP and management connection strings, explicit EULA acceptance. | ## Feature highlights diff --git a/docs/wiki/Modules.md b/docs/wiki/Modules.md index 6c2caf3..597f0b3 100644 --- a/docs/wiki/Modules.md +++ b/docs/wiki/Modules.md @@ -140,6 +140,7 @@ that leans on the Testcontainers modules ports across unchanged: | Azurite | Azure Storage string: `DefaultEndpointsProtocol=http`, `AccountName`, `AccountKey`, `Blob/Queue/TableEndpoint` | `GetBlobEndpoint()`, `GetQueueEndpoint()`, `GetTableEndpoint()` | | NATS | `nats://host:port` | `GetClientEndpoint()`, `GetMonitoringEndpoint()` | | Azure Key Vault Emulator | Vault URI: `https://127.0.0.1:{port}` | `GetVaultUri()`, `GetCertificate()`, `GetSecretClient()`, `GetKeyClient()`, `GetCertificateClient()` | +| Azure Service Bus Emulator | Service Bus string: `Endpoint=sb://127.0.0.1:{amqpPort}`, `SharedAccessKeyName`, `SharedAccessKey`, `UseDevelopmentEmulator=true` | `GetManagementConnectionString()` (for `ServiceBusAdministrationClient`), `SqlServer` | ## Module status @@ -147,12 +148,13 @@ that leans on the Testcontainers modules ports across unchanged: |---|---|---|---|---| | PostgreSQL | `postgres:17` | `pg_isready` | Npgsql | ✅ implemented | | Redis | `redis:7` | `redis-cli ping` | StackExchange.Redis | ✅ implemented — also usable with Valkey/Garnet via `WithImage` | -| SQL Server | `mcr.microsoft.com/mssql/server:2022-latest` | host `SqlClient` connection | Microsoft.Data.SqlClient | ✅ implemented — requires `.AcceptLicense()`; the connection string defaults to `Database=master` (`WithDatabase(...)` to change it); session needs ≥ 2000 MB memory | +| SQL Server | `mcr.microsoft.com/mssql/server:2025-latest` | host `SqlClient` connection | Microsoft.Data.SqlClient | ✅ implemented — requires `.AcceptLicense()`; the connection string defaults to `Database=master` (`WithDatabase(...)` to change it); session needs ≥ 2000 MB memory | | RabbitMQ | `rabbitmq:3-management` | log `"Server startup complete"` | RabbitMQ.Client | ✅ implemented — AMQP + management endpoints | -| Azurite | `mcr.microsoft.com/azure-storage/azurite` | log `"successfully listening"` | Azure.Storage.* | ✅ implemented — blob/queue/table endpoints; the well-known `devstoreaccount1` key is a placeholder in `AzuriteAccount.Key` until the consuming repo supplies it | +| Azurite | `mcr.microsoft.com/azure-storage/azurite` | log `"successfully listening"` | Azure.Storage.* | ✅ implemented — blob/queue/table endpoints; the connection string carries the well-known `devstoreaccount1` key (`AzuriteAccount.Key`) | | NATS | `nats:2` | log `"Listening for client connections"` | NATS.Client.Core | ✅ implemented — client + monitoring endpoints | | MySQL | `mysql:8` | host `MySqlConnector` connection | MySqlConnector | ✅ implemented — uses a real connection poll (the image logs `"ready for connections"` during its temporary init server) | | Azure Key Vault Emulator | `jamesgoulddev/azure-keyvault-emulator:3.1.3` (`3.1.3-arm` on ARM64) | HTTPS `GET /` | Azure.Security.KeyVault.* | ✅ implemented — secrets/keys/certificates clients that pin the emulator certificate; a self-signed certificate is generated (and by default installed into the host trust store) and mounted at `/certs`; persistence requires a fixed host port | +| Azure Service Bus Emulator | `mcr.microsoft.com/azure-messaging/servicebus-emulator:latest` | HTTP `GET /health` on 5300 | Azure.Messaging.ServiceBus | ✅ implemented — requires `.AcceptLicense()` (emulator and SQL Server EULAs); starts a SQL Server 2025 sidecar first (`WithSqlServerImage` to change it) and reaches it by bridge IP (`IContainer.NetworkIp`); entities from `WithConfig`/`WithConfigFile` or the image default; session needs ≥ 2000 MB memory for the sidecar | Note on RabbitMQ readiness: the default wait uses the canonical **`Server startup complete`** log signal rather than `rabbitmq-diagnostics ping` — under WSLC the exec-based probe races with the Erlang cookie setup and can trigger a startup failure (`eacces` reading `.erlang.cookie`). WSLC auto-provisions image `VOLUME` declarations on an ext4 device by default; bind-mounting a Windows directory onto one replaces it with drvfs, which ignores Unix `chown` and breaks permission-sensitive images — do not bind-mount onto image volumes unless you intend that. diff --git a/docs/wiki/Networking.md b/docs/wiki/Networking.md index 0265ba6..fcf4b6f 100644 --- a/docs/wiki/Networking.md +++ b/docs/wiki/Networking.md @@ -20,9 +20,9 @@ WSLC networking behaviour and how `Purview.Containers` models it. 1. **Default `NetworkingMode = Bridged`** for containers that bind ports (or, by default, for all containers to be useful). This is baked into `ContainerBuilder` defaults. 2. `GetMappedPublicPort(containerPort)` reads `Inspect().Ports["/tcp"][0].HostPort`. -3. `GetNetworkIp()` reads `Inspect().NetworkSettings.Networks.bridge.IPAddress` (Bridged only). +3. `IContainer.NetworkIp` is the container's bridge IP after start: WSLC reads `Inspect().NetworkSettings.Networks.bridge.IPAddress` (Bridged only), and Docker reports the Testcontainers `IpAddress` (the default bridge). It is `null` before start or without a network. 4. TCP/HTTP wait strategies connect to `127.0.0.1:` (IPv4 only). -5. **Multi-container environments are deliberately deferred.** Because there is no native DNS/alias, a future `ContainerEnvironmentBuilder` would wire containers together by passing each other's IPs (from `GetNetworkIp()`) into configuration/env. The core API must not preclude this (it doesn't — `GetNetworkIp` + environment are enough). +5. **Containers are wired together by IP.** Because there is no native DNS/alias, a dependent container is given its dependency's `NetworkIp` through configuration or environment. A module that owns a dependency starts it first and overrides `ContainerBase.ConfigureForStart` to inject the address, as the Azure Service Bus Emulator does with its SQL Server sidecar (`SQL_SERVER=`). A general `ContainerEnvironmentBuilder` is still deferred. ## What NOT to model diff --git a/docs/wiki/Testing.md b/docs/wiki/Testing.md index 7b9e5c5..f49c855 100644 --- a/docs/wiki/Testing.md +++ b/docs/wiki/Testing.md @@ -45,7 +45,7 @@ just test '/*/*/*/*[Category=Integration]' --max-parallel-test-modules 1 Integration suites target whichever backend is selected. Automatic detection prefers WSLC and falls back to Docker, so a WSLC host runs the WSLC suites and a Docker-only host runs the Docker ones; pin one with `PURVIEW_CONTAINERS_BACKEND=wsl|docker` to fail loudly instead of falling back. The Docker suites -(`Docker.IntegrationTests` for the container contract, `Modules.Docker.IntegrationTests` for the eight +(`Docker.IntegrationTests` for the container contract, `Modules.Docker.IntegrationTests` for the nine service modules) skip themselves when no daemon is reachable, and the WSLC suites skip themselves when the host lacks the WSL Containers components. Set `PURVIEW_CONTAINERS_REQUIRE_BACKEND=wsl|docker` to turn that skip into a failure — the CI integration jobs do. See [Backends: WSLC or Docker](Backends.md) for @@ -53,7 +53,7 @@ consumer-facing setup and CI examples. ```powershell just test src/tests/Docker.IntegrationTests/Docker.IntegrationTests.csproj # Docker contract -just test src/tests/Modules.Docker.IntegrationTests/Modules.Docker.IntegrationTests.csproj # the eight modules on Docker +just test src/tests/Modules.Docker.IntegrationTests/Modules.Docker.IntegrationTests.csproj # the nine modules on Docker ``` ## Why test modules run serially @@ -89,7 +89,7 @@ the workflow adds `C:\Program Files\WSL` and calls it by absolute path. Coverage is split for cost: - **Every pull request**: `pr.yml` runs `Wsl.IntegrationTests` on `windows-2025` (the contract suite). -- **Nightly**: `.github/workflows/integration-wsl.yml` runs the contract suite and the eight service-module +- **Nightly**: `.github/workflows/integration-wsl.yml` runs the contract suite and the nine service-module suites on `windows-2025`, one project at a time. - **On demand**: the same workflow accepts `runner: self-hosted` to use a warm self-hosted Windows runner with the custom `wslc` label, and `scope: contract` to run only the contract suite. diff --git a/package.json b/package.json index 4f7f793..1cb9fb0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "containers", - "version": "1.0.0-prerelease.8", + "version": "1.1.0-prerelease.1", "description": "Testcontainers-style library for .NET that runs throwaway Linux containers for integration testing, on Microsoft WSL Containers or Docker.", "license": "MIT", "author": { diff --git a/skills/purview-containers-lifecycle/SKILL.md b/skills/purview-containers-lifecycle/SKILL.md index 01b84ed..96da38e 100644 --- a/skills/purview-containers-lifecycle/SKILL.md +++ b/skills/purview-containers-lifecycle/SKILL.md @@ -129,14 +129,17 @@ Verified WSLC behaviour, and the parts that surprise people: create/connect`, unlike the CLI. How the library models it: `GetMappedPublicPort(containerPort)` reads -`Inspect().Ports["/tcp"][0].HostPort`; `GetNetworkIp()` reads -`Inspect().NetworkSettings.Networks.bridge.IPAddress` (Bridged only); TCP and HTTP waits connect to +`Inspect().Ports["/tcp"][0].HostPort`; `IContainer.NetworkIp` is the bridge IP after start +(WSLC reads `Inspect().NetworkSettings.Networks.bridge.IPAddress`, Docker the Testcontainers +`IpAddress`; `null` before start or without a network); TCP and HTTP waits connect to `127.0.0.1:`. **What not to model.** Do not invent Docker-style `Network` objects — the semantics are just "bridged -with IPs". Do not claim container-name resolution works; it does not. Multi-container environments -are deliberately deferred, and would wire containers together by passing each other's IPs from -`GetNetworkIp()` into configuration or environment — which is why the core API does not preclude it. +with IPs". Do not claim container-name resolution works; it does not. Containers are wired +together by passing a dependency's `NetworkIp` into configuration or environment: a module that owns a +dependency starts it first and injects the address from `ContainerBase.ConfigureForStart` (the Azure +Service Bus Emulator does this with its SQL Server sidecar). A general multi-container environment +builder is still deferred. ## Related skills diff --git a/skills/purview-containers-quickstart/SKILL.md b/skills/purview-containers-quickstart/SKILL.md index c90b6c1..6d439f7 100644 --- a/skills/purview-containers-quickstart/SKILL.md +++ b/skills/purview-containers-quickstart/SKILL.md @@ -129,8 +129,8 @@ await connection.OpenAsync(); No image names, no ports, no `docker`, no `wslc`. The default image, the random host port mapping and the readiness check all come from the module. -Modules: PostgreSQL, Redis, SQL Server, MySQL, RabbitMQ, Azurite, NATS and the Azure Key Vault -Emulator. Each ships a README inside its own package. +Modules: PostgreSQL, Redis, SQL Server, MySQL, RabbitMQ, Azurite, NATS, the Azure Key Vault +Emulator and the Azure Service Bus Emulator. Each ships a README inside its own package. ## Connection strings diff --git a/src/Containers.slnx b/src/Containers.slnx index 184155c..3035886 100644 --- a/src/Containers.slnx +++ b/src/Containers.slnx @@ -11,6 +11,7 @@ + @@ -33,6 +34,8 @@ + + diff --git a/src/src/AzureServiceBusEmulator/AzureServiceBusEmulator.csproj b/src/src/AzureServiceBusEmulator/AzureServiceBusEmulator.csproj new file mode 100644 index 0000000..c3831ae --- /dev/null +++ b/src/src/AzureServiceBusEmulator/AzureServiceBusEmulator.csproj @@ -0,0 +1,16 @@ + + + true + Azure Service Bus Emulator module for Purview.Containers: throwaway Service Bus emulators (queues, topics, subscriptions) with their SQL Server dependency, on WSL Containers or Docker. + wsl;containers;docker;servicebus;azure;emulator;messaging;testing;integration + MIT + Purview + + + + + + + + diff --git a/src/src/AzureServiceBusEmulator/AzureServiceBusEmulatorBuilder.cs b/src/src/AzureServiceBusEmulator/AzureServiceBusEmulatorBuilder.cs new file mode 100644 index 0000000..090bf54 --- /dev/null +++ b/src/src/AzureServiceBusEmulator/AzureServiceBusEmulatorBuilder.cs @@ -0,0 +1,199 @@ +using Purview.Containers.Diagnostics; +using Purview.Containers.MsSql; +using Purview.Containers.Runtime; +using Purview.Containers.Waiting; + +namespace Purview.Containers.AzureServiceBusEmulator; + +/// +/// Fluent builder for an Azure +/// Service Bus Emulator test container. The emulator stores its state in SQL Server, so the container +/// starts a SQL Server sidecar on the same backend first and points the emulator at its bridge address. +/// +public class AzureServiceBusEmulatorBuilder + : ContainerBuilder< + AzureServiceBusEmulatorBuilder, + AzureServiceBusEmulatorContainer, + AzureServiceBusEmulatorConfiguration + > +{ + /// AMQP port the Service Bus clients connect to. + public const ushort AmqpPort = 5672; + + /// HTTP management port: the health endpoint and the administration (entity management) API. + public const ushort ManagementPort = 5300; + + /// Default image. + public const string EmulatorImage = "mcr.microsoft.com/azure-messaging/servicebus-emulator:latest"; + + /// Container directory the emulator reads Config.json from. + public const string ConfigMountPath = "/ServiceBus_Emulator/ConfigFiles"; + + /// Default SA password of the SQL Server sidecar. + public const string DefaultSqlServerPassword = "YourStrong!Passw0rd"; + + /// + /// Default SQL Server sidecar image: the MsSql module's default, SQL Server 2025. SQL Server 2022 does not + /// start on WSLC (it cannot create its system directory as the non-root mssql user). + /// + public const string DefaultSqlServerImage = MsSqlBuilder.MsSqlImage; + + bool _acceptLicense; + string _sqlServerImage = DefaultSqlServerImage; + Secret _sqlServerPassword = Secret.From(DefaultSqlServerPassword); + string? _configJson; + + /// Creates a builder with the default image. + public AzureServiceBusEmulatorBuilder() + : this(EmulatorImage) { } + + /// Creates a builder with a custom image. + public AzureServiceBusEmulatorBuilder(string image) + { + ApplyDefaults(image); + } + + /// Creates a builder using an explicit backend. The SQL Server sidecar runs on it too. + public AzureServiceBusEmulatorBuilder(IContainerBackend backend) + : base(backend) + { + ApplyDefaults(EmulatorImage); + } + + /// + /// Explicitly accepts the Azure Service Bus Emulator EULA and the SQL Server EULA of its sidecar + /// (ACCEPT_EULA=Y on both). Required before Build; the library never accepts licensing terms on the + /// caller's behalf. + /// + public AzureServiceBusEmulatorBuilder AcceptLicense() + { + _acceptLicense = true; + WithEnvironment("ACCEPT_EULA", "Y"); + return this; + } + + /// Sets the SQL Server sidecar image (default ). + public AzureServiceBusEmulatorBuilder WithSqlServerImage(string image) + { + ArgumentException.ThrowIfNullOrWhiteSpace(image); + _sqlServerImage = image; + return this; + } + + /// Sets the SQL Server sidecar SA password. Must satisfy SQL Server password complexity (at least 8 characters). + public AzureServiceBusEmulatorBuilder WithSqlServerPassword(string password) + { + _sqlServerPassword = Secret.From(password); + return this; + } + + /// + /// Uses as the emulator Config.json, declaring the namespace and its + /// queues, topics and subscriptions. Without it the image's default configuration applies. + /// + public AzureServiceBusEmulatorBuilder WithConfig(string json) + { + ArgumentException.ThrowIfNullOrWhiteSpace(json); + _configJson = json; + return this; + } + + /// Reads the emulator Config.json from ; see . + public AzureServiceBusEmulatorBuilder WithConfigFile(string path) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + if (!File.Exists(path)) + { + throw new ContainerConfigurationException( + $"The Service Bus emulator configuration file '{path}' does not exist." + ); + } + + return WithConfig(File.ReadAllText(path)); + } + + /// Builds the immutable configuration (internal; used by the module's own tests). + internal AzureServiceBusEmulatorConfiguration BuildConfigurationForTesting() => BuildConfiguration(); + + /// + protected override AzureServiceBusEmulatorConfiguration BuildConfiguration() + { + var configuration = base.BuildConfiguration(); + Dictionary environment = new(configuration.Environment, StringComparer.Ordinal) + { + ["MSSQL_SA_PASSWORD"] = _sqlServerPassword.Value, + // The sidecar is already accepting connections when the emulator starts, so skip its built-in delay. + ["SQL_WAIT_INTERVAL"] = "0", + }; + if (_acceptLicense) + { + environment["ACCEPT_EULA"] = "Y"; + } + + var waitStrategies = + configuration.WaitStrategies.Count > 0 + ? configuration.WaitStrategies + : new[] { (IWaitStrategy)BuildDefaultWaitStrategy() }; + + return configuration with + { + Environment = environment, + WaitStrategies = waitStrategies, + AcceptLicense = _acceptLicense, + SqlServerImage = _sqlServerImage, + SqlServerPassword = _sqlServerPassword, + ConfigJson = _configJson, + }; + } + + /// + protected override void Validate(ContainerConfiguration configuration) + { + base.Validate(configuration); + if (!_acceptLicense) + { + throw new ContainerConfigurationException( + "The Azure Service Bus Emulator and its SQL Server sidecar require accepting their EULAs. Call AcceptLicense() explicitly before Build()." + ); + } + + if (_sqlServerPassword.Value.Length < 8) + { + throw new ContainerConfigurationException("The SQL Server SA password must be at least 8 characters long."); + } + } + + /// + protected override AzureServiceBusEmulatorContainer CreateContainer( + AzureServiceBusEmulatorConfiguration configuration + ) + { + ArgumentNullException.ThrowIfNull(configuration); + return new(configuration, BuildSqlServer(configuration), Backend); + } + + MsSqlContainer BuildSqlServer(AzureServiceBusEmulatorConfiguration configuration) + { + var builder = Backend is null ? new MsSqlBuilder() : new MsSqlBuilder(Backend); + return builder + .WithImage(configuration.SqlServerImage) + .WithPassword(configuration.SqlServerPassword.Value) + .AcceptLicense() + .WithStartupTimeout(configuration.StartupTimeout) + .Build(); + } + + static WaitStrategy BuildDefaultWaitStrategy() + { + // The emulator's own health endpoint proves the service is up, not merely that a port is listening. + return Wait.ForHttp("/health").ForPort(ManagementPort).WithInterval(TimeSpan.FromSeconds(1)); + } + + void ApplyDefaults(string image) + { + WithImage(image) + .WithPortBinding(AmqpPort, assignRandomHostPort: true) + .WithPortBinding(ManagementPort, assignRandomHostPort: true) + .WithConnectionStringProvider(new AzureServiceBusEmulatorConnectionStringProvider()); + } +} diff --git a/src/src/AzureServiceBusEmulator/AzureServiceBusEmulatorConfiguration.cs b/src/src/AzureServiceBusEmulator/AzureServiceBusEmulatorConfiguration.cs new file mode 100644 index 0000000..a025282 --- /dev/null +++ b/src/src/AzureServiceBusEmulator/AzureServiceBusEmulatorConfiguration.cs @@ -0,0 +1,23 @@ +using Purview.Containers.Diagnostics; + +namespace Purview.Containers.AzureServiceBusEmulator; + +/// Immutable configuration for an Azure Service Bus Emulator test container. +public sealed record AzureServiceBusEmulatorConfiguration : ContainerConfiguration +{ + /// True when the emulator and SQL Server EULAs have been explicitly accepted. + public bool AcceptLicense { get; init; } + + /// Image of the SQL Server sidecar the emulator stores its state in. + public string SqlServerImage { get; init; } = AzureServiceBusEmulatorBuilder.DefaultSqlServerImage; + + /// SA password of the SQL Server sidecar. Rendered as redacted. + public Secret SqlServerPassword { get; init; } = + Secret.From(AzureServiceBusEmulatorBuilder.DefaultSqlServerPassword); + + /// + /// The emulator Config.json (namespaces, queues, topics, subscriptions), or null to use the + /// configuration baked into the image. + /// + public string? ConfigJson { get; init; } +} diff --git a/src/src/AzureServiceBusEmulator/AzureServiceBusEmulatorConnectionStringProvider.cs b/src/src/AzureServiceBusEmulator/AzureServiceBusEmulatorConnectionStringProvider.cs new file mode 100644 index 0000000..cc13e29 --- /dev/null +++ b/src/src/AzureServiceBusEmulator/AzureServiceBusEmulatorConnectionStringProvider.cs @@ -0,0 +1,9 @@ +namespace Purview.Containers.AzureServiceBusEmulator; + +/// Provides the emulator's Service Bus connection string. +sealed class AzureServiceBusEmulatorConnectionStringProvider + : ContainerConnectionStringProvider +{ + /// + protected override string GetHostConnectionString() => Container.GetConnectionString(); +} diff --git a/src/src/AzureServiceBusEmulator/AzureServiceBusEmulatorContainer.cs b/src/src/AzureServiceBusEmulator/AzureServiceBusEmulatorContainer.cs new file mode 100644 index 0000000..f0843b7 --- /dev/null +++ b/src/src/AzureServiceBusEmulator/AzureServiceBusEmulatorContainer.cs @@ -0,0 +1,125 @@ +using Purview.Containers.Mounts; +using Purview.Containers.MsSql; +using Purview.Containers.Runtime; + +namespace Purview.Containers.AzureServiceBusEmulator; + +/// +/// A throwaway Azure Service Bus +/// Emulator instance running on WSL Containers or Docker, together with the SQL Server sidecar it +/// stores its state in. Starting the container starts the sidecar first; disposing it removes both. +/// +public sealed class AzureServiceBusEmulatorContainer : ContainerBase +{ + const string ConfigFileName = "Config.json"; + + // The emulator's fixed development credentials: it accepts only this key name and value. + const string SharedAccessKeyName = "RootManageSharedAccessKey"; + const string SharedAccessKey = "SAS_KEY_VALUE"; + + readonly AzureServiceBusEmulatorConfiguration _configuration; + + int _prepared; + string? _configDirectory; + + internal AzureServiceBusEmulatorContainer( + AzureServiceBusEmulatorConfiguration configuration, + MsSqlContainer sqlServer, + IContainerBackend? backend + ) + : base(configuration, backend) + { + _configuration = configuration; + SqlServer = sqlServer; + } + + /// The SQL Server sidecar the emulator stores its state in (for diagnostics such as its logs). + public MsSqlContainer SqlServer { get; } + + /// + /// Service Bus connection string for the AMQP endpoint, for ServiceBusClient. Safe to call after + /// . + /// + public string GetConnectionString() => BuildConnectionString(AzureServiceBusEmulatorBuilder.AmqpPort); + + /// + /// Service Bus connection string for the management endpoint, for ServiceBusAdministrationClient. + /// Safe to call after . + /// + public string GetManagementConnectionString() => + BuildConnectionString(AzureServiceBusEmulatorBuilder.ManagementPort); + + /// + public override async Task StartAsync(CancellationToken cancellationToken = default) + { + if (Interlocked.Exchange(ref _prepared, 1) == 1) + { + return; + } + + // The emulator connects to SQL Server as soon as it starts, so the sidecar must be accepting + // connections first. There is no container-name DNS, so the emulator reaches it by bridge IP. + await SqlServer.StartAsync(cancellationToken).ConfigureAwait(false); + if (SqlServer.NetworkIp is null) + { + throw new ContainerException( + "The SQL Server sidecar has no bridge-network IP address, so the Service Bus emulator cannot reach it." + ); + } + + if (_configuration.ConfigJson is { } configJson) + { + _configDirectory = Path.Combine(Path.GetTempPath(), "purview-servicebus-emulator", Name); + Directory.CreateDirectory(_configDirectory); + await File.WriteAllTextAsync(Path.Combine(_configDirectory, ConfigFileName), configJson, cancellationToken) + .ConfigureAwait(false); + } + + await base.StartAsync(cancellationToken).ConfigureAwait(false); + } + + /// + public override async ValueTask DisposeAsync() + { + await base.DisposeAsync().ConfigureAwait(false); + await SqlServer.DisposeAsync().ConfigureAwait(false); + + if (_configDirectory is not null && Directory.Exists(_configDirectory)) + { + Directory.Delete(_configDirectory, recursive: true); + } + + _configDirectory = null; + } + + /// + protected override IContainerConfiguration ConfigureForStart(IContainerConfiguration configuration) + { + if (configuration is not ContainerConfiguration concrete) + { + return configuration; + } + + Dictionary environment = new(concrete.Environment, StringComparer.Ordinal) + { + ["SQL_SERVER"] = SqlServer.NetworkIp!, + }; + + List bindMounts = [.. concrete.BindMounts]; + if (_configDirectory is not null) + { + bindMounts.Add( + new BindMount(_configDirectory, AzureServiceBusEmulatorBuilder.ConfigMountPath, ReadOnly: true) + ); + } + + return concrete with + { + Environment = environment, + BindMounts = bindMounts, + }; + } + + string BuildConnectionString(ushort containerPort) => + $"Endpoint=sb://127.0.0.1:{GetMappedPublicPort(containerPort)};SharedAccessKeyName={SharedAccessKeyName};SharedAccessKey={SharedAccessKey};UseDevelopmentEmulator=true;"; +} diff --git a/src/src/AzureServiceBusEmulator/Sdk/README.md b/src/src/AzureServiceBusEmulator/Sdk/README.md new file mode 100644 index 0000000..25b3b81 --- /dev/null +++ b/src/src/AzureServiceBusEmulator/Sdk/README.md @@ -0,0 +1,211 @@ +# Purview.Containers.AzureServiceBusEmulator + +Throwaway [Azure Service Bus Emulator](https://github.com/Azure/azure-service-bus-emulator-installer) +instances for .NET integration testing on **WSL Containers (WSLC)** or **Docker**. The emulator speaks +AMQP, so `ServiceBusClient` from `Azure.Messaging.ServiceBus` works against it unchanged. + +```bash +dotnet add package Purview.Containers.AzureServiceBusEmulator +``` + +Backend-neutral: depends on `Purview.Containers.Core` and `Purview.Containers.MsSql` (the emulator stores +its state in SQL Server), and needs a backend package (`Purview.Containers.Wsl` or +`Purview.Containers.Docker`). + +## Requirements + +- **WSL Containers backend:** Windows 10/11 with WSL Containers (`wsl --install --no-distribution`), and a + `.NET 10` or later project. A platform-neutral `net10.0` project binds the portable facade and gets + automatic WSLC-or-Docker selection; a Windows target framework + (`net10.0-windows10.0.19041.0`, x64 or arm64) binds the implementation directly. The + `Purview.Containers.Wsl` package supplies `buildTransitive` defaults for + `WindowsSdkPackageVersion`/`PlatformTarget` and (for a platform-neutral consumer on a Windows build + host) the implementation payload, rejecting an unsupported consumer with `PCC0001`/`PCC0002` — see the + [consumer requirements](https://github.com/purview-dev/containers/blob/main/docs/wiki/Consumer-Requirements.md). +- **Docker backend:** any reachable Docker daemon (`docker info`), with a `net10.0` or later project on any + platform. No Windows target framework and no `PCC` guards apply. +- **SQL Server sidecar:** the default is SQL Server 2025 (`mcr.microsoft.com/mssql/server:2025-latest`), + which is x64 only and needs at least 2000 MB of memory in the WSLC session VM or Docker host. On ARM64, + `WithSqlServerImage("mcr.microsoft.com/azure-sql-edge:latest")` is an alternative the emulator supports. + SQL Server 2022 does not start on WSLC (it fails to create `/.system` as the non-root `mssql` user). +- **Experimental:** the API, defaults and packaging can change between prereleases; there is no + production support guarantee. +- The emulator is for local development and testing only; it is not a replacement for Azure Service Bus. + +## Quick start + +```csharp +using Azure.Messaging.ServiceBus; +using Purview.Containers.AzureServiceBusEmulator; + +await using var emulator = new AzureServiceBusEmulatorBuilder() + .AcceptLicense() // explicit: the emulator and SQL Server EULAs + .Build(); + +await emulator.StartAsync(); // starts SQL Server, then the emulator; waits for GET /health + +await using ServiceBusClient client = new(emulator.GetConnectionString()); +await client.CreateSender("queue.1").SendMessageAsync(new ServiceBusMessage("hello")); +``` + +Without a configuration the image's default `Config.json` applies (namespace `sbemulatorns`, queue +`queue.1`, topic `topic.1` with subscription `subscription.1`). + +## Licensing + +`Build()` throws until `AcceptLicense()` is called. That single call accepts both the +[emulator EULA](https://github.com/Azure/azure-service-bus-emulator-installer/blob/main/EMULATOR_EULA.txt) +and the SQL Server EULA of the sidecar (`ACCEPT_EULA=Y` on both containers). The library never accepts +licensing terms on your behalf. + +## Entities + +Declare the namespace, queues, topics and subscriptions with the emulator's `Config.json` format: + +```csharp +await using var emulator = new AzureServiceBusEmulatorBuilder() + .AcceptLicense() + .WithConfigFile("servicebus-config.json") // or .WithConfig(json) + .Build(); +``` + +The configuration is written to a per-container temporary directory, mounted read-only at +`/ServiceBus_Emulator/ConfigFiles`, and deleted on dispose. See the +[emulator configuration reference](https://learn.microsoft.com/azure/service-bus-messaging/overview-emulator#configuration) +for the schema and its quotas. + +## How the SQL Server sidecar is wired + +WSL Containers has no container-name DNS, so the module does what both backends support: it starts a SQL +Server container (through the `MsSql` module, so readiness is a real client connection) and passes its +bridge IP (`IContainer.NetworkIp`) to the emulator as `SQL_SERVER`. Disposing the emulator disposes the +sidecar. `SqlServer` exposes the sidecar for diagnostics such as its logs. + +## Sharing one emulator across tests + +Starting an emulator takes roughly a minute (SQL Server, then the emulator), so start **one per test +session** and give every test its own entities. The tests can then run in parallel: a test only ever +reads from the queue or topic it created, so it never sees another test's messages. + +Changing the database name does not help instead. The emulator always uses the same two databases +(`SbGatewayDatabase`, `SbMessageContainerDatabase00001`) and drops and recreates them on every start, so +two emulators pointed at one SQL Server would wipe each other. + +With TUnit, share the emulator through a `ClassDataSource` fixture: + +```csharp +using Azure.Messaging.ServiceBus; +using Azure.Messaging.ServiceBus.Administration; +using Purview.Containers.AzureServiceBusEmulator; +using TUnit.Core.Interfaces; + +public sealed class ServiceBusEmulatorFixture : IAsyncInitializer, IAsyncDisposable +{ + public AzureServiceBusEmulatorContainer Emulator { get; } = + new AzureServiceBusEmulatorBuilder().AcceptLicense().Build(); + + public ServiceBusClient Client { get; private set; } = default!; + + public ServiceBusAdministrationClient Administration { get; private set; } = default!; + + public async Task InitializeAsync() + { + await Emulator.StartAsync(); + Client = new ServiceBusClient(Emulator.GetConnectionString()); + Administration = new ServiceBusAdministrationClient(Emulator.GetManagementConnectionString()); + } + + // A unique queue per test; delete it afterwards to stay under the namespace's entity quota. + public async Task CreateQueueAsync() + { + var name = $"q-{Guid.NewGuid():N}"; + await Administration.CreateQueueAsync(name); + return name; + } + + public async ValueTask DisposeAsync() + { + await Client.DisposeAsync(); + await Emulator.DisposeAsync(); + } +} + +[ClassDataSource(Shared = SharedType.PerTestSession)] +public class OrderProcessingTests(ServiceBusEmulatorFixture serviceBus) +{ + [Test] + public async Task PlacedOrder_IsPublished() + { + var queue = await serviceBus.CreateQueueAsync(); + try + { + await using var sender = serviceBus.Client.CreateSender(queue); + await sender.SendMessageAsync(new ServiceBusMessage("order-1")); + + await using var receiver = serviceBus.Client.CreateReceiver(queue); + var message = await receiver.ReceiveMessageAsync(TimeSpan.FromSeconds(30)); + + await Assert.That(message!.Body.ToString()).IsEqualTo("order-1"); + } + finally + { + await serviceBus.Administration.DeleteQueueAsync(queue); + } + } +} +``` + +Topics work the same way: `CreateTopicAsync(name)` followed by `CreateSubscriptionAsync(name, subscription)`. +Create entities at run time rather than in `Config.json` when tests share an instance; the configuration +is fixed when the emulator starts. With xUnit, put the same start/stop code in an assembly or collection +fixture. + +## Limitations + +What this module and its tests have established: + +- **One SQL Server per emulator.** The fixed database names (see above) mean an emulator owns its SQL + Server outright. Share the emulator, never the database server. +- **No state survives a restart.** The databases are recreated on every start, so entities created at run + time and any unconsumed messages are lost with the container. +- **Slow, memory-hungry start.** About a minute per instance, and the SQL Server sidecar needs at least + 2000 MB. Running several emulators at once multiplies both, which is why sharing one is recommended. +- **x64 only by default.** SQL Server 2025 has no ARM64 image. +- **Host connections only.** The connection strings point at `127.0.0.1` and the mapped host ports. An + application under test that runs in another container cannot use them (`ConnectionMode.Container` is + not supported). +- **Fixed development credentials.** The emulator accepts only `RootManageSharedAccessKey` / + `SAS_KEY_VALUE`; there is no Microsoft Entra ID (token) authentication. +- **Unpinned image.** The default emulator tag is `latest`, so an upstream release can change behaviour. + Pin a tag with `WithImage(...)` when a test run must be reproducible. +- **Its logs contain the SQL Server password.** The emulator writes its SQL connection string, password + included, to its log. Treat those logs as sensitive. + +The emulator is also not the full service. Check Microsoft's +[emulator documentation](https://learn.microsoft.com/azure/service-bus-messaging/overview-emulator) for its +current quotas (such as the number of entities per namespace and the maximum message size) and the +features it does not support, before relying on it for a given scenario. + +## API + +| Member | Purpose | +| -- | -- | +| `AzureServiceBusEmulatorBuilder()` / `(string image)` / `(IContainerBackend)` | Default image `mcr.microsoft.com/azure-messaging/servicebus-emulator:latest`, or a custom image. The sidecar uses the same backend. | +| `AzureServiceBusEmulatorBuilder.AmqpPort` (5672), `ManagementPort` (5300) | Container ports; both are mapped to random host ports. | +| `AcceptLicense()` | Required: accepts the emulator and SQL Server EULAs. | +| `WithConfig(string json)` / `WithConfigFile(string path)` | The emulator `Config.json`. | +| `WithSqlServerImage(string)` / `WithSqlServerPassword(string)` | Sidecar image (default `mcr.microsoft.com/mssql/server:2025-latest`) and SA password (at least 8 characters). | +| `AzureServiceBusEmulatorContainer.GetConnectionString()` | `Endpoint=sb://127.0.0.1:{amqpPort};SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=SAS_KEY_VALUE;UseDevelopmentEmulator=true;` for `ServiceBusClient`. | +| `AzureServiceBusEmulatorContainer.GetManagementConnectionString()` | The same against the management port, for `ServiceBusAdministrationClient`. | +| `AzureServiceBusEmulatorContainer.SqlServer` | The SQL Server sidecar container. | + +The default wait strategy polls `GET /health` on the management port and is replaced when you supply +your own with `WithWaitStrategy(...)`. Call the accessors after `StartAsync()`, once the mapped ports are +known. + +## Documentation + +- [Backends: WSLC or Docker](https://github.com/purview-dev/containers/blob/main/docs/wiki/Backends.md) — choosing and configuring the runtime. +- [Modules](https://github.com/purview-dev/containers/blob/main/docs/wiki/Modules.md) — the module contract and readiness choices. +- [Networking](https://github.com/purview-dev/containers/blob/main/docs/wiki/Networking.md) — how containers reach each other by IP. +- [Azure Service Bus Emulator](https://learn.microsoft.com/azure/service-bus-messaging/overview-emulator) — the emulator itself. diff --git a/src/src/Azurite/AzuriteAccount.cs b/src/src/Azurite/AzuriteAccount.cs index f1d9c34..ace9cfc 100644 --- a/src/src/Azurite/AzuriteAccount.cs +++ b/src/src/Azurite/AzuriteAccount.cs @@ -6,8 +6,11 @@ public static class AzuriteAccount /// The well-known Azurite storage account name. public const string Name = "devstoreaccount1"; - // The well-known Azurite devstoreaccount1 key is intentionally NOT embedded in this library. - // The consuming repository supplies it here (or via a runtime configuration point) before - // authenticated operations are exercised. - public const string Key = ""; + /// + /// The well-known Azurite devstoreaccount1 key. It is a published constant of the emulator (the + /// same value the Azure Storage Emulator used), not a credential: it only ever authenticates against a + /// local emulator. + /// + public const string Key = + "Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw=="; } diff --git a/src/src/Azurite/AzuriteContainer.cs b/src/src/Azurite/AzuriteContainer.cs index a3f22ef..032eb84 100644 --- a/src/src/Azurite/AzuriteContainer.cs +++ b/src/src/Azurite/AzuriteContainer.cs @@ -27,8 +27,8 @@ public Uri GetTableEndpoint() return new Uri($"http://127.0.0.1:{GetMappedPublicPort(AzuriteBuilder.TablePort)}/{AzuriteAccount.Name}"); } - /// Azure Storage connection string for the emulator. The account key comes from - /// (a placeholder until the consuming repository supplies it). + /// Azure Storage connection string for the emulator, authenticated with the well-known + /// . public string GetConnectionString() { StringBuilder builder = new(); diff --git a/src/src/Azurite/Sdk/README.md b/src/src/Azurite/Sdk/README.md index df24de4..3ae026c 100644 --- a/src/src/Azurite/Sdk/README.md +++ b/src/src/Azurite/Sdk/README.md @@ -46,7 +46,7 @@ string connectionString = azurite.GetConnectionString(); | `AzuriteBuilder.BlobPort` (10000), `QueuePort` (10001), `TablePort` (10002) | Container service ports; all three are mapped to random host ports. | | `AzuriteContainer.GetBlobEndpoint()`, `GetQueueEndpoint()`, `GetTableEndpoint()` | Service endpoints (`http://127.0.0.1:{mappedPort}/devstoreaccount1`). | | `AzuriteContainer.GetConnectionString()` | Azure Storage connection string for the emulator. | -| `AzuriteAccount.Name` | The well-known account name `devstoreaccount1`. | +| `AzuriteAccount.Name` / `AzuriteAccount.Key` | The well-known account name `devstoreaccount1` and its published key. | The container starts Azurite with `--blobHost 0.0.0.0 --queueHost 0.0.0.0 --tableHost 0.0.0.0` and waits for the `successfully listening` log signal. Endpoints and the connection string are only meaningful after @@ -54,8 +54,14 @@ for the `successfully listening` log signal. Endpoints and the connection string ## Account key -The well-known `devstoreaccount1` key is a published constant of the emulator, but this library does not -embed it: `AzuriteAccount.Key` holds a placeholder. Supply the real key in your test infrastructure before -exercising authenticated operations (anonymous/local development paths are unaffected when the client does -not require the key). See [Modules](https://github.com/purview-dev/containers/blob/main/docs/wiki/Modules.md) -for the module contract. +`AzuriteAccount.Key` is the well-known `devstoreaccount1` key, a published constant of the emulator, and +`GetConnectionString()` includes it. The Azure Storage clients therefore authenticate against the +container without further setup: + +```csharp +BlobServiceClient blobs = new(azurite.GetConnectionString()); +await blobs.CreateBlobContainerAsync("my-container"); +``` + +See [Modules](https://github.com/purview-dev/containers/blob/main/docs/wiki/Modules.md) for the module +contract. diff --git a/src/src/Core/ContainerBase.cs b/src/src/Core/ContainerBase.cs index a02efa4..6de0aca 100644 --- a/src/src/Core/ContainerBase.cs +++ b/src/src/Core/ContainerBase.cs @@ -52,6 +52,9 @@ protected ContainerBase(IContainerConfiguration configuration, IContainerBackend /// public string Image => Configuration.Image; + /// + public string? NetworkIp => _container?.NetworkIp; + /// public virtual async Task StartAsync(CancellationToken cancellationToken = default) { @@ -62,7 +65,7 @@ public virtual async Task StartAsync(CancellationToken cancellationToken = defau } var backend = Backend ?? await ContainerBackends.ResolveAsync(cancellationToken).ConfigureAwait(false); - _container = backend.CreateContainer(WithResolvedName()); + _container = backend.CreateContainer(ConfigureForStart(WithResolvedName())); await _container.StartAsync(cancellationToken).ConfigureAwait(false); _configureConnectionStringProvider?.Invoke(); } @@ -166,6 +169,13 @@ public virtual async ValueTask DisposeAsync() GC.SuppressFinalize(this); } + /// + /// Adjusts the configuration the backend container is created from, immediately before it is created. Use + /// it for values only known at start time (for example the address of a dependency the module started + /// first). The default returns unchanged. + /// + protected virtual IContainerConfiguration ConfigureForStart(IContainerConfiguration configuration) => configuration; + /// /// Snapshots the configuration with the resolved container name so the backend creates the container /// under the name this instance reports. diff --git a/src/src/Core/IContainer.cs b/src/src/Core/IContainer.cs index a88dee5..98e98de 100644 --- a/src/src/Core/IContainer.cs +++ b/src/src/Core/IContainer.cs @@ -15,6 +15,13 @@ public interface IContainer : IConnectionStringProvider, IAsyncDisposable /// The image the container was created from. string Image { get; } + /// + /// The container's bridge-network IP address, available after start. Other containers on the same backend + /// reach this one at that address (there is no container-name DNS). null before start, or when the + /// container has no network (). + /// + string? NetworkIp { get; } + /// Starts the container: ensures the image, creates and starts the init process. Task StartAsync(CancellationToken cancellationToken = default); diff --git a/src/src/Docker/DockerContainer.cs b/src/src/Docker/DockerContainer.cs index 211822e..448c240 100644 --- a/src/src/Docker/DockerContainer.cs +++ b/src/src/Docker/DockerContainer.cs @@ -38,15 +38,21 @@ internal DockerContainer(IContainerConfiguration configuration, TcContainer cont /// public string Image => _configuration.Image; + /// + public string? NetworkIp { get; private set; } + /// public async Task StartAsync(CancellationToken cancellationToken = default) { ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) == 1, this); await Handle.StartAsync(cancellationToken).ConfigureAwait(false); + // Testcontainers reports an unspecified address (or none) when the container has no network. + NetworkIp = Handle.IpAddress is { Length: > 0 } ip && ip != "0.0.0.0" ? ip : null; + if (_configuration.WaitStrategies.Count > 0) { - WaitContext context = new(this, GetMappedPublicPorts(), networkIp: null); + WaitContext context = new(this, GetMappedPublicPorts(), NetworkIp); await WaitStrategyRunner .RunAsync(context, _configuration.WaitStrategies, _configuration.StartupTimeout, cancellationToken) .ConfigureAwait(false); diff --git a/src/src/MsSql/MsSqlBuilder.cs b/src/src/MsSql/MsSqlBuilder.cs index 91acfaf..4a9d0b3 100644 --- a/src/src/MsSql/MsSqlBuilder.cs +++ b/src/src/MsSql/MsSqlBuilder.cs @@ -12,7 +12,7 @@ public class MsSqlBuilder : ContainerBuilderDefault image. - public const string MsSqlImage = "mcr.microsoft.com/mssql/server:2022-latest"; + public const string MsSqlImage = "mcr.microsoft.com/mssql/server:2025-latest"; Secret _password = Secret.From("YourStrong!Passw0rd"); string _database = "master"; diff --git a/src/src/MsSql/Sdk/README.md b/src/src/MsSql/Sdk/README.md index 15a2083..7f16f11 100644 --- a/src/src/MsSql/Sdk/README.md +++ b/src/src/MsSql/Sdk/README.md @@ -45,7 +45,7 @@ await connection.OpenAsync(); | Member | Purpose | | -- | -- | -| `MsSqlBuilder()` / `MsSqlBuilder(string image)` | Default image `mcr.microsoft.com/mssql/server:2022-latest`, or a custom image. | +| `MsSqlBuilder()` / `MsSqlBuilder(string image)` | Default image `mcr.microsoft.com/mssql/server:2025-latest`, or a custom image. | | `MsSqlBuilder.MsSqlPort` (1433) | Container port, mapped to a random host port. | | `WithPassword(string)` | Sets `MSSQL_SA_PASSWORD` (default `YourStrong!Passw0rd`); stored as a redacted `Secret`. | | `WithDatabase(string)` | Sets the initial catalog the connection string points at (default `master`, matching Testcontainers). The database must already exist. | diff --git a/src/src/Wsl/WslContainer.cs b/src/src/Wsl/WslContainer.cs index 1236a3b..1eee2ac 100644 --- a/src/src/Wsl/WslContainer.cs +++ b/src/src/Wsl/WslContainer.cs @@ -55,6 +55,9 @@ public WslContainer(ContainerConfiguration configuration, IContainerRuntime runt /// public string Image => Configuration.Image; + /// + public string? NetworkIp => _networkIp; + /// public virtual async Task StartAsync(CancellationToken cancellationToken = default) { diff --git a/src/tests/AzureServiceBusEmulator.IntegrationTests/AzureServiceBusEmulator.IntegrationTests.csproj b/src/tests/AzureServiceBusEmulator.IntegrationTests/AzureServiceBusEmulator.IntegrationTests.csproj new file mode 100644 index 0000000..7267771 --- /dev/null +++ b/src/tests/AzureServiceBusEmulator.IntegrationTests/AzureServiceBusEmulator.IntegrationTests.csproj @@ -0,0 +1,5 @@ + + + + + diff --git a/src/tests/AzureServiceBusEmulator.IntegrationTests/AzureServiceBusEmulatorIntegrationTests.cs b/src/tests/AzureServiceBusEmulator.IntegrationTests/AzureServiceBusEmulatorIntegrationTests.cs new file mode 100644 index 0000000..0a10628 --- /dev/null +++ b/src/tests/AzureServiceBusEmulator.IntegrationTests/AzureServiceBusEmulatorIntegrationTests.cs @@ -0,0 +1,125 @@ +using System.Globalization; +using Azure.Messaging.ServiceBus; +using Azure.Messaging.ServiceBus.Administration; + +namespace Purview.Containers.AzureServiceBusEmulator; + +// Each test starts the emulator and its SQL Server sidecar, so the suite runs serially to keep the +// session VM (or Docker daemon) from starting several SQL Server instances at once. +[NotInParallel] +public class AzureServiceBusEmulatorIntegrationTests +{ + const string QueueName = "integration-queue"; + + const string Config = """ + { + "UserConfig": { + "Namespaces": [ + { + "Name": "sbemulatorns", + "Queues": [ + { + "Name": "integration-queue", + "Properties": { + "DeadLetteringOnMessageExpiration": false, + "DefaultMessageTimeToLive": "PT1H", + "DuplicateDetectionHistoryTimeWindow": "PT20S", + "ForwardDeadLetteredMessagesTo": "", + "ForwardTo": "", + "LockDuration": "PT1M", + "MaxDeliveryCount": 3, + "RequiresDuplicateDetection": false, + "RequiresSession": false + } + } + ], + "Topics": [] + } + ], + "Logging": { "Type": "File" } + } + } + """; + + [Test] + public async Task Emulator_SendsAndReceivesAMessage() + { + await WslcTest.SkipIfUnavailableAsync(); + + await using var emulator = new AzureServiceBusEmulatorBuilder().AcceptLicense().WithConfig(Config).Build(); + + await emulator.StartAsync(); + + await using ServiceBusClient client = new(emulator.GetConnectionString()); + await using var sender = client.CreateSender(QueueName); + await sender.SendMessageAsync(new ServiceBusMessage("hello")); + + await using var receiver = client.CreateReceiver(QueueName); + var message = await receiver.ReceiveMessageAsync(TimeSpan.FromSeconds(30)); + + await Assert.That(message).IsNotNull(); + await Assert.That(message!.Body.ToString()).IsEqualTo("hello"); + } + + [Test] + public async Task Emulator_ManagementConnectionString_ReachesTheAdministrationAPI() + { + await WslcTest.SkipIfUnavailableAsync(); + + await using var emulator = new AzureServiceBusEmulatorBuilder().AcceptLicense().WithConfig(Config).Build(); + + await emulator.StartAsync(); + + ServiceBusAdministrationClient administration = new(emulator.GetManagementConnectionString()); + var exists = await administration.QueueExistsAsync(QueueName); + + await Assert.That(exists.Value).IsTrue(); + } + + [Test] + public async Task Emulator_QueueCreatedAtRuntime_SendsAndReceives() + { + await WslcTest.SkipIfUnavailableAsync(); + + await using var emulator = new AzureServiceBusEmulatorBuilder().AcceptLicense().Build(); + + await emulator.StartAsync(); + + // A queue per test is how one shared emulator isolates concurrent tests. + var queueName = $"runtime-{Guid.NewGuid():N}"; + ServiceBusAdministrationClient administration = new(emulator.GetManagementConnectionString()); + await administration.CreateQueueAsync(queueName); + + await using ServiceBusClient client = new(emulator.GetConnectionString()); + await using var sender = client.CreateSender(queueName); + await sender.SendMessageAsync(new ServiceBusMessage("runtime")); + + await using var receiver = client.CreateReceiver(queueName); + var message = await receiver.ReceiveMessageAsync(TimeSpan.FromSeconds(30)); + + await Assert.That(message).IsNotNull(); + await Assert.That(message!.Body.ToString()).IsEqualTo("runtime"); + } + + [Test] + public async Task ConnectionStrings_UseTheMappedPorts() + { + await WslcTest.SkipIfUnavailableAsync(); + + await using var emulator = new AzureServiceBusEmulatorBuilder().AcceptLicense().Build(); + + await emulator.StartAsync(); + + var amqpPort = emulator.GetMappedPublicPort(AzureServiceBusEmulatorBuilder.AmqpPort); + var managementPort = emulator.GetMappedPublicPort(AzureServiceBusEmulatorBuilder.ManagementPort); + + await Assert + .That(emulator.GetConnectionString()) + .Contains($"Endpoint=sb://127.0.0.1:{amqpPort.ToString(CultureInfo.InvariantCulture)};"); + await Assert.That(emulator.GetConnectionString()).Contains("UseDevelopmentEmulator=true"); + await Assert + .That(emulator.GetManagementConnectionString()) + .Contains($"Endpoint=sb://127.0.0.1:{managementPort.ToString(CultureInfo.InvariantCulture)};"); + await Assert.That(emulator.SqlServer.NetworkIp).IsNotNull(); + } +} diff --git a/src/tests/AzureServiceBusEmulator.IntegrationTests/SharedServiceBusEmulator.cs b/src/tests/AzureServiceBusEmulator.IntegrationTests/SharedServiceBusEmulator.cs new file mode 100644 index 0000000..49047cb --- /dev/null +++ b/src/tests/AzureServiceBusEmulator.IntegrationTests/SharedServiceBusEmulator.cs @@ -0,0 +1,89 @@ +using Azure.Messaging.ServiceBus; +using Azure.Messaging.ServiceBus.Administration; +using Purview.Containers.Wsl; +using TUnit.Core.Interfaces; + +namespace Purview.Containers.AzureServiceBusEmulator; + +/// +/// One emulator for the whole test session, shared through +/// [ClassDataSource<SharedServiceBusEmulator>(Shared = SharedType.PerTestSession)]. The emulator +/// drops and recreates its fixed SQL Server databases on start, so instances cannot share a database +/// server; tests share one instance instead and isolate themselves with their own entities. +/// +public sealed class SharedServiceBusEmulator : IAsyncInitializer, IAsyncDisposable +{ + AzureServiceBusEmulatorContainer? _emulator; + + /// The started emulator. Tests call first. + public AzureServiceBusEmulatorContainer Emulator => + _emulator + ?? throw new InvalidOperationException("WSL Containers is unavailable; the emulator was not started."); + + /// Client for the AMQP endpoint, shared by every test. + public ServiceBusClient Client { get; private set; } = default!; + + /// Client for the management endpoint, used to create and delete per-test entities. + public ServiceBusAdministrationClient Administration { get; private set; } = default!; + + /// + public async Task InitializeAsync() + { + // Start only on a usable host, so the tests themselves skip (or fail under + // PURVIEW_CONTAINERS_REQUIRE_BACKEND) instead of the fixture failing every test. + WslcTest.EnsureBackendRegistered(); + var info = await WslContainerRuntime.Instance.GetInfoAsync(); + if (!info.IsAvailable || !info.IsCompatible) + { + return; + } + + _emulator = new AzureServiceBusEmulatorBuilder().AcceptLicense().Build(); + await _emulator.StartAsync(); + + Client = new ServiceBusClient(_emulator.GetConnectionString()); + Administration = new ServiceBusAdministrationClient(_emulator.GetManagementConnectionString()); + } + + /// Creates a uniquely named queue for one test; dispose the result to delete it. + public async Task CreateQueueAsync(string prefix = "q") + { + var name = UniqueName(prefix); + await Administration.CreateQueueAsync(name); + return new TestEntity(name, () => Administration.DeleteQueueAsync(name)); + } + + /// Creates a uniquely named topic with one subscription; dispose the result to delete both. + public async Task CreateTopicAsync(string subscription, string prefix = "t") + { + var name = UniqueName(prefix); + await Administration.CreateTopicAsync(name); + await Administration.CreateSubscriptionAsync(name, subscription); + return new TestEntity(name, () => Administration.DeleteTopicAsync(name)); + } + + /// + public async ValueTask DisposeAsync() + { + if (_emulator is null) + { + return; + } + + await Client.DisposeAsync(); + await _emulator.DisposeAsync(); + } + + // Short names: a GUID keeps them unique without approaching the entity name length limit. + static string UniqueName(string prefix) => $"{prefix}-{Guid.NewGuid():N}"; + + /// A per-test entity, deleted on dispose so the namespace stays under its entity quota. + public sealed class TestEntity(string name, Func delete) : IAsyncDisposable + { + /// The entity name. + public string Name { get; } = name; + + /// + public async ValueTask DisposeAsync() => await delete(); + } +} diff --git a/src/tests/AzureServiceBusEmulator.IntegrationTests/SharedServiceBusEmulatorTests.cs b/src/tests/AzureServiceBusEmulator.IntegrationTests/SharedServiceBusEmulatorTests.cs new file mode 100644 index 0000000..ed82503 --- /dev/null +++ b/src/tests/AzureServiceBusEmulator.IntegrationTests/SharedServiceBusEmulatorTests.cs @@ -0,0 +1,50 @@ +using Azure.Messaging.ServiceBus; + +namespace Purview.Containers.AzureServiceBusEmulator; + +// These tests deliberately run in parallel against one emulator: each owns its entities, so they never +// see each other's messages. This is the pattern the package README recommends. +[ClassDataSource(Shared = SharedType.PerTestSession)] +public class SharedServiceBusEmulatorTests(SharedServiceBusEmulator serviceBus) +{ + [Test] + [Arguments(1)] + [Arguments(2)] + [Arguments(3)] + [Arguments(4)] + public async Task SharedEmulator_QueuePerTest_ReceivesOnlyItsOwnMessages(int testNumber) + { + await WslcTest.SkipIfUnavailableAsync(); + + await using var queue = await serviceBus.CreateQueueAsync(); + await using var sender = serviceBus.Client.CreateSender(queue.Name); + await using var receiver = serviceBus.Client.CreateReceiver(queue.Name); + + var body = $"message-{testNumber}"; + await sender.SendMessageAsync(new ServiceBusMessage(body)); + + var message = await receiver.ReceiveMessageAsync(TimeSpan.FromSeconds(30)); + var unexpected = await receiver.ReceiveMessageAsync(TimeSpan.FromSeconds(2)); + + await Assert.That(message).IsNotNull(); + await Assert.That(message!.Body.ToString()).IsEqualTo(body); + await Assert.That(unexpected).IsNull(); + } + + [Test] + public async Task SharedEmulator_TopicPerTest_DeliversToItsSubscription() + { + await WslcTest.SkipIfUnavailableAsync(); + + await using var topic = await serviceBus.CreateTopicAsync("all"); + await using var sender = serviceBus.Client.CreateSender(topic.Name); + await using var receiver = serviceBus.Client.CreateReceiver(topic.Name, "all"); + + await sender.SendMessageAsync(new ServiceBusMessage("published")); + + var message = await receiver.ReceiveMessageAsync(TimeSpan.FromSeconds(30)); + + await Assert.That(message).IsNotNull(); + await Assert.That(message!.Body.ToString()).IsEqualTo("published"); + } +} diff --git a/src/tests/AzureServiceBusEmulator.UnitTests/AzureServiceBusEmulator.UnitTests.csproj b/src/tests/AzureServiceBusEmulator.UnitTests/AzureServiceBusEmulator.UnitTests.csproj new file mode 100644 index 0000000..e6c35ec --- /dev/null +++ b/src/tests/AzureServiceBusEmulator.UnitTests/AzureServiceBusEmulator.UnitTests.csproj @@ -0,0 +1 @@ + diff --git a/src/tests/AzureServiceBusEmulator.UnitTests/AzureServiceBusEmulatorBuilderTests.cs b/src/tests/AzureServiceBusEmulator.UnitTests/AzureServiceBusEmulatorBuilderTests.cs new file mode 100644 index 0000000..94ab4ab --- /dev/null +++ b/src/tests/AzureServiceBusEmulator.UnitTests/AzureServiceBusEmulatorBuilderTests.cs @@ -0,0 +1,105 @@ +using Purview.Containers.Runtime; +using Purview.Containers.Waiting; + +namespace Purview.Containers.AzureServiceBusEmulator; + +public class AzureServiceBusEmulatorBuilderTests +{ + [Test] + public async Task BuildConfig_AppliesDefaults() + { + var configuration = new AzureServiceBusEmulatorBuilder().AcceptLicense().BuildConfigurationForTesting(); + + await Assert.That(configuration.Image).IsEqualTo(AzureServiceBusEmulatorBuilder.EmulatorImage); + await Assert.That(configuration.PortBindings.Count).IsEqualTo(2); + await Assert.That(configuration.PortBindings).Contains(p => p.ContainerPort == 5672 && p.AssignRandomHostPort); + await Assert.That(configuration.PortBindings).Contains(p => p.ContainerPort == 5300 && p.AssignRandomHostPort); + await Assert.That(configuration.Environment["ACCEPT_EULA"]).IsEqualTo("Y"); + await Assert.That(configuration.Environment["SQL_WAIT_INTERVAL"]).IsEqualTo("0"); + await Assert + .That(configuration.Environment["MSSQL_SA_PASSWORD"]) + .IsEqualTo(AzureServiceBusEmulatorBuilder.DefaultSqlServerPassword); + await Assert.That(configuration.SqlServerImage).IsEqualTo(AzureServiceBusEmulatorBuilder.DefaultSqlServerImage); + await Assert.That(configuration.ConfigJson).IsNull(); + await Assert.That(configuration.WaitStrategies.Count).IsEqualTo(1); + await Assert.That(configuration.WaitStrategies[0]).IsTypeOf(); + } + + [Test] + public async Task Build_WithoutLicenseAcceptance_Throws() + { + AzureServiceBusEmulatorBuilder builder = new(); + + await Assert.That(() => builder.Build()).Throws(); + } + + [Test] + public async Task Build_WithAShortSqlServerPassword_Throws() + { + var builder = new AzureServiceBusEmulatorBuilder().AcceptLicense().WithSqlServerPassword("short"); + + await Assert.That(() => builder.Build()).Throws(); + } + + [Test] + public async Task BuildConfig_SqlServerSettings_AreHonoured() + { + var configuration = new AzureServiceBusEmulatorBuilder() + .AcceptLicense() + .WithSqlServerImage("mcr.microsoft.com/mssql/server:2025-CU9-ubuntu-24.04") + .WithSqlServerPassword("Another!Passw0rd") + .BuildConfigurationForTesting(); + + await Assert + .That(configuration.SqlServerImage) + .IsEqualTo("mcr.microsoft.com/mssql/server:2025-CU9-ubuntu-24.04"); + await Assert.That(configuration.SqlServerPassword.Value).IsEqualTo("Another!Passw0rd"); + await Assert.That(configuration.Environment["MSSQL_SA_PASSWORD"]).IsEqualTo("Another!Passw0rd"); + } + + [Test] + public async Task BuildConfig_Config_IsCarried() + { + const string json = """{ "UserConfig": { "Namespaces": [] } }"""; + + var configuration = new AzureServiceBusEmulatorBuilder() + .AcceptLicense() + .WithConfig(json) + .BuildConfigurationForTesting(); + + await Assert.That(configuration.ConfigJson).IsEqualTo(json); + } + + [Test] + public async Task WithConfigFile_MissingFile_Throws() + { + AzureServiceBusEmulatorBuilder builder = new(); + + await Assert + .That(() => builder.WithConfigFile(Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid():N}.json"))) + .Throws(); + } + + [Test] + public async Task ToString_RedactsTheSqlServerPassword() + { + var configuration = new AzureServiceBusEmulatorBuilder() + .AcceptLicense() + .WithSqlServerPassword("Another!Passw0rd") + .BuildConfigurationForTesting(); + + await Assert.That(configuration.ToString()).DoesNotContain("Another!Passw0rd"); + } + + [Test] + public async Task BuildConfig_PreservesCustomWaitStrategy() + { + var configuration = new AzureServiceBusEmulatorBuilder() + .AcceptLicense() + .WithWaitStrategy(Wait.ForTcpPort(5672)) + .BuildConfigurationForTesting(); + + await Assert.That(configuration.WaitStrategies.Count).IsEqualTo(1); + await Assert.That(configuration.WaitStrategies[0]).IsTypeOf(); + } +} diff --git a/src/tests/Azurite.IntegrationTests/Azurite.IntegrationTests.csproj b/src/tests/Azurite.IntegrationTests/Azurite.IntegrationTests.csproj index e6c35ec..5500ad3 100644 --- a/src/tests/Azurite.IntegrationTests/Azurite.IntegrationTests.csproj +++ b/src/tests/Azurite.IntegrationTests/Azurite.IntegrationTests.csproj @@ -1 +1,5 @@ - + + + + + diff --git a/src/tests/Azurite.IntegrationTests/AzuriteIntegrationTests.cs b/src/tests/Azurite.IntegrationTests/AzuriteIntegrationTests.cs index 4c07c9c..4ae9ea7 100644 --- a/src/tests/Azurite.IntegrationTests/AzuriteIntegrationTests.cs +++ b/src/tests/Azurite.IntegrationTests/AzuriteIntegrationTests.cs @@ -1,8 +1,32 @@ +using Azure.Storage.Blobs; + namespace Purview.Containers.Azurite; [NotInParallel] public class AzuriteIntegrationTests { + [Test] + public async Task ConnectionString_AuthenticatesWithTheWellKnownAccountKey() + { + await WslcTest.SkipIfUnavailableAsync(); + + await using var azurite = new AzuriteBuilder().Build(); + + await azurite.StartAsync(); + + // Creating a container is a shared-key-signed request: Azurite rejects it unless the key is right. + // The service version is pinned because the SDK can be ahead of what the Azurite image supports. + BlobServiceClient client = new( + azurite.GetConnectionString(), + new BlobClientOptions(BlobClientOptions.ServiceVersion.V2024_11_04) + ); + var container = await client.CreateBlobContainerAsync("authenticated"); + + var exists = await container.Value.ExistsAsync(); + + await Assert.That(exists.Value).IsTrue(); + } + [Test] public async Task Azurite_StartsAndExposesEndpoints() { diff --git a/src/tests/Modules.Docker.IntegrationTests/ModuleDockerTests.cs b/src/tests/Modules.Docker.IntegrationTests/ModuleDockerTests.cs index 62977c8..d7cb7a9 100644 --- a/src/tests/Modules.Docker.IntegrationTests/ModuleDockerTests.cs +++ b/src/tests/Modules.Docker.IntegrationTests/ModuleDockerTests.cs @@ -1,4 +1,6 @@ using System.Globalization; +using Azure.Messaging.ServiceBus; +using Azure.Storage.Blobs; using Purview.Containers.Docker; using TUnit.Core.Exceptions; @@ -134,6 +136,48 @@ public async Task Azurite_StartsAndMapsItsPort() await Assert.That(azurite.GetBlobEndpoint().Port).IsGreaterThan(0); } + [Test] + public async Task Azurite_AuthenticatesWithTheWellKnownAccountKey() + { + await SkipIfUnavailableAsync(); + + await using var azurite = new Azurite.AzuriteBuilder().Build(); + + await AssertStartedAsync(azurite, Azurite.AzuriteBuilder.BlobPort); + + // Creating a container is a shared-key-signed request: Azurite rejects it unless the key is right. + // The service version is pinned because the SDK can be ahead of what the Azurite image supports. + BlobServiceClient client = new( + azurite.GetConnectionString(), + new BlobClientOptions(BlobClientOptions.ServiceVersion.V2024_11_04) + ); + var container = await client.CreateBlobContainerAsync("authenticated"); + var exists = await container.Value.ExistsAsync(); + + await Assert.That(exists.Value).IsTrue(); + } + + [Test] + public async Task AzureServiceBusEmulator_SendsAndReceivesAMessage() + { + await SkipIfUnavailableAsync(); + + // No custom Config.json: the image's default configuration declares the queue 'queue.1'. + await using var emulator = new AzureServiceBusEmulator.AzureServiceBusEmulatorBuilder().AcceptLicense().Build(); + + await AssertStartedAsync(emulator, AzureServiceBusEmulator.AzureServiceBusEmulatorBuilder.AmqpPort); + + await using ServiceBusClient client = new(emulator.GetConnectionString()); + await using var sender = client.CreateSender("queue.1"); + await sender.SendMessageAsync(new ServiceBusMessage("hello")); + + await using var receiver = client.CreateReceiver("queue.1"); + var message = await receiver.ReceiveMessageAsync(TimeSpan.FromSeconds(30)); + + await Assert.That(message).IsNotNull(); + await Assert.That(message!.Body.ToString()).IsEqualTo("hello"); + } + [Test] public async Task Nats_StartsAndMapsItsPort() { diff --git a/src/tests/Modules.Docker.IntegrationTests/Modules.Docker.IntegrationTests.csproj b/src/tests/Modules.Docker.IntegrationTests/Modules.Docker.IntegrationTests.csproj index 55b1138..4548b03 100644 --- a/src/tests/Modules.Docker.IntegrationTests/Modules.Docker.IntegrationTests.csproj +++ b/src/tests/Modules.Docker.IntegrationTests/Modules.Docker.IntegrationTests.csproj @@ -11,6 +11,7 @@ + @@ -21,6 +22,11 @@ + + + + +