Skip to content

Commit 4f4b6ad

Browse files
committed
feat: Add Platform property to IImage interface
1 parent cd2f52a commit 4f4b6ad

File tree

15 files changed

+214
-33
lines changed

15 files changed

+214
-33
lines changed

src/Testcontainers/Clients/DockerImageOperations.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ public async Task CreateAsync(IImage image, IDockerRegistryAuthenticationConfigu
6060
var createParameters = new ImagesCreateParameters
6161
{
6262
FromImage = image.FullName,
63+
Platform = image.Platform,
6364
};
6465

6566
var authConfig = new AuthConfig

src/Testcontainers/Images/DockerImage.cs

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,12 +31,15 @@ public sealed class DockerImage : IImage
3131
[CanBeNull]
3232
private readonly string _digest;
3333

34+
[CanBeNull]
35+
private readonly string _platform;
36+
3437
/// <summary>
3538
/// Initializes a new instance of the <see cref="DockerImage" /> class.
3639
/// </summary>
3740
/// <param name="image">The image.</param>
3841
public DockerImage(IImage image)
39-
: this(image.Repository, image.Registry, image.Tag, image.Digest)
42+
: this(image.Repository, image.Registry, image.Tag, image.Digest, image.Platform)
4043
{
4144
}
4245

@@ -50,26 +53,48 @@ public DockerImage(string image)
5053
{
5154
}
5255

56+
/// <summary>
57+
/// Initializes a new instance of the <see cref="DockerImage" /> class.
58+
/// </summary>
59+
/// <remarks>
60+
/// The supported format for <paramref name="platform" /> is <c>&lt;os&gt;|&lt;arch&gt;|&lt;os&gt;/&lt;arch&gt;[/&lt;variant&gt;]</c>.
61+
/// You can provide the operating system, the architecture, or both.
62+
/// For more details and examples, see <see href="https://github.com/containerd/platforms">containerd/platforms</see>.
63+
/// </remarks>
64+
/// <param name="image">The image.</param>
65+
/// <param name="platform">The platform.</param>
66+
/// <example><c>fedora/httpd:version1.0</c> where <c>fedora/httpd</c> is the repository and <c>version1.0</c> the tag.</example>
67+
public DockerImage(
68+
string image,
69+
string platform)
70+
: this(GetDockerImage(image))
71+
{
72+
_platform = TrimOrDefault(platform);
73+
}
74+
5375
/// <summary>
5476
/// Initializes a new instance of the <see cref="DockerImage" /> class.
5577
/// </summary>
5678
/// <param name="repository">The repository.</param>
5779
/// <param name="registry">The registry.</param>
5880
/// <param name="tag">The tag.</param>
5981
/// <param name="digest">The digest.</param>
82+
/// <param name="platform">The platform.</param>
6083
/// <param name="hubImageNamePrefix">The Docker Hub image name prefix.</param>
6184
/// <example><c>fedora/httpd:version1.0</c> where <c>fedora/httpd</c> is the repository and <c>version1.0</c> the tag.</example>
6285
public DockerImage(
6386
string repository,
6487
string registry = null,
6588
string tag = null,
6689
string digest = null,
90+
string platform = null,
6791
string hubImageNamePrefix = null)
6892
: this(
6993
TrimOrDefault(repository),
7094
TrimOrDefault(registry),
7195
TrimOrDefault(tag, tag == null && digest == null ? LatestTag : null),
7296
TrimOrDefault(digest),
97+
TrimOrDefault(platform),
7398
hubImageNamePrefix == null ? [] : hubImageNamePrefix.Trim(TrimChars).Split(SlashChar, 2, StringSplitOptions.RemoveEmptyEntries))
7499
{
75100
}
@@ -79,6 +104,7 @@ private DockerImage(
79104
string registry,
80105
string tag,
81106
string digest,
107+
string platform,
82108
string[] substitutions)
83109
{
84110
_ = Guard.Argument(repository, nameof(repository))
@@ -109,6 +135,7 @@ private DockerImage(
109135

110136
_tag = tag;
111137
_digest = digest;
138+
_platform = platform;
112139
}
113140

114141
/// <inheritdoc />
@@ -123,6 +150,9 @@ private DockerImage(
123150
/// <inheritdoc />
124151
public string Digest => _digest;
125152

153+
/// <inheritdoc />
154+
public string Platform => _platform;
155+
126156
/// <inheritdoc />
127157
public string FullName
128158
{

src/Testcontainers/Images/DockerfileArchive.cs

Lines changed: 121 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -123,12 +123,14 @@ private DockerfileArchive(
123123
/// <returns>An <see cref="IEnumerable{T}" /> of <see cref="IImage" />.</returns>
124124
public IEnumerable<IImage> GetBaseImages()
125125
{
126-
const string imageGroup = "image";
127-
128126
const string nameGroup = "name";
129127

130128
const string valueGroup = "value";
131129

130+
const string argGroup = "arg";
131+
132+
const string imageGroup = "image";
133+
132134
var lines = File.ReadAllLines(_dockerfile.FullName)
133135
.Select(line => line.Trim())
134136
.Where(line => !string.IsNullOrEmpty(line))
@@ -160,13 +162,16 @@ public IEnumerable<IImage> GetBaseImages()
160162
.ToArray();
161163

162164
var images = fromMatches
163-
.Select(match => match.Groups[imageGroup])
164-
.Select(match => match.Value)
165-
.Select(line => ReplaceVariables(line, args))
166-
.Where(line => !line.Any(char.IsUpper))
167-
.Where(value => !stages.Contains(value))
168-
.Distinct()
169-
.Select(value => new DockerImage(value))
165+
.Select(match => (Arg: match.Groups[argGroup], Image: match.Groups[imageGroup]))
166+
.Select(item => (Arg: ReplaceVariables(item.Arg.Value, args), Image: ReplaceVariables(item.Image.Value, args)))
167+
.Where(item => !item.Image.Any(char.IsUpper))
168+
.Where(item => !stages.Contains(item.Image))
169+
.Select(item =>
170+
{
171+
var fromArgs = ParseFromArgs(item.Arg).ToDictionary(arg => arg.Name, arg => arg.Value);
172+
_ = fromArgs.TryGetValue("platform", out var platform);
173+
return new DockerImage(item.Image, platform);
174+
})
170175
.ToArray();
171176

172177
return images;
@@ -213,11 +218,11 @@ await AddAsync(absoluteFilePath, relativeFilePath, tarOutputStream)
213218
.ConfigureAwait(false);
214219
}
215220

216-
var dockerfileDirectoryLength = _dockerfileDirectory.FullName
221+
var dockerfileDirectoryLength = _dockerfileDirectory.FullName
217222
.TrimEnd(Path.DirectorySeparatorChar).Length + 1;
218223

219224
var dockerfileRelativeFilePath = _dockerfile.FullName
220-
.Substring(dockerfileDirectoryLength );
225+
.Substring(dockerfileDirectoryLength);
221226

222227
var dockerfileNormalizedRelativeFilePath = Unix.Instance.NormalizePath(dockerfileRelativeFilePath);
223228

@@ -306,23 +311,124 @@ private static int GetUnixFileMode(string filePath)
306311
/// corresponding build argument if present; otherwise, the default value in the
307312
/// Dockerfile is preserved.
308313
/// </summary>
309-
/// <param name="image">The image string from a Dockerfile <c>FROM</c> statement.</param>
314+
/// <param name="line">The line from a Dockerfile <c>FROM</c> statement.</param>
310315
/// <param name="variables">A dictionary containing variable names as keys and their replacement values as values.</param>
311316
/// <returns>A new image string where placeholders are replaced with their corresponding values.</returns>
312-
private static string ReplaceVariables(string image, IDictionary<string, string> variables)
317+
private static string ReplaceVariables(string line, IDictionary<string, string> variables)
313318
{
314319
const string nameGroup = "name";
315320

316321
if (variables.Count == 0)
317322
{
318-
return image;
323+
return line;
319324
}
320325

321-
return VariablePattern.Replace(image, match =>
326+
return VariablePattern.Replace(line, match =>
322327
{
323328
var name = match.Groups[nameGroup].Value;
324329
return variables.TryGetValue(name, out var value) ? value : match.Value;
325330
});
326331
}
332+
333+
/// <summary>
334+
/// Parses a FROM statement arg string into flag and value pairs.
335+
/// </summary>
336+
/// <remarks>
337+
/// This method parses a string containing FROM statement style flags,
338+
/// respecting quoted values. Both double quotes (<c>"</c>) and single
339+
/// quotes (<c>'</c>) are supported. Whitespaces outside of quotes are
340+
/// treated as separators.
341+
///
342+
/// For example, the line:
343+
/// <code>
344+
/// --pull=always --platform="linux/amd64"
345+
/// </code>
346+
/// becomes:
347+
/// <list type="bullet">
348+
/// <item>
349+
/// <description>
350+
/// (<c>pull</c>, <c>always</c>)
351+
/// </description>
352+
/// </item>
353+
/// <item>
354+
/// <description>
355+
/// (<c>platform</c>, <c>linux/amd64</c>)
356+
/// </description>
357+
/// </item>
358+
/// </list>
359+
/// </remarks>
360+
/// <param name="line">
361+
/// The FROM statement arg string containing flags and optional values.
362+
/// </param>
363+
/// <returns>
364+
/// A sequence of (<c>Name</c>, <c>Value</c>) tuples.
365+
/// </returns>
366+
/// <exception cref="FormatException">
367+
/// Thrown if a quoted value is missing a closing quote.
368+
/// </exception>
369+
private static IEnumerable<(string Name, string Value)> ParseFromArgs(string line)
370+
{
371+
if (string.IsNullOrEmpty(line))
372+
{
373+
yield break;
374+
}
375+
376+
char? quote = null;
377+
378+
var start = 0;
379+
380+
for (var i = 0; i < line.Length; i++)
381+
{
382+
var c = line[i];
383+
384+
if ((c == '"' || c == '\'') && (quote == null || quote == c))
385+
{
386+
quote = quote == null ? c : null;
387+
}
388+
389+
if (quote != null || !char.IsWhiteSpace(c))
390+
{
391+
continue;
392+
}
393+
394+
if (i > start)
395+
{
396+
yield return ParseFlag(line.Substring(start, i - start));
397+
}
398+
399+
start = i + 1;
400+
}
401+
402+
if (quote != null)
403+
{
404+
throw new FormatException($"Unmatched {quote} quote starting at position {start - 1} in line: '{line}'.");
405+
}
406+
407+
if (line.Length > start)
408+
{
409+
yield return ParseFlag(line.Substring(start));
410+
}
411+
}
412+
413+
/// <summary>
414+
/// Splits a single flag token into a flag name and an optional value.
415+
/// </summary>
416+
/// <param name="flag">A single flag token, optionally containing an equals sign and value.</param>
417+
/// <returns>A tuple containing the flag name and its value, or <c>null</c> if no value is specified.</returns>
418+
private static (string Name, string Value) ParseFlag(string flag)
419+
{
420+
var trimmed = flag.TrimStart('-');
421+
var eqIndex = trimmed.IndexOf('=');
422+
if (eqIndex == -1)
423+
{
424+
return (trimmed, null);
425+
}
426+
else
427+
{
428+
var name = trimmed.Substring(0, eqIndex);
429+
var value = trimmed.Substring(eqIndex + 1).Trim(' ', '"', '\'');
430+
return (name, value);
431+
}
432+
}
327433
}
328434
}

src/Testcontainers/Images/FutureDockerImage.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,16 @@ public string Digest
6868
}
6969
}
7070

71+
/// <inheritdoc />
72+
public string Platform
73+
{
74+
get
75+
{
76+
ThrowIfResourceNotFound();
77+
return _configuration.Image.Platform;
78+
}
79+
}
80+
7181
/// <inheritdoc />
7282
public string FullName
7383
{

src/Testcontainers/Images/IImage.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,17 @@ public interface IImage
3333
[CanBeNull]
3434
string Digest { get; }
3535

36+
/// <summary>
37+
/// Gets the platform.
38+
/// </summary>
39+
/// <remarks>
40+
/// The supported format is <c>&lt;os&gt;|&lt;arch&gt;|&lt;os&gt;/&lt;arch&gt;[/&lt;variant&gt;]</c>.
41+
/// You can provide the operating system, the architecture, or both.
42+
/// For more details and examples, see <see href="https://github.com/containerd/platforms">containerd/platforms</see>.
43+
/// </remarks>
44+
[CanBeNull]
45+
string Platform { get; }
46+
3647
/// <summary>
3748
/// Gets the full image name.
3849
/// </summary>

src/Testcontainers/Images/IImageExtensions.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ public static IImage ApplyHubImageNamePrefix(this IImage image)
2727
return image;
2828
}
2929

30-
return new DockerImage(image.Repository, image.Registry, image.Tag, image.Digest, TestcontainersSettings.HubImageNamePrefix);
30+
return new DockerImage(image.Repository, image.Registry, image.Tag, image.Digest, image.Platform, TestcontainersSettings.HubImageNamePrefix);
3131
}
3232
}
3333
}

