Skip to content

Commit b933ae3

Browse files
authored
feat: Resolve Dockerfile ARGs pulling base images (#1532)
1 parent c27a94b commit b933ae3

File tree

4 files changed

+110
-26
lines changed

4 files changed

+110
-26
lines changed

src/Testcontainers/Clients/TestcontainersClient.cs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -371,7 +371,12 @@ public async Task<string> BuildAsync(IImageFromDockerfileConfiguration configura
371371

372372
if (configuration.ImageBuildPolicy(cachedImage))
373373
{
374-
var dockerfileArchive = new DockerfileArchive(configuration.DockerfileDirectory, configuration.Dockerfile, configuration.Image, _logger);
374+
var dockerfileArchive = new DockerfileArchive(
375+
configuration.DockerfileDirectory,
376+
configuration.Dockerfile,
377+
configuration.Image,
378+
configuration.BuildArguments,
379+
_logger);
375380

376381
var baseImages = dockerfileArchive.GetBaseImages().ToArray();
377382

src/Testcontainers/Images/DockerfileArchive.cs

Lines changed: 74 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,20 @@ namespace DotNet.Testcontainers.Images
1717
/// </summary>
1818
internal sealed class DockerfileArchive : ITarArchive
1919
{
20-
private static readonly Regex FromLinePattern = new Regex("FROM (?<arg>--\\S+\\s)*(?<image>\\S+).*", RegexOptions.None, TimeSpan.FromSeconds(1));
20+
private static readonly Regex ArgLinePattern = new Regex("^ARG\\s+(?<name>[A-Za-z_][A-Za-z0-9_]*)=(?:\"(?<value>[^\"]*)\"|'(?<value>[^']*)'|(?<value>\\S+))", RegexOptions.IgnoreCase, TimeSpan.FromSeconds(1));
21+
22+
private static readonly Regex FromLinePattern = new Regex("^FROM\\s+(?<arg>--\\S+\\s)*(?<image>\\S+).*", RegexOptions.IgnoreCase, TimeSpan.FromSeconds(1));
23+
24+
private static readonly Regex VariablePattern = new Regex("\\$(\\{(?<name>[A-Za-z_][A-Za-z0-9_]*)\\}|(?<name>[A-Za-z_][A-Za-z0-9_]*))", RegexOptions.None, TimeSpan.FromSeconds(1));
2125

2226
private readonly DirectoryInfo _dockerfileDirectory;
2327

2428
private readonly FileInfo _dockerfile;
2529

2630
private readonly IImage _image;
2731

32+
private readonly IReadOnlyDictionary<string, string> _buildArguments;
33+
2834
private readonly ILogger _logger;
2935

3036
/// <summary>
@@ -33,10 +39,21 @@ internal sealed class DockerfileArchive : ITarArchive
3339
/// <param name="dockerfileDirectory">Directory to Docker configuration files.</param>
3440
/// <param name="dockerfile">Name of the Dockerfile, which is necessary to start the Docker build.</param>
3541
/// <param name="image">Docker image information to create the tar archive for.</param>
42+
/// <param name="buildArguments">Docker build arguments.</param>
3643
/// <param name="logger">The logger.</param>
3744
/// <exception cref="ArgumentException">Thrown when the Dockerfile directory does not exist or the directory does not contain a Dockerfile.</exception>
38-
public DockerfileArchive(string dockerfileDirectory, string dockerfile, IImage image, ILogger logger)
39-
: this(new DirectoryInfo(dockerfileDirectory), new FileInfo(dockerfile), image, logger)
45+
public DockerfileArchive(
46+
string dockerfileDirectory,
47+
string dockerfile,
48+
IImage image,
49+
IReadOnlyDictionary<string, string> buildArguments,
50+
ILogger logger)
51+
: this(
52+
new DirectoryInfo(dockerfileDirectory),
53+
new FileInfo(dockerfile),
54+
image,
55+
buildArguments,
56+
logger)
4057
{
4158
}
4259

@@ -46,9 +63,15 @@ public DockerfileArchive(string dockerfileDirectory, string dockerfile, IImage i
4663
/// <param name="dockerfileDirectory">Directory to Docker configuration files.</param>
4764
/// <param name="dockerfile">Name of the Dockerfile, which is necessary to start the Docker build.</param>
4865
/// <param name="image">Docker image information to create the tar archive for.</param>
66+
/// <param name="buildArguments">Docker build arguments.</param>
4967
/// <param name="logger">The logger.</param>
5068
/// <exception cref="ArgumentException">Thrown when the Dockerfile directory does not exist or the directory does not contain a Dockerfile.</exception>
51-
public DockerfileArchive(DirectoryInfo dockerfileDirectory, FileInfo dockerfile, IImage image, ILogger logger)
69+
public DockerfileArchive(
70+
DirectoryInfo dockerfileDirectory,
71+
FileInfo dockerfile,
72+
IImage image,
73+
IReadOnlyDictionary<string, string> buildArguments,
74+
ILogger logger)
5275
{
5376
if (!dockerfileDirectory.Exists)
5477
{
@@ -63,6 +86,7 @@ public DockerfileArchive(DirectoryInfo dockerfileDirectory, FileInfo dockerfile,
6386
_dockerfileDirectory = dockerfileDirectory;
6487
_dockerfile = dockerfile;
6588
_image = image;
89+
_buildArguments = buildArguments;
6690
_logger = logger;
6791
}
6892

@@ -83,28 +107,44 @@ public IEnumerable<IImage> GetBaseImages()
83107
{
84108
const string imageGroup = "image";
85109

110+
const string nameGroup = "name";
111+
112+
const string valueGroup = "value";
113+
86114
var lines = File.ReadAllLines(Path.Combine(_dockerfileDirectory.FullName, _dockerfile.ToString()))
87115
.Select(line => line.Trim())
88116
.Where(line => !string.IsNullOrEmpty(line))
89117
.Where(line => !line.StartsWith("#", StringComparison.Ordinal))
118+
.ToArray();
119+
120+
var argMatches = lines
121+
.Select(line => ArgLinePattern.Match(line))
122+
.Where(match => match.Success)
123+
.ToArray();
124+
125+
var fromMatches = lines
90126
.Select(line => FromLinePattern.Match(line))
91127
.Where(match => match.Success)
92128
.ToArray();
93129

94-
var stages = lines
130+
var args = argMatches
131+
.Select(match => new KeyValuePair<string, string>(match.Groups[nameGroup].Value, match.Groups[valueGroup].Value))
132+
.Concat(_buildArguments)
133+
.GroupBy(kvp => kvp.Key)
134+
.ToDictionary(group => group.Key, group => group.Last().Value);
135+
136+
var stages = fromMatches
95137
.Select(line => line.Value)
96138
.Select(line => line.Split(new[] { " AS ", " As ", " aS ", " as " }, StringSplitOptions.RemoveEmptyEntries))
97139
.Where(substrings => substrings.Length > 1)
98140
.Select(substrings => substrings[substrings.Length - 1])
99141
.Distinct()
100142
.ToArray();
101143

102-
var images = lines
144+
var images = fromMatches
103145
.Select(match => match.Groups[imageGroup])
104146
.Select(match => match.Value)
105-
// Until now, we are unable to resolve variables within Dockerfiles. Ignore base
106-
// images that utilize variables. Expect them to exist on the host.
107-
.Where(line => !line.Contains('$'))
147+
.Select(line => ReplaceVariables(line, args))
108148
.Where(line => !line.Any(char.IsUpper))
109149
.Where(value => !stages.Contains(value))
110150
.Distinct()
@@ -205,5 +245,30 @@ private static int GetUnixFileMode(string filePath)
205245
_ = filePath;
206246
return (int)Unix.FileMode755;
207247
}
248+
249+
/// <summary>
250+
/// Replaces placeholders in the Dockerfile <c>FROM</c> image string with the values
251+
/// provided in <paramref name="variables" />. Each placeholder is replaced with the
252+
/// corresponding build argument if present; otherwise, the default value in the
253+
/// Dockerfile is preserved.
254+
/// </summary>
255+
/// <param name="image">The image string from a Dockerfile <c>FROM</c> statement.</param>
256+
/// <param name="variables">A dictionary containing variable names as keys and their replacement values as values.</param>
257+
/// <returns>A new image string where placeholders are replaced with their corresponding values.</returns>
258+
private static string ReplaceVariables(string image, IDictionary<string, string> variables)
259+
{
260+
const string nameGroup = "name";
261+
262+
if (variables.Count == 0)
263+
{
264+
return image;
265+
}
266+
267+
return VariablePattern.Replace(image, match =>
268+
{
269+
var name = match.Groups[nameGroup].Value;
270+
return variables.TryGetValue(name, out var value) ? value : match.Value;
271+
});
272+
}
208273
}
209274
}
Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
ARG REPO=mcr.microsoft.com/dotnet/aspnet
2-
FROM $REPO:6.0.21-jammy-amd64
3-
FROM ${REPO}:6.0.21-jammy-amd64
4-
FROM mcr.microsoft.com/dotnet/sdk:6.0 AS build
5-
FROM mcr.microsoft.com/dotnet/runtime:6.0 AS runtime
2+
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
3+
FROM mcr.microsoft.com/dotnet/runtime:8.0 AS runtime
64
FROM build
75
FROM build AS publish
8-
FROM mcr.microsoft.com/dotnet/aspnet:6.0.22-jammy-amd64
6+
FROM $REPO:8.0-jammy
7+
FROM ${REPO}:8.0-noble
8+
FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine
99

1010
# https://github.com/testcontainers/testcontainers-dotnet/issues/993.
11-
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/aspnet:6.0.23-jammy-amd64
11+
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/aspnet:8.0-azurelinux3.0
1212

1313
# https://github.com/testcontainers/testcontainers-dotnet/issues/1030.
14-
FROM mcr.microsoft.com/dotnet/sdk:$SDK_VERSION_6_0 AS build_sdk_6_0
15-
FROM build_sdk_6_0 AS publish_sdk_6_0
14+
FROM mcr.microsoft.com/dotnet/sdk:$SDK_VERSION_8_0 AS build_sdk_8_0
15+
FROM build_sdk_8_0 AS publish_sdk_8_0

tests/Testcontainers.Tests/Unit/Images/ImageFromDockerfileTest.cs

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ namespace DotNet.Testcontainers.Tests.Unit
22
{
33
using System;
44
using System.Collections.Generic;
5+
using System.Collections.ObjectModel;
56
using System.IO;
67
using System.Linq;
78
using System.Text;
@@ -19,19 +20,30 @@ public sealed class ImageFromDockerfileTest
1920
public void DockerfileArchiveGetBaseImages()
2021
{
2122
// Given
23+
var expected = new[]
24+
{
25+
"mcr.microsoft.com/dotnet/sdk:8.0",
26+
"mcr.microsoft.com/dotnet/runtime:8.0",
27+
"mcr.microsoft.com/dotnet/aspnet:8.0-jammy",
28+
"mcr.microsoft.com/dotnet/aspnet:8.0-noble",
29+
"mcr.microsoft.com/dotnet/aspnet:8.0-alpine",
30+
"mcr.microsoft.com/dotnet/aspnet:8.0-azurelinux3.0",
31+
"mcr.microsoft.com/dotnet/sdk:8.0.414",
32+
};
33+
2234
IImage image = new DockerImage("localhost/testcontainers", Guid.NewGuid().ToString("D"), string.Empty);
2335

24-
var dockerfileArchive = new DockerfileArchive("Assets/pullBaseImages/", "Dockerfile", image, NullLogger.Instance);
36+
// The Dockerfile does not contain a default value.
37+
var buildArguments = new Dictionary<string, string>();
38+
buildArguments.Add("SDK_VERSION_8_0", "8.0.414");
39+
40+
var dockerfileArchive = new DockerfileArchive("Assets/pullBaseImages/", "Dockerfile", image, buildArguments, NullLogger.Instance);
2541

2642
// When
27-
var baseImages = dockerfileArchive.GetBaseImages().ToArray();
43+
var actual = dockerfileArchive.GetBaseImages();
2844

2945
// Then
30-
Assert.Equal(4, baseImages.Length);
31-
Assert.Contains(baseImages, item => "mcr.microsoft.com/dotnet/sdk:6.0".Equals(item.FullName));
32-
Assert.Contains(baseImages, item => "mcr.microsoft.com/dotnet/runtime:6.0".Equals(item.FullName));
33-
Assert.Contains(baseImages, item => "mcr.microsoft.com/dotnet/aspnet:6.0.22-jammy-amd64".Equals(item.FullName));
34-
Assert.Contains(baseImages, item => "mcr.microsoft.com/dotnet/aspnet:6.0.23-jammy-amd64".Equals(item.FullName));
46+
Assert.Equal(expected, actual.Select(baseImage => baseImage.FullName));
3547
}
3648

3749
[Fact]
@@ -44,7 +56,9 @@ public async Task DockerfileArchiveTar()
4456

4557
var actual = new SortedSet<string>();
4658

47-
var dockerfileArchive = new DockerfileArchive("Assets/", "Dockerfile", image, NullLogger.Instance);
59+
var buildArguments = new ReadOnlyDictionary<string, string>(new Dictionary<string, string>());
60+
61+
var dockerfileArchive = new DockerfileArchive("Assets/", "Dockerfile", image, buildArguments, NullLogger.Instance);
4862

4963
var dockerfileArchiveFilePath = await dockerfileArchive.Tar(TestContext.Current.CancellationToken)
5064
.ConfigureAwait(true);

0 commit comments

Comments
 (0)