File: Publishing\DotnetProgramArchiveTests.cs
Web Access
Project: src\tests\Aspire.Hosting.Tests\Aspire.Hosting.Tests.csproj (Aspire.Hosting.Tests)
// 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 ASPIRECONTAINERRUNTIME001
#pragma warning disable ASPIREPIPELINES001
#pragma warning disable ASPIREPIPELINES003
#pragma warning disable ASPIREPROJECTS001
 
using System.Collections.Concurrent;
using Aspire.Hosting.Dcp.Process;
using Aspire.Hosting.Pipelines;
using Aspire.Hosting.Publishing;
using Aspire.Hosting.Tests.Utils;
using Aspire.Hosting.Utils;
using Microsoft.AspNetCore.InternalTesting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
 
namespace Aspire.Hosting.Tests.Publishing;
 
public class DotnetProgramArchiveTests(ITestOutputHelper output)
{
    private const string ImageName = "registry.example.com:5000/team/program";
    private const string ImageTag = "release";
    private const string ImageReference = $"{ImageName}:{ImageTag}";
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public async Task LayeredArchivePreservesExistingLocalImages(bool usePipeline)
    {
        using var workspace = TemporaryWorkspace.Create(output);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var images = CreateExistingImages();
        var existingImages = images.ToArray();
        var runner = CreateProcessRunner(images, "sdk");
        var runtime = CreateRuntime(images);
        builder.Services.AddSingleton<IProcessRunner>(runner);
        builder.Services.AddFakeContainerRuntime(runtime);
        var archivePath = Path.Combine(workspace.WorkspaceRoot.FullName, "program.tar");
        var resource = AddArchiveResource(builder, workspace.WorkspaceRoot.FullName, archivePath);
        using var app = builder.Build();
 
        await BuildAsync(app, resource.Resource, usePipeline, TestContext.Current.CancellationToken).DefaultTimeout();
 
        AssertImagesUnchanged(existingImages, images);
        Assert.Equal([ImageReference], TestContainerImageArchive.ReadDockerImageReferences(archivePath));
        Assert.Equal("sdk+assets", TestContainerImageArchive.ReadLayerContents(archivePath));
        Assert.Empty(runtime.TagImageCalls);
        var sdkReference = GetSdkImageReference(runner.ProcessSpecs[0]);
        var layeredBuild = Assert.Single(runtime.BuildImageCalls);
        var layeredOptions = layeredBuild.options!;
        Assert.False(File.Exists(layeredBuild.dockerfilePath));
        Assert.False(Directory.Exists(layeredOptions.OutputPath));
        Assert.Equal(
            new[] { sdkReference, $"{layeredOptions.ImageName}:{layeredOptions.Tag}" }.Order(StringComparer.Ordinal),
            runtime.RemoveImageCalls.Order(StringComparer.Ordinal));
    }
 
