File: Publishing\DockerContainerRuntimeTests.cs
Web Access
Project: src\tests\Aspire.Hosting.Tests\Aspire.Hosting.Tests.csproj (Aspire.Hosting.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 ASPIRECONTAINERRUNTIME001
#pragma warning disable ASPIREPIPELINES003
 
using Aspire.Hosting.Dcp.Process;
using Aspire.Hosting.Publishing;
using Aspire.Hosting.Tests.Utils;
using Microsoft.AspNetCore.InternalTesting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Logging.Testing;
 
namespace Aspire.Hosting.Tests.Publishing;
 
public class DockerContainerRuntimeTests
{
    [Theory]
    [InlineData(null)]
    [InlineData(ContainerImageFormat.Docker)]
    [InlineData(ContainerImageFormat.Oci)]
    public async Task BuildImageAsync_ArchiveUsesIsolatedBuilder(ContainerImageFormat? imageFormat)
    {
        var processRunner = new TestProcessRunner();
        processRunner.EnqueueResult();
        processRunner.EnqueueResult();
        processRunner.EnqueueResult();
        processRunner.EnqueueResult();
        var runtime = new DockerContainerRuntime(
            NullLogger<DockerContainerRuntime>.Instance,
            processRunner);
        var options = new ContainerImageBuildOptions
        {
            ImageName = "myapp",
            Tag = "latest",
            OutputPath = "out",
            ImageFormat = imageFormat
        };
 
        await runtime.BuildImageAsync(
            contextPath: "context",
            dockerfilePath: "Dockerfile",
            options: options,
            buildArguments: [],
            buildSecrets: [],
            stage: null,
            cancellationToken: CancellationToken.None);
 
        var archivePath = ResourceExtensions.GetContainerImageArchivePath("out", "myapp:latest");
        var outputType = imageFormat == ContainerImageFormat.Oci ? "type=oci" : "type=docker";
        var processSpecs = processRunner.ProcessSpecs;
        Assert.Equal(4, processSpecs.Count);
        Assert.Equal("buildx version", processSpecs[0].Arguments);
        var builderName = AssertAndGetBuilderName(processSpecs[1].Arguments);
        Assert.Equal(
            $"buildx build --file \"Dockerfile\" --tag \"myapp:latest\" --builder \"{builderName}\" " +
            $"--output \"{outputType},dest={archivePath}\" \"{GetNormalizedContextPath()}\"",
            processSpecs[2].Arguments);
        Assert.Equal($"buildx rm \"{builderName}\"", processSpecs[3].Arguments);
    }
 
    [Fact]
    public async Task BuildImageAsync_ArchiveUsesUniqueIsolatedBuilders()
    {
        var processRunner = new TestProcessRunner();
        for (var i = 0; i < 8; i++)
        {
            processRunner.EnqueueResult();
        }
 
        var runtime = new DockerContainerRuntime(
            NullLogger<DockerContainerRuntime>.Instance,
            processRunner);
        var options = new ContainerImageBuildOptions
        {
            ImageName = "myapp",
            Tag = "latest",
            OutputPath = "out",
            ImageFormat = ContainerImageFormat.Docker
        };
 
        await BuildImageAsync(runtime, options);
        await BuildImageAsync(runtime, options);
 
        var builderNames = processRunner.ProcessSpecs
            .Where(static spec => spec.Arguments is string arguments &&
                arguments.StartsWith("buildx create ", StringComparison.Ordinal))
            .Select(spec => AssertAndGetBuilderName(spec.Arguments))
            .ToArray();
        Assert.Equal(2, builderNames.Length);
        Assert.Equal(2, builderNames.Distinct(StringComparer.Ordinal).Count());
    }
 
    [Fact]
    public async Task BuildImageAsync_CancellationWaitsForIsolatedBuilderCleanup()
    {
        var processRunner = new TestProcessRunner();
        processRunner.EnqueueResult();
        processRunner.EnqueueResult();
        var buildResult = new TaskCompletionSource<ProcessResult>(TaskCreationOptions.RunContinuationsAsynchronously);
        var cleanupResult = new TaskCompletionSource<ProcessResult>(TaskCreationOptions.RunContinuationsAsynchronously);
        processRunner.EnqueuePending(buildResult.Task);
        processRunner.EnqueuePending(cleanupResult.Task);
        var runtime = new DockerContainerRuntime(
            NullLogger<DockerContainerRuntime>.Instance,
            processRunner);
        var options = new ContainerImageBuildOptions
        {
            ImageName = "myapp",
            Tag = "latest",
            OutputPath = "out",
            ImageFormat = ContainerImageFormat.Docker
        };
        using var cancellation = new CancellationTokenSource();
 
        var buildTask = runtime.BuildImageAsync(
            contextPath: "context",
            dockerfilePath: "Dockerfile",
            options: options,
            buildArguments: [],
            buildSecrets: [],
            stage: null,
            cancellationToken: cancellation.Token);
 
        await AsyncTestHelpers.AssertIsTrueRetryAsync(
            () => processRunner.ProcessSpecs.Count == 3,
            "The Docker build should start.");
        await cancellation.CancelAsync();
        await AsyncTestHelpers.AssertIsTrueRetryAsync(
            () => processRunner.ProcessSpecs.Count == 4,
            "The isolated builder cleanup should start after cancellation.");
 
        var builderName = AssertAndGetBuilderName(processRunner.ProcessSpecs[1].Arguments);
        Assert.Equal($"buildx rm \"{builderName}\"", processRunner.ProcessSpecs[3].Arguments);
        try
        {
            Assert.False(buildTask.IsCompleted);
        }
        finally
        {
            cleanupResult.TrySetResult(new ProcessResult(0));
        }
 
        await Assert.ThrowsAnyAsync<OperationCanceledException>(() => buildTask).DefaultTimeout();
    }
 
    [Fact]
    public async Task BuildImageAsync_CleanupFailureDoesNotMaskBuildFailure()
    {
        var processRunner = new TestProcessRunner();
        processRunner.EnqueueResult();
        processRunner.EnqueueResult();
        processRunner.EnqueueResult(exitCode: 42, output: ["build failed"]);
        processRunner.EnqueueException(new InvalidOperationException("cleanup failed"));
        var logger = new FakeLogger<DockerContainerRuntime>();
        var runtime = new DockerContainerRuntime(logger, processRunner);
        var options = new ContainerImageBuildOptions
        {
            ImageName = "myapp",
            Tag = "latest",
            OutputPath = "out",
            ImageFormat = ContainerImageFormat.Docker
        };
 
        var exception = await Assert.ThrowsAsync<ProcessFailedException>(
            () => BuildImageAsync(runtime, options));
 
        Assert.Equal(42, exception.ExitCode);
        Assert.Contains(
            logger.Collector.GetSnapshot(),
            log => log.Level == LogLevel.Warning &&
                log.Message.Contains("Failed to remove buildkit instance aspire-", StringComparison.Ordinal));
    }
 
    [Theory]
    [InlineData(null, null, null)]
    [InlineData(null, ContainerTargetPlatform.LinuxAmd64, "linux/amd64")]
    [InlineData(null, ContainerTargetPlatform.LinuxArm64, "linux/arm64")]
    [InlineData(null, ContainerTargetPlatform.AllLinux, "linux/amd64,linux/arm64")]
    [InlineData(ContainerImageFormat.Docker, null, null)]
    [InlineData(ContainerImageFormat.Docker, ContainerTargetPlatform.LinuxAmd64, "linux/amd64")]
    [InlineData(ContainerImageFormat.Docker, ContainerTargetPlatform.LinuxArm64, "linux/arm64")]
    [InlineData(ContainerImageFormat.Docker, ContainerTargetPlatform.AllLinux, "linux/amd64,linux/arm64")]
    public async Task BuildImageAsync_LocalImageArchiveUsesActiveContextBuilderThenDockerSave(
        ContainerImageFormat? imageFormat, ContainerTargetPlatform? targetPlatform, string? expectedPlatforms)
    {
        var processRunner = new TestProcessRunner();
        processRunner.EnqueueResult();
        processRunner.EnqueueResult(output: ["desktop-linux"]);
        processRunner.EnqueueResult();
        processRunner.EnqueueResult();
        var runtime = new DockerContainerRuntime(
            NullLogger<DockerContainerRuntime>.Instance,
            processRunner);
        var options = new ContainerImageBuildOptions
        {
            ImageName = "myapp",
            Tag = "latest",
            OutputPath = "out",
            ImageFormat = imageFormat,
            TargetPlatform = targetPlatform,
            RequiresLocalImageStore = true
        };
 
        await BuildImageAsync(runtime, options);
 
        var archivePath = ResourceExtensions.GetContainerImageArchivePath("out", "myapp:latest");
        var platformArgument = expectedPlatforms is null ? string.Empty : $" --platform \"{expectedPlatforms}\"";
        Assert.Collection(
            processRunner.ProcessSpecs,
            check => Assert.Equal("buildx version", check.Arguments),
            context => Assert.Equal("context show", context.Arguments),
            build => Assert.Equal(
                $"buildx build --file \"Dockerfile\" --tag \"myapp:latest\" --builder \"desktop-linux\"" +
                $"{platformArgument} \"{GetNormalizedContextPath()}\"",
                build.Arguments),
            save => Assert.Equal(
                $"image save --output \"{archivePath}\" \"myapp:latest\"",
                save.Arguments));
    }
 
    [Fact]
    public async Task BuildImageAsync_LocalMultiPlatformBuildFailurePreservesDiagnosticWithoutSaving()
    {
        string[] diagnostic =
        [
            "Multi-platform build is not supported for the docker driver.",
            "Switch to a different driver, or turn on the containerd image store, and try again.",
            "Learn more at https://docs.docker.com/go/build-multi-platform/"
        ];
        var processRunner = new TestProcessRunner();
        processRunner.EnqueueResult();
        processRunner.EnqueueResult(output: ["desktop-linux"]);
        processRunner.EnqueueResult(exitCode: 1, error: diagnostic);
        var runtime = new DockerContainerRuntime(
            NullLogger<DockerContainerRuntime>.Instance,
            processRunner);
        var options = new ContainerImageBuildOptions
        {
            ImageName = "myapp",
            Tag = "latest",
            OutputPath = "out",
            ImageFormat = ContainerImageFormat.Docker,
            TargetPlatform = ContainerTargetPlatform.AllLinux,
            RequiresLocalImageStore = true
        };
 
        var exception = await Assert.ThrowsAsync<ProcessFailedException>(() => BuildImageAsync(runtime, options));
 
        Assert.Equal(1, exception.ExitCode);
        Assert.Equal(diagnostic, exception.ProcessOutput);
        Assert.Equal(
            $"Docker build failed with exit code 1.{Environment.NewLine}{string.Join(Environment.NewLine, diagnostic)}",
            exception.Message);
        Assert.Collection(
            processRunner.ProcessSpecs,
            check => Assert.Equal("buildx version", check.Arguments),
            context => Assert.Equal("context show", context.Arguments),
            build => Assert.Equal(
                $"buildx build --file \"Dockerfile\" --tag \"myapp:latest\" --builder \"desktop-linux\" " +
                $"--platform \"linux/amd64,linux/arm64\" \"{GetNormalizedContextPath()}\"",
                build.Arguments));
    }
 
    [Fact]
    public async Task BuildImageAsync_LocalImageWithoutArchiveUsesActiveContextBuilder()
    {
        var processRunner = new TestProcessRunner();
        processRunner.EnqueueResult();
        processRunner.EnqueueResult(output: ["desktop-linux"]);
        processRunner.EnqueueResult();
        var runtime = new DockerContainerRuntime(
            NullLogger<DockerContainerRuntime>.Instance,
            processRunner);
        var options = new ContainerImageBuildOptions
        {
            ImageName = "myapp",
            Tag = "latest",
            TargetPlatform = ContainerTargetPlatform.LinuxAmd64,
            RequiresLocalImageStore = true
        };
 
        await BuildImageAsync(runtime, options);
 
        Assert.Collection(
            processRunner.ProcessSpecs,
            check => Assert.Equal("buildx version", check.Arguments),
            context => Assert.Equal("context show", context.Arguments),
            build => Assert.Equal(
                $"buildx build --file \"Dockerfile\" --tag \"myapp:latest\" --builder \"desktop-linux\" " +
                $"--platform \"linux/amd64\" \"{GetNormalizedContextPath()}\"",
                build.Arguments));
    }
 
    [Fact]
    public async Task BuildImageAsync_LocalImageWithEmptyActiveContextFails()
    {
        var processRunner = new TestProcessRunner();
        processRunner.EnqueueResult();
        processRunner.EnqueueResult(output: [" "]);
        var runtime = new DockerContainerRuntime(
            NullLogger<DockerContainerRuntime>.Instance,
            processRunner);
        var options = new ContainerImageBuildOptions
        {
            ImageName = "myapp",
            Tag = "latest",
            RequiresLocalImageStore = true
        };
 
        var exception = await Assert.ThrowsAsync<DistributedApplicationException>(
            () => BuildImageAsync(runtime, options));
 
        Assert.Equal("Docker did not report exactly one active context.", exception.Message);
        Assert.Collection(
            processRunner.ProcessSpecs,
            check => Assert.Equal("buildx version", check.Arguments),
            context => Assert.Equal("context show", context.Arguments));
    }
 
    [Fact]
    public async Task BuildImageAsync_LocalImageContextDiscoveryFailureIsReported()
    {
        var processRunner = new TestProcessRunner();
        processRunner.EnqueueResult();
        processRunner.EnqueueResult(exitCode: 42, error: ["context failed"]);
        var runtime = new DockerContainerRuntime(
            NullLogger<DockerContainerRuntime>.Instance,
            processRunner);
        var options = new ContainerImageBuildOptions
        {
            ImageName = "myapp",
            Tag = "latest",
            RequiresLocalImageStore = true
        };
 
        var exception = await Assert.ThrowsAsync<DistributedApplicationException>(
            () => BuildImageAsync(runtime, options));
 
        Assert.Equal(
            $"Docker context discovery for 'myapp:latest' failed with exit code 42.{Environment.NewLine}context failed",
            exception.Message);
        Assert.Collection(
            processRunner.ProcessSpecs,
            check => Assert.Equal("buildx version", check.Arguments),
            context => Assert.Equal("context show", context.Arguments));
    }
 
    [Fact]
    public async Task BuildImageAsync_LocalImageOciArchiveFailsBeforeInvokingDocker()
    {
        var processRunner = new TestProcessRunner();
        var runtime = new DockerContainerRuntime(
            NullLogger<DockerContainerRuntime>.Instance,
            processRunner);
        var options = new ContainerImageBuildOptions
        {
            ImageName = "myapp",
            Tag = "latest",
            OutputPath = "out",
            ImageFormat = ContainerImageFormat.Oci,
            RequiresLocalImageStore = true
        };
 
        var exception = await Assert.ThrowsAsync<DistributedApplicationException>(
            () => BuildImageAsync(runtime, options));
 
        Assert.Equal(
            "Docker cannot export an OCI archive when container-file layering references locally built images. " +
            "Use ContainerImageFormat.Docker for this archive or run the publish with Podman.",
            exception.Message);
        Assert.Empty(processRunner.ProcessSpecs);
    }
 
    [Fact]
    public async Task BuildImageAsync_LocalImageArchiveRejectsInvalidFormat()
    {
        var processRunner = new TestProcessRunner();
        var runtime = new DockerContainerRuntime(
            NullLogger<DockerContainerRuntime>.Instance,
            processRunner);
        var options = new ContainerImageBuildOptions
        {
            ImageName = "myapp",
            Tag = "latest",
            OutputPath = "out",
            ImageFormat = (ContainerImageFormat)42,
            RequiresLocalImageStore = true
        };
 
        var exception = await Assert.ThrowsAsync<ArgumentOutOfRangeException>(
            () => BuildImageAsync(runtime, options));
 
        Assert.Equal("options", exception.ParamName);
        Assert.Empty(processRunner.ProcessSpecs);
    }
 
    [Fact]
    public async Task BuildImageAsync_WithoutArchiveUsesDefaultBuilder()
    {
        var processRunner = new TestProcessRunner();
        processRunner.EnqueueResult();
        processRunner.EnqueueResult();
        var runtime = new DockerContainerRuntime(
            NullLogger<DockerContainerRuntime>.Instance,
            processRunner);
        var options = new ContainerImageBuildOptions
        {
            ImageName = "myapp",
            Tag = "latest",
            ImageFormat = ContainerImageFormat.Docker
        };
 
        await runtime.BuildImageAsync(
            contextPath: "context",
            dockerfilePath: "Dockerfile",
            options: options,
            buildArguments: [],
            buildSecrets: [],
            stage: null,
            cancellationToken: CancellationToken.None);
 
        Assert.Collection(
            processRunner.ProcessSpecs,
            check => Assert.Equal("buildx version", check.Arguments),
            build => Assert.Equal(
                $"buildx build --file \"Dockerfile\" --tag \"myapp:latest\" " +
                $"--output \"type=docker\" \"{GetNormalizedContextPath()}\"",
                build.Arguments));
    }
 
    private static Task BuildImageAsync(
        DockerContainerRuntime runtime,
        ContainerImageBuildOptions options)
    {
        return runtime.BuildImageAsync(
            contextPath: "context",
            dockerfilePath: "Dockerfile",
            options: options,
            buildArguments: [],
            buildSecrets: [],
            stage: null,
            cancellationToken: CancellationToken.None);
    }
 
    private static string AssertAndGetBuilderName(object? argumentsValue)
    {
        var arguments = Assert.IsType<string>(argumentsValue);
        const string prefix = "buildx create --name \"";
        const string suffix = "\" --driver docker-container";
        Assert.StartsWith(prefix, arguments, StringComparison.Ordinal);
        Assert.EndsWith(suffix, arguments, StringComparison.Ordinal);
        var builderName = arguments[prefix.Length..^suffix.Length];
        Assert.StartsWith("aspire-", builderName, StringComparison.Ordinal);
        Assert.True(Guid.TryParseExact(builderName["aspire-".Length..], "N", out _));
        return builderName;
    }
 
    private static string GetNormalizedContextPath()
    {
        return Path.GetFullPath("context")
            .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
    }
}