Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
14 changes: 12 additions & 2 deletions src/Aspire.Hosting/Publishing/ContainerRuntimeBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
#pragma warning disable ASPIREPIPELINES003
#pragma warning disable ASPIRECONTAINERRUNTIME001

using System.Collections.Concurrent;
using System.Text.Json;
using System.Text.Json.Serialization;
using Aspire.Hosting.ApplicationModel;
Expand Down Expand Up @@ -179,16 +180,18 @@ private async Task ExecuteContainerCommandAsync(
/// <param name="cancellationToken">Cancellation token.</param>
/// <param name="logArguments">Arguments to pass to the log templates.</param>
/// <param name="environmentVariables">Optional environment variables to set for the process.</param>
/// <param name="outputBuffer">Optional buffer to collect stdout/stderr lines.</param>
/// <returns>The exit code of the process.</returns>
protected async Task<int> ExecuteContainerCommandWithExitCodeAsync(
string arguments,
string errorLogTemplate,
string successLogTemplate,
CancellationToken cancellationToken,
object[] logArguments,
Dictionary<string, string>? environmentVariables = null)
Dictionary<string, string>? environmentVariables = null,
ConcurrentQueue<string>? outputBuffer = null)
{
var spec = CreateProcessSpec(arguments);
var spec = CreateProcessSpec(arguments, outputBuffer);

// Add environment variables if provided
if (environmentVariables is not null)
Expand Down Expand Up @@ -280,17 +283,24 @@ protected static string BuildStageString(string? stage)
/// <param name="arguments">The command arguments.</param>
/// <returns>A configured ProcessSpec instance.</returns>
private ProcessSpec CreateProcessSpec(string arguments)
{
return CreateProcessSpec(arguments, outputBuffer: null);
}

private ProcessSpec CreateProcessSpec(string arguments, ConcurrentQueue<string>? outputBuffer)
{
return new ProcessSpec(RuntimeExecutable)
{
Arguments = arguments,
OnOutputData = output =>
{
_logger.LogDebug("{RuntimeName} (stdout): {Output}", RuntimeExecutable, output);
outputBuffer?.Enqueue(output);
},
OnErrorData = error =>
{
_logger.LogDebug("{RuntimeName} (stderr): {Error}", RuntimeExecutable, error);
outputBuffer?.Enqueue(error);
},
ThrowOnNonZeroReturnCode = false,
InheritEnv = true
Expand Down
82 changes: 32 additions & 50 deletions src/Aspire.Hosting/Publishing/DockerContainerRuntime.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
#pragma warning disable ASPIREPIPELINES003
#pragma warning disable ASPIRECONTAINERRUNTIME001

using System.Collections.Concurrent;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Dcp.Process;
using Microsoft.Extensions.Logging;

namespace Aspire.Hosting.Publishing;
Expand All @@ -18,7 +18,7 @@ public DockerContainerRuntime(ILogger<DockerContainerRuntime> logger) : base(log

protected override string RuntimeExecutable => "docker";
public override string Name => "Docker";
private async Task<int> RunDockerBuildAsync(string contextPath, string dockerfilePath, ContainerImageBuildOptions? options, Dictionary<string, string?> buildArguments, Dictionary<string, BuildImageSecretValue> buildSecrets, string? stage, CancellationToken cancellationToken)
private async Task RunDockerBuildAsync(string contextPath, string dockerfilePath, ContainerImageBuildOptions? options, Dictionary<string, string?> buildArguments, Dictionary<string, BuildImageSecretValue> buildSecrets, string? stage, CancellationToken cancellationToken)
{
var imageName = !string.IsNullOrEmpty(options?.Tag)
? $"{options.ImageName}:{options.Tag}"
Expand All @@ -37,13 +37,7 @@ private async Task<int> RunDockerBuildAsync(string contextPath, string dockerfil
}

builderName = $"{resourceName}-builder";
var createBuilderResult = await CreateBuildkitInstanceAsync(builderName, cancellationToken).ConfigureAwait(false);

if (createBuilderResult != 0)
{
Logger.LogError("Failed to create buildkit instance {BuilderName} with exit code {ExitCode}.", builderName, createBuilderResult);
return createBuilderResult;
}
await CreateBuildkitInstanceAsync(builderName, cancellationToken).ConfigureAwait(false);
}

try
Expand Down Expand Up @@ -93,47 +87,30 @@ private async Task<int> RunDockerBuildAsync(string contextPath, string dockerfil

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

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

// Add build secrets as environment variables (only for environment-type secrets)
// Prepare environment variables for build secrets
var environmentVariables = new Dictionary<string, string>();
foreach (var buildSecret in buildSecrets)
{
if (buildSecret.Value.Type == BuildImageSecretType.Environment && buildSecret.Value.Value is not null)
{
spec.EnvironmentVariables[buildSecret.Key.ToUpperInvariant()] = buildSecret.Value.Value;
environmentVariables[buildSecret.Key.ToUpperInvariant()] = buildSecret.Value.Value;
}
}

Logger.LogDebug("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);
var buildOutput = new ConcurrentQueue<string>();

if (processResult.ExitCode != 0)
{
Logger.LogError("docker buildx for {ImageName} failed with exit code {ExitCode}.", imageName, processResult.ExitCode);
return processResult.ExitCode;
}
var exitCode = await ExecuteContainerCommandWithExitCodeAsync(
arguments,
"Docker build for {ImageName} failed with exit code {ExitCode}.",
"Docker build for {ImageName} succeeded.",
cancellationToken,
new object[] { imageName },
environmentVariables,
buildOutput).ConfigureAwait(false);

Logger.LogInformation("docker buildx for {ImageName} succeeded.", imageName);
return processResult.ExitCode;
if (exitCode != 0)
{
throw new ProcessFailedException($"Docker build failed with exit code {exitCode}.", exitCode, buildOutput.ToArray());
}
}
finally
Expand All @@ -151,19 +128,14 @@ public override async Task BuildImageAsync(string contextPath, string dockerfile
// Normalize the context path to handle trailing slashes and relative paths
var normalizedContextPath = Path.GetFullPath(contextPath).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);

var exitCode = await RunDockerBuildAsync(
await RunDockerBuildAsync(
normalizedContextPath,
dockerfilePath,
options,
buildArguments,
buildSecrets,
stage,
cancellationToken).ConfigureAwait(false);

if (exitCode != 0)
{
throw new DistributedApplicationException($"Docker build failed with exit code {exitCode}.");
}
}

public override async Task<bool> CheckIfRunningAsync(CancellationToken cancellationToken)
Expand Down Expand Up @@ -216,16 +188,26 @@ private async Task<bool> CheckDockerBuildxAsync(CancellationToken cancellationTo
}
}

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

return await ExecuteContainerCommandWithExitCodeAsync(
var exitCode = await ExecuteContainerCommandWithExitCodeAsync(
arguments,
"Failed to create buildkit instance {BuilderName} with exit code {ExitCode}.",
"Successfully created buildkit instance {BuilderName}.",
cancellationToken,
new object[] { builderName }).ConfigureAwait(false);
new object[] { builderName },
outputBuffer: buildOutput).ConfigureAwait(false);

if (exitCode != 0)
{
throw new ProcessFailedException(
$"Failed to create buildkit instance '{builderName}' with exit code {exitCode}.",
exitCode,
buildOutput.ToArray());
}
}

private async Task<int> RemoveBuildkitInstanceAsync(string builderName, CancellationToken cancellationToken)
Expand Down
22 changes: 13 additions & 9 deletions src/Aspire.Hosting/Publishing/PodmanContainerRuntime.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
#pragma warning disable ASPIREPIPELINES003
#pragma warning disable ASPIRECONTAINERRUNTIME001

using System.Collections.Concurrent;
using System.Text.Json;
using System.Text.Json.Serialization;
using Aspire.Hosting.Dcp.Process;
Expand Down Expand Up @@ -140,7 +141,7 @@ internal static List<ComposeServiceInfo> ParsePodmanPsOutput(List<string> output
Publishers = g.Value
}).ToList();
}
private async Task<int> RunPodmanBuildAsync(string contextPath, string dockerfilePath, ContainerImageBuildOptions? options, Dictionary<string, string?> buildArguments, Dictionary<string, BuildImageSecretValue> buildSecrets, string? stage, CancellationToken cancellationToken)
private async Task RunPodmanBuildAsync(string contextPath, string dockerfilePath, ContainerImageBuildOptions? options, Dictionary<string, string?> buildArguments, Dictionary<string, BuildImageSecretValue> buildSecrets, string? stage, CancellationToken cancellationToken)
{
var imageName = !string.IsNullOrEmpty(options?.Tag)
? $"{options.ImageName}:{options.Tag}"
Expand Down Expand Up @@ -195,30 +196,33 @@ private async Task<int> RunPodmanBuildAsync(string contextPath, string dockerfil
}
}

return await ExecuteContainerCommandWithExitCodeAsync(
var buildOutput = new ConcurrentQueue<string>();

var exitCode = await ExecuteContainerCommandWithExitCodeAsync(
arguments,
"Podman build for {ImageName} failed with exit code {ExitCode}.",
"Podman build for {ImageName} succeeded.",
cancellationToken,
new object[] { imageName },
environmentVariables).ConfigureAwait(false);
environmentVariables,
buildOutput).ConfigureAwait(false);

if (exitCode != 0)
{
throw new ProcessFailedException($"Podman build failed with exit code {exitCode}.", exitCode, buildOutput.ToArray());
}
}

