From 8fa346d0bd4de5f217102111f157f169221f4206 Mon Sep 17 00:00:00 2001 From: Andre Hofmeister <9199345+HofmeisterAn@users.noreply.github.com> Date: Mon, 5 Oct 2026 12:45:35 +0200 Subject: [PATCH 1/2] fix: Do not pull base image with unresolved platform variable --- .../Clients/TestcontainersClient.cs | 16 ++++--- .../Images/DockerfileArchive.cs | 43 +++++++++++++++++-- src/Testcontainers/Images/Platform.cs | 2 +- .../Assets/pullBaseImages/Dockerfile | 4 ++ .../Unit/Images/ImageFromDockerfileTest.cs | 39 +++++++++++++++++ 5 files changed, 93 insertions(+), 11 deletions(-) diff --git a/src/Testcontainers/Clients/TestcontainersClient.cs b/src/Testcontainers/Clients/TestcontainersClient.cs index 892e6a88e..75b2ffaf3 100644 --- a/src/Testcontainers/Clients/TestcontainersClient.cs +++ b/src/Testcontainers/Clients/TestcontainersClient.cs @@ -436,6 +436,9 @@ private async Task BuildAsync(IImageBuildOperations for a base image that + /// declares a platform with a variable. /// /// The Dockerfile configuration. /// The image build parameters. @@ -468,14 +471,15 @@ private async Task PrepareBuildAsync(IImageFromDockerfileConf configuration.BuildArguments, _logger); - var platform = buildParameters.Platform; + // The image build targets a single platform or a comma-separated + // list of platforms. + var platforms = buildParameters.Platform; - var baseImages = dockerfileArchive.GetBaseImages(); + // The base images pull for the single platform the image build targets. + // A multi-platform build pulls them for the platform of the Docker host. + var targetPlatform = string.IsNullOrEmpty(platforms) || platforms.Contains(",") ? null : platforms; - if (!string.IsNullOrEmpty(platform) && !platform.Contains(",")) - { - baseImages = baseImages.Select(image => string.IsNullOrEmpty(image.Platform) ? new DockerImage(image.Repository, image.Registry, image.Tag, image.Digest, platform) : image); - } + var baseImages = dockerfileArchive.GetBaseImages(targetPlatform); await PullImagesAsync(baseImages, ct) .ConfigureAwait(false); diff --git a/src/Testcontainers/Images/DockerfileArchive.cs b/src/Testcontainers/Images/DockerfileArchive.cs index d02c3ffa1..5671bfb63 100644 --- a/src/Testcontainers/Images/DockerfileArchive.cs +++ b/src/Testcontainers/Images/DockerfileArchive.cs @@ -115,14 +115,24 @@ private DockerfileArchive( /// This method reads the Dockerfile and collects a list of base images. It /// excludes stages that do not correspond to base images. For example, it will not include /// the second line from the following Dockerfile configuration: + /// /// /// FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build /// FROM build /// + /// + /// A base image that does not declare a platform (FROM --platform) + /// resolves to the . A base image that + /// declares a platform with a variable that does not resolve, such as the + /// built-in build argument $BUILDPLATFORM, resolves to no platform, + /// which is the platform of the Docker host that runs the image build. /// + /// The platform the image build targets, or null if it does not target a single platform. /// An of . - public IEnumerable GetBaseImages() + public IEnumerable GetBaseImages([CanBeNull] string targetPlatform = null) { + const string targetPlatformArg = "TARGETPLATFORM"; + const string nameGroup = "name"; const string valueGroup = "value"; @@ -147,8 +157,17 @@ public IEnumerable GetBaseImages() .Where(match => match.Success) .ToArray(); - var args = argMatches - .Select(match => new KeyValuePair(match.Groups[nameGroup].Value, match.Groups[valueGroup].Value)) + // The image builder sets the built-in build argument. + // The Dockerfile and the build arguments override it. + var builtInArgs = string.IsNullOrEmpty(targetPlatform) + ? Array.Empty>() + : new[] { new KeyValuePair(targetPlatformArg, targetPlatform) }; + + var parsedArguments = argMatches + .Select(match => new KeyValuePair(match.Groups[nameGroup].Value, match.Groups[valueGroup].Value)); + + var args = builtInArgs + .Concat(parsedArguments) .Concat(_buildArguments) .GroupBy(kvp => kvp.Key) .ToDictionary(group => group.Key, group => group.Last().Value); @@ -168,8 +187,24 @@ public IEnumerable GetBaseImages() .Where(item => !stages.Contains(item.Image)) .Select(item => { + string? platform; + var fromArgs = ParseFromArgs(item.FromArgs).ToDictionary(arg => arg.Name, arg => arg.Value); - _ = fromArgs.TryGetValue("platform", out var platform); + + if (!fromArgs.TryGetValue("platform", out var fromPlatform)) + { + platform = targetPlatform; + } + else if (fromPlatform != null && fromPlatform.Contains("$")) + { + // The Docker daemon rejects unresolved variables such as $BUILDPLATFORM. + platform = null; + } + else + { + platform = fromPlatform; + } + return new DockerImage(item.Image, new Platform(platform)); }) .ToArray(); diff --git a/src/Testcontainers/Images/Platform.cs b/src/Testcontainers/Images/Platform.cs index ace0ea4ef..33a3f0f2a 100644 --- a/src/Testcontainers/Images/Platform.cs +++ b/src/Testcontainers/Images/Platform.cs @@ -20,7 +20,7 @@ public readonly struct Platform /// /// The platform identifier. [PublicAPI] - public Platform(string value) + public Platform([CanBeNull] string value) { Value = value; } diff --git a/tests/Testcontainers.Tests/Assets/pullBaseImages/Dockerfile b/tests/Testcontainers.Tests/Assets/pullBaseImages/Dockerfile index 6d04bc65e..de73002c9 100644 --- a/tests/Testcontainers.Tests/Assets/pullBaseImages/Dockerfile +++ b/tests/Testcontainers.Tests/Assets/pullBaseImages/Dockerfile @@ -15,6 +15,10 @@ FROM --platform=$PLATFORM mcr.microsoft.com/dotnet/aspnet:8.0-azurelinux3.0 FROM --platform="linux/arm/v6" mcr.microsoft.com/dotnet/aspnet:8.0-azurelinux3.0 FROM --platform='linux/arm/v7' mcr.microsoft.com/dotnet/aspnet:8.0-azurelinux3.0 +# Built-in build arguments are not declared with ARG. +FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0-alpine AS build_platform +FROM --platform=${TARGETPLATFORM} mcr.microsoft.com/dotnet/runtime:8.0-alpine AS target_platform + # https://github.com/testcontainers/testcontainers-dotnet/issues/1030. FROM mcr.microsoft.com/dotnet/sdk:$SDK_VERSION_8_0 AS build_sdk_8_0 FROM build_sdk_8_0 AS publish_sdk_8_0 diff --git a/tests/Testcontainers.Tests/Unit/Images/ImageFromDockerfileTest.cs b/tests/Testcontainers.Tests/Unit/Images/ImageFromDockerfileTest.cs index 9b51007d3..46f0a525a 100644 --- a/tests/Testcontainers.Tests/Unit/Images/ImageFromDockerfileTest.cs +++ b/tests/Testcontainers.Tests/Unit/Images/ImageFromDockerfileTest.cs @@ -32,6 +32,8 @@ public void DockerfileArchiveGetBaseImages() new DockerImage("mcr.microsoft.com/dotnet/aspnet:8.0-azurelinux3.0", new Platform("linux/arm64")), new DockerImage("mcr.microsoft.com/dotnet/aspnet:8.0-azurelinux3.0", new Platform("linux/arm/v6")), new DockerImage("mcr.microsoft.com/dotnet/aspnet:8.0-azurelinux3.0", new Platform("linux/arm/v7")), + new DockerImage("mcr.microsoft.com/dotnet/sdk:8.0-alpine"), + new DockerImage("mcr.microsoft.com/dotnet/runtime:8.0-alpine"), new DockerImage("mcr.microsoft.com/dotnet/sdk:8.0.414"), }; @@ -50,6 +52,43 @@ public void DockerfileArchiveGetBaseImages() Assert.Equivalent(expected, actual); } + [Fact] + public void DockerfileArchiveGetBaseImagesForTargetPlatform() + { + // Given + var targetPlatform = new Platform("linux/s390x"); + + var expected = new[] + { + new DockerImage("mcr.microsoft.com/dotnet/sdk:8.0", targetPlatform), + new DockerImage("mcr.microsoft.com/dotnet/runtime:8.0", targetPlatform), + new DockerImage("mcr.microsoft.com/dotnet/aspnet:8.0-jammy", targetPlatform), + new DockerImage("mcr.microsoft.com/dotnet/aspnet:8.0-noble", targetPlatform), + new DockerImage("mcr.microsoft.com/dotnet/aspnet:8.0-alpine", targetPlatform), + new DockerImage("mcr.microsoft.com/dotnet/aspnet:8.0-azurelinux3.0", new Platform("linux/amd64")), + new DockerImage("mcr.microsoft.com/dotnet/aspnet:8.0-azurelinux3.0", new Platform("linux/arm64")), + new DockerImage("mcr.microsoft.com/dotnet/aspnet:8.0-azurelinux3.0", new Platform("linux/arm/v6")), + new DockerImage("mcr.microsoft.com/dotnet/aspnet:8.0-azurelinux3.0", new Platform("linux/arm/v7")), + new DockerImage("mcr.microsoft.com/dotnet/sdk:8.0-alpine"), + new DockerImage("mcr.microsoft.com/dotnet/runtime:8.0-alpine", targetPlatform), + new DockerImage("mcr.microsoft.com/dotnet/sdk:8.0.414", targetPlatform), + }; + + IImage image = new DockerImage("localhost/testcontainers", Guid.NewGuid().ToString("D"), string.Empty); + + // The Dockerfile does not contain a default value. + var buildArguments = new Dictionary(); + buildArguments.Add("SDK_VERSION_8_0", "8.0.414"); + + var dockerfileArchive = new DockerfileArchive(null, "Assets/pullBaseImages/", "Dockerfile", image, buildArguments, NullLogger.Instance); + + // When + var actual = dockerfileArchive.GetBaseImages(targetPlatform.Value); + + // Then + Assert.Equivalent(expected, actual); + } + [Fact] public async Task DockerfileArchiveTar() { From 713c876490e08735324406b114ac331ed93eb5d9 Mon Sep 17 00:00:00 2001 From: Andre Hofmeister <9199345+HofmeisterAn@users.noreply.github.com> Date: Mon, 5 Oct 2026 17:57:42 +0200 Subject: [PATCH 2/2] fix: Ignore stage-local ARG defaults when resolving base images --- src/Testcontainers/Images/DockerfileArchive.cs | 3 +++ tests/Testcontainers.Tests/Assets/pullBaseImages/Dockerfile | 5 +++++ 2 files changed, 8 insertions(+) diff --git a/src/Testcontainers/Images/DockerfileArchive.cs b/src/Testcontainers/Images/DockerfileArchive.cs index 5671bfb63..35a949cda 100644 --- a/src/Testcontainers/Images/DockerfileArchive.cs +++ b/src/Testcontainers/Images/DockerfileArchive.cs @@ -147,7 +147,10 @@ public IEnumerable GetBaseImages([CanBeNull] string targetPlatform = nul .Where(line => !line.StartsWith("#", StringComparison.Ordinal)) .ToArray(); + // Only ARG instructions declared before the first FROM instruction are in + // scope for FROM instructions. Stage-local declarations are not. var argMatches = lines + .TakeWhile(line => !FromLinePattern.IsMatch(line)) .Select(line => ArgLinePattern.Match(line)) .Where(match => match.Success) .ToArray(); diff --git a/tests/Testcontainers.Tests/Assets/pullBaseImages/Dockerfile b/tests/Testcontainers.Tests/Assets/pullBaseImages/Dockerfile index de73002c9..147925b9b 100644 --- a/tests/Testcontainers.Tests/Assets/pullBaseImages/Dockerfile +++ b/tests/Testcontainers.Tests/Assets/pullBaseImages/Dockerfile @@ -2,6 +2,11 @@ ARG REPO=mcr.microsoft.com/dotnet/aspnet ARG PLATFORM=linux/arm64 FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build + +# Stage-local build arguments are out of scope for FROM instructions. +ARG REPO=stage-local +ARG TARGETPLATFORM=linux/stage-local + FROM mcr.microsoft.com/dotnet/runtime:8.0 AS runtime FROM build FROM build AS publish