diff --git a/Directory.Packages.props b/Directory.Packages.props index 3b7235616..f684b4ee2 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -29,6 +29,7 @@ + diff --git a/Testcontainers.sln b/Testcontainers.sln index 9595905ed..7046c518c 100644 --- a/Testcontainers.sln +++ b/Testcontainers.sln @@ -35,6 +35,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.Couchbase", EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.CouchDb", "src\Testcontainers.CouchDb\Testcontainers.CouchDb.csproj", "{DCECB1F6-D9AA-431F-AE42-25D56B9E7DFC}" 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.DynamoDb", "src\Testcontainers.DynamoDb\Testcontainers.DynamoDb.csproj", "{2EAFA567-9F68-4C52-9DBC-8F3EC11BB2CE}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.Elasticsearch", "src\Testcontainers.Elasticsearch\Testcontainers.Elasticsearch.csproj", "{641DDEA5-B6E0-41E6-BA11-7A28C0913127}" @@ -125,6 +127,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.CouchDb.Test EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.Databases.Tests", "tests\Testcontainers.Databases.Tests\Testcontainers.Databases.Tests.csproj", "{DA54916E-1128-4200-B6AE-9F5BF02D832D}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.DockerCompose.Tests", "tests\Testcontainers.DockerCompose.Tests\Testcontainers.DockerCompose.Tests.csproj", "{D77017F1-9E38-4B06-8CEB-9B3D98B6497C}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.DynamoDb.Tests", "tests\Testcontainers.DynamoDb.Tests\Testcontainers.DynamoDb.Tests.csproj", "{101515E6-74C1-40F9-85C8-871F742A378D}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.Elasticsearch.Tests", "tests\Testcontainers.Elasticsearch.Tests\Testcontainers.Elasticsearch.Tests.csproj", "{DD5B3678-468F-4D73-AECE-705E3D66CD43}" @@ -248,6 +252,10 @@ Global {DCECB1F6-D9AA-431F-AE42-25D56B9E7DFC}.Debug|Any CPU.Build.0 = Debug|Any CPU {DCECB1F6-D9AA-431F-AE42-25D56B9E7DFC}.Release|Any CPU.ActiveCfg = Release|Any CPU {DCECB1F6-D9AA-431F-AE42-25D56B9E7DFC}.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 {2EAFA567-9F68-4C52-9DBC-8F3EC11BB2CE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {2EAFA567-9F68-4C52-9DBC-8F3EC11BB2CE}.Debug|Any CPU.Build.0 = Debug|Any CPU {2EAFA567-9F68-4C52-9DBC-8F3EC11BB2CE}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -428,6 +436,10 @@ Global {DA54916E-1128-4200-B6AE-9F5BF02D832D}.Debug|Any CPU.Build.0 = Debug|Any CPU {DA54916E-1128-4200-B6AE-9F5BF02D832D}.Release|Any CPU.ActiveCfg = Release|Any CPU {DA54916E-1128-4200-B6AE-9F5BF02D832D}.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 {101515E6-74C1-40F9-85C8-871F742A378D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {101515E6-74C1-40F9-85C8-871F742A378D}.Debug|Any CPU.Build.0 = Debug|Any CPU {101515E6-74C1-40F9-85C8-871F742A378D}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -581,6 +593,7 @@ Global {A724806F-8C94-4438-8011-04A9A1575318} = {673F23AE-7694-4BB9-ABD4-136D6C13634E} {58E94721-2681-4D82-8D94-0B2F9DB0D575} = {673F23AE-7694-4BB9-ABD4-136D6C13634E} {DCECB1F6-D9AA-431F-AE42-25D56B9E7DFC} = {673F23AE-7694-4BB9-ABD4-136D6C13634E} + {5CF21353-F3DB-4993-B9D8-DFAA8B2C4D2F} = {673F23AE-7694-4BB9-ABD4-136D6C13634E} {2EAFA567-9F68-4C52-9DBC-8F3EC11BB2CE} = {673F23AE-7694-4BB9-ABD4-136D6C13634E} {641DDEA5-B6E0-41E6-BA11-7A28C0913127} = {673F23AE-7694-4BB9-ABD4-136D6C13634E} {84D707E0-C9FA-4327-85DC-0AFEBEA73572} = {673F23AE-7694-4BB9-ABD4-136D6C13634E} @@ -626,6 +639,7 @@ Global {809322BA-D690-4F2B-B884-23F895663963} = {7164F1FB-7F24-444A-ACD2-2C329C2B3CCF} {E4520FB1-4466-4DCA-AD08-4075102C68D3} = {7164F1FB-7F24-444A-ACD2-2C329C2B3CCF} {DA54916E-1128-4200-B6AE-9F5BF02D832D} = {7164F1FB-7F24-444A-ACD2-2C329C2B3CCF} + {D77017F1-9E38-4B06-8CEB-9B3D98B6497C} = {7164F1FB-7F24-444A-ACD2-2C329C2B3CCF} {101515E6-74C1-40F9-85C8-871F742A378D} = {7164F1FB-7F24-444A-ACD2-2C329C2B3CCF} {DD5B3678-468F-4D73-AECE-705E3D66CD43} = {7164F1FB-7F24-444A-ACD2-2C329C2B3CCF} {64F8E9B9-78FD-4E13-BDDF-0340E2D4E1D0} = {7164F1FB-7F24-444A-ACD2-2C329C2B3CCF} 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..9e9c930a8 --- /dev/null +++ b/src/Testcontainers.DockerCompose/DockerComposeBuilder.cs @@ -0,0 +1,114 @@ +namespace Testcontainers.DockerCompose; + +/// +[PublicAPI] +public sealed class DockerComposeBuilder : ContainerBuilder +{ + public const string DockerComposeFilePath = "/docker-compose.yml"; + + public const string DockerComposeImage = "docker:25.0-cli"; + + // TODO: This does not support all container runtimes (host configurations). We should do something similar to what we are doing in the Resource Reaper implementation. + private 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; } + + /// + /// Sets the Docker Compose file. + /// + /// The Docker Compose file path. + /// A configured instance of . + public DockerComposeBuilder WithComposeFile(string composeFilePath) + { + return Merge(DockerResourceConfiguration, new DockerComposeConfiguration(composeFilePath: composeFilePath)) + .WithEntrypoint("docker", "compose", "--project-name", Guid.NewGuid().ToString("D"), "--file", composeFilePath) + .WithResourceMapping(new FileInfo(composeFilePath), new FileInfo(DockerComposeFilePath)); + } + + /// + /// Sets the Docker Compose mode. + /// + /// The Docker Compose mode. + /// A configured instance of . + public DockerComposeBuilder WithComposeMode(DockerComposeMode mode) + { + return Merge(DockerResourceConfiguration, new DockerComposeConfiguration(mode: mode)); + } + + /// + public override DockerComposeContainer Build() + { + Validate(); + + switch (DockerResourceConfiguration.Mode) + { + case DockerComposeMode.Local: + return new DockerComposeLocalContainer(DockerResourceConfiguration, TestcontainersSettings.Logger); + case DockerComposeMode.Remote: + return new DockerComposeRemoteContainer(DockerResourceConfiguration, TestcontainersSettings.Logger); + default: + throw new ArgumentOutOfRangeException(nameof(DockerResourceConfiguration.Mode), "Docker Compose mode not supported."); + } + } + + /// + protected override DockerComposeBuilder Init() + { + return base.Init() + .WithImage(DockerComposeImage) + .WithCommand("up") + .WithCommand("--detach") + .WithComposeMode(DockerComposeMode.Remote) + .WithBindMount(DockerSocketPath, DockerSocketPath, AccessMode.ReadOnly); + } + + /// + protected override void Validate() + { + base.Validate(); + + const string dockerComposeFileNotFound = "Docker Compose file not found."; + _ = Guard.Argument(DockerResourceConfiguration.ComposeFilePath, nameof(DockerComposeConfiguration.ComposeFilePath)) + .NotNull() + .NotEmpty() + .ThrowIf(argument => !File.Exists(argument.Value), argument => new FileNotFoundException(dockerComposeFileNotFound, argument.Value)); + } + + /// + 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)); + } +} \ 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..0771ccd40 --- /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 Docker Compose file path. + /// The Docker Compose mode. + public DockerComposeConfiguration( + string composeFilePath = null, + DockerComposeMode? mode = null) + { + ComposeFilePath = composeFilePath; + Mode = mode; + } + + /// + /// 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) + { + ComposeFilePath = BuildConfiguration.Combine(oldValue.ComposeFilePath, newValue.ComposeFilePath); + Mode = BuildConfiguration.Combine(oldValue.Mode, newValue.Mode); + } + + /// + /// Gets the Docker Compose file path. + /// + public string ComposeFilePath { get; } + + /// + /// Gets the Docker Compose mode. + /// + public DockerComposeMode? Mode { 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..1ddc29367 --- /dev/null +++ b/src/Testcontainers.DockerCompose/DockerComposeContainer.cs @@ -0,0 +1,16 @@ +namespace Testcontainers.DockerCompose; + +/// +[PublicAPI] +public abstract class DockerComposeContainer : DockerContainer +{ + /// + /// Initializes a new instance of the class. + /// + /// The container configuration. + /// The logger. + protected DockerComposeContainer(DockerComposeConfiguration configuration, ILogger logger) + : base(configuration, logger) + { + } +} \ No newline at end of file diff --git a/src/Testcontainers.DockerCompose/DockerComposeLocalContainer.cs b/src/Testcontainers.DockerCompose/DockerComposeLocalContainer.cs new file mode 100644 index 000000000..083fff8f5 --- /dev/null +++ b/src/Testcontainers.DockerCompose/DockerComposeLocalContainer.cs @@ -0,0 +1,42 @@ +namespace Testcontainers.DockerCompose; + +/// +internal sealed class DockerComposeLocalContainer : DockerComposeContainer +{ + private readonly DockerComposeConfiguration _configuration; + + /// + /// Initializes a new instance of the class. + /// + /// The container configuration. + /// The logger. + public DockerComposeLocalContainer(DockerComposeConfiguration configuration, ILogger logger) + : base(configuration, logger) + { + _configuration = configuration; + } + + /// + public override Task StartAsync(CancellationToken ct = default) + { + var workingDirectoryPath = Path.GetDirectoryName(_configuration.ComposeFilePath); + + return Cli.Wrap(_configuration.Entrypoint.First()) + .WithArguments(_configuration.Entrypoint.Skip(1).Concat(_configuration.Command)) + .WithWorkingDirectory(workingDirectoryPath) + .WithValidation(CommandResultValidation.None) + .ExecuteBufferedAsync(ct); + } + + /// + public override Task StopAsync(CancellationToken ct = default) + { + var workingDirectoryPath = Path.GetDirectoryName(_configuration.ComposeFilePath); + + return Cli.Wrap(_configuration.Entrypoint.First()) + .WithArguments(_configuration.Entrypoint.Skip(1).Concat(new[] { "down" })) + .WithWorkingDirectory(workingDirectoryPath) + .WithValidation(CommandResultValidation.None) + .ExecuteBufferedAsync(ct); + } +} \ No newline at end of file diff --git a/src/Testcontainers.DockerCompose/DockerComposeMode.cs b/src/Testcontainers.DockerCompose/DockerComposeMode.cs new file mode 100644 index 000000000..66bceb2a3 --- /dev/null +++ b/src/Testcontainers.DockerCompose/DockerComposeMode.cs @@ -0,0 +1,18 @@ +namespace Testcontainers.DockerCompose +{ + /// + /// Docker Compose mode. + /// + public enum DockerComposeMode + { + /// + /// The local Docker Compose mode utilizes the Docker Compose CLI to execute the configuration. + /// + Local, + + /// + /// The remote Docker Compose mode utilizes a sidecar container to execute the configuration. + /// + Remote, + } +} \ No newline at end of file diff --git a/src/Testcontainers.DockerCompose/DockerComposeRemoteContainer.cs b/src/Testcontainers.DockerCompose/DockerComposeRemoteContainer.cs new file mode 100644 index 000000000..8f9b4827a --- /dev/null +++ b/src/Testcontainers.DockerCompose/DockerComposeRemoteContainer.cs @@ -0,0 +1,15 @@ +namespace Testcontainers.DockerCompose; + +/// +internal sealed class DockerComposeRemoteContainer : DockerComposeContainer +{ + /// + /// Initializes a new instance of the class. + /// + /// The container configuration. + /// The logger. + public DockerComposeRemoteContainer(DockerComposeConfiguration configuration, ILogger logger) + : base(configuration, logger) + { + } +} \ 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..4dea72a7c --- /dev/null +++ b/src/Testcontainers.DockerCompose/Testcontainers.DockerCompose.csproj @@ -0,0 +1,13 @@ + + + net6.0;net8.0;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..5090695fb --- /dev/null +++ b/src/Testcontainers.DockerCompose/Usings.cs @@ -0,0 +1,14 @@ +global using System; +global using System.IO; +global using System.Linq; +global using System.Threading; +global using System.Threading.Tasks; +global using CliWrap; +global using CliWrap.Buffered; +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; \ No newline at end of file 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/DockerComposeTest.cs b/tests/Testcontainers.DockerCompose.Tests/DockerComposeTest.cs new file mode 100644 index 000000000..8f4c12b97 --- /dev/null +++ b/tests/Testcontainers.DockerCompose.Tests/DockerComposeTest.cs @@ -0,0 +1,54 @@ +namespace Testcontainers.DockerCompose; + +public abstract class DockerComposeContainerTest : IAsyncLifetime +{ + private readonly DockerComposeContainer _dockerComposeContainer; + + private DockerComposeContainerTest(DockerComposeContainer dockerComposeContainer) + { + _dockerComposeContainer = dockerComposeContainer; + } + + public Task InitializeAsync() + { + return _dockerComposeContainer.StartAsync(); + } + + public Task DisposeAsync() + { + return Task.CompletedTask; + return _dockerComposeContainer.DisposeAsync().AsTask(); + } + + [Fact] + [Trait(nameof(DockerCli.DockerPlatform), nameof(DockerCli.DockerPlatform.Linux))] + public void ContainerStartedSuccessfully() + { + // TODO: How do we test that the Compose configuration is actually working? How do we access services? + Assert.Equal(TestcontainersHealthStatus.Healthy, TestcontainersHealthStatus.Healthy); + } + + [UsedImplicitly] + public sealed class DockerComposeLocalConfiguration : DockerComposeContainerTest + { + public DockerComposeLocalConfiguration() + : base(new DockerComposeBuilder() + .WithComposeFile("docker-compose.yml") + .WithComposeMode(DockerComposeMode.Local) + .Build()) + { + } + } + + [UsedImplicitly] + public sealed class DockerComposeRemoteConfiguration : DockerComposeContainerTest + { + public DockerComposeRemoteConfiguration() + : base(new DockerComposeBuilder() + .WithComposeFile("docker-compose.yml") + .WithComposeMode(DockerComposeMode.Remote) + .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..4c2e731a4 --- /dev/null +++ b/tests/Testcontainers.DockerCompose.Tests/Testcontainers.DockerCompose.Tests.csproj @@ -0,0 +1,22 @@ + + + net8.0 + false + false + + + + + + + + + + + + + + PreserveNewest + + + \ No newline at end of file diff --git a/tests/Testcontainers.DockerCompose.Tests/Usings.cs b/tests/Testcontainers.DockerCompose.Tests/Usings.cs new file mode 100644 index 000000000..aa9188c68 --- /dev/null +++ b/tests/Testcontainers.DockerCompose.Tests/Usings.cs @@ -0,0 +1,5 @@ +global using System.Threading.Tasks; +global using DotNet.Testcontainers.Commons; +global using DotNet.Testcontainers.Containers; +global using JetBrains.Annotations; +global using Xunit; \ No newline at end of file diff --git a/tests/Testcontainers.DockerCompose.Tests/docker-compose.yml b/tests/Testcontainers.DockerCompose.Tests/docker-compose.yml new file mode 100644 index 000000000..37999d25d --- /dev/null +++ b/tests/Testcontainers.DockerCompose.Tests/docker-compose.yml @@ -0,0 +1,9 @@ +version: '3.9' +services: + test: + image: alpine:3.17 + entrypoint: + - /bin/sh + - -c + command: + - 'trap : TERM INT; sleep infinity & wait' \ No newline at end of file