public override async Task BuildImageAsync(string contextPath, string dockerfilePath, ContainerImageBuildOptions? options, Dictionary<string, string?> buildArguments, Dictionary<string, BuildImageSecretValue> buildSecrets, string? stage, CancellationToken cancellationToken)
{
var exitCode = await RunPodmanBuildAsync(
await RunPodmanBuildAsync(
contextPath,
dockerfilePath,
options,
buildArguments,
buildSecrets,
stage,
cancellationToken).ConfigureAwait(false);

if (exitCode != 0)
{
throw new DistributedApplicationException($"Podman build failed with exit code {exitCode}.");
}
}

public override async Task<bool> CheckIfRunningAsync(CancellationToken cancellationToken)
Expand Down
55 changes: 55 additions & 0 deletions src/Aspire.Hosting/Publishing/ProcessFailedException.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

namespace Aspire.Hosting.Publishing;

/// <summary>
/// Exception thrown when a container image build or dotnet publish operation fails.
/// </summary>
internal sealed class ProcessFailedException : Exception
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

ProcessFailedException extends Exception directly, but the pipeline step executor in DistributedApplicationPipeline.ExecuteStepAsync re-throws DistributedApplicationException subtypes unchanged while wrapping everything else in InvalidOperationException($"Step '{step.Name}' failed: {ex.Message}").

Since build image calls flow through pipeline steps (e.g. ContainerResourceBuilderExtensions and ProjectResource.BuildProjectImage), this exception will be wrapped and the user-facing message will include a redundant "Step 'build-myapi' failed:" prefix — even though the step name is already shown by the pipeline reporter context ((build-myapi) ✗).

Consider either making ProcessFailedException extend DistributedApplicationException, or adding it to the pipeline's pass-through catch clause so the formatted build output surfaces cleanly without the extra wrapping.

{
/// <summary>
/// Initializes a new instance of <see cref="ProcessFailedException"/>.
/// </summary>
/// <param name="message">A summary of the failure (e.g., "Docker build failed with exit code 1.").</param>
/// <param name="exitCode">The process exit code.</param>
/// <param name="buildOutput">The captured stdout/stderr lines from the build process.</param>
public ProcessFailedException(string message, int exitCode, IReadOnlyList<string> buildOutput)
: base(message)
{
ExitCode = exitCode;
BuildOutput = buildOutput;
}

/// <summary>
/// The process exit code.
/// </summary>
public int ExitCode { get; }

/// <summary>
/// The captured stdout/stderr lines from the build process.
/// </summary>
public IReadOnlyList<string> BuildOutput { get; }

/// <inheritdoc/>
public override string Message => BuildOutput.Count > 0
? $"{base.Message}{Environment.NewLine}{GetFormattedOutput()}"
: base.Message;

/// <summary>
/// Returns the last <paramref name="maxLines"/> lines of build output formatted for display.
/// </summary>
public string GetFormattedOutput(int maxLines = 50)
{
if (BuildOutput.Count == 0)
{
return string.Empty;
}

IEnumerable<string> lines = BuildOutput.Count > maxLines
? BuildOutput.Skip(BuildOutput.Count - maxLines)
: BuildOutput;
Comment on lines +49 to +51
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

If the number of lines is truncated then should that be included in the message? e.g. Build output truncated: showing last 50 of 200 lines.


return string.Join(Environment.NewLine, lines);
}
}
24 changes: 10 additions & 14 deletions src/Aspire.Hosting/Publishing/ResourceContainerImageManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
#pragma warning disable ASPIREPIPELINES001
#pragma warning disable ASPIRECONTAINERRUNTIME001

using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using Aspire.Hosting.ApplicationModel;
Expand Down Expand Up @@ -304,25 +305,17 @@ private async Task BuildProjectContainerImageAsync(IResource resource, ResolvedC

logger.LogInformation("Building image: {ResourceName}", resource.Name);

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

if (!success)
{
logger.LogError("Building image for {ResourceName} failed", resource.Name);
throw new DistributedApplicationException($"Failed to build container image for resource '{resource.Name}'.");
}
else
{
logger.LogInformation("Building image for {ResourceName} completed", resource.Name);
}
logger.LogInformation("Building image for {ResourceName} completed", resource.Name);
}
finally
{
_throttle.Release();
}
}