    [Fact]
    public async Task ConcurrentArchivesOwnDistinctImages()
    {
        using var firstWorkspace = TemporaryWorkspace.Create(output);
        using var secondWorkspace = TemporaryWorkspace.Create(output);
        using var firstBuilder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        using var secondBuilder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var images = CreateExistingImages();
        var existingImages = images.ToArray();
        var runtime = CreateRuntime(images);
        var firstRunner = CreateProcessRunner(images, "first-sdk");
        var secondRunner = CreateProcessRunner(images, "second-sdk");
        firstBuilder.Services.AddSingleton<IProcessRunner>(firstRunner);
        secondBuilder.Services.AddSingleton<IProcessRunner>(secondRunner);
        firstBuilder.Services.AddFakeContainerRuntime(runtime);
        secondBuilder.Services.AddFakeContainerRuntime(runtime);
        var firstArchive = Path.Combine(firstWorkspace.WorkspaceRoot.FullName, "program.tar");
        var secondArchive = Path.Combine(secondWorkspace.WorkspaceRoot.FullName, "program.tar");
        var firstResource = AddArchiveResource(firstBuilder, firstWorkspace.WorkspaceRoot.FullName, firstArchive);
        var secondResource = AddArchiveResource(secondBuilder, secondWorkspace.WorkspaceRoot.FullName, secondArchive);
        using var firstApp = firstBuilder.Build();
        using var secondApp = secondBuilder.Build();
        var bothLayersStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var releaseLayers = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var layerCount = 0;
        var buildLayer = runtime.BuildImageAsyncCallback!;
        runtime.BuildImageAsyncCallback = async (context, dockerfile, options, arguments, secrets, stage, cancellationToken) =>
        {
            if (Interlocked.Increment(ref layerCount) == 2)
            {
                bothLayersStarted.TrySetResult();
            }
 
            await releaseLayers.Task.WaitAsync(cancellationToken);
            await buildLayer(context, dockerfile, options, arguments, secrets, stage, cancellationToken);
        };
 
        var firstBuild = BuildAsync(firstApp, firstResource.Resource, false, TestContext.Current.CancellationToken);
        var secondBuild = BuildAsync(secondApp, secondResource.Resource, false, TestContext.Current.CancellationToken);
        try
        {
            await bothLayersStarted.Task.DefaultTimeout();
        }
        finally
        {
            releaseLayers.TrySetResult();
        }
 
        await Task.WhenAll(firstBuild, secondBuild).DefaultTimeout();
 
        AssertImagesUnchanged(existingImages, images);
        Assert.Equal("first-sdk+assets", TestContainerImageArchive.ReadLayerContents(firstArchive));
        Assert.Equal("second-sdk+assets", TestContainerImageArchive.ReadLayerContents(secondArchive));
        Assert.Equal([ImageReference], TestContainerImageArchive.ReadDockerImageReferences(firstArchive));
        Assert.Equal([ImageReference], TestContainerImageArchive.ReadDockerImageReferences(secondArchive));
        Assert.NotEqual(GetSdkImageReference(firstRunner.ProcessSpecs[0]), GetSdkImageReference(secondRunner.ProcessSpecs[0]));
        Assert.Equal(4, runtime.RemoveImageCalls.Count);
        Assert.Equal(4, runtime.RemoveImageCalls.Distinct(StringComparer.Ordinal).Count());
    }
 
    [Fact]
    public async Task FailedSdkPublishCleansOnlyItsPrivateImage()
    {
        using var workspace = TemporaryWorkspace.Create(output);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var images = CreateExistingImages();
        var existingImages = images.ToArray();
        var runner = new TestProcessRunner
        {
            RunCallback = spec => images[GetSdkImageReference(spec)] = "partial-sdk"
        };
        runner.EnqueueResult(exitCode: 42, error: ["publish failed after writing the image"]);
        var runtime = CreateRuntime(images);
        builder.Services.AddSingleton<IProcessRunner>(runner);
        builder.Services.AddFakeContainerRuntime(runtime);
        var resource = AddArchiveResource(builder, workspace.WorkspaceRoot.FullName, Path.Combine(workspace.WorkspaceRoot.FullName, "program.tar"));
        using var app = builder.Build();
 
        var exception = await Assert.ThrowsAsync<ProcessFailedException>(
            () => BuildAsync(app, resource.Resource, false, TestContext.Current.CancellationToken));
 
        Assert.Equal(42, exception.ExitCode);
        AssertImagesUnchanged(existingImages, images);
        Assert.Equal(GetSdkImageReference(Assert.Single(runner.ProcessSpecs)), Assert.Single(runtime.RemoveImageCalls));
        Assert.Empty(runtime.BuildImageCalls);
    }
 
