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); } }