Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Qdrant container #994

Closed
wants to merge 3 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions Testcontainers.sln
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,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.Qdrant", "src\Testcontainers.Qdrant\Testcontainers.Qdrant.csproj", "{7C98973D-53D7-49F9-BDFE-E3268F402584}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.Qdrant.Tests", "tests\Testcontainers.Qdrant.Tests\Testcontainers.Qdrant.Tests.csproj", "{9DCE3E7F-B341-4AD0-BAAA-C3B91EB91B0D}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -394,6 +398,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
{7C98973D-53D7-49F9-BDFE-E3268F402584}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7C98973D-53D7-49F9-BDFE-E3268F402584}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7C98973D-53D7-49F9-BDFE-E3268F402584}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7C98973D-53D7-49F9-BDFE-E3268F402584}.Release|Any CPU.Build.0 = Release|Any CPU
{9DCE3E7F-B341-4AD0-BAAA-C3B91EB91B0D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{9DCE3E7F-B341-4AD0-BAAA-C3B91EB91B0D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{9DCE3E7F-B341-4AD0-BAAA-C3B91EB91B0D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{9DCE3E7F-B341-4AD0-BAAA-C3B91EB91B0D}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{3F2E254F-C203-43FD-A078-DC3E2CBC0F9F} = {673F23AE-7694-4BB9-ABD4-136D6C13634E}
Expand Down Expand Up @@ -458,5 +470,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}
{7C98973D-53D7-49F9-BDFE-E3268F402584} = {673F23AE-7694-4BB9-ABD4-136D6C13634E}
{9DCE3E7F-B341-4AD0-BAAA-C3B91EB91B0D} = {7164F1FB-7F24-444A-ACD2-2C329C2B3CCF}
EndGlobalSection
EndGlobal
1 change: 1 addition & 0 deletions src/Testcontainers.Qdrant/.editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
root = true
109 changes: 109 additions & 0 deletions src/Testcontainers.Qdrant/QdrantBuilder.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
using System.IO;
using System.Security.Cryptography.X509Certificates;
using Org.BouncyCastle.OpenSsl;
using Org.BouncyCastle.Security;
Comment on lines +1 to +4
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please move the usings (other classes too) into the global Usings.cs file.


namespace Testcontainers.Qdrant;

/// <inheritdoc cref="ContainerBuilder{TBuilderEntity, TContainerEntity, TConfigurationEntity}" />
[PublicAPI]
public sealed class QdrantBuilder : ContainerBuilder<QdrantBuilder, QdrantContainer, QdrantConfiguration>
{
public const string QdrantImage = "qdrant/qdrant:v1.5.0";

public const ushort QdrantHttpPort = 6333;

public const ushort QdrantGrpcPort = 6334;

public const string QdrantLocalConfigurationFilePath = "/qdrant/config/local.yaml";

public const string QdrantTlsCertFilePath = "/qdrant/tls/cert.pem";

public const string QdrantTlsKeyFilePath = "/qdrant/tls/key.pem";

public QdrantBuilder() : this(new QdrantConfiguration()) =>
DockerResourceConfiguration = Init().DockerResourceConfiguration;

private QdrantBuilder(QdrantConfiguration dockerResourceConfiguration) : base(dockerResourceConfiguration) =>
DockerResourceConfiguration = dockerResourceConfiguration;

/// <summary>
/// A path to a configuration file with which configure the instance.
/// </summary>
/// <param name="configurationFilePath">The path to the configuration file</param>
public QdrantBuilder WithConfigFile(string configurationFilePath) =>
Merge(DockerResourceConfiguration, new QdrantConfiguration(configurationFilePath: configurationFilePath))
Copy link
Collaborator

@HofmeisterAn HofmeisterAn Sep 13, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we do not require the ConfigurationFilePath property, along with the other configuration properties, at some time later, such as within the Qdrant container instance, then there is no need to keep and store the value. I think we can remove those properties from QdrantConfiguration

.WithBindMount(configurationFilePath, QdrantLocalConfigurationFilePath);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
.WithBindMount(configurationFilePath, QdrantLocalConfigurationFilePath);
.WithResourceMapping(configurationFilePath, QdrantLocalConfigurationFilePath);


/// <summary>
/// The API key used to secure the instance. A certificate should also be provided to <see cref="WithCertificate"/>
/// to enable TLS
/// </summary>
/// <param name="apiKey">The API key</param>
public QdrantBuilder WithApiKey(string apiKey) =>
Merge(DockerResourceConfiguration, new QdrantConfiguration(apiKey: apiKey))
.WithEnvironment("QDRANT__SERVICE__API_KEY", apiKey);

/// <summary>
/// A certificate to use to enable Transport Layer Security (TLS). The certificate must contain the
/// private key.
/// </summary>
/// <param name="certificate">A certificate containing a private key</param>
public QdrantBuilder WithCertificate(X509Certificate2 certificate)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we not expect the developer to provide valid cert.pem and key.pem files? This way, we can avoid dealing with all the certificate-related issues and Bouncy Castle.

{
if (!certificate.HasPrivateKey)
{
throw new ArgumentException("certificate must contain a private key", nameof(certificate));
}

var builder = new StringBuilder();
builder.AppendLine("-----BEGIN CERTIFICATE-----");
builder.AppendLine(Convert.ToBase64String(certificate.RawData, Base64FormattingOptions.InsertLineBreaks));
builder.AppendLine("-----END CERTIFICATE-----");
var cert = builder.ToString();
builder.Clear();

var keyPair = DotNetUtilities.GetKeyPair(certificate.PrivateKey);
var pemWriter = new PemWriter(new StringWriter(builder));
pemWriter.WriteObject(keyPair.Private);
var key = builder.ToString();

return Merge(DockerResourceConfiguration, new QdrantConfiguration(certificate: certificate))
.WithEnvironment("QDRANT__SERVICE__ENABLE_TLS", "1")
.WithResourceMapping(Encoding.UTF8.GetBytes(cert), QdrantTlsCertFilePath)
.WithEnvironment("QDRANT__TLS__CERT", QdrantTlsCertFilePath)
.WithResourceMapping(Encoding.UTF8.GetBytes(key), QdrantTlsKeyFilePath)
.WithEnvironment("QDRANT__TLS__KEY", QdrantTlsKeyFilePath);
}

/// <inheritdoc />
public override QdrantContainer Build()
{
Validate();
return new QdrantContainer(DockerResourceConfiguration, TestcontainersSettings.Logger);
}

/// <inheritdoc />
protected override QdrantBuilder Init() =>
base.Init()
.WithImage(QdrantImage)
.WithPortBinding(QdrantHttpPort, true)
.WithPortBinding(QdrantGrpcPort, true)
.WithWaitStrategy(Wait.ForUnixContainer()
.UntilMessageIsLogged(".*Actix runtime found; starting in Actix runtime.*"));

/// <inheritdoc />
protected override QdrantBuilder Clone(IResourceConfiguration<CreateContainerParameters> resourceConfiguration) =>
Merge(DockerResourceConfiguration, new QdrantConfiguration(resourceConfiguration));

/// <inheritdoc />
protected override QdrantBuilder Merge(QdrantConfiguration oldValue, QdrantConfiguration newValue) =>
new(new QdrantConfiguration(oldValue, newValue));

/// <inheritdoc />
protected override QdrantConfiguration DockerResourceConfiguration { get; }

/// <inheritdoc />
protected override QdrantBuilder Clone(IContainerConfiguration resourceConfiguration) =>
Merge(DockerResourceConfiguration, new QdrantConfiguration(resourceConfiguration));
}
73 changes: 73 additions & 0 deletions src/Testcontainers.Qdrant/QdrantConfiguration.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
using System.Security.Cryptography.X509Certificates;

namespace Testcontainers.Qdrant;

/// <inheritdoc cref="ContainerConfiguration" />
[PublicAPI]
public sealed class QdrantConfiguration : ContainerConfiguration
{
/// <summary>
/// Initializes a new instance of the <see cref="QdrantConfiguration" /> class.
/// </summary>
public QdrantConfiguration(string apiKey = null, X509Certificate2 certificate = null, string configurationFilePath = null)
{
ApiKey = apiKey;
Certificate = certificate;
ConfigurationFilePath = configurationFilePath;
}

/// <summary>
/// Initializes a new instance of the <see cref="QdrantConfiguration" /> class.
/// </summary>
/// <param name="resourceConfiguration">The Docker resource configuration.</param>
public QdrantConfiguration(IResourceConfiguration<CreateContainerParameters> resourceConfiguration)
: base(resourceConfiguration)
{
}

/// <summary>
/// Initializes a new instance of the <see cref="QdrantConfiguration" /> class.
/// </summary>
/// <param name="resourceConfiguration">The Docker resource configuration.</param>
public QdrantConfiguration(IContainerConfiguration resourceConfiguration)
: base(resourceConfiguration)
{
}

/// <summary>
/// Initializes a new instance of the <see cref="QdrantConfiguration" /> class.
/// </summary>
/// <param name="resourceConfiguration">The Docker resource configuration.</param>
public QdrantConfiguration(QdrantConfiguration resourceConfiguration)
: this(new QdrantConfiguration(), resourceConfiguration)
{
}

/// <summary>
/// Initializes a new instance of the <see cref="QdrantConfiguration" /> class.
/// </summary>
/// <param name="oldValue">The old Docker resource configuration.</param>
/// <param name="newValue">The new Docker resource configuration.</param>
public QdrantConfiguration(QdrantConfiguration oldValue, QdrantConfiguration newValue)
: base(oldValue, newValue)
{
ApiKey = BuildConfiguration.Combine(oldValue.ApiKey, newValue.ApiKey);
Certificate = BuildConfiguration.Combine(oldValue.Certificate, newValue.Certificate);
ConfigurationFilePath = BuildConfiguration.Combine(oldValue.ConfigurationFilePath, newValue.ConfigurationFilePath);
}

/// <summary>
/// Gets the API key used to secure Qdrant
/// </summary>
public string ApiKey { get; }

/// <summary>
/// Gets the certificate used to configure Transport Layer Security
/// </summary>
public X509Certificate2 Certificate { get; }

/// <summary>
/// Gets the path to the configuration file used to configure Qdrant
/// </summary>
public string ConfigurationFilePath { get; }
}
27 changes: 27 additions & 0 deletions src/Testcontainers.Qdrant/QdrantContainer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
namespace Testcontainers.Qdrant;

/// <inheritdoc cref="DockerContainer" />
[PublicAPI]
public class QdrantContainer : DockerContainer
{
private readonly QdrantConfiguration _configuration;

public QdrantContainer(QdrantConfiguration configuration, ILogger logger) : base(configuration, logger)
{
_configuration = configuration;
}

public string GetHttpConnectionString()
{
var scheme = _configuration.Certificate != null ? Uri.UriSchemeHttps : Uri.UriSchemeHttp;
var endpoint = new UriBuilder(scheme, Hostname, GetMappedPublicPort(QdrantBuilder.QdrantHttpPort));
return endpoint.ToString();
}

public string GetGrpcConnectionString()
{
var scheme = _configuration.Certificate != null ? Uri.UriSchemeHttps : Uri.UriSchemeHttp;
var endpoint = new UriBuilder(scheme, Hostname, GetMappedPublicPort(QdrantBuilder.QdrantGrpcPort));
return endpoint.ToString();
}
}
13 changes: 13 additions & 0 deletions src/Testcontainers.Qdrant/Testcontainers.Qdrant.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>netstandard2.0;netstandard2.1</TargetFrameworks>
<LangVersion>latest</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.1.1" PrivateAssets="All"/>
<PackageReference Include="JetBrains.Annotations" Version="2022.3.1" PrivateAssets="All"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="$(SolutionDir)src/Testcontainers/Testcontainers.csproj"/>
</ItemGroup>
</Project>
12 changes: 12 additions & 0 deletions src/Testcontainers.Qdrant/Usings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
global using System;
global using System.Collections.Generic;
global using System.Linq;
global using System.Text;
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;
1 change: 1 addition & 0 deletions tests/Testcontainers.Qdrant.Tests/.editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
root = true
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
using System.Net.Http;
using System.Security.Cryptography.X509Certificates;

namespace Testcontainers.Qdrant;

public sealed class QdrantContainerApiKeyCertificateTest : IAsyncLifetime
{
private static readonly X509Certificate2 Cert = X509CertificateGenerator.GenerateCert("CN=Testcontainers");
private const string ApiKey = "password!";

private readonly QdrantContainer _qdrantContainer = new QdrantBuilder()
.WithApiKey(ApiKey)
.WithCertificate(Cert)
.Build();

public Task InitializeAsync()
{
return _qdrantContainer.StartAsync();
}

public Task DisposeAsync()
{
return _qdrantContainer.DisposeAsync().AsTask();
}

[Fact]
[Trait(nameof(DockerCli.DockerPlatform), nameof(DockerCli.DockerPlatform.Linux))]
public async Task PingReturnsValidResponse()
{
var httpMessageHandler = new HttpClientHandler
{
ServerCertificateCustomValidationCallback = (_, certificate, _, _) =>
certificate.Thumbprint == Cert.Thumbprint,
};

var client = new HttpClient(httpMessageHandler)
{
BaseAddress = new Uri(_qdrantContainer.GetHttpConnectionString()),
DefaultRequestHeaders = { Host = "Testcontainers" },
};

client.DefaultRequestHeaders.Add("api-key", ApiKey);

var response = await client.GetAsync("/collections");

Assert.True(response.IsSuccessStatusCode);
}

[Fact]
[Trait(nameof(DockerCli.DockerPlatform), nameof(DockerCli.DockerPlatform.Linux))]
public async Task PingWithoutApiKeyReturnsInvalidResponse()
{
var httpMessageHandler = new HttpClientHandler
{
ServerCertificateCustomValidationCallback = (_, _, _, _) => true,
};

var client = new HttpClient(httpMessageHandler)
{
BaseAddress = new Uri(_qdrantContainer.GetHttpConnectionString()),
DefaultRequestHeaders = { Host = "Testcontainers" },
};

var response = await client.GetAsync("/collections");

Assert.False(response.IsSuccessStatusCode);
Assert.Equal("Invalid api-key", await response.Content.ReadAsStringAsync());
}

[Fact]
[Trait(nameof(DockerCli.DockerPlatform), nameof(DockerCli.DockerPlatform.Linux))]
public async Task PingWithoutCertificateValidationReturnsInvalidResponse()
{
var client = new HttpClient
{
BaseAddress = new Uri(_qdrantContainer.GetHttpConnectionString()),
DefaultRequestHeaders = { Host = "Testcontainers" },
};

client.DefaultRequestHeaders.Add("api-key", ApiKey);

// The SSL connection could not be established
await Assert.ThrowsAsync<HttpRequestException>(() => client.GetAsync("/collections"));
}
}
Loading