diff --git a/src/Aspire.Hosting.Docker/DockerComposePublishingContext.cs b/src/Aspire.Hosting.Docker/DockerComposePublishingContext.cs
index b3aa916365f..c0b030774c3 100644
--- a/src/Aspire.Hosting.Docker/DockerComposePublishingContext.cs
+++ b/src/Aspire.Hosting.Docker/DockerComposePublishingContext.cs
@@ -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(
diff --git a/src/Aspire.Hosting/CompatibilitySuppressions.xml b/src/Aspire.Hosting/CompatibilitySuppressions.xml
index c612d4d2a5b..bfcbe1ccf84 100644
--- a/src/Aspire.Hosting/CompatibilitySuppressions.xml
+++ b/src/Aspire.Hosting/CompatibilitySuppressions.xml
@@ -43,9 +43,23 @@
lib/net8.0/Aspire.Hosting.dll
true
+
+ CP0002
+ M:Aspire.Hosting.Publishing.IResourceContainerImageBuilder.BuildImageAsync(Aspire.Hosting.ApplicationModel.IResource,System.Threading.CancellationToken)
+ lib/net8.0/Aspire.Hosting.dll
+ lib/net8.0/Aspire.Hosting.dll
+ true
+
+
+ CP0006
+ M:Aspire.Hosting.Publishing.IResourceContainerImageBuilder.BuildImageAsync(Aspire.Hosting.ApplicationModel.IResource,Aspire.Hosting.Publishing.ContainerBuildOptions,System.Threading.CancellationToken)
+ lib/net8.0/Aspire.Hosting.dll
+ lib/net8.0/Aspire.Hosting.dll
+ true
+
CP0006
- M:Aspire.Hosting.Publishing.IResourceContainerImageBuilder.BuildImagesAsync(System.Collections.Generic.IEnumerable{Aspire.Hosting.ApplicationModel.IResource},System.Threading.CancellationToken)
+ M:Aspire.Hosting.Publishing.IResourceContainerImageBuilder.BuildImagesAsync(System.Collections.Generic.IEnumerable{Aspire.Hosting.ApplicationModel.IResource},Aspire.Hosting.Publishing.ContainerBuildOptions,System.Threading.CancellationToken)
lib/net8.0/Aspire.Hosting.dll
lib/net8.0/Aspire.Hosting.dll
true
diff --git a/src/Aspire.Hosting/Publishing/DockerContainerRuntime.cs b/src/Aspire.Hosting/Publishing/DockerContainerRuntime.cs
index cbb71d32fab..a5bc8d11a05 100644
--- a/src/Aspire.Hosting/Publishing/DockerContainerRuntime.cs
+++ b/src/Aspire.Hosting/Publishing/DockerContainerRuntime.cs
@@ -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;
@@ -9,49 +11,118 @@ namespace Aspire.Hosting.Publishing;
internal sealed class DockerContainerRuntime(ILogger logger) : IContainerRuntime
{
public string Name => "Docker";
- private async Task RunDockerBuildAsync(string contextPath, string dockerfilePath, string imageName, CancellationToken cancellationToken)
+ private async Task RunDockerBuildAsync(string contextPath, string dockerfilePath, string imageName, ContainerBuildOptions? options, CancellationToken cancellationToken)
{
- var spec = new ProcessSpec("docker")
+ string? builderName = null;
+ var resourceName = imageName.Replace('/', '-').Replace(':', '-');
+
+ // 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 = $"{resourceName}-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={Path.Combine(options.OutputPath, resourceName)}.tar";
+ }
+
+ 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)
@@ -64,14 +135,14 @@ public Task CheckIfRunningAsync(CancellationToken cancellationToken)
{
var spec = new ProcessSpec("docker")
{
- Arguments = "info",
+ Arguments = "buildx version",
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
@@ -80,9 +151,9 @@ public Task 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 CheckDockerInfoAsync(Task pendingResult, IAsyncDisposable processDisposable, CancellationToken ct)
+ async Task CheckDockerBuildxAsync(Task pendingResult, IAsyncDisposable processDisposable, CancellationToken ct)
{
await using (processDisposable)
{
@@ -90,14 +161,95 @@ async Task CheckDockerInfoAsync(Task pendingResult, IAsyncD
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;
}
}
}
-}
\ No newline at end of file
+
+ private async Task 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 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;
+ }
+ }
+}
diff --git a/src/Aspire.Hosting/Publishing/IContainerRuntime.cs b/src/Aspire.Hosting/Publishing/IContainerRuntime.cs
index ebf44851cc9..7f4bbafbd0f 100644
--- a/src/Aspire.Hosting/Publishing/IContainerRuntime.cs
+++ b/src/Aspire.Hosting/Publishing/IContainerRuntime.cs
@@ -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 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);
}
\ No newline at end of file
diff --git a/src/Aspire.Hosting/Publishing/PodmanContainerRuntime.cs b/src/Aspire.Hosting/Publishing/PodmanContainerRuntime.cs
index dcfb09ff4f3..cbcdc0e1939 100644
--- a/src/Aspire.Hosting/Publishing/PodmanContainerRuntime.cs
+++ b/src/Aspire.Hosting/Publishing/PodmanContainerRuntime.cs
@@ -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;
@@ -9,11 +11,41 @@ namespace Aspire.Hosting.Publishing;
internal sealed class PodmanContainerRuntime(ILogger logger) : IContainerRuntime
{
public string Name => "Podman";
- private async Task RunPodmanBuildAsync(string contextPath, string dockerfilePath, string imageName, CancellationToken cancellationToken)
+ private async Task RunPodmanBuildAsync(string contextPath, string dockerfilePath, string imageName, ContainerBuildOptions? options, CancellationToken cancellationToken)
{
+ var arguments = $"build --file \"{dockerfilePath}\" --tag \"{imageName}\"";
+
+ // Add platform support if specified
+ if (options?.TargetPlatform is not null)
+ {
+ arguments += $" --platform \"{options.TargetPlatform.Value.ToRuntimePlatformString()}\"";
+ }
+
+ // Add format support if specified
+ if (options?.ImageFormat is not null)
+ {
+ var format = options.ImageFormat.Value switch
+ {
+ ContainerImageFormat.Oci => "oci",
+ ContainerImageFormat.Docker => "docker",
+ _ => throw new ArgumentOutOfRangeException(nameof(options), options.ImageFormat, "Invalid container image format")
+ };
+ arguments += $" --format \"{format}\"";
+ }
+
+ // Add output support if specified
+ if (!string.IsNullOrEmpty(options?.OutputPath))
+ {
+ // Extract resource name from imageName for the file name
+ var resourceName = imageName.Split('/').Last().Split(':').First();
+ arguments += $" --output \"{Path.Combine(options.OutputPath, resourceName)}.tar\"";
+ }
+
+ arguments += $" \"{contextPath}\"";
+
var spec = new ProcessSpec("podman")
{
- Arguments = $"build --file {dockerfilePath} --tag {imageName} {contextPath}",
+ Arguments = arguments,
OnOutputData = output =>
{
logger.LogInformation("podman build (stdout): {Output}", output);
@@ -46,12 +78,13 @@ private async Task RunPodmanBuildAsync(string contextPath, string dockerfil
}
}
- 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 RunPodmanBuildAsync(
contextPath,
dockerfilePath,
imageName,
+ options,
cancellationToken).ConfigureAwait(false);
if (exitCode != 0)
@@ -100,4 +133,4 @@ async Task CheckPodmanHealthAsync(Task pending, IAsyncDispo
}
}
}
-}
\ No newline at end of file
+}
diff --git a/src/Aspire.Hosting/Publishing/ResourceContainerImageBuilder.cs b/src/Aspire.Hosting/Publishing/ResourceContainerImageBuilder.cs
index 488bd668799..e846f24bf77 100644
--- a/src/Aspire.Hosting/Publishing/ResourceContainerImageBuilder.cs
+++ b/src/Aspire.Hosting/Publishing/ResourceContainerImageBuilder.cs
@@ -13,6 +13,82 @@
namespace Aspire.Hosting.Publishing;
+///
+/// Specifies the format for container images.
+///
+[Experimental("ASPIREPUBLISHERS001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
+public enum ContainerImageFormat
+{
+ ///
+ /// Docker format (default).
+ ///
+ Docker,
+
+ ///
+ /// OCI format.
+ ///
+ Oci
+}
+
+///
+/// Specifies the target platform for container images.
+///
+[Experimental("ASPIREPUBLISHERS001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
+public enum ContainerTargetPlatform
+{
+ ///
+ /// Linux AMD64 (linux/amd64).
+ ///
+ LinuxAmd64,
+
+ ///
+ /// Linux ARM64 (linux/arm64).
+ ///
+ LinuxArm64,
+
+ ///
+ /// Linux ARM (linux/arm).
+ ///
+ LinuxArm,
+
+ ///
+ /// Linux 386 (linux/386).
+ ///
+ Linux386,
+
+ ///
+ /// Windows AMD64 (windows/amd64).
+ ///
+ WindowsAmd64,
+
+ ///
+ /// Windows ARM64 (windows/arm64).
+ ///
+ WindowsArm64
+}
+
+///
+/// Options for building container images.
+///
+[Experimental("ASPIREPUBLISHERS001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
+public class ContainerBuildOptions
+{
+ ///
+ /// Gets the output path for the container archive.
+ ///
+ public string? OutputPath { get; init; }
+
+ ///
+ /// Gets the container image format.
+ ///
+ public ContainerImageFormat? ImageFormat { get; init; }
+
+ ///
+ /// Gets the target platform for the container.
+ ///
+ public ContainerTargetPlatform? TargetPlatform { get; init; }
+}
+
///
/// Provides a service to publishers for building containers that represent a resource.
///
@@ -23,16 +99,18 @@ public interface IResourceContainerImageBuilder
/// Builds a container that represents the specified resource.
///
/// The resource to build.
+ /// The container build options.
/// The cancellation token.
- Task BuildImageAsync(IResource resource, CancellationToken cancellationToken);
+ Task BuildImageAsync(IResource resource, ContainerBuildOptions? options = null, CancellationToken cancellationToken = default);
///
/// Builds container images for a collection of resources.
///
/// The resources to build images for.
+ /// The container build options.
/// The cancellation token.
///
- Task BuildImagesAsync(IEnumerable resources, CancellationToken cancellationToken);
+ Task BuildImagesAsync(IEnumerable resources, ContainerBuildOptions? options = null, CancellationToken cancellationToken = default);
}
internal sealed class ResourceContainerImageBuilder(
@@ -48,7 +126,7 @@ internal sealed class ResourceContainerImageBuilder(
null => serviceProvider.GetRequiredKeyedService("docker")
};
- public async Task BuildImagesAsync(IEnumerable resources, CancellationToken cancellationToken)
+ public async Task BuildImagesAsync(IEnumerable resources, ContainerBuildOptions? options = null, CancellationToken cancellationToken = default)
{
var step = await activityReporter.CreateStepAsync(
"Building container images for resources",
@@ -87,19 +165,19 @@ await task.SucceedAsync(
foreach (var resource in resources)
{
// TODO: Consider parallelizing this.
- await BuildImageAsync(step, resource, cancellationToken).ConfigureAwait(false);
+ await BuildImageAsync(step, resource, options, cancellationToken).ConfigureAwait(false);
}
await step.CompleteAsync("Building container images completed", CompletionState.Completed, cancellationToken).ConfigureAwait(false);
}
}
- public Task BuildImageAsync(IResource resource, CancellationToken cancellationToken)
+ public Task BuildImageAsync(IResource resource, ContainerBuildOptions? options = null, CancellationToken cancellationToken = default)
{
- return BuildImageAsync(step: null, resource, cancellationToken);
+ return BuildImageAsync(step: null, resource, options, cancellationToken);
}
- private async Task BuildImageAsync(IPublishingStep? step, IResource resource, CancellationToken cancellationToken)
+ private async Task BuildImageAsync(IPublishingStep? step, IResource resource, ContainerBuildOptions? options, CancellationToken cancellationToken)
{
logger.LogInformation("Building container image for resource {Resource}", resource.Name);
@@ -110,6 +188,7 @@ private async Task BuildImageAsync(IPublishingStep? step, IResource resource, Ca
await BuildProjectContainerImageAsync(
resource,
step,
+ options,
cancellationToken).ConfigureAwait(false);
return;
}
@@ -124,6 +203,7 @@ await BuildContainerImageFromDockerfileAsync(
dockerfileBuildAnnotation.DockerfilePath,
containerImageAnnotation.Image,
step,
+ options,
cancellationToken).ConfigureAwait(false);
return;
}
@@ -134,7 +214,7 @@ await BuildContainerImageFromDockerfileAsync(
}
}
- private async Task BuildProjectContainerImageAsync(IResource resource, IPublishingStep? step, CancellationToken cancellationToken)
+ private async Task BuildProjectContainerImageAsync(IResource resource, IPublishingStep? step, ContainerBuildOptions? options, CancellationToken cancellationToken)
{
var publishingTask = await CreateTaskAsync(
step,
@@ -142,111 +222,106 @@ private async Task BuildProjectContainerImageAsync(IResource resource, IPublishi
cancellationToken
).ConfigureAwait(false);
+ var success = await ExecuteDotnetPublishAsync(resource, options, cancellationToken).ConfigureAwait(false);
+
if (publishingTask is not null)
{
await using (publishingTask.ConfigureAwait(false))
{
- // This is a resource project so we'll use the .NET SDK to build the container image.
- if (!resource.TryGetLastAnnotation(out var projectMetadata))
+ if (!success)
{
- throw new DistributedApplicationException($"The resource '{projectMetadata}' does not have a project metadata annotation.");
+ await publishingTask.FailAsync($"Building image for {resource.Name} failed", cancellationToken).ConfigureAwait(false);
}
-
- var spec = new ProcessSpec("dotnet")
+ else
{
- Arguments = $"publish {projectMetadata.ProjectPath} --configuration Release /t:PublishContainer /p:ContainerRepository={resource.Name}",
- OnOutputData = output =>
- {
- logger.LogInformation("dotnet publish {ProjectPath} (stdout): {Output}", projectMetadata.ProjectPath, output);
- },
- OnErrorData = error =>
- {
- logger.LogError("dotnet publish {ProjectPath} (stderr): {Error}", projectMetadata.ProjectPath, error);
- }
- };
+ await publishingTask.SucceedAsync($"Building image for {resource.Name} completed", cancellationToken).ConfigureAwait(false);
+ }
+ }
+ }
- logger.LogInformation(
- "Starting .NET CLI with arguments: {Arguments}",
- string.Join(" ", spec.Arguments)
- );
+ if (!success)
+ {
+ throw new DistributedApplicationException($"Failed to build container image.");
+ }
+ }
- var (pendingProcessResult, processDisposable) = ProcessUtil.Run(spec);
+ private async Task ExecuteDotnetPublishAsync(IResource resource, ContainerBuildOptions? options, CancellationToken cancellationToken)
+ {
+ // This is a resource project so we'll use the .NET SDK to build the container image.
+ if (!resource.TryGetLastAnnotation(out var projectMetadata))
+ {
+ throw new DistributedApplicationException($"The resource '{projectMetadata}' does not have a project metadata annotation.");
+ }
+
+ var arguments = $"publish \"{projectMetadata.ProjectPath}\" --configuration Release /t:PublishContainer /p:ContainerRepository=\"{resource.Name}\"";
+
+ // Add additional arguments based on options
+ if (options is not null)
+ {
+ if (!string.IsNullOrEmpty(options.OutputPath))
+ {
+ arguments += $" /p:ContainerArchiveOutputPath=\"{options.OutputPath}\"";
+ }
- await using (processDisposable)
+ if (options.ImageFormat is not null)
+ {
+ var format = options.ImageFormat.Value switch
{
- var processResult = await pendingProcessResult
- .WaitAsync(cancellationToken)
- .ConfigureAwait(false);
-
- if (processResult.ExitCode != 0)
- {
- logger.LogError("dotnet publish for project {ProjectPath} failed with exit code {ExitCode}.", projectMetadata.ProjectPath, processResult.ExitCode);
-
- await publishingTask.FailAsync($"Building image for {resource.Name} failed", cancellationToken).ConfigureAwait(false);
- throw new DistributedApplicationException($"Failed to build container image.");
- }
- else
- {
- await publishingTask.SucceedAsync($"Building image for {resource.Name} completed", cancellationToken).ConfigureAwait(false);
-
- logger.LogDebug(
- ".NET CLI completed with exit code: {ExitCode}",
- processResult.ExitCode);
- }
- }
+ ContainerImageFormat.Docker => "Docker",
+ ContainerImageFormat.Oci => "OCI",
+ _ => throw new ArgumentOutOfRangeException(nameof(options), options.ImageFormat, "Invalid container image format")
+ };
+ arguments += $" /p:ContainerImageFormat=\"{format}\"";
+ }
+
+ if (options.TargetPlatform is not null)
+ {
+ arguments += $" /p:ContainerRuntimeIdentifier=\"{options.TargetPlatform.Value.ToMSBuildRuntimeIdentifierString()}\"";
}
}
- else
+
+ var spec = new ProcessSpec("dotnet")
{
- // Handle case when publishingTask is null (no step provided)
- // This is a resource project so we'll use the .NET SDK to build the container image.
- if (!resource.TryGetLastAnnotation(out var projectMetadata))
+ Arguments = arguments,
+ OnOutputData = output =>
{
- throw new DistributedApplicationException($"The resource '{projectMetadata}' does not have a project metadata annotation.");
+ logger.LogInformation("dotnet publish {ProjectPath} (stdout): {Output}", projectMetadata.ProjectPath, output);
+ },
+ OnErrorData = error =>
+ {
+ logger.LogError("dotnet publish {ProjectPath} (stderr): {Error}", projectMetadata.ProjectPath, error);
}
+ };
- var spec = new ProcessSpec("dotnet")
- {
- Arguments = $"publish {projectMetadata.ProjectPath} --configuration Release /t:PublishContainer /p:ContainerRepository={resource.Name}",
- OnOutputData = output =>
- {
- logger.LogInformation("dotnet publish {ProjectPath} (stdout): {Output}", projectMetadata.ProjectPath, output);
- },
- OnErrorData = error =>
- {
- logger.LogError("dotnet publish {ProjectPath} (stderr): {Error}", projectMetadata.ProjectPath, error);
- }
- };
+ logger.LogInformation(
+ "Starting .NET CLI with arguments: {Arguments}",
+ string.Join(" ", spec.Arguments)
+ );
- logger.LogInformation(
- "Starting .NET CLI with arguments: {Arguments}",
- string.Join(" ", spec.Arguments)
- );
+ var (pendingProcessResult, processDisposable) = ProcessUtil.Run(spec);
- var (pendingProcessResult, processDisposable) = ProcessUtil.Run(spec);
+ await using (processDisposable)
+ {
+ var processResult = await pendingProcessResult
+ .WaitAsync(cancellationToken)
+ .ConfigureAwait(false);
- await using (processDisposable)
+ if (processResult.ExitCode != 0)
{
- var processResult = await pendingProcessResult
- .WaitAsync(cancellationToken)
- .ConfigureAwait(false);
-
- if (processResult.ExitCode != 0)
- {
- logger.LogError("dotnet publish for project {ProjectPath} failed with exit code {ExitCode}.", projectMetadata.ProjectPath, processResult.ExitCode);
- throw new DistributedApplicationException($"Failed to build container image.");
- }
- else
- {
- logger.LogDebug(
- ".NET CLI completed with exit code: {ExitCode}",
- processResult.ExitCode);
- }
+ logger.LogError("dotnet publish for project {ProjectPath} failed with exit code {ExitCode}.", projectMetadata.ProjectPath, processResult.ExitCode);
+ return false;
+ }
+ else
+ {
+ logger.LogDebug(
+ ".NET CLI completed with exit code: {ExitCode}",
+ processResult.ExitCode);
+ return true;
}
}
}
- private async Task BuildContainerImageFromDockerfileAsync(string resourceName, string contextPath, string dockerfilePath, string imageName, IPublishingStep? step, CancellationToken cancellationToken)
+ private async Task BuildContainerImageFromDockerfileAsync(string resourceName, string contextPath, string dockerfilePath, string imageName, IPublishingStep? step, ContainerBuildOptions? options, CancellationToken cancellationToken)
{
var publishingTask = await CreateTaskAsync(
step,
@@ -264,6 +339,7 @@ await ContainerRuntime.BuildImageAsync(
contextPath,
dockerfilePath,
imageName,
+ options,
cancellationToken).ConfigureAwait(false);
await publishingTask.SucceedAsync($"Building image for {resourceName} completed", cancellationToken).ConfigureAwait(false);
@@ -285,6 +361,7 @@ await ContainerRuntime.BuildImageAsync(
contextPath,
dockerfilePath,
imageName,
+ options,
cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
@@ -310,3 +387,42 @@ await ContainerRuntime.BuildImageAsync(
}
}
+
+///
+/// Extension methods for .
+///
+[Experimental("ASPIREPUBLISHERS001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
+internal static class ContainerTargetPlatformExtensions
+{
+ ///
+ /// Converts the target platform to the format used by container runtimes (Docker/Podman).
+ ///
+ /// The target platform.
+ /// The platform string in the format used by container runtimes.
+ public static string ToRuntimePlatformString(this ContainerTargetPlatform platform) => platform switch
+ {
+ ContainerTargetPlatform.LinuxAmd64 => "linux/amd64",
+ ContainerTargetPlatform.LinuxArm64 => "linux/arm64",
+ ContainerTargetPlatform.LinuxArm => "linux/arm",
+ ContainerTargetPlatform.Linux386 => "linux/386",
+ ContainerTargetPlatform.WindowsAmd64 => "windows/amd64",
+ ContainerTargetPlatform.WindowsArm64 => "windows/arm64",
+ _ => throw new ArgumentOutOfRangeException(nameof(platform), platform, "Unknown container target platform")
+ };
+
+ ///
+ /// Converts the target platform to the format used by MSBuild ContainerRuntimeIdentifier.
+ ///
+ /// The target platform.
+ /// The platform string in the format used by MSBuild.
+ public static string ToMSBuildRuntimeIdentifierString(this ContainerTargetPlatform platform) => platform switch
+ {
+ ContainerTargetPlatform.LinuxAmd64 => "linux-x64",
+ ContainerTargetPlatform.LinuxArm64 => "linux-arm64",
+ ContainerTargetPlatform.LinuxArm => "linux-arm",
+ ContainerTargetPlatform.Linux386 => "linux-x86",
+ ContainerTargetPlatform.WindowsAmd64 => "win-x64",
+ ContainerTargetPlatform.WindowsArm64 => "win-arm64",
+ _ => throw new ArgumentOutOfRangeException(nameof(platform), platform, "Unknown container target platform")
+ };
+}
diff --git a/tests/Aspire.Hosting.Docker.Tests/DockerComposePublisherTests.cs b/tests/Aspire.Hosting.Docker.Tests/DockerComposePublisherTests.cs
index 45c5f9bd581..a4750f92e30 100644
--- a/tests/Aspire.Hosting.Docker.Tests/DockerComposePublisherTests.cs
+++ b/tests/Aspire.Hosting.Docker.Tests/DockerComposePublisherTests.cs
@@ -483,13 +483,13 @@ private sealed class MockImageBuilder : IResourceContainerImageBuilder
{
public bool BuildImageCalled { get; private set; }
- public Task BuildImageAsync(IResource resource, CancellationToken cancellationToken)
+ public Task BuildImageAsync(IResource resource, ContainerBuildOptions? options = null, CancellationToken cancellationToken = default)
{
BuildImageCalled = true;
return Task.CompletedTask;
}
- public Task BuildImagesAsync(IEnumerable resources, CancellationToken cancellationToken)
+ public Task BuildImagesAsync(IEnumerable resources, ContainerBuildOptions? options = null, CancellationToken cancellationToken = default)
{
BuildImageCalled = true;
return Task.CompletedTask;
diff --git a/tests/Aspire.Hosting.Docker.Tests/DockerComposeTests.cs b/tests/Aspire.Hosting.Docker.Tests/DockerComposeTests.cs
index db73c1fb67a..0e4324a7ca7 100644
--- a/tests/Aspire.Hosting.Docker.Tests/DockerComposeTests.cs
+++ b/tests/Aspire.Hosting.Docker.Tests/DockerComposeTests.cs
@@ -146,13 +146,13 @@ private sealed class MockImageBuilder : IResourceContainerImageBuilder
{
public bool BuildImageCalled { get; private set; }
- public Task BuildImageAsync(IResource resource, CancellationToken cancellationToken)
+ public Task BuildImageAsync(IResource resource, ContainerBuildOptions? options = null, CancellationToken cancellationToken = default)
{
BuildImageCalled = true;
return Task.CompletedTask;
}
- public Task BuildImagesAsync(IEnumerable resources, CancellationToken cancellationToken)
+ public Task BuildImagesAsync(IEnumerable resources, ContainerBuildOptions? options = null, CancellationToken cancellationToken = default)
{
BuildImageCalled = true;
return Task.CompletedTask;
diff --git a/tests/Aspire.Hosting.Tests/Publishing/ResourceContainerImageBuilderTests.cs b/tests/Aspire.Hosting.Tests/Publishing/ResourceContainerImageBuilderTests.cs
index 61ea59d5138..9d0729f8d68 100644
--- a/tests/Aspire.Hosting.Tests/Publishing/ResourceContainerImageBuilderTests.cs
+++ b/tests/Aspire.Hosting.Tests/Publishing/ResourceContainerImageBuilderTests.cs
@@ -9,6 +9,7 @@
using Aspire.TestUtilities;
using Microsoft.AspNetCore.InternalTesting;
using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
using Xunit;
namespace Aspire.Hosting.Tests.Publishing;
@@ -20,13 +21,28 @@ public class ResourceContainerImageBuilderTests(ITestOutputHelper output)
public async Task CanBuildImageFromProjectResource()
{
using var builder = TestDistributedApplicationBuilder.CreateWithTestContainerRegistry(output);
+
+ builder.Services.AddLogging(logging =>
+ {
+ logging.AddFakeLogging();
+ logging.AddXunit(output);
+ });
+
var servicea = builder.AddProject("servicea");
using var app = builder.Build();
using var cts = new CancellationTokenSource(TestConstants.LongTimeoutTimeSpan);
var imageBuilder = app.Services.GetRequiredService();
- await imageBuilder.BuildImageAsync(servicea.Resource, cts.Token);
+ await imageBuilder.BuildImageAsync(servicea.Resource, options: null, cts.Token);
+
+ // Validate that BuildImageAsync succeeded by checking the log output
+ var collector = app.Services.GetFakeLogCollector();
+ var logs = collector.GetSnapshot();
+
+ // Check for success logs
+ Assert.Contains(logs, log => log.Message.Contains("Building container image for resource servicea"));
+ Assert.Contains(logs, log => log.Message.Contains(".NET CLI completed with exit code: 0"));
}
[Fact]
@@ -35,6 +51,12 @@ public async Task CanBuildImageFromDockerfileResource()
{
using var builder = TestDistributedApplicationBuilder.CreateWithTestContainerRegistry(output);
+ builder.Services.AddLogging(logging =>
+ {
+ logging.AddFakeLogging();
+ logging.AddXunit(output);
+ });
+
var (tempContextPath, tempDockerfilePath) = await DockerfileUtils.CreateTemporaryDockerfileAsync();
var servicea = builder.AddDockerfile("container", tempContextPath, tempDockerfilePath);
@@ -42,6 +64,322 @@ public async Task CanBuildImageFromDockerfileResource()
using var cts = new CancellationTokenSource(TestConstants.LongTimeoutTimeSpan);
var imageBuilder = app.Services.GetRequiredService();
- await imageBuilder.BuildImageAsync(servicea.Resource, cts.Token);
+ await imageBuilder.BuildImageAsync(servicea.Resource, options: null, cts.Token);
+
+ // Validate that BuildImageAsync succeeded by checking the log output
+ var collector = app.Services.GetFakeLogCollector();
+ var logs = collector.GetSnapshot();
+
+ // Check for success logs
+ Assert.Contains(logs, log => log.Message.Contains("Building container image for resource container"));
+ // Ensure no error logs were produced during the build process
+ Assert.DoesNotContain(logs, log => log.Level >= LogLevel.Error &&
+ log.Message.Contains("Failed to build container image"));
+ }
+
+ [Fact]
+ [RequiresDocker]
+ public async Task CanBuildImageFromProjectResourceWithOptions()
+ {
+ using var builder = TestDistributedApplicationBuilder.CreateWithTestContainerRegistry(output);
+
+ builder.Services.AddLogging(logging =>
+ {
+ logging.AddFakeLogging();
+ logging.AddXunit(output);
+ });
+
+ var servicea = builder.AddProject("servicea");
+
+ using var app = builder.Build();
+
+ var options = new ContainerBuildOptions
+ {
+ ImageFormat = ContainerImageFormat.Oci,
+ OutputPath = "/tmp/test-output",
+ TargetPlatform = ContainerTargetPlatform.LinuxAmd64
+ };
+
+ using var cts = new CancellationTokenSource(TestConstants.LongTimeoutTimeSpan);
+ var imageBuilder = app.Services.GetRequiredService();
+ await imageBuilder.BuildImageAsync(servicea.Resource, options, cts.Token);
+
+ // Validate that BuildImageAsync succeeded by checking the log output
+ var collector = app.Services.GetFakeLogCollector();
+ var logs = collector.GetSnapshot();
+
+ // Check for success logs
+ Assert.Contains(logs, log => log.Message.Contains("Building container image for resource servicea"));
+ Assert.Contains(logs, log => log.Message.Contains(".NET CLI completed with exit code: 0"));
+
+ // Ensure no error logs were produced during the build process
+ Assert.DoesNotContain(logs, log => log.Level >= LogLevel.Error &&
+ log.Message.Contains("Failed to build container image"));
+ }
+
+ [Fact]
+ [RequiresDocker]
+ public async Task CanBuildImageFromProjectResource_WithDockerImageFormat()
+ {
+ using var builder = TestDistributedApplicationBuilder.CreateWithTestContainerRegistry(output);
+
+ builder.Services.AddLogging(logging =>
+ {
+ logging.AddFakeLogging();
+ logging.AddXunit(output);
+ });
+
+ var servicea = builder.AddProject("servicea");
+
+ using var app = builder.Build();
+
+ var options = new ContainerBuildOptions
+ {
+ ImageFormat = ContainerImageFormat.Docker
+ };
+
+ using var cts = new CancellationTokenSource(TestConstants.LongTimeoutTimeSpan);
+ var imageBuilder = app.Services.GetRequiredService();
+ await imageBuilder.BuildImageAsync(servicea.Resource, options, cts.Token);
+
+ // Validate that BuildImageAsync succeeded by checking the log output
+ var collector = app.Services.GetFakeLogCollector();
+ var logs = collector.GetSnapshot();
+
+ // Check for success logs
+ Assert.Contains(logs, log => log.Message.Contains("Building container image for resource servicea"));
+ Assert.Contains(logs, log => log.Message.Contains(".NET CLI completed with exit code: 0"));
+ }
+
+ [Fact]
+ [RequiresDocker]
+ public async Task CanBuildImageFromProjectResource_WithLinuxArm64Platform()
+ {
+ using var builder = TestDistributedApplicationBuilder.CreateWithTestContainerRegistry(output);
+
+ builder.Services.AddLogging(logging =>
+ {
+ logging.AddFakeLogging();
+ logging.AddXunit(output);
+ });
+
+ var servicea = builder.AddProject("servicea");
+
+ using var app = builder.Build();
+
+ var options = new ContainerBuildOptions
+ {
+ TargetPlatform = ContainerTargetPlatform.LinuxArm64
+ };
+
+ using var cts = new CancellationTokenSource(TestConstants.LongTimeoutTimeSpan);
+ var imageBuilder = app.Services.GetRequiredService();
+ await imageBuilder.BuildImageAsync(servicea.Resource, options, cts.Token);
+
+ // Validate that BuildImageAsync succeeded by checking the log output
+ var collector = app.Services.GetFakeLogCollector();
+ var logs = collector.GetSnapshot();
+
+ // Check for success logs
+ Assert.Contains(logs, log => log.Message.Contains("Building container image for resource servicea"));
+ Assert.Contains(logs, log => log.Message.Contains(".NET CLI completed with exit code: 0"));
+ }
+
+ [Fact]
+ [RequiresDocker]
+ public async Task CanBuildImageFromDockerfileResource_WithCustomOutputPath()
+ {
+ using var builder = TestDistributedApplicationBuilder.CreateWithTestContainerRegistry(output);
+
+ builder.Services.AddLogging(logging =>
+ {
+ logging.AddFakeLogging();
+ logging.AddXunit(output);
+ });
+
+ var (tempContextPath, tempDockerfilePath) = await DockerfileUtils.CreateTemporaryDockerfileAsync();
+ var container = builder.AddDockerfile("container", tempContextPath, tempDockerfilePath);
+
+ using var app = builder.Build();
+
+ var tempOutputPath = Path.GetTempPath();
+ var options = new ContainerBuildOptions
+ {
+ OutputPath = tempOutputPath,
+ ImageFormat = ContainerImageFormat.Oci
+ };
+
+ using var cts = new CancellationTokenSource(TestConstants.LongTimeoutTimeSpan);
+ var imageBuilder = app.Services.GetRequiredService();
+ await imageBuilder.BuildImageAsync(container.Resource, options, cts.Token);
+
+ // Validate that BuildImageAsync succeeded by checking the log output
+ var collector = app.Services.GetFakeLogCollector();
+ var logs = collector.GetSnapshot();
+
+ // Check for success logs
+ Assert.Contains(logs, log => log.Message.Contains("Building container image for resource container"));
+ // Ensure no error logs were produced during the build process
+ Assert.DoesNotContain(logs, log => log.Level >= LogLevel.Error &&
+ log.Message.Contains("Failed to build container image"));
+ }
+
+ [Fact]
+ [RequiresDocker]
+ public async Task CanBuildImageFromDockerfileResource_WithAllOptionsSet()
+ {
+ using var builder = TestDistributedApplicationBuilder.CreateWithTestContainerRegistry(output);
+
+ builder.Services.AddLogging(logging =>
+ {
+ logging.AddFakeLogging();
+ logging.AddXunit(output);
+ });
+
+ var (tempContextPath, tempDockerfilePath) = await DockerfileUtils.CreateTemporaryDockerfileAsync();
+ var container = builder.AddDockerfile("container", tempContextPath, tempDockerfilePath);
+
+ using var app = builder.Build();
+
+ var tempOutputPath = Path.GetTempPath();
+ var options = new ContainerBuildOptions
+ {
+ ImageFormat = ContainerImageFormat.Oci,
+ OutputPath = tempOutputPath,
+ TargetPlatform = ContainerTargetPlatform.LinuxAmd64
+ };
+
+ using var cts = new CancellationTokenSource(TestConstants.LongTimeoutTimeSpan);
+ var imageBuilder = app.Services.GetRequiredService();
+ await imageBuilder.BuildImageAsync(container.Resource, options, cts.Token);
+
+ // Validate that BuildImageAsync succeeded by checking the log output
+ var collector = app.Services.GetFakeLogCollector();
+ var logs = collector.GetSnapshot();
+
+ // Check for success logs
+ Assert.Contains(logs, log => log.Message.Contains("Building container image for resource container"));
+
+ // Ensure no error logs were produced during the build process
+ Assert.DoesNotContain(logs, log => log.Level >= LogLevel.Error &&
+ log.Message.Contains("Failed to build container image"));
+ }
+
+ [Theory]
+ [InlineData(ContainerImageFormat.Docker)]
+ [InlineData(ContainerImageFormat.Oci)]
+ [RequiresDocker]
+ public async Task CanBuildImageFromProjectResource_WithDifferentImageFormats(ContainerImageFormat imageFormat)
+ {
+ using var builder = TestDistributedApplicationBuilder.CreateWithTestContainerRegistry(output);
+
+ builder.Services.AddLogging(logging =>
+ {
+ logging.AddFakeLogging();
+ logging.AddXunit(output);
+ });
+
+ var servicea = builder.AddProject("servicea");
+
+ using var app = builder.Build();
+
+ var options = new ContainerBuildOptions
+ {
+ ImageFormat = imageFormat
+ };
+
+ using var cts = new CancellationTokenSource(TestConstants.LongTimeoutTimeSpan);
+ var imageBuilder = app.Services.GetRequiredService();
+ await imageBuilder.BuildImageAsync(servicea.Resource, options, cts.Token);
+
+ // Validate that BuildImageAsync succeeded by checking the log output
+ var collector = app.Services.GetFakeLogCollector();
+ var logs = collector.GetSnapshot();
+
+ // Check for success logs
+ Assert.Contains(logs, log => log.Message.Contains("Building container image for resource servicea"));
+ Assert.Contains(logs, log => log.Message.Contains(".NET CLI completed with exit code: 0"));
+ }
+
+ [Theory]
+ [InlineData(ContainerTargetPlatform.LinuxAmd64)]
+ [InlineData(ContainerTargetPlatform.LinuxArm64)]
+ [RequiresDocker]
+ public async Task CanBuildImageFromProjectResource_WithDifferentTargetPlatforms(ContainerTargetPlatform targetPlatform)
+ {
+ using var builder = TestDistributedApplicationBuilder.CreateWithTestContainerRegistry(output);
+
+ builder.Services.AddLogging(logging =>
+ {
+ logging.AddFakeLogging();
+ logging.AddXunit(output);
+ });
+
+ var servicea = builder.AddProject("servicea");
+
+ using var app = builder.Build();
+
+ var options = new ContainerBuildOptions
+ {
+ TargetPlatform = targetPlatform
+ };
+
+ using var cts = new CancellationTokenSource(TestConstants.LongTimeoutTimeSpan);
+ var imageBuilder = app.Services.GetRequiredService();
+ await imageBuilder.BuildImageAsync(servicea.Resource, options, cts.Token);
+
+ // Validate that BuildImageAsync succeeded by checking the log output
+ var collector = app.Services.GetFakeLogCollector();
+ var logs = collector.GetSnapshot();
+
+ // Check for success logs
+ Assert.Contains(logs, log => log.Message.Contains("Building container image for resource servicea"));
+ Assert.Contains(logs, log => log.Message.Contains(".NET CLI completed with exit code: 0"));
+ }
+
+ [Fact]
+ [RequiresDocker]
+ public async Task BuildImageAsync_WithNullOptions_UsesDefaults()
+ {
+ using var builder = TestDistributedApplicationBuilder.CreateWithTestContainerRegistry(output);
+
+ builder.Services.AddLogging(logging =>
+ {
+ logging.AddFakeLogging();
+ logging.AddXunit(output);
+ });
+
+ var servicea = builder.AddProject("servicea");
+
+ using var app = builder.Build();
+
+ using var cts = new CancellationTokenSource(TestConstants.LongTimeoutTimeSpan);
+ var imageBuilder = app.Services.GetRequiredService();
+
+ // Test with null options - should use defaults
+ await imageBuilder.BuildImageAsync(servicea.Resource, options: null, cts.Token);
+
+ // Validate that BuildImageAsync succeeded by checking the log output
+ var collector = app.Services.GetFakeLogCollector();
+ var logs = collector.GetSnapshot();
+
+ // Check for success logs
+ Assert.Contains(logs, log => log.Message.Contains("Building container image for resource servicea"));
+ Assert.Contains(logs, log => log.Message.Contains(".NET CLI completed with exit code: 0"));
+ }
+
+ [Fact]
+ public void ContainerBuildOptions_CanSetAllProperties()
+ {
+ var options = new ContainerBuildOptions
+ {
+ ImageFormat = ContainerImageFormat.Oci,
+ OutputPath = "/custom/path",
+ TargetPlatform = ContainerTargetPlatform.LinuxArm64
+ };
+
+ Assert.Equal(ContainerImageFormat.Oci, options.ImageFormat);
+ Assert.Equal("/custom/path", options.OutputPath);
+ Assert.Equal(ContainerTargetPlatform.LinuxArm64, options.TargetPlatform);
}
}