diff --git a/src/Aspire.Hosting/Publishing/ResourceContainerImageBuilder.cs b/src/Aspire.Hosting/Publishing/ResourceContainerImageBuilder.cs index d903ae8dd32..33a91140428 100644 --- a/src/Aspire.Hosting/Publishing/ResourceContainerImageBuilder.cs +++ b/src/Aspire.Hosting/Publishing/ResourceContainerImageBuilder.cs @@ -134,32 +134,33 @@ public async Task BuildImagesAsync(IEnumerable resources, ContainerBu await using (step.ConfigureAwait(false)) { - // Currently, we build these images to the local Docker daemon. We need to ensure that - // the Docker daemon is running and accessible - - var task = await step.CreateTaskAsync( - $"Checking {ContainerRuntime.Name} health", - cancellationToken).ConfigureAwait(false); - - await using (task.ConfigureAwait(false)) + // Only check container runtime health if there are resources that need it + if (ResourcesRequireContainerRuntime(resources, options)) { - var containerRuntimeHealthy = await ContainerRuntime.CheckIfRunningAsync(cancellationToken).ConfigureAwait(false); + var task = await step.CreateTaskAsync( + $"Checking {ContainerRuntime.Name} health", + cancellationToken).ConfigureAwait(false); - if (!containerRuntimeHealthy) + await using (task.ConfigureAwait(false)) { - logger.LogError("Container runtime is not running or is unhealthy. Cannot build container images."); + var containerRuntimeHealthy = await ContainerRuntime.CheckIfRunningAsync(cancellationToken).ConfigureAwait(false); - await task.FailAsync( - $"{ContainerRuntime.Name} is not running or is unhealthy.", - cancellationToken).ConfigureAwait(false); + if (!containerRuntimeHealthy) + { + logger.LogError("Container runtime is not running or is unhealthy. Cannot build container images."); - await step.CompleteAsync("Building container images failed", CompletionState.CompletedWithError, cancellationToken).ConfigureAwait(false); - throw new InvalidOperationException("Container runtime is not running or is unhealthy."); - } + await task.FailAsync( + $"{ContainerRuntime.Name} is not running or is unhealthy.", + cancellationToken).ConfigureAwait(false); - await task.SucceedAsync( - $"{ContainerRuntime.Name} is healthy.", - cancellationToken).ConfigureAwait(false); + await step.CompleteAsync("Building container images failed", CompletionState.CompletedWithError, cancellationToken).ConfigureAwait(false); + throw new InvalidOperationException("Container runtime is not running or is unhealthy."); + } + + await task.SucceedAsync( + $"{ContainerRuntime.Name} is healthy.", + cancellationToken).ConfigureAwait(false); + } } foreach (var resource in resources) @@ -386,6 +387,17 @@ await ContainerRuntime.BuildImageAsync( return await step.CreateTaskAsync(description, cancellationToken).ConfigureAwait(false); } + // .NET Container builds that push OCI images to a local file path do not need a runtime + internal static bool ResourcesRequireContainerRuntime(IEnumerable resources, ContainerBuildOptions? options) + { + var hasDockerfileResources = resources.Any(resource => + resource.TryGetLastAnnotation(out _) && + resource.TryGetLastAnnotation(out _)); + var usesDocker = options == null || options.ImageFormat == ContainerImageFormat.Docker; + var hasNoOutputPath = options?.OutputPath == null; + return hasDockerfileResources || usesDocker || hasNoOutputPath; + } + } /// diff --git a/tests/Aspire.Hosting.Tests/Publishing/ResourceContainerImageBuilderTests.cs b/tests/Aspire.Hosting.Tests/Publishing/ResourceContainerImageBuilderTests.cs index 82fdb853d91..5fae91fcbc6 100644 --- a/tests/Aspire.Hosting.Tests/Publishing/ResourceContainerImageBuilderTests.cs +++ b/tests/Aspire.Hosting.Tests/Publishing/ResourceContainerImageBuilderTests.cs @@ -382,6 +382,66 @@ public void ContainerBuildOptions_CanSetAllProperties() Assert.Equal(ContainerTargetPlatform.LinuxArm64, options.TargetPlatform); } + [Fact] + public async Task BuildImagesAsync_WithOnlyProjectResourcesAndOci_DoesNotNeedContainerRuntime() + { + using var builder = TestDistributedApplicationBuilder.Create(output); + + builder.Services.AddLogging(logging => + { + logging.AddFakeLogging(); + logging.AddXunit(output); + }); + + // Create a fake container runtime that would fail if called + var fakeContainerRuntime = new FakeContainerRuntime(shouldFail: true); + builder.Services.AddKeyedSingleton("docker", fakeContainerRuntime); + + var servicea = builder.AddProject("servicea"); + + using var app = builder.Build(); + + using var cts = new CancellationTokenSource(TestConstants.LongTimeoutTimeSpan); + var imageBuilder = app.Services.GetRequiredService(); + + // This should not fail despite the fake container runtime being configured to fail + // because we only have project resources (no DockerfileBuildAnnotation) + var options = new ContainerBuildOptions { ImageFormat = ContainerImageFormat.Oci, OutputPath = "/tmp/test-path" }; + await imageBuilder.BuildImagesAsync([servicea.Resource], options: options, cts.Token); + + // Validate that the container runtime health check was not called + Assert.False(fakeContainerRuntime.WasHealthCheckCalled); + } + + [Fact] + public async Task BuildImagesAsync_WithDockerfileResources_ChecksContainerRuntimeHealth() + { + using var builder = TestDistributedApplicationBuilder.Create(output); + + builder.Services.AddLogging(logging => + { + logging.AddFakeLogging(); + logging.AddXunit(output); + }); + + // Create a fake container runtime that tracks health check calls + var fakeContainerRuntime = new FakeContainerRuntime(shouldFail: false); + builder.Services.AddKeyedSingleton("docker", fakeContainerRuntime); + + var (tempContextPath, tempDockerfilePath) = await DockerfileUtils.CreateTemporaryDockerfileAsync(); + var dockerfileResource = builder.AddDockerfile("test-dockerfile", tempContextPath, tempDockerfilePath); + + using var app = builder.Build(); + + using var cts = new CancellationTokenSource(TestConstants.LongTimeoutTimeSpan); + var imageBuilder = app.Services.GetRequiredService(); + + await imageBuilder.BuildImagesAsync([dockerfileResource.Resource], options: null, cts.Token); + + // Validate that the container runtime health check was called for resources with DockerfileBuildAnnotation + Assert.True(fakeContainerRuntime.WasHealthCheckCalled); + } + [Fact] public async Task BuildImageAsync_ThrowsInvalidOperationException_WhenDockerRuntimeNotAvailable() { @@ -393,7 +453,7 @@ public async Task BuildImageAsync_ThrowsInvalidOperationException_WhenDockerRunt logging.AddXunit(output); }); - builder.Services.AddKeyedSingleton("docker", new UnhealthyMockContainerRuntime()); + builder.Services.AddKeyedSingleton("docker", new FakeContainerRuntime(shouldFail: true)); var (tempContextPath, tempDockerfilePath) = await DockerfileUtils.CreateTemporaryDockerfileAsync(); var container = builder.AddDockerfile("container", tempContextPath, tempDockerfilePath); @@ -414,18 +474,21 @@ public async Task BuildImageAsync_ThrowsInvalidOperationException_WhenDockerRunt Assert.Contains(logs, log => log.Message.Contains("Container runtime is not running or is unhealthy. Cannot build container images.")); } - private sealed class UnhealthyMockContainerRuntime : IContainerRuntime + private sealed class FakeContainerRuntime(bool shouldFail) : IContainerRuntime { - public string Name => "MockDocker"; + public string Name => "fake-runtime"; + public bool WasHealthCheckCalled { get; private set; } public Task CheckIfRunningAsync(CancellationToken cancellationToken) { - return Task.FromResult(false); + WasHealthCheckCalled = true; + return Task.FromResult(!shouldFail); } public Task BuildImageAsync(string contextPath, string dockerfilePath, string imageName, ContainerBuildOptions? options, CancellationToken cancellationToken) { - throw new InvalidOperationException("This mock runtime should not be used for building images."); + // For testing, we don't need to actually build anything + return Task.CompletedTask; } } }