// 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 ASPIREDOCKERFILEBUILDER001
#pragma warning disable ASPIREFILESYSTEM001
#pragma warning disable ASPIRECONTAINERRUNTIME001
#pragma warning disable ASPIREPIPELINES001
#pragma warning disable ASPIREPIPELINES003
#pragma warning disable ASPIREPROJECTS001
using System.IO.Compression;
using Aspire.Hosting.ApplicationModel.Docker;
using Aspire.Hosting.Pipelines;
using Aspire.Hosting.Publishing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace Aspire.Hosting.ApplicationModel;
/// <summary>
/// Installs and executes the shared .NET SDK container-publishing pipeline.
/// </summary>
internal static class DotnetProgramPublishing
{
public static void Configure(IResource resource)
{
ArgumentNullException.ThrowIfNull(resource);
if (resource.HasAnnotationOfType<DotnetProgramPublishingAnnotation>())
{
return;
}
resource.Annotations.Add(new DotnetProgramPublishingAnnotation());
resource.Annotations.Add(new PipelineStepAnnotation(factoryContext =>
{
var stepResource = factoryContext.Resource;
var buildEnvironmentCallbacks = stepResource.Annotations
.OfType<DotnetProgramBuildEnvironmentCallbackAnnotation>()
.ToArray();
var steps = new List<PipelineStep>();
ValidatePrebuiltContainerImageConfiguration(stepResource);
if (!stepResource.RequiresImageBuild())
{
return steps;
}
var buildStep = new PipelineStep
{
Name = $"build-{stepResource.Name}",
Description = $"Builds the container image for the {stepResource.Name} project.",
Action = context => BuildImageAsync(stepResource, buildEnvironmentCallbacks, context),
Tags = [WellKnownPipelineTags.BuildCompute],
RequiredBySteps = [WellKnownPipelineSteps.Build],
DependsOnSteps = [WellKnownPipelineSteps.BuildPrereq],
Resource = stepResource
};
steps.Add(buildStep);
if (stepResource.RequiresImageBuildAndPush())
{
var pushStep = new PipelineStep
{
Name = $"push-{stepResource.Name}",
Action = context => PipelineStepHelpers.PushImageToRegistryAsync(stepResource, context),
Tags = [WellKnownPipelineTags.PushContainerImage],
RequiredBySteps = [WellKnownPipelineSteps.Push],
Resource = stepResource
};
steps.Add(pushStep);
}
return steps;
}));
resource.Annotations.Add(new ContainerBuildOptionsCallbackAnnotation(context =>
{
context.LocalImageName = context.Resource.Name.ToLowerInvariant();
context.LocalImageTag = "latest";
context.TargetPlatform = ContainerTargetPlatform.LinuxAmd64;
}));
resource.Annotations.Add(new PipelineConfigurationAnnotation(context =>
{
if (resource.TryGetAnnotationsOfType<ContainerFilesDestinationAnnotation>(out var containerFilesAnnotations))
{
var buildSteps = context.GetSteps(resource, WellKnownPipelineTags.BuildCompute);
foreach (var containerFile in containerFilesAnnotations)
{
buildSteps.DependsOn(context.GetSteps(containerFile.Source, WellKnownPipelineTags.BuildCompute));
}
}
var projectBuildSteps = context.GetSteps(resource, WellKnownPipelineTags.BuildCompute);
var pushSteps = context.GetSteps(resource, WellKnownPipelineTags.PushContainerImage);
pushSteps.DependsOn(projectBuildSteps);
pushSteps.DependsOn(WellKnownPipelineSteps.PushPrereq);
}));
}
internal static void ValidatePrebuiltContainerImageConfiguration(IResource resource)
{
if (!resource.IsExcludedFromPublish() &&
resource.SupportsDotnetProgramPublishing() &&
resource.HasPrebuiltContainerImage() &&
resource.HasAnnotationOfType<ContainerFilesDestinationAnnotation>())
{
throw new DistributedApplicationException(
$"The .NET program resource '{resource.Name}' cannot use PublishWithContainerFiles with a prebuilt container image. " +
"Prebuilt images are treated as final artifacts and are not rebuilt. Remove the prebuilt image to let Aspire build " +
"and layer the resource, or include the requested files in the prebuilt image before publishing.");
}
}
private static async Task BuildImageAsync(
IResource resource,
IReadOnlyList<DotnetProgramBuildEnvironmentCallbackAnnotation> buildEnvironmentCallbacks,
PipelineStepContext context)
{
var currentCallbacks = resource.Annotations
.OfType<DotnetProgramBuildEnvironmentCallbackAnnotation>();
if (!currentCallbacks.SequenceEqual(buildEnvironmentCallbacks, ReferenceEqualityComparer.Instance))
{
throw new DistributedApplicationException(
$"The build environment of .NET program resource '{resource.Name}' changed after the publish pipeline was resolved.");
}
var containerImageBuilder = context.Services.GetRequiredService<IResourceContainerImageManager>();
if (containerImageBuilder is not IDotnetProgramContainerImageManager dotnetProgramImageBuilder)
{
// A replacement for the public manager owns the complete build contract, including
// ContainerFilesDestinationAnnotation. Only the built-in partial contract exposes the
// resolved image identity and archive options needed for framework-provided layering.
await containerImageBuilder.BuildImageAsync(resource, context.CancellationToken).ConfigureAwait(false);
return;
}
// Only the resolved build options determine whether SDK publishing needs a runtime.
// Keep interactive recovery for those builds without blocking daemon-free archives.
var readiness = context.Services.GetRequiredService<ContainerRuntimeReadiness>();
var buildResult = await dotnetProgramImageBuilder.BuildDotnetProgramImageAsync(
resource,
buildEnvironmentCallbacks,
readiness.EnsureRunningAsync,
context.CancellationToken).ConfigureAwait(false);
await using var buildResultLifetime = buildResult.ConfigureAwait(false);
if (resource.TryGetAnnotationsOfType<ContainerFilesDestinationAnnotation>(out _))
{
await LayerContainerFilesAsync(
resource,
resource.GetProjectMetadata(),
buildResult,
context.Services,
context.Logger,
context.CancellationToken).ConfigureAwait(false);
}
}
internal static async Task LayerContainerFilesAsync(
IResource resource,
IProjectMetadata projectMetadata,
DotnetProgramImageBuildResult buildResult,
IServiceProvider services,
ILogger logger,
CancellationToken cancellationToken)
{
var sourceImageName = buildResult.SourceImageReference;
var exportsArchive = buildResult.Destination == ContainerImageDestination.Archive;
var tempImageTag = exportsArchive ? $"aspire-layered-{Guid.NewGuid():N}" : $"temp-{Guid.NewGuid():N}";
var tempImageName = $"{buildResult.LocalImageName}:{tempImageTag}";
var containerRuntime = await services
.GetRequiredService<IContainerRuntimeResolver>()
.ResolveAsync(cancellationToken)
.ConfigureAwait(false);
var directoryService = services.GetRequiredService<IFileSystemService>();
TemporaryContainerImage? temporaryImage = null;
TempDirectory? stagedArchiveDirectory = null;
string? tempDockerfilePath = null;
var builtSuccessfully = false;
try
{
stagedArchiveDirectory = exportsArchive
? directoryService.TempDirectory.CreateTempSubdirectory("aspire-container-archive")
: null;
if (!exportsArchive)
{
temporaryImage = new TemporaryContainerImage(containerRuntime, tempImageName, logger);
logger.LogDebug("Tagging image {SourceImageName} as {TempImageName}", sourceImageName, tempImageName);
await containerRuntime.TagImageAsync(sourceImageName, tempImageName, cancellationToken).ConfigureAwait(false);
sourceImageName = tempImageName;
}
var dockerfileBuilder = new DockerfileBuilder();
dockerfileBuilder.AddContainerFilesStages(resource, logger);
dockerfileBuilder
.From(sourceImageName)
.AddContainerFiles(resource, buildResult.ContainerWorkingDirectory, logger);
var projectDirectory = Path.GetDirectoryName(projectMetadata.ProjectPath)!;
tempDockerfilePath = directoryService.TempDirectory.CreateTempFile("Dockerfile").Path;
using (var writer = new StreamWriter(tempDockerfilePath))
{
await dockerfileBuilder.WriteAsync(writer, cancellationToken).ConfigureAwait(false);
}
var runtimeOutputPath = stagedArchiveDirectory?.Path ?? buildResult.OutputPath;
var buildOptions = new ContainerImageBuildOptions
{
ImageName = buildResult.LocalImageName,
Tag = exportsArchive ? tempImageTag : buildResult.LocalImageTag,
Destination = buildResult.Destination,
OutputPath = runtimeOutputPath,
ImageFormat = buildResult.ImageFormat,
TargetPlatform = buildResult.TargetPlatform ?? ContainerTargetPlatform.LinuxAmd64,
RequiresLocalImageStore = true
};
if (exportsArchive)
{
// Docker and Podman both build locally before saving these archives. Isolate the
// layered image too; rewriting archive metadata must never require tagging the daemon.
temporaryImage = new TemporaryContainerImage(containerRuntime, tempImageName, logger);
}
await containerRuntime.BuildImageAsync(
projectDirectory,
tempDockerfilePath,
buildOptions,
[],
[],
null,
cancellationToken).ConfigureAwait(false);
if (stagedArchiveDirectory is not null)
{
var stagedArchivePath = ResourceExtensions.GetContainerImageArchivePath(
stagedArchiveDirectory.Path,
buildResult.LocalImageName,
tempImageTag);
var normalizedArchivePath = Path.Combine(stagedArchiveDirectory.Path, "normalized.tar");
await ContainerImageArchiveRewriter.RewriteImageTagAsync(
stagedArchivePath,
normalizedArchivePath,
buildResult.LocalImageName,
tempImageTag,
buildResult.LocalImageTag,
cancellationToken).ConfigureAwait(false);
var outputPath = IsExplicitArchiveOutputPath(buildResult.OutputPath!)
? buildResult.OutputPath!
: ResourceExtensions.GetContainerImageArchivePath(
buildResult.OutputPath!,
buildResult.LocalImageName,
buildResult.LocalImageTag);
await PublishArchiveAsync(
normalizedArchivePath,
outputPath,
logger,
cancellationToken).ConfigureAwait(false);
}
builtSuccessfully = true;
}
finally
{
// Archive builds also clean their staging files and private source images on failure,
// so retaining just the Dockerfile would not provide a runnable reproduction. Preserve
// the existing debug-file retention behavior only for non-archive builds.
if ((builtSuccessfully || exportsArchive) && tempDockerfilePath is not null && File.Exists(tempDockerfilePath))
{
try
{
File.Delete(tempDockerfilePath);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
logger.LogWarning(ex, "Failed to delete temporary Dockerfile {DockerfilePath}", tempDockerfilePath);
}
}
else if (!builtSuccessfully && tempDockerfilePath is not null)
{
logger.LogDebug("Failed build - temporary Dockerfile left at {DockerfilePath} for debugging", tempDockerfilePath);
}
if (stagedArchiveDirectory is not null)
{
try
{
stagedArchiveDirectory.Dispose();
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
logger.LogWarning(ex, "Failed to delete temporary container archive directory {ArchiveDirectory}", stagedArchiveDirectory.Path);
}
}
if (temporaryImage is not null)
{
await temporaryImage.DisposeAsync().ConfigureAwait(false);
}
}
}
internal static bool IsExplicitArchiveOutputPath(string outputPath)
{
// The SDK accepts arbitrary file extensions, such as "image.custom". A trailing
// directory separator makes a dotted path such as "artifacts.v1\" unambiguous.
// https://github.com/dotnet/sdk/blob/v10.0.400/src/Containers/Microsoft.NET.Build.Containers/LocalDaemons/ArchiveFileRegistry.cs
return !Directory.Exists(outputPath) &&
(File.Exists(outputPath) || Path.HasExtension(outputPath));
}
private static async Task PublishArchiveAsync(
string sourcePath,
string destinationPath,
ILogger logger,
CancellationToken cancellationToken)
{
var destinationDirectory = Path.GetDirectoryName(destinationPath);
if (!string.IsNullOrEmpty(destinationDirectory))
{
Directory.CreateDirectory(destinationDirectory);
}
var tempPath = Path.Combine(
string.IsNullOrEmpty(destinationDirectory) ? Directory.GetCurrentDirectory() : destinationDirectory,
$".{Path.GetFileName(destinationPath)}.{Guid.NewGuid():N}.tmp");
var tempFileCreated = false;
try
{
// Keep the staging file beside the destination so the final replacement is same-volume and atomic.
// FileMode.CreateNew prevents a path race even if an unlikely random-name collision occurs.
using (var source = new FileStream(
sourcePath,
FileMode.Open,
FileAccess.Read,
FileShare.Read,
bufferSize: 81920,
useAsync: true))
using (var destination = new FileStream(
tempPath,
FileMode.CreateNew,
FileAccess.Write,
FileShare.None,
bufferSize: 81920,
useAsync: true))
{
tempFileCreated = true;
if (destinationPath.EndsWith(".gz", StringComparison.OrdinalIgnoreCase) ||
destinationPath.EndsWith(".tgz", StringComparison.OrdinalIgnoreCase))
{
using var gzip = new GZipStream(destination, CompressionLevel.Optimal);
await source.CopyToAsync(gzip, cancellationToken).ConfigureAwait(false);
}
else
{
await source.CopyToAsync(destination, cancellationToken).ConfigureAwait(false);
}
}
cancellationToken.ThrowIfCancellationRequested();
if (File.Exists(destinationPath))
{
File.Replace(tempPath, destinationPath, destinationBackupFileName: null);
}
else
{
File.Move(tempPath, destinationPath);
}
}
finally
{
if (tempFileCreated && File.Exists(tempPath))
{
try
{
File.Delete(tempPath);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
logger.LogWarning(ex, "Failed to delete incomplete container archive {ArchivePath}", tempPath);
}
}
}
}
}