Skip to content

Commit d6cf08d

Browse files
authored
feat: Add image name substitution hook (#1710)
1 parent 8394aab commit d6cf08d

6 files changed

Lines changed: 158 additions & 17 deletions

File tree

docs/custom_configuration/index.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,28 @@ Setting the context to `tcc` in this example will use the Docker host running at
6161
docker.context=tcc
6262
```
6363

64+
## Substitute image names
65+
66+
Testcontainers supports substituting an image name with an alternative on the fly, for example to pull from a private registry mirror instead of Docker Hub. This is useful if you have complex rules that a simple prefix cannot express, such as a non-deterministic name mapping, rules depending on the developer or environment, or auditing and restricting the images used in a build.
67+
68+
Set `TestcontainersSettings.ImageNameSubstitution` to a function that receives the original `IImage` and returns the image to use instead. Return the original image (or `null`) to leave it unchanged.
69+
70+
Configure the substitution once before any test resources are created. A static class with a [`[ModuleInitializer]`](https://learn.microsoft.com/en-us/dotnet/api/system.runtime.compilerservices.moduleinitializerattribute) method runs automatically before the first type in the test assembly is used:
71+
72+
```csharp
73+
internal static class TestcontainersConfiguration
74+
{
75+
[ModuleInitializer]
76+
public static void Initialize()
77+
{
78+
TestcontainersSettings.ImageNameSubstitution = image
79+
=> new DockerImage(image.Repository, "registry.mycompany.com", image.Tag, image.Digest, image.Platform);
80+
}
81+
}
82+
```
83+
84+
The substitution runs first, then the Docker Hub image name prefix (`TESTCONTAINERS_HUB_IMAGE_NAME_PREFIX`) is applied to the substituted image. As with any image, the prefix is only added when the image does not already specify a registry, so a substitution that sets a registry (such as the examples above) takes precedence over the prefix.
85+
6486
## Automatically modify Docker Hub image names
6587

6688
Testcontainers can automatically add a registry prefix to Docker Hub image names used in your tests. This is handy if you use a private registry that mirrors Docker Hub images with predictable naming.

src/Testcontainers/Builders/ContainerBuilder`3.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ public TBuilderEntity WithImage(string image)
9292
/// <inheritdoc />
9393
public TBuilderEntity WithImage(IImage image)
9494
{
95-
return Clone(new ContainerConfiguration(image: image.ApplyHubImageNamePrefix()));
95+
return Clone(new ContainerConfiguration(image: image.ApplyImageNameSubstitution()));
9696
}
9797

9898
/// <inheritdoc />

src/Testcontainers/Builders/ImageFromDockerfileBuilder.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ public ImageFromDockerfileBuilder WithName(string name)
6060
/// <inheritdoc />
6161
public ImageFromDockerfileBuilder WithName(IImage image)
6262
{
63-
return Merge(DockerResourceConfiguration, new ImageFromDockerfileConfiguration(image: image.ApplyHubImageNamePrefix()));
63+
return Merge(DockerResourceConfiguration, new ImageFromDockerfileConfiguration(image: image.ApplyImageNameSubstitution()));
6464
}
6565

6666
/// <inheritdoc />

src/Testcontainers/Configurations/TestcontainersSettings.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,20 @@ static TestcontainersSettings()
137137
public static TimeSpan? WaitStrategyTimeout { get; set; }
138138
= EnvironmentConfiguration.Instance.GetWaitStrategyTimeout() ?? PropertiesFileConfiguration.Instance.GetWaitStrategyTimeout();
139139

140+
/// <summary>
141+
/// Gets or sets a function that substitutes the image name before an image is pulled.
142+
/// </summary>
143+
/// <remarks>
144+
/// This allows replacing an image name with an alternative on the fly, for example to pull
145+
/// from a private registry mirror instead of Docker Hub. The substitution runs first; the
146+
/// Docker Hub image name prefix (see <see cref="HubImageNamePrefix" />) is then applied to
147+
/// the substituted image, but only if that image does not already specify a registry.
148+
/// A substitution that sets a registry therefore takes precedence over the prefix.
149+
/// Return the original image (or <see langword="null" />) to leave it unchanged.
150+
/// </remarks>
151+
[CanBeNull]
152+
public static Func<IImage, IImage> ImageNameSubstitution { get; set; }
153+
140154
/// <summary>
141155
/// Gets or sets the host operating system.
142156
/// </summary>

src/Testcontainers/Images/IImageExtensions.cs

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,27 @@ namespace DotNet.Testcontainers.Images
88
internal static class IImageExtensions
99
{
1010
/// <summary>
11-
/// Applies the Docker Hub image name prefix if it is configured.
11+
/// Applies the configured image name substitution and then the Docker Hub image name prefix.
1212
/// </summary>
1313
/// <param name="image">The original <see cref="IImage" /> instance.</param>
1414
/// <returns>
15-
/// A new <see cref="IImage" /> instance with the Docker Hub image name prefix
16-
/// applied, or the original instance if no prefix is set.
15+
/// A new <see cref="IImage" /> instance with the configured substitution and Docker Hub image
16+
/// name prefix applied, or the original instance if neither is configured.
17+
/// </returns>
18+
public static IImage ApplyImageNameSubstitution(this IImage image)
19+
{
20+
var substitution = TestcontainersSettings.ImageNameSubstitution;
21+
var substitute = substitution == null ? image : substitution(image) ?? image;
22+
return substitute.ApplyHubImageNamePrefix();
23+
}
24+
25+
/// <summary>
26+
/// Applies the configured Docker Hub image name prefix.
27+
/// </summary>
28+
/// <param name="image">The original <see cref="IImage" /> instance.</param>
29+
/// <returns>
30+
/// A new <see cref="IImage" /> instance with the configured Docker Hub image name prefix
31+
/// applied, or the original instance if no prefix is configured.
1732
/// </returns>
1833
public static IImage ApplyHubImageNamePrefix(this IImage image)
1934
{

tests/Testcontainers.Tests/Unit/Configurations/DockerImageNameSubstitutionTest.cs

Lines changed: 102 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,108 @@ namespace DotNet.Testcontainers.Tests.Unit
1010
[CollectionDefinition(nameof(DockerImageNameSubstitutionTest), DisableParallelization = true)]
1111
public static class DockerImageNameSubstitutionTest
1212
{
13+
public abstract class ImageNameSubstitutionTest : IDisposable
14+
{
15+
private bool _disposed;
16+
17+
protected ImageNameSubstitutionTest()
18+
{
19+
Reset();
20+
}
21+
22+
public void Dispose()
23+
{
24+
Dispose(true);
25+
GC.SuppressFinalize(this);
26+
}
27+
28+
protected virtual void Dispose(bool disposing)
29+
{
30+
if (_disposed)
31+
{
32+
return;
33+
}
34+
35+
if (disposing)
36+
{
37+
Reset();
38+
}
39+
40+
_disposed = true;
41+
}
42+
43+
private static void Reset()
44+
{
45+
TestcontainersSettings.HubImageNamePrefix = string.Empty;
46+
TestcontainersSettings.ImageNameSubstitution = null;
47+
}
48+
}
49+
1350
[Collection(nameof(DockerImageNameSubstitutionTest))]
14-
public sealed class HubImageNamePrefixIsSet : IDisposable
51+
public sealed class ImageNameSubstitutionIsSet : ImageNameSubstitutionTest
52+
{
53+
[Fact]
54+
public void SubstitutesImageNameForStringConfiguration()
55+
{
56+
// Given
57+
TestcontainersSettings.ImageNameSubstitution = image => new DockerImage("my.proxy.com/mirror/" + image.FullName);
58+
59+
// When
60+
IContainer container = new ContainerBuilder("bar:1.0.0")
61+
.Build();
62+
63+
// Then
64+
Assert.Equal("my.proxy.com/mirror/bar:1.0.0", container.Image.FullName);
65+
}
66+
67+
[Fact]
68+
public void SubstitutesImageNameForObjectConfiguration()
69+
{
70+
// Given
71+
TestcontainersSettings.ImageNameSubstitution = image => new DockerImage("my.proxy.com/mirror/" + image.FullName);
72+
73+
IImage image = new DockerImage("bar:1.0.0");
74+
75+
// When
76+
IContainer container = new ContainerBuilder(image)
77+
.Build();
78+
79+
// Then
80+
Assert.Equal("my.proxy.com/mirror/bar:1.0.0", container.Image.FullName);
81+
}
82+
83+
[Fact]
84+
public void SubstitutesImageNameBeforeHubImageNamePrefix()
85+
{
86+
// Given
87+
TestcontainersSettings.HubImageNamePrefix = "my.proxy.com";
88+
TestcontainersSettings.ImageNameSubstitution = image => new DockerImage("registry.azurecr.io/" + image.FullName);
89+
90+
// When
91+
IContainer container = new ContainerBuilder("bar:1.0.0")
92+
.Build();
93+
94+
// Then
95+
Assert.Equal("registry.azurecr.io/bar:1.0.0", container.Image.FullName);
96+
}
97+
98+
[Fact]
99+
public void KeepsOriginalImageWhenSubstitutionReturnsNull()
100+
{
101+
// Given
102+
TestcontainersSettings.ImageNameSubstitution = _ => null;
103+
104+
// When
105+
IContainer container = new ContainerBuilder("bar:1.0.0")
106+
.Build();
107+
108+
// Then
109+
Assert.Equal("bar:1.0.0", container.Image.FullName);
110+
}
111+
}
112+
113+
[Collection(nameof(DockerImageNameSubstitutionTest))]
114+
public sealed class HubImageNamePrefixIsSet : ImageNameSubstitutionTest
15115
{
16116
public static TheoryData<string, string, string> Substitutions { get; }
17117
= new TheoryData<string, string, string>
@@ -60,21 +160,11 @@ public void PrependForObjectConfiguration(string hubImageNamePrefix, string imag
60160
// Then
61161
Assert.Equal(expectedFullName, container.Image.FullName);
62162
}
63-
64-
public void Dispose()
65-
{
66-
TestcontainersSettings.HubImageNamePrefix = string.Empty;
67-
}
68163
}
69164

70165
[Collection(nameof(DockerImageNameSubstitutionTest))]
71-
public sealed class HubImageNamePrefixIsNotSet
166+
public sealed class HubImageNamePrefixIsNotSet : ImageNameSubstitutionTest
72167
{
73-
public HubImageNamePrefixIsNotSet()
74-
{
75-
TestcontainersSettings.HubImageNamePrefix = string.Empty;
76-
}
77-
78168
[Fact]
79169
public void DoNotPrependForStringConfiguration()
80170
{

0 commit comments

Comments
 (0)