From 70113b0fafe0aa9b6b8005ef5ed3263d9e137485 Mon Sep 17 00:00:00 2001 From: Dmitriy Fingerman Date: Tue, 17 Feb 2026 11:57:08 -0500 Subject: [PATCH 1/2] HIVE-29468: Standalone HMS REST Catalog Server: Migrate to Spring Boot --- itests/qtest-iceberg/pom.xml | 21 ++ .../cli/TestStandaloneRESTCatalogServer.java | 279 +++++++++++------- .../metastore-rest-catalog/pom.xml | 120 ++++++++ .../HMSReadinessHealthIndicator.java | 69 +++++ .../StandaloneRESTCatalogServer.java | 185 ++++++------ .../src/main/resources/application.properties | 27 ++ 6 files changed, 505 insertions(+), 196 deletions(-) create mode 100644 standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/standalone/HMSReadinessHealthIndicator.java create mode 100644 standalone-metastore/metastore-rest-catalog/src/main/resources/application.properties diff --git a/itests/qtest-iceberg/pom.xml b/itests/qtest-iceberg/pom.xml index bf8121923183..f625dbbc7fb1 100644 --- a/itests/qtest-iceberg/pom.xml +++ b/itests/qtest-iceberg/pom.xml @@ -475,6 +475,27 @@ ${project.version} test + + + org.springframework.boot + spring-boot-starter-test + 2.7.18 + test + + + org.springframework.boot + spring-boot-starter-logging + + + org.junit.jupiter + junit-jupiter + + + org.junit.vintage + junit-vintage-engine + + + org.testcontainers testcontainers diff --git a/itests/qtest-iceberg/src/test/java/org/apache/hadoop/hive/cli/TestStandaloneRESTCatalogServer.java b/itests/qtest-iceberg/src/test/java/org/apache/hadoop/hive/cli/TestStandaloneRESTCatalogServer.java index a5ec398d4b2b..4606079aea7b 100644 --- a/itests/qtest-iceberg/src/test/java/org/apache/hadoop/hive/cli/TestStandaloneRESTCatalogServer.java +++ b/itests/qtest-iceberg/src/test/java/org/apache/hadoop/hive/cli/TestStandaloneRESTCatalogServer.java @@ -31,114 +31,199 @@ import org.apache.hadoop.hive.metastore.conf.MetastoreConf; import org.apache.hadoop.hive.metastore.conf.MetastoreConf.ConfVars; import org.apache.iceberg.rest.standalone.StandaloneRESTCatalogServer; -import org.junit.After; -import org.junit.Before; +import org.junit.AfterClass; import org.junit.Test; +import org.junit.runner.RunWith; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.context.annotation.Bean; +import org.springframework.test.context.TestContext; +import org.springframework.test.context.TestExecutionListener; +import org.springframework.test.context.TestExecutionListeners; +import org.springframework.test.context.junit4.SpringRunner; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; /** - * Integration test for Standalone REST Catalog Server. - * + * Integration test for Standalone REST Catalog Server with Spring Boot. + * * Tests that the standalone server can: - * 1. Start independently of HMS + * 1. Start independently of HMS using Spring Boot * 2. Connect to an external HMS instance * 3. Serve REST Catalog requests - * 4. Provide health check endpoint + * 4. Provide health check endpoints (liveness and readiness) + * 5. Expose Prometheus metrics */ +@RunWith(SpringRunner.class) +@SpringBootTest( + classes = {StandaloneRESTCatalogServer.class, TestStandaloneRESTCatalogServer.TestConfig.class}, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = { + "spring.main.allow-bean-definition-overriding=true", + "spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration" + } +) +@TestExecutionListeners( + listeners = TestStandaloneRESTCatalogServer.HmsStartupListener.class, + mergeMode = TestExecutionListeners.MergeMode.MERGE_WITH_DEFAULTS +) public class TestStandaloneRESTCatalogServer { private static final Logger LOG = LoggerFactory.getLogger(TestStandaloneRESTCatalogServer.class); - - private Configuration hmsConf; - private Configuration restCatalogConf; - private int hmsPort; - private StandaloneRESTCatalogServer restCatalogServer; - private File warehouseDir; - private File hmsTempDir; - - @Before - public void setup() throws Exception { - // Setup temporary directories - hmsTempDir = new File(System.getProperty("java.io.tmpdir"), "test-hms-" + System.currentTimeMillis()); - hmsTempDir.mkdirs(); - warehouseDir = new File(hmsTempDir, "warehouse"); - warehouseDir.mkdirs(); - - // Configure and start embedded HMS - hmsConf = MetastoreConf.newMetastoreConf(); - MetaStoreTestUtils.setConfForStandloneMode(hmsConf); - - String jdbcUrl = String.format("jdbc:derby:memory:%s;create=true", - new File(hmsTempDir, "metastore_db").getAbsolutePath()); - MetastoreConf.setVar(hmsConf, ConfVars.CONNECT_URL_KEY, jdbcUrl); - MetastoreConf.setVar(hmsConf, ConfVars.WAREHOUSE, warehouseDir.getAbsolutePath()); - MetastoreConf.setVar(hmsConf, ConfVars.WAREHOUSE_EXTERNAL, warehouseDir.getAbsolutePath()); - - // Start HMS - hmsPort = MetaStoreTestUtils.startMetaStoreWithRetry( - HadoopThriftAuthBridge.getBridge(), hmsConf, true, false, false, false); - LOG.info("Started embedded HMS on port: {}", hmsPort); - - // Configure standalone REST Catalog server - restCatalogConf = MetastoreConf.newMetastoreConf(); - String hmsUri = "thrift://localhost:" + hmsPort; - MetastoreConf.setVar(restCatalogConf, ConfVars.THRIFT_URIS, hmsUri); - MetastoreConf.setVar(restCatalogConf, ConfVars.WAREHOUSE, warehouseDir.getAbsolutePath()); - MetastoreConf.setVar(restCatalogConf, ConfVars.WAREHOUSE_EXTERNAL, warehouseDir.getAbsolutePath()); - - // Configure REST Catalog servlet - int restPort = MetaStoreTestUtils.findFreePort(); - MetastoreConf.setLongVar(restCatalogConf, ConfVars.CATALOG_SERVLET_PORT, restPort); - MetastoreConf.setVar(restCatalogConf, ConfVars.ICEBERG_CATALOG_SERVLET_PATH, "iceberg"); - MetastoreConf.setVar(restCatalogConf, ConfVars.CATALOG_SERVLET_AUTH, "none"); - - // Start standalone REST Catalog server - restCatalogServer = new StandaloneRESTCatalogServer(restCatalogConf); - restCatalogServer.start(); - LOG.info("Started standalone REST Catalog server on port: {}", restCatalogServer.getPort()); + + @LocalServerPort + private int port; + + @Autowired + private StandaloneRESTCatalogServer server; + + private static Configuration hmsConf; + private static int hmsPort; + private static File warehouseDir; + private static File hmsTempDir; + + /** + * Starts HMS before the Spring ApplicationContext loads. + * Spring loads the context before @BeforeClass, so we use a TestExecutionListener + * which runs before context initialization. + */ + public static class HmsStartupListener implements TestExecutionListener { + @Override + public void beforeTestClass(TestContext testContext) throws Exception { + if (hmsPort > 0) { + return; // Already started + } + hmsTempDir = new File(System.getProperty("java.io.tmpdir"), "test-hms-" + System.currentTimeMillis()); + hmsTempDir.mkdirs(); + warehouseDir = new File(hmsTempDir, "warehouse"); + warehouseDir.mkdirs(); + + hmsConf = MetastoreConf.newMetastoreConf(); + MetaStoreTestUtils.setConfForStandloneMode(hmsConf); + + String jdbcUrl = String.format("jdbc:derby:memory:%s;create=true", + new File(hmsTempDir, "metastore_db").getAbsolutePath()); + MetastoreConf.setVar(hmsConf, ConfVars.CONNECT_URL_KEY, jdbcUrl); + MetastoreConf.setVar(hmsConf, ConfVars.WAREHOUSE, warehouseDir.getAbsolutePath()); + MetastoreConf.setVar(hmsConf, ConfVars.WAREHOUSE_EXTERNAL, warehouseDir.getAbsolutePath()); + + hmsPort = MetaStoreTestUtils.startMetaStoreWithRetry( + HadoopThriftAuthBridge.getBridge(), hmsConf, true, false, false, false); + LOG.info("Started embedded HMS on port: {} (before Spring context)", hmsPort); + } } - - @After - public void teardown() { - if (restCatalogServer != null) { - restCatalogServer.stop(); + + /** + * Test configuration that provides the Configuration bean. + * Spring injects this into StandaloneRESTCatalogServer constructor. + */ + @TestConfiguration + public static class TestConfig { + @Bean + public Configuration hadoopConfiguration() { + // Create Configuration for REST Catalog (standard Hive approach) + Configuration restCatalogConf = MetastoreConf.newMetastoreConf(); + String hmsUri = "thrift://localhost:" + hmsPort; + MetastoreConf.setVar(restCatalogConf, ConfVars.THRIFT_URIS, hmsUri); + MetastoreConf.setVar(restCatalogConf, ConfVars.WAREHOUSE, warehouseDir.getAbsolutePath()); + MetastoreConf.setVar(restCatalogConf, ConfVars.WAREHOUSE_EXTERNAL, warehouseDir.getAbsolutePath()); + MetastoreConf.setVar(restCatalogConf, ConfVars.ICEBERG_CATALOG_SERVLET_PATH, "iceberg"); + MetastoreConf.setVar(restCatalogConf, ConfVars.CATALOG_SERVLET_AUTH, "none"); + // HMSCatalogFactory returns null when CATALOG_SERVLET_PORT is -1; use 0 for Spring Boot managed port + MetastoreConf.setLongVar(restCatalogConf, ConfVars.CATALOG_SERVLET_PORT, 0); + return restCatalogConf; } + } + + @AfterClass + public static void teardownClass() { if (hmsPort > 0) { MetaStoreTestUtils.close(hmsPort); } if (hmsTempDir != null && hmsTempDir.exists()) { - deleteDirectory(hmsTempDir); + deleteDirectoryStatic(hmsTempDir); + } + } + + private static void deleteDirectoryStatic(File directory) { + if (directory.exists()) { + File[] files = directory.listFiles(); + if (files != null) { + for (File file : files) { + if (file.isDirectory()) { + deleteDirectoryStatic(file); + } else { + file.delete(); + } + } + } + directory.delete(); + } + } + + @Test(timeout = 60000) + public void testLivenessProbe() throws Exception { + LOG.info("=== Test: Liveness Probe (Kubernetes) ==="); + + String livenessUrl = "http://localhost:" + port + "/actuator/health/liveness"; + try (CloseableHttpClient httpClient = HttpClients.createDefault()) { + HttpGet request = new HttpGet(livenessUrl); + try (CloseableHttpResponse response = httpClient.execute(request)) { + assertEquals("Liveness probe should return 200", 200, response.getStatusLine().getStatusCode()); + String body = EntityUtils.toString(response.getEntity()); + assertTrue("Liveness should be UP", body.contains("UP")); + LOG.info("Liveness probe passed: {}", body); + } } } - + + @Test(timeout = 60000) + public void testReadinessProbe() throws Exception { + LOG.info("=== Test: Readiness Probe (Kubernetes) ==="); + + String readinessUrl = "http://localhost:" + port + "/actuator/health/readiness"; + try (CloseableHttpClient httpClient = HttpClients.createDefault()) { + HttpGet request = new HttpGet(readinessUrl); + try (CloseableHttpResponse response = httpClient.execute(request)) { + assertEquals("Readiness probe should return 200", 200, response.getStatusLine().getStatusCode()); + String body = EntityUtils.toString(response.getEntity()); + assertTrue("Readiness should be UP", body.contains("UP")); + LOG.info("Readiness probe passed: {}", body); + } + } + } + @Test(timeout = 60000) - public void testHealthCheck() throws Exception { - LOG.info("=== Test: Health Check ==="); - - String healthUrl = "http://localhost:" + restCatalogServer.getPort() + "/health"; + public void testPrometheusMetrics() throws Exception { + LOG.info("=== Test: Prometheus Metrics (for K8s HPA) ==="); + + String metricsUrl = "http://localhost:" + port + "/actuator/prometheus"; try (CloseableHttpClient httpClient = HttpClients.createDefault()) { - HttpGet request = new HttpGet(healthUrl); + HttpGet request = new HttpGet(metricsUrl); try (CloseableHttpResponse response = httpClient.execute(request)) { - assertEquals("Health check should return 200", 200, response.getStatusLine().getStatusCode()); - LOG.info("Health check passed"); + assertEquals("Metrics endpoint should return 200", 200, response.getStatusLine().getStatusCode()); + String body = EntityUtils.toString(response.getEntity()); + assertTrue("Should contain JVM metrics", body.contains("jvm_memory")); + LOG.info("Prometheus metrics available"); } } } - + @Test(timeout = 60000) public void testRESTCatalogConfig() throws Exception { LOG.info("=== Test: REST Catalog Config Endpoint ==="); - - String configUrl = restCatalogServer.getRestEndpoint() + "/v1/config"; + + String configUrl = "http://localhost:" + port + "/iceberg/v1/config"; try (CloseableHttpClient httpClient = HttpClients.createDefault()) { HttpGet request = new HttpGet(configUrl); try (CloseableHttpResponse response = httpClient.execute(request)) { assertEquals("Config endpoint should return 200", 200, response.getStatusLine().getStatusCode()); - + String responseBody = EntityUtils.toString(response.getEntity()); LOG.info("Config response: {}", responseBody); // ConfigResponse should contain endpoints, defaults, and overrides @@ -147,14 +232,14 @@ public void testRESTCatalogConfig() throws Exception { } } } - + @Test(timeout = 60000) public void testRESTCatalogNamespaceOperations() throws Exception { LOG.info("=== Test: REST Catalog Namespace Operations ==="); - - String namespacesUrl = restCatalogServer.getRestEndpoint() + "/v1/namespaces"; + + String namespacesUrl = "http://localhost:" + port + "/iceberg/v1/namespaces"; String namespaceName = "testdb"; - + try (CloseableHttpClient httpClient = HttpClients.createDefault()) { // List namespaces (before creation) HttpGet listRequest = new HttpGet(namespacesUrl); @@ -162,66 +247,50 @@ public void testRESTCatalogNamespaceOperations() throws Exception { try (CloseableHttpResponse response = httpClient.execute(listRequest)) { assertEquals("List namespaces should return 200", 200, response.getStatusLine().getStatusCode()); } - + // Create namespace - REST Catalog API requires JSON body with namespace array HttpPost createRequest = new HttpPost(namespacesUrl); createRequest.setHeader("Content-Type", "application/json"); String jsonBody = "{\"namespace\":[\"" + namespaceName + "\"]}"; createRequest.setEntity(new StringEntity(jsonBody, "UTF-8")); - + try (CloseableHttpResponse response = httpClient.execute(createRequest)) { assertEquals("Create namespace should return 200", 200, response.getStatusLine().getStatusCode()); } - + // Verify namespace exists by checking it in the list HttpGet listAfterRequest = new HttpGet(namespacesUrl); listAfterRequest.setHeader("Content-Type", "application/json"); try (CloseableHttpResponse response = httpClient.execute(listAfterRequest)) { - assertEquals("List namespaces after creation should return 200", + assertEquals("List namespaces after creation should return 200", 200, response.getStatusLine().getStatusCode()); - + String responseBody = EntityUtils.toString(response.getEntity()); LOG.info("Namespaces list response: {}", responseBody); assertTrue("Response should contain created namespace", responseBody.contains(namespaceName)); } - + // Verify namespace exists by getting it directly - String getNamespaceUrl = restCatalogServer.getRestEndpoint() + "/v1/namespaces/" + namespaceName; + String getNamespaceUrl = "http://localhost:" + port + "/iceberg/v1/namespaces/" + namespaceName; HttpGet getRequest = new HttpGet(getNamespaceUrl); getRequest.setHeader("Content-Type", "application/json"); try (CloseableHttpResponse response = httpClient.execute(getRequest)) { - assertEquals("Get namespace should return 200", + assertEquals("Get namespace should return 200", 200, response.getStatusLine().getStatusCode()); String responseBody = EntityUtils.toString(response.getEntity()); LOG.info("Get namespace response: {}", responseBody); assertTrue("Response should contain namespace", responseBody.contains(namespaceName)); } } - + LOG.info("Namespace operations passed"); } - + @Test(timeout = 60000) public void testServerPort() { LOG.info("=== Test: Server Port ==="); - assertTrue("Server port should be > 0", restCatalogServer.getPort() > 0); - assertNotNull("REST endpoint should not be null", restCatalogServer.getRestEndpoint()); - LOG.info("Server port: {}, Endpoint: {}", restCatalogServer.getPort(), restCatalogServer.getRestEndpoint()); - } - - private void deleteDirectory(File directory) { - if (directory.exists()) { - File[] files = directory.listFiles(); - if (files != null) { - for (File file : files) { - if (file.isDirectory()) { - deleteDirectory(file); - } else { - file.delete(); - } - } - } - directory.delete(); - } + assertTrue("Server port should be > 0", port > 0); + assertNotNull("REST endpoint should not be null", server.getRestEndpoint()); + LOG.info("Server port: {}, Endpoint: {}", port, server.getRestEndpoint()); } } diff --git a/standalone-metastore/metastore-rest-catalog/pom.xml b/standalone-metastore/metastore-rest-catalog/pom.xml index 896723d22768..f115ce36153c 100644 --- a/standalone-metastore/metastore-rest-catalog/pom.xml +++ b/standalone-metastore/metastore-rest-catalog/pom.xml @@ -24,7 +24,80 @@ UTF-8 false 1.10.0 + 2.7.18 + + 9.4.57.v20241219 + + + + + org.eclipse.jetty + jetty-servlets + ${jetty.version} + + + org.eclipse.jetty + jetty-webapp + ${jetty.version} + + + org.eclipse.jetty + jetty-continuation + ${jetty.version} + + + org.eclipse.jetty + jetty-xml + ${jetty.version} + + + org.eclipse.jetty + jetty-annotations + ${jetty.version} + + + org.eclipse.jetty + jetty-plus + ${jetty.version} + + + org.eclipse.jetty + jetty-client + ${jetty.version} + + + org.eclipse.jetty.websocket + websocket-server + ${jetty.version} + + + org.eclipse.jetty.websocket + websocket-common + ${jetty.version} + + + org.eclipse.jetty.websocket + websocket-client + ${jetty.version} + + + org.eclipse.jetty.websocket + websocket-servlet + ${jetty.version} + + + org.eclipse.jetty.websocket + javax-websocket-server-impl + ${jetty.version} + + + org.eclipse.jetty.websocket + javax-websocket-client-impl + ${jetty.version} + + + org.apache.hive @@ -42,6 +115,37 @@ hive-iceberg-catalog ${hive.version} + + + org.springframework.boot + spring-boot-starter-web + ${spring-boot.version} + + + org.springframework.boot + spring-boot-starter-logging + + + org.springframework.boot + spring-boot-starter-tomcat + + + + + org.springframework.boot + spring-boot-starter-jetty + ${spring-boot.version} + + + org.springframework.boot + spring-boot-starter-actuator + ${spring-boot.version} + + + io.micrometer + micrometer-registry-prometheus + 1.9.17 + org.apache.hive @@ -303,6 +407,22 @@ + + org.springframework.boot + spring-boot-maven-plugin + ${spring-boot.version} + + org.apache.iceberg.rest.standalone.StandaloneRESTCatalogServer + exec + + + + + repackage + + + + diff --git a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/standalone/HMSReadinessHealthIndicator.java b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/standalone/HMSReadinessHealthIndicator.java new file mode 100644 index 000000000000..45780c69a495 --- /dev/null +++ b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/standalone/HMSReadinessHealthIndicator.java @@ -0,0 +1,69 @@ +/* + * 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.iceberg.rest.standalone; + +import org.apache.hadoop.conf.Configuration; +import org.apache.hadoop.hive.metastore.HiveMetaStoreClient; +import org.apache.hadoop.hive.metastore.conf.MetastoreConf; +import org.apache.hadoop.hive.metastore.conf.MetastoreConf.ConfVars; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.actuate.health.Health; +import org.springframework.boot.actuate.health.HealthIndicator; +import org.springframework.stereotype.Component; + +/** + * Custom health indicator for HMS connectivity. + * Verifies that HMS is reachable via Thrift, not just that configuration is present. + * Used by Kubernetes readiness probes to determine if the server is ready to accept traffic. + */ +@Component +public class HMSReadinessHealthIndicator implements HealthIndicator { + private static final Logger LOG = LoggerFactory.getLogger(HMSReadinessHealthIndicator.class); + + private final Configuration conf; + + public HMSReadinessHealthIndicator(Configuration conf) { + this.conf = conf; + } + + @Override + public Health health() { + String hmsThriftUris = MetastoreConf.getVar(conf, ConfVars.THRIFT_URIS); + if (hmsThriftUris == null || hmsThriftUris.isEmpty()) { + return Health.down() + .withDetail("reason", "HMS Thrift URIs not configured") + .build(); + } + + try (HiveMetaStoreClient client = new HiveMetaStoreClient(conf)) { + // Lightweight call to verify HMS is reachable + client.getAllDatabases(); + return Health.up() + .withDetail("hmsThriftUris", hmsThriftUris) + .withDetail("warehouse", MetastoreConf.getVar(conf, ConfVars.WAREHOUSE)) + .build(); + } catch (Exception e) { + LOG.warn("HMS connectivity check failed: {}", e.getMessage()); + return Health.down() + .withDetail("hmsThriftUris", hmsThriftUris) + .withDetail("error", e.getMessage()) + .build(); + } + } +} diff --git a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/standalone/StandaloneRESTCatalogServer.java b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/standalone/StandaloneRESTCatalogServer.java index 79c89b2cae8d..499a72db84fe 100644 --- a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/standalone/StandaloneRESTCatalogServer.java +++ b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/standalone/StandaloneRESTCatalogServer.java @@ -18,23 +18,24 @@ package org.apache.iceberg.rest.standalone; import javax.servlet.http.HttpServlet; -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpServletResponse; import com.google.common.annotations.VisibleForTesting; import org.apache.hadoop.conf.Configuration; -import org.apache.hadoop.hive.metastore.ServletServerBuilder; import org.apache.hadoop.hive.metastore.conf.MetastoreConf; import org.apache.hadoop.hive.metastore.conf.MetastoreConf.ConfVars; import org.apache.iceberg.rest.HMSCatalogFactory; -import org.eclipse.jetty.server.Server; import org.slf4j.Logger; import org.slf4j.LoggerFactory; - -import java.io.IOException; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; +import org.springframework.boot.web.context.WebServerInitializedEvent; +import org.springframework.boot.web.servlet.ServletRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.event.EventListener; /** - * Standalone REST Catalog Server. + * Standalone REST Catalog Server with Spring Boot. * *

This server runs independently of HMS and provides a REST API for Iceberg catalog operations. * It connects to an external HMS instance via Thrift. @@ -46,85 +47,107 @@ * *

Multiple instances can run behind a Kubernetes Service for load balancing. */ +@SpringBootApplication(exclude = DataSourceAutoConfiguration.class) public class StandaloneRESTCatalogServer { private static final Logger LOG = LoggerFactory.getLogger(StandaloneRESTCatalogServer.class); private final Configuration conf; - private Server server; + private String restEndpoint; private int port; - public StandaloneRESTCatalogServer(Configuration conf) { - this.conf = conf; - } - /** - * Starts the standalone REST Catalog server. + * Constructor that accepts Configuration. + * Standard Hive approach - caller controls Configuration creation. */ - public void start() { + public StandaloneRESTCatalogServer(Configuration conf) { + this.conf = conf; + // Validate required configuration String thriftUris = MetastoreConf.getVar(conf, ConfVars.THRIFT_URIS); if (thriftUris == null || thriftUris.isEmpty()) { throw new IllegalArgumentException("metastore.thrift.uris must be configured to connect to HMS"); } - int servletPort = MetastoreConf.getIntVar(conf, ConfVars.CATALOG_SERVLET_PORT); + LOG.info("Hadoop Configuration initialized"); + LOG.info(" HMS Thrift URIs: {}", thriftUris); + LOG.info(" Warehouse: {}", MetastoreConf.getVar(conf, ConfVars.WAREHOUSE)); + } + + /** + * Exposes the Configuration as a Spring bean. + */ + @Bean + public Configuration hadoopConfiguration() { + return conf; + } + + /** + * Registers the REST Catalog servlet with Spring Boot. + */ + @Bean + public ServletRegistrationBean restCatalogServlet() { + // Get servlet path from config or use default String servletPath = MetastoreConf.getVar(conf, ConfVars.ICEBERG_CATALOG_SERVLET_PATH); - if (servletPath == null || servletPath.isEmpty()) { servletPath = "iceberg"; // Default path MetastoreConf.setVar(conf, ConfVars.ICEBERG_CATALOG_SERVLET_PATH, servletPath); } - LOG.info("Starting Standalone REST Catalog Server"); - LOG.info(" HMS Thrift URIs: {}", thriftUris); - LOG.info(" Servlet Port: {}", servletPort); + // Get port from configuration or use default + port = MetastoreConf.getIntVar(conf, ConfVars.CATALOG_SERVLET_PORT); + if (port == 0) { + port = 8080; // Default port + MetastoreConf.setLongVar(conf, ConfVars.CATALOG_SERVLET_PORT, port); + } + + LOG.info("Creating REST Catalog servlet"); LOG.info(" Servlet Path: /{}", servletPath); + LOG.info(" Port: {}", port); // Create servlet using factory - ServletServerBuilder.Descriptor catalogDescriptor = HMSCatalogFactory.createServlet(conf); - if (catalogDescriptor == null) { + org.apache.hadoop.hive.metastore.ServletServerBuilder.Descriptor descriptor = + HMSCatalogFactory.createServlet(conf); + if (descriptor == null) { + throw new IllegalStateException( + "HMSCatalogFactory.createServlet returned null. Ensure metastore.catalog.servlet.port " + + "is set to 0 or a positive value (negative disables the servlet)."); + } + HttpServlet catalogServlet = descriptor.getServlet(); + if (catalogServlet == null) { throw new IllegalStateException("Failed to create REST Catalog servlet. " + "Check that metastore.catalog.servlet.port and metastore.iceberg.catalog.servlet.path are configured."); } - // Create health check servlet - HealthCheckServlet healthServlet = new HealthCheckServlet(); + // Register servlet with Spring Boot + ServletRegistrationBean registration = + new ServletRegistrationBean<>(catalogServlet, "/" + servletPath + "/*"); + registration.setName("IcebergRESTCatalog"); + registration.setLoadOnStartup(1); - // Build and start server - ServletServerBuilder builder = new ServletServerBuilder(conf); - builder.addServlet(catalogDescriptor); - builder.addServlet(servletPort, "health", healthServlet); + // Store endpoint + restEndpoint = "http://localhost:" + port + "/" + servletPath; - server = builder.start(LOG); - if (server == null || !server.isStarted()) { - // Server failed to start - likely a port conflict - throw new IllegalStateException(String.format( - "Failed to start REST Catalog server on port %d. Port may already be in use. ", servletPort)); - } + LOG.info("REST Catalog servlet registered successfully"); + LOG.info(" REST Catalog endpoint: {}", restEndpoint); - // Get actual port (may be auto-assigned) - port = catalogDescriptor.getPort(); - LOG.info("Standalone REST Catalog Server started successfully on port {}", port); - LOG.info(" REST Catalog endpoint: http://localhost:{}/{}", port, servletPath); - LOG.info(" Health check endpoint: http://localhost:{}/health", port); + return registration; } - + /** - * Stops the server. + * Updates port and restEndpoint with the actual server port once the web server has started. + * Handles RANDOM_PORT (tests) and server.port=0 where the real port differs from config. */ - public void stop() { - if (server != null && server.isStarted()) { - try { - LOG.info("Stopping Standalone REST Catalog Server"); - server.stop(); - server.join(); - LOG.info("Standalone REST Catalog Server stopped"); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - LOG.warn("Server stop interrupted", e); - } catch (Exception e) { - LOG.error("Error stopping server", e); + @EventListener + public void onWebServerInitialized(WebServerInitializedEvent event) { + int actualPort = event.getWebServer().getPort(); + if (actualPort > 0) { + this.port = actualPort; + String servletPath = MetastoreConf.getVar(conf, ConfVars.ICEBERG_CATALOG_SERVLET_PATH); + if (servletPath == null || servletPath.isEmpty()) { + servletPath = "iceberg"; } + this.restEndpoint = "http://localhost:" + actualPort + "/" + servletPath; + LOG.info("REST endpoint set to actual server port: {}", restEndpoint); } } @@ -142,27 +165,7 @@ public int getPort() { * @return the endpoint URL */ public String getRestEndpoint() { - String servletPath = MetastoreConf.getVar(conf, ConfVars.ICEBERG_CATALOG_SERVLET_PATH); - if (servletPath == null || servletPath.isEmpty()) { - servletPath = "iceberg"; - } - return "http://localhost:" + port + "/" + servletPath; - } - - /** - * Simple health check servlet for Kubernetes readiness/liveness probes. - */ - private static final class HealthCheckServlet extends HttpServlet { - @Override - protected void doGet(HttpServletRequest req, HttpServletResponse resp) { - try { - resp.setContentType("application/json"); - resp.setStatus(HttpServletResponse.SC_OK); - resp.getWriter().println("{\"status\":\"healthy\"}"); - } catch (IOException e) { - LOG.warn("Failed to write health check response", e); - } - } + return restEndpoint; } /** @@ -183,26 +186,26 @@ public static void main(String[] args) { } } + // Sync port from MetastoreConf to Spring's Environment so server.port uses it + int port = MetastoreConf.getIntVar(conf, ConfVars.CATALOG_SERVLET_PORT); + if (port > 0) { + System.setProperty(ConfVars.CATALOG_SERVLET_PORT.getVarname(), String.valueOf(port)); + } + StandaloneRESTCatalogServer server = new StandaloneRESTCatalogServer(conf); - // Add shutdown hook - Runtime.getRuntime().addShutdownHook(new Thread(() -> { - LOG.info("Shutdown hook triggered"); - server.stop(); - })); + // Start Spring Boot with the pre-configured server instance + SpringApplication app = new SpringApplication(StandaloneRESTCatalogServer.class); + app.addInitializers(ctx -> { + // Register the pre-created server instance as the primary bean + ctx.getBeanFactory().registerSingleton("standaloneRESTCatalogServer", server); + }); - try { - server.start(); - LOG.info("Server running. Press Ctrl+C to stop."); - - // Keep server running - server.server.join(); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - LOG.warn("Server stop interrupted", e); - } catch (Exception e) { - LOG.error("Failed to start server", e); - System.exit(1); - } + app.run(args); + + LOG.info("Standalone REST Catalog Server started successfully"); + LOG.info("Server running. Press Ctrl+C to stop."); + + // Spring Boot's graceful shutdown will handle cleanup automatically } } diff --git a/standalone-metastore/metastore-rest-catalog/src/main/resources/application.properties b/standalone-metastore/metastore-rest-catalog/src/main/resources/application.properties new file mode 100644 index 000000000000..a631a8c95e82 --- /dev/null +++ b/standalone-metastore/metastore-rest-catalog/src/main/resources/application.properties @@ -0,0 +1,27 @@ +# Spring Boot Configuration for Standalone HMS REST Catalog Server + +# Server configuration +# Port is set via MetastoreConf.CATALOG_SERVLET_PORT +server.port=${metastore.catalog.servlet.port:8080} +server.shutdown=graceful +spring.lifecycle.timeout-per-shutdown-phase=30s + +# Actuator endpoints for Kubernetes +management.endpoints.web.exposure.include=health,prometheus,info +management.endpoint.health.show-details=always +management.endpoint.health.probes.enabled=true +management.health.livenessState.enabled=true +management.health.readinessState.enabled=true + +# Prometheus metrics for HPA +management.metrics.export.prometheus.enabled=true + +# Logging +logging.level.org.apache.iceberg.rest.standalone=INFO +logging.level.org.apache.hadoop.hive.metastore=INFO +logging.level.org.springframework.boot=WARN + +# Application info +info.app.name=Standalone HMS REST Catalog Server +info.app.description=Standalone REST Catalog Server for Apache Hive Metastore +info.app.version=@project.version@ From 2e24788ef3e7f61fd9bf71628a00df9bee064c81 Mon Sep 17 00:00:00 2001 From: Dmitriy Fingerman Date: Wed, 18 Feb 2026 10:01:55 -0500 Subject: [PATCH 2/2] Fixing spring-boot missing artifacts. --- itests/qtest-iceberg/pom.xml | 12 ++++++++++++ packaging/pom.xml | 3 +++ standalone-metastore/metastore-rest-catalog/pom.xml | 12 ++++++++++++ 3 files changed, 27 insertions(+) diff --git a/itests/qtest-iceberg/pom.xml b/itests/qtest-iceberg/pom.xml index f625dbbc7fb1..8ff2c1f1a501 100644 --- a/itests/qtest-iceberg/pom.xml +++ b/itests/qtest-iceberg/pom.xml @@ -23,6 +23,18 @@ hive-it-iceberg-qfile jar Hive Integration - QFile Iceberg Tests + + + central + https://repo.maven.apache.org/maven2 + + true + + + false + + + ../.. diff --git a/packaging/pom.xml b/packaging/pom.xml index 9b9ff3c8b499..46949bd66b7f 100644 --- a/packaging/pom.xml +++ b/packaging/pom.xml @@ -184,6 +184,9 @@ https?://(www\.)?opensource\.org/licenses/mit(-license.php)? + + https?://creativecommons\.org/publicdomain/zero/1\.0/? + diff --git a/standalone-metastore/metastore-rest-catalog/pom.xml b/standalone-metastore/metastore-rest-catalog/pom.xml index f115ce36153c..35a9829414bd 100644 --- a/standalone-metastore/metastore-rest-catalog/pom.xml +++ b/standalone-metastore/metastore-rest-catalog/pom.xml @@ -19,6 +19,18 @@ 4.0.0 hive-standalone-metastore-rest-catalog Hive Metastore REST Catalog + + + central + https://repo.maven.apache.org/maven2 + + true + + + false + + + .. UTF-8