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..1223e18ce905 --- /dev/null +++ b/itests/qtest-iceberg/src/test/java/org/apache/hadoop/hive/cli/BaseStandaloneRESTCatalogServerTest.java @@ -0,0 +1,358 @@ +/* + * 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 javax.net.ssl.SSLContext; + +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.conn.ssl.NoopHostnameVerifier; +import org.apache.http.ssl.SSLContextBuilder; +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 javax.servlet.http.HttpServlet; + +import org.apache.iceberg.rest.standalone.IcebergCatalogConfiguration; +import org.apache.iceberg.rest.standalone.RestCatalogServerRuntime; +import org.junit.Test; +import org.slf4j.Logger; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.slf4j.LoggerFactory; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.web.ServerProperties; +import org.springframework.boot.web.servlet.ServletRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.FilterType; +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 org.springframework.web.util.UriComponentsBuilder; + +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); + + protected static Configuration hmsConf; + protected static int hmsPort; + protected static File warehouseDir; + protected static File hmsTempDir; + + @LocalServerPort + private int port; + + @Autowired + private RestCatalogServerRuntime server; + + /** + * 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(TestCatalogConfig.class) + @ComponentScan( + basePackages = "org.apache.iceberg.rest.standalone", + excludeFilters = { + @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = IcebergCatalogConfiguration.class), + @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = RestCatalogServerRuntime.class) + }) + public static class TestRestCatalogApplication {} + + /** + * Test-specific config providing the REST catalog servlet. + * Uses Configuration from test's TestConfig (with hmsPort, warehouseDir). + * Does NOT import IcebergCatalogConfiguration to avoid production hadoopConfiguration. + */ + @org.springframework.context.annotation.Configuration + static class TestCatalogConfig { + + @Bean + public Configuration hadoopConfiguration() { + Configuration conf = createBaseTestConfiguration(); + MetastoreConf.setVar(conf, ConfVars.CATALOG_SERVLET_AUTH, "none"); + return conf; + } + + @Bean + public RestCatalogServerRuntime restCatalogServerRuntime(ServerProperties serverProperties) { + Configuration conf = createBaseTestConfiguration(); + MetastoreConf.setVar(conf, ConfVars.CATALOG_SERVLET_AUTH, "none"); + return new RestCatalogServerRuntime(conf, serverProperties); + } + + @Bean + public ServletRegistrationBean restCatalogServlet(Configuration conf) { + return IcebergCatalogConfiguration.createRestCatalogServlet(conf); + } + } + + protected String url(String path) { + return UriComponentsBuilder.newInstance() + .scheme("https") + .host("localhost") + .port(getPort()) + .path(path.startsWith("/") ? path : "/" + path) + .toUriString(); + } + + /** + * Creates an HttpClient that trusts the test server's self-signed certificate. + */ + protected CloseableHttpClient createHttpClient() throws Exception { + SSLContext sslContext = SSLContextBuilder.create() + .loadTrustMaterial((chain, authType) -> true) + .build(); + return HttpClients.custom() + .setSSLContext(sslContext) + .setSSLHostnameVerifier(NoopHostnameVerifier.INSTANCE) + .build(); + } + + protected int getPort() { + return port; + } + + protected RestCatalogServerRuntime getServer() { + return server; + } + + /** + * Creates base test Configuration with HMS URI and warehouse dirs. + * Subclasses add auth-specific settings. + */ + protected static Configuration createBaseTestConfiguration() { + Configuration conf = MetastoreConf.newMetastoreConf(); + MetastoreConf.setVar(conf, ConfVars.THRIFT_URIS, "thrift://localhost:" + hmsPort); + MetastoreConf.setVar(conf, ConfVars.WAREHOUSE, warehouseDir.getAbsolutePath()); + MetastoreConf.setVar(conf, ConfVars.WAREHOUSE_EXTERNAL, warehouseDir.getAbsolutePath()); + MetastoreConf.setVar(conf, ConfVars.ICEBERG_CATALOG_SERVLET_PATH, "iceberg"); + MetastoreConf.setLongVar(conf, ConfVars.CATALOG_SERVLET_PORT, 0); + return conf; + } + + /** + * Returns the Bearer token for catalog API tests, or null if no auth. + * Subclasses with auth (e.g. JWT) override to return a valid token. + */ + protected String getBearerTokenForCatalogTests() { + return null; + } + + /** + * 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); + } + } + + @Test(timeout = 60000) + public void testLivenessProbe() throws Exception { + try (CloseableHttpClient httpClient = createHttpClient(); + 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); + } + } + + @Test(timeout = 60000) + public void testReadinessProbe() throws Exception { + try (CloseableHttpClient httpClient = createHttpClient(); + 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); + } + } + + @Test(timeout = 60000) + public void testPrometheusMetrics() throws Exception { + try (CloseableHttpClient httpClient = createHttpClient(); + 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"); + } + } + + @Test(timeout = 60000) + public void testServerPort() { + RestCatalogServerRuntime s = getServer(); + assertTrue("Server port should be > 0", getPort() > 0); + assertNotNull("REST endpoint should not be null", s.getRestEndpoint()); + LOG.info("Server port: {}, Endpoint: {}", getPort(), s.getRestEndpoint()); + } + + @Test(timeout = 120000) + public void testRESTCatalogConfig() throws Exception { + String token = getBearerTokenForCatalogTests(); + try (CloseableHttpClient httpClient = createHttpClient(); + CloseableHttpResponse response = httpClient.execute(get(String.format("/%s/%s", + IcebergCatalogConfiguration.DEFAULT_SERVLET_PATH, "v1/config"), token))) { + assertEquals("Config endpoint should return 200", 200, response.getStatusLine().getStatusCode()); + String responseBody = EntityUtils.toString(response.getEntity()); + assertTrue("Response should contain endpoints", responseBody.contains("endpoints")); + assertTrue("Response should be valid JSON", responseBody.startsWith("{") && responseBody.endsWith("}")); + } + } + + @Test(timeout = 120000) + public void testRESTCatalogNamespaceOperations() throws Exception { + String token = getBearerTokenForCatalogTests(); + String namespacePath = String.format("/%s/%s", IcebergCatalogConfiguration.DEFAULT_SERVLET_PATH, "v1/namespaces"); + String namespaceName = "testdb"; + + try (CloseableHttpClient httpClient = createHttpClient()) { + try (CloseableHttpResponse response = httpClient.execute(get(namespacePath, token))) { + assertEquals("List namespaces should return 200", 200, response.getStatusLine().getStatusCode()); + } + + try (CloseableHttpResponse response = httpClient.execute( + post(namespacePath, "{\"namespace\":[\"" + namespaceName + "\"]}", token))) { + assertEquals("Create namespace should return 200", 200, response.getStatusLine().getStatusCode()); + } + + try (CloseableHttpResponse response = httpClient.execute(get(namespacePath, token))) { + assertEquals("List namespaces after creation should return 200", + 200, response.getStatusLine().getStatusCode()); + String responseBody = EntityUtils.toString(response.getEntity()); + assertTrue("Response should contain created namespace", responseBody.contains(namespaceName)); + } + + try (CloseableHttpResponse response = httpClient.execute( + get(String.format("/%s/%s/%s", IcebergCatalogConfiguration.DEFAULT_SERVLET_PATH, + "v1/namespaces", namespaceName), token))) { + assertEquals("Get namespace should return 200", + 200, response.getStatusLine().getStatusCode()); + String responseBody = EntityUtils.toString(response.getEntity()); + assertTrue("Response should contain namespace", responseBody.contains(namespaceName)); + } + } + } +} 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..8998dbf77e35 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,40 @@ */ package org.apache.hadoop.hive.cli; -import java.io.File; -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.Test; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import java.io.IOException; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertTrue; +import org.junit.AfterClass; +import org.junit.runner.RunWith; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.TestExecutionListeners; +import org.springframework.test.context.junit4.SpringRunner; /** - * 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(); - } - if (hmsPort > 0) { - MetaStoreTestUtils.close(hmsPort); - } - if (hmsTempDir != null && hmsTempDir.exists()) { - deleteDirectory(hmsTempDir); - } - } - - @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"); - } - } - } - - @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("}")); - } - } - } - - @Test(timeout = 60000) - public void testRESTCatalogNamespaceOperations() throws Exception { - LOG.info("=== Test: REST Catalog Namespace Operations ==="); - - String namespacesUrl = restCatalogServer.getRestEndpoint() + "/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)) { - 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", - 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", - 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(); +@RunWith(SpringRunner.class) +@SpringBootTest( + classes = BaseStandaloneRESTCatalogServerTest.TestRestCatalogApplication.class, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = { + "spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration", + "spring.main.allow-bean-definition-overriding=true" } +) +@TestExecutionListeners( + listeners = BaseStandaloneRESTCatalogServerTest.HmsStartupListener.class, + mergeMode = TestExecutionListeners.MergeMode.MERGE_WITH_DEFAULTS +) +public class TestStandaloneRESTCatalogServer extends BaseStandaloneRESTCatalogServerTest { + @AfterClass + public static void teardownClass() throws IOException { + teardownBase(); } } 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..c0a168b44a69 --- /dev/null +++ b/itests/qtest-iceberg/src/test/java/org/apache/hadoop/hive/cli/TestStandaloneRESTCatalogServerJwtAuth.java @@ -0,0 +1,160 @@ +/* + * 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.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.RestCatalogServerRuntime; +import org.junit.AfterClass; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.boot.autoconfigure.web.ServerProperties; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; +import org.springframework.core.annotation.Order; +import org.springframework.core.Ordered; +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; + +/** + * 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, + TestStandaloneRESTCatalogServerJwtAuth.TestConfig.class + }, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = { + "spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration", + "spring.main.allow-bean-definition-overriding=true" + } +) +@TestExecutionListeners( + listeners = { + BaseStandaloneRESTCatalogServerTest.HmsStartupListener.class, + TestStandaloneRESTCatalogServerJwtAuth.KeycloakStartupListener.class + }, + mergeMode = TestExecutionListeners.MergeMode.MERGE_WITH_DEFAULTS +) +public class TestStandaloneRESTCatalogServerJwtAuth extends BaseStandaloneRESTCatalogServerTest { + private static OAuth2AuthorizationServer authorizationServer; + + @Override + protected String getBearerTokenForCatalogTests() { + return authorizationServer != null ? authorizationServer.getAccessToken() : null; + } + + @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()); + } + } + + @TestConfiguration + static class TestConfig { + @Bean + @Primary + public Configuration hadoopConfiguration() { + Configuration conf = createBaseTestConfiguration(); + MetastoreConf.setVar(conf, ConfVars.CATALOG_SERVLET_AUTH, "jwt"); + MetastoreConf.setVar(conf, ConfVars.THRIFT_METASTORE_AUTHENTICATION_JWT_JWKS_URL, + authorizationServer.getIssuer() + "/protocol/openid-connect/certs"); + return conf; + } + + @Bean + @Primary + public RestCatalogServerRuntime restCatalogServerRuntime(ServerProperties serverProperties) { + Configuration conf = createBaseTestConfiguration(); + MetastoreConf.setVar(conf, ConfVars.CATALOG_SERVLET_AUTH, "jwt"); + MetastoreConf.setVar(conf, ConfVars.THRIFT_METASTORE_AUTHENTICATION_JWT_JWKS_URL, + authorizationServer.getIssuer() + "/protocol/openid-connect/certs"); + return new RestCatalogServerRuntime(conf, serverProperties); + } + } + + @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 = 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 = createHttpClient(); + 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 = createHttpClient(); + 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"); + } + } +} 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 744e434b9a0c..a216883c077c 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..7f30b3221a90 --- /dev/null +++ b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/standalone/IcebergCatalogConfiguration.java @@ -0,0 +1,91 @@ +/* + * 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.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.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.web.servlet.ServletRegistrationBean; +import org.springframework.context.annotation.Bean; + +/** + * Spring configuration for the Iceberg REST Catalog. + */ +@org.springframework.context.annotation.Configuration +public class IcebergCatalogConfiguration { + private static final Logger LOG = LoggerFactory.getLogger(IcebergCatalogConfiguration.class); + public static final String DEFAULT_SERVLET_PATH = "iceberg"; + public static final int DEFAULT_PORT = 8080; + + @Bean + public Configuration hadoopConfiguration(ApplicationArguments args) { + Configuration conf = MetastoreConf.newMetastoreConf(); + for (String arg : args.getSourceArgs()) { + if (arg.startsWith("-D")) { + String[] kv = arg.substring(2).split("=", 2); + if (kv.length == 2) { + conf.set(kv[0], kv[1]); + } + } + } + return conf; + } + + @Bean + public ServletRegistrationBean restCatalogServlet(Configuration conf) { + return createRestCatalogServlet(conf); + } + + /** + * Creates the REST Catalog servlet registration. Shared by production config and tests. + */ + public static ServletRegistrationBean createRestCatalogServlet(Configuration conf) { + String servletPath = MetastoreConf.getVar(conf, ConfVars.ICEBERG_CATALOG_SERVLET_PATH); + if (servletPath == null || servletPath.isEmpty()) { + servletPath = DEFAULT_SERVLET_PATH; + MetastoreConf.setVar(conf, ConfVars.ICEBERG_CATALOG_SERVLET_PATH, servletPath); + } + + int port = MetastoreConf.getIntVar(conf, ConfVars.CATALOG_SERVLET_PORT); + if (port == 0) { + port = DEFAULT_PORT; + MetastoreConf.setLongVar(conf, ConfVars.CATALOG_SERVLET_PORT, port); + } + + LOG.info("Creating REST Catalog servlet at /{}", servletPath); + + 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/RestCatalogServerRuntime.java b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/standalone/RestCatalogServerRuntime.java new file mode 100644 index 000000000000..403c4a4423d1 --- /dev/null +++ b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/standalone/RestCatalogServerRuntime.java @@ -0,0 +1,100 @@ +/* + * 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 com.google.common.annotations.VisibleForTesting; +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.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.web.context.WebServerInitializedEvent; +import org.springframework.boot.web.server.Ssl; +import org.springframework.boot.autoconfigure.web.ServerProperties; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; +import org.springframework.web.util.UriComponentsBuilder; + +/** + * Runtime lifecycle for the Standalone REST Catalog Server. + * Holds port, rest endpoint, and handles web server initialization. + */ +@Component +public class RestCatalogServerRuntime { + private static final Logger LOG = LoggerFactory.getLogger(RestCatalogServerRuntime.class); + + private final Configuration conf; + private final ServerProperties serverProperties; + private String restEndpoint; + private int port; + + public RestCatalogServerRuntime(Configuration conf, ServerProperties serverProperties) { + this.conf = conf; + this.serverProperties = serverProperties; + + 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"); + } + + LOG.info("Hadoop Configuration initialized"); + LOG.info(" HMS Thrift URIs: {}", thriftUris); + + if (LOG.isInfoEnabled()) { + LOG.info(" Warehouse: {}", MetastoreConf.getVar(conf, ConfVars.WAREHOUSE)); + LOG.info(" Warehouse (external): {}", MetastoreConf.getVar(conf, ConfVars.WAREHOUSE_EXTERNAL)); + } + } + + @EventListener + public void onWebServerInitialized(WebServerInitializedEvent event) { + int actualPort = event.getWebServer().getPort(); + + if (actualPort > 0) { + port = actualPort; + String servletPath = MetastoreConf.getVar(conf, ConfVars.ICEBERG_CATALOG_SERVLET_PATH); + + if (servletPath == null || servletPath.isEmpty()) { + servletPath = IcebergCatalogConfiguration.DEFAULT_SERVLET_PATH; + } + + restEndpoint = UriComponentsBuilder.newInstance() + .scheme(isSslEnabled() ? "https" : "http") + .host("localhost") + .port(actualPort) + .pathSegment(servletPath) + .toUriString(); + + LOG.info("REST endpoint set to actual server port: {}", restEndpoint); + } + } + + private boolean isSslEnabled() { + Ssl ssl = serverProperties != null ? serverProperties.getSsl() : null; + return ssl != null && ssl.isEnabled(); + } + + @VisibleForTesting + public int getPort() { + return port; + } + + public String getRestEndpoint() { + return restEndpoint; + } +} 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..d1f2d87f2f63 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,192 +17,57 @@ */ 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; /** - * 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. - * + * *

Designed for Kubernetes deployment with load balancer/API gateway in front: *

  *   Client → Load Balancer/API Gateway → StandaloneRESTCatalogServer → HMS
  * 
- * + * *

Multiple instances can run behind a Kubernetes Service for load balancing. */ +@SpringBootApplication(exclude = DataSourceAutoConfiguration.class) +@SuppressWarnings("java:S1118") // Not a utility class; Spring Boot requires instantiation public class StandaloneRESTCatalogServer { private static final Logger LOG = LoggerFactory.getLogger(StandaloneRESTCatalogServer.class); - - private final Configuration conf; - private Server server; - private int port; - - public StandaloneRESTCatalogServer(Configuration conf) { - this.conf = conf; - } - - /** - * Starts the standalone REST Catalog server. - */ - public void start() { - // 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(" 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)); - } - - // 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. - */ - 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); - } - } - } - - /** - * Gets the port the server is listening on. - * @return the port number - */ - @VisibleForTesting - public int getPort() { - return port; - } - /** - * Gets the REST Catalog endpoint URL. - * @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); - } - } - } - /** * Main method for running as a standalone application. - * @param args command line arguments + * @param args command line arguments (-Dkey=value for configuration) */ public static void main(String[] args) { - Configuration conf = MetastoreConf.newMetastoreConf(); - - // Load configuration from command line args or environment - // Format: -Dkey=value or use system properties + // Apply -D args to system properties so application.yml and Configuration bean pick them up for (String arg : args) { if (arg.startsWith("-D")) { String[] kv = arg.substring(2).split("=", 2); if (kv.length == 2) { - conf.set(kv[0], kv[1]); + System.setProperty(kv[0], kv[1]); } } } - - StandaloneRESTCatalogServer server = new StandaloneRESTCatalogServer(conf); - - // Add shutdown hook - Runtime.getRuntime().addShutdownHook(new Thread(() -> { - LOG.info("Shutdown hook triggered"); - server.stop(); - })); - - 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); + // Sync port from MetastoreConf to Spring's server.port if not already set + if (System.getProperty(ConfVars.CATALOG_SERVLET_PORT.getVarname()) == null) { + int port = MetastoreConf.getIntVar(MetastoreConf.newMetastoreConf(), ConfVars.CATALOG_SERVLET_PORT); + if (port > 0) { + System.setProperty(ConfVars.CATALOG_SERVLET_PORT.getVarname(), String.valueOf(port)); + } } + + SpringApplication.run(StandaloneRESTCatalogServer.class, args); + + LOG.info("Standalone REST Catalog Server started successfully"); + LOG.info("Server running. Press Ctrl+C to stop."); } } 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..d15c0701e800 --- /dev/null +++ b/standalone-metastore/metastore-rest-catalog/src/main/resources/application.yml @@ -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. + +# Spring Boot Configuration for Standalone HMS REST Catalog Server + +# Server configuration +# Port is set via MetastoreConf.CATALOG_SERVLET_PORT or -Dmetastore.catalog.servlet.port +# SSL is enabled by default with a bundled self-signed cert (dev). Override for production: +# -Dserver.ssl.key-store=/path/to/keystore.p12 -Dserver.ssl.key-store-password=secret +server: + port: ${metastore.catalog.servlet.port:8080} + shutdown: graceful + ssl: + enabled: true + key-store: classpath:keystore.p12 + key-store-password: changeit + key-store-type: PKCS12 + key-alias: iceberg +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-rest-catalog/src/main/resources/keystore.p12 b/standalone-metastore/metastore-rest-catalog/src/main/resources/keystore.p12 new file mode 100644 index 000000000000..b5ba6825dad2 Binary files /dev/null and b/standalone-metastore/metastore-rest-catalog/src/main/resources/keystore.p12 differ 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 991cb6c20a4b..b2a697a9a756 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} +