Skip to content

Commit

Permalink
Add Qdrant container
Browse files Browse the repository at this point in the history
This commit adds a qdrant container to the list of supported
Testcontainers.

The qdrant container allows configuration of:

- an API key to authenticate to Qdrant
- an x509 certificate used to secure communication to Qdrant with
  Transport Layer Security
- a custom configuration file. See
  https://qdrant.tech/documentation/guides/configuration/

Closes testcontainers#992
  • Loading branch information
russcam committed Sep 10, 2023
1 parent 59991ec commit 75528e0
Show file tree
Hide file tree
Showing 14 changed files with 520 additions and 0 deletions.
18 changes: 18 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,18 @@ 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
{23D898F8-36BE-4393-BFE2-41A862C0F951}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{23D898F8-36BE-4393-BFE2-41A862C0F951}.Debug|Any CPU.Build.0 = Debug|Any CPU
{23D898F8-36BE-4393-BFE2-41A862C0F951}.Release|Any CPU.ActiveCfg = Release|Any CPU
{23D898F8-36BE-4393-BFE2-41A862C0F951}.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 +474,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;

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))
.WithBindMount(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)
{
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,82 @@
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()),
};

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

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

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

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

0 comments on commit 75528e0

Please sign in to comment.