File: Publishing\ResourceContainerImageBuilder.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 ASPIREPUBLISHERS001
 
using System.Diagnostics.CodeAnalysis;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Dcp;
using Aspire.Hosting.Dcp.Process;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
 
namespace Aspire.Hosting.Publishing;
 
/// <summary>
/// Provides a service to publishers for building containers that represent a resource.
/// </summary>
[Experimental("ASPIREPUBLISHERS001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
public interface IResourceContainerImageBuilder
{
    /// <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);
}
 
internal sealed class ResourceContainerImageBuilder(
    ILogger<ResourceContainerImageBuilder> logger,
    IOptions<DcpOptions> dcpOptions,
    IServiceProvider serviceProvider,
    IPublishingActivityProgressReporter activityReporter) : IResourceContainerImageBuilder
{
    public async Task BuildImageAsync(IResource resource, CancellationToken cancellationToken)
    {
        logger.LogInformation("Building container image for resource {Resource}", resource.Name);
 
        if (resource is ProjectResource)
        {
            // If it is a project resource we need to build the container image
            // using the .NET SDK.
            await BuildProjectContainerImageAsync(
                resource,
                cancellationToken).ConfigureAwait(false);
            return;
        }
        else if (resource.TryGetLastAnnotation<ContainerImageAnnotation>(out var containerImageAnnotation))
        {
            if (resource.TryGetLastAnnotation<DockerfileBuildAnnotation>(out var dockerfileBuildAnnotation))
            {
                // This is a container resource so we'll use the container runtime to build the image
                await BuildContainerImageFromDockerfileAsync(
                    resource.Name,
                    dockerfileBuildAnnotation.ContextPath,
                    dockerfileBuildAnnotation.DockerfilePath,
                    containerImageAnnotation.Image,
                    cancellationToken).ConfigureAwait(false);
                return;
            }
            else
            {
                // Nothing to do here, the resource is already a container image.
                return;
            }
        }
        else
        {
            throw new NotSupportedException($"The resource type '{resource.GetType().Name}' is not supported.");
        }
    }
 
    private async Task BuildProjectContainerImageAsync(IResource resource, CancellationToken cancellationToken)
    {
        var publishingActivity = await activityReporter.CreateActivityAsync(
            $"{resource.Name}-build-image",
            $"Building image: {resource.Name}",
            isPrimary: false,
            cancellationToken
            ).ConfigureAwait(false);
 
        // This is a resource project so we'll use the .NET SDK to build the container image.
        if (!resource.TryGetLastAnnotation<IProjectMetadata>(out var projectMetadata))
        {
            throw new DistributedApplicationException($"The resource '{projectMetadata}' does not have a project metadata annotation.");
        }
 
        var spec = new ProcessSpec("dotnet")
        {
            Arguments = $"publish {projectMetadata.ProjectPath} --configuration Release /t:PublishContainer /p:ContainerRepository={resource.Name}",
            OnOutputData = output =>
            {
                logger.LogInformation("dotnet publish {ProjectPath} (stdout): {Output}", projectMetadata.ProjectPath, output);
            },
            OnErrorData = error =>
            {
                logger.LogError("dotnet publish {ProjectPath} (stderr): {Error}", projectMetadata.ProjectPath, error);
            }
        };
 
        logger.LogInformation(
            "Starting .NET CLI with arguments: {Arguments}",
            string.Join(" ", spec.Arguments)
            );
 
        var (pendingProcessResult, processDisposable) = ProcessUtil.Run(spec);
 
        await using (processDisposable)
        {
            var processResult = await pendingProcessResult
                .WaitAsync(cancellationToken)
                .ConfigureAwait(false);
 
            if (processResult.ExitCode != 0)
            {
                logger.LogError("dotnet publish for project {ProjectPath} failed with exit code {ExitCode}.", projectMetadata.ProjectPath, processResult.ExitCode);
                await activityReporter.UpdateActivityStatusAsync(
                    publishingActivity, (status) => status with { IsError = true },
                    cancellationToken).ConfigureAwait(false);
                throw new DistributedApplicationException($"Failed to build container image.");
            }
            else
            {
                await activityReporter.UpdateActivityStatusAsync(
                    publishingActivity, (status) => status with { IsComplete = true },
                    cancellationToken).ConfigureAwait(false);
 
                logger.LogDebug(
                    ".NET CLI completed with exit code: {ExitCode}",
                    processResult.ExitCode);
            }
        }
    }
 
    private async Task BuildContainerImageFromDockerfileAsync(string resourceName, string contextPath, string dockerfilePath, string imageName, CancellationToken cancellationToken)
    {
        var publishingActivity = await activityReporter.CreateActivityAsync(
            $"{resourceName}-build-image",
            $"Building image: {resourceName}",
            isPrimary: false,
            cancellationToken
            ).ConfigureAwait(false);
 
        try
        {
            var containerRuntime = dcpOptions.Value.ContainerRuntime switch
            {
                string rt => serviceProvider.GetRequiredKeyedService<IContainerRuntime>(rt),
                null => serviceProvider.GetRequiredKeyedService<IContainerRuntime>("docker")
            };
 
            await containerRuntime.BuildImageAsync(
                contextPath,
                dockerfilePath,
                imageName,
                cancellationToken).ConfigureAwait(false);
 
            await activityReporter.UpdateActivityStatusAsync(
                publishingActivity, (status) => status with { IsComplete = true },
                cancellationToken).ConfigureAwait(false);
        }
        catch (Exception ex)
        {
            logger.LogError(ex, "Failed to build container image from Dockerfile.");
 
            await activityReporter.UpdateActivityStatusAsync(
                publishingActivity, (status) => status with { IsError = true },
                cancellationToken).ConfigureAwait(false);
 
            throw;
        }
 
    }
}