diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml
index f2e1e531b..8e46ccef2 100644
--- a/.github/workflows/cicd.yml
+++ b/.github/workflows/cicd.yml
@@ -61,6 +61,7 @@ jobs:
{ name: "Testcontainers.Keycloak", runs-on: "ubuntu-22.04" },
{ name: "Testcontainers.Kusto", runs-on: "ubuntu-22.04" },
{ name: "Testcontainers.LocalStack", runs-on: "ubuntu-22.04" },
+ { name: "Testcontainers.LowkeyVault", runs-on: "ubuntu-22.04" },
{ name: "Testcontainers.MariaDb", runs-on: "ubuntu-22.04" },
{ name: "Testcontainers.Milvus", runs-on: "ubuntu-22.04" },
{ name: "Testcontainers.Minio", runs-on: "ubuntu-22.04" },
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 8c825fa6c..002172d15 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -69,5 +69,8 @@
+
+
+
diff --git a/Testcontainers.sln b/Testcontainers.sln
index 58609127f..9ead6f064 100644
--- a/Testcontainers.sln
+++ b/Testcontainers.sln
@@ -61,6 +61,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.Kusto", "src
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.LocalStack", "src\Testcontainers.LocalStack\Testcontainers.LocalStack.csproj", "{3792268A-EF08-4569-8118-991E08FD61C4}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.LowkeyVault", "src\Testcontainers.LowkeyVault\Testcontainers.LowkeyVault.csproj", "{436486CE-E855-43DA-A2C7-9832E98BD86E}"
+EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.MariaDb", "src\Testcontainers.MariaDb\Testcontainers.MariaDb.csproj", "{4B204EB3-C478-422E-9B6F-62DF3871291A}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.Milvus", "src\Testcontainers.Milvus\Testcontainers.Milvus.csproj", "{B024E315-831F-429D-92AA-44B839AC10F4}"
@@ -157,6 +159,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.Kusto.Tests"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.LocalStack.Tests", "tests\Testcontainers.LocalStack.Tests\Testcontainers.LocalStack.Tests.csproj", "{728CBE16-1D52-4F84-AF01-7229E6013512}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.LowkeyVault.Tests", "tests\Testcontainers.LowkeyVault.Tests\Testcontainers.LowkeyVault.Tests.csproj", "{CB4F241B-EB79-49D5-A45F-050BEE2191B8}"
+EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.MariaDb.Tests", "tests\Testcontainers.MariaDb.Tests\Testcontainers.MariaDb.Tests.csproj", "{7F0AE083-9DB8-4BD4-91F7-C199DCC7301D}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.Milvus.Tests", "tests\Testcontainers.Milvus.Tests\Testcontainers.Milvus.Tests.csproj", "{5247DF94-32F3-4ED6-AE71-6AB4F4078E6D}"
@@ -210,9 +214,6 @@ Global
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
- GlobalSection(SolutionProperties) = preSolution
- HideSolutionNode = FALSE
- EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{5365F780-0E6C-41F0-B1B9-7DC34368F80C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{5365F780-0E6C-41F0-B1B9-7DC34368F80C}.Debug|Any CPU.Build.0 = Debug|Any CPU
@@ -386,6 +387,10 @@ Global
{64A87DE5-29B0-4A54-9E74-560484D8C7C0}.Debug|Any CPU.Build.0 = Debug|Any CPU
{64A87DE5-29B0-4A54-9E74-560484D8C7C0}.Release|Any CPU.ActiveCfg = Release|Any CPU
{64A87DE5-29B0-4A54-9E74-560484D8C7C0}.Release|Any CPU.Build.0 = Release|Any CPU
+ {436486CE-E855-43DA-A2C7-9832E98BD86E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {436486CE-E855-43DA-A2C7-9832E98BD86E}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {436486CE-E855-43DA-A2C7-9832E98BD86E}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {436486CE-E855-43DA-A2C7-9832E98BD86E}.Release|Any CPU.Build.0 = Release|Any CPU
{380BB29B-F556-404D-B13B-CA250599C565}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{380BB29B-F556-404D-B13B-CA250599C565}.Debug|Any CPU.Build.0 = Debug|Any CPU
{380BB29B-F556-404D-B13B-CA250599C565}.Release|Any CPU.ActiveCfg = Release|Any CPU
@@ -594,11 +599,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
+ {CB4F241B-EB79-49D5-A45F-050BEE2191B8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {CB4F241B-EB79-49D5-A45F-050BEE2191B8}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {CB4F241B-EB79-49D5-A45F-050BEE2191B8}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {CB4F241B-EB79-49D5-A45F-050BEE2191B8}.Release|Any CPU.Build.0 = Release|Any CPU
{E901DF14-6F05-4FC2-825A-3055FAD33561}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E901DF14-6F05-4FC2-825A-3055FAD33561}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E901DF14-6F05-4FC2-825A-3055FAD33561}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E901DF14-6F05-4FC2-825A-3055FAD33561}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{5365F780-0E6C-41F0-B1B9-7DC34368F80C} = {673F23AE-7694-4BB9-ABD4-136D6C13634E}
{AB9C1563-07C7-4685-BACD-BB1FF64B3611} = {673F23AE-7694-4BB9-ABD4-136D6C13634E}
@@ -643,6 +655,7 @@ Global
{45D6F69C-4D87-4130-AA90-0DB2F7460DAE} = {673F23AE-7694-4BB9-ABD4-136D6C13634E}
{2E39E532-B81E-4B48-A004-FAE18EDF9E79} = {673F23AE-7694-4BB9-ABD4-136D6C13634E}
{64A87DE5-29B0-4A54-9E74-560484D8C7C0} = {673F23AE-7694-4BB9-ABD4-136D6C13634E}
+ {436486CE-E855-43DA-A2C7-9832E98BD86E} = {673F23AE-7694-4BB9-ABD4-136D6C13634E}
{380BB29B-F556-404D-B13B-CA250599C565} = {673F23AE-7694-4BB9-ABD4-136D6C13634E}
{84911C93-C2A9-46E9-AE5E-D567306589E5} = {673F23AE-7694-4BB9-ABD4-136D6C13634E}
{EC76857B-A3B8-4B7A-A1B0-8D867A4D1733} = {673F23AE-7694-4BB9-ABD4-136D6C13634E}
@@ -695,6 +708,7 @@ Global
{232DD918-46ED-4BA8-B383-1A9146D83064} = {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}
+ {CB4F241B-EB79-49D5-A45F-050BEE2191B8} = {7164F1FB-7F24-444A-ACD2-2C329C2B3CCF}
{E901DF14-6F05-4FC2-825A-3055FAD33561} = {7164F1FB-7F24-444A-ACD2-2C329C2B3CCF}
EndGlobalSection
EndGlobal
diff --git a/docs/modules/index.md b/docs/modules/index.md
index 48a75587b..03eb2b643 100644
--- a/docs/modules/index.md
+++ b/docs/modules/index.md
@@ -46,6 +46,7 @@ await moduleNameContainer.StartAsync();
| Keycloak | `quay.io/keycloak/keycloak:21.1` | [NuGet](https://www.nuget.org/packages/Testcontainers.Keycloak) | [Source](https://github.com/testcontainers/testcontainers-dotnet/tree/develop/src/Testcontainers.Keycloak) |
| Kusto emulator | `mcr.microsoft.com/azuredataexplorer/kustainer-linux:latest` | [NuGet](https://www.nuget.org/packages/Testcontainers.Kusto) | [Source](https://github.com/testcontainers/testcontainers-dotnet/tree/develop/src/Testcontainers.Kusto) |
| LocalStack | `localstack/localstack:2.0` | [NuGet](https://www.nuget.org/packages/Testcontainers.LocalStack) | [Source](https://github.com/testcontainers/testcontainers-dotnet/tree/develop/src/Testcontainers.LocalStack) |
+| Lowkey Vault | `nagyesta/lowkey-vault:2.7.1-ubi9-minimal` | [NuGet](https://www.nuget.org/packages/Testcontainers.LowkeyVault) | [Source](https://github.com/testcontainers/testcontainers-dotnet/tree/develop/src/Testcontainers.LowkeyVault) |
| MariaDB | `mariadb:10.10` | [NuGet](https://www.nuget.org/packages/Testcontainers.MariaDb) | [Source](https://github.com/testcontainers/testcontainers-dotnet/tree/develop/src/Testcontainers.MariaDb) |
| Milvus | `milvusdb/milvus:v2.3.10` | [NuGet](https://www.nuget.org/packages/Testcontainers.Milvus) | [Source](https://github.com/testcontainers/testcontainers-dotnet/tree/develop/src/Testcontainers.Milvus) |
| MinIO | `minio/minio:RELEASE.2023-01-31T02-24-19Z` | [NuGet](https://www.nuget.org/packages/Testcontainers.Minio) | [Source](https://github.com/testcontainers/testcontainers-dotnet/tree/develop/src/Testcontainers.Minio) |
diff --git a/src/Testcontainers.LowkeyVault/.editorconfig b/src/Testcontainers.LowkeyVault/.editorconfig
new file mode 100644
index 000000000..6f066619d
--- /dev/null
+++ b/src/Testcontainers.LowkeyVault/.editorconfig
@@ -0,0 +1 @@
+root = true
\ No newline at end of file
diff --git a/src/Testcontainers.LowkeyVault/LowkeyVaultBuilder.cs b/src/Testcontainers.LowkeyVault/LowkeyVaultBuilder.cs
new file mode 100644
index 000000000..dc445b5b6
--- /dev/null
+++ b/src/Testcontainers.LowkeyVault/LowkeyVaultBuilder.cs
@@ -0,0 +1,334 @@
+namespace Testcontainers.LowkeyVault;
+
+///
+[PublicAPI]
+public sealed class LowkeyVaultBuilder : ContainerBuilder
+{
+ public const string LowkeyVaultImage = "nagyesta/lowkey-vault:2.7.1-ubi9-minimal";
+
+ public const ushort LowkeyVaultPort = 8443;
+
+ public const ushort LowkeyVaultTokenPort = 8080;
+
+ public const string TokenEndPointPath = "/metadata/identity/oauth2/token";
+
+ private const string LowKeyVaultEnvVarKey = "LOWKEY_ARGS";
+
+ private readonly HashSet NoAutoRegistration = ["-"];
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public LowkeyVaultBuilder()
+ : this(new LowkeyVaultConfiguration())
+ {
+ DockerResourceConfiguration = Init().DockerResourceConfiguration;
+ }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The Docker resource configuration.
+ private LowkeyVaultBuilder(LowkeyVaultConfiguration dockerResourceConfiguration)
+ : base(dockerResourceConfiguration)
+ {
+ DockerResourceConfiguration = dockerResourceConfiguration;
+ }
+
+ ///
+ protected override LowkeyVaultConfiguration DockerResourceConfiguration { get; }
+
+
+ ///
+ /// Sets the vault names.
+ ///
+ /// The vault names.
+ /// A configured instance of .
+ public LowkeyVaultBuilder WithVaultNames(HashSet vaultNames)
+ {
+ return Merge(DockerResourceConfiguration, new LowkeyVaultConfiguration(vaultNames: vaultNames))
+ .WithEnvironment(LowKeyVaultEnvVarKey, AddOrAppend($"--LOWKEY_VAULT_NAMES={string.Join(",", vaultNames)}"));
+ }
+
+ ///
+ /// Sets the vault aliases.
+ ///
+ /// The vault aliases.
+ /// A configured instance of .
+ public LowkeyVaultBuilder WithVaultAliases(Dictionary> aliasMap)
+ {
+ return Merge(DockerResourceConfiguration, new LowkeyVaultConfiguration(aliasMap: aliasMap))
+ .WithEnvironment(LowKeyVaultEnvVarKey, AddOrAppend($"--LOWKEY_VAULT_ALIASES={ProcessVaultAliases(aliasMap)}"));
+ }
+
+ ///
+ /// Sets No Auto Registration.
+ ///
+ /// A configured instance of .
+ public LowkeyVaultBuilder WithNoAutoRegistration()
+ {
+ return WithVaultNames(NoAutoRegistration);
+ }
+
+ ///
+ /// Sets Import file.
+ ///
+ /// The import file path.
+ /// A configured instance of .
+ public LowkeyVaultBuilder WithImportFile(string importFilePath)
+ {
+ return WithEnvironment(LowKeyVaultEnvVarKey, AddOrAppend($"--LOWKEY_IMPORT_LOCATION={importFilePath}"))
+ .WithResourceMapping(new FileInfo(importFilePath), new FileInfo("/import/vaults.json"));
+ }
+
+ ///
+ /// Sets External config file.
+ ///
+ /// The external config file path.
+ /// A configured instance of .
+ public LowkeyVaultBuilder WithExternalConfigFile(string externalConfigFilePath)
+ {
+ return Merge(DockerResourceConfiguration, new LowkeyVaultConfiguration(externalConfigFilePath: externalConfigFilePath))
+ .WithResourceMapping(new FileInfo(externalConfigFilePath), new FileInfo("/config/application.properties"));
+ }
+
+ ///
+ /// Sets Custom Ssl Certificate file.
+ ///
+ /// The keyStore (custom Ssl Certificate) file path.
+ /// The keyStore password.
+ /// The keyStore Type.
+ /// A configured instance of .
+ public LowkeyVaultBuilder WithCustomSslCertificate(string keyStoreFilePath, string keyStorePassword, StoreType keyStoreType = StoreType.PKCS12)
+ {
+ return WithEnvironment(LowKeyVaultEnvVarKey, AddOrAppend($"--server.ssl.key-store={keyStoreFilePath} "
+ + $"--server.ssl.key-store-type={keyStoreType} "
+ + $"--server.ssl.key-store-password={keyStorePassword ?? string.Empty}"))
+ .WithResourceMapping(new FileInfo(keyStoreFilePath), new FileInfo("/config/cert.store"));
+ }
+
+ ///
+ /// Enable Or Disable Debug.
+ ///
+ /// The flag to enable or disable debug.
+ /// A configured instance of .
+ public LowkeyVaultBuilder WithDebug(bool debug)
+ {
+ return WithEnvironment(LowKeyVaultEnvVarKey, AddOrAppend($"--LOWKEY_DEBUG_REQUEST_LOG={debug}"));
+ }
+
+ ///
+ /// Enable Or Disable Relaxed Ports.
+ ///
+ /// The flag to enable or disable relaxed ports.
+ /// A configured instance of .
+ public LowkeyVaultBuilder WithRelaxedPorts(bool relaxedPorts)
+ {
+ return WithEnvironment(LowKeyVaultEnvVarKey, AddOrAppend($"--LOWKEY_VAULT_RELAXED_PORTS={relaxedPorts}"));
+ }
+
+ ///
+ /// Sets The host used to replace host placeholder in import template.
+ ///
+ /// The logical host.
+ /// A configured instance of .
+ public LowkeyVaultBuilder WithLogicalHost(string logicalHost)
+ {
+ return WithEnvironment(LowKeyVaultEnvVarKey, AddOrAppend($"--LOWKEY_IMPORT_TEMPLATE_HOST={logicalHost}"));
+ }
+
+ ///
+ /// Sets The port used to replace host placeholder in import template.
+ ///
+ /// The logical port.
+ /// A configured instance of .
+ public LowkeyVaultBuilder WithLogicalPort(ushort logicalPort)
+ {
+ return WithEnvironment(LowKeyVaultEnvVarKey, AddOrAppend($"--LOWKEY_IMPORT_TEMPLATE_PORT={logicalPort}"));
+ }
+
+
+ ///
+ /// Sets Additional Arguments.
+ ///
+ /// The additional arguments.
+ /// A configured instance of .
+ public LowkeyVaultBuilder WithAdditionalArguments(List additionalArguments)
+ {
+ return Merge(DockerResourceConfiguration, new LowkeyVaultConfiguration(additionalArguments: additionalArguments))
+ .WithEnvironment(LowKeyVaultEnvVarKey, AddOrAppend(string.Join(" ", additionalArguments)));
+ }
+
+ ///
+ public override LowkeyVaultContainer Build()
+ {
+ Validate();
+
+ var waitStrategy = Wait.ForUnixContainer().UntilMessageIsLogged("(?s).*Started LowkeyVaultApp.*$");
+
+ var lowkeyVaultBuilder = DockerResourceConfiguration.WaitStrategies.Count() > 1 ? this : WithWaitStrategy(waitStrategy);
+
+ return new LowkeyVaultContainer(lowkeyVaultBuilder.DockerResourceConfiguration);
+ }
+
+ ///
+ protected override LowkeyVaultBuilder Init()
+ {
+ return base.Init()
+ .WithImage(LowkeyVaultImage)
+ .WithPortBinding(LowkeyVaultPort, true)
+ .WithPortBinding(LowkeyVaultTokenPort, true)
+ .WithRelaxedPorts(true);
+ }
+
+ ///
+ protected override void Validate()
+ {
+ base.Validate();
+
+ _ = Guard.Argument(DockerResourceConfiguration.VaultNames, nameof(DockerResourceConfiguration.VaultNames)).NotNull();
+ _ = Guard.Argument(DockerResourceConfiguration.AliasMap, nameof(DockerResourceConfiguration.AliasMap)).NotNull();
+ _ = Guard.Argument(DockerResourceConfiguration.AdditionalArguments, nameof(DockerResourceConfiguration.AdditionalArguments)).NotNull();
+ _ = Guard.Argument(DockerResourceConfiguration, nameof(DockerResourceConfiguration.ExternalConfigFilePath))
+ .ThrowIf(argument =>
+ {
+ var externalConfigFilePath = argument.Value.ExternalConfigFilePath;
+ var fileName = Path.GetFileName(externalConfigFilePath);
+ return !string.IsNullOrEmpty(externalConfigFilePath) && !Path.GetFileName(fileName).EndsWith(".properties", StringComparison.Ordinal);
+ }, argument => throw new ArgumentException("External configuration file must be a *.properties file."));
+
+ ValidateVaultNames(DockerResourceConfiguration.VaultNames);
+
+ ValidateAliasMap(DockerResourceConfiguration.AliasMap);
+ }
+
+ ///
+ protected override LowkeyVaultBuilder Clone(IResourceConfiguration resourceConfiguration)
+ {
+ return Merge(DockerResourceConfiguration, new LowkeyVaultConfiguration(resourceConfiguration));
+ }
+
+ ///
+ protected override LowkeyVaultBuilder Clone(IContainerConfiguration resourceConfiguration)
+ {
+ return Merge(DockerResourceConfiguration, new LowkeyVaultConfiguration(resourceConfiguration));
+ }
+
+ ///
+ protected override LowkeyVaultBuilder Merge(LowkeyVaultConfiguration oldValue, LowkeyVaultConfiguration newValue)
+ {
+ return new LowkeyVaultBuilder(new LowkeyVaultConfiguration(oldValue, newValue));
+ }
+
+ private string AddOrAppend(string value)
+ {
+ return DockerResourceConfiguration.Environments.TryGetValue(LowKeyVaultEnvVarKey, out var existingValue)
+ ? MergeEnv(existingValue, value)
+ : value;
+ }
+
+ ///
+ /// Merges two input strings by treating strings starting with `--` as keys
+ /// and strings after `=` as their corresponding values. Updates the original
+ /// string with values from the new string if keys overlap.
+ ///
+ /// The original input string containing key-value pairs.
+ /// The new input string containing updated key-value pairs.
+ /// The merged string with updated key-value pairs.
+ private static string MergeEnv(string originalString, string newString)
+ {
+ var originalDict = ParseToDictionary(originalString);
+ var newDict = ParseToDictionary(newString);
+
+ foreach (var kvp in newDict)
+ originalDict[kvp.Key] = kvp.Value;
+
+ return string.Join(" ", originalDict.Select(kvp => $"{kvp.Key}={kvp.Value}"));
+
+ ///
+ /// Parses an input string into a dictionary where keys are the parts starting with `--`
+ /// and values are extracted from the segment after `=`. Handles multi-word values.
+ ///
+ /// The input string to parse.
+ /// A dictionary representation of the key-value pairs in the input string.
+ static Dictionary ParseToDictionary(string input)
+ {
+ var dictionary = new Dictionary();
+ var parts = input.Split([' '], StringSplitOptions.RemoveEmptyEntries);
+ string currentKey = null;
+
+ foreach (var part in parts)
+ {
+ if (part.StartsWith("--"))
+ {
+ var equalIndex = part.IndexOf('=');
+
+ if (equalIndex > 0)
+ {
+ dictionary[part.Substring(0, equalIndex)] = part.Substring(equalIndex + 1);
+ }
+ else
+ {
+ currentKey = part;
+ }
+ }
+ else if (currentKey != null)
+ {
+ dictionary[currentKey] = part;
+ currentKey = null;
+ }
+ }
+
+ return dictionary;
+ }
+ }
+
+ private void ValidateVaultNames(HashSet vaultNames)
+ {
+ if (!NoAutoRegistration.SetEquals(vaultNames))
+ {
+ var invalid = vaultNames.Where(s => !Regex.IsMatch(s ?? string.Empty, "^[0-9a-zA-Z-]+$", RegexOptions.Compiled)).ToList();
+
+ if (invalid.Count != 0)
+ {
+ throw new ArgumentException("VaultNames contains invalid values: " + string.Join(", ", invalid));
+ }
+ }
+ }
+
+ private static void ValidateAliasMap(Dictionary> aliasMap)
+ {
+ foreach (var host in aliasMap.Keys)
+ {
+ if (!Regex.IsMatch(host, "^[0-9a-z\\-_.]+$"))
+ {
+ throw new ArgumentException($"Vault host names must match '^[0-9a-z\\-_.]+$'. Found: {host}");
+ }
+ }
+
+ foreach (var alias in aliasMap.Values)
+ {
+ foreach (var host in alias)
+ {
+ if (!Regex.IsMatch(host, "^[0-9a-z\\-_.]+(:[0-9]+|:)?$"))
+ {
+ throw new ArgumentException($"Vault aliases must match '^[0-9a-z\\-_.]+(:[0-9]+|:)?$'. Found: {host}");
+ }
+ }
+ }
+ }
+
+ private static string ProcessVaultAliases(Dictionary> aliasMap)
+ {
+ return aliasMap.OrderBy(pair => pair.Key) // Sort keys
+ .SelectMany(pair => pair.Value.OrderBy(alias => alias) // Sort values
+ .Select(alias => $"{pair.Key}={alias}"))
+ .Aggregate((current, next) => $"{current},{next}"); // Join the pairs into a single string with commas
+ }
+
+ public enum StoreType
+ {
+ JKS,
+ PKCS12
+ }
+}
\ No newline at end of file
diff --git a/src/Testcontainers.LowkeyVault/LowkeyVaultConfiguration.cs b/src/Testcontainers.LowkeyVault/LowkeyVaultConfiguration.cs
new file mode 100644
index 000000000..98b361e52
--- /dev/null
+++ b/src/Testcontainers.LowkeyVault/LowkeyVaultConfiguration.cs
@@ -0,0 +1,88 @@
+namespace Testcontainers.LowkeyVault;
+
+///
+[PublicAPI]
+public sealed class LowkeyVaultConfiguration : ContainerConfiguration
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The vault names.
+ /// The vault aliases.
+ /// The external config file path.
+ /// The additional arguments to be passed via environment variables to the container.
+ public LowkeyVaultConfiguration(HashSet vaultNames = null,
+ Dictionary> aliasMap = null,
+ string externalConfigFilePath = null,
+ List additionalArguments = null)
+ {
+ VaultNames = vaultNames;
+ AliasMap = aliasMap;
+ ExternalConfigFilePath = externalConfigFilePath;
+ AdditionalArguments = additionalArguments;
+ }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The Docker resource configuration.
+ public LowkeyVaultConfiguration(IResourceConfiguration resourceConfiguration)
+ : base(resourceConfiguration)
+ {
+ // Passes the configuration upwards to the base implementations to create an updated immutable copy.
+ }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The Docker resource configuration.
+ public LowkeyVaultConfiguration(IContainerConfiguration resourceConfiguration)
+ : base(resourceConfiguration)
+ {
+ // Passes the configuration upwards to the base implementations to create an updated immutable copy.
+ }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The Docker resource configuration.
+ public LowkeyVaultConfiguration(LowkeyVaultConfiguration resourceConfiguration)
+ : this(new LowkeyVaultConfiguration(), resourceConfiguration)
+ {
+ // Passes the configuration upwards to the base implementations to create an updated immutable copy.
+ }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The old Docker resource configuration.
+ /// The new Docker resource configuration.
+ public LowkeyVaultConfiguration(LowkeyVaultConfiguration oldValue, LowkeyVaultConfiguration newValue)
+ : base(oldValue, newValue)
+ {
+ VaultNames = BuildConfiguration.Combine(oldValue.VaultNames, newValue.VaultNames);
+ AliasMap = BuildConfiguration.Combine(oldValue.AliasMap, newValue.AliasMap);
+ ExternalConfigFilePath = BuildConfiguration.Combine(oldValue.ExternalConfigFilePath, newValue.ExternalConfigFilePath);
+ AdditionalArguments = BuildConfiguration.Combine(oldValue.AdditionalArguments, newValue.AdditionalArguments);
+ }
+
+ ///
+ /// Gets the Vault names.
+ ///
+ internal HashSet VaultNames { get; } = [];
+
+ ///
+ /// Gets the Alias Map.
+ ///
+ internal Dictionary> AliasMap { get; } = [];
+
+ ///
+ /// Gets the External Config File Path.
+ ///
+ internal string ExternalConfigFilePath { get; }
+
+ ///
+ /// Gets Additional Arguments.
+ ///
+ internal List AdditionalArguments { get; } = [];
+}
\ No newline at end of file
diff --git a/src/Testcontainers.LowkeyVault/LowkeyVaultContainer.cs b/src/Testcontainers.LowkeyVault/LowkeyVaultContainer.cs
new file mode 100644
index 000000000..16523d5a3
--- /dev/null
+++ b/src/Testcontainers.LowkeyVault/LowkeyVaultContainer.cs
@@ -0,0 +1,107 @@
+namespace Testcontainers.LowkeyVault;
+
+///
+[PublicAPI]
+public sealed class LowkeyVaultContainer : DockerContainer
+{
+ private const string LocalHost = "localhost";
+
+ ///
+ /// Gets a configured HTTP client
+ ///
+ private static HttpClient HttpClient => new();
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The container configuration.
+ public LowkeyVaultContainer(LowkeyVaultConfiguration configuration)
+ : base(configuration)
+ {
+ }
+
+ ///
+ /// Gets the URL of the default vault.
+ ///
+ /// The default vault base URL.
+ public string GetDefaultVaultBaseUrl()
+ {
+ return new UriBuilder(Uri.UriSchemeHttps, Hostname, GetMappedPublicPort(LowkeyVaultBuilder.LowkeyVaultPort)).ToString();
+ }
+
+ ///
+ /// Gets the full URL of the token endpoint.
+ ///
+ /// The full token endpoint URL.
+ public string GetTokenEndpointUrl()
+ {
+ return new UriBuilder(Uri.UriSchemeHttp, Hostname, GetMappedPublicPort(LowkeyVaultBuilder.LowkeyVaultTokenPort), LowkeyVaultBuilder.TokenEndPointPath).ToString();
+ }
+
+ ///
+ /// Gets the URL of the vault with a given name.
+ /// the name of the vault.
+ ///
+ /// The vault base URL.
+ public string GetVaultBaseUrl(string vaultName)
+ {
+ // Using `LocalHost` here instead of `Hostname` (which resolves to `127.0.0.1` in this environment)
+ // to address a compatibility issue with the Java URI parser utilized by the Lowkey Vault client.
+ // The parser fails to properly handle URIs containing a mix of DNS names and IP addresses, leading to errors.
+ // For more details, refer to: https://github.com/nagyesta/lowkey-vault/issues/1319#issuecomment-2600214768
+ return new UriBuilder(Uri.UriSchemeHttps, $"{vaultName}.{LocalHost}", GetMappedPublicPort(LowkeyVaultBuilder.LowkeyVaultPort)).ToString();
+ }
+
+ ///
+ /// Gets a containing the default certificate shipped with Lowkey Vault.
+ ///
+ /// The .
+ public async Task GetDefaultKeyStore()
+ {
+ var requestUri = new UriBuilder(Uri.UriSchemeHttp, Hostname, GetMappedPublicPort(LowkeyVaultBuilder.LowkeyVaultTokenPort), "/metadata/default-cert/lowkey-vault.p12").ToString();
+ var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
+
+ try
+ {
+ var response = await HttpClient.SendAsync(request);
+ response.EnsureSuccessStatusCode();
+
+ var keyStoreBytes = await response.Content.ReadAsByteArrayAsync();
+
+ var password = await GetDefaultKeyStorePassword();
+
+ // Load the PKCS12 keystore
+#if NET9_0_OR_GREATER
+ return X509CertificateLoader.LoadPkcs12Collection(keyStoreBytes, password, X509KeyStorageFlags.DefaultKeySet);
+#else
+ return [new X509Certificate2(keyStoreBytes, password, X509KeyStorageFlags.DefaultKeySet)];
+#endif
+ }
+ catch (Exception e)
+ {
+ throw new InvalidOperationException("Failed to get default key store", e);
+ }
+ }
+
+ ///
+ /// Gets the password protecting the default certificate shipped with Lowkey Vault.
+ ///
+ /// The password.
+ public async Task GetDefaultKeyStorePassword()
+ {
+ var requestUri = new UriBuilder(Uri.UriSchemeHttp, Hostname, GetMappedPublicPort(LowkeyVaultBuilder.LowkeyVaultTokenPort), "/metadata/default-cert/password").ToString();
+ var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
+
+ try
+ {
+ var response = await HttpClient.SendAsync(request);
+ response.EnsureSuccessStatusCode();
+
+ return await response.Content.ReadAsStringAsync();
+ }
+ catch (Exception e)
+ {
+ throw new InvalidOperationException("Failed to get default key store password", e);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Testcontainers.LowkeyVault/Testcontainers.LowkeyVault.csproj b/src/Testcontainers.LowkeyVault/Testcontainers.LowkeyVault.csproj
new file mode 100644
index 000000000..906f34018
--- /dev/null
+++ b/src/Testcontainers.LowkeyVault/Testcontainers.LowkeyVault.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.LowkeyVault/Usings.cs b/src/Testcontainers.LowkeyVault/Usings.cs
new file mode 100644
index 000000000..c5334f8f4
--- /dev/null
+++ b/src/Testcontainers.LowkeyVault/Usings.cs
@@ -0,0 +1,14 @@
+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 System;
+global using System.Collections.Generic;
+global using System.IO;
+global using System.Linq;
+global using System.Net.Http;
+global using System.Security.Cryptography.X509Certificates;
+global using System.Text.RegularExpressions;
+global using System.Threading.Tasks;
diff --git a/tests/Testcontainers.LowkeyVault.Tests/.editorconfig b/tests/Testcontainers.LowkeyVault.Tests/.editorconfig
new file mode 100644
index 000000000..6f066619d
--- /dev/null
+++ b/tests/Testcontainers.LowkeyVault.Tests/.editorconfig
@@ -0,0 +1 @@
+root = true
\ No newline at end of file
diff --git a/tests/Testcontainers.LowkeyVault.Tests/LowkeyVaultContainerTest.cs b/tests/Testcontainers.LowkeyVault.Tests/LowkeyVaultContainerTest.cs
new file mode 100644
index 000000000..34c682c72
--- /dev/null
+++ b/tests/Testcontainers.LowkeyVault.Tests/LowkeyVaultContainerTest.cs
@@ -0,0 +1,216 @@
+namespace Testcontainers.LowkeyVault;
+
+public sealed class LowkeyVaultContainerTest : IAsyncLifetime
+{
+ private readonly LowkeyVaultContainer _fakeLowkeyVaultContainer = new LowkeyVaultBuilder().Build();
+
+ public Task InitializeAsync()
+ {
+ return _fakeLowkeyVaultContainer.StartAsync();
+ }
+
+ public Task DisposeAsync()
+ {
+ return _fakeLowkeyVaultContainer.DisposeAsync().AsTask();
+ }
+
+ [Fact]
+ [Trait(nameof(DockerCli.DockerPlatform), nameof(DockerCli.DockerPlatform.Linux))]
+ public async Task TestContainerDefaults()
+ {
+ // Given
+ const string Alias = "lowkey-vault.local";
+
+ // When
+ var tokenEndpoint = _fakeLowkeyVaultContainer.GetTokenEndpointUrl();
+
+ var keyStore = await _fakeLowkeyVaultContainer.GetDefaultKeyStore();
+
+ var password = await _fakeLowkeyVaultContainer.GetDefaultKeyStorePassword();
+
+ // Then
+ await VerifyTokenEndpointIsWorking(tokenEndpoint, CreateHttpClientHandlerWithDisabledSslValidation());
+
+ Assert.NotNull(keyStore);
+ Assert.NotNull(password);
+ Assert.Contains(keyStore, cert => cert.Subject.Split('=')?.LastOrDefault() == Alias);
+ }
+
+
+ [Fact]
+ [Trait(nameof(DockerCli.DockerPlatform), nameof(DockerCli.DockerPlatform.Linux))]
+ public async Task TestThatSetAndGetSecretWorksWithNoOpCredential()
+ {
+ await VerifyThatSetAndGetSecretWorks(CreateNoOpCredential());
+ }
+
+ [Fact]
+ [Trait(nameof(DockerCli.DockerPlatform), nameof(DockerCli.DockerPlatform.Linux))]
+ public async Task TestThatSetAndGetSecretWorksWithManagedIdentity()
+ {
+ // Set ENV vars to configure the token provider endpoint of the managed identity credential
+ Environment.SetEnvironmentVariable("IDENTITY_ENDPOINT", _fakeLowkeyVaultContainer.GetTokenEndpointUrl());
+ Environment.SetEnvironmentVariable("IDENTITY_HEADER", "header");
+
+ await VerifyThatSetAndGetSecretWorks(CreateDefaultAzureCredential());
+ }
+
+ [Fact]
+ [Trait(nameof(DockerCli.DockerPlatform), nameof(DockerCli.DockerPlatform.Linux))]
+ public async Task TestThatCreateAndDownloadCertificateWorksWithNoOpCredential()
+ {
+ await VerifyThatCreateAndDownloadCertificateWorks(CreateNoOpCredential());
+ }
+
+ [Fact]
+ [Trait(nameof(DockerCli.DockerPlatform), nameof(DockerCli.DockerPlatform.Linux))]
+ public async Task TestThatCreateAndDownloadCertificateWorksWithManagedIdentity()
+ {
+ // Set ENV vars to configure the token provider endpoint of the managed identity credential
+ Environment.SetEnvironmentVariable("IDENTITY_ENDPOINT", _fakeLowkeyVaultContainer.GetTokenEndpointUrl());
+ Environment.SetEnvironmentVariable("IDENTITY_HEADER", "header");
+
+ await VerifyThatCreateAndDownloadCertificateWorks(CreateDefaultAzureCredential());
+ }
+
+ private async Task VerifyThatSetAndGetSecretWorks(TokenCredential credential)
+ {
+ //Given
+ const string SecretName = "name";
+ const string SecretValue = "value";
+
+ var vaultUrl = _fakeLowkeyVaultContainer.GetDefaultVaultBaseUrl();
+
+ var secretClient = new SecretClient(new Uri(vaultUrl), credential, CreateSecretClientOption());
+
+ await secretClient.SetSecretAsync(SecretName, SecretValue);
+
+ //When
+ var secret = await secretClient.GetSecretAsync(SecretName);
+
+ //Then
+ Assert.NotNull(secret.Value);
+ Assert.Equal(SecretName, secret.Value.Name);
+ Assert.Equal(SecretValue, secret.Value.Value);
+ }
+
+ private async Task VerifyThatCreateAndDownloadCertificateWorks(TokenCredential credential)
+ {
+ //Given
+ const string CertificateName = "certificate";
+ const string Subject = "CN=example.com";
+
+ var vaultUrl = _fakeLowkeyVaultContainer.GetDefaultVaultBaseUrl();
+
+ var certificateClient = new CertificateClient(new Uri(vaultUrl), credential, CreateCertificateClientOption());
+
+ var certificatePolicy = new CertificatePolicy("Self", Subject)
+ {
+ KeyType = CertificateKeyType.Rsa,
+ KeySize = 2048,
+ Exportable = true,
+ ContentType = CertificateContentType.Pkcs12,
+ ValidityInMonths = 12
+ };
+
+ var certOp = await certificateClient.StartCreateCertificateAsync(CertificateName, certificatePolicy);
+
+ await certOp.WaitForCompletionAsync();
+
+ //When
+ var response = await certificateClient.DownloadCertificateAsync(CertificateName);
+
+ var certificate = response?.Value;
+
+ //Then
+ Assert.Equal(Subject, certificate.Subject);
+ Assert.NotNull(certificate.GetRSAPublicKey());
+ Assert.NotNull(certificate.GetRSAPrivateKey());
+ }
+
+ private static NoopCredentials CreateNoOpCredential()
+ {
+ return new NoopCredentials();
+ }
+
+ private static DefaultAzureCredential CreateDefaultAzureCredential()
+ {
+ return new DefaultAzureCredential();
+ }
+
+ private static SecretClientOptions CreateSecretClientOption()
+ {
+ return GetClientOptions(new SecretClientOptions(SecretClientOptions.ServiceVersion.V7_4)
+ {
+ DisableChallengeResourceVerification = true,
+ RetryPolicy = new RetryPolicy(0, DelayStrategy.CreateFixedDelayStrategy(TimeSpan.Zero))
+ });
+ }
+
+ private static CertificateClientOptions CreateCertificateClientOption()
+ {
+ return GetClientOptions(new CertificateClientOptions(CertificateClientOptions.ServiceVersion.V7_4)
+ {
+ DisableChallengeResourceVerification = true,
+ RetryPolicy = new RetryPolicy(0, DelayStrategy.CreateFixedDelayStrategy(TimeSpan.Zero))
+ });
+ }
+
+ private static T GetClientOptions(T options) where T : ClientOptions
+ {
+ DisableSslValidationOnClientOptions(options);
+ return options;
+ }
+
+ ///
+ /// Disables server certification callback.
+ ///
+ /// WARNING: Do not use in production environments.
+ ///
+ ///
+ private static void DisableSslValidationOnClientOptions(ClientOptions options)
+ {
+ options.Transport = new HttpClientTransport(CreateHttpClientHandlerWithDisabledSslValidation());
+ }
+
+ private static HttpClientHandler CreateHttpClientHandlerWithDisabledSslValidation()
+ {
+ return new HttpClientHandler { ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator };
+ }
+
+ private static async Task VerifyTokenEndpointIsWorking(string endpointUrl, HttpClientHandler httpClientHandler)
+ {
+ using var httpClient = new HttpClient(httpClientHandler);
+
+ var requestUri = $"{endpointUrl}?resource=https://localhost";
+ using var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
+
+ try
+ {
+ using var response = await httpClient.SendAsync(request);
+ Assert.Equal(200, (int)response.StatusCode);
+ }
+ catch (Exception ex)
+ {
+ Assert.Fail($"Request failed: {ex.Message}");
+ }
+ }
+}
+
+///
+/// Allows us to bypass authentication when using Lowkey Vault.
+///
+/// WARNING: Will not work with real Azure services.
+///
+internal class NoopCredentials : TokenCredential
+{
+ public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken)
+ {
+ return new AccessToken("noop", DateTimeOffset.MaxValue);
+ }
+
+ public override ValueTask GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken)
+ {
+ return new ValueTask(GetToken(requestContext, cancellationToken));
+ }
+}
\ No newline at end of file
diff --git a/tests/Testcontainers.LowkeyVault.Tests/Testcontainers.LowkeyVault.Tests.csproj b/tests/Testcontainers.LowkeyVault.Tests/Testcontainers.LowkeyVault.Tests.csproj
new file mode 100644
index 000000000..82b9967bd
--- /dev/null
+++ b/tests/Testcontainers.LowkeyVault.Tests/Testcontainers.LowkeyVault.Tests.csproj
@@ -0,0 +1,20 @@
+
+
+ net9.0
+ false
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/Testcontainers.LowkeyVault.Tests/Usings.cs b/tests/Testcontainers.LowkeyVault.Tests/Usings.cs
new file mode 100644
index 000000000..02f38184b
--- /dev/null
+++ b/tests/Testcontainers.LowkeyVault.Tests/Usings.cs
@@ -0,0 +1,13 @@
+global using Azure.Core;
+global using Azure.Core.Pipeline;
+global using Azure.Identity;
+global using Azure.Security.KeyVault.Certificates;
+global using Azure.Security.KeyVault.Secrets;
+global using DotNet.Testcontainers.Commons;
+global using System;
+global using System.Linq;
+global using System.Net.Http;
+global using System.Security.Cryptography.X509Certificates;
+global using System.Threading;
+global using System.Threading.Tasks;
+global using Xunit;