diff --git a/.github/workflows/integration-wsl.yml b/.github/workflows/integration-wsl.yml index 22f8275..a4a5007 100644 --- a/.github/workflows/integration-wsl.yml +++ b/.github/workflows/integration-wsl.yml @@ -65,6 +65,7 @@ jobs: shell: pwsh run: | $projects = @( + 'src/tests/AzureKeyVaultEmulator.IntegrationTests/AzureKeyVaultEmulator.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/.github/workflows/pr.yml b/.github/workflows/pr.yml index cb9c684..237cd14 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -24,7 +24,7 @@ jobs: # Mirrors Build:TestFilter in purview-build.json: the SDK categorises unit test projects as Unit, # so the WSLC integration suites are never selected and the runner needs no WSLC host. test-filter: "/*/*/*/*[Category=Unit]" - # All eight library projects are packable, so pack and validate the produced packages. + # All library projects are packable, so pack and validate the produced packages. run-pack: true validate-pack: true secrets: inherit @@ -46,4 +46,4 @@ jobs: - name: Docker backend run: dotnet test src/tests/Docker.IntegrationTests/Docker.IntegrationTests.csproj --configuration Release --treenode-filter '/*/*/*/*' - name: Service modules on Docker - run: dotnet test src/tests/Modules.DockerIntegrationTests/Modules.DockerIntegrationTests.csproj --configuration Release --treenode-filter '/*/*/*/*' + run: dotnet test src/tests/Modules.Docker.IntegrationTests/Modules.Docker.IntegrationTests.csproj --configuration Release --treenode-filter '/*/*/*/*' diff --git a/AGENTS.md b/AGENTS.md index 5d7c890..9f9603f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,7 +5,7 @@ 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`). +`Redis`, `MsSql`, `RabbitMq`, `Azurite`, `Nats`, `MySql`, `AzureKeyVaultEmulator`). `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 641e840..589ea84 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -12,6 +12,10 @@ + + + + diff --git a/README.md b/README.md index aacf2e4..6abebb7 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 **seven service -> modules** (PostgreSQL, Redis, SQL Server, RabbitMQ, Azurite, NATS, MySQL). On WSLC, **images are shared +> 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 > 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 @@ -146,7 +146,7 @@ src/ Docker/ Docker backend (Purview.Containers.Docker) Containers/ umbrella package (Purview.Containers): Core + both backends PostgreSql/ Redis/ MsSql/ MySql/ - RabbitMq/ Azurite/ Nats/ service modules + RabbitMq/ Azurite/ Nats/ AzureKeyVaultEmulator/ service modules tests/ TUnit unit + integration projects (WSLC and Docker suites) samples/ getting-started/ runnable samples (WslSample, DockerSample, AutoSample) @@ -176,7 +176,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 seven service modules work today: +All eight service modules work today: ```csharp await using var postgres = new PostgreSqlBuilder() @@ -234,6 +234,16 @@ string connectionString = azurite.GetConnectionString(); Uri blob = azurite.GetBlobEndpoint(); ``` +```csharp +await using var keyVault = new AzureKeyVaultEmulatorBuilder().Build(); + +await keyVault.StartAsync(); // waits for the HTTPS listener + +// Clients are already wired for the emulator (pinned certificate, emulated credential). +var secrets = keyVault.GetSecretClient(); +await secrets.SetSecretAsync("mySecret", "myValue"); +``` + ## 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 54672eb..e693feb 100644 --- a/docs/wiki/Backends.md +++ b/docs/wiki/Backends.md @@ -1,7 +1,8 @@ # Backends: WSL Containers or Docker `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`), and +modules (`Purview.Containers.PostgreSql`, `Redis`, `MsSql`, `RabbitMq`, `Azurite`, `Nats`, `MySql`, +`AzureKeyVaultEmulator`), 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/Consumer-Requirements.md b/docs/wiki/Consumer-Requirements.md index cf1bb1f..6bba3e4 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`) is backend-neutral and does **not** bring a backend, so reference the module **and** +> `MySql`, `AzureKeyVaultEmulator`) 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 eaac378..fa566aa 100644 --- a/docs/wiki/Home.md +++ b/docs/wiki/Home.md @@ -39,6 +39,7 @@ This wiki is the project documentation hub. The packages are published under the | `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. | ## Feature highlights diff --git a/docs/wiki/Modules.md b/docs/wiki/Modules.md index df4ac4f..6c2caf3 100644 --- a/docs/wiki/Modules.md +++ b/docs/wiki/Modules.md @@ -139,6 +139,7 @@ that leans on the Testcontainers modules ports across unchanged: | RabbitMQ | `amqp://user:pass@host:port/vhost` | `GetAmqpEndpoint()`, `GetManagementEndpoint()` | | 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()` | ## Module status @@ -151,6 +152,7 @@ that leans on the Testcontainers modules ports across unchanged: | 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 | | 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 | 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/Testing.md b/docs/wiki/Testing.md index 329d114..3c75277 100644 --- a/docs/wiki/Testing.md +++ b/docs/wiki/Testing.md @@ -34,14 +34,14 @@ 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.DockerIntegrationTests` for the seven +(`Docker.IntegrationTests` for the container contract, `Modules.DockerIntegrationTests` for the eight service modules) skip themselves when no daemon is reachable, and the WSLC suites skip themselves when the host lacks the WSL Containers components. See [Backends: WSLC or Docker](Backends.md) for consumer-facing setup and CI examples. ```powershell just test src/tests/Docker.IntegrationTests/Docker.IntegrationTests.csproj # Docker contract -just test src/tests/Modules.DockerIntegrationTests/Modules.DockerIntegrationTests.csproj # the seven modules on Docker +just test src/tests/Modules.DockerIntegrationTests/Modules.DockerIntegrationTests.csproj # the eight modules on Docker ``` ## Why test modules run serially @@ -74,8 +74,8 @@ gh workflow run "Integration (WSL Containers)" --ref main gh run watch ``` -It runs `Wsl.IntegrationTests` and then the seven service-module suites (`PostgreSql`, `Redis`, `MsSql`, -`MySql`, `RabbitMq`, `Azurite`, `Nats`) one project at a time, so the shared WSLC image store is never +It runs `Wsl.IntegrationTests` and then the eight service-module suites (`AzureKeyVaultEmulator`, +`PostgreSql`, `Redis`, `MsSql`, `MySql`, `RabbitMq`, `Azurite`, `Nats`) one project at a time, so the shared WSLC image store is never contended. Two optional inputs: `ref` (a branch, tag or SHA other than the selected one) and `filter` (a TUnit treenode filter, defaulting to every test). diff --git a/docs/wiki/Using-in-Your-Tests.md b/docs/wiki/Using-in-Your-Tests.md index 6beddd1..3107d46 100644 --- a/docs/wiki/Using-in-Your-Tests.md +++ b/docs/wiki/Using-in-Your-Tests.md @@ -112,8 +112,8 @@ diagnostics. - The abstractions and every service module are portable `net10.0`; see [Consumer Requirements](Consumer-Requirements.md) for the exact target-framework contract, the `PCC0001`/`PCC0002` guards and the `EnableWindowsTargeting` workaround for non-Windows build agents. -- The per-module connection-string shapes (Redis, PostgreSQL, SQL Server, MySQL, RabbitMQ, Azurite, NATS) - are in [Modules](Modules.md#connection-strings). +- The per-module connection-string shapes (Redis, PostgreSQL, SQL Server, MySQL, RabbitMQ, Azurite, NATS, + Azure Key Vault Emulator) are in [Modules](Modules.md#connection-strings). - A live sample of this shape is `samples/getting-started/AutoSample` (`just sample-auto`). ## Related diff --git a/package.json b/package.json index fabfbb3..1b0a74b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "purview-containers", - "version": "1.0.0-prerelease.5", + "version": "1.0.0-prerelease.6", "license": "MIT", "author": { "name": "Kieron Lanning", diff --git a/purview-build.json b/purview-build.json index ff82b02..5f36156 100644 --- a/purview-build.json +++ b/purview-build.json @@ -55,6 +55,12 @@ "payload/win-x64/*", "payload/win-arm64/*" ], + "purview.containers.azurekeyvaultemulator": [ + "lib/$(TFM)/Purview.Containers.AzureKeyVaultEmulator.dll", + "lib/$(TFM)/Purview.Containers.AzureKeyVaultEmulator.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/src/Containers.slnx b/src/Containers.slnx index 6b9ec5c..0121ba7 100644 --- a/src/Containers.slnx +++ b/src/Containers.slnx @@ -10,6 +10,7 @@ + @@ -30,11 +31,13 @@ + + - + diff --git a/src/src/AzureKeyVaultEmulator/AzureKeyVaultEmulator.csproj b/src/src/AzureKeyVaultEmulator/AzureKeyVaultEmulator.csproj new file mode 100644 index 0000000..06e5690 --- /dev/null +++ b/src/src/AzureKeyVaultEmulator/AzureKeyVaultEmulator.csproj @@ -0,0 +1,20 @@ + + + true + Azure Key Vault Emulator module for Purview.Containers: throwaway vault emulators (secrets, keys, certificates) on WSL Containers or Docker. + wsl;containers;docker;keyvault;azure;emulator;testing;integration + MIT + Purview + + + + + + + + + + + + + diff --git a/src/src/AzureKeyVaultEmulator/AzureKeyVaultEmulatorBuilder.cs b/src/src/AzureKeyVaultEmulator/AzureKeyVaultEmulatorBuilder.cs new file mode 100644 index 0000000..abd0dce --- /dev/null +++ b/src/src/AzureKeyVaultEmulator/AzureKeyVaultEmulatorBuilder.cs @@ -0,0 +1,249 @@ +using System.Runtime.InteropServices; +using Purview.Containers.Mounts; +using Purview.Containers.Networking; +using Purview.Containers.Runtime; +using Purview.Containers.Waiting; + +namespace Purview.Containers.AzureKeyVaultEmulator; + +/// +/// Fluent builder for an Azure Key Vault +/// Emulator test container. The emulator serves the full Key Vault REST API (secrets, keys, +/// certificates) over HTTPS on a single port, so the Azure SDK clients work against it unchanged. +/// +public class AzureKeyVaultEmulatorBuilder + : ContainerBuilder +{ + /// Default emulator port (HTTPS only). + public const ushort EmulatorPort = 4997; + + /// Default image repository. + public const string EmulatorImage = "jamesgoulddev/azure-keyvault-emulator"; + + /// Default image tag (x64). The newest tag published to Docker Hub; latest tracks it. + public const string EmulatorTag = "3.1.3"; + + /// Default image tag (ARM64). + public const string EmulatorArmTag = "3.1.3-arm"; + + /// Container path the certificate directory is mounted at. + public const string CertificateMountPath = "/certs"; + + /// Password of the emulator PFX. Fixed by the image: Kestrel is configured with it up front. + public const string CertificatePassword = "emulator"; + + bool _persist; + bool _generateCertificates = true; + bool _installCertificatesIntoTrustStore = true; + bool _cleanupCertificatesOnDispose; + ushort? _fixedHostPort; + string? _certificateDirectory; + + /// Creates a builder with the default image. + public AzureKeyVaultEmulatorBuilder() + : this(DefaultImageReference) { } + + /// Creates a builder with a custom image. + public AzureKeyVaultEmulatorBuilder(string image) + { + ApplyDefaults(image); + } + + /// Creates a builder using an explicit backend. + public AzureKeyVaultEmulatorBuilder(IContainerBackend backend) + : base(backend) + { + ApplyDefaults(DefaultImageReference); + } + + /// The default image reference for this host's process architecture. + public static string DefaultImageReference => + RuntimeInformation.ProcessArchitecture == Architecture.Arm64 + ? $"{EmulatorImage}:{EmulatorArmTag}" + : $"{EmulatorImage}:{EmulatorTag}"; + + /// + /// Persists vault data in an emulator.db next to the certificates. Requires + /// : the persisted data embeds the vault URI, so a random port would + /// leave it unreachable after a restart. + /// + public AzureKeyVaultEmulatorBuilder WithPersistence(bool persist = true) + { + _persist = persist; + return this; + } + + /// + /// Uses as the host certificate directory mounted at + /// . It must contain emulator.pfx (password + /// ) and emulator.crt; pair the two methods with + /// when the files must exist rather than be generated. + /// + public AzureKeyVaultEmulatorBuilder WithCertificateDirectory(string path) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + _certificateDirectory = path; + return this; + } + + /// + /// Generates a self-signed certificate when the certificate directory has none (default). Disable it to + /// require an explicit ; Build() throws otherwise. + /// + public AzureKeyVaultEmulatorBuilder WithGeneratedCertificates(bool generate = true) + { + _generateCertificates = generate; + return this; + } + + /// + /// Installs the certificates into the host trust store (default). Disable it when the test host must + /// not be modified: the module's own clients pin the certificate, so they never need the trust store. + /// + public AzureKeyVaultEmulatorBuilder WithTrustStoreInstallation(bool install = true) + { + _installCertificatesIntoTrustStore = install; + return this; + } + + /// Uninstalls and deletes the generated certificates on dispose. Default is to keep them. + public AzureKeyVaultEmulatorBuilder WithCertificateCleanup(bool cleanup = true) + { + _cleanupCertificatesOnDispose = cleanup; + return this; + } + + /// + /// Pins the host port the emulator is exposed on. Required with , + /// because the persisted data embeds the vault URI. + /// + public AzureKeyVaultEmulatorBuilder WithFixedHostPort(ushort hostPort) + { + if (hostPort == 0) + { + throw new ArgumentOutOfRangeException( + nameof(hostPort), + hostPort, + "Host port 0 is invalid; the emulator needs a real port." + ); + } + + _fixedHostPort = hostPort; + return this; + } + + /// Builds the immutable configuration (internal; used by the module's own tests). + internal AzureKeyVaultEmulatorConfiguration BuildConfigurationForTesting() => BuildConfiguration(); + + /// + protected override AzureKeyVaultEmulatorConfiguration BuildConfiguration() + { + var configuration = base.BuildConfiguration(); + + var certificateDirectory = _certificateDirectory ?? AzureKeyVaultEmulatorCertificates.DefaultDirectory; + List bindMounts = + [ + .. configuration.BindMounts, + new BindMount(certificateDirectory, CertificateMountPath, ReadOnly: true), + ]; + + Dictionary environment = new(configuration.Environment, StringComparer.Ordinal) + { + // Always sent so the image default can never surprise a test run. + ["Persist"] = _persist ? "true" : "false", + }; + + var waitStrategies = + configuration.WaitStrategies.Count > 0 + ? configuration.WaitStrategies + : new[] { (IWaitStrategy)BuildDefaultWaitStrategy() }; + + return configuration with + { + Environment = environment, + BindMounts = bindMounts, + PortBindings = ResolvePortBindings(configuration.PortBindings), + WaitStrategies = waitStrategies, + Persist = _persist, + CertificateDirectory = certificateDirectory, + GenerateCertificates = _generateCertificates, + InstallCertificatesIntoTrustStore = _installCertificatesIntoTrustStore, + CleanupCertificatesOnDispose = _cleanupCertificatesOnDispose, + }; + } + + /// + protected override void Validate(ContainerConfiguration configuration) + { + base.Validate(configuration); + + if (configuration is not AzureKeyVaultEmulatorConfiguration emulatorConfiguration) + { + return; + } + + if ( + emulatorConfiguration.Persist + && !emulatorConfiguration.PortBindings.Any(binding => + binding.ContainerPort == EmulatorPort && !binding.AssignRandomHostPort + ) + ) + { + throw new ContainerConfigurationException( + "Persistence requires a fixed host port: the emulator embeds the vault URI in its persisted data, so a random port would leave that data unreachable after a restart. Call WithFixedHostPort(...) before Build()." + ); + } + + if (!_generateCertificates && string.IsNullOrWhiteSpace(_certificateDirectory)) + { + throw new ContainerConfigurationException( + "Certificate generation is disabled but no certificate directory was supplied. Call WithCertificateDirectory(...) with a directory containing emulator.pfx (and emulator.crt)." + ); + } + } + + /// + protected override AzureKeyVaultEmulatorContainer CreateContainer( + AzureKeyVaultEmulatorConfiguration configuration + ) => new(configuration, Backend); + + WaitStrategy BuildDefaultWaitStrategy() + { + // GET / is served unauthenticated once Kestrel is listening; any HTTP response proves the HTTPS + // stack is up. AllowInsecureTls keeps the probe independent of the host trust store. + return Wait.ForHttp("/") + .ForPort(EmulatorPort) + .ForScheme("https") + .AllowInsecureTls() + .ForStatusPredicate(static _ => true) + .WithInterval(TimeSpan.FromSeconds(1)); + } + + IReadOnlyList ResolvePortBindings(IReadOnlyList bindings) + { + if (_fixedHostPort is not ushort hostPort) + { + return bindings; + } + + // Replace the module's default random binding rather than adding a second one. + return + [ + .. bindings.Select(binding => + binding.ContainerPort == EmulatorPort && binding.AssignRandomHostPort + ? binding with + { + HostPort = hostPort, + } + : binding + ), + ]; + } + + void ApplyDefaults(string image) + { + WithImage(image) + .WithPortBinding(EmulatorPort, assignRandomHostPort: true) + .WithConnectionStringProvider(new AzureKeyVaultEmulatorConnectionStringProvider()); + } +} diff --git a/src/src/AzureKeyVaultEmulator/AzureKeyVaultEmulatorCertificates.cs b/src/src/AzureKeyVaultEmulator/AzureKeyVaultEmulatorCertificates.cs new file mode 100644 index 0000000..a724502 --- /dev/null +++ b/src/src/AzureKeyVaultEmulator/AzureKeyVaultEmulatorCertificates.cs @@ -0,0 +1,266 @@ +using System.Diagnostics; +using System.Net; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using Purview.Containers.Runtime; + +namespace Purview.Containers.AzureKeyVaultEmulator; + +/// Generates, loads, installs and removes the emulator's self-signed certificate pair. +static class AzureKeyVaultEmulatorCertificates +{ + internal const string PfxFileName = "emulator.pfx"; + internal const string CrtFileName = "emulator.crt"; + + const string Subject = "CN=localhost"; + const string HostParentDirectory = "keyvaultemulator"; + const string HostChildDirectory = "certs"; + const string LinuxCaCertificateDirectory = "/usr/local/share/ca-certificates"; + const string ServerAuthenticationEnhancedKeyUsage = "1.3.6.1.5.5.7.3.1"; + + // The same variables the emulator's own TestContainers module detects, so the throwaway certificates + // stay out of a runner's user profile. + static readonly string[] ContinuousIntegrationVariables = ["BUILD_BUILDID", "CI", "GITHUB_ACTIONS", "TF_BUILD"]; + + internal static string DefaultDirectory { get; } = ResolveDefaultDirectory(); + + /// + /// Returns the certificate the emulator must be started with, generating it (when allowed) into the + /// configured directory. The generated flag reports whether this call created the pair. + /// + internal static (X509Certificate2 Certificate, bool Generated) EnsureCertificate( + AzureKeyVaultEmulatorConfiguration configuration + ) + { + ArgumentNullException.ThrowIfNull(configuration); + ArgumentException.ThrowIfNullOrWhiteSpace(configuration.CertificateDirectory); + + Directory.CreateDirectory(configuration.CertificateDirectory); + + var pfxPath = Path.Combine(configuration.CertificateDirectory, PfxFileName); + var crtPath = Path.Combine(configuration.CertificateDirectory, CrtFileName); + + if (File.Exists(pfxPath) && File.Exists(crtPath)) + { + return (LoadPfx(pfxPath), Generated: false); + } + + if (!configuration.GenerateCertificates) + { + throw new ContainerConfigurationException( + $"No certificate pair was found in '{configuration.CertificateDirectory}' and certificate generation is disabled. Provide '{PfxFileName}' (password '{AzureKeyVaultEmulatorBuilder.CertificatePassword}') and '{CrtFileName}', or re-enable generated certificates." + ); + } + + // Half of the pair has gone missing: remove both so the regenerated pair is consistent. + TryDelete(pfxPath); + TryDelete(crtPath); + + return (GenerateAndExport(pfxPath, crtPath), Generated: true); + } + + /// Installs the certificate into the host trust store so unmodified Azure SDK clients connect. + internal static void InstallIntoTrustStore(X509Certificate2 certificate, string certificateDirectory) + { + ArgumentNullException.ThrowIfNull(certificate); + + if (OperatingSystem.IsWindows()) + { + InstallIntoWindowsTrustStore(certificate); + return; + } + + if (OperatingSystem.IsLinux()) + { + InstallIntoLinuxTrustStore(certificate); + return; + } + + if (OperatingSystem.IsMacOS()) + { + // .NET cannot add a trust anchor to the macOS system keychain; tell the caller what to run. + Console.WriteLine("To install the emulator certificate into the macOS trust store, run:"); + Console.WriteLine( + $"sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain \"{Path.Combine(certificateDirectory, CrtFileName)}\"" + ); + } + } + + /// Removes the certificate from the trust store (Windows only; other platforms are untouched). + internal static void UninstallFromTrustStore(X509Certificate2 certificate) + { + ArgumentNullException.ThrowIfNull(certificate); + + if (!OperatingSystem.IsWindows()) + { + return; + } + + using X509Store store = new(StoreName.Root, StoreLocation.CurrentUser); + store.Open(OpenFlags.ReadWrite); + + var matches = store.Certificates.Find(X509FindType.FindByThumbprint, certificate.Thumbprint, validOnly: false); + foreach (var match in matches) + { + store.Remove(match); + match.Dispose(); + } + + store.Close(); + } + + /// Deletes a generated certificate pair. Caller-supplied directories are never touched. + internal static void DeleteCertificates(string certificateDirectory) + { + ArgumentException.ThrowIfNullOrWhiteSpace(certificateDirectory); + + TryDelete(Path.Combine(certificateDirectory, PfxFileName)); + TryDelete(Path.Combine(certificateDirectory, CrtFileName)); + } + + static void TryDelete(string path) + { + if (File.Exists(path)) + { + File.Delete(path); + } + } + + static string ResolveDefaultDirectory() + { + var path = Path.Combine(HostParentDirectory, HostChildDirectory); + + // CI runners are ephemeral: keep the throwaway certificates out of the user profile. + if (ContinuousIntegrationVariables.Any(name => !string.IsNullOrEmpty(Environment.GetEnvironmentVariable(name)))) + { + return Path.Combine(Path.GetTempPath(), path); + } + + var profile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + + return string.IsNullOrWhiteSpace(profile) + ? Path.Combine(Path.GetTempPath(), path) + : Path.Combine(profile, path); + } + + static X509Certificate2 LoadPfx(string pfxPath) + { + try + { + return X509CertificateLoader.LoadPkcs12FromFile(pfxPath, AzureKeyVaultEmulatorBuilder.CertificatePassword); + } + catch (CryptographicException exception) + { + throw new ContainerConfigurationException( + $"The certificate at '{pfxPath}' could not be loaded. The PFX password must be '{AzureKeyVaultEmulatorBuilder.CertificatePassword}'.", + exception + ); + } + } + + static X509Certificate2 GenerateAndExport(string pfxPath, string crtPath) + { + X500DistinguishedName subject = new(Subject); + using var key = RSA.Create(); + + CertificateRequest request = new(subject, key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + request.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, false)); + request.CertificateExtensions.Add(new X509SubjectKeyIdentifierExtension(request.PublicKey, false)); + request.CertificateExtensions.Add(BuildSubjectAlternativeNames()); + request.CertificateExtensions.Add( + new X509KeyUsageExtension( + X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment, + critical: true + ) + ); + request.CertificateExtensions.Add( + new X509EnhancedKeyUsageExtension([new Oid(ServerAuthenticationEnhancedKeyUsage)], critical: false) + ); + + var certificate = request.CreateSelfSigned( + DateTimeOffset.UtcNow.AddDays(-1), + DateTimeOffset.UtcNow.AddYears(1) + ); + + // FriendlyName assignment is only supported on Windows. + if (OperatingSystem.IsWindows()) + { + certificate.FriendlyName = "Azure Key Vault Emulator"; + } + + File.WriteAllBytes( + pfxPath, + certificate.Export(X509ContentType.Pfx, AzureKeyVaultEmulatorBuilder.CertificatePassword) + ); + File.WriteAllText(crtPath, ExportToPem(certificate)); + + return certificate; + } + + static X509Extension BuildSubjectAlternativeNames() + { + SubjectAlternativeNameBuilder builder = new(); + + builder.AddDnsName("host.docker.internal"); + builder.AddDnsName("localhost"); + builder.AddIpAddress(IPAddress.Parse("127.0.0.1")); + + return builder.Build(); + } + + static string ExportToPem(X509Certificate2 certificate) => certificate.ExportCertificatePem(); + + static void InstallIntoWindowsTrustStore(X509Certificate2 certificate) + { + using X509Store store = new(StoreName.Root, StoreLocation.CurrentUser); + store.Open(OpenFlags.ReadWrite); + + // Adding a root anchor is the point of this call: the emulator serves a self-signed certificate and + // the Azure SDK enforces HTTPS. The certificate is the module's own throwaway one, never a CA. +#pragma warning disable CA5380 // Do Not Add Certificates To Root Certificate Store In Windows + store.Add(certificate); +#pragma warning restore CA5380 // Do Not Add Certificates To Root Certificate Store In Windows + + store.Close(); + } + + static void InstallIntoLinuxTrustStore(X509Certificate2 certificate) + { + var destination = Path.Combine(LinuxCaCertificateDirectory, CrtFileName); + var stagedPath = Path.Combine(Path.GetTempPath(), CrtFileName); + + // /usr/local/share/ca-certificates is root-owned, so stage the PEM somewhere writable and copy it + // with sudo; rebuilding the CA bundle needs sudo too. + File.WriteAllText(stagedPath, ExportToPem(certificate)); + RunBash($"sudo cp '{stagedPath}' '{destination}'"); + RunBash("sudo update-ca-certificates"); + } + + static void RunBash(string command) + { + ProcessStartInfo startInfo = new() + { + FileName = "/bin/bash", + ArgumentList = { "-c", command }, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + + using var process = + Process.Start(startInfo) + ?? throw new ContainerConfigurationException($"Failed to start /bin/bash for: {command}"); + + var error = process.StandardError.ReadToEnd(); + process.WaitForExit(); + + if (process.ExitCode != 0) + { + throw new ContainerConfigurationException( + $"Installing the emulator certificate into the Linux trust store failed (exit code {process.ExitCode}): {error.Trim()}. " + + "sudo is required; alternatively disable trust-store installation with WithTrustStoreInstallation(false) — the module's own clients pin the certificate and do not need it." + ); + } + } +} diff --git a/src/src/AzureKeyVaultEmulator/AzureKeyVaultEmulatorConfiguration.cs b/src/src/AzureKeyVaultEmulator/AzureKeyVaultEmulatorConfiguration.cs new file mode 100644 index 0000000..93c6da9 --- /dev/null +++ b/src/src/AzureKeyVaultEmulator/AzureKeyVaultEmulatorConfiguration.cs @@ -0,0 +1,20 @@ +namespace Purview.Containers.AzureKeyVaultEmulator; + +/// Immutable configuration for an Azure Key Vault Emulator test container. +public sealed record AzureKeyVaultEmulatorConfiguration : ContainerConfiguration +{ + /// True when the emulator persists vault data next to the mounted certificates. + public bool Persist { get; init; } + + /// Host directory mounted at . + public string CertificateDirectory { get; init; } = string.Empty; + + /// True when a missing certificate pair is generated instead of being required. + public bool GenerateCertificates { get; init; } = true; + + /// True when the certificates are installed into the host trust store. + public bool InstallCertificatesIntoTrustStore { get; init; } = true; + + /// True when the certificates are uninstalled and deleted on dispose. + public bool CleanupCertificatesOnDispose { get; init; } +} diff --git a/src/src/AzureKeyVaultEmulator/AzureKeyVaultEmulatorConnectionStringProvider.cs b/src/src/AzureKeyVaultEmulator/AzureKeyVaultEmulatorConnectionStringProvider.cs new file mode 100644 index 0000000..906006e --- /dev/null +++ b/src/src/AzureKeyVaultEmulator/AzureKeyVaultEmulatorConnectionStringProvider.cs @@ -0,0 +1,9 @@ +namespace Purview.Containers.AzureKeyVaultEmulator; + +/// Provides the emulator's vault URI as the connection string. +sealed class AzureKeyVaultEmulatorConnectionStringProvider + : ContainerConnectionStringProvider +{ + /// + protected override string GetHostConnectionString() => Container.GetConnectionString(); +} diff --git a/src/src/AzureKeyVaultEmulator/AzureKeyVaultEmulatorContainer.cs b/src/src/AzureKeyVaultEmulator/AzureKeyVaultEmulatorContainer.cs new file mode 100644 index 0000000..7f1b197 --- /dev/null +++ b/src/src/AzureKeyVaultEmulator/AzureKeyVaultEmulatorContainer.cs @@ -0,0 +1,183 @@ +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; +using Azure.Core; +using Azure.Core.Pipeline; +using Azure.Security.KeyVault.Certificates; +using Azure.Security.KeyVault.Keys; +using Azure.Security.KeyVault.Secrets; + +namespace Purview.Containers.AzureKeyVaultEmulator; + +/// +/// A throwaway Azure Key Vault +/// Emulator instance running on WSL Containers or Docker. The Azure SDK clients work against it +/// unchanged; the module hands out ones that are already wired for the emulator. +/// +public sealed class AzureKeyVaultEmulatorContainer : ContainerBase +{ + readonly AzureKeyVaultEmulatorConfiguration _configuration; + + int _prepared; + X509Certificate2? _certificate; + bool _generatedCertificates; + bool _installedCertificates; + HttpClient? _httpClient; + TokenCredential? _credential; + + internal AzureKeyVaultEmulatorContainer( + AzureKeyVaultEmulatorConfiguration configuration, + IContainerBackend? backend + ) + : base(configuration, backend) + { + _configuration = configuration; + } + + /// Vault URI (https://127.0.0.1:{mappedPort}). Safe to call after . + public Uri GetVaultUri() + { + // 127.0.0.1 is required: WSLC maps IPv4 loopback only, and 'localhost' resolves to IPv6 ::1. + return new Uri( + $"https://127.0.0.1:{GetMappedPublicPort(AzureKeyVaultEmulatorBuilder.EmulatorPort)}", + UriKind.Absolute + ); + } + + /// The vault URI. Safe to call after . + public string GetConnectionString() => GetVaultUri().ToString(); + + /// The certificate the emulator serves. Safe to call after . + public X509Certificate2 GetCertificate() => + _certificate ?? throw new InvalidOperationException("Container has not been started. Call StartAsync() first."); + + /// A SecretClient configured for the emulator. Safe to call after . + public SecretClient GetSecretClient() + { + SecretClientOptions options = new() + { + DisableChallengeResourceVerification = true, + Transport = CreateTransport(), + }; + return new SecretClient(GetVaultUri(), CreateCredential(), options); + } + + /// A KeyClient configured for the emulator. Safe to call after . + public KeyClient GetKeyClient() + { + KeyClientOptions options = new() { DisableChallengeResourceVerification = true, Transport = CreateTransport() }; + return new KeyClient(GetVaultUri(), CreateCredential(), options); + } + + /// A CertificateClient configured for the emulator. Safe to call after . + public CertificateClient GetCertificateClient() + { + CertificateClientOptions options = new() + { + DisableChallengeResourceVerification = true, + Transport = CreateTransport(), + }; + return new CertificateClient(GetVaultUri(), CreateCredential(), options); + } + + /// + public override async Task StartAsync(CancellationToken cancellationToken = default) + { + if (Interlocked.Exchange(ref _prepared, 1) == 1) + { + return; + } + + // Ensure the certificate pair exists (and install it when asked) before the backend creates the + // container, so the certificate bind mount has files to expose. + (_certificate, _generatedCertificates) = AzureKeyVaultEmulatorCertificates.EnsureCertificate(_configuration); + + if (_configuration.InstallCertificatesIntoTrustStore) + { + AzureKeyVaultEmulatorCertificates.InstallIntoTrustStore(_certificate, _configuration.CertificateDirectory); + _installedCertificates = true; + } + + await base.StartAsync(cancellationToken).ConfigureAwait(false); + } + + /// + public override async ValueTask DisposeAsync() + { + if (_configuration.CleanupCertificatesOnDispose) + { + if (_installedCertificates && _certificate is not null) + { + AzureKeyVaultEmulatorCertificates.UninstallFromTrustStore(_certificate); + } + + if (_generatedCertificates) + { + AzureKeyVaultEmulatorCertificates.DeleteCertificates(_configuration.CertificateDirectory); + } + } + + _credential = null; + _httpClient?.Dispose(); + _httpClient = null; + + await base.DisposeAsync().ConfigureAwait(false); + } + + HttpClient PinningHttpClient + { + get + { + if (_httpClient is not null) + { + return _httpClient; + } + +#pragma warning disable CA2000 // Dispose objects before losing scope + // HttpClient owns the handler: it disposes it when the client is disposed (in DisposeAsync). + _httpClient = new HttpClient(CreatePinningHandler()) { Timeout = TimeSpan.FromSeconds(30) }; +#pragma warning restore CA2000 // Dispose objects before losing scope + return _httpClient; + } + } + + TokenCredential CreateCredential() => + _credential ??= new AzureKeyVaultEmulatorTokenCredential(PinningHttpClient, GetVaultUri()); + + HttpClientTransport CreateTransport() => new(PinningHttpClient); + + // HttpClient takes ownership of the handler and disposes it with itself, so the handler is not leaked. + HttpClientHandler CreatePinningHandler() => + new() + { + // The emulator serves a self-signed certificate, so the module's clients pin it. That keeps + // them working without the certificate being present in the host trust store, which is what + // makes the module portable across Windows, Linux and CI runners. + ServerCertificateCustomValidationCallback = ValidateServerCertificate, + }; + + bool ValidateServerCertificate( + HttpRequestMessage request, + X509Certificate2? certificate, + X509Chain? chain, + SslPolicyErrors errors + ) + { + if (certificate is null) + { + return false; + } + + var emulatorCertificate = _certificate; + if (emulatorCertificate is null) + { + return errors == SslPolicyErrors.None; + } + + // The emulator serves a self-signed certificate, so the module's clients pin it. That keeps them working without the certificate being present in the host trust store, which is what makes the module portable across Windows, Linux and CI runners. + return string.Equals( + certificate.Thumbprint, + emulatorCertificate.Thumbprint, + StringComparison.OrdinalIgnoreCase + ); + } +} diff --git a/src/src/AzureKeyVaultEmulator/AzureKeyVaultEmulatorTokenCredential.cs b/src/src/AzureKeyVaultEmulator/AzureKeyVaultEmulatorTokenCredential.cs new file mode 100644 index 0000000..edace7a --- /dev/null +++ b/src/src/AzureKeyVaultEmulator/AzureKeyVaultEmulatorTokenCredential.cs @@ -0,0 +1,46 @@ +using Azure.Core; + +namespace Purview.Containers.AzureKeyVaultEmulator; + +/// +/// Token credential backed by the emulator's own token endpoint. The emulator issues an unconditional JWT +/// and its JwtBearer pipeline does not validate it, so the token is fetched once and cached for the +/// container's lifetime. +/// +sealed class AzureKeyVaultEmulatorTokenCredential(HttpClient httpClient, Uri vaultUri) : TokenCredential +{ + readonly Uri _tokenUri = new(vaultUri, "/token"); + string? _token; + + /// + public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken) + { + // Someone, somewhere, is using the synchronous client: the fetch is a single cached HTTPS call. + return GetTokenAsync(requestContext, cancellationToken).AsTask().GetAwaiter().GetResult(); + } + + /// + public override async ValueTask GetTokenAsync( + TokenRequestContext requestContext, + CancellationToken cancellationToken + ) + { + if (_token is { } cached) + { + return new AccessToken(cached, DateTimeOffset.UtcNow.AddHours(1)); + } + + using HttpRequestMessage request = new(HttpMethod.Get, _tokenUri); + using var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + + var token = (await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false)).Trim(); + if (string.IsNullOrEmpty(token)) + { + throw new InvalidOperationException($"The emulator returned an empty token from '{_tokenUri}'."); + } + + _token = token; + return new AccessToken(token, DateTimeOffset.UtcNow.AddHours(1)); + } +} diff --git a/src/src/AzureKeyVaultEmulator/Sdk/README.md b/src/src/AzureKeyVaultEmulator/Sdk/README.md new file mode 100644 index 0000000..cbe8b6d --- /dev/null +++ b/src/src/AzureKeyVaultEmulator/Sdk/README.md @@ -0,0 +1,115 @@ +# Purview.Containers.AzureKeyVaultEmulator + +Throwaway [Azure Key Vault Emulator](https://github.com/james-gould/azure-keyvault-emulator) instances +for .NET integration testing on **WSL Containers (WSLC)** or **Docker**. The emulator serves the full +Key Vault REST API — secrets, keys and certificates — so the Azure SDK clients work against it unchanged. + +```bash +dotnet add package Purview.Containers.AzureKeyVaultEmulator +``` + +Backend-neutral: depends on `Purview.Containers.Core` and needs a backend package (`Purview.Containers.Wsl` +or `Purview.Containers.Docker`). The Azure SDK client packages are referenced by this package so the +container can hand out clients that are already wired for the emulator. + +## 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. +- **Experimental:** the API, defaults and packaging can change between prereleases; there is no + production support guarantee. +- The emulator is not a replacement for Azure Key Vault; it exists to make developing against it easier. + +## Quick start + +```csharp +using Purview.Containers.AzureKeyVaultEmulator; + +await using var emulator = new AzureKeyVaultEmulatorBuilder().Build(); + +await emulator.StartAsync(); // waits for the HTTPS listener + +var secretClient = emulator.GetSecretClient(); +await secretClient.SetSecretAsync("mySecret", "myValue"); + +var secret = await secretClient.GetSecretAsync("mySecret"); +``` + +## Certificates and trust + +The Azure SDK enforces HTTPS, so the emulator must be started with a trusted certificate. The module does +the same as the emulator's own TestContainers module, fully automated: + +- A self-signed `CN=localhost` certificate (SANs `localhost`, `127.0.0.1`, `host.docker.internal`) is + generated into a per-user directory (`/keyvaultemulator/certs`; the temp directory on CI + runners) and reused between runs, then mounted read-only at `/certs`. +- By default the certificate is installed into the host trust store (Windows: `CurrentUser\Root`; Linux: + `/usr/local/share/ca-certificates` + `update-ca-certificates`, which needs `sudo`; macOS: the command to + run is printed). Windows may prompt once to confirm the installation. +- The clients the module returns **pin the emulator certificate** through their HTTP transport, so they + work even when trust-store installation is skipped. Call `WithTrustStoreInstallation(false)` to leave the + host untouched — the recommended setting for CI. + +```csharp +await using var emulator = new AzureKeyVaultEmulatorBuilder() + .WithTrustStoreInstallation(false) // never modify the host (recommended for CI) + .WithCertificateCleanup() // remove the certificate again on dispose + .Build(); +``` + +Bring your own certificates with `WithCertificateDirectory(path)`: the directory must contain +`emulator.pfx` (password `emulator`, a fixed requirement of the image) and `emulator.crt`. Pair it with +`WithGeneratedCertificates(false)` to require the files rather than generate them. + +## Persistence + +`WithPersistence()` writes an `emulator.db` next to the certificates, so vault data survives between runs. +Persisted data embeds the vault URI, so it requires a fixed host port — enabling persistence without one +throws at `Build()`: + +```csharp +await using var emulator = new AzureKeyVaultEmulatorBuilder() + .WithPersistence() + .WithFixedHostPort(4997) + .Build(); +``` + +## API + +| Member | Purpose | +| -- | -- | +| `AzureKeyVaultEmulatorBuilder()` / `(string image)` / `(IContainerBackend)` | Default image `jamesgoulddev/azure-keyvault-emulator:3.1.3` (`3.1.3-arm` on ARM64), or a custom image. | +| `AzureKeyVaultEmulatorBuilder.EmulatorPort` (4997) | Container port, mapped to a random host port unless pinned. | +| `WithPersistence()` / `WithFixedHostPort(ushort)` | Persist vault data; pin the host port persistence requires. | +| `WithCertificateDirectory(string)` / `WithGeneratedCertificates(bool)` | Supply the certificate pair, or control generation. | +| `WithTrustStoreInstallation(bool)` / `WithCertificateCleanup(bool)` | Control host trust-store installation and dispose-time cleanup. | +| `AzureKeyVaultEmulatorContainer.GetVaultUri()` | `https://127.0.0.1:{mappedPort}` — the vault URI. | +| `AzureKeyVaultEmulatorContainer.GetConnectionString()` | The vault URI (so `IContainer.GetConnectionString()` works too). | +| `AzureKeyVaultEmulatorContainer.GetCertificate()` | The certificate the emulator serves. | +| `GetSecretClient()` / `GetKeyClient()` / `GetCertificateClient()` | Azure SDK clients wired for the emulator. | + +The default wait strategy probes `GET /` over HTTPS (ignoring the self-signed certificate) and is replaced +when you supply your own with `WithWaitStrategy(...)`. Call the accessors after `StartAsync()`, once the +mapped port is known. + +## Using your own clients + +Build clients against `GetVaultUri()` with any credential; the emulator accepts any bearer token. When the +certificate is installed into the trust store, the default client options work. Otherwise set +`DisableChallengeResourceVerification = true` and supply a transport that accepts the certificate from +`GetCertificate()`. + +## 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. +- [Wait Strategies](https://github.com/purview-dev/containers/blob/main/docs/wiki/Wait-Strategies.md) — overriding readiness checks. +- [Azure Key Vault Emulator](https://github.com/james-gould/azure-keyvault-emulator) — the emulator itself. diff --git a/src/src/Wsl/Sdk/README.md b/src/src/Wsl/Sdk/README.md index a525791..535c984 100644 --- a/src/src/Wsl/Sdk/README.md +++ b/src/src/Wsl/Sdk/README.md @@ -144,4 +144,4 @@ See the [project wiki](https://github.com/purview-dev/containers/blob/main/docs/ [Networking](https://github.com/purview-dev/containers/blob/main/docs/wiki/Networking.md) and [Wait Strategies](https://github.com/purview-dev/containers/blob/main/docs/wiki/Wait-Strategies.md). Ready-made service modules ship as `Purview.Containers.PostgreSql`, `Redis`, `MsSql`, `RabbitMq`, -`Azurite`, `Nats` and `MySql`. +`Azurite`, `Nats`, `MySql` and `AzureKeyVaultEmulator`. diff --git a/src/tests/AzureKeyVaultEmulator.IntegrationTests/AzureKeyVaultEmulator.IntegrationTests.csproj b/src/tests/AzureKeyVaultEmulator.IntegrationTests/AzureKeyVaultEmulator.IntegrationTests.csproj new file mode 100644 index 0000000..e6c35ec --- /dev/null +++ b/src/tests/AzureKeyVaultEmulator.IntegrationTests/AzureKeyVaultEmulator.IntegrationTests.csproj @@ -0,0 +1 @@ + diff --git a/src/tests/AzureKeyVaultEmulator.IntegrationTests/AzureKeyVaultEmulatorIntegrationTests.cs b/src/tests/AzureKeyVaultEmulator.IntegrationTests/AzureKeyVaultEmulatorIntegrationTests.cs new file mode 100644 index 0000000..db5e559 --- /dev/null +++ b/src/tests/AzureKeyVaultEmulator.IntegrationTests/AzureKeyVaultEmulatorIntegrationTests.cs @@ -0,0 +1,41 @@ +using System.Globalization; + +namespace Purview.Containers.AzureKeyVaultEmulator; + +// Trust-store installation is disabled: the module's own clients pin the emulator certificate, so the +// tests never modify the host trust store. +public class AzureKeyVaultEmulatorIntegrationTests +{ + [Test] + public async Task Emulator_StoresAndReturnsASecret() + { + await WslcTest.SkipIfUnavailableAsync(); + + await using var emulator = new AzureKeyVaultEmulatorBuilder().WithTrustStoreInstallation(false).Build(); + + await emulator.StartAsync(); + + var client = emulator.GetSecretClient(); + await client.SetSecretAsync("integration-secret", "s3cret"); + + var secret = await client.GetSecretAsync("integration-secret"); + + await Assert.That(secret.Value.Value).IsEqualTo("s3cret"); + } + + [Test] + public async Task Emulator_VaultUriUsesTheMappedHttpsPort() + { + await WslcTest.SkipIfUnavailableAsync(); + + await using var emulator = new AzureKeyVaultEmulatorBuilder().WithTrustStoreInstallation(false).Build(); + + await emulator.StartAsync(); + + var port = emulator.GetMappedPublicPort(AzureKeyVaultEmulatorBuilder.EmulatorPort); + + await Assert.That(emulator.GetVaultUri().Scheme).IsEqualTo("https"); + await Assert.That(emulator.GetVaultUri().Port).IsEqualTo(port); + await Assert.That(emulator.GetConnectionString()).Contains(port.ToString(CultureInfo.InvariantCulture)); + } +} diff --git a/src/tests/AzureKeyVaultEmulator.UnitTests/AzureKeyVaultEmulator.UnitTests.csproj b/src/tests/AzureKeyVaultEmulator.UnitTests/AzureKeyVaultEmulator.UnitTests.csproj new file mode 100644 index 0000000..e6c35ec --- /dev/null +++ b/src/tests/AzureKeyVaultEmulator.UnitTests/AzureKeyVaultEmulator.UnitTests.csproj @@ -0,0 +1 @@ + diff --git a/src/tests/AzureKeyVaultEmulator.UnitTests/AzureKeyVaultEmulatorBuilderTests.cs b/src/tests/AzureKeyVaultEmulator.UnitTests/AzureKeyVaultEmulatorBuilderTests.cs new file mode 100644 index 0000000..eb771de --- /dev/null +++ b/src/tests/AzureKeyVaultEmulator.UnitTests/AzureKeyVaultEmulatorBuilderTests.cs @@ -0,0 +1,106 @@ +using System.Runtime.InteropServices; +using Purview.Containers.Runtime; +using Purview.Containers.Waiting; + +namespace Purview.Containers.AzureKeyVaultEmulator; + +public class AzureKeyVaultEmulatorBuilderTests +{ + [Test] + public async Task BuildConfig_AppliesDefaults() + { + var configuration = new AzureKeyVaultEmulatorBuilder().BuildConfigurationForTesting(); + + await Assert + .That(configuration.Image) + .IsEqualTo($"docker.io/{AzureKeyVaultEmulatorBuilder.EmulatorImage}:{ExpectedTag()}"); + await Assert.That(configuration.PortBindings.Count).IsEqualTo(1); + await Assert.That(configuration.PortBindings[0].ContainerPort).IsEqualTo((ushort)4997); + await Assert.That(configuration.PortBindings[0].AssignRandomHostPort).IsTrue(); + await Assert.That(configuration.BindMounts.Count).IsEqualTo(1); + await Assert.That(configuration.BindMounts[0].ContainerPath).IsEqualTo("/certs"); + await Assert.That(configuration.BindMounts[0].ReadOnly).IsTrue(); + await Assert.That(configuration.Environment["Persist"]).IsEqualTo("false"); + await Assert + .That(configuration.CertificateDirectory) + .IsEqualTo(AzureKeyVaultEmulatorCertificates.DefaultDirectory); + await Assert.That(configuration.GenerateCertificates).IsTrue(); + await Assert.That(configuration.InstallCertificatesIntoTrustStore).IsTrue(); + await Assert.That(configuration.CleanupCertificatesOnDispose).IsFalse(); + await Assert.That(configuration.WaitStrategies.Count).IsEqualTo(1); + await Assert.That(configuration.WaitStrategies[0]).IsTypeOf(); + } + + [Test] + public async Task BuildConfig_PersistenceWithAFixedHostPort_PinsTheBinding() + { + var configuration = new AzureKeyVaultEmulatorBuilder() + .WithPersistence() + .WithFixedHostPort(4997) + .BuildConfigurationForTesting(); + + await Assert.That(configuration.Persist).IsTrue(); + await Assert.That(configuration.Environment["Persist"]).IsEqualTo("true"); + await Assert.That(configuration.PortBindings.Count).IsEqualTo(1); + await Assert.That(configuration.PortBindings[0].HostPort).IsEqualTo((ushort)4997); + await Assert.That(configuration.PortBindings[0].AssignRandomHostPort).IsFalse(); + } + + [Test] + public async Task Build_PersistenceWithoutAFixedHostPort_Throws() + { + var builder = new AzureKeyVaultEmulatorBuilder().WithPersistence(); + + await Assert.That(() => builder.Build()).Throws(); + } + + [Test] + public async Task BuildConfig_CertificateDirectory_IsMountedReadOnlyAtCerts() + { + var configuration = new AzureKeyVaultEmulatorBuilder() + .WithCertificateDirectory("certs/emulator") + .BuildConfigurationForTesting(); + + await Assert.That(configuration.CertificateDirectory).IsEqualTo("certs/emulator"); + await Assert.That(configuration.BindMounts.Count).IsEqualTo(1); + await Assert.That(configuration.BindMounts[0].HostPath).IsEqualTo("certs/emulator"); + await Assert.That(configuration.BindMounts[0].ContainerPath).IsEqualTo("/certs"); + await Assert.That(configuration.BindMounts[0].ReadOnly).IsTrue(); + } + + [Test] + public async Task Build_WithoutGeneratedCertificatesAndNoDirectory_Throws() + { + var builder = new AzureKeyVaultEmulatorBuilder().WithGeneratedCertificates(false); + + await Assert.That(() => builder.Build()).Throws(); + } + + [Test] + public async Task BuildConfig_TrustStoreAndCleanupFlags_AreHonoured() + { + var configuration = new AzureKeyVaultEmulatorBuilder() + .WithTrustStoreInstallation(false) + .WithCertificateCleanup() + .BuildConfigurationForTesting(); + + await Assert.That(configuration.InstallCertificatesIntoTrustStore).IsFalse(); + await Assert.That(configuration.CleanupCertificatesOnDispose).IsTrue(); + } + + [Test] + public async Task BuildConfig_PreservesCustomWaitStrategy() + { + var configuration = new AzureKeyVaultEmulatorBuilder() + .WithWaitStrategy(Wait.ForTcpPort(4997)) + .BuildConfigurationForTesting(); + + await Assert.That(configuration.WaitStrategies.Count).IsEqualTo(1); + await Assert.That(configuration.WaitStrategies[0]).IsTypeOf(); + } + + static string ExpectedTag() => + RuntimeInformation.ProcessArchitecture == Architecture.Arm64 + ? AzureKeyVaultEmulatorBuilder.EmulatorArmTag + : AzureKeyVaultEmulatorBuilder.EmulatorTag; +} diff --git a/src/tests/Modules.DockerIntegrationTests/ModuleDockerTests.cs b/src/tests/Modules.Docker.IntegrationTests/ModuleDockerTests.cs similarity index 82% rename from src/tests/Modules.DockerIntegrationTests/ModuleDockerTests.cs rename to src/tests/Modules.Docker.IntegrationTests/ModuleDockerTests.cs index 847ef86..deafdb4 100644 --- a/src/tests/Modules.DockerIntegrationTests/ModuleDockerTests.cs +++ b/src/tests/Modules.Docker.IntegrationTests/ModuleDockerTests.cs @@ -2,7 +2,7 @@ using Purview.Containers.Docker; using TUnit.Core.Exceptions; -namespace Purview.Containers.Modules; +namespace Purview.Containers.Modules.Docker; /// /// Every service module on the Docker backend. The WSLC suites prove the modules against WSL Containers; @@ -127,4 +127,26 @@ public async Task Nats_StartsAndMapsItsPort() await AssertStartedAsync(nats, Nats.NatsBuilder.ClientPort); await Assert.That(nats.GetClientEndpoint().Port).IsGreaterThan(0); } + + [Test] + public async Task AzureKeyVaultEmulator_StoresAndReturnsASecret() + { + await SkipIfUnavailableAsync(); + + // Trust-store installation is disabled: the module's clients pin the emulator certificate, so the + // runner's trust store is never modified. + await using var emulator = new AzureKeyVaultEmulator.AzureKeyVaultEmulatorBuilder() + .WithTrustStoreInstallation(false) + .Build(); + + await AssertStartedAsync(emulator, AzureKeyVaultEmulator.AzureKeyVaultEmulatorBuilder.EmulatorPort); + + var client = emulator.GetSecretClient(); + await client.SetSecretAsync("docker-integration-secret", "s3cret"); + + var secret = await client.GetSecretAsync("docker-integration-secret"); + + await Assert.That(secret.Value.Value).IsEqualTo("s3cret"); + await Assert.That(emulator.GetVaultUri().Scheme).IsEqualTo("https"); + } } diff --git a/src/tests/Modules.DockerIntegrationTests/Modules.DockerIntegrationTests.csproj b/src/tests/Modules.Docker.IntegrationTests/Modules.Docker.IntegrationTests.csproj similarity index 94% rename from src/tests/Modules.DockerIntegrationTests/Modules.DockerIntegrationTests.csproj rename to src/tests/Modules.Docker.IntegrationTests/Modules.Docker.IntegrationTests.csproj index 8cbd4d0..55b1138 100644 --- a/src/tests/Modules.DockerIntegrationTests/Modules.DockerIntegrationTests.csproj +++ b/src/tests/Modules.Docker.IntegrationTests/Modules.Docker.IntegrationTests.csproj @@ -10,6 +10,7 @@ +