diff --git a/src/Aspire.Hosting/Publishing/ResourceContainerImageManager.cs b/src/Aspire.Hosting/Publishing/ResourceContainerImageManager.cs index 3f316cd6ff5..d23f8564f33 100644 --- a/src/Aspire.Hosting/Publishing/ResourceContainerImageManager.cs +++ b/src/Aspire.Hosting/Publishing/ResourceContainerImageManager.cs @@ -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"; } @@ -195,6 +196,7 @@ private async Task 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; @@ -214,8 +216,8 @@ public async Task BuildImagesAsync(IEnumerable resources, Cancellatio if (!containerRuntimeHealthy) { - logger.LogError("Container runtime is not running or is unhealthy. Cannot build container images."); - throw new InvalidOperationException("Container runtime is not running or is unhealthy."); + logger.LogError("Container runtime '{ContainerRuntimeName}' is not running or is unhealthy. Cannot build container images.", ContainerRuntime.Name); + throw new InvalidOperationException($"Container runtime '{ContainerRuntime.Name}' is not running or is unhealthy."); } logger.LogDebug("{ContainerRuntimeName} is healthy", ContainerRuntime.Name); @@ -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)) + { + logger.LogDebug("Checking {ContainerRuntimeName} health", ContainerRuntime.Name); + + var containerRuntimeHealthy = await ContainerRuntime.CheckIfRunningAsync(cancellationToken).ConfigureAwait(false); + + if (!containerRuntimeHealthy) + { + logger.LogError("Container runtime '{ContainerRuntimeName}' is not running or is unhealthy. Cannot build container image.", ContainerRuntime.Name); + throw new InvalidOperationException($"Container runtime '{ContainerRuntime.Name}' 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 @@ -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 ResourcesRequireContainerRuntimeAsync(IEnumerable resources, CancellationToken cancellationToken) { - var hasDockerfileResources = resources.Any(resource => - resource.TryGetLastAnnotation(out _) && - resource.TryGetLastAnnotation(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(out _) && + resource.TryGetLastAnnotation(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; diff --git a/tests/Aspire.Hosting.Tests/Publishing/FakeContainerRuntime.cs b/tests/Aspire.Hosting.Tests/Publishing/FakeContainerRuntime.cs index b5f56060247..100109755df 100644 --- a/tests/Aspire.Hosting.Tests/Publishing/FakeContainerRuntime.cs +++ b/tests/Aspire.Hosting.Tests/Publishing/FakeContainerRuntime.cs @@ -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; } @@ -32,7 +33,8 @@ public sealed class FakeContainerRuntime(bool shouldFail = false) : IContainerRu public Task CheckIfRunningAsync(CancellationToken cancellationToken) { WasHealthCheckCalled = true; - return Task.FromResult(!shouldFail); + CheckIfRunningCallCount++; + return Task.FromResult(isRunning && !shouldFail); } public Task TagImageAsync(string localImageName, string targetImageName, CancellationToken cancellationToken) diff --git a/tests/Aspire.Hosting.Tests/Publishing/ResourceContainerImageManagerTests.cs b/tests/Aspire.Hosting.Tests/Publishing/ResourceContainerImageManagerTests.cs index ae66957625d..f8770b6190c 100644 --- a/tests/Aspire.Hosting.Tests/Publishing/ResourceContainerImageManagerTests.cs +++ b/tests/Aspire.Hosting.Tests/Publishing/ResourceContainerImageManagerTests.cs @@ -616,12 +616,13 @@ public async Task BuildImageAsync_ThrowsInvalidOperationException_WhenDockerRunt var exception = await Assert.ThrowsAsync(() => imageBuilder.BuildImagesAsync([container.Resource], cts.Token)); - Assert.Equal("Container runtime is not running or is unhealthy.", exception.Message); + Assert.Contains("Container runtime", exception.Message); + Assert.Contains("is not running or is unhealthy", exception.Message); var collector = app.Services.GetFakeLogCollector(); var logs = collector.GetSnapshot(); - Assert.Contains(logs, log => log.Message.Contains("Container runtime is not running or is unhealthy. Cannot build container images.")); + Assert.Contains(logs, log => log.Message.Contains("is not running or is unhealthy. Cannot build container images.")); } [Fact] @@ -1080,4 +1081,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("docker", fakeContainerRuntime); + + using var tempDir = new TestTempDirectory(); + + var servicea = builder.AddProject("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(); + + // 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("docker", fakeContainerRuntime); + + var servicea = builder.AddProject("servicea") + .WithContainerBuildOptions(ctx => + { + ctx.Destination = ContainerImageDestination.Registry; + }); + + using var app = builder.Build(); + + using var cts = new CancellationTokenSource(TestConstants.DefaultTimeoutTimeSpan); + var imageManager = app.Services.GetRequiredService(); + + // Should throw because container runtime is not running + var exception = await Assert.ThrowsAsync( + () => imageManager.BuildImagesAsync([servicea.Resource], cts.Token)); + + Assert.Contains("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("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(); + + // Should throw because Dockerfile builds always require Docker + var exception = await Assert.ThrowsAsync( + () => imageManager.BuildImageAsync(container.Resource, cts.Token)); + + Assert.Contains("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("docker", fakeContainerRuntime); + + using var tempDir = new TestTempDirectory(); + + // Add two projects: one with Archive destination, one without + var servicea = builder.AddProject("servicea") + .WithContainerBuildOptions(ctx => + { + ctx.Destination = ContainerImageDestination.Archive; + ctx.OutputPath = Path.Combine(tempDir.Path, "archives"); + ctx.ImageFormat = ContainerImageFormat.Oci; + }); + + var serviceb = builder.AddProject("serviceb"); + + using var app = builder.Build(); + + using var cts = new CancellationTokenSource(TestConstants.DefaultTimeoutTimeSpan); + var imageManager = app.Services.GetRequiredService(); + + // Should throw because serviceb requires Docker and it's not running + var exception = await Assert.ThrowsAsync( + () => imageManager.BuildImagesAsync([servicea.Resource, serviceb.Resource], cts.Token)); + + Assert.Contains("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("docker", fakeContainerRuntime); + + // Project without any destination set should default to requiring Docker + var servicea = builder.AddProject("servicea"); + + using var app = builder.Build(); + + using var cts = new CancellationTokenSource(TestConstants.DefaultTimeoutTimeSpan); + var imageManager = app.Services.GetRequiredService(); + + // Should throw because container runtime is not running and no Archive destination is set + var exception = await Assert.ThrowsAsync( + () => imageManager.BuildImagesAsync([servicea.Resource], cts.Token)); + + Assert.Contains("is not running or is unhealthy", exception.Message); + + // Verify CheckIfRunningAsync was called + Assert.Equal(1, fakeContainerRuntime.CheckIfRunningCallCount); + } }