File: Publishing\ResourceContainerImageManager.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
#pragma warning disable ASPIREPROJECTS001
#pragma warning disable ASPIREEXTENSION001
 
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Dcp.Process;
using Aspire.Hosting.Internal;
using Aspire.Hosting.Utils;
using Aspire.Shared;
using Microsoft.Extensions.Logging;
 
namespace Aspire.Hosting.Publishing;
 
/// <summary>
/// Specifies the format for container images.
/// </summary>
[Experimental("ASPIREPIPELINES003", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
public enum ContainerImageFormat
{
    /// <summary>
    /// Docker format (default).
    /// </summary>
    Docker,
 
    /// <summary>
    /// OCI format.
    /// </summary>
    Oci
}
 
/// <summary>
/// Specifies the destination for container images.
/// </summary>
[Experimental("ASPIREPIPELINES003", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
public enum ContainerImageDestination
{
    /// <summary>
    /// Image will be pushed to a container registry.
    /// </summary>
    Registry,
 
    /// <summary>
    /// Image will be saved as an archive file.
    /// </summary>
    Archive
}
 
/// <summary>
/// Specifies the target platform for container images.
/// </summary>
[Experimental("ASPIREPIPELINES003", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
[Flags]
public enum ContainerTargetPlatform
{
    /// <summary>
    /// Linux AMD64 (linux/amd64).
    /// </summary>
    LinuxAmd64 = 1,
 
    /// <summary>
    /// Linux ARM64 (linux/arm64).
    /// </summary>
    LinuxArm64 = 2,
 
    /// <summary>
    /// Linux ARM (linux/arm).
    /// </summary>
    LinuxArm = 4,
 
    /// <summary>
    /// Linux 386 (linux/386).
    /// </summary>
    Linux386 = 8,
 
    /// <summary>
    /// Windows AMD64 (windows/amd64).
    /// </summary>
    WindowsAmd64 = 16,
 
    /// <summary>
    /// Windows ARM64 (windows/arm64).
    /// </summary>
    WindowsArm64 = 32,
 
    /// <summary>
    /// All Linux platforms (AMD64 and ARM64).
    /// </summary>
    AllLinux = LinuxAmd64 | LinuxArm64
}
 
/// <summary>
/// Options for building container images.
/// </summary>
[Experimental("ASPIREPIPELINES003", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
public class ContainerImageBuildOptions
{
    /// <summary>
    /// Gets the name to assign to the built image.
    /// </summary>
    public string? ImageName { get; init; }
 
    /// <summary>
    /// Gets the tag to assign to the built image.
    /// </summary>
    public string? Tag { get; init; }
 
    /// <summary>
    /// Gets the destination for the container image.
    /// </summary>
    public ContainerImageDestination? Destination { get; init; }
 
    /// <summary>
    /// Gets the output path for the container archive.
    /// </summary>
    public string? OutputPath { get; init; }
 
    /// <summary>
    /// Gets the container image format.
    /// </summary>
    public ContainerImageFormat? ImageFormat { get; init; }
 
    /// <summary>
    /// Gets the target platform for the container.
    /// </summary>
    public ContainerTargetPlatform? TargetPlatform { get; init; }
 
    /// <summary>
    /// Gets a value indicating whether the Dockerfile references images available only in the local runtime store.
    /// </summary>
    internal bool RequiresLocalImageStore { get; init; }
}
 
/// <summary>
/// Provides a service to publishers for building and pushing container images that represent a resource.
/// </summary>
[Experimental("ASPIREPIPELINES003", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
public interface IResourceContainerImageManager
{
    /// <summary>
    /// Builds a container that represents the specified resource.
    /// </summary>
    /// <param name="resource">The resource to build.</param>
    /// <param name="cancellationToken">The cancellation token.</param>
    Task BuildImageAsync(IResource resource, CancellationToken cancellationToken = default);
 
    /// <summary>
    /// Builds container images for a collection of resources.
    /// </summary>
    /// <param name="resources">The resources to build images for.</param>
    /// <param name="cancellationToken">The cancellation token.</param>
    /// <returns></returns>
    Task BuildImagesAsync(IEnumerable<IResource> resources, CancellationToken cancellationToken = default);
 
    /// <summary>
    /// Pushes a container image to a registry.
    /// </summary>
    /// <param name="resource">The resource to push.</param>
    /// <param name="cancellationToken">The cancellation token.</param>
    Task PushImageAsync(IResource resource, CancellationToken cancellationToken);
}
 
internal interface IDotnetProgramContainerImageManager
{
    Task<DotnetProgramImageBuildResult> BuildDotnetProgramImageAsync(
        IResource resource,
        IReadOnlyList<DotnetProgramBuildEnvironmentCallbackAnnotation> buildEnvironmentCallbacks,
        Func<IContainerRuntime, CancellationToken, Task> ensureContainerRuntimeRunning,
        CancellationToken cancellationToken);
}
 
internal sealed class DotnetProgramImageBuildResult(
    string localImageName,
    string localImageTag,
    string sourceImageReference,
    ContainerTargetPlatform? targetPlatform,
    ContainerImageDestination? destination,
    string? outputPath,
    ContainerImageFormat? imageFormat,
    string containerWorkingDirectory,
    IDisposable? buildContext,
    TemporaryContainerImage? temporarySourceImage) : IAsyncDisposable
{
    public string LocalImageName { get; } = localImageName;
 
    public string LocalImageTag { get; } = localImageTag;
 
    public string SourceImageReference { get; } = sourceImageReference;
 
    public ContainerTargetPlatform? TargetPlatform { get; } = targetPlatform;
 
    public ContainerImageDestination? Destination { get; } = destination;
 
    public string? OutputPath { get; } = outputPath;
 
    public ContainerImageFormat? ImageFormat { get; } = imageFormat;
 
    public string ContainerWorkingDirectory { get; } = containerWorkingDirectory;
 
    public async ValueTask DisposeAsync()
    {
        try
        {
            buildContext?.Dispose();
        }
        finally
        {
            if (temporarySourceImage is not null)
            {
                await temporarySourceImage.DisposeAsync().ConfigureAwait(false);
            }
        }
    }
}
 
internal sealed class ResourceContainerImageManager(
    ILogger<ResourceContainerImageManager> logger,
    IContainerRuntimeResolver containerRuntimeResolver,
    IProcessRunner processRunner,
    IServiceProvider serviceProvider,
    DistributedApplicationExecutionContext? executionContext = null) :
    IResourceContainerImageManager,
    IDotnetProgramContainerImageManager
{
    private const string CrossOsAotDiagnostic = "Cross-OS native compilation is not supported.";
 
    // These .NET SDK properties select the image artifact that Aspire passes to later layering,
    // tagging, and deployment steps. Build-environment global properties cannot change them without
    // desynchronizing the SDK output from the artifact tracked by the publishing pipeline.
    // https://learn.microsoft.com/dotnet/core/containers/publish-configuration
    private static readonly HashSet<string> s_containerArtifactProperties = new(StringComparer.OrdinalIgnoreCase)
    {
        "ContainerRepository",
        "ContainerImageTag",
        "ContainerImageTags",
        "ContainerRegistry",
        "ContainerImageName",
        "PublishImageTag",
        "AutoGenerateImageTag",
        "RegistryUrl",
        "ContainerArchiveOutputPath",
        "ContainerImageFormat",
        "LocalRegistry",
        "RuntimeIdentifier",
        "RuntimeIdentifiers",
        "ContainerRuntimeIdentifier",
        "ContainerRuntimeIdentifiers"
    };
 
    // Disable concurrent builds for project resources to avoid issues with overlapping msbuild projects
    private readonly SemaphoreSlim _throttle = new(1);
 
    private async Task<IContainerRuntime> GetContainerRuntimeAsync(CancellationToken cancellationToken)
        => await containerRuntimeResolver.ResolveAsync(cancellationToken).ConfigureAwait(false);
 
    private sealed record ResolvedContainerBuildOptions
    {
        public string? OutputPath { get; set; }
        public ContainerImageFormat? ImageFormat { get; set; }
        public ContainerTargetPlatform? TargetPlatform { get; set; }
        public ContainerImageDestination? Destination { get; set; }
        public string LocalImageName { get; set; } = string.Empty;
        public string LocalImageTag { get; set; } = "latest";
    }
 
    private async Task<ResolvedContainerBuildOptions> ResolveContainerBuildOptionsAsync(
        IResource resource,
        CancellationToken cancellationToken)
    {
        var options = new ResolvedContainerBuildOptions
        {
            LocalImageName = resource.Name,
            LocalImageTag = "latest"
        };
 
        var context = await resource.ProcessContainerBuildOptionsCallbackAsync(
            serviceProvider,
            logger,
            executionContext,
            cancellationToken).ConfigureAwait(false);
 
        options.OutputPath = context.OutputPath;
        options.ImageFormat = context.ImageFormat;
        options.TargetPlatform = context.TargetPlatform;
        options.Destination = context.Destination;
        options.LocalImageName = context.LocalImageName ?? options.LocalImageName;
        options.LocalImageTag = context.LocalImageTag ?? options.LocalImageTag;
 
        return options;
    }
 
    public async Task BuildImagesAsync(IEnumerable<IResource> resources, CancellationToken cancellationToken = default)
    {
        cancellationToken.ThrowIfCancellationRequested();
        logger.LogInformation("Starting to build container images");
 
        var resourceList = resources.ToList();
        foreach (var resource in resourceList)
        {
            DotnetProgramPublishing.ValidatePrebuiltContainerImageConfiguration(resource);
        }
 
        var resourcesToBuild = resourceList
            .Where(static resource => !resource.HasPrebuiltContainerImage())
            .ToList();
        if (resourcesToBuild.Count == 0)
        {
            logger.LogDebug("Building container images completed");
            return;
        }
 
        var containerRuntime = await GetContainerRuntimeAsync(cancellationToken).ConfigureAwait(false);
 
        // Only check container runtime health if there are resources that need it
        if (await ResourcesRequireContainerRuntimeAsync(resourcesToBuild, cancellationToken).ConfigureAwait(false))
        {
            logger.LogDebug("Checking {ContainerRuntimeName} health", containerRuntime.Name);
 
            var containerRuntimeHealthy = await containerRuntime.CheckIfRunningAsync(cancellationToken).ConfigureAwait(false);
 
            if (!containerRuntimeHealthy)
            {
                logger.LogError("Container runtime '{ContainerRuntimeName}' is not running or is unhealthy. Cannot build container images.", containerRuntime.Name);
                throw new InvalidOperationException($"Container runtime '{containerRuntime.Name}' is not running or is unhealthy.");
            }
 
            logger.LogDebug("{ContainerRuntimeName} is healthy", containerRuntime.Name);
        }
 
        foreach (var resource in resourcesToBuild)
        {
            // TODO: Consider parallelizing this.
            await BuildImageAsync(resource, cancellationToken).ConfigureAwait(false);
        }
 
        logger.LogDebug("Building container images completed");
    }
 
    public async Task BuildImageAsync(IResource resource, CancellationToken cancellationToken = default)
    {
        cancellationToken.ThrowIfCancellationRequested();
 
        DotnetProgramPublishing.ValidatePrebuiltContainerImageConfiguration(resource);
 
        if (resource.HasPrebuiltContainerImage())
        {
            logger.LogDebug(
                "Resource {ResourceName} already has a container image associated and no build annotation. Skipping build.",
                resource.Name);
            return;
        }
 
        if (resource.SupportsDotnetProgramPublishing())
        {
            var result = await BuildDotnetProgramImageAsync(
                resource,
                resource.Annotations.OfType<DotnetProgramBuildEnvironmentCallbackAnnotation>().ToArray(),
                EnsureContainerRuntimeRunningAsync,
                cancellationToken).ConfigureAwait(false);
            await using var resultLifetime = result.ConfigureAwait(false);
 
            if (resource.TryGetAnnotationsOfType<ContainerFilesDestinationAnnotation>(out _))
            {
                await DotnetProgramPublishing.LayerContainerFilesAsync(
                    resource,
                    resource.GetProjectMetadata(),
                    result,
                    serviceProvider,
                    logger,
                    cancellationToken).ConfigureAwait(false);
            }
 
            return;
        }
 
        var containerRuntime = await GetContainerRuntimeAsync(cancellationToken).ConfigureAwait(false);
        logger.LogInformation("Building container image for resource {ResourceName}", resource.Name);
 
        var options = await ResolveContainerBuildOptionsAsync(resource, cancellationToken).ConfigureAwait(false);
 
        if (ResourceRequiresContainerRuntime(resource, options))
        {
            logger.LogDebug("Checking {ContainerRuntimeName} health", containerRuntime.Name);
 
            var containerRuntimeHealthy = await containerRuntime.CheckIfRunningAsync(cancellationToken).ConfigureAwait(false);
 
            if (!containerRuntimeHealthy)
            {
                logger.LogError("Container runtime '{ContainerRuntimeName}' is not running or is unhealthy. Cannot build container image.", containerRuntime.Name);
                throw new InvalidOperationException($"Container runtime '{containerRuntime.Name}' is not running or is unhealthy.");
            }
 
            logger.LogDebug("{ContainerRuntimeName} is healthy", containerRuntime.Name);
        }
 
        if (resource.TryGetLastAnnotation<DockerfileBuildAnnotation>(out var dockerfileBuildAnnotation))
        {
            if (!resource.TryGetContainerImageName(out var imageName))
            {
                throw new InvalidOperationException($"The container image name for resource '{resource.Name}' could not be determined.");
            }
 
            // This is a container resource so we'll use the container runtime to build the image
            await BuildContainerImageFromDockerfileAsync(
                resource,
                dockerfileBuildAnnotation,
                imageName,
                options,
                cancellationToken).ConfigureAwait(false);
            return;
        }
        throw new NotSupportedException($"The resource '{resource.Name}' of type '{resource.GetType().Name}' is not supported.");
    }
 
    async Task<DotnetProgramImageBuildResult> IDotnetProgramContainerImageManager.BuildDotnetProgramImageAsync(
        IResource resource,
        IReadOnlyList<DotnetProgramBuildEnvironmentCallbackAnnotation> buildEnvironmentCallbacks,
        Func<IContainerRuntime, CancellationToken, Task> ensureContainerRuntimeRunning,
        CancellationToken cancellationToken) =>
        await BuildDotnetProgramImageAsync(resource, buildEnvironmentCallbacks, ensureContainerRuntimeRunning, cancellationToken).ConfigureAwait(false);
 
    private async Task<DotnetProgramImageBuildResult> BuildDotnetProgramImageAsync(
        IResource resource,
        IReadOnlyList<DotnetProgramBuildEnvironmentCallbackAnnotation> buildEnvironmentCallbacks,
        Func<IContainerRuntime, CancellationToken, Task> ensureContainerRuntimeRunning,
        CancellationToken cancellationToken)
    {
        logger.LogInformation("Building container image for resource {ResourceName}", resource.Name);
        var options = await ResolveContainerBuildOptionsAsync(resource, cancellationToken).ConfigureAwait(false);
        if (options.Destination == ContainerImageDestination.Archive &&
            string.IsNullOrEmpty(options.OutputPath))
        {
            throw new InvalidOperationException(
                $"Resource '{resource.Name}' has Destination set to Archive but OutputPath is not configured. " +
                "Please set the OutputPath in the container build options.");
        }
 
        var hasContainerFiles = resource.TryGetAnnotationsOfType<ContainerFilesDestinationAnnotation>(out _);
        var containerRuntime = await GetContainerRuntimeAsync(cancellationToken).ConfigureAwait(false);
        if (hasContainerFiles || ResourceRequiresContainerRuntime(resource, options))
        {
            await ensureContainerRuntimeRunning(containerRuntime, cancellationToken).ConfigureAwait(false);
        }
 
        await _throttle.WaitAsync(cancellationToken).ConfigureAwait(false);
        DotnetProgramBuildContext? buildContext = null;
        TemporaryContainerImage? temporarySourceImage = null;
 
        try
        {
            logger.LogInformation("Building image: {ResourceName}", resource.Name);
 
            var projectMetadata = resource.GetProjectMetadata();
            buildContext = await CreateDotnetProgramBuildContextAsync(
                resource,
                projectMetadata,
                buildEnvironmentCallbacks,
                cancellationToken).ConfigureAwait(false);
 
            var sdkPublishOptions = options;
            if (hasContainerFiles && options.Destination == ContainerImageDestination.Archive)
            {
                // The SDK must publish locally for layering, but an archive must not overwrite a
                // caller's existing daemon tag, even briefly. Keep its final identity in options.
                sdkPublishOptions = options with
                {
                    LocalImageTag = $"aspire-sdk-{Guid.NewGuid():N}",
                    Destination = null,
                    OutputPath = null,
                    ImageFormat = null
                };
                temporarySourceImage = new TemporaryContainerImage(
                    containerRuntime,
                    $"{sdkPublishOptions.LocalImageName}:{sdkPublishOptions.LocalImageTag}",
                    logger);
            }
 
            await ExecuteDotnetPublishAsync(
                resource,
                projectMetadata,
                sdkPublishOptions,
                buildContext,
                cancellationToken).ConfigureAwait(false);
 
            var containerWorkingDirectory = hasContainerFiles
                ? await GetContainerWorkingDirectoryAsync(
                    projectMetadata,
                    options,
                    buildContext,
                    cancellationToken).ConfigureAwait(false)
                : "/app";
 
            logger.LogInformation("Building image for {ResourceName} completed", resource.Name);
            var result = new DotnetProgramImageBuildResult(
                options.LocalImageName,
                options.LocalImageTag,
                string.Equals(sdkPublishOptions.LocalImageTag, "latest", StringComparison.Ordinal)
                    ? sdkPublishOptions.LocalImageName
                    : $"{sdkPublishOptions.LocalImageName}:{sdkPublishOptions.LocalImageTag}",
                options.TargetPlatform,
                options.Destination,
                options.OutputPath,
                options.ImageFormat,
                containerWorkingDirectory,
                buildContext,
                temporarySourceImage);
            buildContext = null;
            temporarySourceImage = null;
            return result;
        }
        catch (OperationCanceledException)
        {
            throw;
        }
        catch (Exception ex)
        {
            logger.LogError(ex, "Building image for {ResourceName} failed", resource.Name);
            throw;
        }
        finally
        {
            _throttle.Release();
            try
            {
                buildContext?.Dispose();
            }
            finally
            {
                if (temporarySourceImage is not null)
                {
                    await temporarySourceImage.DisposeAsync().ConfigureAwait(false);
                }
            }
        }
    }
 
    private async Task EnsureContainerRuntimeRunningAsync(IContainerRuntime containerRuntime, CancellationToken cancellationToken)
    {
        logger.LogDebug("Checking {ContainerRuntimeName} health", containerRuntime.Name);
        if (!await containerRuntime.CheckIfRunningAsync(cancellationToken).ConfigureAwait(false))
        {
            logger.LogError("Container runtime '{ContainerRuntimeName}' is not running or is unhealthy. Cannot build container image.", containerRuntime.Name);
            throw new InvalidOperationException($"Container runtime '{containerRuntime.Name}' is not running or is unhealthy.");
        }
    }
 
    private async Task ExecuteDotnetPublishAsync(
        IResource resource,
        IProjectMetadata projectMetadata,
        ResolvedContainerBuildOptions options,
        DotnetProgramBuildContext buildContext,
        CancellationToken cancellationToken)
    {
        var containerRuntime = await GetContainerRuntimeAsync(cancellationToken).ConfigureAwait(false);
        var arguments = new List<string>
        {
            "publish",
            projectMetadata.ProjectPath,
            "--configuration",
            "Release",
            "/t:PublishContainer",
            MsBuildResponseFileFactory.CreatePropertyArgument("ContainerRepository", options.LocalImageName),
            MsBuildResponseFileFactory.CreatePropertyArgument("ContainerImageTag", options.LocalImageTag)
        };
 
        if (GetLocalRegistryName(containerRuntime) is string localRegistry)
        {
            arguments.Add(MsBuildResponseFileFactory.CreatePropertyArgument("LocalRegistry", localRegistry));
        }
 
        if (!string.IsNullOrEmpty(options.OutputPath))
        {
            arguments.Add(MsBuildResponseFileFactory.CreatePropertyArgument("ContainerArchiveOutputPath", options.OutputPath));
        }
 
        if (options.ImageFormat is not null)
        {
            var format = options.ImageFormat.Value switch
            {
                ContainerImageFormat.Docker => "Docker",
                ContainerImageFormat.Oci => "OCI",
                _ => throw new ArgumentOutOfRangeException(nameof(options), options.ImageFormat, "Invalid container image format")
            };
            arguments.Add(MsBuildResponseFileFactory.CreatePropertyArgument("ContainerImageFormat", format));
        }
 
        AddTargetPlatformArguments(arguments, options.TargetPlatform);
 
        if (resource.TryGetLastAnnotation<DockerfileBaseImageAnnotation>(out var baseImageAnnotation) &&
            baseImageAnnotation.RuntimeImage is string baseImage)
        {
            arguments.Add(MsBuildResponseFileFactory.CreatePropertyArgument("ContainerBaseImage", baseImage));
        }
 
        if (buildContext.ResponseFile is not null)
        {
            arguments.Add(buildContext.ResponseFile.Argument);
        }
 
        // The SDK can emit "error : Cross-OS native compilation is not supported." before
        // other output. Observe both streams before the retained output tail truncates it.
        var crossOsAotDiagnosticSeen = 0;
        void ObserveAotDiagnostic(string line)
        {
            if (line.Contains(CrossOsAotDiagnostic, StringComparison.Ordinal))
            {
                Interlocked.Exchange(ref crossOsAotDiagnosticSeen, 1);
            }
        }
 
        var spec = new ProcessSpec("dotnet")
        {
            ArgumentList = arguments,
            WorkingDirectory = buildContext.WorkingDirectory,
            EnvironmentVariables = new Dictionary<string, string>(buildContext.Environment, buildContext.Environment.Comparer),
            ThrowOnNonZeroReturnCode = false,
            RetainedOutputLineCount = ProcessSpec.DefaultRetainedOutputLineCount,
            OnOutputData = output =>
            {
                ObserveAotDiagnostic(output);
                logger.LogDebug("dotnet publish {ProjectPath} (stdout): {Output}", projectMetadata.ProjectPath, output);
            },
            OnErrorData = error =>
            {
                ObserveAotDiagnostic(error);
                logger.LogDebug("dotnet publish {ProjectPath} (stderr): {Error}", projectMetadata.ProjectPath, error);
            }
        };
 
        logger.LogDebug(
            "Starting .NET CLI with arguments: {Arguments}",
            string.Join(" ", arguments));
 
        var (pendingProcessResult, processDisposable) = processRunner.Run(spec);
 
        await using (processDisposable)
        {
            var processResult = await pendingProcessResult
                .WaitAsync(cancellationToken)
                .ConfigureAwait(false);
 
            if (processResult.ExitCode != 0)
            {
                var message =
                    $"Failed to build container image for resource '{resource.Name}' from project '{projectMetadata.ProjectPath}' with exit code {processResult.ExitCode}.";
                var crossOsDiagnosticPresent = Volatile.Read(ref crossOsAotDiagnosticSeen) != 0 ||
                    processResult.ProcessOutput.Any(static line => line.Contains(CrossOsAotDiagnostic, StringComparison.Ordinal));
                var guidance = GetFileAppAotGuidance(
                    projectMetadata,
                    options,
                    crossOsDiagnosticPresent);
                if (guidance is not null)
                {
                    message = $"{message}{Environment.NewLine}{guidance}";
                }
 
                throw new ProcessFailedException(
                    message,
                    processResult.ExitCode,
                    processResult.ProcessOutput,
                    processResult.TotalProcessOutputLineCount);
            }
            else
            {
                logger.LogDebug(
                    ".NET CLI completed with exit code: {ExitCode}",
                    processResult.ExitCode);
            }
        }
    }
 
    private static string? GetFileAppAotGuidance(
            IProjectMetadata projectMetadata,
            ResolvedContainerBuildOptions options,
            bool crossOsDiagnosticPresent)
    {
        if (!projectMetadata.IsFileBasedApp ||
            !IsCrossOperatingSystemTarget(options.TargetPlatform) ||
            !crossOsDiagnosticPresent)
        {
            return null;
        }
 
        var targetRuntimeIdentifiers = options.TargetPlatform?.ToMSBuildRuntimeIdentifierString() ?? "the configured target";
        return $"Native AOT cannot publish this file-based app for '{targetRuntimeIdentifiers}' from the current operating system. " +
            "File-based apps enable PublishAot by default. Add '#:property PublishAot=false' to the C# file, " +
            "or run Aspire publishing on the target operating system to retain Native AOT.";
    }
 
    private static bool IsCrossOperatingSystemTarget(ContainerTargetPlatform? targetPlatform)
    {
        if (targetPlatform is null)
        {
            return false;
        }
 
        var targetsLinux = (targetPlatform.Value & ContainerTargetPlatform.AllLinux) != 0 ||
            targetPlatform.Value.HasFlag(ContainerTargetPlatform.LinuxArm) ||
            targetPlatform.Value.HasFlag(ContainerTargetPlatform.Linux386);
        var targetsWindows = targetPlatform.Value.HasFlag(ContainerTargetPlatform.WindowsAmd64) ||
            targetPlatform.Value.HasFlag(ContainerTargetPlatform.WindowsArm64);
 
        return OperatingSystem.IsWindows()
            ? targetsLinux
            : targetsWindows || (OperatingSystem.IsMacOS() && targetsLinux);
    }
 
    private async Task<DotnetProgramBuildContext> CreateDotnetProgramBuildContextAsync(
            IResource resource,
            IProjectMetadata projectMetadata,
            IReadOnlyList<DotnetProgramBuildEnvironmentCallbackAnnotation> callbacks,
            CancellationToken cancellationToken)
    {
        var comparer = OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal;
        var unresolvedEnvironment = new Dictionary<string, object>(comparer);
 
        if (callbacks.Count > 0)
        {
            var activeExecutionContext = executionContext ?? throw new InvalidOperationException(
                $"An execution context is required to evaluate the build environment for resource '{resource.Name}'.");
            var callbackContext = new EnvironmentCallbackContext(
                activeExecutionContext,
                resource,
                unresolvedEnvironment,
                cancellationToken)
            {
                Logger = logger
            };
 
            foreach (var callback in callbacks)
            {
                await callback.ApplyAsync(callbackContext).ConfigureAwait(false);
            }
        }
 
        var environment = new Dictionary<string, string>(unresolvedEnvironment.Count, comparer);
        foreach (var (name, value) in unresolvedEnvironment)
        {
            if (value is not string stringValue)
            {
                throw new DistributedApplicationException(
                    $"The build environment variable '{name}' for .NET program resource '{resource.Name}' " +
                    $"has unsupported value type '{value?.GetType().Name ?? "null"}'. Build environment values must be strings.");
            }
 
            environment[name] = stringValue;
        }
 
        ValidateBuildEnvironmentForContainerPublishing(resource, environment);
 
        var responseFile = await MsBuildResponseFileFactory.CreateAsync(
            environment,
            logger,
            cancellationToken).ConfigureAwait(false);
        var workingDirectory = projectMetadata.BuildWorkingDirectory ??
            Path.GetDirectoryName(projectMetadata.ProjectPath);
 
        return new DotnetProgramBuildContext(environment, workingDirectory, responseFile);
    }
 
    private static void ValidateBuildEnvironmentForContainerPublishing(
        IResource resource,
        IReadOnlyDictionary<string, string> environment)
    {
        foreach (var propertyName in environment.Keys)
        {
            if (s_containerArtifactProperties.Contains(propertyName))
            {
                throw new DistributedApplicationException(
                    $"The build environment property '{propertyName}' for .NET program resource '{resource.Name}' " +
                    "is reserved by Aspire container publishing because it controls the image artifact used by downstream steps. " +
                    "Configure container publishing with WithContainerBuildOptions instead.");
            }
        }
    }
 
    private async Task<string> GetContainerWorkingDirectoryAsync(
            IProjectMetadata projectMetadata,
            ResolvedContainerBuildOptions options,
            DotnetProgramBuildContext buildContext,
            CancellationToken cancellationToken)
    {
        try
        {
            var outputLines = new List<string>();
            var command = projectMetadata.IsFileBasedApp ? "build" : "msbuild";
            var arguments = new List<string>
                {
                    command,
                    projectMetadata.ProjectPath,
                    "-p:Configuration=Release",
                    "-getProperty:ContainerWorkingDirectory",
                    "-v:q"
                };
            AddTargetPlatformArguments(arguments, options.TargetPlatform);
            if (buildContext.ResponseFile is not null)
            {
                arguments.Add(buildContext.ResponseFile.Argument);
            }
 
            var spec = new ProcessSpec("dotnet")
            {
                ArgumentList = arguments,
                WorkingDirectory = buildContext.WorkingDirectory,
                EnvironmentVariables = new Dictionary<string, string>(buildContext.Environment, buildContext.Environment.Comparer),
                OnOutputData = output =>
                {
                    if (!string.IsNullOrWhiteSpace(output))
                    {
                        outputLines.Add(output.Trim());
                    }
                },
                OnErrorData = error => logger.LogDebug("dotnet {Command} (stderr): {Error}", command, error),
                ThrowOnNonZeroReturnCode = false
            };
 
            logger.LogDebug(
                "Getting ContainerWorkingDirectory for .NET program {ProjectPath}",
                projectMetadata.ProjectPath);
            var (pendingResult, processDisposable) = processRunner.Run(spec);
 
            await using (processDisposable)
            {
                var result = await pendingResult.WaitAsync(cancellationToken).ConfigureAwait(false);
                if (result.ExitCode != 0)
                {
                    logger.LogDebug(
                        "Failed to get ContainerWorkingDirectory for .NET program {ProjectPath}. Exit code: {ExitCode}. Using default /app",
                        projectMetadata.ProjectPath,
                        result.ExitCode);
                    return "/app";
                }
 
                var workingDirectory = outputLines.LastOrDefault();
                if (string.IsNullOrWhiteSpace(workingDirectory))
                {
                    logger.LogDebug(
                        "dotnet {Command} returned an empty ContainerWorkingDirectory for .NET program {ProjectPath}. Using default /app",
                        command,
                        projectMetadata.ProjectPath);
                    return "/app";
                }
 
                return workingDirectory;
            }
        }
        catch (OperationCanceledException)
        {
            throw;
        }
        catch (Exception ex)
        {
            logger.LogDebug(ex, "Error getting ContainerWorkingDirectory. Using default /app");
            return "/app";
        }
    }
 
    private static void AddTargetPlatformArguments(
            List<string> arguments,
            ContainerTargetPlatform? targetPlatform)
    {
        if (targetPlatform is null)
        {
            return;
        }
 
        var runtimeIdentifiers = targetPlatform.Value.ToMSBuildRuntimeIdentifierString();
        if (runtimeIdentifiers.Contains(';'))
        {
            // ArgumentList preserves "linux-x64;linux-arm64" as one argument, but MSBuild still
            // splits property switches on ';'. Escape the value for that second parsing layer.
            // https://github.com/dotnet/msbuild/issues/471
            arguments.Add(MsBuildResponseFileFactory.CreatePropertyArgument("RuntimeIdentifiers", runtimeIdentifiers));
            arguments.Add(MsBuildResponseFileFactory.CreatePropertyArgument("ContainerRuntimeIdentifiers", runtimeIdentifiers));
        }
        else
        {
            arguments.Add(MsBuildResponseFileFactory.CreatePropertyArgument("RuntimeIdentifier", runtimeIdentifiers));
            arguments.Add(MsBuildResponseFileFactory.CreatePropertyArgument("ContainerRuntimeIdentifier", runtimeIdentifiers));
        }
    }
 
    private sealed class DotnetProgramBuildContext(
            Dictionary<string, string> environment,
            string? workingDirectory,
            MsBuildResponseFile? responseFile) : IDisposable
    {
        public Dictionary<string, string> Environment { get; } = environment;
 
        public string? WorkingDirectory { get; } = workingDirectory;
 
        public MsBuildResponseFile? ResponseFile { get; } = responseFile;
 
        public void Dispose() => ResponseFile?.Dispose();
    }
 
    private static bool ResourceRequiresContainerRuntime(
        IResource resource,
        ResolvedContainerBuildOptions options)
    {
        if (resource.TryGetLastAnnotation<ContainerImageAnnotation>(out _) &&
            resource.TryGetLastAnnotation<DockerfileBuildAnnotation>(out _))
        {
            return true;
        }
 
        if (options.Destination == ContainerImageDestination.Archive)
        {
            return false;
        }
 
        return options.ImageFormat is null or ContainerImageFormat.Docker ||
            options.OutputPath is null;
    }
 
    private static string? GetLocalRegistryName(IContainerRuntime containerRuntime)
    {
        // The .NET SDK container targets require these exact LocalRegistry values;
        // lower-case executable names fail with CONTAINER2002.
        if (string.Equals(containerRuntime.Name, KnownContainerRuntimes.Docker, StringComparison.OrdinalIgnoreCase))
        {
            return "Docker";
        }
 
        if (string.Equals(containerRuntime.Name, KnownContainerRuntimes.Podman, StringComparison.OrdinalIgnoreCase))
        {
            return "Podman";
        }
 
        return null;
    }
 
    private async Task BuildContainerImageFromDockerfileAsync(IResource resource, DockerfileBuildAnnotation dockerfileBuildAnnotation, string imageName, ResolvedContainerBuildOptions options, CancellationToken cancellationToken)
    {
        var containerRuntime = await GetContainerRuntimeAsync(cancellationToken).ConfigureAwait(false);
        logger.LogInformation("Building image: {ResourceName}", resource.Name);
 
        // If there's a factory, generate the Dockerfile content and write it to the specified path
        if (dockerfileBuildAnnotation.DockerfileFactory is not null)
        {
            await DockerfileHelper.ExecuteDockerfileFactoryAsync(dockerfileBuildAnnotation, resource, serviceProvider, cancellationToken).ConfigureAwait(false);
        }
 
        // Resolve build arguments
        var resolvedBuildArguments = new Dictionary<string, string?>();
        foreach (var buildArg in dockerfileBuildAnnotation.BuildArguments)
        {
            resolvedBuildArguments[buildArg.Key] = await ResolveValue(buildArg.Value, cancellationToken).ConfigureAwait(false);
        }
 
        // Resolve build secrets
        var resolvedBuildSecrets = new Dictionary<string, BuildImageSecretValue>();
        foreach (var buildSecret in dockerfileBuildAnnotation.BuildSecrets)
        {
            var secretType = buildSecret.Value is FileInfo ? BuildImageSecretType.File : BuildImageSecretType.Environment;
            var resolvedValue = await ResolveValue(buildSecret.Value, cancellationToken).ConfigureAwait(false);
            resolvedBuildSecrets[buildSecret.Key] = new BuildImageSecretValue(resolvedValue, secretType);
        }
 
        // ensure outputPath is created if specified since docker/podman won't create it for us
        if (options.OutputPath is { } outputPath)
        {
            Directory.CreateDirectory(outputPath);
        }
 
        // Parse image name and tag
        var imageNameParts = imageName.Split(':', 2);
        var imageNameOnly = imageNameParts[0];
        var imageTag = imageNameParts.Length > 1 ? imageNameParts[1] : null;
 
        // Create a ContainerImageBuildOptions for the container runtime
        var containerBuildOptions = new ContainerImageBuildOptions
        {
            ImageName = imageNameOnly,
            Tag = imageTag,
            OutputPath = options.OutputPath,
            ImageFormat = options.ImageFormat,
            TargetPlatform = options.TargetPlatform
        };
 
        try
        {
            await containerRuntime.BuildImageAsync(
                dockerfileBuildAnnotation.ContextPath,
                dockerfileBuildAnnotation.DockerfilePath,
                containerBuildOptions,
                resolvedBuildArguments,
                resolvedBuildSecrets,
                dockerfileBuildAnnotation.Stage,
                cancellationToken).ConfigureAwait(false);
 
            logger.LogInformation("Building image for {ResourceName} completed", resource.Name);
        }
        catch (Exception ex)
        {
            logger.LogError(ex, "Failed to build container image from Dockerfile for {ResourceName}", resource.Name);
            throw;
        }
    }
 
    internal static async Task<string?> ResolveValue(object? value, CancellationToken cancellationToken)
    {
        try
        {
            return value switch
            {
                FileInfo filePath => filePath.FullName,
                string stringValue => stringValue,
                IValueProvider valueProvider => await valueProvider.GetValueAsync(cancellationToken).ConfigureAwait(false),
                bool boolValue => boolValue ? "true" : "false",
                null => null,
                IFormattable formattable => formattable.ToString(null, CultureInfo.InvariantCulture),
                _ => value.ToString()
            };
        }
        catch (MissingParameterValueException)
        {
            // If a parameter value is missing, we return null to indicate that the build argument or secret cannot be resolved
            // and we should fallback to resolving it from environment variables.
            return null;
        }
    }
 
    public async Task PushImageAsync(IResource resource, CancellationToken cancellationToken)
    {
        var containerRuntime = await GetContainerRuntimeAsync(cancellationToken).ConfigureAwait(false);
        await containerRuntime.PushImageAsync(resource, cancellationToken).ConfigureAwait(false);
    }
 
    // .NET Container builds that push OCI images to a local file path do not need a runtime
    private async Task<bool> ResourcesRequireContainerRuntimeAsync(IEnumerable<IResource> resources, CancellationToken cancellationToken)
    {
        foreach (var resource in resources)
        {
            if (resource.HasPrebuiltContainerImage())
            {
                continue;
            }
 
            // Dockerfile resources always need container runtime
            if (resource.TryGetLastAnnotation<ContainerImageAnnotation>(out _) &&
                resource.TryGetLastAnnotation<DockerfileBuildAnnotation>(out _))
            {
                return true;
            }
 
            // Check if any resource uses Docker format or has no output path
            var options = await ResolveContainerBuildOptionsAsync(resource, cancellationToken).ConfigureAwait(false);
 
            // Skip resources that are explicitly configured to save as archives - they don't need Docker
            if (options.Destination == ContainerImageDestination.Archive)
            {
                continue;
            }
 
            var usesDocker = options.ImageFormat == null || options.ImageFormat == ContainerImageFormat.Docker;
            var hasNoOutputPath = options.OutputPath == null;
 
            if (usesDocker || hasNoOutputPath)
            {
                return true;
            }
        }
 
        return false;
    }
 
}
 
/// <summary>
/// Extension methods for <see cref="ContainerTargetPlatform"/>.
/// </summary>
[Experimental("ASPIREPIPELINES003", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
internal static class ContainerTargetPlatformExtensions
{
    /// <summary>
    /// Converts the target platform to the format used by container runtimes (Docker/Podman).
    /// </summary>
    /// <param name="platform">The target platform.</param>
    /// <returns>The platform string in the format used by container runtimes.</returns>
    public static string ToRuntimePlatformString(this ContainerTargetPlatform platform)
    {
        var platforms = new List<string>();
 
        if (platform.HasFlag(ContainerTargetPlatform.LinuxAmd64))
        {
            platforms.Add("linux/amd64");
        }
        if (platform.HasFlag(ContainerTargetPlatform.LinuxArm64))
        {
            platforms.Add("linux/arm64");
        }
        if (platform.HasFlag(ContainerTargetPlatform.LinuxArm))
        {
            platforms.Add("linux/arm");
        }
        if (platform.HasFlag(ContainerTargetPlatform.Linux386))
        {
            platforms.Add("linux/386");
        }
        if (platform.HasFlag(ContainerTargetPlatform.WindowsAmd64))
        {
            platforms.Add("windows/amd64");
        }
        if (platform.HasFlag(ContainerTargetPlatform.WindowsArm64))
        {
            platforms.Add("windows/arm64");
        }
 
        if (platforms.Count == 0)
        {
            throw new ArgumentOutOfRangeException(nameof(platform), platform, "Unknown container target platform");
        }
 
        return string.Join(",", platforms);
    }
 
    /// <summary>
    /// Converts the target platform to the format used by MSBuild RuntimeIdentifiers.
    /// </summary>
    /// <param name="platform">The target platform.</param>
    /// <returns>The platform string in the format used by MSBuild.</returns>
    public static string ToMSBuildRuntimeIdentifierString(this ContainerTargetPlatform platform)
    {
        var rids = new List<string>();
 
        if (platform.HasFlag(ContainerTargetPlatform.LinuxAmd64))
        {
            rids.Add("linux-x64");
        }
        if (platform.HasFlag(ContainerTargetPlatform.LinuxArm64))
        {
            rids.Add("linux-arm64");
        }
        if (platform.HasFlag(ContainerTargetPlatform.LinuxArm))
        {
            rids.Add("linux-arm");
        }
        if (platform.HasFlag(ContainerTargetPlatform.Linux386))
        {
            rids.Add("linux-x86");
        }
        if (platform.HasFlag(ContainerTargetPlatform.WindowsAmd64))
        {
            rids.Add("win-x64");
        }
        if (platform.HasFlag(ContainerTargetPlatform.WindowsArm64))
        {
            rids.Add("win-arm64");
        }
 
        if (rids.Count == 0)
        {
            throw new ArgumentOutOfRangeException(nameof(platform), platform, "Unknown container target platform");
        }
 
        return string.Join(";", rids);
    }
}