    [Fact]
    public async Task CanceledWorkingDirectoryQueryCleansTheSdkImageWithAnIndependentToken()
    {
        using var workspace = TemporaryWorkspace.Create(output);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
        var images = CreateExistingImages();
        var existingImages = images.ToArray();
        var runner = new TestProcessRunner
        {
            RunCallback = spec =>
            {
                if (spec.ArgumentList![0] == "publish")
                {
                    images[GetSdkImageReference(spec)] = "sdk";
                }
                else
                {
                    cancellation.Cancel();
                }
            }
        };
        runner.EnqueueResult();
        runner.EnqueuePending(new TaskCompletionSource<ProcessResult>(TaskCreationOptions.RunContinuationsAsynchronously).Task);
        var runtime = CreateRuntime(images);
        var removeImage = runtime.RemoveImageAsyncCallback!;
        runtime.RemoveImageAsyncCallback = (image, cleanupToken) =>
        {
            Assert.True(cleanupToken.CanBeCanceled);
            Assert.False(cleanupToken.IsCancellationRequested);
            return removeImage(image, cleanupToken);
        };
        builder.Services.AddSingleton<IProcessRunner>(runner);
        builder.Services.AddFakeContainerRuntime(runtime);
        var resource = AddArchiveResource(builder, workspace.WorkspaceRoot.FullName, Path.Combine(workspace.WorkspaceRoot.FullName, "program.tar"));
        using var app = builder.Build();
 
        await Assert.ThrowsAnyAsync<OperationCanceledException>(
            () => BuildAsync(app, resource.Resource, false, cancellation.Token)).DefaultTimeout();
 
        AssertImagesUnchanged(existingImages, images);
        Assert.Equal(GetSdkImageReference(runner.ProcessSpecs[0]), Assert.Single(runtime.RemoveImageCalls));
        Assert.Empty(runtime.BuildImageCalls);
    }
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public async Task LayerFailurePreservesExistingImagesAndArchive(bool cancel)
    {
        using var workspace = TemporaryWorkspace.Create(output);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
        var images = CreateExistingImages();
        var existingImages = images.ToArray();
        var runner = CreateProcessRunner(images, "sdk");
        var runtime = CreateRuntime(images);
        var buildLayer = runtime.BuildImageAsyncCallback!;
        var failure = new InvalidOperationException("layer archive save failed");
        runtime.BuildImageAsyncCallback = async (context, dockerfile, options, arguments, secrets, stage, token) =>
        {
            await buildLayer(context, dockerfile, options, arguments, secrets, stage, token);
            if (cancel)
            {
                cancellation.Cancel();
                token.ThrowIfCancellationRequested();
            }
 
            throw failure;
        };
        builder.Services.AddSingleton<IProcessRunner>(runner);
        builder.Services.AddFakeContainerRuntime(runtime);
        var archivePath = Path.Combine(workspace.WorkspaceRoot.FullName, "program.tar.gz");
        await File.WriteAllTextAsync(archivePath, "previous archive");
        var resource = AddArchiveResource(builder, workspace.WorkspaceRoot.FullName, archivePath);
        using var app = builder.Build();
 
        if (cancel)
        {
            await Assert.ThrowsAnyAsync<OperationCanceledException>(
                () => BuildAsync(app, resource.Resource, false, cancellation.Token)).DefaultTimeout();
        }
        else
        {
            var exception = await Assert.ThrowsAsync<InvalidOperationException>(
                () => BuildAsync(app, resource.Resource, false, cancellation.Token));
            Assert.Same(failure, exception);
        }
 
        AssertImagesUnchanged(existingImages, images);
        Assert.Equal("previous archive", await File.ReadAllTextAsync(archivePath));
        Assert.Equal(2, runtime.RemoveImageCalls.Count);
        var layeredBuild = Assert.Single(runtime.BuildImageCalls);
        Assert.False(File.Exists(layeredBuild.dockerfilePath));
        Assert.False(Directory.Exists(layeredBuild.options!.OutputPath));
    }
 
