Skip to content

Hms standalone rest server with Spring Boot#6327

Open
difin wants to merge 1 commit intoapache:masterfrom
difin:hms-standalone-rest-server-with-spring-boot
Open

Hms standalone rest server with Spring Boot#6327
difin wants to merge 1 commit intoapache:masterfrom
difin:hms-standalone-rest-server-with-spring-boot

Conversation

@difin
Copy link
Contributor

@difin difin commented Feb 19, 2026

What changes were proposed in this pull request?

The Standalone REST Catalog Server is reimplemented to use Spring Boot instead of plain Java:

  • Server framework – Uses Spring Boot with an embedded Jetty server instead of raw servlet wiring.
  • Health checks – Adds Actuator liveness and readiness probes; readiness verifies HMS connectivity via Thrift.
  • Observability – Exposes Prometheus metrics for Kubernetes HPA and monitoring.
  • Configuration – Keeps port and other settings in MetastoreConf but bridges them into Spring (e.g., via system properties) so Spring Boot uses the configured port.
  • Graceful shutdown – Uses Spring Boot’s shutdown handling with a configurable timeout.
    Standalone packaging – Adds a spring-boot-maven-plugin “exec” JAR for running the server as a standalone process.

Why are the changes needed?

Spring Boot improves how the Standalone REST Catalog Server is run and operated:

  • Kubernetes support – Liveness and readiness probes (/actuator/health/*) let Kubernetes reliably route traffic and restart unhealthy pods. Readiness includes an actual HMS connectivity check instead of a simple config check.
  • Observability – Prometheus metrics enable HPA, dashboards, and alerting, which is standard for production deployments.
  • Operational behavior – Graceful shutdown and a well-defined lifecycle reduce the chance of dropped requests during restarts.
  • Maintainability – Spring Boot replaces custom servlet wiring and configuration, and aligns with common patterns for cloud-native Java services.

Does this PR introduce any user-facing change?

If the standalone REST Catalog server is deployed in Kubernetes:

  • Liveness/readiness probes – Configure HTTP probes to use the new actuator endpoints:
    -- Liveness: httpGet: /actuator/health/liveness
    -- Readiness: httpGet: /actuator/health/readiness
  • Metrics/HPA – Prometheus scraping or custom metrics use /actuator/prometheus.

How was this patch tested?

Integration tests in TestStandaloneRESTCatalogServer and TestStandaloneRESTCatalogServerJwtAuth run the Spring Boot standalone HMS REST catalog server and verify liveness/readiness probes, Prometheus metrics, REST catalog operations, and JWT auth with Keycloak (Testcontainers).

@deniskuzZ
Copy link
Member

	Suppressed: java.lang.NullPointerException: Cannot invoke "org.keycloak.admin.client.Keycloak.close()" because "this.keycloak" is null
		at org.apache.iceberg.rest.extension.OAuth2AuthorizationServer.stop(OAuth2AuthorizationServer.java:181)
		at org.apache.iceberg.rest.extension.HiveRESTCatalogServerExtension.afterAll(HiveRESTCatalogServerExtension.java:124)
		... 1 more
Caused by: java.lang.ClassNotFoundException: jakarta.annotation.Priority

LOG.info("=== Test: Health Check ===");
String healthUrl = "http://localhost:" + restCatalogServer.getPort() + "/health";
public void testPrometheusMetrics() throws Exception {
Copy link
Member

@deniskuzZ deniskuzZ Feb 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we use spring actuator? nice :)

@deniskuzZ
Copy link
Member

deniskuzZ commented Feb 20, 2026

to support OAuth / JWT Authentication don't we need SecurityConfig?

@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.oauth2ResourceServer()
            .jwt(); // validate JWT tokens
    }
}

cc @okumin

@difin
Copy link
Contributor Author

difin commented Mar 4, 2026

to support OAuth / JWT Authentication don't we need SecurityConfig?

@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.oauth2ResourceServer()
            .jwt(); // validate JWT tokens
    }
}

We don’t need Spring Security for JWT/OAuth2 here. Auth is handled by the Hive metastore’s ServletSecurity, which wraps the Iceberg REST Catalog servlet in HMSCatalogFactory. That layer extracts the Bearer token and validates it with SimpleJWTAuthenticator (JWT) or OAuth2Authenticator (OAuth2). This is the same path used by the embedded HMS REST catalog, so the standalone server reuses that logic instead of introducing a separate Spring Security filter chain. Adding Spring Security would duplicate and potentially conflict with the existing auth handling.

I also added JWT integration tests for the Standalone REST Catalog server in TestStandaloneRESTCatalogServerJwtAuth, using Keycloak (Testcontainers) as the token issuer and the same ServletSecurity / SimpleJWTAuthenticator pipeline as the embedded HMS REST catalog.

@difin difin changed the title Hms standalone rest server with spring boot Hms standalone rest server with Spring Boot Mar 5, 2026
@difin difin force-pushed the hms-standalone-rest-server-with-spring-boot branch from a054c87 to adc0bc2 Compare March 5, 2026 14:53
@sonarqubecloud
Copy link

sonarqubecloud bot commented Mar 5, 2026

@okumin
Copy link
Contributor

okumin commented Mar 7, 2026

to support OAuth / JWT Authentication don't we need SecurityConfig?

DISCLAIMER: I might not be understanding Spring with Servlet correctly.
We may finally be able to use the Spring's tool if all are migrated to Spring. As of today, if Spring can leverage ServletSecurity, we don't immediately move to there.

String namespacesUrl = restCatalogServer.getRestEndpoint() + "/v1/namespaces";

String namespacePath = "/iceberg/v1/namespaces";
Copy link
Member

@deniskuzZ deniskuzZ Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

optional: would it be better to use ResourcePaths.V1_NAMESPACES?


@Test(timeout = 60000)
public void testServerPort() {
super.testServerPort(server);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why no add test annotation in base class instead of copy&paste?

String namespacePath = "/iceberg/v1/namespaces";
String namespaceName = "jwt_test_db";

try (CloseableHttpClient httpClient = HttpClients.createDefault()) {
Copy link
Member

@deniskuzZ deniskuzZ Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we extract common parts to base class? both tests use similar logic

* Spring configuration for the Iceberg REST Catalog servlet.
* Extracted to separate concerns from the main application bootstrap.
*/
@org.springframework.context.annotation.Configuration
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we import the package instead of fqname?

// Determine servlet path and port
String servletPath = MetastoreConf.getVar(conf, ConfVars.ICEBERG_CATALOG_SERVLET_PATH);
if (servletPath == null || servletPath.isEmpty()) {
servletPath = "iceberg";
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we extract to constant


int port = MetastoreConf.getIntVar(conf, ConfVars.CATALOG_SERVLET_PORT);
if (port == 0) {
port = 8080;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same

"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));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not sure about that: should we display both WAREHOUSE and WAREHOUSE_EXTERNAL?
see https://issues.apache.org/jira/browse/HIVE-29461

if (servletPath == null || servletPath.isEmpty()) {
servletPath = "iceberg";
}
this.restEndpoint = "http://localhost:" + actualPort + "/" + servletPath;
Copy link
Member

@deniskuzZ deniskuzZ Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we need to construct this manually or iceberg RestCatalog has utils for that? if not, maybe

this.restEndpoint = UriComponentsBuilder
    .scheme("http").host("localhost").port(actualPort)
    .pathSegment(servletPath)
    .toUriString();

should we support ssl?


// Start Spring Boot with pre-configured beans
SpringApplication app = new SpringApplication(StandaloneRESTCatalogServer.class, IcebergCatalogConfiguration.class);
app.addInitializers(ctx -> {
Copy link
Member

@deniskuzZ deniskuzZ Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You don't need manual registerSingleton at all, Spring should handle this

  1. StandaloneRESTCatalogServer (bootstrap only)
/**
 * StandaloneRESTCatalogServer (bootstrap)
 */
@SpringBootApplication(exclude = DataSourceAutoConfiguration.class)
public class StandaloneRESTCatalogServer {

  public static void main(String[] args) {
    SpringApplication.run(StandaloneRESTCatalogServer.class, args);
  }
}
  1. RestCatalogServerRuntime (runtime lifecycle)
@Component
public class RestCatalogServerRuntime {

  private static final Logger LOG = LoggerFactory.getLogger(RestCatalogServerRuntime.class);

  private final Configuration conf;

  private String restEndpoint;
  private int port;

  public RestCatalogServerRuntime(Configuration conf) {
    this.conf = conf;

    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));
    }
  }

  @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 = UriComponentsBuilder
        .scheme("http").host("localhost").port(actualPort)
        .pathSegment(servletPath)
        .toUriString();

      LOG.info("REST endpoint set to actual server port: {}", restEndpoint);
    }
  }

  @VisibleForTesting
  public int getPort() {
    return port;
  }

  public String getRestEndpoint() {
    return restEndpoint;
  }
}
  1. IcebergCatalogConfig
@Configuration
public class IcebergCatalogConfig {

  private static final Logger LOG =
      LoggerFactory.getLogger(IcebergCatalogConfig.class);

  /**
   * Hadoop configuration bean.
   */
  @Bean
  public Configuration hadoopConfiguration() {
    return MetastoreConf.newMetastoreConf();
  }

  /**
   * Iceberg REST Catalog servlet registration.
   */
  @Bean
  public ServletRegistrationBean<HttpServlet> restCatalogServlet(Configuration conf) {

    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);

    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<HttpServlet> registration =
        new ServletRegistrationBean<>(descriptor.getServlet(), "/" + servletPath + "/*");

    registration.setName("IcebergRESTCatalog");
    registration.setLoadOnStartup(1);

    return registration;
  }
}

System.setProperty(ConfVars.CATALOG_SERVLET_PORT.getVarname(), String.valueOf(port));
}

StandaloneRESTCatalogServer server = new StandaloneRESTCatalogServer(conf);
Copy link
Member

@deniskuzZ deniskuzZ Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Spring Boot should manage the lifecycle of your application class. Typically you don't manually instantiate your @SpringBootApplication class. see above snippet

Copy link
Member

@deniskuzZ deniskuzZ left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added few comments, rest LGTM

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants