File: Publishing\DockerContainerRuntime.cs
Web Access
Project: src\src\Aspire.Hosting\Aspire.Hosting.csproj (Aspire.Hosting)
// 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 ASPIREPIPELINES003
#pragma warning disable ASPIRECONTAINERRUNTIME001
 
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Dcp.Process;
using Aspire.Shared;
using Microsoft.Extensions.Logging;
 
namespace Aspire.Hosting.Publishing;
 
internal sealed class DockerContainerRuntime : ContainerRuntimeBase<DockerContainerRuntime>
{
    private const string LocalImageOciArchiveNotSupportedMessage =
        "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.";
    private readonly ILogger<DockerContainerRuntime> _logger;
 
    public DockerContainerRuntime(ILogger<DockerContainerRuntime> logger, IProcessRunner processRunner) : base(logger, processRunner)
    {
        _logger = logger;
    }
 
    protected override string RuntimeExecutable => KnownContainerRuntimes.Docker;
    public override string Name => "Docker";
    private async Task RunDockerBuildAsync(string contextPath, string dockerfilePath, ContainerImageBuildOptions? options, Dictionary<string, string?> buildArguments, Dictionary<string, BuildImageSecretValue> buildSecrets, string? stage, CancellationToken cancellationToken)
    {
        var imageName = !string.IsNullOrEmpty(options?.Tag)
            ? $"{options.ImageName}:{options.Tag}"
            : options?.ImageName ?? throw new ArgumentException("ImageName must be provided in options.", nameof(options));
 
        string? selectedBuilderName = null;
        string? isolatedBuilderName = null;
        var exportsArchive = !string.IsNullOrEmpty(options?.OutputPath);
        var requiresLocalImageStore = options?.RequiresLocalImageStore == true;
        var exportsLocalImageArchive = exportsArchive && requiresLocalImageStore;
 
        if (options?.ImageFormat == ContainerImageFormat.Oci)
        {
            if (!exportsArchive)
            {
                throw new ArgumentException("OutputPath must be provided when ImageFormat is Oci.", nameof(options));
            }
        }
 
        if (requiresLocalImageStore)
        {
            // Container-file layering refers to images tagged only in the active daemon's image store.
            // A Docker context name selects that context's daemon-backed Buildx builder, avoiding an
            // ambient custom builder that cannot resolve the local-only image.
            selectedBuilderName = await GetActiveDockerContextAsync(imageName, cancellationToken).ConfigureAwait(false);
        }
        else if (exportsArchive)
        {
            // Docker's in-daemon builder cannot reliably write Docker or OCI archive exporters.
            // Use an isolated BuildKit container for archive output and remove it after the build.
            // https://docs.docker.com/build/exporters/
            isolatedBuilderName = $"aspire-{Guid.NewGuid():N}";
            await CreateBuildkitInstanceAsync(isolatedBuilderName, cancellationToken).ConfigureAwait(false);
            selectedBuilderName = isolatedBuilderName;
        }
 
        try
        {
            var arguments = $"buildx build --file \"{dockerfilePath}\" --tag \"{imageName}\"";
 
            if (!string.IsNullOrEmpty(selectedBuilderName))
            {
                arguments += $" --builder \"{selectedBuilderName}\"";
            }
 
            // Add platform support if specified
            if (options?.TargetPlatform is not null)
            {
                arguments += $" --platform \"{options.TargetPlatform.Value.ToRuntimePlatformString()}\"";
            }
 
            // Add output format support if specified
            if (!exportsLocalImageArchive &&
                (options?.ImageFormat is not null || !string.IsNullOrEmpty(options?.OutputPath)))
            {
                var outputType = options?.ImageFormat switch
                {
                    ContainerImageFormat.Oci => "type=oci",
                    ContainerImageFormat.Docker => "type=docker",
                    null => "type=docker",
                    _ => throw new ArgumentOutOfRangeException(nameof(options), options.ImageFormat, "Invalid container image format")
                };
 
                if (!string.IsNullOrEmpty(options?.OutputPath))
                {
                    var archivePath = ResourceExtensions.GetContainerImageArchivePath(options.OutputPath, imageName);
                    outputType += $",dest={archivePath}";
                }
 
                arguments += $" --output \"{outputType}\"";
            }
 
            // Add build arguments if specified
            arguments += BuildArgumentsString(buildArguments);
 
            // Add build secrets if specified
            arguments += BuildSecretsString(buildSecrets);
 
            // Add stage if specified
            arguments += BuildStageString(stage);
 
            arguments += $" \"{contextPath}\"";
 
            // Prepare environment variables for build secrets
            var environmentVariables = new Dictionary<string, string>();
            foreach (var buildSecret in buildSecrets)
            {
                if (buildSecret.Value.Type == BuildImageSecretType.Environment && buildSecret.Value.Value is not null)
                {
                    environmentVariables[buildSecret.Key.ToUpperInvariant()] = buildSecret.Value.Value;
                }
            }
 
            var processResult = await ExecuteContainerCommandWithResultAsync(
                arguments,
                "Docker build for {ImageName} failed with exit code {ExitCode}.",
                "Docker build for {ImageName} succeeded.",
                cancellationToken,
                new object[] { imageName },
                environmentVariables,
                retainOutput: true).ConfigureAwait(false);
 
            if (processResult.ExitCode != 0)
            {
                throw new ProcessFailedException(
                    $"Docker build failed with exit code {processResult.ExitCode}.",
                    processResult.ExitCode,
                    processResult.ProcessOutput,
                    processResult.TotalProcessOutputLineCount);
            }
 
            if (exportsLocalImageArchive)
            {
                await RunDockerSaveAsync(imageName, options!, cancellationToken).ConfigureAwait(false);
            }
        }
        finally
        {
            if (!string.IsNullOrEmpty(isolatedBuilderName))
            {
                await RemoveBuildkitInstanceBestEffortAsync(isolatedBuilderName).ConfigureAwait(false);
            }
        }
    }
 
