// 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 ASPIREPIPELINES001
#pragma warning disable ASPIREPIPELINES003
#pragma warning disable ASPIREPROJECTS001
#pragma warning disable ASPIREEXTENSION001
#pragma warning disable ASPIRECONTAINERRUNTIME001
using Aspire.Hosting.Pipelines;
using Aspire.Hosting.Dcp;
using Aspire.Hosting.Dcp.Process;
using Aspire.Hosting.Publishing;
using Aspire.Hosting.Tests.Publishing;
using Aspire.Hosting.Tests.Utils;
using Aspire.Hosting.Utils;
using Microsoft.AspNetCore.InternalTesting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Logging.Testing;
using Microsoft.Extensions.Options;
namespace Aspire.Hosting.Tests;
public class DotnetProgramPublishingTests(ITestOutputHelper outputHelper)
{
[Fact]
public void ProjectResourceIsConfiguredForDotnetProgramPublishing()
{
var resource = new ProjectResource("project");
Assert.IsAssignableFrom<IDotnetProgramResource>(resource);
Assert.True(resource.SupportsDotnetProgramPublishing());
Assert.Single(resource.Annotations.OfType<DotnetProgramPublishingAnnotation>());
Assert.Single(resource.Annotations.OfType<PipelineStepAnnotation>());
Assert.Single(resource.Annotations.OfType<PipelineConfigurationAnnotation>());
Assert.Single(resource.Annotations.OfType<ContainerBuildOptionsCallbackAnnotation>());
}
[Fact]
public void WithDotnetProgramBuildEnvironmentPreservesCallbackOrder()
{
using var builder = TestDistributedApplicationBuilder.Create();
var resource = builder.AddResource(new TestDotnetProgramResource("program"));
Func<EnvironmentCallbackContext, Task> first = _ => Task.CompletedTask;
Func<EnvironmentCallbackContext, Task> second = _ => Task.CompletedTask;
resource.WithDotnetProgramBuildEnvironment(first);
resource.WithDotnetProgramBuildEnvironment(second);
Assert.Collection(
resource.Resource.Annotations.OfType<DotnetProgramBuildEnvironmentCallbackAnnotation>(),
annotation => Assert.Same(first, annotation.Callback),
annotation => Assert.Same(second, annotation.Callback));
}
[Fact]
public void WithDotnetProgramBuildEnvironmentRejectsNullCallback()
{
using var builder = TestDistributedApplicationBuilder.Create();
var resource = builder.AddResource(new TestDotnetProgramResource("program"));
Func<EnvironmentCallbackContext, Task> callback = null!;
var exception = Assert.Throws<ArgumentNullException>(() =>
resource.WithDotnetProgramBuildEnvironment(callback));
Assert.Equal(nameof(callback), exception.ParamName);
Assert.Empty(resource.Resource.Annotations.OfType<DotnetProgramBuildEnvironmentCallbackAnnotation>());
}
[Fact]
public void WithDotnetProgramPublishingRequiresProjectMetadata()
{
using var builder = TestDistributedApplicationBuilder.Create();
var resource = builder.AddResource(new TestDotnetProgramResource("program"));
var exception = Assert.Throws<InvalidOperationException>(() =>
{
resource.WithDotnetProgramPublishing();
});
Assert.Equal(
$"Resource 'program' does not carry an {nameof(IProjectMetadata)} annotation.",
exception.Message);
Assert.False(resource.Resource.SupportsDotnetProgramPublishing());
}
[Fact]
public void WithDotnetProgramPublishingRequiresComputeResource()
{
using var builder = TestDistributedApplicationBuilder.Create();
var resource = builder.AddResource(new NonComputeDotnetProgramResource("program"))
.WithAnnotation(new TestProjectMetadata("program.csproj"));
var exception = Assert.Throws<InvalidOperationException>(() =>
{
resource.WithDotnetProgramPublishing();
});
Assert.Equal(
$"Resource 'program' must implement {nameof(IComputeResource)} to use .NET SDK publishing.",
exception.Message);
Assert.False(resource.Resource.SupportsDotnetProgramPublishing());
}
[Fact]
public void WithDotnetProgramPublishingIsIdempotent()
{
using var builder = TestDistributedApplicationBuilder.Create();
var resource = builder.AddResource(new TestDotnetProgramResource("program"))
.WithAnnotation(new TestProjectMetadata("program.csproj"));
resource.WithDotnetProgramPublishing();
resource.WithDotnetProgramPublishing();
Assert.True(resource.Resource.SupportsDotnetProgramPublishing());
Assert.Single(resource.Resource.Annotations.OfType<DotnetProgramPublishingAnnotation>());
Assert.Single(resource.Resource.Annotations.OfType<PipelineStepAnnotation>());
Assert.Single(resource.Resource.Annotations.OfType<PipelineConfigurationAnnotation>());
Assert.Single(resource.Resource.Annotations.OfType<ContainerBuildOptionsCallbackAnnotation>());
}
[Fact]
public void GetProjectMetadataValidatesDuplicateMetadata()
{
var resource = new TestDotnetProgramResource("program");
resource.Annotations.Add(new TestProjectMetadata("first.csproj"));
resource.Annotations.Add(new TestProjectMetadata("second.csproj"));
var exception = Assert.Throws<InvalidOperationException>(() =>
{
resource.GetProjectMetadata();
});
Assert.Contains("carries more than one IProjectMetadata annotation", exception.Message);
}
[Fact]
public async Task ConfiguredProgramUsesProjectManifestWithoutEvaluatingLaunchToolArguments()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var projectPath = Path.Combine(workspace.WorkspaceRoot.FullName, "program.csproj");
await File.WriteAllTextAsync(projectPath, "<Project Sdk=\"Microsoft.NET.Sdk\" />");
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var launchToolCallbackInvoked = false;
Action<CommandLineArgsCallbackContext> launchToolCallback = context =>
{
launchToolCallbackInvoked = true;
context.Args.Add("run");
context.Args.Add("--project");
context.Args.Add(projectPath);
};
var resource = builder.AddResource(new TestDotnetProgramResource("program"))
.WithAnnotation(new TestProjectMetadata(projectPath))
.WithDotnetProgramPublishing()
.WithLaunchToolArgs(launchToolCallback)
.WithArgs("application-argument");
var manifest = await ManifestUtils.GetManifest(
resource.Resource,
workspace.WorkspaceRoot.FullName);
Assert.False(launchToolCallbackInvoked);
Assert.Equal("project.v0", manifest["type"]?.GetValue<string>());
Assert.Equal("program.csproj", manifest["path"]?.GetValue<string>());
Assert.Equal(
["application-argument"],
manifest["args"]?.AsArray().Select(static value => value!.GetValue<string>()));
}
[Fact]
public void ConfiguredProgramParticipatesInComputeAndBuildSelection()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var resource = builder.AddResource(new TestDotnetProgramResource("program"))
.WithAnnotation(new TestProjectMetadata("program.csproj"))
.WithDotnetProgramPublishing();
using var app = builder.Build();
var model = app.Services.GetRequiredService<DistributedApplicationModel>();
Assert.Contains(resource.Resource, model.GetComputeResources());
Assert.Contains(resource.Resource, model.GetBuildResources());
Assert.True(resource.Resource.RequiresImageBuild());
}
[Fact]
public async Task PrebuiltProgramParticipatesInComputeWithoutBuildOrPushSteps()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var processRunner = new TestProcessRunner();
builder.Services.AddSingleton<IProcessRunner>(processRunner);
var runtime = new FakeContainerRuntime
{
ResolveAsyncCallback = _ => throw new InvalidOperationException("Prebuilt images must not resolve a container runtime.")
};
builder.Services.AddFakeContainerRuntime(runtime);
var resource = builder.AddResource(new TestDotnetProgramResource("program"))
.WithAnnotation(new TestProjectMetadata("program.csproj"))
.WithDotnetProgramPublishing()
.WithAnnotation(new ContainerImageAnnotation
{
Registry = "example.com",
Image = "program",
Tag = "v1"
});
using var app = builder.Build();
var model = app.Services.GetRequiredService<DistributedApplicationModel>();
Assert.Contains(resource.Resource, model.GetComputeResources());
Assert.DoesNotContain(resource.Resource, model.GetBuildResources());
Assert.DoesNotContain(resource.Resource, model.GetBuildAndPushResources());
Assert.False(resource.Resource.RequiresImageBuild());
Assert.True(resource.Resource.SupportsDotnetProgramPublishing());
var pipelineContext = new PipelineContext(
model,
app.Services.GetRequiredService<DistributedApplicationExecutionContext>(),
app.Services,
NullLogger.Instance,
TestContext.Current.CancellationToken);
var annotation = Assert.Single(resource.Resource.Annotations.OfType<PipelineStepAnnotation>());
var steps = await annotation.CreateStepsAsync(new PipelineStepFactoryContext
{
PipelineContext = pipelineContext,
Resource = resource.Resource
});
Assert.Empty(steps);
var imageManager = app.Services.GetRequiredService<IResourceContainerImageManager>();
await imageManager.BuildImageAsync(resource.Resource, TestContext.Current.CancellationToken);
await imageManager.BuildImagesAsync([resource.Resource], TestContext.Current.CancellationToken);
Assert.Empty(processRunner.ProcessSpecs);
Assert.Equal(0, runtime.ResolveAsyncCallCount);
}
[Fact]
public async Task PrebuiltProgramWithContainerFilesIsRejectedWithoutBuildWork()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var processRunner = new TestProcessRunner();
builder.Services.AddSingleton<IProcessRunner>(processRunner);
var runtime = new FakeContainerRuntime
{
ResolveAsyncCallback = _ => throw new InvalidOperationException("Invalid prebuilt image configuration must not resolve a container runtime.")
};
builder.Services.AddFakeContainerRuntime(runtime);
var source = builder.AddContainer("assets", "assets-image")
.WithAnnotation(new ContainerFilesSourceAnnotation { SourcePath = "/assets" });
var resource = builder.AddResource(new TestDotnetProgramResource("program"))
.WithAnnotation(new TestProjectMetadata("program.csproj"))
.WithDotnetProgramPublishing()
.WithAnnotation(new ContainerImageAnnotation
{
Registry = "example.com",
Image = "program",
Tag = "v1"
})
.WithAnnotation(new ContainerFilesDestinationAnnotation
{
Source = source.Resource,
DestinationPath = "/app/assets"
});
using var app = builder.Build();
const string expectedMessage =
"The .NET program resource 'program' 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.";
var pipelineException = await Assert.ThrowsAsync<DistributedApplicationException>(
() => ExecuteBuildPipelineAsync(app, WellKnownPipelineSteps.Build)).DefaultTimeout();
var imageManager = app.Services.GetRequiredService<IResourceContainerImageManager>();
var directException = await Assert.ThrowsAsync<DistributedApplicationException>(
() => imageManager.BuildImageAsync(resource.Resource, TestContext.Current.CancellationToken));
var batchException = await Assert.ThrowsAsync<DistributedApplicationException>(
() => imageManager.BuildImagesAsync([resource.Resource], TestContext.Current.CancellationToken));
Assert.Equal(expectedMessage, pipelineException.Message);
Assert.Equal(expectedMessage, directException.Message);
Assert.Equal(expectedMessage, batchException.Message);
Assert.Empty(processRunner.ProcessSpecs);
Assert.Equal(0, runtime.ResolveAsyncCallCount);
}
[Fact]
public void DotnetProgramReplicasProduceDistinctDcpInstances()
{
var resource = new TestDotnetProgramResource("program");
resource.Annotations.Add(new ReplicaAnnotation(3));
var nameGenerator = new DcpNameGenerator(
new ConfigurationBuilder().Build(),
Options.Create(new DcpOptions()));
nameGenerator.EnsureDcpInstancesPopulated(resource);
Assert.True(resource.TryGetInstances(out var instances));
Assert.Equal(3, instances.Length);
Assert.Equal([0, 1, 2], instances.Select(static instance => instance.Index));
}
[Theory]
[InlineData(false, false)]
[InlineData(true, false)]
[InlineData(false, true)]
[InlineData(true, true)]
public async Task ArchiveBuildPipelineOnlyRequiresRuntimeForContainerFiles(bool legacyProject, bool containerFiles)
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var processRunner = new TestProcessRunner();
builder.Services.AddSingleton<IProcessRunner>(processRunner);
var runtime = new FakeContainerRuntime(isRunning: false, name: "Docker");
builder.Services.AddFakeContainerRuntime(runtime);
builder.Services.AddSingleton<IInteractionService>(new TestInteractionService { IsAvailable = false });
var callbackCount = 0;
IResourceBuilder<IDotnetProgramResource> resource = legacyProject
? builder.AddResource(new ProjectResource("program"))
: builder.AddResource(new TestDotnetProgramResource("program"));
resource.WithAnnotation(new TestProjectMetadata(Path.Combine(workspace.WorkspaceRoot.FullName, "program.csproj")))
.WithDotnetProgramPublishing()
.WithAnnotation(new ContainerBuildOptionsCallbackAnnotation(context =>
{
callbackCount++;
context.Destination = ContainerImageDestination.Archive;
context.OutputPath = Path.Combine(workspace.WorkspaceRoot.FullName, "program.tar");
}));
if (containerFiles)
{
var source = builder.AddContainer("assets", "assets-image")
.WithAnnotation(new ContainerFilesSourceAnnotation { SourcePath = "/assets" });
resource.WithAnnotation(new ContainerFilesDestinationAnnotation
{
Source = source.Resource,
DestinationPath = "wwwroot"
});
}
using var app = builder.Build();
if (containerFiles)
{
var exception = await Assert.ThrowsAsync<DistributedApplicationException>(
() => ExecuteBuildPipelineAsync(app, "build-program")).DefaultTimeout();
Assert.Equal("Docker is not running. Start Docker and try again.", exception.Message);
Assert.True(runtime.WasHealthCheckCalled);
Assert.Empty(processRunner.ProcessSpecs);
}
else
{
await ExecuteBuildPipelineAsync(app, "build-program").DefaultTimeout();
Assert.False(runtime.WasHealthCheckCalled);
Assert.Equal("publish", Assert.Single(processRunner.ProcessSpecs).ArgumentList![0]);
}
Assert.Equal(1, callbackCount);
Assert.False(runtime.WasBuildImageCalled);
}
[Theory]
[InlineData(WellKnownPipelineSteps.Build, 2)]
[InlineData(WellKnownPipelineSteps.CheckContainerRuntime, 0)]
public async Task PipelineRecoversRuntimeForRequiredBuildsAndExplicitChecks(string stepName, int publishCount)
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var processRunner = new TestProcessRunner();
builder.Services.AddSingleton<IProcessRunner>(processRunner);
var running = 0;
var runtime = new FakeContainerRuntime(name: "Docker")
{
CheckIfRunningAsyncCallback = _ => Task.FromResult(Volatile.Read(ref running) != 0)
};
builder.Services.AddFakeContainerRuntime(runtime);
var interactions = new TestInteractionService();
builder.Services.AddSingleton<IInteractionService>(interactions);
builder.AddResource(new ProjectResource("local"))
.WithAnnotation(new TestProjectMetadata(Path.Combine(workspace.WorkspaceRoot.FullName, "local.csproj")));
builder.AddResource(new ProjectResource("archive"))
.WithAnnotation(new TestProjectMetadata(Path.Combine(workspace.WorkspaceRoot.FullName, "archive.csproj")))
.WithContainerBuildOptions(context =>
{
context.Destination = ContainerImageDestination.Archive;
context.OutputPath = Path.Combine(workspace.WorkspaceRoot.FullName, "archive.tar");
});
using var app = builder.Build();
var build = ExecuteBuildPipelineAsync(app, stepName);
var interaction = await interactions.Interactions.Reader.ReadAsync(TestContext.Current.CancellationToken).AsTask().DefaultTimeout();
Interlocked.Exchange(ref running, 1);
interaction.CompletionTcs.SetResult(InteractionResult.Ok(true));
await build.DefaultTimeout();
Assert.Equal(2, runtime.CheckIfRunningCallCount);
Assert.Equal(publishCount, processRunner.ProcessSpecs.Count);
Assert.All(processRunner.ProcessSpecs, spec => Assert.Equal("publish", spec.ArgumentList![0]));
Assert.False(interactions.Interactions.Reader.TryRead(out _));
}
[Fact]
public async Task DirectImageBuildRetainsNonInteractiveRuntimeFailure()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var runtime = new FakeContainerRuntime(isRunning: false, name: "Docker");
builder.Services.AddFakeContainerRuntime(runtime);
var interactions = new TestInteractionService();
builder.Services.AddSingleton<IInteractionService>(interactions);
var resource = builder.AddResource(new ProjectResource("program"))
.WithAnnotation(new TestProjectMetadata("program.csproj"));
using var app = builder.Build();
var manager = app.Services.GetRequiredService<IResourceContainerImageManager>();
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() =>
manager.BuildImageAsync(resource.Resource, TestContext.Current.CancellationToken)).DefaultTimeout();
Assert.Equal("Container runtime 'Docker' is not running or is unhealthy.", exception.Message);
Assert.False(interactions.Interactions.Reader.TryRead(out _));
}
[Fact]
public async Task FailedLocalImageLayeringRetainsDockerfileForDebugging()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var failure = new InvalidOperationException("layer build failed");
string? dockerfilePath = null;
var runtime = new FakeContainerRuntime(name: "Docker")
{
BuildImageAsyncCallback = (_, path, _, _, _, _, _) =>
{
dockerfilePath = path;
throw failure;
}
};
builder.Services.AddFakeContainerRuntime(runtime);
var source = builder.AddContainer("assets", "assets-image")
.WithAnnotation(new ContainerFilesSourceAnnotation { SourcePath = "/assets" });
var metadata = new TestProjectMetadata(Path.Combine(workspace.WorkspaceRoot.FullName, "program.csproj"));
var resource = builder.AddResource(new ProjectResource("program"))
.WithAnnotation(metadata)
.WithAnnotation(new ContainerFilesDestinationAnnotation
{
Source = source.Resource,
DestinationPath = "/app/assets"
});
using var app = builder.Build();
var logger = new FakeLogger();
await using var buildResult = new DotnetProgramImageBuildResult(
"program", "latest", "program", ContainerTargetPlatform.LinuxAmd64,
destination: null, outputPath: null, imageFormat: null, containerWorkingDirectory: "/app",
buildContext: null, temporarySourceImage: null);
try
{
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() =>
DotnetProgramPublishing.LayerContainerFilesAsync(
resource.Resource, metadata, buildResult, app.Services, logger, TestContext.Current.CancellationToken));
Assert.Same(failure, exception);
Assert.NotNull(dockerfilePath);
Assert.True(File.Exists(dockerfilePath));
var diagnostic = Assert.Single(
logger.Collector.GetSnapshot(),
record => record.Message.StartsWith("Failed build - temporary Dockerfile", StringComparison.Ordinal));
Assert.Equal(LogLevel.Debug, diagnostic.Level);
Assert.Equal($"Failed build - temporary Dockerfile left at {dockerfilePath} for debugging", diagnostic.Message);
var taggedImage = Assert.Single(runtime.TagImageCalls);
Assert.Equal("program", taggedImage.localImageName);
Assert.Equal(taggedImage.targetImageName, Assert.Single(runtime.RemoveImageCalls));
}
finally
{
if (dockerfilePath is not null)
{
File.Delete(dockerfilePath);
}
}
}
[Theory]
[InlineData("artifacts", false, false)]
[InlineData("artifacts.v1", false, true)]
[InlineData("image.custom", false, true)]
[InlineData("image.tar", false, true)]
[InlineData("image.tar.gz", false, true)]
[InlineData("image.tgz", false, true)]
[InlineData("artifacts.v1", true, false)]
[InlineData("image.tar", true, false)]
public void ArchiveOutputPathPreservesSdkFilenameConventions(string name, bool directoryIntent, bool explicitArchive)
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var path = Path.Combine(workspace.WorkspaceRoot.FullName, name);
if (directoryIntent)
{
path += Path.DirectorySeparatorChar;
}
Assert.Equal(explicitArchive, DotnetProgramPublishing.IsExplicitArchiveOutputPath(path));
}
[Theory]
[InlineData("artifacts.v1", true)]
[InlineData("image.tar", true)]
[InlineData("archive", false)]
[InlineData("image.custom", false)]
public void ArchiveOutputPathPreservesExistingFileAndDirectoryIntent(string name, bool directory)
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var path = Path.Combine(workspace.WorkspaceRoot.FullName, name);
if (directory)
{
Directory.CreateDirectory(path);
}
else
{
File.WriteAllText(path, "archive");
}
Assert.Equal(!directory, DotnetProgramPublishing.IsExplicitArchiveOutputPath(path));
}
private static Task ExecuteBuildPipelineAsync(DistributedApplication app, string stepName)
{
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,
TestContext.Current.CancellationToken);
return pipeline.ExecuteStepSequentiallyAsync(stepName, context);
}
private sealed class TestDotnetProgramResource(string name) :
Resource(name),
IDotnetProgramResource,
IComputeResource,
IResourceWithArgs;
private sealed class NonComputeDotnetProgramResource(string name) : Resource(name), IDotnetProgramResource;
private sealed class TestProjectMetadata(string projectPath) : IProjectMetadata
{
public string ProjectPath { get; } = projectPath;
}
}