This is an automated email from the ASF dual-hosted git repository.
roryqi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gravitino.git
The following commit(s) were added to refs/heads/main by this push:
new 4d72543e0c [#12131] feat(kms): Configure and resolve named sources
(#12133)
4d72543e0c is described below
commit 4d72543e0cd0beccc36acbd8adabfd7cd6d32475
Author: Nevin Zheng <[email protected]>
AuthorDate: Sun Jul 26 19:33:57 2026 -0700
[#12131] feat(kms): Configure and resolve named sources (#12133)
### What changes were proposed in this pull request?
Add server-side configuration, resolution, and lifecycle management for
named KMS sources:
- parse and validate `gravitino.kms.*` source configuration;
- discover `KmsClientFactory` implementations with `ServiceLoader`;
- create and own one reusable client per configured source during
environment initialization;
- resolve each `KmsReference` to its configured source and API;
- close clients in reverse order, including after partial startup
failure; and
- define provider-client concurrency and ownership expectations.
`KmsClientFactory.create(...)` validates local configuration and
constructs a reusable client without contacting the configured KMS.
Network, authentication, and availability failures are reported by
client operations and do not invalidate the reusable client.
This builds on the provider-neutral KMS contracts merged in #12132.
Concrete provider implementations remain outside this PR.
### Why are the changes needed?
A deployment can use multiple named KMS instances, including multiple
instances of the same provider. Gravitino needs one server-side resolver
and lifecycle owner so callers do not construct provider SDK or HTTP
clients per request or depend on provider-specific configuration.
Creating clients during environment initialization validates the
complete local configuration deterministically without making startup
depend on KMS availability.
Part of #12131.
### Does this PR introduce _any_ user-facing change?
Yes. It introduces these server configuration properties:
- `gravitino.kms.sources`
- `gravitino.kms.source.<name>.api`
- `gravitino.kms.source.<name>.<provider-property>`
Provider properties remain server-private. The registry is initialized
with both base and full Gravitino environments. Client construction does
not connect to the configured KMS.
### How was this patch tested?
- `./gradlew :core:test --tests
org.apache.gravitino.encryption.kms.TestKmsConfig --tests
org.apache.gravitino.encryption.kms.TestKmsClientRegistry --tests
org.apache.gravitino.TestGravitinoEnvKmsClientRegistry -PskipITs
-PskipDockerTests=true -PskipWeb=true`
- `./gradlew :common:spotlessCheck :core:spotlessCheck :common:javadoc
:core:javadoc -PskipITs -PskipDockerTests=true -PskipWeb=true`
### Review size
This PR contains 1,129 additions: 434 production lines and 695 test
lines. Configuration parsing, client resolution, eager lifecycle
management, and server wiring form one usable unit. Splitting them would
leave unused internal code or separate lifecycle tests from the behavior
they verify.
### Series
- Epic: #12131
- Previous: #12132 (merged)
- Position: 2 of 2
---
.../apache/gravitino/encryption/kms/KmsClient.java | 3 +
.../gravitino/encryption/kms/KmsClientFactory.java | 7 +-
.../java/org/apache/gravitino/GravitinoEnv.java | 24 +
.../encryption/kms/KmsClientRegistry.java | 197 +++++++++
.../apache/gravitino/encryption/kms/KmsConfig.java | 161 +++++++
.../TestGravitinoEnvKmsClientRegistry.java | 62 +++
.../encryption/kms/TestKmsClientRegistry.java | 489 +++++++++++++++++++++
.../gravitino/encryption/kms/TestKmsConfig.java | 145 ++++++
8 files changed, 1085 insertions(+), 3 deletions(-)
diff --git
a/common/src/main/java/org/apache/gravitino/encryption/kms/KmsClient.java
b/common/src/main/java/org/apache/gravitino/encryption/kms/KmsClient.java
index fc4339d9c8..78455cbc0c 100644
--- a/common/src/main/java/org/apache/gravitino/encryption/kms/KmsClient.java
+++ b/common/src/main/java/org/apache/gravitino/encryption/kms/KmsClient.java
@@ -28,6 +28,9 @@ import
org.apache.gravitino.exceptions.ConnectionFailedException;
* <p>This is a server-side operation client, not a credential-vending API.
Provider credentials
* authenticate calls made by the client and must never be returned to
callers. This client does not
* perform cryptographic operations.
+ *
+ * <p>Implementations must support concurrent operation calls on one client
instance. Operation
+ * failures must not close the client; later calls reuse the same instance.
*/
@DeveloperApi
public interface KmsClient extends AutoCloseable {
diff --git
a/common/src/main/java/org/apache/gravitino/encryption/kms/KmsClientFactory.java
b/common/src/main/java/org/apache/gravitino/encryption/kms/KmsClientFactory.java
index 5695d4140c..c72ea2a89a 100644
---
a/common/src/main/java/org/apache/gravitino/encryption/kms/KmsClientFactory.java
+++
b/common/src/main/java/org/apache/gravitino/encryption/kms/KmsClientFactory.java
@@ -20,7 +20,6 @@ package org.apache.gravitino.encryption.kms;
import java.util.Map;
import org.apache.gravitino.annotation.DeveloperApi;
-import org.apache.gravitino.exceptions.ConnectionFailedException;
/** Creates server-side KMS clients for one KMS API. */
@DeveloperApi
@@ -40,13 +39,15 @@ public interface KmsClientFactory {
* Creates a client bound to a configured KMS source.
*
* <p>Provider credentials are private implementation details of the
returned client. They must
- * not be exposed as Gravitino credentials or key properties.
+ * not be exposed as Gravitino credentials or key properties. The caller
owns the returned client
+ * and must close it. This method validates configuration and constructs a
reusable client without
+ * contacting the configured KMS; network and authentication failures are
reported by client
+ * operations.
*
* @param source logical name of the configured KMS instance
* @param properties provider-specific configuration
* @return the configured client
* @throws IllegalArgumentException if the source or configuration is invalid
- * @throws ConnectionFailedException if required external initialization
fails
*/
KmsClient create(String source, Map<String, String> properties);
}
diff --git a/core/src/main/java/org/apache/gravitino/GravitinoEnv.java
b/core/src/main/java/org/apache/gravitino/GravitinoEnv.java
index 4f42989045..0b955f08d7 100644
--- a/core/src/main/java/org/apache/gravitino/GravitinoEnv.java
+++ b/core/src/main/java/org/apache/gravitino/GravitinoEnv.java
@@ -56,6 +56,7 @@ import org.apache.gravitino.catalog.ViewDispatcher;
import org.apache.gravitino.catalog.ViewNormalizeDispatcher;
import org.apache.gravitino.catalog.ViewOperationDispatcher;
import org.apache.gravitino.credential.CredentialOperationDispatcher;
+import org.apache.gravitino.encryption.kms.KmsClientRegistry;
import org.apache.gravitino.hook.AccessControlHookDispatcher;
import org.apache.gravitino.hook.CatalogHookDispatcher;
import org.apache.gravitino.hook.FilesetHookDispatcher;
@@ -154,6 +155,8 @@ public class GravitinoEnv {
private CredentialOperationDispatcher credentialOperationDispatcher;
+ private KmsClientRegistry kmsClientRegistry;
+
private TagDispatcher tagDispatcher;
private PolicyDispatcher policyDispatcher;
@@ -418,6 +421,21 @@ public class GravitinoEnv {
return credentialOperationDispatcher;
}
+ /**
+ * Get the metadata-only KMS client registry associated with the Gravitino
environment.
+ *
+ * <p>The environment owns this registry. Callers may inject it into
dependent components but must
+ * not close it.
+ *
+ * @return The KMS client registry instance.
+ * @throws IllegalStateException if the environment has not been initialized
+ */
+ public KmsClientRegistry kmsClientRegistry() {
+ Preconditions.checkState(
+ kmsClientRegistry != null, "GravitinoEnv components are not
initialized.");
+ return kmsClientRegistry;
+ }
+
/**
* Get the IdGenerator associated with the Gravitino environment.
*
@@ -635,10 +653,16 @@ public class GravitinoEnv {
}
}
+ if (kmsClientRegistry != null) {
+ kmsClientRegistry.close();
+ }
+
LOG.info("Gravitino Environment is shut down.");
}
private void initBaseComponents() {
+ this.kmsClientRegistry = new KmsClientRegistry(config);
+
this.metricsSystem = new MetricsSystem();
metricsSystem.register(new JVMMetricsSource());
diff --git
a/core/src/main/java/org/apache/gravitino/encryption/kms/KmsClientRegistry.java
b/core/src/main/java/org/apache/gravitino/encryption/kms/KmsClientRegistry.java
new file mode 100644
index 0000000000..c7a687fe53
--- /dev/null
+++
b/core/src/main/java/org/apache/gravitino/encryption/kms/KmsClientRegistry.java
@@ -0,0 +1,197 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.gravitino.encryption.kms;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.ServiceLoader;
+import org.apache.gravitino.Config;
+
+/** Creates, resolves, and owns server-private KMS clients by configured
source. */
+public final class KmsClientRegistry implements AutoCloseable {
+
+ private final Map<String, ConfiguredClient> clients;
+ private volatile boolean closed;
+
+ /**
+ * Loads configuration and available {@link KmsClientFactory}
implementations, then creates one
+ * client for each configured source.
+ *
+ * @param config Gravitino server configuration
+ * @throws IllegalArgumentException if configuration or factory discovery is
invalid
+ */
+ public KmsClientRegistry(Config config) {
+ this(config, loadFactories());
+ }
+
+ KmsClientRegistry(Config config, Iterable<KmsClientFactory> factories) {
+ KmsConfig kmsConfig = new KmsConfig(config);
+ if (kmsConfig.sources().isEmpty()) {
+ this.clients = Collections.emptyMap();
+ return;
+ }
+
+ if (factories == null) {
+ throw new IllegalArgumentException("KMS client factories cannot be
null");
+ }
+
+ Map<String, KmsClientFactory> factoriesByApi = indexFactories(factories);
+ this.clients = createClients(kmsConfig.sources(), factoriesByApi);
+ }
+
+ /**
+ * Resolves the client configured for a key reference.
+ *
+ * <p>The registry owns the returned client. Callers must not close it or
use it after the
+ * registry is closed.
+ *
+ * @param reference key whose source and API select the client
+ * @return client configured for the reference
+ * @throws IllegalArgumentException if the source is unknown or configured
for another API
+ * @throws IllegalStateException if the registry is closed
+ */
+ public KmsClient getClient(KmsReference reference) {
+ checkOpen();
+ return resolveClient(reference).client;
+ }
+
+ /** Closes all configured clients. This operation is idempotent. */
+ @Override
+ public synchronized void close() {
+ if (closed) {
+ return;
+ }
+ closed = true;
+ RuntimeException failure = closeClients(new ArrayList<>(clients.values()));
+ if (failure != null) {
+ throw failure;
+ }
+ }
+
+ private static Map<String, KmsClientFactory> indexFactories(
+ Iterable<KmsClientFactory> factories) {
+ Map<String, KmsClientFactory> factoriesByApi = new LinkedHashMap<>();
+ for (KmsClientFactory factory : factories) {
+ if (factory == null) {
+ throw new IllegalArgumentException("KMS client factory cannot be
null");
+ }
+ String api = KmsApiIdentifiers.requireValid(factory.api());
+ KmsClientFactory existing = factoriesByApi.putIfAbsent(api, factory);
+ if (existing != null) {
+ throw new IllegalArgumentException(
+ String.format("Multiple KMS client factories support API '%s'",
api));
+ }
+ }
+ return factoriesByApi;
+ }
+
+ private static Iterable<KmsClientFactory> loadFactories() {
+ ClassLoader classLoader = Thread.currentThread().getContextClassLoader();
+ if (classLoader == null) {
+ classLoader = KmsClientRegistry.class.getClassLoader();
+ }
+ return ServiceLoader.load(KmsClientFactory.class, classLoader);
+ }
+
+ private static Map<String, ConfiguredClient> createClients(
+ Map<String, KmsConfig.SourceConfig> sourceConfigs,
+ Map<String, KmsClientFactory> factoriesByApi) {
+ Map<String, ConfiguredClient> clients = new LinkedHashMap<>();
+ try {
+ sourceConfigs.forEach(
+ (source, sourceConfig) -> {
+ KmsClientFactory factory = factoriesByApi.get(sourceConfig.api());
+ if (factory == null) {
+ throw new IllegalArgumentException(
+ String.format(
+ "No KMS client factory supports API '%s' for source
'%s'",
+ sourceConfig.api(), source));
+ }
+ KmsClient client = factory.create(source,
sourceConfig.properties());
+ if (client == null) {
+ throw new IllegalStateException(
+ String.format(
+ "KMS client factory for API '%s' returned null",
sourceConfig.api()));
+ }
+ clients.put(source, new ConfiguredClient(sourceConfig.api(),
client));
+ });
+ return Collections.unmodifiableMap(clients);
+ } catch (RuntimeException | Error e) {
+ RuntimeException closeFailure = closeClients(new
ArrayList<>(clients.values()));
+ if (closeFailure != null) {
+ e.addSuppressed(closeFailure);
+ }
+ throw e;
+ }
+ }
+
+ private static RuntimeException closeClients(List<ConfiguredClient> clients)
{
+ RuntimeException failure = null;
+ for (int index = clients.size() - 1; index >= 0; index--) {
+ try {
+ clients.get(index).client.close();
+ } catch (RuntimeException e) {
+ if (failure == null) {
+ failure = e;
+ } else {
+ failure.addSuppressed(e);
+ }
+ }
+ }
+ return failure;
+ }
+
+ private ConfiguredClient resolveClient(KmsReference reference) {
+ if (reference == null) {
+ throw new IllegalArgumentException("KMS reference cannot be null");
+ }
+
+ ConfiguredClient configuredClient = clients.get(reference.source());
+ if (configuredClient == null) {
+ throw new IllegalArgumentException(
+ String.format("No KMS client is configured for source '%s'",
reference.source()));
+ }
+ if (!configuredClient.api.equals(reference.api())) {
+ throw new IllegalArgumentException(
+ String.format(
+ "KMS source '%s' uses API '%s', not '%s'",
+ reference.source(), configuredClient.api, reference.api()));
+ }
+ return configuredClient;
+ }
+
+ private void checkOpen() {
+ if (closed) {
+ throw new IllegalStateException("KMS client registry is closed");
+ }
+ }
+
+ private static final class ConfiguredClient {
+ private final String api;
+ private final KmsClient client;
+
+ private ConfiguredClient(String api, KmsClient client) {
+ this.api = api;
+ this.client = client;
+ }
+ }
+}
diff --git
a/core/src/main/java/org/apache/gravitino/encryption/kms/KmsConfig.java
b/core/src/main/java/org/apache/gravitino/encryption/kms/KmsConfig.java
new file mode 100644
index 0000000000..24b46f26d7
--- /dev/null
+++ b/core/src/main/java/org/apache/gravitino/encryption/kms/KmsConfig.java
@@ -0,0 +1,161 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.gravitino.encryption.kms;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.regex.Pattern;
+import org.apache.gravitino.Config;
+
+final class KmsConfig {
+
+ static final String KMS_CONFIG_PREFIX = "gravitino.kms.";
+ static final String KMS_SOURCES = KMS_CONFIG_PREFIX + "sources";
+
+ private static final String SOURCES = "sources";
+ private static final String SOURCE_PREFIX = "source.";
+ private static final String API = "api";
+ private static final Pattern SOURCE_NAME_PATTERN =
Pattern.compile("[A-Za-z0-9][A-Za-z0-9_-]*");
+
+ private final Map<String, SourceConfig> sources;
+
+ KmsConfig(Config config) {
+ if (config == null) {
+ throw new KmsConfigurationException("Gravitino configuration cannot be
null");
+ }
+
+ Map<String, String> values =
config.getConfigsWithPrefix(KMS_CONFIG_PREFIX);
+ List<String> configuredSources = parseSources(values.get(SOURCES));
+ this.sources = parseSourceConfigs(values, configuredSources);
+ }
+
+ Map<String, SourceConfig> sources() {
+ return sources;
+ }
+
+ private static List<String> parseSources(String value) {
+ if (value == null || value.trim().isEmpty()) {
+ return Collections.emptyList();
+ }
+
+ List<String> sources = new ArrayList<>();
+ Set<String> uniqueSources = new LinkedHashSet<>();
+ for (String item : value.split(",", -1)) {
+ String source = item.trim();
+ if (!SOURCE_NAME_PATTERN.matcher(source).matches()) {
+ throw new KmsConfigurationException(
+ "Invalid KMS source name '%s' in %s", source, KMS_SOURCES);
+ }
+ if (!uniqueSources.add(source)) {
+ throw new KmsConfigurationException("Duplicate KMS source '%s' in %s",
source, KMS_SOURCES);
+ }
+ sources.add(source);
+ }
+ return Collections.unmodifiableList(sources);
+ }
+
+ private static Map<String, SourceConfig> parseSourceConfigs(
+ Map<String, String> values, List<String> configuredSources) {
+ Map<String, Map<String, String>> propertiesBySource = new
LinkedHashMap<>();
+ for (String source : configuredSources) {
+ propertiesBySource.put(source, new LinkedHashMap<>());
+ }
+
+ for (Map.Entry<String, String> entry : values.entrySet()) {
+ String key = entry.getKey();
+ if (SOURCES.equals(key)) {
+ continue;
+ }
+ if (!key.startsWith(SOURCE_PREFIX)) {
+ throw invalidConfigurationKey(key);
+ }
+
+ String sourceAndProperty = key.substring(SOURCE_PREFIX.length());
+ int separator = sourceAndProperty.indexOf('.');
+ if (separator <= 0 || separator == sourceAndProperty.length() - 1) {
+ throw invalidConfigurationKey(key);
+ }
+
+ String source = sourceAndProperty.substring(0, separator);
+ if (!SOURCE_NAME_PATTERN.matcher(source).matches()) {
+ throw invalidConfigurationKey(key);
+ }
+
+ Map<String, String> properties = propertiesBySource.get(source);
+ if (properties == null) {
+ throw new KmsConfigurationException(
+ "KMS configuration references unlisted source '%s'", source);
+ }
+
+ String property = sourceAndProperty.substring(separator + 1);
+ properties.put(property, entry.getValue());
+ }
+
+ Map<String, SourceConfig> sourceConfigs = new LinkedHashMap<>();
+
+ for (String source : configuredSources) {
+ String apiKey = SOURCE_PREFIX + source + "." + API;
+ Map<String, String> properties = propertiesBySource.get(source);
+ String apiValue = properties.remove(API);
+ if (apiValue == null || apiValue.trim().isEmpty()) {
+ throw new KmsConfigurationException(
+ "KMS API property '%s%s' cannot be blank", KMS_CONFIG_PREFIX,
apiKey);
+ }
+ String api;
+ try {
+ api = KmsApiIdentifiers.requireValid(apiValue);
+ } catch (IllegalArgumentException e) {
+ throw new KmsConfigurationException(
+ e, "Invalid KMS API property '%s%s': %s", KMS_CONFIG_PREFIX,
apiKey, e.getMessage());
+ }
+
+ sourceConfigs.put(source, new SourceConfig(api, properties));
+ }
+
+ return Collections.unmodifiableMap(sourceConfigs);
+ }
+
+ private static KmsConfigurationException invalidConfigurationKey(String key)
{
+ return new KmsConfigurationException(
+ "Invalid KMS configuration key '%s%s'", KMS_CONFIG_PREFIX, key);
+ }
+
+ static final class SourceConfig {
+ private final String api;
+ private final Map<String, String> properties;
+
+ private SourceConfig(String api, Map<String, String> properties) {
+ this.api = api;
+ this.properties = Collections.unmodifiableMap(new
LinkedHashMap<>(properties));
+ }
+
+ String api() {
+ return api;
+ }
+
+ Map<String, String> properties() {
+ return properties;
+ }
+ }
+}
diff --git
a/core/src/test/java/org/apache/gravitino/TestGravitinoEnvKmsClientRegistry.java
b/core/src/test/java/org/apache/gravitino/TestGravitinoEnvKmsClientRegistry.java
new file mode 100644
index 0000000000..5f08061a78
--- /dev/null
+++
b/core/src/test/java/org/apache/gravitino/TestGravitinoEnvKmsClientRegistry.java
@@ -0,0 +1,62 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.gravitino;
+
+import org.apache.commons.lang3.reflect.FieldUtils;
+import org.apache.gravitino.encryption.kms.KmsClientRegistry;
+import org.apache.gravitino.encryption.kms.KmsReference;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+public class TestGravitinoEnvKmsClientRegistry {
+
+ @Test
+ void testEmptyRegistryIsOptionalAndClosedWithEnvironment() throws
IllegalAccessException {
+ TestGravitinoEnv env = new TestGravitinoEnv();
+ Assertions.assertThrows(IllegalStateException.class,
env::kmsClientRegistry);
+
+ KmsClientRegistry registry = new KmsClientRegistry(new Config(false) {});
+ FieldUtils.writeField(env, "kmsClientRegistry", registry, true);
+
+ Assertions.assertSame(registry, env.kmsClientRegistry());
+ KmsReference reference = new KmsReference("aws-kms", "missing", "key");
+ Assertions.assertThrows(IllegalArgumentException.class, () ->
registry.getClient(reference));
+
+ env.shutdown();
+
+ Assertions.assertSame(registry, env.kmsClientRegistry());
+ Assertions.assertThrows(IllegalStateException.class, () ->
registry.getClient(reference));
+ }
+
+ @Test
+ void testBaseEnvironmentInitializesKmsClientRegistry() {
+ TestGravitinoEnv env = new TestGravitinoEnv();
+
+ env.initializeBaseComponents(new Config(false) {});
+ KmsClientRegistry registry = env.kmsClientRegistry();
+ env.shutdown();
+
+ Assertions.assertSame(registry, env.kmsClientRegistry());
+ Assertions.assertThrows(
+ IllegalStateException.class,
+ () -> registry.getClient(new KmsReference("aws-kms", "missing",
"key")));
+ }
+
+ private static final class TestGravitinoEnv extends GravitinoEnv {}
+}
diff --git
a/core/src/test/java/org/apache/gravitino/encryption/kms/TestKmsClientRegistry.java
b/core/src/test/java/org/apache/gravitino/encryption/kms/TestKmsClientRegistry.java
new file mode 100644
index 0000000000..aebdcfcfb5
--- /dev/null
+++
b/core/src/test/java/org/apache/gravitino/encryption/kms/TestKmsClientRegistry.java
@@ -0,0 +1,489 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.gravitino.encryption.kms;
+
+import java.net.URL;
+import java.net.URLClassLoader;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.apache.gravitino.Config;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+public class TestKmsClientRegistry {
+
+ private static final String AWS_API = "aws-kms";
+ private static final String GCP_API = "google-cloud-kms";
+ private static final String AZURE_API = "azure-key-vault";
+
+ @Test
+ void testEmptyRegistryDoesNotEnumerateFactories() {
+ Iterable<KmsClientFactory> factories =
+ () -> {
+ throw new AssertionError("Factories must not be enumerated without
configured sources");
+ };
+ KmsClientRegistry registry = new KmsClientRegistry(config(), factories);
+ KmsReference reference = new KmsReference(AWS_API, "primary",
"alias/orders");
+
+ IllegalArgumentException exception =
+ Assertions.assertThrows(
+ IllegalArgumentException.class, () ->
registry.getClient(reference));
+ Assertions.assertEquals(
+ "No KMS client is configured for source 'primary'",
exception.getMessage());
+ }
+
+ @Test
+ void testCreatesAndDispatchesConfiguredClients() {
+ RecordingFactory awsFactory = new RecordingFactory(AWS_API);
+ RecordingFactory gcpFactory = new RecordingFactory(GCP_API);
+ KmsClientRegistry registry =
+ new KmsClientRegistry(
+ config(
+ "gravitino.kms.sources", "primary,analytics",
+ "gravitino.kms.source.primary.api", AWS_API,
+ "gravitino.kms.source.primary.endpoint.region", "us-west-2",
+ "gravitino.kms.source.analytics.api", "google-cloud-kms",
+ "gravitino.kms.source.analytics.endpoint.project",
"data-project"),
+ List.of(awsFactory, gcpFactory));
+
+ KmsReference awsReference = new KmsReference(AWS_API, "primary",
"alias/orders");
+ KmsReference gcpReference =
+ new KmsReference(GCP_API, "analytics",
"projects/p/locations/l/keyRings/r/cryptoKeys/k");
+
+ KmsClient awsClient = registry.getClient(awsReference);
+ KmsClient gcpClient = registry.getClient(gcpReference);
+
+ Assertions.assertSame(awsClient, registry.getClient(awsReference));
+ Assertions.assertSame(gcpClient, registry.getClient(gcpReference));
+ Assertions.assertEquals(Map.of("endpoint.region", "us-west-2"),
awsFactory.properties);
+ Assertions.assertEquals(Map.of("endpoint.project", "data-project"),
gcpFactory.properties);
+ Assertions.assertEquals("primary", awsFactory.createdSource);
+ Assertions.assertEquals("analytics", gcpFactory.createdSource);
+ Assertions.assertEquals(1, awsFactory.createCount.get());
+ Assertions.assertEquals(1, gcpFactory.createCount.get());
+ }
+
+ @Test
+ void testRejectsUnknownSourceAndApiMismatch() {
+ KmsClientRegistry registry =
+ new KmsClientRegistry(
+ config(
+ "gravitino.kms.sources", "primary",
+ "gravitino.kms.source.primary.api", "aws-kms"),
+ List.of(new RecordingFactory(AWS_API)));
+
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> registry.getClient(new KmsReference(AWS_API, "other", "key")));
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> registry.getClient(new KmsReference(GCP_API, "primary", "key")));
+ Assertions.assertThrows(IllegalArgumentException.class, () ->
registry.getClient(null));
+ }
+
+ @Test
+ void testCreatesMultipleSourcesForSameApi() {
+ RecordingFactory factory = new RecordingFactory(AZURE_API);
+ KmsClientRegistry registry =
+ new KmsClientRegistry(
+ config(
+ "gravitino.kms.sources", "azure-eu,azure-us",
+ "gravitino.kms.source.azure-eu.api", "azure-key-vault",
+ "gravitino.kms.source.azure-us.api", "azure-key-vault"),
+ List.of(factory));
+
+ KmsReference euReference = new KmsReference(AZURE_API, "azure-eu",
"primary");
+ KmsReference usReference = new KmsReference(AZURE_API, "azure-us",
"primary");
+
+ KmsClient euClient = registry.getClient(euReference);
+ KmsClient usClient = registry.getClient(usReference);
+
+ Assertions.assertSame(euClient, registry.getClient(euReference));
+ Assertions.assertSame(usClient, registry.getClient(usReference));
+ Assertions.assertEquals(2, factory.createCount.get());
+ }
+
+ @Test
+ void testRoutesCustomApi() {
+ String customApi = "custom-kms";
+ KmsClientRegistry registry =
+ new KmsClientRegistry(
+ config("gravitino.kms.sources", "custom",
"gravitino.kms.source.custom.api", customApi),
+ List.of(new RecordingFactory(customApi)));
+ KmsReference reference = new KmsReference(customApi, "custom", "key");
+
+ Assertions.assertNotNull(registry.getClient(reference));
+ }
+
+ @Test
+ void testMatchesApiIdentifiersByValue() {
+ KmsClientRegistry registry =
+ new KmsClientRegistry(
+ config(
+ "gravitino.kms.sources",
+ "primary",
+ "gravitino.kms.source.primary.api",
+ new String(AWS_API)),
+ List.of(new RecordingFactory(new String(AWS_API))));
+ KmsReference reference = new KmsReference(new String(AWS_API), "primary",
"key");
+
+ Assertions.assertNotNull(registry.getClient(reference));
+ }
+
+ @Test
+ void testRejectsMissingDuplicateAndInvalidFactories() {
+ Config awsConfig =
+ config(
+ "gravitino.kms.sources", "primary",
+ "gravitino.kms.source.primary.api", "aws-kms");
+
+ Assertions.assertThrows(
+ IllegalArgumentException.class, () -> new KmsClientRegistry(awsConfig,
List.of()));
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ new KmsClientRegistry(
+ awsConfig, List.of(new RecordingFactory(AWS_API), new
RecordingFactory(AWS_API))));
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> new KmsClientRegistry(awsConfig, List.of(new
RecordingFactory(null))));
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> new KmsClientRegistry(awsConfig, List.of(new RecordingFactory("
"))));
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> new KmsClientRegistry(awsConfig, List.of(new RecordingFactory("
aws-kms"))));
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> new KmsClientRegistry(awsConfig, List.of(new
RecordingFactory("AWS-KMS"))));
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> new KmsClientRegistry(awsConfig,
java.util.Arrays.asList((KmsClientFactory) null)));
+ Assertions.assertThrows(
+ IllegalArgumentException.class, () -> new KmsClientRegistry(awsConfig,
null));
+ }
+
+ @Test
+ void testRejectsConfiguredApiWithoutFactory() {
+ Config customConfig =
+ config(
+ "gravitino.kms.sources", "primary",
+ "gravitino.kms.source.primary.api", "custom-kms");
+
+ IllegalArgumentException exception =
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> new KmsClientRegistry(customConfig, List.of(new
RecordingFactory(AWS_API))));
+ Assertions.assertTrue(
+ exception.getMessage().contains("No KMS client factory supports API
'custom-kms'"));
+ }
+
+ @Test
+ void testPublicConstructorUsesContextClassLoader(@TempDir Path
tempDirectory) throws Exception {
+ Path serviceFile =
+ tempDirectory.resolve(
+
"META-INF/services/org.apache.gravitino.encryption.kms.KmsClientFactory");
+ Files.createDirectories(serviceFile.getParent());
+ Files.write(serviceFile,
ServiceLoadedFactory.class.getName().getBytes(StandardCharsets.UTF_8));
+
+ ClassLoader originalClassLoader =
Thread.currentThread().getContextClassLoader();
+ try (URLClassLoader serviceClassLoader =
+ new URLClassLoader(new URL[] {tempDirectory.toUri().toURL()},
originalClassLoader)) {
+ Thread.currentThread().setContextClassLoader(serviceClassLoader);
+ try (KmsClientRegistry registry =
+ new KmsClientRegistry(
+ config(
+ "gravitino.kms.sources", "primary",
+ "gravitino.kms.source.primary.api", "aws-kms"))) {
+ KmsReference reference = new KmsReference(AWS_API, "primary", "key");
+ Assertions.assertNotNull(registry.getClient(reference));
+ }
+ } finally {
+ Thread.currentThread().setContextClassLoader(originalClassLoader);
+ }
+ }
+
+ @Test
+ void testRejectsNullClientAndClosesPreviouslyCreatedClient() {
+ CloseTrackingFactory awsFactory =
+ new CloseTrackingFactory(AWS_API, "aws", new ArrayList<>(), null);
+ Config awsConfig =
+ config(
+ "gravitino.kms.sources", "primary,analytics",
+ "gravitino.kms.source.primary.api", "aws-kms",
+ "gravitino.kms.source.analytics.api", "google-cloud-kms");
+
+ KmsClientFactory nullClientFactory = factory(GCP_API, (source, properties)
-> null);
+
+ Assertions.assertThrows(
+ IllegalStateException.class,
+ () -> new KmsClientRegistry(awsConfig, List.of(awsFactory,
nullClientFactory)));
+ Assertions.assertEquals(1, awsFactory.closeCount.get());
+ }
+
+ @Test
+ void testClosesClientsInReverseOrderAndIsIdempotent() {
+ List<String> closeOrder = new ArrayList<>();
+ CloseTrackingFactory awsFactory = new CloseTrackingFactory(AWS_API, "aws",
closeOrder, null);
+ CloseTrackingFactory gcpFactory = new CloseTrackingFactory(GCP_API, "gcp",
closeOrder, null);
+ KmsClientRegistry registry =
+ new KmsClientRegistry(
+ config(
+ "gravitino.kms.sources", "primary,analytics",
+ "gravitino.kms.source.primary.api", "aws-kms",
+ "gravitino.kms.source.analytics.api", "google-cloud-kms"),
+ List.of(awsFactory, gcpFactory));
+ KmsReference awsReference = new KmsReference(AWS_API, "primary", "key");
+
+ registry.close();
+ registry.close();
+
+ Assertions.assertEquals(List.of("gcp", "aws"), closeOrder);
+ Assertions.assertEquals(1, awsFactory.closeCount.get());
+ Assertions.assertEquals(1, gcpFactory.closeCount.get());
+ Assertions.assertThrows(IllegalStateException.class, () ->
registry.getClient(awsReference));
+ }
+
+ @Test
+ void testClosesCreatedClientsAfterPartialInitializationFailure() {
+ List<String> closeOrder = new ArrayList<>();
+ CloseTrackingFactory awsFactory = new CloseTrackingFactory(AWS_API, "aws",
closeOrder, null);
+ KmsClientFactory failingFactory =
+ factory(
+ GCP_API,
+ (source, properties) -> {
+ throw new IllegalArgumentException("invalid GCP configuration");
+ });
+
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ new KmsClientRegistry(
+ config(
+ "gravitino.kms.sources", "primary,analytics",
+ "gravitino.kms.source.primary.api", "aws-kms",
+ "gravitino.kms.source.analytics.api", "google-cloud-kms"),
+ List.of(awsFactory, failingFactory)));
+
+ Assertions.assertEquals(1, awsFactory.closeCount.get());
+ Assertions.assertEquals(List.of("aws"), closeOrder);
+ }
+
+ @Test
+ void testPreservesInitializationFailureWhenCleanupFails() {
+ RuntimeException closeFailure = new IllegalStateException("close failed");
+ CloseTrackingFactory awsFactory =
+ new CloseTrackingFactory(AWS_API, "aws", new ArrayList<>(),
closeFailure);
+ IllegalArgumentException creationFailure =
+ new IllegalArgumentException("invalid GCP configuration");
+ KmsClientFactory failingFactory =
+ factory(
+ GCP_API,
+ (source, properties) -> {
+ throw creationFailure;
+ });
+
+ IllegalArgumentException exception =
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ new KmsClientRegistry(
+ config(
+ "gravitino.kms.sources", "primary,analytics",
+ "gravitino.kms.source.primary.api", "aws-kms",
+ "gravitino.kms.source.analytics.api",
"google-cloud-kms"),
+ List.of(awsFactory, failingFactory)));
+
+ Assertions.assertSame(creationFailure, exception);
+ Assertions.assertArrayEquals(new Throwable[] {closeFailure},
exception.getSuppressed());
+ }
+
+ @Test
+ void testAggregatesCloseFailures() {
+ RuntimeException awsFailure = new IllegalStateException("aws close
failed");
+ RuntimeException gcpFailure = new IllegalStateException("gcp close
failed");
+ CloseTrackingFactory awsFactory =
+ new CloseTrackingFactory(AWS_API, "aws", new ArrayList<>(),
awsFailure);
+ CloseTrackingFactory gcpFactory =
+ new CloseTrackingFactory(GCP_API, "gcp", new ArrayList<>(),
gcpFailure);
+ KmsClientRegistry registry =
+ new KmsClientRegistry(
+ config(
+ "gravitino.kms.sources", "primary,analytics",
+ "gravitino.kms.source.primary.api", "aws-kms",
+ "gravitino.kms.source.analytics.api", "google-cloud-kms"),
+ List.of(awsFactory, gcpFactory));
+
+ RuntimeException exception =
Assertions.assertThrows(RuntimeException.class, registry::close);
+ Assertions.assertSame(gcpFailure, exception);
+ Assertions.assertArrayEquals(new Throwable[] {awsFailure},
exception.getSuppressed());
+ }
+
+ private static Config config(String... entries) {
+ Map<String, String> properties = new HashMap<>();
+ for (int index = 0; index < entries.length; index += 2) {
+ properties.put(entries[index], entries[index + 1]);
+ }
+ return new MapConfig(properties);
+ }
+
+ private static KmsClientFactory factory(String api, ClientCreator creator) {
+ return new KmsClientFactory() {
+ @Override
+ public String api() {
+ return api;
+ }
+
+ @Override
+ public KmsClient create(String source, Map<String, String> properties) {
+ return creator.create(source, properties);
+ }
+ };
+ }
+
+ private interface ClientCreator {
+ KmsClient create(String source, Map<String, String> properties);
+ }
+
+ private static final class RecordingFactory implements KmsClientFactory {
+ private final String api;
+ private String createdSource;
+ private Map<String, String> properties;
+ private final AtomicInteger createCount = new AtomicInteger();
+
+ private RecordingFactory(String api) {
+ this.api = api;
+ }
+
+ @Override
+ public String api() {
+ return api;
+ }
+
+ @Override
+ public KmsClient create(String source, Map<String, String> properties) {
+ createCount.incrementAndGet();
+ this.createdSource = source;
+ this.properties = properties;
+ return reference -> Optional.of(new Properties(reference));
+ }
+ }
+
+ private static final class CloseTrackingFactory implements KmsClientFactory {
+ private final String api;
+ private final String name;
+ private final List<String> closeOrder;
+ private final RuntimeException closeFailure;
+ private final AtomicInteger closeCount = new AtomicInteger();
+
+ private CloseTrackingFactory(
+ String api, String name, List<String> closeOrder, RuntimeException
closeFailure) {
+ this.api = api;
+ this.name = name;
+ this.closeOrder = closeOrder;
+ this.closeFailure = closeFailure;
+ }
+
+ @Override
+ public String api() {
+ return api;
+ }
+
+ @Override
+ public KmsClient create(String source, Map<String, String> properties) {
+ return new KmsClient() {
+ @Override
+ public Optional<KmsKeyProperties> getKeyProperties(KmsReference
reference) {
+ return Optional.of(new Properties(reference));
+ }
+
+ @Override
+ public void close() {
+ closeCount.incrementAndGet();
+ closeOrder.add(name);
+ if (closeFailure != null) {
+ throw closeFailure;
+ }
+ }
+ };
+ }
+ }
+
+ private static final class Properties implements KmsKeyProperties {
+ private final KmsReference reference;
+
+ private Properties(KmsReference reference) {
+ this.reference = reference;
+ }
+
+ @Override
+ public KmsReference reference() {
+ return reference;
+ }
+
+ @Override
+ public boolean enabled() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsWrapping() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsUnwrapping() {
+ return true;
+ }
+ }
+
+ private static final class MapConfig extends Config {
+ private MapConfig(Map<String, String> properties) {
+ super(false);
+ loadFromMap(properties, key -> true);
+ }
+ }
+
+ /** Factory exposed for the context-classloader ServiceLoader test. */
+ public static final class ServiceLoadedFactory implements KmsClientFactory {
+
+ /** Creates a test service-loaded factory. */
+ public ServiceLoadedFactory() {}
+
+ /** {@inheritDoc} */
+ @Override
+ public String api() {
+ return AWS_API;
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public KmsClient create(String source, Map<String, String> properties) {
+ return reference -> Optional.of(new Properties(reference));
+ }
+ }
+}
diff --git
a/core/src/test/java/org/apache/gravitino/encryption/kms/TestKmsConfig.java
b/core/src/test/java/org/apache/gravitino/encryption/kms/TestKmsConfig.java
new file mode 100644
index 0000000000..5a47052cb2
--- /dev/null
+++ b/core/src/test/java/org/apache/gravitino/encryption/kms/TestKmsConfig.java
@@ -0,0 +1,145 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.gravitino.encryption.kms;
+
+import java.util.HashMap;
+import java.util.Map;
+import org.apache.gravitino.Config;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+public class TestKmsConfig {
+
+ private static final String AWS_API = "aws-kms";
+ private static final String GCP_API = "google-cloud-kms";
+
+ @Test
+ void testParsesSourcesAndProviderProperties() {
+ KmsConfig config =
+ parse(
+ Map.of(
+ "gravitino.kms.sources", "primary, disaster-recovery",
+ "gravitino.kms.source.primary.api", AWS_API,
+ "gravitino.kms.source.primary.endpoint.region", "us-west-2",
+ "gravitino.kms.source.primary.credential.method", "default",
+ "gravitino.kms.source.disaster-recovery.api",
"google-cloud-kms",
+ "gravitino.kms.source.disaster-recovery.endpoint.projectId",
"backup-project",
+ "gravitino.kms.source.disaster-recovery.credential.method",
"default"));
+
+ Assertions.assertEquals(2, config.sources().size());
+ Assertions.assertEquals(AWS_API, config.sources().get("primary").api());
+ Assertions.assertEquals(
+ Map.of("endpoint.region", "us-west-2", "credential.method", "default"),
+ config.sources().get("primary").properties());
+ Assertions.assertEquals(GCP_API,
config.sources().get("disaster-recovery").api());
+ Assertions.assertEquals(
+ Map.of("endpoint.projectId", "backup-project", "credential.method",
"default"),
+ config.sources().get("disaster-recovery").properties());
+ Assertions.assertThrows(
+ UnsupportedOperationException.class,
+ () ->
config.sources().get("primary").properties().put("endpoint.region", "other"));
+ }
+
+ @Test
+ void testAllowsNoConfiguredSources() {
+ Assertions.assertTrue(parse(Map.of()).sources().isEmpty());
+ Assertions.assertTrue(parse(Map.of("gravitino.kms.sources", "
")).sources().isEmpty());
+ }
+
+ @Test
+ void testRejectsInvalidOrDuplicateSourceNames() {
+ assertInvalid(Map.of("gravitino.kms.sources", "primary,"), "Invalid KMS
source name");
+ assertInvalid(Map.of("gravitino.kms.sources", "bad.name"), "Invalid KMS
source name");
+ assertInvalid(
+ Map.of("gravitino.kms.sources", "primary,primary"), "Duplicate KMS
source 'primary'");
+ }
+
+ @Test
+ void testRejectsMalformedOrUnlistedSourceProperties() {
+ assertInvalid(Map.of("gravitino.kms.unexpected", "value"), "Invalid KMS
configuration key");
+ assertInvalid(Map.of("gravitino.kms.source.primary", "value"), "Invalid
KMS configuration key");
+ assertInvalid(Map.of("gravitino.kms.source..api", AWS_API), "Invalid KMS
configuration key");
+ assertInvalid(
+ Map.of("gravitino.kms.source.bad$name.api", AWS_API), "Invalid KMS
configuration key");
+ assertInvalid(
+ Map.of(
+ "gravitino.kms.sources", "primary",
+ "gravitino.kms.source.other.api", "aws-kms"),
+ "unlisted source 'other'");
+ assertInvalid(
+ Map.of(
+ "gravitino.kms.sources", "primary",
+ "gravitino.kms.source.primary.", "value"),
+ "Invalid KMS configuration key");
+ }
+
+ @Test
+ void testRequiresApi() {
+ assertInvalid(
+ Map.of("gravitino.kms.sources", "primary"),
+ "gravitino.kms.source.primary.api' cannot be blank");
+ assertInvalid(
+ Map.of(
+ "gravitino.kms.sources", "primary",
+ "gravitino.kms.source.primary.api", " "),
+ "gravitino.kms.source.primary.api' cannot be blank");
+ assertInvalid(
+ Map.of(
+ "gravitino.kms.sources", "primary",
+ "gravitino.kms.source.primary.api", "Custom-KMS"),
+ "must be lowercase kebab-case");
+ }
+
+ @Test
+ void testAllowsMoreThanOneSourceForAnApi() {
+ KmsConfig config =
+ parse(
+ Map.of(
+ "gravitino.kms.sources", "primary,secondary",
+ "gravitino.kms.source.primary.api", "aws-kms",
+ "gravitino.kms.source.secondary.api", "aws-kms"));
+
+ Assertions.assertEquals(AWS_API, config.sources().get("primary").api());
+ Assertions.assertEquals(AWS_API, config.sources().get("secondary").api());
+ }
+
+ @Test
+ void testRejectsNullConfiguration() {
+ Assertions.assertThrows(KmsConfigurationException.class, () -> new
KmsConfig(null));
+ }
+
+ private static KmsConfig parse(Map<String, String> properties) {
+ return new KmsConfig(new MapConfig(properties));
+ }
+
+ private static void assertInvalid(Map<String, String> properties, String
expectedMessage) {
+ KmsConfigurationException exception =
+ Assertions.assertThrows(KmsConfigurationException.class, () ->
parse(properties));
+ Assertions.assertTrue(
+ exception.getMessage().contains(expectedMessage),
+ () -> String.format("Expected '%s' in '%s'", expectedMessage,
exception.getMessage()));
+ }
+
+ private static final class MapConfig extends Config {
+ private MapConfig(Map<String, String> properties) {
+ super(false);
+ loadFromMap(new HashMap<>(properties), key -> true);
+ }
+ }
+}