This is an automated email from the ASF dual-hosted git repository.
diqiu50 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gravitino.git
The following commit(s) were added to refs/heads/main by this push:
new 03da573642 [#13098] improvement(server): Support an advertised URI for
Iceberg REST service discovery (#13126)
03da573642 is described below
commit 03da573642dcee1a97e896b0fa9d3e86a0b7315e
Author: Yuhui <[email protected]>
AuthorDate: Tue Sep 15 14:16:00 2026 +0800
[#13098] improvement(server): Support an advertised URI for Iceberg REST
service discovery (#13126)
### What changes were proposed in this pull request?
- Add `gravitino.iceberg-rest.advertised-uri`. When set,
`/api/system/iceberg-rest` reports it as-is after the existing service
availability, config provider and metalake checks; otherwise the
endpoint is derived from the listener configuration as before.
- Validate the configured value (absolute `http`/`https` URI with a
host, no query or fragment). An invalid value fails the discovery
request with an internal error instead of being silently ignored.
- Document `/system/iceberg-rest` in the OpenAPI spec and describe the
new property in the Iceberg REST service and Trino connector docs.
### Why are the changes needed?
Behind a reverse proxy the public Iceberg REST endpoint may differ from
the listener's scheme, host, port or path, so discovery reports an
endpoint clients cannot reach and every Trino client has to set
`gravitino.iceberg.rest-uri` manually.
Fix: #13098
### Does this PR introduce _any_ user-facing change?
New optional server property `gravitino.iceberg-rest.advertised-uri`. It
only affects the advertised endpoint; the listener configuration is
unchanged.
### How was this patch tested?
Unit tests in `TestIcebergRESTServiceOperations` cover the reverse-proxy
endpoint, fallback to the derived endpoint, metalake and config provider
checks, and invalid values. `./gradlew :docs:build` validates the
OpenAPI spec.
---
docs/iceberg-rest-service.md | 6 ++
docs/open-api/openapi.yaml | 3 +
docs/open-api/system.yaml | 95 ++++++++++++++++++++++
docs/trino-connector/catalog-iceberg.md | 6 ++
.../web/rest/IcebergRESTServiceOperations.java | 45 +++++++++-
.../web/rest/TestIcebergRESTServiceOperations.java | 91 +++++++++++++++++++++
6 files changed, 245 insertions(+), 1 deletion(-)
diff --git a/docs/iceberg-rest-service.md b/docs/iceberg-rest-service.md
index 76f35650db..098c6a7feb 100644
--- a/docs/iceberg-rest-service.md
+++ b/docs/iceberg-rest-service.md
@@ -125,10 +125,16 @@ Do not add them to the standalone server configuration.
| `gravitino.iceberg-rest.responseHeaderSize` | The maximum size of an
HTTP response.
| `131072`
| No |
| `gravitino.iceberg-rest.includeErrorStackTrace` | Whether error responses
include server-side stack traces. Set this to `false` in new deployments
because responses can expose internal implementation details. | `true`
| No |
| `gravitino.iceberg-rest.customFilters` | Comma-separated list of
filter class names to apply to the APIs.
| (none)
| No |
+| `gravitino.iceberg-rest.advertised-uri` | The public endpoint
reported to clients that discover the service through the Gravitino server.
Must be an absolute `http`/`https` URI with a host and no query or fragment.
| (none)
| No |
The filter in `customFilters` should be a standard javax servlet filter.
Specify filter parameters by setting configuration entries in the style
`gravitino.iceberg-rest.<class name of filter>.param.<param name>=<value>`.
+Set `gravitino.iceberg-rest.advertised-uri` when clients such as the Trino
connector reach the service through a reverse proxy whose scheme, host, port or
path differs from the listener's, for example
`https://iceberg.example.com/iceberg/`.
+An explicit port must be in the range 1-65535; an invalid value makes
discovery requests fail instead of advertising an unreachable endpoint.
+It only affects the advertised endpoint, not the listener; when unset, the
endpoint is derived from `host`, `httpPort`/`httpsPort` and `enableHttps`.
+This setting applies only to the auxiliary service.
+
#### Asynchronous Table Purge
By default, dropping a table with `purgeRequested=true` is synchronous: the
catalog entry and the table files are removed before the `DELETE` returns.
diff --git a/docs/open-api/openapi.yaml b/docs/open-api/openapi.yaml
index 826dd33aa8..5af0739eb1 100644
--- a/docs/open-api/openapi.yaml
+++ b/docs/open-api/openapi.yaml
@@ -58,6 +58,9 @@ paths:
/health/ready:
$ref: "./health.yaml#/paths/~1health~1ready"
+ /system/iceberg-rest:
+ $ref: "./system.yaml#/paths/~1system~1iceberg-rest"
+
/metalakes:
# escape the path name by replacing `/` with `~1`
$ref: "./metalakes.yaml#/paths/~1metalakes"
diff --git a/docs/open-api/system.yaml b/docs/open-api/system.yaml
new file mode 100644
index 0000000000..6558c10b90
--- /dev/null
+++ b/docs/open-api/system.yaml
@@ -0,0 +1,95 @@
+# 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.
+
+---
+
+paths:
+ /system/iceberg-rest:
+ get:
+ tags:
+ - system
+ summary: Discover the Iceberg REST service endpoint
+ operationId: getIcebergRestServiceUri
+ description: >
+ Reports the endpoint of the Iceberg REST service running as an
auxiliary service of this
+ Gravitino server, so that clients which already connect to the
Gravitino server, such as
+ the Trino connector, can discover it instead of configuring it
separately.
+ The reported `uri` is `null` when the service is not registered, does
not use the dynamic
+ catalog config provider, or serves a different metalake than the
requested one.
+ When `gravitino.iceberg-rest.advertised-uri` is configured, it is
reported as-is;
+ otherwise the endpoint is derived from the service's listener
configuration, and a wildcard
+ listener host is replaced with the host the caller used to reach the
Gravitino server.
+ The response is therefore marked `Cache-Control: no-store`.
+ parameters:
+ - name: metalake
+ in: query
+ description: >
+ The metalake the caller intends to route through the Iceberg REST
service. When
+ omitted or blank, the endpoint is reported regardless of which
metalake it serves.
+ required: false
+ schema:
+ type: string
+ responses:
+ "200":
+ $ref: "#/components/responses/IcebergRESTServiceResponse"
+ "4XX":
+ $ref: "./openapi.yaml#/components/responses/BadRequestErrorResponse"
+ "5XX":
+ $ref: "./openapi.yaml#/components/responses/ServerErrorResponse"
+
+components:
+ responses:
+ IcebergRESTServiceResponse:
+ description: The Iceberg REST service endpoint
+ content:
+ application/vnd.gravitino.v1+json:
+ schema:
+ $ref: "#/components/schemas/IcebergRESTServiceResponse"
+ examples:
+ IcebergRESTServiceAvailable:
+ $ref: "#/components/examples/IcebergRESTServiceAvailable"
+ IcebergRESTServiceUnavailable:
+ $ref: "#/components/examples/IcebergRESTServiceUnavailable"
+
+ schemas:
+ IcebergRESTServiceResponse:
+ type: object
+ required:
+ - code
+ properties:
+ code:
+ type: integer
+ description: Response code, 0 for success
+ example: 0
+ uri:
+ type: string
+ nullable: true
+ description: >
+ The Iceberg REST service endpoint, or null when the service is not
running or does not
+ serve the requested metalake
+ example: "http://gravitino-host:9001/iceberg"
+
+ examples:
+ IcebergRESTServiceAvailable:
+ value:
+ code: 0
+ uri: "http://gravitino-host:9001/iceberg"
+
+ IcebergRESTServiceUnavailable:
+ value:
+ code: 0
+ uri: null
diff --git a/docs/trino-connector/catalog-iceberg.md
b/docs/trino-connector/catalog-iceberg.md
index 6244590e72..ccfae7bf9e 100644
--- a/docs/trino-connector/catalog-iceberg.md
+++ b/docs/trino-connector/catalog-iceberg.md
@@ -48,6 +48,12 @@ as part of that catalog's own definition — the same way
Trino replicates any o
cluster-wide. A catalog that could not be registered before the IRC started is
registered
automatically after a later discovery poll succeeds; no Trino restart is
required.
+The Gravitino server derives the discovered endpoint from the IRC's listener
configuration, so
+behind a reverse proxy it may report an endpoint clients cannot reach. In that
case, set
+`gravitino.iceberg-rest.advertised-uri` on the Gravitino server to the public
endpoint (see
+[Iceberg REST service](../iceberg-rest-service.md#http-server)); discovery
then reports that URI
+instead.
+
Set `gravitino.iceberg.rest-uri` to override the discovered endpoint, and it
is required — not just
an override — for a standalone IRC (its own process, not the Gravitino
server's auxiliary service):
the Gravitino server has no way to know a standalone IRC exists, so discovery
never finds one. See
diff --git
a/server/src/main/java/org/apache/gravitino/server/web/rest/IcebergRESTServiceOperations.java
b/server/src/main/java/org/apache/gravitino/server/web/rest/IcebergRESTServiceOperations.java
index 05969db42f..ce10a056e4 100644
---
a/server/src/main/java/org/apache/gravitino/server/web/rest/IcebergRESTServiceOperations.java
+++
b/server/src/main/java/org/apache/gravitino/server/web/rest/IcebergRESTServiceOperations.java
@@ -20,6 +20,8 @@ package org.apache.gravitino.server.web.rest;
import com.codahale.metrics.annotation.ResponseMetered;
import com.codahale.metrics.annotation.Timed;
+import java.net.URI;
+import java.net.URISyntaxException;
import java.util.Map;
import javax.servlet.http.HttpServletRequest;
import javax.ws.rs.Consumes;
@@ -63,6 +65,8 @@ public class IcebergRESTServiceOperations {
// The post-strip key used by the Iceberg REST server itself; see
// IcebergConstants.GRAVITINO_METALAKE and DynamicIcebergConfigProvider.
private static final String SERVED_METALAKE_KEY = "gravitino-metalake";
+ // Overrides the listener-derived endpoint; see docs/iceberg-rest-service.md.
+ private static final String ADVERTISED_URI_KEY = "advertised-uri";
private static final String HOST_KEY = "host";
private static final String HTTP_PORT_KEY = "httpPort";
private static final String HTTPS_PORT_KEY = "httpsPort";
@@ -90,9 +94,18 @@ public class IcebergRESTServiceOperations {
@Timed(name = "iceberg-rest-service." + MetricNames.HTTP_PROCESS_DURATION,
absolute = true)
@ResponseMetered(name = "iceberg-rest-service", absolute = true)
public Response getIcebergRestServiceUri(@QueryParam("metalake") String
metalake) {
+ String uri;
+ try {
+ uri = resolveUri(metalake);
+ } catch (IllegalStateException e) {
+ // A misconfiguration, re-reported on every discovery poll until fixed;
the message alone
+ // identifies it, so the stack trace is omitted from both the log and
the response.
+ LOG.error("Failed to resolve the Iceberg REST service endpoint: {}",
e.getMessage());
+ return Utils.internalError(e.getMessage());
+ }
// The reported host can depend on the caller's own Host header (see
resolveUri), so this
// response must never be cached and replayed to a different caller.
- return Response.fromResponse(Utils.ok(new
IcebergRESTServiceResponse(resolveUri(metalake))))
+ return Response.fromResponse(Utils.ok(new IcebergRESTServiceResponse(uri)))
.header("Cache-Control", "no-store")
.build();
}
@@ -147,6 +160,11 @@ public class IcebergRESTServiceOperations {
return null;
}
+ String advertisedUri =
StringUtils.trimToNull(config.get(ADVERTISED_URI_KEY));
+ if (advertisedUri != null) {
+ return checkAdvertisedUri(advertisedUri);
+ }
+
String host = config.getOrDefault(HOST_KEY, DEFAULT_HOST);
if (isWildcardHost(host)) {
// The Iceberg REST server binds to all interfaces, so it has no single
externally
@@ -166,6 +184,31 @@ public class IcebergRESTServiceOperations {
return String.format("%s://%s:%d/iceberg", scheme, bracketIfIPv6(host),
port);
}
+ private static String checkAdvertisedUri(String value) {
+ boolean valid;
+ try {
+ URI uri = new URI(value);
+ // URI accepts any non-negative integer as a port; -1 means no explicit
port.
+ int port = uri.getPort();
+ valid =
+ StringUtils.equalsAnyIgnoreCase(uri.getScheme(), "http", "https")
+ && StringUtils.isNotBlank(uri.getHost())
+ && (port == -1 || (port >= 1 && port <= 65535))
+ && uri.getQuery() == null
+ && uri.getFragment() == null;
+ } catch (URISyntaxException e) {
+ valid = false;
+ }
+ if (!valid) {
+ throw new IllegalStateException(
+ String.format(
+ "Invalid Iceberg REST service %s '%s': expected an absolute
http(s) URI with a "
+ + "host, a port in 1-65535 if present, and no query or
fragment",
+ ADVERTISED_URI_KEY, value));
+ }
+ return value;
+ }
+
// An IPv6 literal host (e.g. "::1", from an explicit config value or from
// HttpServletRequest#getServerName()) must be bracketed to form a valid URI
authority;
// otherwise its colons are parsed as the port separator. A hostname or IPv4
address never
diff --git
a/server/src/test/java/org/apache/gravitino/server/web/rest/TestIcebergRESTServiceOperations.java
b/server/src/test/java/org/apache/gravitino/server/web/rest/TestIcebergRESTServiceOperations.java
index 3db9f8fa43..ef34808f55 100644
---
a/server/src/test/java/org/apache/gravitino/server/web/rest/TestIcebergRESTServiceOperations.java
+++
b/server/src/test/java/org/apache/gravitino/server/web/rest/TestIcebergRESTServiceOperations.java
@@ -20,6 +20,7 @@ package org.apache.gravitino.server.web.rest;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
@@ -29,8 +30,11 @@ import javax.servlet.http.HttpServletRequest;
import javax.ws.rs.Produces;
import javax.ws.rs.core.Response;
import org.apache.gravitino.auxiliary.AuxiliaryServiceManager;
+import org.apache.gravitino.dto.responses.ErrorResponse;
import org.apache.gravitino.dto.responses.IcebergRESTServiceResponse;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
public class TestIcebergRESTServiceOperations {
@@ -224,6 +228,93 @@ public class TestIcebergRESTServiceOperations {
assertEquals("http://irc-host:9001/iceberg",
uriOf(ops.getIcebergRestServiceUri("")));
}
+ @Test
+ public void testAdvertisedUriIsReportedAsIs() {
+ // A reverse-proxied IRC: the public scheme, host, port and path all
differ from the listener's,
+ // and the path prefix and trailing slash must survive untouched.
+ IcebergRESTServiceOperations ops =
+ newOps(
+ true,
+ withDynamicProvider(
+ ImmutableMap.of(
+ "host", "0.0.0.0",
+ "httpPort", "9001",
+ "advertised-uri",
"https://iceberg.example.com:8443/proxy/iceberg/")),
+ "gravitino.example.com");
+ assertEquals(
+ "https://iceberg.example.com:8443/proxy/iceberg/",
uriOf(ops.getIcebergRestServiceUri("")));
+ }
+
+ @Test
+ public void testAdvertisedUriStillRequiresRegisteredAuxService() {
+ IcebergRESTServiceOperations ops =
+ newOps(
+ false,
+ withDynamicProvider(
+ ImmutableMap.of("advertised-uri",
"https://iceberg.example.com/iceberg")),
+ "gravitino-host");
+ assertNull(uriOf(ops.getIcebergRestServiceUri("")));
+ }
+
+ @Test
+ public void testAdvertisedUriStillRequiresMatchingMetalake() {
+ IcebergRESTServiceOperations ops =
+ newOps(
+ true,
+ withDynamicProvider(
+ ImmutableMap.of(
+ "gravitino-metalake", "prod",
+ "advertised-uri", "https://iceberg.example.com/iceberg")),
+ "gravitino-host");
+ assertNull(uriOf(ops.getIcebergRestServiceUri("test")));
+ assertEquals(
+ "https://iceberg.example.com/iceberg",
uriOf(ops.getIcebergRestServiceUri("prod")));
+ }
+
+ @Test
+ public void testAdvertisedUriStillRequiresDynamicConfigProvider() {
+ IcebergRESTServiceOperations ops =
+ newOps(
+ true,
+ ImmutableMap.of("advertised-uri",
"https://iceberg.example.com/iceberg"),
+ "gravitino-host");
+ assertNull(uriOf(ops.getIcebergRestServiceUri("")));
+ }
+
+ @Test
+ public void testBlankAdvertisedUriFallsBackToDerivedEndpoint() {
+ IcebergRESTServiceOperations ops =
+ newOps(
+ true,
+ withDynamicProvider(ImmutableMap.of("host", "irc-host",
"advertised-uri", " ")),
+ "gravitino-host");
+ assertEquals("http://irc-host:9001/iceberg",
uriOf(ops.getIcebergRestServiceUri("")));
+ }
+
+ @ParameterizedTest
+ @ValueSource(
+ strings = {
+ "iceberg.example.com/iceberg",
+ "ftp://iceberg.example.com/iceberg",
+ "https:///iceberg",
+ "https://iceberg.example.com/iceberg?x=1",
+ "https://iceberg.example.com/iceberg#frag",
+ "https://iceberg.example.com:0/iceberg",
+ "https://iceberg.example.com:70000/iceberg",
+ "http://bad host/iceberg"
+ })
+ public void testInvalidAdvertisedUriIsAnInternalError(String invalid) {
+ IcebergRESTServiceOperations ops =
+ newOps(
+ true,
+ withDynamicProvider(ImmutableMap.of("host", "irc-host",
"advertised-uri", invalid)),
+ "gravitino-host");
+ Response response = ops.getIcebergRestServiceUri("");
+ assertEquals(Response.Status.INTERNAL_SERVER_ERROR.getStatusCode(),
response.getStatus());
+ ErrorResponse error = (ErrorResponse) response.getEntity();
+ assertTrue(error.getMessage().contains("advertised-uri"));
+ }
+
@Test
public void testResponseIsNotCacheable() {
IcebergRESTServiceOperations ops =