Skip to content

Commit d339d16

Browse files
feat: Add reuse support (#1051)
Co-authored-by: Andre Hofmeister <9199345+HofmeisterAn@users.noreply.github.com>
1 parent 7a23453 commit d339d16

25 files changed

+514
-21
lines changed
File renamed without changes.

docs/api/resource_reuse.md

+22
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# Resource Reuse
2+
3+
Reuse is an experimental feature designed to simplify and enhance the development experience. Instead of disposing resources after the tests are finished, enabling reuse will retain the resources and reuse them in the next test run. Testcontainers assigns a hash value according to the builder configuration. If it identifies a matching resource, it will reuse this resource instead of creating a new one. Enabling reuse will disable the resource reaper, meaning the resource will not be cleaned up.
4+
5+
```csharp title="Enable container reuse"
6+
_ = new ContainerBuilder()
7+
.WithReuse(true);
8+
```
9+
10+
The reuse implementation does not currently consider (support) all builder APIs when calculating the hash value. Therefore, collisions may occur. To prevent collisions, simply use a distinct label to identify the resource.
11+
12+
```csharp title="Label container resource to identify it"
13+
_ = new ContainerBuilder()
14+
.WithReuse(true)
15+
.WithLabel("reuse-id", "WeatherForecast");
16+
```
17+
18+
!!!warning
19+
20+
Reuse does not replace singleton implementations to improve test performance. Prefer proper shared instances according to your chosen test framework.
21+
22+
Calling `Dispose()` on a reusable container will stop it. Testcontainers will automatically start it in the next test run. This will assign a new random host port. Some services (e.g. Kafka) require the random assigned host port on the initial configuration. This may interfere with the new random assigned host port.

mkdocs.yml

+2-1
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@ nav:
3131
- api/create_docker_image.md
3232
- api/create_docker_container.md
3333
- api/create_docker_network.md
34-
- api/resource-reaper.md
34+
- api/resource_reaper.md
35+
# - api/resource_reuse.md
3536
- api/wait_strategies.md
3637
- api/best_practices.md
3738
- Examples:

src/Testcontainers/Builders/AbstractBuilder`4.cs

+12-2
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,12 @@ public TBuilderEntity WithCleanUp(bool cleanUp)
5757
return WithResourceReaperSessionId(TestcontainersSettings.ResourceReaperEnabled && cleanUp ? ResourceReaper.DefaultSessionId : Guid.Empty);
5858
}
5959

60+
/// <inheritdoc />
61+
public TBuilderEntity WithReuse(bool reuse)
62+
{
63+
return Clone(new ResourceConfiguration<TCreateResourceEntity>(reuse: reuse)).WithCleanUp(!reuse);
64+
}
65+
6066
/// <inheritdoc />
6167
public TBuilderEntity WithLabel(string name, string value)
6268
{
@@ -126,9 +132,13 @@ protected virtual TBuilderEntity Init()
126132
/// <exception cref="ArgumentException">Thrown when a mandatory Docker resource configuration is not set.</exception>
127133
protected virtual void Validate()
128134
{
129-
const string message = "Docker is either not running or misconfigured. Please ensure that Docker is running and that the endpoint is properly configured. You can customize your configuration using either the environment variables or the ~/.testcontainers.properties file. For more information, visit:\nhttps://dotnet.testcontainers.org/custom_configuration/";
135+
const string containerRuntimeNotFound = "Docker is either not running or misconfigured. Please ensure that Docker is running and that the endpoint is properly configured. You can customize your configuration using either the environment variables or the ~/.testcontainers.properties file. For more information, visit:\nhttps://dotnet.testcontainers.org/custom_configuration/";
130136
_ = Guard.Argument(DockerResourceConfiguration.DockerEndpointAuthConfig, nameof(IResourceConfiguration<TCreateResourceEntity>.DockerEndpointAuthConfig))
131-
.ThrowIf(argument => argument.Value == null, argument => new ArgumentException(message, argument.Name));
137+
.ThrowIf(argument => argument.Value == null, argument => new ArgumentException(containerRuntimeNotFound, argument.Name));
138+
139+
const string reuseNotSupported = "Reuse cannot be used in conjunction with WithCleanUp(true).";
140+
_ = Guard.Argument(DockerResourceConfiguration, nameof(IResourceConfiguration<TCreateResourceEntity>.Reuse))
141+
.ThrowIf(argument => argument.Value.Reuse.HasValue && argument.Value.Reuse.Value && !Guid.Empty.Equals(argument.Value.SessionId), argument => new ArgumentException(reuseNotSupported, argument.Name));
132142
}
133143

134144
/// <summary>

src/Testcontainers/Builders/ContainerBuilder`3.cs

+4
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,10 @@ protected override void Validate()
364364
{
365365
base.Validate();
366366

367+
const string reuseNotSupported = "Reuse cannot be used in conjunction with WithAutoRemove(true).";
368+
_ = Guard.Argument(DockerResourceConfiguration, nameof(IContainerConfiguration.Reuse))
369+
.ThrowIf(argument => argument.Value.Reuse.HasValue && argument.Value.Reuse.Value && argument.Value.AutoRemove.HasValue && argument.Value.AutoRemove.Value, argument => new ArgumentException(reuseNotSupported, argument.Name));
370+
367371
_ = Guard.Argument(DockerResourceConfiguration.Image, nameof(IContainerConfiguration.Image))
368372
.NotNull();
369373
}

src/Testcontainers/Builders/IAbstractBuilder`3.cs

+20
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,26 @@ public interface IAbstractBuilder<out TBuilderEntity, out TResourceEntity, out T
5959
[PublicAPI]
6060
TBuilderEntity WithCleanUp(bool cleanUp);
6161

62+
/// <summary>
63+
/// Reuses an existing Docker resource.
64+
/// </summary>
65+
/// <remarks>
66+
/// If reuse is enabled, Testcontainers will label the resource with a hash value
67+
/// according to the respective build/resource configuration. When Testcontainers finds a
68+
/// matching resource, it will reuse this resource instead of creating a new one. Enabling
69+
/// reuse will disable the resource reaper, meaning the resource will not be cleaned up
70+
/// after the tests are finished.
71+
///
72+
/// This is an <b>experimental</b> feature. Reuse does not take all builder
73+
/// configurations into account when calculating the hash value. There might be configurations
74+
/// where Testcontainers is not, or not yet, able to find a matching resource and
75+
/// recreate the resource.
76+
/// </remarks>
77+
/// <param name="reuse">Determines whether to reuse an existing resource configuration or not.</param>
78+
/// <returns>A configured instance of <typeparamref name="TBuilderEntity" />.</returns>
79+
[PublicAPI]
80+
TBuilderEntity WithReuse(bool reuse);
81+
6282
/// <summary>
6383
/// Adds user-defined metadata to the Docker resource.
6484
/// </summary>

src/Testcontainers/Builders/ImageFromDockerfileBuilder.cs

+10
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,16 @@ protected sealed override ImageFromDockerfileBuilder Init()
112112
return base.Init().WithImageBuildPolicy(PullPolicy.Always).WithDockerfile("Dockerfile").WithDockerfileDirectory(Directory.GetCurrentDirectory()).WithName(new DockerImage("localhost/testcontainers", Guid.NewGuid().ToString("D"), string.Empty));
113113
}
114114

115+
/// <inheritdoc />
116+
protected override void Validate()
117+
{
118+
base.Validate();
119+
120+
const string reuseNotSupported = "Building an image does not support the reuse feature. To keep the built image, disable the cleanup.";
121+
_ = Guard.Argument(DockerResourceConfiguration, nameof(IImageFromDockerfileConfiguration.Reuse))
122+
.ThrowIf(argument => argument.Value.Reuse.HasValue && argument.Value.Reuse.Value, argument => new ArgumentException(reuseNotSupported, argument.Name));
123+
}
124+
115125
/// <inheritdoc />
116126
protected override ImageFromDockerfileBuilder Clone(IResourceConfiguration<ImageBuildParameters> resourceConfiguration)
117127
{

src/Testcontainers/Clients/DockerContainerOperations.cs

+5
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,11 @@ public async Task<string> RunAsync(IContainerConfiguration configuration, Cancel
200200
NetworkingConfig = networkingConfig,
201201
};
202202

203+
if (configuration.Reuse.HasValue && configuration.Reuse.Value)
204+
{
205+
createParameters.Labels.Add(TestcontainersClient.TestcontainersReuseHashLabel, configuration.GetReuseHash());
206+
}
207+
203208
if (configuration.ParameterModifiers != null)
204209
{
205210
foreach (var parameterModifier in configuration.ParameterModifiers)

src/Testcontainers/Clients/DockerNetworkOperations.cs

+5
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,11 @@ public async Task<string> CreateAsync(INetworkConfiguration configuration, Cance
6363
Labels = configuration.Labels.ToDictionary(item => item.Key, item => item.Value),
6464
};
6565

66+
if (configuration.Reuse.HasValue && configuration.Reuse.Value)
67+
{
68+
createParameters.Labels.Add(TestcontainersClient.TestcontainersReuseHashLabel, configuration.GetReuseHash());
69+
}
70+
6671
if (configuration.ParameterModifiers != null)
6772
{
6873
foreach (var parameterModifier in configuration.ParameterModifiers)

src/Testcontainers/Clients/DockerVolumeOperations.cs

+5
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,11 @@ public async Task<string> CreateAsync(IVolumeConfiguration configuration, Cancel
6565
Labels = configuration.Labels.ToDictionary(item => item.Key, item => item.Value),
6666
};
6767

68+
if (configuration.Reuse.HasValue && configuration.Reuse.Value)
69+
{
70+
createParameters.Labels.Add(TestcontainersClient.TestcontainersReuseHashLabel, configuration.GetReuseHash());
71+
}
72+
6873
if (configuration.ParameterModifiers != null)
6974
{
7075
foreach (var parameterModifier in configuration.ParameterModifiers)

src/Testcontainers/Clients/FilterByProperty.cs

+25-1
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@ namespace DotNet.Testcontainers.Clients
22
{
33
using System.Collections.Concurrent;
44
using System.Collections.Generic;
5+
using DotNet.Testcontainers.Configurations;
56

6-
internal sealed class FilterByProperty : ConcurrentDictionary<string, IDictionary<string, bool>>
7+
public class FilterByProperty : ConcurrentDictionary<string, IDictionary<string, bool>>
78
{
89
public FilterByProperty Add(string property, string value)
910
{
@@ -12,4 +13,27 @@ public FilterByProperty Add(string property, string value)
1213
return this;
1314
}
1415
}
16+
17+
public sealed class FilterByReuseHash : FilterByProperty
18+
{
19+
public FilterByReuseHash(IContainerConfiguration resourceConfiguration)
20+
: this(resourceConfiguration.GetReuseHash())
21+
{
22+
}
23+
24+
public FilterByReuseHash(INetworkConfiguration resourceConfiguration)
25+
: this(resourceConfiguration.GetReuseHash())
26+
{
27+
}
28+
29+
public FilterByReuseHash(IVolumeConfiguration resourceConfiguration)
30+
: this(resourceConfiguration.GetReuseHash())
31+
{
32+
}
33+
34+
private FilterByReuseHash(string hash)
35+
{
36+
Add("label", string.Join("=", TestcontainersClient.TestcontainersReuseHashLabel, hash));
37+
}
38+
}
1539
}

src/Testcontainers/Clients/TestcontainersClient.cs

+2
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ internal sealed class TestcontainersClient : ITestcontainersClient
2727

2828
public const string TestcontainersSessionIdLabel = TestcontainersLabel + ".session-id";
2929

30+
public const string TestcontainersReuseHashLabel = TestcontainersLabel + ".reuse-hash";
31+
3032
public static readonly string Version = typeof(TestcontainersClient).Assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>().InformationalVersion;
3133

3234
private static readonly string OSRootDirectory = Path.GetPathRoot(Directory.GetCurrentDirectory());

src/Testcontainers/Configurations/AuthConfigs/DockerEndpointAuthenticationConfiguration.cs

-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ namespace DotNet.Testcontainers.Configurations
22
{
33
using System;
44
using System.Collections.Generic;
5-
using System.Collections.ObjectModel;
65
using Docker.DotNet;
76
using DotNet.Testcontainers.Clients;
87
using JetBrains.Annotations;

src/Testcontainers/Configurations/Commons/IResourceConfiguration.cs

+10
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@ public interface IResourceConfiguration<in TCreateResourceEntity>
1616
/// </summary>
1717
Guid SessionId { get; }
1818

19+
/// <summary>
20+
/// Gets a value indicating whether to reuse an existing resource configuration or not.
21+
/// </summary>
22+
bool? Reuse { get; }
23+
1924
/// <summary>
2025
/// Gets the Docker endpoint authentication configuration.
2126
/// </summary>
@@ -30,5 +35,10 @@ public interface IResourceConfiguration<in TCreateResourceEntity>
3035
/// Gets a list of low level modifications of the Docker.DotNet entity.
3136
/// </summary>
3237
IReadOnlyList<Action<TCreateResourceEntity>> ParameterModifiers { get; }
38+
39+
/// <summary>
40+
/// Gets the reuse hash.
41+
/// </summary>
42+
string GetReuseHash();
3343
}
3444
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
namespace DotNet.Testcontainers.Configurations
2+
{
3+
using System;
4+
using System.Collections.Generic;
5+
using System.Linq;
6+
using System.Text.Json;
7+
using System.Text.Json.Serialization;
8+
using DotNet.Testcontainers.Clients;
9+
using DotNet.Testcontainers.Containers;
10+
11+
internal sealed class JsonIgnoreRuntimeResourceLabels : JsonConverter<IReadOnlyDictionary<string, string>>
12+
{
13+
private static readonly ISet<string> IgnoreLabels = new HashSet<string> { ResourceReaper.ResourceReaperSessionLabel, TestcontainersClient.TestcontainersVersionLabel, TestcontainersClient.TestcontainersSessionIdLabel };
14+
15+
public override bool CanConvert(Type typeToConvert)
16+
{
17+
return typeof(IEnumerable<KeyValuePair<string, string>>).IsAssignableFrom(typeToConvert);
18+
}
19+
20+
public override IReadOnlyDictionary<string, string> Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
21+
{
22+
return JsonSerializer.Deserialize<IReadOnlyDictionary<string, string>>(ref reader);
23+
}
24+
25+
public override void Write(Utf8JsonWriter writer, IReadOnlyDictionary<string, string> value, JsonSerializerOptions options)
26+
{
27+
var labels = value.Where(label => !IgnoreLabels.Contains(label.Key)).ToDictionary(label => label.Key, label => label.Value);
28+
29+
writer.WriteStartObject();
30+
31+
foreach (var label in labels)
32+
{
33+
writer.WriteString(label.Key, label.Value);
34+
}
35+
36+
writer.WriteEndObject();
37+
}
38+
}
39+
}

src/Testcontainers/Configurations/Commons/ResourceConfiguration.cs

+28-2
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ namespace DotNet.Testcontainers.Configurations
22
{
33
using System;
44
using System.Collections.Generic;
5+
using System.Security.Cryptography;
6+
using System.Text.Json;
7+
using System.Text.Json.Serialization;
58
using DotNet.Testcontainers.Builders;
69
using DotNet.Testcontainers.Containers;
710
using JetBrains.Annotations;
@@ -16,15 +19,18 @@ public class ResourceConfiguration<TCreateResourceEntity> : IResourceConfigurati
1619
/// <param name="dockerEndpointAuthenticationConfiguration">The Docker endpoint authentication configuration.</param>
1720
/// <param name="labels">The test session id.</param>
1821
/// <param name="parameterModifiers">A list of low level modifications of the Docker.DotNet entity.</param>
22+
/// <param name="reuse">A value indicating whether to reuse an existing resource configuration or not.</param>
1923
public ResourceConfiguration(
2024
IDockerEndpointAuthenticationConfiguration dockerEndpointAuthenticationConfiguration = null,
2125
IReadOnlyDictionary<string, string> labels = null,
22-
IReadOnlyList<Action<TCreateResourceEntity>> parameterModifiers = null)
26+
IReadOnlyList<Action<TCreateResourceEntity>> parameterModifiers = null,
27+
bool? reuse = null)
2328
{
2429
SessionId = labels != null && labels.TryGetValue(ResourceReaper.ResourceReaperSessionLabel, out var resourceReaperSessionId) && Guid.TryParseExact(resourceReaperSessionId, "D", out var sessionId) ? sessionId : Guid.Empty;
2530
DockerEndpointAuthConfig = dockerEndpointAuthenticationConfiguration;
2631
Labels = labels;
2732
ParameterModifiers = parameterModifiers;
33+
Reuse = reuse;
2834
}
2935

3036
/// <summary>
@@ -44,21 +50,41 @@ protected ResourceConfiguration(IResourceConfiguration<TCreateResourceEntity> re
4450
protected ResourceConfiguration(IResourceConfiguration<TCreateResourceEntity> oldValue, IResourceConfiguration<TCreateResourceEntity> newValue)
4551
: this(
4652
dockerEndpointAuthenticationConfiguration: BuildConfiguration.Combine(oldValue.DockerEndpointAuthConfig, newValue.DockerEndpointAuthConfig),
53+
labels: BuildConfiguration.Combine(oldValue.Labels, newValue.Labels),
4754
parameterModifiers: BuildConfiguration.Combine(oldValue.ParameterModifiers, newValue.ParameterModifiers),
48-
labels: BuildConfiguration.Combine(oldValue.Labels, newValue.Labels))
55+
reuse: (oldValue.Reuse.HasValue && oldValue.Reuse.Value) || (newValue.Reuse.HasValue && newValue.Reuse.Value))
4956
{
5057
}
5158

5259
/// <inheritdoc />
60+
[JsonIgnore]
5361
public Guid SessionId { get; }
5462

5563
/// <inheritdoc />
64+
[JsonIgnore]
65+
public bool? Reuse { get; }
66+
67+
/// <inheritdoc />
68+
[JsonIgnore]
5669
public IDockerEndpointAuthenticationConfiguration DockerEndpointAuthConfig { get; }
5770

5871
/// <inheritdoc />
72+
[JsonConverter(typeof(JsonIgnoreRuntimeResourceLabels))]
5973
public IReadOnlyDictionary<string, string> Labels { get; }
6074

6175
/// <inheritdoc />
76+
[JsonIgnore]
6277
public IReadOnlyList<Action<TCreateResourceEntity>> ParameterModifiers { get; }
78+
79+
/// <inheritdoc />
80+
public virtual string GetReuseHash()
81+
{
82+
var jsonUtf8Bytes = JsonSerializer.SerializeToUtf8Bytes(this, GetType());
83+
84+
using (var sha1 = SHA1.Create())
85+
{
86+
return Convert.ToBase64String(sha1.ComputeHash(jsonUtf8Bytes));
87+
}
88+
}
6389
}
6490
}

0 commit comments

Comments
 (0)