From 39d2989e8c5fe880364fc1a4fe9740c2410c7ec4 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Fri, 17 Jul 2026 16:32:56 +0100 Subject: [PATCH] feat: Add Testcontainers.FlociAz module Adds a module for floci-az (https://floci.io/az/), the floci Azure emulator. Complements the existing Testcontainers.Floci (AWS) module. The emulator serves all REST services on one port (4577), routed by path suffix (/{account}, /{account}-queue, /{account}-table, ...), with Event Hubs and Service Bus AMQP on 5672/5673. The container exposes an Azurite-format storage connection string, a generic GetServiceEndpoint(service) for the path-routed services, a Cosmos connection string, and the AMQP endpoints. --- Testcontainers.slnx | 2 + src/Testcontainers.FlociAz/.editorconfig | 1 + src/Testcontainers.FlociAz/FlociAzBuilder.cs | 97 +++++++++++++++++ .../FlociAzConfiguration.cs | 41 +++++++ .../FlociAzConnectionStringProvider.cs | 13 +++ .../FlociAzContainer.cs | 78 ++++++++++++++ .../Testcontainers.FlociAz.csproj | 12 +++ src/Testcontainers.FlociAz/Usings.cs | 9 ++ .../.editorconfig | 1 + tests/Testcontainers.FlociAz.Tests/.runs-on | 1 + tests/Testcontainers.FlociAz.Tests/Dockerfile | 1 + .../FlociAzContainerTest.cs | 102 ++++++++++++++++++ .../Testcontainers.FlociAz.Tests.csproj | 26 +++++ tests/Testcontainers.FlociAz.Tests/Usings.cs | 8 ++ 14 files changed, 392 insertions(+) create mode 100644 src/Testcontainers.FlociAz/.editorconfig create mode 100644 src/Testcontainers.FlociAz/FlociAzBuilder.cs create mode 100644 src/Testcontainers.FlociAz/FlociAzConfiguration.cs create mode 100644 src/Testcontainers.FlociAz/FlociAzConnectionStringProvider.cs create mode 100644 src/Testcontainers.FlociAz/FlociAzContainer.cs create mode 100644 src/Testcontainers.FlociAz/Testcontainers.FlociAz.csproj create mode 100644 src/Testcontainers.FlociAz/Usings.cs create mode 100644 tests/Testcontainers.FlociAz.Tests/.editorconfig create mode 100644 tests/Testcontainers.FlociAz.Tests/.runs-on create mode 100644 tests/Testcontainers.FlociAz.Tests/Dockerfile create mode 100644 tests/Testcontainers.FlociAz.Tests/FlociAzContainerTest.cs create mode 100644 tests/Testcontainers.FlociAz.Tests/Testcontainers.FlociAz.Tests.csproj create mode 100644 tests/Testcontainers.FlociAz.Tests/Usings.cs diff --git a/Testcontainers.slnx b/Testcontainers.slnx index fd3853239..1f12420fe 100644 --- a/Testcontainers.slnx +++ b/Testcontainers.slnx @@ -37,6 +37,7 @@ + @@ -106,6 +107,7 @@ + diff --git a/src/Testcontainers.FlociAz/.editorconfig b/src/Testcontainers.FlociAz/.editorconfig new file mode 100644 index 000000000..78b36ca08 --- /dev/null +++ b/src/Testcontainers.FlociAz/.editorconfig @@ -0,0 +1 @@ +root = true diff --git a/src/Testcontainers.FlociAz/FlociAzBuilder.cs b/src/Testcontainers.FlociAz/FlociAzBuilder.cs new file mode 100644 index 000000000..a7c5de18f --- /dev/null +++ b/src/Testcontainers.FlociAz/FlociAzBuilder.cs @@ -0,0 +1,97 @@ +namespace Testcontainers.FlociAz; + +/// +[PublicAPI] +public sealed class FlociAzBuilder : ContainerBuilder +{ + public const ushort FlociAzPort = 4577; + + public const ushort EventHubsPort = 5672; + + public const ushort ServiceBusPort = 5673; + + public const string AccountName = "devstoreaccount1"; + + public const string AccountKey = "Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw=="; + + /// + /// Initializes a new instance of the class. + /// + /// + /// The full Docker image name, including the image repository and tag + /// (e.g., floci/floci-az:0.9.0). + /// + /// + /// Docker image tags available at . + /// + public FlociAzBuilder(string image) + : this(new DockerImage(image)) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// + /// An instance that specifies the Docker image to be used + /// for the container builder configuration. + /// + /// + /// Docker image tags available at . + /// + public FlociAzBuilder(IImage image) + : this(new FlociAzConfiguration()) + { + DockerResourceConfiguration = Init().WithImage(image).DockerResourceConfiguration; + } + + /// + /// Initializes a new instance of the class. + /// + /// The Docker resource configuration. + private FlociAzBuilder(FlociAzConfiguration resourceConfiguration) + : base(resourceConfiguration) + { + DockerResourceConfiguration = resourceConfiguration; + } + + /// + protected override FlociAzConfiguration DockerResourceConfiguration { get; } + + /// + public override FlociAzContainer Build() + { + Validate(); + return new FlociAzContainer(DockerResourceConfiguration); + } + + /// + protected override FlociAzBuilder Init() + { + return base.Init() + .WithPortBinding(FlociAzPort, true) + .WithPortBinding(EventHubsPort, true) + .WithPortBinding(ServiceBusPort, true) + .WithConnectionStringProvider(new FlociAzConnectionStringProvider()) + .WithWaitStrategy(Wait.ForUnixContainer().UntilHttpRequestIsSucceeded(request => + request.ForPath("/_floci/health").ForPort(FlociAzPort))); + } + + /// + protected override FlociAzBuilder Clone(IResourceConfiguration resourceConfiguration) + { + return Merge(DockerResourceConfiguration, new FlociAzConfiguration(resourceConfiguration)); + } + + /// + protected override FlociAzBuilder Clone(IContainerConfiguration resourceConfiguration) + { + return Merge(DockerResourceConfiguration, new FlociAzConfiguration(resourceConfiguration)); + } + + /// + protected override FlociAzBuilder Merge(FlociAzConfiguration oldValue, FlociAzConfiguration newValue) + { + return new FlociAzBuilder(new FlociAzConfiguration(oldValue, newValue)); + } +} diff --git a/src/Testcontainers.FlociAz/FlociAzConfiguration.cs b/src/Testcontainers.FlociAz/FlociAzConfiguration.cs new file mode 100644 index 000000000..b24d5f5dc --- /dev/null +++ b/src/Testcontainers.FlociAz/FlociAzConfiguration.cs @@ -0,0 +1,41 @@ +namespace Testcontainers.FlociAz; + +/// +[PublicAPI] +public sealed class FlociAzConfiguration : ContainerConfiguration +{ + /// + /// Initializes a new instance of the class. + /// + public FlociAzConfiguration() + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The Docker resource configuration. + public FlociAzConfiguration(IResourceConfiguration resourceConfiguration) + : base(resourceConfiguration) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The container configuration. + public FlociAzConfiguration(IContainerConfiguration resourceConfiguration) + : base(resourceConfiguration) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The old FlociAz configuration. + /// The new FlociAz configuration. + public FlociAzConfiguration(FlociAzConfiguration oldValue, FlociAzConfiguration newValue) + : base(oldValue, newValue) + { + } +} diff --git a/src/Testcontainers.FlociAz/FlociAzConnectionStringProvider.cs b/src/Testcontainers.FlociAz/FlociAzConnectionStringProvider.cs new file mode 100644 index 000000000..95c2881fc --- /dev/null +++ b/src/Testcontainers.FlociAz/FlociAzConnectionStringProvider.cs @@ -0,0 +1,13 @@ +namespace Testcontainers.FlociAz; + +/// +/// Provides the FlociAz connection string. +/// +internal sealed class FlociAzConnectionStringProvider : ContainerConnectionStringProvider +{ + /// + protected override string GetHostConnectionString() + { + return Container.GetConnectionString(); + } +} diff --git a/src/Testcontainers.FlociAz/FlociAzContainer.cs b/src/Testcontainers.FlociAz/FlociAzContainer.cs new file mode 100644 index 000000000..947d71424 --- /dev/null +++ b/src/Testcontainers.FlociAz/FlociAzContainer.cs @@ -0,0 +1,78 @@ +namespace Testcontainers.FlociAz; + +/// +[PublicAPI] +public sealed class FlociAzContainer : DockerContainer +{ + /// + /// Initializes a new instance of the class. + /// + /// The container configuration. + public FlociAzContainer(FlociAzConfiguration configuration) + : base(configuration) + { + } + + /// + /// Gets the FlociAz storage connection string. + /// + /// The FlociAz storage connection string. + public string GetConnectionString() + { + var properties = new Dictionary(); + properties.Add("DefaultEndpointsProtocol", Uri.UriSchemeHttp); + properties.Add("AccountName", FlociAzBuilder.AccountName); + properties.Add("AccountKey", FlociAzBuilder.AccountKey); + properties.Add("BlobEndpoint", GetServiceEndpoint()); + properties.Add("QueueEndpoint", GetServiceEndpoint("queue")); + properties.Add("TableEndpoint", GetServiceEndpoint("table")); + return string.Join(";", properties.Select(property => string.Join("=", property.Key, property.Value))); + } + + /// + /// Gets the FlociAz service endpoint. + /// + /// + /// FlociAz routes its REST services by path: the blob storage service uses the + /// bare /{accountName} path (omit the argument), + /// other services use /{accountName}-{service} (e.g., queue, + /// table, cosmos, keyvault, appconfig, functions). + /// + /// The service name, or null for the blob storage endpoint. + /// The FlociAz service endpoint. + public string GetServiceEndpoint(string service = null) + { + var path = string.IsNullOrEmpty(service) ? FlociAzBuilder.AccountName : string.Join("-", FlociAzBuilder.AccountName, service); + return new UriBuilder(Uri.UriSchemeHttp, Hostname, GetMappedPublicPort(FlociAzBuilder.FlociAzPort), path).ToString(); + } + + /// + /// Gets the FlociAz Cosmos DB connection string. + /// + /// The FlociAz Cosmos DB connection string. + public string GetCosmosConnectionString() + { + var properties = new Dictionary(); + properties.Add("AccountEndpoint", GetServiceEndpoint("cosmos")); + properties.Add("AccountKey", FlociAzBuilder.AccountKey); + return string.Join(";", properties.Select(property => string.Join("=", property.Key, property.Value))); + } + + /// + /// Gets the FlociAz Event Hubs endpoint. + /// + /// The FlociAz Event Hubs endpoint. + public string GetEventHubsEndpoint() + { + return new UriBuilder("amqp", Hostname, GetMappedPublicPort(FlociAzBuilder.EventHubsPort)).ToString(); + } + + /// + /// Gets the FlociAz Service Bus endpoint. + /// + /// The FlociAz Service Bus endpoint. + public string GetServiceBusEndpoint() + { + return new UriBuilder("amqp", Hostname, GetMappedPublicPort(FlociAzBuilder.ServiceBusPort)).ToString(); + } +} diff --git a/src/Testcontainers.FlociAz/Testcontainers.FlociAz.csproj b/src/Testcontainers.FlociAz/Testcontainers.FlociAz.csproj new file mode 100644 index 000000000..16967e2c6 --- /dev/null +++ b/src/Testcontainers.FlociAz/Testcontainers.FlociAz.csproj @@ -0,0 +1,12 @@ + + + net8.0;net9.0;net10.0;netstandard2.0;netstandard2.1 + latest + + + + + + + + diff --git a/src/Testcontainers.FlociAz/Usings.cs b/src/Testcontainers.FlociAz/Usings.cs new file mode 100644 index 000000000..e6a7c21fd --- /dev/null +++ b/src/Testcontainers.FlociAz/Usings.cs @@ -0,0 +1,9 @@ +global using System; +global using System.Collections.Generic; +global using System.Linq; +global using Docker.DotNet.Models; +global using DotNet.Testcontainers.Builders; +global using DotNet.Testcontainers.Configurations; +global using DotNet.Testcontainers.Containers; +global using DotNet.Testcontainers.Images; +global using JetBrains.Annotations; diff --git a/tests/Testcontainers.FlociAz.Tests/.editorconfig b/tests/Testcontainers.FlociAz.Tests/.editorconfig new file mode 100644 index 000000000..78b36ca08 --- /dev/null +++ b/tests/Testcontainers.FlociAz.Tests/.editorconfig @@ -0,0 +1 @@ +root = true diff --git a/tests/Testcontainers.FlociAz.Tests/.runs-on b/tests/Testcontainers.FlociAz.Tests/.runs-on new file mode 100644 index 000000000..2c0c1ac72 --- /dev/null +++ b/tests/Testcontainers.FlociAz.Tests/.runs-on @@ -0,0 +1 @@ +ubuntu-24.04 diff --git a/tests/Testcontainers.FlociAz.Tests/Dockerfile b/tests/Testcontainers.FlociAz.Tests/Dockerfile new file mode 100644 index 000000000..10c218371 --- /dev/null +++ b/tests/Testcontainers.FlociAz.Tests/Dockerfile @@ -0,0 +1 @@ +FROM floci/floci-az:0.9.0@sha256:5e403a7d788c24ab2d1dc03d4803ef2d3962ca01522abba65ed86f221ae87ac0 diff --git a/tests/Testcontainers.FlociAz.Tests/FlociAzContainerTest.cs b/tests/Testcontainers.FlociAz.Tests/FlociAzContainerTest.cs new file mode 100644 index 000000000..752a028a3 --- /dev/null +++ b/tests/Testcontainers.FlociAz.Tests/FlociAzContainerTest.cs @@ -0,0 +1,102 @@ +namespace Testcontainers.FlociAz; + +public sealed class FlociAzContainerTest : IAsyncLifetime +{ + private const string AzureService = "Service"; + + private readonly FlociAzContainer _flociAzContainer = new FlociAzBuilder(TestSession.GetImageFromDockerfile()).Build(); + + public async ValueTask InitializeAsync() + { + await _flociAzContainer.StartAsync() + .ConfigureAwait(false); + } + + public ValueTask DisposeAsync() + { + return _flociAzContainer.DisposeAsync(); + } + + [Fact] + [Trait(nameof(DockerCli.DockerPlatform), nameof(DockerCli.DockerPlatform.Linux))] + [Trait(AzureService, "blob")] + public async Task DownloadBlobReturnsUploadedBlob() + { + // Given + var content = Guid.NewGuid().ToString("D"); + + var client = new BlobServiceClient(_flociAzContainer.GetConnectionString()); + + var containerClient = client.GetBlobContainerClient(Guid.NewGuid().ToString("D")); + + var blobClient = containerClient.GetBlobClient(Guid.NewGuid().ToString("D")); + + // When + _ = await containerClient.CreateAsync(cancellationToken: TestContext.Current.CancellationToken) + .ConfigureAwait(true); + + _ = await blobClient.UploadAsync(BinaryData.FromString(content), cancellationToken: TestContext.Current.CancellationToken) + .ConfigureAwait(true); + + var downloadResult = await blobClient.DownloadContentAsync(TestContext.Current.CancellationToken) + .ConfigureAwait(true); + + // Then + Assert.Equal(content, downloadResult.Value.Content.ToString()); + Assert.Equal(_flociAzContainer.GetConnectionString(), _flociAzContainer.GetConnectionString(ConnectionMode.Host)); + } + + [Fact] + [Trait(nameof(DockerCli.DockerPlatform), nameof(DockerCli.DockerPlatform.Linux))] + [Trait(AzureService, "queue")] + public async Task ReceiveMessageReturnsSentMessage() + { + // Given + var message = Guid.NewGuid().ToString("D"); + + var client = new QueueServiceClient(_flociAzContainer.GetConnectionString()); + + var queueClient = client.GetQueueClient(Guid.NewGuid().ToString("D")); + + // When + _ = await queueClient.CreateAsync(cancellationToken: TestContext.Current.CancellationToken) + .ConfigureAwait(true); + + _ = await queueClient.SendMessageAsync(message, TestContext.Current.CancellationToken) + .ConfigureAwait(true); + + var receivedMessage = await queueClient.ReceiveMessageAsync(cancellationToken: TestContext.Current.CancellationToken) + .ConfigureAwait(true); + + // Then + Assert.Equal(message, receivedMessage.Value.MessageText); + } + + [Fact] + [Trait(nameof(DockerCli.DockerPlatform), nameof(DockerCli.DockerPlatform.Linux))] + [Trait(AzureService, "table")] + public async Task GetEntityReturnsAddedEntity() + { + // Given + var partitionKey = Guid.NewGuid().ToString("D"); + + var rowKey = Guid.NewGuid().ToString("D"); + + var client = new TableServiceClient(_flociAzContainer.GetConnectionString()); + + var tableClient = client.GetTableClient("Table" + Guid.NewGuid().ToString("N")); + + // When + _ = await tableClient.CreateAsync(TestContext.Current.CancellationToken) + .ConfigureAwait(true); + + _ = await tableClient.AddEntityAsync(new TableEntity(partitionKey, rowKey), TestContext.Current.CancellationToken) + .ConfigureAwait(true); + + var entityResponse = await tableClient.GetEntityAsync(partitionKey, rowKey, cancellationToken: TestContext.Current.CancellationToken) + .ConfigureAwait(true); + + // Then + Assert.Equal(rowKey, entityResponse.Value.RowKey); + } +} diff --git a/tests/Testcontainers.FlociAz.Tests/Testcontainers.FlociAz.Tests.csproj b/tests/Testcontainers.FlociAz.Tests/Testcontainers.FlociAz.Tests.csproj new file mode 100644 index 000000000..da101b543 --- /dev/null +++ b/tests/Testcontainers.FlociAz.Tests/Testcontainers.FlociAz.Tests.csproj @@ -0,0 +1,26 @@ + + + net10.0 + false + false + Exe + + + + + + + + + + + + + + + + + PreserveNewest + + + diff --git a/tests/Testcontainers.FlociAz.Tests/Usings.cs b/tests/Testcontainers.FlociAz.Tests/Usings.cs new file mode 100644 index 000000000..5f185dc68 --- /dev/null +++ b/tests/Testcontainers.FlociAz.Tests/Usings.cs @@ -0,0 +1,8 @@ +global using System; +global using System.Threading.Tasks; +global using Azure.Data.Tables; +global using Azure.Storage.Blobs; +global using Azure.Storage.Queues; +global using DotNet.Testcontainers.Commons; +global using DotNet.Testcontainers.Configurations; +global using Xunit;