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

feat: add Qdrant module #1149

Open
wants to merge 15 commits into
base: develop
Choose a base branch
from
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
<PackageVersion Include="Neo4j.Driver" Version="5.5.0"/>
<PackageVersion Include="Npgsql" Version="6.0.10"/>
<PackageVersion Include="Oracle.ManagedDataAccess.Core" Version="3.21.90"/>
<PackageVersion Include="Qdrant.Client" Version="1.8.0"/>
<PackageVersion Include="RabbitMQ.Client" Version="6.4.0"/>
<PackageVersion Include="RavenDB.Client" Version="5.4.100"/>
<PackageVersion Include="Selenium.WebDriver" Version="4.8.1"/>
Expand Down
14 changes: 14 additions & 0 deletions Testcontainers.sln
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,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 @@ -568,6 +572,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
{5365F780-0E6C-41F0-B1B9-7DC34368F80C} = {673F23AE-7694-4BB9-ABD4-136D6C13634E}
Expand Down Expand Up @@ -661,5 +673,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
77 changes: 77 additions & 0 deletions src/Testcontainers.Qdrant/QdrantBuilder.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
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.8.3";

public const ushort QdrantHttpPort = 6333;

public const ushort QdrantGrpcPort = 6334;

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>
/// The API key used to secure the instance. A certificate and private key should also be
/// provided to <see cref="WithCertificate"/> to enable Transport Layer Security (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 and private key to enable Transport Layer Security (TLS).
/// </summary>
/// <param name="certificate">A public certificate in PEM format</param>
/// <param name="privateKey">A private key for the certificate in PEM format</param>
public QdrantBuilder WithCertificate(string certificate, string privateKey)
{
return Merge(DockerResourceConfiguration, new QdrantConfiguration(certificate: certificate, privateKey: privateKey))
.WithEnvironment("QDRANT__SERVICE__ENABLE_TLS", "1")
.WithResourceMapping(Encoding.UTF8.GetBytes(certificate), QdrantTlsCertFilePath)
.WithEnvironment("QDRANT__TLS__CERT", QdrantTlsCertFilePath)
.WithResourceMapping(Encoding.UTF8.GetBytes(privateKey), QdrantTlsKeyFilePath)
.WithEnvironment("QDRANT__TLS__KEY", QdrantTlsKeyFilePath);
}

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

/// <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.*"));
russcam marked this conversation as resolved.
Show resolved Hide resolved

/// <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 static DotNet.Testcontainers.Builders.BuildConfiguration;

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, string certificate = null, string privateKey = null)
{
ApiKey = apiKey;
Copy link
Collaborator

Choose a reason for hiding this comment

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

It looks like that the API key is never used anywhere afterward, you do not need to store it in the configuration.

Certificate = certificate;
PrivateKey = privateKey;
}

/// <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 = Combine(oldValue.ApiKey, newValue.ApiKey);
Certificate = Combine(oldValue.Certificate, newValue.Certificate);
PrivateKey = Combine(oldValue.PrivateKey, newValue.PrivateKey);
}

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

/// <summary>
/// Gets the certificate used to configure Transport Layer Security. Certificate must be in PEM format.
/// </summary>
public string Certificate { get; }

/// <summary>
/// Gets the private key used to configure Transport Layer Security. Private key must be in PEM format.
/// </summary>
public string PrivateKey { get; }
}
33 changes: 33 additions & 0 deletions src/Testcontainers.Qdrant/QdrantContainer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
namespace Testcontainers.Qdrant;

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

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

/// <summary>
/// Gets the connection string for connecting to Qdrant REST APIs
/// </summary>
public string GetHttpConnectionString()
{
var scheme = _configuration.Certificate != null ? Uri.UriSchemeHttps : Uri.UriSchemeHttp;
var endpoint = new UriBuilder(scheme, Hostname, GetMappedPublicPort(QdrantBuilder.QdrantHttpPort));
return endpoint.ToString();
}

