Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
6b6d3fc
Add Qdrant container
russcam Sep 10, 2023
1cd8f77
Fix TLS tests on Linux
russcam Sep 10, 2023
0181b8e
Remove superfluous solution items
russcam Sep 10, 2023
dc5f0cb
PR feedback
russcam Mar 29, 2024
93494d7
remove superfluous whitespace
russcam Mar 30, 2024
cb55a2f
address PR checks
russcam Apr 4, 2024
ca0b39b
Use readyz endpoint
russcam Apr 6, 2024
da10ea1
Also target net462
russcam Apr 6, 2024
bb42811
chore: Order csproj in sln
HofmeisterAn Apr 7, 2024
21a3439
chore: Replace SolutionDir with relative path
HofmeisterAn Apr 7, 2024
f662ec6
chore: Remove BOM
HofmeisterAn Apr 7, 2024
331abc5
chore: Fix minor repo inconsistencies
HofmeisterAn Apr 7, 2024
96be0c7
Use CertificateRequest to generate PEM certificate
russcam Apr 8, 2024
6b5e170
Merge branch 'qdrant-container' of github.com:russcam/testcontainers-…
russcam Apr 8, 2024
f31250e
remove BOM
russcam Apr 8, 2024
6bcd473
Update version and add docs
russcam Mar 8, 2025
e0cdcbd
Merge branch 'develop' into qdrant-container
russcam Mar 8, 2025
be6f7b6
Update to net9.0
russcam Mar 8, 2025
eb3fa5a
Add qdrant docs to configuration
russcam Mar 8, 2025
7e95140
Configure qdrant in cicd.yml
russcam Mar 8, 2025
7b1ae6f
chore: Align repo standards
HofmeisterAn Mar 13, 2025
04c7e9e
chore: Remove BOM
HofmeisterAn Mar 13, 2025
3397634
fix: Use correct id to include code snippet in docs
HofmeisterAn Mar 13, 2025
d26602b
chore: Add note about PEM format
HofmeisterAn Mar 13, 2025
3cb3c3b
Update src/Testcontainers.Qdrant/QdrantBuilder.cs
HofmeisterAn Mar 14, 2025
7729cbd
Update docs/modules/qdrant.md
HofmeisterAn Mar 14, 2025
cf77f83
Merge branch 'develop' into qdrant-container
HofmeisterAn Mar 14, 2025
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
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
97 changes: 97 additions & 0 deletions src/Testcontainers.Qdrant/QdrantBuilder.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
using System.Linq;
using System.Net.Http;

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();

var waitStrategy = Wait.ForUnixContainer().UntilHttpRequestIsSucceeded(request =>
{
var httpWaitStrategy = request.ForPort(QdrantHttpPort).ForPath("/readyz");

// allow any certificate defined to pass validation
if (DockerResourceConfiguration.Certificate is not null)
{
httpWaitStrategy.UsingTls()
.UsingHttpMessageHandler(new HttpClientHandler
{
ServerCertificateCustomValidationCallback = (_, _, _, _) => true
});
}

return httpWaitStrategy;
});

var qdrantBuilder = DockerResourceConfiguration.WaitStrategies.Count() > 1 ? this : WithWaitStrategy(waitStrategy);
return new QdrantContainer(qdrantBuilder.DockerResourceConfiguration);
}

/// <inheritdoc />
protected override QdrantBuilder Init() =>
base.Init()
.WithImage(QdrantImage)
.WithPortBinding(QdrantHttpPort, true)
.WithPortBinding(QdrantGrpcPort, true);

/// <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;
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;net462</TargetFrameworks>
<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