diff --git a/Testcontainers.slnx b/Testcontainers.slnx index 6b046c1f1..3dfc43113 100644 --- a/Testcontainers.slnx +++ b/Testcontainers.slnx @@ -37,6 +37,7 @@ + @@ -108,6 +109,7 @@ + diff --git a/docs/modules/flociaz.md b/docs/modules/flociaz.md new file mode 100644 index 000000000..4b965713e --- /dev/null +++ b/docs/modules/flociaz.md @@ -0,0 +1,90 @@ +# FlociAz + +[FlociAz](https://github.com/floci-io/floci-az) emulates Azure management and data-plane APIs in one container. The module starts in a Docker-safe mode: services that would otherwise create child containers use their mocked or embedded implementations. + +Add the module to a test project: + +```shell +dotnet add package Testcontainers.FlociAz +``` + +Start FlociAz and use its storage connection string or service-specific endpoints: + +```csharp +await using var flociAz = new FlociAzBuilder("floci/floci-az:0.12.0") + .Build(); + +await flociAz.StartAsync(); + +var blobs = new BlobServiceClient(flociAz.GetConnectionString()); +var keyVaultEndpoint = flociAz.GetServiceEndpoint("keyvault"); +var armEndpoint = flociAz.GetEndpoint(); +``` + +## Service compatibility + +The following matrix is covered against FlociAz 0.12.0. “Real” means the test reaches the service's actual protocol or runtime, not only its ARM representation. + +| Service | Verified compatibility | +|---------|------------------------| +| Blob Storage | Azure Storage SDK create, upload, and download | +| Queue Storage | Azure Storage SDK create, send, and receive | +| Table Storage | Azure Data Tables SDK create, insert, and read | +| Functions | Management lifecycle, mocked invocation, and real Node.js runtime execution | +| App Configuration | Key-value write and read | +| Cosmos DB for NoSQL | Database, container, and partitioned document lifecycle | +| Key Vault | Authenticated secret write and read | +| Event Hubs | Mocked namespace management only | +| Azure SQL Database | ARM server lifecycle in the default management-only provider | +| Azure Database for PostgreSQL | ARM lifecycle and real Npgsql query | +| Service Bus | Mocked queue/topic/subscription/rule topology and real Azure SDK AMQP send/receive | +| Azure Monitor | Workspace, collection endpoint/rule, log ingestion, and KQL query | +| AKS | Mocked ARM cluster lifecycle | +| Azure Container Instances | Mocked ARM container-group lifecycle | +| Virtual Machines | Mocked ARM VM lifecycle | +| API Management | ARM service lifecycle | +| Azure Cache for Redis | ARM lifecycle and real RESP write/read | +| Azure Container Registry | ARM lifecycle and real Registry V2 API | +| Microsoft Entra ID | OAuth client-credentials token issuance | +| Microsoft Graph | Service-principal discovery and seeded group membership | +| Communication Services Email | Send operation and inspection mailbox | +| Azure Resource Manager | Resource-group and service resource lifecycle | +| Virtual Network | ARM virtual-network lifecycle | +| Event Grid | Topic keys, event publication, and lifecycle | +| Managed Identity | User-assigned ARM lifecycle and IMDS token issuance | + +### Upstream 0.12.0 boundaries + +- Event Hubs AMQP is deliberately hard-coded to mocked mode upstream because Azure SDK connections reset. +- Azure Container Instances accepts `mocked=false`, but 0.12.0 still behaves as mocked mode; container-backed mode is planned upstream. +- AKS real mode starts k3s, but does not reliably transition the ARM resource from `Creating` to `Succeeded` in the containerized Testcontainers topology. The module therefore defaults it to mocked mode. +- Azure SQL's managed data plane requires explicit acceptance of the Microsoft SQL Server EULA. The module never accepts it on the user's behalf; enable and test that mode only after reviewing the license. + +## Docker-backed services + +Functions, PostgreSQL, Service Bus, Redis, and ACR have verified real modes. Grant FlociAz Docker access and opt individual services into real mode: + +```csharp +await using var flociAz = new FlociAzBuilder("floci/floci-az:0.12.0") + .WithDockerSocket() + .WithEnvironment("FLOCI_AZ_SERVICES_FUNCTIONS_MOCKED", "false") + .WithEnvironment("FLOCI_AZ_SERVICES_POSTGRES_MOCKED", "false") + .WithEnvironment("FLOCI_AZ_SERVICES_SERVICE_BUS_MOCKED", "false") + .WithEnvironment("FLOCI_AZ_SERVICES_REDIS_MOCKED", "false") + .WithEnvironment("FLOCI_AZ_SERVICES_ACR_MOCKED", "false") + .Build(); +``` + +!!! warning + + The Docker socket provides root-equivalent access to the Docker host. Use `WithDockerSocket()` only with trusted images. Child containers and volumes are namespaced and registered with the Testcontainers Resource Reaper. + +FlociAz `/connect` responses contain the child container's internal hostname and port. Resolve that pair to a host port before connecting from the test process: + +```csharp +var mappedPort = await flociAz.GetSidecarMappedPublicPortAsync( + sidecarHostname, + sidecarPrivatePort); +``` + +Use `flociAz.Hostname` with the returned port. This works with local and remote Docker endpoints supported by Testcontainers. diff --git a/docs/modules/index.md b/docs/modules/index.md index dfb43b2a2..6165f07a2 100644 --- a/docs/modules/index.md +++ b/docs/modules/index.md @@ -44,6 +44,7 @@ await moduleNameContainer.StartAsync(); | Firebird | `jacobalberty/firebird:v4.0` | [NuGet](https://www.nuget.org/packages/Testcontainers.FirebirdSql) | [Source](https://github.com/testcontainers/testcontainers-dotnet/tree/develop/src/Testcontainers.FirebirdSql) | | Firestore | `gcr.io/google.com/cloudsdktool/google-cloud-cli:446.0.1-emulators` | [NuGet](https://www.nuget.org/packages/Testcontainers.Firestore) | [Source](https://github.com/testcontainers/testcontainers-dotnet/tree/develop/src/Testcontainers.Firestore) | | Floci | `floci/floci:1.5.13` | [NuGet](https://www.nuget.org/packages/Testcontainers.Floci) | [Source](https://github.com/testcontainers/testcontainers-dotnet/tree/develop/src/Testcontainers.Floci) | +| FlociAz | `floci/floci-az:0.12.0` | [NuGet](https://www.nuget.org/packages/Testcontainers.FlociAz) | [Source](https://github.com/testcontainers/testcontainers-dotnet/tree/develop/src/Testcontainers.FlociAz) | | Grafana | `grafana/grafana:12.2` | [NuGet](https://www.nuget.org/packages/Testcontainers.Grafana) | [Source](https://github.com/testcontainers/testcontainers-dotnet/tree/develop/src/Testcontainers.Grafana) | | InfluxDB | `influxdb:2.7` | [NuGet](https://www.nuget.org/packages/Testcontainers.InfluxDb) | [Source](https://github.com/testcontainers/testcontainers-dotnet/tree/develop/src/Testcontainers.InfluxDb) | | JanusGraph | `janusgraph/janusgraph:1.0.0` | [NuGet](https://www.nuget.org/packages/Testcontainers.JanusGraph) | [Source](https://github.com/testcontainers/testcontainers-dotnet/tree/develop/src/Testcontainers.JanusGraph) | diff --git a/mkdocs.yml b/mkdocs.yml index b4f0a73cb..46d034c47 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -59,6 +59,7 @@ nav: - modules/pulsar.md # Apache - modules/aspire-dashboard.md - modules/eventhubs.md # Azure + - modules/flociaz.md # Azure - modules/servicebus.md # Azure - modules/clickhouse.md - modules/db2.md @@ -78,4 +79,4 @@ nav: - modules/toxiproxy.md - modules/valkey.md - contributing.md - - contributing_docs.md \ No newline at end of file + - contributing_docs.md 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..2c97c5ede --- /dev/null +++ b/src/Testcontainers.FlociAz/FlociAzBuilder.cs @@ -0,0 +1,122 @@ +namespace Testcontainers.FlociAz; + +/// +[PublicAPI] +public sealed class FlociAzBuilder : ContainerBuilder +{ + private const string DockerSocket = "/var/run/docker.sock"; + + public const ushort FlociAzPort = 4577; + + 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.12.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; } + + /// + /// Grants FlociAz access to the Docker daemon for services that use sidecar containers. + /// + /// + /// The Docker socket provides root-equivalent access to the Docker host. Only enable it for + /// trusted images. FlociAz child containers and volumes receive a unique namespace that is + /// registered with the Testcontainers Resource Reaper. + /// + /// The host Docker socket path, or null to detect it. + /// A configured instance of . + public FlociAzBuilder WithDockerSocket(string dockerSocket = null) + { + var endpoint = DockerResourceConfiguration.DockerEndpointAuthConfig.Endpoint; + var detectedSocket = endpoint.Scheme.Equals("unix", StringComparison.OrdinalIgnoreCase) ? endpoint.AbsolutePath : DockerSocket; + var source = dockerSocket ?? TestcontainersSettings.DockerSocketOverride ?? detectedSocket; + var resourceNamespace = "tc-" + Guid.NewGuid().ToString("N"); + + return WithBindMount(source, DockerSocket, AccessMode.ReadWrite) + .WithEnvironment("FLOCI_AZ_DOCKER_RESOURCE_NAMESPACE", resourceNamespace); + } + + /// + public override FlociAzContainer Build() + { + Validate(); + return new FlociAzContainer(DockerResourceConfiguration); + } + + /// + protected override FlociAzBuilder Init() + { + return base.Init() + .WithPortBinding(FlociAzPort, true) + .WithEnvironment("FLOCI_AZ_SERVICES_EVENT_HUB_ENABLED", "false") + .WithEnvironment("FLOCI_AZ_SERVICES_FUNCTIONS_MOCKED", "true") + .WithEnvironment("FLOCI_AZ_SERVICES_POSTGRES_MOCKED", "true") + .WithEnvironment("FLOCI_AZ_SERVICES_AKS_MOCKED", "true") + .WithEnvironment("FLOCI_AZ_SERVICES_ACR_MOCKED", "true") + .WithEnvironment("FLOCI_AZ_SERVICES_REDIS_MOCKED", "true") + .WithEnvironment("FLOCI_AZ_SERVICES_SERVICE_BUS_MOCKED", "true") + .WithEnvironment("FLOCI_AZ_SERVICES_COSMOS_MOCKED", "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..b0cc5e497 --- /dev/null +++ b/src/Testcontainers.FlociAz/FlociAzContainer.cs @@ -0,0 +1,127 @@ +namespace Testcontainers.FlociAz; + +/// +[PublicAPI] +public sealed class FlociAzContainer : DockerContainer +{ + private const string ResourceNamespaceEnvironmentVariable = "FLOCI_AZ_DOCKER_RESOURCE_NAMESPACE"; + + private readonly FlociAzConfiguration _configuration; + + /// + /// Initializes a new instance of the class. + /// + /// The container configuration. + public FlociAzContainer(FlociAzConfiguration configuration) + : base(configuration) + { + _configuration = configuration; + } + + /// + public override async Task StartAsync(CancellationToken ct = default) + { + await base.StartAsync(ct) + .ConfigureAwait(false); + + if (Guid.Empty.Equals(_configuration.SessionId) + || !_configuration.Environments.TryGetValue(ResourceNamespaceEnvironmentVariable, out var resourceNamespace) + || string.IsNullOrEmpty(resourceNamespace)) + { + return; + } + + var resourceReaper = await ResourceReaper.GetAndStartDefaultAsync(_configuration.DockerEndpointAuthConfig, _configuration.Logger, ct: ct) + .ConfigureAwait(false); + await resourceReaper.RegisterFilterAsync($"label=floci_namespace={resourceNamespace}", ct) + .ConfigureAwait(false); + } + + /// + /// 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 endpoint used by root-level and Azure Resource Manager APIs. + /// + /// The FlociAz endpoint. + public string GetEndpoint() + { + return new UriBuilder(Uri.UriSchemeHttp, Hostname, GetMappedPublicPort(FlociAzBuilder.FlociAzPort)).ToString(); + } + + /// + /// 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 host port mapped to a FlociAz sidecar container port. + /// + /// The sidecar hostname returned by FlociAz. + /// The sidecar container port. + /// The cancellation token. + /// The Docker host port mapped to . + /// + /// The container was not configured with , or + /// the requested sidecar or port does not exist. + /// + public async Task GetSidecarMappedPublicPortAsync(string sidecarHostname, ushort privatePort, CancellationToken ct = default) + { + if (!_configuration.Environments.TryGetValue(ResourceNamespaceEnvironmentVariable, out var resourceNamespace) + || string.IsNullOrEmpty(resourceNamespace)) + { + throw new InvalidOperationException($"Configure the container with {nameof(FlociAzBuilder)}.{nameof(FlociAzBuilder.WithDockerSocket)}() before resolving sidecar ports."); + } + + using var dockerClient = _configuration.DockerEndpointAuthConfig.GetDockerClientBuilder(_configuration.SessionId).Build(); + var filters = new Dictionary> + { + ["label"] = new Dictionary { [$"floci_namespace={resourceNamespace}"] = true }, + }; + var sidecars = await dockerClient.Containers.ListContainersAsync(new ContainersListParameters { All = true, Filters = filters }, ct) + .ConfigureAwait(false); + var sidecar = sidecars.SingleOrDefault(container => container.Names.Any(name => sidecarHostname.Equals(name.TrimStart('/'), StringComparison.Ordinal))); + var port = sidecar?.Ports.FirstOrDefault(binding => binding.PrivatePort == privatePort && binding.PublicPort > 0); + + return port == null + ? throw new InvalidOperationException($"FlociAz sidecar '{sidecarHostname}' does not expose container port {privatePort}.") + : checked((ushort)port.PublicPort); + } + + /// + /// 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))); + } +} 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..b39130814 --- /dev/null +++ b/src/Testcontainers.FlociAz/Usings.cs @@ -0,0 +1,11 @@ +global using System; +global using System.Collections.Generic; +global using System.Linq; +global using System.Threading; +global using System.Threading.Tasks; +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..7e2806e99 --- /dev/null +++ b/tests/Testcontainers.FlociAz.Tests/Dockerfile @@ -0,0 +1 @@ +FROM floci/floci-az:0.12.0@sha256:0c673d49bb75b502ea0750f1c1347777483ffc33945539e1d9254438cb441a03 diff --git a/tests/Testcontainers.FlociAz.Tests/FlociAzContainerTest.Compatibility.cs b/tests/Testcontainers.FlociAz.Tests/FlociAzContainerTest.Compatibility.cs new file mode 100644 index 000000000..573c6c58d --- /dev/null +++ b/tests/Testcontainers.FlociAz.Tests/FlociAzContainerTest.Compatibility.cs @@ -0,0 +1,251 @@ +namespace Testcontainers.FlociAz; + +public sealed partial class FlociAzContainerTest +{ + [Fact] + [Trait(nameof(DockerCli.DockerPlatform), nameof(DockerCli.DockerPlatform.Linux))] + [Trait(AzureService, "eventhub")] + public async Task MockedEventHubSupportsNamespaceManagement() + { + // Given + var namespaceName = "eventhub-" + Guid.NewGuid().ToString("N"); + using var client = fixture.CreateHttpClient("eventhub"); + + // When + using var createResponse = await client.PutAsJsonAsync($"namespaces/{namespaceName}", new { entities = "events:2", consumerGroups = "$Default,test" }, TestContext.Current.CancellationToken) + .ConfigureAwait(true); + await AssertSuccessAsync(createResponse).ConfigureAwait(true); + + using var listResponse = await client.GetAsync("namespaces", TestContext.Current.CancellationToken) + .ConfigureAwait(true); + using var namespaces = await ReadJsonAsync(listResponse).ConfigureAwait(true); + + // Then + Assert.Contains(namespaces.RootElement.GetProperty("namespaces").EnumerateArray(), item => item.GetProperty("name").GetString() == namespaceName); + + using var deleteResponse = await client.DeleteAsync($"namespaces/{namespaceName}", TestContext.Current.CancellationToken) + .ConfigureAwait(true); + await AssertSuccessAsync(deleteResponse).ConfigureAwait(true); + } + + [Fact] + [Trait(nameof(DockerCli.DockerPlatform), nameof(DockerCli.DockerPlatform.Linux))] + [Trait(AzureService, "graph")] + public async Task GraphReturnsSeededDirectoryMembership() + { + // Given + using var client = fixture.CreateHttpClient(); + + // When + using var groupsResponse = await client.PostAsJsonAsync("v1.0/users/dev-user@floci-az.local/getMemberGroups", new { securityEnabledOnly = true }, TestContext.Current.CancellationToken) + .ConfigureAwait(true); + using var groups = await ReadJsonAsync(groupsResponse).ConfigureAwait(true); + + using var principalsResponse = await client.GetAsync("v1.0/servicePrincipals?$filter=appId%20eq%20'11111111-1111-1111-1111-111111111111'", TestContext.Current.CancellationToken) + .ConfigureAwait(true); + using var principals = await ReadJsonAsync(principalsResponse).ConfigureAwait(true); + + // Then + Assert.Contains(groups.RootElement.GetProperty("value").EnumerateArray(), group => group.GetString() == "44444444-4444-4444-4444-444444444444"); + Assert.NotEmpty(principals.RootElement.GetProperty("value").EnumerateArray()); + } + + [Fact] + [Trait(nameof(DockerCli.DockerPlatform), nameof(DockerCli.DockerPlatform.Linux))] + [Trait(AzureService, "functions")] + public async Task MockedFunctionSupportsDeploymentInvocationAndDeletion() + { + // Given + var appName = "app-" + Guid.NewGuid().ToString("N"); + var functionName = "function-" + Guid.NewGuid().ToString("N"); + using var client = fixture.CreateHttpClient("functions"); + using var package = CreateFunctionPackage(functionName); + + // When + using var appResponse = await client.PutAsJsonAsync($"admin/apps/{appName}", new { runtime = "node" }, TestContext.Current.CancellationToken) + .ConfigureAwait(true); + await AssertSuccessAsync(appResponse).ConfigureAwait(true); + + using var deployResponse = await client.PutAsJsonAsync($"admin/apps/{appName}/functions/{functionName}", new { handler = "index.handler", zipBase64 = Convert.ToBase64String(package.ToArray()) }, TestContext.Current.CancellationToken) + .ConfigureAwait(true); + await AssertSuccessAsync(deployResponse).ConfigureAwait(true); + + using var listResponse = await client.GetAsync($"admin/apps/{appName}/functions", TestContext.Current.CancellationToken) + .ConfigureAwait(true); + using var functions = await ReadJsonAsync(listResponse).ConfigureAwait(true); + + using var invocationResponse = await client.PostAsJsonAsync($"api/{appName}/{functionName}", new { value = "test" }, TestContext.Current.CancellationToken) + .ConfigureAwait(true); + await AssertSuccessAsync(invocationResponse).ConfigureAwait(true); + + using var deleteResponse = await client.DeleteAsync($"admin/apps/{appName}/functions/{functionName}", TestContext.Current.CancellationToken) + .ConfigureAwait(true); + await AssertSuccessAsync(deleteResponse).ConfigureAwait(true); + + // Then + Assert.Contains(functions.RootElement.GetProperty("value").EnumerateArray(), function => function.GetProperty("name").GetString() == functionName); + using var deletedResponse = await client.GetAsync($"admin/apps/{appName}/functions/{functionName}", TestContext.Current.CancellationToken) + .ConfigureAwait(true); + Assert.Equal(HttpStatusCode.NotFound, deletedResponse.StatusCode); + } + + [Fact] + [Trait(nameof(DockerCli.DockerPlatform), nameof(DockerCli.DockerPlatform.Linux))] + [Trait(AzureService, "servicebus")] + public async Task MockedServiceBusSupportsQueueTopicSubscriptionAndRuleTopology() + { + // Given + var queueName = "queue-" + Guid.NewGuid().ToString("N"); + var topicName = "topic-" + Guid.NewGuid().ToString("N"); + var subscriptionName = "subscription-" + Guid.NewGuid().ToString("N"); + var ruleName = "rule-" + Guid.NewGuid().ToString("N"); + using var client = fixture.CreateHttpClient("servicebus"); + + // When + using var queueResponse = await PutXmlAsync(client, queueName, "").ConfigureAwait(true); + await AssertSuccessAsync(queueResponse).ConfigureAwait(true); + + using var topicResponse = await PutXmlAsync(client, topicName, "").ConfigureAwait(true); + await AssertSuccessAsync(topicResponse).ConfigureAwait(true); + + using var subscriptionResponse = await PutXmlAsync(client, $"{topicName}/subscriptions/{subscriptionName}", "").ConfigureAwait(true); + await AssertSuccessAsync(subscriptionResponse).ConfigureAwait(true); + + const string rule = ""; + using var ruleResponse = await PutXmlAsync(client, $"{topicName}/subscriptions/{subscriptionName}/rules/{ruleName}", rule).ConfigureAwait(true); + await AssertSuccessAsync(ruleResponse).ConfigureAwait(true); + + using var queuesResponse = await client.GetAsync("$Resources/queues", TestContext.Current.CancellationToken) + .ConfigureAwait(true); + await AssertSuccessAsync(queuesResponse).ConfigureAwait(true); + var queues = await queuesResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken).ConfigureAwait(true); + + using var rulesResponse = await client.GetAsync($"{topicName}/subscriptions/{subscriptionName}/rules", TestContext.Current.CancellationToken) + .ConfigureAwait(true); + await AssertSuccessAsync(rulesResponse).ConfigureAwait(true); + var rules = await rulesResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken).ConfigureAwait(true); + + // Then + Assert.Contains(queueName, queues, StringComparison.Ordinal); + Assert.Contains(ruleName, rules, StringComparison.Ordinal); + + using var deleteRuleResponse = await client.DeleteAsync($"{topicName}/subscriptions/{subscriptionName}/rules/{ruleName}", TestContext.Current.CancellationToken).ConfigureAwait(true); + await AssertSuccessAsync(deleteRuleResponse).ConfigureAwait(true); + using var deleteSubscriptionResponse = await client.DeleteAsync($"{topicName}/subscriptions/{subscriptionName}", TestContext.Current.CancellationToken).ConfigureAwait(true); + await AssertSuccessAsync(deleteSubscriptionResponse).ConfigureAwait(true); + using var deleteTopicResponse = await client.DeleteAsync(topicName, TestContext.Current.CancellationToken).ConfigureAwait(true); + await AssertSuccessAsync(deleteTopicResponse).ConfigureAwait(true); + using var deleteQueueResponse = await client.DeleteAsync(queueName, TestContext.Current.CancellationToken).ConfigureAwait(true); + await AssertSuccessAsync(deleteQueueResponse).ConfigureAwait(true); + } + + [Fact] + [Trait(nameof(DockerCli.DockerPlatform), nameof(DockerCli.DockerPlatform.Linux))] + [Trait(AzureService, "monitor")] + public async Task MonitorIngestsAndQueriesCustomLogs() + { + // Given + var workspaceName = "ws" + Guid.NewGuid().ToString("N"); + var endpointName = "dce" + Guid.NewGuid().ToString("N"); + var ruleName = "dcr" + Guid.NewGuid().ToString("N"); + var providers = $"subscriptions/{FlociAzFixture.SubscriptionId}/resourceGroups/{FlociAzFixture.ResourceGroup}/providers"; + var workspacePath = $"{providers}/Microsoft.OperationalInsights/workspaces/{workspaceName}?api-version=2023-09-01"; + var endpointPath = $"{providers}/Microsoft.Insights/dataCollectionEndpoints/{endpointName}?api-version=2023-09-01"; + var rulePath = $"{providers}/Microsoft.Insights/dataCollectionRules/{ruleName}?api-version=2023-09-01"; + var workspaceId = $"/{providers}/Microsoft.OperationalInsights/workspaces/{workspaceName}"; + using var client = fixture.CreateHttpClient(); + + // When + using var workspaceResponse = await client.PutAsJsonAsync(workspacePath, new { location = "eastus", properties = new { } }, TestContext.Current.CancellationToken).ConfigureAwait(true); + using var workspace = await ReadJsonAsync(workspaceResponse).ConfigureAwait(true); + var customerId = workspace.RootElement.GetProperty("properties").GetProperty("customerId").GetString(); + + using var endpointResponse = await client.PutAsJsonAsync(endpointPath, new { location = "eastus", properties = new { } }, TestContext.Current.CancellationToken).ConfigureAwait(true); + await AssertSuccessAsync(endpointResponse).ConfigureAwait(true); + + var rulePayload = new + { + location = "eastus", + properties = new + { + destinations = new { logAnalytics = new[] { new { name = "workspace", workspaceResourceId = workspaceId } } }, + }, + }; + using var ruleResponse = await client.PutAsJsonAsync(rulePath, rulePayload, TestContext.Current.CancellationToken).ConfigureAwait(true); + using var ruleDocument = await ReadJsonAsync(ruleResponse).ConfigureAwait(true); + var immutableId = ruleDocument.RootElement.GetProperty("properties").GetProperty("immutableId").GetString(); + + var records = new[] { new { TimeGenerated = "2026-09-01T12:00:00Z", Level = "ERROR", Message = "compatible" } }; + using var ingestResponse = await client.PostAsJsonAsync($"dataCollectionRules/{immutableId}/streams/Custom-Test_CL?api-version=2023-01-01", records, TestContext.Current.CancellationToken).ConfigureAwait(true); + await AssertSuccessAsync(ingestResponse).ConfigureAwait(true); + + using var queryResponse = await client.PostAsJsonAsync($"v1/workspaces/{customerId}/query", new { query = "Test_CL | where level == 'ERROR' | project message | take 1" }, TestContext.Current.CancellationToken).ConfigureAwait(true); + using var query = await ReadJsonAsync(queryResponse).ConfigureAwait(true); + + // Then + var table = Assert.Single(query.RootElement.GetProperty("tables").EnumerateArray()); + var row = Assert.Single(table.GetProperty("rows").EnumerateArray()); + Assert.Equal("compatible", row[0].GetString()); + + using var deleteRuleResponse = await client.DeleteAsync(rulePath, TestContext.Current.CancellationToken).ConfigureAwait(true); + await AssertSuccessAsync(deleteRuleResponse).ConfigureAwait(true); + using var deleteEndpointResponse = await client.DeleteAsync(endpointPath, TestContext.Current.CancellationToken).ConfigureAwait(true); + await AssertSuccessAsync(deleteEndpointResponse).ConfigureAwait(true); + using var deleteWorkspaceResponse = await client.DeleteAsync(workspacePath, TestContext.Current.CancellationToken).ConfigureAwait(true); + await AssertSuccessAsync(deleteWorkspaceResponse).ConfigureAwait(true); + } + + [Fact] + [Trait(nameof(DockerCli.DockerPlatform), nameof(DockerCli.DockerPlatform.Linux))] + [Trait(AzureService, "eventgrid")] + public async Task EventGridReturnsKeysAndAcceptsPublishedEvents() + { + // Given + var topicName = "topic" + Guid.NewGuid().ToString("N"); + var topicPath = $"subscriptions/{FlociAzFixture.SubscriptionId}/resourceGroups/{FlociAzFixture.ResourceGroup}/providers/Microsoft.EventGrid/topics/{topicName}?api-version=2025-02-15"; + using var client = fixture.CreateHttpClient(); + + // When + using var topicResponse = await client.PutAsJsonAsync(topicPath, new { location = "eastus", properties = new { inputSchema = "EventGridSchema" } }, TestContext.Current.CancellationToken).ConfigureAwait(true); + await AssertSuccessAsync(topicResponse).ConfigureAwait(true); + + using var keysResponse = await client.PostAsJsonAsync(topicPath.Replace("?", "/listKeys?", StringComparison.Ordinal), new { }, TestContext.Current.CancellationToken).ConfigureAwait(true); + using var keys = await ReadJsonAsync(keysResponse).ConfigureAwait(true); + + client.DefaultRequestHeaders.Add("aeg-sas-key", keys.RootElement.GetProperty("key1").GetString()); + var events = new[] { new { id = Guid.NewGuid().ToString("D"), subject = "/test/1", eventType = "Test.Created", eventTime = DateTimeOffset.UtcNow, data = new { value = 1 }, dataVersion = "1.0" } }; + using var publishResponse = await client.PostAsJsonAsync($"{topicName}-eventgrid/api/events", events, TestContext.Current.CancellationToken).ConfigureAwait(true); + + // Then + await AssertSuccessAsync(publishResponse).ConfigureAwait(true); + + using var deleteResponse = await client.DeleteAsync(topicPath, TestContext.Current.CancellationToken).ConfigureAwait(true); + await AssertSuccessAsync(deleteResponse).ConfigureAwait(true); + } + + private static async Task PutXmlAsync(HttpClient client, string path, string xml) + { + return await client.PutAsync(path, new StringContent(xml, Encoding.UTF8, "application/atom+xml"), TestContext.Current.CancellationToken) + .ConfigureAwait(true); + } + + private static MemoryStream CreateFunctionPackage(string functionName) + { + var stream = new MemoryStream(); + using (var archive = new ZipArchive(stream, ZipArchiveMode.Create, true)) + { + WriteEntry(archive, "host.json", "{\"version\":\"2.0\"}"); + WriteEntry(archive, $"{functionName}/function.json", "{\"bindings\":[{\"authLevel\":\"anonymous\",\"type\":\"httpTrigger\",\"direction\":\"in\",\"name\":\"req\"},{\"type\":\"http\",\"direction\":\"out\",\"name\":\"res\"}]}"); + WriteEntry(archive, $"{functionName}/index.js", "module.exports = async () => ({ status: 200, body: 'compatible' });"); + } + + stream.Position = 0; + return stream; + } + + private static void WriteEntry(ZipArchive archive, string path, string content) + { + using var writer = new StreamWriter(archive.CreateEntry(path).Open(), Encoding.UTF8); + writer.Write(content); + } +} diff --git a/tests/Testcontainers.FlociAz.Tests/FlociAzContainerTest.cs b/tests/Testcontainers.FlociAz.Tests/FlociAzContainerTest.cs new file mode 100644 index 000000000..e28f941dd --- /dev/null +++ b/tests/Testcontainers.FlociAz.Tests/FlociAzContainerTest.cs @@ -0,0 +1,515 @@ +namespace Testcontainers.FlociAz; + +public sealed partial class FlociAzContainerTest(FlociAzContainerTest.FlociAzFixture fixture) + : IClassFixture +{ + private const string AzureService = "Service"; + + public static TheoryData ArmResources + => new() + { + { + "Microsoft.Network/virtualNetworks/{name}?api-version=2024-05-01", + """{"location":"eastus","properties":{"addressSpace":{"addressPrefixes":["10.0.0.0/16"]}}}""", + "properties.provisioningState", + "Succeeded", + true + }, + { + "Microsoft.Compute/virtualMachines/{name}?api-version=2024-11-01", + """{"location":"eastus","properties":{"hardwareProfile":{"vmSize":"Standard_B1s"},"storageProfile":{"imageReference":{"publisher":"Canonical","offer":"ubuntu","sku":"22_04-lts","version":"latest"}},"osProfile":{"computerName":"{name}","adminUsername":"azureuser"},"networkProfile":{"networkInterfaces":[]}}}""", + "properties.hardwareProfile.vmSize", + "Standard_B1s", + true + }, + { + "Microsoft.Sql/servers/{name}?api-version=2021-11-01", + """{"location":"eastus","properties":{"administratorLogin":"sa","administratorLoginPassword":"FlociAz_Strong123!"}}""", + "properties.administratorLogin", + "sa", + true + }, + { + "Microsoft.ContainerService/managedClusters/{name}?api-version=2024-04-01", + """{"location":"eastus","properties":{"kubernetesVersion":"1.29","dnsPrefix":"{name}","agentPoolProfiles":[{"name":"nodepool1","count":1,"vmSize":"Standard_DS2_v2","osType":"Linux","mode":"System"}]}}""", + "properties.dnsPrefix", + "{name}", + true + }, + { + "Microsoft.ContainerRegistry/registries/{name}?api-version=2023-07-01", + """{"location":"eastus","sku":{"name":"Basic"},"properties":{"adminUserEnabled":true}}""", + "sku.name", + "Basic", + true + }, + { + "Microsoft.Cache/redis/{name}?api-version=2024-11-01", + """{"location":"eastus","properties":{"sku":{"name":"Basic","family":"C","capacity":0},"enableNonSslPort":true}}""", + "properties.sku.name", + "Basic", + true + }, + { + "Microsoft.ContainerInstance/containerGroups/{name}?api-version=2023-05-01", + """{"location":"eastus","properties":{"osType":"Linux","containers":[{"name":"app","properties":{"image":"alpine:3.20","resources":{"requests":{"cpu":1,"memoryInGB":1}}}}]}}""", + "properties.osType", + "Linux", + true + }, + { + "Microsoft.ApiManagement/service/{name}?api-version=2024-05-01", + """{"location":"eastus","sku":{"name":"Developer","capacity":1},"properties":{"publisherEmail":"dev@example.com","publisherName":"FlociAz"}}""", + "properties.publisherName", + "FlociAz", + true + }, + { + "Microsoft.ManagedIdentity/userAssignedIdentities/{name}?api-version=2024-11-30", + """{"location":"eastus"}""", + "location", + "eastus", + true + }, + { + "Microsoft.EventGrid/topics/{name}?api-version=2023-12-15-preview", + """{"location":"eastus","properties":{}}""", + "properties.provisioningState", + "Succeeded", + true + }, + { + "Microsoft.OperationalInsights/workspaces/{name}?api-version=2023-09-01", + """{"location":"eastus","properties":{}}""", + "properties.provisioningState", + "Succeeded", + false + }, + { + "Microsoft.Communication/communicationServices/{name}?api-version=2023-04-01", + """{"location":"global","properties":{"dataLocation":"United States"}}""", + "properties.dataLocation", + "United States", + true + }, + { + "Microsoft.DBforPostgreSQL/flexibleServers/{name}?api-version=2025-08-01", + """{"location":"eastus","sku":{"name":"Standard_B1ms","tier":"Burstable"},"properties":{"administratorLogin":"psqladmin","administratorLoginPassword":"FlociAz_Strong123!","version":"16","storage":{"storageSizeGB":32}}}""", + "properties.administratorLogin", + "psqladmin", + true + }, + }; + + [Fact] + [Trait(nameof(DockerCli.DockerPlatform), nameof(DockerCli.DockerPlatform.Linux))] + public async Task ResolvingSidecarPortRequiresDockerSocket() + { + var exception = await Assert.ThrowsAsync(async () => + { + _ = await fixture.Container.GetSidecarMappedPublicPortAsync("sidecar", 1234, TestContext.Current.CancellationToken).ConfigureAwait(true); + }); + + Assert.Contains(nameof(FlociAzBuilder.WithDockerSocket), exception.Message, StringComparison.Ordinal); + } + + [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(fixture.Container.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(fixture.Container.GetConnectionString(), fixture.Container.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(fixture.Container.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(fixture.Container.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); + } + + [Fact] + [Trait(nameof(DockerCli.DockerPlatform), nameof(DockerCli.DockerPlatform.Linux))] + [Trait(AzureService, "appconfig")] + public async Task GetConfigurationReturnsSetValue() + { + // Given + var key = Guid.NewGuid().ToString("D"); + var value = Guid.NewGuid().ToString("D"); + using var client = fixture.CreateHttpClient("appconfig"); + + // When + using var setResponse = await client.PutAsJsonAsync($"kv/{key}?api-version=2024-09-01", new { value }, TestContext.Current.CancellationToken) + .ConfigureAwait(true); + await AssertSuccessAsync(setResponse).ConfigureAwait(true); + + using var getResponse = await client.GetAsync($"kv/{key}?api-version=2024-09-01", TestContext.Current.CancellationToken) + .ConfigureAwait(true); + using var document = await ReadJsonAsync(getResponse).ConfigureAwait(true); + + // Then + Assert.Equal(value, document.RootElement.GetProperty("value").GetString()); + } + + [Fact] + [Trait(nameof(DockerCli.DockerPlatform), nameof(DockerCli.DockerPlatform.Linux))] + [Trait(AzureService, "cosmos")] + public async Task GetDocumentReturnsCreatedDocument() + { + // Given + var database = "db" + Guid.NewGuid().ToString("N"); + var documentId = Guid.NewGuid().ToString("D"); + using var client = fixture.CreateHttpClient("cosmos"); + + // When + using var databaseResponse = await client.PostAsJsonAsync("dbs", new { id = database }, TestContext.Current.CancellationToken) + .ConfigureAwait(true); + await AssertSuccessAsync(databaseResponse).ConfigureAwait(true); + + using var collectionResponse = await client.PostAsJsonAsync($"dbs/{database}/colls", new { id = "items", partitionKey = new { paths = new[] { "/category" }, kind = "Hash" } }, TestContext.Current.CancellationToken) + .ConfigureAwait(true); + await AssertSuccessAsync(collectionResponse).ConfigureAwait(true); + + using var createRequest = new HttpRequestMessage(HttpMethod.Post, $"dbs/{database}/colls/items/docs"); + createRequest.Headers.Add("x-ms-documentdb-partitionkey", "[\"test\"]"); + createRequest.Content = JsonContent.Create(new { id = documentId, category = "test", value = "created" }); + using var createResponse = await client.SendAsync(createRequest, TestContext.Current.CancellationToken) + .ConfigureAwait(true); + await AssertSuccessAsync(createResponse).ConfigureAwait(true); + + using var getRequest = new HttpRequestMessage(HttpMethod.Get, $"dbs/{database}/colls/items/docs/{documentId}"); + getRequest.Headers.Add("x-ms-documentdb-partitionkey", "[\"test\"]"); + using var getResponse = await client.SendAsync(getRequest, TestContext.Current.CancellationToken) + .ConfigureAwait(true); + using var document = await ReadJsonAsync(getResponse).ConfigureAwait(true); + + // Then + Assert.Equal("created", document.RootElement.GetProperty("value").GetString()); + Assert.Contains(fixture.Container.GetServiceEndpoint("cosmos"), fixture.Container.GetCosmosConnectionString()); + } + + [Fact] + [Trait(nameof(DockerCli.DockerPlatform), nameof(DockerCli.DockerPlatform.Linux))] + [Trait(AzureService, "keyvault")] + public async Task GetSecretReturnsSetSecret() + { + // Given + var secretName = "secret-" + Guid.NewGuid().ToString("N"); + var secretValue = Guid.NewGuid().ToString("D"); + using var client = fixture.CreateHttpClient("keyvault"); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "fake"); + + // When + using var setResponse = await client.PutAsJsonAsync($"secrets/{secretName}?api-version=7.4", new { value = secretValue }, TestContext.Current.CancellationToken) + .ConfigureAwait(true); + await AssertSuccessAsync(setResponse).ConfigureAwait(true); + + using var getResponse = await client.GetAsync($"secrets/{secretName}?api-version=7.4", TestContext.Current.CancellationToken) + .ConfigureAwait(true); + using var document = await ReadJsonAsync(getResponse).ConfigureAwait(true); + + // Then + Assert.Equal(secretValue, document.RootElement.GetProperty("value").GetString()); + } + + [Fact] + [Trait(nameof(DockerCli.DockerPlatform), nameof(DockerCli.DockerPlatform.Linux))] + [Trait(AzureService, "functions")] + public async Task GetFunctionAppReturnsCreatedApp() + { + // Given + var appName = "app-" + Guid.NewGuid().ToString("N"); + using var client = fixture.CreateHttpClient("functions"); + + // When + using var createResponse = await client.PutAsJsonAsync($"admin/apps/{appName}", new { runtime = "dotnet" }, TestContext.Current.CancellationToken) + .ConfigureAwait(true); + await AssertSuccessAsync(createResponse).ConfigureAwait(true); + + using var getResponse = await client.GetAsync($"admin/apps/{appName}", TestContext.Current.CancellationToken) + .ConfigureAwait(true); + using var document = await ReadJsonAsync(getResponse).ConfigureAwait(true); + + // Then + Assert.Equal(appName, document.RootElement.GetProperty("name").GetString()); + } + + [Fact] + [Trait(nameof(DockerCli.DockerPlatform), nameof(DockerCli.DockerPlatform.Linux))] + [Trait(AzureService, "servicebus")] + public async Task GetServiceBusNamespaceReturnsCreatedNamespace() + { + // Given + var namespaceName = "sb-" + Guid.NewGuid().ToString("N"); + using var client = fixture.CreateHttpClient("servicebus"); + + // When + using var createResponse = await client.PutAsJsonAsync($"namespaces/{namespaceName}", new { }, TestContext.Current.CancellationToken) + .ConfigureAwait(true); + await AssertSuccessAsync(createResponse).ConfigureAwait(true); + + using var getResponse = await client.GetAsync($"namespaces/{namespaceName}", TestContext.Current.CancellationToken) + .ConfigureAwait(true); + using var document = await ReadJsonAsync(getResponse).ConfigureAwait(true); + + // Then + Assert.Equal(namespaceName, document.RootElement.GetProperty("name").GetString()); + } + + [Fact] + [Trait(nameof(DockerCli.DockerPlatform), nameof(DockerCli.DockerPlatform.Linux))] + [Trait(AzureService, "entra")] + public async Task TokenEndpointReturnsAccessToken() + { + // Given + using var client = fixture.CreateHttpClient(); + using var content = new FormUrlEncodedContent(new Dictionary + { + { "grant_type", "client_credentials" }, + { "client_id", "11111111-1111-1111-1111-111111111111" }, + { "client_secret", "floci-az-dev-secret" }, + { "scope", "api://resource/.default" }, + }); + + // When + using var response = await client.PostAsync("00000000-0000-0000-0000-000000000002/oauth2/v2.0/token", content, TestContext.Current.CancellationToken) + .ConfigureAwait(true); + using var document = await ReadJsonAsync(response).ConfigureAwait(true); + + // Then + Assert.False(string.IsNullOrEmpty(document.RootElement.GetProperty("access_token").GetString())); + } + + [Fact] + [Trait(nameof(DockerCli.DockerPlatform), nameof(DockerCli.DockerPlatform.Linux))] + [Trait(AzureService, "managedidentity")] + public async Task ImdsReturnsAccessToken() + { + // Given + using var client = fixture.CreateHttpClient(); + using var request = new HttpRequestMessage(HttpMethod.Get, "metadata/identity/oauth2/token?resource=https%3A%2F%2Fmanagement.azure.com%2F&api-version=2018-02-01"); + request.Headers.Add("Metadata", "true"); + + // When + using var response = await client.SendAsync(request, TestContext.Current.CancellationToken) + .ConfigureAwait(true); + using var document = await ReadJsonAsync(response).ConfigureAwait(true); + + // Then + Assert.Equal("Bearer", document.RootElement.GetProperty("token_type").GetString()); + Assert.False(string.IsNullOrEmpty(document.RootElement.GetProperty("access_token").GetString())); + } + + [Fact] + [Trait(nameof(DockerCli.DockerPlatform), nameof(DockerCli.DockerPlatform.Linux))] + [Trait(AzureService, "email")] + public async Task InspectionMailboxReturnsSentEmail() + { + // Given + var subject = Guid.NewGuid().ToString("D"); + using var client = fixture.CreateHttpClient(); + var message = new + { + senderAddress = "DoNotReply@example.com", + content = new { subject, plainText = "Hello from Testcontainers" }, + recipients = new { to = new[] { new { address = "dev@example.com" } } }, + }; + + // When + using var sendResponse = await client.PostAsJsonAsync("./emails:send?api-version=2023-03-31", message, TestContext.Current.CancellationToken) + .ConfigureAwait(true); + await AssertSuccessAsync(sendResponse).ConfigureAwait(true); + + using var mailboxResponse = await client.GetAsync("emailMessages", TestContext.Current.CancellationToken) + .ConfigureAwait(true); + using var document = await ReadJsonAsync(mailboxResponse).ConfigureAwait(true); + + // Then + var email = Assert.Single(document.RootElement.GetProperty("value").EnumerateArray()); + Assert.Equal(subject, email.GetProperty("subject").GetString()); + } + + [Theory] + [MemberData(nameof(ArmResources))] + [Trait(nameof(DockerCli.DockerPlatform), nameof(DockerCli.DockerPlatform.Linux))] + [Trait(AzureService, "arm")] + public async Task ArmResourceSupportsLifecycle(string resourcePath, string payload, string expectedProperty, string expectedValue, bool supportsList) + { + // Given + var resourceName = "tc" + Guid.NewGuid().ToString("N"); + var path = $"subscriptions/{FlociAzFixture.SubscriptionId}/resourceGroups/{FlociAzFixture.ResourceGroup}/providers/{resourcePath}" + .Replace("{name}", resourceName, StringComparison.Ordinal); + var queryIndex = resourcePath.IndexOf('?', StringComparison.Ordinal); + var resourceIndex = resourcePath.IndexOf("/{name}", StringComparison.Ordinal); + var collectionResourcePath = resourcePath.Substring(0, resourceIndex) + resourcePath.Substring(queryIndex); + var collectionPath = $"subscriptions/{FlociAzFixture.SubscriptionId}/resourceGroups/{FlociAzFixture.ResourceGroup}/providers/{collectionResourcePath}"; + using var client = fixture.CreateHttpClient(); + + // When + using var content = new StringContent(payload.Replace("{name}", resourceName, StringComparison.Ordinal), Encoding.UTF8, "application/json"); + using var createResponse = await client.PutAsync(path, content, TestContext.Current.CancellationToken) + .ConfigureAwait(true); + await AssertSuccessAsync(createResponse).ConfigureAwait(true); + + using var getResponse = await client.GetAsync(path, TestContext.Current.CancellationToken) + .ConfigureAwait(true); + using var document = await ReadJsonAsync(getResponse).ConfigureAwait(true); + + // Then + Assert.Equal(resourceName, document.RootElement.GetProperty("name").GetString()); + Assert.Equal(expectedValue.Replace("{name}", resourceName, StringComparison.Ordinal), GetProperty(document.RootElement, expectedProperty).GetString()); + + if (supportsList) + { + using var listResponse = await client.GetAsync(collectionPath, TestContext.Current.CancellationToken) + .ConfigureAwait(true); + using var listDocument = await ReadJsonAsync(listResponse).ConfigureAwait(true); + Assert.Contains(listDocument.RootElement.GetProperty("value").EnumerateArray(), resource => resource.GetProperty("name").GetString() == resourceName); + } + + using var deleteResponse = await client.DeleteAsync(path, TestContext.Current.CancellationToken) + .ConfigureAwait(true); + await AssertSuccessAsync(deleteResponse).ConfigureAwait(true); + + using var deletedResponse = await client.GetAsync(path, TestContext.Current.CancellationToken) + .ConfigureAwait(true); + Assert.Equal(HttpStatusCode.NotFound, deletedResponse.StatusCode); + } + + private static JsonElement GetProperty(JsonElement element, string path) + { + foreach (var segment in path.Split('.')) + { + element = element.GetProperty(segment); + } + + return element; + } + + private static async Task AssertSuccessAsync(HttpResponseMessage response) + { + if (response.IsSuccessStatusCode) + { + return; + } + + var content = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken) + .ConfigureAwait(true); + Assert.Fail($"{response.RequestMessage?.Method} {response.RequestMessage?.RequestUri} returned {(int)response.StatusCode}: {content}"); + } + + private static async Task ReadJsonAsync(HttpResponseMessage response) + { + await AssertSuccessAsync(response).ConfigureAwait(true); + return await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(TestContext.Current.CancellationToken).ConfigureAwait(true), cancellationToken: TestContext.Current.CancellationToken) + .ConfigureAwait(true); + } + + public sealed class FlociAzFixture : IAsyncLifetime + { + public const string SubscriptionId = "00000000-0000-0000-0000-000000000001"; + + public const string ResourceGroup = "testcontainers"; + + public FlociAzContainer Container { get; } + = new FlociAzBuilder(TestSession.GetImageFromDockerfile()) + .WithEnvironment("FLOCI_AZ_SERVICES_EVENT_HUB_ENABLED", "true") + .WithEnvironment("FLOCI_AZ_SERVICES_EVENT_HUB_MOCKED", "true") + .WithEnvironment("FLOCI_AZ_SERVICES_FUNCTIONS_MOCKED", "true") + .WithEnvironment("FLOCI_AZ_SERVICES_POSTGRES_MOCKED", "true") + .WithEnvironment("FLOCI_AZ_SERVICES_AKS_MOCKED", "true") + .WithEnvironment("FLOCI_AZ_SERVICES_ACR_MOCKED", "true") + .WithEnvironment("FLOCI_AZ_SERVICES_REDIS_MOCKED", "true") + .Build(); + + public async ValueTask InitializeAsync() + { + await Container.StartAsync() + .ConfigureAwait(false); + + using var client = CreateHttpClient(); + using var response = await client.PutAsJsonAsync($"subscriptions/{SubscriptionId}/resourceGroups/{ResourceGroup}?api-version=2021-04-01", new { location = "eastus" }, TestContext.Current.CancellationToken) + .ConfigureAwait(false); + await AssertSuccessAsync(response).ConfigureAwait(false); + } + + public ValueTask DisposeAsync() + { + return Container.DisposeAsync(); + } + + public HttpClient CreateHttpClient(string service = null) + { + var endpoint = service is null ? Container.GetEndpoint() : Container.GetServiceEndpoint(service); + return new HttpClient { BaseAddress = new Uri(endpoint) }; + } + } +} diff --git a/tests/Testcontainers.FlociAz.Tests/FlociAzFunctionsSidecarTest.cs b/tests/Testcontainers.FlociAz.Tests/FlociAzFunctionsSidecarTest.cs new file mode 100644 index 000000000..274192ef5 --- /dev/null +++ b/tests/Testcontainers.FlociAz.Tests/FlociAzFunctionsSidecarTest.cs @@ -0,0 +1,103 @@ +namespace Testcontainers.FlociAz; + +[Collection(nameof(FlociAzSidecarCollection))] +public sealed class FlociAzFunctionsSidecarTest(FlociAzFunctionsSidecarTest.FlociAzFunctionsFixture fixture) + : IClassFixture +{ + [Fact] + [Trait(nameof(DockerCli.DockerPlatform), nameof(DockerCli.DockerPlatform.Linux))] + [Trait("Service", "functions-real")] + public async Task FunctionSidecarExecutesDeployedCode() + { + // Given + var appName = "app" + Guid.NewGuid().ToString("N"); + const string functionName = "HttpTrigger"; + using var client = new HttpClient { BaseAddress = new Uri(fixture.Container.GetServiceEndpoint("functions")) }; + using var package = CreateFunctionPackage(); + + // When + using var appResponse = await client.PutAsJsonAsync($"admin/apps/{appName}", new { runtime = "node" }, TestContext.Current.CancellationToken).ConfigureAwait(true); + await AssertSuccessAsync(appResponse).ConfigureAwait(true); + using var deployResponse = await client.PutAsJsonAsync($"admin/apps/{appName}/functions/{functionName}", new { handler = "index.handler", zipBase64 = Convert.ToBase64String(package.ToArray()) }, TestContext.Current.CancellationToken).ConfigureAwait(true); + await AssertSuccessAsync(deployResponse).ConfigureAwait(true); + using var invokeResponse = await InvokeWhenReadyAsync(client, appName, functionName).ConfigureAwait(true); + var responseBody = await invokeResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken).ConfigureAwait(true); + + // Then + await AssertSuccessAsync(invokeResponse).ConfigureAwait(true); + Assert.Contains("compatible", responseBody, StringComparison.OrdinalIgnoreCase); + + using var deleteResponse = await client.DeleteAsync($"admin/apps/{appName}", TestContext.Current.CancellationToken).ConfigureAwait(true); + await AssertSuccessAsync(deleteResponse).ConfigureAwait(true); + } + + private static MemoryStream CreateFunctionPackage() + { + var stream = new MemoryStream(); + using (var archive = new ZipArchive(stream, ZipArchiveMode.Create, true)) + { + WriteEntry(archive, "function.json", "{\"scriptFile\":\"index.js\",\"bindings\":[{\"authLevel\":\"anonymous\",\"type\":\"httpTrigger\",\"direction\":\"in\",\"name\":\"req\",\"methods\":[\"get\",\"post\"]},{\"type\":\"http\",\"direction\":\"out\",\"name\":\"res\"}]}"); + WriteEntry(archive, "index.js", "module.exports = async function (context) { context.res = { status: 200, body: 'compatible' }; }; "); + } + + stream.Position = 0; + return stream; + } + + private static void WriteEntry(ZipArchive archive, string path, string content) + { + using var writer = new StreamWriter(archive.CreateEntry(path).Open(), Encoding.UTF8); + writer.Write(content); + } + + private static async Task AssertSuccessAsync(HttpResponseMessage response) + { + if (response.IsSuccessStatusCode) + { + return; + } + + var content = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken).ConfigureAwait(true); + Assert.Fail($"{response.RequestMessage?.Method} {response.RequestMessage?.RequestUri} returned {(int)response.StatusCode}: {content}"); + } + + private static async Task InvokeWhenReadyAsync(HttpClient client, string appName, string functionName) + { + var timeout = DateTime.UtcNow.AddMinutes(2); + HttpResponseMessage response; + + do + { + response = await client.PostAsJsonAsync($"api/{appName}/{functionName}", new { }, TestContext.Current.CancellationToken).ConfigureAwait(true); + if (response.StatusCode != HttpStatusCode.NotFound) + { + return response; + } + + response.Dispose(); + await Task.Delay(TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken).ConfigureAwait(true); + } + while (DateTime.UtcNow < timeout); + + return await client.PostAsJsonAsync($"api/{appName}/{functionName}", new { }, TestContext.Current.CancellationToken).ConfigureAwait(true); + } + + public sealed class FlociAzFunctionsFixture : IAsyncLifetime + { + public FlociAzContainer Container { get; } + = new FlociAzBuilder(TestSession.GetImageFromDockerfile()) + .WithDockerSocket() + .WithEnvironment("FLOCI_AZ_SERVICES_FUNCTIONS_MOCKED", "false") + .Build(); + + public async ValueTask InitializeAsync() + { + await Container.StartAsync().ConfigureAwait(false); + } + + public ValueTask DisposeAsync() + { + return Container.DisposeAsync(); + } + } +} diff --git a/tests/Testcontainers.FlociAz.Tests/FlociAzSidecarTest.cs b/tests/Testcontainers.FlociAz.Tests/FlociAzSidecarTest.cs new file mode 100644 index 000000000..4bc10d471 --- /dev/null +++ b/tests/Testcontainers.FlociAz.Tests/FlociAzSidecarTest.cs @@ -0,0 +1,198 @@ +namespace Testcontainers.FlociAz; + +[Collection(nameof(FlociAzSidecarCollection))] +public sealed class FlociAzSidecarTest(FlociAzSidecarTest.FlociAzSidecarFixture fixture) + : IClassFixture +{ + private const string AzureService = "Service"; + + [Fact] + [Trait(nameof(DockerCli.DockerPlatform), nameof(DockerCli.DockerPlatform.Linux))] + [Trait(AzureService, "servicebus")] + public async Task ServiceBusSendsAndReceivesMessage() + { + // Given + var queueName = "queue-" + Guid.NewGuid().ToString("N"); + using var managementClient = fixture.CreateHttpClient("servicebus"); + using var createResponse = await managementClient.PutAsync(queueName, new StringContent("", Encoding.UTF8, "application/atom+xml"), TestContext.Current.CancellationToken) + .ConfigureAwait(true); + await AssertSuccessAsync(createResponse).ConfigureAwait(true); + + await using var client = new ServiceBusClient(CreateAmqpConnectionString(fixture.Container.Hostname, 5673)); + await using var sender = client.CreateSender(queueName); + await using var receiver = client.CreateReceiver(queueName); + var messageBody = Guid.NewGuid().ToString("D"); + + // When + await sender.SendMessageAsync(new ServiceBusMessage(messageBody), TestContext.Current.CancellationToken).ConfigureAwait(true); + var message = await receiver.ReceiveMessageAsync(TimeSpan.FromSeconds(20), TestContext.Current.CancellationToken).ConfigureAwait(true); + + // Then + Assert.NotNull(message); + Assert.Equal(messageBody, message.Body.ToString()); + await receiver.CompleteMessageAsync(message, TestContext.Current.CancellationToken).ConfigureAwait(true); + } + + [Fact] + [Trait(nameof(DockerCli.DockerPlatform), nameof(DockerCli.DockerPlatform.Linux))] + [Trait(AzureService, "redis")] + public async Task RedisSidecarStoresAndReturnsValue() + { + // Given + var cacheName = "redis" + Guid.NewGuid().ToString("N"); + var path = fixture.GetArmPath($"Microsoft.Cache/redis/{cacheName}?api-version=2024-11-01"); + using var client = fixture.CreateHttpClient(); + var payload = new { location = "eastus", properties = new { sku = new { name = "Basic", family = "C", capacity = 0 }, enableNonSslPort = true } }; + + // When + using var createResponse = await client.PutAsJsonAsync(path, payload, TestContext.Current.CancellationToken).ConfigureAwait(true); + using var cache = await ReadJsonAsync(createResponse).ConfigureAwait(true); + var properties = cache.RootElement.GetProperty("properties"); + var sidecarHostname = properties.GetProperty("hostName").GetString(); + var privatePort = properties.GetProperty("port").GetUInt16(); + var publicPort = await fixture.Container.GetSidecarMappedPublicPortAsync(sidecarHostname, privatePort, TestContext.Current.CancellationToken).ConfigureAwait(true); + var password = properties.GetProperty("accessKeys").GetProperty("primaryKey").GetString(); + + await using var connection = await ConnectionMultiplexer.ConnectAsync($"{fixture.Container.Hostname}:{publicPort},password={password},abortConnect=false").ConfigureAwait(true); + var database = connection.GetDatabase(); + var key = Guid.NewGuid().ToString("D"); + var value = Guid.NewGuid().ToString("D"); + _ = await database.StringSetAsync(key, value).ConfigureAwait(true); + + // Then + Assert.Equal(value, await database.StringGetAsync(key).ConfigureAwait(true)); + + using var deleteResponse = await client.DeleteAsync(path, TestContext.Current.CancellationToken).ConfigureAwait(true); + await AssertSuccessAsync(deleteResponse).ConfigureAwait(true); + } + + [Fact] + [Trait(nameof(DockerCli.DockerPlatform), nameof(DockerCli.DockerPlatform.Linux))] + [Trait(AzureService, "postgresql")] + public async Task PostgreSqlSidecarExecutesQuery() + { + // Given + var serverName = "pg" + Guid.NewGuid().ToString("N"); + var path = fixture.GetArmPath($"Microsoft.DBforPostgreSQL/flexibleServers/{serverName}?api-version=2025-08-01"); + using var client = fixture.CreateHttpClient(); + var payload = new + { + location = "eastus", + sku = new { name = "Standard_B1ms", tier = "Burstable" }, + properties = new { administratorLogin = "psqladmin", administratorLoginPassword = "FlociAz_Strong123!", version = "16", storage = new { storageSizeGB = 32 } }, + }; + + // When + using var createResponse = await client.PutAsJsonAsync(path, payload, TestContext.Current.CancellationToken).ConfigureAwait(true); + await AssertSuccessAsync(createResponse).ConfigureAwait(true); + using var postgresClient = fixture.CreateHttpClient("postgres"); + using var connectResponse = await postgresClient.GetAsync($"flexibleServers/{serverName}/connect", TestContext.Current.CancellationToken).ConfigureAwait(true); + using var connectionInfo = await ReadJsonAsync(connectResponse).ConfigureAwait(true); + var sidecarHostname = connectionInfo.RootElement.GetProperty("host").GetString(); + var privatePort = connectionInfo.RootElement.GetProperty("port").GetUInt16(); + var publicPort = await fixture.Container.GetSidecarMappedPublicPortAsync(sidecarHostname, privatePort, TestContext.Current.CancellationToken).ConfigureAwait(true); + + var connectionString = $"Host={fixture.Container.Hostname};Port={publicPort};Database=postgres;Username=psqladmin;Password=FlociAz_Strong123!;SSL Mode=Disable"; + await using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(TestContext.Current.CancellationToken).ConfigureAwait(true); + await using var command = new NpgsqlCommand("SELECT 42", connection); + + // Then + Assert.Equal(42, await command.ExecuteScalarAsync(TestContext.Current.CancellationToken).ConfigureAwait(true)); + + using var deleteResponse = await client.DeleteAsync(path, TestContext.Current.CancellationToken).ConfigureAwait(true); + await AssertSuccessAsync(deleteResponse).ConfigureAwait(true); + } + + [Fact] + [Trait(nameof(DockerCli.DockerPlatform), nameof(DockerCli.DockerPlatform.Linux))] + [Trait(AzureService, "acr")] + public async Task ContainerRegistrySidecarExposesRegistryApi() + { + // Given + var registryName = "acr" + Guid.NewGuid().ToString("N"); + var path = fixture.GetArmPath($"Microsoft.ContainerRegistry/registries/{registryName}?api-version=2023-07-01"); + using var client = fixture.CreateHttpClient(); + + // When + using var createResponse = await client.PutAsJsonAsync(path, new { location = "eastus", sku = new { name = "Basic" }, properties = new { adminUserEnabled = true } }, TestContext.Current.CancellationToken).ConfigureAwait(true); + using var registry = await ReadJsonAsync(createResponse).ConfigureAwait(true); + var loginServer = registry.RootElement.GetProperty("properties").GetProperty("loginServer").GetString(); + var registryUri = new Uri(Uri.UriSchemeHttp + Uri.SchemeDelimiter + loginServer); + var sidecarHostname = registryUri.Host; + var privatePort = checked((ushort)registryUri.Port); + var publicPort = await fixture.Container.GetSidecarMappedPublicPortAsync(sidecarHostname, privatePort, TestContext.Current.CancellationToken).ConfigureAwait(true); + + using var registryClient = new HttpClient { BaseAddress = new UriBuilder(Uri.UriSchemeHttp, fixture.Container.Hostname, publicPort).Uri }; + using var apiResponse = await registryClient.GetAsync("v2/", TestContext.Current.CancellationToken).ConfigureAwait(true); + + // Then + await AssertSuccessAsync(apiResponse).ConfigureAwait(true); + Assert.True(apiResponse.Headers.Contains("Docker-Distribution-Api-Version")); + } + + private static string CreateAmqpConnectionString(string hostname, ushort port) + { + return $"Endpoint=sb://{hostname}:{port};SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=devkey;UseDevelopmentEmulator=true;"; + } + + private static async Task AssertSuccessAsync(HttpResponseMessage response) + { + if (response.IsSuccessStatusCode) + { + return; + } + + var content = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken).ConfigureAwait(true); + Assert.Fail($"{response.RequestMessage?.Method} {response.RequestMessage?.RequestUri} returned {(int)response.StatusCode}: {content}"); + } + + private static async Task ReadJsonAsync(HttpResponseMessage response) + { + await AssertSuccessAsync(response).ConfigureAwait(true); + return await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(TestContext.Current.CancellationToken).ConfigureAwait(true), cancellationToken: TestContext.Current.CancellationToken).ConfigureAwait(true); + } + + public sealed class FlociAzSidecarFixture : IAsyncLifetime + { + private const string SubscriptionId = "00000000-0000-0000-0000-000000000003"; + + private const string ResourceGroup = "sidecars"; + + public FlociAzContainer Container { get; } + = new FlociAzBuilder(TestSession.GetImageFromDockerfile()) + .WithDockerSocket() + .WithEnvironment("FLOCI_AZ_SERVICES_SERVICE_BUS_MOCKED", "false") + .WithEnvironment("FLOCI_AZ_SERVICES_SERVICE_BUS_START_ON_BOOT", "true") + .WithEnvironment("FLOCI_AZ_SERVICES_POSTGRES_MOCKED", "false") + .WithEnvironment("FLOCI_AZ_SERVICES_ACR_MOCKED", "false") + .WithEnvironment("FLOCI_AZ_SERVICES_REDIS_MOCKED", "false") + .Build(); + + public async ValueTask InitializeAsync() + { + await Container.StartAsync().ConfigureAwait(false); + using var client = CreateHttpClient(); + using var response = await client.PutAsJsonAsync($"subscriptions/{SubscriptionId}/resourceGroups/{ResourceGroup}?api-version=2021-04-01", new { location = "eastus" }, TestContext.Current.CancellationToken).ConfigureAwait(false); + await AssertSuccessAsync(response).ConfigureAwait(false); + } + + public ValueTask DisposeAsync() + { + return Container.DisposeAsync(); + } + + public HttpClient CreateHttpClient(string service = null) + { + return new HttpClient { BaseAddress = new Uri(service is null ? Container.GetEndpoint() : Container.GetServiceEndpoint(service)) }; + } + + public string GetArmPath(string resourcePath) + { + return $"subscriptions/{SubscriptionId}/resourceGroups/{ResourceGroup}/providers/{resourcePath}"; + } + } +} + +[CollectionDefinition(nameof(FlociAzSidecarCollection), DisableParallelization = true)] +public sealed class FlociAzSidecarCollection; 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..a1eda61df --- /dev/null +++ b/tests/Testcontainers.FlociAz.Tests/Testcontainers.FlociAz.Tests.csproj @@ -0,0 +1,29 @@ + + + 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..7bbf43aaa --- /dev/null +++ b/tests/Testcontainers.FlociAz.Tests/Usings.cs @@ -0,0 +1,21 @@ +global using System; +global using System.Collections.Generic; +global using System.IO; +global using System.IO.Compression; +global using System.Net; +global using System.Net.Http; +global using System.Net.Http.Headers; +global using System.Net.Http.Json; +global using System.Text; +global using System.Text.Json; +global using System.Threading; +global using System.Threading.Tasks; +global using Azure.Data.Tables; +global using Azure.Messaging.ServiceBus; +global using Azure.Storage.Blobs; +global using Azure.Storage.Queues; +global using DotNet.Testcontainers.Commons; +global using DotNet.Testcontainers.Configurations; +global using Npgsql; +global using StackExchange.Redis; +global using Xunit;