    [Fact]
    public async Task InvalidArchivePreservesExistingImagesAndOutput()
    {
        using var workspace = TemporaryWorkspace.Create(output);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var images = CreateExistingImages();
        var existingImages = images.ToArray();
        var runtime = CreateRuntime(images);
        var buildLayer = runtime.BuildImageAsyncCallback!;
        runtime.BuildImageAsyncCallback = async (context, dockerfile, options, arguments, secrets, stage, cancellationToken) =>
        {
            await buildLayer(context, dockerfile, options, arguments, secrets, stage, cancellationToken);
            var archivePath = ResourceExtensions.GetContainerImageArchivePath(options!.OutputPath!, options.ImageName!, options.Tag);
            await File.WriteAllTextAsync(archivePath, "not an image archive", cancellationToken);
        };
        builder.Services.AddSingleton<IProcessRunner>(CreateProcessRunner(images, "sdk"));
        builder.Services.AddFakeContainerRuntime(runtime);
        var outputPath = Path.Combine(workspace.WorkspaceRoot.FullName, "program.tar");
        await File.WriteAllTextAsync(outputPath, "previous archive");
        var resource = AddArchiveResource(builder, workspace.WorkspaceRoot.FullName, outputPath);
        using var app = builder.Build();
 
        await Assert.ThrowsAsync<DistributedApplicationException>(
            () => BuildAsync(app, resource.Resource, false, TestContext.Current.CancellationToken));
 
        AssertImagesUnchanged(existingImages, images);
        Assert.Equal("previous archive", await File.ReadAllTextAsync(outputPath));
        Assert.Equal(2, runtime.RemoveImageCalls.Count);
    }
 
    [Fact]
    public async Task FinalizationFailurePreservesExistingImagesAndFiles()
    {
        using var workspace = TemporaryWorkspace.Create(output);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var images = CreateExistingImages();
        var existingImages = images.ToArray();
        var runtime = CreateRuntime(images);
        builder.Services.AddSingleton<IProcessRunner>(CreateProcessRunner(images, "sdk"));
        builder.Services.AddFakeContainerRuntime(runtime);
        var blockedDirectory = Path.Combine(workspace.WorkspaceRoot.FullName, "output");
        await File.WriteAllTextAsync(blockedDirectory, "existing file");
        var resource = AddArchiveResource(
            builder,
            workspace.WorkspaceRoot.FullName,
            Path.Combine(blockedDirectory, "program.tar.gz"));
        using var app = builder.Build();
 
        await Assert.ThrowsAsync<IOException>(
            () => BuildAsync(app, resource.Resource, false, TestContext.Current.CancellationToken));
 
        AssertImagesUnchanged(existingImages, images);
        Assert.Equal("existing file", await File.ReadAllTextAsync(blockedDirectory));
        Assert.Equal(2, runtime.RemoveImageCalls.Count);
        var layeredBuild = Assert.Single(runtime.BuildImageCalls);
        Assert.False(File.Exists(layeredBuild.dockerfilePath));
        Assert.False(Directory.Exists(layeredBuild.options!.OutputPath));
    }
 
    [Fact]
    public async Task CleanupFailuresDoNotReplaceTheLayerFailure()
    {
        using var workspace = TemporaryWorkspace.Create(output);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        builder.Services.AddLogging(logging => logging.AddFakeLogging());
        var images = CreateExistingImages();
        var runtime = CreateRuntime(images);
        var failure = new InvalidOperationException("layer build failed");
        var cleanupFailure = new InvalidOperationException("cleanup failed");
        runtime.BuildImageAsyncCallback = (_, _, _, _, _, _, _) => throw failure;
        runtime.RemoveImageAsyncCallback = (_, _) => throw cleanupFailure;
        builder.Services.AddSingleton<IProcessRunner>(CreateProcessRunner(images, "sdk"));
        builder.Services.AddFakeContainerRuntime(runtime);
        var resource = AddArchiveResource(builder, workspace.WorkspaceRoot.FullName, Path.Combine(workspace.WorkspaceRoot.FullName, "program.tar"));
        using var app = builder.Build();
 
        var exception = await Assert.ThrowsAsync<InvalidOperationException>(
            () => BuildAsync(app, resource.Resource, false, TestContext.Current.CancellationToken));
 
        Assert.Same(failure, exception);
        var warnings = app.Services.GetFakeLogCollector().GetSnapshot()
            .Where(static record => record.Level == LogLevel.Warning &&
                record.Message.StartsWith("Failed to remove temporary container image", StringComparison.Ordinal))
            .ToArray();
        Assert.Equal(2, warnings.Length);
        Assert.All(warnings, record => Assert.Same(cleanupFailure, record.Exception));
    }
 