tests/Testcontainers.Tests/Assets/pullBaseImages/Dockerfile

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
ARG REPO=mcr.microsoft.com/dotnet/aspnet
2+
ARG PLATFORM=linux/arm64
3+
24
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
35
FROM mcr.microsoft.com/dotnet/runtime:8.0 AS runtime
46
FROM build
@@ -8,7 +10,10 @@ FROM ${REPO}:8.0-noble
810
FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine
911

1012
# https://github.com/testcontainers/testcontainers-dotnet/issues/993.
11-
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/aspnet:8.0-azurelinux3.0
13+
FROM --platform=linux/amd64 mcr.microsoft.com/dotnet/aspnet:8.0-azurelinux3.0
14+
FROM --platform=$PLATFORM mcr.microsoft.com/dotnet/aspnet:8.0-azurelinux3.0
15+
FROM --platform="linux/arm/v6" mcr.microsoft.com/dotnet/aspnet:8.0-azurelinux3.0
16+
FROM --platform='linux/arm/v7' mcr.microsoft.com/dotnet/aspnet:8.0-azurelinux3.0
1217

1318
# https://github.com/testcontainers/testcontainers-dotnet/issues/1030.
1419
FROM mcr.microsoft.com/dotnet/sdk:$SDK_VERSION_8_0 AS build_sdk_8_0

