This is an automated email from the ASF dual-hosted git repository.
zy-kkk pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris.git
The following commit(s) were added to refs/heads/master by this push:
new 8fc58e929b2 [fix](iceberg) Reject server planning before snapshot
conversion (#68095)
8fc58e929b2 is described below
commit 8fc58e929b2151c9ff4ae71d07375a7f8e944697
Author: zy-kkk <[email protected]>
AuthorDate: Sat Sep 19 20:32:12 2026 +0800
[fix](iceberg) Reject server planning before snapshot conversion (#68095)
Issue Number: close #68035
Related feature: #68036
Doris reconstructs loaded Iceberg tables as BaseTable snapshot views.
BaseTable implements SupportsDistributedScanPlanning with a default true
result, so wrapping RESTTable loses its false override. The existing scan-entry
guard then allows unsupported local planning. With restricted metadata
credentials this surfaces as an S3 error; a separately documented simulated
server residual demonstrates why ignoring the server plan can also change
results.
Check the original table before statement snapshot conversion,
tracked-table publication, and cached TableOwner metadata serialization/weight
estimation. Reject unsupported tables before local manifest access and release
acquired table/catalog ownership on failure. Preserve the existing scan-entry
guard and normal client-planned snapshot semantics.
**Rejection includes metadata acquisition, not only SELECT scans.** DESC,
SHOW CREATE TABLE, and INSERT target-column binding also fail for
server-planned tables. Row-count and partition metadata paths using the same
read acquisition are subject to this guard. The sharedWritableTable method
itself is unchanged; this does not imply SQL INSERT remains supported.
Add a dedicated native Iceberg REST fixture, pinned by multi-platform
digest, to the existing Iceberg Compose stack. Keep the existing Spark/Postgres
REST baseline. MinIO initialization creates a private test bucket and data-only
credentials. The enabled regression requires a working /plan endpoint: an old
or missing fixture fails instead of skipping.
The fixture digest fixes the contents of an upstream `main` snapshot, built
on 2026-04-29 from `b0df3ca01d61b2f7ae7143ac660c6b16e33b6e46` (recorded in the
JAR build properties). This commit precedes the `apache-iceberg-1.11.0` tag by
105 commits; the image is not a release build. Moving `latest` cannot change
the pinned contents. The retention risk is that the old manifest can become
untagged and later be removed upstream, breaking fresh pulls.
**Request to CI maintainers:** build a linux/amd64 + linux/arm64 fixture
from the upstream `apache-iceberg-1.11.0` tag using the upstream shadowJar task
and Dockerfile, and publish it as
`doristhirdpartydocker/iceberg-rest-fixture:1.11.0`. Keep the current digest
until that artifact exists; verify source/architectures and rerun this
regression, then switch to the versioned image with its verified digest.
Mirroring the current image into the Doris namespace is a retention fallback,
but [...]
The regression invokes the real /plan endpoint, verifies restricted
data-file reads succeed and manifest access returns 403, and checks explicit
Doris rejection for SELECT, metadata statements, INSERT, empty tables, and
$files with cache enabled/disabled. Client-planned reads remain four rows
before and after. No policy proxy is part of this Docker regression.
Server-forced planning mode is additionally covered by HTTP RESTCatalog unit
tests.
---
.../thirdparties/docker-compose/iceberg/README.md | 88 ++++++
.../docker-compose/iceberg/iceberg.env | 2 +
.../docker-compose/iceberg/iceberg.yaml.tpl | 36 +++
.../scripts/scan-planning/data-only-policy.json | 32 +++
.../scan-planning/init-storage.sh} | 16 +-
.../connector/iceberg/IcebergScanPlanProvider.java | 10 +-
.../connector/iceberg/IcebergScanPlanning.java | 38 +++
.../connector/iceberg/IcebergStatementScope.java | 36 ++-
.../doris/connector/iceberg/IcebergTableCache.java | 16 +-
.../iceberg/IcebergRestScanPlanningTest.java | 303 +++++++++++++++++++++
.../iceberg/IcebergStatementScopeTest.java | 2 +-
regression-test/conf/regression-conf.groovy | 1 +
.../test_iceberg_rest_server_planning_guard.out | 13 +
.../pipeline/external/conf/regression-conf.groovy | 1 +
.../nonConcurrent/conf/regression-conf.groovy | 1 +
.../pipeline/p0/conf/regression-conf.groovy | 1 +
.../test_iceberg_rest_server_planning_guard.groovy | 177 ++++++++++++
17 files changed, 747 insertions(+), 26 deletions(-)
diff --git a/docker/thirdparties/docker-compose/iceberg/README.md
b/docker/thirdparties/docker-compose/iceberg/README.md
index fdac87f128f..41d82b2affd 100644
--- a/docker/thirdparties/docker-compose/iceberg/README.md
+++ b/docker/thirdparties/docker-compose/iceberg/README.md
@@ -55,3 +55,91 @@ amd64 emulation:
CONTAINER_UID=dorismac- /opt/homebrew/bin/bash \
docker/thirdparties/run-thirdparties-docker.sh -c iceberg-rest
```
+
+## Server-side scan planning regression
+
+The `iceberg` stack also starts `rest-scan-planning` and `scan-planning-init`.
+These services are required by `test_iceberg_rest_server_planning_guard`
whenever
+`enableIcebergTest=true`. The existing `rest` service remains the
Spark/Postgres
+baseline; the new REST service has its own disposable SQLite catalog and uses
+the private `scan-planning` bucket in the same MinIO service.
+
+The new fixture is pinned to the multi-platform image digest used by
+[Iceberg Go's integration
tests](https://github.com/apache/iceberg-go/issues/1880).
+There is currently no `apache/iceberg-rest-fixture:1.11.0` tag. Do not replace
the
+pin with a floating `latest` tag or an older released image: the regression
+requires the real `/plan` implementation. The pinned index includes both
+`linux/amd64` and `linux/arm64`.
+
+The image was built on 2026-04-29 from upstream `main` commit
+[`b0df3ca01d61b2f7ae7143ac660c6b16e33b6e46`](https://github.com/apache/iceberg/commit/b0df3ca01d61b2f7ae7143ac660c6b16e33b6e46),
+as recorded in its JAR's `iceberg-build.properties`. This commit is an ancestor
+of `apache-iceberg-1.11.0`, with 105 intervening commits; it is a development
+snapshot, not a 1.11.0 release build. The digest fixes the image contents;
+`latest` moving does not change what is pulled. If the old manifest becomes
+untagged and upstream removes it, fresh pulls will fail.
+
+The preferred replacement is a multi-platform image built from the upstream
+`apache-iceberg-1.11.0` tag and published by Doris CI maintainers as
+`doristhirdpartydocker/iceberg-rest-fixture:1.11.0`. That image has not yet
been
+provisioned for this change. Keep the current digest until the replacement is
+available, then verify its source and architectures, pin its digest, and rerun
+the regression before switching. Mirroring the current image into the Doris
+namespace is a fallback for retention, but would still be a snapshot build.
+Upstream versioned-image availability is tracked by Iceberg Go issue #1880;
+this change does not depend on an upstream publishing fix arriving first.
+
+The standard `run-thirdparties-docker.sh -c iceberg` command starts the new
+services automatically. Docker initialization creates the private bucket and a
+test-only `scan_data_reader` identity. This identity can read table data but is
+explicitly denied access to metadata under `wh/rest_guard_db/`. Initialization
+is repeatable and does not change the existing `warehouse` bucket's policy.
+
+Ports and regression settings must match:
+
+| Compose environment variable | Default | Regression setting |
+| --- | --- | --- |
+| `ICEBERG_SCAN_PLANNING_REST_PORT` | 18182 |
`iceberg_scan_planning_rest_uri_port` |
+| `MINIO_API_PORT` | 19001 | `iceberg_minio_port` |
+
+After starting the stack and FE/BE, run from the repository root:
+
+```bash
+bash run-regression-test.sh --run -d external_table_p0/iceberg \
+ -s test_iceberg_rest_server_planning_guard
+```
+
+The regression creates an empty table and a four-row table through a normal
+client-planned catalog. It submits a real REST plan, reads a byte from every
+planned data file with restricted credentials, and checks that a manifest HEAD
+fails with HTTP 403. Doris server-planned reads must then report the explicit
+unsupported-mode error, including with restricted storage credentials. `DESC`,
+`SHOW CREATE TABLE`, and `INSERT` are also rejected during metadata
acquisition;
+client-planned reads remain unchanged. Both enabled and disabled table caches
+are covered.
+
+An absent service, missing `/plan`, failed storage initialization, or an
invalid
+storage-policy control **fails** the enabled suite. There is no
capability-based
+skip. The normal top-level `enableIcebergTest` gate still applies to jobs that
do
+not run external Iceberg tests.
+
+For a lightweight local run without the Spark data archive, render the same
+Compose template with a unique prefix, then start only the new fixture and its
+MinIO dependencies (from this directory):
+
+```bash
+sed 's/doris--/scan-check-/g' iceberg.yaml.tpl > iceberg.yaml
+# Override these ports if another local test stack already uses the defaults.
+docker compose -p scan-check --env-file iceberg.env -f iceberg.yaml \
+ up -d --wait rest-scan-planning
+```
+
+This uses the same repository services and initialization as the full stack.
+Do not share the generated YAML between simultaneously running stacks. Set the
+matching host/ports and `enableIcebergTest=true` in your local regression
config.
+The new catalog is ephemeral across container recreation; the suite recreates
+its own tables. The bucket remains in MinIO's existing data volume.
+
+This bug regression does not simulate an additional server row policy. A
+client-supplied filter and a server-imposed policy are different contracts;
+server-forced planning mode is covered separately by real RESTCatalog unit
tests.
diff --git a/docker/thirdparties/docker-compose/iceberg/iceberg.env
b/docker/thirdparties/docker-compose/iceberg/iceberg.env
index 0950783075c..dfaa5f962a0 100644
--- a/docker/thirdparties/docker-compose/iceberg/iceberg.env
+++ b/docker/thirdparties/docker-compose/iceberg/iceberg.env
@@ -23,3 +23,5 @@ SPARK_THRIFT_PORT=11000
REST_CATALOG_PORT=18181
MINIO_UI_PORT=9000
MINIO_API_PORT=19001
+
+ICEBERG_SCAN_PLANNING_REST_PORT=18182
diff --git a/docker/thirdparties/docker-compose/iceberg/iceberg.yaml.tpl
b/docker/thirdparties/docker-compose/iceberg/iceberg.yaml.tpl
index a9fecf4a936..de392befe8a 100644
--- a/docker/thirdparties/docker-compose/iceberg/iceberg.yaml.tpl
+++ b/docker/thirdparties/docker-compose/iceberg/iceberg.yaml.tpl
@@ -101,6 +101,42 @@ services:
- /usr/lib/iceberg-rest/iceberg-rest-adapter.jar:/opt/jdbc/postgresql.jar
- org.apache.iceberg.rest.RESTCatalogServer
+ # Dedicated, mandatory fixture for the server-planning regression suite.
Keep the
+ # existing Spark/Postgres baseline on its released REST fixture.
+ rest-scan-planning:
+ # Content pinned by digest: upstream main
b0df3ca01d61b2f7ae7143ac660c6b16e33b6e46,
+ # built 2026-04-29 before the 1.11.0 release; same pin as
apache/iceberg-go.
+ # Replace after CI maintainers publish a verified 1.11.0 build to
doristhirdpartydocker.
+ image:
apache/iceberg-rest-fixture:latest@sha256:db8de90b5b7693d4ac334c336f91d9bbe320d7b19f4f514d26de84cdfbcbfe8d
+ container_name: doris--iceberg-rest-scan-planning
+ ports:
+ - ${ICEBERG_SCAN_PLANNING_REST_PORT:-18182}:8181
+ depends_on:
+ scan-planning-init:
+ condition: service_completed_successfully
+ environment:
+ AWS_ACCESS_KEY_ID: admin
+ AWS_SECRET_ACCESS_KEY: password
+ AWS_REGION: us-east-1
+ CATALOG_WAREHOUSE: s3://scan-planning/wh/
+ CATALOG_IO__IMPL: org.apache.iceberg.aws.s3.S3FileIO
+ CATALOG_S3_ENDPOINT: http://minio:9000
+ CATALOG_S3_PATH__STYLE__ACCESS: "true"
+ networks:
+ - doris--iceberg
+
+ scan-planning-init:
+ image: doristhirdpartydocker/mc:RELEASE.2025-01-17T23-25-50Z
+ container_name: doris--iceberg-scan-planning-init
+ depends_on:
+ minio:
+ condition: service_healthy
+ volumes:
+ - ./scripts/scan-planning:/fixtures:ro
+ entrypoint: ["/bin/sh", "/fixtures/init-storage.sh"]
+ networks:
+ - doris--iceberg
+
trino:
image: trinodb/trino:482
container_name: doris--iceberg-trino
diff --git
a/docker/thirdparties/docker-compose/iceberg/scripts/scan-planning/data-only-policy.json
b/docker/thirdparties/docker-compose/iceberg/scripts/scan-planning/data-only-policy.json
new file mode 100644
index 00000000000..1ee22e00fa0
--- /dev/null
+++
b/docker/thirdparties/docker-compose/iceberg/scripts/scan-planning/data-only-policy.json
@@ -0,0 +1,32 @@
+{
+ "Version": "2012-10-17",
+ "Statement": [
+ {
+ "Effect": "Allow",
+ "Action": [
+ "s3:GetBucketLocation"
+ ],
+ "Resource": [
+ "arn:aws:s3:::scan-planning"
+ ]
+ },
+ {
+ "Effect": "Allow",
+ "Action": [
+ "s3:GetObject"
+ ],
+ "Resource": [
+ "arn:aws:s3:::scan-planning/wh/rest_guard_db/*/data/*"
+ ]
+ },
+ {
+ "Effect": "Deny",
+ "Action": [
+ "s3:GetObject"
+ ],
+ "Resource": [
+ "arn:aws:s3:::scan-planning/wh/rest_guard_db/*/metadata/*"
+ ]
+ }
+ ]
+}
diff --git a/docker/thirdparties/docker-compose/iceberg/iceberg.env
b/docker/thirdparties/docker-compose/iceberg/scripts/scan-planning/init-storage.sh
similarity index 68%
copy from docker/thirdparties/docker-compose/iceberg/iceberg.env
copy to
docker/thirdparties/docker-compose/iceberg/scripts/scan-planning/init-storage.sh
index 0950783075c..df796c3919d 100644
--- a/docker/thirdparties/docker-compose/iceberg/iceberg.env
+++
b/docker/thirdparties/docker-compose/iceberg/scripts/scan-planning/init-storage.sh
@@ -1,4 +1,4 @@
-#!/bin/bash
+#!/bin/sh
# 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
@@ -16,10 +16,10 @@
# specific language governing permissions and limitations
# under the License.
-NOTEBOOK_SERVER_PORT=8888
-SPARK_DRIVER_UI_PORT=8080
-SPARK_HISTORY_UI_PORT=10000
-SPARK_THRIFT_PORT=11000
-REST_CATALOG_PORT=18181
-MINIO_UI_PORT=9000
-MINIO_API_PORT=19001
+set -eu
+mc alias set local http://minio:9000 admin password
+mc mb --ignore-existing local/scan-planning
+mc anonymous set none local/scan-planning
+mc admin user add local scan_data_reader ScanDataOnly2026
+mc admin policy create local scan-data-only /fixtures/data-only-policy.json
+mc admin policy attach local scan-data-only --user scan_data_reader
diff --git
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergScanPlanProvider.java
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergScanPlanProvider.java
index 3fb1472114a..5d1f12a57fb 100644
---
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergScanPlanProvider.java
+++
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergScanPlanProvider.java
@@ -70,7 +70,6 @@ import org.apache.iceberg.SchemaAwareDataTableScan;
import org.apache.iceberg.SchemaParser;
import org.apache.iceberg.Snapshot;
import org.apache.iceberg.SplittableScanTask;
-import org.apache.iceberg.SupportsDistributedScanPlanning;
import org.apache.iceberg.Table;
import org.apache.iceberg.TableOperations;
import org.apache.iceberg.TableProperties;
@@ -3112,14 +3111,7 @@ public class IcebergScanPlanProvider implements
ConnectorScanPlanProvider {
}
private static void rejectServerSideScanPlanning(Table table,
IcebergTableHandle handle) {
- if (table instanceof SupportsDistributedScanPlanning
- && !((SupportsDistributedScanPlanning)
table).allowDistributedPlanning()) {
- // Iceberg 1.11 marks REST server-planned tables this way. Doris
reads manifests and table.io()
- // before planFiles(), when REST scan-scoped credentials do not
exist, so fail before any local I/O.
- throw new DorisConnectorException("Iceberg server-side scan
planning is not supported for table "
- + handle.getDbName() + "." + handle.getTableName()
- + "; configure the REST catalog to use client-side scan
planning");
- }
+ IcebergScanPlanning.rejectServerSideScanPlanning(table,
handle.getDbName() + "." + handle.getTableName());
}
/**
diff --git
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergScanPlanning.java
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergScanPlanning.java
new file mode 100644
index 00000000000..bf48244ff84
--- /dev/null
+++
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergScanPlanning.java
@@ -0,0 +1,38 @@
+// 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.doris.connector.iceberg;
+
+import org.apache.doris.connector.spi.DorisConnectorException;
+
+import org.apache.iceberg.SupportsDistributedScanPlanning;
+import org.apache.iceberg.Table;
+
+/** Checks native table capabilities before snapshot wrappers replace them
with BaseTable defaults. */
+final class IcebergScanPlanning {
+ private IcebergScanPlanning() {}
+
+ static void rejectServerSideScanPlanning(Table table, String tableName) {
+ if (table instanceof SupportsDistributedScanPlanning
+ && !((SupportsDistributedScanPlanning)
table).allowDistributedPlanning()) {
+ // RESTTable overrides this to false; plain BaseTable inherits
true. Doris cannot consume
+ // server tasks/residuals or scan-scoped credentials yet, so
reject before local manifest I/O.
+ throw new DorisConnectorException("Iceberg server-side scan
planning is not supported for table "
+ + tableName + "; configure the REST catalog to use
client-side scan planning");
+ }
+ }
+}
diff --git
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergStatementScope.java
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergStatementScope.java
index d64ded6f039..06919691b1b 100644
---
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergStatementScope.java
+++
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergStatementScope.java
@@ -83,7 +83,7 @@ final class IcebergStatementScope {
// historical "iceberg.table:" key prefix byte-for-byte, so the funnel
keeps identical hits / misses / NONE
// fall-through (proved by
IcebergStatementScopeTest#sharedTableKeyReproducesLegacyPrefixByteForByte).
return ConnectorStatementScopes.resolveInStatement(session,
TABLE_NAMESPACE, dbName, tableName,
- () -> snapshotReadTable(loader.get()));
+ () -> snapshotReadTable(loader.get(), dbName, tableName));
}
/**
@@ -97,7 +97,7 @@ final class IcebergStatementScope {
if (session == null || session.getStatementScope() ==
ConnectorStatementScope.NONE) {
// NONE has no statement-end callback, so it cannot safely own a
lease. Preserve its original direct
// load-every-time behavior; creating a lease here would drop its
only close handle and leak forever.
- return snapshotReadTable(unscopedLoader.get());
+ return snapshotReadTable(unscopedLoader.get(), dbName, tableName);
}
ScopedBorrow borrowed = ConnectorStatementScopes.resolveInStatement(
session, TABLE_NAMESPACE, dbName, tableName, () -> new
ScopedBorrow(loader.get()));
@@ -121,11 +121,34 @@ final class IcebergStatementScope {
IcebergCatalogResourceTracker resourceTracker, Supplier<Table>
loader,
Function<Table, Runnable> cleanupFactory) {
if (session == null || session.getStatementScope() ==
ConnectorStatementScope.NONE) {
- return snapshotReadTable(loader.get());
+ Table table = loader.get();
+ try {
+ return snapshotReadTable(table, dbName, tableName);
+ } catch (RuntimeException | Error failure) {
+ try {
+ cleanupFactory.apply(table).run();
+ } catch (RuntimeException | Error cleanupFailure) {
+ failure.addSuppressed(cleanupFailure);
+ }
+ throw failure;
+ }
}
TrackedTable tracked = ConnectorStatementScopes.resolveInStatement(
session, TABLE_NAMESPACE, dbName, tableName,
- () -> trackedTable(resourceTracker, loader, cleanupFactory,
true));
+ () -> {
+ TrackedTable loaded = trackedTable(resourceTracker,
loader, cleanupFactory, true);
+ try {
+
IcebergScanPlanning.rejectServerSideScanPlanning(loaded.table(), dbName + "." +
tableName);
+ return loaded;
+ } catch (RuntimeException | Error failure) {
+ try {
+ loaded.close();
+ } catch (RuntimeException | Error cleanupFailure) {
+ failure.addSuppressed(cleanupFailure);
+ }
+ throw failure;
+ }
+ });
return tracked.table();
}
@@ -135,7 +158,7 @@ final class IcebergStatementScope {
Function<Table, Runnable> cleanupFactory, Function<Table, T>
action) {
if (session == null || session.getStatementScope() ==
ConnectorStatementScope.NONE) {
try (TrackedTable tracked = trackedTable(resourceTracker, loader,
cleanupFactory, false)) {
- return action.apply(snapshotReadTable(tracked.table()));
+ return action.apply(snapshotReadTable(tracked.table(), dbName,
tableName));
}
}
return action.apply(sharedTrackedTable(
@@ -254,7 +277,8 @@ final class IcebergStatementScope {
}
}
- private static Table snapshotReadTable(Table table) {
+ private static Table snapshotReadTable(Table table, String dbName, String
tableName) {
+ IcebergScanPlanning.rejectServerSideScanPlanning(table, dbName + "." +
tableName);
if (!(table instanceof BaseTable)) {
return table;
}
diff --git
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergTableCache.java
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergTableCache.java
index eb9bf97256f..43be412bd04 100644
---
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergTableCache.java
+++
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergTableCache.java
@@ -143,8 +143,20 @@ final class IcebergTableCache {
}
}
};
- TableOwner loaded = new TableOwner(table, cleanup, true,
- entry.isEnabled() && entry.isWeightBounded());
+ TableOwner loaded;
+ try {
+ // Reject before weight estimation/metadata
serialization as well as snapshot conversion.
+
IcebergScanPlanning.rejectServerSideScanPlanning(table, identifier.toString());
+ loaded = new TableOwner(table, cleanup, true,
+ entry.isEnabled() && entry.isWeightBounded());
+ } catch (RuntimeException | Error failure) {
+ try {
+ cleanup.run();
+ } catch (RuntimeException | Error cleanupFailure) {
+ failure.addSuppressed(cleanupFailure);
+ }
+ throw failure;
+ }
loadedHere[0] = loaded;
return loaded;
} finally {
diff --git
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergRestScanPlanningTest.java
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergRestScanPlanningTest.java
new file mode 100644
index 00000000000..bb48030e317
--- /dev/null
+++
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergRestScanPlanningTest.java
@@ -0,0 +1,303 @@
+// 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.doris.connector.iceberg;
+
+import org.apache.doris.connector.cache.CacheSpec;
+import org.apache.doris.connector.cache.CatalogMetaCache;
+import
org.apache.doris.connector.iceberg.IcebergStatementScopeTest.ScopeSession;
+import org.apache.doris.connector.spi.ConnectorSession;
+import org.apache.doris.connector.spi.ConnectorStatementScope;
+import org.apache.doris.connector.spi.DorisConnectorException;
+import org.apache.doris.connector.spi.scan.ConnectorScanRequest;
+
+import com.sun.net.httpserver.HttpServer;
+import org.apache.iceberg.BaseTable;
+import org.apache.iceberg.DataFiles;
+import org.apache.iceberg.PartitionSpec;
+import org.apache.iceberg.Schema;
+import org.apache.iceberg.SupportsDistributedScanPlanning;
+import org.apache.iceberg.Table;
+import org.apache.iceberg.catalog.Namespace;
+import org.apache.iceberg.catalog.TableIdentifier;
+import org.apache.iceberg.inmemory.InMemoryCatalog;
+import org.apache.iceberg.io.FileIO;
+import org.apache.iceberg.io.InputFile;
+import org.apache.iceberg.io.OutputFile;
+import org.apache.iceberg.rest.Endpoint;
+import org.apache.iceberg.rest.RESTCatalog;
+import org.apache.iceberg.rest.responses.ConfigResponse;
+import org.apache.iceberg.rest.responses.ConfigResponseParser;
+import org.apache.iceberg.rest.responses.LoadTableResponse;
+import org.apache.iceberg.rest.responses.LoadTableResponseParser;
+import org.apache.iceberg.types.Types;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.function.Executable;
+
+import java.io.IOException;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/** Exercises actual SDK RESTTable capabilities across Doris read ownership
boundaries. */
+public class IcebergRestScanPlanningTest {
+ private static final TableIdentifier ID = TableIdentifier.of("db", "t");
+
+ @Test
+ public void directReadRejectsBeforeSnapshotConversion() throws Exception {
+ for (boolean serverOverride : new boolean[] {false, true}) {
+ for (boolean populated : new boolean[] {false, true}) {
+ try (RestFixture fixture = new RestFixture(serverOverride,
populated, true)) {
+ Table table = fixture.load();
+ Assertions.assertInstanceOf(BaseTable.class, table);
+ Assertions.assertFalse(((SupportsDistributedScanPlanning)
table).allowDistributedPlanning());
+ for (ConnectorStatementScope scope :
List.of(ConnectorStatementScope.NONE,
+ new TestStatementScope())) {
+ ConnectorSession session = new ScopeSession(1L, "q",
scope);
+ reject(() ->
IcebergStatementScope.sharedTable(session, "db", "t", () -> table));
+ if (scope == ConnectorStatementScope.NONE) {
+ reject(() ->
IcebergStatementScope.sharedBorrowedTable(session, "db", "t", () -> {
+ throw new AssertionError("NONE must use direct
loader");
+ }, () -> table));
+ }
+ scope.closeAll();
+ }
+ Assertions.assertEquals(0, fixture.io(table).reads.get());
+ Assertions.assertEquals(0, fixture.planRequests.get());
+ }
+ }
+ }
+ }
+
+ @Test
+ public void cacheRejectsBeforeSizingAndReleasesOwnership() throws
Exception {
+ for (boolean serverOverride : new boolean[] {false, true}) {
+ for (boolean populated : new boolean[] {false, true}) {
+ try (RestFixture fixture = new RestFixture(serverOverride,
populated, true)) {
+ for (CacheSpec spec : List.of(CacheSpec.ofConnectorTtl(0,
10),
+ CacheSpec.ofConnectorTtl(100, 10),
CacheSpec.ofWeight(true, 100, 1000, 1000000))) {
+ AtomicInteger tableCleanups = new AtomicInteger();
+ AtomicInteger catalogCleanups = new AtomicInteger();
+ IcebergCatalogResourceTracker tracker = new
IcebergCatalogResourceTracker();
+ try (CatalogMetaCache owner =
CatalogMetaCache.unmanaged()) {
+ IcebergTableCache cache = new
IcebergTableCache(owner, spec,
+ ignored -> tableCleanups::incrementAndGet,
tracker);
+ Table table = fixture.load();
+ for (int attempt = 0; attempt < 2; attempt++) {
+ reject(() -> cache.borrow(ID, () -> table));
+ }
+ Assertions.assertEquals(0, cache.size());
+ Assertions.assertEquals(2, tableCleanups.get());
+ Assertions.assertEquals(0,
fixture.io(table).reads.get());
+ cache.close();
+ tracker.close(catalogCleanups::incrementAndGet);
+ Assertions.assertEquals(1, catalogCleanups.get(),
"rejected loads must release leases");
+ Assertions.assertEquals(0,
tracker.retainedCleanupCount());
+ }
+ }
+ }
+ }
+ }
+ }
+
+ @Test
+ public void
trackedReadsReleaseRejectedTablesButWritableTablesRemainAvailable() throws
Exception {
+ try (RestFixture fixture = new RestFixture(true, true, true)) {
+ for (ConnectorStatementScope scope :
List.of(ConnectorStatementScope.NONE, new TestStatementScope())) {
+ ConnectorSession session = new ScopeSession(1L, "q", scope);
+ IcebergCatalogResourceTracker tracker = new
IcebergCatalogResourceTracker();
+ AtomicInteger tableCleanups = new AtomicInteger();
+ AtomicInteger catalogCleanups = new AtomicInteger();
+ Table table = fixture.load();
+ reject(() -> IcebergStatementScope.withTrackedTable(session,
"db", "t", tracker,
+ () -> table, ignored ->
tableCleanups::incrementAndGet, value -> value));
+ Assertions.assertEquals(1, tableCleanups.get());
+ Assertions.assertSame(table,
+ IcebergStatementScope.sharedWritableTable(session,
"db", "t", () -> table));
+ scope.closeAll();
+ tracker.close(catalogCleanups::incrementAndGet);
+ Assertions.assertEquals(1, tableCleanups.get(), "failed scope
load must not retain a close callback");
+ Assertions.assertEquals(1, catalogCleanups.get());
+ Assertions.assertEquals(0, fixture.io(table).reads.get());
+ }
+ }
+ }
+
+ @Test
+ public void unscopedDirectRejectionReleasesTableOwnedResources() throws
Exception {
+ try (RestFixture fixture = new RestFixture(true, true, true)) {
+ Table table = fixture.load();
+ AtomicInteger cleanups = new AtomicInteger();
+ reject(() -> IcebergStatementScope.sharedTrackedTable(null, "db",
"t",
+ new IcebergCatalogResourceTracker(), () -> table, ignored
-> cleanups::incrementAndGet));
+ Assertions.assertEquals(1, cleanups.get());
+ Assertions.assertEquals(0, fixture.io(table).reads.get());
+ }
+ }
+
+ @Test
+ public void clientPlanningStillCreatesPinnedReadViews() throws Exception {
+ try (RestFixture fixture = new RestFixture(false, true, false);
+ CatalogMetaCache owner = CatalogMetaCache.unmanaged()) {
+ Table table = fixture.load();
+ Assertions.assertTrue(((SupportsDistributedScanPlanning)
table).allowDistributedPlanning());
+ Table direct = IcebergStatementScope.sharedTable(null, "db", "t",
() -> table);
+ Assertions.assertNotSame(table, direct);
+ Assertions.assertEquals(table.currentSnapshot().snapshotId(),
direct.currentSnapshot().snapshotId());
+ IcebergTableCache cache = new IcebergTableCache(owner, 100, 10,
ignored -> () -> { }, null);
+ try (IcebergTableCache.TableLease lease = cache.borrow(ID, () ->
table)) {
+ Table cached = lease.snapshotReadTable();
+ Assertions.assertNotSame(table, cached);
+ Assertions.assertEquals(direct.currentSnapshot().snapshotId(),
cached.currentSnapshot().snapshotId());
+ }
+ cache.close();
+ Assertions.assertEquals(0, fixture.io(table).reads.get());
+ }
+ }
+
+ @Test
+ public void scanAndMetadataTableRejectThroughBothAcquisitionPaths() throws
Exception {
+ try (RestFixture fixture = new RestFixture(true, true, true)) {
+ for (boolean cached : new boolean[] {false, true}) {
+ for (ConnectorStatementScope scope :
List.of(ConnectorStatementScope.NONE, new TestStatementScope())) {
+ ConnectorSession session = new ScopeSession(1L, "q",
scope);
+ Table table = fixture.load();
+ RecordingIcebergCatalogOps ops = new
RecordingIcebergCatalogOps();
+ ops.table = table;
+ IcebergTableCache cache = new IcebergTableCache(100, 10);
+ try {
+ IcebergScanPlanProvider provider = new
IcebergScanPlanProvider(
+ IcebergCatalogProperties.of(Map.of()), ignored
-> ops, null, null,
+ cached ? cache : null);
+ reject(() -> provider.planScan(session,
ConnectorScanRequest.builder(
+ new IcebergTableHandle("db", "t"),
List.of()).build()));
+ reject(() -> provider.planScan(session,
ConnectorScanRequest.builder(
+ IcebergTableHandle.forSystemTable("db", "t",
"files", -1L, null, -1L),
+ List.of()).build()));
+ Assertions.assertEquals(0,
fixture.io(table).reads.get());
+ } finally {
+ scope.closeAll();
+ cache.close();
+ }
+ }
+ }
+ Assertions.assertEquals(0, fixture.planRequests.get());
+ }
+ }
+
+ private static void reject(Executable action) {
+ DorisConnectorException failure =
Assertions.assertThrows(DorisConnectorException.class, action);
+ Assertions.assertEquals("Iceberg server-side scan planning is not
supported for table db.t; "
+ + "configure the REST catalog to use client-side scan
planning", failure.getMessage());
+ }
+
+ /** A minimal real HTTP catalog; any attempt to read local manifests fails
immediately. */
+ private static final class RestFixture implements AutoCloseable {
+ private final HttpServer server;
+ private final RESTCatalog catalog = new RESTCatalog();
+ private final AtomicInteger planRequests = new AtomicInteger();
+
+ private RestFixture(boolean serverOverride, boolean populated, boolean
serverPlanning) throws IOException {
+ byte[] loadResponse;
+ try (InMemoryCatalog storage = new InMemoryCatalog()) {
+ storage.initialize("storage", Map.of());
+ storage.createNamespace(Namespace.of("db"));
+ Table table = storage.createTable(ID,
+ new Schema(Types.NestedField.required(1, "id",
Types.IntegerType.get())));
+ if (populated) {
+
table.newAppend().appendFile(DataFiles.builder(PartitionSpec.unpartitioned())
+
.withPath("memory://data.parquet").withRecordCount(4).withFileSizeInBytes(100).build())
+ .commit();
+ }
+ LoadTableResponse.Builder response =
LoadTableResponse.builder()
+ .withTableMetadata(((BaseTable)
table).operations().current());
+ if (serverOverride) {
+ response.addConfig("scan-planning-mode", "server");
+ }
+ loadResponse =
LoadTableResponseParser.toJson(response.build()).getBytes(StandardCharsets.UTF_8);
+ }
+ byte[] config =
ConfigResponseParser.toJson(ConfigResponse.builder()
+ .withEndpoints(List.of(Endpoint.V1_LOAD_TABLE,
Endpoint.V1_SUBMIT_TABLE_SCAN_PLAN)).build())
+ .getBytes(StandardCharsets.UTF_8);
+ server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0),
0);
+ server.createContext("/v1/", exchange -> {
+ String path = exchange.getRequestURI().getPath();
+ byte[] response;
+ int status = 200;
+ if (path.equals("/v1/config")) {
+ response = config;
+ } else if (path.equals("/v1/namespaces/db/tables/t")) {
+ response = loadResponse;
+ } else {
+ planRequests.incrementAndGet();
+ response = new byte[0];
+ status = 500;
+ }
+ exchange.getResponseHeaders().set("Content-Type",
"application/json");
+ exchange.sendResponseHeaders(status, response.length);
+ try (var out = exchange.getResponseBody()) {
+ out.write(response);
+ }
+ });
+ server.start();
+ catalog.initialize("rest", Map.of("uri", "http://127.0.0.1:" +
server.getAddress().getPort(),
+ "scan-planning-mode", serverPlanning && !serverOverride ?
"server" : "client",
+ "rest.auth.type", "none", "io-impl",
RecordingFileIO.class.getName()));
+ }
+
+ private Table load() {
+ return catalog.loadTable(ID);
+ }
+
+ private RecordingFileIO io(Table table) {
+ return (RecordingFileIO) table.io();
+ }
+
+ @Override
+ public void close() throws IOException {
+ try {
+ catalog.close();
+ } finally {
+ server.stop(0);
+ }
+ }
+ }
+
+ public static class RecordingFileIO implements FileIO {
+ private final AtomicInteger reads = new AtomicInteger();
+
+ @Override
+ public InputFile newInputFile(String path) {
+ reads.incrementAndGet();
+ throw new AssertionError("Unexpected local metadata read: " +
path);
+ }
+
+ @Override
+ public OutputFile newOutputFile(String path) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public void deleteFile(String path) {
+ throw new UnsupportedOperationException();
+ }
+ }
+
+}
diff --git
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergStatementScopeTest.java
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergStatementScopeTest.java
index bab105775e1..2df7d37c00b 100644
---
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergStatementScopeTest.java
+++
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergStatementScopeTest.java
@@ -314,7 +314,7 @@ public class IcebergStatementScopeTest {
}
/** Minimal {@link ConnectorSession} carrying a catalog id, queryId and
scope for the key + memo assertions. */
- private static final class ScopeSession implements ConnectorSession {
+ static final class ScopeSession implements ConnectorSession {
private final long catalogId;
private final String queryId;
private final ConnectorStatementScope scope;
diff --git a/regression-test/conf/regression-conf.groovy
b/regression-test/conf/regression-conf.groovy
index afbe0b1a5c3..8240d3f082e 100644
--- a/regression-test/conf/regression-conf.groovy
+++ b/regression-test/conf/regression-conf.groovy
@@ -239,6 +239,7 @@ extArrowFlightHttpPort= 8030
// iceberg rest catalog config
iceberg_rest_uri_port=18181
+iceberg_scan_planning_rest_uri_port=18182
iceberg_minio_port=19001
iceberg_rest_uri_port_s3=19181
iceberg_rest_uri_port_oss=19182
diff --git
a/regression-test/data/external_table_p0/iceberg/test_iceberg_rest_server_planning_guard.out
b/regression-test/data/external_table_p0/iceberg/test_iceberg_rest_server_planning_guard.out
new file mode 100644
index 00000000000..f95ccb035c7
--- /dev/null
+++
b/regression-test/data/external_table_p0/iceberg/test_iceberg_rest_server_planning_guard.out
@@ -0,0 +1,13 @@
+-- This file is automatically generated. You should know what you did if you
want to edit this
+-- !client --
+1
+2
+3
+4
+
+-- !client_after --
+1
+2
+3
+4
+
diff --git a/regression-test/pipeline/external/conf/regression-conf.groovy
b/regression-test/pipeline/external/conf/regression-conf.groovy
index d6679434ecc..4195d59f214 100644
--- a/regression-test/pipeline/external/conf/regression-conf.groovy
+++ b/regression-test/pipeline/external/conf/regression-conf.groovy
@@ -144,6 +144,7 @@ enableRefactorParamsTest=true
// iceberg test config
iceberg_rest_uri_port=18181
+iceberg_scan_planning_rest_uri_port=18182
iceberg_rest_uri_port_s3=19181
iceberg_rest_uri_port_oss=19182
iceberg_rest_uri_port_cos=19183
diff --git a/regression-test/pipeline/nonConcurrent/conf/regression-conf.groovy
b/regression-test/pipeline/nonConcurrent/conf/regression-conf.groovy
index b6f43050860..2d6ac80d06b 100644
--- a/regression-test/pipeline/nonConcurrent/conf/regression-conf.groovy
+++ b/regression-test/pipeline/nonConcurrent/conf/regression-conf.groovy
@@ -122,6 +122,7 @@ kafka_port=19193
// iceberg test config
iceberg_rest_uri_port=18181
+iceberg_scan_planning_rest_uri_port=18182
iceberg_minio_port=19001
iceberg_rest_uri_port_s3=19181
iceberg_rest_uri_port_oss=19182
diff --git a/regression-test/pipeline/p0/conf/regression-conf.groovy
b/regression-test/pipeline/p0/conf/regression-conf.groovy
index 6c8b89be058..a44485ec44d 100644
--- a/regression-test/pipeline/p0/conf/regression-conf.groovy
+++ b/regression-test/pipeline/p0/conf/regression-conf.groovy
@@ -129,6 +129,7 @@ kafka_port=19193
// iceberg test config
iceberg_rest_uri_port=18181
+iceberg_scan_planning_rest_uri_port=18182
iceberg_minio_port=19001
iceberg_rest_uri_port_s3=19181
iceberg_rest_uri_port_oss=19182
diff --git
a/regression-test/suites/external_table_p0/iceberg/test_iceberg_rest_server_planning_guard.groovy
b/regression-test/suites/external_table_p0/iceberg/test_iceberg_rest_server_planning_guard.groovy
new file mode 100644
index 00000000000..c455270d098
--- /dev/null
+++
b/regression-test/suites/external_table_p0/iceberg/test_iceberg_rest_server_planning_guard.groovy
@@ -0,0 +1,177 @@
+// 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.
+
+import com.amazonaws.auth.AWSStaticCredentialsProvider
+import com.amazonaws.auth.BasicAWSCredentials
+import com.amazonaws.client.builder.AwsClientBuilder
+import com.amazonaws.services.s3.AmazonS3ClientBuilder
+import com.amazonaws.services.s3.model.AmazonS3Exception
+import com.amazonaws.services.s3.model.GetObjectRequest
+import groovy.json.JsonSlurper
+import org.junit.jupiter.api.Assertions
+
+suite("test_iceberg_rest_server_planning_guard", "p0,external") {
+ if
(!"true".equalsIgnoreCase(context.config.otherConfigs.get("enableIcebergTest")))
{
+ return
+ }
+ String host = context.config.otherConfigs.get("externalEnvIp")
+ String restPort =
context.config.otherConfigs.get("iceberg_scan_planning_rest_uri_port")
+ String minioPort = context.config.otherConfigs.get("iceberg_minio_port")
+ String uri = "http://${host}:${restPort}"
+ // The dedicated fixture is mandatory whenever Iceberg tests are enabled.
Missing capabilities
+ // must fail the suite, not silently turn this regression into a passing
no-op.
+ def config = new JsonSlurper().parseText(new
URL("${uri}/v1/config").getText(
+ connectTimeout: 10000, readTimeout: 10000))
+ assertTrue(config.endpoints?.any { it.startsWith("POST ") &&
it.endsWith("/tables/{table}/plan") },
+ "Dedicated Iceberg REST fixture must advertise scan planning")
+
+ sql "DROP CATALOG IF EXISTS rest_guard_client"
+ sql """CREATE CATALOG rest_guard_client PROPERTIES (
+ 'type' = 'iceberg', 'iceberg.catalog.type' = 'rest', 'uri' = '${uri}',
+ 's3.access_key' = 'admin', 's3.secret_key' = 'password',
+ 's3.endpoint' = 'http://${host}:${minioPort}', 's3.region' =
'us-east-1',
+ 'use_path_style' = 'true', 'scan-planning-mode' = 'client'
+ )"""
+ sql "CREATE DATABASE IF NOT EXISTS rest_guard_client.rest_guard_db"
+ sql "DROP TABLE IF EXISTS rest_guard_client.rest_guard_db.populated"
+ sql "DROP TABLE IF EXISTS rest_guard_client.rest_guard_db.empty_table"
+ sql "CREATE TABLE rest_guard_client.rest_guard_db.populated (id INT)"
+ sql "CREATE TABLE rest_guard_client.rest_guard_db.empty_table (id INT)"
+ sql "INSERT INTO rest_guard_client.rest_guard_db.populated VALUES (1),
(2), (3), (4)"
+ order_qt_client "SELECT * FROM rest_guard_client.rest_guard_db.populated
ORDER BY id"
+
+ // Exercise the real /plan endpoint before testing rejection. A capability
advertisement alone
+ // would not prove the selected image can plan this table using its own
storage credentials.
+ String tablePath = "/v1/namespaces/rest_guard_db/tables/populated"
+ def connection = (HttpURLConnection) new
URL("${uri}${tablePath}/plan").openConnection()
+ connection.setConnectTimeout(10000)
+ connection.setReadTimeout(30000)
+ connection.setRequestMethod("POST")
+ connection.setRequestProperty("Content-Type", "application/json")
+ connection.setDoOutput(true)
+ def plan
+ try {
+ connection.outputStream.withCloseable {
it.write("{}".getBytes("UTF-8")) }
+ assertEquals(200, connection.responseCode, "Real server-side planning
must succeed")
+ connection.inputStream.withCloseable { plan = new
JsonSlurper().parse(it) }
+ } finally {
+ connection.disconnect()
+ }
+ assertEquals("completed", plan.status)
+ assertTrue(!plan["file-scan-tasks"].isEmpty(), "The real fixture must
return data-file tasks")
+
+ def loaded = new JsonSlurper().parseText(new
URL("${uri}${tablePath}").getText(
+ connectTimeout: 10000, readTimeout: 10000))
+ def snapshot = loaded.metadata.snapshots.find {
+ it["snapshot-id"] == loaded.metadata["current-snapshot-id"]
+ }
+ def restrictedStorage = AmazonS3ClientBuilder.standard()
+ .withEndpointConfiguration(new
AwsClientBuilder.EndpointConfiguration(
+ "http://${host}:${minioPort}", "us-east-1"))
+ .withCredentials(new AWSStaticCredentialsProvider(
+ new BasicAWSCredentials("scan_data_reader",
"ScanDataOnly2026")))
+ .withPathStyleAccessEnabled(true)
+ .build()
+ try {
+ // Verify both halves of the actual policy: data GET succeeds,
metadata HEAD is forbidden.
+ for (def task : plan["file-scan-tasks"]) {
+ URI file = new URI(task["data-file"]["file-path"])
+ def request = new GetObjectRequest(file.host,
file.path.substring(1)).withRange(0, 0)
+ restrictedStorage.getObject(request).withCloseable { obj ->
+ assertTrue(obj.objectContent.read() >= 0, "Restricted client
must be able to read data")
+ }
+ }
+ URI manifest = new URI(snapshot["manifest-list"])
+ try {
+ restrictedStorage.getObjectMetadata(manifest.host,
manifest.path.substring(1))
+ Assertions.fail("Restricted client unexpectedly read manifest
metadata")
+ } catch (AmazonS3Exception denied) {
+ assertEquals(403, denied.statusCode, "The failure must be a real
storage permission denial")
+ }
+ } finally {
+ restrictedStorage.shutdown()
+ }
+ // Cancel only after the storage checks succeed, so cancellation cannot
mask their failure.
+ // Completed plans may omit plan-id; only cancel when the server provides
one.
+ if (plan["plan-id"] != null) {
+ def cancel = (HttpURLConnection) new
URL("${uri}${tablePath}/plan/${plan['plan-id']}").openConnection()
+ try {
+ cancel.setRequestMethod("DELETE")
+ cancel.setConnectTimeout(10000)
+ cancel.setReadTimeout(10000)
+ assertEquals(204, cancel.responseCode)
+ } finally {
+ cancel.disconnect()
+ }
+ }
+
+ for (String cacheEnabled : ["true", "false"]) {
+ String catalog = "rest_guard_server_${cacheEnabled}"
+ sql "DROP CATALOG IF EXISTS ${catalog}"
+ sql """CREATE CATALOG ${catalog} PROPERTIES (
+ 'type' = 'iceberg', 'iceberg.catalog.type' = 'rest', 'uri' =
'${uri}',
+ 's3.access_key' = 'admin', 's3.secret_key' = 'password',
+ 's3.endpoint' = 'http://${host}:${minioPort}', 's3.region' =
'us-east-1',
+ 'use_path_style' = 'true', 'scan-planning-mode' = 'server',
+ 'meta.cache.iceberg.table.enable' = '${cacheEnabled}'
+ )"""
+ test {
+ sql "SELECT * FROM ${catalog}.rest_guard_db.populated ORDER BY id"
+ exception "Iceberg server-side scan planning is not supported"
+ }
+ test {
+ sql "DESC ${catalog}.rest_guard_db.populated"
+ exception "Iceberg server-side scan planning is not supported"
+ }
+ test {
+ sql "SHOW CREATE TABLE ${catalog}.rest_guard_db.populated"
+ exception "Iceberg server-side scan planning is not supported"
+ }
+ test {
+ sql "INSERT INTO ${catalog}.rest_guard_db.populated VALUES (99)"
+ exception "Iceberg server-side scan planning is not supported"
+ }
+ test {
+ sql "SELECT * FROM ${catalog}.rest_guard_db.empty_table"
+ exception "Iceberg server-side scan planning is not supported"
+ }
+ test {
+ sql "SELECT * FROM ${catalog}.rest_guard_db.`populated\$files`"
+ exception "Iceberg server-side scan planning is not supported"
+ }
+ }
+ sql "DROP CATALOG IF EXISTS rest_guard_restricted"
+ sql """CREATE CATALOG rest_guard_restricted PROPERTIES (
+ 'type' = 'iceberg', 'iceberg.catalog.type' = 'rest', 'uri' = '${uri}',
+ 's3.access_key' = 'scan_data_reader', 's3.secret_key' =
'ScanDataOnly2026',
+ 's3.endpoint' = 'http://${host}:${minioPort}', 's3.region' =
'us-east-1',
+ 'use_path_style' = 'true', 'scan-planning-mode' = 'server'
+ )"""
+ test {
+ sql "SELECT * FROM rest_guard_restricted.rest_guard_db.populated ORDER
BY id"
+ exception "Iceberg server-side scan planning is not supported"
+ }
+ test {
+ sql "SELECT * FROM
rest_guard_restricted.rest_guard_db.`populated\$files`"
+ exception "Iceberg server-side scan planning is not supported"
+ }
+ order_qt_client_after "SELECT * FROM
rest_guard_client.rest_guard_db.populated ORDER BY id"
+ sql "DROP CATALOG rest_guard_restricted"
+ sql "DROP CATALOG rest_guard_server_true"
+ sql "DROP CATALOG rest_guard_server_false"
+ sql "DROP CATALOG rest_guard_client"
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]