From eb986f48f84078bb9f2cf19f92244d0912250ff4 Mon Sep 17 00:00:00 2001 From: nkz <5499916+nkz-soft@users.noreply.github.com> Date: Tue, 2 Jan 2024 14:34:36 +0300 Subject: [PATCH] feat: Add docker compose support (#122) --- Testcontainers.sln | 14 ++ .../.editorconfig | 1 + .../DockerComposeBuilder.cs | 123 ++++++++++++++++++ .../DockerComposeConfiguration.cs | 71 ++++++++++ .../DockerComposeContainer.cs | 30 +++++ .../DockerComposeLocal.cs | 40 ++++++ .../DockerComposeRemote.cs | 29 +++++ .../Testcontainers.DockerCompose.csproj | 15 +++ src/Testcontainers.DockerCompose/Usings.cs | 18 +++ .../Containers/DockerContainer.cs | 4 +- .../.editorconfig | 1 + .../DockerComposeRemoteTest.cs | 55 ++++++++ .../Testcontainers.DockerCompose.Tests.csproj | 17 +++ .../Usings.cs | 6 + .../docker-compose.yaml | 9 ++ 15 files changed, 431 insertions(+), 2 deletions(-) create mode 100644 src/Testcontainers.DockerCompose/.editorconfig create mode 100644 src/Testcontainers.DockerCompose/DockerComposeBuilder.cs create mode 100644 src/Testcontainers.DockerCompose/DockerComposeConfiguration.cs create mode 100644 src/Testcontainers.DockerCompose/DockerComposeContainer.cs create mode 100644 src/Testcontainers.DockerCompose/DockerComposeLocal.cs create mode 100644 src/Testcontainers.DockerCompose/DockerComposeRemote.cs create mode 100644 src/Testcontainers.DockerCompose/Testcontainers.DockerCompose.csproj create mode 100644 src/Testcontainers.DockerCompose/Usings.cs create mode 100644 tests/Testcontainers.DockerCompose.Tests/.editorconfig create mode 100644 tests/Testcontainers.DockerCompose.Tests/DockerComposeRemoteTest.cs create mode 100644 tests/Testcontainers.DockerCompose.Tests/Testcontainers.DockerCompose.Tests.csproj create mode 100644 tests/Testcontainers.DockerCompose.Tests/Usings.cs create mode 100644 tests/Testcontainers.DockerCompose.Tests/docker-compose.yaml diff --git a/Testcontainers.sln b/Testcontainers.sln index 7507b0faa..6494a2bb4 100644 --- a/Testcontainers.sln +++ b/Testcontainers.sln @@ -183,6 +183,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.Tests", "tes EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.WebDriver.Tests", "tests\Testcontainers.WebDriver.Tests\Testcontainers.WebDriver.Tests.csproj", "{EBA72C3B-57D5-43FF-A5B4-3D55B3B6D4C2}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.DockerCompose", "src\Testcontainers.DockerCompose\Testcontainers.DockerCompose.csproj", "{5CF21353-F3DB-4993-B9D8-DFAA8B2C4D2F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.DockerCompose.Tests", "tests\Testcontainers.DockerCompose.Tests\Testcontainers.DockerCompose.Tests.csproj", "{D77017F1-9E38-4B06-8CEB-9B3D98B6497C}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -532,6 +536,14 @@ Global {EBA72C3B-57D5-43FF-A5B4-3D55B3B6D4C2}.Debug|Any CPU.Build.0 = Debug|Any CPU {EBA72C3B-57D5-43FF-A5B4-3D55B3B6D4C2}.Release|Any CPU.ActiveCfg = Release|Any CPU {EBA72C3B-57D5-43FF-A5B4-3D55B3B6D4C2}.Release|Any CPU.Build.0 = Release|Any CPU + {5CF21353-F3DB-4993-B9D8-DFAA8B2C4D2F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5CF21353-F3DB-4993-B9D8-DFAA8B2C4D2F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5CF21353-F3DB-4993-B9D8-DFAA8B2C4D2F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5CF21353-F3DB-4993-B9D8-DFAA8B2C4D2F}.Release|Any CPU.Build.0 = Release|Any CPU + {D77017F1-9E38-4B06-8CEB-9B3D98B6497C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D77017F1-9E38-4B06-8CEB-9B3D98B6497C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D77017F1-9E38-4B06-8CEB-9B3D98B6497C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D77017F1-9E38-4B06-8CEB-9B3D98B6497C}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {5365F780-0E6C-41F0-B1B9-7DC34368F80C} = {673F23AE-7694-4BB9-ABD4-136D6C13634E} @@ -619,5 +631,7 @@ Global {1A1983E6-5297-435F-B467-E8E1F11277D6} = {7164F1FB-7F24-444A-ACD2-2C329C2B3CCF} {27CDB869-A150-4593-958F-6F26E5391E7C} = {7164F1FB-7F24-444A-ACD2-2C329C2B3CCF} {EBA72C3B-57D5-43FF-A5B4-3D55B3B6D4C2} = {7164F1FB-7F24-444A-ACD2-2C329C2B3CCF} + {5CF21353-F3DB-4993-B9D8-DFAA8B2C4D2F} = {673F23AE-7694-4BB9-ABD4-136D6C13634E} + {D77017F1-9E38-4B06-8CEB-9B3D98B6497C} = {7164F1FB-7F24-444A-ACD2-2C329C2B3CCF} EndGlobalSection EndGlobal diff --git a/src/Testcontainers.DockerCompose/.editorconfig b/src/Testcontainers.DockerCompose/.editorconfig new file mode 100644 index 000000000..6f066619d --- /dev/null +++ b/src/Testcontainers.DockerCompose/.editorconfig @@ -0,0 +1 @@ +root = true \ No newline at end of file diff --git a/src/Testcontainers.DockerCompose/DockerComposeBuilder.cs b/src/Testcontainers.DockerCompose/DockerComposeBuilder.cs new file mode 100644 index 000000000..121368d04 --- /dev/null +++ b/src/Testcontainers.DockerCompose/DockerComposeBuilder.cs @@ -0,0 +1,123 @@ +namespace Testcontainers.DockerCompose; + +/// +[PublicAPI] +public sealed class DockerComposeBuilder : ContainerBuilder +{ + private const string NoComposeFile = "No docker compose file have been provided."; + + //Docker Compose is included as part of this image. + public const string DockerComposeImage = "docker:24-cli"; + + public const string DockerSocketPath = "/var/run/docker.sock"; + + /// + /// Initializes a new instance of the class. + /// + public DockerComposeBuilder() + : this(new DockerComposeConfiguration()) + { + DockerResourceConfiguration = Init().DockerResourceConfiguration; + } + + /// + /// Initializes a new instance of the class. + /// + /// The Docker resource configuration. + private DockerComposeBuilder(DockerComposeConfiguration resourceConfiguration) + : base(resourceConfiguration) + { + DockerResourceConfiguration = resourceConfiguration; + } + + /// + protected override DockerComposeConfiguration DockerResourceConfiguration { get; } + + /// + public override DockerComposeContainer Build() + { + Validate(); + + return new DockerComposeContainer(DockerResourceConfiguration, TestcontainersSettings.Logger); + } + + /// + /// Sets the compose file. + /// + /// The compose file. + /// + public DockerComposeBuilder WithComposeFile(string composeFile) + { + return Merge(DockerResourceConfiguration, new DockerComposeConfiguration + (composeFile: composeFile)); + } + + /// + /// If true use a local Docker Compose binary instead of a container. + /// + /// + /// + public DockerComposeBuilder WithLocalCompose(bool localCompose) + { + return Merge(DockerResourceConfiguration, new DockerComposeConfiguration + (localCompose: localCompose)); + } + + /// + protected override DockerComposeBuilder Init() + { + return base.Init() + .WithImage(DockerComposeImage) + .WithEntrypoint(CommonCommands.SleepInfinity) + .WithBindMount(DockerSocketPath, DockerSocketPath, AccessMode.ReadWrite) + .WithStartupCallback(ConfigureDockerComposeAsync); + } + + /// + protected override void Validate() + { + base.Validate(); + + _ = Guard.Argument(DockerResourceConfiguration.ComposeFile, nameof(DockerResourceConfiguration.ComposeFile)) + .NotEmpty(); + } + + /// + protected override DockerComposeBuilder Clone(IResourceConfiguration resourceConfiguration) + { + return Merge(DockerResourceConfiguration, new DockerComposeConfiguration(resourceConfiguration)); + } + + /// + protected override DockerComposeBuilder Clone(IContainerConfiguration resourceConfiguration) + { + return Merge(DockerResourceConfiguration, new DockerComposeConfiguration(resourceConfiguration)); + } + + /// + protected override DockerComposeBuilder Merge(DockerComposeConfiguration oldValue, DockerComposeConfiguration newValue) + { + return new DockerComposeBuilder(new DockerComposeConfiguration(oldValue, newValue)); + } + + /// + /// Configures the compose container. + /// + /// The container. + /// Cancellation token. + private async Task ConfigureDockerComposeAsync(IContainer container, CancellationToken ct = default) + { + if (container is DockerComposeRemote dockerComposeContainer && + !dockerComposeContainer.RuntimeConfiguration.LocalCompose ) + { + var fileInfo = new FileInfo(dockerComposeContainer.RuntimeConfiguration.ComposeFile); + if (fileInfo.Exists) + { + await container.CopyAsync(fileInfo, ".", Unix.FileMode644, ct) + .ConfigureAwait(false); + } + await container.ExecAsync(new[] { "docker", "compose", "up", "-d" }, ct) + .ConfigureAwait(false); + } + } +} \ No newline at end of file diff --git a/src/Testcontainers.DockerCompose/DockerComposeConfiguration.cs b/src/Testcontainers.DockerCompose/DockerComposeConfiguration.cs new file mode 100644 index 000000000..1a163064c --- /dev/null +++ b/src/Testcontainers.DockerCompose/DockerComposeConfiguration.cs @@ -0,0 +1,71 @@ +namespace Testcontainers.DockerCompose; + +/// +[PublicAPI] +public sealed class DockerComposeConfiguration : ContainerConfiguration +{ + /// + /// Initializes a new instance of the class. + /// + /// The fully qualified path to the compose file. + /// Whether the local compose will be used. + public DockerComposeConfiguration( + string composeFile = null, + bool localCompose = false) + { + ComposeFile = composeFile; + LocalCompose = localCompose; + } + + /// + /// Initializes a new instance of the class. + /// + /// The Docker resource configuration. + public DockerComposeConfiguration(IResourceConfiguration resourceConfiguration) + : base(resourceConfiguration) + { + // Passes the configuration upwards to the base implementations to create an updated immutable copy. + } + + /// + /// Initializes a new instance of the class. + /// + /// The Docker resource configuration. + public DockerComposeConfiguration(IContainerConfiguration resourceConfiguration) + : base(resourceConfiguration) + { + // Passes the configuration upwards to the base implementations to create an updated immutable copy. + } + + /// + /// Initializes a new instance of the class. + /// + /// The Docker resource configuration. + public DockerComposeConfiguration(DockerComposeConfiguration resourceConfiguration) + : this(new DockerComposeConfiguration(), resourceConfiguration) + { + // Passes the configuration upwards to the base implementations to create an updated immutable copy. + } + + /// + /// Initializes a new instance of the class. + /// + /// The old Docker resource configuration. + /// The new Docker resource configuration. + public DockerComposeConfiguration(DockerComposeConfiguration oldValue, DockerComposeConfiguration newValue) + : base(oldValue, newValue) + { + ComposeFile = BuildConfiguration.Combine(oldValue.ComposeFile, newValue.ComposeFile); + LocalCompose = BuildConfiguration.Combine(oldValue.LocalCompose, newValue.LocalCompose); + } + + /// + /// Gets the path to the compose file. + /// + public string ComposeFile { get; } + + /// + /// Indicates whether local compose is enabled. + /// + public bool LocalCompose { get; } +} \ No newline at end of file diff --git a/src/Testcontainers.DockerCompose/DockerComposeContainer.cs b/src/Testcontainers.DockerCompose/DockerComposeContainer.cs new file mode 100644 index 000000000..bb8688dfc --- /dev/null +++ b/src/Testcontainers.DockerCompose/DockerComposeContainer.cs @@ -0,0 +1,30 @@ +namespace Testcontainers.DockerCompose; + +public class DockerComposeContainer : DockerContainer +{ + private readonly IContainer _proxyContainer; + + /// + /// Initializes a new instance of the class. + /// + /// + /// + public DockerComposeContainer(DockerComposeConfiguration configuration, ILogger logger) : base(configuration, logger) + { + _proxyContainer = configuration.LocalCompose + ? new DockerComposeLocal(configuration, logger) + : new DockerComposeRemote(configuration, logger); + } + + /// + public override async Task StartAsync(CancellationToken ct = default) + { + await _proxyContainer.StartAsync(ct); + } + + /// + public override async Task StopAsync(CancellationToken ct = default) + { + await _proxyContainer.StopAsync(ct); + } +} \ No newline at end of file diff --git a/src/Testcontainers.DockerCompose/DockerComposeLocal.cs b/src/Testcontainers.DockerCompose/DockerComposeLocal.cs new file mode 100644 index 000000000..7ecf058f6 --- /dev/null +++ b/src/Testcontainers.DockerCompose/DockerComposeLocal.cs @@ -0,0 +1,40 @@ +namespace Testcontainers.DockerCompose; + +[PublicAPI] +public sealed class DockerComposeLocal : DockerContainer +{ + private readonly string _dockerComposeBinary = + RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "docker-compose.exe" : "docker-compose"; + + /// + /// Initializes a new instance of the class. + /// + /// The container configuration. + /// The logger. + public DockerComposeLocal(DockerComposeConfiguration configuration, ILogger logger) : base(configuration, logger) + { + } + + /// + /// Gets the runtime configuration. + /// + public DockerComposeConfiguration RuntimeConfiguration => _configuration as DockerComposeConfiguration; + + /// + public override async Task StartAsync(CancellationToken ct = default) + { + await Cli.Wrap(_dockerComposeBinary) + .WithArguments(new[] {"up", "-d"}) + .WithWorkingDirectory(Path.GetDirectoryName(RuntimeConfiguration.ComposeFile)!) + .ExecuteBufferedAsync(); + } + + /// + public override async Task StopAsync(CancellationToken ct = default) + { + await Cli.Wrap(_dockerComposeBinary) + .WithArguments(new[] {"down"}) + .WithWorkingDirectory(Path.GetDirectoryName(RuntimeConfiguration.ComposeFile)!) + .ExecuteBufferedAsync(); + } +} \ No newline at end of file diff --git a/src/Testcontainers.DockerCompose/DockerComposeRemote.cs b/src/Testcontainers.DockerCompose/DockerComposeRemote.cs new file mode 100644 index 000000000..d2ca4e1b8 --- /dev/null +++ b/src/Testcontainers.DockerCompose/DockerComposeRemote.cs @@ -0,0 +1,29 @@ +namespace Testcontainers.DockerCompose; + +/// +[PublicAPI] +public class DockerComposeRemote : DockerContainer +{ + /// + /// Initializes a new instance of the class. + /// + /// The container configuration. + /// The logger. + public DockerComposeRemote(DockerComposeConfiguration configuration, ILogger logger) + : base(configuration, logger) + { + } + + /// + /// Gets the runtime configuration. + /// + public DockerComposeConfiguration RuntimeConfiguration => _configuration as DockerComposeConfiguration; + + /// + public override async Task StopAsync(CancellationToken ct = default) + { + await ExecAsync(new[] { "docker", "compose", "down"}, ct) + .ConfigureAwait(false); + await base.StopAsync(ct); + } +} \ No newline at end of file diff --git a/src/Testcontainers.DockerCompose/Testcontainers.DockerCompose.csproj b/src/Testcontainers.DockerCompose/Testcontainers.DockerCompose.csproj new file mode 100644 index 000000000..894c19cd2 --- /dev/null +++ b/src/Testcontainers.DockerCompose/Testcontainers.DockerCompose.csproj @@ -0,0 +1,15 @@ + + + netstandard2.0;netstandard2.1 + latest + + + + + + + + + + + \ No newline at end of file diff --git a/src/Testcontainers.DockerCompose/Usings.cs b/src/Testcontainers.DockerCompose/Usings.cs new file mode 100644 index 000000000..fbfb35ec4 --- /dev/null +++ b/src/Testcontainers.DockerCompose/Usings.cs @@ -0,0 +1,18 @@ +global using System; +global using System.Collections.Generic; +global using System.IO; +global using System.Threading; +global using System.Threading.Tasks; +global using Docker.DotNet.Models; +global using DotNet.Testcontainers; +global using DotNet.Testcontainers.Builders; +global using DotNet.Testcontainers.Configurations; +global using DotNet.Testcontainers.Containers; +global using JetBrains.Annotations; +global using Microsoft.Extensions.Logging; +global using System.Diagnostics; +global using System.Linq; +global using System.Runtime.InteropServices; +global using CliWrap; +global using CliWrap.Buffered; +global using DotNet.Testcontainers.Commons; \ No newline at end of file diff --git a/src/Testcontainers/Containers/DockerContainer.cs b/src/Testcontainers/Containers/DockerContainer.cs index 1e9bbf855..a84e9c64b 100644 --- a/src/Testcontainers/Containers/DockerContainer.cs +++ b/src/Testcontainers/Containers/DockerContainer.cs @@ -24,9 +24,9 @@ public class DockerContainer : Resource, IContainer private readonly ITestcontainersClient _client; - private readonly IContainerConfiguration _configuration; + protected ContainerInspectResponse _container = new ContainerInspectResponse(); - private ContainerInspectResponse _container = new ContainerInspectResponse(); + protected readonly IContainerConfiguration _configuration; /// /// Initializes a new instance of the class. diff --git a/tests/Testcontainers.DockerCompose.Tests/.editorconfig b/tests/Testcontainers.DockerCompose.Tests/.editorconfig new file mode 100644 index 000000000..6f066619d --- /dev/null +++ b/tests/Testcontainers.DockerCompose.Tests/.editorconfig @@ -0,0 +1 @@ +root = true \ No newline at end of file diff --git a/tests/Testcontainers.DockerCompose.Tests/DockerComposeRemoteTest.cs b/tests/Testcontainers.DockerCompose.Tests/DockerComposeRemoteTest.cs new file mode 100644 index 000000000..adfbee1b3 --- /dev/null +++ b/tests/Testcontainers.DockerCompose.Tests/DockerComposeRemoteTest.cs @@ -0,0 +1,55 @@ +using JetBrains.Annotations; + +namespace Testcontainers.DockerCompose; + +public abstract class DockerComposeRemoteTest : IAsyncLifetime +{ + private readonly DockerComposeContainer _dockerComposeContainer; + + protected DockerComposeRemoteTest(DockerComposeContainer dockerComposeContainer) + { + _dockerComposeContainer = dockerComposeContainer; + } + + public Task InitializeAsync() + { + return _dockerComposeContainer.StartAsync(); + } + + public async Task DisposeAsync() + { + await _dockerComposeContainer.StopAsync(); + await _dockerComposeContainer.DisposeAsync().AsTask(); + } + + [Fact] + [Trait(nameof(DockerCli.DockerPlatform), nameof(DockerCli.DockerPlatform.Linux))] + public void ContainerStartedSuccessfully() + { + Assert.Equal(TestcontainersHealthStatus.Healthy, TestcontainersHealthStatus.Healthy); + } + + [UsedImplicitly] + public sealed class DockerComposeRemoteConfiguration : DockerComposeRemoteTest + { + public DockerComposeRemoteConfiguration() + : base(new DockerComposeBuilder() + .WithComposeFile(Path.Combine(Directory.GetCurrentDirectory(), @"./../../../docker-compose.yaml")) + .Build()) + { + } + } + + [UsedImplicitly] + public sealed class DockerComposeLocalConfiguration : DockerComposeRemoteTest + { + public DockerComposeLocalConfiguration() + : base(new DockerComposeBuilder() + .WithComposeFile(Path.Combine(Directory.GetCurrentDirectory(), @"./../../../docker-compose.yaml")) + .WithLocalCompose(true) + .Build()) + { + } + } +} + \ No newline at end of file diff --git a/tests/Testcontainers.DockerCompose.Tests/Testcontainers.DockerCompose.Tests.csproj b/tests/Testcontainers.DockerCompose.Tests/Testcontainers.DockerCompose.Tests.csproj new file mode 100644 index 000000000..3afc73302 --- /dev/null +++ b/tests/Testcontainers.DockerCompose.Tests/Testcontainers.DockerCompose.Tests.csproj @@ -0,0 +1,17 @@ + + + net8.0 + false + false + + + + + + + + + + + + diff --git a/tests/Testcontainers.DockerCompose.Tests/Usings.cs b/tests/Testcontainers.DockerCompose.Tests/Usings.cs new file mode 100644 index 000000000..784a06819 --- /dev/null +++ b/tests/Testcontainers.DockerCompose.Tests/Usings.cs @@ -0,0 +1,6 @@ +global using System.IO; +global using System.Threading.Tasks; +global using DotNet.Testcontainers.Commons; +global using DotNet.Testcontainers.Containers; +global using Testcontainers.DockerCompose; +global using Xunit; \ No newline at end of file diff --git a/tests/Testcontainers.DockerCompose.Tests/docker-compose.yaml b/tests/Testcontainers.DockerCompose.Tests/docker-compose.yaml new file mode 100644 index 000000000..d7effad58 --- /dev/null +++ b/tests/Testcontainers.DockerCompose.Tests/docker-compose.yaml @@ -0,0 +1,9 @@ +version: '3.9' + +name: test-nginx + +services: + nginx: + image: nginx + ports: + - "8080:80"