Skip to content

Commit 465012e

Browse files
authored
feat: Add Platform property to IImage interface (#1610)
1 parent cd2f52a commit 465012e

18 files changed

Lines changed: 285 additions & 33 deletions

docs/api/create_docker_container.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,30 @@
22

33
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.
44

5+
## Configure container image
6+
7+
To specify the container image, use `WithImage(...)`.
8+
9+
The simplest overload accepts a `string`:
10+
11+
```csharp
12+
_ = new ContainerBuilder()
13+
.WithImage("postgres:15.1");
14+
```
15+
16+
For more advanced scenarios, `WithImage` also supports `IImage`, giving you more control over how the image is represented and its properties are resolved.
17+
18+
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.
19+
20+
```csharp
21+
_ = new ContainerBuilder()
22+
.WithImage(new DockerImage("postgres:15.1", new Platform("linux/amd64")));
23+
```
24+
25+
!!!tip
26+
27+
A specifier has the format `<os>|<arch>|<os>/<arch>[/<variant>]`. The user can provide either the operating system or the architecture or both. For more details, see [containerd/platforms](https://github.com/containerd/platforms).
28+
529
## Configure container start
630

731
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`.

src/Testcontainers/Clients/DockerContainerOperations.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,7 @@ public async Task<string> RunAsync(IContainerConfiguration configuration, Cancel
202202
var createParameters = new CreateContainerParameters
203203
{
204204
Image = configuration.Image.FullName,
205+
Platform = configuration.Image.Platform,
205206
Name = configuration.Name,
206207
Hostname = configuration.Hostname,
207208
WorkingDir = configuration.WorkingDirectory,

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: 26 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,43 @@ public DockerImage(string image)
5053
{
5154
}
5255

56+
/// <summary>
57+
/// Initializes a new instance of the <see cref="DockerImage" /> class.
58+
/// </summary>
59+
/// <param name="image">The image.</param>
60+
/// <param name="platform">The platform.</param>
61+
/// <example><c>fedora/httpd:version1.0</c> where <c>fedora/httpd</c> is the repository and <c>version1.0</c> the tag.</example>
62+
public DockerImage(
63+
string image,
64+
Platform platform)
65+
: this(GetDockerImage(image))
66+
{
67+
_platform = platform.Value;
68+
}
69+
5370
/// <summary>
5471
/// Initializes a new instance of the <see cref="DockerImage" /> class.
5572
/// </summary>
5673
/// <param name="repository">The repository.</param>
5774
/// <param name="registry">The registry.</param>
5875
/// <param name="tag">The tag.</param>
5976
/// <param name="digest">The digest.</param>
77+
/// <param name="platform">The platform.</param>
6078
/// <param name="hubImageNamePrefix">The Docker Hub image name prefix.</param>
6179
/// <example><c>fedora/httpd:version1.0</c> where <c>fedora/httpd</c> is the repository and <c>version1.0</c> the tag.</example>
6280
public DockerImage(
6381
string repository,
6482
string registry = null,
6583
string tag = null,
6684
string digest = null,
85+
string platform = null,
6786
string hubImageNamePrefix = null)
6887
: this(
6988
TrimOrDefault(repository),
7089
TrimOrDefault(registry),
7190
TrimOrDefault(tag, tag == null && digest == null ? LatestTag : null),
7291
TrimOrDefault(digest),
92+
TrimOrDefault(platform),
7393
hubImageNamePrefix == null ? [] : hubImageNamePrefix.Trim(TrimChars).Split(SlashChar, 2, StringSplitOptions.RemoveEmptyEntries))
7494
{
7595
}
@@ -79,6 +99,7 @@ private DockerImage(
7999
string registry,
80100
string tag,
81101
string digest,
102+
string platform,
82103
string[] substitutions)
83104
{
84105
_ = Guard.Argument(repository, nameof(repository))
@@ -109,6 +130,7 @@ private DockerImage(
109130

110131
_tag = tag;
111132
_digest = digest;
133+
_platform = platform;
112134
}
113135

114136
/// <inheritdoc />
@@ -123,6 +145,9 @@ private DockerImage(
123145
/// <inheritdoc />
124146
public string Digest => _digest;
125147

148+
/// <inheritdoc />
149+
public string Platform => _platform;
150+
126151
/// <inheritdoc />
127152
public string FullName
128153
{

src/Testcontainers/Images/DockerfileArchive.cs

Lines changed: 118 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 => (FromArgs: match.Groups[argGroup], Image: match.Groups[imageGroup]))
166+
.Select(item => (FromArgs: ReplaceVariables(item.FromArgs.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.FromArgs).ToDictionary(arg => arg.Name, arg => arg.Value);
172+
_ = fromArgs.TryGetValue("platform", out var platform);
173+
return new DockerImage(item.Image, new Platform(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,121 @@ 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+
/// E.g., the line <c>--pull=always --platform="linux/amd64"</c> becomes:
343+
///
344+
/// <list type="bullet">
345+
/// <item>
346+
/// <description>
347+
/// (<c>pull</c>, <c>always</c>)
348+
/// </description>
349+
/// </item>
350+
/// <item>
351+
/// <description>
352+
/// (<c>platform</c>, <c>linux/amd64</c>)
353+
/// </description>
354+
/// </item>
355+
/// </list>
356+
/// </remarks>
357+
/// <param name="line">
358+
/// The FROM statement arg string containing flags and optional values.
359+
/// </param>
360+
/// <returns>
361+
/// A sequence of (<c>Name</c>, <c>Value</c>) tuples.
362+
/// </returns>
363+
/// <exception cref="FormatException">
364+
/// Thrown if a quoted value is missing a closing quote.
365+
/// </exception>
366+
private static IEnumerable<(string Name, string Value)> ParseFromArgs(string line)
367+
{
368+
if (string.IsNullOrEmpty(line))
369+
{
370+
yield break;
371+
}
372+
373+
char? quote = null;
374+
375+
var start = 0;
376+
377+
for (var i = 0; i < line.Length; i++)
378+
{
379+
var c = line[i];
380+
381+
if ((c == '"' || c == '\'') && (quote == null || quote == c))
382+
{
383+
quote = quote == null ? c : null;
384+
}
385+
386+
if (quote != null || !char.IsWhiteSpace(c))
387+
{
388+
continue;
389+
}
390+
391+
if (i > start)
392+
{
393+
yield return ParseArg(line.Substring(start, i - start));
394+
}
395+
396+
start = i + 1;
397+
}
398+
399+
if (quote != null)
400+
{
401+
throw new FormatException($"Unmatched {quote} quote in line: '{line}'.");
402+
}
403+
404+
if (line.Length > start)
405+
{
406+
yield return ParseArg(line.Substring(start));
407+
}
408+
}
409+
410+
/// <summary>
411+
/// Splits a single arg into flag name and an optional value.
412+
/// </summary>
413+
/// <param name="arg">A single arg, optionally containing an equals sign and value.</param>
414+
/// <returns>A tuple containing the flag name and its value, or <c>null</c> if no value is specified.</returns>
415+
private static (string Name, string Value) ParseArg(string arg)
416+
{
417+
var trimmed = arg.TrimStart('-');
418+
var eqIndex = trimmed.IndexOf('=');
419+
if (eqIndex == -1)
420+
{
421+
return (trimmed, null);
422+
}
423+
else
424+
{
425+
var name = trimmed.Substring(0, eqIndex);
426+
var value = trimmed.Substring(eqIndex + 1).Trim().Trim('"', '\'');
427+
return (name, value);
428+
}
429+
}
327430
}
328431
}

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: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,16 @@ public interface IImage
3333
[CanBeNull]
3434
string Digest { get; }
3535

36+
/// <summary>
37+
/// Gets the platform.
38+
/// </summary>
39+
/// <remarks>
40+
/// The supported format for a platform value is:
41+
/// <c>&lt;os&gt;|&lt;arch&gt;|&lt;os&gt;/&lt;arch&gt;[/&lt;variant&gt;]</c>.
42+
/// </remarks>
43+
[CanBeNull]
44+
string Platform { get; }
45+
3646
/// <summary>
3747
/// Gets the full image name.
3848
/// </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
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
namespace DotNet.Testcontainers.Images
2+
{
3+
using JetBrains.Annotations;
4+
5+
/// <summary>
6+
/// Represents a container platform identifier.
7+
/// </summary>
8+
/// <remarks>
9+
/// The supported format for a platform value is:
10+
/// <c>&lt;os&gt;|&lt;arch&gt;|&lt;os&gt;/&lt;arch&gt;[/&lt;variant&gt;]</c>.
11+
///
12+
/// You can provide either the operating system or the architecture or both.
13+
/// For more details, see <see href="https://github.com/containerd/platforms">containerd/platforms</see>.
14+
/// </remarks>
15+
[PublicAPI]
16+
public readonly struct Platform
17+
{
18+
/// <summary>
19+
/// Initializes a new instance of the <see cref="Platform" /> struct.
20+
/// </summary>
21+
/// <param name="value">The platform identifier.</param>
22+
[PublicAPI]
23+
public Platform(string value)
24+
{
25+
Value = value;
26+
}
27+
28+
/// <summary>
29+
/// Gets the platform identifier.
30+
/// </summary>
31+
/// <remarks>
32+
/// A string representing the container platform in <c>containerd/platforms</c> format, or
33+
/// <c>null</c> if no platform was specified.
34+
/// </remarks>
35+
[PublicAPI]
36+
[CanBeNull]
37+
public string Value { get; }
38+
}
39+
}

0 commit comments

Comments
 (0)