From 4f4b6ad7aec28ebebcb371c4cffae0a922cdaae9 Mon Sep 17 00:00:00 2001 From: Andre Hofmeister <9199345+HofmeisterAn@users.noreply.github.com> Date: Mon, 22 Dec 2025 15:23:57 +0100 Subject: [PATCH 1/6] feat: Add Platform property to IImage interface --- .../Clients/DockerImageOperations.cs | 1 + src/Testcontainers/Images/DockerImage.cs | 32 ++++- .../Images/DockerfileArchive.cs | 136 ++++++++++++++++-- .../Images/FutureDockerImage.cs | 10 ++ src/Testcontainers/Images/IImage.cs | 11 ++ src/Testcontainers/Images/IImageExtensions.cs | 2 +- .../Assets/pullBaseImages/Dockerfile | 7 +- .../Fixtures/Containers/Unix/DockerMTls.cs | 1 - .../Containers/Unix/DockerTlsFixture.cs | 1 - .../Fixtures/Images/DockerImageFixture.cs | 4 +- .../Images/DockerImageFixtureSerializable.cs | 4 +- .../Fixtures/Images/HealthCheckFixture.cs | 2 + ...ockerRegistryAuthenticationProviderTest.cs | 2 +- .../Unit/Images/ImageFromDockerfileTest.cs | 20 +-- .../Unit/Images/TestcontainersImageTest.cs | 14 ++ 15 files changed, 214 insertions(+), 33 deletions(-) diff --git a/src/Testcontainers/Clients/DockerImageOperations.cs b/src/Testcontainers/Clients/DockerImageOperations.cs index 3fdad710c..53d04d77f 100644 --- a/src/Testcontainers/Clients/DockerImageOperations.cs +++ b/src/Testcontainers/Clients/DockerImageOperations.cs @@ -60,6 +60,7 @@ public async Task CreateAsync(IImage image, IDockerRegistryAuthenticationConfigu var createParameters = new ImagesCreateParameters { FromImage = image.FullName, + Platform = image.Platform, }; var authConfig = new AuthConfig diff --git a/src/Testcontainers/Images/DockerImage.cs b/src/Testcontainers/Images/DockerImage.cs index d41a4c1d7..ec3629ceb 100644 --- a/src/Testcontainers/Images/DockerImage.cs +++ b/src/Testcontainers/Images/DockerImage.cs @@ -31,12 +31,15 @@ public sealed class DockerImage : IImage [CanBeNull] private readonly string _digest; + [CanBeNull] + private readonly string _platform; + /// /// Initializes a new instance of the class. /// /// The image. public DockerImage(IImage image) - : this(image.Repository, image.Registry, image.Tag, image.Digest) + : this(image.Repository, image.Registry, image.Tag, image.Digest, image.Platform) { } @@ -50,6 +53,25 @@ public DockerImage(string image) { } + /// + /// Initializes a new instance of the class. + /// + /// + /// The supported format for is <os>|<arch>|<os>/<arch>[/<variant>]. + /// You can provide the operating system, the architecture, or both. + /// For more details and examples, see containerd/platforms. + /// + /// The image. + /// The platform. + /// fedora/httpd:version1.0 where fedora/httpd is the repository and version1.0 the tag. + public DockerImage( + string image, + string platform) + : this(GetDockerImage(image)) + { + _platform = TrimOrDefault(platform); + } + /// /// Initializes a new instance of the class. /// @@ -57,6 +79,7 @@ public DockerImage(string image) /// The registry. /// The tag. /// The digest. + /// The platform. /// The Docker Hub image name prefix. /// fedora/httpd:version1.0 where fedora/httpd is the repository and version1.0 the tag. public DockerImage( @@ -64,12 +87,14 @@ public DockerImage( string registry = null, string tag = null, string digest = null, + string platform = null, string hubImageNamePrefix = null) : this( TrimOrDefault(repository), TrimOrDefault(registry), TrimOrDefault(tag, tag == null && digest == null ? LatestTag : null), TrimOrDefault(digest), + TrimOrDefault(platform), hubImageNamePrefix == null ? [] : hubImageNamePrefix.Trim(TrimChars).Split(SlashChar, 2, StringSplitOptions.RemoveEmptyEntries)) { } @@ -79,6 +104,7 @@ private DockerImage( string registry, string tag, string digest, + string platform, string[] substitutions) { _ = Guard.Argument(repository, nameof(repository)) @@ -109,6 +135,7 @@ private DockerImage( _tag = tag; _digest = digest; + _platform = platform; } /// @@ -123,6 +150,9 @@ private DockerImage( /// public string Digest => _digest; + /// + public string Platform => _platform; + /// public string FullName { diff --git a/src/Testcontainers/Images/DockerfileArchive.cs b/src/Testcontainers/Images/DockerfileArchive.cs index e2f1e1e23..e4b44b065 100644 --- a/src/Testcontainers/Images/DockerfileArchive.cs +++ b/src/Testcontainers/Images/DockerfileArchive.cs @@ -123,12 +123,14 @@ private DockerfileArchive( /// An of . public IEnumerable GetBaseImages() { - const string imageGroup = "image"; - const string nameGroup = "name"; const string valueGroup = "value"; + const string argGroup = "arg"; + + const string imageGroup = "image"; + var lines = File.ReadAllLines(_dockerfile.FullName) .Select(line => line.Trim()) .Where(line => !string.IsNullOrEmpty(line)) @@ -160,13 +162,16 @@ public IEnumerable GetBaseImages() .ToArray(); var images = fromMatches - .Select(match => match.Groups[imageGroup]) - .Select(match => match.Value) - .Select(line => ReplaceVariables(line, args)) - .Where(line => !line.Any(char.IsUpper)) - .Where(value => !stages.Contains(value)) - .Distinct() - .Select(value => new DockerImage(value)) + .Select(match => (Arg: match.Groups[argGroup], Image: match.Groups[imageGroup])) + .Select(item => (Arg: ReplaceVariables(item.Arg.Value, args), Image: ReplaceVariables(item.Image.Value, args))) + .Where(item => !item.Image.Any(char.IsUpper)) + .Where(item => !stages.Contains(item.Image)) + .Select(item => + { + var fromArgs = ParseFromArgs(item.Arg).ToDictionary(arg => arg.Name, arg => arg.Value); + _ = fromArgs.TryGetValue("platform", out var platform); + return new DockerImage(item.Image, platform); + }) .ToArray(); return images; @@ -213,11 +218,11 @@ await AddAsync(absoluteFilePath, relativeFilePath, tarOutputStream) .ConfigureAwait(false); } - var dockerfileDirectoryLength = _dockerfileDirectory.FullName + var dockerfileDirectoryLength = _dockerfileDirectory.FullName .TrimEnd(Path.DirectorySeparatorChar).Length + 1; var dockerfileRelativeFilePath = _dockerfile.FullName - .Substring(dockerfileDirectoryLength ); + .Substring(dockerfileDirectoryLength); var dockerfileNormalizedRelativeFilePath = Unix.Instance.NormalizePath(dockerfileRelativeFilePath); @@ -306,23 +311,124 @@ private static int GetUnixFileMode(string filePath) /// corresponding build argument if present; otherwise, the default value in the /// Dockerfile is preserved. /// - /// The image string from a Dockerfile FROM statement. + /// The line from a Dockerfile FROM statement. /// A dictionary containing variable names as keys and their replacement values as values. /// A new image string where placeholders are replaced with their corresponding values. - private static string ReplaceVariables(string image, IDictionary variables) + private static string ReplaceVariables(string line, IDictionary variables) { const string nameGroup = "name"; if (variables.Count == 0) { - return image; + return line; } - return VariablePattern.Replace(image, match => + return VariablePattern.Replace(line, match => { var name = match.Groups[nameGroup].Value; return variables.TryGetValue(name, out var value) ? value : match.Value; }); } + + /// + /// Parses a FROM statement arg string into flag and value pairs. + /// + /// + /// This method parses a string containing FROM statement style flags, + /// respecting quoted values. Both double quotes (") and single + /// quotes (') are supported. Whitespaces outside of quotes are + /// treated as separators. + /// + /// For example, the line: + /// + /// --pull=always --platform="linux/amd64" + /// + /// becomes: + /// + /// + /// + /// (pull, always) + /// + /// + /// + /// + /// (platform, linux/amd64) + /// + /// + /// + /// + /// + /// The FROM statement arg string containing flags and optional values. + /// + /// + /// A sequence of (Name, Value) tuples. + /// + /// + /// Thrown if a quoted value is missing a closing quote. + /// + private static IEnumerable<(string Name, string Value)> ParseFromArgs(string line) + { + if (string.IsNullOrEmpty(line)) + { + yield break; + } + + char? quote = null; + + var start = 0; + + for (var i = 0; i < line.Length; i++) + { + var c = line[i]; + + if ((c == '"' || c == '\'') && (quote == null || quote == c)) + { + quote = quote == null ? c : null; + } + + if (quote != null || !char.IsWhiteSpace(c)) + { + continue; + } + + if (i > start) + { + yield return ParseFlag(line.Substring(start, i - start)); + } + + start = i + 1; + } + + if (quote != null) + { + throw new FormatException($"Unmatched {quote} quote starting at position {start - 1} in line: '{line}'."); + } + + if (line.Length > start) + { + yield return ParseFlag(line.Substring(start)); + } + } + + /// + /// Splits a single flag token into a flag name and an optional value. + /// + /// A single flag token, optionally containing an equals sign and value. + /// A tuple containing the flag name and its value, or null if no value is specified. + private static (string Name, string Value) ParseFlag(string flag) + { + var trimmed = flag.TrimStart('-'); + var eqIndex = trimmed.IndexOf('='); + if (eqIndex == -1) + { + return (trimmed, null); + } + else + { + var name = trimmed.Substring(0, eqIndex); + var value = trimmed.Substring(eqIndex + 1).Trim(' ', '"', '\''); + return (name, value); + } + } } } diff --git a/src/Testcontainers/Images/FutureDockerImage.cs b/src/Testcontainers/Images/FutureDockerImage.cs index 32a44ee96..6c2f7dab4 100644 --- a/src/Testcontainers/Images/FutureDockerImage.cs +++ b/src/Testcontainers/Images/FutureDockerImage.cs @@ -68,6 +68,16 @@ public string Digest } } + /// + public string Platform + { + get + { + ThrowIfResourceNotFound(); + return _configuration.Image.Platform; + } + } + /// public string FullName { diff --git a/src/Testcontainers/Images/IImage.cs b/src/Testcontainers/Images/IImage.cs index 8f35495f7..182156b61 100644 --- a/src/Testcontainers/Images/IImage.cs +++ b/src/Testcontainers/Images/IImage.cs @@ -33,6 +33,17 @@ public interface IImage [CanBeNull] string Digest { get; } + /// + /// Gets the platform. + /// + /// + /// The supported format is <os>|<arch>|<os>/<arch>[/<variant>]. + /// You can provide the operating system, the architecture, or both. + /// For more details and examples, see containerd/platforms. + /// + [CanBeNull] + string Platform { get; } + /// /// Gets the full image name. /// diff --git a/src/Testcontainers/Images/IImageExtensions.cs b/src/Testcontainers/Images/IImageExtensions.cs index 55067ae80..b31c8e013 100644 --- a/src/Testcontainers/Images/IImageExtensions.cs +++ b/src/Testcontainers/Images/IImageExtensions.cs @@ -27,7 +27,7 @@ public static IImage ApplyHubImageNamePrefix(this IImage image) return image; } - return new DockerImage(image.Repository, image.Registry, image.Tag, image.Digest, TestcontainersSettings.HubImageNamePrefix); + return new DockerImage(image.Repository, image.Registry, image.Tag, image.Digest, image.Platform, TestcontainersSettings.HubImageNamePrefix); } } } diff --git a/tests/Testcontainers.Tests/Assets/pullBaseImages/Dockerfile b/tests/Testcontainers.Tests/Assets/pullBaseImages/Dockerfile index ff431cd45..6d04bc65e 100644 --- a/tests/Testcontainers.Tests/Assets/pullBaseImages/Dockerfile +++ b/tests/Testcontainers.Tests/Assets/pullBaseImages/Dockerfile @@ -1,4 +1,6 @@ ARG REPO=mcr.microsoft.com/dotnet/aspnet +ARG PLATFORM=linux/arm64 + FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build FROM mcr.microsoft.com/dotnet/runtime:8.0 AS runtime FROM build @@ -8,7 +10,10 @@ FROM ${REPO}:8.0-noble FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine # https://github.com/testcontainers/testcontainers-dotnet/issues/993. -FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/aspnet:8.0-azurelinux3.0 +FROM --platform=linux/amd64 mcr.microsoft.com/dotnet/aspnet:8.0-azurelinux3.0 +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 # https://github.com/testcontainers/testcontainers-dotnet/issues/1030. FROM mcr.microsoft.com/dotnet/sdk:$SDK_VERSION_8_0 AS build_sdk_8_0 diff --git a/tests/Testcontainers.Tests/Fixtures/Containers/Unix/DockerMTls.cs b/tests/Testcontainers.Tests/Fixtures/Containers/Unix/DockerMTls.cs index 8921710df..44dd34268 100644 --- a/tests/Testcontainers.Tests/Fixtures/Containers/Unix/DockerMTls.cs +++ b/tests/Testcontainers.Tests/Fixtures/Containers/Unix/DockerMTls.cs @@ -2,7 +2,6 @@ namespace DotNet.Testcontainers.Tests.Fixtures { using System.Collections.Generic; using DotNet.Testcontainers.Builders; - using DotNet.Testcontainers.Images; public abstract class DockerMTls : ProtectDockerDaemonSocket { diff --git a/tests/Testcontainers.Tests/Fixtures/Containers/Unix/DockerTlsFixture.cs b/tests/Testcontainers.Tests/Fixtures/Containers/Unix/DockerTlsFixture.cs index aee4d2981..1693aa47e 100644 --- a/tests/Testcontainers.Tests/Fixtures/Containers/Unix/DockerTlsFixture.cs +++ b/tests/Testcontainers.Tests/Fixtures/Containers/Unix/DockerTlsFixture.cs @@ -2,7 +2,6 @@ namespace DotNet.Testcontainers.Tests.Fixtures { using System.Collections.Generic; using DotNet.Testcontainers.Builders; - using DotNet.Testcontainers.Images; using JetBrains.Annotations; [UsedImplicitly] diff --git a/tests/Testcontainers.Tests/Fixtures/Images/DockerImageFixture.cs b/tests/Testcontainers.Tests/Fixtures/Images/DockerImageFixture.cs index 4497fd8fd..e4a37ea45 100644 --- a/tests/Testcontainers.Tests/Fixtures/Images/DockerImageFixture.cs +++ b/tests/Testcontainers.Tests/Fixtures/Images/DockerImageFixture.cs @@ -37,8 +37,8 @@ public DockerImageFixture() Add(new DockerImageFixtureSerializable(new DockerImage(FedoraHttpd, PortSeparatorRegistry, CustomTag1, null)), $"{PortSeparatorRegistry}/{FedoraHttpd}:{CustomTag1}", $"{PortSeparatorRegistry}/{FedoraHttpd}:{CustomTag1}"); Add(new DockerImageFixtureSerializable(new DockerImage(FooBarBaz, DotSeparatorRegistry, SemVerTag, Digest)), $"{DotSeparatorRegistry}/{FooBarBaz}:{SemVerTag}@{Digest}", $"{DotSeparatorRegistry}/{FooBarBaz}:{SemVerTag}@{Digest}"); Add(new DockerImageFixtureSerializable(new DockerImage(FooBarBaz, DotSeparatorRegistry, null, Digest)), $"{DotSeparatorRegistry}/{FooBarBaz}@{Digest}", $"{DotSeparatorRegistry}/{FooBarBaz}@{Digest}"); - Add(new DockerImageFixtureSerializable(new DockerImage(BarBaz, null, null, null, HubImageNamePrefixImplicitLibrary)), $"{HubImageNamePrefixImplicitLibrary}/{BarBaz}", $"{HubImageNamePrefixImplicitLibrary}/{BarBaz}:{LatestTag}"); - Add(new DockerImageFixtureSerializable(new DockerImage(BarBaz, null, null, null, HubImageNamePrefixExplicitLibrary)), $"{HubImageNamePrefixExplicitLibrary}/{BarBaz}", $"{HubImageNamePrefixExplicitLibrary}/{BarBaz}:{LatestTag}"); + Add(new DockerImageFixtureSerializable(new DockerImage(BarBaz, null, null, null, null, HubImageNamePrefixImplicitLibrary)), $"{HubImageNamePrefixImplicitLibrary}/{BarBaz}", $"{HubImageNamePrefixImplicitLibrary}/{BarBaz}:{LatestTag}"); + Add(new DockerImageFixtureSerializable(new DockerImage(BarBaz, null, null, null, null, HubImageNamePrefixExplicitLibrary)), $"{HubImageNamePrefixExplicitLibrary}/{BarBaz}", $"{HubImageNamePrefixExplicitLibrary}/{BarBaz}:{LatestTag}"); } } } diff --git a/tests/Testcontainers.Tests/Fixtures/Images/DockerImageFixtureSerializable.cs b/tests/Testcontainers.Tests/Fixtures/Images/DockerImageFixtureSerializable.cs index ebe799359..2176f5a5c 100644 --- a/tests/Testcontainers.Tests/Fixtures/Images/DockerImageFixtureSerializable.cs +++ b/tests/Testcontainers.Tests/Fixtures/Images/DockerImageFixtureSerializable.cs @@ -22,7 +22,8 @@ public void Deserialize(IXunitSerializationInfo info) var registry = info.GetValue("Registry"); var tag = info.GetValue("Tag"); var digest = info.GetValue("Digest"); - Image = new DockerImage(repository, registry, tag, digest); + var platform = info.GetValue("Platform"); + Image = new DockerImage(repository, registry, tag, digest, platform); } public void Serialize(IXunitSerializationInfo info) @@ -31,6 +32,7 @@ public void Serialize(IXunitSerializationInfo info) info.AddValue("Registry", Image.Registry); info.AddValue("Tag", Image.Tag); info.AddValue("Digest", Image.Digest); + info.AddValue("Platform", Image.Platform); } } } diff --git a/tests/Testcontainers.Tests/Fixtures/Images/HealthCheckFixture.cs b/tests/Testcontainers.Tests/Fixtures/Images/HealthCheckFixture.cs index d99399573..f27984fa6 100644 --- a/tests/Testcontainers.Tests/Fixtures/Images/HealthCheckFixture.cs +++ b/tests/Testcontainers.Tests/Fixtures/Images/HealthCheckFixture.cs @@ -23,6 +23,8 @@ public sealed class HealthCheckFixture : IImage, IAsyncLifetime public string Digest => _image.Digest; + public string Platform => _image.Platform; + public string FullName => _image.FullName; public string GetHostname() diff --git a/tests/Testcontainers.Tests/Unit/Configurations/DockerRegistryAuthenticationProviderTest.cs b/tests/Testcontainers.Tests/Unit/Configurations/DockerRegistryAuthenticationProviderTest.cs index 855d190c0..51dbc0358 100644 --- a/tests/Testcontainers.Tests/Unit/Configurations/DockerRegistryAuthenticationProviderTest.cs +++ b/tests/Testcontainers.Tests/Unit/Configurations/DockerRegistryAuthenticationProviderTest.cs @@ -48,7 +48,7 @@ public void GetHostnameFromDockerImage(string dockerImageName, string hostname) public void GetHostnameFromHubImageNamePrefix(string repository, string tag) { const string hubImageNamePrefix = "myregistry.azurecr.io"; - IImage image = new DockerImage(repository, null, tag, null, hubImageNamePrefix); + IImage image = new DockerImage(repository, null, tag, null, null, hubImageNamePrefix); Assert.Equal(hubImageNamePrefix, image.GetHostname()); } diff --git a/tests/Testcontainers.Tests/Unit/Images/ImageFromDockerfileTest.cs b/tests/Testcontainers.Tests/Unit/Images/ImageFromDockerfileTest.cs index 82c4d4ef8..1baada99d 100644 --- a/tests/Testcontainers.Tests/Unit/Images/ImageFromDockerfileTest.cs +++ b/tests/Testcontainers.Tests/Unit/Images/ImageFromDockerfileTest.cs @@ -4,7 +4,6 @@ namespace DotNet.Testcontainers.Tests.Unit using System.Collections.Generic; using System.Collections.ObjectModel; using System.IO; - using System.Linq; using System.Text; using System.Threading.Tasks; using DotNet.Testcontainers.Builders; @@ -23,13 +22,16 @@ public void DockerfileArchiveGetBaseImages() // Given var expected = new[] { - "mcr.microsoft.com/dotnet/sdk:8.0", - "mcr.microsoft.com/dotnet/runtime:8.0", - "mcr.microsoft.com/dotnet/aspnet:8.0-jammy", - "mcr.microsoft.com/dotnet/aspnet:8.0-noble", - "mcr.microsoft.com/dotnet/aspnet:8.0-alpine", - "mcr.microsoft.com/dotnet/aspnet:8.0-azurelinux3.0", - "mcr.microsoft.com/dotnet/sdk:8.0.414", + new DockerImage("mcr.microsoft.com/dotnet/sdk:8.0"), + new DockerImage("mcr.microsoft.com/dotnet/runtime:8.0"), + new DockerImage("mcr.microsoft.com/dotnet/aspnet:8.0-jammy"), + new DockerImage("mcr.microsoft.com/dotnet/aspnet:8.0-noble"), + new DockerImage("mcr.microsoft.com/dotnet/aspnet:8.0-alpine"), + new DockerImage("mcr.microsoft.com/dotnet/aspnet:8.0-azurelinux3.0", "linux/amd64"), + new DockerImage("mcr.microsoft.com/dotnet/aspnet:8.0-azurelinux3.0", "linux/arm64"), + new DockerImage("mcr.microsoft.com/dotnet/aspnet:8.0-azurelinux3.0", "linux/arm/v6"), + new DockerImage("mcr.microsoft.com/dotnet/aspnet:8.0-azurelinux3.0", "linux/arm/v7"), + new DockerImage("mcr.microsoft.com/dotnet/sdk:8.0.414"), }; IImage image = new DockerImage("localhost/testcontainers", Guid.NewGuid().ToString("D"), string.Empty); @@ -44,7 +46,7 @@ public void DockerfileArchiveGetBaseImages() var actual = dockerfileArchive.GetBaseImages(); // Then - Assert.Equal(expected, actual.Select(baseImage => baseImage.FullName)); + Assert.Equivalent(expected, actual); } [Fact] diff --git a/tests/Testcontainers.Tests/Unit/Images/TestcontainersImageTest.cs b/tests/Testcontainers.Tests/Unit/Images/TestcontainersImageTest.cs index 4dc349470..e79222527 100644 --- a/tests/Testcontainers.Tests/Unit/Images/TestcontainersImageTest.cs +++ b/tests/Testcontainers.Tests/Unit/Images/TestcontainersImageTest.cs @@ -69,6 +69,20 @@ public void WhenImageNameGetsAssigned(DockerImageFixtureSerializable serializabl Assert.Equal(fullName, actual.FullName); } + [Fact] + public void Platform_PlatformIsLinuxAmd64_ReturnsLinuxAmd64() + { + // Given + const string platform = "linux/amd64"; + IImage dockerImage = new DockerImage("foo", platform); + + // When + var result = dockerImage.Platform; + + // Then + Assert.Equal(platform, result); + } + [Fact] public void MatchLatestOrNightly_TagIsLatest_ReturnsTrue() { From c1b76c5982fd441a6ca62cf0e6a7d4c3fb85af4d Mon Sep 17 00:00:00 2001 From: Andre Hofmeister <9199345+HofmeisterAn@users.noreply.github.com> Date: Mon, 22 Dec 2025 16:29:46 +0100 Subject: [PATCH 2/6] docs: Add platform doc --- docs/api/create_docker_container.md | 18 ++++++++++++++++++ src/Testcontainers/Images/DockerfileArchive.cs | 4 ++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/docs/api/create_docker_container.md b/docs/api/create_docker_container.md index 653d4eee6..a2881ad80 100644 --- a/docs/api/create_docker_container.md +++ b/docs/api/create_docker_container.md @@ -2,6 +2,24 @@ Testcontainers' generic container support offers the greatest flexibility and makes it easy to use virtually any container image in the context of a temporary test environment. To interact or exchange data with a container, Testcontainers provides `ContainerBuilder` to configure and create the resource. +## Configure container image + +Use `WithImage(...)` to specify the container image. + +The simplest overload accepts a `string`: + +```csharp +_ = new ContainerBuilder() + .WithImage("postgres:15.1"); +``` + +For platform-specific scenarios, `WithImage` also accepts an `IImage`. Using the `DockerImage` implementation, you can explicitly set the platform: + +```csharp +_ = new ContainerBuilder() + .WithImage(new DockerImage("postgres:15.1", "linux/amd64")); +``` + ## Configure container start Both `ENTRYPOINT` and `CMD` allows you to configure an executable and parameters, that a container runs at the start. By default, a container will run whatever `ENTRYPOINT` or `CMD` is specified in the Docker container image. At least one of both configurations is necessary. The container builder implementation supports `WithEntrypoint(params string[])` and `WithCommand(params string[])` to set or override the executable. Ideally, the `ENTRYPOINT` should set the container's executable, whereas the `CMD` sets the default arguments for the `ENTRYPOINT`. diff --git a/src/Testcontainers/Images/DockerfileArchive.cs b/src/Testcontainers/Images/DockerfileArchive.cs index e4b44b065..00ce502be 100644 --- a/src/Testcontainers/Images/DockerfileArchive.cs +++ b/src/Testcontainers/Images/DockerfileArchive.cs @@ -401,7 +401,7 @@ private static string ReplaceVariables(string line, IDictionary if (quote != null) { - throw new FormatException($"Unmatched {quote} quote starting at position {start - 1} in line: '{line}'."); + throw new FormatException($"Unmatched {quote} quote in line: '{line}'."); } if (line.Length > start) @@ -426,7 +426,7 @@ private static (string Name, string Value) ParseFlag(string flag) else { var name = trimmed.Substring(0, eqIndex); - var value = trimmed.Substring(eqIndex + 1).Trim(' ', '"', '\''); + var value = trimmed.Substring(eqIndex + 1).Trim().Trim('"', '\''); return (name, value); } } From 3dfe17a8284212cc3ac234b38b9a9c8a7776277e Mon Sep 17 00:00:00 2001 From: Andre Hofmeister <9199345+HofmeisterAn@users.noreply.github.com> Date: Tue, 23 Dec 2025 10:43:51 +0100 Subject: [PATCH 3/6] fix: Add Platform type --- docs/api/create_docker_container.md | 12 ++++-- .../Clients/DockerContainerOperations.cs | 1 + src/Testcontainers/Images/DockerImage.cs | 9 +---- .../Images/DockerfileArchive.cs | 21 +++++----- src/Testcontainers/Images/IImage.cs | 5 +-- src/Testcontainers/Images/Platform.cs | 38 +++++++++++++++++++ .../Unit/Images/ImageFromDockerfileTest.cs | 8 ++-- 7 files changed, 65 insertions(+), 29 deletions(-) create mode 100644 src/Testcontainers/Images/Platform.cs diff --git a/docs/api/create_docker_container.md b/docs/api/create_docker_container.md index a2881ad80..08c87ff4a 100644 --- a/docs/api/create_docker_container.md +++ b/docs/api/create_docker_container.md @@ -4,7 +4,7 @@ Testcontainers' generic container support offers the greatest flexibility and ma ## Configure container image -Use `WithImage(...)` to specify the container image. +To specify the container image, use `WithImage(...)`. The simplest overload accepts a `string`: @@ -13,13 +13,19 @@ _ = new ContainerBuilder() .WithImage("postgres:15.1"); ``` -For platform-specific scenarios, `WithImage` also accepts an `IImage`. Using the `DockerImage` implementation, you can explicitly set the platform: +For more advanced scenarios, `WithImage` also supports `IImage`, giving you more control over how the image is represented and its properties are resolved. + +If you need to target a specific platform, the `DockerImage` implementation provides an overload that lets you explicitly set the platform, such as `linux/amd64`. ```csharp _ = new ContainerBuilder() - .WithImage(new DockerImage("postgres:15.1", "linux/amd64")); + .WithImage(new DockerImage("postgres:15.1", new Platform("linux/amd64"))); ``` +!!!tip + + A specifier has the format `||/[/]`. The user can provide either the operating system or the architecture or both. For more details, [see containerd/platforms](https://github.com/containerd/platforms). + ## Configure container start Both `ENTRYPOINT` and `CMD` allows you to configure an executable and parameters, that a container runs at the start. By default, a container will run whatever `ENTRYPOINT` or `CMD` is specified in the Docker container image. At least one of both configurations is necessary. The container builder implementation supports `WithEntrypoint(params string[])` and `WithCommand(params string[])` to set or override the executable. Ideally, the `ENTRYPOINT` should set the container's executable, whereas the `CMD` sets the default arguments for the `ENTRYPOINT`. diff --git a/src/Testcontainers/Clients/DockerContainerOperations.cs b/src/Testcontainers/Clients/DockerContainerOperations.cs index efc521678..23aacf026 100644 --- a/src/Testcontainers/Clients/DockerContainerOperations.cs +++ b/src/Testcontainers/Clients/DockerContainerOperations.cs @@ -202,6 +202,7 @@ public async Task RunAsync(IContainerConfiguration configuration, Cancel var createParameters = new CreateContainerParameters { Image = configuration.Image.FullName, + Platform = configuration.Image.Platform, Name = configuration.Name, Hostname = configuration.Hostname, WorkingDir = configuration.WorkingDirectory, diff --git a/src/Testcontainers/Images/DockerImage.cs b/src/Testcontainers/Images/DockerImage.cs index ec3629ceb..ac2884d68 100644 --- a/src/Testcontainers/Images/DockerImage.cs +++ b/src/Testcontainers/Images/DockerImage.cs @@ -56,20 +56,15 @@ public DockerImage(string image) /// /// Initializes a new instance of the class. /// - /// - /// The supported format for is <os>|<arch>|<os>/<arch>[/<variant>]. - /// You can provide the operating system, the architecture, or both. - /// For more details and examples, see containerd/platforms. - /// /// The image. /// The platform. /// fedora/httpd:version1.0 where fedora/httpd is the repository and version1.0 the tag. public DockerImage( string image, - string platform) + Platform platform) : this(GetDockerImage(image)) { - _platform = TrimOrDefault(platform); + _platform = platform.Value; } /// diff --git a/src/Testcontainers/Images/DockerfileArchive.cs b/src/Testcontainers/Images/DockerfileArchive.cs index 00ce502be..dfb370957 100644 --- a/src/Testcontainers/Images/DockerfileArchive.cs +++ b/src/Testcontainers/Images/DockerfileArchive.cs @@ -170,7 +170,7 @@ public IEnumerable GetBaseImages() { var fromArgs = ParseFromArgs(item.Arg).ToDictionary(arg => arg.Name, arg => arg.Value); _ = fromArgs.TryGetValue("platform", out var platform); - return new DockerImage(item.Image, platform); + return new DockerImage(item.Image, new Platform(platform)); }) .ToArray(); @@ -339,11 +339,8 @@ private static string ReplaceVariables(string line, IDictionary /// quotes (') are supported. Whitespaces outside of quotes are /// treated as separators. /// - /// For example, the line: - /// - /// --pull=always --platform="linux/amd64" - /// - /// becomes: + /// E.g., the line --pull=always --platform="linux/amd64" becomes: + /// /// /// /// @@ -393,7 +390,7 @@ private static string ReplaceVariables(string line, IDictionary if (i > start) { - yield return ParseFlag(line.Substring(start, i - start)); + yield return ParseArg(line.Substring(start, i - start)); } start = i + 1; @@ -406,18 +403,18 @@ private static string ReplaceVariables(string line, IDictionary if (line.Length > start) { - yield return ParseFlag(line.Substring(start)); + yield return ParseArg(line.Substring(start)); } } /// - /// Splits a single flag token into a flag name and an optional value. + /// Splits a single arg into flag name and an optional value. /// - /// A single flag token, optionally containing an equals sign and value. + /// A single arg, optionally containing an equals sign and value. /// A tuple containing the flag name and its value, or null if no value is specified. - private static (string Name, string Value) ParseFlag(string flag) + private static (string Name, string Value) ParseArg(string arg) { - var trimmed = flag.TrimStart('-'); + var trimmed = arg.TrimStart('-'); var eqIndex = trimmed.IndexOf('='); if (eqIndex == -1) { diff --git a/src/Testcontainers/Images/IImage.cs b/src/Testcontainers/Images/IImage.cs index 182156b61..ec0083f08 100644 --- a/src/Testcontainers/Images/IImage.cs +++ b/src/Testcontainers/Images/IImage.cs @@ -37,9 +37,8 @@ public interface IImage /// Gets the platform. /// /// - /// The supported format is <os>|<arch>|<os>/<arch>[/<variant>]. - /// You can provide the operating system, the architecture, or both. - /// For more details and examples, see containerd/platforms. + /// The supported format for a platform value is: + /// <os>|<arch>|<os>/<arch>[/<variant>]. /// [CanBeNull] string Platform { get; } diff --git a/src/Testcontainers/Images/Platform.cs b/src/Testcontainers/Images/Platform.cs new file mode 100644 index 000000000..df92f49c9 --- /dev/null +++ b/src/Testcontainers/Images/Platform.cs @@ -0,0 +1,38 @@ +namespace DotNet.Testcontainers.Images +{ + using JetBrains.Annotations; + + /// + /// Represents a container platform identifier. + /// + /// + /// The supported format for a platform value is: + /// <os>|<arch>|<os>/<arch>[/<variant>]. + /// + /// You can provide either the operating system or the architecture or both. + /// For more details, see containerd/platforms. + /// + [PublicAPI] + public readonly struct Platform + { + /// + /// Initializes a new instance of the struct. + /// + /// The platform identifier. + [PublicAPI] + public Platform(string value) + { + } + + /// + /// Gets the platform identifier. + /// + /// + /// A string representing the container platform in containerd/platforms format, or + /// null if no platform was specified. + /// + [PublicAPI] + [CanBeNull] + public string Value { get; } + } +} diff --git a/tests/Testcontainers.Tests/Unit/Images/ImageFromDockerfileTest.cs b/tests/Testcontainers.Tests/Unit/Images/ImageFromDockerfileTest.cs index 1baada99d..95c55f961 100644 --- a/tests/Testcontainers.Tests/Unit/Images/ImageFromDockerfileTest.cs +++ b/tests/Testcontainers.Tests/Unit/Images/ImageFromDockerfileTest.cs @@ -27,10 +27,10 @@ public void DockerfileArchiveGetBaseImages() new DockerImage("mcr.microsoft.com/dotnet/aspnet:8.0-jammy"), new DockerImage("mcr.microsoft.com/dotnet/aspnet:8.0-noble"), new DockerImage("mcr.microsoft.com/dotnet/aspnet:8.0-alpine"), - new DockerImage("mcr.microsoft.com/dotnet/aspnet:8.0-azurelinux3.0", "linux/amd64"), - new DockerImage("mcr.microsoft.com/dotnet/aspnet:8.0-azurelinux3.0", "linux/arm64"), - new DockerImage("mcr.microsoft.com/dotnet/aspnet:8.0-azurelinux3.0", "linux/arm/v6"), - new DockerImage("mcr.microsoft.com/dotnet/aspnet:8.0-azurelinux3.0", "linux/arm/v7"), + 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.414"), }; From f2bf9d75981871988068ce060e91340695fa9b60 Mon Sep 17 00:00:00 2001 From: Andre Hofmeister <9199345+HofmeisterAn@users.noreply.github.com> Date: Tue, 23 Dec 2025 14:06:15 +0100 Subject: [PATCH 4/6] fix: Set Platform.Value --- docs/api/create_docker_container.md | 4 ++-- src/Testcontainers/Images/DockerfileArchive.cs | 6 +++--- src/Testcontainers/Images/Platform.cs | 1 + 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/api/create_docker_container.md b/docs/api/create_docker_container.md index 08c87ff4a..496547dd4 100644 --- a/docs/api/create_docker_container.md +++ b/docs/api/create_docker_container.md @@ -15,7 +15,7 @@ _ = new ContainerBuilder() For more advanced scenarios, `WithImage` also supports `IImage`, giving you more control over how the image is represented and its properties are resolved. -If you need to target a specific platform, the `DockerImage` implementation provides an overload that lets you explicitly set the platform, such as `linux/amd64`. +If you need to target a specific platform, the `DockerImage` implementation provides an overload that lets you explicitly set the platform, such as `linux/amd64`. By default, the container runtime uses the platform that matches the container host. ```csharp _ = new ContainerBuilder() @@ -24,7 +24,7 @@ _ = new ContainerBuilder() !!!tip - A specifier has the format `||/[/]`. The user can provide either the operating system or the architecture or both. For more details, [see containerd/platforms](https://github.com/containerd/platforms). + A specifier has the format `||/[/]`. The user can provide either the operating system or the architecture or both. For more details, see [containerd/platforms](https://github.com/containerd/platforms). ## Configure container start diff --git a/src/Testcontainers/Images/DockerfileArchive.cs b/src/Testcontainers/Images/DockerfileArchive.cs index dfb370957..79261fa2b 100644 --- a/src/Testcontainers/Images/DockerfileArchive.cs +++ b/src/Testcontainers/Images/DockerfileArchive.cs @@ -162,13 +162,13 @@ public IEnumerable GetBaseImages() .ToArray(); var images = fromMatches - .Select(match => (Arg: match.Groups[argGroup], Image: match.Groups[imageGroup])) - .Select(item => (Arg: ReplaceVariables(item.Arg.Value, args), Image: ReplaceVariables(item.Image.Value, args))) + .Select(match => (FromArgs: match.Groups[argGroup], Image: match.Groups[imageGroup])) + .Select(item => (FromArgs: ReplaceVariables(item.FromArgs.Value, args), Image: ReplaceVariables(item.Image.Value, args))) .Where(item => !item.Image.Any(char.IsUpper)) .Where(item => !stages.Contains(item.Image)) .Select(item => { - var fromArgs = ParseFromArgs(item.Arg).ToDictionary(arg => arg.Name, arg => arg.Value); + var fromArgs = ParseFromArgs(item.FromArgs).ToDictionary(arg => arg.Name, arg => arg.Value); _ = fromArgs.TryGetValue("platform", out var platform); return new DockerImage(item.Image, new Platform(platform)); }) diff --git a/src/Testcontainers/Images/Platform.cs b/src/Testcontainers/Images/Platform.cs index df92f49c9..ffedf0bfa 100644 --- a/src/Testcontainers/Images/Platform.cs +++ b/src/Testcontainers/Images/Platform.cs @@ -22,6 +22,7 @@ public readonly struct Platform [PublicAPI] public Platform(string value) { + Value = value; } /// From eb674c88efa686a7688428265d73204b55325c35 Mon Sep 17 00:00:00 2001 From: Andre Hofmeister <9199345+HofmeisterAn@users.noreply.github.com> Date: Tue, 23 Dec 2025 15:09:34 +0100 Subject: [PATCH 5/6] fix: Use correct ctor --- .../Unit/Images/TestcontainersImageTest.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/Testcontainers.Tests/Unit/Images/TestcontainersImageTest.cs b/tests/Testcontainers.Tests/Unit/Images/TestcontainersImageTest.cs index e79222527..d371bfaea 100644 --- a/tests/Testcontainers.Tests/Unit/Images/TestcontainersImageTest.cs +++ b/tests/Testcontainers.Tests/Unit/Images/TestcontainersImageTest.cs @@ -73,14 +73,14 @@ public void WhenImageNameGetsAssigned(DockerImageFixtureSerializable serializabl public void Platform_PlatformIsLinuxAmd64_ReturnsLinuxAmd64() { // Given - const string platform = "linux/amd64"; - IImage dockerImage = new DockerImage("foo", platform); + const string linuxAmd64 = "linux/amd64"; + IImage dockerImage = new DockerImage("foo", new Platform(linuxAmd64)); // When var result = dockerImage.Platform; // Then - Assert.Equal(platform, result); + Assert.Equal(linuxAmd64, result); } [Fact] From 09ea9c16efe5afd68ad089421603b8e3ca464e0a Mon Sep 17 00:00:00 2001 From: Andre Hofmeister <9199345+HofmeisterAn@users.noreply.github.com> Date: Tue, 23 Dec 2025 17:17:41 +0100 Subject: [PATCH 6/6] chore: Apply suggestions --- src/Testcontainers/Images/Platform.cs | 2 +- .../Unit/Images/TestcontainersImageTest.cs | 24 +++++++++++++++---- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/src/Testcontainers/Images/Platform.cs b/src/Testcontainers/Images/Platform.cs index ffedf0bfa..ace0ea4ef 100644 --- a/src/Testcontainers/Images/Platform.cs +++ b/src/Testcontainers/Images/Platform.cs @@ -1,4 +1,4 @@ -namespace DotNet.Testcontainers.Images +namespace DotNet.Testcontainers.Images { using JetBrains.Annotations; diff --git a/tests/Testcontainers.Tests/Unit/Images/TestcontainersImageTest.cs b/tests/Testcontainers.Tests/Unit/Images/TestcontainersImageTest.cs index d371bfaea..cf1cd9e45 100644 --- a/tests/Testcontainers.Tests/Unit/Images/TestcontainersImageTest.cs +++ b/tests/Testcontainers.Tests/Unit/Images/TestcontainersImageTest.cs @@ -70,17 +70,33 @@ public void WhenImageNameGetsAssigned(DockerImageFixtureSerializable serializabl } [Fact] - public void Platform_PlatformIsLinuxAmd64_ReturnsLinuxAmd64() + public void Platform_NoPlatformSpecified_ReturnsNull() { // Given - const string linuxAmd64 = "linux/amd64"; - IImage dockerImage = new DockerImage("foo", new Platform(linuxAmd64)); + IImage dockerImage = new DockerImage("foo"); // When var result = dockerImage.Platform; // Then - Assert.Equal(linuxAmd64, result); + Assert.Null(result); + } + + [Theory] + [InlineData("linux/amd64")] + [InlineData("linux/arm64")] + [InlineData("linux/arm/v6")] + [InlineData("linux/arm/v7")] + public void Platform_PlatformSpecified_ReturnsPlatform(string platform) + { + // Given + IImage dockerImage = new DockerImage("foo", new Platform(platform)); + + // When + var result = dockerImage.Platform; + + // Then + Assert.Equal(platform, result); } [Fact]