diff --git a/itests/hive-iceberg/pom.xml b/itests/hive-iceberg/pom.xml index 5e661cc65e93..fca0b1e4d8f1 100644 --- a/itests/hive-iceberg/pom.xml +++ b/itests/hive-iceberg/pom.xml @@ -51,6 +51,12 @@ ${keycloak.version} test + + jakarta.annotation + jakarta.annotation-api + ${jakarta.annotation.version} + test + org.apache.hive hive-standalone-metastore-common diff --git a/itests/qtest-iceberg/pom.xml b/itests/qtest-iceberg/pom.xml index bf8121923183..3dc736007f4e 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 + ${spring-boot.version} + 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/BaseStandaloneRESTCatalogServerTest.java b/itests/qtest-iceberg/src/test/java/org/apache/hadoop/hive/cli/BaseStandaloneRESTCatalogServerTest.java new file mode 100644 index 000000000000..74c7314bf6e2 --- /dev/null +++ b/itests/qtest-iceberg/src/test/java/org/apache/hadoop/hive/cli/BaseStandaloneRESTCatalogServerTest.java @@ -0,0 +1,209 @@ +/* + * 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.hadoop.hive.cli; + +import java.io.File; +import java.io.IOException; +import java.util.UUID; + +import org.apache.hadoop.conf.Configuration; +import org.apache.hadoop.hive.common.FileUtils; +import org.apache.http.HttpHeaders; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.entity.StringEntity; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClients; +import org.apache.http.util.EntityUtils; +import org.apache.hadoop.hive.metastore.MetaStoreTestUtils; +import org.apache.hadoop.hive.metastore.security.HadoopThriftAuthBridge; +import org.apache.hadoop.hive.metastore.conf.MetastoreConf; +import org.apache.hadoop.hive.metastore.conf.MetastoreConf.ConfVars; +import org.apache.iceberg.rest.standalone.IcebergCatalogConfiguration; +import org.apache.iceberg.rest.standalone.StandaloneRESTCatalogServer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Import; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.test.context.TestContext; +import org.springframework.test.context.TestExecutionListener; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +/** + * Base class for Standalone REST Catalog Server integration tests. + * + * Provides shared setup (HMS, listeners), HTTP helpers (with optional auth), and common tests + * (liveness, readiness, Prometheus, server port). Subclasses provide auth-specific configuration + * and tests. + */ +public abstract class BaseStandaloneRESTCatalogServerTest { + protected static final Logger LOG = LoggerFactory.getLogger(BaseStandaloneRESTCatalogServerTest.class); + private static final String REST_CATALOG_URL_TEMPLATE = "http://localhost:%d%s"; + + protected static Configuration hmsConf; + protected static int hmsPort; + protected static File warehouseDir; + protected 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. + */ + @Order(Ordered.HIGHEST_PRECEDENCE) + public static class HmsStartupListener implements TestExecutionListener { + private static final String TEMP_DIR_PREFIX = "StandaloneRESTCatalogServer"; + + @Override + public void beforeTestClass(TestContext testContext) throws Exception { + if (hmsPort > 0) { + return; + } + String uniqueTestKey = String.format("%s_%s", TEMP_DIR_PREFIX, UUID.randomUUID()); + hmsTempDir = new File(MetaStoreTestUtils.getTestWarehouseDir(uniqueTestKey)); + 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); + } + } + + @SpringBootApplication + @Import(IcebergCatalogConfiguration.class) + public static class TestRestCatalogApplication {} + + protected String url(String path) { + return String.format(REST_CATALOG_URL_TEMPLATE, getPort(), path); + } + + /** + * Returns the server port. Subclasses must provide this (e.g. from @LocalServerPort). + */ + protected abstract int getPort(); + + /** + * Creates a GET request with optional Bearer token. + * + * @param path the request path (e.g. "/iceberg/v1/config") + * @param bearerToken optional Bearer token; if null, no Authorization header is set + */ + protected HttpGet get(String path, String bearerToken) { + HttpGet request = new HttpGet(url(path)); + request.setHeader("Content-Type", "application/json"); + if (bearerToken != null) { + request.setHeader(HttpHeaders.AUTHORIZATION, "Bearer " + bearerToken); + } + return request; + } + + /** + * Creates a GET request without auth. + */ + protected HttpGet get(String path) { + return get(path, null); + } + + /** + * Creates a POST request with optional Bearer token. + * + * @param path the request path + * @param jsonBody the JSON body + * @param bearerToken optional Bearer token; if null, no Authorization header is set + */ + protected HttpPost post(String path, String jsonBody, String bearerToken) { + HttpPost request = new HttpPost(url(path)); + request.setHeader("Content-Type", "application/json"); + if (bearerToken != null) { + request.setHeader(HttpHeaders.AUTHORIZATION, "Bearer " + bearerToken); + } + if (jsonBody != null) { + request.setEntity(new StringEntity(jsonBody, "UTF-8")); + } + return request; + } + + /** + * Creates a POST request without auth. + */ + protected HttpPost post(String path, String jsonBody) { + return post(path, jsonBody, null); + } + + protected static void teardownBase() throws IOException { + if (hmsPort > 0) { + MetaStoreTestUtils.close(hmsPort); + } + if (hmsTempDir != null && hmsTempDir.exists()) { + FileUtils.deleteDirectory(hmsTempDir); + } + } + + protected void testLivenessProbe() throws Exception { + try (CloseableHttpClient httpClient = HttpClients.createDefault(); + CloseableHttpResponse response = httpClient.execute(get("/actuator/health/liveness"))) { + 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); + } + } + + protected void testReadinessProbe() throws Exception { + try (CloseableHttpClient httpClient = HttpClients.createDefault(); + CloseableHttpResponse response = httpClient.execute(get("/actuator/health/readiness"))) { + 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); + } + } + + protected void testPrometheusMetrics() throws Exception { + try (CloseableHttpClient httpClient = HttpClients.createDefault(); + CloseableHttpResponse response = httpClient.execute(get("/actuator/prometheus"))) { + 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"); + } + } + + protected void testServerPort(StandaloneRESTCatalogServer server) { + assertTrue("Server port should be > 0", getPort() > 0); + assertNotNull("REST endpoint should not be null", server.getRestEndpoint()); + LOG.info("Server port: {}, Endpoint: {}", getPort(), server.getRestEndpoint()); + } +} 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..ad6c4d13d567 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 @@ -17,211 +17,171 @@ */ package org.apache.hadoop.hive.cli; -import java.io.File; +import java.io.IOException; + import org.apache.hadoop.conf.Configuration; import org.apache.http.client.methods.CloseableHttpResponse; -import org.apache.http.client.methods.HttpGet; -import org.apache.http.client.methods.HttpPost; -import org.apache.http.entity.StringEntity; import org.apache.http.impl.client.CloseableHttpClient; import org.apache.http.impl.client.HttpClients; import org.apache.http.util.EntityUtils; -import org.apache.hadoop.hive.metastore.MetaStoreTestUtils; -import org.apache.hadoop.hive.metastore.security.HadoopThriftAuthBridge; 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.slf4j.Logger; -import org.slf4j.LoggerFactory; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.context.ApplicationContextInitializer; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.test.context.ContextConfiguration; +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 (no auth). + * * 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 */ -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()); - } - - @After - public void teardown() { - if (restCatalogServer != null) { - restCatalogServer.stop(); +@RunWith(SpringRunner.class) +@SpringBootTest( + classes = BaseStandaloneRESTCatalogServerTest.TestRestCatalogApplication.class, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = { + "spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration" } - if (hmsPort > 0) { - MetaStoreTestUtils.close(hmsPort); - } - if (hmsTempDir != null && hmsTempDir.exists()) { - deleteDirectory(hmsTempDir); +) +@ContextConfiguration(initializers = TestStandaloneRESTCatalogServer.RestCatalogTestContextInitializer.class) +@TestExecutionListeners( + listeners = BaseStandaloneRESTCatalogServerTest.HmsStartupListener.class, + mergeMode = TestExecutionListeners.MergeMode.MERGE_WITH_DEFAULTS +) +public class TestStandaloneRESTCatalogServer extends BaseStandaloneRESTCatalogServerTest { + @LocalServerPort + private int port; + + @Autowired + private StandaloneRESTCatalogServer server; + + @Override + protected int getPort() { + return port; + } + + /** + * Registers Configuration and StandaloneRESTCatalogServer before the context loads. + * Mirrors production main() - we create both and register them, so Spring uses our + * Configuration (with THRIFT_URIS from HMS) and never tries to instantiate the server. + */ + public static class RestCatalogTestContextInitializer + implements ApplicationContextInitializer { + @Override + public void initialize(ConfigurableApplicationContext context) { + 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"); + MetastoreConf.setLongVar(restCatalogConf, ConfVars.CATALOG_SERVLET_PORT, 0); + context.getBeanFactory().registerSingleton("hadoopConfiguration", restCatalogConf); + StandaloneRESTCatalogServer server = new StandaloneRESTCatalogServer(restCatalogConf); + context.getBeanFactory().registerSingleton("standaloneRESTCatalogServer", server); } } - + + @AfterClass + public static void teardownClass() throws IOException { + teardownBase(); + } + + @Override @Test(timeout = 60000) - public void testHealthCheck() throws Exception { - LOG.info("=== Test: Health Check ==="); - - String healthUrl = "http://localhost:" + restCatalogServer.getPort() + "/health"; - try (CloseableHttpClient httpClient = HttpClients.createDefault()) { - HttpGet request = new HttpGet(healthUrl); - try (CloseableHttpResponse response = httpClient.execute(request)) { - assertEquals("Health check should return 200", 200, response.getStatusLine().getStatusCode()); - LOG.info("Health check passed"); - } - } + public void testLivenessProbe() throws Exception { + LOG.info("=== Test: Liveness Probe (Kubernetes) ==="); + super.testLivenessProbe(); } - + + @Override + @Test(timeout = 60000) + public void testReadinessProbe() throws Exception { + LOG.info("=== Test: Readiness Probe (Kubernetes) ==="); + super.testReadinessProbe(); + } + + @Override + @Test(timeout = 60000) + public void testPrometheusMetrics() throws Exception { + LOG.info("=== Test: Prometheus Metrics (for K8s HPA) ==="); + super.testPrometheusMetrics(); + } + @Test(timeout = 60000) public void testRESTCatalogConfig() throws Exception { LOG.info("=== Test: REST Catalog Config Endpoint ==="); - - String configUrl = restCatalogServer.getRestEndpoint() + "/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 - assertTrue("Response should contain endpoints", responseBody.contains("endpoints")); - assertTrue("Response should be valid JSON", responseBody.startsWith("{") && responseBody.endsWith("}")); - } + + try (CloseableHttpClient httpClient = HttpClients.createDefault(); + CloseableHttpResponse response = httpClient.execute(get("/iceberg/v1/config"))) { + assertEquals("Config endpoint should return 200", 200, response.getStatusLine().getStatusCode()); + + String responseBody = EntityUtils.toString(response.getEntity()); + LOG.info("Config response: {}", responseBody); + assertTrue("Response should contain endpoints", responseBody.contains("endpoints")); + assertTrue("Response should be valid JSON", responseBody.startsWith("{") && responseBody.endsWith("}")); } } - + @Test(timeout = 60000) public void testRESTCatalogNamespaceOperations() throws Exception { LOG.info("=== Test: REST Catalog Namespace Operations ==="); - - String namespacesUrl = restCatalogServer.getRestEndpoint() + "/v1/namespaces"; + + String namespacePath = "/iceberg/v1/namespaces"; String namespaceName = "testdb"; - + try (CloseableHttpClient httpClient = HttpClients.createDefault()) { - // List namespaces (before creation) - HttpGet listRequest = new HttpGet(namespacesUrl); - listRequest.setHeader("Content-Type", "application/json"); - try (CloseableHttpResponse response = httpClient.execute(listRequest)) { + try (CloseableHttpResponse response = httpClient.execute(get(namespacePath))) { 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)) { + + try (CloseableHttpResponse response = httpClient.execute( + post(namespacePath, "{\"namespace\":[\"" + namespaceName + "\"]}"))) { 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", + + try (CloseableHttpResponse response = httpClient.execute(get(namespacePath))) { + 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; - HttpGet getRequest = new HttpGet(getNamespaceUrl); - getRequest.setHeader("Content-Type", "application/json"); - try (CloseableHttpResponse response = httpClient.execute(getRequest)) { - assertEquals("Get namespace should return 200", + + try (CloseableHttpResponse response = httpClient.execute( + get("/iceberg/v1/namespaces/" + namespaceName))) { + 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(); - } + super.testServerPort(server); } } diff --git a/itests/qtest-iceberg/src/test/java/org/apache/hadoop/hive/cli/TestStandaloneRESTCatalogServerJwtAuth.java b/itests/qtest-iceberg/src/test/java/org/apache/hadoop/hive/cli/TestStandaloneRESTCatalogServerJwtAuth.java new file mode 100644 index 000000000000..75a0e62647db --- /dev/null +++ b/itests/qtest-iceberg/src/test/java/org/apache/hadoop/hive/cli/TestStandaloneRESTCatalogServerJwtAuth.java @@ -0,0 +1,224 @@ +/* + * 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.hadoop.hive.cli; + +import java.io.IOException; + +import org.apache.hadoop.conf.Configuration; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClients; +import org.apache.http.util.EntityUtils; +import org.apache.hadoop.hive.metastore.conf.MetastoreConf; +import org.apache.hadoop.hive.metastore.conf.MetastoreConf.ConfVars; +import org.apache.iceberg.rest.extension.OAuth2AuthorizationServer; +import org.apache.iceberg.rest.standalone.StandaloneRESTCatalogServer; +import org.junit.AfterClass; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.context.ApplicationContextInitializer; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.core.annotation.Order; +import org.springframework.core.Ordered; +import org.springframework.test.context.ContextConfiguration; +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.assertTrue; + +/** + * Integration test for Standalone REST Catalog Server with JWT authentication. + * + * Uses Keycloak (via Testcontainers) as the real OIDC server - matching the design of + * existing OAuth2 tests (TestRESTCatalogOAuth2Jwt). Verifies that the standalone server correctly + * enforces JWT auth. + * + *

Requires Docker to be available (Testcontainers starts Keycloak in a container). + * + *

Verifies: + * - Accepts valid JWT tokens from Keycloak + * - Rejects invalid/malformed tokens + * - Rejects requests without a Bearer token + */ +@RunWith(SpringRunner.class) +@SpringBootTest( + classes = BaseStandaloneRESTCatalogServerTest.TestRestCatalogApplication.class, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = { + "spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration" + } +) +@ContextConfiguration(initializers = TestStandaloneRESTCatalogServerJwtAuth.RestCatalogJwtTestContextInitializer.class) +@TestExecutionListeners( + listeners = { + BaseStandaloneRESTCatalogServerTest.HmsStartupListener.class, + TestStandaloneRESTCatalogServerJwtAuth.KeycloakStartupListener.class + }, + mergeMode = TestExecutionListeners.MergeMode.MERGE_WITH_DEFAULTS +) +public class TestStandaloneRESTCatalogServerJwtAuth extends BaseStandaloneRESTCatalogServerTest { + @LocalServerPort + private int port; + + @Autowired + private StandaloneRESTCatalogServer server; + + private static OAuth2AuthorizationServer authorizationServer; + + @Override + protected int getPort() { + return port; + } + + @Order(Ordered.HIGHEST_PRECEDENCE - 1) + public static class KeycloakStartupListener implements TestExecutionListener { + @Override + public void beforeTestClass(TestContext testContext) throws Exception { + if (authorizationServer != null) { + return; + } + // Use accessTokenHeaderTypeRfc9068=false so Keycloak emits "JWT" (not "at+jwt") in the token + // header - SimpleJWTAuthenticator accepts null and JWT but not "at+jwt" by default. + authorizationServer = new OAuth2AuthorizationServer( + org.testcontainers.containers.Network.newNetwork(), false); + authorizationServer.start(); + LOG.info("Started Keycloak authorization server at {}", authorizationServer.getIssuer()); + } + } + + public static class RestCatalogJwtTestContextInitializer + implements ApplicationContextInitializer { + @Override + public void initialize(ConfigurableApplicationContext context) { + String jwksUrl = authorizationServer.getIssuer() + "/protocol/openid-connect/certs"; + + 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, "jwt"); + MetastoreConf.setVar(restCatalogConf, ConfVars.THRIFT_METASTORE_AUTHENTICATION_JWT_JWKS_URL, jwksUrl); + MetastoreConf.setLongVar(restCatalogConf, ConfVars.CATALOG_SERVLET_PORT, 0); + context.getBeanFactory().registerSingleton("hadoopConfiguration", restCatalogConf); + StandaloneRESTCatalogServer server = new StandaloneRESTCatalogServer(restCatalogConf); + context.getBeanFactory().registerSingleton("standaloneRESTCatalogServer", server); + } + } + + @AfterClass + public static void teardownClass() throws IOException { + if (authorizationServer != null) { + try { + authorizationServer.stop(); + } catch (Exception e) { + LOG.warn("Failed to stop Keycloak (may not have started): {}", e.getMessage()); + } + } + teardownBase(); + } + + @Test(timeout = 120000) + public void testRESTCatalogConfigWithValidToken() throws Exception { + LOG.info("=== Test: REST Catalog Config with Valid JWT from Keycloak ==="); + + String token = authorizationServer.getAccessToken(); + try (CloseableHttpClient httpClient = HttpClients.createDefault(); + CloseableHttpResponse response = httpClient.execute(get("/iceberg/v1/config", token))) { + assertEquals("Config endpoint with valid JWT should return 200", 200, response.getStatusLine().getStatusCode()); + String responseBody = EntityUtils.toString(response.getEntity()); + assertTrue("Response should contain endpoints", responseBody.contains("endpoints")); + LOG.info("Config with valid JWT passed"); + } + } + + @Test(timeout = 60000) + public void testRESTCatalogRejectsInvalidToken() throws Exception { + LOG.info("=== Test: REST Catalog Rejects Invalid JWT ==="); + + String invalidToken = "invalid-token-not-a-valid-jwt"; + try (CloseableHttpClient httpClient = HttpClients.createDefault(); + CloseableHttpResponse response = httpClient.execute(get("/iceberg/v1/config", invalidToken))) { + assertEquals("Config endpoint with invalid JWT should return 401", 401, response.getStatusLine().getStatusCode()); + LOG.info("Invalid JWT correctly rejected"); + } + } + + @Test(timeout = 60000) + public void testRESTCatalogRejectsRequestWithoutToken() throws Exception { + LOG.info("=== Test: REST Catalog Rejects Request Without Token ==="); + + try (CloseableHttpClient httpClient = HttpClients.createDefault(); + CloseableHttpResponse response = httpClient.execute(get("/iceberg/v1/config"))) { + assertEquals("Config endpoint without token should return 401", 401, response.getStatusLine().getStatusCode()); + LOG.info("Request without token correctly rejected"); + } + } + + @Test(timeout = 120000) + public void testRESTCatalogNamespaceOperationsWithValidToken() throws Exception { + LOG.info("=== Test: REST Catalog Namespace Operations with Valid JWT ==="); + + String token = authorizationServer.getAccessToken(); + String namespacePath = "/iceberg/v1/namespaces"; + String namespaceName = "jwt_test_db"; + + try (CloseableHttpClient httpClient = HttpClients.createDefault()) { + try (CloseableHttpResponse response = httpClient.execute(get(namespacePath, token))) { + assertEquals("List namespaces with valid JWT should return 200", 200, response.getStatusLine().getStatusCode()); + } + + try (CloseableHttpResponse response = httpClient.execute( + post(namespacePath, "{\"namespace\":[\"" + namespaceName + "\"]}", token))) { + assertEquals("Create namespace with valid JWT should return 200", 200, + response.getStatusLine().getStatusCode()); + } + + try (CloseableHttpResponse response = httpClient.execute( + get("/iceberg/v1/namespaces/" + namespaceName, token))) { + assertEquals("Get namespace with valid JWT should return 200", 200, response.getStatusLine().getStatusCode()); + String responseBody = EntityUtils.toString(response.getEntity()); + assertTrue("Response should contain namespace", responseBody.contains(namespaceName)); + } + } + + LOG.info("Namespace operations with JWT passed"); + } + + @Test(timeout = 60000) + public void testServerPort() { + super.testServerPort(server); + } + + @Test(timeout = 60000) + public void testHealthEndpointsRemainUnauthenticated() throws Exception { + LOG.info("=== Test: Health endpoints work without auth (for K8s probes) ==="); + + super.testLivenessProbe(); + super.testReadinessProbe(); + + LOG.info("Health endpoints accessible without auth"); + } +} 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/pom.xml b/pom.xml index ae48bf9ba4f4..b2a8b6d383d9 100644 --- a/pom.xml +++ b/pom.xml @@ -228,6 +228,7 @@ 2.0.1 2.9.0 3.1.12 + 2.1.1 2.0.0 4.8.6 1.1.0.Final @@ -235,6 +236,7 @@ 2.4.0 5.3.39 + 2.7.18 2.4.4 2025-01-01T00:00:00Z 26.0.6 diff --git a/standalone-metastore/metastore-rest-catalog/pom.xml b/standalone-metastore/metastore-rest-catalog/pom.xml index fde65eddc344..d987f7cce972 100644 --- a/standalone-metastore/metastore-rest-catalog/pom.xml +++ b/standalone-metastore/metastore-rest-catalog/pom.xml @@ -42,6 +42,34 @@ hive-iceberg-catalog ${hive.version} + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-logging + + + org.springframework.boot + spring-boot-starter-tomcat + + + + + org.springframework.boot + spring-boot-starter-jetty + + + org.springframework.boot + spring-boot-starter-actuator + + + io.micrometer + micrometer-registry-prometheus + 1.9.17 + org.apache.hive @@ -236,6 +264,13 @@ keycloak-admin-client test + + + jakarta.annotation + jakarta.annotation-api + ${jakarta.annotation.version} + test + org.testcontainers testcontainers @@ -303,6 +338,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/IcebergCatalogConfiguration.java b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/standalone/IcebergCatalogConfiguration.java new file mode 100644 index 000000000000..8ab58c3fd686 --- /dev/null +++ b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/standalone/IcebergCatalogConfiguration.java @@ -0,0 +1,76 @@ +/* + * 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 javax.servlet.http.HttpServlet; + +import org.apache.hadoop.conf.Configuration; +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.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.web.servlet.ServletRegistrationBean; +import org.springframework.context.annotation.Bean; + +/** + * Spring configuration for the Iceberg REST Catalog servlet. + * Extracted to separate concerns from the main application bootstrap. + */ +@org.springframework.context.annotation.Configuration +public class IcebergCatalogConfiguration { + private static final Logger LOG = LoggerFactory.getLogger(IcebergCatalogConfiguration.class); + + private final Configuration conf; + + public IcebergCatalogConfiguration(Configuration conf) { + this.conf = conf; + } + + @Bean + public ServletRegistrationBean restCatalogServlet() { + // Determine servlet path and port + String servletPath = MetastoreConf.getVar(conf, ConfVars.ICEBERG_CATALOG_SERVLET_PATH); + if (servletPath == null || servletPath.isEmpty()) { + servletPath = "iceberg"; + MetastoreConf.setVar(conf, ConfVars.ICEBERG_CATALOG_SERVLET_PATH, servletPath); + } + + int port = MetastoreConf.getIntVar(conf, ConfVars.CATALOG_SERVLET_PORT); + if (port == 0) { + port = 8080; + MetastoreConf.setLongVar(conf, ConfVars.CATALOG_SERVLET_PORT, port); + } + + LOG.info("Creating REST Catalog servlet at /{}", servletPath); + + // Create servlet from Iceberg factory + org.apache.hadoop.hive.metastore.ServletServerBuilder.Descriptor descriptor = + HMSCatalogFactory.createServlet(conf); + if (descriptor == null || descriptor.getServlet() == null) { + throw new IllegalStateException("Failed to create Iceberg REST Catalog servlet"); + } + + ServletRegistrationBean registration = + new ServletRegistrationBean<>(descriptor.getServlet(), "/" + servletPath + "/*"); + registration.setName("IcebergRESTCatalog"); + registration.setLoadOnStartup(1); + + return registration; + } +} 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..753d60d81f0f 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 @@ -17,24 +17,20 @@ */ 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.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 +42,50 @@ * *

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); - 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("Hadoop Configuration initialized"); LOG.info(" HMS Thrift URIs: {}", thriftUris); - LOG.info(" Servlet Port: {}", servletPort); - LOG.info(" Servlet Path: /{}", servletPath); - - // Create servlet using factory - ServletServerBuilder.Descriptor catalogDescriptor = HMSCatalogFactory.createServlet(conf); - if (catalogDescriptor == 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(); - - // Build and start server - ServletServerBuilder builder = new ServletServerBuilder(conf); - builder.addServlet(catalogDescriptor); - builder.addServlet(servletPort, "health", healthServlet); - - 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)); + + if (LOG.isInfoEnabled()) { + LOG.info(" Warehouse: {}", MetastoreConf.getVar(conf, ConfVars.WAREHOUSE)); } - - // 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); } /** - * 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 +103,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 +124,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); + + // Start Spring Boot with pre-configured beans + SpringApplication app = new SpringApplication(StandaloneRESTCatalogServer.class, IcebergCatalogConfiguration.class); + app.addInitializers(ctx -> { + ctx.getBeanFactory().registerSingleton("hadoopConfiguration", conf); + ctx.getBeanFactory().registerSingleton("standaloneRESTCatalogServer", server); + }); - // Add shutdown hook - Runtime.getRuntime().addShutdownHook(new Thread(() -> { - LOG.info("Shutdown hook triggered"); - server.stop(); - })); + app.run(args); - 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); - } + 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/java/org/apache/iceberg/rest/standalone/health/HMSReadinessHealthIndicator.java b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/standalone/health/HMSReadinessHealthIndicator.java new file mode 100644 index 000000000000..252a3d298b2b --- /dev/null +++ b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/standalone/health/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.health; + +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/resources/application.yml b/standalone-metastore/metastore-rest-catalog/src/main/resources/application.yml new file mode 100644 index 000000000000..99aec2c763f8 --- /dev/null +++ b/standalone-metastore/metastore-rest-catalog/src/main/resources/application.yml @@ -0,0 +1,61 @@ +# 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. + +# 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} + shutdown: graceful +spring: + lifecycle: + timeout-per-shutdown-phase: 30s + +# Actuator endpoints for Kubernetes +management: + endpoints: + web: + exposure: + include: health,prometheus,info + endpoint: + health: + show-details: always + probes: + enabled: true + health: + livenessState: + enabled: true + readinessState: + enabled: true + metrics: + export: + prometheus: + enabled: true + +# Logging +logging: + level: + org.apache.iceberg.rest.standalone: INFO + org.apache.hadoop.hive.metastore: INFO + org.springframework.boot: WARN + +# Application info +info: + app: + name: Standalone HMS REST Catalog Server + description: Standalone REST Catalog Server for Apache Hive Metastore + version: "@project.version@" diff --git a/standalone-metastore/metastore-server/src/main/java/org/apache/hadoop/hive/metastore/auth/jwt/SimpleJWTAuthenticator.java b/standalone-metastore/metastore-server/src/main/java/org/apache/hadoop/hive/metastore/auth/jwt/SimpleJWTAuthenticator.java index a6e85def82c3..45fc4d337a76 100644 --- a/standalone-metastore/metastore-server/src/main/java/org/apache/hadoop/hive/metastore/auth/jwt/SimpleJWTAuthenticator.java +++ b/standalone-metastore/metastore-server/src/main/java/org/apache/hadoop/hive/metastore/auth/jwt/SimpleJWTAuthenticator.java @@ -38,7 +38,10 @@ public class SimpleJWTAuthenticator { private static final Logger LOG = LoggerFactory.getLogger(SimpleJWTAuthenticator.class.getName()); - private static final Set ACCEPTABLE_TYPES = Sets.newHashSet(null, JOSEObjectType.JWT); + // Accept both traditional "JWT" and RFC 9068 "at+jwt" (access token as JWT) - Keycloak and other + // OIDC providers may use "at+jwt" when configured for RFC 9068 compliance. + private static final Set ACCEPTABLE_TYPES = + Sets.newHashSet(null, JOSEObjectType.JWT, new JOSEObjectType("at+jwt")); private final JWTValidator validator; diff --git a/standalone-metastore/pom.xml b/standalone-metastore/pom.xml index 30ca1bf00f4d..edbb92c52c4e 100644 --- a/standalone-metastore/pom.xml +++ b/standalone-metastore/pom.xml @@ -43,6 +43,7 @@ 21 21 21 + 2.1.1 false 2.7.10 ${settings.localRepository} @@ -126,6 +127,7 @@ 26.0.6 5.3.39 + 2.7.18 2.4.4 1.21.3 @@ -578,6 +580,86 @@ testcontainers ${testcontainers.version} + + 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.springframework.boot + spring-boot-starter-web + ${spring-boot.version} + + + org.springframework.boot + spring-boot-starter-jetty + ${spring-boot.version} + + + org.springframework.boot + spring-boot-starter-actuator + ${spring-boot.version} +