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;