From d397fac87cc9d7936b2258a64f787f9e3823e2c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?D=C3=A1vid=20Szab=C3=B3?= Date: Tue, 14 Nov 2023 21:46:10 +0100 Subject: [PATCH 01/12] feat: Reusable containers --- .../Builders/ContainerBuilder`3.cs | 11 ++++ .../Builders/IContainerBuilder`2.cs | 8 +++ .../Clients/DockerContainerOperations.cs | 33 ++++++++++ .../Clients/TestcontainersClient.cs | 1 + .../Containers/ContainerConfiguration.cs | 8 ++- .../Containers/IContainerConfiguration.cs | 5 ++ .../ReuseTest.cs | 63 +++++++++++++++++++ 7 files changed, 128 insertions(+), 1 deletion(-) create mode 100644 tests/Testcontainers.Platform.Linux.Tests/ReuseTest.cs diff --git a/src/Testcontainers/Builders/ContainerBuilder`3.cs b/src/Testcontainers/Builders/ContainerBuilder`3.cs index ef68e607c..44f0fbd71 100644 --- a/src/Testcontainers/Builders/ContainerBuilder`3.cs +++ b/src/Testcontainers/Builders/ContainerBuilder`3.cs @@ -8,6 +8,7 @@ namespace DotNet.Testcontainers.Builders using System.Threading; using System.Threading.Tasks; using Docker.DotNet.Models; + using DotNet.Testcontainers.Clients; using DotNet.Testcontainers.Configurations; using DotNet.Testcontainers.Containers; using DotNet.Testcontainers.Images; @@ -329,6 +330,16 @@ public TBuilderEntity WithAutoRemove(bool autoRemove) return Clone(new ContainerConfiguration(autoRemove: autoRemove)); } + /// + public TBuilderEntity WithReuse(bool reuse) + { + return Clone(new ContainerConfiguration(reuse: reuse)).WithCreateParameterModifier(createContainerParameters => + { + createContainerParameters.Labels.Remove(TestcontainersClient.TestcontainersSessionIdLabel); + createContainerParameters.Labels.Remove(ResourceReaper.ResourceReaperSessionLabel); + }); + } + /// public TBuilderEntity WithPrivileged(bool privileged) { diff --git a/src/Testcontainers/Builders/IContainerBuilder`2.cs b/src/Testcontainers/Builders/IContainerBuilder`2.cs index 6516c3c22..974b1e26c 100644 --- a/src/Testcontainers/Builders/IContainerBuilder`2.cs +++ b/src/Testcontainers/Builders/IContainerBuilder`2.cs @@ -392,6 +392,14 @@ public interface IContainerBuilder : I [PublicAPI] TBuilderEntity WithAutoRemove(bool autoRemove); + /// + /// TODO + /// + /// TODO + /// A configured instance of . + [PublicAPI] + TBuilderEntity WithReuse(bool reuse); + /// /// Sets the privileged flag. /// diff --git a/src/Testcontainers/Clients/DockerContainerOperations.cs b/src/Testcontainers/Clients/DockerContainerOperations.cs index 42cd9d9c0..beefed648 100644 --- a/src/Testcontainers/Clients/DockerContainerOperations.cs +++ b/src/Testcontainers/Clients/DockerContainerOperations.cs @@ -4,6 +4,9 @@ namespace DotNet.Testcontainers.Clients using System.Collections.Generic; using System.Globalization; using System.IO; + using System.Linq; + using System.Security.Cryptography; + using System.Text; using System.Threading; using System.Threading.Tasks; using Docker.DotNet; @@ -208,11 +211,41 @@ public async Task RunAsync(IContainerConfiguration configuration, Cancel } } + if (configuration.Reuse.HasValue && configuration.Reuse.Value) + { + var hash = CreateReuseHash(createParameters); + createParameters.Labels.Add(TestcontainersClient.TestcontainersReuseHashLabel, hash); + + var containers = await Docker.Containers.ListContainersAsync(new ContainersListParameters() + { + Filters = new FilterByProperty { + { "label", $"{TestcontainersClient.TestcontainersReuseHashLabel}={hash}" } + }, + Limit = 1, + }, ct) + .ConfigureAwait(false); + + var container = containers.FirstOrDefault(); + if (container != default) { + return container.ID; + } + } + var createContainerResponse = await Docker.Containers.CreateContainerAsync(createParameters, ct) .ConfigureAwait(false); _logger.DockerContainerCreated(createContainerResponse.ID); return createContainerResponse.ID; } + + static string CreateReuseHash(CreateContainerParameters createContainerParameters) + { + var createContainerParametersJson = System.Text.Json.JsonSerializer.Serialize(createContainerParameters); + + using (var sha1 = SHA1.Create()) + { + return Convert.ToBase64String(sha1.ComputeHash(Encoding.Default.GetBytes(createContainerParametersJson))); + } + } } } diff --git a/src/Testcontainers/Clients/TestcontainersClient.cs b/src/Testcontainers/Clients/TestcontainersClient.cs index d7b397c1b..9d4008c5a 100644 --- a/src/Testcontainers/Clients/TestcontainersClient.cs +++ b/src/Testcontainers/Clients/TestcontainersClient.cs @@ -26,6 +26,7 @@ internal sealed class TestcontainersClient : ITestcontainersClient public const string TestcontainersVersionLabel = TestcontainersLabel + ".version"; public const string TestcontainersSessionIdLabel = TestcontainersLabel + ".session-id"; + public const string TestcontainersReuseHashLabel = TestcontainersLabel + ".reuse-hash"; public static readonly string Version = typeof(TestcontainersClient).Assembly.GetCustomAttribute().InformationalVersion; diff --git a/src/Testcontainers/Configurations/Containers/ContainerConfiguration.cs b/src/Testcontainers/Configurations/Containers/ContainerConfiguration.cs index 34833b3b7..98a0d1c05 100644 --- a/src/Testcontainers/Configurations/Containers/ContainerConfiguration.cs +++ b/src/Testcontainers/Configurations/Containers/ContainerConfiguration.cs @@ -62,7 +62,8 @@ public class ContainerConfiguration : ResourceConfiguration waitStrategies = null, Func startupCallback = null, bool? autoRemove = null, - bool? privileged = null) + bool? privileged = null, + bool? reuse = null) { AutoRemove = autoRemove; Privileged = privileged; @@ -86,6 +87,7 @@ public class ContainerConfiguration : ResourceConfiguration @@ -136,11 +138,15 @@ public ContainerConfiguration(IContainerConfiguration oldValue, IContainerConfig StartupCallback = BuildConfiguration.Combine(oldValue.StartupCallback, newValue.StartupCallback); AutoRemove = (oldValue.AutoRemove.HasValue && oldValue.AutoRemove.Value) || (newValue.AutoRemove.HasValue && newValue.AutoRemove.Value); Privileged = (oldValue.Privileged.HasValue && oldValue.Privileged.Value) || (newValue.Privileged.HasValue && newValue.Privileged.Value); + Reuse = (oldValue.Reuse.HasValue && oldValue.Reuse.Value) || (newValue.Reuse.HasValue && newValue.Reuse.Value); } /// public bool? AutoRemove { get; } + /// + public bool? Reuse { get; } + /// public bool? Privileged { get; } diff --git a/src/Testcontainers/Configurations/Containers/IContainerConfiguration.cs b/src/Testcontainers/Configurations/Containers/IContainerConfiguration.cs index 1093e8480..0016fd35b 100644 --- a/src/Testcontainers/Configurations/Containers/IContainerConfiguration.cs +++ b/src/Testcontainers/Configurations/Containers/IContainerConfiguration.cs @@ -21,6 +21,11 @@ public interface IContainerConfiguration : IResourceConfiguration bool? AutoRemove { get; } + /// + /// TODO + /// + bool? Reuse { get; } + /// /// Gets a value indicating whether the privileged flag is set or not. /// diff --git a/tests/Testcontainers.Platform.Linux.Tests/ReuseTest.cs b/tests/Testcontainers.Platform.Linux.Tests/ReuseTest.cs new file mode 100644 index 000000000..380faae9b --- /dev/null +++ b/tests/Testcontainers.Platform.Linux.Tests/ReuseTest.cs @@ -0,0 +1,63 @@ +namespace Testcontainers.Tests; + +public sealed class ReuseTest : IAsyncLifetime +{ + private const string LabelKey = "org.testcontainers.reuse-test"; + private const string LabelValue = "true"; + private const int ContainersToCreate = 3; + + private readonly List _containers = new(); + + private static IContainer CreateContainer() + { + return new ContainerBuilder() + .WithImage(CommonImages.Alpine) + .WithLabel(LabelKey, LabelValue) + .WithAutoRemove(false) + .WithCleanUp(false) + .WithReuse(true) + .WithEntrypoint("sleep") + .WithCommand("infinity") + .Build(); + } + + public async Task InitializeAsync() + { + for (var i = 0; i < ContainersToCreate; i++) + { + var container = CreateContainer(); + await container.StartAsync(); + _containers.Add(container); + } + } + + public async Task DisposeAsync() + { + var distinctContainers = _containers.DistinctBy(container => container.Id).ToList(); + await Task.WhenAll(distinctContainers.Select(container => container.DisposeAsync().AsTask())); + + using var clientConfiguration = TestcontainersSettings.OS.DockerEndpointAuthConfig.GetDockerClientConfiguration(ResourceReaper.DefaultSessionId); + using var client = clientConfiguration.CreateClient(); + await Task.WhenAll(distinctContainers.Select(container => client.Containers.RemoveContainerAsync(container.Id, new()))); + } + + [Fact] + [Trait(nameof(DockerCli.DockerPlatform), nameof(DockerCli.DockerPlatform.Linux))] + public async Task ReusesContainer() + { + // Given + using var clientConfiguration = TestcontainersSettings.OS.DockerEndpointAuthConfig.GetDockerClientConfiguration(ResourceReaper.DefaultSessionId); + using var client = clientConfiguration.CreateClient(); + + var labelFilter = new Dictionary { { $"{LabelKey}={LabelValue}", true } }; + var filters = new Dictionary> { { "label", labelFilter } }; + var containersListParameters = new ContainersListParameters { All = true, Filters = filters }; + + // When + var containers = await client.Containers.ListContainersAsync(containersListParameters) + .ConfigureAwait(false); + + // Then + Assert.Single(containers); + } +} \ No newline at end of file From 45a578b65309af48a7d39dcdbf4a31e7f48f60d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?D=C3=A1vid=20Szab=C3=B3?= Date: Wed, 15 Nov 2023 18:53:19 +0100 Subject: [PATCH 02/12] Refactor to make WithReuse available for all resources --- Packages.props | 1 + .../Builders/AbstractBuilder`4.cs | 10 ++++ .../Builders/ContainerBuilder`3.cs | 11 ---- .../Builders/IAbstractBuilder`3.cs | 8 +++ .../Builders/IContainerBuilder`2.cs | 8 --- .../Clients/DockerContainerOperations.cs | 33 ------------ .../Commons/IResourceConfiguration.cs | 10 ++++ .../Commons/ResourceConfiguration.cs | 13 ++++- .../Containers/ContainerConfiguration.cs | 51 ++++++++++++++++--- .../Containers/IContainerConfiguration.cs | 5 -- .../Containers/DockerContainer.cs | 16 +++++- src/Testcontainers/Networks/DockerNetwork.cs | 17 ++++++- src/Testcontainers/Testcontainers.csproj | 1 + src/Testcontainers/Volumes/DockerVolume.cs | 19 ++++++- .../ReuseTest.cs | 12 ++++- 15 files changed, 144 insertions(+), 71 deletions(-) diff --git a/Packages.props b/Packages.props index ef7c70552..900b16baa 100644 --- a/Packages.props +++ b/Packages.props @@ -10,6 +10,7 @@ + diff --git a/src/Testcontainers/Builders/AbstractBuilder`4.cs b/src/Testcontainers/Builders/AbstractBuilder`4.cs index 70d45fa2a..47910289a 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, labels: new Dictionary { { TestcontainersClient.TestcontainersReuseHashLabel, DockerResourceConfiguration.GetHash() } })); + } + /// public TBuilderEntity WithLabel(string name, string value) { @@ -129,6 +135,10 @@ 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/"; _ = Guard.Argument(DockerResourceConfiguration.DockerEndpointAuthConfig, nameof(IResourceConfiguration.DockerEndpointAuthConfig)) .ThrowIf(argument => argument.Value == null, argument => new ArgumentException(message, argument.Name)); + + // TODO: Validate WithReuse(), WithAutoRemove() and WithCleanUp() combinations. + Guard.Argument(DockerResourceConfiguration.Reuse, nameof(IResourceConfiguration.Reuse)) + .ThrowIf(argument => DockerResourceConfiguration.Labels.ContainsKey(TestcontainersClient.TestcontainersReuseHashLabel) && DockerResourceConfiguration.Labels[TestcontainersClient.TestcontainersReuseHashLabel] != DockerResourceConfiguration.GetHash(), argument => new ArgumentException("ResoureConfiguration hash mismatch, WithReuse(true) must be the last called builder method", argument.Name)); } /// diff --git a/src/Testcontainers/Builders/ContainerBuilder`3.cs b/src/Testcontainers/Builders/ContainerBuilder`3.cs index 44f0fbd71..ef68e607c 100644 --- a/src/Testcontainers/Builders/ContainerBuilder`3.cs +++ b/src/Testcontainers/Builders/ContainerBuilder`3.cs @@ -8,7 +8,6 @@ namespace DotNet.Testcontainers.Builders using System.Threading; using System.Threading.Tasks; using Docker.DotNet.Models; - using DotNet.Testcontainers.Clients; using DotNet.Testcontainers.Configurations; using DotNet.Testcontainers.Containers; using DotNet.Testcontainers.Images; @@ -330,16 +329,6 @@ public TBuilderEntity WithAutoRemove(bool autoRemove) return Clone(new ContainerConfiguration(autoRemove: autoRemove)); } - /// - public TBuilderEntity WithReuse(bool reuse) - { - return Clone(new ContainerConfiguration(reuse: reuse)).WithCreateParameterModifier(createContainerParameters => - { - createContainerParameters.Labels.Remove(TestcontainersClient.TestcontainersSessionIdLabel); - createContainerParameters.Labels.Remove(ResourceReaper.ResourceReaperSessionLabel); - }); - } - /// public TBuilderEntity WithPrivileged(bool privileged) { diff --git a/src/Testcontainers/Builders/IAbstractBuilder`3.cs b/src/Testcontainers/Builders/IAbstractBuilder`3.cs index 420412fd8..ca5249315 100644 --- a/src/Testcontainers/Builders/IAbstractBuilder`3.cs +++ b/src/Testcontainers/Builders/IAbstractBuilder`3.cs @@ -59,6 +59,14 @@ public interface IAbstractBuilder + /// TODO + /// + /// TODO + /// A configured instance of . + [PublicAPI] + TBuilderEntity WithReuse(bool reuse); + /// /// Adds user-defined metadata to the Docker resource. /// diff --git a/src/Testcontainers/Builders/IContainerBuilder`2.cs b/src/Testcontainers/Builders/IContainerBuilder`2.cs index 974b1e26c..6516c3c22 100644 --- a/src/Testcontainers/Builders/IContainerBuilder`2.cs +++ b/src/Testcontainers/Builders/IContainerBuilder`2.cs @@ -392,14 +392,6 @@ public interface IContainerBuilder : I [PublicAPI] TBuilderEntity WithAutoRemove(bool autoRemove); - /// - /// TODO - /// - /// TODO - /// A configured instance of . - [PublicAPI] - TBuilderEntity WithReuse(bool reuse); - /// /// Sets the privileged flag. /// diff --git a/src/Testcontainers/Clients/DockerContainerOperations.cs b/src/Testcontainers/Clients/DockerContainerOperations.cs index beefed648..42cd9d9c0 100644 --- a/src/Testcontainers/Clients/DockerContainerOperations.cs +++ b/src/Testcontainers/Clients/DockerContainerOperations.cs @@ -4,9 +4,6 @@ namespace DotNet.Testcontainers.Clients using System.Collections.Generic; using System.Globalization; using System.IO; - using System.Linq; - using System.Security.Cryptography; - using System.Text; using System.Threading; using System.Threading.Tasks; using Docker.DotNet; @@ -211,41 +208,11 @@ public async Task RunAsync(IContainerConfiguration configuration, Cancel } } - if (configuration.Reuse.HasValue && configuration.Reuse.Value) - { - var hash = CreateReuseHash(createParameters); - createParameters.Labels.Add(TestcontainersClient.TestcontainersReuseHashLabel, hash); - - var containers = await Docker.Containers.ListContainersAsync(new ContainersListParameters() - { - Filters = new FilterByProperty { - { "label", $"{TestcontainersClient.TestcontainersReuseHashLabel}={hash}" } - }, - Limit = 1, - }, ct) - .ConfigureAwait(false); - - var container = containers.FirstOrDefault(); - if (container != default) { - return container.ID; - } - } - var createContainerResponse = await Docker.Containers.CreateContainerAsync(createParameters, ct) .ConfigureAwait(false); _logger.DockerContainerCreated(createContainerResponse.ID); return createContainerResponse.ID; } - - static string CreateReuseHash(CreateContainerParameters createContainerParameters) - { - var createContainerParametersJson = System.Text.Json.JsonSerializer.Serialize(createContainerParameters); - - using (var sha1 = SHA1.Create()) - { - return Convert.ToBase64String(sha1.ComputeHash(Encoding.Default.GetBytes(createContainerParametersJson))); - } - } } } diff --git a/src/Testcontainers/Configurations/Commons/IResourceConfiguration.cs b/src/Testcontainers/Configurations/Commons/IResourceConfiguration.cs index d10ceb500..0b60a342a 100644 --- a/src/Testcontainers/Configurations/Commons/IResourceConfiguration.cs +++ b/src/Testcontainers/Configurations/Commons/IResourceConfiguration.cs @@ -30,5 +30,15 @@ public interface IResourceConfiguration /// Gets a list of low level modifications of the Docker.DotNet entity. /// IReadOnlyList> ParameterModifiers { get; } + + /// + /// TODO + /// + bool? Reuse { get; } + + /// + /// TODO + /// + string GetHash(); } } diff --git a/src/Testcontainers/Configurations/Commons/ResourceConfiguration.cs b/src/Testcontainers/Configurations/Commons/ResourceConfiguration.cs index d66fbac76..1c87bb975 100644 --- a/src/Testcontainers/Configurations/Commons/ResourceConfiguration.cs +++ b/src/Testcontainers/Configurations/Commons/ResourceConfiguration.cs @@ -19,12 +19,14 @@ public class ResourceConfiguration : IResourceConfigurati 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; } /// @@ -45,7 +47,8 @@ protected ResourceConfiguration(IResourceConfiguration ol : this( dockerEndpointAuthenticationConfiguration: BuildConfiguration.Combine(oldValue.DockerEndpointAuthConfig, newValue.DockerEndpointAuthConfig), parameterModifiers: BuildConfiguration.Combine(oldValue.ParameterModifiers, newValue.ParameterModifiers), - labels: BuildConfiguration.Combine(oldValue.Labels, newValue.Labels)) + labels: BuildConfiguration.Combine(oldValue.Labels, newValue.Labels), + reuse: BuildConfiguration.Combine(oldValue.Reuse, newValue.Reuse)) { } @@ -60,5 +63,11 @@ protected ResourceConfiguration(IResourceConfiguration ol /// public IReadOnlyList> ParameterModifiers { get; } + + public bool? Reuse { get; } + + public virtual string GetHash() { + throw new NotImplementedException(); + } } } diff --git a/src/Testcontainers/Configurations/Containers/ContainerConfiguration.cs b/src/Testcontainers/Configurations/Containers/ContainerConfiguration.cs index 98a0d1c05..fa1a4b7f3 100644 --- a/src/Testcontainers/Configurations/Containers/ContainerConfiguration.cs +++ b/src/Testcontainers/Configurations/Containers/ContainerConfiguration.cs @@ -2,14 +2,19 @@ namespace DotNet.Testcontainers.Configurations { using System; using System.Collections.Generic; + using System.Linq; + using System.Security.Cryptography; + using System.Text; using System.Threading; using System.Threading.Tasks; using Docker.DotNet.Models; using DotNet.Testcontainers.Builders; + using DotNet.Testcontainers.Clients; using DotNet.Testcontainers.Containers; using DotNet.Testcontainers.Images; using DotNet.Testcontainers.Networks; using JetBrains.Annotations; + using Newtonsoft.Json.Linq; /// [PublicAPI] @@ -62,8 +67,7 @@ public class ContainerConfiguration : ResourceConfiguration waitStrategies = null, Func startupCallback = null, bool? autoRemove = null, - bool? privileged = null, - bool? reuse = null) + bool? privileged = null) { AutoRemove = autoRemove; Privileged = privileged; @@ -87,7 +91,6 @@ public class ContainerConfiguration : ResourceConfiguration @@ -138,15 +141,11 @@ public ContainerConfiguration(IContainerConfiguration oldValue, IContainerConfig StartupCallback = BuildConfiguration.Combine(oldValue.StartupCallback, newValue.StartupCallback); AutoRemove = (oldValue.AutoRemove.HasValue && oldValue.AutoRemove.Value) || (newValue.AutoRemove.HasValue && newValue.AutoRemove.Value); Privileged = (oldValue.Privileged.HasValue && oldValue.Privileged.Value) || (newValue.Privileged.HasValue && newValue.Privileged.Value); - Reuse = (oldValue.Reuse.HasValue && oldValue.Reuse.Value) || (newValue.Reuse.HasValue && newValue.Reuse.Value); } /// public bool? AutoRemove { get; } - /// - public bool? Reuse { get; } - /// public bool? Privileged { get; } @@ -209,5 +208,43 @@ public ContainerConfiguration(IContainerConfiguration oldValue, IContainerConfig /// public Func StartupCallback { get; } + + public override string GetHash() + { + var fingerprint = new + { + AutoRemove = AutoRemove, + Privileged = Privileged, + ExtraHosts = ExtraHosts, + PortBindings = PortBindings, + Mounts = Mounts, + + Networks = Networks, + + Image = Image, + Name = Name, + Hostname = Hostname, + MacAddress = MacAddress, + WorkingDir = WorkingDirectory, + Entrypoint = Entrypoint, + Cmd = Command, + Env = Environments, + Labels = Labels, + ExposedPorts = ExposedPorts, + + ParameterModifiers = ParameterModifiers.Select(parameterModifier => parameterModifier.GetHashCode()), + }; + + var fingerprintJObject = JObject.FromObject(fingerprint); + fingerprintJObject.SelectToken($"$.Labels.['{TestcontainersClient.TestcontainersSessionIdLabel}']")?.Parent.Remove(); + fingerprintJObject.SelectToken($"$.Labels.['{ResourceReaper.ResourceReaperSessionLabel}']")?.Parent.Remove(); + fingerprintJObject.SelectToken($"$.Labels.['{TestcontainersClient.TestcontainersReuseHashLabel}']")?.Parent.Remove(); + + var fingerprintJson = fingerprintJObject.ToString(); + using (var sha1 = SHA1.Create()) + { + return Convert.ToBase64String(sha1.ComputeHash(Encoding.Default.GetBytes(fingerprintJson))); + } + } } } diff --git a/src/Testcontainers/Configurations/Containers/IContainerConfiguration.cs b/src/Testcontainers/Configurations/Containers/IContainerConfiguration.cs index 0016fd35b..1093e8480 100644 --- a/src/Testcontainers/Configurations/Containers/IContainerConfiguration.cs +++ b/src/Testcontainers/Configurations/Containers/IContainerConfiguration.cs @@ -21,11 +21,6 @@ public interface IContainerConfiguration : IResourceConfiguration bool? AutoRemove { get; } - /// - /// TODO - /// - bool? Reuse { get; } - /// /// Gets a value indicating whether the privileged flag is set or not. /// diff --git a/src/Testcontainers/Containers/DockerContainer.cs b/src/Testcontainers/Containers/DockerContainer.cs index 1e9bbf855..4253dbe7e 100644 --- a/src/Testcontainers/Containers/DockerContainer.cs +++ b/src/Testcontainers/Containers/DockerContainer.cs @@ -364,8 +364,22 @@ protected override async Task UnsafeCreateAsync(CancellationToken ct = default) Creating?.Invoke(this, EventArgs.Empty); - var id = await _client.RunAsync(_configuration, ct) + string id = string.Empty; + if (_configuration.Reuse == true) + { + var reusableContainer = (await _client.Container.GetAllAsync(new FilterByProperty { { "label", $"{TestcontainersClient.TestcontainersReuseHashLabel}={_configuration.GetHash()}" } }, ct).ConfigureAwait(false)).FirstOrDefault(); + + if (reusableContainer != null) + { + id = reusableContainer.ID; + } + } + + if (id == string.Empty) + { + id = await _client.RunAsync(_configuration, ct) .ConfigureAwait(false); + } _container = await _client.Container.ByIdAsync(id, ct) .ConfigureAwait(false); diff --git a/src/Testcontainers/Networks/DockerNetwork.cs b/src/Testcontainers/Networks/DockerNetwork.cs index 9eea998d6..0ded62990 100644 --- a/src/Testcontainers/Networks/DockerNetwork.cs +++ b/src/Testcontainers/Networks/DockerNetwork.cs @@ -1,6 +1,7 @@ namespace DotNet.Testcontainers.Networks { using System; + using System.Linq; using System.Threading; using System.Threading.Tasks; using Docker.DotNet.Models; @@ -94,8 +95,22 @@ protected override async Task UnsafeCreateAsync(CancellationToken ct = default) return; } - var id = await _client.Network.CreateAsync(_configuration, ct) + string id = string.Empty; + if (_configuration.Reuse == true) + { + var reusableNetwork = (await _client.Network.GetAllAsync(new FilterByProperty { { "label", $"{TestcontainersClient.TestcontainersReuseHashLabel}={_configuration.GetHash()}" } }, ct).ConfigureAwait(false)).FirstOrDefault(); + + if (reusableNetwork != null) + { + id = reusableNetwork.Name; + } + } + + if (id == string.Empty) + { + id = await _client.Network.CreateAsync(_configuration, ct) .ConfigureAwait(false); + } _network = await _client.Network.ByIdAsync(id, ct) .ConfigureAwait(false); diff --git a/src/Testcontainers/Testcontainers.csproj b/src/Testcontainers/Testcontainers.csproj index ffb609e61..8275fed3d 100644 --- a/src/Testcontainers/Testcontainers.csproj +++ b/src/Testcontainers/Testcontainers.csproj @@ -13,6 +13,7 @@ + diff --git a/src/Testcontainers/Volumes/DockerVolume.cs b/src/Testcontainers/Volumes/DockerVolume.cs index b161c0e8e..90e0cc8b8 100644 --- a/src/Testcontainers/Volumes/DockerVolume.cs +++ b/src/Testcontainers/Volumes/DockerVolume.cs @@ -1,6 +1,7 @@ namespace DotNet.Testcontainers.Volumes { using System; + using System.Linq; using System.Threading; using System.Threading.Tasks; using Docker.DotNet.Models; @@ -94,8 +95,22 @@ protected override async Task UnsafeCreateAsync(CancellationToken ct = default) return; } - var id = await _client.Volume.CreateAsync(_configuration, ct) - .ConfigureAwait(false); + string id = string.Empty; + if (_configuration.Reuse == true) + { + var reusableVolume = (await _client.Volume.GetAllAsync(new FilterByProperty { { "label", $"{TestcontainersClient.TestcontainersReuseHashLabel}={_configuration.GetHash()}" } }, ct).ConfigureAwait(false)).FirstOrDefault(); + + if (reusableVolume != null) + { + id = reusableVolume.Name; + } + } + + if (id == string.Empty) + { + id = await _client.Volume.CreateAsync(_configuration, ct) + .ConfigureAwait(false); + } _volume = await _client.Volume.ByIdAsync(id, ct) .ConfigureAwait(false); diff --git a/tests/Testcontainers.Platform.Linux.Tests/ReuseTest.cs b/tests/Testcontainers.Platform.Linux.Tests/ReuseTest.cs index 380faae9b..52c8e95f9 100644 --- a/tests/Testcontainers.Platform.Linux.Tests/ReuseTest.cs +++ b/tests/Testcontainers.Platform.Linux.Tests/ReuseTest.cs @@ -15,9 +15,9 @@ private static IContainer CreateContainer() .WithLabel(LabelKey, LabelValue) .WithAutoRemove(false) .WithCleanUp(false) - .WithReuse(true) .WithEntrypoint("sleep") .WithCommand("infinity") + .WithReuse(true) .Build(); } @@ -60,4 +60,14 @@ public async Task ReusesContainer() // Then Assert.Single(containers); } + + [Fact] + [Trait(nameof(DockerCli.DockerPlatform), nameof(DockerCli.DockerPlatform.Linux))] + public void ThrowsIfWithReuseIsNotLastCalledMethod() + { + Assert.Throws(() => new ContainerBuilder() + .WithReuse(true) + .WithImage(CommonImages.Alpine) + .Build()); + } } \ No newline at end of file From 964d61da24ce199f5f78c2e8d40cd7d63137dc05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?D=C3=A1vid=20Szab=C3=B3?= Date: Thu, 23 Nov 2023 21:45:17 +0100 Subject: [PATCH 03/12] Remove Newtonsoft JSON dependency --- Packages.props | 1 - .../Containers/ContainerConfiguration.cs | 49 ++++++++++--------- src/Testcontainers/Testcontainers.csproj | 1 - 3 files changed, 25 insertions(+), 26 deletions(-) diff --git a/Packages.props b/Packages.props index 900b16baa..ef7c70552 100644 --- a/Packages.props +++ b/Packages.props @@ -10,7 +10,6 @@ - diff --git a/src/Testcontainers/Configurations/Containers/ContainerConfiguration.cs b/src/Testcontainers/Configurations/Containers/ContainerConfiguration.cs index fa1a4b7f3..a7399b350 100644 --- a/src/Testcontainers/Configurations/Containers/ContainerConfiguration.cs +++ b/src/Testcontainers/Configurations/Containers/ContainerConfiguration.cs @@ -5,6 +5,7 @@ namespace DotNet.Testcontainers.Configurations using System.Linq; using System.Security.Cryptography; using System.Text; + using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Docker.DotNet.Models; @@ -14,7 +15,6 @@ namespace DotNet.Testcontainers.Configurations using DotNet.Testcontainers.Images; using DotNet.Testcontainers.Networks; using JetBrains.Annotations; - using Newtonsoft.Json.Linq; /// [PublicAPI] @@ -213,34 +213,35 @@ public override string GetHash() { var fingerprint = new { - AutoRemove = AutoRemove, - Privileged = Privileged, - ExtraHosts = ExtraHosts, - PortBindings = PortBindings, - Mounts = Mounts, - - Networks = Networks, - - Image = Image, - Name = Name, - Hostname = Hostname, - MacAddress = MacAddress, - WorkingDir = WorkingDirectory, - Entrypoint = Entrypoint, - Cmd = Command, - Env = Environments, - Labels = Labels, - ExposedPorts = ExposedPorts, + AutoRemove, + Privileged, + ExtraHosts, + PortBindings, + Mounts, + + Networks, + + Image, + Name, + Hostname, + MacAddress, + WorkingDirectory, + Entrypoint, + Command, + Environments, + Labels, + ExposedPorts, ParameterModifiers = ParameterModifiers.Select(parameterModifier => parameterModifier.GetHashCode()), }; - var fingerprintJObject = JObject.FromObject(fingerprint); - fingerprintJObject.SelectToken($"$.Labels.['{TestcontainersClient.TestcontainersSessionIdLabel}']")?.Parent.Remove(); - fingerprintJObject.SelectToken($"$.Labels.['{ResourceReaper.ResourceReaperSessionLabel}']")?.Parent.Remove(); - fingerprintJObject.SelectToken($"$.Labels.['{TestcontainersClient.TestcontainersReuseHashLabel}']")?.Parent.Remove(); + var fingerPrintJsonNode = JsonSerializer.SerializeToNode(fingerprint); + var labelsJsonObject = fingerPrintJsonNode["Labels"].AsObject(); + labelsJsonObject.Remove(TestcontainersClient.TestcontainersSessionIdLabel); + labelsJsonObject.Remove(ResourceReaper.ResourceReaperSessionLabel); + labelsJsonObject.Remove(TestcontainersClient.TestcontainersReuseHashLabel); - var fingerprintJson = fingerprintJObject.ToString(); + var fingerprintJson = fingerPrintJsonNode.ToJsonString(); using (var sha1 = SHA1.Create()) { return Convert.ToBase64String(sha1.ComputeHash(Encoding.Default.GetBytes(fingerprintJson))); diff --git a/src/Testcontainers/Testcontainers.csproj b/src/Testcontainers/Testcontainers.csproj index 8275fed3d..ffb609e61 100644 --- a/src/Testcontainers/Testcontainers.csproj +++ b/src/Testcontainers/Testcontainers.csproj @@ -13,7 +13,6 @@ - From 7c194b359f1ba13871a223e3523540ce6e4080b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?D=C3=A1vid=20Szab=C3=B3?= Date: Fri, 24 Nov 2023 15:57:23 +0100 Subject: [PATCH 04/12] Serialize resource configuration --- .../Commons/ExcludeDynamicLabelsConverter.cs | 33 +++++++++ .../Commons/ResourceConfiguration.cs | 19 ++++- .../Containers/ContainerConfiguration.cs | 51 ++------------ .../Networks/NetworkConfiguration.cs | 3 + .../ReuseTest.cs | 69 ++++++++++++++----- .../WithReuseTest.cs | 14 ++++ 6 files changed, 126 insertions(+), 63 deletions(-) create mode 100644 src/Testcontainers/Configurations/Commons/ExcludeDynamicLabelsConverter.cs create mode 100644 tests/Testcontainers.Platform.Linux.Tests/WithReuseTest.cs diff --git a/src/Testcontainers/Configurations/Commons/ExcludeDynamicLabelsConverter.cs b/src/Testcontainers/Configurations/Commons/ExcludeDynamicLabelsConverter.cs new file mode 100644 index 000000000..4ecb023c7 --- /dev/null +++ b/src/Testcontainers/Configurations/Commons/ExcludeDynamicLabelsConverter.cs @@ -0,0 +1,33 @@ +using DotNet.Testcontainers.Clients; +using DotNet.Testcontainers.Containers; +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace DotNet.Testcontainers.Configurations +{ + internal class ExcludeDynamicLabelsConverter : JsonConverter> + { + public override bool CanConvert(Type typeToConvert) + { + return true; + } + + 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 = JsonSerializer.SerializeToNode(value).AsObject(); + + labels.Remove(TestcontainersClient.TestcontainersSessionIdLabel); + labels.Remove(ResourceReaper.ResourceReaperSessionLabel); + labels.Remove(TestcontainersClient.TestcontainersReuseHashLabel); + + labels.WriteTo(writer); + } + } +} diff --git a/src/Testcontainers/Configurations/Commons/ResourceConfiguration.cs b/src/Testcontainers/Configurations/Commons/ResourceConfiguration.cs index 1c87bb975..ffba16c1d 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; @@ -53,21 +56,33 @@ protected ResourceConfiguration(IResourceConfiguration ol } /// + [JsonIgnore] public Guid SessionId { get; } /// + [JsonIgnore] public IDockerEndpointAuthenticationConfiguration DockerEndpointAuthConfig { get; } /// + [JsonConverter(typeof(ExcludeDynamicLabelsConverter))] public IReadOnlyDictionary Labels { get; } /// + [JsonIgnore] public IReadOnlyList> ParameterModifiers { get; } + [JsonIgnore] public bool? Reuse { get; } - public virtual string GetHash() { - throw new NotImplementedException(); + public virtual string GetHash() + { + var jsonUtf8Bytes = JsonSerializer.SerializeToUtf8Bytes(this, GetType()); + + using (var sha1 = SHA1.Create()) + { + var json = JsonSerializer.Serialize(this, GetType()); + return Convert.ToBase64String(sha1.ComputeHash(jsonUtf8Bytes)); + } } } } diff --git a/src/Testcontainers/Configurations/Containers/ContainerConfiguration.cs b/src/Testcontainers/Configurations/Containers/ContainerConfiguration.cs index a7399b350..635e87b03 100644 --- a/src/Testcontainers/Configurations/Containers/ContainerConfiguration.cs +++ b/src/Testcontainers/Configurations/Containers/ContainerConfiguration.cs @@ -2,15 +2,11 @@ namespace DotNet.Testcontainers.Configurations { using System; using System.Collections.Generic; - using System.Linq; - using System.Security.Cryptography; - using System.Text; - using System.Text.Json; + using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; using Docker.DotNet.Models; using DotNet.Testcontainers.Builders; - using DotNet.Testcontainers.Clients; using DotNet.Testcontainers.Containers; using DotNet.Testcontainers.Images; using DotNet.Testcontainers.Networks; @@ -153,6 +149,7 @@ public ContainerConfiguration(IContainerConfiguration oldValue, IContainerConfig public IImage Image { get; } /// + [JsonIgnore] public Func ImagePullPolicy { get; } /// @@ -183,9 +180,11 @@ public ContainerConfiguration(IContainerConfiguration oldValue, IContainerConfig public IReadOnlyDictionary PortBindings { get; } /// + [JsonIgnore] public IEnumerable ResourceMappings { get; } /// + [JsonIgnore] public IEnumerable Containers { get; } /// @@ -201,51 +200,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; } - - public override string GetHash() - { - var fingerprint = new - { - AutoRemove, - Privileged, - ExtraHosts, - PortBindings, - Mounts, - - Networks, - - Image, - Name, - Hostname, - MacAddress, - WorkingDirectory, - Entrypoint, - Command, - Environments, - Labels, - ExposedPorts, - - ParameterModifiers = ParameterModifiers.Select(parameterModifier => parameterModifier.GetHashCode()), - }; - - var fingerPrintJsonNode = JsonSerializer.SerializeToNode(fingerprint); - var labelsJsonObject = fingerPrintJsonNode["Labels"].AsObject(); - labelsJsonObject.Remove(TestcontainersClient.TestcontainersSessionIdLabel); - labelsJsonObject.Remove(ResourceReaper.ResourceReaperSessionLabel); - labelsJsonObject.Remove(TestcontainersClient.TestcontainersReuseHashLabel); - - var fingerprintJson = fingerPrintJsonNode.ToJsonString(); - using (var sha1 = SHA1.Create()) - { - return Convert.ToBase64String(sha1.ComputeHash(Encoding.Default.GetBytes(fingerprintJson))); - } - } } } 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/tests/Testcontainers.Platform.Linux.Tests/ReuseTest.cs b/tests/Testcontainers.Platform.Linux.Tests/ReuseTest.cs index 52c8e95f9..b229dde13 100644 --- a/tests/Testcontainers.Platform.Linux.Tests/ReuseTest.cs +++ b/tests/Testcontainers.Platform.Linux.Tests/ReuseTest.cs @@ -1,3 +1,6 @@ +using DotNet.Testcontainers.Networks; +using DotNet.Testcontainers.Volumes; + namespace Testcontainers.Tests; public sealed class ReuseTest : IAsyncLifetime @@ -7,8 +10,29 @@ public sealed class ReuseTest : IAsyncLifetime private const int ContainersToCreate = 3; private readonly List _containers = new(); + private readonly List _volumes = new(); + private readonly List _networks = new(); + + private static IVolume BuildVolume() + { + return new VolumeBuilder() + .WithName("reuse-test-volume") + .WithLabel(LabelKey, LabelValue) + .WithReuse(true) + .Build(); + } - private static IContainer CreateContainer() + private static INetwork BuildNetwork() + { + return new NetworkBuilder() + .WithName("reuse-test-network") + .WithDriver(NetworkDriver.Host) + .WithLabel(LabelKey, LabelValue) + .WithReuse(true) + .Build(); + } + + private static IContainer BuildContainer(IVolume volume, INetwork network) { return new ContainerBuilder() .WithImage(CommonImages.Alpine) @@ -17,6 +41,8 @@ private static IContainer CreateContainer() .WithCleanUp(false) .WithEntrypoint("sleep") .WithCommand("infinity") + .WithNetwork(network) + .WithVolumeMount(volume, "/test", AccessMode.ReadWrite) .WithReuse(true) .Build(); } @@ -25,7 +51,15 @@ public async Task InitializeAsync() { for (var i = 0; i < ContainersToCreate; i++) { - var container = CreateContainer(); + var network = BuildNetwork(); + await network.CreateAsync(); + _networks.Add(network); + + var volume = BuildVolume(); + await volume.CreateAsync(); + _volumes.Add(volume); + + var container = BuildContainer(volume, network); await container.StartAsync(); _containers.Add(container); } @@ -33,12 +67,18 @@ public async Task InitializeAsync() public async Task DisposeAsync() { - var distinctContainers = _containers.DistinctBy(container => container.Id).ToList(); - await Task.WhenAll(distinctContainers.Select(container => container.DisposeAsync().AsTask())); - using var clientConfiguration = TestcontainersSettings.OS.DockerEndpointAuthConfig.GetDockerClientConfiguration(ResourceReaper.DefaultSessionId); using var client = clientConfiguration.CreateClient(); + + var distinctContainers = _containers.DistinctBy(container => container.Id).ToList(); + await Task.WhenAll(distinctContainers.Select(container => container.DisposeAsync().AsTask())); await Task.WhenAll(distinctContainers.Select(container => client.Containers.RemoveContainerAsync(container.Id, new()))); + + var distinctVolumes = _volumes.DistinctBy(volume => volume.Name).ToList(); + await Task.WhenAll(distinctVolumes.Select(volume => volume.DisposeAsync().AsTask())); + + var distinctNetworks = _networks.DistinctBy(network => network.Name).ToList(); + await Task.WhenAll(distinctNetworks.Select(network => network.DisposeAsync().AsTask())); } [Fact] @@ -52,22 +92,17 @@ public async Task ReusesContainer() var labelFilter = new Dictionary { { $"{LabelKey}={LabelValue}", true } }; var filters = new Dictionary> { { "label", labelFilter } }; var containersListParameters = new ContainersListParameters { All = true, Filters = filters }; + var networksListParameters = new NetworksListParameters { Filters = filters }; + var volumeListParameters = new VolumesListParameters { Filters = filters }; // When - var containers = await client.Containers.ListContainersAsync(containersListParameters) - .ConfigureAwait(false); + var containers = await client.Containers.ListContainersAsync(containersListParameters); + var networks = await client.Networks.ListNetworksAsync(networksListParameters); + var volumes = await client.Volumes.ListAsync(volumeListParameters); // Then Assert.Single(containers); - } - - [Fact] - [Trait(nameof(DockerCli.DockerPlatform), nameof(DockerCli.DockerPlatform.Linux))] - public void ThrowsIfWithReuseIsNotLastCalledMethod() - { - Assert.Throws(() => new ContainerBuilder() - .WithReuse(true) - .WithImage(CommonImages.Alpine) - .Build()); + Assert.Single(networks); + Assert.Single(volumes.Volumes); } } \ No newline at end of file diff --git a/tests/Testcontainers.Platform.Linux.Tests/WithReuseTest.cs b/tests/Testcontainers.Platform.Linux.Tests/WithReuseTest.cs new file mode 100644 index 000000000..51af0f5e0 --- /dev/null +++ b/tests/Testcontainers.Platform.Linux.Tests/WithReuseTest.cs @@ -0,0 +1,14 @@ +namespace Testcontainers.Tests; + +public sealed class WithReuseTest +{ + [Fact] + [Trait(nameof(DockerCli.DockerPlatform), nameof(DockerCli.DockerPlatform.Linux))] + public void ThrowsIfWithReuseIsNotLastCalledMethod() + { + Assert.Throws(() => new ContainerBuilder() + .WithReuse(true) + .WithImage(CommonImages.Alpine) + .Build()); + } +} \ No newline at end of file From 7994956dabc6e6a44c7ec577f62ea930f5c78ead Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?D=C3=A1vid=20Szab=C3=B3?= Date: Fri, 24 Nov 2023 16:18:16 +0100 Subject: [PATCH 05/12] Add reuse hash label via CreateParameterModifier --- src/Testcontainers/Builders/AbstractBuilder`4.cs | 12 +++++++++--- .../WithReuseTest.cs | 14 -------------- 2 files changed, 9 insertions(+), 17 deletions(-) delete mode 100644 tests/Testcontainers.Platform.Linux.Tests/WithReuseTest.cs diff --git a/src/Testcontainers/Builders/AbstractBuilder`4.cs b/src/Testcontainers/Builders/AbstractBuilder`4.cs index 47910289a..430bffd5a 100644 --- a/src/Testcontainers/Builders/AbstractBuilder`4.cs +++ b/src/Testcontainers/Builders/AbstractBuilder`4.cs @@ -60,7 +60,15 @@ public TBuilderEntity WithCleanUp(bool cleanUp) /// public TBuilderEntity WithReuse(bool reuse) { - return Clone(new ResourceConfiguration(reuse: reuse, labels: new Dictionary { { TestcontainersClient.TestcontainersReuseHashLabel, DockerResourceConfiguration.GetHash() } })); + return Clone(new ResourceConfiguration(reuse: reuse, parameterModifiers: new List>() + { + parameter => { + var labelsProperty = parameter.GetType().GetProperty("Labels"); + var labels = (IDictionary)labelsProperty.GetValue(parameter); + labels[TestcontainersClient.TestcontainersReuseHashLabel] = DockerResourceConfiguration.GetHash(); + } + }) + ); } /// @@ -137,8 +145,6 @@ protected virtual void Validate() .ThrowIf(argument => argument.Value == null, argument => new ArgumentException(message, argument.Name)); // TODO: Validate WithReuse(), WithAutoRemove() and WithCleanUp() combinations. - Guard.Argument(DockerResourceConfiguration.Reuse, nameof(IResourceConfiguration.Reuse)) - .ThrowIf(argument => DockerResourceConfiguration.Labels.ContainsKey(TestcontainersClient.TestcontainersReuseHashLabel) && DockerResourceConfiguration.Labels[TestcontainersClient.TestcontainersReuseHashLabel] != DockerResourceConfiguration.GetHash(), argument => new ArgumentException("ResoureConfiguration hash mismatch, WithReuse(true) must be the last called builder method", argument.Name)); } /// diff --git a/tests/Testcontainers.Platform.Linux.Tests/WithReuseTest.cs b/tests/Testcontainers.Platform.Linux.Tests/WithReuseTest.cs deleted file mode 100644 index 51af0f5e0..000000000 --- a/tests/Testcontainers.Platform.Linux.Tests/WithReuseTest.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace Testcontainers.Tests; - -public sealed class WithReuseTest -{ - [Fact] - [Trait(nameof(DockerCli.DockerPlatform), nameof(DockerCli.DockerPlatform.Linux))] - public void ThrowsIfWithReuseIsNotLastCalledMethod() - { - Assert.Throws(() => new ContainerBuilder() - .WithReuse(true) - .WithImage(CommonImages.Alpine) - .Build()); - } -} \ No newline at end of file From 7d62a1404bc271a41905bc024841890250958998 Mon Sep 17 00:00:00 2001 From: Andre Hofmeister <9199345+HofmeisterAn@users.noreply.github.com> Date: Thu, 4 Jan 2024 15:38:03 +0100 Subject: [PATCH 06/12] chore: Move labeling reuse hash to resource operation class --- .../Builders/AbstractBuilder`4.cs | 10 +- .../Builders/IAbstractBuilder`3.cs | 11 +- .../Clients/DockerContainerOperations.cs | 5 + .../Clients/DockerNetworkOperations.cs | 5 + .../Clients/DockerVolumeOperations.cs | 5 + .../Clients/FilterByProperty.cs | 26 ++++- .../Clients/TestcontainersClient.cs | 1 + .../Commons/ExcludeDynamicLabelsConverter.cs | 33 ------ .../Commons/IResourceConfiguration.cs | 14 +-- .../JsonIgnoreRuntimeResourceLabels.cs | 39 +++++++ .../Commons/ResourceConfiguration.cs | 18 +-- .../Containers/ContainerConfiguration.cs | 16 +++ .../ImageFromDockerfileConfiguration.cs | 7 ++ .../Networks/NetworkConfiguration.cs | 1 + .../Volumes/VolumeConfiguration.cs | 2 + .../Containers/DockerContainer.cs | 22 +++- src/Testcontainers/Networks/DockerNetwork.cs | 41 +++++-- src/Testcontainers/Volumes/DockerVolume.cs | 37 ++++-- .../ReusableResourceTest.cs | 80 +++++++++++++ .../ReuseTest.cs | 108 ------------------ .../Usings.cs | 2 + 21 files changed, 289 insertions(+), 194 deletions(-) delete mode 100644 src/Testcontainers/Configurations/Commons/ExcludeDynamicLabelsConverter.cs create mode 100644 src/Testcontainers/Configurations/Commons/JsonIgnoreRuntimeResourceLabels.cs create mode 100644 tests/Testcontainers.Platform.Linux.Tests/ReusableResourceTest.cs delete mode 100644 tests/Testcontainers.Platform.Linux.Tests/ReuseTest.cs diff --git a/src/Testcontainers/Builders/AbstractBuilder`4.cs b/src/Testcontainers/Builders/AbstractBuilder`4.cs index 430bffd5a..1729b5927 100644 --- a/src/Testcontainers/Builders/AbstractBuilder`4.cs +++ b/src/Testcontainers/Builders/AbstractBuilder`4.cs @@ -60,15 +60,7 @@ public TBuilderEntity WithCleanUp(bool cleanUp) /// public TBuilderEntity WithReuse(bool reuse) { - return Clone(new ResourceConfiguration(reuse: reuse, parameterModifiers: new List>() - { - parameter => { - var labelsProperty = parameter.GetType().GetProperty("Labels"); - var labels = (IDictionary)labelsProperty.GetValue(parameter); - labels[TestcontainersClient.TestcontainersReuseHashLabel] = DockerResourceConfiguration.GetHash(); - } - }) - ); + return Clone(new ResourceConfiguration(reuse: reuse)); } /// diff --git a/src/Testcontainers/Builders/IAbstractBuilder`3.cs b/src/Testcontainers/Builders/IAbstractBuilder`3.cs index ca5249315..d88a29ae7 100644 --- a/src/Testcontainers/Builders/IAbstractBuilder`3.cs +++ b/src/Testcontainers/Builders/IAbstractBuilder`3.cs @@ -60,9 +60,16 @@ public interface IAbstractBuilder - /// TODO + /// Reuses an existing Docker resource. /// - /// TODO + /// + /// 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. + /// + /// Determines whether to reuse an existing resource configuration or not. /// A configured instance of . [PublicAPI] TBuilderEntity WithReuse(bool reuse); 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 14c2e1a10..e6852f95a 100644 --- a/src/Testcontainers/Clients/TestcontainersClient.cs +++ b/src/Testcontainers/Clients/TestcontainersClient.cs @@ -26,6 +26,7 @@ internal sealed class TestcontainersClient : ITestcontainersClient public const string TestcontainersVersionLabel = TestcontainersLabel + ".version"; public const string TestcontainersSessionIdLabel = TestcontainersLabel + ".session-id"; + public const string TestcontainersReuseHashLabel = TestcontainersLabel + ".reuse-hash"; public static readonly string Version = typeof(TestcontainersClient).Assembly.GetCustomAttribute().InformationalVersion; diff --git a/src/Testcontainers/Configurations/Commons/ExcludeDynamicLabelsConverter.cs b/src/Testcontainers/Configurations/Commons/ExcludeDynamicLabelsConverter.cs deleted file mode 100644 index 4ecb023c7..000000000 --- a/src/Testcontainers/Configurations/Commons/ExcludeDynamicLabelsConverter.cs +++ /dev/null @@ -1,33 +0,0 @@ -using DotNet.Testcontainers.Clients; -using DotNet.Testcontainers.Containers; -using System; -using System.Collections.Generic; -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace DotNet.Testcontainers.Configurations -{ - internal class ExcludeDynamicLabelsConverter : JsonConverter> - { - public override bool CanConvert(Type typeToConvert) - { - return true; - } - - 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 = JsonSerializer.SerializeToNode(value).AsObject(); - - labels.Remove(TestcontainersClient.TestcontainersSessionIdLabel); - labels.Remove(ResourceReaper.ResourceReaperSessionLabel); - labels.Remove(TestcontainersClient.TestcontainersReuseHashLabel); - - labels.WriteTo(writer); - } - } -} diff --git a/src/Testcontainers/Configurations/Commons/IResourceConfiguration.cs b/src/Testcontainers/Configurations/Commons/IResourceConfiguration.cs index 0b60a342a..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. /// @@ -32,13 +37,8 @@ public interface IResourceConfiguration IReadOnlyList> ParameterModifiers { get; } /// - /// TODO - /// - bool? Reuse { get; } - - /// - /// TODO + /// Gets the reuse hash. /// - string GetHash(); + 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 ffba16c1d..0fa813bdf 100644 --- a/src/Testcontainers/Configurations/Commons/ResourceConfiguration.cs +++ b/src/Testcontainers/Configurations/Commons/ResourceConfiguration.cs @@ -19,6 +19,7 @@ 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, @@ -49,9 +50,9 @@ protected ResourceConfiguration(IResourceConfiguration re protected ResourceConfiguration(IResourceConfiguration oldValue, IResourceConfiguration newValue) : this( dockerEndpointAuthenticationConfiguration: BuildConfiguration.Combine(oldValue.DockerEndpointAuthConfig, newValue.DockerEndpointAuthConfig), - parameterModifiers: BuildConfiguration.Combine(oldValue.ParameterModifiers, newValue.ParameterModifiers), labels: BuildConfiguration.Combine(oldValue.Labels, newValue.Labels), - reuse: BuildConfiguration.Combine(oldValue.Reuse, newValue.Reuse)) + parameterModifiers: BuildConfiguration.Combine(oldValue.ParameterModifiers, newValue.ParameterModifiers), + reuse: (oldValue.Reuse.HasValue && oldValue.Reuse.Value) || (newValue.Reuse.HasValue && newValue.Reuse.Value)) { } @@ -59,28 +60,29 @@ protected ResourceConfiguration(IResourceConfiguration ol [JsonIgnore] public Guid SessionId { get; } + /// + [JsonIgnore] + public bool? Reuse { get; } + /// [JsonIgnore] public IDockerEndpointAuthenticationConfiguration DockerEndpointAuthConfig { get; } /// - [JsonConverter(typeof(ExcludeDynamicLabelsConverter))] + [JsonConverter(typeof(JsonIgnoreRuntimeResourceLabels))] public IReadOnlyDictionary Labels { get; } /// [JsonIgnore] public IReadOnlyList> ParameterModifiers { get; } - [JsonIgnore] - public bool? Reuse { get; } - - public virtual string GetHash() + /// + public virtual string GetReuseHash() { var jsonUtf8Bytes = JsonSerializer.SerializeToUtf8Bytes(this, GetType()); using (var sha1 = SHA1.Create()) { - var json = JsonSerializer.Serialize(this, GetType()); return Convert.ToBase64String(sha1.ComputeHash(jsonUtf8Bytes)); } } diff --git a/src/Testcontainers/Configurations/Containers/ContainerConfiguration.cs b/src/Testcontainers/Configurations/Containers/ContainerConfiguration.cs index 635e87b03..dc7712988 100644 --- a/src/Testcontainers/Configurations/Containers/ContainerConfiguration.cs +++ b/src/Testcontainers/Configurations/Containers/ContainerConfiguration.cs @@ -140,12 +140,15 @@ public ContainerConfiguration(IContainerConfiguration oldValue, IContainerConfig } /// + [JsonIgnore] public bool? AutoRemove { get; } /// + [JsonIgnore] public bool? Privileged { get; } /// + [JsonPropertyName("Image")] public IImage Image { get; } /// @@ -153,30 +156,39 @@ public ContainerConfiguration(IContainerConfiguration oldValue, IContainerConfig public Func ImagePullPolicy { get; } /// + [JsonIgnore] public string Name { get; } /// + [JsonIgnore] public string Hostname { get; } /// + [JsonIgnore] public string MacAddress { get; } /// + [JsonIgnore] public string WorkingDirectory { get; } /// + [JsonPropertyName("Entrypoint")] public IEnumerable Entrypoint { get; } /// + [JsonPropertyName("Command")] public IEnumerable Command { get; } /// + [JsonIgnore] public IReadOnlyDictionary Environments { get; } /// + [JsonIgnore] public IReadOnlyDictionary ExposedPorts { get; } /// + [JsonIgnore] public IReadOnlyDictionary PortBindings { get; } /// @@ -188,15 +200,19 @@ public ContainerConfiguration(IContainerConfiguration oldValue, IContainerConfig public IEnumerable Containers { get; } /// + [JsonIgnore] public IEnumerable Mounts { get; } /// + [JsonIgnore] public IEnumerable Networks { get; } /// + [JsonIgnore] public IEnumerable NetworkAliases { get; } /// + [JsonIgnore] public IEnumerable ExtraHosts { 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 1c20822d3..a9320c260 100644 --- a/src/Testcontainers/Configurations/Networks/NetworkConfiguration.cs +++ b/src/Testcontainers/Configurations/Networks/NetworkConfiguration.cs @@ -58,6 +58,7 @@ public NetworkConfiguration(INetworkConfiguration oldValue, INetworkConfiguratio } /// + [JsonPropertyName("Name")] public string Name { get; } /// diff --git a/src/Testcontainers/Configurations/Volumes/VolumeConfiguration.cs b/src/Testcontainers/Configurations/Volumes/VolumeConfiguration.cs index 166b86b20..2f7df95b7 100644 --- a/src/Testcontainers/Configurations/Volumes/VolumeConfiguration.cs +++ b/src/Testcontainers/Configurations/Volumes/VolumeConfiguration.cs @@ -1,5 +1,6 @@ namespace DotNet.Testcontainers.Configurations { + using System.Text.Json.Serialization; using Docker.DotNet.Models; using DotNet.Testcontainers.Builders; using JetBrains.Annotations; @@ -48,6 +49,7 @@ public VolumeConfiguration(IVolumeConfiguration oldValue, IVolumeConfiguration n } /// + [JsonPropertyName("Name")] public string Name { get; } } } diff --git a/src/Testcontainers/Containers/DockerContainer.cs b/src/Testcontainers/Containers/DockerContainer.cs index 4253dbe7e..e6419da3f 100644 --- a/src/Testcontainers/Containers/DockerContainer.cs +++ b/src/Testcontainers/Containers/DockerContainer.cs @@ -364,21 +364,31 @@ protected override async Task UnsafeCreateAsync(CancellationToken ct = default) Creating?.Invoke(this, EventArgs.Empty); - string id = string.Empty; - if (_configuration.Reuse == true) + string id; + + if (_configuration.Reuse.HasValue && _configuration.Reuse.Value) { - var reusableContainer = (await _client.Container.GetAllAsync(new FilterByProperty { { "label", $"{TestcontainersClient.TestcontainersReuseHashLabel}={_configuration.GetHash()}" } }, ct).ConfigureAwait(false)).FirstOrDefault(); + var filters = new FilterByReuseHash(_configuration); + + var reusableContainers = await _client.Container.GetAllAsync(filters, ct) + .ConfigureAwait(false); + + var reusableContainer = reusableContainers.SingleOrDefault(); if (reusableContainer != null) { id = reusableContainer.ID; } + else + { + id = await _client.RunAsync(_configuration, ct) + .ConfigureAwait(false); + } } - - if (id == string.Empty) + else { id = await _client.RunAsync(_configuration, ct) - .ConfigureAwait(false); + .ConfigureAwait(false); } _container = await _client.Container.ByIdAsync(id, ct) diff --git a/src/Testcontainers/Networks/DockerNetwork.cs b/src/Testcontainers/Networks/DockerNetwork.cs index 0ded62990..f18e5816c 100644 --- a/src/Testcontainers/Networks/DockerNetwork.cs +++ b/src/Testcontainers/Networks/DockerNetwork.cs @@ -4,6 +4,7 @@ namespace DotNet.Testcontainers.Networks 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; @@ -95,21 +96,31 @@ protected override async Task UnsafeCreateAsync(CancellationToken ct = default) return; } - string id = string.Empty; - if (_configuration.Reuse == true) + string id; + + if (_configuration.Reuse.HasValue && _configuration.Reuse.Value) { - var reusableNetwork = (await _client.Network.GetAllAsync(new FilterByProperty { { "label", $"{TestcontainersClient.TestcontainersReuseHashLabel}={_configuration.GetHash()}" } }, ct).ConfigureAwait(false)).FirstOrDefault(); + var filters = new FilterByReuseHash(_configuration); + + var reusableNetworks = await _client.Network.GetAllAsync(filters, ct) + .ConfigureAwait(false); + + var reusableNetwork = reusableNetworks.SingleOrDefault(); if (reusableNetwork != null) { - id = reusableNetwork.Name; + id = reusableNetwork.ID; + } + else + { + id = await _client.Network.CreateAsync(_configuration, ct) + .ConfigureAwait(false); } } - - if (id == string.Empty) + else { id = await _client.Network.CreateAsync(_configuration, ct) - .ConfigureAwait(false); + .ConfigureAwait(false); } _network = await _client.Network.ByIdAsync(id, ct) @@ -126,10 +137,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 90e0cc8b8..106d00d8e 100644 --- a/src/Testcontainers/Volumes/DockerVolume.cs +++ b/src/Testcontainers/Volumes/DockerVolume.cs @@ -4,6 +4,7 @@ namespace DotNet.Testcontainers.Volumes 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; @@ -95,18 +96,28 @@ protected override async Task UnsafeCreateAsync(CancellationToken ct = default) return; } - string id = string.Empty; - if (_configuration.Reuse == true) + string id; + + if (_configuration.Reuse.HasValue && _configuration.Reuse.Value) { - var reusableVolume = (await _client.Volume.GetAllAsync(new FilterByProperty { { "label", $"{TestcontainersClient.TestcontainersReuseHashLabel}={_configuration.GetHash()}" } }, ct).ConfigureAwait(false)).FirstOrDefault(); + var filters = new FilterByReuseHash(_configuration); + + var reusableVolumes = await _client.Volume.GetAllAsync(filters, ct) + .ConfigureAwait(false); + + var reusableVolume = reusableVolumes.SingleOrDefault(); if (reusableVolume != null) { id = reusableVolume.Name; } + else + { + id = await _client.Volume.CreateAsync(_configuration, ct) + .ConfigureAwait(false); + } } - - if (id == string.Empty) + else { id = await _client.Volume.CreateAsync(_configuration, ct) .ConfigureAwait(false); @@ -126,10 +137,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..953ab3b9b --- /dev/null +++ b/tests/Testcontainers.Platform.Linux.Tests/ReusableResourceTest.cs @@ -0,0 +1,80 @@ +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("sleep") + .WithCommand("infinity") + .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 }) + .ConfigureAwait(false); + + var networks = await _dockerClient.Networks.ListNetworksAsync(new NetworksListParameters { Filters = _filters }) + .ConfigureAwait(false); + + var response = await _dockerClient.Volumes.ListAsync(new VolumesListParameters { Filters = _filters }) + .ConfigureAwait(false); + + Assert.Single(containers); + Assert.Single(networks); + Assert.Single(response.Volumes); + } +} \ No newline at end of file diff --git a/tests/Testcontainers.Platform.Linux.Tests/ReuseTest.cs b/tests/Testcontainers.Platform.Linux.Tests/ReuseTest.cs deleted file mode 100644 index b229dde13..000000000 --- a/tests/Testcontainers.Platform.Linux.Tests/ReuseTest.cs +++ /dev/null @@ -1,108 +0,0 @@ -using DotNet.Testcontainers.Networks; -using DotNet.Testcontainers.Volumes; - -namespace Testcontainers.Tests; - -public sealed class ReuseTest : IAsyncLifetime -{ - private const string LabelKey = "org.testcontainers.reuse-test"; - private const string LabelValue = "true"; - private const int ContainersToCreate = 3; - - private readonly List _containers = new(); - private readonly List _volumes = new(); - private readonly List _networks = new(); - - private static IVolume BuildVolume() - { - return new VolumeBuilder() - .WithName("reuse-test-volume") - .WithLabel(LabelKey, LabelValue) - .WithReuse(true) - .Build(); - } - - private static INetwork BuildNetwork() - { - return new NetworkBuilder() - .WithName("reuse-test-network") - .WithDriver(NetworkDriver.Host) - .WithLabel(LabelKey, LabelValue) - .WithReuse(true) - .Build(); - } - - private static IContainer BuildContainer(IVolume volume, INetwork network) - { - return new ContainerBuilder() - .WithImage(CommonImages.Alpine) - .WithLabel(LabelKey, LabelValue) - .WithAutoRemove(false) - .WithCleanUp(false) - .WithEntrypoint("sleep") - .WithCommand("infinity") - .WithNetwork(network) - .WithVolumeMount(volume, "/test", AccessMode.ReadWrite) - .WithReuse(true) - .Build(); - } - - public async Task InitializeAsync() - { - for (var i = 0; i < ContainersToCreate; i++) - { - var network = BuildNetwork(); - await network.CreateAsync(); - _networks.Add(network); - - var volume = BuildVolume(); - await volume.CreateAsync(); - _volumes.Add(volume); - - var container = BuildContainer(volume, network); - await container.StartAsync(); - _containers.Add(container); - } - } - - public async Task DisposeAsync() - { - using var clientConfiguration = TestcontainersSettings.OS.DockerEndpointAuthConfig.GetDockerClientConfiguration(ResourceReaper.DefaultSessionId); - using var client = clientConfiguration.CreateClient(); - - var distinctContainers = _containers.DistinctBy(container => container.Id).ToList(); - await Task.WhenAll(distinctContainers.Select(container => container.DisposeAsync().AsTask())); - await Task.WhenAll(distinctContainers.Select(container => client.Containers.RemoveContainerAsync(container.Id, new()))); - - var distinctVolumes = _volumes.DistinctBy(volume => volume.Name).ToList(); - await Task.WhenAll(distinctVolumes.Select(volume => volume.DisposeAsync().AsTask())); - - var distinctNetworks = _networks.DistinctBy(network => network.Name).ToList(); - await Task.WhenAll(distinctNetworks.Select(network => network.DisposeAsync().AsTask())); - } - - [Fact] - [Trait(nameof(DockerCli.DockerPlatform), nameof(DockerCli.DockerPlatform.Linux))] - public async Task ReusesContainer() - { - // Given - using var clientConfiguration = TestcontainersSettings.OS.DockerEndpointAuthConfig.GetDockerClientConfiguration(ResourceReaper.DefaultSessionId); - using var client = clientConfiguration.CreateClient(); - - var labelFilter = new Dictionary { { $"{LabelKey}={LabelValue}", true } }; - var filters = new Dictionary> { { "label", labelFilter } }; - var containersListParameters = new ContainersListParameters { All = true, Filters = filters }; - var networksListParameters = new NetworksListParameters { Filters = filters }; - var volumeListParameters = new VolumesListParameters { Filters = filters }; - - // When - var containers = await client.Containers.ListContainersAsync(containersListParameters); - var networks = await client.Networks.ListNetworksAsync(networksListParameters); - var volumes = await client.Volumes.ListAsync(volumeListParameters); - - // Then - Assert.Single(containers); - Assert.Single(networks); - Assert.Single(volumes.Volumes); - } -} \ 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; From 823e37838dcefa8363b3698effc1240ad5d0d907 Mon Sep 17 00:00:00 2001 From: Andre Hofmeister <9199345+HofmeisterAn@users.noreply.github.com> Date: Thu, 4 Jan 2024 15:45:52 +0100 Subject: [PATCH 07/12] chore: Remove JsonPropertyName attribute --- .../Configurations/Containers/ContainerConfiguration.cs | 3 --- .../Configurations/Networks/NetworkConfiguration.cs | 1 - .../Configurations/Volumes/VolumeConfiguration.cs | 1 - 3 files changed, 5 deletions(-) diff --git a/src/Testcontainers/Configurations/Containers/ContainerConfiguration.cs b/src/Testcontainers/Configurations/Containers/ContainerConfiguration.cs index dc7712988..1c94f9d6b 100644 --- a/src/Testcontainers/Configurations/Containers/ContainerConfiguration.cs +++ b/src/Testcontainers/Configurations/Containers/ContainerConfiguration.cs @@ -148,7 +148,6 @@ public ContainerConfiguration(IContainerConfiguration oldValue, IContainerConfig public bool? Privileged { get; } /// - [JsonPropertyName("Image")] public IImage Image { get; } /// @@ -172,11 +171,9 @@ public ContainerConfiguration(IContainerConfiguration oldValue, IContainerConfig public string WorkingDirectory { get; } /// - [JsonPropertyName("Entrypoint")] public IEnumerable Entrypoint { get; } /// - [JsonPropertyName("Command")] public IEnumerable Command { get; } /// diff --git a/src/Testcontainers/Configurations/Networks/NetworkConfiguration.cs b/src/Testcontainers/Configurations/Networks/NetworkConfiguration.cs index a9320c260..1c20822d3 100644 --- a/src/Testcontainers/Configurations/Networks/NetworkConfiguration.cs +++ b/src/Testcontainers/Configurations/Networks/NetworkConfiguration.cs @@ -58,7 +58,6 @@ public NetworkConfiguration(INetworkConfiguration oldValue, INetworkConfiguratio } /// - [JsonPropertyName("Name")] public string Name { get; } /// diff --git a/src/Testcontainers/Configurations/Volumes/VolumeConfiguration.cs b/src/Testcontainers/Configurations/Volumes/VolumeConfiguration.cs index 2f7df95b7..a643265e4 100644 --- a/src/Testcontainers/Configurations/Volumes/VolumeConfiguration.cs +++ b/src/Testcontainers/Configurations/Volumes/VolumeConfiguration.cs @@ -49,7 +49,6 @@ public VolumeConfiguration(IVolumeConfiguration oldValue, IVolumeConfiguration n } /// - [JsonPropertyName("Name")] public string Name { get; } } } From 77fbbb7db8d618c6570dc6e2212c743375fc140f Mon Sep 17 00:00:00 2001 From: Andre Hofmeister <9199345+HofmeisterAn@users.noreply.github.com> Date: Thu, 4 Jan 2024 16:06:11 +0100 Subject: [PATCH 08/12] chore: Add log messages --- src/Testcontainers/Containers/DockerContainer.cs | 4 ++++ src/Testcontainers/Logging.cs | 16 ++++++++++++++++ src/Testcontainers/Networks/DockerNetwork.cs | 7 +++++++ src/Testcontainers/Volumes/DockerVolume.cs | 7 +++++++ 4 files changed, 34 insertions(+) diff --git a/src/Testcontainers/Containers/DockerContainer.cs b/src/Testcontainers/Containers/DockerContainer.cs index e6419da3f..a25eb3419 100644 --- a/src/Testcontainers/Containers/DockerContainer.cs +++ b/src/Testcontainers/Containers/DockerContainer.cs @@ -377,10 +377,14 @@ protected override async Task UnsafeCreateAsync(CancellationToken ct = default) if (reusableContainer != null) { + Logger.ReusableResourceFound(); + id = reusableContainer.ID; } else { + Logger.ReusableResourceNotFound(); + id = await _client.RunAsync(_configuration, ct) .ConfigureAwait(false); } diff --git a/src/Testcontainers/Logging.cs b/src/Testcontainers/Logging.cs index 18f5d2ae0..5aff43c66 100644 --- a/src/Testcontainers/Logging.cs +++ b/src/Testcontainers/Logging.cs @@ -101,6 +101,12 @@ internal static class Logging private static readonly Action _DockerRegistryCredentialFound = LoggerMessage.Define(LogLevel.Information, default, "Docker registry credential {DockerRegistry} found"); + 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 +261,16 @@ public static void DockerRegistryCredentialFound(this ILogger logger, string doc _DockerRegistryCredentialFound(logger, dockerRegistry, 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 f18e5816c..4200a9dc4 100644 --- a/src/Testcontainers/Networks/DockerNetwork.cs +++ b/src/Testcontainers/Networks/DockerNetwork.cs @@ -19,6 +19,8 @@ internal sealed class DockerNetwork : Resource, INetwork private readonly INetworkConfiguration _configuration; + private readonly ILogger _logger; + private NetworkResponse _network = new NetworkResponse(); /// @@ -30,6 +32,7 @@ public DockerNetwork(INetworkConfiguration configuration, ILogger logger) { _client = new TestcontainersClient(configuration.SessionId, configuration.DockerEndpointAuthConfig, logger); _configuration = configuration; + _logger = logger; } /// @@ -109,10 +112,14 @@ protected override async Task UnsafeCreateAsync(CancellationToken ct = default) if (reusableNetwork != null) { + _logger.ReusableResourceFound(); + id = reusableNetwork.ID; } else { + _logger.ReusableResourceNotFound(); + id = await _client.Network.CreateAsync(_configuration, ct) .ConfigureAwait(false); } diff --git a/src/Testcontainers/Volumes/DockerVolume.cs b/src/Testcontainers/Volumes/DockerVolume.cs index 106d00d8e..324c388a7 100644 --- a/src/Testcontainers/Volumes/DockerVolume.cs +++ b/src/Testcontainers/Volumes/DockerVolume.cs @@ -19,6 +19,8 @@ internal sealed class DockerVolume : Resource, IVolume private readonly IVolumeConfiguration _configuration; + private readonly ILogger _logger; + private VolumeResponse _volume = new VolumeResponse(); /// @@ -30,6 +32,7 @@ public DockerVolume(IVolumeConfiguration configuration, ILogger logger) { _client = new TestcontainersClient(configuration.SessionId, configuration.DockerEndpointAuthConfig, logger); _configuration = configuration; + _logger = logger; } /// @@ -109,10 +112,14 @@ protected override async Task UnsafeCreateAsync(CancellationToken ct = default) if (reusableVolume != null) { + _logger.ReusableResourceFound(); + id = reusableVolume.Name; } else { + _logger.ReusableResourceNotFound(); + id = await _client.Volume.CreateAsync(_configuration, ct) .ConfigureAwait(false); } From 61ef156ef5117635678e90911ac0900cc52b42cb Mon Sep 17 00:00:00 2001 From: Andre Hofmeister <9199345+HofmeisterAn@users.noreply.github.com> Date: Fri, 5 Jan 2024 17:05:40 +0100 Subject: [PATCH 09/12] chore: Remove some JsonIgnore attributes --- .../Configurations/Containers/ContainerConfiguration.cs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/Testcontainers/Configurations/Containers/ContainerConfiguration.cs b/src/Testcontainers/Configurations/Containers/ContainerConfiguration.cs index 1c94f9d6b..4058c248d 100644 --- a/src/Testcontainers/Configurations/Containers/ContainerConfiguration.cs +++ b/src/Testcontainers/Configurations/Containers/ContainerConfiguration.cs @@ -177,15 +177,12 @@ public ContainerConfiguration(IContainerConfiguration oldValue, IContainerConfig public IEnumerable Command { get; } /// - [JsonIgnore] public IReadOnlyDictionary Environments { get; } /// - [JsonIgnore] public IReadOnlyDictionary ExposedPorts { get; } /// - [JsonIgnore] public IReadOnlyDictionary PortBindings { get; } /// @@ -205,11 +202,9 @@ public ContainerConfiguration(IContainerConfiguration oldValue, IContainerConfig public IEnumerable Networks { get; } /// - [JsonIgnore] public IEnumerable NetworkAliases { get; } /// - [JsonIgnore] public IEnumerable ExtraHosts { get; } /// From e56bf19f3dc0a55c79029998a50a9ffb6fa504ec Mon Sep 17 00:00:00 2001 From: Andre Hofmeister <9199345+HofmeisterAn@users.noreply.github.com> Date: Sat, 20 Jan 2024 10:46:17 +0100 Subject: [PATCH 10/12] chore: Disable clean up on reuse, throw exception invalid config --- .../Builders/AbstractBuilder`4.cs | 10 +-- .../Builders/ContainerBuilder`3.cs | 4 ++ .../Builders/IAbstractBuilder`3.cs | 5 ++ .../Builders/ImageFromDockerfileBuilder.cs | 10 +++ .../ReusableResourceTest.cs | 71 ++++++++++++++++++- 5 files changed, 94 insertions(+), 6 deletions(-) diff --git a/src/Testcontainers/Builders/AbstractBuilder`4.cs b/src/Testcontainers/Builders/AbstractBuilder`4.cs index 1729b5927..364bc7b2a 100644 --- a/src/Testcontainers/Builders/AbstractBuilder`4.cs +++ b/src/Testcontainers/Builders/AbstractBuilder`4.cs @@ -60,7 +60,7 @@ public TBuilderEntity WithCleanUp(bool cleanUp) /// public TBuilderEntity WithReuse(bool reuse) { - return Clone(new ResourceConfiguration(reuse: reuse)); + return Clone(new ResourceConfiguration(reuse: reuse)).WithCleanUp(!reuse); } /// @@ -132,11 +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)); - // TODO: Validate WithReuse(), WithAutoRemove() and WithCleanUp() combinations. + 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 d88a29ae7..12e4cf8b3 100644 --- a/src/Testcontainers/Builders/IAbstractBuilder`3.cs +++ b/src/Testcontainers/Builders/IAbstractBuilder`3.cs @@ -68,6 +68,11 @@ public interface IAbstractBuilderexperimental 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 . 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/tests/Testcontainers.Platform.Linux.Tests/ReusableResourceTest.cs b/tests/Testcontainers.Platform.Linux.Tests/ReusableResourceTest.cs index 953ab3b9b..05fc71af5 100644 --- a/tests/Testcontainers.Platform.Linux.Tests/ReusableResourceTest.cs +++ b/tests/Testcontainers.Platform.Linux.Tests/ReusableResourceTest.cs @@ -24,8 +24,7 @@ public async Task InitializeAsync() // We are running in a single session, we do not need to disable the cleanup feature. var container = new ContainerBuilder() .WithImage(CommonImages.Alpine) - .WithEntrypoint("sleep") - .WithCommand("infinity") + .WithEntrypoint(CommonCommands.SleepInfinity) .WithLabel(_labelKey, _labelValue) .WithReuse(true) .Build(); @@ -77,4 +76,72 @@ public async Task ShouldReuseExistingResource() 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 From 18ac11e9d1a318f869c3f6eda7dd1d8f6947f4b5 Mon Sep 17 00:00:00 2001 From: Andre Hofmeister <9199345+HofmeisterAn@users.noreply.github.com> Date: Sat, 20 Jan 2024 14:20:30 +0100 Subject: [PATCH 11/12] docs: Prepare reuse docs --- ...{resource-reaper.md => resource_reaper.md} | 0 docs/api/resource_reuse.md | 22 +++++++++++++++++++ mkdocs.yml | 3 ++- ...ckerEndpointAuthenticationConfiguration.cs | 1 - .../Volumes/VolumeConfiguration.cs | 1 - .../ReusableResourceTest.cs | 9 +++----- 6 files changed, 27 insertions(+), 9 deletions(-) rename docs/api/{resource-reaper.md => resource_reaper.md} (100%) create mode 100644 docs/api/resource_reuse.md 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/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/Volumes/VolumeConfiguration.cs b/src/Testcontainers/Configurations/Volumes/VolumeConfiguration.cs index a643265e4..166b86b20 100644 --- a/src/Testcontainers/Configurations/Volumes/VolumeConfiguration.cs +++ b/src/Testcontainers/Configurations/Volumes/VolumeConfiguration.cs @@ -1,6 +1,5 @@ namespace DotNet.Testcontainers.Configurations { - using System.Text.Json.Serialization; using Docker.DotNet.Models; using DotNet.Testcontainers.Builders; using JetBrains.Annotations; diff --git a/tests/Testcontainers.Platform.Linux.Tests/ReusableResourceTest.cs b/tests/Testcontainers.Platform.Linux.Tests/ReusableResourceTest.cs index 05fc71af5..18a2499e1 100644 --- a/tests/Testcontainers.Platform.Linux.Tests/ReusableResourceTest.cs +++ b/tests/Testcontainers.Platform.Linux.Tests/ReusableResourceTest.cs @@ -63,14 +63,11 @@ public void Dispose() [Fact] public async Task ShouldReuseExistingResource() { - var containers = await _dockerClient.Containers.ListContainersAsync(new ContainersListParameters { Filters = _filters }) - .ConfigureAwait(false); + var containers = await _dockerClient.Containers.ListContainersAsync(new ContainersListParameters { Filters = _filters }); - var networks = await _dockerClient.Networks.ListNetworksAsync(new NetworksListParameters { Filters = _filters }) - .ConfigureAwait(false); + var networks = await _dockerClient.Networks.ListNetworksAsync(new NetworksListParameters { Filters = _filters }); - var response = await _dockerClient.Volumes.ListAsync(new VolumesListParameters { Filters = _filters }) - .ConfigureAwait(false); + var response = await _dockerClient.Volumes.ListAsync(new VolumesListParameters { Filters = _filters }); Assert.Single(containers); Assert.Single(networks); From bafb41b6d1a872257da141cee6131fc8707ceb7a Mon Sep 17 00:00:00 2001 From: Andre Hofmeister <9199345+HofmeisterAn@users.noreply.github.com> Date: Sat, 20 Jan 2024 14:31:02 +0100 Subject: [PATCH 12/12] chore: Log experimental feature --- src/Testcontainers/Containers/DockerContainer.cs | 2 ++ src/Testcontainers/Logging.cs | 8 ++++++++ src/Testcontainers/Networks/DockerNetwork.cs | 2 ++ src/Testcontainers/Volumes/DockerVolume.cs | 2 ++ 4 files changed, 14 insertions(+) diff --git a/src/Testcontainers/Containers/DockerContainer.cs b/src/Testcontainers/Containers/DockerContainer.cs index a25eb3419..55f058fb4 100644 --- a/src/Testcontainers/Containers/DockerContainer.cs +++ b/src/Testcontainers/Containers/DockerContainer.cs @@ -368,6 +368,8 @@ protected override async Task UnsafeCreateAsync(CancellationToken ct = default) if (_configuration.Reuse.HasValue && _configuration.Reuse.Value) { + Logger.ReusableExperimentalFeature(); + var filters = new FilterByReuseHash(_configuration); var reusableContainers = await _client.Container.GetAllAsync(filters, ct) diff --git a/src/Testcontainers/Logging.cs b/src/Testcontainers/Logging.cs index 5aff43c66..8e1d5b07a 100644 --- a/src/Testcontainers/Logging.cs +++ b/src/Testcontainers/Logging.cs @@ -101,6 +101,9 @@ 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"); @@ -261,6 +264,11 @@ 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); diff --git a/src/Testcontainers/Networks/DockerNetwork.cs b/src/Testcontainers/Networks/DockerNetwork.cs index 4200a9dc4..f29a418f7 100644 --- a/src/Testcontainers/Networks/DockerNetwork.cs +++ b/src/Testcontainers/Networks/DockerNetwork.cs @@ -103,6 +103,8 @@ protected override async Task UnsafeCreateAsync(CancellationToken ct = default) if (_configuration.Reuse.HasValue && _configuration.Reuse.Value) { + _logger.ReusableExperimentalFeature(); + var filters = new FilterByReuseHash(_configuration); var reusableNetworks = await _client.Network.GetAllAsync(filters, ct) diff --git a/src/Testcontainers/Volumes/DockerVolume.cs b/src/Testcontainers/Volumes/DockerVolume.cs index 324c388a7..c344b79c9 100644 --- a/src/Testcontainers/Volumes/DockerVolume.cs +++ b/src/Testcontainers/Volumes/DockerVolume.cs @@ -103,6 +103,8 @@ protected override async Task UnsafeCreateAsync(CancellationToken ct = default) if (_configuration.Reuse.HasValue && _configuration.Reuse.Value) { + _logger.ReusableExperimentalFeature(); + var filters = new FilterByReuseHash(_configuration); var reusableVolumes = await _client.Volume.GetAllAsync(filters, ct)