/// <summary>
/// Gets the connection string for connecting to Qdrant gRPC APIs
/// </summary>
public string GetGrpcConnectionString()
{
var scheme = _configuration.Certificate != null ? Uri.UriSchemeHttps : Uri.UriSchemeHttp;
var endpoint = new UriBuilder(scheme, Hostname, GetMappedPublicPort(QdrantBuilder.QdrantGrpcPort));
return endpoint.ToString();
}
}
12 changes: 12 additions & 0 deletions src/Testcontainers.Qdrant/Testcontainers.Qdrant.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net6.0;net8.0;netstandard2.0;netstandard2.1</TargetFrameworks>
Copy link

Choose a reason for hiding this comment

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

Are all these really necessary? I would think the following would be enough:

Suggested change
<TargetFrameworks>net6.0;net8.0;netstandard2.0;netstandard2.1</TargetFrameworks>
<TargetFrameworks>net6.0;netstandard2.0</TargetFrameworks>

Copy link
Author

Choose a reason for hiding this comment

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

Every other TestContainer module defines these target frameworks, so was sticking to the project convention. I've also added net462 for same reason Milvus module does: #1131 (comment)

Copy link
Collaborator

Choose a reason for hiding this comment

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

I think we should stick to the repo convention too 👍. In the end, it does not really matter, right (except for the legacy framework)? We can consider dropping support for all projects. I just kept it because there were some preprocessor directives we used in the past, but I think we have already changed them. I need to double-check.

<LangVersion>latest</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="JetBrains.Annotations" VersionOverride="2023.3.0" PrivateAssets="All"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="$(SolutionDir)src/Testcontainers/Testcontainers.csproj"/>
</ItemGroup>
</Project>
8 changes: 8 additions & 0 deletions src/Testcontainers.Qdrant/Usings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
global using System;
global using System.Text;
global using Docker.DotNet.Models;
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,105 @@
namespace Testcontainers.Qdrant;

public sealed class QdrantContainerApiKeyCertificateTest : IAsyncLifetime
{
private const string Host = "Testcontainers";
private const string ApiKey = "password!";

private static readonly X509CertificateGenerator.PemCertificate Cert =
X509CertificateGenerator.Generate($"CN={Host}");
private static readonly string Thumbprint =
X509Certificate2.CreateFromPem(Cert.Certificate, Cert.PrivateKey)
.GetCertHashString(HashAlgorithmName.SHA256);

private readonly QdrantContainer _qdrantContainer = new QdrantBuilder()
.WithApiKey(ApiKey)
.WithCertificate(Cert.Certificate, Cert.PrivateKey)
.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 ListCollectionsReturnsValidResponse()
{
var httpMessageHandler = new HttpClientHandler
{
ServerCertificateCustomValidationCallback =
CertificateValidation.Thumbprint(Thumbprint),
};

var channel = GrpcChannel.ForAddress(
_qdrantContainer.GetGrpcConnectionString(),
new GrpcChannelOptions
{
HttpClient = new HttpClient(httpMessageHandler)
{
DefaultRequestHeaders = { Host = Host },
},
});
var callInvoker = channel.Intercept(metadata =>
{
metadata.Add("api-key", ApiKey);
return metadata;
});

var grpcClient = new QdrantGrpcClient(callInvoker);
var client = new QdrantClient(grpcClient);
var response = await client.ListCollectionsAsync();

Assert.Empty(response);
}

[Fact]
[Trait(nameof(DockerCli.DockerPlatform), nameof(DockerCli.DockerPlatform.Linux))]
public async Task ListCollectionsWithoutApiKeyReturnsInvalidResponse()
{
var httpMessageHandler = new HttpClientHandler
{
ServerCertificateCustomValidationCallback =
CertificateValidation.Thumbprint(Thumbprint)
};

var channel = GrpcChannel.ForAddress(
_qdrantContainer.GetGrpcConnectionString(),
new GrpcChannelOptions
{
HttpClient = new HttpClient(httpMessageHandler)
{
DefaultRequestHeaders = { Host = Host },
},
});

var grpcClient = new QdrantGrpcClient(channel);
var client = new QdrantClient(grpcClient);

var exception = await Assert.ThrowsAsync<RpcException>(async () =>
await client.ListCollectionsAsync());

Assert.Equal(StatusCode.PermissionDenied, exception.Status.StatusCode);
}

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

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

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