This is an automated email from the ASF dual-hosted git repository.
jerryshao 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 2325b412a2 [#12794] feat(catalog): Support connection tests with
proposed changes (#12798)
2325b412a2 is described below
commit 2325b412a29e40221d8c3b729542f6d5f4e995be
Author: mchades <[email protected]>
AuthorDate: Thu Sep 3 19:12:21 2026 +0800
[#12794] feat(catalog): Support connection tests with proposed changes
(#12798)
### What changes were proposed in this pull request?
Extend existing catalog connection testing to accept proposed
`CatalogChange` values.
The changes are applied to a temporary effective catalog configuration
before running the existing connection probe. The temporary catalog is
always closed, and no catalog configuration or secret material is
persisted.
This also adds REST, OpenAPI, Java client, Python client, and regression
test coverage.
### Why are the changes needed?
The existing API can only test the stored catalog configuration. Users
need to validate proposed catalog changes before altering the catalog.
Fix: #12794
### Does this PR introduce _any_ user-facing change?
Yes.
- Adds `testConnection(String, CatalogChange...)` for existing catalogs.
- Allows an optional `CatalogUpdatesRequest` body on the
existing-catalog connection-test endpoint. Omitting the body retains the
existing stored-configuration behavior.
- Adds corresponding Java and Python client support.
- No property keys are added or removed.
### How was this patch tested?
- Focused Core, REST, and Java client unit tests.
- Hive Docker integration test covering temporary configuration and
non-persistence.
- Python client unit tests, Black, and Ruff.
- Spotless checks.
- `./gradlew :docs:build :api:javadoc -PskipITs`.
- `git diff --check`.
---
.../org/apache/gravitino/SupportsCatalogs.java | 22 +++
.../apache/gravitino/client/GravitinoClient.java | 12 ++
.../apache/gravitino/client/GravitinoMetalake.java | 40 ++++
.../gravitino/client/TestGravitinoAdminClient.java | 30 +++
.../client/integration/test/CatalogIT.java | 13 ++
.../gravitino/client/gravitino_client.py | 6 +-
.../gravitino/client/gravitino_metalake.py | 17 +-
.../client-python/tests/unittests/test_metalake.py | 21 +++
.../apache/gravitino/catalog/CatalogManager.java | 74 ++++++++
.../catalog/CatalogNormalizeDispatcher.java | 13 ++
.../apache/gravitino/catalog/SupportsCatalogs.java | 10 +
.../gravitino/hook/CatalogHookDispatcher.java | 5 +
.../gravitino/listener/CatalogEventDispatcher.java | 6 +
.../gravitino/secret/SecretAlterChanges.java | 54 ++++++
.../org/apache/gravitino/secret/SecretManager.java | 51 ++++--
.../gravitino/catalog/TestCatalogManager.java | 201 ++++++++++++++++++++-
.../catalog/TestCatalogNormalizeDispatcher.java | 14 ++
docs/open-api/catalogs.yaml | 20 +-
.../server/web/rest/CatalogOperations.java | 14 +-
.../server/web/rest/TestCatalogOperations.java | 42 +++++
20 files changed, 637 insertions(+), 28 deletions(-)
diff --git a/api/src/main/java/org/apache/gravitino/SupportsCatalogs.java
b/api/src/main/java/org/apache/gravitino/SupportsCatalogs.java
index d3560f5466..f79ace08ed 100644
--- a/api/src/main/java/org/apache/gravitino/SupportsCatalogs.java
+++ b/api/src/main/java/org/apache/gravitino/SupportsCatalogs.java
@@ -273,4 +273,26 @@ public interface SupportsCatalogs {
throw new UnsupportedOperationException(
String.format("Catalog %s does not support connection testing",
catalogName));
}
+
+ /**
+ * Test the connection of an existing catalog with proposed changes without
persisting them.
+ *
+ * <p>The default implementation preserves the existing connection-test
behavior when no changes
+ * are supplied and rejects non-empty changes. Implementations that support
testing proposed
+ * changes should override this method.
+ *
+ * @param catalogName the name of the existing catalog.
+ * @param changes the proposed changes to apply temporarily.
+ * @throws NoSuchCatalogException if the catalog does not exist.
+ * @throws Exception if the test failed.
+ */
+ default void testConnection(String catalogName, CatalogChange... changes)
throws Exception {
+ if (changes.length == 0) {
+ testConnection(catalogName);
+ return;
+ }
+ throw new UnsupportedOperationException(
+ String.format(
+ "Catalog %s does not support connection testing with proposed
changes", catalogName));
+ }
}
diff --git
a/clients/client-java/src/main/java/org/apache/gravitino/client/GravitinoClient.java
b/clients/client-java/src/main/java/org/apache/gravitino/client/GravitinoClient.java
index 78aed2a690..9a7fb8e749 100644
---
a/clients/client-java/src/main/java/org/apache/gravitino/client/GravitinoClient.java
+++
b/clients/client-java/src/main/java/org/apache/gravitino/client/GravitinoClient.java
@@ -573,6 +573,18 @@ public class GravitinoClient extends GravitinoClientBase
getMetalake().testConnection(catalogName);
}
+ /**
+ * Test the connection of an existing catalog with proposed changes without
persisting them.
+ *
+ * @param catalogName the name of the existing catalog.
+ * @param changes the proposed changes to apply temporarily.
+ * @throws Exception if the test failed.
+ */
+ @Override
+ public void testConnection(String catalogName, CatalogChange... changes)
throws Exception {
+ getMetalake().testConnection(catalogName, changes);
+ }
+
@Override
public String[] listTags() throws NoSuchMetalakeException {
return getMetalake().listTags();
diff --git
a/clients/client-java/src/main/java/org/apache/gravitino/client/GravitinoMetalake.java
b/clients/client-java/src/main/java/org/apache/gravitino/client/GravitinoMetalake.java
index 8eced5740e..aee5c604d9 100644
---
a/clients/client-java/src/main/java/org/apache/gravitino/client/GravitinoMetalake.java
+++
b/clients/client-java/src/main/java/org/apache/gravitino/client/GravitinoMetalake.java
@@ -497,6 +497,46 @@ public class GravitinoMetalake extends MetalakeDTO
ErrorHandlers.catalogErrorHandler().accept(resp);
}
+ /**
+ * Test the connection of an existing catalog with proposed changes without
persisting them.
+ *
+ * @param catalogName the name of the existing catalog.
+ * @param changes the proposed changes to apply temporarily.
+ * @throws Exception if the test failed.
+ */
+ @Override
+ public void testConnection(String catalogName, CatalogChange... changes)
throws Exception {
+ Preconditions.checkArgument(changes != null, "changes must not be null");
+ if (changes.length == 0) {
+ testConnection(catalogName);
+ return;
+ }
+
+ List<CatalogUpdateRequest> requests =
+ Arrays.stream(changes)
+ .map(DTOConverters::toCatalogUpdateRequest)
+ .collect(Collectors.toList());
+ CatalogUpdatesRequest updatesRequest = new CatalogUpdatesRequest(requests);
+ updatesRequest.validate();
+
+ ErrorResponse resp =
+ restClient.post(
+ String.format(
+ API_METALAKES_CATALOGS_PATH + "/testConnection",
+ RESTUtils.encodeString(this.name()),
+ RESTUtils.encodeString(catalogName)),
+ updatesRequest,
+ ErrorResponse.class,
+ Collections.emptyMap(),
+ ErrorHandlers.catalogErrorHandler());
+
+ if (resp.getCode() == 0) {
+ return;
+ }
+
+ ErrorHandlers.catalogErrorHandler().accept(resp);
+ }
+
@Override
public SupportsRoles supportsRoles() {
return this;
diff --git
a/clients/client-java/src/test/java/org/apache/gravitino/client/TestGravitinoAdminClient.java
b/clients/client-java/src/test/java/org/apache/gravitino/client/TestGravitinoAdminClient.java
index 0bcf28d0d0..4073bca3be 100644
---
a/clients/client-java/src/test/java/org/apache/gravitino/client/TestGravitinoAdminClient.java
+++
b/clients/client-java/src/test/java/org/apache/gravitino/client/TestGravitinoAdminClient.java
@@ -25,11 +25,14 @@ import java.util.Collections;
import java.util.Map;
import java.util.stream.Collectors;
import org.apache.gravitino.Catalog;
+import org.apache.gravitino.CatalogChange;
import org.apache.gravitino.MetalakeChange;
import org.apache.gravitino.NameIdentifier;
import org.apache.gravitino.dto.AuditDTO;
import org.apache.gravitino.dto.MetalakeDTO;
import org.apache.gravitino.dto.requests.CatalogCreateRequest;
+import org.apache.gravitino.dto.requests.CatalogUpdateRequest;
+import org.apache.gravitino.dto.requests.CatalogUpdatesRequest;
import org.apache.gravitino.dto.requests.MetalakeCreateRequest;
import org.apache.gravitino.dto.requests.MetalakeUpdatesRequest;
import org.apache.gravitino.dto.responses.BaseResponse;
@@ -324,6 +327,33 @@ public class TestGravitinoAdminClient extends TestBase {
HttpStatus.SC_OK);
Assertions.assertDoesNotThrow(() -> metaLake.testConnection("catalog"));
+ buildMockResource(
+ Method.POST,
+ "/api/metalakes/mock/catalogs/catalog/testConnection",
+ null,
+ new BaseResponse(),
+ HttpStatus.SC_OK);
+ Assertions.assertDoesNotThrow(() -> metaLake.testConnection("catalog", new
CatalogChange[0]));
+
+ exception =
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> metaLake.testConnection("catalog", (CatalogChange[]) null));
+ Assertions.assertTrue(exception.getMessage().contains("changes must not be
null"));
+
+ CatalogUpdatesRequest updatesRequest =
+ new CatalogUpdatesRequest(
+ Collections.singletonList(
+ new CatalogUpdateRequest.SetCatalogPropertyRequest("key",
"value")));
+ buildMockResource(
+ Method.POST,
+ "/api/metalakes/mock/catalogs/catalog/testConnection",
+ updatesRequest,
+ new BaseResponse(),
+ HttpStatus.SC_OK);
+ Assertions.assertDoesNotThrow(
+ () -> metaLake.testConnection("catalog",
CatalogChange.setProperty("key", "value")));
+
buildMockResource(
Method.POST,
"/api/metalakes/mock/catalogs/catalog/testConnection",
diff --git
a/clients/client-java/src/test/java/org/apache/gravitino/client/integration/test/CatalogIT.java
b/clients/client-java/src/test/java/org/apache/gravitino/client/integration/test/CatalogIT.java
index f3df2f4c9d..21e46b1ebe 100644
---
a/clients/client-java/src/test/java/org/apache/gravitino/client/integration/test/CatalogIT.java
+++
b/clients/client-java/src/test/java/org/apache/gravitino/client/integration/test/CatalogIT.java
@@ -40,6 +40,7 @@ import org.apache.gravitino.client.GravitinoMetalake;
import org.apache.gravitino.exceptions.CatalogAlreadyExistsException;
import org.apache.gravitino.exceptions.CatalogInUseException;
import org.apache.gravitino.exceptions.CatalogNotInUseException;
+import org.apache.gravitino.exceptions.ConnectionFailedException;
import org.apache.gravitino.file.Fileset;
import org.apache.gravitino.file.FilesetCatalog;
import org.apache.gravitino.file.FilesetChange;
@@ -125,6 +126,18 @@ public class CatalogIT extends BaseIT {
Assertions.assertTrue(catalog.properties().containsKey("metastore.uris"));
Assertions.assertDoesNotThrow(() -> metalake.testConnection(catalogName));
+ Assertions.assertThrows(
+ ConnectionFailedException.class,
+ () ->
+ metalake.testConnection(
+ catalogName,
+ CatalogChange.updateComment("temporary comment"),
+ CatalogChange.setProperty("metastore.uris",
"thrift://127.0.0.1:1")));
+ Catalog unchangedCatalog = metalake.loadCatalog(catalogName);
+ Assertions.assertEquals("catalog comment", unchangedCatalog.comment());
+ Assertions.assertEquals(hmsUri,
unchangedCatalog.properties().get("metastore.uris"));
+ Assertions.assertDoesNotThrow(() -> metalake.testConnection(catalogName));
+
metalake.dropCatalog(catalogName, true);
}
diff --git a/clients/client-python/gravitino/client/gravitino_client.py
b/clients/client-python/gravitino/client/gravitino_client.py
index 7a501deeb2..b5644c2dc1 100644
--- a/clients/client-python/gravitino/client/gravitino_client.py
+++ b/clients/client-python/gravitino/client/gravitino_client.py
@@ -133,9 +133,9 @@ class GravitinoClient(GravitinoClientBase, SupportsJobs,
TagOperations):
def disable_catalog(self, name: str):
return self.get_metalake().disable_catalog(name)
- def test_connection(self, name: str) -> None:
- """Test an existing catalog connection using its stored
configuration."""
- self.get_metalake().test_connection(name)
+ def test_connection(self, name: str, *changes: CatalogChange) -> None:
+ """Test an existing catalog connection with optional proposed
changes."""
+ self.get_metalake().test_connection(name, *changes)
def list_job_templates(self) -> List[JobTemplate]:
"""Lists all job templates in the current metalake.
diff --git a/clients/client-python/gravitino/client/gravitino_metalake.py
b/clients/client-python/gravitino/client/gravitino_metalake.py
index 68574cb2cb..9b662999b0 100644
--- a/clients/client-python/gravitino/client/gravitino_metalake.py
+++ b/clients/client-python/gravitino/client/gravitino_metalake.py
@@ -377,11 +377,12 @@ class GravitinoMetalake(
url, json=catalog_disable_request,
error_handler=CATALOG_ERROR_HANDLER
)
- def test_connection(self, name: str) -> None:
- """Test an existing catalog connection using its stored configuration.
+ def test_connection(self, name: str, *changes: CatalogChange) -> None:
+ """Test an existing catalog connection with optional proposed changes.
Args:
name: The name of the existing catalog.
+ changes: Proposed catalog changes to apply temporarily without
persisting.
Raises:
NoSuchCatalogException: If the catalog does not exist.
@@ -394,7 +395,17 @@ class GravitinoMetalake(
)
+ "/testConnection"
)
- response = self.rest_client.post(url,
error_handler=CATALOG_ERROR_HANDLER)
+ if changes:
+ requests = [
+ DTOConverters.to_catalog_update_request(change) for change in
changes
+ ]
+ updates_request = CatalogUpdatesRequest(requests)
+ updates_request.validate()
+ response = self.rest_client.post(
+ url, json=updates_request, error_handler=CATALOG_ERROR_HANDLER
+ )
+ else:
+ response = self.rest_client.post(url,
error_handler=CATALOG_ERROR_HANDLER)
base_response = BaseResponse.from_json(response.body,
infer_missing=True)
base_response.validate()
if base_response.code() == 0:
diff --git a/clients/client-python/tests/unittests/test_metalake.py
b/clients/client-python/tests/unittests/test_metalake.py
index 674539389c..1a00985b13 100644
--- a/clients/client-python/tests/unittests/test_metalake.py
+++ b/clients/client-python/tests/unittests/test_metalake.py
@@ -18,9 +18,12 @@
import unittest
from unittest.mock import MagicMock
+from gravitino.api.catalog_change import CatalogChange
from gravitino.client.gravitino_metalake import GravitinoMetalake
from gravitino.constants.error import ErrorConstants
from gravitino.dto.metalake_dto import MetalakeDTO
+from gravitino.dto.requests.catalog_update_request import CatalogUpdateRequest
+from gravitino.dto.requests.catalog_updates_request import
CatalogUpdatesRequest
from gravitino.dto.responses.metalake_response import MetalakeResponse
from gravitino.exceptions.base import (
ConnectionFailedException,
@@ -44,6 +47,24 @@ class TestMetalake(unittest.TestCase):
error_handler=CATALOG_ERROR_HANDLER,
)
+ def test_existing_catalog_connection_with_changes(self):
+ rest_client = MagicMock()
+ rest_client.post.return_value.body = b'{"code":0}'
+ metalake = GravitinoMetalake(
+ MetalakeDTO("metalake", None, {}, None), rest_client
+ )
+
+ metalake.test_connection("catalog", CatalogChange.set_property("key",
"value"))
+
+ expected_request = CatalogUpdatesRequest(
+ [CatalogUpdateRequest.SetCatalogPropertyRequest("key", "value")]
+ )
+ rest_client.post.assert_called_once_with(
+ "api/metalakes/metalake/catalogs/catalog/testConnection",
+ json=expected_request,
+ error_handler=CATALOG_ERROR_HANDLER,
+ )
+
def test_existing_catalog_connection_failure(self):
rest_client = MagicMock()
rest_client.post.return_value.body = (
diff --git
a/core/src/main/java/org/apache/gravitino/catalog/CatalogManager.java
b/core/src/main/java/org/apache/gravitino/catalog/CatalogManager.java
index ec3439c4f4..c78438838c 100644
--- a/core/src/main/java/org/apache/gravitino/catalog/CatalogManager.java
+++ b/core/src/main/java/org/apache/gravitino/catalog/CatalogManager.java
@@ -788,6 +788,80 @@ public class CatalogManager implements CatalogDispatcher,
Closeable {
});
}
+ /**
+ * Test the connection of an existing catalog with proposed changes without
persisting them.
+ *
+ * @param ident The identifier of the existing catalog.
+ * @param changes The proposed changes to apply temporarily.
+ */
+ @Override
+ public void testConnection(NameIdentifier ident, CatalogChange... changes) {
+ Preconditions.checkArgument(changes != null, "changes must not be null");
+ if (changes.length == 0) {
+ testConnection(ident);
+ return;
+ }
+
+ TreeLockUtils.doWithTreeLock(
+ ident,
+ LockType.READ,
+ () -> {
+ CatalogWrapper storedWrapper = loadCatalogAndWrap(ident);
+ BaseCatalog<?> storedCatalog = storedWrapper.catalog();
+ storedCatalog.checkMetalakeAndCatalogInUse();
+ try {
+ storedWrapper.doWithPropertiesMeta(
+ metadata -> {
+ Pair<Map<String, String>, Map<String, String>> alterProperty
=
+ getCatalogAlterProperty(changes);
+ validatePropertyForAlter(
+ metadata.catalogPropertiesMetadata(),
+ alterProperty.getLeft(),
+ alterProperty.getRight());
+ return null;
+ });
+
+ CatalogEntity storedEntity = storedCatalog.entity();
+ Map<String, String> effectiveProperties =
+ storedEntity.getProperties() == null
+ ? new HashMap<>()
+ : new HashMap<>(storedEntity.getProperties());
+ CatalogChange[] effectiveChanges =
+ SecretAlterChanges.prepareCatalogChangesForTest(
+ secretManager, storedEntity.id(), changes);
+ CatalogEntity effectiveEntity =
+ updateEntity(
+ newCatalogBuilder(storedEntity.namespace(),
storedEntity),
+ effectiveProperties,
+ effectiveChanges)
+ .build();
+ effectiveEntity = convertFilesetCatalogEntity(effectiveEntity);
+
+ CatalogWrapper temporaryWrapper =
createCatalogWrapper(effectiveEntity, null);
+ try {
+ NameIdentifier effectiveIdent = effectiveEntity.nameIdentifier();
+ temporaryWrapper.doWithCatalogOps(
+ operations -> {
+ operations.testConnection(effectiveIdent);
+ return null;
+ });
+ } finally {
+ temporaryWrapper.close();
+ }
+ } catch (UnsupportedOperationException e) {
+ throw e;
+ } catch (Exception e) {
+ LOG.warn(
+ "Failed to test existing catalog connection {} with proposed
changes", ident, e);
+ if (e instanceof RuntimeException) {
+ throw (RuntimeException) e;
+ }
+ throw new RuntimeException(e);
+ }
+ return null;
+ });
+ }
+
@Override
public void enableCatalog(NameIdentifier ident)
throws NoSuchCatalogException, CatalogNotInUseException {
diff --git
a/core/src/main/java/org/apache/gravitino/catalog/CatalogNormalizeDispatcher.java
b/core/src/main/java/org/apache/gravitino/catalog/CatalogNormalizeDispatcher.java
index 96202b0366..8d4df78433 100644
---
a/core/src/main/java/org/apache/gravitino/catalog/CatalogNormalizeDispatcher.java
+++
b/core/src/main/java/org/apache/gravitino/catalog/CatalogNormalizeDispatcher.java
@@ -148,6 +148,19 @@ public class CatalogNormalizeDispatcher implements
CatalogDispatcher {
dispatcher.testConnection(ident);
}
+ @Override
+ public void testConnection(NameIdentifier ident, CatalogChange... changes)
throws Exception {
+ validateCatalogName(ident.name());
+ Arrays.stream(changes)
+ .forEach(
+ change -> {
+ if (change instanceof CatalogChange.RenameCatalog) {
+ validateCatalogName(((CatalogChange.RenameCatalog)
change).getNewName());
+ }
+ });
+ dispatcher.testConnection(ident, changes);
+ }
+
@Override
public void enableCatalog(NameIdentifier ident) throws
NoSuchCatalogException {
dispatcher.enableCatalog(ident);
diff --git
a/core/src/main/java/org/apache/gravitino/catalog/SupportsCatalogs.java
b/core/src/main/java/org/apache/gravitino/catalog/SupportsCatalogs.java
index 243a1dca20..bda44ae2ba 100644
--- a/core/src/main/java/org/apache/gravitino/catalog/SupportsCatalogs.java
+++ b/core/src/main/java/org/apache/gravitino/catalog/SupportsCatalogs.java
@@ -226,6 +226,16 @@ public interface SupportsCatalogs {
*/
void testConnection(NameIdentifier ident) throws Exception;
+ /**
+ * Test the connection of an existing catalog with proposed changes without
persisting them.
+ *
+ * @param ident The identifier of the existing catalog.
+ * @param changes The proposed changes to apply temporarily.
+ * @throws NoSuchCatalogException If the catalog does not exist.
+ * @throws Exception If the connection test fails.
+ */
+ void testConnection(NameIdentifier ident, CatalogChange... changes) throws
Exception;
+
/**
* Enable a catalog. If the catalog is already enabled, this method does
nothing.
*
diff --git
a/core/src/main/java/org/apache/gravitino/hook/CatalogHookDispatcher.java
b/core/src/main/java/org/apache/gravitino/hook/CatalogHookDispatcher.java
index cc520f3d91..5b518e56e7 100644
--- a/core/src/main/java/org/apache/gravitino/hook/CatalogHookDispatcher.java
+++ b/core/src/main/java/org/apache/gravitino/hook/CatalogHookDispatcher.java
@@ -192,6 +192,11 @@ public class CatalogHookDispatcher implements
CatalogDispatcher {
dispatcher.testConnection(ident);
}
+ @Override
+ public void testConnection(NameIdentifier ident, CatalogChange... changes)
throws Exception {
+ dispatcher.testConnection(ident, changes);
+ }
+
@Override
public void enableCatalog(NameIdentifier ident)
throws NoSuchCatalogException, CatalogNotInUseException {
diff --git
a/core/src/main/java/org/apache/gravitino/listener/CatalogEventDispatcher.java
b/core/src/main/java/org/apache/gravitino/listener/CatalogEventDispatcher.java
index eec1732b50..e69f1d5c0c 100644
---
a/core/src/main/java/org/apache/gravitino/listener/CatalogEventDispatcher.java
+++
b/core/src/main/java/org/apache/gravitino/listener/CatalogEventDispatcher.java
@@ -228,6 +228,12 @@ public class CatalogEventDispatcher implements
CatalogDispatcher {
dispatcher.testConnection(ident);
}
+ @Override
+ public void testConnection(NameIdentifier ident, CatalogChange... changes)
throws Exception {
+ // TODO(#12566): Support event dispatching for testConnection
+ dispatcher.testConnection(ident, changes);
+ }
+
@Override
public void enableCatalog(NameIdentifier ident)
throws NoSuchCatalogException, CatalogNotInUseException {
diff --git
a/core/src/main/java/org/apache/gravitino/secret/SecretAlterChanges.java
b/core/src/main/java/org/apache/gravitino/secret/SecretAlterChanges.java
index 1615847a0a..7e9310c204 100644
--- a/core/src/main/java/org/apache/gravitino/secret/SecretAlterChanges.java
+++ b/core/src/main/java/org/apache/gravitino/secret/SecretAlterChanges.java
@@ -18,11 +18,13 @@
*/
package org.apache.gravitino.secret;
+import com.google.common.base.Preconditions;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import javax.annotation.Nullable;
+import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.tuple.Pair;
import org.apache.gravitino.CatalogChange;
import org.apache.gravitino.SchemaChange;
@@ -90,6 +92,58 @@ public final class SecretAlterChanges {
}
}
+ /**
+ * Prepares catalog changes for a connection test without writing or
deleting secret material.
+ *
+ * <p>Write-through bindings are validated but represented by their
plaintext only in the
+ * temporary catalog configuration. External references are converted to
reference URNs so the
+ * temporary catalog resolves them through the configured provider.
+ *
+ * @param secretManager secret manager
+ * @param entityId catalog entity id
+ * @param changes proposed catalog changes
+ * @return effective changes for a temporary catalog entity
+ */
+ public static CatalogChange[] prepareCatalogChangesForTest(
+ SecretManager secretManager, long entityId, CatalogChange... changes) {
+ Preconditions.checkArgument(secretManager != null, "secretManager must not
be null");
+ Preconditions.checkArgument(changes != null, "changes must not be null");
+
+ List<CatalogChange> out = new ArrayList<>(changes.length);
+ for (CatalogChange change : changes) {
+ if (change instanceof CatalogChange.SetSecretBinding) {
+ CatalogChange.SetSecretBinding c = (CatalogChange.SetSecretBinding)
change;
+ String property = c.getProperty();
+ SecretBinding binding = c.getBinding();
+ Preconditions.checkArgument(StringUtils.isNotBlank(property),
"property must not be blank");
+ Preconditions.checkArgument(binding != null, "binding must not be
null");
+
SecretPropertyUtils.validateAlterSecretBindingPlaintext(binding.plaintext());
+ secretManager.validateSecretBindingUrns("catalog", entityId,
Map.of(property, binding));
+ out.add(CatalogChange.setProperty(property, binding.plaintext()));
+ } else if (change instanceof CatalogChange.SetSecretReference) {
+ CatalogChange.SetSecretReference c =
(CatalogChange.SetSecretReference) change;
+ String property = c.getProperty();
+ SecretReference reference = c.getReference();
+ Preconditions.checkArgument(StringUtils.isNotBlank(property),
"property must not be blank");
+ Preconditions.checkArgument(reference != null, "reference must not be
null");
+ SecretUrn urn =
secretManager.buildSecretReferenceUrns(Map.of(property, reference)).get(0);
+ out.add(CatalogChange.setProperty(property, urn.toString()));
+ } else if (change instanceof CatalogChange.SetProperty) {
+ CatalogChange.SetProperty c = (CatalogChange.SetProperty) change;
+ SecretPropertyUtils.validateAlterSetPropertyValue(c.getProperty(),
c.getValue());
+ out.add(change);
+ } else if (change instanceof CatalogChange.RemoveProperty) {
+ CatalogChange.RemoveProperty c = (CatalogChange.RemoveProperty) change;
+ Preconditions.checkArgument(
+ StringUtils.isNotBlank(c.getProperty()), "property must not be
blank");
+ out.add(change);
+ } else {
+ out.add(change);
+ }
+ }
+ return out.toArray(new CatalogChange[0]);
+ }
+
/**
* Prepares schema alter changes that involve secrets.
*
diff --git a/core/src/main/java/org/apache/gravitino/secret/SecretManager.java
b/core/src/main/java/org/apache/gravitino/secret/SecretManager.java
index ec84c5e2ae..aa88fd4cf5 100644
--- a/core/src/main/java/org/apache/gravitino/secret/SecretManager.java
+++ b/core/src/main/java/org/apache/gravitino/secret/SecretManager.java
@@ -199,6 +199,32 @@ public class SecretManager implements Closeable {
return List.copyOf(urns);
}
+ /**
+ * Validates write-through secret bindings and their URNs without writing
secret material.
+ *
+ * @param entityType {@code catalog}, {@code schema}, or {@code fileset}
+ * @param entityId stable numeric entity id
+ * @param secretBindings property key → write-through binding (empty is
valid; must not be null)
+ */
+ public void validateSecretBindingUrns(
+ String entityType, long entityId, Map<String, SecretBinding>
secretBindings) {
+ Preconditions.checkArgument(StringUtils.isNotBlank(entityType),
"entityType must not be blank");
+ Preconditions.checkArgument(secretBindings != null, "secretBindings must
not be null");
+ if (secretBindings.isEmpty()) {
+ return;
+ }
+ validateSecretBindings(secretBindings);
+
+ for (Map.Entry<String, SecretBinding> entry : secretBindings.entrySet()) {
+ String key = entry.getKey();
+ String providerName = entry.getValue().provider();
+ // Ensure the provider is registered before building the URN.
+ registry.getProvider(providerName);
+ validateUrnEndsWithPropertyKey(
+ buildWriteThroughUrn(entityType, entityId, key, providerName), key);
+ }
+ }
+
/**
* Builds write-through URNs from {@code secretBindings} without writing
secret material.
*
@@ -214,27 +240,16 @@ public class SecretManager implements Closeable {
*/
public List<SecretUrn> buildSecretBindingUrns(
String entityType, long entityId, Map<String, SecretBinding>
secretBindings) {
- Preconditions.checkArgument(StringUtils.isNotBlank(entityType),
"entityType must not be blank");
- Preconditions.checkArgument(secretBindings != null, "secretBindings must
not be null");
+ validateSecretBindingUrns(entityType, entityId, secretBindings);
if (secretBindings.isEmpty()) {
return List.of();
}
- validateSecretBindings(secretBindings);
List<SecretUrn> urns = new ArrayList<>(secretBindings.size());
for (Map.Entry<String, SecretBinding> entry : secretBindings.entrySet()) {
String key = entry.getKey();
String providerName = entry.getValue().provider();
- // Ensure the provider is registered before building the URN.
- registry.getProvider(providerName);
- Map<String, String> attributes =
- ImmutableMap.of(
- ATTR_ENTITY_TYPE, entityType,
- ATTR_ENTITY_ID, String.valueOf(entityId),
- ATTR_PROPERTY_KEY, key);
- SecretUrn urn = SecretUrn.buildWriteThrough(providerName, attributes);
- validateUrnEndsWithPropertyKey(urn, key);
- urns.add(urn);
+ urns.add(buildWriteThroughUrn(entityType, entityId, key, providerName));
}
return List.copyOf(urns);
}
@@ -541,6 +556,16 @@ public class SecretManager implements Closeable {
return List.copyOf(secretMaterials);
}
+ private static SecretUrn buildWriteThroughUrn(
+ String entityType, long entityId, String propertyKey, String
providerName) {
+ Map<String, String> attributes =
+ ImmutableMap.of(
+ ATTR_ENTITY_TYPE, entityType,
+ ATTR_ENTITY_ID, String.valueOf(entityId),
+ ATTR_PROPERTY_KEY, propertyKey);
+ return SecretUrn.buildWriteThrough(providerName, attributes);
+ }
+
private static void validateUrnEndsWithPropertyKey(SecretUrn urn, String
propertyKey) {
Preconditions.checkArgument(
urn.toString().endsWith(propertyKey),
diff --git
a/core/src/test/java/org/apache/gravitino/catalog/TestCatalogManager.java
b/core/src/test/java/org/apache/gravitino/catalog/TestCatalogManager.java
index 1bdd7b6d93..7697431211 100644
--- a/core/src/test/java/org/apache/gravitino/catalog/TestCatalogManager.java
+++ b/core/src/test/java/org/apache/gravitino/catalog/TestCatalogManager.java
@@ -62,6 +62,7 @@ import org.apache.gravitino.NameIdentifier;
import org.apache.gravitino.Namespace;
import org.apache.gravitino.Schema;
import org.apache.gravitino.connector.BaseCatalog;
+import org.apache.gravitino.connector.CatalogOperations;
import org.apache.gravitino.connector.HiddenPropertyMaskUtils;
import org.apache.gravitino.connector.TestCatalogOperations;
import org.apache.gravitino.connector.capability.Capability;
@@ -84,7 +85,9 @@ import org.apache.gravitino.secret.SecretBinding;
import org.apache.gravitino.secret.SecretConstants;
import org.apache.gravitino.secret.SecretManager;
import org.apache.gravitino.secret.SecretPropertyUtils;
+import org.apache.gravitino.secret.SecretProvider;
import org.apache.gravitino.secret.SecretProviderRegistry;
+import org.apache.gravitino.secret.SecretReference;
import org.apache.gravitino.secret.SecretUrn;
import org.apache.gravitino.secret.memory.InMemorySecretsProvider;
import org.apache.gravitino.storage.IdGenerator;
@@ -96,6 +99,7 @@ import
org.apache.gravitino.storage.relational.SupportsEntityChangeLog;
import org.apache.gravitino.storage.relational.po.cache.EntityChangeRecord;
import org.apache.gravitino.storage.relational.po.cache.OperateType;
import org.apache.gravitino.utils.PrincipalUtils;
+import org.apache.gravitino.utils.ThrowableFunction;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assertions;
@@ -107,6 +111,46 @@ import org.mockito.stubbing.Answer;
public class TestCatalogManager {
+ /** Test-only external-reference provider. */
+ public static class TestReferenceSecretsProvider implements SecretProvider {
+
+ private String providerName;
+
+ @Override
+ public void initialize(String name, Map<String, String> providerConfig) {
+ providerName = name;
+ }
+
+ @Override
+ public String type() {
+ return "test-reference";
+ }
+
+ @Override
+ public SecretUrn writeSecret(String plaintext, Map<String, String>
attributes) {
+ throw new UnsupportedOperationException("write-through is not
supported");
+ }
+
+ @Override
+ public String readSecret(SecretUrn urn) {
+ return "resolved-" + urn.identifierSegments().get(0);
+ }
+
+ @Override
+ public void deleteSecret(SecretUrn urn) {}
+
+ @Override
+ public SecretUrn buildReferenceUrn(String propertyKey, Map<String, String>
attributes) {
+ return SecretUrn.parse(
+ String.format(
+ "%s%s:%s:%s",
+ SecretConstants.URN_PREFIX, providerName,
attributes.get("path"), propertyKey));
+ }
+
+ @Override
+ public void close() {}
+ }
+
private static CatalogManager catalogManager;
private static EntityStore entityStore;
@@ -1503,6 +1547,85 @@ public class TestCatalogManager {
}
}
+ @Test
+ void testExistingCatalogConnectionWithProposedChanges() throws Exception {
+ NameIdentifier ident = NameIdentifier.of("metalake",
"connection_changes_test");
+ Map<String, String> properties =
+ ImmutableMap.<String, String>builder()
+ .put("provider", "test")
+ .put(PROPERTY_KEY1, "value1")
+ .put(PROPERTY_KEY2, "value2")
+ .put(PROPERTY_KEY5_PREFIX + "1", "value3")
+ .put("removable", "stored")
+ .build();
+ catalogManager.createCatalog(
+ ident, Catalog.Type.RELATIONAL, provider, "stored comment",
properties);
+ CatalogEntity storedBefore = entityStore.get(ident, EntityType.CATALOG,
CatalogEntity.class);
+ CatalogManager.CatalogWrapper cachedBefore =
+ catalogManager.getCatalogCache().getIfPresent(ident);
+
+ CatalogManager.CatalogWrapper temporaryWrapper =
+ Mockito.mock(CatalogManager.CatalogWrapper.class);
+ CatalogOperations temporaryOperations =
Mockito.mock(CatalogOperations.class);
+ AtomicReference<CatalogEntity> effectiveEntity = new AtomicReference<>();
+ Mockito.doAnswer(
+ invocation -> {
+ effectiveEntity.set(invocation.getArgument(0));
+ return temporaryWrapper;
+ })
+ .when(catalogManager)
+ .createCatalogWrapper(any(CatalogEntity.class), eq(null));
+ Mockito.doAnswer(
+ invocation -> {
+ ThrowableFunction<CatalogOperations, Object> operation =
invocation.getArgument(0);
+ return operation.apply(temporaryOperations);
+ })
+ .when(temporaryWrapper)
+ .doWithCatalogOps(any());
+
+ NameIdentifier renamedIdent = NameIdentifier.of("metalake",
"connection_changes_renamed");
+ try {
+ catalogManager.testConnection(
+ ident,
+ CatalogChange.rename(renamedIdent.name()),
+ CatalogChange.updateComment("temporary comment"),
+ CatalogChange.setProperty(PROPERTY_KEY2, "temporary value"),
+ CatalogChange.removeProperty("removable"));
+
+ CatalogEntity effective = effectiveEntity.get();
+ Assertions.assertNotNull(effective);
+ Assertions.assertEquals(renamedIdent.name(), effective.name());
+ Assertions.assertEquals("temporary comment", effective.getComment());
+ Assertions.assertEquals("temporary value",
effective.getProperties().get(PROPERTY_KEY2));
+
Assertions.assertFalse(effective.getProperties().containsKey("removable"));
+ Mockito.verify(temporaryOperations).testConnection(renamedIdent);
+ Mockito.verify(temporaryWrapper).close();
+
+ CatalogEntity storedAfter = entityStore.get(ident, EntityType.CATALOG,
CatalogEntity.class);
+ Assertions.assertEquals(storedBefore.name(), storedAfter.name());
+ Assertions.assertEquals(storedBefore.getComment(),
storedAfter.getComment());
+ Assertions.assertEquals(storedBefore.getProperties(),
storedAfter.getProperties());
+ Assertions.assertFalse(entityStore.exists(renamedIdent,
EntityType.CATALOG));
+ Assertions.assertSame(cachedBefore,
catalogManager.getCatalogCache().getIfPresent(ident));
+
+ Mockito.doThrow(new IOException("probe failed"))
+ .when(temporaryOperations)
+ .testConnection(any(NameIdentifier.class));
+ RuntimeException failure =
+ Assertions.assertThrows(
+ RuntimeException.class,
+ () ->
+ catalogManager.testConnection(
+ ident, CatalogChange.setProperty(PROPERTY_KEY2, "another
value")));
+ Assertions.assertInstanceOf(IOException.class, failure.getCause());
+ Mockito.verify(temporaryWrapper, Mockito.times(2)).close();
+ } finally {
+ Mockito.doCallRealMethod()
+ .when(catalogManager)
+ .createCatalogWrapper(any(CatalogEntity.class), eq(null));
+ }
+ }
+
@Test
public void testCatalogCacheRemoveListener() throws IOException {
NameIdentifier ident = NameIdentifier.of(metalake, "catalog");
@@ -1600,6 +1723,77 @@ public class TestCatalogManager {
}
}
+ @Test
+ void testConnectionChangesDoNotMutateSecrets() throws Exception {
+ try (SecretManager secrets = memorySecretManager();
+ CatalogManager manager =
+ Mockito.spy(
+ new CatalogManager(config, entityStore, new
RandomIdGenerator(), secrets))) {
+ NameIdentifier ident = NameIdentifier.of("metalake",
"secret_connection_test");
+ manager.createCatalog(
+ ident,
+ Catalog.Type.RELATIONAL,
+ provider,
+ "comment",
+ catalogProps(),
+ Map.of(PROPERTY_KEY4, new SecretBinding("memory", "stored-secret")),
+ Map.of());
+ CatalogEntity stored = entityStore.get(ident, EntityType.CATALOG,
CatalogEntity.class);
+ String storedUrn = stored.getProperties().get(PROPERTY_KEY4);
+ Assertions.assertEquals("stored-secret",
secrets.readSecret(SecretUrn.parse(storedUrn)));
+ SecretUrn proposedUrn = writeThroughUrn("catalog", stored.id(),
PROPERTY_KEY2);
+
+ CatalogManager.CatalogWrapper temporaryWrapper =
+ Mockito.mock(CatalogManager.CatalogWrapper.class);
+ AtomicReference<CatalogEntity> effectiveEntity = new AtomicReference<>();
+ Mockito.doAnswer(
+ invocation -> {
+ effectiveEntity.set(invocation.getArgument(0));
+ return temporaryWrapper;
+ })
+ .when(manager)
+ .createCatalogWrapper(any(CatalogEntity.class), eq(null));
+ Mockito.doReturn(null).when(temporaryWrapper).doWithCatalogOps(any());
+
+ manager.testConnection(
+ ident,
+ CatalogChange.setSecretBinding(
+ PROPERTY_KEY4, new SecretBinding("memory", "temporary-secret")),
+ CatalogChange.setSecretBinding(
+ PROPERTY_KEY2, new SecretBinding("memory",
"temporary-new-secret")));
+ Assertions.assertEquals(
+ "temporary-secret",
effectiveEntity.get().getProperties().get(PROPERTY_KEY4));
+ Assertions.assertEquals(
+ "temporary-new-secret",
effectiveEntity.get().getProperties().get(PROPERTY_KEY2));
+ Assertions.assertEquals("stored-secret",
secrets.readSecret(SecretUrn.parse(storedUrn)));
+ Assertions.assertThrows(
+ IllegalArgumentException.class, () ->
secrets.readSecret(proposedUrn));
+
+ manager.testConnection(ident,
CatalogChange.removeProperty(PROPERTY_KEY4));
+
Assertions.assertFalse(effectiveEntity.get().getProperties().containsKey(PROPERTY_KEY4));
+ Assertions.assertEquals("stored-secret",
secrets.readSecret(SecretUrn.parse(storedUrn)));
+
+ manager.testConnection(
+ ident,
+ CatalogChange.setSecretReference(
+ PROPERTY_KEY4, new SecretReference("reference", Map.of("path",
"external-secret"))));
+ String referenceUrn =
effectiveEntity.get().getProperties().get(PROPERTY_KEY4);
+
Assertions.assertTrue(SecretPropertyUtils.isSecretProperty(PROPERTY_KEY4,
referenceUrn));
+ Assertions.assertEquals(
+ "resolved-external-secret",
+
secrets.toPlaintextProperties(effectiveEntity.get().getProperties()).get(PROPERTY_KEY4));
+ Assertions.assertEquals("stored-secret",
secrets.readSecret(SecretUrn.parse(storedUrn)));
+
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> manager.testConnection(ident,
CatalogChange.setProperty(PROPERTY_KEY4, storedUrn)));
+ CatalogEntity storedAfter = entityStore.get(ident, EntityType.CATALOG,
CatalogEntity.class);
+ Assertions.assertEquals(stored.getProperties(),
storedAfter.getProperties());
+ Assertions.assertEquals("stored-secret",
secrets.readSecret(SecretUrn.parse(storedUrn)));
+ Mockito.verify(temporaryWrapper, Mockito.times(3)).close();
+ }
+ }
+
@Test
void testSecretRollback() throws Exception {
try (SecretManager secrets = memorySecretManager()) {
@@ -1674,12 +1868,17 @@ public class TestCatalogManager {
private static SecretManager memorySecretManager() {
Config c = new Config(false) {};
Properties p = new Properties();
- p.setProperty(SecretProviderRegistry.GRAVITINO_SECRET_PROVIDERS, "memory");
+ p.setProperty(SecretProviderRegistry.GRAVITINO_SECRET_PROVIDERS,
"memory,reference");
p.setProperty(
SecretProviderRegistry.GRAVITINO_SECRET_PROVIDER_PREFIX
+ "memory."
+ SecretProviderRegistry.CLASS_NAME,
InMemorySecretsProvider.class.getName());
+ p.setProperty(
+ SecretProviderRegistry.GRAVITINO_SECRET_PROVIDER_PREFIX
+ + "reference."
+ + SecretProviderRegistry.CLASS_NAME,
+ TestReferenceSecretsProvider.class.getName());
c.loadFromProperties(p);
return new SecretManager(c);
}
diff --git
a/core/src/test/java/org/apache/gravitino/catalog/TestCatalogNormalizeDispatcher.java
b/core/src/test/java/org/apache/gravitino/catalog/TestCatalogNormalizeDispatcher.java
index a299beb1b7..70a6b574c0 100644
---
a/core/src/test/java/org/apache/gravitino/catalog/TestCatalogNormalizeDispatcher.java
+++
b/core/src/test/java/org/apache/gravitino/catalog/TestCatalogNormalizeDispatcher.java
@@ -29,6 +29,7 @@ import java.time.Instant;
import java.util.Map;
import org.apache.commons.lang3.reflect.FieldUtils;
import org.apache.gravitino.Catalog;
+import org.apache.gravitino.CatalogChange;
import org.apache.gravitino.Config;
import org.apache.gravitino.Configs;
import org.apache.gravitino.EntityStore;
@@ -175,4 +176,17 @@ public class TestCatalogNormalizeDispatcher {
"The catalog name '" + illegalName + "' is illegal.",
exception.getMessage());
}
}
+
+ @Test
+ void testConnectionChangesValidateRenamedCatalogName() {
+ NameIdentifier catalogIdent = NameIdentifier.of(metalake, "catalog");
+ Mockito.clearInvocations(catalogManager);
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ catalogNormalizeDispatcher.testConnection(
+ catalogIdent, CatalogChange.rename("invalid/name")));
+ Mockito.verify(catalogManager, Mockito.never())
+ .testConnection(Mockito.eq(catalogIdent),
Mockito.any(CatalogChange[].class));
+ }
}
diff --git a/docs/open-api/catalogs.yaml b/docs/open-api/catalogs.yaml
index 1340ecea74..973be684dd 100644
--- a/docs/open-api/catalogs.yaml
+++ b/docs/open-api/catalogs.yaml
@@ -159,13 +159,21 @@ paths:
summary: Test an existing catalog connection
description: >-
Runs the provider's smallest meaningful, read-only catalog-level
operation using the
- catalog's stored configuration and effective credentials. This
fail-fast preflight
- surfaces configuration and connectivity failures before regular
catalog operations, but
- does not guarantee that every object-level or mutating operation will
succeed. Fileset
- catalogs test all catalog-level `location` and `location-*` targets.
Model and Generic
- catalogs do not support connection testing. Expected test failures are
returned as
- application error codes in an HTTP 200 response.
+ catalog's stored configuration and effective credentials. Optional
proposed catalog
+ changes are applied to a temporary effective configuration for this
probe and are not
+ persisted. This fail-fast preflight surfaces configuration and
connectivity failures before
+ regular catalog operations, but does not guarantee that every
object-level or mutating
+ operation will succeed. Fileset catalogs test all catalog-level
`location` and `location-*`
+ targets. Model and Generic catalogs do not support connection testing.
Expected test
+ failures are returned as application error codes in an HTTP 200
response.
operationId: testExistingCatalogConnection
+ requestBody:
+ required: false
+ description: Optional catalog changes to apply only to this connection
test
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/CatalogUpdatesRequest"
responses:
"200":
description: Connection test completed, including expected test
failures
diff --git
a/server/src/main/java/org/apache/gravitino/server/web/rest/CatalogOperations.java
b/server/src/main/java/org/apache/gravitino/server/web/rest/CatalogOperations.java
index 9aab629818..60f17c0239 100644
---
a/server/src/main/java/org/apache/gravitino/server/web/rest/CatalogOperations.java
+++
b/server/src/main/java/org/apache/gravitino/server/web/rest/CatalogOperations.java
@@ -237,14 +237,24 @@ public class CatalogOperations {
@PathParam("metalake") @AuthorizationMetadata(type =
Entity.EntityType.METALAKE)
String metalake,
@PathParam("catalog") @AuthorizationMetadata(type =
Entity.EntityType.CATALOG)
- String catalogName) {
+ String catalogName,
+ CatalogUpdatesRequest request) {
LOG.info("Received test connection request for existing catalog: {}.{}",
metalake, catalogName);
try {
return Utils.doAs(
httpRequest,
() -> {
NameIdentifier ident = NameIdentifierUtil.ofCatalog(metalake,
catalogName);
- catalogDispatcher.testConnection(ident);
+ if (request == null) {
+ catalogDispatcher.testConnection(ident);
+ } else {
+ request.validate();
+ CatalogChange[] changes =
+ request.getUpdates().stream()
+ .map(CatalogUpdateRequest::catalogChange)
+ .toArray(CatalogChange[]::new);
+ catalogDispatcher.testConnection(ident, changes);
+ }
LOG.info(
"Successfully tested connection for existing catalog: {}.{}",
metalake,
diff --git
a/server/src/test/java/org/apache/gravitino/server/web/rest/TestCatalogOperations.java
b/server/src/test/java/org/apache/gravitino/server/web/rest/TestCatalogOperations.java
index 363a03c322..b3f02a1c0c 100644
---
a/server/src/test/java/org/apache/gravitino/server/web/rest/TestCatalogOperations.java
+++
b/server/src/test/java/org/apache/gravitino/server/web/rest/TestCatalogOperations.java
@@ -47,6 +47,7 @@ import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import org.apache.commons.lang3.reflect.FieldUtils;
import org.apache.gravitino.Catalog;
+import org.apache.gravitino.CatalogChange;
import org.apache.gravitino.Config;
import org.apache.gravitino.GravitinoEnv;
import org.apache.gravitino.NameIdentifier;
@@ -81,6 +82,7 @@ import org.glassfish.jersey.test.TestProperties;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
import org.mockito.Mockito;
public class TestCatalogOperations extends BaseOperationsTest {
@@ -400,6 +402,46 @@ public class TestCatalogOperations extends
BaseOperationsTest {
Assertions.assertEquals(Response.Status.OK.getStatusCode(),
response.getStatus());
Assertions.assertEquals(0,
response.readEntity(BaseResponse.class).getCode());
+ CatalogUpdatesRequest proposedChanges =
+ new CatalogUpdatesRequest(
+ ImmutableList.of(
+ new CatalogUpdateRequest.RenameCatalogRequest("catalog2"),
+ new CatalogUpdateRequest.UpdateCatalogCommentRequest("new
comment"),
+ new CatalogUpdateRequest.SetCatalogPropertyRequest("key", "new
value"),
+ new
CatalogUpdateRequest.RemoveCatalogPropertyRequest("old-key")));
+ doNothing().when(manager).testConnection(any(NameIdentifier.class),
any(CatalogChange[].class));
+ Response changedResponse =
+ target("/metalakes/metalake1/catalogs/catalog1/testConnection")
+ .request(MediaType.APPLICATION_JSON_TYPE)
+ .accept("application/vnd.gravitino.v1+json")
+ .post(Entity.entity(proposedChanges,
MediaType.APPLICATION_JSON_TYPE));
+ Assertions.assertEquals(Response.Status.OK.getStatusCode(),
changedResponse.getStatus());
+ Assertions.assertEquals(0,
changedResponse.readEntity(BaseResponse.class).getCode());
+ ArgumentCaptor<CatalogChange[]> changesCaptor =
ArgumentCaptor.forClass(CatalogChange[].class);
+ Mockito.verify(manager).testConnection(any(NameIdentifier.class),
changesCaptor.capture());
+ Assertions.assertArrayEquals(
+ new CatalogChange[] {
+ CatalogChange.rename("catalog2"),
+ CatalogChange.updateComment("new comment"),
+ CatalogChange.setProperty("key", "new value"),
+ CatalogChange.removeProperty("old-key")
+ },
+ changesCaptor.getValue());
+
+ doThrow(new IllegalArgumentException("invalid proposed change"))
+ .when(manager)
+ .testConnection(any(NameIdentifier.class), any(CatalogChange[].class));
+ Response invalidChangesResponse =
+ target("/metalakes/metalake1/catalogs/catalog1/testConnection")
+ .request(MediaType.APPLICATION_JSON_TYPE)
+ .accept("application/vnd.gravitino.v1+json")
+ .post(Entity.entity(proposedChanges,
MediaType.APPLICATION_JSON_TYPE));
+ Assertions.assertEquals(Response.Status.OK.getStatusCode(),
invalidChangesResponse.getStatus());
+ ErrorResponse invalidChanges =
invalidChangesResponse.readEntity(ErrorResponse.class);
+ Assertions.assertEquals(ErrorConstants.ILLEGAL_ARGUMENTS_CODE,
invalidChanges.getCode());
+ Assertions.assertEquals("invalid proposed change",
invalidChanges.getMessage());
+ Assertions.assertNull(invalidChanges.getStack());
+
doThrow(new ConnectionFailedException("sanitized failure"))
.when(manager)
.testConnection(any(NameIdentifier.class));