diff --git a/src/Aspire.Hosting/Publishing/ContainerRuntimeBase.cs b/src/Aspire.Hosting/Publishing/ContainerRuntimeBase.cs index 6802dcb2e35..5d6e4854ccc 100644 --- a/src/Aspire.Hosting/Publishing/ContainerRuntimeBase.cs +++ b/src/Aspire.Hosting/Publishing/ContainerRuntimeBase.cs @@ -35,6 +35,8 @@ protected ContainerRuntimeBase(ILogger logger) public abstract Task CheckIfRunningAsync(CancellationToken cancellationToken); + public abstract Task SupportsMultiArchAsync(CancellationToken cancellationToken); + public abstract Task BuildImageAsync(string contextPath, string dockerfilePath, string imageName, ContainerBuildOptions? options, Dictionary buildArguments, Dictionary buildSecrets, string? stage, CancellationToken cancellationToken); public virtual async Task TagImageAsync(string localImageName, string targetImageName, CancellationToken cancellationToken) diff --git a/src/Aspire.Hosting/Publishing/DockerContainerRuntime.cs b/src/Aspire.Hosting/Publishing/DockerContainerRuntime.cs index 574534c28af..497106f2b88 100644 --- a/src/Aspire.Hosting/Publishing/DockerContainerRuntime.cs +++ b/src/Aspire.Hosting/Publishing/DockerContainerRuntime.cs @@ -3,6 +3,7 @@ #pragma warning disable ASPIREPIPELINES003 +using System.Text.Json; using Aspire.Hosting.Dcp.Process; using Microsoft.Extensions.Logging; @@ -172,6 +173,106 @@ public override async Task CheckIfRunningAsync(CancellationToken cancellat return await CheckDockerBuildxAsync(cancellationToken).ConfigureAwait(false); } + public override async Task SupportsMultiArchAsync(CancellationToken cancellationToken) + { + try + { + var outputBuilder = new System.Text.StringBuilder(); + + // Run docker info --format json to get Docker daemon information + var spec = new ProcessSpec(RuntimeExecutable) + { + Arguments = "info --format json", + OnOutputData = output => outputBuilder.AppendLine(output), + OnErrorData = _ => { }, + ThrowOnNonZeroReturnCode = false, + InheritEnv = true, + }; + + Logger.LogDebug("Checking Docker multi-arch support using 'docker info --format json'"); + + 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 get Docker info. Exit code: {ExitCode}", processResult.ExitCode); + return false; + } + + var dockerInfo = outputBuilder.ToString(); + + // Parse JSON to check for containerd image store + try + { + using var jsonDoc = JsonDocument.Parse(dockerInfo); + var root = jsonDoc.RootElement; + + // Check if containerd is configured as the image store + // The containerd image store is indicated by the presence of Containerd configuration + if (root.TryGetProperty("DriverStatus", out var driverStatus) && driverStatus.ValueKind == JsonValueKind.Array) + { + foreach (var item in driverStatus.EnumerateArray()) + { + if (item.ValueKind == JsonValueKind.Array && item.GetArrayLength() >= 2) + { + var key = item[0].GetString(); + var value = item[1].GetString(); + + // Check for "Image store" entry which indicates containerd image store + if (key == "Image store" && value == "containerd") + { + Logger.LogDebug("Docker is configured with containerd image store. Multi-arch builds are supported."); + return true; + } + } + } + } + + // If we have Containerd configuration with image store support, multi-arch is supported + if (root.TryGetProperty("Containerd", out _)) + { + // Check if we're using the containerd image store through the Driver field + if (root.TryGetProperty("Driver", out var driver)) + { + var driverValue = driver.GetString(); + // When containerd image store is enabled, the driver might be "containerd" or we need additional checks + if (driverValue == "containerd") + { + Logger.LogDebug("Docker is using containerd driver. Multi-arch builds are supported."); + return true; + } + } + } + + // If containerd image store is not configured, log a warning + Logger.LogWarning( + "Docker does not appear to be configured with containerd image store. " + + "Multi-arch builds require containerd image store to be enabled. " + + "You can enable it in Docker Desktop settings under 'Features in development' > 'Use containerd for pulling and storing images'. " + + "See https://docs.docker.com/engine/storage/containerd/ for more information."); + + return false; + } + catch (JsonException ex) + { + Logger.LogWarning(ex, "Failed to parse Docker info JSON output"); + return false; + } + } + } + catch (Exception ex) + { + Logger.LogWarning(ex, "Error checking Docker multi-arch support"); + return false; + } + } + private async Task CheckDockerDaemonAsync(CancellationToken cancellationToken) { try diff --git a/src/Aspire.Hosting/Publishing/IContainerRuntime.cs b/src/Aspire.Hosting/Publishing/IContainerRuntime.cs index 4eced69788a..54d63bbe9d8 100644 --- a/src/Aspire.Hosting/Publishing/IContainerRuntime.cs +++ b/src/Aspire.Hosting/Publishing/IContainerRuntime.cs @@ -9,6 +9,7 @@ internal interface IContainerRuntime { string Name { get; } Task CheckIfRunningAsync(CancellationToken cancellationToken); + Task SupportsMultiArchAsync(CancellationToken cancellationToken); Task BuildImageAsync(string contextPath, string dockerfilePath, string imageName, ContainerBuildOptions? options, Dictionary buildArguments, Dictionary buildSecrets, string? stage, CancellationToken cancellationToken); Task TagImageAsync(string localImageName, string targetImageName, CancellationToken cancellationToken); Task RemoveImageAsync(string imageName, CancellationToken cancellationToken); diff --git a/src/Aspire.Hosting/Publishing/PodmanContainerRuntime.cs b/src/Aspire.Hosting/Publishing/PodmanContainerRuntime.cs index ab88ed579bb..6aa031fa69c 100644 --- a/src/Aspire.Hosting/Publishing/PodmanContainerRuntime.cs +++ b/src/Aspire.Hosting/Publishing/PodmanContainerRuntime.cs @@ -17,62 +17,154 @@ public PodmanContainerRuntime(ILogger logger) : base(log public override string Name => "Podman"; private async Task RunPodmanBuildAsync(string contextPath, string dockerfilePath, string imageName, ContainerBuildOptions? options, Dictionary buildArguments, Dictionary buildSecrets, string? stage, CancellationToken cancellationToken) { - var arguments = $"build --file \"{dockerfilePath}\" --tag \"{imageName}\""; + // Check if this is a multi-platform build + var isMultiPlatform = options?.TargetPlatform is not null && + HasMultiplePlatforms(options.TargetPlatform.Value); - // Add platform support if specified - if (options?.TargetPlatform is not null) + string? manifestName = null; + + if (isMultiPlatform) { - arguments += $" --platform \"{options.TargetPlatform.Value.ToRuntimePlatformString()}\""; + // For multi-platform builds, create a manifest + manifestName = imageName; + var createManifestResult = await CreateManifestAsync(manifestName, cancellationToken).ConfigureAwait(false); + + if (createManifestResult != 0) + { + Logger.LogError("Failed to create manifest {ManifestName} with exit code {ExitCode}.", manifestName, createManifestResult); + return createManifestResult; + } } - // Add format support if specified - if (options?.ImageFormat is not null) + try { - var format = options.ImageFormat.Value switch + var arguments = $"build --file \"{dockerfilePath}\" --tag \"{imageName}\""; + + // If we have a manifest, use it + if (manifestName is not null) { - ContainerImageFormat.Oci => "oci", - ContainerImageFormat.Docker => "docker", - _ => throw new ArgumentOutOfRangeException(nameof(options), options.ImageFormat, "Invalid container image format") - }; - arguments += $" --format \"{format}\""; - } + arguments += $" --manifest \"{manifestName}\""; + } - // 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\""; - } + // 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\""; + } + + // Add build arguments if specified + arguments += BuildArgumentsString(buildArguments); - // Add build arguments if specified - arguments += BuildArgumentsString(buildArguments); + // Add build secrets if specified + arguments += BuildSecretsString(buildSecrets, requireValue: true); - // Add build secrets if specified - arguments += BuildSecretsString(buildSecrets, requireValue: true); + // Add stage if specified + arguments += BuildStageString(stage); - // Add stage if specified - arguments += BuildStageString(stage); + arguments += $" \"{contextPath}\""; - arguments += $" \"{contextPath}\""; + // Prepare environment variables for build secrets + var environmentVariables = new Dictionary(); + foreach (var buildSecret in buildSecrets) + { + if (buildSecret.Value is not null) + { + environmentVariables[buildSecret.Key.ToUpperInvariant()] = buildSecret.Value; + } + } - // Prepare environment variables for build secrets - var environmentVariables = new Dictionary(); - foreach (var buildSecret in buildSecrets) + return await ExecuteContainerCommandWithExitCodeAsync( + arguments, + "Podman build for {ImageName} failed with exit code {ExitCode}.", + "Podman build for {ImageName} succeeded.", + cancellationToken, + new object[] { imageName }, + environmentVariables).ConfigureAwait(false); + } + finally { - if (buildSecret.Value is not null) + // Clean up the manifest if we created one + if (manifestName is not null && isMultiPlatform) { - environmentVariables[buildSecret.Key.ToUpperInvariant()] = buildSecret.Value; + await RemoveManifestAsync(manifestName, cancellationToken).ConfigureAwait(false); } } + } + + private static bool HasMultiplePlatforms(ContainerTargetPlatform platform) + { + // Count how many flags are set + var count = 0; + if (platform.HasFlag(ContainerTargetPlatform.LinuxAmd64)) + { + count++; + } + if (platform.HasFlag(ContainerTargetPlatform.LinuxArm64)) + { + count++; + } + if (platform.HasFlag(ContainerTargetPlatform.LinuxArm)) + { + count++; + } + if (platform.HasFlag(ContainerTargetPlatform.Linux386)) + { + count++; + } + if (platform.HasFlag(ContainerTargetPlatform.WindowsAmd64)) + { + count++; + } + if (platform.HasFlag(ContainerTargetPlatform.WindowsArm64)) + { + count++; + } + + return count > 1; + } + + private async Task CreateManifestAsync(string manifestName, CancellationToken cancellationToken) + { + var arguments = $"manifest create \"{manifestName}\""; return await ExecuteContainerCommandWithExitCodeAsync( arguments, - "Podman build for {ImageName} failed with exit code {ExitCode}.", - "Podman build for {ImageName} succeeded.", + "Failed to create manifest {ManifestName} with exit code {ExitCode}.", + "Successfully created manifest {ManifestName}.", cancellationToken, - new object[] { imageName }, - environmentVariables).ConfigureAwait(false); + new object[] { manifestName }).ConfigureAwait(false); + } + + private async Task RemoveManifestAsync(string manifestName, CancellationToken cancellationToken) + { + var arguments = $"manifest rm \"{manifestName}\""; + + return await ExecuteContainerCommandWithExitCodeAsync( + arguments, + "Failed to remove manifest {ManifestName} with exit code {ExitCode}.", + "Successfully removed manifest {ManifestName}.", + cancellationToken, + new object[] { manifestName }).ConfigureAwait(false); } public override async Task BuildImageAsync(string contextPath, string dockerfilePath, string imageName, ContainerBuildOptions? options, Dictionary buildArguments, Dictionary buildSecrets, string? stage, CancellationToken cancellationToken) @@ -111,4 +203,37 @@ public override async Task CheckIfRunningAsync(CancellationToken cancellat return false; } } + + public override async Task SupportsMultiArchAsync(CancellationToken cancellationToken) + { + try + { + // Check if podman manifest command is available + var exitCode = await ExecuteContainerCommandWithExitCodeAsync( + "manifest --help", + "Podman manifest command failed with exit code {ExitCode}.", + "Podman manifest command is available.", + cancellationToken, + Array.Empty()).ConfigureAwait(false); + + if (exitCode == 0) + { + Logger.LogDebug("Podman supports manifest commands. Multi-arch builds are supported."); + return true; + } + else + { + Logger.LogWarning( + "Podman does not support manifest commands. " + + "Multi-arch builds require manifest support. " + + "Please ensure you are using a recent version of Podman."); + return false; + } + } + catch (Exception ex) + { + Logger.LogWarning(ex, "Error checking Podman multi-arch support"); + return false; + } + } } diff --git a/src/Aspire.Hosting/Publishing/ResourceContainerImageBuilder.cs b/src/Aspire.Hosting/Publishing/ResourceContainerImageBuilder.cs index 1d07e496b7a..8f09638e032 100644 --- a/src/Aspire.Hosting/Publishing/ResourceContainerImageBuilder.cs +++ b/src/Aspire.Hosting/Publishing/ResourceContainerImageBuilder.cs @@ -161,6 +161,30 @@ public async Task BuildImagesAsync(IEnumerable resources, ContainerBu } logger.LogDebug("{ContainerRuntimeName} is healthy", ContainerRuntime.Name); + + // Check if multi-arch builds are supported when multi-platform builds are requested + if (options?.TargetPlatform is not null && HasMultiplePlatforms(options.TargetPlatform.Value)) + { + logger.LogDebug("Checking {ContainerRuntimeName} multi-arch support", ContainerRuntime.Name); + + var supportsMultiArch = await ContainerRuntime.SupportsMultiArchAsync(cancellationToken).ConfigureAwait(false); + + if (!supportsMultiArch) + { + logger.LogWarning( + "Multi-platform build requested but {ContainerRuntimeName} does not support multi-arch builds. " + + "Defaulting to linux/amd64 platform. To enable multi-arch builds, configure your container runtime appropriately.", + ContainerRuntime.Name); + + // Modify options to default to LinuxAmd64 + options = new ContainerBuildOptions + { + OutputPath = options.OutputPath, + ImageFormat = options.ImageFormat, + TargetPlatform = ContainerTargetPlatform.LinuxAmd64 + }; + } + } } foreach (var resource in resources) @@ -172,6 +196,38 @@ public async Task BuildImagesAsync(IEnumerable resources, ContainerBu logger.LogDebug("Building container images completed"); } + private static bool HasMultiplePlatforms(ContainerTargetPlatform platform) + { + // Count how many flags are set + var count = 0; + if (platform.HasFlag(ContainerTargetPlatform.LinuxAmd64)) + { + count++; + } + if (platform.HasFlag(ContainerTargetPlatform.LinuxArm64)) + { + count++; + } + if (platform.HasFlag(ContainerTargetPlatform.LinuxArm)) + { + count++; + } + if (platform.HasFlag(ContainerTargetPlatform.Linux386)) + { + count++; + } + if (platform.HasFlag(ContainerTargetPlatform.WindowsAmd64)) + { + count++; + } + if (platform.HasFlag(ContainerTargetPlatform.WindowsArm64)) + { + count++; + } + + return count > 1; + } + public async Task BuildImageAsync(IResource resource, ContainerBuildOptions? options = null, CancellationToken cancellationToken = default) { logger.LogInformation("Building container image for resource {ResourceName}", resource.Name); diff --git a/tests/Aspire.Hosting.Tests/Publishing/FakeContainerRuntime.cs b/tests/Aspire.Hosting.Tests/Publishing/FakeContainerRuntime.cs index 071fb3233f7..520b67c4e7c 100644 --- a/tests/Aspire.Hosting.Tests/Publishing/FakeContainerRuntime.cs +++ b/tests/Aspire.Hosting.Tests/Publishing/FakeContainerRuntime.cs @@ -7,10 +7,11 @@ namespace Aspire.Hosting.Tests.Publishing; -internal sealed class FakeContainerRuntime(bool shouldFail = false) : IContainerRuntime +internal sealed class FakeContainerRuntime(bool shouldFail = false, bool supportsMultiArch = true) : IContainerRuntime { public string Name => "fake-runtime"; public bool WasHealthCheckCalled { get; private set; } + public bool WasMultiArchCheckCalled { get; private set; } public bool WasTagImageCalled { get; private set; } public bool WasRemoveImageCalled { get; private set; } public bool WasPushImageCalled { get; private set; } @@ -29,6 +30,12 @@ public Task CheckIfRunningAsync(CancellationToken cancellationToken) return Task.FromResult(!shouldFail); } + public Task SupportsMultiArchAsync(CancellationToken cancellationToken) + { + WasMultiArchCheckCalled = true; + return Task.FromResult(supportsMultiArch && !shouldFail); + } + public Task TagImageAsync(string localImageName, string targetImageName, CancellationToken cancellationToken) { WasTagImageCalled = true; diff --git a/tests/Aspire.Hosting.Tests/Publishing/ResourceContainerImageBuilderTests.cs b/tests/Aspire.Hosting.Tests/Publishing/ResourceContainerImageBuilderTests.cs index c91a96ad8ff..ea790c6ca34 100644 --- a/tests/Aspire.Hosting.Tests/Publishing/ResourceContainerImageBuilderTests.cs +++ b/tests/Aspire.Hosting.Tests/Publishing/ResourceContainerImageBuilderTests.cs @@ -893,4 +893,99 @@ public async Task CanResolveBuildSecretsWithDifferentValueTypes() // Null parameter should resolve to null Assert.Null(fakeContainerRuntime.CapturedBuildSecrets["NULL_SECRET"]); } + + [Fact] + public async Task DockerRuntimeSupportsMultiArchAsync() + { + // This test verifies that the SupportsMultiArchAsync method can be called + // We can't test the actual detection without a real Docker daemon + using var builder = TestDistributedApplicationBuilder.CreateWithTestContainerRegistry(output); + + builder.Services.AddLogging(logging => + { + logging.AddFakeLogging(); + logging.AddXunit(output); + }); + + using var app = builder.Build(); + + var runtime = app.Services.GetRequiredService(); + + using var cts = new CancellationTokenSource(TestConstants.LongTimeoutTimeSpan); + + // Just verify the method can be called without throwing + // The actual result depends on the Docker configuration + var supportsMultiArch = await runtime.SupportsMultiArchAsync(cts.Token); + + // We can't assert the result since it depends on the environment + // but we can log it for diagnostic purposes + output.WriteLine($"Runtime {runtime.Name} supports multi-arch: {supportsMultiArch}"); + } + + [Fact] + public async Task FakeRuntimeSupportsMultiArch() + { + var fakeRuntime = new FakeContainerRuntime(shouldFail: false); + + using var cts = new CancellationTokenSource(TestConstants.DefaultTimeoutTimeSpan); + var supportsMultiArch = await fakeRuntime.SupportsMultiArchAsync(cts.Token); + + Assert.True(supportsMultiArch); + Assert.True(fakeRuntime.WasMultiArchCheckCalled); + } + + [Fact] + public async Task BuildImagesAsync_WhenMultiArchNotSupported_LogsWarningAndDefaultsToLinuxAmd64() + { + // Create a fake runtime that doesn't support multi-arch + var fakeRuntime = new FakeContainerRuntime(shouldFail: false, supportsMultiArch: false); + + using var builder = TestDistributedApplicationBuilder.CreateWithTestContainerRegistry(output); + + // Replace the default container runtime with our fake one + builder.Services.AddSingleton(fakeRuntime); + + builder.Services.AddLogging(logging => + { + logging.AddFakeLogging(); + logging.AddXunit(output); + }); + + // Create a dockerfile resource to build + var (tempContextPath, tempDockerfilePath) = await DockerfileUtils.CreateTemporaryDockerfileAsync(); + var container = builder.AddDockerfile("container", tempContextPath, tempDockerfilePath); + + using var app = builder.Build(); + + // Request a multi-platform build (which should trigger the warning) + var options = new ContainerBuildOptions + { + TargetPlatform = ContainerTargetPlatform.AllLinux // This is multi-platform + }; + + using var cts = new CancellationTokenSource(TestConstants.LongTimeoutTimeSpan); + var imageBuilder = app.Services.GetRequiredService(); + + // Use BuildImagesAsync instead of BuildImageAsync to trigger the multi-arch check + await imageBuilder.BuildImagesAsync([container.Resource], options, cts.Token); + + // Verify multi-arch check was called + Assert.True(fakeRuntime.WasMultiArchCheckCalled); + + // Verify the build was still executed + Assert.True(fakeRuntime.WasBuildImageCalled); + + // Verify that the options were modified to LinuxAmd64 + var buildCall = fakeRuntime.BuildImageCalls.Single(); + Assert.NotNull(buildCall.options); + Assert.Equal(ContainerTargetPlatform.LinuxAmd64, buildCall.options.TargetPlatform); + + // Verify warning was logged + var collector = app.Services.GetFakeLogCollector(); + var logs = collector.GetSnapshot(); + Assert.Contains(logs, log => + log.Level == LogLevel.Warning && + log.Message.Contains("Multi-platform build requested") && + log.Message.Contains("does not support multi-arch builds")); + } }