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;
using System.Diagnostics.CodeAnalysis;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Dcp;
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")]
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<string> 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 startInfo = new ProcessStartInfo
        {
            FileName = "dotnet",
            RedirectStandardOutput = true,
            RedirectStandardError = true
        };
 
        startInfo.ArgumentList.Add("publish");
        startInfo.ArgumentList.Add(projectMetadata.ProjectPath);
        startInfo.ArgumentList.Add("--configuration");
        startInfo.ArgumentList.Add("Release");
        startInfo.ArgumentList.Add("/t:PublishContainer");
        startInfo.ArgumentList.Add($"/p:ContainerRepository={resource.Name}");
 
        logger.LogInformation(
            "Starting .NET CLI with arguments: {Arguments}",
            string.Join(" ", startInfo.ArgumentList.ToArray())
            );
 
        using var process = Process.Start(startInfo);
 
        if (process is null)
        {
            throw new DistributedApplicationException("Failed to start .NET CLI.");
        }
 
        logger.LogInformation(
            "Started .NET CLI with PID: {PID}",
            process.Id
            );
 
        await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
 
        if (process.ExitCode != 0)
        {
            var stderr = process.StandardError.ReadToEnd();
            var stdout = process.StandardOutput.ReadToEnd();
 
            logger.LogError(
                ".NET CLI failed with exit code {ExitCode}. Output: {Stdout}, Error: {Stderr}",
                process.ExitCode,
                stdout,
                stderr);
 
            await activityReporter.UpdateActivityStatusAsync(
                publishingActivity, (status) => status with { IsError = true },
                cancellationToken).ConfigureAwait(false);
 
            throw new DistributedApplicationException($"Failed to build container image, stdout: {stdout}, stderr: {stderr}");
        }
        else
        {
            await activityReporter.UpdateActivityStatusAsync(
                publishingActivity, (status) => status with { IsComplete = true },
                cancellationToken).ConfigureAwait(false);
 
            logger.LogDebug(
                ".NET CLI completed with exit code: {ExitCode}",
                process.ExitCode);
 
            return $"{resource.Name}:latest";
        }
    }
 
    private async Task<string> 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")
            };
 
            var image = await containerRuntime.BuildImageAsync(
                contextPath,
                dockerfilePath,
                imageName,
                cancellationToken).ConfigureAwait(false);
 
            await activityReporter.UpdateActivityStatusAsync(
                publishingActivity, (status) => status with { IsComplete = true },
                cancellationToken).ConfigureAwait(false);
 
            return image;
        }
        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;
        }
 
    }
}