tests/Testcontainers.Tests/Fixtures/Containers/Unix/DockerMTls.cs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ namespace DotNet.Testcontainers.Tests.Fixtures
22
{
33
using System.Collections.Generic;
44
using DotNet.Testcontainers.Builders;
5-
using DotNet.Testcontainers.Images;
65

76
public abstract class DockerMTls : ProtectDockerDaemonSocket
87
{

tests/Testcontainers.Tests/Fixtures/Containers/Unix/DockerTlsFixture.cs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ namespace DotNet.Testcontainers.Tests.Fixtures
22
{
33
using System.Collections.Generic;
44
using DotNet.Testcontainers.Builders;
5-
using DotNet.Testcontainers.Images;
65
using JetBrains.Annotations;
76

87
[UsedImplicitly]

tests/Testcontainers.Tests/Fixtures/Images/DockerImageFixture.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,8 @@ public DockerImageFixture()
3737
Add(new DockerImageFixtureSerializable(new DockerImage(FedoraHttpd, PortSeparatorRegistry, CustomTag1, null)), $"{PortSeparatorRegistry}/{FedoraHttpd}:{CustomTag1}", $"{PortSeparatorRegistry}/{FedoraHttpd}:{CustomTag1}");
3838
Add(new DockerImageFixtureSerializable(new DockerImage(FooBarBaz, DotSeparatorRegistry, SemVerTag, Digest)), $"{DotSeparatorRegistry}/{FooBarBaz}:{SemVerTag}@{Digest}", $"{DotSeparatorRegistry}/{FooBarBaz}:{SemVerTag}@{Digest}");
3939
Add(new DockerImageFixtureSerializable(new DockerImage(FooBarBaz, DotSeparatorRegistry, null, Digest)), $"{DotSeparatorRegistry}/{FooBarBaz}@{Digest}", $"{DotSeparatorRegistry}/{FooBarBaz}@{Digest}");
40-
Add(new DockerImageFixtureSerializable(new DockerImage(BarBaz, null, null, null, HubImageNamePrefixImplicitLibrary)), $"{HubImageNamePrefixImplicitLibrary}/{BarBaz}", $"{HubImageNamePrefixImplicitLibrary}/{BarBaz}:{LatestTag}");
41-
Add(new DockerImageFixtureSerializable(new DockerImage(BarBaz, null, null, null, HubImageNamePrefixExplicitLibrary)), $"{HubImageNamePrefixExplicitLibrary}/{BarBaz}", $"{HubImageNamePrefixExplicitLibrary}/{BarBaz}:{LatestTag}");
40+
Add(new DockerImageFixtureSerializable(new DockerImage(BarBaz, null, null, null, null, HubImageNamePrefixImplicitLibrary)), $"{HubImageNamePrefixImplicitLibrary}/{BarBaz}", $"{HubImageNamePrefixImplicitLibrary}/{BarBaz}:{LatestTag}");
41+
Add(new DockerImageFixtureSerializable(new DockerImage(BarBaz, null, null, null, null, HubImageNamePrefixExplicitLibrary)), $"{HubImageNamePrefixExplicitLibrary}/{BarBaz}", $"{HubImageNamePrefixExplicitLibrary}/{BarBaz}:{LatestTag}");
4242
}
4343
}
4444
}

0 commit comments

Comments
 (0)