This is an automated email from the ASF dual-hosted git repository.
jerryshao pushed a commit to branch branch-1.3
in repository https://gitbox.apache.org/repos/asf/gravitino.git
The following commit(s) were added to refs/heads/branch-1.3 by this push:
new cc50b86e92 [Cherry-pick to branch-1.3] [#13098] improvement(server):
Support an advertised URI for Iceberg REST service discovery (#13126) (#13169)
cc50b86e92 is described below
commit cc50b86e925a63235e25823144693de39c664c8e
Author: github-actions[bot]
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Tue Sep 15 19:07:55 2026 +0800
[Cherry-pick to branch-1.3] [#13098] improvement(server): Support an
advertised URI for Iceberg REST service discovery (#13126) (#13169)
**Cherry-pick Information:**
- Original commit: 03da573642dcee1a97e896b0fa9d3e86a0b7315e
- Target branch: `branch-1.3`
- Status: ✅ Clean cherry-pick (no conflicts)
Co-authored-by: Yuhui <[email protected]>
---
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 1da9e11ccf..738f9839e7 100644
--- a/docs/iceberg-rest-service.md
+++ b/docs/iceberg-rest-service.md
@@ -124,10 +124,16 @@ Do not add them to the standalone server configuration.
| `gravitino.iceberg-rest.requestHeaderSize` | The maximum size of an
HTTP request.
| `131072`
| No |
| `gravitino.iceberg-rest.responseHeaderSize` | The maximum size of an
HTTP response.
| `131072`
| 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 565218e8cc..d1623504e5 100644
--- a/docs/open-api/openapi.yaml
+++ b/docs/open-api/openapi.yaml
@@ -57,6 +57,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 =