Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 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
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ private async Task WriteDockerComposeOutputAsync(DistributedApplicationModel mod
// Build container images for the services that require it
if (containerImagesToBuild.Count > 0)
{
await ImageBuilder.BuildImagesAsync(containerImagesToBuild, cancellationToken).ConfigureAwait(false);
await ImageBuilder.BuildImagesAsync(containerImagesToBuild, options: null, cancellationToken).ConfigureAwait(false);
}

var step = await activityReporter.CreateStepAsync(
Expand Down
16 changes: 15 additions & 1 deletion src/Aspire.Hosting/CompatibilitySuppressions.xml
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,23 @@
<Right>lib/net8.0/Aspire.Hosting.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Aspire.Hosting.Publishing.IResourceContainerImageBuilder.BuildImageAsync(Aspire.Hosting.ApplicationModel.IResource,System.Threading.CancellationToken)</Target>
<Left>lib/net8.0/Aspire.Hosting.dll</Left>
<Right>lib/net8.0/Aspire.Hosting.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0006</DiagnosticId>
<Target>M:Aspire.Hosting.Publishing.IResourceContainerImageBuilder.BuildImageAsync(Aspire.Hosting.ApplicationModel.IResource,Aspire.Hosting.Publishing.ContainerBuildOptions,System.Threading.CancellationToken)</Target>
<Left>lib/net8.0/Aspire.Hosting.dll</Left>
<Right>lib/net8.0/Aspire.Hosting.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0006</DiagnosticId>
<Target>M:Aspire.Hosting.Publishing.IResourceContainerImageBuilder.BuildImagesAsync(System.Collections.Generic.IEnumerable{Aspire.Hosting.ApplicationModel.IResource},System.Threading.CancellationToken)</Target>
<Target>M:Aspire.Hosting.Publishing.IResourceContainerImageBuilder.BuildImagesAsync(System.Collections.Generic.IEnumerable{Aspire.Hosting.ApplicationModel.IResource},Aspire.Hosting.Publishing.ContainerBuildOptions,System.Threading.CancellationToken)</Target>
<Left>lib/net8.0/Aspire.Hosting.dll</Left>
<Right>lib/net8.0/Aspire.Hosting.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
Expand Down
219 changes: 185 additions & 34 deletions src/Aspire.Hosting/Publishing/DockerContainerRuntime.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
// 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 ASPIREPUBLISHERS001

using Aspire.Hosting.Dcp.Process;
using Microsoft.Extensions.Logging;

Expand All @@ -9,49 +11,117 @@ namespace Aspire.Hosting.Publishing;
internal sealed class DockerContainerRuntime(ILogger<DockerContainerRuntime> logger) : IContainerRuntime
{
public string Name => "Docker";
private async Task<int> RunDockerBuildAsync(string contextPath, string dockerfilePath, string imageName, CancellationToken cancellationToken)
private async Task<int> RunDockerBuildAsync(string contextPath, string dockerfilePath, string imageName, ContainerBuildOptions? options, CancellationToken cancellationToken)
{
var spec = new ProcessSpec("docker")
string? builderName = null;

// Docker requires a custom buildkit instance for the image when
// targeting the OCI format so we construct it and remove it here.
if (options?.ImageFormat == ContainerImageFormat.Oci)
{
Arguments = $"build --file {dockerfilePath} --tag {imageName} {contextPath}",
OnOutputData = output =>
if (string.IsNullOrEmpty(options?.OutputPath))
{
logger.LogInformation("docker build (stdout): {Output}", output);
},
OnErrorData = error =>
{
logger.LogInformation("docker build (stderr): {Error}", error);
},
ThrowOnNonZeroReturnCode = false,
InheritEnv = true
};
throw new ArgumentException("OutputPath must be provided when ImageFormat is Oci.", nameof(options));
}

logger.LogInformation("Running Docker CLI with arguments: {ArgumentList}", spec.Arguments);
var (pendingProcessResult, processDisposable) = ProcessUtil.Run(spec);
builderName = $"{imageName.Replace('/', '-').Replace(':', '-')}-builder";
var createBuilderResult = await CreateBuildkitInstanceAsync(builderName, cancellationToken).ConfigureAwait(false);

await using (processDisposable)
if (createBuilderResult != 0)
{
logger.LogError("Failed to create buildkit instance {BuilderName} with exit code {ExitCode}.", builderName, createBuilderResult);
return createBuilderResult;
}
}

try
{
var processResult = await pendingProcessResult
.WaitAsync(cancellationToken)
.ConfigureAwait(false);
var arguments = $"buildx build --file \"{dockerfilePath}\" --tag \"{imageName}\"";

if (processResult.ExitCode != 0)
// Use the specific builder for OCI builds
if (!string.IsNullOrEmpty(builderName))
{
logger.LogError("Docker build for {ImageName} failed with exit code {ExitCode}.", imageName, processResult.ExitCode);
return processResult.ExitCode;
arguments += $" --builder \"{builderName}\"";
}

logger.LogInformation("Docker build for {ImageName} succeeded.", imageName);
return processResult.ExitCode;
// Add platform support if specified
if (options?.TargetPlatform is not null)
{
arguments += $" --platform \"{options.TargetPlatform.Value.ToRuntimePlatformString()}\"";
}

// Add output format support if specified
if (options?.ImageFormat is not null || !string.IsNullOrEmpty(options?.OutputPath))
{
var outputType = options?.ImageFormat switch
{
ContainerImageFormat.Oci => "type=oci",
ContainerImageFormat.Docker => "type=docker",
null => "type=docker",
_ => throw new ArgumentOutOfRangeException(nameof(options), options.ImageFormat, "Invalid container image format")
};

if (!string.IsNullOrEmpty(options?.OutputPath))
{
outputType += $",dest=\"{options.OutputPath}/{imageName}.tar\"";
Copy link
Member

Choose a reason for hiding this comment

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

How does quotes inside of quotes work here?

--output "type=oci,dest="C:\temp\foo.tar""

Copy link
Member

@captainsafia captainsafia Jul 9, 2025

Choose a reason for hiding this comment

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

Quoted values have been working fine for me in Bash/zshell. Some further digging reveals that Powershell doesn't handle this as nicely so the best thing to do is no quotes, although that assumes an output path with no spaces. :/

Copy link
Member

Choose a reason for hiding this comment

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

Why can't this be inverted? Quote the path, but don't quote around the value to --output?

Copy link
Member

Choose a reason for hiding this comment

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

You mean like --output type=oci,dest="C:\temp\foo.tar"? I think that's probably worse because there is no indication to the shell that the value is on cohesive string other than the whitespace separation. We're also pretty consistently quoting outer arguments in our CLI calls.

Copy link
Member

Choose a reason for hiding this comment

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

You mean like --output type=oci,dest="C:\temp\foo.tar"?

Yes

I think that's probably worse because there is no indication to the shell that the value is on cohesive string other than the whitespace separation.

What else could have whitespace?

}

arguments += $" --output \"{outputType}\"";
}

arguments += $" \"{contextPath}\"";

var spec = new ProcessSpec("docker")
{
Arguments = arguments,
OnOutputData = output =>
{
logger.LogInformation("docker buildx (stdout): {Output}", output);
},
OnErrorData = error =>
{
logger.LogInformation("docker buildx (stderr): {Error}", error);
},
ThrowOnNonZeroReturnCode = false,
InheritEnv = true
};

logger.LogInformation("Running Docker CLI with arguments: {ArgumentList}", spec.Arguments);
var (pendingProcessResult, processDisposable) = ProcessUtil.Run(spec);

await using (processDisposable)
{
var processResult = await pendingProcessResult
.WaitAsync(cancellationToken)
.ConfigureAwait(false);

if (processResult.ExitCode != 0)
{
logger.LogError("docker buildx for {ImageName} failed with exit code {ExitCode}.", imageName, processResult.ExitCode);
return processResult.ExitCode;
}

logger.LogInformation("docker buildx for {ImageName} succeeded.", imageName);
return processResult.ExitCode;
}
}
finally
{
// Clean up the buildkit instance if we created one
if (!string.IsNullOrEmpty(builderName))
{
await RemoveBuildkitInstanceAsync(builderName, cancellationToken).ConfigureAwait(false);
}
}
}

public async Task BuildImageAsync(string contextPath, string dockerfilePath, string imageName, CancellationToken cancellationToken)
public async Task BuildImageAsync(string contextPath, string dockerfilePath, string imageName, ContainerBuildOptions? options, CancellationToken cancellationToken)
{
var exitCode = await RunDockerBuildAsync(
contextPath,
dockerfilePath,
imageName,
options,
cancellationToken).ConfigureAwait(false);

if (exitCode != 0)
Expand All @@ -64,14 +134,14 @@ public Task<bool> CheckIfRunningAsync(CancellationToken cancellationToken)
{
var spec = new ProcessSpec("docker")
{
Arguments = "info",
Arguments = "buildx version",
Copy link
Member

Choose a reason for hiding this comment

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

Why did this change from info to buildx version?

Copy link
Member

Choose a reason for hiding this comment

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

We're taking a dependency on buildx now to support all the output types and buildx is an add-on that's included by default in certain implementations. docker info isn't a sufficient enough check.

OnOutputData = output =>
{
logger.LogInformation("docker info (stdout): {Output}", output);
logger.LogInformation("docker buildx version (stdout): {Output}", output);
},
OnErrorData = error =>
{
logger.LogInformation("docker info (stderr): {Error}", error);
logger.LogInformation("docker buildx version (stderr): {Error}", error);
},
ThrowOnNonZeroReturnCode = false,
InheritEnv = true
Expand All @@ -80,24 +150,105 @@ public Task<bool> CheckIfRunningAsync(CancellationToken cancellationToken)
logger.LogInformation("Running Docker CLI with arguments: {ArgumentList}", spec.Arguments);
var (pendingProcessResult, processDisposable) = ProcessUtil.Run(spec);

return CheckDockerInfoAsync(pendingProcessResult, processDisposable, cancellationToken);
return CheckDockerBuildxAsync(pendingProcessResult, processDisposable, cancellationToken);

async Task<bool> CheckDockerInfoAsync(Task<ProcessResult> pendingResult, IAsyncDisposable processDisposable, CancellationToken ct)
async Task<bool> CheckDockerBuildxAsync(Task<ProcessResult> pendingResult, IAsyncDisposable processDisposable, CancellationToken ct)
{
await using (processDisposable)
{
var processResult = await pendingResult.WaitAsync(ct).ConfigureAwait(false);

if (processResult.ExitCode != 0)
{
logger.LogError("Docker info failed with exit code {ExitCode}.", processResult.ExitCode);
logger.LogError("Docker buildx version failed with exit code {ExitCode}.", processResult.ExitCode);
return false;
}

// Optionally, parse output for health, but exit code 0 is usually sufficient.
logger.LogInformation("Docker is running and healthy.");
logger.LogInformation("Docker buildx is available and running.");
return true;
}
}
}
}

private async Task<int> CreateBuildkitInstanceAsync(string builderName, CancellationToken cancellationToken)
{
var arguments = $"buildx create --name \"{builderName}\" --driver docker-container";

var spec = new ProcessSpec("docker")
{
Arguments = arguments,
OnOutputData = output =>
{
logger.LogInformation("docker buildx create (stdout): {Output}", output);
},
OnErrorData = error =>
{
logger.LogInformation("docker buildx create (stderr): {Error}", error);
},
ThrowOnNonZeroReturnCode = false,
InheritEnv = true
};

logger.LogInformation("Creating buildkit instance with arguments: {ArgumentList}", spec.Arguments);
var (pendingProcessResult, processDisposable) = ProcessUtil.Run(spec);

await using (processDisposable)
{
var processResult = await pendingProcessResult
.WaitAsync(cancellationToken)
.ConfigureAwait(false);

if (processResult.ExitCode != 0)
{
logger.LogError("Failed to create buildkit instance {BuilderName} with exit code {ExitCode}.", builderName, processResult.ExitCode);
}
else
{
logger.LogInformation("Successfully created buildkit instance {BuilderName}.", builderName);
}

return processResult.ExitCode;
}
}

private async Task<int> RemoveBuildkitInstanceAsync(string builderName, CancellationToken cancellationToken)
{
var arguments = $"buildx rm \"{builderName}\"";

var spec = new ProcessSpec("docker")
{
Arguments = arguments,
OnOutputData = output =>
{
logger.LogInformation("docker buildx rm (stdout): {Output}", output);
},
OnErrorData = error =>
{
logger.LogInformation("docker buildx rm (stderr): {Error}", error);
},
ThrowOnNonZeroReturnCode = false,
InheritEnv = true
};

logger.LogInformation("Removing buildkit instance with arguments: {ArgumentList}", spec.Arguments);
var (pendingProcessResult, processDisposable) = ProcessUtil.Run(spec);

await using (processDisposable)
{
var processResult = await pendingProcessResult
.WaitAsync(cancellationToken)
.ConfigureAwait(false);

if (processResult.ExitCode != 0)
{
logger.LogWarning("Failed to remove buildkit instance {BuilderName} with exit code {ExitCode}.", builderName, processResult.ExitCode);
}
else
{
logger.LogInformation("Successfully removed buildkit instance {BuilderName}.", builderName);
}

return processResult.ExitCode;
}
}
}
4 changes: 3 additions & 1 deletion src/Aspire.Hosting/Publishing/IContainerRuntime.cs
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
// 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 ASPIREPUBLISHERS001

namespace Aspire.Hosting.Publishing;

internal interface IContainerRuntime
{
string Name { get; }
Task<bool> CheckIfRunningAsync(CancellationToken cancellationToken);
public Task BuildImageAsync(string contextPath, string dockerfilePath, string imageName, CancellationToken cancellationToken);
public Task BuildImageAsync(string contextPath, string dockerfilePath, string imageName, ContainerBuildOptions? options, CancellationToken cancellationToken);
}
Loading
Loading