File: DockerComposePublisherTests.cs
Web Access
Project: src\tests\Aspire.Hosting.Docker.Tests\Aspire.Hosting.Docker.Tests.csproj (Aspire.Hosting.Docker.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
#pragma warning disable ASPIREPUBLISHERS001
 
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Utils;
using Aspire.Hosting.Publishing;
using Microsoft.Extensions.DependencyInjection;
using Aspire.Hosting.Docker.Resources.ComposeNodes;
 
namespace Aspire.Hosting.Docker.Tests;
 
public class DockerComposePublisherTests(ITestOutputHelper outputHelper)
{
    [Fact]
    public async Task PublishAsync_GeneratesValidDockerComposeFile()
    {
        using var tempDir = new TempDirectory();
        // Arrange
 
        var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, publisher: "default", outputPath: tempDir.Path);
 
        builder.Services.AddSingleton<IResourceContainerImageBuilder, MockImageBuilder>();
 
        builder.AddDockerComposeEnvironment("docker-compose");
 
        var param0 = builder.AddParameter("param0");
        var param1 = builder.AddParameter("param1", secret: true);
        var param2 = builder.AddParameter("param2", "default", publishValueAsDefault: true);
        var cs = builder.AddConnectionString("cs", ReferenceExpression.Create($"Url={param0}, Secret={param1}"));
 
        // Add a container to the application
        var redis = builder.AddContainer("cache", "redis")
                    .WithEntrypoint("/bin/sh")
                    .WithHttpEndpoint(name: "h2", port: 5000, targetPort: 5001)
                    .WithHttpEndpoint(env: "REDIS_PORT")
                    .WithArgs("-c", "hello $MSG")
                    .WithEnvironment("MSG", "world")
                    .WithContainerFiles("/usr/local/share", [
                        new ContainerFile
                        {
                            Name = "redis.conf",
                            Contents = "hello world",
                        },
                        new ContainerDirectory
                        {
                            Name = "folder",
                            Entries = [
                                new ContainerFile
                                {
                                    Name = "file.sh",
                                    SourcePath = "./hello.sh",
                                    Owner = 1000,
                                    Group = 1000,
                                    Mode = UnixFileMode.UserExecute | UnixFileMode.UserWrite | UnixFileMode.UserRead,
                                },
                            ],
                        },
                    ])
                    .WithEnvironment(context =>
                    {
                        var resource = (IResourceWithEndpoints)context.Resource;
 
                        context.EnvironmentVariables["TP"] = resource.GetEndpoint("http").Property(EndpointProperty.TargetPort);
                        context.EnvironmentVariables["TPH2"] = resource.GetEndpoint("h2").Property(EndpointProperty.TargetPort);
                    });
 
        var migration = builder.AddContainer("something", "dummy/migration:latest")
                         .WithContainerName("cn");
 
        var api = builder.AddContainer("myapp", "mcr.microsoft.com/dotnet/aspnet:8.0")
                         .WithEnvironment("ASPNETCORE_ENVIRONMENT", "Development")
                         .WithHttpEndpoint(env: "PORT")
                         .WithEnvironment("param0", param0)
                         .WithEnvironment("param1", param1)
                         .WithEnvironment("param2", param2)
                         .WithReference(cs)
                         .WithArgs("--cs", cs.Resource)
                         .WaitFor(redis)
                         .WaitForCompletion(migration)
                         .WaitFor(param0);
 
        builder.AddProject(
            "project1",
            "..\\TestingAppHost1\\TestingAppHost1.MyWebApp\\TestingAppHost1.MyWebApp.csproj",
            launchProfileName: null)
            .WithReference(api.GetEndpoint("http"));
 
        var app = builder.Build();
 
        // Act
        app.Run();
 
        // Assert
        var composePath = Path.Combine(tempDir.Path, "docker-compose.yaml");
        var envPath = Path.Combine(tempDir.Path, ".env");
        Assert.True(File.Exists(composePath));
        Assert.True(File.Exists(envPath));
 
        await Verify(File.ReadAllText(composePath), "yaml")
            .AppendContentAsFile(File.ReadAllText(envPath), "env");
    }
 
    [Fact]
    public async Task DockerComposeCorrectlyEmitsPortMappings()
    {
        using var tempDir = new TempDirectory();
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, publisher: "default", outputPath: tempDir.Path)
            .WithTestAndResourceLogging(outputHelper);
 
        builder.AddDockerComposeEnvironment("docker-compose");
 
        builder.AddContainer("resource", "mcr.microsoft.com/dotnet/aspnet:8.0")
               .WithEnvironment("ASPNETCORE_ENVIRONMENT", "Development")
               .WithHttpEndpoint(env: "HTTP_PORT");
 
        var app = builder.Build();
 
        await app.RunAsync().WaitAsync(TimeSpan.FromSeconds(60));
 
        var composePath = Path.Combine(tempDir.Path, "docker-compose.yaml");
        Assert.True(File.Exists(composePath));
 
        await Verify(File.ReadAllText(composePath), "yaml");
    }
 
    [Theory]
    [InlineData(true)]
    [InlineData(false)]
    public void DockerComposeHandleImageBuilding(bool shouldBuildImages)
    {
        using var tempDir = new TempDirectory();
        using var builder = TestDistributedApplicationBuilder.Create(["--operation", "publish", "--publisher", "default", "--output-path", tempDir.Path])
            .WithTestAndResourceLogging(outputHelper);
 
        builder.AddDockerComposeEnvironment("docker-compose")
               .WithProperties(e => e.BuildContainerImages = shouldBuildImages);
 
        builder.Services.AddSingleton<IResourceContainerImageBuilder, MockImageBuilder>();
 
        builder.AddContainer("resource", "mcr.microsoft.com/dotnet/aspnet:8.0")
            .WithEnvironment("ASPNETCORE_ENVIRONMENT", "Development")
            .WithHttpEndpoint(env: "HTTP_PORT");
 
        var app = builder.Build();
 
        var mockImageBuilder = app.Services.GetRequiredService<IResourceContainerImageBuilder>() as MockImageBuilder;
 
        Assert.NotNull(mockImageBuilder);
 
        // Act
        app.Run();
 
        var composePath = Path.Combine(tempDir.Path, "docker-compose.yaml");
        Assert.True(File.Exists(composePath));
        Assert.Equal(shouldBuildImages, mockImageBuilder.BuildImageCalled);
    }
 
    [Fact]
    public async Task DockerComposeAppliesServiceCustomizations()
    {
        using var tempDir = new TempDirectory();
        var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, "default", outputPath: tempDir.Path);
 
        builder.Services.AddSingleton<IResourceContainerImageBuilder, MockImageBuilder>();
 
        var containerNameParam = builder.AddParameter("param-1", "default-name", publishValueAsDefault: true);
 
        builder.AddDockerComposeEnvironment("docker-compose")
               .WithProperties(e => e.DefaultNetworkName = "default-network")
               .ConfigureComposeFile(file =>
               {
                   file.AddNetwork(new Network { Name = "custom-network", Driver = "host" });
 
                   file.Name = "my application";
               });
 
        // Add a container to the application
        var container = builder.AddContainer("service", "nginx")
            .WithEnvironment("ORIGINAL_ENV", "value")
            .PublishAsDockerComposeService((serviceResource, composeService) =>
            {
                // Add a custom label
                composeService.Labels["custom-label"] = "test-value";
 
                // Add a custom environment variable
                composeService.AddEnvironmentalVariable("CUSTOM_ENV", "custom-value");
 
                // Set a restart policy
                composeService.Restart = "always";
 
                composeService.ContainerName = containerNameParam.AsEnvironmentPlaceholder(serviceResource);
 
                // Add a custom network
                composeService.Networks.Add("custom-network");
            });
 
        var app = builder.Build();
 
        app.Run();
 
        // Assert
        var composePath = Path.Combine(tempDir.Path, "docker-compose.yaml");
        Assert.True(File.Exists(composePath));
        var envPath = Path.Combine(tempDir.Path, ".env");
        Assert.True(File.Exists(envPath));
 
        await Verify(File.ReadAllText(composePath), "yaml")
            .AppendContentAsFile(File.ReadAllText(envPath), "env");
    }
 
    [Fact]
    public async Task DockerComposeDoesNotOverwriteEnvFileOnPublish()
    {
        using var tempDir = new TempDirectory();
        var envFilePath = Path.Combine(tempDir.Path, ".env");
 
        void PublishApp()
        {
            var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, publisher: "default", outputPath: tempDir.Path);
            builder.AddDockerComposeEnvironment("docker-compose");
            var param = builder.AddParameter("param1");
            builder.AddContainer("app", "busybox").WithEnvironment("param1", param);
            var app = builder.Build();
            app.Run();
        }
 
        PublishApp();
        Assert.True(File.Exists(envFilePath));
        var firstContent = File.ReadAllText(envFilePath).Replace("PARAM1=", "PARAM1=changed");
        File.WriteAllText(envFilePath, firstContent);
 
        PublishApp();
        Assert.True(File.Exists(envFilePath));
        var secondContent = File.ReadAllText(envFilePath);
 
        await Verify(firstContent, "env")
            .AppendContentAsFile(secondContent, "env");
    }
 
    [Fact]
    public async Task DockerComposeAppendsNewKeysToEnvFileOnPublish()
    {
        using var tempDir = new TempDirectory();
        var envFilePath = Path.Combine(tempDir.Path, ".env");
 
        void PublishApp(params string[] paramNames)
        {
            var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, publisher: "default", outputPath: tempDir.Path);
            builder.AddDockerComposeEnvironment("docker-compose");
 
            var parmeters = paramNames.Select(name => builder.AddParameter(name).Resource).ToArray();
 
            builder.AddContainer("app", "busybox")
                    .WithEnvironment(context =>
                    {
                        foreach (var param in parmeters)
                        {
                            context.EnvironmentVariables[param.Name] = param;
                        }
                    });
 
            var app = builder.Build();
            app.Run();
        }
 
        PublishApp(["param1"]);
        Assert.True(File.Exists(envFilePath));
        var firstContent = File.ReadAllText(envFilePath).Replace("PARAM1=", "PARAM1=changed");
        File.WriteAllText(envFilePath, firstContent);
 
        PublishApp(["param1", "param2"]);
        Assert.True(File.Exists(envFilePath));
        var secondContent = File.ReadAllText(envFilePath);
 
        await Verify(firstContent, "env")
            .AppendContentAsFile(secondContent, "env");
    }
 
    [Fact]
    public async Task DockerComposeMapsPortsProperly()
    {
        using var tempDir = new TempDirectory();
        var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, publisher: "default", outputPath: tempDir.Path);
 
        builder.Services.AddSingleton<IResourceContainerImageBuilder, MockImageBuilder>();
 
        builder.AddDockerComposeEnvironment("docker-compose");
 
        var container = builder.AddExecutable("service", "foo", ".")
            .PublishAsDockerFile()
            .WithHttpEndpoint(env: "PORT");
 
        var app = builder.Build();
        app.Run();
 
        var composePath = Path.Combine(tempDir.Path, "docker-compose.yaml");
        Assert.True(File.Exists(composePath));
 
        var composeFile = File.ReadAllText(composePath);
 
        await Verify(composeFile);
    }
 
    private sealed class MockImageBuilder : IResourceContainerImageBuilder
    {
        public bool BuildImageCalled { get; private set; }
 
        public Task BuildImageAsync(IResource resource, CancellationToken cancellationToken)
        {
            BuildImageCalled = true;
            return Task.CompletedTask;
        }
    }
 
    private sealed class TestProject : IProjectMetadata
    {
        public string ProjectPath => "another-path";
 
        public LaunchSettings? LaunchSettings { get; set; }
    }
}