private async Task<bool> ExecuteDotnetPublishAsync(IResource resource, ResolvedContainerBuildOptions options, CancellationToken cancellationToken)
private async Task ExecuteDotnetPublishAsync(IResource resource, ResolvedContainerBuildOptions options, CancellationToken cancellationToken)
{
// This is a resource project so we'll use the .NET SDK to build the container image.
if (!resource.TryGetLastAnnotation<IProjectMetadata>(out var projectMetadata))
Expand Down Expand Up @@ -378,16 +371,21 @@ private async Task<bool> ExecuteDotnetPublishAsync(IResource resource, ResolvedC
}
#pragma warning restore ASPIREDOCKERFILEBUILDER001

var buildOutput = new ConcurrentQueue<string>();

var spec = new ProcessSpec("dotnet")
{
Arguments = arguments,
ThrowOnNonZeroReturnCode = false,
OnOutputData = output =>
{
logger.LogDebug("dotnet publish {ProjectPath} (stdout): {Output}", projectMetadata.ProjectPath, output);
buildOutput.Enqueue(output);
},
OnErrorData = error =>
{
logger.LogDebug("dotnet publish {ProjectPath} (stderr): {Error}", projectMetadata.ProjectPath, error);
buildOutput.Enqueue(error);
}
};

Expand All @@ -406,15 +404,13 @@ private async Task<bool> ExecuteDotnetPublishAsync(IResource resource, ResolvedC

if (processResult.ExitCode != 0)
{
logger.LogError("dotnet publish for project {ProjectPath} failed with exit code {ExitCode}.", projectMetadata.ProjectPath, processResult.ExitCode);
return false;
throw new ProcessFailedException($"dotnet publish for project '{projectMetadata.ProjectPath}' failed with exit code {processResult.ExitCode}.", processResult.ExitCode, buildOutput.ToArray());
}
else
{
logger.LogDebug(
".NET CLI completed with exit code: {ExitCode}",
processResult.ExitCode);
return true;
}
}
}
Expand Down
Loading
Loading