This is an automated email from the ASF dual-hosted git repository. majin1102 pushed a commit to branch emr/hms-namespace-explorer in repository https://gitbox.apache.org/repos/asf/amoro.git
commit 4090a2cab0980102c641a304bd1fe214bc642d8d Author: majin.nathan <[email protected]> AuthorDate: Thu Aug 6 20:57:09 2026 +0800 feat: add HMS namespace catalog explorer --- .../apache/amoro/server/AmoroManagementConf.java | 6 + .../amoro/server/catalog/CatalogManager.java | 26 ++ .../server/catalog/DefaultCatalogManager.java | 62 +++++ .../amoro/server/dashboard/DashboardServer.java | 8 + .../dashboard/controller/TableController.java | 33 ++- .../amoro/server/las/LasCatalogSynchronizer.java | 266 +++++++++++++++++++++ .../amoro/server/las/LasIntegrationConfig.java | 12 + .../amoro/server/las/LasIntegrationContext.java | 17 ++ .../apache/amoro/server/las/LasRestExtension.java | 11 + .../persistence/SqlSessionFactoryProvider.java | 2 + .../mapper/NamespaceAllowlistMapper.java | 43 ++++ .../resources/authorization/privilege_mapping.yaml | 15 ++ .../src/main/resources/derby/ams-derby-init.sql | 4 + amoro-ams/src/main/resources/derby/upgrade.sql | 4 + .../src/main/resources/mysql/ams-mysql-init.sql | 5 + amoro-ams/src/main/resources/mysql/upgrade.sql | 4 + amoro-ams/src/main/resources/openapi/openapi.yaml | 60 ++++- .../main/resources/postgres/ams-postgres-init.sql | 5 + amoro-ams/src/main/resources/postgres/upgrade.sql | 4 + .../TestDefaultCatalogManagerNamespaces.java | 130 ++++++++++ .../controller/TestTableControllerNamespaces.java | 113 +++++++++ .../server/las/TestLasCatalogSynchronizer.java | 245 +++++++++++++++++++ .../amoro/properties/CatalogMetaProperties.java | 3 + amoro-web/src/components/Sidebar.vue | 10 +- amoro-web/src/services/table.service.ts | 10 +- amoro-web/src/views/tables/components/Details.vue | 28 ++- .../src/views/tables/components/TableExplorer.vue | 207 ++++++++++++++-- dist/src/main/amoro-bin/conf/config.yaml | 7 + docs/configuration/ams-config.md | 2 +- 29 files changed, 1311 insertions(+), 31 deletions(-) diff --git a/amoro-ams/src/main/java/org/apache/amoro/server/AmoroManagementConf.java b/amoro-ams/src/main/java/org/apache/amoro/server/AmoroManagementConf.java index f38f6f5ac..82efcb281 100644 --- a/amoro-ams/src/main/java/org/apache/amoro/server/AmoroManagementConf.java +++ b/amoro-ams/src/main/java/org/apache/amoro/server/AmoroManagementConf.java @@ -113,6 +113,12 @@ public class AmoroManagementConf { .defaultValue(Duration.ofSeconds(60)) .withDescription("TTL for catalog metadata."); + public static final ConfigOption<Boolean> CATALOG_NAMESPACE_ENABLED = + ConfigOptions.key("catalog.namespace-enabled") + .booleanType() + .defaultValue(false) + .withDescription("Whether catalogs are grouped by an optional namespace."); + public static final ConfigOption<Integer> TABLE_MANIFEST_IO_THREAD_COUNT = ConfigOptions.key("table-manifest-io.thread-count") .intType() diff --git a/amoro-ams/src/main/java/org/apache/amoro/server/catalog/CatalogManager.java b/amoro-ams/src/main/java/org/apache/amoro/server/catalog/CatalogManager.java index 7f331dcbf..5a1c05c19 100644 --- a/amoro-ams/src/main/java/org/apache/amoro/server/catalog/CatalogManager.java +++ b/amoro-ams/src/main/java/org/apache/amoro/server/catalog/CatalogManager.java @@ -22,6 +22,7 @@ import org.apache.amoro.AmoroTable; import org.apache.amoro.api.CatalogMeta; import org.apache.amoro.table.TableIdentifier; +import java.util.Collections; import java.util.List; /** The CatalogManager interface defines the operations that can be performed on catalogs. */ @@ -33,6 +34,31 @@ public interface CatalogManager { */ List<CatalogMeta> listCatalogMetas(); + /** Returns whether this catalog manager exposes a namespace level. */ + default boolean supportNamespace() { + return false; + } + + /** Lists namespaces, or the community-compatible default namespace when unsupported. */ + default List<String> listNamespaces() { + return Collections.singletonList("default"); + } + + /** Lists catalogs in a namespace. Implementations without namespace support return all. */ + default List<CatalogMeta> listCatalogMetas(String namespace) { + return listCatalogMetas(); + } + + /** Adds a namespace to the allowlist. */ + default void addNamespace(String namespace) { + throw new UnsupportedOperationException("Catalog namespaces are not supported"); + } + + /** Removes a namespace from the allowlist. */ + default void removeNamespace(String namespace) { + throw new UnsupportedOperationException("Catalog namespaces are not supported"); + } + /** * Gets the catalog metadata for the given catalog name. * diff --git a/amoro-ams/src/main/java/org/apache/amoro/server/catalog/DefaultCatalogManager.java b/amoro-ams/src/main/java/org/apache/amoro/server/catalog/DefaultCatalogManager.java index 6535283da..c815afc8a 100644 --- a/amoro-ams/src/main/java/org/apache/amoro/server/catalog/DefaultCatalogManager.java +++ b/amoro-ams/src/main/java/org/apache/amoro/server/catalog/DefaultCatalogManager.java @@ -29,12 +29,15 @@ import org.apache.amoro.config.Configurations; import org.apache.amoro.exception.AlreadyExistsException; import org.apache.amoro.exception.IllegalMetadataException; import org.apache.amoro.exception.ObjectNotExistsException; +import org.apache.amoro.exception.PersistenceException; import org.apache.amoro.properties.CatalogMetaProperties; import org.apache.amoro.server.AmoroManagementConf; import org.apache.amoro.server.dashboard.utils.AmsUtil; import org.apache.amoro.server.persistence.PersistentBase; import org.apache.amoro.server.persistence.mapper.CatalogMetaMapper; +import org.apache.amoro.server.persistence.mapper.NamespaceAllowlistMapper; import org.apache.amoro.shade.guava32.com.google.common.annotations.VisibleForTesting; +import org.apache.amoro.shade.guava32.com.google.common.base.Preconditions; import org.apache.amoro.shade.guava32.com.google.common.cache.CacheBuilder; import org.apache.amoro.shade.guava32.com.google.common.cache.CacheLoader; import org.apache.amoro.shade.guava32.com.google.common.cache.LoadingCache; @@ -46,6 +49,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.time.Duration; +import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Optional; @@ -104,6 +108,64 @@ public class DefaultCatalogManager extends PersistentBase implements CatalogMana .collect(Collectors.toList()); } + @Override + public boolean supportNamespace() { + return serverConfiguration.getBoolean(AmoroManagementConf.CATALOG_NAMESPACE_ENABLED); + } + + @Override + public List<String> listNamespaces() { + if (!supportNamespace()) { + return Collections.singletonList("default"); + } + return getAs(NamespaceAllowlistMapper.class, NamespaceAllowlistMapper::listNamespaces); + } + + @Override + public List<CatalogMeta> listCatalogMetas(String namespace) { + if (namespace == null || namespace.trim().isEmpty()) { + return listCatalogMetas(); + } + if (!supportNamespace()) { + return "default".equals(namespace) ? listCatalogMetas() : Collections.emptyList(); + } + if (!listNamespaces().contains(namespace)) { + return Collections.emptyList(); + } + return listCatalogMetas().stream() + .filter( + catalog -> + catalog.getCatalogProperties() != null + && namespace.equals( + catalog.getCatalogProperties().get(CatalogMetaProperties.NAMESPACE))) + .collect(Collectors.toList()); + } + + @Override + public void addNamespace(String namespace) { + Preconditions.checkState(supportNamespace(), "Catalog namespaces are not enabled"); + try { + doAs( + NamespaceAllowlistMapper.class, + mapper -> { + if (mapper.getNamespace(namespace) == null) { + mapper.insertNamespace(namespace); + } + }); + } catch (PersistenceException e) { + // Another AMS node may have inserted the same namespace after our existence check. + if (getAs(NamespaceAllowlistMapper.class, mapper -> mapper.getNamespace(namespace)) == null) { + throw e; + } + } + } + + @Override + public void removeNamespace(String namespace) { + Preconditions.checkState(supportNamespace(), "Catalog namespaces are not enabled"); + doAs(NamespaceAllowlistMapper.class, mapper -> mapper.deleteNamespace(namespace)); + } + @Override public CatalogMeta getCatalogMeta(String catalogName) { return getCatalogMetaOptional(catalogName) diff --git a/amoro-ams/src/main/java/org/apache/amoro/server/dashboard/DashboardServer.java b/amoro-ams/src/main/java/org/apache/amoro/server/dashboard/DashboardServer.java index f6e1ab29d..eeea24b5d 100644 --- a/amoro-ams/src/main/java/org/apache/amoro/server/dashboard/DashboardServer.java +++ b/amoro-ams/src/main/java/org/apache/amoro/server/dashboard/DashboardServer.java @@ -301,6 +301,14 @@ public class DashboardServer { }); get("/upgrade/properties", tableController::getUpgradeHiveTableProperties); + path( + "/namespaces", + () -> { + get("", tableController::getNamespaces); + put("/{namespace}", tableController::addNamespace); + delete("/{namespace}", tableController::removeNamespace); + }); + // catalog apis path( "/catalogs", diff --git a/amoro-ams/src/main/java/org/apache/amoro/server/dashboard/controller/TableController.java b/amoro-ams/src/main/java/org/apache/amoro/server/dashboard/controller/TableController.java index 81d29a425..7d460bb8c 100644 --- a/amoro-ams/src/main/java/org/apache/amoro/server/dashboard/controller/TableController.java +++ b/amoro-ams/src/main/java/org/apache/amoro/server/dashboard/controller/TableController.java @@ -626,10 +626,41 @@ public class TableController { * @param ctx - context for handling the request and response */ public void getCatalogs(Context ctx) { - List<CatalogMeta> catalogs = catalogManager.listCatalogMetas(); + String namespace = ctx.queryParam("namespace"); + List<CatalogMeta> catalogs = + StringUtils.isBlank(namespace) + ? catalogManager.listCatalogMetas() + : catalogManager.listCatalogMetas(namespace.trim()); ctx.json(OkResponse.of(catalogs)); } + /** Lists the namespace level used by the catalog explorer. */ + public void getNamespaces(Context ctx) { + ctx.json(OkResponse.of(catalogManager.listNamespaces())); + } + + /** Adds a namespace to the synchronization allowlist. */ + public void addNamespace(Context ctx) { + String namespace = validateNamespace(ctx.pathParam("namespace")); + catalogManager.addNamespace(namespace); + ctx.json(OkResponse.of(namespace)); + } + + /** Removes a namespace from the synchronization allowlist. */ + public void removeNamespace(Context ctx) { + String namespace = validateNamespace(ctx.pathParam("namespace")); + catalogManager.removeNamespace(namespace); + ctx.json(OkResponse.of(namespace)); + } + + private static String validateNamespace(String namespace) { + String normalized = StringUtils.trimToEmpty(namespace); + Preconditions.checkArgument(!normalized.isEmpty(), "namespace cannot be empty"); + Preconditions.checkArgument(normalized.length() <= 128, "namespace is too long"); + Preconditions.checkArgument(!normalized.contains("@"), "namespace cannot contain @"); + return normalized; + } + /** * get single page query token. * diff --git a/amoro-ams/src/main/java/org/apache/amoro/server/las/LasCatalogSynchronizer.java b/amoro-ams/src/main/java/org/apache/amoro/server/las/LasCatalogSynchronizer.java new file mode 100644 index 000000000..e52a1f5b4 --- /dev/null +++ b/amoro-ams/src/main/java/org/apache/amoro/server/las/LasCatalogSynchronizer.java @@ -0,0 +1,266 @@ +/* + * 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.amoro.server.las; + +import org.apache.amoro.TableFormat; +import org.apache.amoro.api.CatalogMeta; +import org.apache.amoro.properties.CatalogMetaProperties; +import org.apache.amoro.server.catalog.CatalogManager; +import org.apache.amoro.shade.guava32.com.google.common.util.concurrent.ThreadFactoryBuilder; +import org.apache.amoro.table.TableProperties; +import org.apache.hadoop.conf.Configuration; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.util.Base64; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.stream.Collectors; + +/** Periodically projects allowlisted HMS3 catalogs into AMS CatalogMeta records. */ +public final class LasCatalogSynchronizer implements AutoCloseable { + + private static final Logger LOG = LoggerFactory.getLogger(LasCatalogSynchronizer.class); + + static final String CATALOG_SOURCE = "catalog.source"; + static final String CATALOG_SOURCE_LAS_HMS = "las-hms-sync"; + + private static final String HIVE_METASTORE_URIS = "hive.metastore.uris"; + private static final String METASTORE_CATALOG_DEFAULT = "metastore.catalog.default"; + private static final int MAX_CATALOG_NAME_LENGTH = 64; + + private final LasIntegrationContext context; + private final LasHmsClient hmsClient; + private final CatalogManager catalogManager; + private final ScheduledExecutorService scheduler; + private final long intervalMillis; + private final AtomicBoolean started = new AtomicBoolean(false); + + public LasCatalogSynchronizer( + LasIntegrationContext context, LasHmsClient hmsClient, CatalogManager catalogManager) { + this( + context, + hmsClient, + catalogManager, + Executors.newSingleThreadScheduledExecutor( + new ThreadFactoryBuilder() + .setDaemon(true) + .setNameFormat("las-hms-catalog-sync-%d") + .build())); + } + + LasCatalogSynchronizer( + LasIntegrationContext context, + LasHmsClient hmsClient, + CatalogManager catalogManager, + ScheduledExecutorService scheduler) { + this.context = Objects.requireNonNull(context, "context"); + this.hmsClient = Objects.requireNonNull(hmsClient, "hmsClient"); + this.catalogManager = Objects.requireNonNull(catalogManager, "catalogManager"); + this.scheduler = Objects.requireNonNull(scheduler, "scheduler"); + this.intervalMillis = context.catalogSyncInterval().toMillis(); + } + + /** Starts synchronization after one full interval; no HMS call is made during AMS startup. */ + public void start() { + if (started.compareAndSet(false, true)) { + scheduler.scheduleWithFixedDelay( + this::syncSafely, intervalMillis, intervalMillis, TimeUnit.MILLISECONDS); + LOG.info("LAS HMS catalog synchronization scheduled every {} ms", intervalMillis); + } + } + + void syncOnce() throws Exception { + List<String> discoveredCatalogs = hmsClient.listCatalogs(); + if (discoveredCatalogs == null || discoveredCatalogs.stream().anyMatch(Objects::isNull)) { + throw new IllegalStateException("HMS getCatalogs returned an invalid snapshot"); + } + + Set<String> rawCatalogNames = new HashSet<>(discoveredCatalogs); + Set<String> allowlistedNamespaces = new HashSet<>(catalogManager.listNamespaces()); + Map<String, CatalogMeta> existingCatalogs = + catalogManager.listCatalogMetas().stream() + .collect(Collectors.toMap(CatalogMeta::getCatalogName, catalog -> catalog)); + + int created = 0; + int updated = 0; + int removed = 0; + for (String physicalCatalogName : rawCatalogNames) { + Optional<String> namespace = parseNamespace(physicalCatalogName); + if (!namespace.isPresent() || !allowlistedNamespaces.contains(namespace.get())) { + continue; + } + + CatalogMeta existing = existingCatalogs.get(physicalCatalogName); + if (existing == null) { + catalogManager.createCatalog(createCatalogMeta(physicalCatalogName, namespace.get())); + created++; + } else if (isManagedByThisSynchronizer(existing)) { + CatalogMeta refreshed = mergeCatalogMeta(existing, physicalCatalogName, namespace.get()); + if (!existing.equals(refreshed)) { + catalogManager.updateCatalog(refreshed); + updated++; + } + } else { + LOG.warn( + "Skip HMS catalog {} because an unmanaged catalog has the same name", + physicalCatalogName); + } + } + + for (CatalogMeta existing : existingCatalogs.values()) { + if (isManagedByThisSynchronizer(existing) + && !rawCatalogNames.contains(existing.getCatalogName())) { + catalogManager.dropCatalog(existing.getCatalogName()); + removed++; + } + } + + LOG.info( + "LAS HMS catalog synchronization completed: discovered={}, allowlistedNamespaces={}, created={}, updated={}, removed={}", + rawCatalogNames.size(), + allowlistedNamespaces.size(), + created, + updated, + removed); + } + + static Optional<String> parseNamespace(String catalogName) { + if (catalogName == null || catalogName.length() > MAX_CATALOG_NAME_LENGTH) { + return Optional.empty(); + } + int separator = catalogName.indexOf('@'); + if (separator <= 0 || separator == catalogName.length() - 1) { + return Optional.empty(); + } + return Optional.of(catalogName.substring(0, separator)); + } + + private CatalogMeta mergeCatalogMeta( + CatalogMeta existing, String physicalCatalogName, String namespace) { + CatalogMeta refreshed = createCatalogMeta(physicalCatalogName, namespace); + Map<String, String> properties = new HashMap<>(existing.getCatalogProperties()); + properties.putAll(refreshed.getCatalogProperties()); + refreshed.setCatalogProperties(properties); + return refreshed; + } + + private CatalogMeta createCatalogMeta(String physicalCatalogName, String namespace) { + CatalogMeta catalogMeta = new CatalogMeta(); + catalogMeta.setCatalogName(physicalCatalogName); + catalogMeta.setCatalogType(CatalogMetaProperties.CATALOG_TYPE_HIVE); + + Map<String, String> catalogProperties = new HashMap<>(); + catalogProperties.put( + CatalogMetaProperties.TABLE_FORMATS, + TableFormat.ICEBERG.name() + "," + TableFormat.PAIMON.name()); + catalogProperties.put(CatalogMetaProperties.NAMESPACE, namespace); + catalogProperties.put(CATALOG_SOURCE, CATALOG_SOURCE_LAS_HMS); + catalogProperties.put( + CatalogMetaProperties.TABLE_PROPERTIES_PREFIX + TableProperties.ENABLE_SELF_OPTIMIZING, + "false"); + catalogMeta.setCatalogProperties(catalogProperties); + + Map<String, String> storageConfigs = new HashMap<>(); + storageConfigs.put( + CatalogMetaProperties.STORAGE_CONFIGS_KEY_TYPE, + CatalogMetaProperties.STORAGE_CONFIGS_VALUE_TYPE_HADOOP); + storageConfigs.put( + CatalogMetaProperties.STORAGE_CONFIGS_KEY_HIVE_SITE, + encodeConfiguration(createHiveConfiguration(physicalCatalogName))); + storageConfigs.put( + CatalogMetaProperties.STORAGE_CONFIGS_KEY_CORE_SITE, + encodeConfiguration(createTosConfiguration(namespace))); + storageConfigs.put( + CatalogMetaProperties.STORAGE_CONFIGS_KEY_HDFS_SITE, + encodeConfiguration(new Configuration(false))); + catalogMeta.setStorageConfigs(storageConfigs); + catalogMeta.setAuthConfigs(Collections.emptyMap()); + return catalogMeta; + } + + private Configuration createHiveConfiguration(String physicalCatalogName) { + Configuration configuration = new Configuration(false); + configuration.set(HIVE_METASTORE_URIS, context.hmsUri().toString()); + configuration.set(METASTORE_CATALOG_DEFAULT, physicalCatalogName); + return configuration; + } + + private Configuration createTosConfiguration(String namespace) { + Configuration configuration = new Configuration(false); + configuration.set("fs.AbstractFileSystem.tos.impl", "io.proton.fs.ProtonFS"); + configuration.set("fs.tos.impl", "io.proton.fs.ProtonFileSystem"); + configuration.set("fs.tos.endpoint", context.tosEndpoint().toString()); + configuration.set("proton.cache.enable", "false"); + configuration.set( + "mapreduce.outputcommitter.factory.class", "io.proton.commit.CommitterFactory"); + configuration.set( + "fs.tos.credentials.provider", "io.proton.tos.iam.AssumeIamRoleCredentialProvider"); + configuration.set("fs.volc.openapi.host", context.iamEndpoint().getAuthority()); + configuration.set("fs.volc.openapi.region", context.region()); + configuration.set("fs.tos.http.maxConnections", "1024"); + configuration.set( + "fs.tos.credential.sts.iam-role-trn", + String.format("trn:iam::%s:role/%s", namespace, context.iamDataRoleName())); + configuration.set( + "fs.tos.credential.sts.token.time-to-live", + String.valueOf(context.iamAssumeRoleTtl().getSeconds())); + return configuration; + } + + private static String encodeConfiguration(Configuration configuration) { + try (ByteArrayOutputStream output = new ByteArrayOutputStream()) { + configuration.writeXml(output); + return Base64.getEncoder().encodeToString(output.toByteArray()); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + private static boolean isManagedByThisSynchronizer(CatalogMeta catalogMeta) { + return catalogMeta.getCatalogProperties() != null + && CATALOG_SOURCE_LAS_HMS.equals(catalogMeta.getCatalogProperties().get(CATALOG_SOURCE)); + } + + private void syncSafely() { + try { + syncOnce(); + } catch (Throwable t) { + LOG.error("LAS HMS catalog synchronization failed", t); + } + } + + @Override + public void close() { + scheduler.shutdownNow(); + } +} diff --git a/amoro-ams/src/main/java/org/apache/amoro/server/las/LasIntegrationConfig.java b/amoro-ams/src/main/java/org/apache/amoro/server/las/LasIntegrationConfig.java index 92852712d..65bd5cc6a 100644 --- a/amoro-ams/src/main/java/org/apache/amoro/server/las/LasIntegrationConfig.java +++ b/amoro-ams/src/main/java/org/apache/amoro/server/las/LasIntegrationConfig.java @@ -40,6 +40,12 @@ public final class LasIntegrationConfig { .noDefaultValue() .withDescription("HMS3 Thrift URI used as the metadata source of truth."); + public static final ConfigOption<Duration> CATALOG_SYNC_INTERVAL = + ConfigOptions.key(PREFIX + "catalog-sync-interval") + .durationType() + .defaultValue(Duration.ofMinutes(5)) + .withDescription("Interval for synchronizing allowlisted HMS catalogs into AMS."); + public static final ConfigOption<String> TOS_ENDPOINT = ConfigOptions.key(PREFIX + "tos-endpoint") .stringType() @@ -88,6 +94,12 @@ public final class LasIntegrationConfig { .defaultValue(1000) .withDescription("Maximum number of role credentials cached by AMS."); + public static final ConfigOption<String> IAM_DATA_ROLE_NAME = + ConfigOptions.key(PREFIX + "iam.data-role-name") + .stringType() + .defaultValue("ServiceRoleForLAS") + .withDescription("Tenant IAM role name used by Proton to access TOS data."); + public static final ConfigOption<String> EMR_SERVERLESS_ENDPOINT = ConfigOptions.key(PREFIX + "emr-serverless-endpoint") .stringType() diff --git a/amoro-ams/src/main/java/org/apache/amoro/server/las/LasIntegrationContext.java b/amoro-ams/src/main/java/org/apache/amoro/server/las/LasIntegrationContext.java index 94dcaf911..7c558747b 100644 --- a/amoro-ams/src/main/java/org/apache/amoro/server/las/LasIntegrationContext.java +++ b/amoro-ams/src/main/java/org/apache/amoro/server/las/LasIntegrationContext.java @@ -79,9 +79,11 @@ public final class LasIntegrationContext { requiredString(configurations, LasIntegrationConfig.IAM_BOOTSTRAP_ACCESS_KEY); requiredString(configurations, LasIntegrationConfig.IAM_BOOTSTRAP_SECRET_KEY); requiredString(configurations, LasIntegrationConfig.IAM_ROLE_SESSION_NAME); + requiredString(configurations, LasIntegrationConfig.IAM_DATA_ROLE_NAME); positiveDuration(configurations, LasIntegrationConfig.CONNECT_TIMEOUT); positiveDuration(configurations, LasIntegrationConfig.READ_TIMEOUT); positiveDuration(configurations, LasIntegrationConfig.IAM_ASSUME_ROLE_TTL); + positiveDuration(configurations, LasIntegrationConfig.CATALOG_SYNC_INTERVAL); if (configurations.getInteger(LasIntegrationConfig.IAM_CREDENTIAL_CACHE_SIZE) <= 0) { throw new IllegalArgumentException( LasIntegrationConfig.IAM_CREDENTIAL_CACHE_SIZE.key() + " must be greater than zero"); @@ -155,6 +157,11 @@ public final class LasIntegrationContext { return hmsUri; } + public URI tosEndpoint() { + ensureEnabled(); + return tosEndpoint; + } + public URI iamEndpoint() { ensureEnabled(); return iamEndpoint; @@ -191,6 +198,16 @@ public final class LasIntegrationContext { return configurations.get(LasIntegrationConfig.IAM_ASSUME_ROLE_TTL); } + public String iamDataRoleName() { + ensureEnabled(); + return configurations.getString(LasIntegrationConfig.IAM_DATA_ROLE_NAME); + } + + public Duration catalogSyncInterval() { + ensureEnabled(); + return configurations.get(LasIntegrationConfig.CATALOG_SYNC_INTERVAL); + } + public int iamCredentialCacheSize() { ensureEnabled(); return configurations.getInteger(LasIntegrationConfig.IAM_CREDENTIAL_CACHE_SIZE); diff --git a/amoro-ams/src/main/java/org/apache/amoro/server/las/LasRestExtension.java b/amoro-ams/src/main/java/org/apache/amoro/server/las/LasRestExtension.java index 224d29170..bb6bb2693 100644 --- a/amoro-ams/src/main/java/org/apache/amoro/server/las/LasRestExtension.java +++ b/amoro-ams/src/main/java/org/apache/amoro/server/las/LasRestExtension.java @@ -85,6 +85,7 @@ public class LasRestExtension implements RestExtension { private CatalogManager catalogManager; private TableManager tableManager; private LasIamClient iamClient; + private LasCatalogSynchronizer catalogSynchronizer; @Override public RestExtensionFactory withServiceConfig(Configurations serviceConfig) { @@ -116,6 +117,12 @@ public class LasRestExtension implements RestExtension { iamClient = new LasIamClient(context); hmsClient = new LasHmsClient(context); sparkSqlManager = new ServerlessSparkSqlManager(context, iamClient); + if (catalogManager.supportNamespace()) { + catalogSynchronizer = new LasCatalogSynchronizer(context, hmsClient, catalogManager); + catalogSynchronizer.start(); + } else { + LOG.info("LAS HMS catalog synchronization is disabled because namespaces are disabled"); + } } return new LasRestExtension( context, hmsClient, sparkSqlManager, catalogManager, tableManager); @@ -128,6 +135,10 @@ public class LasRestExtension implements RestExtension { @Override public void close() { + if (catalogSynchronizer != null) { + catalogSynchronizer.close(); + catalogSynchronizer = null; + } if (iamClient != null) { try { iamClient.close(); diff --git a/amoro-ams/src/main/java/org/apache/amoro/server/persistence/SqlSessionFactoryProvider.java b/amoro-ams/src/main/java/org/apache/amoro/server/persistence/SqlSessionFactoryProvider.java index c6cb1bd53..03383dc2b 100644 --- a/amoro-ams/src/main/java/org/apache/amoro/server/persistence/SqlSessionFactoryProvider.java +++ b/amoro-ams/src/main/java/org/apache/amoro/server/persistence/SqlSessionFactoryProvider.java @@ -28,6 +28,7 @@ import org.apache.amoro.server.persistence.mapper.ApiTokensMapper; import org.apache.amoro.server.persistence.mapper.BucketAssignMapper; import org.apache.amoro.server.persistence.mapper.CatalogMetaMapper; import org.apache.amoro.server.persistence.mapper.HaLeaseMapper; +import org.apache.amoro.server.persistence.mapper.NamespaceAllowlistMapper; import org.apache.amoro.server.persistence.mapper.OptimizerMapper; import org.apache.amoro.server.persistence.mapper.OptimizingProcessMapper; import org.apache.amoro.server.persistence.mapper.PlatformFileMapper; @@ -78,6 +79,7 @@ public class SqlSessionFactoryProvider { configuration.addMapper(TableRuntimeMapper.class); configuration.addMapper(HaLeaseMapper.class); configuration.addMapper(BucketAssignMapper.class); + configuration.addMapper(NamespaceAllowlistMapper.class); PageInterceptor interceptor = new PageInterceptor(); Properties interceptorProperties = new Properties(); diff --git a/amoro-ams/src/main/java/org/apache/amoro/server/persistence/mapper/NamespaceAllowlistMapper.java b/amoro-ams/src/main/java/org/apache/amoro/server/persistence/mapper/NamespaceAllowlistMapper.java new file mode 100644 index 000000000..4d2c867d6 --- /dev/null +++ b/amoro-ams/src/main/java/org/apache/amoro/server/persistence/mapper/NamespaceAllowlistMapper.java @@ -0,0 +1,43 @@ +/* + * 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.amoro.server.persistence.mapper; + +import org.apache.ibatis.annotations.Delete; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +/** Persistence mapper for namespaces that are allowed to synchronize external catalogs. */ +public interface NamespaceAllowlistMapper { + String TABLE_NAME = "namespace_allowlist"; + + @Select("SELECT namespace FROM " + TABLE_NAME + " ORDER BY namespace") + List<String> listNamespaces(); + + @Select("SELECT namespace FROM " + TABLE_NAME + " WHERE namespace = #{namespace}") + String getNamespace(@Param("namespace") String namespace); + + @Insert("INSERT INTO " + TABLE_NAME + " (namespace) VALUES (#{namespace})") + void insertNamespace(@Param("namespace") String namespace); + + @Delete("DELETE FROM " + TABLE_NAME + " WHERE namespace = #{namespace}") + int deleteNamespace(@Param("namespace") String namespace); +} diff --git a/amoro-ams/src/main/resources/authorization/privilege_mapping.yaml b/amoro-ams/src/main/resources/authorization/privilege_mapping.yaml index b0a32fe9a..aa6fe981c 100644 --- a/amoro-ams/src/main/resources/authorization/privilege_mapping.yaml +++ b/amoro-ams/src/main/resources/authorization/privilege_mapping.yaml @@ -39,6 +39,21 @@ mappings: resource-type: CATALOG privilege: MANAGE_CATALOG + - prefixes: + - /api/ams/v1/namespaces + methods: + - GET + resource-type: CATALOG + privilege: VIEW_CATALOG + + - prefixes: + - /api/ams/v1/namespaces + methods: + - PUT + - DELETE + resource-type: CATALOG + privilege: MANAGE_CATALOG + - prefixes: - /api/ams/v1/tables methods: diff --git a/amoro-ams/src/main/resources/derby/ams-derby-init.sql b/amoro-ams/src/main/resources/derby/ams-derby-init.sql index 72c43f4d8..1e9628ee8 100644 --- a/amoro-ams/src/main/resources/derby/ams-derby-init.sql +++ b/amoro-ams/src/main/resources/derby/ams-derby-init.sql @@ -276,3 +276,7 @@ CREATE TABLE bucket_assignments ( node_heartbeat_ts BIGINT NOT NULL DEFAULT 0, PRIMARY KEY (cluster_name, node_key) ); + +CREATE TABLE namespace_allowlist ( + namespace VARCHAR(128) NOT NULL PRIMARY KEY +); diff --git a/amoro-ams/src/main/resources/derby/upgrade.sql b/amoro-ams/src/main/resources/derby/upgrade.sql index 0282c32ec..ccc8efee0 100644 --- a/amoro-ams/src/main/resources/derby/upgrade.sql +++ b/amoro-ams/src/main/resources/derby/upgrade.sql @@ -14,3 +14,7 @@ -- limitations under the License. ALTER TABLE database_metadata ADD COLUMN properties CLOB(64m); + +CREATE TABLE namespace_allowlist ( + namespace VARCHAR(128) NOT NULL PRIMARY KEY +); diff --git a/amoro-ams/src/main/resources/mysql/ams-mysql-init.sql b/amoro-ams/src/main/resources/mysql/ams-mysql-init.sql index b7c423776..5d391b1c5 100644 --- a/amoro-ams/src/main/resources/mysql/ams-mysql-init.sql +++ b/amoro-ams/src/main/resources/mysql/ams-mysql-init.sql @@ -292,3 +292,8 @@ CREATE TABLE IF NOT EXISTS bucket_assignments ( node_heartbeat_ts BIGINT NOT NULL DEFAULT 0 COMMENT 'Per-node heartbeat timestamp updated only by the owning node (ms since epoch)', PRIMARY KEY (cluster_name, node_key) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Bucket ID assignments per AMS node for master-slave mode'; + +CREATE TABLE IF NOT EXISTS `namespace_allowlist` ( + `namespace` VARCHAR(128) NOT NULL COMMENT 'Catalog namespace allowed for synchronization', + PRIMARY KEY (`namespace`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Catalog namespace synchronization allowlist'; diff --git a/amoro-ams/src/main/resources/mysql/upgrade.sql b/amoro-ams/src/main/resources/mysql/upgrade.sql index e8752b1ca..349ee074e 100644 --- a/amoro-ams/src/main/resources/mysql/upgrade.sql +++ b/amoro-ams/src/main/resources/mysql/upgrade.sql @@ -178,3 +178,7 @@ ALTER TABLE `bucket_assignments` ADD COLUMN `node_heartbeat_ts` BIGINT NOT NULL -- ADD properties to table database_metadata ALTER TABLE `database_metadata` ADD COLUMN `properties` MEDIUMTEXT COMMENT 'Database properties'; +CREATE TABLE IF NOT EXISTS `namespace_allowlist` ( + `namespace` VARCHAR(128) NOT NULL COMMENT 'Catalog namespace allowed for synchronization', + PRIMARY KEY (`namespace`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Catalog namespace synchronization allowlist'; diff --git a/amoro-ams/src/main/resources/openapi/openapi.yaml b/amoro-ams/src/main/resources/openapi/openapi.yaml index 2a37aceb9..1824e8867 100644 --- a/amoro-ams/src/main/resources/openapi/openapi.yaml +++ b/amoro-ams/src/main/resources/openapi/openapi.yaml @@ -39,11 +39,69 @@ tags: - name: Overview description: Overview operations paths: + /api/ams/v1/namespaces: + get: + tags: + - Catalogs + summary: List catalog namespaces + description: Returns default when namespace support is disabled, otherwise the namespace allowlist. + responses: + '200': + description: Successful response + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/Response' + - type: object + properties: + result: + type: array + items: + type: string + /api/ams/v1/namespaces/{namespace}: + parameters: + - name: namespace + in: path + required: true + schema: + type: string + description: The namespace allowlist entry + put: + tags: + - Catalogs + summary: Add a namespace to the allowlist + responses: + '200': + description: Namespace was added or already existed + content: + application/json: + schema: + $ref: '#/components/schemas/Response' + delete: + tags: + - Catalogs + summary: Remove a namespace from the allowlist + description: Stops future synchronization and hides its catalogs without treating them as deleted in HMS. + responses: + '200': + description: Namespace was removed or did not exist + content: + application/json: + schema: + $ref: '#/components/schemas/Response' /api/ams/v1/catalogs: get: tags: - Catalogs summary: Get list of catalogs + parameters: + - name: namespace + in: query + required: false + schema: + type: string + description: Optional namespace filter. Omitting it preserves the legacy all-catalog response. responses: '200': description: Successful response @@ -979,4 +1037,4 @@ components: storageConfigs: type: object additionalProperties: - type: string \ No newline at end of file + type: string diff --git a/amoro-ams/src/main/resources/postgres/ams-postgres-init.sql b/amoro-ams/src/main/resources/postgres/ams-postgres-init.sql index 5f1494bc0..87ca82fa9 100644 --- a/amoro-ams/src/main/resources/postgres/ams-postgres-init.sql +++ b/amoro-ams/src/main/resources/postgres/ams-postgres-init.sql @@ -477,3 +477,8 @@ COMMENT ON COLUMN server_info_json IS 'JSON encoded server info (AmsServerInfo)' COMMENT ON COLUMN lease_expire_ts IS 'Lease expiration timestamp (ms since epoch)'; COMMENT ON COLUMN version IS 'Optimistic lock version of the lease row'; COMMENT ON COLUMN updated_at IS 'Last update timestamp (ms since epoch)'; + +CREATE TABLE IF NOT EXISTS namespace_allowlist ( + namespace VARCHAR(128) PRIMARY KEY +); +COMMENT ON TABLE namespace_allowlist IS 'Catalog namespace synchronization allowlist'; diff --git a/amoro-ams/src/main/resources/postgres/upgrade.sql b/amoro-ams/src/main/resources/postgres/upgrade.sql index 8c290769e..cba35c8fb 100644 --- a/amoro-ams/src/main/resources/postgres/upgrade.sql +++ b/amoro-ams/src/main/resources/postgres/upgrade.sql @@ -240,3 +240,7 @@ ALTER TABLE bucket_assignments ADD COLUMN node_heartbeat_ts BIGINT NOT NULL DEFA -- ADD properties to table database_metadata ALTER TABLE database_metadata ADD COLUMN properties text; COMMENT ON COLUMN database_metadata.properties IS 'Database properties'; + +CREATE TABLE IF NOT EXISTS namespace_allowlist ( + namespace VARCHAR(128) PRIMARY KEY +); diff --git a/amoro-ams/src/test/java/org/apache/amoro/server/catalog/TestDefaultCatalogManagerNamespaces.java b/amoro-ams/src/test/java/org/apache/amoro/server/catalog/TestDefaultCatalogManagerNamespaces.java new file mode 100644 index 000000000..af8dbed85 --- /dev/null +++ b/amoro-ams/src/test/java/org/apache/amoro/server/catalog/TestDefaultCatalogManagerNamespaces.java @@ -0,0 +1,130 @@ +/* + * 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.amoro.server.catalog; + +import org.apache.amoro.TableFormat; +import org.apache.amoro.api.CatalogMeta; +import org.apache.amoro.catalog.CatalogTestHelpers; +import org.apache.amoro.config.Configurations; +import org.apache.amoro.properties.CatalogMetaProperties; +import org.apache.amoro.server.AmoroManagementConf; +import org.apache.amoro.server.persistence.PersistentBase; +import org.apache.amoro.server.persistence.mapper.CatalogMetaMapper; +import org.apache.amoro.server.persistence.mapper.NamespaceAllowlistMapper; +import org.apache.amoro.server.table.DerbyPersistence; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.stream.Collectors; + +public class TestDefaultCatalogManagerNamespaces { + + private static final TestPersistence PERSISTENCE = new TestPersistence(); + private static final DirectDbAccess DB = new DirectDbAccess(); + + @BeforeAll + public static void initializePersistence() { + PERSISTENCE.initialize(); + } + + @AfterEach + public void cleanUp() { + DB.deleteCatalog("tenant-a@las"); + DB.deleteCatalog("tenant-b@las"); + DB.deleteNamespace("tenant-a"); + DB.deleteNamespace("tenant-b"); + } + + @Test + public void testCommunityDefaultsRemainCompatible() { + DefaultCatalogManager manager = new DefaultCatalogManager(new Configurations()); + + Assertions.assertFalse(manager.supportNamespace()); + Assertions.assertEquals(Collections.singletonList("default"), manager.listNamespaces()); + Assertions.assertEquals(manager.listCatalogMetas(), manager.listCatalogMetas("default")); + Assertions.assertTrue(manager.listCatalogMetas("unknown").isEmpty()); + } + + @Test + public void testEnabledNamespaceAllowlistAndCatalogFiltering() { + Configurations configurations = new Configurations(); + configurations.setBoolean(AmoroManagementConf.CATALOG_NAMESPACE_ENABLED, true); + DefaultCatalogManager manager = new DefaultCatalogManager(configurations); + + Assertions.assertTrue(manager.listNamespaces().isEmpty()); + manager.addNamespace("tenant-b"); + manager.addNamespace("tenant-a"); + manager.addNamespace("tenant-a"); + Assertions.assertEquals(Arrays.asList("tenant-a", "tenant-b"), manager.listNamespaces()); + + DB.insertCatalog(catalog("tenant-a@las", "tenant-a")); + DB.insertCatalog(catalog("tenant-b@las", "tenant-b")); + + Assertions.assertEquals( + Collections.singletonList("tenant-a@las"), + catalogNames(manager.listCatalogMetas("tenant-a"))); + Assertions.assertEquals( + Collections.singletonList("tenant-b@las"), + catalogNames(manager.listCatalogMetas("tenant-b"))); + Assertions.assertTrue(manager.listCatalogMetas("unknown").isEmpty()); + Assertions.assertEquals(2, manager.listCatalogMetas().size()); + + manager.removeNamespace("tenant-a"); + manager.removeNamespace("tenant-a"); + Assertions.assertEquals(Collections.singletonList("tenant-b"), manager.listNamespaces()); + Assertions.assertTrue(manager.listCatalogMetas("tenant-a").isEmpty()); + } + + private static CatalogMeta catalog(String catalogName, String namespace) { + HashMap<String, String> properties = new HashMap<>(); + properties.put(CatalogMetaProperties.NAMESPACE, namespace); + return CatalogTestHelpers.buildCatalogMeta( + catalogName, CatalogMetaProperties.CATALOG_TYPE_HIVE, properties, TableFormat.ICEBERG); + } + + private static List<String> catalogNames(List<CatalogMeta> catalogs) { + return catalogs.stream().map(CatalogMeta::getCatalogName).collect(Collectors.toList()); + } + + private static class TestPersistence extends DerbyPersistence { + void initialize() { + // Class initialization creates the shared Derby schema used by persistence tests. + } + } + + private static class DirectDbAccess extends PersistentBase { + void insertCatalog(CatalogMeta catalogMeta) { + doAs(CatalogMetaMapper.class, mapper -> mapper.insertCatalog(catalogMeta)); + } + + void deleteCatalog(String catalogName) { + doAs(CatalogMetaMapper.class, mapper -> mapper.deleteCatalog(catalogName)); + } + + void deleteNamespace(String namespace) { + doAs(NamespaceAllowlistMapper.class, mapper -> mapper.deleteNamespace(namespace)); + } + } +} diff --git a/amoro-ams/src/test/java/org/apache/amoro/server/dashboard/controller/TestTableControllerNamespaces.java b/amoro-ams/src/test/java/org/apache/amoro/server/dashboard/controller/TestTableControllerNamespaces.java new file mode 100644 index 000000000..ffa3d9e54 --- /dev/null +++ b/amoro-ams/src/test/java/org/apache/amoro/server/dashboard/controller/TestTableControllerNamespaces.java @@ -0,0 +1,113 @@ +/* + * 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.amoro.server.dashboard.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import io.javalin.http.Context; +import org.apache.amoro.api.CatalogMeta; +import org.apache.amoro.config.Configurations; +import org.apache.amoro.server.catalog.CatalogManager; +import org.apache.amoro.server.dashboard.ServerTableDescriptor; +import org.apache.amoro.server.dashboard.response.OkResponse; +import org.apache.amoro.server.table.TableManager; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.util.Collections; + +public class TestTableControllerNamespaces { + + @Test + public void testCatalogEndpointWithoutNamespaceKeepsLegacyCall() { + CatalogManager catalogManager = mock(CatalogManager.class); + CatalogMeta catalog = new CatalogMeta(); + when(catalogManager.listCatalogMetas()).thenReturn(Collections.singletonList(catalog)); + Context context = context(); + when(context.queryParam("namespace")).thenReturn(null); + + controller(catalogManager).getCatalogs(context); + + verify(catalogManager).listCatalogMetas(); + verify(catalogManager, never()).listCatalogMetas(any()); + Assertions.assertEquals( + Collections.singletonList(catalog), capturedResponse(context).getResult()); + } + + @Test + public void testCatalogEndpointFiltersByNamespace() { + CatalogManager catalogManager = mock(CatalogManager.class); + CatalogMeta catalog = new CatalogMeta(); + when(catalogManager.listCatalogMetas("tenant-a")) + .thenReturn(Collections.singletonList(catalog)); + Context context = context(); + when(context.queryParam("namespace")).thenReturn(" tenant-a "); + + controller(catalogManager).getCatalogs(context); + + verify(catalogManager).listCatalogMetas("tenant-a"); + Assertions.assertEquals( + Collections.singletonList(catalog), capturedResponse(context).getResult()); + } + + @Test + public void testNamespaceAllowlistEndpointsDelegateToCatalogManager() { + CatalogManager catalogManager = mock(CatalogManager.class); + when(catalogManager.listNamespaces()).thenReturn(Collections.singletonList("tenant-a")); + Context listContext = context(); + controller(catalogManager).getNamespaces(listContext); + Assertions.assertEquals( + Collections.singletonList("tenant-a"), capturedResponse(listContext).getResult()); + + Context putContext = context(); + when(putContext.pathParam("namespace")).thenReturn("tenant-a"); + controller(catalogManager).addNamespace(putContext); + verify(catalogManager).addNamespace("tenant-a"); + + Context deleteContext = context(); + when(deleteContext.pathParam("namespace")).thenReturn("tenant-a"); + controller(catalogManager).removeNamespace(deleteContext); + verify(catalogManager).removeNamespace("tenant-a"); + } + + private static TableController controller(CatalogManager catalogManager) { + return new TableController( + catalogManager, + mock(TableManager.class), + mock(ServerTableDescriptor.class), + new Configurations()); + } + + private static Context context() { + Context context = mock(Context.class); + when(context.json(any())).thenReturn(context); + return context; + } + + private static OkResponse<?> capturedResponse(Context context) { + ArgumentCaptor<Object> response = ArgumentCaptor.forClass(Object.class); + verify(context).json(response.capture()); + return (OkResponse<?>) response.getValue(); + } +} diff --git a/amoro-ams/src/test/java/org/apache/amoro/server/las/TestLasCatalogSynchronizer.java b/amoro-ams/src/test/java/org/apache/amoro/server/las/TestLasCatalogSynchronizer.java new file mode 100644 index 000000000..24af35cc7 --- /dev/null +++ b/amoro-ams/src/test/java/org/apache/amoro/server/las/TestLasCatalogSynchronizer.java @@ -0,0 +1,245 @@ +/* + * 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.amoro.server.las; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import org.apache.amoro.api.CatalogMeta; +import org.apache.amoro.client.ClientPool; +import org.apache.amoro.hive.HMSClient; +import org.apache.amoro.hive.HMSClientPool; +import org.apache.amoro.properties.CatalogMetaProperties; +import org.apache.amoro.server.catalog.CatalogManager; +import org.apache.amoro.table.TableProperties; +import org.apache.hadoop.conf.Configuration; +import org.apache.thrift.TException; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.io.ByteArrayInputStream; +import java.util.Arrays; +import java.util.Base64; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +public class TestLasCatalogSynchronizer { + + @Test + public void testParseNamespaceUsesFirstSeparator() { + Assertions.assertEquals( + "tenant-a", LasCatalogSynchronizer.parseNamespace("tenant-a@las@archive").orElse(null)); + Assertions.assertFalse(LasCatalogSynchronizer.parseNamespace("las").isPresent()); + Assertions.assertFalse(LasCatalogSynchronizer.parseNamespace("@las").isPresent()); + Assertions.assertFalse(LasCatalogSynchronizer.parseNamespace("tenant-a@").isPresent()); + Assertions.assertFalse( + LasCatalogSynchronizer.parseNamespace( + "tenant-a@" + String.join("", Collections.nCopies(64, "x"))) + .isPresent()); + Assertions.assertFalse(LasCatalogSynchronizer.parseNamespace(null).isPresent()); + } + + @Test + public void testSyncUsesOneSnapshotAndProtectsManualAndFilteredCatalogs() throws Exception { + HMSClient hmsSdk = mock(HMSClient.class); + when(hmsSdk.getCatalogs()) + .thenReturn( + Arrays.asList("tenant-a@las", "tenant-a@manual", "tenant-b@keep", "invalid-catalog")); + CatalogManager catalogManager = mock(CatalogManager.class); + when(catalogManager.listNamespaces()).thenReturn(Collections.singletonList("tenant-a")); + CatalogMeta manual = catalog("tenant-a@manual", "tenant-a", false); + CatalogMeta removedFromAllowlist = catalog("tenant-b@keep", "tenant-b", true); + CatalogMeta stale = catalog("tenant-z@gone", "tenant-z", true); + when(catalogManager.listCatalogMetas()) + .thenReturn(Arrays.asList(manual, removedFromAllowlist, stale)); + + LasCatalogSynchronizer synchronizer = synchronizer(hmsSdk, catalogManager); + synchronizer.syncOnce(); + + verify(hmsSdk, times(1)).getCatalogs(); + ArgumentCaptor<CatalogMeta> created = ArgumentCaptor.forClass(CatalogMeta.class); + verify(catalogManager).createCatalog(created.capture()); + Assertions.assertEquals("tenant-a@las", created.getValue().getCatalogName()); + Assertions.assertEquals( + CatalogMetaProperties.CATALOG_TYPE_HIVE, created.getValue().getCatalogType()); + Assertions.assertEquals( + "tenant-a", created.getValue().getCatalogProperties().get(CatalogMetaProperties.NAMESPACE)); + Assertions.assertEquals( + "false", + created + .getValue() + .getCatalogProperties() + .get( + CatalogMetaProperties.TABLE_PROPERTIES_PREFIX + + TableProperties.ENABLE_SELF_OPTIMIZING)); + Configuration hiveConfiguration = + decodeConfiguration( + created + .getValue() + .getStorageConfigs() + .get(CatalogMetaProperties.STORAGE_CONFIGS_KEY_HIVE_SITE)); + Assertions.assertEquals( + "thrift://hms-service:9083", hiveConfiguration.get("hive.metastore.uris")); + Assertions.assertEquals("tenant-a@las", hiveConfiguration.get("metastore.catalog.default")); + Configuration tosConfiguration = + decodeConfiguration( + created + .getValue() + .getStorageConfigs() + .get(CatalogMetaProperties.STORAGE_CONFIGS_KEY_CORE_SITE)); + Assertions.assertEquals( + "https://tos-cn-beijing.volces.com", tosConfiguration.get("fs.tos.endpoint")); + Assertions.assertEquals( + "io.proton.tos.iam.AssumeIamRoleCredentialProvider", + tosConfiguration.get("fs.tos.credentials.provider")); + Assertions.assertEquals( + "trn:iam::tenant-a:role/ServiceRoleForLAS", + tosConfiguration.get("fs.tos.credential.sts.iam-role-trn")); + verify(catalogManager).dropCatalog("tenant-z@gone"); + verify(catalogManager, never()).dropCatalog("tenant-b@keep"); + verify(catalogManager, never()).updateCatalog(manual); + } + + @Test + public void testManagedCatalogIsUpdatedIdempotently() throws Exception { + HMSClient hmsSdk = mock(HMSClient.class); + when(hmsSdk.getCatalogs()).thenReturn(Collections.singletonList("tenant-a@las")); + CatalogManager catalogManager = mock(CatalogManager.class); + when(catalogManager.listNamespaces()).thenReturn(Collections.singletonList("tenant-a")); + CatalogMeta existing = catalog("tenant-a@las", "tenant-a", true); + existing.getCatalogProperties().put("preserved", "value"); + when(catalogManager.listCatalogMetas()).thenReturn(Collections.singletonList(existing)); + + LasCatalogSynchronizer synchronizer = synchronizer(hmsSdk, catalogManager); + synchronizer.syncOnce(); + + ArgumentCaptor<CatalogMeta> updated = ArgumentCaptor.forClass(CatalogMeta.class); + verify(catalogManager).updateCatalog(updated.capture()); + Assertions.assertEquals("value", updated.getValue().getCatalogProperties().get("preserved")); + Assertions.assertEquals( + "ICEBERG,PAIMON", + updated.getValue().getCatalogProperties().get(CatalogMetaProperties.TABLE_FORMATS)); + when(catalogManager.listCatalogMetas()) + .thenReturn(Collections.singletonList(updated.getValue())); + synchronizer.syncOnce(); + verify(catalogManager, times(1)).updateCatalog(any()); + verify(catalogManager, never()).createCatalog(any()); + verify(catalogManager, never()).dropCatalog(any()); + } + + @Test + public void testHmsFailureDoesNotMutateCatalogState() throws Exception { + HMSClient hmsSdk = mock(HMSClient.class); + when(hmsSdk.getCatalogs()).thenThrow(new TException("unavailable")); + CatalogManager catalogManager = mock(CatalogManager.class); + LasCatalogSynchronizer synchronizer = synchronizer(hmsSdk, catalogManager); + + Assertions.assertThrows(TException.class, synchronizer::syncOnce); + verifyNoInteractions(catalogManager); + } + + @Test + public void testInvalidHmsSnapshotDoesNotMutateCatalogState() throws Exception { + HMSClient hmsSdk = mock(HMSClient.class); + when(hmsSdk.getCatalogs()).thenReturn(Arrays.asList("tenant-a@las", null)); + CatalogManager catalogManager = mock(CatalogManager.class); + LasCatalogSynchronizer synchronizer = synchronizer(hmsSdk, catalogManager); + + Assertions.assertThrows(IllegalStateException.class, synchronizer::syncOnce); + verifyNoInteractions(catalogManager); + } + + @Test + public void testFirstRunIsDelayedByOneFiveMinuteInterval() { + HMSClient hmsSdk = mock(HMSClient.class); + CatalogManager catalogManager = mock(CatalogManager.class); + ScheduledExecutorService scheduler = mock(ScheduledExecutorService.class); + LasIntegrationContext context = + LasIntegrationContext.initialize(TestLasIntegrationContext.validConfigurations()); + LasCatalogSynchronizer synchronizer = + new LasCatalogSynchronizer( + context, + new LasHmsClient(directPool(hmsSdk), ignored -> directPool(hmsSdk)), + catalogManager, + scheduler); + + synchronizer.start(); + synchronizer.start(); + + verify(scheduler, times(1)) + .scheduleWithFixedDelay( + any(Runnable.class), eq(300_000L), eq(300_000L), eq(TimeUnit.MILLISECONDS)); + verifyNoInteractions(hmsSdk); + } + + private static LasCatalogSynchronizer synchronizer( + HMSClient hmsSdk, CatalogManager catalogManager) { + LasIntegrationContext context = + LasIntegrationContext.initialize(TestLasIntegrationContext.validConfigurations()); + HMSClientPool pool = directPool(hmsSdk); + return new LasCatalogSynchronizer( + context, new LasHmsClient(pool, ignored -> pool), catalogManager); + } + + private static CatalogMeta catalog(String name, String namespace, boolean managed) { + Map<String, String> properties = new HashMap<>(); + properties.put(CatalogMetaProperties.NAMESPACE, namespace); + if (managed) { + properties.put( + LasCatalogSynchronizer.CATALOG_SOURCE, LasCatalogSynchronizer.CATALOG_SOURCE_LAS_HMS); + } + return new CatalogMeta( + name, + CatalogMetaProperties.CATALOG_TYPE_HIVE, + new HashMap<>(), + new HashMap<>(), + properties); + } + + private static HMSClientPool directPool(HMSClient client) { + return new HMSClientPool() { + @Override + public <R> R run(ClientPool.Action<R, HMSClient, TException> action) throws TException { + return action.run(client); + } + + @Override + public <R> R run(ClientPool.Action<R, HMSClient, TException> action, boolean retry) + throws TException { + return action.run(client); + } + }; + } + + private static Configuration decodeConfiguration(String encoded) { + Configuration configuration = new Configuration(false); + configuration.addResource(new ByteArrayInputStream(Base64.getDecoder().decode(encoded))); + return configuration; + } +} diff --git a/amoro-common/src/main/java/org/apache/amoro/properties/CatalogMetaProperties.java b/amoro-common/src/main/java/org/apache/amoro/properties/CatalogMetaProperties.java index 10f055b80..df324b891 100644 --- a/amoro-common/src/main/java/org/apache/amoro/properties/CatalogMetaProperties.java +++ b/amoro-common/src/main/java/org/apache/amoro/properties/CatalogMetaProperties.java @@ -69,6 +69,9 @@ public class CatalogMetaProperties { public static final String TABLE_FORMATS = "table-formats"; + /** Optional namespace used to group catalogs in namespace-aware deployments. */ + public static final String NAMESPACE = "namespace"; + public static final String CLIENT_POOL_SIZE = "clients"; public static final int CLIENT_POOL_SIZE_DEFAULT = 20; diff --git a/amoro-web/src/components/Sidebar.vue b/amoro-web/src/components/Sidebar.vue index f5cf27526..058645e78 100644 --- a/amoro-web/src/components/Sidebar.vue +++ b/amoro-web/src/components/Sidebar.vue @@ -146,11 +146,18 @@ export default defineComponent({ let catalog: string | undefined let db: string | undefined let tableName: string | undefined + let namespace: string | undefined try { const stored = localStorage.getItem('easylake-menu-catalog-db-table') if (stored) { - const parsed = JSON.parse(stored) as { catalog?: string; database?: string; tableName?: string } + const parsed = JSON.parse(stored) as { + namespace?: string + catalog?: string + database?: string + tableName?: string + } + namespace = parsed.namespace catalog = parsed.catalog db = parsed.database tableName = parsed.tableName @@ -164,6 +171,7 @@ export default defineComponent({ router.replace({ path: '/tables', query: { + ...(namespace && namespace !== 'default' ? { namespace } : {}), catalog, db, table: tableName, diff --git a/amoro-web/src/services/table.service.ts b/amoro-web/src/services/table.service.ts index 0ff5ba928..2a5b51c7b 100644 --- a/amoro-web/src/services/table.service.ts +++ b/amoro-web/src/services/table.service.ts @@ -20,8 +20,14 @@ import type { ICatalogItem, IMap } from '@/types/common.type' import request from '@/utils/request' -export function getCatalogList(): Promise<ICatalogItem[]> { - return request.get('api/ams/v1/catalogs') +export function getNamespaceList(): Promise<string[]> { + return request.get('api/ams/v1/namespaces') +} + +export function getCatalogList(namespace?: string): Promise<ICatalogItem[]> { + return request.get('api/ams/v1/catalogs', { + params: namespace ? { namespace } : undefined, + }) } export function getDatabaseList(params: { catalog: string diff --git a/amoro-web/src/views/tables/components/Details.vue b/amoro-web/src/views/tables/components/Details.vue index 93c26b737..19ce44d96 100644 --- a/amoro-web/src/views/tables/components/Details.vue +++ b/amoro-web/src/views/tables/components/Details.vue @@ -17,7 +17,7 @@ limitations under the License. / --> <script setup lang="ts"> -import { computed, onMounted, reactive, shallowReactive, watch } from 'vue' +import { computed, reactive, shallowReactive, watch } from 'vue' import { useI18n } from 'vue-i18n' import { useRoute, useRouter } from 'vue-router' import type { ColumnProps } from 'ant-design-vue/es/table' @@ -27,13 +27,14 @@ import { dateFormat } from '@/utils' const emit = defineEmits<{ (e: 'setBaseDetailInfo', data: IBaseDetailInfo): void - (e: 'tableNotFound', info: { catalog: string; db: string; table: string }): void + (e: 'tableNotFound', info: { catalog: string, db: string, table: string }): void }>() const { t } = useI18n() const route = useRoute() const router = useRouter() const STORAGE_TABLE_KEY = 'easylake-menu-catalog-db-table' +let detailRequestGeneration = 0 const params = computed(() => { return { @@ -44,6 +45,7 @@ const params = computed(() => { watch( () => route.query, (val) => { + detailRequestGeneration += 1 val?.catalog && route.path === '/tables' && getTableDetails() }, ) @@ -72,7 +74,7 @@ const state = reactive({ createTime: '', tableFormat: '', hasPartition: false, // Whether there is a partition, if there is no partition, the file list will be displayed - comment: '' + comment: '', } as IBaseDetailInfo, pkList: [] as DetailColumnItem[], partitionColumnList: [] as PartitionColumnItem[], @@ -83,6 +85,7 @@ const state = reactive({ }) async function getTableDetails() { + const requestGeneration = ++detailRequestGeneration const requestParams = { ...params.value } const { catalog, db, table } = requestParams if (!catalog || !db || !table) { @@ -93,6 +96,9 @@ async function getTableDetails() { const result = await getTableDetail({ ...requestParams, }) + if (requestGeneration !== detailRequestGeneration) { + return + } const { pkList = [], tableType, partitionColumnList = [], properties, changeMetrics, schema, createTime, tableIdentifier, baseMetrics, tableSummary, comment } = result state.baseDetailInfo = { ...tableSummary, @@ -100,7 +106,7 @@ async function getTableDetails() { tableName: `${tableIdentifier?.catalog || ''}.${tableIdentifier?.database || ''}.${tableIdentifier?.tableName || ''}`, createTime: createTime ? dateFormat(createTime) : '', hasPartition: !!(partitionColumnList?.length), - comment: comment || '' + comment: comment || '', } state.pkList = pkList || [] @@ -129,10 +135,15 @@ async function getTableDetails() { setBaseDetailInfo() } catch (error) { + if (requestGeneration !== detailRequestGeneration) { + return + } const errorMessage = (error as Error)?.message || '' const isNotFoundError = /not exist|not found/i.test(errorMessage) if (isNotFoundError) { + const namespace = (route.query.namespace as string) || 'default' + localStorage.removeItem(`${STORAGE_TABLE_KEY}:${namespace}`) localStorage.removeItem(STORAGE_TABLE_KEY) emit('tableNotFound', { @@ -141,11 +152,16 @@ async function getTableDetails() { table: table as string, }) - router.replace({ path: '/tables', query: {} }) + router.replace({ + path: '/tables', + query: route.query.namespace ? { namespace: route.query.namespace } : {}, + }) } } finally { - state.detailLoading = false + if (requestGeneration === detailRequestGeneration) { + state.detailLoading = false + } } function setBaseDetailInfo() { diff --git a/amoro-web/src/views/tables/components/TableExplorer.vue b/amoro-web/src/views/tables/components/TableExplorer.vue index 96740e9de..f21946c9b 100755 --- a/amoro-web/src/views/tables/components/TableExplorer.vue +++ b/amoro-web/src/views/tables/components/TableExplorer.vue @@ -19,7 +19,7 @@ limitations under the License. <script setup lang="ts"> import { computed, onBeforeMount, reactive, watch } from 'vue' import { useRoute, useRouter } from 'vue-router' -import { getCatalogList, getDatabaseList, getTableList } from '@/services/table.service' +import { getCatalogList, getDatabaseList, getNamespaceList, getTableList } from '@/services/table.service' import type { ICatalogItem } from '@/types/common.type' // Node types: Catalog / Database / Table @@ -47,7 +47,7 @@ const router = useRouter() const route = useRoute() const storageTableKey = 'easylake-menu-catalog-db-table' -const expandedKeysSessionKey = 'tables_expanded_keys' +const expandedKeysSessionKeyPrefix = 'tables_expanded_keys' const state = reactive({ loading: false, @@ -56,16 +56,34 @@ const state = reactive({ treeData: [] as TreeNode[], expandedKeys: [] as string[], selectedKeys: [] as string[], + namespaces: [] as string[], + selectedNamespace: '', + namespaceMode: false, // Cache catalogList: [] as string[], dbListByCatalog: {} as Record<string, string[]>, tablesByCatalogDb: {} as Record<string, TableItem[]>, }) +let namespaceGeneration = 0 +let initialized = false + +function expandedKeysSessionKey() { + return `${expandedKeysSessionKeyPrefix}:${state.selectedNamespace || 'default'}` +} + +function catalogDisplayName(catalog: string) { + if (!state.namespaceMode || !state.selectedNamespace) { + return catalog + } + const prefix = `${state.selectedNamespace}@` + return catalog.startsWith(prefix) ? catalog.slice(prefix.length) : catalog +} + function buildCatalogNode(catalog: string): TreeNode { return { key: `catalog:${catalog}`, - title: catalog, + title: catalogDisplayName(catalog), isLeaf: false, nodeType: 'catalog', catalog, @@ -114,18 +132,42 @@ function updateTreeNodeChildren(targetKey: string, children: TreeNode[]) { } async function initRootCatalogs() { + if (state.namespaceMode && !state.selectedNamespace) { + state.catalogList = [] + state.treeData = [] + return + } + const generation = namespaceGeneration state.loading = true try { - const res = await getCatalogList() + const res = await getCatalogList(state.namespaceMode ? state.selectedNamespace : undefined) + if (generation !== namespaceGeneration) { + return + } const catalogs = (res || []).map((item: ICatalogItem) => item.catalogName) state.catalogList = catalogs state.treeData = catalogs.map(catalog => buildCatalogNode(catalog)) } finally { - state.loading = false + if (generation === namespaceGeneration) { + state.loading = false + } } } +function clearExplorerState() { + namespaceGeneration += 1 + state.loading = false + state.searchKey = '' + state.filterKey = '' + state.treeData = [] + state.expandedKeys = [] + state.selectedKeys = [] + state.catalogList = [] + state.dbListByCatalog = {} + state.tablesByCatalogDb = {} +} + async function loadChildren(node: any) { const data = node?.dataRef || node if (!data) { @@ -133,6 +175,7 @@ async function loadChildren(node: any) { } const nodeType = data.nodeType as NodeType + const generation = namespaceGeneration if (nodeType === 'catalog') { const catalog = data.catalog as string if (!catalog || state.dbListByCatalog[catalog]) { @@ -142,6 +185,9 @@ async function loadChildren(node: any) { state.loading = true try { const res = await getDatabaseList({ catalog, keywords: '' }) + if (generation !== namespaceGeneration) { + return + } const dbs = (res || []) as string[] state.dbListByCatalog[catalog] = dbs if (!dbs.length) { @@ -171,6 +217,9 @@ async function loadChildren(node: any) { state.loading = true try { const res = await getTableList({ catalog, db, keywords: '' }) + if (generation !== namespaceGeneration) { + return + } const tables = (res || []) as TableItem[] state.tablesByCatalogDb[cacheKey] = tables if (!tables.length) { @@ -217,7 +266,7 @@ async function expandPathBySelected(catalog: string, db: string) { state.expandedKeys = Array.from(nextExpandedKeys) try { - sessionStorage.setItem(expandedKeysSessionKey, JSON.stringify(state.expandedKeys)) + sessionStorage.setItem(expandedKeysSessionKey(), JSON.stringify(state.expandedKeys)) } catch (e) { // ignore sessionStorage write errors @@ -231,16 +280,21 @@ function handleSelectTable(catalog: string, db: string, tableName: string, table const type = tableType || 'MIXED_ICEBERG' - localStorage.setItem(storageTableKey, JSON.stringify({ + const namespace = state.namespaceMode ? state.selectedNamespace : 'default' + const storedSelection = JSON.stringify({ + namespace, catalog, database: db, tableName, - })) + }) + localStorage.setItem(`${storageTableKey}:${namespace}`, storedSelection) + localStorage.setItem(storageTableKey, storedSelection) const path = type === 'HIVE' ? '/hive-tables' : '/tables' const pathQuery = { path, query: { + ...(state.namespaceMode ? { namespace: state.selectedNamespace } : {}), catalog, db, table: tableName, @@ -289,7 +343,7 @@ function handleTreeSelect(selectedKeys: (string | number)[], info: any) { async function handleTreeExpand(expandedKeys: (string | number)[], info: any) { state.expandedKeys = expandedKeys.map(key => String(key)) try { - sessionStorage.setItem(expandedKeysSessionKey, JSON.stringify(state.expandedKeys)) + sessionStorage.setItem(expandedKeysSessionKey(), JSON.stringify(state.expandedKeys)) } catch (e) { // ignore sessionStorage write errors @@ -320,7 +374,7 @@ async function toggleNodeExpand(dataRef: TreeNode) { state.expandedKeys = nextExpandedKeys try { - sessionStorage.setItem(expandedKeysSessionKey, JSON.stringify(state.expandedKeys)) + sessionStorage.setItem(expandedKeysSessionKey(), JSON.stringify(state.expandedKeys)) } catch (e) { // ignore sessionStorage write errors @@ -455,10 +509,34 @@ watch( watch( () => route.query, async (value, oldValue) => { - const { catalog, db, table } = value as any - const { catalog: oldCatalog, db: oldDb, table: oldTable } = (oldValue || {}) as any + if (!initialized) { + return + } - if (`${catalog || ''}${db || ''}${table || ''}` === `${oldCatalog || ''}${oldDb || ''}${oldTable || ''}`) { + const { namespace, catalog, db, table } = value as any + const { + namespace: oldNamespace, + catalog: oldCatalog, + db: oldDb, + table: oldTable, + } = (oldValue || {}) as any + + if (state.namespaceMode && namespace !== oldNamespace) { + const nextNamespace = state.namespaces.includes(namespace as string) ? namespace as string : '' + if (nextNamespace !== state.selectedNamespace) { + clearExplorerState() + state.selectedNamespace = nextNamespace + if (nextNamespace) { + await initRootCatalogs() + await restoreExpandedState() + } + } + } + + if ( + namespace === oldNamespace + && `${catalog || ''}${db || ''}${table || ''}` === `${oldCatalog || ''}${oldDb || ''}${oldTable || ''}` + ) { return } @@ -496,13 +574,11 @@ const searchResult = computed(() => { const displayTreeData = computed(() => searchResult.value.tree) const displayExpandedKeys = computed(() => searchResult.value.expandedKeys) -onBeforeMount(async () => { - await initRootCatalogs() - +async function restoreExpandedState() { let restoredExpandedKeys: string[] = [] try { - const stored = sessionStorage.getItem(expandedKeysSessionKey) + const stored = sessionStorage.getItem(expandedKeysSessionKey()) if (stored) { const parsed = JSON.parse(stored) if (Array.isArray(parsed)) { @@ -538,7 +614,9 @@ onBeforeMount(async () => { state.expandedKeys = restoredExpandedKeys } +} +async function restoreRouteSelection() { const query = route.query || {} const queryCatalog = (query.catalog as string) || '' const queryDb = (query.db as string) || '' @@ -551,12 +629,72 @@ onBeforeMount(async () => { state.selectedKeys = [tableKey] } } + else { + state.selectedKeys = [] + } +} + +async function initializeNamespaces() { + let namespaces: string[] + try { + namespaces = (await getNamespaceList()) || [] + } + catch (e) { + // Fail closed: falling back to the legacy catalog endpoint could expose every namespace. + namespaces = [] + } + + state.namespaces = namespaces + state.namespaceMode = !(namespaces.length === 1 && namespaces[0] === 'default') + if (!state.namespaceMode) { + state.selectedNamespace = 'default' + return + } + + const routeNamespace = (route.query.namespace as string) || '' + state.selectedNamespace = namespaces.includes(routeNamespace) ? routeNamespace : '' +} + +async function handleNamespaceChange(namespace?: string) { + clearExplorerState() + state.selectedNamespace = namespace || '' + + await router.replace({ + path: route.path, + query: state.selectedNamespace ? { namespace: state.selectedNamespace } : {}, + }) + + if (state.selectedNamespace) { + await initRootCatalogs() + await restoreExpandedState() + } +} + +onBeforeMount(async () => { + await initializeNamespaces() + await initRootCatalogs() + await restoreExpandedState() + await restoreRouteSelection() + initialized = true }) </script> <template> <div class="table-explorer"> <div class="table-explorer-header"> + <div class="namespace-selector"> + <span class="namespace-label">Namespace:</span> + <a-select + v-if="state.namespaceMode" + :value="state.selectedNamespace || undefined" + :options="state.namespaces.map(namespace => ({ label: namespace, value: namespace }))" + placeholder="Select namespace" + class="namespace-select" + allow-clear + @change="handleNamespaceChange" + /> + <span v-else class="namespace-default">default</span> + </div> <a-input v-model:value="state.searchKey" placeholder="Search catalog.database.table" @@ -598,10 +736,10 @@ onBeforeMount(async () => { </template> </a-tree> <div v-else class="empty-placeholder"> - <span>No results..</span> + <span v-if="state.namespaceMode && !state.selectedNamespace">Select a namespace to browse tables.</span> + <span v-else>No results.</span> </div> </div> - </div> </template> @@ -621,6 +759,37 @@ onBeforeMount(async () => { padding: 0 7px 0 8px; margin-bottom: 8px; + .namespace-selector { + display: flex; + align-items: center; + min-height: 24px; + margin-bottom: 8px; + font-size: 13px; + + .namespace-label { + margin-right: 6px; + color: #666; + } + + .namespace-default { + color: #262626; + } + + .namespace-select { + flex: 1; + min-width: 0; + + :deep(.ant-select-selector) { + height: 24px; + } + + :deep(.ant-select-selection-item), + :deep(.ant-select-selection-placeholder) { + line-height: 22px; + } + } + } + .search-input { width: 100%; diff --git a/dist/src/main/amoro-bin/conf/config.yaml b/dist/src/main/amoro-bin/conf/config.yaml index c14d19089..c23a8d4a2 100644 --- a/dist/src/main/amoro-bin/conf/config.yaml +++ b/dist/src/main/amoro-bin/conf/config.yaml @@ -82,6 +82,7 @@ ams: integration: enabled: false # hms-uri: thrift://hms-service.<namespace>.svc.cluster.local:9083 + catalog-sync-interval: 5min # tos-endpoint: https://tos-cn-beijing.volces.com # iam-endpoint: https://iam.volcengineapi.com # emr-serverless-endpoint: https://open.volcengineapi.com @@ -96,6 +97,7 @@ ams: role-session-name: AmoroAssumeRoleSession assume-role-ttl: 1h credential-cache-size: 1000 + data-role-name: ServiceRoleForLAS cross-vpc: enabled: false # account-id: <account-id> @@ -146,6 +148,11 @@ ams: catalog-meta-cache: expiration-interval: 60s + # Optional namespace level for catalog browsing. LAS deployments enable this and typically set + # refresh-external-catalogs.interval to 10min; the community defaults remain unchanged. + catalog: + namespace-enabled: false + # Support for encrypted sensitive configuration items shade: identifier: default # Built-in support for default/base64. Defaults to "default", indicating no encryption diff --git a/docs/configuration/ams-config.md b/docs/configuration/ams-config.md index 2a6c6b6e8..32003801a 100644 --- a/docs/configuration/ams-config.md +++ b/docs/configuration/ams-config.md @@ -46,6 +46,7 @@ table td:last-child, table th:last-child { width: 40%; word-break: break-all; } | admin-username | admin | The administrator account name. | | blocker.timeout | 1 min | Session timeout. Default unit is milliseconds if not specified. | | catalog-meta-cache.expiration-interval | 1 min | TTL for catalog metadata. | +| catalog.namespace-enabled | false | Whether catalogs are grouped by an optional namespace. | | database.auto-create-tables | true | Auto init table schema when started | | database.connection-pool-max-idle | 16 | Max idle connect count of database connect pool. | | database.connection-pool-max-total | 20 | Max connect count of database connect pool. | @@ -232,4 +233,3 @@ table td:last-child, table th:last-child { width: 40%; word-break: break-all; } | shade.identifier | default | The identifier of the encryption method for decryption. Defaults to "default", indicating no encryption | | shade.sensitive-keywords | admin-password;database.password | A semicolon-separated list of keywords for the configuration items to be decrypted. | -