    [Theory]
    [InlineData("program.tar", false)]
    [InlineData("program.tar.gz", false)]
    [InlineData("program.tgz", false)]
    [InlineData("program.custom", false)]
    [InlineData("archives", true)]
    [InlineData("archives.v1", true)]
    public async Task LayeredArchivePreservesOutputPathConventions(string path, bool directory)
    {
        using var workspace = TemporaryWorkspace.Create(output);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var images = CreateExistingImages();
        var runtime = CreateRuntime(images);
        builder.Services.AddSingleton<IProcessRunner>(CreateProcessRunner(images, "sdk"));
        builder.Services.AddFakeContainerRuntime(runtime);
        var outputPath = Path.Combine(workspace.WorkspaceRoot.FullName, path);
        if (directory)
        {
            outputPath += Path.DirectorySeparatorChar;
        }
 
        var expectedArchivePath = directory
            ? ResourceExtensions.GetContainerImageArchivePath(outputPath, ImageName, ImageTag)
            : outputPath;
        var resource = AddArchiveResource(builder, workspace.WorkspaceRoot.FullName, outputPath);
        using var app = builder.Build();
 
        await BuildAsync(app, resource.Resource, false, TestContext.Current.CancellationToken);
 
        IValueProvider imageReference = new ContainerImageReference(resource.Resource);
        var resolvedPath = await imageReference.GetValueAsync(
            new ValueProviderContext
            {
                ExecutionContext = app.Services.GetRequiredService<DistributedApplicationExecutionContext>()
            },
            TestContext.Current.CancellationToken);
        Assert.Equal(expectedArchivePath, resolvedPath);
        Assert.Equal([ImageReference], TestContainerImageArchive.ReadDockerImageReferences(expectedArchivePath));
        Assert.Equal("sdk+assets", TestContainerImageArchive.ReadLayerContents(expectedArchivePath));
        Assert.False(Directory.Exists(Assert.Single(runtime.BuildImageCalls).options!.OutputPath));
    }
 
    private static IResourceBuilder<ProjectResource> AddArchiveResource(
        IDistributedApplicationBuilder builder,
        string workspacePath,
        string outputPath)
    {
        var assets = builder.AddContainer("assets", "assets-image")
            .WithAnnotation(new ContainerFilesSourceAnnotation { SourcePath = "/assets" });
        return builder.AddProject("program", Path.Combine(workspacePath, "program.csproj"), static options => options.ExcludeLaunchProfile = true)
            .WithAnnotation(new ContainerFilesDestinationAnnotation
            {
                Source = assets.Resource,
                DestinationPath = "/app/assets"
            })
            .WithContainerBuildOptions(context =>
            {
                context.LocalImageName = ImageName;
                context.LocalImageTag = ImageTag;
                context.Destination = ContainerImageDestination.Archive;
                context.ImageFormat = ContainerImageFormat.Docker;
                context.OutputPath = outputPath;
            });
    }
 
    private static ConcurrentDictionary<string, string> CreateExistingImages() => new(StringComparer.Ordinal)
    {
        [ImageReference] = "existing-image",
        ["unrelated:keep"] = "existing-image"
    };
 
    private static TestProcessRunner CreateProcessRunner(ConcurrentDictionary<string, string> images, string contents)
    {
        var runner = new TestProcessRunner
        {
            RunCallback = spec =>
            {
                if (spec.ArgumentList![0] == "publish")
                {
                    images[GetSdkImageReference(spec)] = contents;
                }
            }
        };
        runner.EnqueueResult();
        runner.EnqueueResult(output: ["/app"]);
 
        return runner;
    }
 
