diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index a71afadfe..162c738d9 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -74,6 +74,7 @@ jobs: { name: "Testcontainers.Nats", runs-on: "ubuntu-22.04" }, { name: "Testcontainers.Neo4j", runs-on: "ubuntu-22.04" }, { name: "Testcontainers.Ollama", runs-on: "ubuntu-22.04" }, + { name: "Testcontainers.OpenSearch", runs-on: "ubuntu-22.04" }, { name: "Testcontainers.Oracle", runs-on: "ubuntu-22.04" }, { name: "Testcontainers.Oracle11", runs-on: "ubuntu-22.04" }, { name: "Testcontainers.Oracle18", runs-on: "ubuntu-22.04" }, diff --git a/Directory.Packages.props b/Directory.Packages.props index be2ddadfd..2ea3a74be 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -79,6 +79,7 @@ + diff --git a/Testcontainers.sln b/Testcontainers.sln index 6227bcc1d..e09e2b093 100644 --- a/Testcontainers.sln +++ b/Testcontainers.sln @@ -89,6 +89,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.Neo4j", "src EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.Ollama", "src\Testcontainers.Ollama\Testcontainers.Ollama.csproj", "{0DB0075D-42EC-4438-93F7-630CF5BCCAF0}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.OpenSearch", "src\Testcontainers.OpenSearch\Testcontainers.OpenSearch.csproj", "{49051DBC-6B80-4412-8505-BC2764A877BD}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.Oracle", "src\Testcontainers.Oracle\Testcontainers.Oracle.csproj", "{596EAFC1-0496-495C-B382-D57415FA456A}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.Papercut", "src\Testcontainers.Papercut\Testcontainers.Papercut.csproj", "{B2608563-8EE4-49AA-A9A0-B1614486AEEF}" @@ -203,6 +205,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.Neo4j.Tests" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.Ollama.Tests", "tests\Testcontainers.Ollama.Tests\Testcontainers.Ollama.Tests.csproj", "{D3AD7D72-510C-43A4-A401-DB3C2594508E}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.OpenSearch.Tests", "tests\Testcontainers.OpenSearch.Tests\Testcontainers.OpenSearch.Tests.csproj", "{04A7AF65-2E02-4E20-8056-2AAC0705B0BC}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.Oracle.Tests", "tests\Testcontainers.Oracle.Tests\Testcontainers.Oracle.Tests.csproj", "{4AC1088B-9965-4497-AC8E-570F1AD5631F}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.Oracle11.Tests", "tests\Testcontainers.Oracle11.Tests\Testcontainers.Oracle11.Tests.csproj", "{0A0AC20D-226B-46F9-B267-0D00964A7601}" @@ -411,6 +415,10 @@ Global {0DB0075D-42EC-4438-93F7-630CF5BCCAF0}.Debug|Any CPU.Build.0 = Debug|Any CPU {0DB0075D-42EC-4438-93F7-630CF5BCCAF0}.Release|Any CPU.ActiveCfg = Release|Any CPU {0DB0075D-42EC-4438-93F7-630CF5BCCAF0}.Release|Any CPU.Build.0 = Release|Any CPU + {49051DBC-6B80-4412-8505-BC2764A877BD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {49051DBC-6B80-4412-8505-BC2764A877BD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {49051DBC-6B80-4412-8505-BC2764A877BD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {49051DBC-6B80-4412-8505-BC2764A877BD}.Release|Any CPU.Build.0 = Release|Any CPU {596EAFC1-0496-495C-B382-D57415FA456A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {596EAFC1-0496-495C-B382-D57415FA456A}.Debug|Any CPU.Build.0 = Debug|Any CPU {596EAFC1-0496-495C-B382-D57415FA456A}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -639,6 +647,10 @@ Global {D3AD7D72-510C-43A4-A401-DB3C2594508E}.Debug|Any CPU.Build.0 = Debug|Any CPU {D3AD7D72-510C-43A4-A401-DB3C2594508E}.Release|Any CPU.ActiveCfg = Release|Any CPU {D3AD7D72-510C-43A4-A401-DB3C2594508E}.Release|Any CPU.Build.0 = Release|Any CPU + {04A7AF65-2E02-4E20-8056-2AAC0705B0BC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {04A7AF65-2E02-4E20-8056-2AAC0705B0BC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {04A7AF65-2E02-4E20-8056-2AAC0705B0BC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {04A7AF65-2E02-4E20-8056-2AAC0705B0BC}.Release|Any CPU.Build.0 = Release|Any CPU {4AC1088B-9965-4497-AC8E-570F1AD5631F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {4AC1088B-9965-4497-AC8E-570F1AD5631F}.Debug|Any CPU.Build.0 = Debug|Any CPU {4AC1088B-9965-4497-AC8E-570F1AD5631F}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -782,6 +794,7 @@ Global {BF37BEA1-0816-4326-B1E0-E82290F8FCE0} = {673F23AE-7694-4BB9-ABD4-136D6C13634E} {ADC2372B-6FE0-421D-8277-BB628E8EFC22} = {673F23AE-7694-4BB9-ABD4-136D6C13634E} {0DB0075D-42EC-4438-93F7-630CF5BCCAF0} = {673F23AE-7694-4BB9-ABD4-136D6C13634E} + {49051DBC-6B80-4412-8505-BC2764A877BD} = {673F23AE-7694-4BB9-ABD4-136D6C13634E} {596EAFC1-0496-495C-B382-D57415FA456A} = {673F23AE-7694-4BB9-ABD4-136D6C13634E} {B2608563-8EE4-49AA-A9A0-B1614486AEEF} = {673F23AE-7694-4BB9-ABD4-136D6C13634E} {8AB91636-9055-4900-A72A-7CFFACDFDBF0} = {673F23AE-7694-4BB9-ABD4-136D6C13634E} @@ -839,6 +852,7 @@ Global {87A3F137-6DC3-4CE5-91E6-01797D076086} = {7164F1FB-7F24-444A-ACD2-2C329C2B3CCF} {D3F63405-C0FA-4F83-8B79-E30BFF5FF5BF} = {7164F1FB-7F24-444A-ACD2-2C329C2B3CCF} {D3AD7D72-510C-43A4-A401-DB3C2594508E} = {7164F1FB-7F24-444A-ACD2-2C329C2B3CCF} + {04A7AF65-2E02-4E20-8056-2AAC0705B0BC} = {7164F1FB-7F24-444A-ACD2-2C329C2B3CCF} {4AC1088B-9965-4497-AC8E-570F1AD5631F} = {7164F1FB-7F24-444A-ACD2-2C329C2B3CCF} {0A0AC20D-226B-46F9-B267-0D00964A7601} = {7164F1FB-7F24-444A-ACD2-2C329C2B3CCF} {E4C887A9-A44A-4641-BB9B-0664CC4C362F} = {7164F1FB-7F24-444A-ACD2-2C329C2B3CCF} diff --git a/docs/modules/index.md b/docs/modules/index.md index 8f1962beb..254a2d9fe 100644 --- a/docs/modules/index.md +++ b/docs/modules/index.md @@ -57,6 +57,7 @@ await moduleNameContainer.StartAsync(); | MySQL | `mysql:8.0` | [NuGet](https://www.nuget.org/packages/Testcontainers.MySql) | [Source](https://github.com/testcontainers/testcontainers-dotnet/tree/develop/src/Testcontainers.MySql) | | NATS | `nats:2.9` | [NuGet](https://www.nuget.org/packages/Testcontainers.Nats) | [Source](https://github.com/testcontainers/testcontainers-dotnet/tree/develop/src/Testcontainers.Nats) | | Neo4j | `neo4j:5.4` | [NuGet](https://www.nuget.org/packages/Testcontainers.Neo4j) | [Source](https://github.com/testcontainers/testcontainers-dotnet/tree/develop/src/Testcontainers.Neo4j) | +| OpenSearch | `opensearchproject/opensearch:2.12.0` | [NuGet](https://www.nuget.org/packages/Testcontainers.OpenSearch) | [Source](https://github.com/testcontainers/testcontainers-dotnet/tree/develop/src/Testcontainers.OpenSearch) | | Oracle | `gvenzl/oracle-xe:21.3.0-slim-faststart` | [NuGet](https://www.nuget.org/packages/Testcontainers.Oracle) | [Source](https://github.com/testcontainers/testcontainers-dotnet/tree/develop/src/Testcontainers.Oracle) | | Papercut | `changemakerstudiosus/papercut-smtp:latest` | [NuGet](https://www.nuget.org/packages/Testcontainers.Papercut) | [Source](https://github.com/testcontainers/testcontainers-dotnet/tree/develop/src/Testcontainers.Papercut) | | PostgreSQL | `postgres:15.1` | [NuGet](https://www.nuget.org/packages/Testcontainers.PostgreSql) | [Source](https://github.com/testcontainers/testcontainers-dotnet/tree/develop/src/Testcontainers.PostgreSql) | diff --git a/docs/modules/opensearch.md b/docs/modules/opensearch.md new file mode 100644 index 000000000..df050268f --- /dev/null +++ b/docs/modules/opensearch.md @@ -0,0 +1,73 @@ +# OpenSearch + +[OpenSearch](https://opensearch.org/) is an open-source, enterprise-grade search and observability suite that brings order to unstructured data at scale. + +Add the following dependency to your project file: + +```shell title="NuGet" +dotnet add package Testcontainers.OpenSearch +``` + +You can start an OpenSearch container instance from any .NET application. To create and start a container instance with the default configuration, use the module-specific builder as shown below: + +=== "Start an OpenSearch container" + ```csharp + var openSearchContainer = new OpenSearchBuilder().Build(); + await openSearchContainer.StartAsync(); + ``` + +This example uses xUnit.net's `IAsyncLifetime` interface to manage the lifecycle of the container. The container is started in the `InitializeAsync` method before the test method runs, ensuring that the environment is ready for testing. After the test completes, the container is removed in the `DisposeAsync` method. + +=== "Base test class" + ```csharp + --8<-- "tests/Testcontainers.OpenSearch.Tests/OpenSearchContainerTest.cs:BaseClass" + } + ``` +=== "Insecure no auth" + ```csharp + --8<-- "tests/Testcontainers.OpenSearch.Tests/OpenSearchContainerTest.cs:InsecureNoAuth" + ``` +=== "SSL default credentials" + ```csharp + --8<-- "tests/Testcontainers.OpenSearch.Tests/OpenSearchContainerTest.cs:SslBasicAuthDefaultCredentials" + ``` +=== "SSL custom credentials" + ```csharp + --8<-- "tests/Testcontainers.OpenSearch.Tests/OpenSearchContainerTest.cs:SslBasicAuthCustomCredentials" + ``` + +How to check if the client has established a connection: + +=== "Ping example" + ```csharp + --8<-- "tests/Testcontainers.OpenSearch.Tests/OpenSearchContainerTest.cs:PingExample" + ``` + +Creating an index and alias: + +=== "Create index and alias" + ```csharp + --8<-- "tests/Testcontainers.OpenSearch.Tests/OpenSearchContainerTest.cs:CreateIndexAndAlias" + ``` +=== "Create index implementation" + ```csharp + --8<-- "tests/Testcontainers.OpenSearch.Tests/OpenSearchContainerTest.cs:CreateIndexImplementation" + ``` + +Indexing and searching a document: + +=== "Indexing document" + ```csharp + --8<-- "tests/Testcontainers.OpenSearch.Tests/OpenSearchContainerTest.cs:IndexingDocument" + ``` + +The test example uses the following NuGet dependencies: + +=== "Package References" + ```xml + --8<-- "tests/Testcontainers.OpenSearch.Tests/Testcontainers.OpenSearch.Tests.csproj:PackageReferences" + ``` + +To execute the tests, use the command `dotnet test` from a terminal. + +--8<-- "docs/modules/_call_out_test_projects.txt" \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml index f5510ebf3..af21de188 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -59,6 +59,7 @@ nav: - modules/mongodb.md - modules/mssql.md - modules/neo4j.md + - modules/opensearch.md - modules/postgres.md - modules/qdrant.md - modules/rabbitmq.md diff --git a/src/Testcontainers.OpenSearch/.editorconfig b/src/Testcontainers.OpenSearch/.editorconfig new file mode 100644 index 000000000..6f066619d --- /dev/null +++ b/src/Testcontainers.OpenSearch/.editorconfig @@ -0,0 +1 @@ +root = true \ No newline at end of file diff --git a/src/Testcontainers.OpenSearch/OpenSearchBuilder.cs b/src/Testcontainers.OpenSearch/OpenSearchBuilder.cs new file mode 100644 index 000000000..50f1e4e5a --- /dev/null +++ b/src/Testcontainers.OpenSearch/OpenSearchBuilder.cs @@ -0,0 +1,196 @@ +namespace Testcontainers.OpenSearch; + +/// +[PublicAPI] +public sealed class OpenSearchBuilder : ContainerBuilder +{ + public const string OpenSearchImage = "opensearchproject/opensearch:2.12.0"; + + public const ushort OpenSearchRestApiPort = 9200; + + public const ushort OpenSearchTransportPort = 9300; + + public const ushort OpenSearchPerformanceAnalyzerPort = 9600; + + public const string DefaultUsername = "admin"; + + public const string DefaultPassword = "yourStrong(!)P@ssw0rd"; + + /// + /// Initializes a new instance of the class. + /// + public OpenSearchBuilder() + : this(new OpenSearchConfiguration()) + { + DockerResourceConfiguration = Init().DockerResourceConfiguration; + } + + /// + /// Initializes a new instance of the class. + /// + /// The Docker resource configuration. + private OpenSearchBuilder(OpenSearchConfiguration resourceConfiguration) + : base(resourceConfiguration) + { + DockerResourceConfiguration = resourceConfiguration; + } + + /// + protected override OpenSearchConfiguration DockerResourceConfiguration { get; } + + /// + /// Sets the password for the admin user. + /// + /// + /// The password must meet the following complexity requirements: + /// + /// Minimum of 8 characters + /// At least one uppercase letter + /// At least one lowercase letter + /// At least one digit + /// At least one special character + /// + /// + /// The admin user password. + /// A configured instance of . + public OpenSearchBuilder WithPassword(string password) + { + return Merge(DockerResourceConfiguration, new OpenSearchConfiguration(password: password)) + .WithEnvironment("OPENSEARCH_INITIAL_ADMIN_PASSWORD", password); + } + + /// + /// Enables or disables the built-in security plugin in OpenSearch. + /// + /// + /// When disabled, the method + /// will use the http protocol instead of https. + /// + /// true to enable the security plugin; false to disable it. + /// A configured instance of . + public OpenSearchBuilder WithSecurityEnabled(bool securityEnabled = true) + { + return Merge(DockerResourceConfiguration, new OpenSearchConfiguration(tlsEnabled: securityEnabled)) + .WithEnvironment("plugins.security.disabled", (!securityEnabled).ToString().ToLowerInvariant()); + } + + /// + public override OpenSearchContainer Build() + { + Validate(); + + OpenSearchBuilder openSearchBuilder; + + Predicate predicate = v => v.Major == 1 || (v.Major == 2 && v.Minor < 12); + + var image = DockerResourceConfiguration.Image; + + // Images before version 2.12.0 use a hardcoded default password. + var requiresHardcodedDefaultPassword = image.MatchVersion(predicate); + if (requiresHardcodedDefaultPassword) + { + openSearchBuilder = WithPassword("admin"); + } + else + { + openSearchBuilder = this; + } + + // By default, the base builder waits until the container is running. However, for OpenSearch, a more advanced waiting strategy is necessary that requires access to the password. + // If the user does not provide a custom waiting strategy, append the default OpenSearch waiting strategy. + openSearchBuilder = DockerResourceConfiguration.WaitStrategies.Count() > 1 ? openSearchBuilder : openSearchBuilder.WithWaitStrategy(Wait.ForUnixContainer().AddCustomWaitStrategy(new WaitUntil(DockerResourceConfiguration))); + return new OpenSearchContainer(openSearchBuilder.DockerResourceConfiguration); + } + + /// + protected override OpenSearchBuilder Init() + { + return base.Init() + .WithImage(OpenSearchImage) + .WithPortBinding(OpenSearchRestApiPort, true) + .WithPortBinding(OpenSearchTransportPort, true) + .WithPortBinding(OpenSearchPerformanceAnalyzerPort, true) + .WithEnvironment("discovery.type", "single-node") + .WithSecurityEnabled() + .WithUsername(DefaultUsername) + .WithPassword(DefaultPassword); + } + + /// + protected override void Validate() + { + base.Validate(); + + _ = Guard.Argument(DockerResourceConfiguration.Password, nameof(DockerResourceConfiguration.Password)) + .NotNull() + .NotEmpty(); + } + + /// + protected override OpenSearchBuilder Clone(IResourceConfiguration resourceConfiguration) + { + return Merge(DockerResourceConfiguration, new OpenSearchConfiguration(resourceConfiguration)); + } + + /// + protected override OpenSearchBuilder Clone(IContainerConfiguration resourceConfiguration) + { + return Merge(DockerResourceConfiguration, new OpenSearchConfiguration(resourceConfiguration)); + } + + /// + protected override OpenSearchBuilder Merge(OpenSearchConfiguration oldValue, OpenSearchConfiguration newValue) + { + return new OpenSearchBuilder(new OpenSearchConfiguration(oldValue, newValue)); + } + + /// + /// Sets the OpenSearch username. + /// + /// + /// The Docker image does not allow to configure the username. + /// + /// The OpenSearch username. + /// A configured instance of . + private OpenSearchBuilder WithUsername(string username) + { + return Merge(DockerResourceConfiguration, new OpenSearchConfiguration(username: username)); + } + + /// + private sealed class WaitUntil : IWaitUntil + { + private readonly bool _tlsEnabled; + + private readonly string _username; + + private readonly string _password; + + /// + /// Initializes a new instance of the class. + /// + /// The container configuration. + public WaitUntil(OpenSearchConfiguration configuration) + { + _tlsEnabled = configuration.TlsEnabled.GetValueOrDefault(); + _username = configuration.Username; + _password = configuration.Password; + } + + /// + public async Task UntilAsync(IContainer container) + { + using var httpMessageHandler = new HttpClientHandler(); + httpMessageHandler.ServerCertificateCustomValidationCallback = (_, _, _, _) => true; + + var httpWaitStrategy = new HttpWaitStrategy() + .UsingHttpMessageHandler(httpMessageHandler) + .UsingTls(_tlsEnabled) + .WithBasicAuthentication(_username, _password) + .ForPort(OpenSearchRestApiPort); + + return await httpWaitStrategy.UntilAsync(container) + .ConfigureAwait(false); + } + } +} \ No newline at end of file diff --git a/src/Testcontainers.OpenSearch/OpenSearchConfiguration.cs b/src/Testcontainers.OpenSearch/OpenSearchConfiguration.cs new file mode 100644 index 000000000..613b75cb1 --- /dev/null +++ b/src/Testcontainers.OpenSearch/OpenSearchConfiguration.cs @@ -0,0 +1,77 @@ +namespace Testcontainers.OpenSearch; + +/// +[PublicAPI] +public sealed class OpenSearchConfiguration : ContainerConfiguration +{ + /// + /// Initializes a new instance of the class. + /// + /// A boolean value indicating whether TLS is enabled. + /// The OpenSearch username. + /// The OpenSearch password. + public OpenSearchConfiguration( + bool? tlsEnabled = null, + string username = null, + string password = null) + { + TlsEnabled = tlsEnabled; + Username = username; + Password = password; + } + + /// + /// Initializes a new instance of the class. + /// + /// The Docker resource configuration. + public OpenSearchConfiguration(IResourceConfiguration resourceConfiguration) + : base(resourceConfiguration) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The Docker resource configuration. + public OpenSearchConfiguration(IContainerConfiguration resourceConfiguration) + : base(resourceConfiguration) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The Docker resource configuration. + public OpenSearchConfiguration(OpenSearchConfiguration resourceConfiguration) + : this(new OpenSearchConfiguration(), resourceConfiguration) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The old Docker resource configuration. + /// The new Docker resource configuration. + public OpenSearchConfiguration(OpenSearchConfiguration oldValue, OpenSearchConfiguration newValue) + : base(oldValue, newValue) + { + TlsEnabled = BuildConfiguration.Combine(oldValue.TlsEnabled, newValue.TlsEnabled); + Username = BuildConfiguration.Combine(oldValue.Username, newValue.Username); + Password = BuildConfiguration.Combine(oldValue.Password, newValue.Password); + } + + /// + /// Gets a value indicating whether TLS is enabled or not. + /// + public bool? TlsEnabled { get; } + + /// + /// Gets the OpenSearch username. + /// + public string Username { get; } + + /// + /// Gets the OpenSearch password. + /// + public string Password { get; } +} \ No newline at end of file diff --git a/src/Testcontainers.OpenSearch/OpenSearchContainer.cs b/src/Testcontainers.OpenSearch/OpenSearchContainer.cs new file mode 100644 index 000000000..49213a424 --- /dev/null +++ b/src/Testcontainers.OpenSearch/OpenSearchContainer.cs @@ -0,0 +1,37 @@ +namespace Testcontainers.OpenSearch; + +/// +[PublicAPI] +public sealed class OpenSearchContainer : DockerContainer +{ + private readonly OpenSearchConfiguration _configuration; + + /// + /// Initializes a new instance of the class. + /// + /// The container configuration. + public OpenSearchContainer(OpenSearchConfiguration configuration) + : base(configuration) + { + _configuration = configuration; + } + + /// + /// Gets the OpenSearch credentials. + /// + /// The OpenSearch credentials. + public NetworkCredential GetCredentials() + { + return new NetworkCredential(_configuration.Username, _configuration.Password); + } + + /// + /// Gets the OpenSearch connection string. + /// + /// The OpenSearch connection string. + public string GetConnectionString() + { + var schema = _configuration.TlsEnabled.GetValueOrDefault() ? Uri.UriSchemeHttps : Uri.UriSchemeHttp; + return new UriBuilder(schema, Hostname, GetMappedPublicPort(OpenSearchBuilder.OpenSearchRestApiPort)).ToString(); + } +} \ No newline at end of file diff --git a/src/Testcontainers.OpenSearch/Testcontainers.OpenSearch.csproj b/src/Testcontainers.OpenSearch/Testcontainers.OpenSearch.csproj new file mode 100644 index 000000000..9a25b9c4d --- /dev/null +++ b/src/Testcontainers.OpenSearch/Testcontainers.OpenSearch.csproj @@ -0,0 +1,12 @@ + + + net8.0;net9.0;netstandard2.0;netstandard2.1 + latest + + + + + + + + \ No newline at end of file diff --git a/src/Testcontainers.OpenSearch/Usings.cs b/src/Testcontainers.OpenSearch/Usings.cs new file mode 100644 index 000000000..ca1247a6e --- /dev/null +++ b/src/Testcontainers.OpenSearch/Usings.cs @@ -0,0 +1,11 @@ +global using System; +global using System.Linq; +global using System.Net; +global using System.Net.Http; +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; \ No newline at end of file diff --git a/tests/Testcontainers.OpenSearch.Tests/.editorconfig b/tests/Testcontainers.OpenSearch.Tests/.editorconfig new file mode 100644 index 000000000..6f066619d --- /dev/null +++ b/tests/Testcontainers.OpenSearch.Tests/.editorconfig @@ -0,0 +1 @@ +root = true \ No newline at end of file diff --git a/tests/Testcontainers.OpenSearch.Tests/OpenSearchBuilderTest.cs b/tests/Testcontainers.OpenSearch.Tests/OpenSearchBuilderTest.cs new file mode 100644 index 000000000..9b3d53d78 --- /dev/null +++ b/tests/Testcontainers.OpenSearch.Tests/OpenSearchBuilderTest.cs @@ -0,0 +1,22 @@ +namespace Testcontainers.OpenSearch; + +public sealed class OpenSearchBuilderTest +{ + [Theory] + [InlineData("opensearchproject/opensearch:1.0.0")] + [InlineData("opensearchproject/opensearch:1.1.0")] + [InlineData("opensearchproject/opensearch:2.11.0")] + [InlineData("opensearchproject/opensearch:2.11.1")] + [Trait(nameof(DockerCli.DockerPlatform), nameof(DockerCli.DockerPlatform.Linux))] + public void ShouldUseHardcodedDefaultPassword(string image) + { + // Given + var opensearchContainer = new OpenSearchBuilder().WithImage(image).Build(); + + // When + var credentials = opensearchContainer.GetCredentials(); + + // Then + Assert.Equal("admin", credentials.Password); + } +} \ No newline at end of file diff --git a/tests/Testcontainers.OpenSearch.Tests/OpenSearchContainerTest.cs b/tests/Testcontainers.OpenSearch.Tests/OpenSearchContainerTest.cs new file mode 100644 index 000000000..d7c41b432 --- /dev/null +++ b/tests/Testcontainers.OpenSearch.Tests/OpenSearchContainerTest.cs @@ -0,0 +1,185 @@ +namespace Testcontainers.OpenSearch; + +// +public abstract class OpenSearchContainerTest : IAsyncLifetime +{ + private const string IndexName = "testcontainers"; + + private readonly OpenSearchContainer _openSearchContainer; + + private OpenSearchContainerTest(OpenSearchContainer openSearchContainer) + { + _openSearchContainer = openSearchContainer; + } + + public async ValueTask InitializeAsync() + { + await _openSearchContainer.StartAsync() + .ConfigureAwait(false); + } + + public async ValueTask DisposeAsync() + { + await DisposeAsyncCore() + .ConfigureAwait(false); + + GC.SuppressFinalize(this); + } + // + + // + [Fact] + [Trait(nameof(DockerCli.DockerPlatform), nameof(DockerCli.DockerPlatform.Linux))] + public async Task PingReturnsValidResponse() + { + // Given + var client = CreateClient(); + + // When + var response = await client.PingAsync(ct: TestContext.Current.CancellationToken) + .ConfigureAwait(true); + + // Then + Assert.True(response.IsValid); + } + // + + // + [Fact] + [Trait(nameof(DockerCli.DockerPlatform), nameof(DockerCli.DockerPlatform.Linux))] + public async Task ShouldCreateIndexAndAlias() + { + // Given + var client = CreateClient(); + + var index = Indices.Index(IndexName); + + var alias = new Name(IndexName + "-alias"); + + // When + var createIndexResponse = await CreateIndexAsync(client) + .ConfigureAwait(true); + + var createAliasResponse = await client.Indices.PutAliasAsync(index, alias, ct: TestContext.Current.CancellationToken) + .ConfigureAwait(true); + + // Then + Assert.True(createIndexResponse.IsValid); + Assert.True(createAliasResponse.IsValid); + } + // + + // + [Fact] + [Trait(nameof(DockerCli.DockerPlatform), nameof(DockerCli.DockerPlatform.Linux))] + public async Task ShouldIndexAndSearchForDocument() + { + // Given + var client = CreateClient(); + + var document = new Document(Guid.NewGuid().ToString(), Guid.NewGuid().ToString()); + + // When + Func, IIndexRequest> indexRequest = i => + i.Index(IndexName).Id(document.Id).Refresh(Refresh.True); + + Func, ISearchRequest> searchRequest = s => + s.Index(IndexName).Query(q => q.Match(m => m.Field("title").Query(document.Title))); + + var createIndexResponse = await CreateIndexAsync(client) + .ConfigureAwait(true); + + var indexResponse = await client.IndexAsync(document, indexRequest, TestContext.Current.CancellationToken) + .ConfigureAwait(true); + + var searchResponse = await client.SearchAsync(searchRequest, TestContext.Current.CancellationToken) + .ConfigureAwait(true); + + // Then + Assert.True(createIndexResponse.IsValid); + + Assert.True(indexResponse.IsValid); + Assert.Equal(document.Id, indexResponse.Id); + + Assert.True(searchResponse.IsValid); + Assert.Single(searchResponse.Documents, item => document.Id.Equals(item.Id)); + } + // + + // + private static Task CreateIndexAsync(OpenSearchClient client) + { + Func createIndexRequest = c => + c.Settings(s => s.NumberOfReplicas(0).NumberOfShards(1)).Map(m => m.AutoMap()); + + return client.Indices.CreateAsync(Indices.Index(IndexName), createIndexRequest, TestContext.Current.CancellationToken); + } + // + + protected virtual ValueTask DisposeAsyncCore() + { + return _openSearchContainer.DisposeAsync(); + } + + protected virtual OpenSearchClient CreateClient() + { + var credentials = _openSearchContainer.GetCredentials(); + + var connectionString = new Uri(_openSearchContainer.GetConnectionString()); + Assert.Equal(Uri.UriSchemeHttps, connectionString.Scheme); + + var connectionSettings = new ConnectionSettings(connectionString) + .BasicAuthentication(credentials.UserName, credentials.Password) + .ServerCertificateValidationCallback((_, _, _, _) => true); + + return new OpenSearchClient(connectionSettings); + } + + // + [UsedImplicitly] + public sealed class InsecureNoAuthConfiguration : OpenSearchContainerTest + { + public InsecureNoAuthConfiguration() + : base(new OpenSearchBuilder() + .WithSecurityEnabled(false) + .Build()) + { + } + + protected override OpenSearchClient CreateClient() + { + var connectionString = new Uri(_openSearchContainer.GetConnectionString()); + Assert.Equal(Uri.UriSchemeHttp, connectionString.Scheme); + return new OpenSearchClient(connectionString); + } + } + // + + // + [UsedImplicitly] + public sealed class SslBasicAuthDefaultCredentialsConfiguration : OpenSearchContainerTest + { + public SslBasicAuthDefaultCredentialsConfiguration() + : base(new OpenSearchBuilder() + .Build()) + { + } + } + // + + // + [UsedImplicitly] + public sealed class SslBasicAuthCustomCredentialsConfiguration : OpenSearchContainerTest + { + public SslBasicAuthCustomCredentialsConfiguration() + : base(new OpenSearchBuilder() + .WithPassword(new string(OpenSearchBuilder.DefaultPassword.Reverse().ToArray())) + .Build()) + { + } + } + // + + [UsedImplicitly] + private record Document(string Id, string Title); +} \ No newline at end of file diff --git a/tests/Testcontainers.OpenSearch.Tests/Testcontainers.OpenSearch.Tests.csproj b/tests/Testcontainers.OpenSearch.Tests/Testcontainers.OpenSearch.Tests.csproj new file mode 100644 index 000000000..6f7d93a01 --- /dev/null +++ b/tests/Testcontainers.OpenSearch.Tests/Testcontainers.OpenSearch.Tests.csproj @@ -0,0 +1,21 @@ + + + net9.0 + false + false + Exe + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/Testcontainers.OpenSearch.Tests/Usings.cs b/tests/Testcontainers.OpenSearch.Tests/Usings.cs new file mode 100644 index 000000000..e99d10a3d --- /dev/null +++ b/tests/Testcontainers.OpenSearch.Tests/Usings.cs @@ -0,0 +1,8 @@ +global using System; +global using System.Linq; +global using System.Threading.Tasks; +global using DotNet.Testcontainers.Commons; +global using JetBrains.Annotations; +global using OpenSearch.Client; +global using OpenSearch.Net; +global using Xunit; \ No newline at end of file