|
// 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>
/// Specifies the format for container images.
/// </summary>
[Experimental("ASPIREPUBLISHERS001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
public enum ContainerImageFormat
{
/// <summary>
/// Docker format (default).
/// </summary>
Docker,
/// <summary>
/// OCI format.
/// </summary>
Oci
}
/// <summary>
/// Specifies the target platform for container images.
/// </summary>
[Experimental("ASPIREPUBLISHERS001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
public enum ContainerTargetPlatform
{
/// <summary>
/// Linux AMD64 (linux/amd64).
/// </summary>
LinuxAmd64,
/// <summary>
/// Linux ARM64 (linux/arm64).
/// </summary>
LinuxArm64,
/// <summary>
/// Linux ARM (linux/arm).
/// </summary>
LinuxArm,
/// <summary>
/// Linux 386 (linux/386).
/// </summary>
Linux386,
/// <summary>
/// Windows AMD64 (windows/amd64).
/// </summary>
WindowsAmd64,
/// <summary>
/// Windows ARM64 (windows/arm64).
/// </summary>
WindowsArm64
}
/// <summary>
/// Options for building container images.
/// </summary>
[Experimental("ASPIREPUBLISHERS001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
public class ContainerBuildOptions
{
/// <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>
/// 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="options">The container build options.</param>
/// <param name="cancellationToken">The cancellation token.</param>
Task BuildImageAsync(IResource resource, ContainerBuildOptions? options = null, 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="options">The container build options.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns></returns>
Task BuildImagesAsync(IEnumerable<IResource> resources, ContainerBuildOptions? options = null, CancellationToken cancellationToken = default);
}
internal sealed class ResourceContainerImageBuilder(
ILogger<ResourceContainerImageBuilder> logger,
IOptions<DcpOptions> dcpOptions,
IServiceProvider serviceProvider,
IPublishingActivityReporter activityReporter) : IResourceContainerImageBuilder
{
private IContainerRuntime? _containerRuntime;
private IContainerRuntime ContainerRuntime => _containerRuntime ??= dcpOptions.Value.ContainerRuntime switch
{
string rt => serviceProvider.GetRequiredKeyedService<IContainerRuntime>(rt),
null => serviceProvider.GetRequiredKeyedService<IContainerRuntime>("docker")
};
public async Task BuildImagesAsync(IEnumerable<IResource> resources, ContainerBuildOptions? options = null, CancellationToken cancellationToken = default)
{
var step = await activityReporter.CreateStepAsync(
"Building container images for resources",
cancellationToken).ConfigureAwait(false);
await using (step.ConfigureAwait(false))
{
// Currently, we build these images to the local Docker daemon. We need to ensure that
// the Docker daemon is running and accessible
var task = await step.CreateTaskAsync(
$"Checking {ContainerRuntime.Name} health",
cancellationToken).ConfigureAwait(false);
await using (task.ConfigureAwait(false))
{
var containerRuntimeHealthy = await ContainerRuntime.CheckIfRunningAsync(cancellationToken).ConfigureAwait(false);
if (!containerRuntimeHealthy)
{
logger.LogError("Container runtime is not running or is unhealthy. Cannot build container images.");
await task.FailAsync(
$"{ContainerRuntime.Name} is not running or is unhealthy.",
cancellationToken).ConfigureAwait(false);
await step.CompleteAsync("Building container images failed", CompletionState.CompletedWithError, cancellationToken).ConfigureAwait(false);
return;
}
await task.SucceedAsync(
$"{ContainerRuntime.Name} is healthy.",
cancellationToken).ConfigureAwait(false);
}
foreach (var resource in resources)
{
// TODO: Consider parallelizing this.
await BuildImageAsync(step, resource, options, cancellationToken).ConfigureAwait(false);
}
await step.CompleteAsync("Building container images completed", CompletionState.Completed, cancellationToken).ConfigureAwait(false);
}
}
public Task BuildImageAsync(IResource resource, ContainerBuildOptions? options = null, CancellationToken cancellationToken = default)
{
return BuildImageAsync(step: null, resource, options, cancellationToken);
}
private async Task BuildImageAsync(IPublishingStep? step, IResource resource, ContainerBuildOptions? options, 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,
step,
options,
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,
step,
options,
cancellationToken).ConfigureAwait(false);
return;
}
}
else
{
throw new NotSupportedException($"The resource type '{resource.GetType().Name}' is not supported.");
}
}
private async Task BuildProjectContainerImageAsync(IResource resource, IPublishingStep? step, ContainerBuildOptions? options, CancellationToken cancellationToken)
{
var publishingTask = await CreateTaskAsync(
step,
$"Building image: {resource.Name}",
cancellationToken
).ConfigureAwait(false);
var success = await ExecuteDotnetPublishAsync(resource, options, cancellationToken).ConfigureAwait(false);
if (publishingTask is not null)
{
await using (publishingTask.ConfigureAwait(false))
{
if (!success)
{
await publishingTask.FailAsync($"Building image for {resource.Name} failed", cancellationToken).ConfigureAwait(false);
}
else
{
await publishingTask.SucceedAsync($"Building image for {resource.Name} completed", cancellationToken).ConfigureAwait(false);
}
}
}
if (!success)
{
throw new DistributedApplicationException($"Failed to build container image.");
}
}
private async Task<bool> ExecuteDotnetPublishAsync(IResource resource, ContainerBuildOptions? options, CancellationToken cancellationToken)
{
// 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 arguments = $"publish \"{projectMetadata.ProjectPath}\" --configuration Release /t:PublishContainer /p:ContainerRepository=\"{resource.Name}\"";
// Add additional arguments based on options
if (options is not null)
{
if (!string.IsNullOrEmpty(options.OutputPath))
{
arguments += $" /p: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 += $" /p:ContainerImageFormat=\"{format}\"";
}
if (options.TargetPlatform is not null)
{
arguments += $" /p:ContainerRuntimeIdentifier=\"{options.TargetPlatform.Value.ToMSBuildRuntimeIdentifierString()}\"";
}
}
var spec = new ProcessSpec("dotnet")
{
Arguments = arguments,
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);
return false;
}
else
{
logger.LogDebug(
".NET CLI completed with exit code: {ExitCode}",
processResult.ExitCode);
return true;
}
}
}
private async Task BuildContainerImageFromDockerfileAsync(string resourceName, string contextPath, string dockerfilePath, string imageName, IPublishingStep? step, ContainerBuildOptions? options, CancellationToken cancellationToken)
{
var publishingTask = await CreateTaskAsync(
step,
$"Building image: {resourceName}",
cancellationToken
).ConfigureAwait(false);
if (publishingTask is not null)
{
await using (publishingTask.ConfigureAwait(false))
{
try
{
await ContainerRuntime.BuildImageAsync(
contextPath,
dockerfilePath,
imageName,
options,
cancellationToken).ConfigureAwait(false);
await publishingTask.SucceedAsync($"Building image for {resourceName} completed", cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to build container image from Dockerfile.");
await publishingTask.FailAsync($"Building image for {resourceName} failed", cancellationToken).ConfigureAwait(false);
throw;
}
}
}
else
{
// Handle case when publishingTask is null (no step provided)
try
{
await ContainerRuntime.BuildImageAsync(
contextPath,
dockerfilePath,
imageName,
options,
cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to build container image from Dockerfile.");
throw;
}
}
}
private static async Task<IPublishingTask?> CreateTaskAsync(
IPublishingStep? step,
string description,
CancellationToken cancellationToken)
{
if (step is null)
{
return null;
}
return await step.CreateTaskAsync(description, cancellationToken).ConfigureAwait(false);
}
}
/// <summary>
/// Extension methods for <see cref="ContainerTargetPlatform"/>.
/// </summary>
[Experimental("ASPIREPUBLISHERS001", 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) => platform switch
{
ContainerTargetPlatform.LinuxAmd64 => "linux/amd64",
ContainerTargetPlatform.LinuxArm64 => "linux/arm64",
ContainerTargetPlatform.LinuxArm => "linux/arm",
ContainerTargetPlatform.Linux386 => "linux/386",
ContainerTargetPlatform.WindowsAmd64 => "windows/amd64",
ContainerTargetPlatform.WindowsArm64 => "windows/arm64",
_ => throw new ArgumentOutOfRangeException(nameof(platform), platform, "Unknown container target platform")
};
/// <summary>
/// Converts the target platform to the format used by MSBuild ContainerRuntimeIdentifier.
/// </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) => platform switch
{
ContainerTargetPlatform.LinuxAmd64 => "linux-x64",
ContainerTargetPlatform.LinuxArm64 => "linux-arm64",
ContainerTargetPlatform.LinuxArm => "linux-arm",
ContainerTargetPlatform.Linux386 => "linux-x86",
ContainerTargetPlatform.WindowsAmd64 => "win-x64",
ContainerTargetPlatform.WindowsArm64 => "win-arm64",
_ => throw new ArgumentOutOfRangeException(nameof(platform), platform, "Unknown container target platform")
};
}
|