    private static string GetSdkImageReference(ProcessSpec spec)
    {
        // SDK invocations carry separate properties, for example:
        // --property:ContainerRepository=registry.example.com:5000/team/program --property:ContainerImageTag=release
        var repository = Assert.Single(spec.ArgumentList!, static argument => argument.StartsWith("--property:ContainerRepository=", StringComparison.Ordinal));
        var tag = Assert.Single(spec.ArgumentList!, static argument => argument.StartsWith("--property:ContainerImageTag=", StringComparison.Ordinal));
 
        return $"{repository["--property:ContainerRepository=".Length..]}:{tag["--property:ContainerImageTag=".Length..]}";
    }
 
    private static FakeContainerRuntime CreateRuntime(ConcurrentDictionary<string, string> images)
    {
        return new FakeContainerRuntime(name: "Docker")
        {
            TagImageAsyncCallback = (source, destination, _) =>
            {
                images[NormalizeImageReference(destination)] = images[NormalizeImageReference(source)];
                return Task.CompletedTask;
            },
            RemoveImageAsyncCallback = (image, cancellationToken) =>
            {
                Assert.True(images.TryRemove(NormalizeImageReference(image), out _), $"Image '{image}' was not created.");
                return Task.CompletedTask;
            },
            BuildImageAsyncCallback = (_, dockerfile, options, _, _, _, _) =>
            {
                var buildOptions = Assert.IsType<ContainerImageBuildOptions>(options);
                Assert.Equal(ContainerImageDestination.Archive, buildOptions.Destination);
                // The generated final stage is a plain "FROM repository:tag"; preceding FROM lines name asset stages.
                var from = File.ReadLines(dockerfile).Last(static line => line.StartsWith("FROM ", StringComparison.Ordinal));
                var contents = images[NormalizeImageReference(from["FROM ".Length..])] + "+assets";
                var imageReference = $"{buildOptions.ImageName}:{buildOptions.Tag}";
                images[imageReference] = contents;
                var archivePath = ResourceExtensions.GetContainerImageArchivePath(
                    buildOptions.OutputPath!,
                    buildOptions.ImageName!,
                    buildOptions.Tag);
                TestContainerImageArchive.WriteDockerArchive(archivePath, imageReference, contents);
                return Task.CompletedTask;
            }
        };
    }
 
    private static string NormalizeImageReference(string reference)
    {
        // "program" implies ":latest", but the colon in "registry:5000/team/program" is not a tag separator.
        return reference.LastIndexOf(':') > reference.LastIndexOf('/') ? reference : $"{reference}:latest";
    }
 
    private static void AssertImagesUnchanged(
        KeyValuePair<string, string>[] expected,
        ConcurrentDictionary<string, string> actual)
    {
        Assert.Equal(
            expected.OrderBy(static pair => pair.Key, StringComparer.Ordinal),
            actual.OrderBy(static pair => pair.Key, StringComparer.Ordinal));
    }
 
    private static Task BuildAsync(DistributedApplication app, IResource resource, bool usePipeline, CancellationToken cancellationToken)
    {
        if (!usePipeline)
        {
            return app.Services.GetRequiredService<IResourceContainerImageManager>().BuildImageAsync(resource, cancellationToken);
        }
 
        var pipeline = Assert.IsType<DistributedApplicationPipeline>(app.Services.GetRequiredService<IDistributedApplicationPipeline>());
        var context = new PipelineContext(
            app.Services.GetRequiredService<DistributedApplicationModel>(),
            app.Services.GetRequiredService<DistributedApplicationExecutionContext>(),
            app.Services,
            NullLogger.Instance,
            cancellationToken);
 
        return pipeline.ExecuteStepSequentiallyAsync($"build-{resource.Name}", context);
    }
}