Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
44 changes: 34 additions & 10 deletions src/Aspire.Hosting/Publishing/ResourceContainerImageManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,19 @@ await BuildProjectContainerImageAsync(
throw new InvalidOperationException("Resource image name could not be determined.");
}

// Dockerfile builds always require a container runtime
logger.LogDebug("Checking {ContainerRuntimeName} health for Dockerfile build", 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 images from Dockerfile.");
throw new InvalidOperationException("Container runtime is not running or is unhealthy.");
}

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

// This is a container resource so we'll use the container runtime to build the image
await BuildContainerImageFromDockerfileAsync(
resource,
Expand Down Expand Up @@ -500,18 +513,29 @@ 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 the container build options for each resource
var buildOptionsContext = await resource.ProcessContainerBuildOptionsCallbackAsync(
serviceProvider,
logger,
executionContext,
cancellationToken).ConfigureAwait(false);

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

// Check if any resource uses Docker format or has no output path
var options = await ResolveContainerBuildOptionsAsync(resource, cancellationToken).ConfigureAwait(false);
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