Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 33 additions & 10 deletions src/Aspire.Hosting/Publishing/ResourceContainerImageManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ private sealed class ResolvedContainerBuildOptions
public string? OutputPath { get; set; }
public ContainerImageFormat? ImageFormat { get; set; }
public ContainerTargetPlatform? TargetPlatform { get; set; }
public ContainerImageDestination? Destination { get; set; }
public string LocalImageName { get; set; } = string.Empty;
public string LocalImageTag { get; set; } = "latest";
}
Expand All @@ -195,6 +196,7 @@ private async Task<ResolvedContainerBuildOptions> ResolveContainerBuildOptionsAs
options.OutputPath = context.OutputPath;
options.ImageFormat = context.ImageFormat;
options.TargetPlatform = context.TargetPlatform;
options.Destination = context.Destination;
options.LocalImageName = context.LocalImageName ?? options.LocalImageName;
options.LocalImageTag = context.LocalImageTag ?? options.LocalImageTag;

Expand Down Expand Up @@ -236,6 +238,22 @@ public async Task BuildImageAsync(IResource resource, CancellationToken cancella

var options = await ResolveContainerBuildOptionsAsync(resource, cancellationToken).ConfigureAwait(false);

// Check if this resource needs a container runtime
if (await ResourcesRequireContainerRuntimeAsync([resource], cancellationToken).ConfigureAwait(false))
Comment on lines 239 to +242
Copy link

Copilot AI Dec 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ResolveContainerBuildOptionsAsync method is being called twice in BuildImageAsync - once at line 239 and again inside ResourcesRequireContainerRuntimeAsync at line 531. This is inefficient since the options are already resolved and available. Consider passing the already-resolved options to ResourcesRequireContainerRuntimeAsync to avoid redundant callback invocations, or move the runtime check before the options resolution to avoid the duplicate work.

Copilot uses AI. Check for mistakes.
{
logger.LogDebug("Checking {ContainerRuntimeName} health", ContainerRuntime.Name);

var containerRuntimeHealthy = await ContainerRuntime.CheckIfRunningAsync(cancellationToken).ConfigureAwait(false);

if (!containerRuntimeHealthy)
{
logger.LogError("Container runtime is not running or is unhealthy. Cannot build container image.");
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot

Suggested change
logger.LogError("Container runtime is not running or is unhealthy. Cannot build container image.");
logger.LogError("Container runtime '{ContainerRuntimeName}' is not running or is unhealthy. Cannot build container image.", ContainerRuntime.Name);

Include the container runtime name here and in the exception below.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in commit e25852e. Updated both the log error message and exception message to include the container runtime name. Also updated test assertions to work with the new format.

throw new InvalidOperationException("Container runtime is not running or is unhealthy.");
}

logger.LogDebug("{ContainerRuntimeName} is healthy", ContainerRuntime.Name);
}

if (resource is ProjectResource)
{
// If it is a project resource we need to build the container image
Expand Down Expand Up @@ -500,19 +518,24 @@ public async Task PushImageAsync(IResource resource, CancellationToken cancellat
// .NET Container builds that push OCI images to a local file path do not need a runtime
private async Task<bool> ResourcesRequireContainerRuntimeAsync(IEnumerable<IResource> resources, CancellationToken cancellationToken)
{
var hasDockerfileResources = resources.Any(resource =>
resource.TryGetLastAnnotation<ContainerImageAnnotation>(out _) &&
resource.TryGetLastAnnotation<DockerfileBuildAnnotation>(out _));

if (hasDockerfileResources)
{
return true;
}

// Check if any resource uses Docker format or has no output path
foreach (var resource in resources)
{
// Dockerfile resources always need container runtime
if (resource.TryGetLastAnnotation<ContainerImageAnnotation>(out _) &&
resource.TryGetLastAnnotation<DockerfileBuildAnnotation>(out _))
{
return true;
}

// Check if any resource uses Docker format or has no output path
var options = await ResolveContainerBuildOptionsAsync(resource, cancellationToken).ConfigureAwait(false);

// Skip resources that are explicitly configured to save as archives - they don't need Docker
if (options.Destination == ContainerImageDestination.Archive)
{
continue;
}

var usesDocker = options.ImageFormat == null || options.ImageFormat == ContainerImageFormat.Docker;
var hasNoOutputPath = options.OutputPath == null;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,11 @@ namespace Aspire.Hosting.Tests.Publishing;

using Aspire.Hosting.ApplicationModel;

public sealed class FakeContainerRuntime(bool shouldFail = false) : IContainerRuntime
public sealed class FakeContainerRuntime(bool shouldFail = false, bool isRunning = true) : IContainerRuntime
{
public string Name => "fake-runtime";
public bool WasHealthCheckCalled { get; private set; }
public int CheckIfRunningCallCount { get; private set; }
public bool WasTagImageCalled { get; private set; }
public bool WasRemoveImageCalled { get; private set; }
public bool WasPushImageCalled { get; private set; }
Expand All @@ -32,7 +33,8 @@ public sealed class FakeContainerRuntime(bool shouldFail = false) : IContainerRu
public Task<bool> CheckIfRunningAsync(CancellationToken cancellationToken)
{
WasHealthCheckCalled = true;
return Task.FromResult(!shouldFail);
CheckIfRunningCallCount++;
return Task.FromResult(isRunning && !shouldFail);
}

public Task TagImageAsync(string localImageName, string targetImageName, CancellationToken cancellationToken)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1080,4 +1080,201 @@ await servicea.Resource.ProcessContainerBuildOptionsCallbackAsync(
Assert.NotNull(capturedServices);
Assert.Equal(app.Services, capturedServices);
}

[Fact]
public async Task BuildImagesAsync_WithArchiveDestinationOnlyResources_DoesNotCheckContainerRuntime()
{
using var builder = TestDistributedApplicationBuilder.Create(output);

builder.Services.AddLogging(logging =>
{
logging.AddFakeLogging();
logging.AddXunit(output);
});

// Use FakeContainerRuntime that simulates Docker not running
var fakeContainerRuntime = new FakeContainerRuntime(shouldFail: false, isRunning: false);
builder.Services.AddKeyedSingleton<IContainerRuntime>("docker", fakeContainerRuntime);

using var tempDir = new TestTempDirectory();

var servicea = builder.AddProject<Projects.ServiceA>("servicea")
.WithContainerBuildOptions(ctx =>
{
ctx.Destination = ContainerImageDestination.Archive;
ctx.OutputPath = Path.Combine(tempDir.Path, "archives");
ctx.ImageFormat = ContainerImageFormat.Oci;
});

using var app = builder.Build();

using var cts = new CancellationTokenSource(TestConstants.DefaultTimeoutTimeSpan);
var imageManager = app.Services.GetRequiredService<IResourceContainerImageManager>();

// The check for whether Docker is needed should not call CheckIfRunningAsync
// when all resources are Archive destination. However, the actual build will fail
// because we can't build without network access to get base images.
// We're just verifying that CheckIfRunningAsync is not called in the upfront check.
try
{
await imageManager.BuildImagesAsync([servicea.Resource], cts.Token);
}
catch (DistributedApplicationException)
{
// Expected to fail during actual build due to missing network/base images
// But we should verify CheckIfRunningAsync was not called
}
catch (TaskCanceledException)
{
// Expected if build takes too long
}

// Verify CheckIfRunningAsync was not called in the upfront runtime check
Assert.Equal(0, fakeContainerRuntime.CheckIfRunningCallCount);
}

[Fact]
public async Task BuildImagesAsync_WithRegistryDestination_ChecksContainerRuntime()
{
using var builder = TestDistributedApplicationBuilder.Create(output);

builder.Services.AddLogging(logging =>
{
logging.AddFakeLogging();
logging.AddXunit(output);
});

// Use FakeContainerRuntime that simulates Docker not running
var fakeContainerRuntime = new FakeContainerRuntime(shouldFail: false, isRunning: false);
builder.Services.AddKeyedSingleton<IContainerRuntime>("docker", fakeContainerRuntime);

var servicea = builder.AddProject<Projects.ServiceA>("servicea")
.WithContainerBuildOptions(ctx =>
{
ctx.Destination = ContainerImageDestination.Registry;
});

using var app = builder.Build();

using var cts = new CancellationTokenSource(TestConstants.DefaultTimeoutTimeSpan);
var imageManager = app.Services.GetRequiredService<IResourceContainerImageManager>();

// Should throw because container runtime is not running
var exception = await Assert.ThrowsAsync<InvalidOperationException>(
() => imageManager.BuildImagesAsync([servicea.Resource], cts.Token));

Assert.Contains("Container runtime is not running or is unhealthy", exception.Message);

// Verify CheckIfRunningAsync was called in the upfront check
Assert.Equal(1, fakeContainerRuntime.CheckIfRunningCallCount);
}

[Fact]
public async Task BuildImageAsync_DockerfileResource_RequiresContainerRuntime()
{
using var builder = TestDistributedApplicationBuilder.Create(output);

builder.Services.AddLogging(logging =>
{
logging.AddFakeLogging();
logging.AddXunit(output);
});

// Use FakeContainerRuntime that simulates Docker not running
var fakeContainerRuntime = new FakeContainerRuntime(shouldFail: false, isRunning: false);
builder.Services.AddKeyedSingleton<IContainerRuntime>("docker", fakeContainerRuntime);

var (tempContextPath, tempDockerfilePath) = await DockerfileUtils.CreateTemporaryDockerfileAsync();
var container = builder.AddDockerfile("container", tempContextPath, tempDockerfilePath);

using var app = builder.Build();

using var cts = new CancellationTokenSource(TestConstants.LongTimeoutTimeSpan);
var imageManager = app.Services.GetRequiredService<IResourceContainerImageManager>();

// Should throw because Dockerfile builds always require Docker
var exception = await Assert.ThrowsAsync<InvalidOperationException>(
() => imageManager.BuildImageAsync(container.Resource, cts.Token));

Assert.Contains("Container runtime is not running or is unhealthy", exception.Message);

// Verify CheckIfRunningAsync was called in BuildImageAsync
Assert.Equal(1, fakeContainerRuntime.CheckIfRunningCallCount);
}

[Fact]
public async Task BuildImagesAsync_MixedDestinations_ChecksRuntimeWhenAnyResourceNeedsIt()
{
using var builder = TestDistributedApplicationBuilder.Create(output);

builder.Services.AddLogging(logging =>
{
logging.AddFakeLogging();
logging.AddXunit(output);
});

// Use FakeContainerRuntime that simulates Docker not running
var fakeContainerRuntime = new FakeContainerRuntime(shouldFail: false, isRunning: false);
builder.Services.AddKeyedSingleton<IContainerRuntime>("docker", fakeContainerRuntime);

using var tempDir = new TestTempDirectory();

// Add two projects: one with Archive destination, one without
var servicea = builder.AddProject<Projects.ServiceA>("servicea")
.WithContainerBuildOptions(ctx =>
{
ctx.Destination = ContainerImageDestination.Archive;
ctx.OutputPath = Path.Combine(tempDir.Path, "archives");
ctx.ImageFormat = ContainerImageFormat.Oci;
});

var serviceb = builder.AddProject<Projects.ServiceB>("serviceb");

using var app = builder.Build();

using var cts = new CancellationTokenSource(TestConstants.DefaultTimeoutTimeSpan);
var imageManager = app.Services.GetRequiredService<IResourceContainerImageManager>();

// Should throw because serviceb requires Docker and it's not running
var exception = await Assert.ThrowsAsync<InvalidOperationException>(
() => imageManager.BuildImagesAsync([servicea.Resource, serviceb.Resource], cts.Token));

Assert.Contains("Container runtime is not running or is unhealthy", exception.Message);

// Verify CheckIfRunningAsync was called once for the entire batch
Assert.Equal(1, fakeContainerRuntime.CheckIfRunningCallCount);
}

[Fact]
public async Task BuildImagesAsync_NoDestinationSet_ChecksContainerRuntime()
{
using var builder = TestDistributedApplicationBuilder.Create(output);

builder.Services.AddLogging(logging =>
{
logging.AddFakeLogging();
logging.AddXunit(output);
});

// Use FakeContainerRuntime that simulates Docker not running
var fakeContainerRuntime = new FakeContainerRuntime(shouldFail: false, isRunning: false);
builder.Services.AddKeyedSingleton<IContainerRuntime>("docker", fakeContainerRuntime);

// Project without any destination set should default to requiring Docker
var servicea = builder.AddProject<Projects.ServiceA>("servicea");

using var app = builder.Build();

using var cts = new CancellationTokenSource(TestConstants.DefaultTimeoutTimeSpan);
var imageManager = app.Services.GetRequiredService<IResourceContainerImageManager>();

// Should throw because container runtime is not running and no Archive destination is set
var exception = await Assert.ThrowsAsync<InvalidOperationException>(
() => imageManager.BuildImagesAsync([servicea.Resource], cts.Token));

Assert.Contains("Container runtime is not running or is unhealthy", exception.Message);

// Verify CheckIfRunningAsync was called
Assert.Equal(1, fakeContainerRuntime.CheckIfRunningCallCount);
}
}
Loading