    public override async Task BuildImageAsync(string contextPath, string dockerfilePath, ContainerImageBuildOptions? options, Dictionary<string, string?> buildArguments, Dictionary<string, BuildImageSecretValue> buildSecrets, string? stage, CancellationToken cancellationToken)
    {
        if (options?.RequiresLocalImageStore == true &&
            !string.IsNullOrEmpty(options.OutputPath))
        {
            switch (options.ImageFormat)
            {
                case ContainerImageFormat.Oci:
                    throw new DistributedApplicationException(LocalImageOciArchiveNotSupportedMessage);
                case ContainerImageFormat.Docker:
                case null:
                    break;
                default:
                    throw new ArgumentOutOfRangeException(
                        nameof(options),
                        options.ImageFormat,
                        "Invalid container image format");
            }
        }
 
        // Verify buildx is available before attempting a Dockerfile build
        if (!await CheckDockerBuildxAsync(cancellationToken).ConfigureAwait(false))
        {
            throw new DistributedApplicationException(
                "Docker buildx is not available. Install the buildx plugin and try again.");
        }
 
        // Normalize the context path to handle trailing slashes and relative paths
        var normalizedContextPath = Path.GetFullPath(contextPath).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
 
        await RunDockerBuildAsync(
            normalizedContextPath,
            dockerfilePath,
            options,
            buildArguments,
            buildSecrets,
            stage,
            cancellationToken).ConfigureAwait(false);
    }
 
