diff --git a/docs/api/resource-reaper.md b/docs/api/resource_reaper.md similarity index 100% rename from docs/api/resource-reaper.md rename to docs/api/resource_reaper.md diff --git a/docs/api/resource_reuse.md b/docs/api/resource_reuse.md new file mode 100644 index 000000000..2c48bece4 --- /dev/null +++ b/docs/api/resource_reuse.md @@ -0,0 +1,22 @@ +# Resource Reuse + +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. + +```csharp title="Enable container reuse" +_ = new ContainerBuilder() + .WithReuse(true); +``` + +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. + +```csharp title="Label container resource to identify it" +_ = new ContainerBuilder() + .WithReuse(true) + .WithLabel("reuse-id", "WeatherForecast"); +``` + +!!!warning + + Reuse does not replace singleton implementations to improve test performance. Prefer proper shared instances according to your chosen test framework. + +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. diff --git a/mkdocs.yml b/mkdocs.yml index aef94ee8e..f5fe4d03b 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -31,7 +31,8 @@ nav: - api/create_docker_image.md - api/create_docker_container.md - api/create_docker_network.md - - api/resource-reaper.md + - api/resource_reaper.md + # - api/resource_reuse.md - api/wait_strategies.md - api/best_practices.md - Examples: diff --git a/src/Testcontainers/Builders/AbstractBuilder`4.cs b/src/Testcontainers/Builders/AbstractBuilder`4.cs index 70d45fa2a..364bc7b2a 100644 --- a/src/Testcontainers/Builders/AbstractBuilder`4.cs +++ b/src/Testcontainers/Builders/AbstractBuilder`4.cs @@ -57,6 +57,12 @@ public TBuilderEntity WithCleanUp(bool cleanUp) return WithResourceReaperSessionId(TestcontainersSettings.ResourceReaperEnabled && cleanUp ? ResourceReaper.DefaultSessionId : Guid.Empty); } + /// + public TBuilderEntity WithReuse(bool reuse) + { + return Clone(new ResourceConfiguration(reuse: reuse)).WithCleanUp(!reuse); + } + /// public TBuilderEntity WithLabel(string name, string value) { @@ -126,9 +132,13 @@ protected virtual TBuilderEntity Init() /// Thrown when a mandatory Docker resource configuration is not set. protected virtual void Validate() { - 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/"; + 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/"; _ = Guard.Argument(DockerResourceConfiguration.DockerEndpointAuthConfig, nameof(IResourceConfiguration.DockerEndpointAuthConfig)) - .ThrowIf(argument => argument.Value == null, argument => new ArgumentException(message, argument.Name)); + .ThrowIf(argument => argument.Value == null, argument => new ArgumentException(containerRuntimeNotFound, argument.Name)); + + const string reuseNotSupported = "Reuse cannot be used in conjunction with WithCleanUp(true)."; + _ = Guard.Argument(DockerResourceConfiguration, nameof(IResourceConfiguration.Reuse)) + .ThrowIf(argument => argument.Value.Reuse.HasValue && argument.Value.Reuse.Value && !Guid.Empty.Equals(argument.Value.SessionId), argument => new ArgumentException(reuseNotSupported, argument.Name)); } /// diff --git a/src/Testcontainers/Builders/ContainerBuilder`3.cs b/src/Testcontainers/Builders/ContainerBuilder`3.cs index ef68e607c..b2288d44c 100644 --- a/src/Testcontainers/Builders/ContainerBuilder`3.cs +++ b/src/Testcontainers/Builders/ContainerBuilder`3.cs @@ -364,6 +364,10 @@ protected override void Validate() { base.Validate(); + const string reuseNotSupported = "Reuse cannot be used in conjunction with WithAutoRemove(true)."; + _ = Guard.Argument(DockerResourceConfiguration, nameof(IContainerConfiguration.Reuse)) + .ThrowIf(argument => argument.Value.Reuse.HasValue && argument.Value.Reuse.Value && argument.Value.AutoRemove.HasValue && argument.Value.AutoRemove.Value, argument => new ArgumentException(reuseNotSupported, argument.Name)); + _ = Guard.Argument(DockerResourceConfiguration.Image, nameof(IContainerConfiguration.Image)) .NotNull(); } diff --git a/src/Testcontainers/Builders/IAbstractBuilder`3.cs b/src/Testcontainers/Builders/IAbstractBuilder`3.cs index 420412fd8..12e4cf8b3 100644 --- a/src/Testcontainers/Builders/IAbstractBuilder`3.cs +++ b/src/Testcontainers/Builders/IAbstractBuilder`3.cs @@ -59,6 +59,26 @@ public interface IAbstractBuilder + /// Reuses an existing Docker resource. + /// + /// + /// If reuse is enabled, Testcontainers will label the resource with a hash value + /// according to the respective build/resource configuration. When Testcontainers finds 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 + /// after the tests are finished. + /// + /// This is an experimental feature. Reuse does not take all builder + /// configurations into account when calculating the hash value. There might be configurations + /// where Testcontainers is not, or not yet, able to find a matching resource and + /// recreate the resource. + /// + /// Determines whether to reuse an existing resource configuration or not. + /// A configured instance of . + [PublicAPI] + TBuilderEntity WithReuse(bool reuse); + /// /// Adds user-defined metadata to the Docker resource. /// diff --git a/src/Testcontainers/Builders/ImageFromDockerfileBuilder.cs b/src/Testcontainers/Builders/ImageFromDockerfileBuilder.cs index 3b5e24da8..fa7986f8d 100644 --- a/src/Testcontainers/Builders/ImageFromDockerfileBuilder.cs +++ b/src/Testcontainers/Builders/ImageFromDockerfileBuilder.cs @@ -112,6 +112,16 @@ protected sealed override ImageFromDockerfileBuilder Init() return base.Init().WithImageBuildPolicy(PullPolicy.Always).WithDockerfile("Dockerfile").WithDockerfileDirectory(Directory.GetCurrentDirectory()).WithName(new DockerImage("localhost/testcontainers", Guid.NewGuid().ToString("D"), string.Empty)); } + /// + protected override void Validate() + { + base.Validate(); + + const string reuseNotSupported = "Building an image does not support the reuse feature. To keep the built image, disable the cleanup."; + _ = Guard.Argument(DockerResourceConfiguration, nameof(IImageFromDockerfileConfiguration.Reuse)) + .ThrowIf(argument => argument.Value.Reuse.HasValue && argument.Value.Reuse.Value, argument => new ArgumentException(reuseNotSupported, argument.Name)); + } + /// protected override ImageFromDockerfileBuilder Clone(IResourceConfiguration resourceConfiguration) { diff --git a/src/Testcontainers/Clients/DockerContainerOperations.cs b/src/Testcontainers/Clients/DockerContainerOperations.cs index 42cd9d9c0..beb30d2d1 100644 --- a/src/Testcontainers/Clients/DockerContainerOperations.cs +++ b/src/Testcontainers/Clients/DockerContainerOperations.cs @@ -200,6 +200,11 @@ public async Task RunAsync(IContainerConfiguration configuration, Cancel NetworkingConfig = networkingConfig, }; + if (configuration.Reuse.HasValue && configuration.Reuse.Value) + { + createParameters.Labels.Add(TestcontainersClient.TestcontainersReuseHashLabel, configuration.GetReuseHash()); + } + if (configuration.ParameterModifiers != null) { foreach (var parameterModifier in configuration.ParameterModifiers) diff --git a/src/Testcontainers/Clients/DockerNetworkOperations.cs b/src/Testcontainers/Clients/DockerNetworkOperations.cs index b0afbf444..5f9e086a4 100644 --- a/src/Testcontainers/Clients/DockerNetworkOperations.cs +++ b/src/Testcontainers/Clients/DockerNetworkOperations.cs @@ -63,6 +63,11 @@ public async Task CreateAsync(INetworkConfiguration configuration, Cance Labels = configuration.Labels.ToDictionary(item => item.Key, item => item.Value), }; + if (configuration.Reuse.HasValue && configuration.Reuse.Value) + { + createParameters.Labels.Add(TestcontainersClient.TestcontainersReuseHashLabel, configuration.GetReuseHash()); + } + if (configuration.ParameterModifiers != null) { foreach (var parameterModifier in configuration.ParameterModifiers) diff --git a/src/Testcontainers/Clients/DockerVolumeOperations.cs b/src/Testcontainers/Clients/DockerVolumeOperations.cs index e1266d809..07b6634dd 100644 --- a/src/Testcontainers/Clients/DockerVolumeOperations.cs +++ b/src/Testcontainers/Clients/DockerVolumeOperations.cs @@ -65,6 +65,11 @@ public async Task CreateAsync(IVolumeConfiguration configuration, Cancel Labels = configuration.Labels.ToDictionary(item => item.Key, item => item.Value), }; + if (configuration.Reuse.HasValue && configuration.Reuse.Value) + { + createParameters.Labels.Add(TestcontainersClient.TestcontainersReuseHashLabel, configuration.GetReuseHash()); + } + if (configuration.ParameterModifiers != null) { foreach (var parameterModifier in configuration.ParameterModifiers) diff --git a/src/Testcontainers/Clients/FilterByProperty.cs b/src/Testcontainers/Clients/FilterByProperty.cs index 9be23a645..d1887a0a3 100644 --- a/src/Testcontainers/Clients/FilterByProperty.cs +++ b/src/Testcontainers/Clients/FilterByProperty.cs @@ -2,8 +2,9 @@ namespace DotNet.Testcontainers.Clients { using System.Collections.Concurrent; using System.Collections.Generic; + using DotNet.Testcontainers.Configurations; - internal sealed class FilterByProperty : ConcurrentDictionary> + public class FilterByProperty : ConcurrentDictionary> { public FilterByProperty Add(string property, string value) { @@ -12,4 +13,27 @@ public FilterByProperty Add(string property, string value) return this; } } + + public sealed class FilterByReuseHash : FilterByProperty + { + public FilterByReuseHash(IContainerConfiguration resourceConfiguration) + : this(resourceConfiguration.GetReuseHash()) + { + } + + public FilterByReuseHash(INetworkConfiguration resourceConfiguration) + : this(resourceConfiguration.GetReuseHash()) + { + } + + public FilterByReuseHash(IVolumeConfiguration resourceConfiguration) + : this(resourceConfiguration.GetReuseHash()) + { + } + + private FilterByReuseHash(string hash) + { + Add("label", string.Join("=", TestcontainersClient.TestcontainersReuseHashLabel, hash)); + } + } } diff --git a/src/Testcontainers/Clients/TestcontainersClient.cs b/src/Testcontainers/Clients/TestcontainersClient.cs index 4d54139cb..e6852f95a 100644 --- a/src/Testcontainers/Clients/TestcontainersClient.cs +++ b/src/Testcontainers/Clients/TestcontainersClient.cs @@ -27,6 +27,8 @@ internal sealed class TestcontainersClient : ITestcontainersClient public const string TestcontainersSessionIdLabel = TestcontainersLabel + ".session-id"; + public const string TestcontainersReuseHashLabel = TestcontainersLabel + ".reuse-hash"; + public static readonly string Version = typeof(TestcontainersClient).Assembly.GetCustomAttribute().InformationalVersion; private static readonly string OSRootDirectory = Path.GetPathRoot(Directory.GetCurrentDirectory()); diff --git a/src/Testcontainers/Configurations/AuthConfigs/DockerEndpointAuthenticationConfiguration.cs b/src/Testcontainers/Configurations/AuthConfigs/DockerEndpointAuthenticationConfiguration.cs index 0f8f17991..a42ee9b88 100644 --- a/src/Testcontainers/Configurations/AuthConfigs/DockerEndpointAuthenticationConfiguration.cs +++ b/src/Testcontainers/Configurations/AuthConfigs/DockerEndpointAuthenticationConfiguration.cs @@ -2,7 +2,6 @@ namespace DotNet.Testcontainers.Configurations { using System; using System.Collections.Generic; - using System.Collections.ObjectModel; using Docker.DotNet; using DotNet.Testcontainers.Clients; using JetBrains.Annotations; diff --git a/src/Testcontainers/Configurations/Commons/IResourceConfiguration.cs b/src/Testcontainers/Configurations/Commons/IResourceConfiguration.cs index d10ceb500..eaf7f720c 100644 --- a/src/Testcontainers/Configurations/Commons/IResourceConfiguration.cs +++ b/src/Testcontainers/Configurations/Commons/IResourceConfiguration.cs @@ -16,6 +16,11 @@ public interface IResourceConfiguration /// Guid SessionId { get; } + /// + /// Gets a value indicating whether to reuse an existing resource configuration or not. + /// + bool? Reuse { get; } + /// /// Gets the Docker endpoint authentication configuration. /// @@ -30,5 +35,10 @@ public interface IResourceConfiguration /// Gets a list of low level modifications of the Docker.DotNet entity. /// IReadOnlyList> ParameterModifiers { get; } + + /// + /// Gets the reuse hash. + /// + string GetReuseHash(); } } diff --git a/src/Testcontainers/Configurations/Commons/JsonIgnoreRuntimeResourceLabels.cs b/src/Testcontainers/Configurations/Commons/JsonIgnoreRuntimeResourceLabels.cs new file mode 100644 index 000000000..14badf1c1 --- /dev/null +++ b/src/Testcontainers/Configurations/Commons/JsonIgnoreRuntimeResourceLabels.cs @@ -0,0 +1,39 @@ +namespace DotNet.Testcontainers.Configurations +{ + using System; + using System.Collections.Generic; + using System.Linq; + using System.Text.Json; + using System.Text.Json.Serialization; + using DotNet.Testcontainers.Clients; + using DotNet.Testcontainers.Containers; + + internal sealed class JsonIgnoreRuntimeResourceLabels : JsonConverter> + { + private static readonly ISet IgnoreLabels = new HashSet { ResourceReaper.ResourceReaperSessionLabel, TestcontainersClient.TestcontainersVersionLabel, TestcontainersClient.TestcontainersSessionIdLabel }; + + public override bool CanConvert(Type typeToConvert) + { + return typeof(IEnumerable>).IsAssignableFrom(typeToConvert); + } + + public override IReadOnlyDictionary Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return JsonSerializer.Deserialize>(ref reader); + } + + public override void Write(Utf8JsonWriter writer, IReadOnlyDictionary value, JsonSerializerOptions options) + { + var labels = value.Where(label => !IgnoreLabels.Contains(label.Key)).ToDictionary(label => label.Key, label => label.Value); + + writer.WriteStartObject(); + + foreach (var label in labels) + { + writer.WriteString(label.Key, label.Value); + } + + writer.WriteEndObject(); + } + } +} diff --git a/src/Testcontainers/Configurations/Commons/ResourceConfiguration.cs b/src/Testcontainers/Configurations/Commons/ResourceConfiguration.cs index d66fbac76..0fa813bdf 100644 --- a/src/Testcontainers/Configurations/Commons/ResourceConfiguration.cs +++ b/src/Testcontainers/Configurations/Commons/ResourceConfiguration.cs @@ -2,6 +2,9 @@ namespace DotNet.Testcontainers.Configurations { using System; using System.Collections.Generic; + using System.Security.Cryptography; + using System.Text.Json; + using System.Text.Json.Serialization; using DotNet.Testcontainers.Builders; using DotNet.Testcontainers.Containers; using JetBrains.Annotations; @@ -16,15 +19,18 @@ public class ResourceConfiguration : IResourceConfigurati /// The Docker endpoint authentication configuration. /// The test session id. /// A list of low level modifications of the Docker.DotNet entity. + /// A value indicating whether to reuse an existing resource configuration or not. public ResourceConfiguration( IDockerEndpointAuthenticationConfiguration dockerEndpointAuthenticationConfiguration = null, IReadOnlyDictionary labels = null, - IReadOnlyList> parameterModifiers = null) + IReadOnlyList> parameterModifiers = null, + bool? reuse = null) { SessionId = labels != null && labels.TryGetValue(ResourceReaper.ResourceReaperSessionLabel, out var resourceReaperSessionId) && Guid.TryParseExact(resourceReaperSessionId, "D", out var sessionId) ? sessionId : Guid.Empty; DockerEndpointAuthConfig = dockerEndpointAuthenticationConfiguration; Labels = labels; ParameterModifiers = parameterModifiers; + Reuse = reuse; } /// @@ -44,21 +50,41 @@ protected ResourceConfiguration(IResourceConfiguration re protected ResourceConfiguration(IResourceConfiguration oldValue, IResourceConfiguration newValue) : this( dockerEndpointAuthenticationConfiguration: BuildConfiguration.Combine(oldValue.DockerEndpointAuthConfig, newValue.DockerEndpointAuthConfig), + labels: BuildConfiguration.Combine(oldValue.Labels, newValue.Labels), parameterModifiers: BuildConfiguration.Combine(oldValue.ParameterModifiers, newValue.ParameterModifiers), - labels: BuildConfiguration.Combine(oldValue.Labels, newValue.Labels)) + reuse: (oldValue.Reuse.HasValue && oldValue.Reuse.Value) || (newValue.Reuse.HasValue && newValue.Reuse.Value)) { } /// + [JsonIgnore] public Guid SessionId { get; } /// + [JsonIgnore] + public bool? Reuse { get; } + + /// + [JsonIgnore] public IDockerEndpointAuthenticationConfiguration DockerEndpointAuthConfig { get; } /// + [JsonConverter(typeof(JsonIgnoreRuntimeResourceLabels))] public IReadOnlyDictionary Labels { get; } /// + [JsonIgnore] public IReadOnlyList> ParameterModifiers { get; } + + /// + public virtual string GetReuseHash() + { + var jsonUtf8Bytes = JsonSerializer.SerializeToUtf8Bytes(this, GetType()); + + using (var sha1 = SHA1.Create()) + { + return Convert.ToBase64String(sha1.ComputeHash(jsonUtf8Bytes)); + } + } } } diff --git a/src/Testcontainers/Configurations/Containers/ContainerConfiguration.cs b/src/Testcontainers/Configurations/Containers/ContainerConfiguration.cs index 34833b3b7..4058c248d 100644 --- a/src/Testcontainers/Configurations/Containers/ContainerConfiguration.cs +++ b/src/Testcontainers/Configurations/Containers/ContainerConfiguration.cs @@ -2,6 +2,7 @@ namespace DotNet.Testcontainers.Configurations { using System; using System.Collections.Generic; + using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; using Docker.DotNet.Models; @@ -139,27 +140,34 @@ public ContainerConfiguration(IContainerConfiguration oldValue, IContainerConfig } /// + [JsonIgnore] public bool? AutoRemove { get; } /// + [JsonIgnore] public bool? Privileged { get; } /// public IImage Image { get; } /// + [JsonIgnore] public Func ImagePullPolicy { get; } /// + [JsonIgnore] public string Name { get; } /// + [JsonIgnore] public string Hostname { get; } /// + [JsonIgnore] public string MacAddress { get; } /// + [JsonIgnore] public string WorkingDirectory { get; } /// @@ -178,15 +186,19 @@ public ContainerConfiguration(IContainerConfiguration oldValue, IContainerConfig public IReadOnlyDictionary PortBindings { get; } /// + [JsonIgnore] public IEnumerable ResourceMappings { get; } /// + [JsonIgnore] public IEnumerable Containers { get; } /// + [JsonIgnore] public IEnumerable Mounts { get; } /// + [JsonIgnore] public IEnumerable Networks { get; } /// @@ -196,12 +208,15 @@ public ContainerConfiguration(IContainerConfiguration oldValue, IContainerConfig public IEnumerable ExtraHosts { get; } /// + [JsonIgnore] public IOutputConsumer OutputConsumer { get; } /// + [JsonIgnore] public IEnumerable WaitStrategies { get; } /// + [JsonIgnore] public Func StartupCallback { get; } } } diff --git a/src/Testcontainers/Configurations/Images/ImageFromDockerfileConfiguration.cs b/src/Testcontainers/Configurations/Images/ImageFromDockerfileConfiguration.cs index 9203c30d9..a3c59509c 100644 --- a/src/Testcontainers/Configurations/Images/ImageFromDockerfileConfiguration.cs +++ b/src/Testcontainers/Configurations/Images/ImageFromDockerfileConfiguration.cs @@ -2,6 +2,7 @@ namespace DotNet.Testcontainers.Configurations { using System; using System.Collections.Generic; + using System.Text.Json.Serialization; using Docker.DotNet.Models; using DotNet.Testcontainers.Builders; using DotNet.Testcontainers.Images; @@ -71,21 +72,27 @@ public ImageFromDockerfileConfiguration(IImageFromDockerfileConfiguration oldVal } /// + [JsonIgnore] public bool? DeleteIfExists { get; } /// + [JsonIgnore] public string Dockerfile { get; } /// + [JsonIgnore] public string DockerfileDirectory { get; } /// + [JsonIgnore] public IImage Image { get; } /// + [JsonIgnore] public Func ImageBuildPolicy { get; } /// + [JsonIgnore] public IReadOnlyDictionary BuildArguments { get; } } } diff --git a/src/Testcontainers/Configurations/Networks/NetworkConfiguration.cs b/src/Testcontainers/Configurations/Networks/NetworkConfiguration.cs index 97c050010..1c20822d3 100644 --- a/src/Testcontainers/Configurations/Networks/NetworkConfiguration.cs +++ b/src/Testcontainers/Configurations/Networks/NetworkConfiguration.cs @@ -1,6 +1,7 @@ namespace DotNet.Testcontainers.Configurations { using System.Collections.Generic; + using System.Text.Json.Serialization; using Docker.DotNet.Models; using DotNet.Testcontainers.Builders; using JetBrains.Annotations; @@ -60,9 +61,11 @@ public NetworkConfiguration(INetworkConfiguration oldValue, INetworkConfiguratio public string Name { get; } /// + [JsonIgnore] public NetworkDriver Driver { get; } /// + [JsonIgnore] public IReadOnlyDictionary Options { get; } } } diff --git a/src/Testcontainers/Containers/DockerContainer.cs b/src/Testcontainers/Containers/DockerContainer.cs index 1e9bbf855..55f058fb4 100644 --- a/src/Testcontainers/Containers/DockerContainer.cs +++ b/src/Testcontainers/Containers/DockerContainer.cs @@ -364,8 +364,38 @@ protected override async Task UnsafeCreateAsync(CancellationToken ct = default) Creating?.Invoke(this, EventArgs.Empty); - var id = await _client.RunAsync(_configuration, ct) - .ConfigureAwait(false); + string id; + + if (_configuration.Reuse.HasValue && _configuration.Reuse.Value) + { + Logger.ReusableExperimentalFeature(); + + var filters = new FilterByReuseHash(_configuration); + + var reusableContainers = await _client.Container.GetAllAsync(filters, ct) + .ConfigureAwait(false); + + var reusableContainer = reusableContainers.SingleOrDefault(); + + if (reusableContainer != null) + { + Logger.ReusableResourceFound(); + + id = reusableContainer.ID; + } + else + { + Logger.ReusableResourceNotFound(); + + id = await _client.RunAsync(_configuration, ct) + .ConfigureAwait(false); + } + } + else + { + id = await _client.RunAsync(_configuration, ct) + .ConfigureAwait(false); + } _container = await _client.Container.ByIdAsync(id, ct) .ConfigureAwait(false); diff --git a/src/Testcontainers/Logging.cs b/src/Testcontainers/Logging.cs index 18f5d2ae0..8e1d5b07a 100644 --- a/src/Testcontainers/Logging.cs +++ b/src/Testcontainers/Logging.cs @@ -101,6 +101,15 @@ internal static class Logging private static readonly Action _DockerRegistryCredentialFound = LoggerMessage.Define(LogLevel.Information, default, "Docker registry credential {DockerRegistry} found"); + private static readonly Action _ReusableExperimentalFeature + = LoggerMessage.Define(LogLevel.Warning, default, "Reuse is an experimental feature. For more information, visit: https://dotnet.testcontainers.org/api/resource_reuse/"); + + private static readonly Action _ReusableResourceFound + = LoggerMessage.Define(LogLevel.Information, default, "Reusable resource found"); + + private static readonly Action _ReusableResourceNotFound + = LoggerMessage.Define(LogLevel.Information, default, "Reusable resource not found, create resource"); + #pragma warning restore InconsistentNaming, SA1309 public static void IgnorePatternAdded(this ILogger logger, Regex ignorePattern) @@ -255,6 +264,21 @@ public static void DockerRegistryCredentialFound(this ILogger logger, string doc _DockerRegistryCredentialFound(logger, dockerRegistry, null); } + public static void ReusableExperimentalFeature(this ILogger logger) + { + _ReusableExperimentalFeature(logger, null); + } + + public static void ReusableResourceFound(this ILogger logger) + { + _ReusableResourceFound(logger, null); + } + + public static void ReusableResourceNotFound(this ILogger logger) + { + _ReusableResourceNotFound(logger, null); + } + private static string TruncId(string id) { return id.Substring(0, Math.Min(12, id.Length)); diff --git a/src/Testcontainers/Networks/DockerNetwork.cs b/src/Testcontainers/Networks/DockerNetwork.cs index 9eea998d6..f29a418f7 100644 --- a/src/Testcontainers/Networks/DockerNetwork.cs +++ b/src/Testcontainers/Networks/DockerNetwork.cs @@ -1,8 +1,10 @@ namespace DotNet.Testcontainers.Networks { using System; + using System.Linq; using System.Threading; using System.Threading.Tasks; + using Docker.DotNet; using Docker.DotNet.Models; using DotNet.Testcontainers.Clients; using DotNet.Testcontainers.Configurations; @@ -17,6 +19,8 @@ internal sealed class DockerNetwork : Resource, INetwork private readonly INetworkConfiguration _configuration; + private readonly ILogger _logger; + private NetworkResponse _network = new NetworkResponse(); /// @@ -28,6 +32,7 @@ public DockerNetwork(INetworkConfiguration configuration, ILogger logger) { _client = new TestcontainersClient(configuration.SessionId, configuration.DockerEndpointAuthConfig, logger); _configuration = configuration; + _logger = logger; } /// @@ -94,8 +99,38 @@ protected override async Task UnsafeCreateAsync(CancellationToken ct = default) return; } - var id = await _client.Network.CreateAsync(_configuration, ct) - .ConfigureAwait(false); + string id; + + if (_configuration.Reuse.HasValue && _configuration.Reuse.Value) + { + _logger.ReusableExperimentalFeature(); + + var filters = new FilterByReuseHash(_configuration); + + var reusableNetworks = await _client.Network.GetAllAsync(filters, ct) + .ConfigureAwait(false); + + var reusableNetwork = reusableNetworks.SingleOrDefault(); + + if (reusableNetwork != null) + { + _logger.ReusableResourceFound(); + + id = reusableNetwork.ID; + } + else + { + _logger.ReusableResourceNotFound(); + + id = await _client.Network.CreateAsync(_configuration, ct) + .ConfigureAwait(false); + } + } + else + { + id = await _client.Network.CreateAsync(_configuration, ct) + .ConfigureAwait(false); + } _network = await _client.Network.ByIdAsync(id, ct) .ConfigureAwait(false); @@ -111,10 +146,18 @@ protected override async Task UnsafeDeleteAsync(CancellationToken ct = default) return; } - await _client.Network.DeleteAsync(_network.ID, ct) - .ConfigureAwait(false); - - _network = new NetworkResponse(); + try + { + await _client.Network.DeleteAsync(_network.ID, ct) + .ConfigureAwait(false); + } + catch (DockerApiException) + { + } + finally + { + _network = new NetworkResponse(); + } } } } diff --git a/src/Testcontainers/Volumes/DockerVolume.cs b/src/Testcontainers/Volumes/DockerVolume.cs index b161c0e8e..c344b79c9 100644 --- a/src/Testcontainers/Volumes/DockerVolume.cs +++ b/src/Testcontainers/Volumes/DockerVolume.cs @@ -1,8 +1,10 @@ namespace DotNet.Testcontainers.Volumes { using System; + using System.Linq; using System.Threading; using System.Threading.Tasks; + using Docker.DotNet; using Docker.DotNet.Models; using DotNet.Testcontainers.Clients; using DotNet.Testcontainers.Configurations; @@ -17,6 +19,8 @@ internal sealed class DockerVolume : Resource, IVolume private readonly IVolumeConfiguration _configuration; + private readonly ILogger _logger; + private VolumeResponse _volume = new VolumeResponse(); /// @@ -28,6 +32,7 @@ public DockerVolume(IVolumeConfiguration configuration, ILogger logger) { _client = new TestcontainersClient(configuration.SessionId, configuration.DockerEndpointAuthConfig, logger); _configuration = configuration; + _logger = logger; } /// @@ -94,8 +99,38 @@ protected override async Task UnsafeCreateAsync(CancellationToken ct = default) return; } - var id = await _client.Volume.CreateAsync(_configuration, ct) - .ConfigureAwait(false); + string id; + + if (_configuration.Reuse.HasValue && _configuration.Reuse.Value) + { + _logger.ReusableExperimentalFeature(); + + var filters = new FilterByReuseHash(_configuration); + + var reusableVolumes = await _client.Volume.GetAllAsync(filters, ct) + .ConfigureAwait(false); + + var reusableVolume = reusableVolumes.SingleOrDefault(); + + if (reusableVolume != null) + { + _logger.ReusableResourceFound(); + + id = reusableVolume.Name; + } + else + { + _logger.ReusableResourceNotFound(); + + id = await _client.Volume.CreateAsync(_configuration, ct) + .ConfigureAwait(false); + } + } + else + { + id = await _client.Volume.CreateAsync(_configuration, ct) + .ConfigureAwait(false); + } _volume = await _client.Volume.ByIdAsync(id, ct) .ConfigureAwait(false); @@ -111,10 +146,18 @@ protected override async Task UnsafeDeleteAsync(CancellationToken ct = default) return; } - await _client.Volume.DeleteAsync(Name, ct) - .ConfigureAwait(false); - - _volume = new VolumeResponse(); + try + { + await _client.Volume.DeleteAsync(Name, ct) + .ConfigureAwait(false); + } + catch (DockerApiException) + { + } + finally + { + _volume = new VolumeResponse(); + } } } } diff --git a/tests/Testcontainers.Platform.Linux.Tests/ReusableResourceTest.cs b/tests/Testcontainers.Platform.Linux.Tests/ReusableResourceTest.cs new file mode 100644 index 000000000..18a2499e1 --- /dev/null +++ b/tests/Testcontainers.Platform.Linux.Tests/ReusableResourceTest.cs @@ -0,0 +1,144 @@ +namespace Testcontainers.Tests; + +public sealed class ReusableResourceTest : IAsyncLifetime, IDisposable +{ + private readonly DockerClient _dockerClient = TestcontainersSettings.OS.DockerEndpointAuthConfig.GetDockerClientConfiguration(Guid.NewGuid()).CreateClient(); + + private readonly FilterByProperty _filters = new FilterByProperty(); + + private readonly IList _disposables = new List(); + + private readonly string _labelKey = Guid.NewGuid().ToString("D"); + + private readonly string _labelValue = Guid.NewGuid().ToString("D"); + + public ReusableResourceTest() + { + _filters.Add("label", string.Join("=", _labelKey, _labelValue)); + } + + public async Task InitializeAsync() + { + for (var _ = 0; _ < 3; _++) + { + // We are running in a single session, we do not need to disable the cleanup feature. + var container = new ContainerBuilder() + .WithImage(CommonImages.Alpine) + .WithEntrypoint(CommonCommands.SleepInfinity) + .WithLabel(_labelKey, _labelValue) + .WithReuse(true) + .Build(); + + var network = new NetworkBuilder() + .WithName(_labelKey) + .WithLabel(_labelKey, _labelValue) + .WithReuse(true) + .Build(); + + var volume = new VolumeBuilder() + .WithName(_labelKey) + .WithLabel(_labelKey, _labelValue) + .WithReuse(true) + .Build(); + + await Task.WhenAll(container.StartAsync(), network.CreateAsync(), volume.CreateAsync()) + .ConfigureAwait(false); + + _disposables.Add(container); + _disposables.Add(network); + _disposables.Add(volume); + } + } + + public Task DisposeAsync() + { + return Task.WhenAll(_disposables.Select(disposable => disposable.DisposeAsync().AsTask())); + } + + public void Dispose() + { + _dockerClient.Dispose(); + } + + [Fact] + public async Task ShouldReuseExistingResource() + { + var containers = await _dockerClient.Containers.ListContainersAsync(new ContainersListParameters { Filters = _filters }); + + var networks = await _dockerClient.Networks.ListNetworksAsync(new NetworksListParameters { Filters = _filters }); + + var response = await _dockerClient.Volumes.ListAsync(new VolumesListParameters { Filters = _filters }); + + Assert.Single(containers); + Assert.Single(networks); + Assert.Single(response.Volumes); + } + + public static class UnsupportedBuilderConfigurationTest + { + private const string EnabledCleanUpExceptionMessage = "Reuse cannot be used in conjunction with WithCleanUp(true). (Parameter 'Reuse')"; + + private const string EnabledAutoRemoveExceptionMessage = "Reuse cannot be used in conjunction with WithAutoRemove(true). (Parameter 'Reuse')"; + + public sealed class ContainerBuilderTest + { + [Fact] + public void EnabledCleanUpThrowsException() + { + // Given + var containerBuilder = new ContainerBuilder().WithReuse(true).WithCleanUp(true); + + // When + var exception = Assert.Throws(() => containerBuilder.Build()); + + // Then + Assert.Equal(EnabledCleanUpExceptionMessage, exception.Message); + } + + [Fact] + public void EnabledAutoRemoveThrowsException() + { + // Given + var containerBuilder = new ContainerBuilder().WithReuse(true).WithAutoRemove(true); + + // When + var exception = Assert.Throws(() => containerBuilder.Build()); + + // Then + Assert.Equal(EnabledAutoRemoveExceptionMessage, exception.Message); + } + } + + public sealed class NetworkBuilderTest + { + [Fact] + public void EnabledCleanUpThrowsException() + { + // Given + var containerBuilder = new NetworkBuilder().WithReuse(true).WithCleanUp(true); + + // When + var exception = Assert.Throws(() => containerBuilder.Build()); + + // Then + Assert.Equal(EnabledCleanUpExceptionMessage, exception.Message); + } + } + + public sealed class VolumeBuilderTest + { + [Fact] + public void EnabledCleanUpThrowsException() + { + // Given + var containerBuilder = new VolumeBuilder().WithReuse(true).WithCleanUp(true); + + // When + var exception = Assert.Throws(() => containerBuilder.Build()); + + // Then + Assert.Equal(EnabledCleanUpExceptionMessage, exception.Message); + } + } + } +} \ No newline at end of file diff --git a/tests/Testcontainers.Platform.Linux.Tests/Usings.cs b/tests/Testcontainers.Platform.Linux.Tests/Usings.cs index 69f9a7f8a..8e7257e47 100644 --- a/tests/Testcontainers.Platform.Linux.Tests/Usings.cs +++ b/tests/Testcontainers.Platform.Linux.Tests/Usings.cs @@ -8,9 +8,11 @@ global using System.Text; global using System.Threading; global using System.Threading.Tasks; +global using Docker.DotNet; global using Docker.DotNet.Models; global using DotNet.Testcontainers; global using DotNet.Testcontainers.Builders; +global using DotNet.Testcontainers.Clients; global using DotNet.Testcontainers.Commons; global using DotNet.Testcontainers.Configurations; global using DotNet.Testcontainers.Containers;