    private async Task<string> GetActiveDockerContextAsync(string imageName, CancellationToken cancellationToken)
    {
        var output = await ExecuteContainerCommandForOutputAsync(
            "context show",
            "context discovery",
            imageName,
            cancellationToken).ConfigureAwait(false);
 
        // `docker context show` emits exactly one context name, for example:
        //   desktop-linux
        var contextNames = output.Split(
            Environment.NewLine,
            StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
        if (contextNames is not [var contextName])
        {
            throw new DistributedApplicationException("Docker did not report exactly one active context.");
        }
 
        return contextName;
    }
 
    private async Task RunDockerSaveAsync(
        string imageName,
        ContainerImageBuildOptions options,
        CancellationToken cancellationToken)
    {
        var arguments = BuildSaveArguments(imageName, options);
        var processResult = await ExecuteContainerCommandWithResultAsync(
            arguments,
            "Docker image save for {ImageName} failed with exit code {ExitCode}.",
            "Docker image save for {ImageName} succeeded.",
            cancellationToken,
            new object[] { imageName },
            retainOutput: true).ConfigureAwait(false);
 
        if (processResult.ExitCode != 0)
        {
            throw new ProcessFailedException(
                $"Docker image save failed with exit code {processResult.ExitCode}.",
                processResult.ExitCode,
                processResult.ProcessOutput,
                processResult.TotalProcessOutputLineCount);
        }
    }
 
    /// <summary>
    /// Builds the arguments that save a locally built image as a Docker archive.
    /// </summary>
    internal static string BuildSaveArguments(string imageName, ContainerImageBuildOptions options)
    {
        var outputPath = options.OutputPath;
        ArgumentException.ThrowIfNullOrEmpty(outputPath);
        var archivePath = ResourceExtensions.GetContainerImageArchivePath(outputPath, imageName);
 
        // Layered archives use a private tag whose platforms were already selected by the build.
        // Save all its variants without requiring API 1.48+ (1.52+ for multiple platforms) for filtering.
        // https://docs.docker.com/reference/cli/docker/image/save/#platform
        return $"image save --output \"{archivePath}\" \"{imageName}\"";
    }
 
    public override async Task<bool> CheckIfRunningAsync(CancellationToken cancellationToken)
    {
        return await CheckDockerDaemonAsync(cancellationToken).ConfigureAwait(false);
    }
 
    private async Task<bool> CheckDockerDaemonAsync(CancellationToken cancellationToken)
    {
        try
        {
            var exitCode = await ExecuteContainerCommandWithExitCodeAsync(
                "container ls -n 1",
                "Docker daemon is not running. Exit code: {ExitCode}.",
                "Docker daemon is running.",
                cancellationToken,
                Array.Empty<object>()).ConfigureAwait(false);
 
            return exitCode == 0;
        }
        catch
        {
            return false;
        }
    }
 
    private async Task<bool> CheckDockerBuildxAsync(CancellationToken cancellationToken)
    {
        try
        {
            var exitCode = await ExecuteContainerCommandWithExitCodeAsync(
                "buildx version",
                "Docker buildx version failed with exit code {ExitCode}.",
                "Docker buildx is available.",
                cancellationToken,
                Array.Empty<object>()).ConfigureAwait(false);
 
            return exitCode == 0;
        }
        catch
        {
            return false;
        }
    }
 
    private async Task CreateBuildkitInstanceAsync(string builderName, CancellationToken cancellationToken)
    {
        var arguments = $"buildx create --name \"{builderName}\" --driver docker-container";
        var processResult = await ExecuteContainerCommandWithResultAsync(
            arguments,
            "Failed to create buildkit instance {BuilderName} with exit code {ExitCode}.",
            "Successfully created buildkit instance {BuilderName}.",
            cancellationToken,
            new object[] { builderName },
            retainOutput: true).ConfigureAwait(false);
 
        if (processResult.ExitCode != 0)
        {
            throw new ProcessFailedException(
                $"Failed to create buildkit instance '{builderName}' with exit code {processResult.ExitCode}.",
                processResult.ExitCode,
                processResult.ProcessOutput,
                processResult.TotalProcessOutputLineCount);
        }
    }
 
    private async Task<int> RemoveBuildkitInstanceAsync(string builderName, CancellationToken cancellationToken)
    {
        var arguments = $"buildx rm \"{builderName}\"";
 
        return await ExecuteContainerCommandWithExitCodeAsync(
            arguments,
            "Failed to remove buildkit instance {BuilderName} with exit code {ExitCode}.",
            "Successfully removed buildkit instance {BuilderName}.",
            cancellationToken,
            new object[] { builderName }).ConfigureAwait(false);
    }
 
    private async Task RemoveBuildkitInstanceBestEffortAsync(string builderName)
    {
        // Cleanup must outlive a canceled publish, but it is bounded so an unresponsive Docker
        // daemon cannot delay cancellation indefinitely.
        using var cleanupCancellation = new CancellationTokenSource(TimeSpan.FromSeconds(10));
        try
        {
            await RemoveBuildkitInstanceAsync(builderName, cleanupCancellation.Token).ConfigureAwait(false);
        }
        catch (Exception ex)
        {
            _logger.LogWarning(ex, "Failed to remove buildkit instance {BuilderName}", builderName);
        }
    }
}