snapshot of https://github.com/seaweedfs/seaweedfs.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
go: build dev binaries / cleanup (push) Successful in 4s
Codespell / Check for spelling errors (push) Failing after 3s
docker: build dev containers / build-rust-binaries (amd64, x86_64-unknown-linux-musl) (push) Failing after 3s
docker: build dev containers / build-dev-containers (push) Has been skipped
docker: build dev containers / build-rust-binaries (arm64, true, aarch64-unknown-linux-musl) (push) Failing after 3s
End to End / FUSE Mount (push) Failing after 2s
EC Integration Tests / EC Integration Tests (push) Failing after 30s
FUSE DLM Integration Tests / FUSE DLM Integration Tests (push) Failing after 3s
FUSE Volume Server Failover Tests / FUSE Volume Server Failover (push) Failing after 3s
FUSE Integration Tests / FUSE Integration Testing (push) Failing after 2s
FUSE P2P Peer Chunk Sharing Integration Tests / FUSE P2P Peer Chunk Sharing (push) Failing after 3s
go: build binary / Go Vet (push) Failing after 2s
go: build binary / Go Vet 32-bit (push) Failing after 3s
go: build binary / Build (push) Failing after 4s
go: build binary / Build cross-platform (freebsd/amd64) (push) Failing after 3s
go: build binary / Build cross-platform (windows/amd64) (push) Failing after 3s
go: build binary / Build cross-platform (darwin/arm64) (push) Failing after 3s
go: build binary / Build cross-platform (windows/arm64) (push) Failing after 3s
go: build binary / Test (push) Failing after 2s
go: build binary / Test 32-bit (push) Failing after 3s
helm: lint and test charts / lint-test (push) Failing after 3s
Java Client Integration Tests / Java Integration Tests (11) (push) Failing after 3s
Java Client Integration Tests / Java Integration Tests (17) (push) Failing after 3s
go: build dev binaries / build_dev_linux_windows (amd64, linux) (push) Failing after 2s
go: build dev binaries / build_dev_linux_windows (amd64, windows) (push) Failing after 3s
go: build dev binaries / build_dev_darwin (amd64, darwin) (push) Has been skipped
go: build dev binaries / build_dev_darwin (arm64, darwin) (push) Has been skipped
Java Client Unit Tests / Java Unit Tests (11) (push) Failing after 3s
Java Client Unit Tests / Java Unit Tests (17) (push) Failing after 2s
Java Client Unit Tests / Java Unit Tests (21) (push) Failing after 3s
Java Client Unit Tests / Java Unit Tests (8) (push) Failing after 3s
Kafka Quick Test (Load Test with Schema Registry) / Kafka Client Load Test (Quick) (push) Failing after 3s
Kafka Gateway Tests / Kafka Unit Tests (unit-tests-1) (push) Failing after 11s
Kafka Gateway Tests / Kafka Integration Tests (Critical) (integration-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka End-to-End Tests (with SMQ) (e2e-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka Consumer Group Tests (Highly Isolated) (consumer-group-1) (push) Failing after 2s
Kafka Gateway Tests / Kafka Client Compatibility (with SMQ) (client-compat-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka SMQ Integration Tests (Full Stack) (smq-integration-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka Protocol Tests (Isolated) (protocol-1) (push) Failing after 3s
KMS Tests / KMS Provider Integration Tests (push) Failing after 2s
KMS Tests / S3 KMS End-to-End Tests (push) Failing after 3s
Master Cold Start Tests / Master Cold Start Tests (push) Failing after 4s
Metadata Subscribe Integration Tests / Metadata Subscribe Integration Tests (push) Failing after 3s
Multi-Master Tests / Multi-Master Failover Tests (push) Failing after 3s
Performance / CPU and Heap Profile (push) Failing after 2s
Performance / S3 Read/Write Benchmark (rust volume) (push) Failing after 3s
pjdfstest POSIX Compliance / pjdfstest (push) Failing after 2s
Plugin Worker Integration Tests / Plugin Worker: erasure_coding (push) Failing after 3s
Plugin Worker Integration Tests / Plugin Worker: vacuum (push) Failing after 3s
Rust Volume Server Tests / Rust Integration Tests (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (grpc - Shard 1) (push) Failing after 2s
Rust Volume Server Tests / Go Tests with Rust Volume (http - Shard 1) (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (grpc - Shard 2) (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (http - Shard 2) (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (grpc - Shard 3) (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Distributed Tests (push) Failing after 2s
S3 IAM Integration Tests / S3 IAM Performance Tests (push) Failing after 3s
Performance / Throughput Benchmark (go volume) (push) Failing after 3s
Performance / Throughput Benchmark (rust volume) (push) Failing after 2s
Performance / S3 Read/Write Benchmark (go volume) (push) Failing after 3s
Plugin Worker Integration Tests / Plugin Worker: volume_balance (push) Failing after 3s
PostgreSQL Gateway Tests / PostgreSQL Basic Tests (push) Failing after 3s
Rust Volume Server Tests / Rust Unit Tests (push) Failing after 2s
Rust Volume Server Tests / Go Tests with Rust Volume (http - Shard 3) (push) Failing after 3s
Rust Plugin Worker Tests / Rust Plugin Worker Build and Unit Tests (push) Failing after 3s
rust: build dev volume server binaries / cleanup (push) Successful in 3s
S3 ETag and ACL Tests / S3 ETag + ACL Integration Tests (push) Failing after 3s
S3 IAM Integration Tests / IAM Unit Tests (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Integration Tests (advanced) (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Integration Tests (basic) (push) Failing after 2s
S3 IAM Integration Tests / S3 IAM Integration Tests (group) (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Integration Tests (policy-enforcement) (push) Failing after 2s
S3 IAM Integration Tests / S3 IAM Integration Tests (sts) (push) Failing after 4s
S3 Keycloak Integration Tests / S3 Keycloak Integration Tests (push) Failing after 3s
S3 PyArrow Parquet Tests / PyArrow Parquet Tests (Python 3.11) (push) Failing after 3s
S3 PyArrow Parquet Tests / Go Unit Tests (Implicit Directory) (push) Has been cancelled
S3 PyArrow Parquet Tests / PyArrow Parquet Tests (Python 3.12) (push) Has been cancelled
rust: build dev volume server binaries / build-rust-volume-dev-darwin (darwin-amd64, x86_64-apple-darwin) (push) Has been cancelled
rust: build dev volume server binaries / build-rust-volume-dev-darwin (darwin-arm64, aarch64-apple-darwin) (push) Has been cancelled
rust: build dev volume server binaries / build-rust-volume-dev-linux (linux-amd64, x86_64-unknown-linux-gnu) (push) Has been cancelled
S3 PyArrow Parquet Tests / PyArrow Parquet Tests (Python 3.9) (push) Has been cancelled
S3 Proxy Signature Tests / S3 Proxy Signature Verification Tests (push) Has been cancelled
S3 SDK V2 Route Disambiguation Tests / S3 SDK V2 Routing Tests (push) Has been cancelled
S3 SSE Tests / S3 SSE Integration Tests (quick) (push) Has been cancelled
S3 SSE Tests / S3 SSE Compatibility Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Metadata Persistence Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Copy Operations Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Multipart Upload Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Performance Test (push) Has been cancelled
S3 SSE Tests / S3 Volume Encryption Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Integration Tests (comprehensive) (push) Has been cancelled
Spark Integration Tests / Spark Integration Tests (push) Failing after 2s
Telemetry Integration Tests / telemetry-integration-test (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (grpc - Shard 1) (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (http - Shard 1) (push) Failing after 3s
Ceph S3 tests / Basic S3 tests (KV store) (push) Has been cancelled
Ceph S3 tests / S3 Versioning & Object Lock tests (push) Has been cancelled
S3 Policy Integration Tests / S3 Policy Unit Tests (push) Failing after 3s
Ceph S3 tests / S3 CORS tests (push) Has been cancelled
Ceph S3 tests / SeaweedFS Custom S3 Copy tests (push) Has been cancelled
Ceph S3 tests / Basic S3 tests (SQL store) (push) Has been cancelled
test s3 over https using aws-cli / awscli-tests (push) Has been cancelled
TLS Rotation Integration Tests / TLS Rotation Integration Tests (push) Has been cancelled
Vacuum Integration Tests / Vacuum Integration Tests (push) Has been cancelled
S3 Policy Integration Tests / S3 Policy Variables Integration Tests (push) Failing after 3s
S3 Policy Integration Tests / S3 Policy Enforcement Integration Tests (advanced-policy) (push) Failing after 3s
S3 Policy Integration Tests / S3 Policy Enforcement Integration Tests (basic-policy) (push) Failing after 2s
S3 Policy Integration Tests / S3 Policy Enforcement Integration Tests (contextual-policy) (push) Failing after 4s
S3 Policy Integration Tests / Trusted Proxy Detection Tests (push) Failing after 2s
S3 Policy Integration Tests / S3 Policy Shell Integration Tests (push) Failing after 3s
Samba on FUSE Integration / samba-integration (push) Failing after 2s
SFTP Integration Tests / SFTP Integration Testing (push) Failing after 3s
terraform: validate and test modules / fmt, validate, plan-level tests (push) Failing after 3s
terraform: validate and test modules / local cluster smoke test (real weed) (push) Failing after 2s
Volume Server Integration Tests / Volume Server Integration Tests (grpc - Shard 3) (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (grpc - Shard 2) (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (http - Shard 2) (push) Failing after 2s
Volume Server Integration Tests / Volume Server Integration Tests (http - Shard 3) (push) Failing after 5s
mount: windows conformance / WinFsp conformance (push) Has been cancelled
mount: windows / Mount on Windows (push) Has been cancelled

This commit is contained in:
upstream-mirror
2026-09-16 03:23:42 +05:00
commit 89f5be005d
3997 changed files with 961970 additions and 0 deletions

321
test/s3tables/Makefile Normal file
View File

@@ -0,0 +1,321 @@
# Makefile for generating local S3Tables demo data via Trino and Spark.
.PHONY: help check-deps check-services ensure-work-dir ensure-table-bucket \
populate-trino populate-spark populate clean
# Local endpoints (host perspective)
S3_ENDPOINT ?= http://localhost:8333
CATALOG_ENDPOINT ?= http://localhost:8181
# Container-visible endpoints (localhost -> host.docker.internal)
S3_ENDPOINT_DOCKER ?= $(subst localhost,host.docker.internal,$(subst 127.0.0.1,host.docker.internal,$(S3_ENDPOINT)))
CATALOG_ENDPOINT_DOCKER ?= $(subst localhost,host.docker.internal,$(subst 127.0.0.1,host.docker.internal,$(CATALOG_ENDPOINT)))
# Auth and table-bucket settings
# If credentials are not explicitly provided, try to reuse the ones from a running
# local weed mini process (often started with inline AWS_ACCESS_KEY_ID/SECRET).
WEED_RUNTIME_PID := $(shell pgrep -f "weed mini" 2>/dev/null | head -n 1)
DETECTED_AWS_ACCESS_KEY_ID := $(shell if [ -n "$(WEED_RUNTIME_PID)" ]; then ps eww -p "$(WEED_RUNTIME_PID)" 2>/dev/null | sed -n 's/.*AWS_ACCESS_KEY_ID=\([^[:space:]]*\).*/\1/p'; fi)
DETECTED_AWS_SECRET_ACCESS_KEY := $(shell if [ -n "$(WEED_RUNTIME_PID)" ]; then ps eww -p "$(WEED_RUNTIME_PID)" 2>/dev/null | sed -n 's/.*AWS_SECRET_ACCESS_KEY=\([^[:space:]]*\).*/\1/p'; fi)
AWS_ACCESS_KEY_ID ?= $(if $(DETECTED_AWS_ACCESS_KEY_ID),$(DETECTED_AWS_ACCESS_KEY_ID),admin)
AWS_SECRET_ACCESS_KEY ?= $(if $(DETECTED_AWS_SECRET_ACCESS_KEY),$(DETECTED_AWS_SECRET_ACCESS_KEY),admin)
AWS_REGION ?= us-east-1
TABLE_ACCOUNT_ID ?= admin
TABLE_BUCKET ?= iceberg-tables
WAREHOUSE ?= s3tablescatalog/$(TABLE_BUCKET)
# Weed shell config for bucket bootstrap
WEED_BIN ?= weed
MASTER_ADDR ?= localhost:9333
# Runtime images
TRINO_IMAGE ?= trinodb/trino:479
SPARK_IMAGE ?= tabulario/spark-iceberg:latest
SPARK_PACKAGES ?= org.apache.iceberg:iceberg-spark-runtime-3.5_2.12:1.7.2,org.apache.iceberg:iceberg-aws-bundle:1.7.2
# Demo data layout
TRINO_NAMESPACE ?= ui_trino
TRINO_TABLE ?= customers
SPARK_NAMESPACE ?= ui_spark
SPARK_TABLE ?= events
# Temp workspace for generated configs/sql
WORK_DIR ?= /tmp/seaweedfs-s3tables-seed
help: ## Show available targets and key variables
@echo "S3Tables local data generator"
@echo ""
@awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " %-20s %s\n", $$1, $$2}' $(MAKEFILE_LIST)
@echo ""
@echo "Defaults:"
@echo " S3_ENDPOINT=$(S3_ENDPOINT)"
@echo " CATALOG_ENDPOINT=$(CATALOG_ENDPOINT)"
@echo " TABLE_BUCKET=$(TABLE_BUCKET)"
@echo " WAREHOUSE=$(WAREHOUSE)"
check-deps: ## Verify local dependencies
@command -v docker >/dev/null 2>&1 || (echo "docker is required" && exit 1)
@if command -v $(WEED_BIN) >/dev/null 2>&1; then \
echo "Using weed binary: $(WEED_BIN)"; \
elif [ -x "$(WEED_BIN)" ]; then \
echo "Using weed binary path: $(WEED_BIN)"; \
else \
echo "weed binary not found at $(WEED_BIN)"; \
echo "Override with WEED_BIN=/path/to/weed"; \
exit 1; \
fi; \
if [ "$(origin AWS_ACCESS_KEY_ID)" = "file" ] && [ -n "$(DETECTED_AWS_ACCESS_KEY_ID)" ]; then \
echo "Using AWS access key from running weed mini process: $(DETECTED_AWS_ACCESS_KEY_ID)"; \
fi
check-services: check-deps ## Validate local master/S3/catalog endpoints before seeding
@set -eu; \
master_addr="$(MASTER_ADDR)"; \
master_addr="$${master_addr%%,*}"; \
master_http=$$(printf '%s' "$$master_addr" | sed -E 's/^([^:]+:[0-9]+)\.[0-9]+$$/\1/'); \
echo "check-services: probing master status at http://$$master_http/cluster/status"; \
status_json=$$(curl -fsS --max-time 3 "http://$$master_http/cluster/status") || { \
echo "ERROR: cannot reach master status endpoint at http://$$master_http/cluster/status"; \
echo "Set MASTER_ADDR to a reachable master, e.g. MASTER_ADDR=localhost:9333"; \
exit 1; \
}; \
leader_addr=$$(printf '%s' "$$status_json" | sed -n 's/.*"Leader":"\([^"]*\)".*/\1/p'); \
if [ -n "$$leader_addr" ]; then \
leader_http=$$(printf '%s' "$$leader_addr" | sed -E 's/^([^:]+:[0-9]+)\.[0-9]+$$/\1/'); \
echo "check-services: master reports leader $$leader_addr (http: $$leader_http)"; \
if ! curl -fsS --max-time 3 "http://$$leader_http/cluster/status" >/dev/null; then \
echo "ERROR: master advertises leader $$leader_addr (http: $$leader_http), but it is unreachable from this host."; \
echo "This causes 'weed shell' (and make populate targets) to block waiting for master connection."; \
echo "Fix master advertised address (-ip / -ip.bind) or point MASTER_ADDR to a reachable leader."; \
exit 1; \
fi; \
fi; \
echo "check-services: probing S3 endpoint $(S3_ENDPOINT)"; \
s3_hostport=$$(printf '%s' "$(S3_ENDPOINT)" | sed -E 's#^https?://([^/]+)/?.*$$#\1#'); \
s3_host=$${s3_hostport%%:*}; \
s3_port=$${s3_hostport##*:}; \
if [ "$$s3_host" = "$$s3_port" ]; then \
s3_port=80; \
fi; \
if command -v nc >/dev/null 2>&1; then \
nc -z -w 3 "$$s3_host" "$$s3_port" || { \
echo "ERROR: S3 endpoint $(S3_ENDPOINT) is not reachable"; \
exit 1; \
}; \
else \
curl -sS --max-time 3 -o /dev/null "$(S3_ENDPOINT)" || { \
echo "ERROR: S3 endpoint $(S3_ENDPOINT) is not reachable"; \
exit 1; \
}; \
fi; \
echo "check-services: probing Iceberg catalog endpoint $(CATALOG_ENDPOINT)/v1/config"; \
curl -sS --max-time 3 -o /dev/null "$(CATALOG_ENDPOINT)/v1/config" || { \
echo "ERROR: Iceberg catalog endpoint $(CATALOG_ENDPOINT)/v1/config is not responding"; \
exit 1; \
}; \
echo "check-services: probing Iceberg auth with access key $(AWS_ACCESS_KEY_ID)"; \
auth_body=$$(mktemp -t s3tables-auth.XXXXXX); \
auth_code=$$(curl -sS --max-time 5 \
--aws-sigv4 "aws:amz:$(AWS_REGION):s3tables" \
--user "$(AWS_ACCESS_KEY_ID):$(AWS_SECRET_ACCESS_KEY)" \
-o "$$auth_body" -w "%{http_code}" "$(CATALOG_ENDPOINT)/v1/namespaces" || true); \
if [ "$$auth_code" = "403" ] && grep -q "access key ID you provided does not exist" "$$auth_body"; then \
echo "ERROR: AWS_ACCESS_KEY_ID=$(AWS_ACCESS_KEY_ID) is not recognized by the running Iceberg service."; \
if [ -n "$(DETECTED_AWS_ACCESS_KEY_ID)" ] && [ -n "$(DETECTED_AWS_SECRET_ACCESS_KEY)" ] && [ "$(AWS_ACCESS_KEY_ID)" != "$(DETECTED_AWS_ACCESS_KEY_ID)" ]; then \
echo "Detected running weed mini credentials via process env:"; \
echo " AWS_ACCESS_KEY_ID=$(DETECTED_AWS_ACCESS_KEY_ID)"; \
echo "Retry with:"; \
echo " make populate AWS_ACCESS_KEY_ID=$(DETECTED_AWS_ACCESS_KEY_ID) AWS_SECRET_ACCESS_KEY=<secret>"; \
fi; \
rm -f "$$auth_body"; \
exit 1; \
fi; \
if [ "$$auth_code" = "403" ] && grep -q "Access Denied" "$$auth_body"; then \
echo "ERROR: provided AWS credentials are valid but do not have permission for Iceberg REST operations."; \
rm -f "$$auth_body"; \
exit 1; \
fi; \
rm -f "$$auth_body"
ensure-work-dir: ## Create temporary workspace for generated files
@mkdir -p "$(WORK_DIR)/trino/catalog"
ensure-table-bucket: check-services ## Create table bucket if it does not exist
@set -eu; \
echo "ensure-table-bucket: checking table bucket $(TABLE_BUCKET) for account $(TABLE_ACCOUNT_ID)"; \
get_output=$$(printf "s3tables.bucket -get -name $(TABLE_BUCKET) -account $(TABLE_ACCOUNT_ID)\nexit\n" | "$(WEED_BIN)" shell -master="$(MASTER_ADDR)" 2>&1 || true); \
if printf '%s' "$$get_output" | grep -q "NoSuchBucket"; then \
echo "Creating table bucket: $(TABLE_BUCKET)"; \
create_output=$$(printf "s3tables.bucket -create -name $(TABLE_BUCKET) -account $(TABLE_ACCOUNT_ID)\nexit\n" | "$(WEED_BIN)" shell -master="$(MASTER_ADDR)" 2>&1 || true); \
if ! printf '%s' "$$create_output" | grep -q "ARN: arn:aws:s3tables:"; then \
echo "ERROR: failed to create table bucket $(TABLE_BUCKET)."; \
printf '%s\n' "$$create_output"; \
exit 1; \
fi; \
echo "Created table bucket: $(TABLE_BUCKET)"; \
elif printf '%s' "$$get_output" | grep -q "ARN: arn:aws:s3tables:"; then \
echo "Table bucket already exists: $(TABLE_BUCKET)"; \
else \
echo "ERROR: unable to verify table bucket $(TABLE_BUCKET)."; \
printf '%s\n' "$$get_output"; \
exit 1; \
fi
populate-trino: ensure-table-bucket ensure-work-dir ## Populate sample data via Trino
@echo "populate-trino: writing Trino catalog and SQL seeds"
@printf '%s\n' \
'connector.name=iceberg' \
'iceberg.catalog.type=rest' \
'iceberg.rest-catalog.uri=$(CATALOG_ENDPOINT_DOCKER)' \
'iceberg.rest-catalog.warehouse=$(WAREHOUSE)' \
'iceberg.file-format=PARQUET' \
'iceberg.unique-table-location=true' \
'fs.native-s3.enabled=true' \
's3.endpoint=$(S3_ENDPOINT_DOCKER)' \
's3.path-style-access=true' \
's3.signer-type=AwsS3V4Signer' \
's3.aws-access-key=$(AWS_ACCESS_KEY_ID)' \
's3.aws-secret-key=$(AWS_SECRET_ACCESS_KEY)' \
's3.region=$(AWS_REGION)' \
'iceberg.rest-catalog.security=SIGV4' \
'iceberg.rest-catalog.signing-name=s3tables' \
'iceberg.rest-catalog.prefix=$(TABLE_BUCKET)' \
> "$(WORK_DIR)/trino/catalog/iceberg.properties"
@printf '%s\n' \
'CREATE SCHEMA IF NOT EXISTS iceberg.$(TRINO_NAMESPACE);' \
'CREATE TABLE IF NOT EXISTS iceberg.$(TRINO_NAMESPACE).$(TRINO_TABLE) (' \
' customer_id INTEGER,' \
' customer_name VARCHAR,' \
' country VARCHAR,' \
' signup_date DATE' \
') WITH (' \
' format = '\''PARQUET'\'',' \
' partitioning = ARRAY['\''country'\'']' \
');' \
'INSERT INTO iceberg.$(TRINO_NAMESPACE).$(TRINO_TABLE)' \
'SELECT *' \
'FROM (' \
' VALUES' \
' (1, '\''Amanda Olson'\'', '\''US'\'', DATE '\''2024-01-10'\''),' \
' (2, '\''Leonard Eads'\'', '\''US'\'', DATE '\''2024-03-22'\''),' \
' (3, '\''Debbie Ward'\'', '\''MX'\'', DATE '\''2025-07-15'\''),' \
' (4, '\''Donald Holt'\'', '\''CA'\'', DATE '\''2025-11-02'\'')' \
') AS src (customer_id, customer_name, country, signup_date)' \
'WHERE NOT EXISTS (' \
' SELECT 1' \
' FROM iceberg.$(TRINO_NAMESPACE).$(TRINO_TABLE) dst' \
' WHERE dst.customer_id = src.customer_id' \
');' \
'SELECT count(*) AS row_count FROM iceberg.$(TRINO_NAMESPACE).$(TRINO_TABLE);' \
'SELECT count(*) AS us_row_count FROM iceberg.$(TRINO_NAMESPACE).$(TRINO_TABLE) WHERE country = '\''US'\'';' \
'SELECT count(*) AS partition_count FROM iceberg.$(TRINO_NAMESPACE)."$(TRINO_TABLE)$$partitions";' \
> "$(WORK_DIR)/trino_seed.sql"
@echo "populate-trino: starting Trino docker run"
@set -eu; \
container_name="seaweedfs-s3tables-trino-seed-$$RANDOM-$$RANDOM"; \
echo "populate-trino: launching Trino server container $$container_name"; \
docker run -d --name "$$container_name" \
--add-host host.docker.internal:host-gateway \
-v "$(WORK_DIR)/trino/catalog:/etc/trino/catalog" \
-v "$(WORK_DIR):/work" \
-e AWS_ACCESS_KEY_ID="$(AWS_ACCESS_KEY_ID)" \
-e AWS_SECRET_ACCESS_KEY="$(AWS_SECRET_ACCESS_KEY)" \
-e AWS_REGION="$(AWS_REGION)" \
"$(TRINO_IMAGE)" >/dev/null; \
trap 'docker rm -f "$$container_name" >/dev/null 2>&1 || true' EXIT INT TERM; \
echo "populate-trino: waiting for Trino server readiness"; \
ready=0; \
for i in $$(seq 1 90); do \
if [ "$$(docker inspect -f '{{.State.Running}}' "$$container_name" 2>/dev/null || echo false)" != "true" ]; then \
break; \
fi; \
if docker exec "$$container_name" trino --execute "SELECT 1" >/dev/null 2>&1; then \
ready=1; \
break; \
fi; \
sleep 1; \
done; \
if [ "$$ready" -ne 1 ]; then \
echo "ERROR: Trino server in container $$container_name did not become ready"; \
docker logs "$$container_name" | tail -n 200; \
exit 1; \
fi; \
echo "populate-trino: running SQL seed script"; \
if ! docker exec "$$container_name" trino --catalog iceberg --output-format CSV --file /work/trino_seed.sql; then \
echo "ERROR: Trino seed query failed. Recent container logs:"; \
docker logs "$$container_name" | tail -n 200; \
exit 1; \
fi
populate-spark: ensure-table-bucket ensure-work-dir ## Populate sample data via Spark SQL
@printf '%s\n' \
'CREATE NAMESPACE IF NOT EXISTS iceberg.$(SPARK_NAMESPACE);' \
'CREATE TABLE IF NOT EXISTS iceberg.$(SPARK_NAMESPACE).$(SPARK_TABLE) (' \
' event_id BIGINT,' \
' user_id STRING,' \
' event_type STRING,' \
' event_day STRING' \
') USING iceberg PARTITIONED BY (event_day);' \
'INSERT INTO iceberg.$(SPARK_NAMESPACE).$(SPARK_TABLE)' \
'SELECT *' \
'FROM (' \
' VALUES' \
' (1, '\''u001'\'', '\''page_view'\'', '\''2026-02-01'\''),' \
' (2, '\''u002'\'', '\''checkout'\'', '\''2026-02-02'\''),' \
' (3, '\''u003'\'', '\''purchase'\'', '\''2026-02-03'\''),' \
' (4, '\''u004'\'', '\''refund'\'', '\''2026-02-04'\'')' \
') AS src (event_id, user_id, event_type, event_day)' \
'WHERE NOT EXISTS (' \
' SELECT 1' \
' FROM iceberg.$(SPARK_NAMESPACE).$(SPARK_TABLE) dst' \
' WHERE dst.event_id = src.event_id' \
');' \
'SELECT count(*) AS row_count FROM iceberg.$(SPARK_NAMESPACE).$(SPARK_TABLE);' \
'SELECT count(*) AS partition_day_rows FROM iceberg.$(SPARK_NAMESPACE).$(SPARK_TABLE) WHERE event_day = '\''2026-02-02'\'';' \
'SELECT event_day, count(*) AS rows_per_day FROM iceberg.$(SPARK_NAMESPACE).$(SPARK_TABLE) GROUP BY event_day ORDER BY event_day;' \
> "$(WORK_DIR)/spark_seed.sql"
@echo "populate-spark: starting Spark docker run"
@docker run --rm \
--entrypoint /bin/bash \
--add-host host.docker.internal:host-gateway \
-v "$(WORK_DIR):/work" \
-e AWS_ACCESS_KEY_ID="$(AWS_ACCESS_KEY_ID)" \
-e AWS_SECRET_ACCESS_KEY="$(AWS_SECRET_ACCESS_KEY)" \
-e AWS_REGION="$(AWS_REGION)" \
"$(SPARK_IMAGE)" \
-lc '/opt/spark/bin/spark-sql \
--packages "$(SPARK_PACKAGES)" \
--conf "spark.sql.extensions=org.apache.iceberg.spark.extensions.IcebergSparkSessionExtensions" \
--conf "spark.sql.defaultCatalog=iceberg" \
--conf "spark.sql.catalog.iceberg=org.apache.iceberg.spark.SparkCatalog" \
--conf "spark.sql.catalog.iceberg.type=rest" \
--conf "spark.sql.catalog.iceberg.uri=$(CATALOG_ENDPOINT_DOCKER)" \
--conf "spark.sql.catalog.iceberg.warehouse=$(WAREHOUSE)" \
--conf "spark.sql.catalog.iceberg.prefix=$(TABLE_BUCKET)" \
--conf "spark.sql.catalog.iceberg.io-impl=org.apache.iceberg.aws.s3.S3FileIO" \
--conf "spark.sql.catalog.iceberg.s3.endpoint=$(S3_ENDPOINT_DOCKER)" \
--conf "spark.sql.catalog.iceberg.s3.path-style-access=true" \
--conf "spark.sql.catalog.iceberg.s3.access-key-id=$(AWS_ACCESS_KEY_ID)" \
--conf "spark.sql.catalog.iceberg.s3.secret-access-key=$(AWS_SECRET_ACCESS_KEY)" \
--conf "spark.sql.catalog.iceberg.s3.region=$(AWS_REGION)" \
--conf "spark.sql.catalog.iceberg.rest.sigv4-enabled=true" \
--conf "spark.sql.catalog.iceberg.rest.signing-name=s3tables" \
--conf "spark.sql.catalog.spark_catalog=org.apache.iceberg.spark.SparkCatalog" \
--conf "spark.sql.catalog.spark_catalog.type=rest" \
--conf "spark.sql.catalog.spark_catalog.uri=$(CATALOG_ENDPOINT_DOCKER)" \
--conf "spark.sql.catalog.spark_catalog.warehouse=$(WAREHOUSE)" \
--conf "spark.sql.catalog.spark_catalog.prefix=$(TABLE_BUCKET)" \
--conf "spark.sql.catalog.spark_catalog.io-impl=org.apache.iceberg.aws.s3.S3FileIO" \
--conf "spark.sql.catalog.spark_catalog.s3.endpoint=$(S3_ENDPOINT_DOCKER)" \
--conf "spark.sql.catalog.spark_catalog.s3.path-style-access=true" \
--conf "spark.sql.catalog.spark_catalog.s3.access-key-id=$(AWS_ACCESS_KEY_ID)" \
--conf "spark.sql.catalog.spark_catalog.s3.secret-access-key=$(AWS_SECRET_ACCESS_KEY)" \
--conf "spark.sql.catalog.spark_catalog.s3.region=$(AWS_REGION)" \
--conf "spark.sql.catalog.spark_catalog.rest.sigv4-enabled=true" \
--conf "spark.sql.catalog.spark_catalog.rest.signing-name=s3tables" \
-f /work/spark_seed.sql'
populate: populate-trino populate-spark ## Populate sample data through Trino and Spark
clean: ## Remove generated temporary files
@rm -rf "$(WORK_DIR)"

11
test/s3tables/README.txt Normal file
View File

@@ -0,0 +1,11 @@
Populate data run:
- make -C test/s3tables help
- make -C test/s3tables populate-trino
- make -C test/s3tables populate-spark
Run:
- make -C test/s3tables populate
- If your account id differs, override: make -C test/s3tables populate
TABLE_ACCOUNT_ID=000000000000

View File

@@ -0,0 +1,559 @@
// Package admin contains integration tests for the admin UI's Iceberg
// catalog and data-preview pages. Tests start a real weed mini cluster
// (with the admin UI enabled), create a table bucket, namespace, and table
// via the S3 Tables manager, populate real Parquet data files, manifests,
// and snapshots via S3 and the filer gRPC API, and then assert on the HTML
// the admin server renders.
package admin
import (
"bytes"
"context"
"crypto/rand"
"encoding/json"
"flag"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"strconv"
"strings"
"sync"
"testing"
"time"
"github.com/apache/iceberg-go"
"github.com/apache/iceberg-go/table"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/parquet-go/parquet-go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"github.com/seaweedfs/seaweedfs/test/testutil"
"github.com/seaweedfs/seaweedfs/weed/command"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
)
// ---------------------------------------------------------------------------
// Cluster lifecycle (mirrors test/s3tables/maintenance)
// ---------------------------------------------------------------------------
type testCluster struct {
dataDir string
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
filerGrpcPort int
s3Port int
adminPort int
s3Endpoint string
adminEndpoint string
isRunning bool
}
var shared *testCluster
func TestMain(m *testing.M) {
flag.Parse()
if testing.Short() {
os.Exit(m.Run())
}
testDir, err := os.MkdirTemp("", "seaweed-admin-iceberg-*")
if err != nil {
fmt.Fprintf(os.Stderr, "SKIP: failed to create temp dir: %v\n", err)
os.Exit(0)
}
cluster, err := startCluster(testDir)
if err != nil {
fmt.Fprintf(os.Stderr, "SKIP: failed to start cluster: %v\n", err)
os.RemoveAll(testDir)
os.Exit(0)
}
shared = cluster
code := m.Run()
shared.stop()
os.RemoveAll(testDir)
os.Exit(code)
}
func startCluster(testDir string) (*testCluster, error) {
ports, err := testutil.AllocatePorts(10)
if err != nil {
return nil, err
}
masterPort, masterGrpc := ports[0], ports[1]
volumePort, volumeGrpc := ports[2], ports[3]
filerPort, filerGrpc := ports[4], ports[5]
s3Port, s3Grpc := ports[6], ports[7]
adminPort, adminGrpc := ports[8], ports[9]
// Empty security.toml disables JWT auth.
if err := os.WriteFile(filepath.Join(testDir, "security.toml"), []byte("# test\n"), 0644); err != nil {
return nil, err
}
if os.Getenv("AWS_ACCESS_KEY_ID") == "" {
os.Setenv("AWS_ACCESS_KEY_ID", "admin")
}
if os.Getenv("AWS_SECRET_ACCESS_KEY") == "" {
os.Setenv("AWS_SECRET_ACCESS_KEY", "admin")
}
ctx, cancel := context.WithCancel(context.Background())
c := &testCluster{
dataDir: testDir,
ctx: ctx,
cancel: cancel,
filerGrpcPort: filerGrpc,
s3Port: s3Port,
adminPort: adminPort,
s3Endpoint: fmt.Sprintf("http://127.0.0.1:%d", s3Port),
adminEndpoint: fmt.Sprintf("http://127.0.0.1:%d", adminPort),
}
// Set on the package global before the goroutine starts so the mini
// command picks it up; cleared in stop(). The global is how the mini
// harness receives its cancellation context in tests.
command.MiniClusterCtx = ctx //nolint:fatcontext // test harness handoff, not a per-request context
c.wg.Add(1)
go func() {
defer c.wg.Done()
oldDir, _ := os.Getwd()
oldArgs := os.Args
defer func() { os.Chdir(oldDir); os.Args = oldArgs }()
os.Chdir(testDir)
args := []string{
"-dir=" + testDir,
"-master.dir=" + testDir,
"-master.port=" + strconv.Itoa(masterPort),
"-master.port.grpc=" + strconv.Itoa(masterGrpc),
"-volume.port=" + strconv.Itoa(volumePort),
"-volume.port.grpc=" + strconv.Itoa(volumeGrpc),
"-volume.port.public=" + strconv.Itoa(volumePort),
"-volume.publicUrl=127.0.0.1:" + strconv.Itoa(volumePort),
"-filer.port=" + strconv.Itoa(filerPort),
"-filer.port.grpc=" + strconv.Itoa(filerGrpc),
"-s3.port=" + strconv.Itoa(s3Port),
"-s3.port.grpc=" + strconv.Itoa(s3Grpc),
"-admin.port=" + strconv.Itoa(adminPort),
"-admin.port.grpc=" + strconv.Itoa(adminGrpc),
"-webdav.port=0",
"-master.volumeSizeLimitMB=32",
"-ip=127.0.0.1",
"-master.peers=none",
"-s3.iam.readOnly=false",
}
os.Args = append([]string{"weed"}, args...)
glog.MaxSize = 1024 * 1024
for _, cmd := range command.Commands {
if cmd.Name() == "mini" && cmd.Run != nil {
cmd.Flag.Parse(os.Args[1:])
cmd.Run(cmd, cmd.Flag.Args())
return
}
}
}()
if err := waitReady(c.s3Endpoint, 30*time.Second); err != nil {
cancel()
return nil, err
}
if err := waitReady(c.adminEndpoint, 30*time.Second); err != nil {
cancel()
return nil, err
}
c.isRunning = true
return c, nil
}
func (c *testCluster) stop() {
command.MiniClusterCtx = nil //nolint:fatcontext // clearing the test harness handoff
if c.cancel != nil {
c.cancel()
}
if c.isRunning {
time.Sleep(500 * time.Millisecond)
}
done := make(chan struct{})
go func() { c.wg.Wait(); close(done) }()
select {
case <-done:
case <-time.After(2 * time.Second):
}
}
func (c *testCluster) filerConn(t *testing.T) filer_pb.SeaweedFilerClient {
t.Helper()
addr := fmt.Sprintf("127.0.0.1:%d", c.filerGrpcPort)
conn, err := grpc.NewClient(addr, grpc.WithTransportCredentials(insecure.NewCredentials()))
require.NoError(t, err)
t.Cleanup(func() { conn.Close() })
return filer_pb.NewSeaweedFilerClient(conn)
}
func waitReady(endpoint string, timeout time.Duration) error {
client := &http.Client{Timeout: 1 * time.Second}
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
resp, err := client.Get(endpoint)
if err == nil {
resp.Body.Close()
time.Sleep(500 * time.Millisecond)
return nil
}
time.Sleep(200 * time.Millisecond)
}
return fmt.Errorf("timeout waiting for %s", endpoint)
}
func randomSuffix() string {
b := make([]byte, 4)
rand.Read(b)
return fmt.Sprintf("%x", b)
}
// ---------------------------------------------------------------------------
// Catalog and table population
// ---------------------------------------------------------------------------
type previewRow struct {
ID int64 `parquet:"id"`
Name string `parquet:"name"`
}
func buildParquet(t *testing.T, rows []previewRow) []byte {
t.Helper()
var buf bytes.Buffer
writer := parquet.NewGenericWriter[previewRow](&buf)
_, err := writer.Write(rows)
require.NoError(t, err)
require.NoError(t, writer.Close())
return buf.Bytes()
}
func newS3Client(t *testing.T, endpoint string) *s3.Client {
t.Helper()
cfg, err := config.LoadDefaultConfig(context.Background(),
config.WithRegion("us-east-1"),
config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider("admin", "admin", "")),
)
require.NoError(t, err)
return s3.NewFromConfig(cfg, func(o *s3.Options) {
o.BaseEndpoint = aws.String(endpoint)
o.UsePathStyle = true
})
}
type catalogFixture struct {
bucket string
namespace string
table string
snap1ID int64
snap2ID int64
file1Path string // manifest-listed location of the first data file (relative)
file2Path string // manifest-listed location of the second data file (s3:// URI)
}
// setupCatalog creates a table bucket, namespace, and two tables via the S3
// Tables manager: "events" with two snapshots backed by real Parquet files,
// and "empty" with no Iceberg metadata.
func setupCatalog(t *testing.T, client filer_pb.SeaweedFilerClient) catalogFixture {
t.Helper()
ctx := context.Background()
fx := catalogFixture{
bucket: "admin-ui-" + randomSuffix(),
namespace: "analytics",
table: "events",
snap1ID: 3001,
snap2ID: 3002,
}
fx.file1Path = "data/p1.parquet"
fx.file2Path = fmt.Sprintf("s3://%s/%s/%s/data/p2.parquet", fx.bucket, fx.namespace, fx.table)
mgr := s3tables.NewManager()
mgr.SetAccountID(s3_constants.AccountAdminId)
mc := s3tables.NewManagerClient(client)
exec := func(op string, req, resp interface{}) {
t.Helper()
require.NoError(t, mgr.Execute(ctx, mc, op, req, resp, s3_constants.AccountAdminId), "s3tables %s", op)
}
var bucketResp s3tables.CreateTableBucketResponse
exec("CreateTableBucket", &s3tables.CreateTableBucketRequest{Name: fx.bucket}, &bucketResp)
exec("CreateNamespace", &s3tables.CreateNamespaceRequest{TableBucketARN: bucketResp.ARN, Namespace: []string{fx.namespace}}, &s3tables.CreateNamespaceResponse{})
exec("CreateTable", &s3tables.CreateTableRequest{TableBucketARN: bucketResp.ARN, Namespace: []string{fx.namespace}, Name: fx.table, Format: "ICEBERG"}, &s3tables.CreateTableResponse{})
exec("CreateTable", &s3tables.CreateTableRequest{TableBucketARN: bucketResp.ARN, Namespace: []string{fx.namespace}, Name: "empty", Format: "ICEBERG"}, &s3tables.CreateTableResponse{})
// Upload real Parquet data files via S3 so they are chunk-backed entries.
s3Client := newS3Client(t, shared.s3Endpoint)
tableKey := path.Join(fx.namespace, fx.table)
rows1 := []previewRow{{1, "p1-row-1"}, {2, "p1-row-2"}, {3, "p1-row-3"}, {4, "p1-row-4"}, {5, "p1-row-5"}}
rows2 := []previewRow{{6, "p2-row-6"}, {7, "p2-row-7"}, {8, "p2-row-8"}}
parquet1 := buildParquet(t, rows1)
parquet2 := buildParquet(t, rows2)
putObject(t, s3Client, fx.bucket, path.Join(tableKey, "data/p1.parquet"), parquet1)
putObject(t, s3Client, fx.bucket, path.Join(tableKey, "data/p2.parquet"), parquet2)
// Build table metadata with two snapshots. Snapshot 2's manifest list is
// cumulative (both manifests) as engines write it.
location := fmt.Sprintf("s3://%s/%s/%s", fx.bucket, fx.namespace, fx.table)
schema := iceberg.NewSchema(0,
iceberg.NestedField{ID: 1, Type: iceberg.PrimitiveTypes.Int64, Name: "id", Required: true},
iceberg.NestedField{ID: 2, Type: iceberg.PrimitiveTypes.String, Name: "name", Required: false},
)
meta, err := table.NewMetadata(schema, iceberg.UnpartitionedSpec, table.UnsortedSortOrder, location, nil)
require.NoError(t, err)
baseMs := time.Now().UnixMilli() + 100
snap1 := table.Snapshot{SnapshotID: fx.snap1ID, SequenceNumber: 1, TimestampMs: baseMs, ManifestList: "metadata/snap-3001.avro"}
snap2 := table.Snapshot{SnapshotID: fx.snap2ID, ParentSnapshotID: &fx.snap1ID, SequenceNumber: 2, TimestampMs: baseMs + 1, ManifestList: "metadata/snap-3002.avro"}
builder, err := table.MetadataBuilderFromBase(meta, location)
require.NoError(t, err)
require.NoError(t, builder.AddSnapshot(&snap1))
require.NoError(t, builder.AddSnapshot(&snap2))
require.NoError(t, builder.SetSnapshotRef(table.MainBranch, fx.snap2ID, table.BranchRef))
meta, err = builder.Build()
require.NoError(t, err)
// Write manifests and manifest lists into the table's metadata directory.
metaDir := path.Join(s3tables.TablesPath, fx.bucket, fx.namespace, fx.table, "metadata")
spec := meta.PartitionSpec()
version := meta.Version()
mf1 := writeManifest(t, ctx, client, metaDir, "manifest-3001.avro", version, spec, schema, fx.snap1ID, fx.file1Path, int64(len(rows1)), int64(len(parquet1)))
mf2 := writeManifest(t, ctx, client, metaDir, "manifest-3002.avro", version, spec, schema, fx.snap2ID, fx.file2Path, int64(len(rows2)), int64(len(parquet2)))
list1 := writeManifestList(t, ctx, client, metaDir, "snap-3001.avro", version, fx.snap1ID, nil, 1, []iceberg.ManifestFile{mf1})
// Re-read snapshot 1's list so mf1 carries its assigned sequence number,
// which the cumulative snapshot 2 list requires.
assigned, err := iceberg.ReadManifestList(bytes.NewReader(list1))
require.NoError(t, err)
require.Len(t, assigned, 1)
writeManifestList(t, ctx, client, metaDir, "snap-3002.avro", version, fx.snap2ID, &fx.snap1ID, 2, []iceberg.ManifestFile{assigned[0], mf2})
// Point the table's catalog xattr at the new metadata.
fullJSON, err := json.Marshal(meta)
require.NoError(t, err)
writeFile(t, ctx, client, metaDir, "v2.metadata.json", fullJSON)
updateTableMetadataXattr(t, ctx, client, fx, fullJSON)
// Snapshot timestamps sit slightly in the future; let them pass.
time.Sleep(200 * time.Millisecond)
return fx
}
func putObject(t *testing.T, client *s3.Client, bucket, key string, body []byte) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
_, err := client.PutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(bucket),
Key: aws.String(key),
Body: bytes.NewReader(body),
})
require.NoError(t, err)
}
func writeManifest(t *testing.T, ctx context.Context, client filer_pb.SeaweedFilerClient, metaDir, name string, version int, spec iceberg.PartitionSpec, schema *iceberg.Schema, snapID int64, dataFilePath string, recordCount, fileSize int64) iceberg.ManifestFile {
t.Helper()
dfBuilder, err := iceberg.NewDataFileBuilder(
spec, iceberg.EntryContentData,
dataFilePath, iceberg.ParquetFile,
map[int]any{}, nil, nil, recordCount, fileSize,
)
require.NoError(t, err)
entry := iceberg.NewManifestEntry(iceberg.EntryStatusADDED, &snapID, nil, nil, dfBuilder.Build())
var buf bytes.Buffer
mf, err := iceberg.WriteManifest(path.Join("metadata", name), &buf, version, spec, schema, snapID, []iceberg.ManifestEntry{entry})
require.NoError(t, err)
writeFile(t, ctx, client, metaDir, name, buf.Bytes())
return mf
}
func writeManifestList(t *testing.T, ctx context.Context, client filer_pb.SeaweedFilerClient, metaDir, name string, version int, snapID int64, parent *int64, seqNum int64, files []iceberg.ManifestFile) []byte {
t.Helper()
var buf bytes.Buffer
require.NoError(t, iceberg.WriteManifestList(version, &buf, snapID, parent, &seqNum, 0, files))
writeFile(t, ctx, client, metaDir, name, buf.Bytes())
return buf.Bytes()
}
func writeFile(t *testing.T, ctx context.Context, client filer_pb.SeaweedFilerClient, dir, name string, content []byte) {
t.Helper()
resp, err := client.CreateEntry(ctx, &filer_pb.CreateEntryRequest{
Directory: dir,
Entry: &filer_pb.Entry{
Name: name,
Attributes: &filer_pb.FuseAttributes{
Mtime: time.Now().Unix(), Crtime: time.Now().Unix(),
FileMode: uint32(0644), FileSize: uint64(len(content)),
},
Content: content,
},
})
require.NoError(t, err, "writeFile(%s, %s)", dir, name)
require.Empty(t, resp.Error, "writeFile(%s, %s)", dir, name)
}
func updateTableMetadataXattr(t *testing.T, ctx context.Context, client filer_pb.SeaweedFilerClient, fx catalogFixture, fullJSON []byte) {
t.Helper()
nsDir := path.Join(s3tables.TablesPath, fx.bucket, fx.namespace)
resp, err := filer_pb.LookupEntry(ctx, client, &filer_pb.LookupDirectoryEntryRequest{Directory: nsDir, Name: fx.table})
require.NoError(t, err)
require.NotNil(t, resp.Entry)
var internalMeta map[string]json.RawMessage
require.NoError(t, json.Unmarshal(resp.Entry.Extended[s3tables.ExtendedKeyMetadata], &internalMeta))
metaObj := map[string]json.RawMessage{}
if raw, ok := internalMeta["metadata"]; ok {
require.NoError(t, json.Unmarshal(raw, &metaObj))
}
metaObj["fullMetadata"] = fullJSON
metaJSON, err := json.Marshal(metaObj)
require.NoError(t, err)
internalMeta["metadata"] = metaJSON
internalMeta["metadataVersion"] = json.RawMessage("2")
internalMeta["metadataLocation"] = json.RawMessage(`"metadata/v2.metadata.json"`)
xattr, err := json.Marshal(internalMeta)
require.NoError(t, err)
resp.Entry.Extended[s3tables.ExtendedKeyMetadata] = xattr
resp.Entry.Extended[s3tables.ExtendedKeyMetadataVersion] = []byte("2")
_, err = client.UpdateEntry(ctx, &filer_pb.UpdateEntryRequest{Directory: nsDir, Entry: resp.Entry})
require.NoError(t, err)
}
// ---------------------------------------------------------------------------
// Page fetching
// ---------------------------------------------------------------------------
func fetchPage(t *testing.T, pagePath string) string {
t.Helper()
resp, err := (&http.Client{Timeout: 30 * time.Second}).Get(shared.adminEndpoint + pagePath)
require.NoError(t, err, "GET %s", pagePath)
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode, "GET %s: %s", pagePath, string(body))
return string(body)
}
// fetchPageUntil re-fetches a page until the predicate passes, tolerating the
// admin server's filer-discovery delay right after startup.
func fetchPageUntil(t *testing.T, pagePath string, predicate func(string) bool) string {
t.Helper()
deadline := time.Now().Add(15 * time.Second)
var body string
for time.Now().Before(deadline) {
body = fetchPage(t, pagePath)
if predicate(body) {
return body
}
time.Sleep(500 * time.Millisecond)
}
return body
}
func tablePagePath(fx catalogFixture, tableName, suffix string) string {
return fmt.Sprintf("/object-store/s3tables/buckets/%s/namespaces/%s/tables/%s%s",
url.PathEscape(fx.bucket), url.PathEscape(fx.namespace), url.PathEscape(tableName), suffix)
}
// ---------------------------------------------------------------------------
// Integration tests
// ---------------------------------------------------------------------------
func TestAdminIcebergPages(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
client := shared.filerConn(t)
fx := setupCatalog(t, client)
t.Run("CatalogBrowsing", func(t *testing.T) {
buckets := fetchPageUntil(t, "/object-store/s3tables/buckets", func(body string) bool {
return strings.Contains(body, fx.bucket)
})
assert.Contains(t, buckets, fx.bucket)
namespaces := fetchPage(t, fmt.Sprintf("/object-store/s3tables/buckets/%s/namespaces", url.PathEscape(fx.bucket)))
assert.Contains(t, namespaces, fx.namespace)
tables := fetchPage(t, fmt.Sprintf("/object-store/s3tables/buckets/%s/namespaces/%s/tables", url.PathEscape(fx.bucket), url.PathEscape(fx.namespace)))
assert.Contains(t, tables, fx.table)
assert.Contains(t, tables, "empty")
})
t.Run("TableDetails", func(t *testing.T) {
body := fetchPage(t, tablePagePath(fx, fx.table, ""))
assert.Contains(t, body, "Browse Data")
assert.Contains(t, body, "/data")
assert.Contains(t, body, ">id<")
assert.Contains(t, body, ">name<")
assert.Contains(t, body, strconv.FormatInt(fx.snap1ID, 10))
assert.Contains(t, body, strconv.FormatInt(fx.snap2ID, 10))
})
t.Run("DataPreviewCurrentSnapshot", func(t *testing.T) {
body := fetchPage(t, tablePagePath(fx, fx.table, "/data"))
assert.Contains(t, body, "Showing 8 row(s) from 2 data file(s).")
assert.Contains(t, body, "p1-row-1")
assert.Contains(t, body, "p2-row-8")
assert.Contains(t, body, fx.file1Path)
assert.Contains(t, body, fx.file2Path)
assert.Contains(t, body, "current")
})
t.Run("DataPreviewOldSnapshot", func(t *testing.T) {
body := fetchPage(t, tablePagePath(fx, fx.table, "/data?snapshot="+strconv.FormatInt(fx.snap1ID, 10)))
assert.Contains(t, body, "Showing 5 row(s) from 1 data file(s).")
assert.Contains(t, body, "p1-row-5")
assert.NotContains(t, body, "p2-row-6")
})
t.Run("DataPreviewSingleFile", func(t *testing.T) {
body := fetchPage(t, tablePagePath(fx, fx.table, "/data?file="+url.QueryEscape(fx.file2Path)))
assert.Contains(t, body, "Showing 3 row(s) from 1 data file(s).")
assert.Contains(t, body, "p2-row-6")
assert.NotContains(t, body, "p1-row-1")
})
t.Run("DataPreviewRowLimit", func(t *testing.T) {
body := fetchPage(t, tablePagePath(fx, fx.table, "/data?limit=3"))
assert.Contains(t, body, "Showing 3 row(s) from 1 data file(s).")
})
t.Run("DataPreviewUnknownFile", func(t *testing.T) {
body := fetchPage(t, tablePagePath(fx, fx.table, "/data?file="+url.QueryEscape("s3://elsewhere/x.parquet")))
assert.Contains(t, body, "Requested data file is not part of this snapshot.")
})
t.Run("DataPreviewUnknownSnapshot", func(t *testing.T) {
body := fetchPage(t, tablePagePath(fx, fx.table, "/data?snapshot=42"))
assert.Contains(t, body, "Snapshot 42 not found.")
})
t.Run("DataPreviewTableWithoutMetadata", func(t *testing.T) {
body := fetchPage(t, tablePagePath(fx, "empty", "/data"))
assert.Contains(t, body, "Table has no Iceberg metadata.")
})
}

View File

@@ -0,0 +1,13 @@
# Lance client container for Lance Namespace REST compatibility testing.
# The point of this image is to exercise the namespace with the real client
# rather than hand-built HTTP: every serious bug in this surface so far looked
# fine to a request we wrote ourselves.
FROM python:3.11-slim
WORKDIR /app
RUN pip install --no-cache-dir lance-namespace pylance pyarrow
COPY test_lance_namespace.py /app/
CMD ["python3", "/app/test_lance_namespace.py", "--help"]

View File

@@ -0,0 +1,13 @@
# PyIceberg test container for Iceberg REST Catalog compatibility testing
FROM python:3.11-slim
WORKDIR /app
# Install PyIceberg with S3 support and dependencies
RUN pip install --no-cache-dir "pyiceberg[s3fs]" pyarrow pandas
# Copy the test script
COPY test_rest_catalog.py /app/
# Default command
CMD ["python3", "/app/test_rest_catalog.py", "--help"]

View File

@@ -0,0 +1,37 @@
# Makefile for Iceberg REST Catalog Integration Tests
#
# This Makefile provides easy-to-use targets for running integration tests
# and starting a local development environment.
.PHONY: build mini test test-pyiceberg clean
# Root directory of the SeaweedFS project
PROJECT_ROOT = ../../..
WEED_BIN = $(PROJECT_ROOT)/weed/weed
# Build the weed binary
build:
@echo "Building SeaweedFS binary..."
cd $(PROJECT_ROOT)/weed && go build -v
# Start SeaweedFS mini cluster for manual testing
# Uses default ports: S3=8333, Iceberg=8182
mini: build
@echo "Starting SeaweedFS mini cluster..."
$(WEED_BIN) mini -s3.port.iceberg=8182 & echo $$! > weed-mini.pid; wait
# Run all integration tests in this directory
test: build
@echo "Running all Iceberg catalog integration tests..."
go test -v .
# Run only the PyIceberg compatibility tests (requires Docker)
test-pyiceberg: build
@echo "Running PyIceberg compatibility tests..."
go test -v -run TestPyIcebergRestCatalog .
# Clean up temporary data and stop weed mini if running
clean:
@echo "Cleaning up..."
@test -f weed-mini.pid && kill $$(cat weed-mini.pid) && rm weed-mini.pid || true
@rm -rf /tmp/seaweed-iceberg-test-*

View File

@@ -0,0 +1,33 @@
# Iceberg REST Catalog Compatibility Test Docker Compose
#
# This compose file sets up a test environment for validating
# the SeaweedFS Iceberg REST Catalog implementation.
#
# Usage:
# docker compose -f docker-compose.test.yaml up --build
#
# Note: SeaweedFS must be running on the host with the Iceberg REST port exposed.
# Set ICEBERG_CATALOG_URL environment variable to point to your catalog.
services:
# PyIceberg-based REST catalog test
pyiceberg-test:
build:
context: .
dockerfile: Dockerfile.pyiceberg
environment:
- CATALOG_URL=${CATALOG_URL:-http://host.docker.internal:8182}
- WAREHOUSE=${WAREHOUSE:-s3://test-bucket/}
- PREFIX=${PREFIX:-pyiceberg-test}
- AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID:-test}
- AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY:-test}
- AWS_REGION=${AWS_REGION:-us-east-1}
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
- ./test_rest_catalog.py:/app/test_rest_catalog.py:ro
command: >
python3 /app/test_rest_catalog.py
--catalog-url ${CATALOG_URL:-http://host.docker.internal:8182}
--warehouse ${WAREHOUSE:-s3://test-bucket/}
--prefix ${PREFIX:-pyiceberg-test}

View File

@@ -0,0 +1,211 @@
package catalog
import (
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"github.com/seaweedfs/seaweedfs/test/testutil"
)
// TestDuckDBDeterministicLocationRead validates that DuckDB's iceberg
// extension can resolve and scan a table that lives at the deterministic
// <bucket>/<namespace>/<tableName> path produced when no client-side UUID
// is appended to the table location. This is the layout produced by:
//
// - iceberg-spark (no concept of UUID-suffixed locations)
// - pyiceberg (no concept of UUID-suffixed locations)
// - Trino with iceberg.unique-table-location=false
//
// Post-#9246, REST clients can resolve table locations under a namespace
// because GetNamespace advertises a default `location` property. This
// test creates a namespace + table via the REST API directly (so no
// client-side UUID is added), then asks DuckDB to ATTACH the catalog and
// describe the table — verifying DuckDB walks the catalog → metadata →
// schema chain successfully against a deterministic on-disk path.
//
// The test does not insert data files — DuckDB scanning an empty table
// is sufficient to validate the catalog/metadata resolution path. If
// DuckDB's iceberg extension is unavailable or the build is too old to
// support the ATTACH syntax, the test skips rather than fails.
func TestDuckDBDeterministicLocationRead(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
if !testutil.HasDocker() {
t.Skip("Docker not available, skipping DuckDB integration test")
}
env := newOAuthTestEnv(t)
defer env.cleanup(t)
env.start(t)
bucketName := "duckdb-detloc-" + randomSuffix()
namespace := "detloc_ns_" + randomSuffix()
tableName := "detloc_tbl_" + randomSuffix()
createTableBucketViaShell(t, env, bucketName)
token := requestOAuthToken(t, env, env.accessKey, env.secretKey)
createNamespaceWithToken(t, env, token, bucketName, namespace)
createIcebergTableWithToken(t, env, token, bucketName, namespace, tableName)
// Sanity-check the catalog row exposes a deterministic location.
tableLoc := getTableLocation(t, env, token, bucketName, namespace, tableName)
wantPrefix := fmt.Sprintf("s3://%s/%s/%s", bucketName, namespace, tableName)
if !strings.HasPrefix(tableLoc, wantPrefix) {
t.Fatalf("table location %q should start with %q (deterministic, no UUID suffix)", tableLoc, wantPrefix)
}
if strings.Contains(strings.TrimPrefix(tableLoc, wantPrefix), "-") {
t.Fatalf("table location %q should not have a -<uuid> suffix after %q", tableLoc, wantPrefix)
}
sqlContent := fmt.Sprintf(`
INSTALL iceberg;
LOAD iceberg;
CREATE SECRET iceberg_secret (
TYPE ICEBERG,
ENDPOINT 'http://host.docker.internal:%d',
CLIENT_ID '%s',
CLIENT_SECRET '%s'
);
CREATE SECRET s3_secret (
TYPE S3,
KEY_ID '%s',
SECRET '%s',
ENDPOINT 'host.docker.internal:%d',
URL_STYLE 'path',
USE_SSL false
);
ATTACH 's3://%s/' AS detloc_cat (TYPE 'ICEBERG', SECRET iceberg_secret);
DESCRIBE detloc_cat.%s.%s;
SELECT 'duckdb deterministic-location describe ok' AS marker;
`,
env.icebergPort, env.accessKey, env.secretKey,
env.accessKey, env.secretKey, env.s3Port,
bucketName,
namespace, tableName,
)
sqlFile := filepath.Join(env.dataDir, "duckdb_detloc.sql")
if err := os.WriteFile(sqlFile, []byte(sqlContent), 0644); err != nil {
t.Fatalf("write SQL file: %v", err)
}
cmd := exec.Command("docker", "run", "--rm",
"-v", fmt.Sprintf("%s:/test", env.dataDir),
"--add-host", "host.docker.internal:host-gateway",
"--entrypoint", "duckdb",
"duckdb/duckdb:latest",
"-init", "/test/duckdb_detloc.sql",
"-c", "SELECT 1",
)
output, err := cmd.CombinedOutput()
outputStr := string(output)
t.Logf("DuckDB output:\n%s", outputStr)
if err != nil {
// Tolerate environments where the Iceberg extension/version is too
// limited to exercise this code path. The intent is to gain
// coverage where it's available, not to require it everywhere.
if strings.Contains(outputStr, "iceberg extension is not available") ||
strings.Contains(outputStr, "Failed to load") ||
strings.Contains(outputStr, "syntax error") ||
strings.Contains(outputStr, "Unknown extension") {
t.Skipf("DuckDB image lacks the required iceberg extension or syntax: %v", err)
}
t.Fatalf("DuckDB run failed: %v\nOutput:\n%s", err, outputStr)
}
if !strings.Contains(outputStr, "duckdb deterministic-location describe ok") {
t.Fatalf("DuckDB DESCRIBE on deterministic-location table did not complete successfully:\n%s", outputStr)
}
}
// createIcebergTableWithToken creates a minimal Iceberg table via the REST
// catalog using the provided OAuth bearer token. The table is created
// without an explicit location, so the server picks the deterministic
// <bucket>/<namespace>/<tableName> path.
func createIcebergTableWithToken(t *testing.T, env *oauthTestEnv, token, bucketName, namespace, tableName string) {
t.Helper()
body := fmt.Sprintf(`{
"name": %q,
"schema": {
"type": "struct",
"schema-id": 0,
"fields": [
{"id": 1, "name": "id", "required": true, "type": "long"},
{"id": 2, "name": "label", "required": false, "type": "string"}
]
}
}`, tableName)
url := fmt.Sprintf("%s/v1/%s/namespaces/%s/tables", env.icebergURL(), bucketName, namespace)
req, err := http.NewRequest(http.MethodPost, url, strings.NewReader(body))
if err != nil {
t.Fatalf("create table request: %v", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("create table: %v", err)
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
t.Fatalf("create table failed: status=%d body=%s", resp.StatusCode, respBody)
}
t.Logf("Created table %s.%s in bucket %s", namespace, tableName, bucketName)
}
// getTableLocation fetches the table's metadata via the REST catalog and
// returns the location field reported in the response.
func getTableLocation(t *testing.T, env *oauthTestEnv, token, bucketName, namespace, tableName string) string {
t.Helper()
url := fmt.Sprintf("%s/v1/%s/namespaces/%s/tables/%s", env.icebergURL(), bucketName, namespace, tableName)
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
t.Fatalf("load table request: %v", err)
}
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("load table: %v", err)
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
t.Fatalf("load table failed: status=%d body=%s", resp.StatusCode, respBody)
}
// The metadata.location field is nested. A naive substring match is
// sufficient for asserting the expected path shape.
body := string(respBody)
const key = `"location":"`
idx := strings.Index(body, key)
if idx < 0 {
t.Fatalf("LoadTable response missing location field: %s", body)
}
rest := body[idx+len(key):]
end := strings.Index(rest, `"`)
if end < 0 {
t.Fatalf("malformed location field: %s", body)
}
return rest[:end]
}

View File

@@ -0,0 +1,477 @@
package catalog
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/test/testutil"
)
// oauthTestEnv holds a weed mini instance started with IAM credentials
// so that the OAuth token endpoint is functional.
type oauthTestEnv struct {
seaweedDir string
weedBinary string
dataDir string
bindIP string
s3Port int
s3GrpcPort int
icebergPort int
masterPort int
masterGrpcPort int
filerPort int
filerGrpcPort int
volumePort int
volumeGrpcPort int
webdavPort int
weedProcess *exec.Cmd
weedCancel context.CancelFunc
accessKey string
secretKey string
// s3ExternalURL, when set before start, is the S3 endpoint the catalog
// advertises to clients in LoadTable FileIO config.
s3ExternalURL string
}
func newOAuthTestEnv(t *testing.T) *oauthTestEnv {
t.Helper()
wd, err := os.Getwd()
if err != nil {
t.Fatalf("get working directory: %v", err)
}
seaweedDir := wd
for i := 0; i < 6; i++ {
if _, err := os.Stat(filepath.Join(seaweedDir, "go.mod")); err == nil {
break
}
seaweedDir = filepath.Dir(seaweedDir)
}
weedBinary := filepath.Join(seaweedDir, "weed", "weed")
if info, err := os.Stat(weedBinary); err == nil && !info.IsDir() {
// Say which binary and how old it is. `make test` rebuilds first, but
// `go test` on its own happily runs a weeks-old binary and reports a
// pass for code that is not being exercised.
t.Logf("using %s, built %s", weedBinary, info.ModTime().Format(time.RFC3339))
} else {
weedBinary = "weed"
if _, err := exec.LookPath(weedBinary); err != nil {
t.Skip("weed binary not found, skipping integration test")
}
}
dataDir, err := os.MkdirTemp("", "seaweed-oauth-test-*")
if err != nil {
t.Fatalf("create temp dir: %v", err)
}
bindIP := testutil.FindBindIP()
ports := testutil.MustAllocatePorts(t, 10)
return &oauthTestEnv{
seaweedDir: seaweedDir,
weedBinary: weedBinary,
dataDir: dataDir,
bindIP: bindIP,
masterPort: ports[0],
masterGrpcPort: ports[1],
volumePort: ports[2],
volumeGrpcPort: ports[3],
filerPort: ports[4],
filerGrpcPort: ports[5],
s3Port: ports[6],
s3GrpcPort: ports[7],
icebergPort: ports[8],
webdavPort: ports[9],
accessKey: "AKIAIOSFODNN7EXAMPLE",
secretKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
}
}
func (env *oauthTestEnv) start(t *testing.T) {
t.Helper()
iamConfigPath, err := testutil.WriteIAMConfig(env.dataDir, env.accessKey, env.secretKey)
if err != nil {
t.Fatalf("write IAM config: %v", err)
}
securityToml := filepath.Join(env.dataDir, "security.toml")
if err := os.WriteFile(securityToml, []byte("# Empty security config for testing\n"), 0644); err != nil {
t.Fatalf("write security.toml: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
env.weedCancel = cancel
args := []string{"mini",
"-master.port", fmt.Sprintf("%d", env.masterPort),
"-master.port.grpc", fmt.Sprintf("%d", env.masterGrpcPort),
"-volume.port", fmt.Sprintf("%d", env.volumePort),
"-volume.port.grpc", fmt.Sprintf("%d", env.volumeGrpcPort),
"-filer.port", fmt.Sprintf("%d", env.filerPort),
"-filer.port.grpc", fmt.Sprintf("%d", env.filerGrpcPort),
"-s3.port", fmt.Sprintf("%d", env.s3Port),
"-s3.port.grpc", fmt.Sprintf("%d", env.s3GrpcPort),
"-s3.port.iceberg", fmt.Sprintf("%d", env.icebergPort),
"-webdav.port", fmt.Sprintf("%d", env.webdavPort),
"-s3.config", iamConfigPath,
"-ip", env.bindIP,
"-ip.bind", "0.0.0.0",
"-dir", env.dataDir,
}
if env.s3ExternalURL != "" {
args = append(args, "-s3.externalUrl", env.s3ExternalURL)
}
cmd := exec.CommandContext(ctx, env.weedBinary, args...)
cmd.Dir = env.dataDir
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Env = append(os.Environ(),
"AWS_ACCESS_KEY_ID="+env.accessKey,
"AWS_SECRET_ACCESS_KEY="+env.secretKey,
)
if err := cmd.Start(); err != nil {
cancel()
t.Fatalf("start weed mini: %v", err)
}
env.weedProcess = cmd
icebergURL := fmt.Sprintf("http://%s:%d/v1/config", env.bindIP, env.icebergPort)
if !testutil.WaitForService(icebergURL, 30*time.Second) {
cancel()
cmd.Wait()
t.Fatalf("Iceberg REST API did not become ready at %s", icebergURL)
}
}
func (env *oauthTestEnv) cleanup(t *testing.T) {
t.Helper()
if env.weedCancel != nil {
env.weedCancel()
}
if env.weedProcess != nil {
env.weedProcess.Wait()
}
if env.dataDir != "" {
os.RemoveAll(env.dataDir)
}
}
func (env *oauthTestEnv) icebergURL() string {
return fmt.Sprintf("http://%s:%d", env.bindIP, env.icebergPort)
}
// TestOAuthTokenEndpoint tests the /v1/oauth/tokens endpoint directly via HTTP.
func TestOAuthTokenEndpoint(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
env := newOAuthTestEnv(t)
defer env.cleanup(t)
env.start(t)
t.Run("valid credentials", func(t *testing.T) {
token := requestOAuthToken(t, env, env.accessKey, env.secretKey)
if token == "" {
t.Fatal("expected non-empty token")
}
})
t.Run("invalid secret", func(t *testing.T) {
resp, err := http.PostForm(env.icebergURL()+"/v1/oauth/tokens", url.Values{
"grant_type": {"client_credentials"},
"client_id": {env.accessKey},
"client_secret": {"wrong-secret"},
})
if err != nil {
t.Fatalf("POST /v1/oauth/tokens: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
body, _ := io.ReadAll(resp.Body)
t.Fatalf("expected 401, got %d: %s", resp.StatusCode, body)
}
})
t.Run("missing grant_type", func(t *testing.T) {
resp, err := http.PostForm(env.icebergURL()+"/v1/oauth/tokens", url.Values{
"client_id": {env.accessKey},
"client_secret": {env.secretKey},
})
if err != nil {
t.Fatalf("POST /v1/oauth/tokens: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
body, _ := io.ReadAll(resp.Body)
t.Fatalf("expected 400, got %d: %s", resp.StatusCode, body)
}
})
t.Run("bearer token auth on catalog endpoint", func(t *testing.T) {
token := requestOAuthToken(t, env, env.accessKey, env.secretKey)
// Point the call at a table bucket that exists, so anything but 200 is an
// auth or routing fault rather than the catalog reporting a missing bucket.
bucketName := "oauth-bearer-" + randomSuffix()
createTableBucketViaShell(t, env, bucketName)
req, err := http.NewRequest(http.MethodGet, env.icebergURL()+"/v1/"+bucketName+"/namespaces", nil)
if err != nil {
t.Fatalf("create request: %v", err)
}
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("GET /v1/%s/namespaces with Bearer: %v", bucketName, err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
t.Fatalf("Bearer auth failed unexpectedly: status=%d body=%s", resp.StatusCode, body)
}
})
}
// TestDuckDBOAuthIntegration tests that DuckDB can connect to the Iceberg REST
// catalog using the OAuth2 client_credentials flow (CREATE SECRET with client_id
// and client_secret). This is the scenario reported in issue #9015.
func TestDuckDBOAuthIntegration(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
if !testutil.HasDocker() {
t.Skip("Docker not available, skipping DuckDB OAuth integration test")
}
env := newOAuthTestEnv(t)
defer env.cleanup(t)
env.start(t)
// Create a table bucket and namespace so DuckDB has something to query
bucketName := "duckdb-oauth-" + randomSuffix()
createTableBucketViaShell(t, env, bucketName)
// Create a namespace via the Iceberg REST API using OAuth token
token := requestOAuthToken(t, env, env.accessKey, env.secretKey)
createNamespaceWithToken(t, env, token, bucketName, "testns")
sqlContent := fmt.Sprintf(`
INSTALL iceberg;
LOAD iceberg;
CREATE SECRET iceberg_secret (
TYPE ICEBERG,
ENDPOINT 'http://host.docker.internal:%d',
CLIENT_ID '%s',
CLIENT_SECRET '%s',
SCOPE 's3://%s/'
);
CREATE SECRET s3_secret (
TYPE S3,
KEY_ID '%s',
SECRET '%s',
ENDPOINT 'host.docker.internal:%d',
URL_STYLE 'path',
USE_SSL false,
SCOPE 's3://%s/'
);
SELECT 'OAuth token obtained successfully' as status;
-- Try listing namespaces via the Iceberg catalog
SELECT * FROM iceberg_scan('iceberg_secret', ALLOW_MOVED_PATHS => TRUE) LIMIT 0;
`, env.icebergPort, env.accessKey, env.secretKey, bucketName,
env.accessKey, env.secretKey, env.s3Port, bucketName)
sqlFile := filepath.Join(env.dataDir, "duckdb_oauth_test.sql")
if err := os.WriteFile(sqlFile, []byte(sqlContent), 0644); err != nil {
t.Fatalf("write SQL file: %v", err)
}
// Run DuckDB in Docker.
// We use a simple test: get a token, create a secret, and verify the
// Iceberg extension can communicate with the catalog.
// The simpler fallback test just verifies CREATE SECRET succeeds (no 404).
fallbackSQL := fmt.Sprintf(`
INSTALL iceberg;
LOAD iceberg;
CREATE SECRET (
TYPE ICEBERG,
ENDPOINT 'http://host.docker.internal:%d',
CLIENT_ID '%s',
CLIENT_SECRET '%s'
);
SELECT 'DuckDB Iceberg OAuth secret created successfully' as result;
`, env.icebergPort, env.accessKey, env.secretKey)
fallbackFile := filepath.Join(env.dataDir, "duckdb_oauth_fallback.sql")
if err := os.WriteFile(fallbackFile, []byte(fallbackSQL), 0644); err != nil {
t.Fatalf("write fallback SQL file: %v", err)
}
// Try the full SQL script first (CREATE SECRET + catalog access).
// Fall back to the simple CREATE SECRET test if iceberg_scan isn't supported.
cmd := exec.Command("docker", "run", "--rm",
"-v", fmt.Sprintf("%s:/test", env.dataDir),
"--add-host", "host.docker.internal:host-gateway",
"--entrypoint", "duckdb",
"duckdb/duckdb:latest",
"-init", "/test/duckdb_oauth_test.sql",
"-c", "SELECT 1",
)
output, err := cmd.CombinedOutput()
outputStr := string(output)
t.Logf("DuckDB output:\n%s", outputStr)
if err != nil {
if strings.Contains(outputStr, "iceberg extension is not available") ||
strings.Contains(outputStr, "Failed to load") {
t.Skip("Skipping: Iceberg extension not available in DuckDB Docker image")
}
// The key check: the old error was "HTTP NotFound_404" on /v1/oauth/tokens.
// With our fix, this should no longer happen.
if strings.Contains(outputStr, "NotFound_404") && strings.Contains(outputStr, "/v1/oauth/tokens") {
t.Fatal("OAuth token endpoint returned 404 - the fix is not working")
}
// If iceberg_scan failed but CREATE SECRET worked, fall back to simpler test
if strings.Contains(outputStr, "OAuth token obtained successfully") {
t.Logf("Full SQL had partial success (token obtained), iceberg_scan may not be supported. Continuing.")
} else {
// Try the fallback script that only tests CREATE SECRET
t.Logf("Full SQL failed, trying fallback CREATE SECRET test...")
fallbackCmd := exec.Command("docker", "run", "--rm",
"-v", fmt.Sprintf("%s:/test", env.dataDir),
"--add-host", "host.docker.internal:host-gateway",
"--entrypoint", "duckdb",
"duckdb/duckdb:latest",
"-init", "/test/duckdb_oauth_fallback.sql",
"-c", "SELECT 1",
)
fallbackOutput, fallbackErr := fallbackCmd.CombinedOutput()
fallbackStr := string(fallbackOutput)
t.Logf("DuckDB fallback output:\n%s", fallbackStr)
if fallbackErr != nil {
if strings.Contains(fallbackStr, "NotFound_404") && strings.Contains(fallbackStr, "/v1/oauth/tokens") {
t.Fatal("OAuth token endpoint returned 404 - the fix is not working")
}
t.Fatalf("DuckDB fallback also failed: %v\nOutput: %s", fallbackErr, fallbackStr)
}
if !strings.Contains(fallbackStr, "DuckDB Iceberg OAuth secret created successfully") {
t.Errorf("expected success message in fallback output, got:\n%s", fallbackStr)
}
return
}
}
if strings.Contains(outputStr, "OAuth token obtained successfully") {
t.Logf("DuckDB OAuth CREATE SECRET succeeded")
} else {
t.Errorf("expected OAuth success message in output, got:\n%s", outputStr)
}
}
// requestOAuthToken obtains a bearer token from the OAuth endpoint.
func requestOAuthToken(t *testing.T, env *oauthTestEnv, accessKey, secretKey string) string {
t.Helper()
resp, err := http.PostForm(env.icebergURL()+"/v1/oauth/tokens", url.Values{
"grant_type": {"client_credentials"},
"client_id": {accessKey},
"client_secret": {secretKey},
})
if err != nil {
t.Fatalf("POST /v1/oauth/tokens: %v", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
t.Fatalf("OAuth token request failed: status=%d body=%s", resp.StatusCode, body)
}
var tokenResp struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
}
if err := json.Unmarshal(body, &tokenResp); err != nil {
t.Fatalf("decode token response: %v", err)
}
if tokenResp.AccessToken == "" {
t.Fatal("got empty access_token")
}
if tokenResp.TokenType != "bearer" {
t.Errorf("expected token_type=bearer, got %s", tokenResp.TokenType)
}
return tokenResp.AccessToken
}
// createTableBucketViaShell creates a table bucket using weed shell,
// which bypasses S3 auth. This is the same approach used by the Trino tests.
func createTableBucketViaShell(t *testing.T, env *oauthTestEnv, bucketName string) {
t.Helper()
cmd := exec.Command(env.weedBinary, "shell",
fmt.Sprintf("-master=%s:%d.%d", env.bindIP, env.masterPort, env.masterGrpcPort),
)
cmd.Stdin = strings.NewReader(fmt.Sprintf("s3tables.bucket -create -name %s -account 000000000000\nexit\n", bucketName))
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("create table bucket %s via weed shell: %v\nOutput: %s", bucketName, err, output)
}
t.Logf("Created table bucket %s", bucketName)
}
// createNamespaceWithToken creates a namespace using a Bearer token.
func createNamespaceWithToken(t *testing.T, env *oauthTestEnv, token, bucketName, namespace string) {
t.Helper()
path := fmt.Sprintf("/v1/%s/namespaces", bucketName)
body := fmt.Sprintf(`{"namespace":["%s"]}`, namespace)
req, err := http.NewRequest(http.MethodPost, env.icebergURL()+path, strings.NewReader(body))
if err != nil {
t.Fatalf("create request: %v", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("create namespace: %v", err)
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusConflict {
t.Fatalf("create namespace failed: status=%d body=%s", resp.StatusCode, respBody)
}
t.Logf("Created namespace %s in bucket %s", namespace, bucketName)
}

View File

@@ -0,0 +1,215 @@
// Tests for the credential-vending access pattern.
//
// DuckDB attaches a catalog with "X-Iceberg-Access-Delegation: vended-credentials"
// and rebuilds its S3 credential out of whatever LoadTable returns in `config`,
// dropping the S3 secret it was configured with. A catalog that advertises an
// endpoint there but vends no credentials therefore leaves DuckDB sending
// unsigned requests, and every metadata and data file comes back 403.
//
// These run against a weed mini started with -s3.externalUrl, which is what
// makes the catalog advertise an endpoint at all; with a wildcard bind and no
// external URL the config is empty and the bug cannot appear.
package catalog
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"github.com/seaweedfs/seaweedfs/test/testutil"
)
// duckDBImage tracks whatever DuckDB users are actually running, matching the
// other DuckDB tests here: a client that changes how it handles vended
// credentials is exactly what this suite exists to catch.
const duckDBImage = "duckdb/duckdb:latest"
// requireDuckDBIceberg skips when the image cannot install the iceberg
// extension -- no egress, or a build without it. Probing separately keeps the
// round trip below free to fail on any error instead of having to guess which
// DuckDB messages mean "no extension" and which mean "the catalog is broken".
func requireDuckDBIceberg(t *testing.T) {
t.Helper()
const ready = "iceberg extension ready"
cmd := exec.Command("docker", "run", "--rm",
"--entrypoint", "duckdb",
duckDBImage,
"-c", fmt.Sprintf("INSTALL iceberg; LOAD iceberg; SELECT '%s' AS marker;", ready),
)
output, err := cmd.CombinedOutput()
if err != nil || !strings.Contains(string(output), ready) {
t.Skipf("DuckDB image cannot load the iceberg extension: %v\n%s", err, output)
}
}
func TestIcebergVendedCredentials(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
env := newOAuthTestEnv(t)
// The DuckDB container reaches S3 through host.docker.internal, so advertise
// the endpoint under the name the client can actually resolve.
env.s3ExternalURL = fmt.Sprintf("http://host.docker.internal:%d", env.s3Port)
defer env.cleanup(t)
env.start(t)
bucketName := "vendcreds-" + randomSuffix()
namespace := "vendcreds_ns_" + randomSuffix()
tableName := "vendcreds_tbl_" + randomSuffix()
createTableBucketViaShell(t, env, bucketName)
token := requestOAuthToken(t, env, env.accessKey, env.secretKey)
createNamespaceWithToken(t, env, token, bucketName, namespace)
createTableWithToken(t, env, token, bucketName, namespace, tableName)
t.Run("plain load table advertises the endpoint", func(t *testing.T) {
config := loadTableConfig(t, env, token, bucketName, namespace, tableName, "")
if config["s3.endpoint"] != env.s3ExternalURL {
t.Fatalf("s3.endpoint = %q, want %q (clients that bring their own credentials still need the endpoint)",
config["s3.endpoint"], env.s3ExternalURL)
}
})
t.Run("vended credentials request gets no half-configured storage", func(t *testing.T) {
config := loadTableConfig(t, env, token, bucketName, namespace, tableName, "vended-credentials")
if config["s3.endpoint"] == "" {
return
}
// If the catalog ever does vend credentials, the endpoint may come back
// -- but only alongside the credentials that make it usable.
if config["s3.access-key-id"] == "" || config["s3.secret-access-key"] == "" {
t.Fatalf("LoadTable config = %v: a client that asked for vended credentials drops its own and signs with what it gets, so an endpoint without credentials makes every data file read 403", config)
}
})
t.Run("duckdb writes and reads back through the catalog", func(t *testing.T) {
if !testutil.HasDocker() {
t.Skip("Docker not available, skipping DuckDB round trip")
}
requireDuckDBIceberg(t)
duckDBRoundTrip(t, env, bucketName, namespace)
})
}
// duckDBRoundTrip runs the reporter's flow: attach the catalog, create a table
// from a query, then read it back. Both halves need signed S3 access to the
// table's metadata and data files.
func duckDBRoundTrip(t *testing.T, env *oauthTestEnv, bucketName, namespace string) {
t.Helper()
const marker = "vended-credentials round trip ok"
sql := fmt.Sprintf(`
INSTALL iceberg;
LOAD iceberg;
CREATE SECRET iceberg_secret (
TYPE ICEBERG,
ENDPOINT 'http://host.docker.internal:%d',
CLIENT_ID '%s',
CLIENT_SECRET '%s'
);
CREATE SECRET s3_secret (
TYPE S3,
KEY_ID '%s',
SECRET '%s',
ENDPOINT 'host.docker.internal:%d',
URL_STYLE 'path',
USE_SSL false
);
ATTACH 's3://%s' AS vend_cat (
TYPE ICEBERG,
SECRET iceberg_secret,
ENDPOINT 'http://host.docker.internal:%d',
READ_ONLY false
);
CREATE TABLE vend_cat.%s.round_trip AS SELECT 42 AS answer;
SELECT CASE WHEN sum(answer) = 42 THEN '%s' ELSE 'wrong answer' END AS marker
FROM vend_cat.%s.round_trip;
`,
env.icebergPort, env.accessKey, env.secretKey,
env.accessKey, env.secretKey, env.s3Port,
bucketName, env.icebergPort,
namespace, marker, namespace,
)
sqlFile := filepath.Join(env.dataDir, "duckdb_vended_credentials.sql")
if err := os.WriteFile(sqlFile, []byte(sql), 0644); err != nil {
t.Fatalf("write SQL file: %v", err)
}
cmd := exec.Command("docker", "run", "--rm",
"-v", fmt.Sprintf("%s:/test", env.dataDir),
"--add-host", "host.docker.internal:host-gateway",
"-e", "AWS_REGION=us-east-1",
"--entrypoint", "duckdb",
duckDBImage,
"-init", "/test/duckdb_vended_credentials.sql",
"-c", "SELECT 1",
)
output, err := cmd.CombinedOutput()
outputStr := string(output)
t.Logf("DuckDB output:\n%s", outputStr)
// Nothing below skips: requireDuckDBIceberg already established that the
// extension loads, so any failure from here is the catalog's.
if strings.Contains(outputStr, "trying to refresh secret") {
t.Fatalf("DuckDB fell back to refreshing the credential the catalog vended, which no stage-created table can satisfy:\n%s", outputStr)
}
// Match the phrasings rather than a bare "403", which a random port could
// carry.
for _, denial := range []string{"AccessDenied", "403 Forbidden", "code 403"} {
if strings.Contains(outputStr, denial) {
t.Fatalf("DuckDB signed its S3 requests with the catalog's credential-less config:\n%s", outputStr)
}
}
if !strings.Contains(outputStr, marker) {
t.Fatalf("DuckDB did not write and read the table back (err=%v):\n%s", err, outputStr)
}
}
// loadTableConfig loads a table and returns the FileIO config the catalog
// advertises, optionally asking for an access delegation mechanism.
func loadTableConfig(t *testing.T, env *oauthTestEnv, token, bucketName, namespace, tableName, delegation string) map[string]string {
t.Helper()
url := fmt.Sprintf("%s/v1/%s/namespaces/%s/tables/%s", env.icebergURL(), bucketName, namespace, tableName)
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
t.Fatalf("create request: %v", err)
}
req.Header.Set("Authorization", "Bearer "+token)
if delegation != "" {
req.Header.Set("X-Iceberg-Access-Delegation", delegation)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("load table: %v", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
t.Fatalf("load table failed: status=%d body=%s", resp.StatusCode, body)
}
var result struct {
Config map[string]string `json:"config"`
}
if err := json.Unmarshal(body, &result); err != nil {
t.Fatalf("decode LoadTableResult: %v", err)
}
return result.Config
}

View File

@@ -0,0 +1,585 @@
// Package catalog provides integration tests for the Iceberg REST Catalog API.
// These tests use DuckDB running in Docker to verify catalog operations.
package catalog
import (
"bytes"
"context"
"encoding/json"
"flag"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"github.com/aws/aws-sdk-go/aws/credentials"
v4 "github.com/aws/aws-sdk-go/aws/signer/v4"
"github.com/seaweedfs/seaweedfs/test/testutil"
)
// sharedEnv is the single TestEnvironment shared across all tests in this package.
var sharedEnv *TestEnvironment
// TestMain starts one weed mini instance for the whole package and tears it down
// after all tests have run.
func TestMain(m *testing.M) {
flag.Parse()
if os.Getenv("SHORT") != "" || testing.Short() {
// Let tests self-skip when run with -short.
os.Exit(m.Run())
}
env, err := newTestEnvironmentForMain()
if err != nil {
fmt.Fprintf(os.Stderr, "SKIP: setup failed: %v\n", err)
os.Exit(0) // Skip all tests rather than fail
}
sharedEnv = env
if startErr := sharedEnv.startSeaweedFSForMain(); startErr != nil {
fmt.Fprintf(os.Stderr, "SKIP: weed mini failed to start: %v\n", startErr)
sharedEnv.cleanupForMain()
os.Exit(0)
}
code := m.Run()
sharedEnv.cleanupForMain()
os.Exit(code)
}
// TestEnvironment contains the test environment configuration
type TestEnvironment struct {
seaweedDir string
weedBinary string
dataDir string
s3Port int
s3GrpcPort int
icebergPort int
lancePort int
masterPort int
masterGrpcPort int
filerPort int
filerGrpcPort int
volumePort int
volumeGrpcPort int
weedProcess *exec.Cmd
weedCancel context.CancelFunc
dockerAvailable bool
}
// newTestEnvironmentForMain creates a TestEnvironment without calling t.Fatalf so it
// can be used from TestMain (which has no *testing.T).
func newTestEnvironmentForMain() (*TestEnvironment, error) {
// Find the SeaweedFS root directory
wd, err := os.Getwd()
if err != nil {
return nil, fmt.Errorf("get working directory: %w", err)
}
seaweedDir := wd
for i := 0; i < 5; i++ {
if _, err := os.Stat(filepath.Join(seaweedDir, "go.mod")); err == nil {
break
}
seaweedDir = filepath.Dir(seaweedDir)
}
// Check for weed binary
weedBinary := filepath.Join(seaweedDir, "weed", "weed")
if info, statErr := os.Stat(weedBinary); statErr == nil {
// Name the binary and its age. `make test` rebuilds first, but a plain
// `go test` will happily drive a weeks-old binary and report a pass for
// code it never ran.
fmt.Fprintf(os.Stderr, "using %s, built %s\n", weedBinary, info.ModTime().Format(time.RFC3339))
} else if os.IsNotExist(statErr) {
weedBinary = "weed"
if _, err := exec.LookPath(weedBinary); err != nil {
return nil, fmt.Errorf("weed binary not found")
}
}
// Create temporary data directory
dataDir, err := os.MkdirTemp("", "seaweed-iceberg-test-*")
if err != nil {
return nil, fmt.Errorf("create temp dir: %w", err)
}
// Allocate 10 unique ports atomically: s3, iceberg, s3Grpc, master, masterGrpc,
// filer, filerGrpc, volume, volumeGrpc, lance
ports, err := testutil.AllocatePorts(10)
if err != nil {
return nil, fmt.Errorf("allocate ports: %w", err)
}
return &TestEnvironment{
seaweedDir: seaweedDir,
weedBinary: weedBinary,
dataDir: dataDir,
s3Port: ports[0],
s3GrpcPort: ports[1],
icebergPort: ports[2],
masterPort: ports[3],
masterGrpcPort: ports[4],
filerPort: ports[5],
filerGrpcPort: ports[6],
volumePort: ports[7],
volumeGrpcPort: ports[8],
lancePort: ports[9],
dockerAvailable: testutil.HasDocker(),
}, nil
}
// startSeaweedFSForMain starts weed mini without a *testing.T (for use in TestMain).
func (env *TestEnvironment) startSeaweedFSForMain() error {
ctx, cancel := context.WithCancel(context.Background())
env.weedCancel = cancel
masterDir := filepath.Join(env.dataDir, "master")
filerDir := filepath.Join(env.dataDir, "filer")
volumeDir := filepath.Join(env.dataDir, "volume")
for _, dir := range []string{masterDir, filerDir, volumeDir} {
if err := os.MkdirAll(dir, 0755); err != nil {
cancel()
return fmt.Errorf("create directory %s: %w", dir, err)
}
}
cmd := exec.CommandContext(ctx, env.weedBinary, "mini",
"-master.port", fmt.Sprintf("%d", env.masterPort),
"-master.port.grpc", fmt.Sprintf("%d", env.masterGrpcPort),
"-volume.port", fmt.Sprintf("%d", env.volumePort),
"-volume.port.grpc", fmt.Sprintf("%d", env.volumeGrpcPort),
"-filer.port", fmt.Sprintf("%d", env.filerPort),
"-filer.port.grpc", fmt.Sprintf("%d", env.filerGrpcPort),
"-s3.port", fmt.Sprintf("%d", env.s3Port),
"-s3.port.grpc", fmt.Sprintf("%d", env.s3GrpcPort),
"-s3.port.iceberg", fmt.Sprintf("%d", env.icebergPort),
"-s3.port.lance", fmt.Sprintf("%d", env.lancePort),
"-ip.bind", "0.0.0.0",
"-dir", env.dataDir,
)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
cancel()
return fmt.Errorf("start SeaweedFS: %w", err)
}
env.weedProcess = cmd
if !env.waitForService(fmt.Sprintf("http://127.0.0.1:%d/v1/config", env.icebergPort), 30*time.Second) {
cancel()
cmd.Wait()
return fmt.Errorf("Iceberg REST API did not become ready")
}
return nil
}
// cleanupForMain stops SeaweedFS and cleans up resources (no *testing.T needed).
func (env *TestEnvironment) cleanupForMain() {
if env.weedCancel != nil {
env.weedCancel()
}
if env.weedProcess != nil {
time.Sleep(2 * time.Second)
env.weedProcess.Wait()
}
if env.dataDir != "" {
os.RemoveAll(env.dataDir)
}
}
// waitForService waits for a service to become available
func (env *TestEnvironment) waitForService(url string, timeout time.Duration) bool {
client := &http.Client{Timeout: 2 * time.Second}
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
resp, err := client.Get(url)
if err == nil {
resp.Body.Close()
if resp.StatusCode == http.StatusOK {
return true
}
}
time.Sleep(500 * time.Millisecond)
}
return false
}
// IcebergURL returns the Iceberg REST Catalog URL
func (env *TestEnvironment) IcebergURL() string {
return fmt.Sprintf("http://127.0.0.1:%d", env.icebergPort)
}
// LanceURL returns the Lance Namespace server URL
func (env *TestEnvironment) LanceURL() string {
return fmt.Sprintf("http://127.0.0.1:%d", env.lancePort)
}
// TestIcebergConfig tests the /v1/config endpoint
func TestIcebergConfig(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
env := sharedEnv
// Test GET /v1/config
resp, err := http.Get(env.IcebergURL() + "/v1/config")
if err != nil {
t.Fatalf("Failed to get config: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("Expected status 200, got %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("Failed to read response body: %v", err)
}
// Verify response contains required fields
bodyStr := string(body)
if !strings.Contains(bodyStr, "defaults") || !strings.Contains(bodyStr, "overrides") {
t.Errorf("Config response missing required fields: %s", bodyStr)
}
}
// TestIcebergNamespaces tests namespace operations
func TestIcebergNamespaces(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
env := sharedEnv
// Create the default table bucket first via S3
bucketName := "warehouse-ns-" + randomSuffix()
createTableBucket(t, env, bucketName, "")
// Test GET /v1/namespaces (should return empty list initially)
resp, err := http.Get(env.IcebergURL() + icebergPath(bucketName, "/v1/namespaces"))
if err != nil {
t.Fatalf("Failed to list namespaces: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
t.Fatalf("Expected status 200, got %d: %s", resp.StatusCode, body)
}
}
// TestStageCreateAndFinalizeFlow verifies staged create remains invisible until assert-create commit finalizes table creation.
func TestStageCreateAndFinalizeFlow(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
env := sharedEnv
bucketName := "warehouse-stage-" + randomSuffix()
createTableBucket(t, env, bucketName, "")
namespace := "stage_ns_" + randomSuffix()
tableName := "orders"
status, _, err := doIcebergJSONRequest(env, http.MethodPost, icebergPath(bucketName, "/v1/namespaces"), map[string]any{
"namespace": []string{namespace},
})
if err != nil {
t.Fatalf("Create namespace request failed: %v", err)
}
if status != http.StatusOK && status != http.StatusConflict {
t.Fatalf("Create namespace status = %d, want 200 or 409", status)
}
status, badReqResp, err := doIcebergJSONRequest(env, http.MethodPost, icebergPath(bucketName, fmt.Sprintf("/v1/namespaces/%s/tables", namespace)), map[string]any{
"stage-create": true,
})
if err != nil {
t.Fatalf("Stage create missing-name request failed: %v", err)
}
if status != http.StatusBadRequest {
t.Fatalf("Stage create missing-name status = %d, want 400", status)
}
errorObj, _ := badReqResp["error"].(map[string]any)
if got := errorObj["type"]; got != "BadRequestException" {
t.Fatalf("error.type = %v, want BadRequestException", got)
}
msg, _ := errorObj["message"].(string)
if !strings.Contains(strings.ToLower(msg), "table name is required") {
t.Fatalf("error.message = %v, want it to include %q", errorObj["message"], "table name is required")
}
status, stageResp, err := doIcebergJSONRequest(env, http.MethodPost, icebergPath(bucketName, fmt.Sprintf("/v1/namespaces/%s/tables", namespace)), map[string]any{
"name": tableName,
"stage-create": true,
})
if err != nil {
t.Fatalf("Stage create request failed: %v", err)
}
if status != http.StatusOK {
t.Fatalf("Stage create status = %d, want 200", status)
}
stageLocation, _ := stageResp["metadata-location"].(string)
if !strings.HasSuffix(stageLocation, "/metadata/v1.metadata.json") {
t.Fatalf("stage metadata-location = %q, want suffix /metadata/v1.metadata.json", stageLocation)
}
status, _, err = doIcebergJSONRequest(env, http.MethodGet, icebergPath(bucketName, fmt.Sprintf("/v1/namespaces/%s/tables/%s", namespace, tableName)), nil)
if err != nil {
t.Fatalf("Load staged table request failed: %v", err)
}
if status != http.StatusNotFound {
t.Fatalf("Load staged table status = %d, want 404", status)
}
status, commitResp, err := doIcebergJSONRequest(env, http.MethodPost, icebergPath(bucketName, fmt.Sprintf("/v1/namespaces/%s/tables/%s", namespace, tableName)), map[string]any{
"requirements": []map[string]any{
{"type": "assert-create"},
},
"updates": []any{},
})
if err != nil {
t.Fatalf("Finalize commit request failed: %v", err)
}
if status != http.StatusOK {
t.Fatalf("Finalize commit status = %d, want 200", status)
}
commitLocation, _ := commitResp["metadata-location"].(string)
if !strings.HasSuffix(commitLocation, "/metadata/v1.metadata.json") {
t.Fatalf("final metadata-location = %q, want suffix /metadata/v1.metadata.json", commitLocation)
}
status, loadResp, err := doIcebergJSONRequest(env, http.MethodGet, icebergPath(bucketName, fmt.Sprintf("/v1/namespaces/%s/tables/%s", namespace, tableName)), nil)
if err != nil {
t.Fatalf("Load finalized table request failed: %v", err)
}
if status != http.StatusOK {
t.Fatalf("Load finalized table status = %d, want 200", status)
}
loadLocation, _ := loadResp["metadata-location"].(string)
if loadLocation != commitLocation {
t.Fatalf("loaded metadata-location = %q, want %q", loadLocation, commitLocation)
}
}
// TestCommitMissingTableWithoutAssertCreate ensures missing-table commits still require assert-create for creation.
func TestCommitMissingTableWithoutAssertCreate(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
env := sharedEnv
bucketName := "warehouse-missing-" + randomSuffix()
createTableBucket(t, env, bucketName, "")
namespace := "stage_missing_assert_ns_" + randomSuffix()
tableName := "missing_table"
status, _, err := doIcebergJSONRequest(env, http.MethodPost, icebergPath(bucketName, "/v1/namespaces"), map[string]any{
"namespace": []string{namespace},
})
if err != nil {
t.Fatalf("Create namespace request failed: %v", err)
}
if status != http.StatusOK && status != http.StatusConflict {
t.Fatalf("Create namespace status = %d, want 200 or 409", status)
}
status, _, err = doIcebergJSONRequest(env, http.MethodPost, icebergPath(bucketName, fmt.Sprintf("/v1/namespaces/%s/tables/%s", namespace, tableName)), map[string]any{
"requirements": []any{},
"updates": []any{},
})
if err != nil {
t.Fatalf("Commit missing table request failed: %v", err)
}
if status != http.StatusNotFound {
t.Fatalf("Commit missing table status = %d, want 404", status)
}
}
// doIcebergJSONRequest decodes JSON object responses used by catalog tests.
func doIcebergJSONRequest(env *TestEnvironment, method, path string, payload any) (int, map[string]any, error) {
url := env.IcebergURL() + path
var bodyReader io.Reader
if payload != nil {
data, err := json.Marshal(payload)
if err != nil {
return 0, nil, err
}
bodyReader = bytes.NewReader(data)
}
req, err := http.NewRequest(method, url, bodyReader)
if err != nil {
return 0, nil, err
}
if payload != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return 0, nil, err
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return resp.StatusCode, nil, err
}
if len(data) == 0 {
return resp.StatusCode, nil, nil
}
var decoded map[string]any
if err := json.Unmarshal(data, &decoded); err != nil {
return resp.StatusCode, nil, fmt.Errorf("failed to decode %s %s response: %w body=%s", method, path, err, string(data))
}
return resp.StatusCode, decoded, nil
}
// icebergPath inserts the table bucket prefix into Iceberg REST API paths.
// For example, "/v1/namespaces" with prefix "my-bucket" becomes
// "/v1/my-bucket/namespaces".
func icebergPath(prefix, path string) string {
if prefix == "" {
return path
}
const base = "/v1/"
if !strings.HasPrefix(path, base) {
return path
}
withPrefix := base + prefix + "/" + strings.TrimPrefix(path, base)
return withPrefix
}
// createTableBucket creates a table bucket via the S3Tables REST API.
// The request is AWS V4 signed for SERVICE=s3tables so the S3 Tables
// route matcher accepts it; signing with regular SERVICE=s3 would let
// the request fall through to the S3 CreateBucket handler.
// createTableBucket makes a table bucket of the given format. An empty format
// leaves it to the server, which means ICEBERG.
func createTableBucket(t *testing.T, env *TestEnvironment, bucketName, format string) {
t.Helper()
endpoint := fmt.Sprintf("http://localhost:%d/buckets", env.s3Port)
reqBody := fmt.Sprintf(`{"name":"%s"}`, bucketName)
if format != "" {
reqBody = fmt.Sprintf(`{"name":"%s","format":"%s"}`, bucketName, format)
}
req, err := http.NewRequest(http.MethodPut, endpoint, strings.NewReader(reqBody))
if err != nil {
t.Fatalf("Failed to create request: %v", err)
}
req.Header.Set("Content-Type", "application/x-amz-json-1.1")
signS3TablesRequest(t, req, reqBody)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("Failed to create table bucket %s: %v", bucketName, err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
t.Logf("Create table bucket %s response: status=%d, body=%s", bucketName, resp.StatusCode, string(body))
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusConflict {
t.Fatalf("Failed to create table bucket %s, status %d: %s", bucketName, resp.StatusCode, body)
}
t.Logf("Created table bucket %s", bucketName)
}
// signS3TablesRequest signs req with AWS V4 for SERVICE=s3tables. The
// underlying weed mini instance runs in default-allow mode so the
// signature itself is not verified; only the credential scope matters,
// because the S3 Tables route matcher requires SERVICE=s3tables to
// distinguish S3 Tables traffic from regular S3 calls on the same paths.
func signS3TablesRequest(t *testing.T, req *http.Request, body string) {
t.Helper()
creds := credentials.NewStaticCredentials("test-ak", "test-sk", "")
signer := v4.NewSigner(creds)
if _, err := signer.Sign(req, strings.NewReader(body), "s3tables", "us-east-1", time.Now()); err != nil {
t.Fatalf("Failed to sign S3 Tables request: %v", err)
}
}
// randomSuffix returns a short random hex suffix for unique resource naming.
func randomSuffix() string {
return fmt.Sprintf("%x", time.Now().UnixNano()&0xffffffff)
}
// TestDuckDBIntegration tests Iceberg catalog operations using DuckDB
// This test requires Docker to be available
func TestDuckDBIntegration(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
env := sharedEnv
if !env.dockerAvailable {
t.Skip("Docker not available, skipping DuckDB integration test")
}
// Create a temporary SQL file for DuckDB to execute
sqlFile := filepath.Join(env.dataDir, "test.sql")
sqlContent := fmt.Sprintf(`
-- Install and load Iceberg extension
INSTALL iceberg;
LOAD iceberg;
-- List namespaces via Iceberg REST catalog (basic connectivity test)
-- Note: Full operations require setting up S3 credentials which is beyond this test
SELECT 'Iceberg extension loaded successfully' as result;
`)
if err := os.WriteFile(sqlFile, []byte(sqlContent), 0644); err != nil {
t.Fatalf("Failed to write SQL file: %v", err)
}
// Run DuckDB in Docker to test Iceberg connectivity
// Use host.docker.internal to connect to the host's Iceberg port
cmd := exec.Command("docker", "run", "--rm",
"-v", fmt.Sprintf("%s:/test", env.dataDir),
"--add-host", "host.docker.internal:host-gateway",
"--entrypoint", "duckdb",
"duckdb/duckdb:latest",
"-init", "/test/test.sql",
"-c", "SELECT 1",
)
output, err := cmd.CombinedOutput()
if err != nil {
t.Logf("DuckDB output: %s", output)
// Check for expected errors in certain CI environments
outputStr := string(output)
if strings.Contains(outputStr, "iceberg extension is not available") ||
strings.Contains(outputStr, "Failed to load") {
t.Skip("Skipping DuckDB test: Iceberg extension not available in Docker image")
}
// Any other error is unexpected
t.Fatalf("DuckDB command failed unexpectedly. Output: %s\nError: %v", output, err)
}
// Verify the test completed successfully
outputStr := string(output)
t.Logf("DuckDB output: %s", outputStr)
if !strings.Contains(outputStr, "Iceberg extension loaded successfully") {
t.Errorf("Expected success message in output, got: %s", outputStr)
}
}

View File

@@ -0,0 +1,283 @@
// Reproduction tests for https://github.com/seaweedfs/seaweedfs/issues/9103
//
// The issue reports two distinct failure modes when using DuckDB against the
// SeaweedFS Iceberg REST catalog:
//
// 1. `ATTACH 's3://test/' AS cat (TYPE 'ICEBERG', ...); SELECT * FROM cat.ovirt.disk;`
// fails with "Table with name 'ovirt.disk' does not exist because schema
// 'ovirt' does not exist." The namespace "ovirt" does exist in the bucket.
//
// 2. `SELECT * FROM iceberg_scan('s3://test/ovirt/disk');` fails with HTTP 403
// AccessDenied when DuckDB tries to glob `s3://test/ovirt/disk/metadata/v*`
// because the LoadTable response does not vend S3 file-io credentials back
// to the client.
//
// These tests reproduce the underlying catalog-level misbehavior at the REST
// protocol layer so they run quickly and deterministically. A DuckDB-based
// end-to-end reproduction is also included (gated on Docker availability).
package catalog
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"github.com/seaweedfs/seaweedfs/test/testutil"
)
// TestIssue9103_ConfigDoesNotVendWarehousePrefix reproduces failure mode #2.
//
// Per the Iceberg REST spec, a client attaching with warehouse=s3://<bucket>/
// calls GET /v1/config?warehouse=s3://<bucket>/ and expects the server to
// return an `overrides.prefix` that identifies the catalog namespace for
// subsequent requests. Without it the client falls back to unprefixed paths
// like /v1/namespaces, which on SeaweedFS resolve to the hard-coded default
// bucket ("warehouse") and therefore do not list the user's namespaces.
//
// Currently the handler ignores the warehouse query parameter and returns
// empty defaults/overrides. That is exactly what makes DuckDB's ATTACH flow
// report `schema "ovirt" does not exist`.
func TestIssue9103_ConfigDoesNotVendWarehousePrefix(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
env := sharedEnv
bucketName := "warehouse-9103cfg-" + randomSuffix()
createTableBucket(t, env, bucketName, "")
warehouse := fmt.Sprintf("s3://%s/", bucketName)
u := fmt.Sprintf("%s/v1/config?warehouse=%s", env.IcebergURL(), url.QueryEscape(warehouse))
status, body, err := doIcebergJSONRequest(env, http.MethodGet, fmt.Sprintf("/v1/config?warehouse=%s", url.QueryEscape(warehouse)), nil)
if err != nil {
t.Fatalf("GET %s failed: %v", u, err)
}
if status != http.StatusOK {
t.Fatalf("GET %s status = %d, want 200", u, status)
}
overrides, _ := body["overrides"].(map[string]any)
gotPrefix, _ := overrides["prefix"].(string)
if gotPrefix != bucketName {
t.Fatalf("GET /v1/config?warehouse=%s: overrides.prefix = %q, want %q (catalog must echo the warehouse's table bucket so clients like DuckDB know which /v1/{prefix}/namespaces to use)",
warehouse, gotPrefix, bucketName)
}
}
// TestIssue9103_BareNamespacesListMissesNamespaceInAttachedBucket
// demonstrates the downstream effect of the /v1/config bug.
//
// The client created the namespace "ovirt" inside bucket "test" (via
// /v1/{bucket}/namespaces), but when it later issues GET /v1/namespaces (no
// prefix, because /v1/config didn't give it one), that request resolves to
// the default bucket and returns no namespaces. DuckDB surfaces this as
// `schema "ovirt" does not exist`.
func TestIssue9103_BareNamespacesListMissesNamespaceInAttachedBucket(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
env := sharedEnv
bucketName := "warehouse-9103ns-" + randomSuffix()
createTableBucket(t, env, bucketName, "")
namespace := "ovirt"
status, _, err := doIcebergJSONRequest(env, http.MethodPost,
icebergPath(bucketName, "/v1/namespaces"),
map[string]any{"namespace": []string{namespace}})
if err != nil {
t.Fatalf("create namespace: %v", err)
}
if status != http.StatusOK && status != http.StatusConflict {
t.Fatalf("create namespace status = %d, want 200 or 409", status)
}
// Sanity: prefixed listing finds the namespace.
status, prefixedBody, err := doIcebergJSONRequest(env, http.MethodGet,
icebergPath(bucketName, "/v1/namespaces"), nil)
if err != nil {
t.Fatalf("list namespaces with prefix: %v", err)
}
if status != http.StatusOK {
t.Fatalf("prefixed list status = %d, want 200", status)
}
if !containsNamespace(prefixedBody, namespace) {
t.Fatalf("prefixed list missing namespace %q: %v", namespace, prefixedBody)
}
// Simulate what a DuckDB-style client does when /v1/config did not vend a
// prefix: it falls back to the unprefixed listing. It should still be
// able to discover the namespace (e.g. via a warehouse query parameter),
// but today it cannot, which is the user-visible bug.
bareWarehouse := "s3://" + bucketName + "/"
status, bareBody, err := doIcebergJSONRequest(env, http.MethodGet,
fmt.Sprintf("/v1/namespaces?warehouse=%s", url.QueryEscape(bareWarehouse)), nil)
if err != nil {
t.Fatalf("bare list namespaces: %v", err)
}
switch {
case status != http.StatusOK:
t.Fatalf("GET /v1/namespaces?warehouse=%s status = %d, want 200 (server ignores the warehouse query parameter and falls back to the default %q bucket, causing DuckDB to see no schemas)",
bareWarehouse, status, "warehouse")
case !containsNamespace(bareBody, namespace):
t.Fatalf("GET /v1/namespaces?warehouse=%s did not return %q: %v (server must honor the warehouse query parameter here or vend overrides.prefix from /v1/config so the client can route to the right bucket)",
bareWarehouse, namespace, bareBody)
}
}
// TestIssue9103_DuckDBAttachCannotResolveNamespace is the end-to-end
// reproduction of the issue using DuckDB in Docker, mirroring the exact
// sequence of commands from the bug report. It runs under its own weed
// mini with IAM configured so the OAuth2 client_credentials flow that
// DuckDB's iceberg extension requires actually works (the shared env has
// no credentials registered). Gated on Docker availability.
func TestIssue9103_DuckDBAttachCannotResolveNamespace(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
if !testutil.HasDocker() {
t.Skip("Docker not available, skipping DuckDB integration reproduction")
}
env := newOAuthTestEnv(t)
defer env.cleanup(t)
env.start(t)
bucketName := "test9103-" + randomSuffix()
createTableBucketViaShell(t, env, bucketName)
namespace := "ovirt"
tableName := "disk"
token := requestOAuthToken(t, env, env.accessKey, env.secretKey)
createNamespaceWithToken(t, env, token, bucketName, namespace)
createTableWithToken(t, env, token, bucketName, namespace, tableName)
sql := fmt.Sprintf(`
INSTALL iceberg;
LOAD iceberg;
CREATE SECRET test_berg (
TYPE ICEBERG,
ENDPOINT 'http://host.docker.internal:%d',
SCOPE 's3://%s/',
CLIENT_ID '%s',
CLIENT_SECRET '%s'
);
CREATE SECRET s3_berg (
TYPE S3,
KEY_ID '%s',
SECRET '%s',
ENDPOINT 'host.docker.internal:%d',
URL_STYLE 'path',
USE_SSL false,
SCOPE 's3://%s/'
);
ATTACH 's3://%s/' AS test_catalog (TYPE 'ICEBERG', secret test_berg);
SELECT * FROM test_catalog.%s.%s LIMIT 0;
`, env.icebergPort, bucketName,
env.accessKey, env.secretKey,
env.accessKey, env.secretKey, env.s3Port, bucketName,
bucketName, namespace, tableName)
sqlFile := filepath.Join(env.dataDir, "issue_9103.sql")
if err := os.WriteFile(sqlFile, []byte(sql), 0644); err != nil {
t.Fatalf("write SQL: %v", err)
}
cmd := exec.Command("docker", "run", "--rm",
"-v", fmt.Sprintf("%s:/test", env.dataDir),
"--add-host", "host.docker.internal:host-gateway",
"-e", "AWS_REGION=us-east-1",
"--entrypoint", "duckdb",
"duckdb/duckdb:latest",
"-init", "/test/issue_9103.sql",
"-c", "SELECT 1",
)
out, runErr := cmd.CombinedOutput()
outStr := string(out)
t.Logf("DuckDB output:\n%s", outStr)
if strings.Contains(outStr, "iceberg extension is not available") ||
strings.Contains(outStr, "Failed to load") ||
strings.Contains(outStr, "could not fetch extension") ||
strings.Contains(outStr, "failed to download") {
t.Skip("Iceberg extension not available in DuckDB Docker image")
}
if runErr != nil {
if strings.Contains(outStr, "does not exist because schema") &&
strings.Contains(outStr, namespace) {
t.Fatalf("reproduced issue #9103: DuckDB cannot see namespace %q after ATTACH 's3://%s/'; see output above",
namespace, bucketName)
}
t.Fatalf("DuckDB run failed for an unexpected reason: %v", runErr)
}
}
// createTableWithToken creates an Iceberg table using an OAuth bearer token.
func createTableWithToken(t *testing.T, env *oauthTestEnv, token, bucketName, namespace, tableName string) {
t.Helper()
payload := map[string]any{
"name": tableName,
"schema": map[string]any{
"type": "struct",
"schema-id": 0,
"fields": []map[string]any{
{"id": 1, "name": "id", "required": true, "type": "long"},
},
},
}
body, err := json.Marshal(payload)
if err != nil {
t.Fatalf("marshal create-table payload: %v", err)
}
url := fmt.Sprintf("%s/v1/%s/namespaces/%s/tables", env.icebergURL(), bucketName, namespace)
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body))
if err != nil {
t.Fatalf("create request: %v", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("create table: %v", err)
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
t.Fatalf("create table failed: status=%d body=%s", resp.StatusCode, respBody)
}
t.Logf("Created table %s.%s in bucket %s", namespace, tableName, bucketName)
}
// containsNamespace checks whether an Iceberg REST ListNamespaces response
// contains the given single-level namespace.
func containsNamespace(body map[string]any, name string) bool {
arr, _ := body["namespaces"].([]any)
for _, item := range arr {
parts, _ := item.([]any)
if len(parts) == 1 {
if s, ok := parts[0].(string); ok && s == name {
return true
}
}
}
return false
}

View File

@@ -0,0 +1,63 @@
package catalog
import (
"fmt"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
"os"
"os/exec"
"path/filepath"
"testing"
)
// TestLanceNamespaceRealClient drives the namespace with the Lance client rather
// than hand-built HTTP. The Go tests beside this one cover the catalog surface;
// what this adds is that the location and storage_options the namespace vends
// are actually enough to write and read a dataset, which needs the S3 layout
// guard, the endpoint and the credentials all to be right at once.
//
// To run manually:
//
// cd test/s3tables/catalog
// docker build -t lance-namespace-test -f Dockerfile.lance .
func TestLanceNamespaceRealClient(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
env := sharedEnv
if !env.dockerAvailable {
t.Skip("Docker not available, skipping Lance client integration test")
}
bucketName := "lance-client-test-" + randomSuffix()
createTableBucket(t, env, bucketName, s3tables.FormatLance)
testDir := filepath.Join(env.seaweedDir, "test", "s3tables", "catalog")
buildCmd := exec.Command("docker", "build", "-t", "lance-namespace-test", "-f", "Dockerfile.lance", ".")
buildCmd.Dir = testDir
if out, err := buildCmd.CombinedOutput(); err != nil {
t.Fatalf("Failed to build test image: %v\n%s", err, string(out))
}
namespaceURL := fmt.Sprintf("http://host.docker.internal:%d", env.lancePort)
s3Endpoint := fmt.Sprintf("http://host.docker.internal:%d", env.s3Port)
cmd := exec.Command("docker", "run", "--rm",
"--add-host", "host.docker.internal:host-gateway",
"-v", fmt.Sprintf("%s:/app:ro", testDir),
"lance-namespace-test",
"python3", "/app/test_lance_namespace.py",
"--namespace-url", namespaceURL,
"--s3-endpoint", s3Endpoint,
"--bucket", bucketName,
)
cmd.Dir = testDir
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
t.Logf("Running Lance client test against %s", namespaceURL)
if err := cmd.Run(); err != nil {
t.Errorf("Lance client test failed: %v", err)
}
}

View File

@@ -0,0 +1,378 @@
// Integration tests for the Lance Namespace REST server, driven against a live
// gateway rather than an in-memory filer. The bugs this surface has produced -
// a deregister that deleted the dataset, an S3 door that refused every Lance
// file - all looked fine against a fake.
package catalog
import (
"bytes"
"encoding/json"
"fmt"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
"io"
"net/http"
"strings"
"sync"
"testing"
)
// lanceCall posts to the Lance namespace and returns the status and body.
func lanceCall(t *testing.T, env *TestEnvironment, method, path, body string) (int, []byte) {
t.Helper()
req, err := http.NewRequest(method, env.LanceURL()+path, strings.NewReader(body))
if err != nil {
t.Fatalf("build request: %v", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("%s %s: %v", method, path, err)
}
defer resp.Body.Close()
payload, _ := io.ReadAll(resp.Body)
return resp.StatusCode, payload
}
func lanceMust(t *testing.T, env *TestEnvironment, method, path, body string, want int) []byte {
t.Helper()
status, payload := lanceCall(t, env, method, path, body)
if status != want {
t.Fatalf("%s %s = %d (%s), want %d", method, path, status, payload, want)
}
return payload
}
// filerEntryExists reports whether a path exists on storage, which is how these
// tests tell "the catalog forgot the table" from "the data is gone".
func filerEntryExists(t *testing.T, env *TestEnvironment, path string) bool {
t.Helper()
url := fmt.Sprintf("http://127.0.0.1:%d%s", env.filerPort, path)
resp, err := http.Get(url)
if err != nil {
t.Fatalf("filer GET %s: %v", path, err)
}
defer resp.Body.Close()
io.Copy(io.Discard, resp.Body)
return resp.StatusCode == http.StatusOK
}
// lanceTestBucket makes a uniquely named table bucket of the given format. Most
// tests want LANCE; the one that checks what the Lance surface hides needs an
// Iceberg bucket, because a declared bucket holds one format only.
func lanceTestBucket(t *testing.T, env *TestEnvironment, prefix, format string) string {
t.Helper()
bucket := prefix + "-" + randomSuffix()
createTableBucket(t, env, bucket, format)
return bucket
}
func TestLanceNamespaceLifecycle(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
env := sharedEnv
bucket := lanceTestBucket(t, env, "lance-ns", s3tables.FormatLance)
lanceMust(t, env, http.MethodPost, "/v1/namespace/"+bucket+"$sales/create", `{}`, http.StatusOK)
lanceMust(t, env, http.MethodPost, "/v1/namespace/"+bucket+"$sales/exists", `{}`, http.StatusOK)
// The root lists table buckets, which is the first namespace level here.
var roots struct {
Namespaces []string `json:"namespaces"`
}
if err := json.Unmarshal(lanceMust(t, env, http.MethodGet, "/v1/namespace/$/list", "", http.StatusOK), &roots); err != nil {
t.Fatalf("decode root listing: %v", err)
}
found := false
for _, name := range roots.Namespaces {
if name == bucket {
found = true
}
}
if !found {
t.Fatalf("root listing %v does not include %s", roots.Namespaces, bucket)
}
var children struct {
Namespaces []string `json:"namespaces"`
}
if err := json.Unmarshal(lanceMust(t, env, http.MethodGet, "/v1/namespace/"+bucket+"/list", "", http.StatusOK), &children); err != nil {
t.Fatalf("decode namespace listing: %v", err)
}
if len(children.Namespaces) != 1 || children.Namespaces[0] != "sales" {
t.Fatalf("namespace listing = %v, want [sales]", children.Namespaces)
}
// A namespace is never created as a side effect of naming one inside a
// bucket that does not exist.
status, _ := lanceCall(t, env, http.MethodPost, "/v1/namespace/nosuchbucket$ns/create", `{}`)
if status != http.StatusNotFound {
t.Fatalf("create under a missing bucket = %d, want 404", status)
}
}
func TestLanceTableLifecyclePreservesData(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
env := sharedEnv
bucket := lanceTestBucket(t, env, "lance-tbl", s3tables.FormatLance)
lanceMust(t, env, http.MethodPost, "/v1/namespace/"+bucket+"$ml/create", `{}`, http.StatusOK)
table := "/v1/table/" + bucket + "$ml$vectors"
var declared struct {
Location string `json:"location"`
StorageOptions map[string]string `json:"storage_options"`
}
if err := json.Unmarshal(lanceMust(t, env, http.MethodPost, table+"/declare", `{}`, http.StatusOK), &declared); err != nil {
t.Fatalf("decode declare: %v", err)
}
want := fmt.Sprintf("s3://%s/ml/vectors", bucket)
if declared.Location != want {
t.Fatalf("declared location = %q, want %q", declared.Location, want)
}
datasetPath := fmt.Sprintf("/buckets/%s/ml/vectors/", bucket)
if !filerEntryExists(t, env, datasetPath) {
t.Fatal("declare did not create the dataset directory")
}
var listed struct {
Tables []string `json:"tables"`
}
if err := json.Unmarshal(lanceMust(t, env, http.MethodGet, "/v1/namespace/"+bucket+"$ml/table/list", "", http.StatusOK), &listed); err != nil {
t.Fatalf("decode listing: %v", err)
}
if len(listed.Tables) != 1 || listed.Tables[0] != bucket+"$ml$vectors" {
t.Fatalf("table listing = %v, want the full identifier", listed.Tables)
}
// Deregistering forgets the table but keeps every byte. The catalog entry is
// the dataset directory, so a drop of the entry would take the data too.
lanceMust(t, env, http.MethodPost, table+"/deregister", `{}`, http.StatusOK)
lanceMust(t, env, http.MethodPost, table+"/exists", `{}`, http.StatusNotFound)
if !filerEntryExists(t, env, datasetPath) {
t.Fatal("deregister deleted the dataset")
}
registerBody := fmt.Sprintf(`{"location":%q}`, want)
lanceMust(t, env, http.MethodPost, table+"/register", registerBody, http.StatusOK)
lanceMust(t, env, http.MethodPost, table+"/exists", `{}`, http.StatusOK)
// Dropping is the operation that does remove the data.
lanceMust(t, env, http.MethodPost, table+"/drop", `{}`, http.StatusOK)
if filerEntryExists(t, env, datasetPath) {
t.Fatal("drop left the dataset behind")
}
}
// A Lance client must never resolve an Iceberg table's location, or it writes a
// dataset over a table another engine owns.
func TestLanceHidesIcebergTables(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
env := sharedEnv
// An Iceberg bucket, because a bucket that declares LANCE cannot hold an
// Iceberg table at all now. The invariant still matters from this side: the
// Lance surface can be pointed at any bucket, and must not describe or list
// a table whose format it does not serve.
bucket := lanceTestBucket(t, env, "lance-mixed", s3tables.FormatIceberg)
lanceMust(t, env, http.MethodPost, "/v1/namespace/"+bucket+"$mixed/create", `{}`, http.StatusOK)
createIcebergTable(t, env, bucket, "mixed", "ledger")
status, _ := lanceCall(t, env, http.MethodPost, "/v1/table/"+bucket+"$mixed$ledger/describe", `{}`)
if status != http.StatusNotFound {
t.Fatalf("describing an iceberg table through lance = %d, want 404", status)
}
status, _ = lanceCall(t, env, http.MethodPost, "/v1/table/"+bucket+"$mixed$ledger/declare", `{}`)
if status != http.StatusConflict {
t.Fatalf("declaring over an iceberg table = %d, want 409", status)
}
// And a new Lance table cannot be smuggled in beside it either.
status, _ = lanceCall(t, env, http.MethodPost, "/v1/table/"+bucket+"$mixed$vectors/declare", `{}`)
if status != http.StatusConflict {
t.Fatalf("declaring a lance table in an iceberg bucket = %d, want 409", status)
}
var listed struct {
Tables []string `json:"tables"`
}
if err := json.Unmarshal(lanceMust(t, env, http.MethodGet, "/v1/namespace/"+bucket+"$mixed/table/list", "", http.StatusOK), &listed); err != nil {
t.Fatalf("decode listing: %v", err)
}
if len(listed.Tables) != 0 {
t.Fatalf("lance listing shows iceberg tables: %v", listed.Tables)
}
}
// The S3 door validates every object written into a table bucket. A Lance
// dataset's files have to get through it, or the catalog is decorative.
func TestLanceFilesAreAcceptedByTheS3Door(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
env := sharedEnv
bucket := lanceTestBucket(t, env, "lance-layout", s3tables.FormatLance)
lanceMust(t, env, http.MethodPost, "/v1/namespace/"+bucket+"$ml/create", `{}`, http.StatusOK)
lanceMust(t, env, http.MethodPost, "/v1/table/"+bucket+"$ml$vectors/declare", `{}`, http.StatusOK)
accepted := []string{
"ml/vectors/data/0111111010111000110101116.lance",
"ml/vectors/_versions/18446744073709551614.manifest",
"ml/vectors/_versions/18446744073709551614.manifest-a3a292ad",
"ml/vectors/_transactions/0-ddb27ab7.txn",
"ml/vectors/_indices/85814508-fts/index.idx",
"ml/vectors/.lance-reserved",
}
for _, object := range accepted {
if status := putS3Object(t, env, bucket, object); status != http.StatusOK {
t.Errorf("PUT %s = %d, want 200", object, status)
}
}
// The guard still rejects files that belong to no table layout.
for _, object := range []string{"ml/vectors/random.txt", "ml/vectors/notadir/x.lance"} {
if status := putS3Object(t, env, bucket, object); status == http.StatusOK {
t.Errorf("PUT %s = 200, want a rejection", object)
}
}
}
// A Lance commit is a conditional PUT of the next manifest: object_store turns
// PutMode::Create into If-None-Match: *, and lance treats the refusal as a
// commit conflict and rebases. So the whole safety of concurrent writers rests
// on this store answering that precondition atomically. It does, because the
// gateway reduces the header to a filer WriteCondition evaluated at the
// object's owner under a per-path lock.
//
// This is the reason the namespace does not manage versions itself.
func TestLanceCommitPreconditionAdmitsOneWriter(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
env := sharedEnv
bucket := lanceTestBucket(t, env, "lance-commit", s3tables.FormatLance)
lanceMust(t, env, http.MethodPost, "/v1/namespace/"+bucket+"$ml/create", `{}`, http.StatusOK)
lanceMust(t, env, http.MethodPost, "/v1/table/"+bucket+"$ml$vectors/declare", `{}`, http.StatusOK)
const writers = 8
var wg sync.WaitGroup
statuses := make([]int, writers)
failures := make([]error, writers)
for i := 0; i < writers; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
// Not t.Fatalf: from a goroutine it ends only that goroutine, and
// the status stays zero, which reads as a bogus response later.
statuses[i], failures[i] = putIfAbsent(env, bucket, "ml/vectors/_versions/1.manifest")
}(i)
}
wg.Wait()
for i, err := range failures {
if err != nil {
t.Fatalf("writer %d could not reach the gateway: %v", i, err)
}
}
won, refused := 0, 0
for _, status := range statuses {
switch status {
case http.StatusOK:
won++
case http.StatusPreconditionFailed:
refused++
default:
t.Fatalf("unexpected status %d committing a manifest", status)
}
}
if won != 1 {
t.Fatalf("%d writers committed version 1; exactly one may win, %d were refused", won, refused)
}
}
// The namespace answers managed_versioning=false: the dataset owns its version
// history, and a reader that never goes through this catalog still sees all of
// it.
func TestLanceDoesNotManageVersions(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
env := sharedEnv
bucket := lanceTestBucket(t, env, "lance-ver", s3tables.FormatLance)
lanceMust(t, env, http.MethodPost, "/v1/namespace/"+bucket+"$ml/create", `{}`, http.StatusOK)
table := "/v1/table/" + bucket + "$ml$vectors"
var declared struct {
ManagedVersioning bool `json:"managed_versioning"`
}
if err := json.Unmarshal(lanceMust(t, env, http.MethodPost, table+"/declare", `{}`, http.StatusOK), &declared); err != nil {
t.Fatalf("decode declare: %v", err)
}
if declared.ManagedVersioning {
t.Fatal("managed_versioning must be false; the dataset owns its versions")
}
// The version ops answer with the spec's Unsupported code rather than a
// bare 404, so a client that asks learns why.
status, _ := lanceCall(t, env, http.MethodPost, table+"/version/list", `{}`)
if status != http.StatusNotImplemented {
t.Fatalf("version/list = %d, want 501", status)
}
}
// putIfAbsent writes an object only if the key is free, the way a Lance commit
// does. It returns an error rather than failing the test, so it is safe to call
// from the racing goroutines.
func putIfAbsent(env *TestEnvironment, bucket, object string) (int, error) {
url := fmt.Sprintf("http://127.0.0.1:%d/%s/%s", env.s3Port, bucket, object)
req, err := http.NewRequest(http.MethodPut, url, bytes.NewReader([]byte("manifest")))
if err != nil {
return 0, err
}
req.Header.Set("If-None-Match", "*")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return 0, err
}
defer resp.Body.Close()
io.Copy(io.Discard, resp.Body)
return resp.StatusCode, nil
}
// putS3Object writes an object through the S3 gateway and returns the status.
func putS3Object(t *testing.T, env *TestEnvironment, bucket, object string) int {
t.Helper()
url := fmt.Sprintf("http://127.0.0.1:%d/%s/%s", env.s3Port, bucket, object)
req, err := http.NewRequest(http.MethodPut, url, bytes.NewReader([]byte("x")))
if err != nil {
t.Fatalf("build request: %v", err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("PUT %s: %v", object, err)
}
defer resp.Body.Close()
io.Copy(io.Discard, resp.Body)
return resp.StatusCode
}
// createIcebergTable registers an ordinary Iceberg table so the mixed-catalog
// tests have one to be confused by.
func createIcebergTable(t *testing.T, env *TestEnvironment, bucket, namespace, name string) {
t.Helper()
body := fmt.Sprintf(`{"name":%q,"schema":{"type":"struct","schema-id":0,"fields":[{"id":1,"name":"id","required":false,"type":"long"}]}}`, name)
url := fmt.Sprintf("%s/v1/%s/namespaces/%s/tables", env.IcebergURL(), bucket, namespace)
resp, err := http.Post(url, "application/json", strings.NewReader(body))
if err != nil {
t.Fatalf("create iceberg table: %v", err)
}
defer resp.Body.Close()
payload, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
t.Fatalf("create iceberg table = %d: %s", resp.StatusCode, payload)
}
}

View File

@@ -0,0 +1,137 @@
// Package catalog provides integration tests for the Iceberg REST Catalog API.
// This file adds PyIceberg-based compatibility tests using Docker.
package catalog
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"testing"
)
// TestPyIcebergRestCatalog tests the Iceberg REST Catalog using PyIceberg client in Docker.
// This provides a more comprehensive test than DuckDB as PyIceberg fully exercises the REST API.
//
// Prerequisites:
// - Docker must be available
// - SeaweedFS must be running with Iceberg REST enabled
//
// To run manually:
//
// cd test/s3tables/catalog
// docker compose -f docker-compose.test.yaml up --build
func TestPyIcebergRestCatalog(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
env := sharedEnv
if !env.dockerAvailable {
t.Skip("Docker not available, skipping PyIceberg integration test")
}
// Create the test bucket first
bucketName := "pyiceberg-compat-test-" + randomSuffix()
createTableBucket(t, env, bucketName, "")
// Build the test working directory path
testDir := filepath.Join(env.seaweedDir, "test", "s3tables", "catalog")
// Run PyIceberg test using Docker
catalogURL := fmt.Sprintf("http://host.docker.internal:%d", env.icebergPort)
s3Endpoint := fmt.Sprintf("http://host.docker.internal:%d", env.s3Port)
warehouse := fmt.Sprintf("s3://%s/", bucketName)
// Build the test image first for faster repeated runs
buildCmd := exec.Command("docker", "build", "-t", "iceberg-rest-test", "-f", "Dockerfile.pyiceberg", ".")
buildCmd.Dir = testDir
if out, err := buildCmd.CombinedOutput(); err != nil {
t.Fatalf("Failed to build test image: %v\n%s", err, string(out))
}
cmd := exec.Command("docker", "run", "--rm",
"--add-host", "host.docker.internal:host-gateway",
"-e", fmt.Sprintf("AWS_ENDPOINT_URL=%s", s3Endpoint),
"-v", fmt.Sprintf("%s:/app:ro", testDir),
"iceberg-rest-test",
"python3", "/app/test_rest_catalog.py",
"--catalog-url", catalogURL,
"--warehouse", warehouse,
"--prefix", bucketName,
)
cmd.Dir = testDir
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
t.Logf("Running PyIceberg REST catalog test...")
t.Logf(" Catalog URL: %s", catalogURL)
t.Logf(" Warehouse: %s", warehouse)
if err := cmd.Run(); err != nil {
t.Errorf("PyIceberg test failed: %v", err)
}
}
// TestPyIcebergRestCatalogAuthenticated tests the Iceberg REST Catalog using PyIceberg with authentication.
// This test uses the default admin credentials that SeaweedFS creates on startup.
func TestPyIcebergRestCatalogAuthenticated(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
env := sharedEnv
if !env.dockerAvailable {
t.Skip("Docker not available, skipping PyIceberg integration test")
}
// Use default admin credentials
testAccessKey := "admin"
testSecretKey := "admin"
// Create the test bucket first (using unauthenticated request, which works with DefaultAllow)
bucketName := "pyiceberg-auth-test-" + randomSuffix()
createTableBucket(t, env, bucketName, "")
// Build the test working directory path
testDir := filepath.Join(env.seaweedDir, "test", "s3tables", "catalog")
// Run PyIceberg test using Docker with authentication
catalogURL := fmt.Sprintf("http://host.docker.internal:%d", env.icebergPort)
s3Endpoint := fmt.Sprintf("http://host.docker.internal:%d", env.s3Port)
warehouse := fmt.Sprintf("s3://%s/", bucketName)
// Build the test image first for faster repeated runs
buildCmd := exec.Command("docker", "build", "-t", "iceberg-rest-test", "-f", "Dockerfile.pyiceberg", ".")
buildCmd.Dir = testDir
if out, err := buildCmd.CombinedOutput(); err != nil {
t.Fatalf("Failed to build test image: %v\n%s", err, string(out))
}
cmd := exec.Command("docker", "run", "--rm",
"--add-host", "host.docker.internal:host-gateway",
"-e", fmt.Sprintf("AWS_ENDPOINT_URL=%s", s3Endpoint),
"-v", fmt.Sprintf("%s:/app:ro", testDir),
"iceberg-rest-test",
"python3", "/app/test_rest_catalog_auth.py",
"--catalog-url", catalogURL,
"--warehouse", warehouse,
"--prefix", bucketName,
"--access-key", testAccessKey,
"--secret-key", testSecretKey,
)
cmd.Dir = testDir
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
t.Logf("Running PyIceberg REST catalog test with authentication...")
t.Logf(" Catalog URL: %s", catalogURL)
t.Logf(" Warehouse: %s", warehouse)
t.Logf(" Access Key: %s", testAccessKey)
if err := cmd.Run(); err != nil {
t.Errorf("PyIceberg authenticated test failed: %v", err)
}
}

View File

@@ -0,0 +1,36 @@
package catalog
import (
"fmt"
"io"
"net/http"
"testing"
)
// verifyTableBucketMetadata verifies that a table bucket was created with proper metadata
func verifyTableBucketMetadata(t *testing.T, env *TestEnvironment, bucketName string) {
t.Helper()
// Use S3Tables REST API to get the bucket
endpoint := fmt.Sprintf("http://localhost:%d/buckets/%s", env.s3Port, bucketName)
req, err := http.NewRequest(http.MethodGet, endpoint, nil)
if err != nil {
t.Fatalf("Failed to create request: %v", err)
}
req.Header.Set("Content-Type", "application/x-amz-json-1.1")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("Failed to get table bucket %s: %v", bucketName, err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
t.Logf("Get table bucket %s response: status=%d, body=%s", bucketName, resp.StatusCode, string(body))
if resp.StatusCode != http.StatusOK {
t.Fatalf("Failed to get table bucket %s, status %d: %s", bucketName, resp.StatusCode, body)
}
t.Logf("Verified table bucket %s exists with metadata", bucketName)
}

View File

@@ -0,0 +1,106 @@
#!/usr/bin/env python3
"""Drive the SeaweedFS Lance Namespace with the real Lance client.
The catalog half can be checked with plain HTTP, and the Go integration tests
do. What only a real client proves is that the location and storage_options it
hands back are enough to write and read a dataset: the layout guard on the S3
door, the endpoint and allow_http options, and the credentials all have to be
right at once, and a hand-built request checks none of that.
"""
import argparse
import sys
import warnings
warnings.filterwarnings("ignore")
import lance
import lance_namespace as ln
import pyarrow as pa
def sample_table():
return pa.table(
{
"id": pa.array([1, 2, 3, 4]),
"vec": pa.array([[1.0, 2.0]] * 4, type=pa.list_(pa.float32(), 2)),
}
)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--namespace-url", required=True, help="Lance namespace REST URL")
parser.add_argument("--s3-endpoint", required=True, help="S3 endpoint the dataset lives behind")
parser.add_argument("--bucket", required=True, help="table bucket to use, already created")
parser.add_argument("--access-key", default="any")
parser.add_argument("--secret-key", default="any")
args = parser.parse_args()
ns = ln.connect("rest", {"uri": args.namespace_url})
ns.create_namespace(ln.CreateNamespaceRequest(id=[args.bucket, "ml"]))
table_id = [args.bucket, "ml", "vectors"]
declared = ns.declare_table(ln.DeclareTableRequest(id=table_id))
print(f"declared {table_id} at {declared.location}")
if not declared.location:
print("FAIL: declare returned no location", file=sys.stderr)
return 1
described = ns.describe_table(ln.DescribeTableRequest(id=table_id))
if described.location != declared.location:
print(
f"FAIL: describe location {described.location} != declare {declared.location}",
file=sys.stderr,
)
return 1
options = dict(described.storage_options or {})
print(f"storage options from the namespace: {sorted(options)}")
# The endpoint is overridden with the container's view of the same gateway:
# what the namespace vends is correct for its own host, and a test harness
# bound to a wildcard address vends nothing at all. That the namespace vends
# a usable endpoint when it can is covered by the unit tests; what matters
# here is that everything else it hands back is enough to reach the data.
options["aws_endpoint"] = args.s3_endpoint
options["allow_http"] = "true"
# A deployment without STS still needs credentials to sign with.
options.setdefault("aws_access_key_id", args.access_key)
options.setdefault("aws_secret_access_key", args.secret_key)
lance.write_dataset(sample_table(), described.location, storage_options=options,
mode="overwrite")
dataset = lance.dataset(described.location, storage_options=options)
rows = dataset.count_rows()
print(f"wrote and read back {rows} rows at version {dataset.version}")
if rows != 4:
print(f"FAIL: read back {rows} rows, want 4", file=sys.stderr)
return 1
# The table is listed, and a client that skips the namespace entirely can
# still open the dataset by URI.
listed = ns.list_tables(ln.ListTablesRequest(id=[args.bucket, "ml"])).tables
if f"{args.bucket}$ml$vectors" not in listed:
print(f"FAIL: {listed} does not contain the table", file=sys.stderr)
return 1
if lance.dataset(declared.location, storage_options=options).count_rows() != 4:
print("FAIL: the dataset is not readable straight off its URI", file=sys.stderr)
return 1
# Deregistering hides the table and keeps the data, which is the difference
# between it and a drop.
ns.deregister_table(ln.DeregisterTableRequest(id=table_id))
if f"{args.bucket}$ml$vectors" in ns.list_tables(ln.ListTablesRequest(id=[args.bucket, "ml"])).tables:
print("FAIL: a deregistered table is still listed", file=sys.stderr)
return 1
if lance.dataset(declared.location, storage_options=options).count_rows() != 4:
print("FAIL: deregister destroyed the dataset", file=sys.stderr)
return 1
print("PASS")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,252 @@
#!/usr/bin/env python3
"""
Iceberg REST Catalog Compatibility Test for SeaweedFS
This script tests the Iceberg REST Catalog API compatibility of the
SeaweedFS Iceberg REST Catalog implementation.
Usage:
python3 test_rest_catalog.py --catalog-url http://localhost:8182
Requirements:
pip install pyiceberg[s3fs]
"""
import argparse
import sys
from pyiceberg.catalog import load_catalog
from pyiceberg.schema import Schema
from pyiceberg.types import (
IntegerType,
LongType,
StringType,
NestedField,
)
from pyiceberg.exceptions import (
NamespaceAlreadyExistsError,
NoSuchNamespaceError,
TableAlreadyExistsError,
NoSuchTableError,
)
def test_config_endpoint(catalog):
"""Test that the catalog config endpoint returns valid configuration."""
print("Testing /v1/config endpoint...")
# The catalog is already loaded which means config endpoint worked
print(" /v1/config endpoint working")
return True
def test_namespace_operations(catalog, prefix):
"""Test namespace CRUD operations."""
print("Testing namespace operations...")
namespace = (f"{prefix.replace('-', '_')}_test_ns",)
# List initial namespaces
namespaces = catalog.list_namespaces()
print(f" Initial namespaces: {namespaces}")
# Create namespace
try:
catalog.create_namespace(namespace)
print(f" Created namespace: {namespace}")
except NamespaceAlreadyExistsError:
print(f" ! Namespace already exists: {namespace}")
# List namespaces (should include our new one)
namespaces = catalog.list_namespaces()
if namespace in namespaces:
print(" Namespace appears in list")
else:
print(f" Namespace not found in list: {namespaces}")
return False
# Get namespace properties
try:
props = catalog.load_namespace_properties(namespace)
print(f" Loaded namespace properties: {props}")
except NoSuchNamespaceError:
print(f" Failed to load namespace properties")
return False
return True
def test_table_operations(catalog, prefix):
"""Test table CRUD operations."""
print("Testing table operations...")
namespace = (f"{prefix.replace('-', '_')}_test_ns",)
table_name = "test_table"
table_id = namespace + (table_name,)
# Define a simple schema
schema = Schema(
NestedField(field_id=1, name="id", field_type=LongType(), required=True),
NestedField(field_id=2, name="name", field_type=StringType(), required=False),
NestedField(field_id=3, name="age", field_type=IntegerType(), required=False),
)
# Create table
try:
table = catalog.create_table(
identifier=table_id,
schema=schema,
)
print(f" Created table: {table_id}")
except TableAlreadyExistsError:
print(f" ! Table already exists: {table_id}")
_ = catalog.load_table(table_id)
# List tables
tables = catalog.list_tables(namespace)
if table_name in [t[1] for t in tables]:
print(" Table appears in list")
else:
print(f" Table not found in list: {tables}")
return False
# Load table
try:
loaded_table = catalog.load_table(table_id)
print(f" Loaded table: {loaded_table.name()}")
print(f" Schema: {loaded_table.schema()}")
print(f" Location: {loaded_table.location()}")
except NoSuchTableError:
print(f" Failed to load table")
return False
return True
def test_table_update(catalog, prefix):
"""Test table update/commit operations."""
print("Testing table update operations...")
namespace = (f"{prefix.replace('-', '_')}_test_ns",)
table_name = "test_table"
table_id = namespace + (table_name,)
try:
table = catalog.load_table(table_id)
# Update table properties
with table.transaction() as transaction:
transaction.set_properties({"test.property": "test.value"})
print(" Updated table properties")
# Reload and verify
table = catalog.load_table(table_id)
if table.properties.get("test.property") == "test.value":
print(" Property update verified")
else:
print(" ! Property update failed or not persisted")
return False
except Exception as e:
print(f" Table update failed: {e}")
return False
return True
def test_cleanup(catalog, prefix):
"""Test table and namespace deletion."""
print("Testing cleanup operations...")
namespace = (f"{prefix.replace('-', '_')}_test_ns",)
table_id = namespace + ("test_table",)
# Drop table
try:
catalog.drop_table(table_id)
print(f" Dropped table: {table_id}")
except NoSuchTableError:
print(f" ! Table already deleted: {table_id}")
# Drop namespace
try:
catalog.drop_namespace(namespace)
print(f" Dropped namespace: {namespace}")
except NoSuchNamespaceError:
print(f" ! Namespace already deleted: {namespace}")
except Exception as e:
print(f" ? Namespace drop error (may be expected): {e}")
return True
def main():
parser = argparse.ArgumentParser(description="Test Iceberg REST Catalog compatibility")
parser.add_argument("--catalog-url", required=True, help="Iceberg REST Catalog URL (e.g., http://localhost:8182)")
parser.add_argument("--warehouse", default="s3://iceberg-test/", help="Warehouse location")
parser.add_argument("--prefix", required=True, help="Table bucket prefix")
parser.add_argument("--skip-cleanup", action="store_true", help="Skip cleanup at the end")
args = parser.parse_args()
print(f"Connecting to Iceberg REST Catalog at: {args.catalog_url}")
print(f"Warehouse: {args.warehouse}")
print(f"Prefix: {args.prefix}")
print()
# Load the REST catalog with retries to handle possible delay in catalog server readiness
import time
max_retries = 10
catalog = None
for attempt in range(max_retries):
try:
catalog = load_catalog(
"rest",
**{
"type": "rest",
"uri": args.catalog_url,
"warehouse": args.warehouse,
"prefix": args.prefix,
"auth": {"type": "noop"},
"s3.anonymous": "true", # Disable AWS request signing for unauthenticated access
}
)
print(f"Successfully connected to catalog on attempt {attempt + 1}")
break
except Exception as e:
if attempt < max_retries - 1:
print(f" Attempt {attempt + 1} failed, retrying in 2s... ({e})")
time.sleep(2)
else:
print(f" All {max_retries} attempts failed.")
raise e
# Run tests
tests = [
("Config Endpoint", lambda: test_config_endpoint(catalog)),
("Namespace Operations", lambda: test_namespace_operations(catalog, args.prefix)),
("Table Operations", lambda: test_table_operations(catalog, args.prefix)),
("Table Update", lambda: test_table_update(catalog, args.prefix)),
]
if not args.skip_cleanup:
tests.append(("Cleanup", lambda: test_cleanup(catalog, args.prefix)))
passed = 0
failed = 0
for name, test_fn in tests:
print(f"\n{'='*50}")
try:
if test_fn():
passed += 1
print(f"PASSED: {name}")
else:
failed += 1
print(f"FAILED: {name}")
except Exception as e:
failed += 1
print(f"ERROR in {name}: {e}")
print(f"\n{'='*50}")
print(f"Results: {passed} passed, {failed} failed")
return 0 if failed == 0 else 1
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,224 @@
#!/usr/bin/env python3
"""
Iceberg REST Catalog Compatibility Test for SeaweedFS (Authenticated)
This script tests the Iceberg REST Catalog API compatibility with authentication.
Usage:
python3 test_rest_catalog_auth.py --catalog-url http://localhost:8182 \\
--access-key admin --secret-key admin
Requirements:
pip install pyiceberg[s3fs]
"""
import argparse
import sys
from pyiceberg.catalog import load_catalog
from pyiceberg.schema import Schema
from pyiceberg.types import (
IntegerType,
LongType,
StringType,
NestedField,
)
from pyiceberg.exceptions import (
NamespaceAlreadyExistsError,
NoSuchNamespaceError,
TableAlreadyExistsError,
NoSuchTableError,
)
def test_config_endpoint(catalog):
"""Test that the catalog config endpoint returns valid configuration."""
print("Testing /v1/config endpoint...")
# The catalog is already loaded which means config endpoint worked
print(" /v1/config endpoint working")
return True
def test_namespace_operations(catalog, prefix):
"""Test namespace CRUD operations."""
print("Testing namespace operations...")
namespace = (f"{prefix.replace('-', '_')}_auth_test_ns",)
# List initial namespaces
namespaces = catalog.list_namespaces()
print(f" Initial namespaces: {namespaces}")
# Create namespace
try:
catalog.create_namespace(namespace)
print(f" Created namespace: {namespace}")
except NamespaceAlreadyExistsError:
print(f" ! Namespace already exists: {namespace}")
# List namespaces (should include our new one)
namespaces = catalog.list_namespaces()
if namespace in namespaces:
print(" Namespace appears in list")
else:
print(f" Namespace not found in list: {namespaces}")
return False
# Get namespace properties
try:
props = catalog.load_namespace_properties(namespace)
print(f" Loaded namespace properties: {props}")
except NoSuchNamespaceError:
print(f" Failed to load namespace properties")
return False
return True
def test_table_operations(catalog, prefix):
"""Test table CRUD operations."""
print("Testing table operations...")
namespace = (f"{prefix.replace('-', '_')}_auth_test_ns",)
table_name = "auth_test_table"
table_id = namespace + (table_name,)
# Define a simple schema
schema = Schema(
NestedField(field_id=1, name="id", field_type=LongType(), required=True),
NestedField(field_id=2, name="name", field_type=StringType(), required=False),
NestedField(field_id=3, name="age", field_type=IntegerType(), required=False),
)
# Create table
try:
table = catalog.create_table(
identifier=table_id,
schema=schema,
)
print(f" Created table: {table_id}")
except TableAlreadyExistsError:
print(f" ! Table already exists: {table_id}")
_ = catalog.load_table(table_id)
# List tables
tables = catalog.list_tables(namespace)
if table_name in [t[1] for t in tables]:
print(" Table appears in list")
else:
print(f" Table not found in list: {tables}")
return False
# Load table
try:
loaded_table = catalog.load_table(table_id)
print(f" Loaded table: {loaded_table.name()}")
print(f" Schema: {loaded_table.schema()}")
print(f" Location: {loaded_table.location()}")
except NoSuchTableError:
print(f" Failed to load table")
return False
return True
def test_cleanup(catalog, prefix):
"""Test table and namespace deletion."""
print("Testing cleanup operations...")
namespace = (f"{prefix.replace('-', '_')}_auth_test_ns",)
table_id = namespace + ("auth_test_table",)
# Drop table
try:
catalog.drop_table(table_id)
print(f" Dropped table: {table_id}")
except NoSuchTableError:
print(f" ! Table already deleted: {table_id}")
# Drop namespace
try:
catalog.drop_namespace(namespace)
print(f" Dropped namespace: {namespace}")
except NoSuchNamespaceError:
print(f" ! Namespace already deleted: {namespace}")
except Exception as e:
print(f" ? Namespace drop error (may be expected): {e}")
return True
def main():
parser = argparse.ArgumentParser(description="Test Iceberg REST Catalog with authentication")
parser.add_argument("--catalog-url", required=True, help="Iceberg REST Catalog URL")
parser.add_argument("--warehouse", default="s3://iceberg-test/", help="Warehouse location")
parser.add_argument("--prefix", required=True, help="Table bucket prefix")
parser.add_argument("--access-key", required=True, help="AWS Access Key ID")
parser.add_argument("--secret-key", required=True, help="AWS Secret Access Key")
parser.add_argument("--skip-cleanup", action="store_true", help="Skip cleanup at the end")
args = parser.parse_args()
print(f"Connecting to Iceberg REST Catalog at: {args.catalog_url}")
print(f"Warehouse: {args.warehouse}")
print(f"Prefix: {args.prefix}")
print(f"Using authenticated access with key: {args.access_key}")
print()
# Load the REST catalog with authentication
import time
max_retries = 10
catalog = None
for attempt in range(max_retries):
try:
catalog = load_catalog(
"rest",
**{
"type": "rest",
"uri": args.catalog_url,
"warehouse": args.warehouse,
"prefix": args.prefix,
"auth": {"type": "noop"},
"s3.access-key-id": args.access_key,
"s3.secret-access-key": args.secret_key,
}
)
print(f"Successfully connected to catalog on attempt {attempt + 1}")
break
except Exception as e:
if attempt < max_retries - 1:
print(f" Attempt {attempt + 1} failed, retrying in 2s... ({e})")
time.sleep(2)
else:
print(f" All {max_retries} attempts failed.")
raise e
# Run tests
tests = [
("Config Endpoint", lambda: test_config_endpoint(catalog)),
("Namespace Operations", lambda: test_namespace_operations(catalog, args.prefix)),
("Table Operations", lambda: test_table_operations(catalog, args.prefix)),
]
if not args.skip_cleanup:
tests.append(("Cleanup", lambda: test_cleanup(catalog, args.prefix)))
passed = 0
failed = 0
for name, test_fn in tests:
print(f"\n{'='*50}")
try:
if test_fn():
passed += 1
print(f"PASSED: {name}")
else:
failed += 1
print(f"FAILED: {name}")
except Exception as e:
failed += 1
print(f"ERROR in {name}: {e}")
print(f"\n{'='*50}")
print(f"Results: {passed} passed, {failed} failed")
return 0 if failed == 0 else 1
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,12 @@
# Iceberg writer used by the ClickHouse integration test to populate a table
# with data files via PyIceberg, so the downstream ClickHouse SELECT verifies
# the read path against non-empty results rather than just count() = 0.
FROM python:3.11-slim
WORKDIR /app
RUN pip install --no-cache-dir "pyiceberg[s3fs]==0.11.1" "pyarrow==25.0.0"
COPY append_rows.py read_rows.py /app/
ENTRYPOINT ["python3", "/app/append_rows.py"]

View File

@@ -0,0 +1,98 @@
# ClickHouse Iceberg Catalog Integration Test
This directory contains a ClickHouse integration smoke test for SeaweedFS's
Iceberg REST Catalog implementation, using ClickHouse's `DataLakeCatalog`
database engine.
## What It Tests
`TestClickHouseIcebergCatalog` verifies the ClickHouse path end to end:
1. Starts a local SeaweedFS mini cluster with S3 Tables and Iceberg REST enabled.
2. Creates a SeaweedFS table bucket.
3. Creates an Iceberg namespace and an empty table through the SeaweedFS REST
catalog OAuth flow.
4. Creates a second table and populates it with three rows by running a
PyIceberg writer container (`Dockerfile.writer` + `append_rows.py`) before
ClickHouse connects, so the snapshot is part of the catalog's first scan.
5. Starts the ClickHouse server container (`clickhouse/clickhouse-server:25.8`
by default, overridable via `CLICKHOUSE_IMAGE`) and waits for the HTTP
interface.
6. Attaches the catalog with `CREATE DATABASE ... ENGINE = DataLakeCatalog`
(`catalog_type = 'rest'`), authenticating to the catalog via the OAuth2
client-credentials flow (`catalog_credential` + `oauth_server_uri`) and to
S3 via the engine's access/secret key arguments and `storage_endpoint`.
7. Runs subtests against the SeaweedFS-backed Iceberg tables:
- `BasicSelect`: ClickHouse is alive and answering SQL.
- `DatabaseVisible`: the DataLakeCatalog database exists.
- `TableVisible`: seeded tables appear as `namespace.table` entries in
`SHOW TABLES` (ClickHouse flattens Iceberg namespaces into table names).
- `DescribeTable`: the Iceberg schema mapped to `id Int64` and
`label Nullable(String)`. Failure here means ClickHouse could not parse
the schema returned by the SeaweedFS catalog.
- `CountEmptyTable`: catalog-to-table resolution and a scan of an empty table.
- `ReadWrittenDataCount` and `ReadWrittenDataValues`: ClickHouse reads back
the three PyIceberg-appended rows and the values match. This exercises the
actual data path (parquet reads via S3), not just metadata.
- `WriteReadBack`: ClickHouse inserts rows with its experimental Iceberg
write support using default settings, which produces manifests without
avro field-ids, bucket-relative paths, and parquet without field ids. The
SeaweedFS catalog repairs the manifests at commit time and stamps a
default name mapping on the table, so PyIceberg (`read_rows.py`, a strict
reader) must return the rows ClickHouse wrote.
- `CreateTableViaCatalog`: ClickHouse creates a table through the catalog
(`CREATE TABLE ... ENGINE = IcebergS3(...)` with
`write_full_path_in_iceberg_metadata = 1`), the test verifies it is
registered in the REST catalog, inserts rows, reads them back, and has
PyIceberg read them too. ClickHouse only issues the catalog createTable
from 26.4, so the subtest skips on older servers.
Queries go through ClickHouse's HTTP interface (port 8123, mapped to a
dynamically allocated host port), so the test needs no ClickHouse client
driver. Tables are referenced as ``iceberg_catalog.`namespace.table` ``.
## Running Locally
Build or install `weed`, then run:
```bash
cd test/s3tables/catalog_clickhouse
go test -v -timeout 20m .
```
The ClickHouse image defaults to `clickhouse/clickhouse-server:25.8` and can
be overridden:
```bash
CLICKHOUSE_IMAGE=clickhouse/clickhouse-server:latest go test -v -timeout 20m .
```
The test requires Docker. The GitHub Actions job runs on `ubuntu-22.04` and
executes the test for pull requests against both the pinned baseline image
and `latest`, so new ClickHouse releases are covered as they ship.
## Configuration
The test uses these fixed credentials for the local SeaweedFS IAM config:
- S3 access key: `AKIAIOSFODNN7EXAMPLE`
- S3 secret key: `wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY`
- Region: `us-west-2`
- Warehouse bucket: `iceberg-tables`
ClickHouse ports:
- Only `8123` (HTTP interface) is mapped to a host port (allocated
dynamically) so the Go test can issue queries from the test process.
- The Iceberg REST endpoint and the S3 endpoint are reached from inside the
ClickHouse container via `host.docker.internal`, matching the Doris, Trino,
and Dremio test paths.
## Troubleshooting
- Ensure Docker is running: `docker version`
- Ensure `weed` is built or available on `PATH`
- `DataLakeCatalog` requires `allow_experimental_database_iceberg = 1`; the
test passes it as a URL setting on the CREATE DATABASE request.
- Container logs are printed in the failure message; you can also check
`docker logs <seaweed-clickhouse-...>` while the test is running.

View File

@@ -0,0 +1,88 @@
#!/usr/bin/env python3
"""Append rows to an existing Iceberg table via the SeaweedFS REST catalog.
Used by the ClickHouse integration test to materialize data files so a
downstream SELECT from ClickHouse verifies the read path against non-empty
results.
Usage:
python3 append_rows.py \\
--catalog-url http://localhost:8181 \\
--warehouse s3://my-bucket \\
--prefix my-bucket \\
--s3-endpoint http://localhost:8333 \\
--access-key AKIA... --secret-key wJalr... \\
--namespace foo --namespace bar \\
--table events
"""
import argparse
import sys
import pyarrow as pa
from pyiceberg.catalog import load_catalog
def main() -> int:
p = argparse.ArgumentParser()
p.add_argument("--catalog-url", required=True)
p.add_argument("--warehouse", required=True, help="s3://<bucket-name>")
p.add_argument("--prefix", required=True, help="REST catalog prefix (table bucket name)")
p.add_argument("--s3-endpoint", required=True, help="http://host:port")
p.add_argument("--access-key", required=True)
p.add_argument("--secret-key", required=True)
p.add_argument("--region", default="us-east-1")
p.add_argument(
"--namespace",
action="append",
required=True,
help="One per level (e.g. --namespace foo --namespace bar for foo.bar).",
)
p.add_argument("--table", required=True)
args = p.parse_args()
# `credential` triggers OAuth2 client_credentials against
# <catalog_uri>/v1/oauth/tokens, matching the helper Go test uses for
# REST-API table creation. The s3.* keys are needed for parquet writes.
catalog = load_catalog(
"rest",
**{
"type": "rest",
"uri": args.catalog_url,
"warehouse": args.warehouse,
"prefix": args.prefix,
"credential": f"{args.access_key}:{args.secret_key}",
"s3.access-key-id": args.access_key,
"s3.secret-access-key": args.secret_key,
"s3.endpoint": args.s3_endpoint,
"s3.region": args.region,
"s3.path-style-access": "true",
},
)
table_id = tuple(args.namespace) + (args.table,)
table = catalog.load_table(table_id)
# Match the Iceberg table schema: id is `required long`, label is
# `optional string`. Default pyarrow columns are nullable, which fails
# PyIceberg's required-field compatibility check.
arrow_schema = pa.schema(
[
pa.field("id", pa.int64(), nullable=False),
pa.field("label", pa.string(), nullable=True),
]
)
arrow_table = pa.Table.from_pydict(
{
"id": [1, 2, 3],
"label": ["one", "two", "three"],
},
schema=arrow_schema,
)
table.append(arrow_table)
print(f"appended {arrow_table.num_rows} rows to {'.'.join(table_id)}")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,892 @@
// Package catalog_clickhouse provides integration tests for ClickHouse with
// the SeaweedFS Iceberg REST Catalog.
package catalog_clickhouse
import (
"bytes"
"context"
"crypto/rand"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/test/testutil"
)
const (
clickhouseDefaultImage = "clickhouse/clickhouse-server:25.8"
clickhouseDatabase = "iceberg_catalog"
clickhouseHTTPPort = 8123
clickhouseStartTimeout = 2 * time.Minute
// The image's passwordless `default` user only accepts connections from
// localhost, and our queries arrive via the mapped port from the Docker
// gateway. The entrypoint grants a CLICKHOUSE_USER/CLICKHOUSE_PASSWORD
// user access from any host.
clickhouseUser = "seaweed"
clickhousePassword = "seaweedtest"
)
type TestEnvironment struct {
seaweedDir string
weedBinary string
dataDir string
bindIP string
s3Port int
s3GrpcPort int
icebergPort int
masterPort int
masterGrpcPort int
filerPort int
filerGrpcPort int
volumePort int
volumeGrpcPort int
weedProcess *exec.Cmd
weedCancel context.CancelFunc
clickhouseContainer string
clickhouseHostPort int
accessKey string
secretKey string
}
// TestClickHouseIcebergCatalog brings up SeaweedFS + ClickHouse and validates
// that ClickHouse's DataLakeCatalog database engine can discover catalog
// metadata served by SeaweedFS's Iceberg REST API and read both empty and
// populated tables through the standard S3 data path.
//
// Subtests:
// - BasicSelect: ClickHouse is alive and answering SQL.
// - DatabaseVisible: the DataLakeCatalog database exists.
// - TableVisible: seeded tables appear as `namespace.table` entries.
// - DescribeTable: ClickHouse mapped the Iceberg schema (long -> Int64,
// optional string -> Nullable(String)).
// - CountEmptyTable: ClickHouse resolves the table and scans an empty
// Iceberg snapshot.
// - ReadWrittenDataCount / ReadWrittenDataValues: a separate table is
// populated by a PyIceberg writer container before ClickHouse connects;
// ClickHouse then reads the three rows back, exercising the actual data
// path (parquet over S3), not just metadata.
func TestClickHouseIcebergCatalog(t *testing.T) {
requireClickHouseRuntime(t)
env := NewTestEnvironment(t)
defer env.Cleanup(t)
fmt.Printf(">>> Starting SeaweedFS...\n")
env.StartSeaweedFS(t)
fmt.Printf(">>> SeaweedFS started.\n")
tableBucket := "iceberg-tables"
fmt.Printf(">>> Creating table bucket: %s\n", tableBucket)
createTableBucket(t, env, tableBucket)
fmt.Printf(">>> Table bucket created.\n")
testIcebergRestAPI(t, env)
namespace := "clickhouse_" + randomString(6)
tableName := "smoke_" + randomString(6)
icebergToken := requestIcebergOAuthToken(t, env)
createIcebergNamespace(t, env, icebergToken, tableBucket, namespace)
createIcebergTable(t, env, icebergToken, tableBucket, namespace, tableName)
// Seed a populated table by creating an empty one through the REST API
// and then appending three rows via PyIceberg, so the snapshot exists
// before ClickHouse's first scan.
populatedTable := "populated_" + randomString(6)
createIcebergTable(t, env, icebergToken, tableBucket, namespace, populatedTable)
buildClickHouseWriterImage(t)
writeIcebergRows(t, env, tableBucket, []string{namespace}, populatedTable)
// Empty table that ClickHouse writes into during the WriteReadBack subtest.
writeTable := "chwrite_" + randomString(6)
createIcebergTable(t, env, icebergToken, tableBucket, namespace, writeTable)
env.startClickHouseContainer(t)
env.waitForClickHouse(t, clickhouseStartTimeout)
env.createClickHouseCatalogDatabase(t, tableBucket)
t.Run("BasicSelect", func(t *testing.T) {
out := env.mustQuery(t, "SELECT 1")
if out != "1" {
t.Fatalf("SELECT 1 = %q, want 1", out)
}
})
t.Run("DatabaseVisible", func(t *testing.T) {
out := env.mustQuery(t, "SHOW DATABASES")
if !containsLine(out, clickhouseDatabase) {
t.Fatalf("SHOW DATABASES did not list %s:\n%s", clickhouseDatabase, out)
}
})
// DataLakeCatalog flattens the Iceberg namespace hierarchy into table
// names of the form "namespace.table", queried with backtick quoting.
emptyRef := fmt.Sprintf("%s.`%s.%s`", clickhouseDatabase, namespace, tableName)
populatedRef := fmt.Sprintf("%s.`%s.%s`", clickhouseDatabase, namespace, populatedTable)
t.Run("TableVisible", func(t *testing.T) {
out := env.mustQuery(t, fmt.Sprintf("SHOW TABLES FROM %s", clickhouseDatabase))
for _, want := range []string{namespace + "." + tableName, namespace + "." + populatedTable} {
if !containsLine(out, want) {
t.Fatalf("SHOW TABLES FROM %s did not list %s:\n%s", clickhouseDatabase, want, out)
}
}
})
t.Run("DescribeTable", func(t *testing.T) {
out := env.mustQuery(t, fmt.Sprintf("DESCRIBE TABLE %s", emptyRef))
if !strings.Contains(out, "id\tInt64") {
t.Fatalf("DESCRIBE %s missing id Int64:\n%s", emptyRef, out)
}
if !strings.Contains(out, "label\tNullable(String)") {
t.Fatalf("DESCRIBE %s missing label Nullable(String):\n%s", emptyRef, out)
}
})
t.Run("CountEmptyTable", func(t *testing.T) {
out := env.mustQuery(t, fmt.Sprintf("SELECT count() FROM %s", emptyRef))
if out != "0" {
t.Fatalf("count(%s) = %q, want 0", emptyRef, out)
}
})
t.Run("ReadWrittenDataCount", func(t *testing.T) {
out := env.mustQuery(t, fmt.Sprintf("SELECT count() FROM %s", populatedRef))
if out != "3" {
t.Fatalf("count(%s) = %q, want 3", populatedRef, out)
}
})
t.Run("ReadWrittenDataValues", func(t *testing.T) {
out := env.mustQuery(t, fmt.Sprintf("SELECT id, label FROM %s ORDER BY id", populatedRef))
want := "1\tone\n2\ttwo\n3\tthree"
if out != want {
t.Fatalf("SELECT id, label FROM %s = %q, want %q", populatedRef, out, want)
}
})
// ClickHouse's experimental Iceberg writes produce manifests without avro
// field-ids, bucket-relative paths, and parquet without field ids. The
// catalog repairs the manifests at commit and stamps a name mapping on the
// table, so a strict reader (PyIceberg) must see ClickHouse's rows.
t.Run("WriteReadBack", func(t *testing.T) {
writeRef := fmt.Sprintf("%s.`%s.%s`", clickhouseDatabase, namespace, writeTable)
insert := fmt.Sprintf("INSERT INTO %s (id, label) VALUES (1, 'alpha'), (2, 'beta')", writeRef)
if _, err := env.query(insert, map[string]string{"allow_experimental_insert_into_iceberg": "1"}); err != nil {
t.Fatalf("%s: %v\nContainer logs:\n%s", insert, err, clickhouseContainerLogs(env.clickhouseContainer))
}
out := env.mustQuery(t, fmt.Sprintf("SELECT id, label FROM %s ORDER BY id", writeRef))
if want := "1\talpha\n2\tbeta"; out != want {
t.Fatalf("ClickHouse read-back = %q, want %q", out, want)
}
rows := readIcebergRows(t, env, tableBucket, []string{namespace}, writeTable)
if want := "1,alpha\n2,beta"; rows != want {
t.Fatalf("PyIceberg read of ClickHouse-written table = %q, want %q", rows, want)
}
})
// From 26.4 ClickHouse registers CREATE TABLE on a DataLakeCatalog database
// with the REST catalog; older versions write storage files without a
// catalog createTable, so the subtest skips there.
t.Run("CreateTableViaCatalog", func(t *testing.T) {
version := env.mustQuery(t, "SELECT version()")
if !versionAtLeast(version, 26, 4) {
t.Skipf("catalog CREATE TABLE requires ClickHouse 26.4+, server is %s", version)
}
ddlTable := "chddl_" + randomString(6)
ddlRef := fmt.Sprintf("%s.`%s.%s`", clickhouseDatabase, namespace, ddlTable)
location := fmt.Sprintf("http://host.docker.internal:%d/%s/%s/%s/", env.s3Port, tableBucket, namespace, ddlTable)
create := fmt.Sprintf("CREATE TABLE %s (id Int64, label Nullable(String)) ENGINE = IcebergS3('%s', '%s', '%s')",
ddlRef, location, env.accessKey, env.secretKey)
if _, err := env.query(create, map[string]string{
"allow_experimental_database_iceberg": "1",
// Without this ClickHouse registers a bare "/namespace/table"
// location, which the catalog rejects.
"write_full_path_in_iceberg_metadata": "1",
}); err != nil {
t.Fatalf("%s: %v\nContainer logs:\n%s", create, err, clickhouseContainerLogs(env.clickhouseContainer))
}
// The table must exist in the catalog itself, not just in ClickHouse.
token := requestIcebergOAuthToken(t, env)
listing := doIcebergJSONRequest(t, env, token, http.MethodGet,
fmt.Sprintf("/v1/%s/namespaces/%s/tables", url.PathEscape(tableBucket), url.PathEscape(namespace)),
nil, http.StatusOK)
var tables struct {
Identifiers []struct {
Namespace []string `json:"namespace"`
Name string `json:"name"`
} `json:"identifiers"`
}
if err := json.Unmarshal([]byte(listing), &tables); err != nil {
t.Fatalf("decode catalog table listing: %v\n%s", err, listing)
}
registered := false
for _, id := range tables.Identifiers {
if id.Name == ddlTable && len(id.Namespace) == 1 && id.Namespace[0] == namespace {
registered = true
break
}
}
if !registered {
t.Fatalf("catalog table listing is missing %s: %s", ddlTable, listing)
}
insert := fmt.Sprintf("INSERT INTO %s (id, label) VALUES (1, 'alpha'), (2, 'beta')", ddlRef)
if _, err := env.query(insert, map[string]string{"allow_experimental_insert_into_iceberg": "1"}); err != nil {
t.Fatalf("%s: %v\nContainer logs:\n%s", insert, err, clickhouseContainerLogs(env.clickhouseContainer))
}
out := env.mustQuery(t, fmt.Sprintf("SELECT id, label FROM %s ORDER BY id", ddlRef))
if want := "1\talpha\n2\tbeta"; out != want {
t.Fatalf("ClickHouse read-back = %q, want %q", out, want)
}
rows := readIcebergRows(t, env, tableBucket, []string{namespace}, ddlTable)
if want := "1,alpha\n2,beta"; rows != want {
t.Fatalf("PyIceberg read of ClickHouse-created table = %q, want %q", rows, want)
}
})
}
// TestVersionAtLeast exercises the gate that decides whether the catalog DDL
// subtest runs.
func TestVersionAtLeast(t *testing.T) {
cases := []struct {
version string
major, minor int
want bool
}{
{"26.7.3.19", 26, 4, true},
{"26.4.5.143", 26, 4, true},
{"26.3.17.110", 26, 4, false},
{"25.8.29.51", 26, 4, false},
{"27.1.1.1", 26, 4, true},
{"weird", 26, 4, false},
}
for _, c := range cases {
if got := versionAtLeast(c.version, c.major, c.minor); got != c.want {
t.Errorf("versionAtLeast(%q, %d, %d) = %v, want %v", c.version, c.major, c.minor, got, c.want)
}
}
}
// NewTestEnvironment allocates ports and returns an environment for the test.
func NewTestEnvironment(t *testing.T) *TestEnvironment {
t.Helper()
wd, err := os.Getwd()
if err != nil {
t.Fatalf("Failed to get working directory: %v", err)
}
seaweedDir := wd
for i := 0; i < 6; i++ {
if _, err := os.Stat(filepath.Join(seaweedDir, "go.mod")); err == nil {
break
}
seaweedDir = filepath.Dir(seaweedDir)
}
weedBinary := filepath.Join(seaweedDir, "weed", "weed")
info, err := os.Stat(weedBinary)
if err != nil || info.IsDir() {
weedBinary = filepath.Join(seaweedDir, "weed", "weed", "weed")
info, err = os.Stat(weedBinary)
if err != nil || info.IsDir() {
weedBinary = "weed"
if _, err := exec.LookPath(weedBinary); err != nil {
t.Skip("weed binary not found, skipping integration test")
}
}
}
dataDir, err := os.MkdirTemp("", "seaweed-clickhouse-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
bindIP := testutil.FindBindIP()
// 9 ports for the seaweed mini cluster, plus one for the ClickHouse HTTP
// interface mapped on the host.
ports := testutil.MustAllocatePorts(t, 10)
env := &TestEnvironment{
seaweedDir: seaweedDir,
weedBinary: weedBinary,
dataDir: dataDir,
bindIP: bindIP,
masterPort: ports[0],
masterGrpcPort: ports[1],
volumePort: ports[2],
volumeGrpcPort: ports[3],
filerPort: ports[4],
filerGrpcPort: ports[5],
s3Port: ports[6],
s3GrpcPort: ports[7],
icebergPort: ports[8],
clickhouseHostPort: ports[9],
}
env.accessKey = "AKIAIOSFODNN7EXAMPLE"
env.secretKey = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
return env
}
// StartSeaweedFS starts a SeaweedFS mini instance with the Iceberg REST API.
func (env *TestEnvironment) StartSeaweedFS(t *testing.T) {
t.Helper()
iamConfigPath, err := testutil.WriteIAMConfig(env.dataDir, env.accessKey, env.secretKey)
if err != nil {
t.Fatalf("Failed to create IAM config: %v", err)
}
securityToml := filepath.Join(env.dataDir, "security.toml")
if err := os.WriteFile(securityToml, []byte("# Empty security config for testing\n"), 0644); err != nil {
t.Fatalf("Failed to create security.toml: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
env.weedCancel = cancel
cmd := exec.CommandContext(ctx, env.weedBinary, "mini",
"-master.port", fmt.Sprintf("%d", env.masterPort),
"-master.port.grpc", fmt.Sprintf("%d", env.masterGrpcPort),
"-volume.port", fmt.Sprintf("%d", env.volumePort),
"-volume.port.grpc", fmt.Sprintf("%d", env.volumeGrpcPort),
"-filer.port", fmt.Sprintf("%d", env.filerPort),
"-filer.port.grpc", fmt.Sprintf("%d", env.filerGrpcPort),
"-s3.port", fmt.Sprintf("%d", env.s3Port),
"-s3.port.grpc", fmt.Sprintf("%d", env.s3GrpcPort),
"-s3.port.iceberg", fmt.Sprintf("%d", env.icebergPort),
"-s3.config", iamConfigPath,
"-ip", env.bindIP,
"-ip.bind", "0.0.0.0",
"-dir", env.dataDir,
)
cmd.Dir = env.dataDir
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Env = append(os.Environ(),
"AWS_ACCESS_KEY_ID="+env.accessKey,
"AWS_SECRET_ACCESS_KEY="+env.secretKey,
"ICEBERG_WAREHOUSE=s3://iceberg-tables",
"S3TABLES_DEFAULT_BUCKET=iceberg-tables",
)
if err := cmd.Start(); err != nil {
t.Fatalf("Failed to start SeaweedFS: %v", err)
}
env.weedProcess = cmd
icebergURL := fmt.Sprintf("http://%s:%d/v1/config", env.bindIP, env.icebergPort)
if !env.waitForService(icebergURL, 30*time.Second) {
t.Fatalf("Iceberg REST API did not become ready at %s", icebergURL)
}
}
// Cleanup stops ClickHouse, SeaweedFS, and removes temporary state.
func (env *TestEnvironment) Cleanup(t *testing.T) {
t.Helper()
if env.clickhouseContainer != "" {
_ = exec.Command("docker", "rm", "-f", env.clickhouseContainer).Run()
}
if env.weedCancel != nil {
env.weedCancel()
}
if env.weedProcess != nil {
time.Sleep(2 * time.Second)
_ = env.weedProcess.Wait()
}
if env.dataDir != "" {
_ = os.RemoveAll(env.dataDir)
}
}
// waitForService polls a URL until it returns a 2xx/401/403 status or timeout.
func (env *TestEnvironment) waitForService(url string, timeout time.Duration) bool {
client := &http.Client{Timeout: 2 * time.Second}
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
resp, err := client.Get(url)
if err != nil {
time.Sleep(500 * time.Millisecond)
continue
}
statusCode := resp.StatusCode
resp.Body.Close()
if statusCode >= 200 && statusCode < 300 {
return true
}
if statusCode == http.StatusUnauthorized || statusCode == http.StatusForbidden {
return true
}
time.Sleep(500 * time.Millisecond)
}
return false
}
// testIcebergRestAPI verifies the Iceberg REST endpoint is reachable.
func testIcebergRestAPI(t *testing.T, env *TestEnvironment) {
t.Helper()
url := fmt.Sprintf("http://%s:%d/v1/config", env.bindIP, env.icebergPort)
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Get(url)
if err != nil {
t.Fatalf("Failed to connect to Iceberg REST API at %s: %v", url, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
t.Fatalf("Expected 200 OK from /v1/config, got %d, body: %s", resp.StatusCode, body)
}
}
// startClickHouseContainer launches the ClickHouse server image and exposes
// only the HTTP interface to the host. The Iceberg REST and S3 endpoints are
// reached via host.docker.internal, matching the Doris/Trino paths.
func (env *TestEnvironment) startClickHouseContainer(t *testing.T) {
t.Helper()
containerName := "seaweed-clickhouse-" + randomString(8)
env.clickhouseContainer = containerName
cmd := exec.Command("docker", "run", "-d",
"--name", containerName,
"--add-host", "host.docker.internal:host-gateway",
"-p", fmt.Sprintf("%d:%d", env.clickhouseHostPort, clickhouseHTTPPort),
"--ulimit", "nofile=262144:262144",
"-e", "CLICKHOUSE_USER="+clickhouseUser,
"-e", "CLICKHOUSE_PASSWORD="+clickhousePassword,
clickhouseImage(),
)
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("Failed to start ClickHouse container: %v\n%s", err, string(output))
}
}
// clickhouseImage returns the server image under test. CI overrides it via
// CLICKHOUSE_IMAGE to also run the suite against latest, so new ClickHouse
// releases are exercised without a code change.
func clickhouseImage() string {
if img := os.Getenv("CLICKHOUSE_IMAGE"); img != "" {
return img
}
return clickhouseDefaultImage
}
// versionAtLeast reports whether a "major.minor.*" version string is at least
// major.minor.
func versionAtLeast(version string, major, minor int) bool {
parts := strings.Split(strings.TrimSpace(version), ".")
if len(parts) < 2 {
return false
}
gotMajor, errMajor := strconv.Atoi(parts[0])
gotMinor, errMinor := strconv.Atoi(parts[1])
if errMajor != nil || errMinor != nil {
return false
}
return gotMajor > major || (gotMajor == major && gotMinor >= minor)
}
// clickhouseContainerLogs returns the tail of the container logs for diagnostics.
func clickhouseContainerLogs(containerName string) string {
cmd := exec.Command("docker", "logs", "--tail", "200", containerName)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Sprintf("(failed to fetch docker logs: %v)\n%s", err, string(output))
}
return string(output)
}
// containerRunning returns true if the named container is in `running` state.
func containerRunning(containerName string) bool {
cmd := exec.Command("docker", "inspect", "--format", "{{.State.Running}}", containerName)
out, err := cmd.Output()
if err != nil {
return false
}
return strings.TrimSpace(string(out)) == "true"
}
// waitForClickHouse polls the HTTP /ping endpoint until it answers.
func (env *TestEnvironment) waitForClickHouse(t *testing.T, timeout time.Duration) {
t.Helper()
pingURL := fmt.Sprintf("http://127.0.0.1:%d/ping", env.clickhouseHostPort)
client := &http.Client{Timeout: 2 * time.Second}
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
if !containerRunning(env.clickhouseContainer) {
t.Fatalf("ClickHouse container exited before becoming ready\nContainer logs:\n%s",
clickhouseContainerLogs(env.clickhouseContainer))
}
resp, err := client.Get(pingURL)
if err == nil {
statusCode := resp.StatusCode
resp.Body.Close()
if statusCode == http.StatusOK {
return
}
}
time.Sleep(time.Second)
}
t.Fatalf("Timed out waiting for ClickHouse to be ready\nContainer logs:\n%s",
clickhouseContainerLogs(env.clickhouseContainer))
}
// query sends one SQL statement to ClickHouse's HTTP interface as the default
// user and returns the TabSeparated response body with trailing whitespace
// trimmed. Settings ride along as URL parameters because SET does not persist
// across stateless HTTP requests.
func (env *TestEnvironment) query(sqlText string, settings map[string]string) (string, error) {
params := url.Values{}
params.Set("user", clickhouseUser)
params.Set("password", clickhousePassword)
params.Set("default_format", "TabSeparated")
for k, v := range settings {
params.Set(k, v)
}
queryURL := fmt.Sprintf("http://127.0.0.1:%d/?%s", env.clickhouseHostPort, params.Encode())
client := &http.Client{Timeout: 120 * time.Second}
resp, err := client.Post(queryURL, "text/plain", strings.NewReader(sqlText))
if err != nil {
return "", fmt.Errorf("POST query: %v", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("query returned status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
return strings.TrimRight(string(body), "\n"), nil
}
// mustQuery runs a query and fails the test with container logs on error.
func (env *TestEnvironment) mustQuery(t *testing.T, sqlText string) string {
t.Helper()
out, err := env.query(sqlText, nil)
if err != nil {
t.Fatalf("%s: %v\nContainer logs:\n%s", sqlText, err, clickhouseContainerLogs(env.clickhouseContainer))
}
return out
}
// createClickHouseCatalogDatabase attaches the SeaweedFS Iceberg REST catalog
// as a DataLakeCatalog database. The engine arguments carry the S3 storage
// credentials; catalog authentication uses the OAuth2 client_credentials flow
// against the SeaweedFS token endpoint, matching the PyIceberg writer.
func (env *TestEnvironment) createClickHouseCatalogDatabase(t *testing.T, warehouseBucket string) {
t.Helper()
icebergURI := fmt.Sprintf("http://host.docker.internal:%d/v1", env.icebergPort)
storageEndpoint := fmt.Sprintf("http://host.docker.internal:%d/%s", env.s3Port, warehouseBucket)
oauthURI := icebergURI + "/oauth/tokens"
createSQL := fmt.Sprintf(`CREATE DATABASE %s
ENGINE = DataLakeCatalog('%s', '%s', '%s')
SETTINGS catalog_type = 'rest',
warehouse = 's3://%s',
storage_endpoint = '%s',
catalog_credential = '%s:%s',
oauth_server_uri = '%s'`,
clickhouseDatabase,
icebergURI, env.accessKey, env.secretKey,
warehouseBucket,
storageEndpoint,
env.accessKey, env.secretKey,
oauthURI,
)
if _, err := env.query(createSQL, map[string]string{"allow_experimental_database_iceberg": "1"}); err != nil {
t.Fatalf("CREATE DATABASE %s failed: %v\nContainer logs:\n%s",
clickhouseDatabase, err, clickhouseContainerLogs(env.clickhouseContainer))
}
}
// containsLine reports whether any line of a TabSeparated result equals want.
func containsLine(out, want string) bool {
for _, line := range strings.Split(out, "\n") {
if strings.TrimSpace(line) == want {
return true
}
}
return false
}
// requestIcebergOAuthToken requests an OAuth2 client_credentials token from
// the SeaweedFS Iceberg REST catalog. Used to seed the catalog with a
// namespace and tables directly through the REST API before ClickHouse connects.
func requestIcebergOAuthToken(t *testing.T, env *TestEnvironment) string {
t.Helper()
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.PostForm(fmt.Sprintf("http://%s:%d/v1/oauth/tokens", env.bindIP, env.icebergPort), url.Values{
"grant_type": {"client_credentials"},
"client_id": {env.accessKey},
"client_secret": {env.secretKey},
})
if err != nil {
t.Fatalf("POST /v1/oauth/tokens: %v", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
t.Fatalf("OAuth token request failed: status=%d body=%s", resp.StatusCode, body)
}
var tokenResp struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
}
if err := json.Unmarshal(body, &tokenResp); err != nil {
t.Fatalf("decode token response: %v", err)
}
if tokenResp.AccessToken == "" {
t.Fatal("got empty access_token")
}
return tokenResp.AccessToken
}
// createIcebergNamespace creates a single-level Iceberg namespace through
// the REST catalog.
func createIcebergNamespace(t *testing.T, env *TestEnvironment, token, bucketName, namespace string) {
t.Helper()
doIcebergJSONRequest(t, env, token, http.MethodPost, fmt.Sprintf("/v1/%s/namespaces", url.PathEscape(bucketName)), map[string]any{
"namespace": []string{namespace},
}, http.StatusOK, http.StatusConflict)
}
// createIcebergTable creates a table inside a single-level namespace through
// the REST catalog. The table is created with the canonical
// (id long not null, label string nullable) schema used by all subtests.
func createIcebergTable(t *testing.T, env *TestEnvironment, token, bucketName, namespace, tableName string) {
t.Helper()
doIcebergJSONRequest(t, env, token, http.MethodPost,
fmt.Sprintf("/v1/%s/namespaces/%s/tables", url.PathEscape(bucketName), url.PathEscape(namespace)),
map[string]any{
"name": tableName,
"schema": map[string]any{
"type": "struct",
"schema-id": 0,
"fields": []map[string]any{
{"id": 1, "name": "id", "required": true, "type": "long"},
{"id": 2, "name": "label", "required": false, "type": "string"},
},
},
}, http.StatusOK)
}
const clickhouseWriterImage = "seaweedfs-clickhouse-writer"
// buildClickHouseWriterImage builds the local PyIceberg writer image. Layer
// caching makes repeat invocations cheap; the first build pulls
// python:3.11-slim and pip-installs pyiceberg+pyarrow (~1-2 min in CI).
func buildClickHouseWriterImage(t *testing.T) {
t.Helper()
wd, err := os.Getwd()
if err != nil {
t.Fatalf("Failed to get working directory: %v", err)
}
cmd := exec.Command("docker", "build",
"-t", clickhouseWriterImage,
"-f", filepath.Join(wd, "Dockerfile.writer"),
wd,
)
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("Failed to build %s image: %v\n%s", clickhouseWriterImage, err, out)
}
}
// writeIcebergRows runs the PyIceberg writer container, which loads the
// already-created table and appends three rows.
func writeIcebergRows(t *testing.T, env *TestEnvironment, bucketName string, namespace []string, tableName string) {
t.Helper()
args := []string{
"run", "--rm",
"--add-host", "host.docker.internal:host-gateway",
clickhouseWriterImage,
"--catalog-url", fmt.Sprintf("http://host.docker.internal:%d", env.icebergPort),
"--warehouse", "s3://" + bucketName,
"--prefix", bucketName,
"--s3-endpoint", fmt.Sprintf("http://host.docker.internal:%d", env.s3Port),
"--access-key", env.accessKey,
"--secret-key", env.secretKey,
"--region", "us-west-2",
"--table", tableName,
}
for _, level := range namespace {
args = append(args, "--namespace", level)
}
cmd := exec.Command("docker", args...)
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("PyIceberg writer failed: %v\n%s", err, out)
}
t.Logf("PyIceberg writer output: %s", strings.TrimSpace(string(out)))
}
// readIcebergRows scans a table with PyIceberg through the REST catalog and
// returns its "id,label" lines, ordered by id.
func readIcebergRows(t *testing.T, env *TestEnvironment, bucketName string, namespace []string, tableName string) string {
t.Helper()
args := []string{
"run", "--rm",
"--add-host", "host.docker.internal:host-gateway",
"--entrypoint", "python3",
clickhouseWriterImage,
"/app/read_rows.py",
"--catalog-url", fmt.Sprintf("http://host.docker.internal:%d", env.icebergPort),
"--warehouse", "s3://" + bucketName,
"--prefix", bucketName,
"--s3-endpoint", fmt.Sprintf("http://host.docker.internal:%d", env.s3Port),
"--access-key", env.accessKey,
"--secret-key", env.secretKey,
"--region", "us-west-2",
"--table", tableName,
}
for _, level := range namespace {
args = append(args, "--namespace", level)
}
// Keep stdout separate: the caller compares it exactly, and warnings on
// stderr from the python stack must not pollute the row data.
cmd := exec.Command("docker", args...)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
t.Fatalf("PyIceberg reader failed: %v\nstdout:\n%s\nstderr:\n%s", err, stdout.String(), stderr.String())
}
return strings.TrimSpace(stdout.String())
}
// doIcebergJSONRequest issues an authenticated JSON request to the Iceberg
// REST endpoint and returns the response body. It fails the test unless the
// response status matches one of expectedStatuses.
func doIcebergJSONRequest(t *testing.T, env *TestEnvironment, token, method, path string, payload any, expectedStatuses ...int) string {
t.Helper()
var body io.Reader
if payload != nil {
payloadBytes, err := json.Marshal(payload)
if err != nil {
t.Fatalf("marshal Iceberg request: %v", err)
}
body = bytes.NewReader(payloadBytes)
}
req, err := http.NewRequest(method, fmt.Sprintf("http://%s:%d%s", env.bindIP, env.icebergPort, path), body)
if err != nil {
t.Fatalf("create Iceberg request: %v", err)
}
req.Header.Set("Authorization", "Bearer "+token)
if payload != nil {
req.Header.Set("Content-Type", "application/json")
}
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
t.Fatalf("Iceberg request failed: %v", err)
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
for _, expectedStatus := range expectedStatuses {
if resp.StatusCode == expectedStatus {
return string(respBody)
}
}
t.Fatalf("Iceberg request returned unexpected status %d, want %v\nPath: %s\nBody: %s",
resp.StatusCode, expectedStatuses, path, respBody)
return ""
}
// createTableBucket creates an S3 table bucket using `weed shell`, which
// talks to the master over gRPC and bypasses the S3 SigV4 path. The `-master`
// flag uses SeaweedFS's canonical `host:port.grpcPort` ServerAddress format
// produced by pb.NewServerAddress — the dot separates the HTTP port from the
// gRPC port and is required, not a typo.
func createTableBucket(t *testing.T, env *TestEnvironment, bucketName string) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, env.weedBinary, "shell",
fmt.Sprintf("-master=%s:%d.%d", env.bindIP, env.masterPort, env.masterGrpcPort),
)
cmd.Stdin = strings.NewReader(fmt.Sprintf("s3tables.bucket -create -name %s -account 000000000000\nexit\n", bucketName))
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("Failed to create table bucket %s via weed shell: %v\nOutput: %s", bucketName, err, string(output))
}
t.Logf("Created table bucket: %s", bucketName)
}
// requireClickHouseRuntime skips the test in `-short` mode or when Docker
// isn't available, since the test cannot run without the ClickHouse container.
func requireClickHouseRuntime(t *testing.T) {
t.Helper()
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
if !hasDocker() {
t.Skip("Docker not available, skipping ClickHouse integration test")
}
}
// hasDocker reports whether `docker version` can run, which we treat as a
// sufficient signal that a Docker daemon is reachable from this process.
func hasDocker() bool {
cmd := exec.Command("docker", "version")
return cmd.Run() == nil
}
// randomString returns a lowercase alphanumeric string of the given length.
func randomString(length int) string {
const charset = "abcdefghijklmnopqrstuvwxyz0123456789"
b := make([]byte, length)
if _, err := rand.Read(b); err != nil {
panic("failed to generate random string: " + err.Error())
}
for i := range b {
b[i] = charset[int(b[i])%len(charset)]
}
return string(b)
}

View File

@@ -0,0 +1,54 @@
#!/usr/bin/env python3
"""Read all rows of an Iceberg table via the SeaweedFS REST catalog.
Used by the ClickHouse integration test to prove that rows written by
ClickHouse are readable by another engine: PyIceberg is a strict reader that
requires spec-compliant manifests and either parquet field ids or a name
mapping. Prints one "id,label" line per row, ordered by id.
"""
import argparse
import sys
from pyiceberg.catalog import load_catalog
def main() -> int:
p = argparse.ArgumentParser()
p.add_argument("--catalog-url", required=True)
p.add_argument("--warehouse", required=True)
p.add_argument("--prefix", required=True)
p.add_argument("--s3-endpoint", required=True)
p.add_argument("--access-key", required=True)
p.add_argument("--secret-key", required=True)
p.add_argument("--region", default="us-east-1")
p.add_argument("--namespace", action="append", required=True)
p.add_argument("--table", required=True)
args = p.parse_args()
catalog = load_catalog(
"rest",
**{
"type": "rest",
"uri": args.catalog_url,
"warehouse": args.warehouse,
"prefix": args.prefix,
"credential": f"{args.access_key}:{args.secret_key}",
"s3.access-key-id": args.access_key,
"s3.secret-access-key": args.secret_key,
"s3.endpoint": args.s3_endpoint,
"s3.region": args.region,
"s3.path-style-access": "true",
},
)
table = catalog.load_table(tuple(args.namespace) + (args.table,))
data = table.scan().to_arrow().to_pydict()
rows = sorted(zip(data["id"], data["label"]))
for row_id, label in rows:
print(f"{row_id},{label}")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,12 @@
# Iceberg writer used by the Doris integration test to populate a table with
# data files via PyIceberg, so the downstream Doris SELECT verifies the read
# path against non-empty results rather than just COUNT(*) = 0.
FROM python:3.11-slim
WORKDIR /app
RUN pip install --no-cache-dir "pyiceberg[s3fs]" pyarrow
COPY append_rows.py /app/
ENTRYPOINT ["python3", "/app/append_rows.py"]

View File

@@ -0,0 +1,80 @@
# Apache Doris Iceberg Catalog Integration Test
This directory contains an Apache Doris integration smoke test for SeaweedFS's
Iceberg REST Catalog implementation.
## What It Tests
`TestDorisIcebergCatalog` verifies the Doris path end to end:
1. Starts a local SeaweedFS mini cluster with S3 Tables and Iceberg REST enabled.
2. Creates a SeaweedFS table bucket.
3. Creates an Iceberg namespace and an empty table through the SeaweedFS REST
catalog OAuth flow.
4. Creates a second table and populates it with three rows by running a
PyIceberg writer container (`Dockerfile.writer` + `append_rows.py`) before
Doris bootstraps, so the snapshot is part of Doris's first scan.
5. Starts `apache/doris:doris-all-in-one-2.1.0` and waits for the FE MySQL
query port and at least one alive BE.
6. Connects to Doris over the MySQL protocol and registers a Doris
`EXTERNAL CATALOG` of `type=iceberg` and `iceberg.catalog.type=rest` that
points at the SeaweedFS REST endpoint, using `credential = key:secret` for
the OAuth2 client-credentials flow.
7. Runs subtests against the SeaweedFS-backed Iceberg tables:
- `BasicSelect`: Doris is alive and answering SQL.
- `CatalogVisible`: `SHOW CATALOGS` lists the SeaweedFS-backed catalog.
- `DatabaseVisible`: the seeded namespace is exposed as a Doris database.
- `TableVisible`: the seeded table appears under `SHOW TABLES FROM ...`.
- `CountEmptyTable`: catalog-to-table resolution and a scan of an empty table.
- `ColumnProjection`: `SELECT id, label` succeeds and the response columns
are `id, label`. Failure here means Doris could not parse the schema
returned by the SeaweedFS catalog.
- `ReadWrittenDataCount` and `ReadWrittenDataValues`: Doris reads back the
three PyIceberg-appended rows and the values match. This exercises the
actual data path (parquet reads via S3), not just metadata.
The PyIceberg writer image is built on demand via Docker layer caching. The
first build pulls `python:3.11-slim` and pip-installs PyIceberg + PyArrow
(~1-2 min in CI); subsequent invocations are cheap.
## Running Locally
Build or install `weed`, then run:
```bash
cd test/s3tables/catalog_doris
go test -v -timeout 25m .
```
The test requires Docker. The GitHub Actions job runs on `ubuntu-22.04` and
executes the test for pull requests.
## Configuration
The test uses these fixed credentials for the local SeaweedFS IAM config:
- S3 access key: `AKIAIOSFODNN7EXAMPLE`
- S3 secret key: `wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY`
- Region: `us-west-2`
- Warehouse bucket: `iceberg-tables`
Doris ports:
- The all-in-one container exposes `9030` (FE MySQL/SQL) and `8030` (FE HTTP).
Only `9030` is mapped to a host port (allocated dynamically) so the Go
MySQL client can connect from the test process.
- The Iceberg REST endpoint and the S3 endpoint are reached from inside the
Doris container via `host.docker.internal`, matching the Trino and Dremio
test paths.
## Troubleshooting
- Ensure Docker is running: `docker version`
- Ensure `weed` is built or available on `PATH`
- The Doris all-in-one image is large (~1 GB) and the FE+BE need
~30-60 seconds to register before queries succeed; the test waits up to
4 minutes for the query port and 60 seconds for at least one alive BE.
- If queries hang on metadata, run `REFRESH CATALOG iceberg_catalog` from a
Doris MySQL client and retry.
- Container logs are printed in the failure message; you can also check
`docker logs <seaweed-doris-...>` while the test is running.

View File

@@ -0,0 +1,87 @@
#!/usr/bin/env python3
"""Append rows to an existing Iceberg table via the SeaweedFS REST catalog.
Used by the Doris integration test to materialize data files so a downstream
SELECT from Doris verifies the read path against non-empty results.
Usage:
python3 append_rows.py \\
--catalog-url http://localhost:8181 \\
--warehouse s3://my-bucket \\
--prefix my-bucket \\
--s3-endpoint http://localhost:8333 \\
--access-key AKIA... --secret-key wJalr... \\
--namespace foo --namespace bar \\
--table events
"""
import argparse
import sys
import pyarrow as pa
from pyiceberg.catalog import load_catalog
def main() -> int:
p = argparse.ArgumentParser()
p.add_argument("--catalog-url", required=True)
p.add_argument("--warehouse", required=True, help="s3://<bucket-name>")
p.add_argument("--prefix", required=True, help="REST catalog prefix (table bucket name)")
p.add_argument("--s3-endpoint", required=True, help="http://host:port")
p.add_argument("--access-key", required=True)
p.add_argument("--secret-key", required=True)
p.add_argument("--region", default="us-east-1")
p.add_argument(
"--namespace",
action="append",
required=True,
help="One per level (e.g. --namespace foo --namespace bar for foo.bar).",
)
p.add_argument("--table", required=True)
args = p.parse_args()
# `credential` triggers OAuth2 client_credentials against
# <catalog_uri>/v1/oauth/tokens, matching the helper Go test uses for
# REST-API table creation. The s3.* keys are needed for parquet writes.
catalog = load_catalog(
"rest",
**{
"type": "rest",
"uri": args.catalog_url,
"warehouse": args.warehouse,
"prefix": args.prefix,
"credential": f"{args.access_key}:{args.secret_key}",
"s3.access-key-id": args.access_key,
"s3.secret-access-key": args.secret_key,
"s3.endpoint": args.s3_endpoint,
"s3.region": args.region,
"s3.path-style-access": "true",
},
)
table_id = tuple(args.namespace) + (args.table,)
table = catalog.load_table(table_id)
# Match the Iceberg table schema: id is `required long`, label is
# `optional string`. Default pyarrow columns are nullable, which fails
# PyIceberg's required-field compatibility check.
arrow_schema = pa.schema(
[
pa.field("id", pa.int64(), nullable=False),
pa.field("label", pa.string(), nullable=True),
]
)
arrow_table = pa.Table.from_pydict(
{
"id": [1, 2, 3],
"label": ["one", "two", "three"],
},
schema=arrow_schema,
)
table.append(arrow_table)
print(f"appended {arrow_table.num_rows} rows to {'.'.join(table_id)}")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,943 @@
// Package catalog_doris provides integration tests for Apache Doris with the
// SeaweedFS Iceberg REST Catalog.
package catalog_doris
import (
"bytes"
"context"
"crypto/rand"
"database/sql"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
_ "github.com/go-sql-driver/mysql"
"github.com/seaweedfs/seaweedfs/test/testutil"
)
const (
dorisImage = "apache/doris:doris-all-in-one-2.1.0"
dorisCatalogName = "iceberg_catalog"
dorisQueryPort = 9030
dorisStartTimeout = 4 * time.Minute
)
type TestEnvironment struct {
seaweedDir string
weedBinary string
dataDir string
bindIP string
s3Port int
s3GrpcPort int
icebergPort int
masterPort int
masterGrpcPort int
filerPort int
filerGrpcPort int
volumePort int
volumeGrpcPort int
weedProcess *exec.Cmd
weedCancel context.CancelFunc
dorisContainer string
dorisHostQueryPort int
accessKey string
secretKey string
}
// TestDorisIcebergCatalog brings up SeaweedFS + Doris and validates that
// Doris can discover catalog metadata served by SeaweedFS's Iceberg REST API
// and read both empty and populated tables through the standard data path.
//
// Subtests:
// - BasicSelect: Doris is alive and answering SQL.
// - CatalogVisible: SHOW CATALOGS lists the SeaweedFS-backed catalog.
// - DatabaseVisible: the seeded namespace is visible as a database.
// - TableVisible: the seeded table is listed under the namespace.
// - CountEmptyTable: Doris resolves the table and scans an empty Iceberg snapshot.
// - ColumnProjection: Doris parsed the schema and accepts column-name projection
// (a column-not-found here means the schema returned by SeaweedFS was rejected).
// - ReadWrittenDataCount / ReadWrittenDataValues: a separate table is populated
// by a PyIceberg writer container before Doris connects; Doris then reads the
// three rows back, exercising the actual data path (not just metadata).
func TestDorisIcebergCatalog(t *testing.T) {
requireDorisRuntime(t)
env := NewTestEnvironment(t)
defer env.Cleanup(t)
fmt.Printf(">>> Starting SeaweedFS...\n")
env.StartSeaweedFS(t)
fmt.Printf(">>> SeaweedFS started.\n")
tableBucket := "iceberg-tables"
fmt.Printf(">>> Creating table bucket: %s\n", tableBucket)
createTableBucket(t, env, tableBucket)
fmt.Printf(">>> Table bucket created.\n")
testIcebergRestAPI(t, env)
namespace := "doris_" + randomString(6)
tableName := "smoke_" + randomString(6)
icebergToken := requestIcebergOAuthToken(t, env)
createIcebergNamespace(t, env, icebergToken, tableBucket, namespace)
createIcebergTable(t, env, icebergToken, tableBucket, namespace, tableName)
// Seed a populated table by creating an empty one through the REST API
// and then appending three rows via PyIceberg. Done before Doris bootstrap
// so the snapshot is part of the catalog's first scan.
populatedTable := "populated_" + randomString(6)
createIcebergTable(t, env, icebergToken, tableBucket, namespace, populatedTable)
buildDorisWriterImage(t)
writeIcebergRows(t, env, tableBucket, []string{namespace}, populatedTable)
env.startDorisContainer(t)
env.waitForDoris(t, dorisStartTimeout)
db := env.connectDoris(t)
defer db.Close()
env.createDorisIcebergCatalog(t, db, tableBucket)
t.Run("BasicSelect", func(t *testing.T) {
var v int
if err := db.QueryRow("SELECT 1").Scan(&v); err != nil {
t.Fatalf("SELECT 1: %v", err)
}
if v != 1 {
t.Fatalf("SELECT 1 = %d, want 1", v)
}
})
t.Run("CatalogVisible", func(t *testing.T) {
// Doris's SHOW CATALOGS columns vary between versions. The catalog
// name appears in the first column for all releases that ship the
// Iceberg REST connector, so a per-row substring check is enough.
if !rowsContain(t, db, "SHOW CATALOGS", dorisCatalogName) {
t.Fatalf("SHOW CATALOGS did not list %s", dorisCatalogName)
}
})
t.Run("DatabaseVisible", func(t *testing.T) {
query := fmt.Sprintf("SHOW DATABASES FROM %s", quoteDorisIdent(dorisCatalogName))
if !rowsContain(t, db, query, namespace) {
t.Fatalf("SHOW DATABASES from %s did not list namespace %s", dorisCatalogName, namespace)
}
})
t.Run("TableVisible", func(t *testing.T) {
query := fmt.Sprintf("SHOW TABLES FROM %s.%s",
quoteDorisIdent(dorisCatalogName), quoteDorisIdent(namespace))
if !rowsContain(t, db, query, tableName) {
t.Fatalf("SHOW TABLES from %s.%s did not list %s", dorisCatalogName, namespace, tableName)
}
})
tableRef := dorisObjectName(dorisCatalogName, namespace, tableName)
t.Run("CountEmptyTable", func(t *testing.T) {
var count int64
query := fmt.Sprintf("SELECT COUNT(*) FROM %s", tableRef)
if err := db.QueryRow(query).Scan(&count); err != nil {
t.Fatalf("%s: %v", query, err)
}
if count != 0 {
t.Fatalf("count(%s) = %d, want 0", tableRef, count)
}
})
t.Run("ColumnProjection", func(t *testing.T) {
// SELECT COUNT(*) doesn't exercise the schema. A projection by column
// name fails fast with "column not found" if the catalog's schema
// response wasn't parsed by Doris.
query := fmt.Sprintf("SELECT id, label FROM %s", tableRef)
rows, err := db.Query(query)
if err != nil {
t.Fatalf("%s: %v", query, err)
}
defer rows.Close()
cols, err := rows.Columns()
if err != nil {
t.Fatalf("Columns(): %v", err)
}
if !equalStringSlicesIgnoreCase(cols, []string{"id", "label"}) {
t.Fatalf("projected columns = %v, want [id label]", cols)
}
if rows.Next() {
t.Fatalf("expected empty result set, got at least one row")
}
})
populatedRef := dorisObjectName(dorisCatalogName, namespace, populatedTable)
t.Run("ReadWrittenDataCount", func(t *testing.T) {
var count int64
query := fmt.Sprintf("SELECT COUNT(*) FROM %s", populatedRef)
if err := db.QueryRow(query).Scan(&count); err != nil {
t.Fatalf("%s: %v", query, err)
}
if count != 3 {
t.Fatalf("count(%s) = %d, want 3", populatedRef, count)
}
})
t.Run("ReadWrittenDataValues", func(t *testing.T) {
query := fmt.Sprintf("SELECT id, label FROM %s ORDER BY id", populatedRef)
rows, err := db.Query(query)
if err != nil {
t.Fatalf("%s: %v", query, err)
}
defer rows.Close()
expected := [][2]string{{"1", "one"}, {"2", "two"}, {"3", "three"}}
i := 0
for rows.Next() {
var id int64
var label sql.NullString
if err := rows.Scan(&id, &label); err != nil {
t.Fatalf("Scan row %d: %v", i, err)
}
if i >= len(expected) {
t.Fatalf("got more than %d rows from %s", len(expected), populatedRef)
}
gotID := fmt.Sprintf("%d", id)
gotLabel := label.String
if gotID != expected[i][0] || gotLabel != expected[i][1] {
t.Errorf("row %d = (%s, %s), want (%s, %s)",
i, gotID, gotLabel, expected[i][0], expected[i][1])
}
i++
}
if err := rows.Err(); err != nil {
t.Fatalf("rows.Err: %v", err)
}
if i != len(expected) {
t.Fatalf("expected %d rows, got %d", len(expected), i)
}
})
}
// NewTestEnvironment allocates ports and returns an environment for the test.
func NewTestEnvironment(t *testing.T) *TestEnvironment {
t.Helper()
wd, err := os.Getwd()
if err != nil {
t.Fatalf("Failed to get working directory: %v", err)
}
seaweedDir := wd
for i := 0; i < 6; i++ {
if _, err := os.Stat(filepath.Join(seaweedDir, "go.mod")); err == nil {
break
}
seaweedDir = filepath.Dir(seaweedDir)
}
weedBinary := filepath.Join(seaweedDir, "weed", "weed")
info, err := os.Stat(weedBinary)
if err != nil || info.IsDir() {
weedBinary = filepath.Join(seaweedDir, "weed", "weed", "weed")
info, err = os.Stat(weedBinary)
if err != nil || info.IsDir() {
weedBinary = "weed"
if _, err := exec.LookPath(weedBinary); err != nil {
t.Skip("weed binary not found, skipping integration test")
}
}
}
dataDir, err := os.MkdirTemp("", "seaweed-doris-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
bindIP := testutil.FindBindIP()
// 9 ports for the seaweed mini cluster, plus one for the Doris MySQL
// query port mapped on the host.
ports := testutil.MustAllocatePorts(t, 10)
env := &TestEnvironment{
seaweedDir: seaweedDir,
weedBinary: weedBinary,
dataDir: dataDir,
bindIP: bindIP,
masterPort: ports[0],
masterGrpcPort: ports[1],
volumePort: ports[2],
volumeGrpcPort: ports[3],
filerPort: ports[4],
filerGrpcPort: ports[5],
s3Port: ports[6],
s3GrpcPort: ports[7],
icebergPort: ports[8],
dorisHostQueryPort: ports[9],
}
env.accessKey = "AKIAIOSFODNN7EXAMPLE"
env.secretKey = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
return env
}
// StartSeaweedFS starts a SeaweedFS mini instance with the Iceberg REST API.
func (env *TestEnvironment) StartSeaweedFS(t *testing.T) {
t.Helper()
iamConfigPath, err := testutil.WriteIAMConfig(env.dataDir, env.accessKey, env.secretKey)
if err != nil {
t.Fatalf("Failed to create IAM config: %v", err)
}
securityToml := filepath.Join(env.dataDir, "security.toml")
if err := os.WriteFile(securityToml, []byte("# Empty security config for testing\n"), 0644); err != nil {
t.Fatalf("Failed to create security.toml: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
env.weedCancel = cancel
cmd := exec.CommandContext(ctx, env.weedBinary, "mini",
"-master.port", fmt.Sprintf("%d", env.masterPort),
"-master.port.grpc", fmt.Sprintf("%d", env.masterGrpcPort),
"-volume.port", fmt.Sprintf("%d", env.volumePort),
"-volume.port.grpc", fmt.Sprintf("%d", env.volumeGrpcPort),
"-filer.port", fmt.Sprintf("%d", env.filerPort),
"-filer.port.grpc", fmt.Sprintf("%d", env.filerGrpcPort),
"-s3.port", fmt.Sprintf("%d", env.s3Port),
"-s3.port.grpc", fmt.Sprintf("%d", env.s3GrpcPort),
"-s3.port.iceberg", fmt.Sprintf("%d", env.icebergPort),
"-s3.config", iamConfigPath,
"-ip", env.bindIP,
"-ip.bind", "0.0.0.0",
"-dir", env.dataDir,
)
cmd.Dir = env.dataDir
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Env = append(os.Environ(),
"AWS_ACCESS_KEY_ID="+env.accessKey,
"AWS_SECRET_ACCESS_KEY="+env.secretKey,
"ICEBERG_WAREHOUSE=s3://iceberg-tables",
"S3TABLES_DEFAULT_BUCKET=iceberg-tables",
)
if err := cmd.Start(); err != nil {
t.Fatalf("Failed to start SeaweedFS: %v", err)
}
env.weedProcess = cmd
icebergURL := fmt.Sprintf("http://%s:%d/v1/config", env.bindIP, env.icebergPort)
if !env.waitForService(icebergURL, 30*time.Second) {
client := &http.Client{Timeout: 2 * time.Second}
resp, err := client.Get(icebergURL)
if err != nil {
t.Logf("WARNING: Could not connect to Iceberg service at %s: %v", icebergURL, err)
} else {
t.Logf("WARNING: Iceberg service returned status %d at %s", resp.StatusCode, icebergURL)
resp.Body.Close()
}
t.Fatalf("Iceberg REST API did not become ready")
}
}
// Cleanup stops Doris, SeaweedFS, and removes temporary state.
func (env *TestEnvironment) Cleanup(t *testing.T) {
t.Helper()
if env.dorisContainer != "" {
_ = exec.Command("docker", "rm", "-f", env.dorisContainer).Run()
}
if env.weedCancel != nil {
env.weedCancel()
}
if env.weedProcess != nil {
time.Sleep(2 * time.Second)
_ = env.weedProcess.Wait()
}
if env.dataDir != "" {
_ = os.RemoveAll(env.dataDir)
}
}
// waitForService polls a URL until it returns a 2xx/4xx status or timeout.
func (env *TestEnvironment) waitForService(url string, timeout time.Duration) bool {
client := &http.Client{Timeout: 2 * time.Second}
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
resp, err := client.Get(url)
if err != nil {
time.Sleep(500 * time.Millisecond)
continue
}
statusCode := resp.StatusCode
resp.Body.Close()
if statusCode >= 200 && statusCode < 300 {
return true
}
if statusCode == http.StatusUnauthorized || statusCode == http.StatusForbidden {
return true
}
time.Sleep(500 * time.Millisecond)
}
return false
}
// testIcebergRestAPI verifies the Iceberg REST endpoint is reachable.
func testIcebergRestAPI(t *testing.T, env *TestEnvironment) {
t.Helper()
fmt.Printf(">>> Testing Iceberg REST API directly...\n")
addr := net.JoinHostPort(env.bindIP, fmt.Sprintf("%d", env.icebergPort))
conn, err := net.Dial("tcp", addr)
if err != nil {
t.Fatalf("Cannot connect to Iceberg service at %s: %v", addr, err)
}
conn.Close()
t.Logf("Successfully connected to Iceberg service at %s", addr)
url := fmt.Sprintf("http://%s:%d/v1/config", env.bindIP, env.icebergPort)
t.Logf("Testing Iceberg REST API at %s", url)
resp, err := http.Get(url)
if err != nil {
t.Fatalf("Failed to connect to Iceberg REST API at %s: %v", url, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
t.Fatalf("Expected 200 OK from /v1/config, got %d, body: %s", resp.StatusCode, body)
}
}
// startDorisContainer launches the Apache Doris all-in-one image and exposes
// only the FE MySQL/SQL port to the host. The Iceberg REST and S3 endpoints
// are reached via host.docker.internal, matching the Dremio/Trino paths.
func (env *TestEnvironment) startDorisContainer(t *testing.T) {
t.Helper()
containerName := "seaweed-doris-" + randomString(8)
env.dorisContainer = containerName
cmd := exec.Command("docker", "run", "-d",
"--name", containerName,
"--add-host", "host.docker.internal:host-gateway",
"-p", fmt.Sprintf("%d:%d", env.dorisHostQueryPort, dorisQueryPort),
"-e", "AWS_ACCESS_KEY_ID="+env.accessKey,
"-e", "AWS_SECRET_ACCESS_KEY="+env.secretKey,
"-e", "AWS_REGION=us-west-2",
dorisImage,
)
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("Failed to start Doris container: %v\n%s", err, string(output))
}
}
// dorisContainerLogs returns the tail of Doris's container logs for diagnostics.
func dorisContainerLogs(containerName string) string {
cmd := exec.Command("docker", "logs", "--tail", "200", containerName)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Sprintf("(failed to fetch docker logs: %v)\n%s", err, string(output))
}
return string(output)
}
// waitForDoris polls Doris's MySQL query port until it accepts SELECT 1.
// The all-in-one image takes ~30-60s to boot the FE and BE; we poll long
// enough to absorb cold-start variance in CI.
func (env *TestEnvironment) waitForDoris(t *testing.T, timeout time.Duration) {
t.Helper()
dsn := fmt.Sprintf("root:@tcp(127.0.0.1:%d)/", env.dorisHostQueryPort)
deadline := time.Now().Add(timeout)
var lastErr error
for time.Now().Before(deadline) {
if !containerRunning(env.dorisContainer) {
t.Fatalf("Doris container exited before becoming ready\nContainer logs:\n%s",
dorisContainerLogs(env.dorisContainer))
}
db, err := sql.Open("mysql", dsn+"?timeout=2s&readTimeout=5s&writeTimeout=5s")
if err != nil {
lastErr = err
time.Sleep(2 * time.Second)
continue
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
var v int
err = db.QueryRowContext(ctx, "SELECT 1").Scan(&v)
cancel()
db.Close()
if err == nil && v == 1 {
// Doris reports the FE is up before the BE has registered. A
// catalog SHOW CATALOGS still works without BEs, but a REFRESH
// CATALOG against an external source needs at least one live
// BE. Wait for one to appear so subsequent subtests don't race.
if env.waitForDorisBackend(t, 60*time.Second) {
return
}
} else {
lastErr = err
}
time.Sleep(2 * time.Second)
}
t.Fatalf("Timed out waiting for Doris to be ready\nLast error: %v\nContainer logs:\n%s",
lastErr, dorisContainerLogs(env.dorisContainer))
}
// waitForDorisBackend polls SHOW BACKENDS until at least one BE reports Alive=true.
func (env *TestEnvironment) waitForDorisBackend(t *testing.T, timeout time.Duration) bool {
t.Helper()
dsn := fmt.Sprintf("root:@tcp(127.0.0.1:%d)/?timeout=2s&readTimeout=5s&writeTimeout=5s",
env.dorisHostQueryPort)
db, err := sql.Open("mysql", dsn)
if err != nil {
return false
}
defer db.Close()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
alive, err := dorisHasAliveBackend(db)
if err == nil && alive {
return true
}
time.Sleep(2 * time.Second)
}
return false
}
// dorisHasAliveBackend returns true if any row in SHOW BACKENDS has Alive="true".
func dorisHasAliveBackend(db *sql.DB) (bool, error) {
rows, err := db.Query("SHOW BACKENDS")
if err != nil {
return false, err
}
defer rows.Close()
cols, err := rows.Columns()
if err != nil {
return false, err
}
aliveIdx := -1
for i, c := range cols {
if strings.EqualFold(c, "Alive") {
aliveIdx = i
break
}
}
if aliveIdx < 0 {
return false, fmt.Errorf("SHOW BACKENDS has no Alive column: %v", cols)
}
values := make([]sql.NullString, len(cols))
scanArgs := make([]interface{}, len(cols))
for i := range values {
scanArgs[i] = &values[i]
}
for rows.Next() {
if err := rows.Scan(scanArgs...); err != nil {
return false, err
}
if values[aliveIdx].Valid && strings.EqualFold(values[aliveIdx].String, "true") {
return true, nil
}
}
return false, rows.Err()
}
// containerRunning returns true if the named container is in `running` state.
func containerRunning(containerName string) bool {
cmd := exec.Command("docker", "inspect", "--format", "{{.State.Running}}", containerName)
out, err := cmd.Output()
if err != nil {
return false
}
return strings.TrimSpace(string(out)) == "true"
}
// connectDoris opens a long-lived MySQL-protocol connection to Doris.
func (env *TestEnvironment) connectDoris(t *testing.T) *sql.DB {
t.Helper()
dsn := fmt.Sprintf("root:@tcp(127.0.0.1:%d)/?parseTime=true&timeout=10s&readTimeout=60s&writeTimeout=60s",
env.dorisHostQueryPort)
db, err := sql.Open("mysql", dsn)
if err != nil {
t.Fatalf("sql.Open(mysql): %v", err)
}
db.SetMaxOpenConns(2)
if err := db.Ping(); err != nil {
_ = db.Close()
t.Fatalf("Doris ping failed: %v\nContainer logs:\n%s", err, dorisContainerLogs(env.dorisContainer))
}
return db
}
// createDorisIcebergCatalog registers a Doris EXTERNAL CATALOG of type=iceberg
// pointing at the SeaweedFS REST endpoint. Doris reuses Iceberg's standard
// REST client, so OAuth2 client credentials are passed via "credential".
func (env *TestEnvironment) createDorisIcebergCatalog(t *testing.T, db *sql.DB, warehouseBucket string) {
t.Helper()
icebergURI := fmt.Sprintf("http://host.docker.internal:%d", env.icebergPort)
s3Endpoint := fmt.Sprintf("http://host.docker.internal:%d", env.s3Port)
credential := env.accessKey + ":" + env.secretKey
// Drop first so a stale state from a previous run does not poison
// metadata caching. Doris versions before 2.1 use IF EXISTS at the end.
if _, err := db.Exec(fmt.Sprintf("DROP CATALOG IF EXISTS %s", quoteDorisIdent(dorisCatalogName))); err != nil {
t.Logf("DROP CATALOG IF EXISTS %s: %v (continuing)", dorisCatalogName, err)
}
createSQL := fmt.Sprintf(`CREATE CATALOG %s PROPERTIES (
"type" = "iceberg",
"iceberg.catalog.type" = "rest",
"uri" = %q,
"warehouse" = %q,
"credential" = %q,
"s3.endpoint" = %q,
"s3.access_key" = %q,
"s3.secret_key" = %q,
"s3.region" = "us-west-2",
"use_path_style" = "true"
)`,
quoteDorisIdent(dorisCatalogName),
icebergURI,
"s3://"+warehouseBucket,
credential,
s3Endpoint,
env.accessKey,
env.secretKey,
)
if _, err := db.Exec(createSQL); err != nil {
t.Fatalf("CREATE CATALOG %s failed: %v\nContainer logs:\n%s",
dorisCatalogName, err, dorisContainerLogs(env.dorisContainer))
}
// Refresh so namespaces/tables seeded via the REST API right before this
// step are visible to the first SHOW DATABASES query.
if _, err := db.Exec(fmt.Sprintf("REFRESH CATALOG %s", quoteDorisIdent(dorisCatalogName))); err != nil {
t.Logf("REFRESH CATALOG %s: %v (continuing)", dorisCatalogName, err)
}
}
// rowsContain runs query and returns true if any value of any column equals
// (case-insensitively) the wanted token. Doris's SHOW family returns
// version-dependent column counts, so we don't pin to a specific column.
func rowsContain(t *testing.T, db *sql.DB, query, want string) bool {
t.Helper()
rows, err := db.Query(query)
if err != nil {
t.Fatalf("%s: %v", query, err)
}
defer rows.Close()
cols, err := rows.Columns()
if err != nil {
t.Fatalf("Columns(): %v", err)
}
values := make([]sql.NullString, len(cols))
scanArgs := make([]interface{}, len(cols))
for i := range values {
scanArgs[i] = &values[i]
}
wantLower := strings.ToLower(want)
for rows.Next() {
if err := rows.Scan(scanArgs...); err != nil {
t.Fatalf("Scan: %v", err)
}
for _, v := range values {
if v.Valid && strings.Contains(strings.ToLower(v.String), wantLower) {
return true
}
}
}
if err := rows.Err(); err != nil {
t.Fatalf("rows.Err: %v", err)
}
return false
}
// equalStringSlicesIgnoreCase compares two []string for equality, ignoring case.
func equalStringSlicesIgnoreCase(got, want []string) bool {
if len(got) != len(want) {
return false
}
for i := range got {
if !strings.EqualFold(got[i], want[i]) {
return false
}
}
return true
}
// quoteDorisIdent wraps an identifier in backticks (Doris uses MySQL syntax),
// escaping any embedded backticks.
func quoteDorisIdent(name string) string {
return "`" + strings.ReplaceAll(name, "`", "``") + "`"
}
// dorisObjectName produces a backtick-quoted dotted reference like
// `catalog`.`namespace`.`table` so identifiers with hyphens or random
// suffixes parse without quoting issues.
func dorisObjectName(parts ...string) string {
quoted := make([]string, 0, len(parts))
for _, part := range parts {
quoted = append(quoted, quoteDorisIdent(part))
}
return strings.Join(quoted, ".")
}
// requestIcebergOAuthToken requests an OAuth2 client_credentials token from
// the SeaweedFS Iceberg REST catalog. Used to seed the catalog with a
// namespace and table directly through the REST API before Doris connects.
func requestIcebergOAuthToken(t *testing.T, env *TestEnvironment) string {
t.Helper()
resp, err := http.PostForm(fmt.Sprintf("http://%s:%d/v1/oauth/tokens", env.bindIP, env.icebergPort), url.Values{
"grant_type": {"client_credentials"},
"client_id": {env.accessKey},
"client_secret": {env.secretKey},
})
if err != nil {
t.Fatalf("POST /v1/oauth/tokens: %v", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
t.Fatalf("OAuth token request failed: status=%d body=%s", resp.StatusCode, body)
}
var tokenResp struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
}
if err := json.Unmarshal(body, &tokenResp); err != nil {
t.Fatalf("decode token response: %v", err)
}
if tokenResp.AccessToken == "" {
t.Fatal("got empty access_token")
}
return tokenResp.AccessToken
}
// createIcebergNamespace creates a single-level Iceberg namespace through
// the REST catalog. Wrapper around createIcebergNamespaceLevels for the
// common single-level case.
func createIcebergNamespace(t *testing.T, env *TestEnvironment, token, bucketName, namespace string) {
t.Helper()
createIcebergNamespaceLevels(t, env, token, bucketName, []string{namespace})
}
// createIcebergTable creates a table inside a single-level namespace through
// the REST catalog. The table is created with the canonical
// (id long not null, label string nullable) schema used by all subtests.
func createIcebergTable(t *testing.T, env *TestEnvironment, token, bucketName, namespace, tableName string) {
t.Helper()
createIcebergTableInLevels(t, env, token, bucketName, []string{namespace}, tableName)
}
// createIcebergNamespaceLevels creates a (possibly multi-level) Iceberg
// namespace via the REST catalog.
func createIcebergNamespaceLevels(t *testing.T, env *TestEnvironment, token, bucketName string, levels []string) {
t.Helper()
doIcebergJSONRequest(t, env, token, http.MethodPost, fmt.Sprintf("/v1/%s/namespaces", url.PathEscape(bucketName)), map[string]any{
"namespace": levels,
}, http.StatusOK, http.StatusConflict)
}
// createIcebergTableInLevels creates a table inside the given namespace levels.
// The namespace path component is encoded with the unit-separator (0x1F)
// convention used by the SeaweedFS Iceberg REST API.
func createIcebergTableInLevels(t *testing.T, env *TestEnvironment, token, bucketName string, levels []string, tableName string) {
t.Helper()
encodedNs := strings.Join(levels, "\x1F")
doIcebergJSONRequest(t, env, token, http.MethodPost,
fmt.Sprintf("/v1/%s/namespaces/%s/tables", url.PathEscape(bucketName), url.PathEscape(encodedNs)),
map[string]any{
"name": tableName,
"schema": map[string]any{
"type": "struct",
"schema-id": 0,
"fields": []map[string]any{
{"id": 1, "name": "id", "required": true, "type": "long"},
{"id": 2, "name": "label", "required": false, "type": "string"},
},
},
}, http.StatusOK)
}
const dorisWriterImage = "seaweedfs-doris-writer"
// buildDorisWriterImage builds the local PyIceberg writer image. Layer caching
// makes repeat invocations cheap; the first build pulls python:3.11-slim and
// pip-installs pyiceberg+pyarrow (~1-2 min in CI).
func buildDorisWriterImage(t *testing.T) {
t.Helper()
wd, err := os.Getwd()
if err != nil {
t.Fatalf("Failed to get working directory: %v", err)
}
cmd := exec.Command("docker", "build",
"-t", dorisWriterImage,
"-f", filepath.Join(wd, "Dockerfile.writer"),
wd,
)
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("Failed to build %s image: %v\n%s", dorisWriterImage, err, out)
}
}
// writeIcebergRows runs the PyIceberg writer container, which loads the
// already-created table and appends three rows.
func writeIcebergRows(t *testing.T, env *TestEnvironment, bucketName string, namespace []string, tableName string) {
t.Helper()
args := []string{
"run", "--rm",
"--add-host", "host.docker.internal:host-gateway",
dorisWriterImage,
"--catalog-url", fmt.Sprintf("http://host.docker.internal:%d", env.icebergPort),
"--warehouse", "s3://" + bucketName,
"--prefix", bucketName,
"--s3-endpoint", fmt.Sprintf("http://host.docker.internal:%d", env.s3Port),
"--access-key", env.accessKey,
"--secret-key", env.secretKey,
"--region", "us-west-2",
"--table", tableName,
}
for _, level := range namespace {
args = append(args, "--namespace", level)
}
cmd := exec.Command("docker", args...)
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("PyIceberg writer failed: %v\n%s", err, out)
}
t.Logf("PyIceberg writer output: %s", strings.TrimSpace(string(out)))
}
// doIcebergJSONRequest issues an authenticated JSON request to the Iceberg
// REST endpoint and returns the response body. It fails the test unless the
// response status matches one of expectedStatuses. Used by namespace and
// table seeding so the tests can fail fast with a useful error if the REST
// API rejects a setup call.
func doIcebergJSONRequest(t *testing.T, env *TestEnvironment, token, method, path string, payload any, expectedStatuses ...int) string {
t.Helper()
var body io.Reader
if payload != nil {
payloadBytes, err := json.Marshal(payload)
if err != nil {
t.Fatalf("marshal Iceberg request: %v", err)
}
body = bytes.NewReader(payloadBytes)
}
req, err := http.NewRequest(method, fmt.Sprintf("http://%s:%d%s", env.bindIP, env.icebergPort, path), body)
if err != nil {
t.Fatalf("create Iceberg request: %v", err)
}
req.Header.Set("Authorization", "Bearer "+token)
if payload != nil {
req.Header.Set("Content-Type", "application/json")
}
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
t.Fatalf("Iceberg request failed: %v", err)
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
for _, expectedStatus := range expectedStatuses {
if resp.StatusCode == expectedStatus {
return string(respBody)
}
}
t.Fatalf("Iceberg request returned unexpected status %d, want %v\nPath: %s\nBody: %s",
resp.StatusCode, expectedStatuses, path, respBody)
return ""
}
// createTableBucket creates an S3 table bucket using `weed shell`, which
// talks to the master over gRPC and bypasses the S3 SigV4 path so the test
// works whether IAM is enabled or not. The `-master` flag uses SeaweedFS's
// canonical `host:port.grpcPort` ServerAddress format produced by
// pb.NewServerAddress (see weed/pb/server_address.go) — the dot is the
// separator between the HTTP port and the gRPC port and is required, not
// a typo. Replacing it with a colon would make the parser treat the gRPC
// port as the HTTP port and synthesize the wrong gRPC port.
func createTableBucket(t *testing.T, env *TestEnvironment, bucketName string) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, env.weedBinary, "shell",
fmt.Sprintf("-master=%s:%d.%d", env.bindIP, env.masterPort, env.masterGrpcPort),
)
cmd.Stdin = strings.NewReader(fmt.Sprintf("s3tables.bucket -create -name %s -account 000000000000\nexit\n", bucketName))
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("Failed to create table bucket %s via weed shell: %v\nOutput: %s", bucketName, err, string(output))
}
t.Logf("Created table bucket: %s", bucketName)
}
// requireDorisRuntime skips the test in `-short` mode or when Docker isn't
// available, since the test cannot run without the Doris container.
func requireDorisRuntime(t *testing.T) {
t.Helper()
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
if !hasDocker() {
t.Skip("Docker not available, skipping Doris integration test")
}
}
// hasDocker reports whether `docker version` can run, which we treat as a
// sufficient signal that a Docker daemon is reachable from this process.
func hasDocker() bool {
cmd := exec.Command("docker", "version")
return cmd.Run() == nil
}
// randomString returns a lowercase alphanumeric string of the given length.
func randomString(length int) string {
const charset = "abcdefghijklmnopqrstuvwxyz0123456789"
b := make([]byte, length)
if _, err := rand.Read(b); err != nil {
panic("failed to generate random string: " + err.Error())
}
for i := range b {
b[i] = charset[int(b[i])%len(charset)]
}
return string(b)
}

View File

@@ -0,0 +1,12 @@
# Iceberg writer used by the Dremio integration test to populate a table with
# data files via PyIceberg, so the downstream Dremio SELECT verifies the read
# path against non-empty results rather than just COUNT(*) = 0.
FROM python:3.11-slim
WORKDIR /app
RUN pip install --no-cache-dir "pyiceberg[s3fs]" pyarrow
COPY append_rows.py /app/
ENTRYPOINT ["python3", "/app/append_rows.py"]

View File

@@ -0,0 +1,55 @@
# Dremio Iceberg Catalog Integration Test
This directory contains a Dremio integration smoke test for SeaweedFS's Iceberg REST Catalog implementation.
## What It Tests
`TestDremioIcebergCatalog` verifies the Dremio path end to end:
1. Starts a local SeaweedFS mini cluster with S3 Tables and Iceberg REST enabled.
2. Creates a SeaweedFS table bucket.
3. Creates an Iceberg namespace and empty table through the SeaweedFS REST catalog OAuth flow.
4. Starts `dremio/dremio-oss:25.2.0`.
5. Bootstraps a Dremio admin user and logs in.
6. Creates a Dremio `RESTCATALOG` source that points at the SeaweedFS catalog.
7. Submits Dremio SQL through `/api/v3/sql`, polls the job API, and reads job results.
8. Runs subtests against the SeaweedFS-backed Iceberg table:
- `BasicSelect`: Dremio is alive and answering SQL.
- `CountEmptyTable`: catalog-to-table resolution and a scan of an empty table.
- `ColumnProjection`: `SELECT id, label` succeeds and the response schema reports both columns. Failure here means Dremio could not parse the schema returned by the SeaweedFS catalog.
- `InformationSchemaColumns`: the table's columns are exposed through Dremio's metadata layer with the expected ordinal order.
- `InformationSchemaTables`: the table is registered in Dremio's `INFORMATION_SCHEMA`.
- `MultiLevelNamespace`: a 2-level Iceberg namespace (created via the REST API) is exposed by Dremio as nested folders, and a table inside it is queryable with dot-separated identifiers.
- `ReadWrittenDataCount` and `ReadWrittenDataValues`: a separate table is populated with three rows by a PyIceberg writer container (`Dockerfile.writer` + `append_rows.py`) before Dremio bootstraps; Dremio reads the data back and the values are verified. This exercises the actual data path, not just metadata.
The PyIceberg writer image is built on demand via Docker layer caching. The first build pulls `python:3.11-slim` and pip-installs PyIceberg + PyArrow (~1-2 min in CI); subsequent invocations are cheap.
## Running Locally
Build or install `weed`, then run:
```bash
cd test/s3tables/catalog_dremio
go test -v -timeout 20m .
```
The test requires Docker. The GitHub Actions job runs on `ubuntu-22.04` and executes the test for pull requests.
## Configuration
The test uses these fixed credentials for the local SeaweedFS IAM config:
- S3 access key: `AKIAIOSFODNN7EXAMPLE`
- S3 secret key: `wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY`
- Region: `us-west-2`
- Warehouse bucket: `iceberg-tables`
The Dremio source is configured via `POST /api/v3/catalog`; it is not configured in `dremio.conf`.
The Dremio container starts with the `plugins.restcatalog.enabled` support key enabled, which is required for the Iceberg REST Catalog source in Dremio OSS 25.2.
## Troubleshooting
- Ensure Docker is running: `docker version`
- Ensure `weed` is built or available on `PATH`
- Check host-gateway routing if Dremio cannot reach SeaweedFS: `docker run --add-host host.docker.internal:host-gateway --rm alpine getent hosts host.docker.internal`
- Check Dremio logs from the failed test output; the harness prints the Dremio container tail on Dremio startup, source setup, or job failures.

View File

@@ -0,0 +1,87 @@
#!/usr/bin/env python3
"""Append rows to an existing Iceberg table via the SeaweedFS REST catalog.
Used by the Dremio integration test to materialize data files so a downstream
SELECT from Dremio verifies the read path against non-empty results.
Usage:
python3 append_rows.py \\
--catalog-url http://localhost:8181 \\
--warehouse s3://my-bucket \\
--prefix my-bucket \\
--s3-endpoint http://localhost:8333 \\
--access-key AKIA... --secret-key wJalr... \\
--namespace foo --namespace bar \\
--table events
"""
import argparse
import sys
import pyarrow as pa
from pyiceberg.catalog import load_catalog
def main() -> int:
p = argparse.ArgumentParser()
p.add_argument("--catalog-url", required=True)
p.add_argument("--warehouse", required=True, help="s3://<bucket-name>")
p.add_argument("--prefix", required=True, help="REST catalog prefix (table bucket name)")
p.add_argument("--s3-endpoint", required=True, help="http://host:port")
p.add_argument("--access-key", required=True)
p.add_argument("--secret-key", required=True)
p.add_argument("--region", default="us-east-1")
p.add_argument(
"--namespace",
action="append",
required=True,
help="One per level (e.g. --namespace foo --namespace bar for foo.bar).",
)
p.add_argument("--table", required=True)
args = p.parse_args()
# `credential` triggers OAuth2 client_credentials against
# <catalog_uri>/v1/oauth/tokens, matching the helper Go test uses for
# REST-API table creation. The s3.* keys are needed for parquet writes.
catalog = load_catalog(
"rest",
**{
"type": "rest",
"uri": args.catalog_url,
"warehouse": args.warehouse,
"prefix": args.prefix,
"credential": f"{args.access_key}:{args.secret_key}",
"s3.access-key-id": args.access_key,
"s3.secret-access-key": args.secret_key,
"s3.endpoint": args.s3_endpoint,
"s3.region": args.region,
"s3.path-style-access": "true",
},
)
table_id = tuple(args.namespace) + (args.table,)
table = catalog.load_table(table_id)
# Match the Iceberg table schema: id is `required long`, label is
# `optional string`. Default pyarrow columns are nullable, which fails
# PyIceberg's required-field compatibility check.
arrow_schema = pa.schema(
[
pa.field("id", pa.int64(), nullable=False),
pa.field("label", pa.string(), nullable=True),
]
)
arrow_table = pa.Table.from_pydict(
{
"id": [1, 2, 3],
"label": ["one", "two", "three"],
},
schema=arrow_schema,
)
table.append(arrow_table)
print(f"appended {arrow_table.num_rows} rows to {'.'.join(table_id)}")
return 0
if __name__ == "__main__":
sys.exit(main())

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,15 @@
# Seeds a Lance dataset so DuckDB has something to read. The catalog records
# where a table lives and does not carry its data, so the writing half is
# pylance; DuckDB then reads it over S3 with no catalog involved.
FROM python:3.11-slim
WORKDIR /app
RUN pip install --no-cache-dir \
"lance-namespace==0.8.6" \
"pylance==10.0.0" \
"pyarrow==25.0.1"
COPY seed_table.py /app/
CMD ["python3", "/app/seed_table.py", "--help"]

View File

@@ -0,0 +1,66 @@
# DuckDB Lance Integration Test
Reads SeaweedFS Lance tables from DuckDB's `lance` core extension, the
counterpart of the DuckDB Iceberg tests in `../catalog/`.
## What this one proves that the others do not
The LanceDB and Spark suites go through the catalog. DuckDB does not: it reaches
the data over S3 with no namespace involved. That exercises the other half of the
design — a table bucket's layout is a valid Lance dataset directory, so a table
stays readable when the catalog is not in the path.
## What it does
`TestDuckDBLance`:
1. Starts a `weed mini` cluster with S3 and the Lance Namespace enabled.
2. Creates a table bucket declared `LANCE`, then declares a table through the
namespace and writes a dataset into it with pylance.
3. Runs `duckdb_lance_ops.sql` in `duckdb/duckdb:latest`.
| Step | What it proves |
| --- | --- |
| `__lance_scan(s3://…)` | DuckDB reads a table this catalog created |
| `DESCRIBE` | the schema survived, vector column included |
| `WHERE id < 5` | the filter path |
| `lance_vector_search` | vector search over data behind SeaweedFS |
| a `.lance` path | the replacement scan works on a suffixed path |
| a suffix-less path | and does **not** see one without the suffix |
## The `.lance` suffix
DuckDB's replacement scan — `SELECT * FROM 's3://…'` — recognises a Lance dataset
by a `.lance` path suffix. Tables created through this catalog deliberately have
none: the catalog entry *is* the dataset directory, table names may not contain a
dot, and a suffix would leak into ARNs and policies.
So from DuckDB, a table in a SeaweedFS table bucket is read with
`__lance_scan('s3://bucket/namespace/table')` rather than the bare `FROM 's3://…'`
form. The test asserts both halves, so if the extension ever recognises a
suffix-less directory the test fails and tells us to update the documentation.
## Credentials
```sql
CREATE SECRET seaweedfs (
TYPE lance,
ACCESS_KEY_ID '…', SECRET_ACCESS_KEY '…',
REGION 'us-east-1',
ENDPOINT 'http://seaweed:8333',
ALLOW_HTTP true,
VIRTUAL_HOSTED_STYLE_REQUEST false
);
```
Those are object_store's key names, the same as everywhere else Lance touches
storage.
## Running it
cd test/s3tables/catalog_duckdb_lance
(cd ../../../weed && go build .) # the harness runs this binary
go test -run TestDuckDBLance -v -timeout 30m .
Skipped without Docker, in `-short` mode, and if the DuckDB image cannot load the
extension.

View File

@@ -0,0 +1,48 @@
-- Read a SeaweedFS Lance table from DuckDB.
--
-- The lance extension reaches the data over S3 rather than through the
-- namespace, so what is exercised here is the layout on the S3 door and the
-- credentials, not the catalog protocol. That is the point: this is the client
-- that proves a Lance table stays readable without a catalog at all.
--
-- Placeholders are substituted by the Go harness: __ENDPOINT__, __KEY__,
-- __SECRET__, __TABLE__ (s3://bucket/ns/table), __SUFFIXED__ (the same data at a
-- path ending in .lance).
INSTALL lance;
LOAD lance;
CREATE SECRET seaweedfs (
TYPE lance,
ACCESS_KEY_ID '__KEY__',
SECRET_ACCESS_KEY '__SECRET__',
REGION 'us-east-1',
ENDPOINT '__ENDPOINT__',
ALLOW_HTTP true,
VIRTUAL_HOSTED_STYLE_REQUEST false
);
-- 1. The table the catalog created, read by URI. A table bucket's layout is a
-- valid Lance dataset directory, which is what makes this possible.
SELECT 'scan_rows=' || count(*) FROM __lance_scan('__TABLE__');
-- 2. Its schema survived, vector column included. Ordered, because string_agg
-- over an unordered relation is free to return the names in any order.
SELECT 'scan_columns=' || string_agg(column_name, ',' ORDER BY column_name)
FROM (DESCRIBE SELECT * FROM __lance_scan('__TABLE__'));
-- 3. A filter, so it is not only a full scan.
SELECT 'filtered_rows=' || count(*) FROM __lance_scan('__TABLE__') WHERE id < 5;
-- 4. Vector search, which is what the format is for. No index is built here, so
-- this is a brute-force search; the ids nearest the query are what matters.
SELECT 'nearest=' || string_agg(id::VARCHAR, ',' ORDER BY _distance ASC, id ASC)
FROM lance_vector_search('__TABLE__', 'vector',
[1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0], k := 3);
-- 5. DuckDB's replacement scan recognises a path by its .lance suffix. Tables
-- this catalog creates deliberately have no suffix - the name is the table's
-- name, and a suffix would leak into ARNs and policies - so the bare
-- SELECT ... FROM 's3://…' form does not see them, and __lance_scan is the
-- way in. This asserts both halves so that a change upstream is noticed.
SELECT 'suffixed_rows=' || count(*) FROM '__SUFFIXED__';

View File

@@ -0,0 +1,390 @@
// Package duckdblance reads SeaweedFS Lance tables from DuckDB's lance
// extension, the counterpart of the DuckDB Iceberg tests next door.
//
// DuckDB reaches the data over S3 rather than through the namespace, so what
// this proves is the other half of the design: a table bucket's layout is a
// valid Lance dataset directory, and a table stays readable with no catalog in
// the path at all. It also pins the one place that costs us - DuckDB's
// replacement scan recognises a dataset by its .lance suffix, which tables
// created through this catalog deliberately do not have.
package duckdblance
import (
"context"
"fmt"
"math/rand"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/test/testutil"
)
const (
// Tracks whatever DuckDB users are actually running; the lance extension is
// a core extension, so there is nothing to pin beyond the image.
duckDBImage = "duckdb/duckdb:latest"
// Seeds a dataset for DuckDB to read.
seedImage = "seaweedfs-lance-seed"
startupTimeout = 60 * time.Second
clientTimeout = 15 * time.Minute
seededRows = 128
)
// TestDuckDBLance reads a table this catalog created, from DuckDB.
func TestDuckDBLance(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
if !hasDocker() {
t.Skip("Docker not available, skipping DuckDB Lance integration test")
}
env := newEnvironment(t)
defer env.cleanup()
env.start(t)
bucket := "duckdb-" + randomSuffix()
env.createTableBucket(t, bucket)
requireDuckDBLance(t)
buildSeedImage(t)
env.seedTable(t, bucket)
env.runDuckDB(t, bucket)
}
type environment struct {
weedBinary string
dataDir string
bindIP string
masterPort int
masterGrpcPort int
volumePort int
volumeGrpcPort int
filerPort int
filerGrpcPort int
s3Port int
s3GrpcPort int
lancePort int
accessKey string
secretKey string
weedCancel context.CancelFunc
weedCmd *exec.Cmd
}
func newEnvironment(t *testing.T) *environment {
t.Helper()
wd, err := os.Getwd()
if err != nil {
t.Fatalf("get working directory: %v", err)
}
seaweedDir := wd
for i := 0; i < 5; i++ {
if _, err := os.Stat(filepath.Join(seaweedDir, "go.mod")); err == nil {
break
}
seaweedDir = filepath.Dir(seaweedDir)
}
weedBinary := filepath.Join(seaweedDir, "weed", "weed")
if info, statErr := os.Stat(weedBinary); statErr == nil && !info.IsDir() {
// `make test` builds first; a plain `go test` will otherwise drive a
// binary from days ago and report a pass for code it never ran.
t.Logf("using %s, built %s", weedBinary, info.ModTime().Format(time.RFC3339))
} else {
weedBinary = "weed"
if _, err := exec.LookPath(weedBinary); err != nil {
t.Skip("weed binary not found, skipping integration test")
}
}
dataDir, err := os.MkdirTemp("", "seaweed-duckdb-lance-test-*")
if err != nil {
t.Fatalf("create temp dir: %v", err)
}
ports := testutil.MustAllocatePorts(t, 9)
return &environment{
weedBinary: weedBinary,
dataDir: dataDir,
bindIP: testutil.FindBindIP(),
masterPort: ports[0],
masterGrpcPort: ports[1],
volumePort: ports[2],
volumeGrpcPort: ports[3],
filerPort: ports[4],
filerGrpcPort: ports[5],
s3Port: ports[6],
s3GrpcPort: ports[7],
lancePort: ports[8],
accessKey: "AKIAIOSFODNN7EXAMPLE",
secretKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
}
}
func (env *environment) start(t *testing.T) {
t.Helper()
iamConfigPath, err := testutil.WriteIAMConfig(env.dataDir, env.accessKey, env.secretKey)
if err != nil {
t.Fatalf("write IAM config: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
env.weedCancel = cancel
cmd := exec.CommandContext(ctx, env.weedBinary, "mini",
"-master.port", fmt.Sprintf("%d", env.masterPort),
"-master.port.grpc", fmt.Sprintf("%d", env.masterGrpcPort),
"-volume.port", fmt.Sprintf("%d", env.volumePort),
"-volume.port.grpc", fmt.Sprintf("%d", env.volumeGrpcPort),
"-filer.port", fmt.Sprintf("%d", env.filerPort),
"-filer.port.grpc", fmt.Sprintf("%d", env.filerGrpcPort),
"-s3.port", fmt.Sprintf("%d", env.s3Port),
"-s3.port.grpc", fmt.Sprintf("%d", env.s3GrpcPort),
"-s3.port.lance", fmt.Sprintf("%d", env.lancePort),
"-s3.config", iamConfigPath,
"-ip", env.bindIP,
"-ip.bind", "0.0.0.0",
"-dir", env.dataDir,
)
cmd.Dir = env.dataDir
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Env = append(os.Environ(),
"AWS_ACCESS_KEY_ID="+env.accessKey,
"AWS_SECRET_ACCESS_KEY="+env.secretKey,
)
if err := cmd.Start(); err != nil {
t.Fatalf("start SeaweedFS: %v", err)
}
env.weedCmd = cmd
// The namespace answers /v1/table once it is serving, which is a cheaper
// readiness check than waiting on a bucket that does not exist yet.
url := fmt.Sprintf("http://%s:%d/v1/table", env.bindIP, env.lancePort)
if !waitForHTTP(url, startupTimeout) {
t.Fatalf("the Lance namespace did not become ready at %s", url)
}
}
// waitForHTTP polls until the URL answers at all. An auth refusal counts: it
// means the server is up, which is the only thing being waited on.
func waitForHTTP(url string, timeout time.Duration) bool {
client := &http.Client{Timeout: 2 * time.Second}
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
resp, err := client.Get(url)
if err != nil {
time.Sleep(500 * time.Millisecond)
continue
}
status := resp.StatusCode
resp.Body.Close()
if status < 500 {
return true
}
time.Sleep(500 * time.Millisecond)
}
return false
}
func (env *environment) cleanup() {
if env.weedCancel != nil {
env.weedCancel()
}
if env.weedCmd != nil {
_ = env.weedCmd.Wait()
}
if env.dataDir != "" {
_ = os.RemoveAll(env.dataDir)
}
}
// createTableBucket makes the bucket LanceDB will read through, declared LANCE
// so the catalog refuses anything of another format in it.
func (env *environment) createTableBucket(t *testing.T, bucket string) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, env.weedBinary, "shell",
fmt.Sprintf("-master=%s:%d.%d", env.bindIP, env.masterPort, env.masterGrpcPort),
)
cmd.Stdin = strings.NewReader(fmt.Sprintf(
"s3tables.bucket -create -name %s -format LANCE -account 000000000000\nexit\n", bucket))
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("create table bucket %s: %v\n%s", bucket, err, out)
}
// weed shell reports a command's own failure on stdout and still exits 0, so
// the exit code alone would let a missing bucket through and turn a setup
// failure into a confusing engine failure later.
if !env.tableBucketExists(t, bucket) {
t.Fatalf("table bucket %s was not created:\n%s", bucket, out)
}
t.Logf("created LANCE table bucket %s", bucket)
}
// tableBucketExists asks the namespace, which lists table buckets at its root.
func (env *environment) tableBucketExists(t *testing.T, bucket string) bool {
t.Helper()
url := fmt.Sprintf("http://%s:%d/v1/namespace/%s/exists", env.bindIP, env.lancePort, bucket)
resp, err := http.Post(url, "application/json", strings.NewReader("{}"))
if err != nil {
t.Fatalf("ask the namespace whether %s exists: %v", bucket, err)
}
defer resp.Body.Close()
return resp.StatusCode == http.StatusOK
}
// runSpark runs the SQL driver inside the stock Spark image. The connector is
// pulled from Maven at submit time, the way the Iceberg Spark suite pulls its
// runtime, so nothing has to be built here.
// requireDuckDBLance skips rather than fails when the image cannot load the
// extension, so a DuckDB build without it reads as "not applicable" rather than
// as a broken catalog.
func requireDuckDBLance(t *testing.T) {
t.Helper()
const ready = "lance extension ready"
cmd := exec.Command("docker", "run", "--rm", "--entrypoint", "duckdb", duckDBImage,
"-c", fmt.Sprintf("INSTALL lance; LOAD lance; SELECT '%s' AS marker;", ready))
out, err := cmd.CombinedOutput()
if err != nil || !strings.Contains(string(out), ready) {
t.Skipf("DuckDB image cannot load the lance extension: %v\n%s", err, out)
}
}
func buildSeedImage(t *testing.T) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
cmd := exec.CommandContext(ctx, "docker", "build", "-t", seedImage, "-f", "Dockerfile.seed", ".")
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("build the seed image: %v\n%s", err, out)
}
}
// seedTable declares a table through the namespace and writes a dataset into
// it, which is the split the catalog serves: it records where a table lives and
// does not carry its data.
func (env *environment) seedTable(t *testing.T, bucket string) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), clientTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, "docker", "run", "--rm",
"--add-host", "host.docker.internal:host-gateway",
"-e", "AWS_ACCESS_KEY_ID="+env.accessKey,
"-e", "AWS_SECRET_ACCESS_KEY="+env.secretKey,
"-e", "AWS_REGION=us-east-1",
seedImage,
"python3", "/app/seed_table.py",
"--namespace-url", fmt.Sprintf("http://host.docker.internal:%d", env.lancePort),
"--s3-endpoint", fmt.Sprintf("http://host.docker.internal:%d", env.s3Port),
"--bucket", bucket,
"--rows", fmt.Sprintf("%d", seededRows),
"--access-key", env.accessKey,
"--secret-key", env.secretKey,
)
out, err := cmd.CombinedOutput()
t.Logf("seeder:\n%s", out)
if err != nil {
t.Fatalf("seeding the table failed: %v", err)
}
}
// runDuckDB substitutes the endpoint and paths into the SQL and runs it.
func (env *environment) runDuckDB(t *testing.T, bucket string) {
t.Helper()
sqlBytes, err := os.ReadFile("duckdb_lance_ops.sql")
if err != nil {
t.Fatalf("read the SQL: %v", err)
}
table := fmt.Sprintf("s3://%s/ml/embeddings", bucket)
sql := strings.NewReplacer(
"__ENDPOINT__", fmt.Sprintf("http://host.docker.internal:%d", env.s3Port),
"__KEY__", env.accessKey,
"__SECRET__", env.secretKey,
"__TABLE__", table,
"__SUFFIXED__", table+"-direct.lance",
).Replace(string(sqlBytes))
ctx, cancel := context.WithTimeout(context.Background(), clientTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, "docker", "run", "--rm", "-i",
"--add-host", "host.docker.internal:host-gateway",
"--entrypoint", "duckdb", duckDBImage, "-c", sql)
out, err := cmd.CombinedOutput()
t.Logf("DuckDB output:\n%s", out)
if err != nil {
t.Fatalf("the DuckDB query failed: %v", err)
}
output := string(out)
for _, want := range []string{
fmt.Sprintf("scan_rows=%d", seededRows),
"scan_columns=id,title,vector",
"filtered_rows=5",
"nearest=1,0,2",
fmt.Sprintf("suffixed_rows=%d", seededRows),
} {
if !strings.Contains(output, want) {
t.Fatalf("DuckDB did not report %q", want)
}
}
// The other half of the suffix rule: a table this catalog created has no
// .lance suffix, so DuckDB's replacement scan does not see it and
// __lance_scan is the way in. If this ever starts working, the docs saying
// otherwise are wrong.
bare := exec.CommandContext(ctx, "docker", "run", "--rm", "-i",
"--add-host", "host.docker.internal:host-gateway",
"--entrypoint", "duckdb", duckDBImage,
"-c", fmt.Sprintf("INSTALL lance; LOAD lance; SELECT count(*) FROM '%s';", table))
bareOut, bareErr := bare.CombinedOutput()
// DuckDB exits nonzero for any error, so the exit status alone does not tell
// "the replacement scan refused the path" from "the query never ran": require
// the catalog error either way.
if !strings.Contains(string(bareOut), "does not exist") {
t.Fatalf("a suffix-less path did not fail the way the docs say it does (%v); "+
"if the replacement scan now reads it, update them:\n%s", bareErr, bareOut)
}
t.Logf("a suffix-less path is not seen by the replacement scan, as expected")
}
// hasDocker reports whether a Docker daemon answers. Bounded, because an
// unhealthy daemon makes `docker version` hang, and this runs before the test
// has a timeout of its own: better to skip than to eat the whole budget.
func hasDocker() bool {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
return exec.CommandContext(ctx, "docker", "version").Run() == nil
}
func randomSuffix() string {
const charset = "abcdefghijklmnopqrstuvwxyz0123456789"
suffix := make([]byte, 8)
for i := range suffix {
suffix[i] = charset[rand.Intn(len(charset))]
}
return string(suffix)
}

View File

@@ -0,0 +1,79 @@
#!/usr/bin/env python3
"""Declare a table through the namespace and write a dataset into it.
Also writes the same rows to a second path whose name ends in `.lance`, because
DuckDB's replacement scan recognises a dataset by that suffix and tables created
through this catalog do not have one. The test asserts both behaviours so a
change upstream is noticed rather than silently making the docs wrong.
"""
import argparse
import sys
import warnings
warnings.filterwarnings("ignore")
import lance
import lance_namespace as ln
import pyarrow as pa
DIM = 8
def rows(count):
return pa.table(
{
"id": pa.array(list(range(count)), type=pa.int64()),
"title": pa.array([f"row-{i}" for i in range(count)]),
"vector": pa.array(
[[float(i) + d for d in range(DIM)] for i in range(count)],
type=pa.list_(pa.float32(), DIM),
),
}
)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--namespace-url", required=True)
parser.add_argument("--s3-endpoint", required=True)
parser.add_argument("--bucket", required=True)
parser.add_argument("--namespace", default="ml")
parser.add_argument("--table", default="embeddings")
parser.add_argument("--rows", type=int, default=128)
parser.add_argument("--access-key", default="any")
parser.add_argument("--secret-key", default="any")
args = parser.parse_args()
storage = {
"aws_endpoint": args.s3_endpoint,
"allow_http": "true",
"aws_access_key_id": args.access_key,
"aws_secret_access_key": args.secret_key,
"aws_region": "us-east-1",
}
ns = ln.connect("rest", {"uri": args.namespace_url})
ns.create_namespace(ln.CreateNamespaceRequest(id=[args.bucket], mode="EXIST_OK"))
ns.create_namespace(
ln.CreateNamespaceRequest(id=[args.bucket, args.namespace], mode="EXIST_OK")
)
declared = ns.declare_table(
ln.DeclareTableRequest(id=[args.bucket, args.namespace, args.table])
)
lance.write_dataset(rows(args.rows), declared.location, storage_options=storage,
mode="overwrite")
print(f"seeded {args.rows} rows at {declared.location}")
# The same data under a name DuckDB's replacement scan recognises. Written
# directly rather than declared, because a table name containing a dot is
# not a valid catalog name.
suffixed = f"s3://{args.bucket}/{args.namespace}/{args.table}-direct.lance"
lance.write_dataset(rows(args.rows), suffixed, storage_options=storage,
mode="overwrite")
print(f"seeded {args.rows} rows at {suffixed}")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,22 @@
# LanceDB client for the SeaweedFS Lance Namespace integration test.
#
# The Iceberg suites point Spark, Trino and ClickHouse at the catalog; this is
# the same idea for Lance. LanceDB connects with connect_namespace("rest", ...),
# which speaks the routes this catalog implements, so what it exercises is the
# protocol rather than requests we wrote ourselves.
FROM python:3.11-slim
WORKDIR /app
# Pinned to the versions this suite was verified against. An unrelated upstream
# release should not be able to change what an old commit reproduces, and this
# client is the thing under test as much as the server is.
RUN pip install --no-cache-dir \
"lancedb==0.37.1" \
"lance-namespace==0.8.6" \
"pylance==10.0.0" \
"pyarrow==25.0.1"
COPY lancedb_ops.py /app/
CMD ["python3", "/app/lancedb_ops.py", "--help"]

View File

@@ -0,0 +1,56 @@
# LanceDB Integration Test
Drives the SeaweedFS Lance Namespace with [LanceDB](https://lancedb.com), the way
`catalog_spark`, `catalog_trino` and `catalog_clickhouse` drive the Iceberg REST
catalog with their engines.
## Why a real client
Every serious bug in this catalog so far looked correct to a request written by
hand: a deregister that deleted the dataset, an S3 door that refused every Lance
file, a namespace that listed tables it would then deny. A hand-built HTTP test
checks the shape of a response. A client checks whether the response is *usable* —
that the location it hands back, the storage options beside it and the layout
rules on the S3 door all line up at once.
LanceDB connects with `connect_namespace("rest", ...)`, which speaks the routes
this catalog implements, so what runs here is the protocol rather than our own
idea of it.
## What it does
`TestLanceDBNamespace`:
1. Starts a `weed mini` cluster with S3 and the Lance Namespace enabled.
2. Creates a table bucket declared `LANCE`, so the catalog refuses tables of any
other format in it.
3. Builds `Dockerfile.client` (LanceDB, pylance, lance-namespace) and runs
`lancedb_ops.py` against the namespace.
Inside the container:
| Step | What it proves |
| --- | --- |
| seed a table | the namespace's location and credentials are enough to write |
| `table_names` | the catalog is browsable through LanceDB |
| `open_table` | LanceDB resolves a table through the catalog and reads it |
| schema check | the vector column survived the round trip |
| `create_index` | an IVF_PQ index builds, and its files land through the S3 door's layout rules |
| `search(...)` | approximate search over that index, on data behind SeaweedFS |
| `where("id < 5")` | so does the scan path, not only the index |
| `create_table` | LanceDB declares through the namespace and writes the data itself |
| `create_table` with pushdown | either the client falls back and the table is complete, or the catalog refuses with the spec's Unsupported and leaves nothing behind |
| direct `lance.dataset(uri)` | the catalog stays optional; the dataset opens without it |
The seeding is pylance rather than LanceDB, because the namespace records where
a table lives and does not carry its data. That split is the design, not a
limitation of the test.
## Running it
cd test/s3tables/catalog_lancedb
(cd ../../../weed && go build .) # the harness runs this binary
go test -run TestLanceDBNamespace -v -timeout 30m .
Skipped without Docker, and in `-short` mode. The first run builds the client
image, which takes a few minutes; later runs reuse it.

View File

@@ -0,0 +1,291 @@
// Package lancedb drives the SeaweedFS Lance Namespace with LanceDB, the way
// the catalog_spark, catalog_trino and catalog_clickhouse suites drive the
// Iceberg REST catalog with their engines.
//
// A catalog is only as good as what a real client can do with it. Every serious
// bug in this surface so far - a deregister that deleted the dataset, an S3 door
// that refused every Lance file, a namespace that listed tables it would then
// deny - looked correct to a request written by hand.
package lancedb
import (
"context"
"fmt"
"math/rand"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/test/testutil"
)
const (
clientImage = "seaweedfs-lancedb-test"
startupTimeout = 60 * time.Second
clientTimeout = 15 * time.Minute
)
// TestLanceDBNamespace runs LanceDB against the namespace end to end: it lists
// what the catalog holds, opens a table through it, searches the vectors, and
// reads the same dataset straight off its URI to show the catalog stays
// optional.
func TestLanceDBNamespace(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
if !hasDocker() {
t.Skip("Docker not available, skipping LanceDB integration test")
}
env := newEnvironment(t)
defer env.cleanup()
env.start(t)
bucket := "lancedb-" + randomSuffix()
env.createTableBucket(t, bucket)
buildClientImage(t)
env.runClient(t, bucket)
}
type environment struct {
weedBinary string
dataDir string
bindIP string
masterPort int
masterGrpcPort int
volumePort int
volumeGrpcPort int
filerPort int
filerGrpcPort int
s3Port int
s3GrpcPort int
lancePort int
accessKey string
secretKey string
weedCancel context.CancelFunc
weedCmd *exec.Cmd
}
func newEnvironment(t *testing.T) *environment {
t.Helper()
wd, err := os.Getwd()
if err != nil {
t.Fatalf("get working directory: %v", err)
}
seaweedDir := wd
for i := 0; i < 5; i++ {
if _, err := os.Stat(filepath.Join(seaweedDir, "go.mod")); err == nil {
break
}
seaweedDir = filepath.Dir(seaweedDir)
}
weedBinary := filepath.Join(seaweedDir, "weed", "weed")
if info, statErr := os.Stat(weedBinary); statErr == nil && !info.IsDir() {
// `make test` builds first; a plain `go test` will otherwise drive a
// binary from days ago and report a pass for code it never ran.
t.Logf("using %s, built %s", weedBinary, info.ModTime().Format(time.RFC3339))
} else {
weedBinary = "weed"
if _, err := exec.LookPath(weedBinary); err != nil {
t.Skip("weed binary not found, skipping integration test")
}
}
dataDir, err := os.MkdirTemp("", "seaweed-lancedb-test-*")
if err != nil {
t.Fatalf("create temp dir: %v", err)
}
ports := testutil.MustAllocatePorts(t, 9)
return &environment{
weedBinary: weedBinary,
dataDir: dataDir,
bindIP: testutil.FindBindIP(),
masterPort: ports[0],
masterGrpcPort: ports[1],
volumePort: ports[2],
volumeGrpcPort: ports[3],
filerPort: ports[4],
filerGrpcPort: ports[5],
s3Port: ports[6],
s3GrpcPort: ports[7],
lancePort: ports[8],
accessKey: "AKIAIOSFODNN7EXAMPLE",
secretKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
}
}
func (env *environment) start(t *testing.T) {
t.Helper()
iamConfigPath, err := testutil.WriteIAMConfig(env.dataDir, env.accessKey, env.secretKey)
if err != nil {
t.Fatalf("write IAM config: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
env.weedCancel = cancel
cmd := exec.CommandContext(ctx, env.weedBinary, "mini",
"-master.port", fmt.Sprintf("%d", env.masterPort),
"-master.port.grpc", fmt.Sprintf("%d", env.masterGrpcPort),
"-volume.port", fmt.Sprintf("%d", env.volumePort),
"-volume.port.grpc", fmt.Sprintf("%d", env.volumeGrpcPort),
"-filer.port", fmt.Sprintf("%d", env.filerPort),
"-filer.port.grpc", fmt.Sprintf("%d", env.filerGrpcPort),
"-s3.port", fmt.Sprintf("%d", env.s3Port),
"-s3.port.grpc", fmt.Sprintf("%d", env.s3GrpcPort),
"-s3.port.lance", fmt.Sprintf("%d", env.lancePort),
"-s3.config", iamConfigPath,
"-ip", env.bindIP,
"-ip.bind", "0.0.0.0",
"-dir", env.dataDir,
)
cmd.Dir = env.dataDir
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Env = append(os.Environ(),
"AWS_ACCESS_KEY_ID="+env.accessKey,
"AWS_SECRET_ACCESS_KEY="+env.secretKey,
)
if err := cmd.Start(); err != nil {
t.Fatalf("start SeaweedFS: %v", err)
}
env.weedCmd = cmd
// The namespace answers /v1/table once it is serving, which is a cheaper
// readiness check than waiting on a bucket that does not exist yet.
url := fmt.Sprintf("http://%s:%d/v1/table", env.bindIP, env.lancePort)
if !waitForHTTP(url, startupTimeout) {
t.Fatalf("the Lance namespace did not become ready at %s", url)
}
}
// waitForHTTP polls until the URL answers at all. An auth refusal counts: it
// means the server is up, which is the only thing being waited on.
func waitForHTTP(url string, timeout time.Duration) bool {
client := &http.Client{Timeout: 2 * time.Second}
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
resp, err := client.Get(url)
if err != nil {
time.Sleep(500 * time.Millisecond)
continue
}
status := resp.StatusCode
resp.Body.Close()
if status < 500 {
return true
}
time.Sleep(500 * time.Millisecond)
}
return false
}
func (env *environment) cleanup() {
if env.weedCancel != nil {
env.weedCancel()
}
if env.weedCmd != nil {
_ = env.weedCmd.Wait()
}
if env.dataDir != "" {
_ = os.RemoveAll(env.dataDir)
}
}
// createTableBucket makes the bucket LanceDB will read through, declared LANCE
// so the catalog refuses anything of another format in it.
func (env *environment) createTableBucket(t *testing.T, bucket string) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, env.weedBinary, "shell",
fmt.Sprintf("-master=%s:%d.%d", env.bindIP, env.masterPort, env.masterGrpcPort),
)
cmd.Stdin = strings.NewReader(fmt.Sprintf(
"s3tables.bucket -create -name %s -format LANCE -account 000000000000\nexit\n", bucket))
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("create table bucket %s: %v\n%s", bucket, err, out)
}
t.Logf("created LANCE table bucket %s", bucket)
}
func buildClientImage(t *testing.T) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
cmd := exec.CommandContext(ctx, "docker", "build",
"-t", clientImage, "-f", "Dockerfile.client", ".")
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("build the LanceDB client image: %v\n%s", err, out)
}
}
func (env *environment) runClient(t *testing.T, bucket string) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), clientTimeout)
defer cancel()
// The container reaches the gateway through the host gateway address, which
// is not the address the namespace advertises to its own clients.
namespaceURL := fmt.Sprintf("http://host.docker.internal:%d", env.lancePort)
s3Endpoint := fmt.Sprintf("http://host.docker.internal:%d", env.s3Port)
cmd := exec.CommandContext(ctx, "docker", "run", "--rm",
"--add-host", "host.docker.internal:host-gateway",
// Also in the environment, not only in storage_options: LanceDB takes
// some paths through the options the namespace vends, and a gateway
// without STS vends none, leaving lance's provider chain to find them.
"-e", "AWS_ACCESS_KEY_ID="+env.accessKey,
"-e", "AWS_SECRET_ACCESS_KEY="+env.secretKey,
"-e", "AWS_REGION=us-east-1",
"-e", "AWS_ENDPOINT_URL="+s3Endpoint,
"-e", "AWS_ALLOW_HTTP=true",
clientImage,
"python3", "/app/lancedb_ops.py",
"--namespace-url", namespaceURL,
"--s3-endpoint", s3Endpoint,
"--bucket", bucket,
"--access-key", env.accessKey,
"--secret-key", env.secretKey,
)
out, err := cmd.CombinedOutput()
t.Logf("LanceDB client output:\n%s", out)
if err != nil {
t.Fatalf("the LanceDB client failed: %v", err)
}
if !strings.Contains(string(out), "PASS") {
t.Fatalf("the LanceDB client did not report PASS")
}
}
func hasDocker() bool {
return exec.Command("docker", "version").Run() == nil
}
func randomSuffix() string {
const charset = "abcdefghijklmnopqrstuvwxyz0123456789"
suffix := make([]byte, 8)
for i := range suffix {
suffix[i] = charset[rand.Intn(len(charset))]
}
return string(suffix)
}

View File

@@ -0,0 +1,244 @@
#!/usr/bin/env python3
"""Drive the SeaweedFS Lance Namespace with LanceDB.
The existing client test uses `lance_namespace` and `pylance` directly, which is
the protocol's reference client. LanceDB is what people actually point at a
catalog: it connects with `connect_namespace("rest", ...)`, lists what is there,
opens a table and searches it. This checks the catalog against that path, the
way the Spark, Trino and ClickHouse suites check the Iceberg one.
Everything it prints is either "PASS" or a line starting with FAIL, so the Go
harness can report the first real failure rather than a stack trace.
"""
import argparse
import sys
import warnings
warnings.filterwarnings("ignore")
import lance
import lance_namespace as ln
import lancedb
import pyarrow as pa
DIM = 8
def sample_rows(count):
"""A vector table, which is the only kind worth putting in Lance."""
return pa.table(
{
"id": pa.array(list(range(count)), type=pa.int64()),
"title": pa.array([f"row-{i}" for i in range(count)]),
"vector": pa.array(
[[float(i) + d for d in range(DIM)] for i in range(count)],
type=pa.list_(pa.float32(), DIM),
),
}
)
def seed_table(namespace_url, storage, bucket, namespace, table, rows):
"""Declares a table through the namespace and writes a dataset into it.
LanceDB reads through the catalog; the writing half is pylance, because the
namespace records where a table lives and does not carry its data.
"""
ns = ln.connect("rest", {"uri": namespace_url})
ns.create_namespace(
ln.CreateNamespaceRequest(id=[bucket], mode="EXIST_OK")
)
ns.create_namespace(
ln.CreateNamespaceRequest(id=[bucket, namespace], mode="EXIST_OK")
)
table_id = [bucket, namespace, table]
declared = ns.declare_table(ln.DeclareTableRequest(id=table_id))
lance.write_dataset(
sample_rows(rows), declared.location, storage_options=storage, mode="overwrite"
)
print(f"seeded {rows} rows at {declared.location}")
return declared.location
def check(condition, message):
if not condition:
print(f"FAIL: {message}", file=sys.stderr)
raise SystemExit(1)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--namespace-url", required=True)
parser.add_argument("--s3-endpoint", required=True)
parser.add_argument("--bucket", required=True)
parser.add_argument("--namespace", default="ml")
parser.add_argument("--table", default="embeddings")
# Enough rows for an IVF_PQ index to be worth building, which is the point
# of step 4: without one, a search is a brute-force scan.
parser.add_argument("--rows", type=int, default=1024)
parser.add_argument("--access-key", default="any")
parser.add_argument("--secret-key", default="any")
args = parser.parse_args()
# The namespace vends an endpoint correct for its own host; a container
# reaches the same gateway by another name, so the endpoint is overridden
# here and the credentials filled in for a deployment without STS.
storage = {
"aws_endpoint": args.s3_endpoint,
"allow_http": "true",
"aws_access_key_id": args.access_key,
"aws_secret_access_key": args.secret_key,
"aws_region": "us-east-1",
}
location = seed_table(
args.namespace_url, storage, args.bucket, args.namespace, args.table, args.rows
)
print(f"lancedb {lancedb.__version__} connecting to {args.namespace_url}")
db = lancedb.connect_namespace(
"rest", {"uri": args.namespace_url}, storage_options=storage
)
# 1. The catalog is browsable: the bucket is a namespace, and the table is
# in it under the name the namespace gave it.
tables = list(db.table_names(namespace_path=[args.bucket, args.namespace], limit=100))
print(f"table_names -> {tables}")
check(
any(args.table in name for name in tables),
f"{args.table} is not listed in {tables}",
)
# 2. Opening it goes through the catalog: LanceDB asks the namespace where
# the table is and reads it from there.
# storage_options is passed per call as well as on the connection: what the
# namespace vends for a table is merged in, and a deployment without STS
# vends no credentials, which is what the client would otherwise be left with.
table = db.open_table(
args.table,
namespace_path=[args.bucket, args.namespace],
storage_options=storage,
)
count = table.count_rows()
print(f"open_table -> {count} rows")
check(count == args.rows, f"read {count} rows, want {args.rows}")
# 3. The schema survived the round trip, vector column included. This is the
# part a catalog that only records a location cannot fake.
names = table.schema.names
print(f"schema -> {names}")
check("vector" in names and "title" in names, f"schema lost columns: {names}")
# 4. Build a vector index, then search it. Both halves matter: an index
# writes files into a directory of the table the S3 door has to admit -
# the layout guard has refused a Lance directory before - and without one
# a search is a brute-force scan that proves nothing about the index path
# the maintenance worker exists to keep in shape.
table.create_index(
metric="l2",
vector_column_name="vector",
index_type="IVF_PQ",
num_partitions=1,
num_sub_vectors=4,
)
indices = table.list_indices()
print(f"create_index -> {indices}")
check(len(indices) >= 1, "no index was created")
# Vectors are laid out so that id N sits near id N+1, so a query built from
# id 1 should come back with its neighbourhood. The assertion is a
# neighbourhood and not an exact id: an IVF_PQ index quantizes, so the
# nearest hit is approximate by construction - with this data it answers 0
# as readily as 1, and both are right.
query = [float(1) + d for d in range(DIM)]
hits = table.search(query).limit(3).to_list()
ids = [hit["id"] for hit in hits]
print(f"search -> {ids}")
check(len(hits) == 3, f"search returned {len(hits)} hits, want 3")
check(
all(i <= 5 for i in ids),
f"search returned {ids}, which is not the neighbourhood of the query",
)
# 5. A filtered scan, so it is not only the ANN path that works.
filtered = table.search().where("id < 5").limit(10).to_list()
print(f"filtered scan -> {len(filtered)} rows")
check(len(filtered) == 5, f"filter returned {len(filtered)} rows, want 5")
# 6. Creating a table. By default LanceDB declares it through the namespace
# and writes the data itself, which is exactly the split this catalog
# serves, so this has to work.
created = db.create_table(
"created_by_lancedb",
data=sample_rows(4),
namespace_path=[args.bucket, args.namespace],
storage_options=storage,
)
check(created.count_rows() == 4, "create_table wrote the wrong number of rows")
listed = list(db.table_names(namespace_path=[args.bucket, args.namespace], limit=100))
check(
any("created_by_lancedb" in name for name in listed),
f"a table created through LanceDB is not listed: {listed}",
)
print(f"create_table -> {created.count_rows()} rows, listed by the catalog")
# 7. The same creation with server-side pushdown, which asks the namespace
# to run CreateTable itself. That operation carries Arrow data and this
# catalog answers the spec's Unsupported for it. What matters is that the
# client is left with something coherent either way - it falls back to
# declare-and-write - rather than a hang, a 404, or a half-made table.
pushdown = lancedb.connect_namespace(
"rest",
{"uri": args.namespace_url},
storage_options=storage,
namespace_client_pushdown_operations=["CreateTable"],
)
pushed_error = None
try:
pushdown.create_table(
"pushed_by_lancedb",
data=sample_rows(2),
namespace_path=[args.bucket, args.namespace],
storage_options=storage,
)
except Exception as err: # noqa: BLE001 - the point is what the client sees
pushed_error = err
after = list(db.table_names(namespace_path=[args.bucket, args.namespace], limit=100))
landed = any("pushed_by_lancedb" in name for name in after)
print(f"create_table with pushdown: error={pushed_error!r}, catalog has it={landed}")
if pushed_error is None:
# The client fell back to declare-and-write, so the table is real and
# has to be readable and complete.
check(landed, "create_table reported success but the catalog has no table")
rows = db.open_table(
"pushed_by_lancedb",
namespace_path=[args.bucket, args.namespace],
storage_options=storage,
).count_rows()
check(rows == 2, f"the pushed table holds {rows} rows, want 2")
else:
# Refused, which is what this catalog answers for a data-plane
# operation. It has to be that refusal and not some other failure, and
# it must not have left a half-made table behind.
message = str(pushed_error).lower()
check(
"unsupported" in message or "501" in message or "not implemented" in message,
f"pushdown failed for an unexpected reason: {pushed_error}",
)
check(not landed, "a refused create left a table behind in the catalog")
# 8. And the dataset is still readable straight off its URI, which is what
# keeps the catalog optional.
direct = lance.dataset(location, storage_options=storage).count_rows()
check(direct == args.rows, f"direct read got {direct} rows, want {args.rows}")
print(f"direct read without the catalog -> {direct} rows")
print("PASS")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,79 @@
package catalog_risingwave
import (
"fmt"
"strings"
"testing"
)
func TestRisingWaveIcebergCatalog(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
env := NewTestEnvironment(t)
defer env.Cleanup(t)
if !env.dockerAvailable {
t.Skip("Docker not available, skipping RisingWave integration test")
}
t.Log(">>> Starting SeaweedFS...")
env.StartSeaweedFS(t)
t.Log(">>> SeaweedFS started.")
tableBucket := "iceberg-tables"
t.Logf(">>> Creating table bucket: %s", tableBucket)
createTableBucket(t, env, tableBucket)
t.Log(">>> Starting RisingWave...")
env.StartRisingWave(t)
t.Log(">>> RisingWave started.")
// Create Iceberg namespace
createIcebergNamespace(t, env, "default")
// Create a catalog in RisingWave that points to SeaweedFS Iceberg REST API
icebergUri := env.dockerIcebergEndpoint()
s3Endpoint := env.dockerS3Endpoint()
tableName := "test_table_" + randomString(6)
createIcebergTable(t, env, tableBucket, "default", tableName)
sourceName := "test_source_" + randomString(6)
createSourceSql := fmt.Sprintf(`
CREATE SOURCE %s WITH (
connector = 'iceberg',
catalog.type = 'rest',
catalog.uri = '%s',
catalog.name = 'default',
database.name = 'default',
table.name = '%s',
warehouse.path = 's3://%s',
s3.endpoint = '%s',
s3.region = 'us-east-1',
s3.access.key = '%s',
s3.secret.key = '%s',
s3.path.style.access = 'true',
catalog.rest.sigv4_enabled = 'true',
catalog.rest.signing_region = 'us-east-1',
catalog.rest.signing_name = 's3'
);`, sourceName, icebergUri, tableName, tableBucket, s3Endpoint, env.accessKey, env.secretKey)
t.Logf(">>> Creating source %s...", sourceName)
runRisingWaveSQL(t, env.postgresSidecar, createSourceSql)
showSourcesOutput := runRisingWaveSQL(t, env.postgresSidecar, "SHOW SOURCES;")
if !strings.Contains(showSourcesOutput, sourceName) {
t.Fatalf("Expected source %s in SHOW SOURCES output:\n%s", sourceName, showSourcesOutput)
}
describeOutput := runRisingWaveSQL(t, env.postgresSidecar, fmt.Sprintf("DESCRIBE %s;", sourceName))
if !strings.Contains(describeOutput, "id") || !strings.Contains(describeOutput, "name") {
t.Fatalf("Expected id/name columns in DESCRIBE output:\n%s", describeOutput)
}
runRisingWaveSQL(t, env.postgresSidecar, fmt.Sprintf("SELECT * FROM %s LIMIT 0;", sourceName))
t.Log(">>> RisingWave Iceberg Catalog test passed!")
}

View File

@@ -0,0 +1,219 @@
package catalog_risingwave
import (
"fmt"
"strings"
"testing"
"time"
)
func TestRisingWaveIcebergDML(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
env := NewTestEnvironment(t)
defer env.Cleanup(t)
if !env.dockerAvailable {
t.Skip("Docker not available, skipping RisingWave integration test")
}
t.Log(">>> Starting SeaweedFS...")
env.StartSeaweedFS(t)
t.Log(">>> SeaweedFS started.")
tableBucket := "iceberg-tables"
t.Logf(">>> Creating table bucket: %s", tableBucket)
createTableBucket(t, env, tableBucket)
t.Log(">>> Starting RisingWave...")
env.StartRisingWave(t)
t.Log(">>> RisingWave started.")
// Create Iceberg namespace
createIcebergNamespace(t, env, "default")
icebergUri := env.dockerIcebergEndpoint()
s3Endpoint := env.dockerS3Endpoint()
// 1. Test INSERT (Append-only)
t.Run("TestInsert", func(t *testing.T) {
tableName := "test_insert_" + randomString(6)
createIcebergTable(t, env, tableBucket, "default", tableName)
rwTableName := "rw_insert_" + randomString(6)
runRisingWaveSQL(t, env.postgresSidecar, fmt.Sprintf("CREATE TABLE %s (id int, name varchar);", rwTableName))
sinkName := "test_sink_insert_" + randomString(6)
createSinkSql := fmt.Sprintf(`
CREATE SINK %s FROM %s
WITH (
connector = 'iceberg',
catalog.type = 'rest',
catalog.uri = '%s',
catalog.name = 'default',
database.name = 'default',
table.name = '%s',
warehouse.path = 's3://%s',
s3.endpoint = '%s',
s3.region = 'us-east-1',
s3.access.key = '%s',
s3.secret.key = '%s',
s3.path.style.access = 'true',
catalog.rest.sigv4_enabled = 'true',
catalog.rest.signing_region = 'us-east-1',
catalog.rest.signing_name = 's3',
type = 'append-only',
force_append_only = 'true'
);`, sinkName, rwTableName, icebergUri, tableName, tableBucket, s3Endpoint, env.accessKey, env.secretKey)
t.Logf(">>> Creating sink %s...", sinkName)
runRisingWaveSQL(t, env.postgresSidecar, createSinkSql)
t.Log(">>> Inserting into RisingWave table...")
runRisingWaveSQL(t, env.postgresSidecar, fmt.Sprintf("INSERT INTO %s VALUES (1, 'Alice'), (2, 'Bob');", rwTableName))
runRisingWaveSQL(t, env.postgresSidecar, "FLUSH;")
// Verify with Source
sourceName := "test_source_insert_" + randomString(6)
createSourceSql := fmt.Sprintf(`
CREATE SOURCE %s WITH (
connector = 'iceberg',
catalog.type = 'rest',
catalog.uri = '%s',
catalog.name = 'default',
database.name = 'default',
table.name = '%s',
warehouse.path = 's3://%s',
s3.endpoint = '%s',
s3.region = 'us-east-1',
s3.access.key = '%s',
s3.secret.key = '%s',
s3.path.style.access = 'true',
catalog.rest.sigv4_enabled = 'true',
catalog.rest.signing_region = 'us-east-1',
catalog.rest.signing_name = 's3'
);`, sourceName, icebergUri, tableName, tableBucket, s3Endpoint, env.accessKey, env.secretKey)
runRisingWaveSQL(t, env.postgresSidecar, createSourceSql)
t.Log(">>> Selecting from source to verify INSERT...")
verifyQuery(t, env, sourceName, "1 | Alice", "2 | Bob")
})
// 2. Test UPSERT (Update/Delete)
t.Run("TestUpsert", func(t *testing.T) {
tableName := "test_upsert_" + randomString(6)
// We need a table with PK for upsert to work effectively in RW logic,
// effectively maps to Iceberg v2 table.
createIcebergTable(t, env, tableBucket, "default", tableName)
rwTableName := "rw_upsert_" + randomString(6)
runRisingWaveSQL(t, env.postgresSidecar, fmt.Sprintf("CREATE TABLE %s (id int PRIMARY KEY, name varchar);", rwTableName))
sinkName := "test_sink_upsert_" + randomString(6)
createSinkSql := fmt.Sprintf(`
CREATE SINK %s FROM %s
WITH (
connector = 'iceberg',
catalog.type = 'rest',
catalog.uri = '%s',
catalog.name = 'default',
database.name = 'default',
table.name = '%s',
warehouse.path = 's3://%s',
s3.endpoint = '%s',
s3.region = 'us-east-1',
s3.access.key = '%s',
s3.secret.key = '%s',
s3.path.style.access = 'true',
catalog.rest.sigv4_enabled = 'true',
catalog.rest.signing_region = 'us-east-1',
catalog.rest.signing_name = 's3',
type = 'upsert', -- Upsert mode
primary_key = 'id'
);`, sinkName, rwTableName, icebergUri, tableName, tableBucket, s3Endpoint, env.accessKey, env.secretKey)
t.Logf(">>> Creating upsert sink %s...", sinkName)
runRisingWaveSQL(t, env.postgresSidecar, createSinkSql)
t.Log(">>> Inserting initial data...")
runRisingWaveSQL(t, env.postgresSidecar, fmt.Sprintf("INSERT INTO %s VALUES (1, 'Charlie'), (2, 'Dave');", rwTableName))
runRisingWaveSQL(t, env.postgresSidecar, "FLUSH;")
// Update 1, Delete 2
t.Log(">>> Updating and Deleting data...")
runRisingWaveSQL(t, env.postgresSidecar, fmt.Sprintf("UPDATE %s SET name = 'Charles' WHERE id = 1;", rwTableName))
runRisingWaveSQL(t, env.postgresSidecar, fmt.Sprintf("DELETE FROM %s WHERE id = 2;", rwTableName))
runRisingWaveSQL(t, env.postgresSidecar, "FLUSH;")
// Verify with Source
sourceName := "test_source_upsert_" + randomString(6)
createSourceSql := fmt.Sprintf(`
CREATE SOURCE %s WITH (
connector = 'iceberg',
catalog.type = 'rest',
catalog.uri = '%s',
catalog.name = 'default',
database.name = 'default',
table.name = '%s',
warehouse.path = 's3://%s',
s3.endpoint = '%s',
s3.region = 'us-east-1',
s3.access.key = '%s',
s3.secret.key = '%s',
s3.path.style.access = 'true',
catalog.rest.sigv4_enabled = 'true',
catalog.rest.signing_region = 'us-east-1',
catalog.rest.signing_name = 's3'
);`, sourceName, icebergUri, tableName, tableBucket, s3Endpoint, env.accessKey, env.secretKey)
runRisingWaveSQL(t, env.postgresSidecar, createSourceSql)
t.Log(">>> Selecting from source to verify UPSERT...")
// Should see (1, 'Charles') and NOT (2, 'Dave')
verifyQuery(t, env, sourceName, "1 | Charles")
verifyQueryAbsence(t, env, sourceName, "2 | Dave")
})
}
func verifyQuery(t *testing.T, env *TestEnvironment, sourceName string, expectedSubstrings ...string) {
t.Helper()
var output string
for i := 0; i < 15; i++ {
output = runRisingWaveSQL(t, env.postgresSidecar, fmt.Sprintf("SELECT * FROM %s ORDER BY id;", sourceName))
allFound := true
for _, s := range expectedSubstrings {
if !strings.Contains(output, s) {
allFound = false
break
}
}
if allFound {
return
}
time.Sleep(2 * time.Second)
}
t.Fatalf("Failed to find expected data %v in output:\n%s", expectedSubstrings, output)
}
func verifyQueryAbsence(t *testing.T, env *TestEnvironment, sourceName string, unexpectedSubstrings ...string) {
t.Helper()
var output string
for i := 0; i < 15; i++ {
output = runRisingWaveSQL(t, env.postgresSidecar, fmt.Sprintf("SELECT * FROM %s ORDER BY id;", sourceName))
noneFound := true
for _, s := range unexpectedSubstrings {
if strings.Contains(output, s) {
noneFound = false
break
}
}
if noneFound {
return
}
time.Sleep(2 * time.Second)
}
t.Fatalf("Found unexpected data %v in output:\n%s", unexpectedSubstrings, output)
}

View File

@@ -0,0 +1,438 @@
package catalog_risingwave
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"math/rand"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"testing"
"time"
v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/seaweedfs/seaweedfs/test/testutil"
)
var (
miniProcessMu sync.Mutex
lastMiniProcess *exec.Cmd
)
func stopPreviousMini() {
miniProcessMu.Lock()
defer miniProcessMu.Unlock()
if lastMiniProcess != nil && lastMiniProcess.Process != nil {
_ = lastMiniProcess.Process.Kill()
_ = lastMiniProcess.Wait()
}
lastMiniProcess = nil
}
func registerMiniProcess(cmd *exec.Cmd) {
miniProcessMu.Lock()
lastMiniProcess = cmd
miniProcessMu.Unlock()
}
func clearMiniProcess(cmd *exec.Cmd) {
miniProcessMu.Lock()
if lastMiniProcess == cmd {
lastMiniProcess = nil
}
miniProcessMu.Unlock()
}
type TestEnvironment struct {
t *testing.T
dockerAvailable bool
seaweedfsDataDir string
masterPort int
filerPort int
s3Port int
icebergRestPort int
bindIP string
accessKey string
secretKey string
risingwaveContainer string
postgresSidecar string
masterProcess *exec.Cmd
logFile *os.File
}
func NewTestEnvironment(t *testing.T) *TestEnvironment {
env := &TestEnvironment{
t: t,
accessKey: "AKIAIOSFODNN7EXAMPLE",
secretKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
}
// Check if Docker is available
cmd := exec.Command("docker", "version")
env.dockerAvailable = cmd.Run() == nil
return env
}
func (env *TestEnvironment) hostMasterAddress() string {
return fmt.Sprintf("127.0.0.1:%d", env.masterPort)
}
func (env *TestEnvironment) hostS3Endpoint() string {
return fmt.Sprintf("http://127.0.0.1:%d", env.s3Port)
}
func (env *TestEnvironment) hostIcebergEndpoint() string {
return fmt.Sprintf("http://127.0.0.1:%d", env.icebergRestPort)
}
func (env *TestEnvironment) dockerS3Endpoint() string {
return fmt.Sprintf("http://host.docker.internal:%d", env.s3Port)
}
func (env *TestEnvironment) dockerIcebergEndpoint() string {
return fmt.Sprintf("http://host.docker.internal:%d", env.icebergRestPort)
}
func (env *TestEnvironment) StartSeaweedFS(t *testing.T) {
t.Helper()
stopPreviousMini()
var err error
env.seaweedfsDataDir, err = os.MkdirTemp("", "seaweed-risingwave-test-")
if err != nil {
t.Fatalf("failed to create temp directory: %v", err)
}
ports := testutil.MustFreeMiniPorts(t, []string{"Master", "Filer", "S3", "Iceberg"})
env.masterPort = ports[0]
env.filerPort = ports[1]
env.s3Port = ports[2]
env.icebergRestPort = ports[3]
env.bindIP = testutil.FindBindIP()
iamConfigPath, err := testutil.WriteIAMConfig(env.seaweedfsDataDir, env.accessKey, env.secretKey)
if err != nil {
t.Fatalf("failed to create IAM config: %v", err)
}
// Create log file for SeaweedFS
logFile, err := os.Create(filepath.Join(env.seaweedfsDataDir, "seaweedfs.log"))
if err != nil {
t.Fatalf("failed to create log file: %v", err)
}
env.logFile = logFile
// Start SeaweedFS using weed mini (all-in-one including Iceberg REST)
env.masterProcess = exec.Command(
"weed", "mini",
"-ip", env.bindIP,
"-ip.bind", "0.0.0.0",
"-master.port", fmt.Sprintf("%d", env.masterPort),
"-filer.port", fmt.Sprintf("%d", env.filerPort),
"-s3.port", fmt.Sprintf("%d", env.s3Port),
"-s3.port.iceberg", fmt.Sprintf("%d", env.icebergRestPort),
"-s3.config", iamConfigPath,
"-dir", env.seaweedfsDataDir,
)
env.masterProcess.Stdout = logFile
env.masterProcess.Stderr = logFile
env.masterProcess.Env = append(os.Environ(),
"AWS_ACCESS_KEY_ID="+env.accessKey,
"AWS_SECRET_ACCESS_KEY="+env.secretKey,
"ICEBERG_WAREHOUSE=s3://iceberg-tables",
"S3TABLES_DEFAULT_BUCKET=iceberg-tables",
)
if err := env.masterProcess.Start(); err != nil {
t.Fatalf("failed to start weed mini: %v", err)
}
registerMiniProcess(env.masterProcess)
// Wait for all services to be ready
if !testutil.WaitForPort(env.masterPort, testutil.SeaweedMiniStartupTimeout) {
t.Fatalf("weed mini failed to start - master port %d not listening", env.masterPort)
}
if !testutil.WaitForPort(env.filerPort, testutil.SeaweedMiniStartupTimeout) {
t.Fatalf("weed mini failed to start - filer port %d not listening", env.filerPort)
}
if !testutil.WaitForService(fmt.Sprintf("http://127.0.0.1:%d/status", env.s3Port), testutil.SeaweedMiniStartupTimeout) {
t.Fatalf("weed mini failed to start - s3 endpoint http://127.0.0.1:%d/status not responding", env.s3Port)
}
if !testutil.WaitForService(fmt.Sprintf("http://127.0.0.1:%d/v1/config", env.icebergRestPort), testutil.SeaweedMiniStartupTimeout) {
t.Fatalf("weed mini failed to start - iceberg rest endpoint http://127.0.0.1:%d/v1/config not responding", env.icebergRestPort)
}
}
func (env *TestEnvironment) StartRisingWave(t *testing.T) {
t.Helper()
containerName := "seaweed-risingwave-" + randomString(8)
env.risingwaveContainer = containerName
cmd := exec.Command("docker", "run", "-d",
"--name", containerName,
"--add-host", "host.docker.internal:host-gateway",
"-e", "AWS_ACCESS_KEY_ID="+env.accessKey,
"-e", "AWS_SECRET_ACCESS_KEY="+env.secretKey,
"-e", "AWS_REGION=us-east-1",
"-e", "AWS_S3_PATH_STYLE_ACCESS=true",
"-e", "AWS_S3_FORCE_PATH_STYLE=true",
"risingwavelabs/risingwave:v2.5.0",
"playground",
)
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("failed to start RisingWave container: %v\n%s", err, string(output))
}
// Start a sidecar postgres container for running psql commands
sidecarName := "seaweed-risingwave-sidecar-" + randomString(8)
env.postgresSidecar = sidecarName
sidecarCmd := exec.Command("docker", "run", "-d", "--rm",
"--name", sidecarName,
"--network", fmt.Sprintf("container:%s", containerName),
"postgres:16-alpine",
"sleep", "infinity",
)
if output, err := sidecarCmd.CombinedOutput(); err != nil {
t.Fatalf("failed to start postgres sidecar: %v\n%s", err, string(output))
}
// Wait for RisingWave to be truly ready via psql in the sidecar.
// The sidecar shares RisingWave's network namespace, so it reaches
// 4566 directly without a host port mapping.
if !env.waitForRisingWave(120 * time.Second) {
t.Fatalf("timed out waiting for RisingWave to be ready via psql")
}
}
func (env *TestEnvironment) waitForRisingWave(timeout time.Duration) bool {
deadline := time.Now().Add(timeout)
env.t.Logf(">>> Waiting for RisingWave to be ready (timeout %v)...\n", timeout)
for time.Now().Before(deadline) {
if output, err := runPostgresClientSQL(env.postgresSidecar, "SELECT 1;"); err == nil {
env.t.Logf(">>> RisingWave is ready.\n")
return true
} else {
env.t.Logf(">>> RisingWave not ready yet: %v (Output: %s)\n", err, string(output))
}
time.Sleep(5 * time.Second)
}
return false
}
func runPostgresClientSQL(containerName, sql string) ([]byte, error) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "docker", "exec",
containerName,
"psql",
"-h", "127.0.0.1",
"-p", "4566",
"-U", "root",
"-d", "dev",
"-v", "ON_ERROR_STOP=1",
"-c", sql,
)
return cmd.CombinedOutput()
}
func (env *TestEnvironment) Cleanup(t *testing.T) {
t.Helper()
if env.risingwaveContainer != "" {
if t.Failed() {
logs, err := exec.Command("docker", "logs", env.risingwaveContainer).CombinedOutput()
if err == nil {
env.t.Logf(">>> RisingWave Logs:\n%s\n", string(logs))
} else {
env.t.Logf(">>> Failed to get RisingWave logs: %v\n", err)
}
}
_ = exec.Command("docker", "rm", "-f", env.risingwaveContainer).Run()
}
if env.postgresSidecar != "" {
_ = exec.Command("docker", "rm", "-f", env.postgresSidecar).Run()
}
if env.seaweedfsDataDir != "" && t.Failed() {
logPath := filepath.Join(env.seaweedfsDataDir, "seaweedfs.log")
if content, err := os.ReadFile(logPath); err == nil {
env.t.Logf(">>> SeaweedFS Logs:\n%s\n", string(content))
}
env.t.Logf(">>> Filer Contents:\n")
listFilerContents(t, env, "/")
}
if env.masterProcess != nil && env.masterProcess.Process != nil {
_ = env.masterProcess.Process.Kill()
_ = env.masterProcess.Wait()
}
clearMiniProcess(env.masterProcess)
if env.seaweedfsDataDir != "" {
if env.logFile != nil {
env.logFile.Close()
}
_ = os.RemoveAll(env.seaweedfsDataDir)
}
}
func runRisingWaveSQL(t *testing.T, containerName, sql string) string {
t.Helper()
output, err := runPostgresClientSQL(containerName, sql)
if err != nil {
t.Fatalf("RisingWave command failed: %v\nSQL: %s\nOutput:\n%s", err, sql, string(output))
}
return string(output)
}
func createTableBucket(t *testing.T, env *TestEnvironment, bucketName string) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "weed", "shell",
fmt.Sprintf("-master=%s", env.hostMasterAddress()),
)
cmd.Stdin = strings.NewReader(fmt.Sprintf("s3tables.bucket -create -name %s -account 000000000000\nexit\n", bucketName))
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("failed to create table bucket %s via weed shell: %v\nOutput: %s", bucketName, err, string(output))
}
}
func doIcebergSignedJSONRequest(env *TestEnvironment, method, path string, payload any) (int, string, error) {
url := env.hostIcebergEndpoint() + path
var body io.Reader
var payloadHash string
if payload != nil {
data, err := json.Marshal(payload)
if err != nil {
return 0, "", err
}
body = bytes.NewReader(data)
// hash := sha256.Sum256(data)
// payloadHash = hex.EncodeToString(hash[:])
payloadHash = "UNSIGNED-PAYLOAD"
} else {
payloadHash = "UNSIGNED-PAYLOAD"
}
req, err := http.NewRequest(method, url, body)
if err != nil {
return 0, "", fmt.Errorf("failed to create request: %w", err)
}
if payload != nil {
req.Header.Set("Content-Type", "application/json")
}
req.Header.Set("X-Amz-Content-Sha256", payloadHash)
// Sign the request
credsProvider := credentials.NewStaticCredentialsProvider(env.accessKey, env.secretKey, "")
creds, err := credsProvider.Retrieve(context.Background())
if err != nil {
return 0, "", fmt.Errorf("failed to retrieve credentials: %w", err)
}
signer := v4.NewSigner()
if err := signer.SignHTTP(context.Background(), creds, req, payloadHash, "s3", "us-east-1", time.Now()); err != nil {
return 0, "", fmt.Errorf("failed to sign request: %w", err)
}
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return 0, "", fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return 0, "", fmt.Errorf("failed to read response body: %w", err)
}
return resp.StatusCode, string(respBody), nil
}
func createIcebergNamespace(t *testing.T, env *TestEnvironment, namespace string) {
t.Helper()
status, raw, err := doIcebergSignedJSONRequest(env, "POST", "/v1/namespaces", map[string]any{
"namespace": []string{namespace},
})
if err != nil {
t.Fatalf("failed to create Iceberg namespace %s: %v", namespace, err)
}
if status != 200 && status != 409 {
t.Fatalf("failed to create Iceberg namespace %s: status %d body: %s", namespace, status, raw)
}
}
func createIcebergTable(t *testing.T, env *TestEnvironment, bucketName, namespace, tableName string) {
t.Helper()
createPath := fmt.Sprintf("/v1/namespaces/%s/tables", namespace)
status, raw, err := doIcebergSignedJSONRequest(env, "POST", createPath, map[string]any{
"name": tableName,
"location": fmt.Sprintf("s3://%s/%s/%s", bucketName, namespace, tableName),
"schema": map[string]any{
"type": "struct",
"fields": []map[string]any{
{"id": 1, "name": "id", "required": false, "type": "int"},
{"id": 2, "name": "name", "required": false, "type": "string"},
},
},
})
if err != nil {
t.Fatalf("failed to create Iceberg table %s.%s in bucket %s: %v", namespace, tableName, bucketName, err)
}
if status != 200 && status != 409 {
t.Fatalf("failed to create Iceberg table %s.%s in bucket %s: status %d body: %s", namespace, tableName, bucketName, status, raw)
}
}
func listFilerContents(t *testing.T, env *TestEnvironment, path string) {
t.Helper()
// Bound diagnostic listing so a hung weed shell during cleanup can't
// burn the whole 20-minute test timeout.
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "weed", "shell",
fmt.Sprintf("-master=%s", env.hostMasterAddress()),
)
cmd.Stdin = strings.NewReader(fmt.Sprintf("fs.ls -R %s\nexit\n", path))
output, err := cmd.CombinedOutput()
if err != nil {
env.t.Logf(">>> Warning: failed to list filer contents: %v\nOutput: %s\n", err, string(output))
} else {
env.t.Logf("%s\n", string(output))
}
}
func randomString(n int) string {
const letters = "abcdefghijklmnopqrstuvwxyz0123456789"
b := make([]byte, n)
for i := range b {
b[i] = letters[rand.Intn(len(letters))]
}
return string(b)
}

View File

@@ -0,0 +1,361 @@
package catalog_spark
import (
"context"
"fmt"
"io"
"math/rand"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/seaweedfs/seaweedfs/test/testutil"
"github.com/testcontainers/testcontainers-go"
)
var (
miniProcessMu sync.Mutex
lastMiniProcess *exec.Cmd
)
func stopPreviousMini() {
miniProcessMu.Lock()
defer miniProcessMu.Unlock()
if lastMiniProcess != nil && lastMiniProcess.Process != nil {
_ = lastMiniProcess.Process.Kill()
_ = lastMiniProcess.Wait()
}
lastMiniProcess = nil
}
func registerMiniProcess(cmd *exec.Cmd) {
miniProcessMu.Lock()
lastMiniProcess = cmd
miniProcessMu.Unlock()
}
func clearMiniProcess(cmd *exec.Cmd) {
miniProcessMu.Lock()
if lastMiniProcess == cmd {
lastMiniProcess = nil
}
miniProcessMu.Unlock()
}
type TestEnvironment struct {
t *testing.T
dockerAvailable bool
seaweedfsDataDir string
sparkConfigDir string
masterPort int
filerPort int
s3Port int
icebergRestPort int
accessKey string
secretKey string
sparkContainer testcontainers.Container
masterProcess *exec.Cmd
}
func NewTestEnvironment(t *testing.T) *TestEnvironment {
env := &TestEnvironment{
t: t,
accessKey: "test",
secretKey: "test",
}
// Check if Docker is available
cmd := exec.Command("docker", "version")
env.dockerAvailable = cmd.Run() == nil
return env
}
func (env *TestEnvironment) StartSeaweedFS(t *testing.T) {
t.Helper()
stopPreviousMini()
var err error
env.seaweedfsDataDir, err = os.MkdirTemp("", "seaweed-spark-test-")
if err != nil {
t.Fatalf("failed to create temp directory: %v", err)
}
ports := testutil.MustFreeMiniPorts(t, []string{"Master", "Filer", "S3", "Iceberg"})
env.masterPort = ports[0]
env.filerPort = ports[1]
env.s3Port = ports[2]
env.icebergRestPort = ports[3]
bindIP := testutil.FindBindIP()
iamConfigPath, err := testutil.WriteIAMConfig(env.seaweedfsDataDir, env.accessKey, env.secretKey)
if err != nil {
t.Fatalf("failed to create IAM config: %v", err)
}
// Start SeaweedFS using weed mini (all-in-one including Iceberg REST)
env.masterProcess = exec.Command(
"weed", "mini",
"-ip", bindIP,
"-ip.bind", "0.0.0.0",
"-master.port", fmt.Sprintf("%d", env.masterPort),
"-filer.port", fmt.Sprintf("%d", env.filerPort),
"-s3.port", fmt.Sprintf("%d", env.s3Port),
"-s3.port.iceberg", fmt.Sprintf("%d", env.icebergRestPort),
"-s3.config", iamConfigPath,
"-dir", env.seaweedfsDataDir,
)
env.masterProcess.Env = append(os.Environ(),
"AWS_ACCESS_KEY_ID="+env.accessKey,
"AWS_SECRET_ACCESS_KEY="+env.secretKey,
"ICEBERG_WAREHOUSE=s3://iceberg-tables",
"S3TABLES_DEFAULT_BUCKET=iceberg-tables",
)
env.masterProcess.Stdout = os.Stdout
env.masterProcess.Stderr = os.Stderr
if err := env.masterProcess.Start(); err != nil {
t.Fatalf("failed to start weed mini: %v", err)
}
registerMiniProcess(env.masterProcess)
// Wait for all services to be ready
if !testutil.WaitForPort(env.masterPort, testutil.SeaweedMiniStartupTimeout) {
t.Fatalf("weed mini failed to start - master port %d not listening", env.masterPort)
}
if !testutil.WaitForPort(env.filerPort, testutil.SeaweedMiniStartupTimeout) {
t.Fatalf("weed mini failed to start - filer port %d not listening", env.filerPort)
}
if !testutil.WaitForService(fmt.Sprintf("http://127.0.0.1:%d/status", env.s3Port), testutil.SeaweedMiniStartupTimeout) {
t.Fatalf("weed mini failed to start - s3 endpoint http://127.0.0.1:%d/status not responding", env.s3Port)
}
if !testutil.WaitForService(fmt.Sprintf("http://127.0.0.1:%d/v1/config", env.icebergRestPort), testutil.SeaweedMiniStartupTimeout) {
t.Fatalf("weed mini failed to start - iceberg rest endpoint http://127.0.0.1:%d/v1/config not responding", env.icebergRestPort)
}
}
func (env *TestEnvironment) writeSparkConfig(t *testing.T, catalogBucket string) string {
t.Helper()
configDir, err := os.MkdirTemp("", "spark-config-")
if err != nil {
t.Fatalf("failed to create config directory: %v", err)
}
// Store for cleanup
env.sparkConfigDir = configDir
s3Endpoint := fmt.Sprintf("http://host.docker.internal:%d", env.s3Port)
catalogEndpoint := fmt.Sprintf("http://host.docker.internal:%d", env.icebergRestPort)
sparkConfig := fmt.Sprintf(`
[spark]
master = "local"
app.name = "SeaweedFS Iceberg Test"
[storage]
s3.endpoint = "%s"
s3.access-key = "test"
s3.secret-key = "test"
s3.path-style-access = "true"
s3.bucket = "%s"
[iceberg]
catalog.type = "rest"
catalog.uri = "%s"
catalog.s3.endpoint = "%s"
catalog.s3.access-key = "test"
catalog.s3.secret-key = "test"
catalog.s3.path-style-access = "true"
`, s3Endpoint, catalogBucket, catalogEndpoint, s3Endpoint)
configPath := filepath.Join(configDir, "spark-config.ini")
if err := os.WriteFile(configPath, []byte(sparkConfig), 0644); err != nil {
t.Fatalf("failed to write spark config: %v", err)
}
return configDir
}
func (env *TestEnvironment) startSparkContainer(t *testing.T, configDir string) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
req := testcontainers.ContainerRequest{
Image: "apache/spark:3.5.1",
ExposedPorts: []string{"4040/tcp"},
Mounts: testcontainers.Mounts(
testcontainers.BindMount(configDir, "/config"),
),
Env: map[string]string{
"SPARK_LOCAL_IP": "localhost",
},
ExtraHosts: []string{"host.docker.internal:host-gateway"},
Cmd: []string{"/bin/sh", "-c", "sleep 3600"},
}
container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
ContainerRequest: req,
Started: true,
})
if err != nil {
t.Fatalf("failed to start spark container: %v", err)
}
env.sparkContainer = container
}
func (env *TestEnvironment) Cleanup(t *testing.T) {
t.Helper()
// Kill weed mini process
if env.masterProcess != nil && env.masterProcess.Process != nil {
env.masterProcess.Process.Kill()
env.masterProcess.Wait()
}
clearMiniProcess(env.masterProcess)
// Stop Spark container
if env.sparkContainer != nil {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
env.sparkContainer.Terminate(ctx)
}
// Remove temporary directories after processes are stopped
if env.seaweedfsDataDir != "" {
os.RemoveAll(env.seaweedfsDataDir)
}
if env.sparkConfigDir != "" {
os.RemoveAll(env.sparkConfigDir)
}
}
func randomString(n int) string {
const letters = "abcdefghijklmnopqrstuvwxyz0123456789"
b := make([]byte, n)
for i := range b {
b[i] = letters[rand.Intn(len(letters))]
}
return string(b)
}
func runSparkPySQL(t *testing.T, container testcontainers.Container, sql string, icebergPort int, s3Port int) string {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
defer cancel()
pythonScript := fmt.Sprintf(`
import glob
import os
import sys
spark_home = os.environ.get("SPARK_HOME", "/opt/spark")
python_path = os.path.join(spark_home, "python")
py4j_glob = glob.glob(os.path.join(python_path, "lib", "py4j-*.zip"))
ivy_dir = "/tmp/ivy"
os.makedirs(ivy_dir, exist_ok=True)
os.environ["AWS_REGION"] = "us-west-2"
os.environ["AWS_DEFAULT_REGION"] = "us-west-2"
os.environ["AWS_ACCESS_KEY_ID"] = "test"
os.environ["AWS_SECRET_ACCESS_KEY"] = "test"
if python_path not in sys.path:
sys.path.insert(0, python_path)
if py4j_glob and py4j_glob[0] not in sys.path:
sys.path.insert(0, py4j_glob[0])
from pyspark.sql import SparkSession
spark = (SparkSession.builder
.appName("SeaweedFS Iceberg Test")
.config("spark.jars.ivy", ivy_dir)
.config("spark.jars.packages", "org.apache.iceberg:iceberg-spark-runtime-3.5_2.12:1.5.2,org.apache.iceberg:iceberg-aws-bundle:1.5.2")
.config("spark.sql.extensions", "org.apache.iceberg.spark.extensions.IcebergSparkSessionExtensions")
.config("spark.sql.catalog.iceberg", "org.apache.iceberg.spark.SparkCatalog")
.config("spark.sql.catalog.iceberg.type", "rest")
.config("spark.sql.catalog.iceberg.metrics-reporter-impl", "org.apache.iceberg.metrics.LoggingMetricsReporter")
.config("spark.sql.catalog.iceberg.uri", "http://host.docker.internal:%d")
.config("spark.sql.catalog.iceberg.rest.auth.type", "sigv4")
.config("spark.sql.catalog.iceberg.rest.auth.sigv4.delegate-auth-type", "none")
.config("spark.sql.catalog.iceberg.rest.sigv4-enabled", "true")
.config("spark.sql.catalog.iceberg.rest.signing-name", "s3")
.config("spark.sql.catalog.iceberg.rest.access-key-id", "test")
.config("spark.sql.catalog.iceberg.rest.secret-access-key", "test")
.config("spark.sql.catalog.iceberg.s3.endpoint", "http://host.docker.internal:%d")
.config("spark.sql.catalog.iceberg.s3.region", "us-west-2")
.config("spark.sql.catalog.iceberg.s3.access-key", "test")
.config("spark.sql.catalog.iceberg.s3.secret-key", "test")
.config("spark.sql.catalog.iceberg.s3.path-style-access", "true")
.config("spark.sql.catalog.iceberg.io-impl", "org.apache.iceberg.aws.s3.S3FileIO")
.getOrCreate())
%s
`, icebergPort, s3Port, sql)
code, out, err := container.Exec(ctx, []string{"python3", "-c", pythonScript})
var output string
if out != nil {
outputBytes, readErr := io.ReadAll(out)
if readErr != nil {
t.Logf("failed to read output: %v", readErr)
} else {
output = string(outputBytes)
}
}
if code != 0 {
t.Logf("Spark Python execution failed with code %d: %v, output: %s", code, err, output)
return output
}
return output
}
func createTableBucket(t *testing.T, env *TestEnvironment, bucketName string) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
masterGrpcPort := env.masterPort + 10000
cmd := exec.CommandContext(ctx, "weed", "shell",
fmt.Sprintf("-master=localhost:%d.%d", env.masterPort, masterGrpcPort),
)
cmd.Stdin = strings.NewReader(fmt.Sprintf("s3tables.bucket -create -name %s -account 000000000000\nexit\n", bucketName))
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("failed to create table bucket %s via weed shell: %v\nOutput: %s", bucketName, err, string(output))
}
}
func createObjectBucket(t *testing.T, env *TestEnvironment, bucketName string) {
t.Helper()
cfg := aws.Config{
Region: "us-east-1",
Credentials: aws.NewCredentialsCache(credentials.NewStaticCredentialsProvider(env.accessKey, env.secretKey, "")),
BaseEndpoint: aws.String(fmt.Sprintf("http://localhost:%d", env.s3Port)),
}
client := s3.NewFromConfig(cfg, func(o *s3.Options) {
o.UsePathStyle = true
})
_, err := client.CreateBucket(context.Background(), &s3.CreateBucketInput{
Bucket: aws.String(bucketName),
})
if err != nil {
t.Fatalf("failed to create object bucket %s: %v", bucketName, err)
}
}

View File

@@ -0,0 +1,135 @@
package catalog_spark
import (
"context"
"fmt"
"strings"
"testing"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
)
// TestSparkDeterministicLocationCTAS exercises Spark's iceberg-spark
// connector against the SeaweedFS REST catalog with no explicit `LOCATION`
// clause and no UUID-suffixing. iceberg-spark does not implement Trino's
// unique-table-location feature, so every CREATE TABLE here produces a
// deterministic <namespace-location>/<tableName> path. The test asserts
// the fresh-CTAS pattern works AND the resulting on-disk layout has no
// UUID-suffixed sibling — i.e. one directory per table.
//
// The fix in #9246 (advertise a default `location` namespace property)
// is what allows iceberg-spark to resolve the table location at all when
// the user did not pass one. Without it, a Spark-side CREATE TABLE on a
// namespace with no `location` property fails to compute a path.
func TestSparkDeterministicLocationCTAS(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
env, _, tableBucket := setupSparkTestEnv(t)
namespace := "detloc_" + randomString(6)
srcTable := "src_" + randomString(6)
ctasTable := "ctas_" + randomString(6)
sql := fmt.Sprintf(`
spark.sql("CREATE NAMESPACE iceberg.%s")
print("namespace created")
spark.sql("""
CREATE TABLE iceberg.%s.%s (
id INT,
name STRING
) USING iceberg
""")
print("source table created")
spark.sql("""
INSERT INTO iceberg.%s.%s VALUES
(1, 'alpha'),
(2, 'beta'),
(3, 'gamma')
""")
print("data inserted")
# CTAS without explicit LOCATION — the exact pattern that breaks on Trino
# without #9246, and that iceberg-spark needs the namespace-location
# property to resolve.
spark.sql("""
CREATE TABLE iceberg.%s.%s
USING iceberg
AS SELECT * FROM iceberg.%s.%s
""")
print("ctas created")
count = spark.sql("SELECT COUNT(*) AS c FROM iceberg.%s.%s").collect()[0]['c']
print(f"ctas row count: {count}")
`,
namespace,
namespace, srcTable,
namespace, srcTable,
namespace, ctasTable, namespace, srcTable,
namespace, ctasTable,
)
output := runSparkPySQL(t, env.sparkContainer, sql, env.icebergRestPort, env.s3Port)
for _, want := range []string{"namespace created", "source table created", "data inserted", "ctas created", "ctas row count: 3"} {
if !strings.Contains(output, want) {
t.Fatalf("expected output to contain %q; full output:\n%s", want, output)
}
}
// Layout assertion: each table's data must land at the deterministic
// <namespace>/<tableName>/ path, with no UUID-suffixed sibling.
keys := listSparkBucketKeysUnder(t, env, tableBucket, namespace+"/")
expectKeyAtSparkLocation(t, keys, namespace, srcTable)
expectKeyAtSparkLocation(t, keys, namespace, ctasTable)
rejectUUIDSuffixedSparkSibling(t, keys, namespace, srcTable)
rejectUUIDSuffixedSparkSibling(t, keys, namespace, ctasTable)
}
func listSparkBucketKeysUnder(t *testing.T, env *TestEnvironment, bucket, prefix string) []string {
t.Helper()
cfg := aws.Config{
Region: "us-east-1",
Credentials: aws.NewCredentialsCache(credentials.NewStaticCredentialsProvider(env.accessKey, env.secretKey, "")),
BaseEndpoint: aws.String(fmt.Sprintf("http://localhost:%d", env.s3Port)),
}
client := s3.NewFromConfig(cfg, func(o *s3.Options) { o.UsePathStyle = true })
out, err := client.ListObjectsV2(context.Background(), &s3.ListObjectsV2Input{
Bucket: aws.String(bucket),
Prefix: aws.String(prefix),
})
if err != nil {
t.Fatalf("ListObjectsV2 prefix=%s: %v", prefix, err)
}
keys := make([]string, 0, len(out.Contents))
for _, obj := range out.Contents {
keys = append(keys, aws.ToString(obj.Key))
}
return keys
}
func expectKeyAtSparkLocation(t *testing.T, keys []string, namespace, table string) {
t.Helper()
want := namespace + "/" + table + "/"
for _, k := range keys {
if strings.HasPrefix(k, want) {
return
}
}
t.Fatalf("expected at least one object under %q; got keys: %v", want, keys)
}
func rejectUUIDSuffixedSparkSibling(t *testing.T, keys []string, namespace, table string) {
t.Helper()
prefix := namespace + "/" + table + "-"
for _, k := range keys {
if strings.HasPrefix(k, prefix) {
t.Fatalf("found UUID-suffixed sibling key %q for table %q (Spark should produce no -<uuid> suffix)", k, table)
}
}
}

View File

@@ -0,0 +1,356 @@
package catalog_spark
import (
"fmt"
"regexp"
"strings"
"testing"
"time"
"github.com/testcontainers/testcontainers-go"
)
// waitForSparkReady polls Spark to verify it's ready by executing a simple query
func waitForSparkReady(t *testing.T, container testcontainers.Container, icebergPort int, s3Port int, timeout time.Duration) {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
output := runSparkPySQL(t, container, `
spark.sql("SELECT 1 as test")
print("Spark ready")
`, icebergPort, s3Port)
if strings.Contains(output, "Spark ready") {
return
}
time.Sleep(5 * time.Second)
}
t.Fatalf("Spark did not become ready within %v", timeout)
}
// setupSparkTestEnv initializes a test environment with SeaweedFS and Spark containers
func setupSparkTestEnv(t *testing.T) (*TestEnvironment, string, string) {
t.Helper()
env := NewTestEnvironment(t)
if !env.dockerAvailable {
t.Skip("Docker not available, skipping Spark integration test")
}
t.Logf(">>> Starting SeaweedFS...")
env.StartSeaweedFS(t)
t.Cleanup(func() { env.Cleanup(t) })
tableBucket := "iceberg-tables"
catalogBucket := tableBucket
createTableBucket(t, env, tableBucket)
configDir := env.writeSparkConfig(t, catalogBucket)
env.startSparkContainer(t, configDir)
// Poll for Spark readiness instead of fixed sleep
waitForSparkReady(t, env.sparkContainer, env.icebergRestPort, env.s3Port, 10*time.Minute)
return env, catalogBucket, tableBucket
}
// TestSparkCatalogBasicOperations tests basic Spark Iceberg catalog operations
func TestSparkCatalogBasicOperations(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
env, _, _ := setupSparkTestEnv(t)
// Test 1: Create a namespace (database)
t.Logf(">>> Test 1: Creating namespace")
namespace := "spark_test_" + randomString(6)
sparkSQL := fmt.Sprintf(`
spark.sql("CREATE NAMESPACE iceberg.%s")
print("Namespace created")
`, namespace)
output := runSparkPySQL(t, env.sparkContainer, sparkSQL, env.icebergRestPort, env.s3Port)
if !strings.Contains(output, "Namespace created") {
t.Fatalf("namespace creation failed, output: %s", output)
}
// Test 2: Create a table
t.Logf(">>> Test 2: Creating table")
tableName := "test_table_" + randomString(6)
createTableSQL := fmt.Sprintf(`
spark.sql("""
CREATE TABLE iceberg.%s.%s (
id INT,
name STRING,
age INT
)
USING iceberg
""")
print("Table created")
`, namespace, tableName)
output = runSparkPySQL(t, env.sparkContainer, createTableSQL, env.icebergRestPort, env.s3Port)
if !strings.Contains(output, "Table created") {
t.Fatalf("table creation failed, output: %s", output)
}
// Test 3: Insert data
t.Logf(">>> Test 3: Inserting data")
insertDataSQL := fmt.Sprintf(`
spark.sql("""
INSERT INTO iceberg.%s.%s VALUES
(1, 'Alice', 30),
(2, 'Bob', 25),
(3, 'Charlie', 35)
""")
print("Data inserted")
`, namespace, tableName)
output = runSparkPySQL(t, env.sparkContainer, insertDataSQL, env.icebergRestPort, env.s3Port)
if !strings.Contains(output, "Data inserted") {
t.Fatalf("data insertion failed, output: %s", output)
}
// Test 4: Query data
t.Logf(">>> Test 4: Querying data")
querySQL := fmt.Sprintf(`
result = spark.sql("SELECT COUNT(*) as count FROM iceberg.%s.%s")
result.show()
count = result.collect()[0]['count']
print(f"Row count: {count}")
`, namespace, tableName)
output = runSparkPySQL(t, env.sparkContainer, querySQL, env.icebergRestPort, env.s3Port)
if !strings.Contains(output, "Row count: 3") {
t.Errorf("expected row count 3, got output: %s", output)
}
// Test 5: Update data
t.Logf(">>> Test 5: Updating data")
updateSQL := fmt.Sprintf(`
spark.sql("""
UPDATE iceberg.%s.%s SET age = 31 WHERE id = 1
""")
print("Data updated")
`, namespace, tableName)
output = runSparkPySQL(t, env.sparkContainer, updateSQL, env.icebergRestPort, env.s3Port)
if !strings.Contains(output, "Data updated") {
t.Errorf("data update failed, output: %s", output)
}
// Test 6: Delete data
t.Logf(">>> Test 6: Deleting data")
deleteSQL := fmt.Sprintf(`
spark.sql("""
DELETE FROM iceberg.%s.%s WHERE id = 3
""")
print("Data deleted")
`, namespace, tableName)
output = runSparkPySQL(t, env.sparkContainer, deleteSQL, env.icebergRestPort, env.s3Port)
if !strings.Contains(output, "Data deleted") {
t.Errorf("data delete failed, output: %s", output)
}
// Verify final count
t.Logf(">>> Verifying final data")
finalCountSQL := fmt.Sprintf(`
result = spark.sql("SELECT COUNT(*) as count FROM iceberg.%s.%s")
result.show()
count = result.collect()[0]['count']
print(f"Final row count: {count}")
`, namespace, tableName)
output = runSparkPySQL(t, env.sparkContainer, finalCountSQL, env.icebergRestPort, env.s3Port)
if !strings.Contains(output, "Final row count: 2") {
t.Errorf("expected final row count 2, got output: %s", output)
}
t.Logf(">>> All tests passed")
}
// TestSparkTimeTravel tests Spark Iceberg time travel capabilities
func TestSparkTimeTravel(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
env, _, _ := setupSparkTestEnv(t)
namespace := "time_travel_test_" + randomString(6)
tableName := "tt_table_" + randomString(6)
// Create namespace and table
setupSQL := fmt.Sprintf(`
spark.sql("CREATE NAMESPACE iceberg.%s")
spark.sql("""
CREATE TABLE iceberg.%s.%s (
id INT,
value INT
)
USING iceberg
""")
print("Setup complete")
`, namespace, namespace, tableName)
output := runSparkPySQL(t, env.sparkContainer, setupSQL, env.icebergRestPort, env.s3Port)
if !strings.Contains(output, "Setup complete") {
t.Fatalf("setup failed for namespace %s and table %s, output: %s", namespace, tableName, output)
}
// Insert initial data
t.Logf(">>> Inserting initial data")
insertSQL := fmt.Sprintf(`
import time
from datetime import timedelta
spark.sql("""
INSERT INTO iceberg.%s.%s VALUES (1, 10)
""")
ts = None
for _ in range(10):
try:
ts = spark.sql("SELECT committed_at FROM iceberg.%s.%s.snapshots ORDER BY committed_at DESC LIMIT 1").collect()[0]["committed_at"]
if ts is not None:
break
except Exception as e:
print(f"Snapshot query failed: {e}")
time.sleep(1)
if ts is None:
raise RuntimeError("Failed to read snapshot committed_at")
ts_for_time_travel = ts + timedelta(seconds=1)
print(f"Snapshot timestamp: {ts_for_time_travel.strftime('%%Y-%%m-%%d %%H:%%M:%%S')}")
`, namespace, tableName, namespace, tableName)
output = runSparkPySQL(t, env.sparkContainer, insertSQL, env.icebergRestPort, env.s3Port)
if !strings.Contains(output, "Snapshot timestamp:") {
t.Fatalf("failed to get snapshot timestamp: %s", output)
}
// Extract snapshot timestamp from output - look specifically for the "Snapshot timestamp:" line
var snapshotTS string
tsRe := regexp.MustCompile(`Snapshot timestamp:\s*(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})`)
matches := tsRe.FindStringSubmatch(output)
if len(matches) > 1 {
snapshotTS = matches[1]
}
if snapshotTS == "" {
t.Fatalf("could not extract snapshot timestamp from output: %s", output)
}
// Wait to ensure the next insert gets a distinct snapshot timestamp
time.Sleep(2 * time.Second)
// Insert more data
t.Logf(">>> Inserting more data")
insertMoreSQL := fmt.Sprintf(`
spark.sql("""
INSERT INTO iceberg.%s.%s VALUES (2, 20)
""")
print("More data inserted")
`, namespace, tableName)
output = runSparkPySQL(t, env.sparkContainer, insertMoreSQL, env.icebergRestPort, env.s3Port)
if !strings.Contains(output, "More data inserted") {
t.Fatalf("failed to insert more data, output: %s", output)
}
// Verify count increased to 2
t.Logf(">>> Verifying row count after second insert")
verifySQL := fmt.Sprintf(`
result = spark.sql("SELECT COUNT(*) as count FROM iceberg.%s.%s")
count = result.collect()[0]['count']
print(f"Current row count: {count}")
`, namespace, tableName)
output = runSparkPySQL(t, env.sparkContainer, verifySQL, env.icebergRestPort, env.s3Port)
if !strings.Contains(output, "Current row count: 2") {
t.Fatalf("expected current row count 2 after second insert, got output: %s", output)
}
// Time travel to first snapshot
t.Logf(">>> Time traveling to first snapshot")
timeTravelSQL := fmt.Sprintf(`
result = spark.sql("""
SELECT COUNT(*) as count FROM iceberg.%s.%s TIMESTAMP AS OF '%s'
""")
result.show()
count = result.collect()[0]['count']
print(f"Count at snapshot: {count}")
`, namespace, tableName, snapshotTS)
output = runSparkPySQL(t, env.sparkContainer, timeTravelSQL, env.icebergRestPort, env.s3Port)
if !strings.Contains(output, "Count at snapshot: 1") {
t.Errorf("expected count 1 at first snapshot, got: %s", output)
}
t.Logf(">>> Time travel test passed")
}
// TestSparkMultiLevelNamespace tests that multi-level namespaces produce correct
// S3 paths (dot-separated) so that Spark can read back the data it writes.
// Regression test for https://github.com/seaweedfs/seaweedfs/issues/8959
func TestSparkMultiLevelNamespace(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
env, _, _ := setupSparkTestEnv(t)
// Use a two-level namespace like "analytics.daily"
nsLevel1 := "analytics_" + randomString(4)
nsLevel2 := "daily_" + randomString(4)
multiNs := fmt.Sprintf("%s.%s", nsLevel1, nsLevel2)
tableName := "events_" + randomString(4)
// Create multi-level namespace
t.Logf(">>> Creating multi-level namespace: %s", multiNs)
createNsSQL := fmt.Sprintf(`
spark.sql("CREATE NAMESPACE iceberg.%s")
print("Namespace created")
`, multiNs)
output := runSparkPySQL(t, env.sparkContainer, createNsSQL, env.icebergRestPort, env.s3Port)
if !strings.Contains(output, "Namespace created") {
t.Fatalf("multi-level namespace creation failed, output: %s", output)
}
// Create table under multi-level namespace
t.Logf(">>> Creating table under multi-level namespace")
createTableSQL := fmt.Sprintf(`
spark.sql("""
CREATE TABLE iceberg.%s.%s (
id INT,
event STRING,
ts TIMESTAMP
)
USING iceberg
""")
print("Table created")
`, multiNs, tableName)
output = runSparkPySQL(t, env.sparkContainer, createTableSQL, env.icebergRestPort, env.s3Port)
if !strings.Contains(output, "Table created") {
t.Fatalf("table creation under multi-level namespace failed, output: %s", output)
}
// Insert data
t.Logf(">>> Inserting data into multi-level namespace table")
insertSQL := fmt.Sprintf(`
spark.sql("""
INSERT INTO iceberg.%s.%s VALUES
(1, 'click', TIMESTAMP '2025-01-01 00:00:00'),
(2, 'view', TIMESTAMP '2025-01-01 01:00:00'),
(3, 'click', TIMESTAMP '2025-01-02 00:00:00')
""")
print("Data inserted")
`, multiNs, tableName)
output = runSparkPySQL(t, env.sparkContainer, insertSQL, env.icebergRestPort, env.s3Port)
if !strings.Contains(output, "Data inserted") {
t.Fatalf("data insertion failed, output: %s", output)
}
// Query data back — this is the key test: if the namespace path separator
// was wrong (\x1F instead of "."), Spark would not find the data files.
t.Logf(">>> Querying data from multi-level namespace table")
querySQL := fmt.Sprintf(`
result = spark.sql("SELECT COUNT(*) as count FROM iceberg.%s.%s")
count = result.collect()[0]['count']
print(f"Row count: {count}")
`, multiNs, tableName)
output = runSparkPySQL(t, env.sparkContainer, querySQL, env.icebergRestPort, env.s3Port)
if !strings.Contains(output, "Row count: 3") {
t.Errorf("expected row count 3 from multi-level namespace table, got output: %s", output)
}
t.Logf(">>> Multi-level namespace test passed")
}

View File

@@ -0,0 +1,63 @@
# Spark Lance Integration Test
Drives the SeaweedFS Lance Namespace with Spark, through the Lance Spark
connector's DSV2 catalog. The Lance counterpart of `catalog_spark`, which does
the same for the Iceberg REST catalog.
## Why Spark
Spark is the engine most likely to be pointed at a lakehouse, and it reaches the
catalog over the same Lance Namespace routes as every other client. Between this
and `catalog_lancedb`, the catalog is exercised by the two clients people
actually use, rather than only by the protocol's reference implementation.
## What it does
`TestSparkLanceNamespace`:
1. Starts a `weed mini` cluster with S3 and the Lance Namespace enabled.
2. Creates a table bucket declared `LANCE`.
3. Runs `spark_lance_ops.py` inside the stock `apache/spark:3.5.1` image, with
the connector pulled from Maven at submit time.
Inside Spark:
| Step | What it proves |
| --- | --- |
| `CREATE NAMESPACE` / `SHOW NAMESPACES` | the bucket is the first level of the path, and the catalog is writable |
| `CREATE TABLE ... USING lance` | the connector declares through the namespace and writes the data itself |
| `INSERT` / `SELECT count(*)` | a write lands and reads back through the catalog |
| schema check | the vector column survived the round trip |
| `WHERE id >= 2` | the filter path, not only a full scan |
| a second `INSERT` | the commit a store that cannot order commits fails on |
## Configuration
The connector's catalog properties. The host names below are placeholders — the
test passes dynamically allocated `host.docker.internal` ports, and a real
deployment uses whatever address the gateway answers on:
```
spark.sql.catalog.lance org.lance.spark.LanceNamespaceSparkCatalog
spark.sql.catalog.lance.impl rest
spark.sql.catalog.lance.uri http://seaweed:9101
spark.sql.catalog.lance.storage.aws_endpoint http://seaweed:8333
spark.sql.catalog.lance.storage.allow_http true
spark.sql.catalog.lance.storage.aws_access_key_id …
spark.sql.catalog.lance.storage.aws_secret_access_key …
```
Anything under `storage.` is handed to lance as object_store options, so those
are object_store's key names rather than Spark's `s3a` ones. Credentials belong
there because a gateway without STS vends none — see the note in
`../catalog_lancedb/README.md`.
## Running it
cd test/s3tables/catalog_spark_lance
(cd ../../../weed && go build .) # the harness runs this binary
go test -run TestSparkLanceNamespace -v -timeout 40m .
Skipped without Docker, and in `-short` mode. The first run downloads the Spark
image and the connector bundle, which is a few hundred megabytes; later runs
reuse both.

View File

@@ -0,0 +1,153 @@
#!/usr/bin/env python3
"""Drive the SeaweedFS Lance Namespace with Spark.
Spark reaches the catalog through the Lance Spark connector's DSV2 catalog,
`org.lance.spark.LanceNamespaceSparkCatalog` with `impl=rest`, which speaks the
same routes this catalog implements. It is the Lance counterpart of the Spark
Iceberg suite next door.
Every step prints what happened. The last line is PASS, or a line starting with
FAIL that names the step, so the Go harness can report something useful rather
than a stack trace.
"""
import argparse
import sys
from pyspark.sql import SparkSession
from pyspark.sql.types import ArrayType, FloatType
def check(condition, message):
if not condition:
print(f"FAIL: {message}", file=sys.stderr)
raise SystemExit(1)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--namespace-url", required=True)
parser.add_argument("--s3-endpoint", required=True)
parser.add_argument("--bucket", required=True)
parser.add_argument("--namespace", default="ml")
parser.add_argument("--table", default="embeddings")
parser.add_argument("--access-key", default="any")
parser.add_argument("--secret-key", default="any")
parser.add_argument("--packages", required=True)
parser.add_argument("--ivy-dir", default="/tmp/ivy")
args = parser.parse_args()
# storage.* is handed to lance as its object_store options, so these are
# object_store's own key names rather than Spark's s3a ones.
spark = (
SparkSession.builder.appName("SeaweedFS Lance Namespace Test")
.config("spark.jars.ivy", args.ivy_dir)
.config("spark.jars.packages", args.packages)
.config("spark.sql.catalog.lance", "org.lance.spark.LanceNamespaceSparkCatalog")
.config("spark.sql.catalog.lance.impl", "rest")
.config("spark.sql.catalog.lance.uri", args.namespace_url)
.config("spark.sql.catalog.lance.storage.aws_endpoint", args.s3_endpoint)
.config("spark.sql.catalog.lance.storage.allow_http", "true")
.config("spark.sql.catalog.lance.storage.aws_access_key_id", args.access_key)
.config("spark.sql.catalog.lance.storage.aws_secret_access_key", args.secret_key)
.config("spark.sql.catalog.lance.storage.aws_region", "us-east-1")
.config("spark.sql.catalog.lance.storage.virtual_hosted_style_request", "false")
.getOrCreate()
)
spark.sparkContext.setLogLevel("WARN")
print(f"spark {spark.version} connected to {args.namespace_url}")
ns = f"lance.`{args.bucket}`.{args.namespace}"
table = f"{ns}.{args.table}"
# 1. A table bucket is the first level of the namespace path, so the
# namespace below it is created through the catalog like any other.
spark.sql(f"CREATE NAMESPACE IF NOT EXISTS {ns}")
namespaces = [row[0] for row in spark.sql(f"SHOW NAMESPACES IN lance.`{args.bucket}`").collect()]
print(f"SHOW NAMESPACES -> {namespaces}")
check(
any(args.namespace in name for name in namespaces),
f"{args.namespace} is not listed in {namespaces}",
)
# 2. Creating a table. The connector declares it through the namespace and
# writes the data itself, which is the split this catalog serves.
spark.sql(f"DROP TABLE IF EXISTS {table}")
spark.sql(
f"CREATE TABLE {table} (id BIGINT, title STRING, vector ARRAY<FLOAT>) USING lance"
)
# SHOW TABLES gives back the namespace's own identifiers - bucket, namespace
# and name joined by the delimiter - rather than a bare Spark table name.
tables = [row[1] for row in spark.sql(f"SHOW TABLES IN {ns}").collect()]
print(f"SHOW TABLES -> {tables}")
check(
any(args.table in name for name in tables),
f"{args.table} is not listed in {tables}",
)
# 3. Write, and read back through the catalog.
spark.sql(
f"""INSERT INTO {table} VALUES
(1, 'one', array(1.0f, 2.0f, 3.0f)),
(2, 'two', array(2.0f, 3.0f, 4.0f)),
(3, 'three', array(3.0f, 4.0f, 5.0f))"""
)
count = spark.sql(f"SELECT count(*) FROM {table}").collect()[0][0]
print(f"count -> {count}")
check(count == 3, f"read back {count} rows, want 3")
# 4. The schema survived the round trip, vector column included - the type
# as well as the name, since a column that came back as ARRAY<DOUBLE> or
# ARRAY<STRING> would still be called "vector".
fields = {field.name: field.dataType for field in spark.table(table).schema.fields}
print(f"schema -> {[(n, t.simpleString()) for n, t in sorted(fields.items())]}")
check({"id", "title", "vector"} <= set(fields), f"schema lost columns: {sorted(fields)}")
vector = fields["vector"]
check(
isinstance(vector, ArrayType) and isinstance(vector.elementType, FloatType),
f"vector came back as {vector.simpleString()}, want array<float>",
)
check(
fields["id"].simpleString() == "bigint",
f"id came back as {fields['id'].simpleString()}, want bigint",
)
# 5. A filter, so it is not only a full scan.
rows = spark.sql(f"SELECT id, title FROM {table} WHERE id >= 2 ORDER BY id").collect()
print(f"filtered -> {[(r[0], r[1]) for r in rows]}")
check(len(rows) == 2, f"filter returned {len(rows)} rows, want 2")
check(rows[0][0] == 2, f"filter returned {rows[0][0]} first, want 2")
# 6. Appending again, because a second commit is the one that fails when a
# store cannot order commits - this store can, so it must not.
spark.sql(f"INSERT INTO {table} VALUES (4, 'four', array(4.0f, 5.0f, 6.0f))")
count = spark.sql(f"SELECT count(*) FROM {table}").collect()[0][0]
print(f"count after a second commit -> {count}")
check(count == 4, f"after appending, {count} rows, want 4")
# 7. And the dataset is readable straight off its location, with no catalog
# in the path. This is the property that lets duckdb, pandas and
# DataFusion read these tables, so it is read rather than asserted by
# comment: the reader is given the storage options directly and never
# consults spark.sql.catalog.lance.
location = f"s3://{args.bucket}/{args.namespace}/{args.table}"
direct = (
spark.read.format("lance")
.option("aws_endpoint", args.s3_endpoint)
.option("allow_http", "true")
.option("aws_access_key_id", args.access_key)
.option("aws_secret_access_key", args.secret_key)
.option("aws_region", "us-east-1")
.load(location)
)
direct_count = direct.count()
print(f"direct read of {location} -> {direct_count} rows")
check(direct_count == 4, f"direct read got {direct_count} rows, want 4")
spark.stop()
print("PASS")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,339 @@
// Package sparklance drives the SeaweedFS Lance Namespace with Spark, through
// the Lance Spark connector's DSV2 catalog. It is the Lance counterpart of the
// catalog_spark suite, which does the same for the Iceberg REST catalog.
//
// Spark is the engine most likely to be pointed at a lakehouse, and it reaches
// the catalog over the same routes as every other client - so what this proves
// is the protocol, not our idea of it.
package sparklance
import (
"context"
"fmt"
"math/rand"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/test/testutil"
)
const (
sparkImage = "apache/spark:3.5.1"
// Pinned: the connector is as much the thing under test as the server, and
// an unrelated release should not change what an old commit reproduces.
lancePackages = "org.lance:lance-spark-bundle-3.5_2.12:0.7.1"
startupTimeout = 60 * time.Second
clientTimeout = 25 * time.Minute
)
// TestSparkLanceNamespace runs Spark SQL against the namespace end to end:
// create a namespace and a table through the catalog, write, read back, filter,
// and append a second time.
func TestSparkLanceNamespace(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
if !hasDocker() {
t.Skip("Docker not available, skipping Spark Lance integration test")
}
env := newEnvironment(t)
defer env.cleanup()
env.start(t)
bucket := "sparklance-" + randomSuffix()
env.createTableBucket(t, bucket)
env.runSpark(t, bucket)
}
type environment struct {
weedBinary string
dataDir string
bindIP string
masterPort int
masterGrpcPort int
volumePort int
volumeGrpcPort int
filerPort int
filerGrpcPort int
s3Port int
s3GrpcPort int
lancePort int
accessKey string
secretKey string
weedCancel context.CancelFunc
weedCmd *exec.Cmd
}
func newEnvironment(t *testing.T) *environment {
t.Helper()
wd, err := os.Getwd()
if err != nil {
t.Fatalf("get working directory: %v", err)
}
seaweedDir := wd
for i := 0; i < 5; i++ {
if _, err := os.Stat(filepath.Join(seaweedDir, "go.mod")); err == nil {
break
}
seaweedDir = filepath.Dir(seaweedDir)
}
weedBinary := filepath.Join(seaweedDir, "weed", "weed")
if info, statErr := os.Stat(weedBinary); statErr == nil && !info.IsDir() {
// `make test` builds first; a plain `go test` will otherwise drive a
// binary from days ago and report a pass for code it never ran.
t.Logf("using %s, built %s", weedBinary, info.ModTime().Format(time.RFC3339))
} else {
weedBinary = "weed"
if _, err := exec.LookPath(weedBinary); err != nil {
t.Skip("weed binary not found, skipping integration test")
}
}
dataDir, err := os.MkdirTemp("", "seaweed-spark-lance-test-*")
if err != nil {
t.Fatalf("create temp dir: %v", err)
}
ports := testutil.MustAllocatePorts(t, 9)
return &environment{
weedBinary: weedBinary,
dataDir: dataDir,
bindIP: testutil.FindBindIP(),
masterPort: ports[0],
masterGrpcPort: ports[1],
volumePort: ports[2],
volumeGrpcPort: ports[3],
filerPort: ports[4],
filerGrpcPort: ports[5],
s3Port: ports[6],
s3GrpcPort: ports[7],
lancePort: ports[8],
accessKey: "AKIAIOSFODNN7EXAMPLE",
secretKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
}
}
func (env *environment) start(t *testing.T) {
t.Helper()
iamConfigPath, err := testutil.WriteIAMConfig(env.dataDir, env.accessKey, env.secretKey)
if err != nil {
t.Fatalf("write IAM config: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
env.weedCancel = cancel
cmd := exec.CommandContext(ctx, env.weedBinary, "mini",
"-master.port", fmt.Sprintf("%d", env.masterPort),
"-master.port.grpc", fmt.Sprintf("%d", env.masterGrpcPort),
"-volume.port", fmt.Sprintf("%d", env.volumePort),
"-volume.port.grpc", fmt.Sprintf("%d", env.volumeGrpcPort),
"-filer.port", fmt.Sprintf("%d", env.filerPort),
"-filer.port.grpc", fmt.Sprintf("%d", env.filerGrpcPort),
"-s3.port", fmt.Sprintf("%d", env.s3Port),
"-s3.port.grpc", fmt.Sprintf("%d", env.s3GrpcPort),
"-s3.port.lance", fmt.Sprintf("%d", env.lancePort),
"-s3.config", iamConfigPath,
"-ip", env.bindIP,
"-ip.bind", "0.0.0.0",
"-dir", env.dataDir,
)
cmd.Dir = env.dataDir
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Env = append(os.Environ(),
"AWS_ACCESS_KEY_ID="+env.accessKey,
"AWS_SECRET_ACCESS_KEY="+env.secretKey,
)
if err := cmd.Start(); err != nil {
t.Fatalf("start SeaweedFS: %v", err)
}
env.weedCmd = cmd
// The namespace answers /v1/table once it is serving, which is a cheaper
// readiness check than waiting on a bucket that does not exist yet.
url := fmt.Sprintf("http://%s:%d/v1/table", env.bindIP, env.lancePort)
if !waitForHTTP(url, startupTimeout) {
t.Fatalf("the Lance namespace did not become ready at %s", url)
}
}
// waitForHTTP polls until the URL answers at all. An auth refusal counts: it
// means the server is up, which is the only thing being waited on.
func waitForHTTP(url string, timeout time.Duration) bool {
client := &http.Client{Timeout: 2 * time.Second}
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
resp, err := client.Get(url)
if err != nil {
time.Sleep(500 * time.Millisecond)
continue
}
status := resp.StatusCode
resp.Body.Close()
if status < 500 {
return true
}
time.Sleep(500 * time.Millisecond)
}
return false
}
func (env *environment) cleanup() {
if env.weedCancel != nil {
env.weedCancel()
}
if env.weedCmd != nil {
_ = env.weedCmd.Wait()
}
if env.dataDir != "" {
_ = os.RemoveAll(env.dataDir)
}
}
// createTableBucket makes the bucket LanceDB will read through, declared LANCE
// so the catalog refuses anything of another format in it.
func (env *environment) createTableBucket(t *testing.T, bucket string) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, env.weedBinary, "shell",
fmt.Sprintf("-master=%s:%d.%d", env.bindIP, env.masterPort, env.masterGrpcPort),
)
cmd.Stdin = strings.NewReader(fmt.Sprintf(
"s3tables.bucket -create -name %s -format LANCE -account 000000000000\nexit\n", bucket))
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("create table bucket %s: %v\n%s", bucket, err, out)
}
// weed shell reports a command's own failure on stdout and still exits 0, so
// the exit code alone would let a missing bucket through and turn a setup
// failure into a confusing engine failure later.
if !env.tableBucketExists(t, bucket) {
t.Fatalf("table bucket %s was not created:\n%s", bucket, out)
}
t.Logf("created LANCE table bucket %s", bucket)
}
// tableBucketExists asks the namespace, which lists table buckets at its root.
func (env *environment) tableBucketExists(t *testing.T, bucket string) bool {
t.Helper()
url := fmt.Sprintf("http://%s:%d/v1/namespace/%s/exists", env.bindIP, env.lancePort, bucket)
resp, err := http.Post(url, "application/json", strings.NewReader("{}"))
if err != nil {
t.Fatalf("ask the namespace whether %s exists: %v", bucket, err)
}
defer resp.Body.Close()
return resp.StatusCode == http.StatusOK
}
// runSpark runs the SQL driver inside the stock Spark image. The connector is
// pulled from Maven at submit time, the way the Iceberg Spark suite pulls its
// runtime, so nothing has to be built here.
func (env *environment) runSpark(t *testing.T, bucket string) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), clientTimeout)
defer cancel()
script, absErr := filepath.Abs("spark_lance_ops.py")
if absErr != nil {
t.Fatalf("resolve the driver script: %v", absErr)
}
// Ivy needs somewhere writable, and the Spark image runs as a user without a
// home directory it can write to. Kept outside the run's data directory so
// the 287MB connector bundle is downloaded once rather than on every run,
// and under the user's own cache rather than a shared temp path: this is
// mounted into a container running as root, so another local user must not
// be able to pre-create it and choose what Spark loads.
cacheRoot, err := os.UserCacheDir()
if err != nil {
cacheRoot = env.dataDir
}
ivyDir := filepath.Join(cacheRoot, "seaweedfs-lance-spark-ivy")
if err := os.MkdirAll(ivyDir, 0o755); err != nil {
t.Fatalf("create the ivy directory: %v", err)
}
// The container reaches the gateway through the host gateway address, which
// is not the address the namespace advertises to its own clients.
namespaceURL := fmt.Sprintf("http://host.docker.internal:%d", env.lancePort)
s3Endpoint := fmt.Sprintf("http://host.docker.internal:%d", env.s3Port)
cmd := exec.CommandContext(ctx, "docker", "run", "--rm",
"--add-host", "host.docker.internal:host-gateway",
"-v", script+":/opt/spark/work-dir/spark_lance_ops.py:ro",
"-v", ivyDir+":/tmp/ivy",
"-e", "HOME=/tmp",
"-e", "SPARK_LOCAL_IP=127.0.0.1",
"-u", "root",
sparkImage,
"/opt/spark/bin/spark-submit",
"--packages", lancePackages,
"--conf", "spark.jars.ivy=/tmp/ivy",
"/opt/spark/work-dir/spark_lance_ops.py",
"--namespace-url", namespaceURL,
"--s3-endpoint", s3Endpoint,
"--bucket", bucket,
"--access-key", env.accessKey,
"--secret-key", env.secretKey,
"--packages", lancePackages,
"--ivy-dir", "/tmp/ivy",
)
out, err := cmd.CombinedOutput()
t.Logf("Spark output:\n%s", tailLines(string(out), 60))
if err != nil {
t.Fatalf("the Spark driver failed: %v", err)
}
if !strings.Contains(string(out), "PASS") {
t.Fatalf("the Spark driver did not report PASS")
}
}
// tailLines keeps the end of Spark's very chatty output, which is where the
// driver's own lines and any failure are.
func tailLines(out string, n int) string {
lines := strings.Split(strings.TrimRight(out, "\n"), "\n")
if len(lines) <= n {
return out
}
return "... " + fmt.Sprintf("%d earlier lines omitted", len(lines)-n) + "\n" +
strings.Join(lines[len(lines)-n:], "\n")
}
// hasDocker reports whether a Docker daemon answers. Bounded, because an
// unhealthy daemon makes `docker version` hang, and this runs before the test
// has a timeout of its own: better to skip than to eat the whole budget.
func hasDocker() bool {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
return exec.CommandContext(ctx, "docker", "version").Run() == nil
}
func randomSuffix() string {
const charset = "abcdefghijklmnopqrstuvwxyz0123456789"
suffix := make([]byte, 8)
for i := range suffix {
suffix[i] = charset[rand.Intn(len(charset))]
}
return string(suffix)
}

View File

@@ -0,0 +1,248 @@
package catalog_trino
import (
"fmt"
"os/exec"
"strconv"
"strings"
"testing"
"time"
)
func TestTrinoBlogOperations(t *testing.T) {
env := setupTrinoTest(t)
defer env.Cleanup(t)
schemaName := "blog_ns_" + randomString(6)
customersTable := "customers_" + randomString(6)
trinoCustomersTable := "trino_customers_" + randomString(6)
warehouseBucket := "iceberg-tables"
customersLocation := fmt.Sprintf("s3://%s/%s/%s_%s", warehouseBucket, schemaName, customersTable, randomString(6))
trinoCustomersLocation := fmt.Sprintf("s3://%s/%s/%s_%s", warehouseBucket, schemaName, trinoCustomersTable, randomString(6))
runTrinoSQL(t, env.trinoContainer, fmt.Sprintf("CREATE SCHEMA IF NOT EXISTS iceberg.%s", schemaName))
defer runTrinoSQLAllowNamespaceNotEmpty(t, env.trinoContainer, fmt.Sprintf("DROP SCHEMA IF EXISTS iceberg.%s CASCADE", schemaName))
defer runTrinoSQL(t, env.trinoContainer, fmt.Sprintf("DROP TABLE IF EXISTS iceberg.%s.%s", schemaName, trinoCustomersTable))
defer runTrinoSQL(t, env.trinoContainer, fmt.Sprintf("DROP TABLE IF EXISTS iceberg.%s.%s", schemaName, customersTable))
runTrinoSQL(t, env.trinoContainer, fmt.Sprintf("DROP TABLE IF EXISTS iceberg.%s.%s", schemaName, trinoCustomersTable))
runTrinoSQL(t, env.trinoContainer, fmt.Sprintf("DROP TABLE IF EXISTS iceberg.%s.%s", schemaName, customersTable))
createCustomersSQL := fmt.Sprintf(`CREATE TABLE IF NOT EXISTS iceberg.%s.%s (
customer_sk INT,
customer_id VARCHAR,
salutation VARCHAR,
first_name VARCHAR,
last_name VARCHAR,
preferred_cust_flag VARCHAR,
birth_day INT,
birth_month INT,
birth_year INT,
birth_country VARCHAR,
login VARCHAR
) WITH (
format = 'PARQUET',
sorted_by = ARRAY['customer_id'],
location = '%s'
)`, schemaName, customersTable, customersLocation)
runTrinoSQLAllowExists(t, env.trinoContainer, createCustomersSQL)
insertCustomersSQL := fmt.Sprintf(`INSERT INTO iceberg.%s.%s VALUES
(1, 'AAAAA', 'Mrs', 'Amanda', 'Olson', 'Y', 8, 4, 1984, 'US', 'aolson'),
(2, 'AAAAB', 'Mr', 'Leonard', 'Eads', 'N', 22, 6, 2001, 'US', 'leads'),
(3, 'BAAAA', 'Mr', 'David', 'White', 'Y', 16, 2, 1999, 'US', 'dwhite'),
(4, 'BBAAA', 'Mr', 'Melvin', 'Lee', 'N', 30, 3, 1973, 'US', 'mlee'),
(5, 'AACAA', 'Mr', 'Donald', 'Holt', 'N', 2, 6, 1982, 'CA', 'dholt'),
(6, 'ABAAA', 'Mrs', 'Jacqueline', 'Harvey', 'N', 5, 12, 1988, 'US', 'jharvey'),
(7, 'BBAAA', 'Ms', 'Debbie', 'Ward', 'N', 6, 1, 2006, 'MX', 'dward'),
(8, 'ACAAA', 'Mr', 'Tim', 'Strong', 'N', 15, 7, 1976, 'US', 'tstrong')
`, schemaName, customersTable)
runTrinoSQL(t, env.trinoContainer, insertCustomersSQL)
countOutput := runTrinoSQL(t, env.trinoContainer, fmt.Sprintf("SELECT count(*) FROM iceberg.%s.%s", schemaName, customersTable))
rowCount := mustParseCSVInt64(t, countOutput)
if rowCount != 8 {
t.Fatalf("expected 8 rows in customers table, got %d", rowCount)
}
output := runTrinoSQL(t, env.trinoContainer, fmt.Sprintf("SELECT first_name FROM iceberg.%s.%s WHERE customer_sk = 1", schemaName, customersTable))
if !strings.Contains(output, "Amanda") {
t.Fatalf("expected sample query to include Amanda, got: %s", output)
}
ctasSQL := fmt.Sprintf(`CREATE TABLE iceberg.%s.%s
WITH (
format = 'PARQUET',
location = '%s'
)
AS SELECT * FROM iceberg.%s.%s`, schemaName, trinoCustomersTable, trinoCustomersLocation, schemaName, customersTable)
ctasInsertSQL := fmt.Sprintf("INSERT INTO iceberg.%s.%s SELECT * FROM iceberg.%s.%s", schemaName, trinoCustomersTable, schemaName, customersTable)
ctasDeleteSQL := fmt.Sprintf("DELETE FROM iceberg.%s.%s", schemaName, trinoCustomersTable)
runTrinoCTAS(t, env.trinoContainer, ctasSQL, ctasDeleteSQL, ctasInsertSQL)
countOutput = runTrinoSQL(t, env.trinoContainer, fmt.Sprintf("SELECT count(*) FROM iceberg.%s.%s", schemaName, trinoCustomersTable))
rowCount = mustParseCSVInt64(t, countOutput)
if rowCount != 8 {
t.Fatalf("expected 8 rows in CTAS table, got %d", rowCount)
}
runTrinoSQL(t, env.trinoContainer, fmt.Sprintf("ALTER TABLE iceberg.%s.%s ADD COLUMN updated_at TIMESTAMP", schemaName, trinoCustomersTable))
output = runTrinoSQL(t, env.trinoContainer, fmt.Sprintf("DESCRIBE iceberg.%s.%s", schemaName, trinoCustomersTable))
if !strings.Contains(output, "updated_at") {
t.Fatalf("expected updated_at column in describe output, got: %s", output)
}
runTrinoSQL(t, env.trinoContainer, fmt.Sprintf("UPDATE iceberg.%s.%s SET updated_at = current_timestamp", schemaName, trinoCustomersTable))
// Sleep to ensure timestamps are in the past for time travel queries
time.Sleep(1 * time.Second)
snapshotOutput := runTrinoSQL(t, env.trinoContainer, fmt.Sprintf(`SELECT snapshot_id FROM iceberg.%s."%s$snapshots" ORDER BY committed_at DESC LIMIT 1`, schemaName, trinoCustomersTable))
snapshotID := mustParseCSVInt64(t, snapshotOutput)
if snapshotID == 0 {
t.Fatalf("expected snapshot ID from snapshots table, got 0")
}
filesOutput := runTrinoSQL(t, env.trinoContainer, fmt.Sprintf(`SELECT file_path FROM iceberg.%s."%s$files" LIMIT 1`, schemaName, trinoCustomersTable))
if !hasCSVDataRow(filesOutput) {
t.Fatalf("expected files metadata rows, got: %s", filesOutput)
}
historyOutput := runTrinoSQL(t, env.trinoContainer, fmt.Sprintf(`SELECT made_current_at FROM iceberg.%s."%s$history" LIMIT 1`, schemaName, trinoCustomersTable))
if !hasCSVDataRow(historyOutput) {
t.Fatalf("expected history metadata rows, got: %s", historyOutput)
}
countOutput = runTrinoSQL(t, env.trinoContainer, fmt.Sprintf("SELECT count(*) FROM iceberg.%s.%s FOR VERSION AS OF %d", schemaName, trinoCustomersTable, snapshotID))
versionCount := mustParseCSVInt64(t, countOutput)
if versionCount != 8 {
t.Fatalf("expected 8 rows for version time travel, got %d", versionCount)
}
// Use current_timestamp - interval '1 second' to ensure it's in the past (Iceberg requirement)
countOutput = runTrinoSQL(t, env.trinoContainer, fmt.Sprintf("SELECT count(*) FROM iceberg.%s.%s FOR TIMESTAMP AS OF (current_timestamp - interval '1' second)", schemaName, trinoCustomersTable))
timestampCount := mustParseCSVInt64(t, countOutput)
if timestampCount != 8 {
t.Fatalf("expected 8 rows for timestamp time travel, got %d", timestampCount)
}
runTrinoSQL(t, env.trinoContainer, fmt.Sprintf("DELETE FROM iceberg.%s.%s WHERE customer_sk = 8", schemaName, trinoCustomersTable))
countOutput = runTrinoSQL(t, env.trinoContainer, fmt.Sprintf("SELECT count(*) FROM iceberg.%s.%s", schemaName, trinoCustomersTable))
rowCount = mustParseCSVInt64(t, countOutput)
if rowCount != 7 {
t.Fatalf("expected 7 rows after delete, got %d", rowCount)
}
runTrinoSQL(t, env.trinoContainer, fmt.Sprintf("ALTER TABLE iceberg.%s.%s EXECUTE rollback_to_snapshot(%d)", schemaName, trinoCustomersTable, snapshotID))
countOutput = runTrinoSQL(t, env.trinoContainer, fmt.Sprintf("SELECT count(*) FROM iceberg.%s.%s", schemaName, trinoCustomersTable))
rowCount = mustParseCSVInt64(t, countOutput)
if rowCount != 8 {
t.Fatalf("expected 8 rows after rollback, got %d", rowCount)
}
}
func runTrinoSQLAllowExists(t *testing.T, containerName, sql string) string {
t.Helper()
cmd := exec.Command("docker", "exec", containerName,
"trino", "--catalog", "iceberg",
"--output-format", "CSV",
"--execute", sql,
)
output, err := cmd.CombinedOutput()
if err != nil {
outputStr := string(output)
if strings.Contains(outputStr, "already exists") {
return sanitizeTrinoOutput(outputStr)
}
t.Fatalf("Trino command failed: %v\nSQL: %s\nOutput:\n%s", err, sql, outputStr)
}
return sanitizeTrinoOutput(string(output))
}
func runTrinoSQLAllowNamespaceNotEmpty(t *testing.T, containerName, sql string) string {
t.Helper()
var output []byte
var err error
for attempt := 0; attempt < 3; attempt++ {
cmd := exec.Command("docker", "exec", containerName,
"trino", "--catalog", "iceberg",
"--output-format", "CSV",
"--execute", sql,
)
output, err = cmd.CombinedOutput()
if err == nil {
return sanitizeTrinoOutput(string(output))
}
outputStr := string(output)
if !strings.Contains(outputStr, "Namespace is not empty") {
t.Fatalf("Trino command failed: %v\nSQL: %s\nOutput:\n%s", err, sql, outputStr)
}
time.Sleep(500 * time.Millisecond)
}
t.Logf("Ignoring cleanup error for SQL %s: %s", sql, sanitizeTrinoOutput(string(output)))
return sanitizeTrinoOutput(string(output))
}
func runTrinoCTAS(t *testing.T, containerName, createSQL, deleteSQL, insertSQL string) {
t.Helper()
cmd := exec.Command("docker", "exec", containerName,
"trino", "--catalog", "iceberg",
"--output-format", "CSV",
"--execute", createSQL,
)
output, err := cmd.CombinedOutput()
if err != nil {
outputStr := string(output)
if strings.Contains(outputStr, "already exists") {
if deleteSQL != "" {
runTrinoSQL(t, containerName, deleteSQL)
}
runTrinoSQL(t, containerName, insertSQL)
return
}
t.Fatalf("Trino command failed: %v\nSQL: %s\nOutput:\n%s", err, createSQL, outputStr)
}
}
func hasCSVDataRow(output string) bool {
lines := strings.Split(strings.TrimSpace(output), "\n")
if len(lines) == 0 {
return false
}
for _, line := range lines {
if strings.TrimSpace(line) != "" {
return true
}
}
return false
}
func mustParseCSVInt64(t *testing.T, output string) int64 {
t.Helper()
value := mustFirstCSVValue(t, output)
parsed, err := strconv.ParseInt(value, 10, 64)
if err != nil {
t.Fatalf("failed to parse int from output %q: %v", output, err)
}
return parsed
}
func mustFirstCSVValue(t *testing.T, output string) string {
t.Helper()
lines := strings.Split(strings.TrimSpace(output), "\n")
if len(lines) == 0 {
t.Fatalf("expected CSV output with data row, got: %q", output)
}
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
parts := strings.Split(line, ",")
return strings.Trim(parts[0], "\"")
}
t.Fatalf("no CSV data rows found in output: %q", output)
return ""
}

View File

@@ -0,0 +1,616 @@
package catalog_trino
import (
"context"
"crypto/rand"
"fmt"
"io"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/seaweedfs/seaweedfs/test/testutil"
)
type TestEnvironment struct {
seaweedDir string
weedBinary string
dataDir string
bindIP string
s3Port int
s3GrpcPort int
icebergPort int
masterPort int
masterGrpcPort int
filerPort int
filerGrpcPort int
volumePort int
volumeGrpcPort int
weedProcess *exec.Cmd
weedCancel context.CancelFunc
trinoContainer string
dockerAvailable bool
accessKey string
secretKey string
}
func TestTrinoIcebergCatalog(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
env := NewTestEnvironment(t)
defer env.Cleanup(t)
if !env.dockerAvailable {
t.Skip("Docker not available, skipping Trino integration test")
}
fmt.Printf(">>> Starting SeaweedFS...\n")
env.StartSeaweedFS(t)
fmt.Printf(">>> SeaweedFS started.\n")
tableBucket := "iceberg-tables"
catalogBucket := tableBucket
fmt.Printf(">>> Creating table bucket: %s\n", tableBucket)
createTableBucket(t, env, tableBucket)
fmt.Printf(">>> All buckets created.\n")
// Test Iceberg REST API directly
testIcebergRestAPI(t, env)
configDir := env.writeTrinoConfig(t, catalogBucket)
env.startTrinoContainer(t, configDir)
waitForTrino(t, env.trinoContainer, 60*time.Second)
schemaName := "trino_" + randomString(6)
runTrinoSQL(t, env.trinoContainer, fmt.Sprintf("CREATE SCHEMA IF NOT EXISTS iceberg.%s", schemaName))
output := runTrinoSQL(t, env.trinoContainer, "SHOW SCHEMAS FROM iceberg")
if !strings.Contains(output, schemaName) {
t.Fatalf("Expected schema %s in output:\n%s", schemaName, output)
}
runTrinoSQL(t, env.trinoContainer, fmt.Sprintf("SHOW TABLES FROM iceberg.%s", schemaName))
}
// TestTrinoMultiLevelNamespace tests that multi-level namespaces (dot-separated)
// produce correct S3 paths so Trino can read back data it writes.
// Regression test for https://github.com/seaweedfs/seaweedfs/issues/8959
func TestTrinoMultiLevelNamespace(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
env := NewTestEnvironment(t)
defer env.Cleanup(t)
if !env.dockerAvailable {
t.Skip("Docker not available, skipping Trino integration test")
}
t.Logf(">>> Starting SeaweedFS...")
env.StartSeaweedFS(t)
tableBucket := "iceberg-tables"
createTableBucket(t, env, tableBucket)
configDir := env.writeTrinoConfig(t, tableBucket, withNestedNamespace())
env.startTrinoContainer(t, configDir)
waitForTrino(t, env.trinoContainer, 60*time.Second)
// Use a two-level namespace: "analytics.daily"
nsLevel1 := "analytics_" + randomString(4)
nsLevel2 := "daily_" + randomString(4)
flatNs := fmt.Sprintf("%s.%s", nsLevel1, nsLevel2)
// Trino uses double-quoted schema names for multi-level namespaces
multiNs := fmt.Sprintf(`"%s"`, flatNs)
tableName := "events_" + randomString(4)
// Create multi-level namespace (schema)
t.Logf(">>> Creating multi-level schema: %s", flatNs)
runTrinoSQL(t, env.trinoContainer, fmt.Sprintf("CREATE SCHEMA IF NOT EXISTS iceberg.%s", multiNs))
// Verify the schema shows up
output := runTrinoSQL(t, env.trinoContainer, "SHOW SCHEMAS FROM iceberg")
if !strings.Contains(output, flatNs) {
t.Fatalf("Expected schema %s in output:\n%s", flatNs, output)
}
// Create table with explicit location to avoid non-empty location conflict.
// The location uses the dot-separated namespace — if #8959 regresses
// (unit separator instead of dot), data would be written to the wrong path.
tableLocation := fmt.Sprintf("s3://%s/%s/%s_%s", tableBucket, flatNs, tableName, randomString(6))
t.Logf(">>> Creating table at location: %s", tableLocation)
createSQL := fmt.Sprintf(`CREATE TABLE IF NOT EXISTS iceberg.%s.%s (
id INTEGER,
event VARCHAR,
ts TIMESTAMP(6)
) WITH (
format = 'PARQUET',
location = '%s'
)`, multiNs, tableName, tableLocation)
runTrinoSQLAllowExists(t, env.trinoContainer, createSQL)
// Insert data
t.Logf(">>> Inserting data into multi-level namespace table")
runTrinoSQL(t, env.trinoContainer, fmt.Sprintf(`
INSERT INTO iceberg.%s.%s VALUES
(1, 'click', TIMESTAMP '2025-01-01 00:00:00'),
(2, 'view', TIMESTAMP '2025-01-01 01:00:00'),
(3, 'click', TIMESTAMP '2025-01-02 00:00:00')
`, multiNs, tableName))
// Query data back — if the namespace path separator were wrong (\x1F
// instead of "."), the metadata location would point to a non-existent
// S3 path and this query would fail.
t.Logf(">>> Querying data from multi-level namespace table")
countOutput := runTrinoSQL(t, env.trinoContainer, fmt.Sprintf(
"SELECT count(*) FROM iceberg.%s.%s", multiNs, tableName))
rowCount := mustParseCSVInt64(t, countOutput)
if rowCount != 3 {
t.Fatalf("expected row count 3, got %d", rowCount)
}
// Verify the S3 file path contains the dot-separated namespace, not \x1F.
filesOutput := runTrinoSQL(t, env.trinoContainer, fmt.Sprintf(
`SELECT file_path FROM iceberg.%s."%s$files" LIMIT 1`, multiNs, tableName))
filePath := strings.TrimSpace(filesOutput)
if filePath == "" {
t.Fatalf("expected at least one data file, got empty output")
}
if !strings.Contains(filePath, flatNs+"/") {
t.Errorf("expected file path to contain dot-separated namespace %q, got: %s", flatNs, filePath)
}
if strings.Contains(filePath, "\x1F") {
t.Errorf("file path contains unit separator (\\x1F), expected dot separator: %s", filePath)
}
t.Logf(">>> Trino multi-level namespace test passed")
}
func NewTestEnvironment(t *testing.T) *TestEnvironment {
t.Helper()
wd, err := os.Getwd()
if err != nil {
t.Fatalf("Failed to get working directory: %v", err)
}
seaweedDir := wd
for i := 0; i < 6; i++ {
if _, err := os.Stat(filepath.Join(seaweedDir, "go.mod")); err == nil {
break
}
seaweedDir = filepath.Dir(seaweedDir)
}
weedBinary := filepath.Join(seaweedDir, "weed", "weed")
info, err := os.Stat(weedBinary)
if err != nil || info.IsDir() {
// Try looking for weed/weed/weed
weedBinary = filepath.Join(seaweedDir, "weed", "weed", "weed")
info, err = os.Stat(weedBinary)
if err != nil || info.IsDir() {
weedBinary = "weed"
if _, err := exec.LookPath(weedBinary); err != nil {
t.Skip("weed binary not found, skipping integration test")
}
}
}
dataDir, err := os.MkdirTemp("", "seaweed-trino-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
bindIP := testutil.FindBindIP()
// 9 ports: master(2), volume(2), filer(2), s3(2), iceberg(1)
ports := testutil.MustAllocatePorts(t, 9)
env := &TestEnvironment{
seaweedDir: seaweedDir,
weedBinary: weedBinary,
dataDir: dataDir,
bindIP: bindIP,
masterPort: ports[0],
masterGrpcPort: ports[1],
volumePort: ports[2],
volumeGrpcPort: ports[3],
filerPort: ports[4],
filerGrpcPort: ports[5],
s3Port: ports[6],
s3GrpcPort: ports[7],
icebergPort: ports[8],
}
env.dockerAvailable = hasDocker()
env.accessKey = "AKIAIOSFODNN7EXAMPLE"
env.secretKey = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
return env
}
func (env *TestEnvironment) StartSeaweedFS(t *testing.T) {
t.Helper()
// Create IAM config file
iamConfigPath, err := testutil.WriteIAMConfig(env.dataDir, env.accessKey, env.secretKey)
if err != nil {
t.Fatalf("Failed to create IAM config: %v", err)
}
securityToml := filepath.Join(env.dataDir, "security.toml")
if err := os.WriteFile(securityToml, []byte("# Empty security config for testing\n"), 0644); err != nil {
t.Fatalf("Failed to create security.toml: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
env.weedCancel = cancel
cmd := exec.CommandContext(ctx, env.weedBinary, "mini",
"-master.port", fmt.Sprintf("%d", env.masterPort),
"-master.port.grpc", fmt.Sprintf("%d", env.masterGrpcPort),
"-volume.port", fmt.Sprintf("%d", env.volumePort),
"-volume.port.grpc", fmt.Sprintf("%d", env.volumeGrpcPort),
"-filer.port", fmt.Sprintf("%d", env.filerPort),
"-filer.port.grpc", fmt.Sprintf("%d", env.filerGrpcPort),
"-s3.port", fmt.Sprintf("%d", env.s3Port),
"-s3.port.grpc", fmt.Sprintf("%d", env.s3GrpcPort),
"-s3.port.iceberg", fmt.Sprintf("%d", env.icebergPort),
"-s3.config", iamConfigPath,
"-ip", env.bindIP,
"-ip.bind", "0.0.0.0",
"-dir", env.dataDir,
)
cmd.Dir = env.dataDir
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
// Set AWS credentials in environment (for compatibility)
cmd.Env = append(os.Environ(),
"AWS_ACCESS_KEY_ID="+env.accessKey,
"AWS_SECRET_ACCESS_KEY="+env.secretKey,
"ICEBERG_WAREHOUSE=s3://iceberg-tables",
"S3TABLES_DEFAULT_BUCKET=iceberg-tables",
)
if err := cmd.Start(); err != nil {
t.Fatalf("Failed to start SeaweedFS: %v", err)
}
env.weedProcess = cmd
// Try to check if Iceberg API is ready
// First try checking the /v1/config endpoint (requires auth, so will return 401 if server is up)
icebergURL := fmt.Sprintf("http://%s:%d/v1/config", env.bindIP, env.icebergPort)
if !env.waitForService(icebergURL, 30*time.Second) {
// Try to get more info about why it failed
client := &http.Client{Timeout: 2 * time.Second}
resp, err := client.Get(icebergURL)
if err != nil {
t.Logf("WARNING: Could not connect to Iceberg service at %s: %v", icebergURL, err)
} else {
t.Logf("WARNING: Iceberg service returned status %d at %s", resp.StatusCode, icebergURL)
resp.Body.Close()
}
t.Fatalf("Iceberg REST API did not become ready")
}
}
func (env *TestEnvironment) Cleanup(t *testing.T) {
t.Helper()
if env.trinoContainer != "" {
_ = exec.Command("docker", "rm", "-f", env.trinoContainer).Run()
}
if env.weedCancel != nil {
env.weedCancel()
}
if env.weedProcess != nil {
time.Sleep(2 * time.Second)
_ = env.weedProcess.Wait()
}
if env.dataDir != "" {
_ = os.RemoveAll(env.dataDir)
}
}
func (env *TestEnvironment) waitForService(url string, timeout time.Duration) bool {
client := &http.Client{Timeout: 2 * time.Second}
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
resp, err := client.Get(url)
if err != nil {
// Service not responding yet
time.Sleep(500 * time.Millisecond)
continue
}
statusCode := resp.StatusCode
resp.Body.Close()
// Accept 2xx status codes (successful responses)
if statusCode >= 200 && statusCode < 300 {
return true
}
// Also accept 401/403 (auth errors mean service is up, just needs credentials)
if statusCode == http.StatusUnauthorized || statusCode == http.StatusForbidden {
return true
}
// For other status codes, keep trying
time.Sleep(500 * time.Millisecond)
}
return false
}
func testIcebergRestAPI(t *testing.T, env *TestEnvironment) {
t.Helper()
fmt.Printf(">>> Testing Iceberg REST API directly...\n")
// First, verify the service is listening
addr := net.JoinHostPort(env.bindIP, fmt.Sprintf("%d", env.icebergPort))
conn, err := net.Dial("tcp", addr)
if err != nil {
t.Fatalf("Cannot connect to Iceberg service at %s: %v", addr, err)
}
conn.Close()
t.Logf("Successfully connected to Iceberg service at %s", addr)
// Test /v1/config endpoint
url := fmt.Sprintf("http://%s:%d/v1/config", env.bindIP, env.icebergPort)
t.Logf("Testing Iceberg REST API at %s", url)
resp, err := http.Get(url)
if err != nil {
t.Fatalf("Failed to connect to Iceberg REST API at %s: %v", url, err)
}
defer resp.Body.Close()
t.Logf("Iceberg REST API response status: %d", resp.StatusCode)
body, _ := io.ReadAll(resp.Body)
t.Logf("Iceberg REST API response body: %s", string(body))
if resp.StatusCode != http.StatusOK {
t.Fatalf("Expected 200 OK from /v1/config, got %d", resp.StatusCode)
}
}
func (env *TestEnvironment) writeTrinoConfig(t *testing.T, warehouseBucket string, opts ...func(*trinoConfigOptions)) string {
t.Helper()
o := trinoConfigOptions{}
for _, fn := range opts {
fn(&o)
}
dirName := "trino"
if o.nestedNamespace {
dirName = "trino-nested"
}
configDir := filepath.Join(env.dataDir, dirName)
if err := os.MkdirAll(configDir, 0755); err != nil {
t.Fatalf("Failed to create Trino config dir: %v", err)
}
nestedLine := ""
if o.nestedNamespace {
nestedLine = "\niceberg.rest-catalog.nested-namespace-enabled=true"
}
uniqueTableLocation := "true"
if o.deterministicTableLocation {
// Skip UUID suffixing in TrinoRestCatalog.createLocationForTable so each
// table lands at the deterministic <namespace-location>/<table> path.
uniqueTableLocation = "false"
}
config := fmt.Sprintf(`connector.name=iceberg
iceberg.catalog.type=rest
iceberg.rest-catalog.uri=http://host.docker.internal:%d
iceberg.rest-catalog.warehouse=s3://%s%s
iceberg.file-format=PARQUET
iceberg.unique-table-location=%s
# S3 storage config
fs.native-s3.enabled=true
s3.endpoint=http://host.docker.internal:%d
s3.path-style-access=true
s3.signer-type=AwsS3V4Signer
s3.aws-access-key=%s
s3.aws-secret-key=%s
s3.region=us-west-2
# REST catalog authentication
iceberg.rest-catalog.security=SIGV4
`, env.icebergPort, warehouseBucket, nestedLine, uniqueTableLocation, env.s3Port, env.accessKey, env.secretKey)
if err := os.WriteFile(filepath.Join(configDir, "iceberg.properties"), []byte(config), 0644); err != nil {
t.Fatalf("Failed to write Trino config: %v", err)
}
return configDir
}
type trinoConfigOptions struct {
nestedNamespace bool
deterministicTableLocation bool
}
func withNestedNamespace() func(*trinoConfigOptions) {
return func(o *trinoConfigOptions) { o.nestedNamespace = true }
}
// withDeterministicTableLocation flips Trino's iceberg.unique-table-location
// flag to false so each table's <location> is the deterministic
// <namespace-location>/<tableName> path instead of a UUID-suffixed one.
func withDeterministicTableLocation() func(*trinoConfigOptions) {
return func(o *trinoConfigOptions) { o.deterministicTableLocation = true }
}
func (env *TestEnvironment) startTrinoContainer(t *testing.T, configDir string) {
t.Helper()
containerName := "seaweed-trino-" + randomString(8)
env.trinoContainer = containerName
cmd := exec.Command("docker", "run", "-d",
"--name", containerName,
"--add-host", "host.docker.internal:host-gateway",
"-v", fmt.Sprintf("%s:/etc/trino/catalog", configDir),
"-v", fmt.Sprintf("%s:/test", env.dataDir),
"-e", "AWS_ACCESS_KEY_ID="+env.accessKey,
"-e", "AWS_SECRET_ACCESS_KEY="+env.secretKey,
"-e", "AWS_REGION=us-west-2",
"trinodb/trino:479",
)
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("Failed to start Trino container: %v\n%s", err, string(output))
}
}
func waitForTrino(t *testing.T, containerName string, timeout time.Duration) {
t.Helper()
deadline := time.Now().Add(timeout)
var lastOutput []byte
retryCount := 0
for time.Now().Before(deadline) {
// Try system catalog query as a readiness check
cmd := exec.Command("docker", "exec", containerName,
"trino", "--catalog", "system", "--schema", "runtime",
"--execute", "SELECT 1",
)
if output, err := cmd.CombinedOutput(); err == nil {
return
} else {
lastOutput = output
outputStr := string(output)
if strings.Contains(outputStr, "No such container") ||
strings.Contains(outputStr, "is not running") {
break
}
retryCount++
}
time.Sleep(1 * time.Second)
}
// If we can't connect to system catalog, try to at least connect to Trino server
cmd := exec.Command("docker", "exec", containerName, "trino", "--version")
if err := cmd.Run(); err == nil {
// Trino process is running, even if catalog isn't ready yet
// Give it a bit more time
time.Sleep(5 * time.Second)
return
}
t.Fatalf("Timed out waiting for Trino to be ready\nLast output:\n%s", string(lastOutput))
}
func runTrinoSQL(t *testing.T, containerName, sql string) string {
t.Helper()
cmd := exec.Command("docker", "exec", containerName,
"trino", "--catalog", "iceberg",
"--output-format", "CSV",
"--execute", sql,
)
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("Trino command failed: %v\nSQL: %s\nOutput:\n%s", err, sql, string(output))
}
return sanitizeTrinoOutput(string(output))
}
func createTableBucket(t *testing.T, env *TestEnvironment, bucketName string) {
t.Helper()
// Use weed shell to create the table bucket
// Create with "000000000000" account ID (matches AccountAdmin.Id from auth_credentials.go)
// This ensures bucket owner matches authenticated identity's Account.Id
cmd := exec.Command(env.weedBinary, "shell",
fmt.Sprintf("-master=%s:%d.%d", env.bindIP, env.masterPort, env.masterGrpcPort),
)
cmd.Stdin = strings.NewReader(fmt.Sprintf("s3tables.bucket -create -name %s -account 000000000000\nexit\n", bucketName))
fmt.Printf(">>> EXECUTING: %v\n", cmd.Args)
output, err := cmd.CombinedOutput()
if err != nil {
fmt.Printf(">>> ERROR Output: %s\n", string(output))
t.Fatalf("Failed to create table bucket %s via weed shell: %v\nOutput: %s", bucketName, err, string(output))
}
fmt.Printf(">>> SUCCESS: Created table bucket %s\n", bucketName)
t.Logf("Created table bucket: %s", bucketName)
}
func sanitizeTrinoOutput(output string) string {
lines := strings.Split(strings.TrimSpace(output), "\n")
filtered := make([]string, 0, len(lines))
for _, line := range lines {
if strings.Contains(line, "org.jline.utils.Log") {
continue
}
if strings.Contains(line, "Unable to create a system terminal") {
continue
}
if strings.HasPrefix(line, "WARNING:") {
continue
}
if strings.TrimSpace(line) == "" {
continue
}
filtered = append(filtered, line)
}
if len(filtered) == 0 {
return ""
}
return strings.Join(filtered, "\n") + "\n"
}
func createObjectBucket(t *testing.T, env *TestEnvironment, bucketName string) {
t.Helper()
// Create an AWS S3 client with the test credentials pointing to our local server
cfg := aws.Config{
Region: "us-east-1",
Credentials: aws.NewCredentialsCache(credentials.NewStaticCredentialsProvider(env.accessKey, env.secretKey, "")),
BaseEndpoint: aws.String(fmt.Sprintf("http://%s:%d", env.bindIP, env.s3Port)),
}
client := s3.NewFromConfig(cfg, func(o *s3.Options) {
o.UsePathStyle = true
})
// Create the bucket using standard S3 API
_, err := client.CreateBucket(context.Background(), &s3.CreateBucketInput{
Bucket: aws.String(bucketName),
})
if err != nil {
t.Fatalf("Failed to create object bucket %s: %v", bucketName, err)
}
}
func hasDocker() bool {
cmd := exec.Command("docker", "version")
return cmd.Run() == nil
}
func randomString(length int) string {
const charset = "abcdefghijklmnopqrstuvwxyz0123456789"
b := make([]byte, length)
if _, err := rand.Read(b); err != nil {
panic("failed to generate random string: " + err.Error())
}
for i := range b {
b[i] = charset[int(b[i])%len(charset)]
}
return string(b)
}

View File

@@ -0,0 +1,208 @@
package catalog_trino
import (
"fmt"
"strings"
"testing"
"time"
)
// setupTrinoTest is a helper function that sets up the common test environment for all Trino CRUD tests
func setupTrinoTest(t *testing.T, configOpts ...func(*trinoConfigOptions)) *TestEnvironment {
t.Helper()
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
env := NewTestEnvironment(t)
if !env.dockerAvailable {
t.Skip("Docker not available, skipping Trino integration test")
}
t.Logf(">>> Starting SeaweedFS...")
env.StartSeaweedFS(t)
tableBucket := "iceberg-tables"
catalogBucket := tableBucket
createTableBucket(t, env, tableBucket)
configDir := env.writeTrinoConfig(t, catalogBucket, configOpts...)
env.startTrinoContainer(t, configDir)
waitForTrino(t, env.trinoContainer, 60*time.Second)
return env
}
// TestNamespaceCRUD tests namespace (schema) CRUD operations via Trino SQL
// Namespaces are the key container for tables in Iceberg, and this test
// verifies the full CRUD lifecycle: Create, Read (List), Update, and Delete
func TestNamespaceCRUD(t *testing.T) {
env := setupTrinoTest(t)
defer env.Cleanup(t)
// CREATE: Create a namespace
namespace1 := "crud_test_ns1_" + randomString(6)
t.Logf(">>> CREATE: Creating namespace %s", namespace1)
runTrinoSQL(t, env.trinoContainer, "CREATE SCHEMA IF NOT EXISTS iceberg."+namespace1)
t.Logf(">>> Namespace %s created", namespace1)
namespace2 := "crud_test_ns2_" + randomString(6)
t.Logf(">>> CREATE: Creating second namespace %s", namespace2)
runTrinoSQL(t, env.trinoContainer, "CREATE SCHEMA IF NOT EXISTS iceberg."+namespace2)
t.Logf(">>> Namespace %s created", namespace2)
// READ: List all namespaces
t.Logf(">>> READ: Listing all namespaces")
output := runTrinoSQL(t, env.trinoContainer, "SHOW SCHEMAS FROM iceberg")
if !strings.Contains(output, namespace1) {
t.Fatalf("Expected namespace %s in listing output:\n%s", namespace1, output)
}
if !strings.Contains(output, namespace2) {
t.Fatalf("Expected namespace %s in listing output:\n%s", namespace2, output)
}
t.Logf(">>> Both namespaces found in listing")
// UPDATE: Namespaces typically don't have "update" semantics in SQL,
// but we can verify properties via metadata queries
t.Logf(">>> UPDATE: Simulating namespace properties (via SQL properties check)")
// Iceberg REST API supports namespace properties, but Trino SQL doesn't expose them directly
// This test verifies the namespace still exists and is accessible
output = runTrinoSQL(t, env.trinoContainer, "SHOW TABLES FROM iceberg."+namespace1)
// DELETE: Drop namespaces
t.Logf(">>> DELETE: Dropping namespace %s", namespace1)
runTrinoSQL(t, env.trinoContainer, "DROP SCHEMA iceberg."+namespace1)
t.Logf(">>> Namespace %s dropped", namespace1)
t.Logf(">>> DELETE: Dropping namespace %s", namespace2)
runTrinoSQL(t, env.trinoContainer, "DROP SCHEMA iceberg."+namespace2)
t.Logf(">>> Namespace %s dropped", namespace2)
// Verify deletion: Namespaces should no longer exist
t.Logf(">>> VERIFY: Checking that namespaces are deleted")
output = runTrinoSQL(t, env.trinoContainer, "SHOW SCHEMAS FROM iceberg")
if strings.Contains(output, namespace1) {
t.Errorf("Namespace %s still appears in listing after deletion", namespace1)
} else {
t.Logf(">>> Namespace %s correctly deleted", namespace1)
}
if strings.Contains(output, namespace2) {
t.Errorf("Namespace %s still appears in listing after deletion", namespace2)
} else {
t.Logf(">>> Namespace %s correctly deleted", namespace2)
}
t.Logf(">>> TestNamespaceCRUD PASSED")
}
// TestNamespaceListingPagination tests that namespace listing works correctly
// This verifies the LIST operation with multiple namespaces
func TestNamespaceListingPagination(t *testing.T) {
env := setupTrinoTest(t)
defer env.Cleanup(t)
// Create multiple namespaces
numNamespaces := 5
namespaces := make([]string, numNamespaces)
t.Logf(">>> Creating %d namespaces for listing test", numNamespaces)
for i := 0; i < numNamespaces; i++ {
namespaces[i] = "list_test_ns" + fmt.Sprintf("%d", i+1) + "_" + randomString(4)
runTrinoSQL(t, env.trinoContainer, "CREATE SCHEMA IF NOT EXISTS iceberg."+namespaces[i])
t.Logf(">>> Created namespace %d: %s", i+1, namespaces[i])
}
// List all namespaces
t.Logf(">>> Listing all namespaces")
output := runTrinoSQL(t, env.trinoContainer, "SHOW SCHEMAS FROM iceberg")
// Verify all namespaces are in the listing
t.Logf(">>> Verifying all namespaces are in listing")
for i, ns := range namespaces {
if !strings.Contains(output, ns) {
t.Fatalf("Expected namespace %d (%s) in listing output", i+1, ns)
}
t.Logf(">>> Namespace %d (%s) found in listing", i+1, ns)
}
// Clean up
t.Logf(">>> Cleaning up namespaces")
for _, ns := range namespaces {
runTrinoSQL(t, env.trinoContainer, "DROP SCHEMA iceberg."+ns)
}
t.Logf(">>> Cleanup complete")
t.Logf(">>> TestNamespaceListingPagination PASSED")
}
// TestNamespaceErrorHandling tests error handling for namespace operations
// Tests both idempotent operations and actual error cases
func TestNamespaceErrorHandling(t *testing.T) {
env := setupTrinoTest(t)
defer env.Cleanup(t)
// Test 1: Create and drop same namespace
ns := "error_test_ns_" + randomString(6)
t.Logf(">>> Test 1: Creating and dropping namespace %s", ns)
runTrinoSQL(t, env.trinoContainer, "CREATE SCHEMA IF NOT EXISTS iceberg."+ns)
runTrinoSQL(t, env.trinoContainer, "DROP SCHEMA iceberg."+ns)
t.Logf(">>> Namespace created and dropped successfully")
// Test 2: Try to drop non-existent namespace with IF EXISTS (should succeed gracefully)
nonExistent := "nonexistent_" + randomString(6)
t.Logf(">>> Test 2: Attempting to drop non-existent namespace %s with IF EXISTS", nonExistent)
runTrinoSQL(t, env.trinoContainer, "DROP SCHEMA IF EXISTS iceberg."+nonExistent)
t.Logf(">>> Drop non-existent namespace handled gracefully (IF EXISTS clause)")
// Test 3: Creating duplicate namespace (with IF NOT EXISTS - should succeed gracefully)
ns2 := "dup_test_ns_" + randomString(6)
t.Logf(">>> Test 3: Creating namespace %s twice (using IF NOT EXISTS)", ns2)
runTrinoSQL(t, env.trinoContainer, "CREATE SCHEMA IF NOT EXISTS iceberg."+ns2)
runTrinoSQL(t, env.trinoContainer, "CREATE SCHEMA IF NOT EXISTS iceberg."+ns2)
t.Logf(">>> Duplicate creation handled gracefully")
// Test 4: Verify schema properties persist after creation
t.Logf(">>> Test 4: Verifying namespace still exists after duplicate creation attempt")
output := runTrinoSQL(t, env.trinoContainer, "SHOW SCHEMAS FROM iceberg")
if !strings.Contains(output, ns2) {
t.Errorf("Expected namespace %s to exist in listing after duplicate creation attempt", ns2)
} else {
t.Logf(">>> Namespace %s correctly persists", ns2)
}
// Cleanup
runTrinoSQL(t, env.trinoContainer, "DROP SCHEMA iceberg."+ns2)
t.Logf(">>> TestNamespaceErrorHandling PASSED")
}
// TestSchemaIntegrationWithCatalog tests that schemas are properly integrated with the catalog
// This verifies that schema operations through Trino are visible through Iceberg REST API concepts
func TestSchemaIntegrationWithCatalog(t *testing.T) {
env := setupTrinoTest(t)
defer env.Cleanup(t)
// Create a schema through Trino
schemaName := "catalog_integration_" + randomString(6)
t.Logf(">>> Creating schema through Trino SQL: %s", schemaName)
runTrinoSQL(t, env.trinoContainer, "CREATE SCHEMA IF NOT EXISTS iceberg."+schemaName)
// Verify it's accessible through catalog (list schemas)
t.Logf(">>> Verifying schema is visible through catalog (SHOW SCHEMAS)")
output := runTrinoSQL(t, env.trinoContainer, "SHOW SCHEMAS FROM iceberg")
if !strings.Contains(output, schemaName) {
t.Fatalf("Created schema %s not visible in catalog listing", schemaName)
}
t.Logf(">>> Schema successfully verified in catalog")
// Verify empty schema
t.Logf(">>> Verifying schema is empty (no tables)")
output = runTrinoSQL(t, env.trinoContainer, "SHOW TABLES FROM iceberg."+schemaName)
// Clean up
t.Logf(">>> Cleaning up schema")
runTrinoSQL(t, env.trinoContainer, "DROP SCHEMA iceberg."+schemaName)
t.Logf(">>> TestSchemaIntegrationWithCatalog PASSED")
}

View File

@@ -0,0 +1,121 @@
package catalog_trino
import (
"context"
"fmt"
"strings"
"testing"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
)
// TestTrinoDeterministicLocationCTAS exercises the Trino REST catalog with
// `iceberg.unique-table-location=false`. With #9246 advertising a default
// namespace `location` property, Trino's defaultTableLocation is non-null,
// so even with the unique-table-location flag flipped off the CREATE flow
// takes the deferred-transaction branch and the listFiles emptiness check
// passes.
//
// The benefit users opt into by flipping the flag is a single dir per
// table on disk (catalog entry and Iceberg <location> share a path, no
// sibling `<table>-<uuid>/` directory). This test exercises a fresh CTAS
// in that mode and asserts the on-disk layout has no UUID-suffixed sibling.
func TestTrinoDeterministicLocationCTAS(t *testing.T) {
env := setupTrinoTest(t, withDeterministicTableLocation())
defer env.Cleanup(t)
schemaName := "detloc_" + randomString(6)
srcName := "src_" + randomString(6)
ctasName := "ctas_" + randomString(6)
srcQualified := fmt.Sprintf("iceberg.%s.%s", schemaName, srcName)
ctasQualified := fmt.Sprintf("iceberg.%s.%s", schemaName, ctasName)
runTrinoSQL(t, env.trinoContainer, fmt.Sprintf("CREATE SCHEMA IF NOT EXISTS iceberg.%s", schemaName))
defer runTrinoSQLAllowNamespaceNotEmpty(t, env.trinoContainer, fmt.Sprintf("DROP SCHEMA IF EXISTS iceberg.%s", schemaName))
defer runTrinoSQL(t, env.trinoContainer, fmt.Sprintf("DROP TABLE IF EXISTS %s", srcQualified))
defer runTrinoSQL(t, env.trinoContainer, fmt.Sprintf("DROP TABLE IF EXISTS %s", ctasQualified))
t.Logf(">>> CREATE source %s (no explicit location, unique-table-location=false)", srcQualified)
runTrinoSQL(t, env.trinoContainer, fmt.Sprintf(
"CREATE TABLE %s (id INTEGER, name VARCHAR)", srcQualified))
runTrinoSQL(t, env.trinoContainer, fmt.Sprintf(
"INSERT INTO %s VALUES (1, 'a'), (2, 'b'), (3, 'c')", srcQualified))
ctasSQL := fmt.Sprintf("CREATE TABLE %s AS SELECT * FROM %s", ctasQualified, srcQualified)
t.Logf(">>> CTAS (no location override): %s", ctasSQL)
runTrinoSQL(t, env.trinoContainer, ctasSQL)
countOutput := runTrinoSQL(t, env.trinoContainer, fmt.Sprintf("SELECT count(*) FROM %s", ctasQualified))
if got := mustParseCSVInt64(t, countOutput); got != 3 {
t.Fatalf("CTAS target: expected 3 rows, got %d", got)
}
out := runTrinoSQL(t, env.trinoContainer, fmt.Sprintf("SELECT name FROM %s ORDER BY id", ctasQualified))
for _, want := range []string{"a", "b", "c"} {
if !strings.Contains(out, want) {
t.Fatalf("CTAS target missing row %q; got:\n%s", want, out)
}
}
// Layout assertion: with unique-table-location=false, both tables must
// land at the deterministic <bucket>/<schema>/<tableName> path without
// any UUID-suffixed sibling directory under the namespace.
keys := listBucketKeysUnder(t, env, "iceberg-tables", schemaName+"/")
expectKeyAtLocation(t, keys, schemaName, srcName)
expectKeyAtLocation(t, keys, schemaName, ctasName)
rejectUUIDSuffixedSibling(t, keys, schemaName, srcName)
rejectUUIDSuffixedSibling(t, keys, schemaName, ctasName)
}
// listBucketKeysUnder lists every object key under the given bucket prefix
// using the test S3 endpoint.
func listBucketKeysUnder(t *testing.T, env *TestEnvironment, bucket, prefix string) []string {
t.Helper()
cfg := aws.Config{
Region: "us-east-1",
Credentials: aws.NewCredentialsCache(credentials.NewStaticCredentialsProvider(env.accessKey, env.secretKey, "")),
BaseEndpoint: aws.String(fmt.Sprintf("http://%s:%d", env.bindIP, env.s3Port)),
}
client := s3.NewFromConfig(cfg, func(o *s3.Options) { o.UsePathStyle = true })
out, err := client.ListObjectsV2(context.Background(), &s3.ListObjectsV2Input{
Bucket: aws.String(bucket),
Prefix: aws.String(prefix),
})
if err != nil {
t.Fatalf("ListObjectsV2 prefix=%s: %v", prefix, err)
}
keys := make([]string, 0, len(out.Contents))
for _, obj := range out.Contents {
keys = append(keys, aws.ToString(obj.Key))
}
return keys
}
// expectKeyAtLocation asserts at least one object key sits under
// <schema>/<table>/ (any depth), proving the table has a deterministic path.
func expectKeyAtLocation(t *testing.T, keys []string, schema, table string) {
t.Helper()
want := schema + "/" + table + "/"
for _, k := range keys {
if strings.HasPrefix(k, want) {
return
}
}
t.Fatalf("expected at least one object under %q; got keys: %v", want, keys)
}
// rejectUUIDSuffixedSibling asserts no object key sits under
// <schema>/<table>-<hex>/, since a UUID-suffixed sibling would mean Trino
// did add a UUID despite unique-table-location=false.
func rejectUUIDSuffixedSibling(t *testing.T, keys []string, schema, table string) {
t.Helper()
prefix := schema + "/" + table + "-"
for _, k := range keys {
if strings.HasPrefix(k, prefix) {
t.Fatalf("found UUID-suffixed sibling key %q for table %q (unique-table-location=false should produce no -<uuid> suffix)", k, table)
}
}
}

View File

@@ -0,0 +1,76 @@
package catalog_trino
import (
"fmt"
"strings"
"testing"
)
// TestTrinoIssue9074CTASFreshTable is a regression test for the follow-up
// report on https://github.com/seaweedfs/seaweedfs/issues/9074.
//
// The original fix purged stale data on DROP so a re-CREATE at the same
// path could succeed. The reporter then observed that even a fresh table
// name fails — they ran a CTAS without an explicit `location =` clause and
// hit the same "Cannot create a table on a non-empty location" error.
//
// Root cause: when (a) the user does not pass `location = ...`, and (b) the
// namespace has no Iceberg `location` property, Trino's REST catalog goes
// through TrinoRestCatalog.newCreateTableTransaction's eager branch
// (`tableBuilder.create().newTransaction()`) which immediately invokes the
// REST POST. Our handleCreateTable persists `<location>/metadata/v1.metadata.json`
// as part of that call. Trino's IcebergMetadata.beginCreateTable then runs
// `fileSystem.listFiles(location).hasNext()` and trips on the metadata file
// we just wrote.
//
// Fix: handleGetNamespace / handleCreateNamespace now advertise a default
// `location` property of `s3://<bucket>/<flattened-namespace>` when the
// namespace doesn't already carry one. Trino's `defaultTableLocation` then
// returns `<namespace-location>/<table>-<UUID>` (UUID added by the standard
// `iceberg.unique-table-location=true` connector setting), and the REST
// catalog takes the deferred `createTransaction` branch that does NOT call
// REST POST until commit-time. The empty-location check sees a genuinely
// empty UUID-suffixed path and passes; on commit, our metadata write lands
// alongside the data files Trino has already produced.
//
// This test exercises the exact pattern from the report: a fresh schema, a
// fresh table name, no `location =` clause, and a CTAS on top.
func TestTrinoIssue9074CTASFreshTable(t *testing.T) {
env := setupTrinoTest(t)
defer env.Cleanup(t)
schemaName := "issue9074_" + randomString(6)
srcName := "src_" + randomString(6)
ctasName := "ctas_" + randomString(6)
srcQualified := fmt.Sprintf("iceberg.%s.%s", schemaName, srcName)
ctasQualified := fmt.Sprintf("iceberg.%s.%s", schemaName, ctasName)
runTrinoSQL(t, env.trinoContainer, fmt.Sprintf("CREATE SCHEMA IF NOT EXISTS iceberg.%s", schemaName))
defer runTrinoSQLAllowNamespaceNotEmpty(t, env.trinoContainer, fmt.Sprintf("DROP SCHEMA IF EXISTS iceberg.%s", schemaName))
defer runTrinoSQL(t, env.trinoContainer, fmt.Sprintf("DROP TABLE IF EXISTS %s", srcQualified))
defer runTrinoSQL(t, env.trinoContainer, fmt.Sprintf("DROP TABLE IF EXISTS %s", ctasQualified))
t.Logf(">>> CREATE source %s (no explicit location)", srcQualified)
runTrinoSQL(t, env.trinoContainer, fmt.Sprintf(
"CREATE TABLE %s (id INTEGER, name VARCHAR)", srcQualified))
runTrinoSQL(t, env.trinoContainer, fmt.Sprintf(
"INSERT INTO %s VALUES (1, 'a'), (2, 'b'), (3, 'c')", srcQualified))
ctasSQL := fmt.Sprintf("CREATE TABLE IF NOT EXISTS %s AS (SELECT * FROM %s)", ctasQualified, srcQualified)
t.Logf(">>> CTAS (no location override): %s", ctasSQL)
runTrinoSQL(t, env.trinoContainer, ctasSQL)
countOutput := runTrinoSQL(t, env.trinoContainer, fmt.Sprintf("SELECT count(*) FROM %s", ctasQualified))
got := mustParseCSVInt64(t, countOutput)
if got != 3 {
t.Fatalf("CTAS target: expected 3 rows, got %d (resulting table should not be empty)", got)
}
out := runTrinoSQL(t, env.trinoContainer, fmt.Sprintf("SELECT name FROM %s ORDER BY id", ctasQualified))
for _, want := range []string{"a", "b", "c"} {
if !strings.Contains(out, want) {
t.Fatalf("CTAS target missing row %q; got:\n%s", want, out)
}
}
}

View File

@@ -0,0 +1,120 @@
package catalog_trino
import (
"fmt"
"strings"
"testing"
)
// TestTrinoCreateDropRecreateTable is a regression test for
// https://github.com/seaweedfs/seaweedfs/issues/9074
//
// Trino CTAS was failing with "Cannot create a table on a non-empty
// location" because Trino's pre-write check saw leftover data files from
// an earlier table at the same S3 path — DROP TABLE removed the catalog
// entry but left the underlying files behind. The Iceberg REST server now
// treats the S3 Tables catalog as the authority on table existence: when a
// DROP succeeds, it purges the storage under the table's location so that
// a subsequent CREATE at the same path finds it empty.
//
// This test pins an explicit location (so the recreated table lands at the
// exact same path, matching what the bug reporter saw), then walks through:
// 1. CREATE TABLE with explicit location
// 2. INSERT rows (produces data files under that location)
// 3. DROP TABLE (catalog entry removed, data files should also be purged)
// 4. CREATE TABLE again at the same explicit location — previously failed
// with "Cannot create a table on a non-empty location"
// 5. CREATE TABLE AS SELECT on top of the recreated table, which is the
// exact operation reported in the issue.
func TestTrinoCreateDropRecreateTable(t *testing.T) {
env := setupTrinoTest(t)
defer env.Cleanup(t)
tableBucket := "iceberg-tables"
schemaName := "recreate_" + randomString(6)
tableName := "events_" + randomString(6)
ctasName := "events_ctas_" + randomString(6)
tableLocation := fmt.Sprintf("s3://%s/%s/%s", tableBucket, schemaName, tableName)
ctasLocation := fmt.Sprintf("s3://%s/%s/%s", tableBucket, schemaName, ctasName)
qualified := fmt.Sprintf("iceberg.%s.%s", schemaName, tableName)
ctasQualified := fmt.Sprintf("iceberg.%s.%s", schemaName, ctasName)
runTrinoSQL(t, env.trinoContainer, fmt.Sprintf("CREATE SCHEMA IF NOT EXISTS iceberg.%s", schemaName))
defer runTrinoSQLAllowNamespaceNotEmpty(t, env.trinoContainer, fmt.Sprintf("DROP SCHEMA IF EXISTS iceberg.%s", schemaName))
// Registered after the DROP SCHEMA defer so LIFO ordering drops the
// tables before the schema. Without this, a failure between the
// CREATE TABLE below and the explicit DROP later in the test would
// leak the table and block the deferred schema drop.
defer runTrinoSQL(t, env.trinoContainer, fmt.Sprintf("DROP TABLE IF EXISTS %s", qualified))
defer runTrinoSQL(t, env.trinoContainer, fmt.Sprintf("DROP TABLE IF EXISTS %s", ctasQualified))
createSQL := fmt.Sprintf(`CREATE TABLE %s (
id INTEGER,
label VARCHAR
) WITH (
format = 'PARQUET',
location = '%s'
)`, qualified, tableLocation)
t.Logf(">>> CREATE #1: %s (location=%s)", qualified, tableLocation)
runTrinoSQL(t, env.trinoContainer, createSQL)
t.Logf(">>> INSERT into %s", qualified)
runTrinoSQL(t, env.trinoContainer, fmt.Sprintf(
"INSERT INTO %s VALUES (1, 'alpha'), (2, 'beta')", qualified))
countOutput := runTrinoSQL(t, env.trinoContainer, fmt.Sprintf("SELECT count(*) FROM %s", qualified))
if got := mustParseCSVInt64(t, countOutput); got != 2 {
t.Fatalf("after first insert: expected 2 rows, got %d", got)
}
t.Logf(">>> DROP %s", qualified)
runTrinoSQL(t, env.trinoContainer, fmt.Sprintf("DROP TABLE %s", qualified))
// The recreate must succeed at the exact same location; DROP is expected
// to have purged the data files so Trino's pre-write check sees an empty
// path.
t.Logf(">>> CREATE #2 at the same location: %s", qualified)
runTrinoSQL(t, env.trinoContainer, createSQL)
t.Logf(">>> INSERT into recreated %s", qualified)
runTrinoSQL(t, env.trinoContainer, fmt.Sprintf(
"INSERT INTO %s VALUES (10, 'gamma')", qualified))
countOutput = runTrinoSQL(t, env.trinoContainer, fmt.Sprintf("SELECT count(*) FROM %s", qualified))
if got := mustParseCSVInt64(t, countOutput); got != 1 {
t.Fatalf("after recreate+insert: expected 1 row (not %d) — recreated table should not see dropped data", got)
}
labelOutput := runTrinoSQL(t, env.trinoContainer, fmt.Sprintf("SELECT label FROM %s WHERE id = 10", qualified))
if !strings.Contains(labelOutput, "gamma") {
t.Fatalf("recreated table missing expected row; got:\n%s", labelOutput)
}
// CTAS on top of the recreated table is the exact operation from #9074.
ctasSQL := fmt.Sprintf(`CREATE TABLE %s
WITH (
format = 'PARQUET',
location = '%s'
)
AS SELECT * FROM %s`, ctasQualified, ctasLocation, qualified)
t.Logf(">>> CTAS %s FROM %s", ctasQualified, qualified)
runTrinoSQL(t, env.trinoContainer, ctasSQL)
countOutput = runTrinoSQL(t, env.trinoContainer, fmt.Sprintf("SELECT count(*) FROM %s", ctasQualified))
if got := mustParseCSVInt64(t, countOutput); got != 1 {
t.Fatalf("CTAS target: expected 1 row, got %d", got)
}
// Drop the CTAS target and recreate it at the same path to make sure
// the drop-cleanup path is exercised for CTAS too.
t.Logf(">>> DROP then CTAS-recreate %s at the same location", ctasQualified)
runTrinoSQL(t, env.trinoContainer, fmt.Sprintf("DROP TABLE %s", ctasQualified))
runTrinoSQL(t, env.trinoContainer, ctasSQL)
countOutput = runTrinoSQL(t, env.trinoContainer, fmt.Sprintf("SELECT count(*) FROM %s", ctasQualified))
if got := mustParseCSVInt64(t, countOutput); got != 1 {
t.Fatalf("CTAS after drop-recreate: expected 1 row, got %d", got)
}
}

View File

@@ -0,0 +1,690 @@
package lakekeeper
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/aws/signer/v4"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
s3types "github.com/aws/aws-sdk-go-v2/service/s3/types"
"github.com/aws/aws-sdk-go-v2/service/sts"
"github.com/seaweedfs/seaweedfs/test/testutil"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
)
type TestEnvironment struct {
seaweedDir string
weedBinary string
dataDir string
bindIP string
s3Port int
s3GrpcPort int
masterPort int
masterGrpcPort int
filerPort int
filerGrpcPort int
volumePort int
volumeGrpcPort int
weedProcess *exec.Cmd
weedCancel context.CancelFunc
accessKey string
secretKey string
}
const (
lakekeeperRegion = "us-east-1"
lakekeeperRoleArn = "arn:aws:iam::000000000000:role/LakekeeperVendedRole"
lakekeeperSessionName = "lakekeeper-session"
)
type lakekeeperSession struct {
endpoint string
region string
creds aws.Credentials
}
func TestLakekeeperIntegration(t *testing.T) {
env := NewTestEnvironment(t)
defer env.Cleanup(t)
fmt.Printf(">>> Starting SeaweedFS with Lakekeeper configuration...\n")
env.StartSeaweedFS(t)
fmt.Printf(">>> SeaweedFS started.\n")
runLakekeeperRepro(t, env)
}
func TestLakekeeperTableBucketIntegration(t *testing.T) {
env := NewTestEnvironment(t)
defer env.Cleanup(t)
fmt.Printf(">>> Starting SeaweedFS with Lakekeeper configuration...\n")
env.StartSeaweedFS(t)
fmt.Printf(">>> SeaweedFS started.\n")
runLakekeeperTableBucketRepro(t, env)
}
func NewTestEnvironment(t *testing.T) *TestEnvironment {
t.Helper()
wd, err := os.Getwd()
if err != nil {
t.Fatalf("Failed to get working directory: %v", err)
}
seaweedDir := wd
for i := 0; i < 6; i++ {
if _, err := os.Stat(filepath.Join(seaweedDir, "go.mod")); err == nil {
break
}
seaweedDir = filepath.Dir(seaweedDir)
}
weedBinary := filepath.Join(seaweedDir, "weed", "weed")
if _, err := os.Stat(weedBinary); err != nil {
weedBinary = "weed"
if _, err := exec.LookPath(weedBinary); err != nil {
t.Skip("weed binary not found, skipping integration test")
}
}
dataDir, err := os.MkdirTemp("", "seaweed-lakekeeper-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
bindIP := testutil.FindBindIP()
// Allocate all ports in a single batch to prevent the OS from recycling
// a released port, which can cause two services to get the same port.
ports := testutil.MustAllocatePorts(t, 8)
masterPort, masterGrpcPort := ports[0], ports[1]
volumePort, volumeGrpcPort := ports[2], ports[3]
filerPort, filerGrpcPort := ports[4], ports[5]
s3Port, s3GrpcPort := ports[6], ports[7]
return &TestEnvironment{
seaweedDir: seaweedDir,
weedBinary: weedBinary,
dataDir: dataDir,
bindIP: bindIP,
s3Port: s3Port,
s3GrpcPort: s3GrpcPort,
masterPort: masterPort,
masterGrpcPort: masterGrpcPort,
filerPort: filerPort,
filerGrpcPort: filerGrpcPort,
volumePort: volumePort,
volumeGrpcPort: volumeGrpcPort,
accessKey: "admin",
secretKey: "admin",
}
}
func (env *TestEnvironment) StartSeaweedFS(t *testing.T) {
t.Helper()
iamConfigPath := filepath.Join(env.dataDir, "iam.json")
// Note: signingKey must be base64 encoded for []byte JSON unmarshaling
iamConfig := fmt.Sprintf(`{
"identities": [
{
"name": "admin",
"credentials": [
{
"accessKey": "%s",
"secretKey": "%s"
}
],
"actions": ["Admin", "Read", "List", "Tagging", "Write"]
}
],
"sts": {
"tokenDuration": "12h",
"maxSessionLength": "24h",
"issuer": "seaweedfs-sts",
"signingKey": "dGVzdC1zaWduaW5nLWtleS1mb3Itc3RzLWludGVncmF0aW9uLXRlc3Rz"
},
"roles": [
{
"roleName": "LakekeeperVendedRole",
"roleArn": "arn:aws:iam::000000000000:role/LakekeeperVendedRole",
"trustPolicy": {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": "*",
"Action": "sts:AssumeRole"
}
]
},
"attachedPolicies": ["FullAccess"]
}
],
"policies": [
{
"name": "FullAccess",
"document": {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "*",
"Resource": "*"
}
]
}
}
]
}`, env.accessKey, env.secretKey)
if err := os.WriteFile(iamConfigPath, []byte(iamConfig), 0644); err != nil {
t.Fatalf("Failed to create IAM config: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
env.weedCancel = cancel
// Start weed mini with both S3 config (standard IAM) and IAM config (advanced IAM/STS)
cmd := exec.CommandContext(ctx, env.weedBinary, "-v", "4", "mini",
"-master.port", fmt.Sprintf("%d", env.masterPort),
"-master.port.grpc", fmt.Sprintf("%d", env.masterGrpcPort),
"-volume.port", fmt.Sprintf("%d", env.volumePort),
"-volume.port.grpc", fmt.Sprintf("%d", env.volumeGrpcPort),
"-filer.port", fmt.Sprintf("%d", env.filerPort),
"-filer.port.grpc", fmt.Sprintf("%d", env.filerGrpcPort),
"-s3.port", fmt.Sprintf("%d", env.s3Port),
"-s3.port.grpc", fmt.Sprintf("%d", env.s3GrpcPort),
"-s3.config", iamConfigPath,
"-s3.iam.config", iamConfigPath,
"-s3.iam.readOnly=false",
"-ip", env.bindIP,
"-ip.bind", "0.0.0.0",
"-dir", env.dataDir,
)
cmd.Dir = env.dataDir
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
t.Fatalf("Failed to start SeaweedFS: %v", err)
}
env.weedProcess = cmd
if !testutil.WaitForService(fmt.Sprintf("http://localhost:%d/status", env.s3Port), 30*time.Second) {
t.Fatalf("S3 API failed to become ready")
}
}
func (env *TestEnvironment) Cleanup(t *testing.T) {
t.Helper()
if env.weedCancel != nil {
env.weedCancel()
}
if env.weedProcess != nil {
time.Sleep(1 * time.Second)
_ = env.weedProcess.Wait()
}
if env.dataDir != "" {
_ = os.RemoveAll(env.dataDir)
}
}
func runLakekeeperRepro(t *testing.T, env *TestEnvironment) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
session, err := newLakekeeperSession(ctx, env)
if err != nil {
t.Fatalf("AssumeRole failed: %v", err)
}
s3Client, err := newS3Client(ctx, session.endpoint, session.region, session.creds)
if err != nil {
t.Fatalf("Create S3 client failed: %v", err)
}
bucketName := fmt.Sprintf("lakekeeper-vended-bucket-%d", time.Now().UnixNano())
if _, err := s3Client.CreateBucket(ctx, &s3.CreateBucketInput{
Bucket: aws.String(bucketName),
}); err != nil {
t.Fatalf("CreateBucket failed: %v", err)
}
bucketCreated := true
defer func() {
if !bucketCreated {
return
}
_, _ = s3Client.DeleteBucket(ctx, &s3.DeleteBucketInput{
Bucket: aws.String(bucketName),
})
}()
listResp, err := s3Client.ListBuckets(ctx, &s3.ListBucketsInput{})
if err != nil {
t.Fatalf("ListBuckets failed: %v", err)
}
found := false
for _, bucket := range listResp.Buckets {
if aws.ToString(bucket.Name) == bucketName {
found = true
break
}
}
if !found {
t.Fatalf("Bucket %s not found in list", bucketName)
}
if _, err := s3Client.DeleteBucket(ctx, &s3.DeleteBucketInput{
Bucket: aws.String(bucketName),
}); err != nil {
t.Fatalf("DeleteBucket failed: %v", err)
}
bucketCreated = false
}
func runLakekeeperTableBucketRepro(t *testing.T, env *TestEnvironment) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
session, err := newLakekeeperSession(ctx, env)
if err != nil {
t.Fatalf("AssumeRole failed: %v", err)
}
client := newS3TablesClient(session.endpoint, session.region, session.creds)
bucketName := fmt.Sprintf("lakekeeper-table-bucket-%d", time.Now().UnixNano())
bucketARN, err := client.CreateTableBucket(ctx, bucketName)
if err != nil {
t.Fatalf("CreateTableBucket failed: %v", err)
}
bucketDeleted := false
namespaceCreated := false
tableCreated := false
namespaceName := fmt.Sprintf("lakekeeper_ns_%d", time.Now().UnixNano())
tableName := fmt.Sprintf("lakekeeper_table_%d", time.Now().UnixNano())
defer func() {
if tableCreated {
if err := client.DeleteTable(ctx, bucketARN, namespaceName, tableName); err != nil {
t.Logf("Failed to delete table: %v", err)
}
}
if namespaceCreated {
if err := client.DeleteNamespace(ctx, bucketARN, namespaceName); err != nil {
t.Logf("Failed to delete namespace: %v", err)
}
}
if bucketDeleted {
return
}
if err := client.DeleteTableBucket(ctx, bucketARN); err != nil {
t.Logf("Failed to delete table bucket: %v", err)
}
}()
buckets, err := client.ListTableBuckets(ctx)
if err != nil {
t.Fatalf("ListTableBuckets failed: %v", err)
}
found := false
for _, b := range buckets {
if b.Name == bucketName {
found = true
break
}
}
if !found {
t.Fatalf("Created table bucket %s not found in list", bucketName)
}
if _, err := client.GetTableBucket(ctx, bucketARN); err != nil {
t.Fatalf("GetTableBucket failed: %v", err)
}
if err := client.CreateNamespace(ctx, bucketARN, namespaceName); err != nil {
t.Fatalf("CreateNamespace failed: %v", err)
}
namespaceCreated = true
if err := client.CreateTable(ctx, bucketARN, namespaceName, tableName); err != nil {
t.Fatalf("CreateTable failed: %v", err)
}
tableCreated = true
s3Client, err := newS3Client(ctx, session.endpoint, session.region, session.creds)
if err != nil {
t.Fatalf("Create S3 client failed: %v", err)
}
objectKey := fmt.Sprintf("%s/%s/data/part-%d.parquet", namespaceName, tableName, time.Now().UnixNano())
createResp, err := s3Client.CreateMultipartUpload(ctx, &s3.CreateMultipartUploadInput{
Bucket: aws.String(bucketName),
Key: aws.String(objectKey),
})
if err != nil {
t.Fatalf("CreateMultipartUpload failed: %v", err)
}
uploadID := aws.ToString(createResp.UploadId)
multipartCompleted := false
defer func() {
if uploadID == "" || multipartCompleted {
return
}
_, _ = s3Client.AbortMultipartUpload(ctx, &s3.AbortMultipartUploadInput{
Bucket: aws.String(bucketName),
Key: aws.String(objectKey),
UploadId: aws.String(uploadID),
})
}()
partSize := 5 * 1024 * 1024
part1 := bytes.Repeat([]byte("a"), partSize)
part2 := bytes.Repeat([]byte("b"), 1024*1024)
part1Resp, err := s3Client.UploadPart(ctx, &s3.UploadPartInput{
Bucket: aws.String(bucketName),
Key: aws.String(objectKey),
UploadId: aws.String(uploadID),
PartNumber: aws.Int32(1),
Body: bytes.NewReader(part1),
})
if err != nil {
t.Fatalf("UploadPart 1 failed: %v", err)
}
part2Resp, err := s3Client.UploadPart(ctx, &s3.UploadPartInput{
Bucket: aws.String(bucketName),
Key: aws.String(objectKey),
UploadId: aws.String(uploadID),
PartNumber: aws.Int32(2),
Body: bytes.NewReader(part2),
})
if err != nil {
t.Fatalf("UploadPart 2 failed: %v", err)
}
_, err = s3Client.CompleteMultipartUpload(ctx, &s3.CompleteMultipartUploadInput{
Bucket: aws.String(bucketName),
Key: aws.String(objectKey),
UploadId: aws.String(uploadID),
MultipartUpload: &s3types.CompletedMultipartUpload{
Parts: []s3types.CompletedPart{
{
ETag: part1Resp.ETag,
PartNumber: aws.Int32(1),
},
{
ETag: part2Resp.ETag,
PartNumber: aws.Int32(2),
},
},
},
})
if err != nil {
t.Fatalf("CompleteMultipartUpload failed: %v", err)
}
multipartCompleted = true
headResp, err := s3Client.HeadObject(ctx, &s3.HeadObjectInput{
Bucket: aws.String(bucketName),
Key: aws.String(objectKey),
})
if err != nil {
t.Fatalf("HeadObject after multipart upload failed: %v", err)
}
expectedSize := int64(len(part1) + len(part2))
if headResp.ContentLength == nil || *headResp.ContentLength != expectedSize {
t.Fatalf("Unexpected content length: got %d want %d", aws.ToInt64(headResp.ContentLength), expectedSize)
}
if err := client.DeleteTable(ctx, bucketARN, namespaceName, tableName); err != nil {
t.Fatalf("DeleteTable failed: %v", err)
}
tableCreated = false
if err := client.DeleteNamespace(ctx, bucketARN, namespaceName); err != nil {
t.Fatalf("DeleteNamespace failed: %v", err)
}
namespaceCreated = false
if err := client.DeleteTableBucket(ctx, bucketARN); err != nil {
t.Fatalf("DeleteTableBucket failed: %v", err)
}
bucketDeleted = true
if _, err := client.GetTableBucket(ctx, bucketARN); err == nil {
t.Fatalf("expected GetTableBucket to fail after deletion")
}
}
type s3TablesClient struct {
endpoint string
region string
creds aws.Credentials
httpClient *http.Client
}
func newS3TablesClient(endpoint, region string, creds aws.Credentials) *s3TablesClient {
return &s3TablesClient{
endpoint: endpoint,
region: region,
creds: creds,
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
}
}
func (c *s3TablesClient) CreateTableBucket(ctx context.Context, name string) (string, error) {
req := &s3tables.CreateTableBucketRequest{Name: name}
var resp s3tables.CreateTableBucketResponse
if err := c.doRequest(ctx, "CreateTableBucket", http.MethodPut, "/buckets", req, &resp); err != nil {
return "", err
}
return resp.ARN, nil
}
func (c *s3TablesClient) GetTableBucket(ctx context.Context, arn string) (*s3tables.GetTableBucketResponse, error) {
path := "/buckets/" + url.PathEscape(arn)
var resp s3tables.GetTableBucketResponse
if err := c.doRequest(ctx, "GetTableBucket", http.MethodGet, path, nil, &resp); err != nil {
return nil, err
}
return &resp, nil
}
func (c *s3TablesClient) ListTableBuckets(ctx context.Context) ([]s3tables.TableBucketSummary, error) {
var resp s3tables.ListTableBucketsResponse
if err := c.doRequest(ctx, "ListTableBuckets", http.MethodGet, "/buckets", nil, &resp); err != nil {
return nil, err
}
return resp.TableBuckets, nil
}
func (c *s3TablesClient) DeleteTableBucket(ctx context.Context, arn string) error {
path := "/buckets/" + url.PathEscape(arn)
return c.doRequest(ctx, "DeleteTableBucket", http.MethodDelete, path, nil, nil)
}
func (c *s3TablesClient) CreateNamespace(ctx context.Context, bucketARN, namespace string) error {
req := &s3tables.CreateNamespaceRequest{
Namespace: []string{namespace},
}
path := "/namespaces/" + url.PathEscape(bucketARN)
return c.doRequest(ctx, "CreateNamespace", http.MethodPut, path, req, nil)
}
func (c *s3TablesClient) DeleteNamespace(ctx context.Context, bucketARN, namespace string) error {
path := "/namespaces/" + url.PathEscape(bucketARN) + "/" + url.PathEscape(namespace)
return c.doRequest(ctx, "DeleteNamespace", http.MethodDelete, path, nil, nil)
}
func (c *s3TablesClient) CreateTable(ctx context.Context, bucketARN, namespace, name string) error {
req := &s3tables.CreateTableRequest{
Name: name,
Format: "ICEBERG",
}
path := "/tables/" + url.PathEscape(bucketARN) + "/" + url.PathEscape(namespace)
return c.doRequest(ctx, "CreateTable", http.MethodPut, path, req, nil)
}
func (c *s3TablesClient) DeleteTable(ctx context.Context, bucketARN, namespace, name string) error {
path := "/tables/" + url.PathEscape(bucketARN) + "/" + url.PathEscape(namespace) + "/" + url.PathEscape(name)
return c.doRequest(ctx, "DeleteTable", http.MethodDelete, path, nil, nil)
}
func (c *s3TablesClient) doRequest(ctx context.Context, operation, method, path string, body interface{}, out interface{}) error {
var bodyBytes []byte
if body != nil {
encoded, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("%s: marshal request: %w", operation, err)
}
bodyBytes = encoded
}
req, err := http.NewRequestWithContext(ctx, method, c.endpoint+path, bytes.NewReader(bodyBytes))
if err != nil {
return fmt.Errorf("%s: create request: %w", operation, err)
}
if body != nil {
req.Header.Set("Content-Type", "application/x-amz-json-1.1")
}
req.Host = req.URL.Host
req.Header.Set("Host", req.URL.Host)
payloadHash := sha256.Sum256(bodyBytes)
if err := v4.NewSigner().SignHTTP(ctx, c.creds, req, hex.EncodeToString(payloadHash[:]), "s3tables", c.region, time.Now()); err != nil {
return fmt.Errorf("%s: sign request: %w", operation, err)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("%s: request failed: %w", operation, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, readErr := io.ReadAll(resp.Body)
if readErr != nil {
return fmt.Errorf("%s failed with status %d and could not read response body: %v", operation, resp.StatusCode, readErr)
}
var errResp s3tables.S3TablesError
if jsonErr := json.Unmarshal(bodyBytes, &errResp); jsonErr == nil && (errResp.Type != "" || errResp.Message != "") {
return fmt.Errorf("%s failed: %s - %s", operation, errResp.Type, errResp.Message)
}
return fmt.Errorf("%s failed with status %d: %s", operation, resp.StatusCode, strings.TrimSpace(string(bodyBytes)))
}
if out == nil {
return nil
}
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
return fmt.Errorf("%s: decode response: %w", operation, err)
}
return nil
}
func assumeRole(ctx context.Context, endpoint, region, accessKey, secretKey, roleArn, sessionName string) (aws.Credentials, error) {
resolver := aws.EndpointResolverWithOptionsFunc(func(service, region string, options ...interface{}) (aws.Endpoint, error) {
if service == sts.ServiceID {
return aws.Endpoint{
URL: endpoint,
SigningRegion: region,
HostnameImmutable: true,
}, nil
}
return aws.Endpoint{}, &aws.EndpointNotFoundError{}
})
cfg, err := config.LoadDefaultConfig(ctx,
config.WithRegion(region),
config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(accessKey, secretKey, "")),
config.WithEndpointResolverWithOptions(resolver),
)
if err != nil {
return aws.Credentials{}, err
}
client := sts.NewFromConfig(cfg)
resp, err := client.AssumeRole(ctx, &sts.AssumeRoleInput{
RoleArn: aws.String(roleArn),
RoleSessionName: aws.String(sessionName),
})
if err != nil {
return aws.Credentials{}, err
}
if resp.Credentials == nil {
return aws.Credentials{}, fmt.Errorf("missing credentials in AssumeRole response")
}
return aws.Credentials{
AccessKeyID: aws.ToString(resp.Credentials.AccessKeyId),
SecretAccessKey: aws.ToString(resp.Credentials.SecretAccessKey),
SessionToken: aws.ToString(resp.Credentials.SessionToken),
Source: "lakekeeper-sts",
}, nil
}
func newS3Client(ctx context.Context, endpoint, region string, creds aws.Credentials) (*s3.Client, error) {
resolver := aws.EndpointResolverWithOptionsFunc(func(service, region string, options ...interface{}) (aws.Endpoint, error) {
if service == s3.ServiceID {
return aws.Endpoint{
URL: endpoint,
SigningRegion: region,
HostnameImmutable: true,
}, nil
}
return aws.Endpoint{}, &aws.EndpointNotFoundError{}
})
cfg, err := config.LoadDefaultConfig(ctx,
config.WithRegion(region),
config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(creds.AccessKeyID, creds.SecretAccessKey, creds.SessionToken)),
config.WithEndpointResolverWithOptions(resolver),
)
if err != nil {
return nil, err
}
return s3.NewFromConfig(cfg, func(o *s3.Options) {
o.UsePathStyle = true
}), nil
}
func newLakekeeperSession(ctx context.Context, env *TestEnvironment) (lakekeeperSession, error) {
endpoint := fmt.Sprintf("http://127.0.0.1:%d", env.s3Port)
creds, err := assumeRole(ctx, endpoint, lakekeeperRegion, env.accessKey, env.secretKey, lakekeeperRoleArn, lakekeeperSessionName)
if err != nil {
return lakekeeperSession{}, err
}
return lakekeeperSession{
endpoint: endpoint,
region: lakekeeperRegion,
creds: creds,
}, nil
}

View File

@@ -0,0 +1,17 @@
# Lance client for the Lance half of the lifecycle test.
#
# Pinned to the versions this suite was verified against, the way
# catalog_lancedb pins its client: an unrelated upstream release should not be
# able to change what an old commit reproduces.
FROM python:3.11-slim
WORKDIR /app
RUN pip install --no-cache-dir \
"lance-namespace==0.8.6" \
"pylance==10.0.0" \
"pyarrow==25.0.1"
COPY lance_lifecycle.py /app/
CMD ["python3", "/app/lance_lifecycle.py", "--help"]

View File

@@ -0,0 +1,10 @@
# PyIceberg client for the Iceberg half of the lifecycle test.
FROM python:3.11-slim
WORKDIR /app
RUN pip install --no-cache-dir "pyiceberg[s3fs]" pyarrow
COPY iceberg_lifecycle.py /app/
CMD ["python3", "/app/iceberg_lifecycle.py", "--help"]

View File

@@ -0,0 +1,77 @@
# Table Lifecycle Integration Tests
One table, all the way through: created in the catalog, filled by a real client,
maintained, read again, dropped. Once for Iceberg and once for Lance. The
Iceberg half always maintains through the worker; the Lance half maintains
through the Rust worker or through the lance library, depending on what the
environment has - see below.
## Why this suite exists
[#10853](https://github.com/seaweedfs/seaweedfs/issues/10853) was a compaction
that rewrote every dictionary-encoded column onto a single value. It shipped.
The maintenance tests we had were thorough about the bookkeeping - sequence
numbers, added and deleted manifest entries, metadata versions, the manifest
list - and every one of them passed, because not one of them opened the parquet
file the worker had just written.
So the assertion here is the dull one nothing else was making: tally the table
before maintenance, tally it again after, and require the two to be equal. The
tally is a row count, the cardinality of each dictionary-encoded column, and an
md5 over whole rows. The cardinalities name the failure that happened; the
digest catches a rewrite that keeps every column's cardinality and hands the
values to the wrong rows.
The same shape covers Lance, because the exposure is the same: a compaction that
merges fragments can hand back a table that reads without complaint and answers
wrongly.
## What runs
`TestIcebergTableLifecycle` starts a `weed mini` cluster, declares an `ICEBERG`
table bucket, and runs two clients against it:
| Client | Why both |
| --- | --- |
| DuckDB | the client the bug was reported against, and the only one here that writes the deprecated `PLAIN_DICTIONARY` encoding - parquet-go normalizes it away on write, so a Go writer cannot produce it |
| PyIceberg | writes `RLE_DICTIONARY`, the modern spelling, so between the two the merge is checked against both dictionary encodings in the spec |
Between the write and the read, the test runs the worker's whole maintenance
cycle in-process against the live filer: compact, expire snapshots, remove
orphans, rewrite manifests. A compaction that merged nothing fails the test
rather than passing it - otherwise the read afterwards is checking a file the
worker never wrote.
`TestLanceTableLifecycle` does the same against a `LANCE` bucket: declare
through the namespace, write a fragment per append, maintain, read, drop.
Maintenance goes through the Rust worker's own handlers where cargo is
installed, and through the two lance calls those handlers wrap where it is not.
`WEED_LANCE_MAINTENANCE=library|worker` picks one instead of letting the test
guess; CI sets `library`, because a cold build of the lance crate costs more
than the layer it would be checking.
Both tests finish by dropping the table and checking the data actually left the
filer, which is the half of a lifecycle a catalog test never reaches.
## Running it
cd test/s3tables/lifecycle
(cd ../../../weed && go build .) # the harness runs this binary
go test -v -timeout 40m .
Skipped without Docker and in `-short` mode. The first run builds the two client
images and pulls `duckdb/duckdb:latest`; later runs reuse them. The DuckDB half
skips itself, rather than failing, on an image whose iceberg extension cannot
write through a REST catalog.
To watch it catch the bug it was written for, pin parquet-go back to the version
that had it:
go mod edit -require=github.com/parquet-go/parquet-go@v0.30.1 && go mod tidy
go test -run TestIcebergTableLifecycle/DuckDB -v .
maintenance collapsed the category column: 7 distinct values -> 1
maintenance collapsed the value column: 13 distinct values -> 1
The PyIceberg half still passes there, which is the reason both clients are in
this directory.

View File

@@ -0,0 +1,277 @@
// Package lifecycle takes a table through everything that happens to it: made
// through the catalog, filled by a real client, maintained by the worker, read
// again, and dropped.
//
// The step that matters is the read after maintenance. A compaction once
// rewrote every dictionary-encoded column onto a single value and went out in a
// release, because the tests we had checked the bookkeeping - sequence numbers,
// manifest entries, metadata versions - and none of them opened the file the
// worker had just written. A tally taken before maintenance and the same tally
// taken after is the whole idea, and it is the same idea for both formats:
// Iceberg compaction merges parquet files, Lance compaction merges fragments,
// and either can hand back a table that reads without complaint and answers
// wrongly.
package lifecycle
import (
"context"
"errors"
"flag"
"fmt"
"io"
"math/rand"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"github.com/seaweedfs/seaweedfs/test/testutil"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
)
const (
// The keys the issue's own recipe uses. weed mini turns them into the
// admin identity, which is what the clients then sign with.
accessKey = "AKIAIOSFODNN7EXAMPLE"
secretKey = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
startupTimeout = 60 * time.Second
clientTimeout = 20 * time.Minute
)
// shared is the one cluster both formats run against. They use separate table
// buckets, and a bucket holds one format only.
var shared *environment
// errNoBinary is the one setup failure worth skipping over.
var errNoBinary = errors.New("weed binary not found")
func TestMain(m *testing.M) {
flag.Parse()
if testing.Short() {
os.Exit(m.Run())
}
// A checkout without a weed binary cannot run this and says so. Anything
// else - ports, a cluster that will not come up - is a failure, because a
// suite that turns its own breakage into a green run is the thing this
// directory exists to stop.
env, err := newEnvironment()
if errors.Is(err, errNoBinary) {
fmt.Fprintf(os.Stderr, "SKIP: %v\n", err)
os.Exit(m.Run())
}
if err != nil {
fmt.Fprintf(os.Stderr, "FAIL: %v\n", err)
os.Exit(1)
}
if err := env.start(); err != nil {
fmt.Fprintf(os.Stderr, "FAIL: weed mini did not start: %v\n", err)
env.cleanup()
os.Exit(1)
}
shared = env
code := m.Run()
shared.cleanup()
os.Exit(code)
}
type environment struct {
weedBinary string
rootDir string
testDir string
dataDir string
masterPort int
masterGrpcPort int
volumePort int
volumeGrpcPort int
filerPort int
filerGrpcPort int
s3Port int
s3GrpcPort int
icebergPort int
lancePort int
weedCancel context.CancelFunc
weedCmd *exec.Cmd
}
func newEnvironment() (*environment, error) {
wd, err := os.Getwd()
if err != nil {
return nil, fmt.Errorf("get working directory: %w", err)
}
seaweedDir := wd
for i := 0; i < 5; i++ {
if _, err := os.Stat(filepath.Join(seaweedDir, "go.mod")); err == nil {
break
}
seaweedDir = filepath.Dir(seaweedDir)
}
weedBinary := filepath.Join(seaweedDir, "weed", "weed")
if info, statErr := os.Stat(weedBinary); statErr == nil && !info.IsDir() {
// A plain `go test` will otherwise drive a binary from days ago and
// report a pass for code it never ran.
fmt.Fprintf(os.Stderr, "using %s, built %s\n", weedBinary, info.ModTime().Format(time.RFC3339))
} else {
weedBinary = "weed"
if _, err := exec.LookPath(weedBinary); err != nil {
return nil, errNoBinary
}
}
dataDir, err := os.MkdirTemp("", "seaweed-lifecycle-*")
if err != nil {
return nil, fmt.Errorf("create temp dir: %w", err)
}
ports, err := testutil.AllocatePorts(10)
if err != nil {
return nil, fmt.Errorf("allocate ports: %w", err)
}
return &environment{
weedBinary: weedBinary,
rootDir: seaweedDir,
testDir: wd,
dataDir: dataDir,
masterPort: ports[0],
masterGrpcPort: ports[1],
volumePort: ports[2],
volumeGrpcPort: ports[3],
filerPort: ports[4],
filerGrpcPort: ports[5],
s3Port: ports[6],
s3GrpcPort: ports[7],
icebergPort: ports[8],
lancePort: ports[9],
}, nil
}
func (env *environment) start() error {
ctx, cancel := context.WithCancel(context.Background())
env.weedCancel = cancel
cmd := exec.CommandContext(ctx, env.weedBinary, "mini",
"-master.port", fmt.Sprintf("%d", env.masterPort),
"-master.port.grpc", fmt.Sprintf("%d", env.masterGrpcPort),
"-volume.port", fmt.Sprintf("%d", env.volumePort),
"-volume.port.grpc", fmt.Sprintf("%d", env.volumeGrpcPort),
"-filer.port", fmt.Sprintf("%d", env.filerPort),
"-filer.port.grpc", fmt.Sprintf("%d", env.filerGrpcPort),
"-s3.port", fmt.Sprintf("%d", env.s3Port),
"-s3.port.grpc", fmt.Sprintf("%d", env.s3GrpcPort),
"-s3.port.iceberg", fmt.Sprintf("%d", env.icebergPort),
"-s3.port.lance", fmt.Sprintf("%d", env.lancePort),
"-ip.bind", "0.0.0.0",
"-dir", env.dataDir,
)
cmd.Dir = env.dataDir
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
// mini makes its admin identity from these, so they are the keys the
// clients sign with rather than a separate IAM file to keep in step.
cmd.Env = append(os.Environ(),
"AWS_ACCESS_KEY_ID="+accessKey,
"AWS_SECRET_ACCESS_KEY="+secretKey,
)
if err := cmd.Start(); err != nil {
cancel()
return err
}
env.weedCmd = cmd
if !testutil.WaitForService(env.catalogURL()+"/v1/config", startupTimeout) {
cancel()
return fmt.Errorf("the Iceberg catalog never answered on port %d", env.icebergPort)
}
if !testutil.WaitForPort(env.lancePort, startupTimeout) {
cancel()
return fmt.Errorf("the Lance namespace never answered on port %d", env.lancePort)
}
return nil
}
func (env *environment) cleanup() {
if env.weedCancel != nil {
env.weedCancel()
}
if env.weedCmd != nil {
_ = env.weedCmd.Wait()
}
if env.dataDir != "" {
_ = os.RemoveAll(env.dataDir)
}
}
func (env *environment) catalogURL() string {
return fmt.Sprintf("http://127.0.0.1:%d", env.icebergPort)
}
// A container reaches the same gateway by another name than the host does.
func (env *environment) containerURL(port int) string {
return fmt.Sprintf("http://host.docker.internal:%d", port)
}
// createTableBucket makes a bucket declared for one format, so the catalog
// refuses tables of any other kind in it.
func (env *environment) createTableBucket(t *testing.T, bucket, format string) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, env.weedBinary, "shell",
fmt.Sprintf("-master=127.0.0.1:%d.%d", env.masterPort, env.masterGrpcPort),
)
cmd.Stdin = strings.NewReader(fmt.Sprintf(
"s3tables.bucket -create -name %s -format %s -account 000000000000\nexit\n", bucket, format))
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("create the %s table bucket %s: %v\n%s", format, bucket, err, out)
}
}
// filerClient dials the filer the maintenance handlers talk to.
func (env *environment) filerClient(t *testing.T) filer_pb.SeaweedFilerClient {
t.Helper()
conn, err := grpc.NewClient(fmt.Sprintf("127.0.0.1:%d", env.filerGrpcPort),
grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
t.Fatalf("dial the filer: %v", err)
}
t.Cleanup(func() { conn.Close() })
return filer_pb.NewSeaweedFilerClient(conn)
}
// entryExists tells "the catalog forgot the table" from "the data is gone".
func (env *environment) entryExists(t *testing.T, path string) bool {
t.Helper()
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Get(fmt.Sprintf("http://127.0.0.1:%d%s", env.filerPort, path))
if err != nil {
t.Fatalf("filer GET %s: %v", path, err)
}
defer resp.Body.Close()
io.Copy(io.Discard, resp.Body)
return resp.StatusCode == http.StatusOK
}
func randomSuffix() string {
const charset = "abcdefghijklmnopqrstuvwxyz0123456789"
suffix := make([]byte, 8)
for i := range suffix {
suffix[i] = charset[rand.Intn(len(charset))]
}
return string(suffix)
}

View File

@@ -0,0 +1,164 @@
#!/usr/bin/env python3
"""PyIceberg half of the table lifecycle, run one phase per invocation.
The Go test calls this three times - write, verify, drop - and runs the
maintenance worker between the first two. Splitting it that way is the whole
point: a tally taken before compaction and the same tally taken after are the
only thing that catches a merge that rewrote every dictionary-encoded column
onto a single value and said nothing.
"""
import argparse
import hashlib
import json
import sys
import time
import pyarrow as pa
from pyiceberg.catalog import load_catalog
from pyiceberg.exceptions import NamespaceAlreadyExistsError, NoSuchTableError
from pyiceberg.schema import Schema
from pyiceberg.types import LongType, NestedField, StringType, TimestamptzType
# Few enough distinct values in the two string columns that any writer worth
# the name dictionary-encodes them, which is the encoding that broke.
CATEGORIES = 7
VALUES = 13
ROWS_PER_BATCH = 4000
BATCHES = 3
SCHEMA = Schema(
NestedField(1, "id", LongType(), required=True),
NestedField(2, "category", StringType(), required=True),
NestedField(3, "value", StringType(), required=True),
NestedField(4, "ts", TimestamptzType(), required=True),
)
ARROW_SCHEMA = pa.schema(
[
pa.field("id", pa.int64(), nullable=False),
pa.field("category", pa.string(), nullable=False),
pa.field("value", pa.string(), nullable=False),
pa.field("ts", pa.timestamp("us", tz="UTC"), nullable=False),
]
)
def batch(start, count):
ids = list(range(start, start + count))
return pa.Table.from_pydict(
{
"id": ids,
"category": [f"cat-{i % CATEGORIES}" for i in ids],
"value": [f"v-{i % VALUES}" for i in ids],
# An hour apart, so the rows spread over months the way a real
# table's do without needing a partition spec to prove it.
"ts": [1772323200000000 + i * 3600000000 for i in ids],
},
schema=ARROW_SCHEMA,
)
def tally(table):
"""Row count, per-column cardinality, and a digest of every row.
The cardinalities catch a column collapsed onto one dictionary entry; the
digest catches everything else, including a merge that keeps the right
number of distinct values while handing them to the wrong rows. Every
column goes into it, not just the two the cardinalities watch - compaction
rewrites the whole row. ts goes in as microseconds so no timezone sits
between the two runs.
"""
scanned = table.scan().to_arrow()
categories = scanned.column("category").to_pylist()
values = scanned.column("value").to_pylist()
rows = [
f"{i}|{t}|{c}|{v}"
for i, t, c, v in zip(
scanned.column("id").to_pylist(),
scanned.column("ts").cast(pa.int64()).to_pylist(),
categories,
values,
strict=True,
)
]
digest = hashlib.md5(
"\n".join(sorted(rows)).encode(), usedforsecurity=False
).hexdigest()
return {
"rows": scanned.num_rows,
"categories": len(set(categories)),
"values": len(set(values)),
"digest": digest,
}
def connect(args):
properties = {
"type": "rest",
"uri": args.catalog_url,
"warehouse": f"s3://{args.bucket}/",
"prefix": args.bucket,
"auth": {"type": "noop"},
"s3.endpoint": args.s3_endpoint,
"s3.access-key-id": args.access_key,
"s3.secret-access-key": args.secret_key,
"s3.region": "us-east-1",
"s3.path-style-access": "true",
}
last = None
for attempt in range(10):
try:
return load_catalog("rest", **properties)
except Exception as err: # the gateway may still be coming up
last = err
print(f"connect attempt {attempt + 1} failed: {err}", file=sys.stderr)
time.sleep(2)
raise last
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--phase", required=True, choices=["write", "verify", "drop"])
parser.add_argument("--catalog-url", required=True)
parser.add_argument("--s3-endpoint", required=True)
parser.add_argument("--bucket", required=True)
parser.add_argument("--namespace", required=True)
parser.add_argument("--table", required=True)
parser.add_argument("--access-key", required=True)
parser.add_argument("--secret-key", required=True)
args = parser.parse_args()
catalog = connect(args)
identifier = f"{args.namespace}.{args.table}"
if args.phase == "write":
try:
catalog.create_namespace(args.namespace)
except NamespaceAlreadyExistsError:
pass
table = catalog.create_table(identifier, schema=SCHEMA)
# One append per batch, so compaction has several files to merge
# rather than one it would leave alone.
for i in range(BATCHES):
table.append(batch(i * ROWS_PER_BATCH + 1, ROWS_PER_BATCH))
table = catalog.load_table(identifier)
print(json.dumps(tally(table)))
return
if args.phase == "verify":
print(json.dumps(tally(catalog.load_table(identifier))))
return
catalog.drop_table(identifier)
try:
catalog.load_table(identifier)
except NoSuchTableError:
pass
else:
raise SystemExit("the table is still in the catalog after a drop")
catalog.drop_namespace(args.namespace)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,389 @@
package lifecycle
import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/test/testutil"
"github.com/seaweedfs/seaweedfs/weed/worker/tasks/iceberg"
)
const (
pyicebergImage = "seaweedfs-lifecycle-pyiceberg"
duckdbImage = "duckdb/duckdb:latest"
// The phase that installs the extension, and so the only one whose failure
// can mean the image rather than the code.
firstPhase = "write"
// Three appends of this many rows, all inside one month so they land in
// one partition and compaction has something to merge. The two string
// columns hold few enough distinct values to be dictionary-encoded, which
// is the encoding the merge destroyed.
rowsPerBatch = 4000
batches = 3
categories = 7
values = 13
)
// tally is what a client reports about a table. Both halves of this suite
// speak it, and the point of the whole suite is that the one taken before
// maintenance equals the one taken after.
type tally struct {
Rows int `json:"rows"`
Categories int `json:"categories"`
Values int `json:"values"`
Digest string `json:"digest"`
// Lance only: a compaction that merged nothing would otherwise let this
// test pass without having tested anything.
Fragments int `json:"fragments"`
Version int `json:"version"`
}
func TestIcebergTableLifecycle(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
if shared == nil {
t.Skip("no cluster")
}
if !testutil.HasDocker() {
t.Skip("Docker not available")
}
// DuckDB is the client the bug was reported against, and the only one
// here that writes the deprecated PLAIN_DICTIONARY encoding a Go writer
// will not produce. PyIceberg writes the modern one, so between them the
// merge is checked against both dictionary encodings in the spec.
t.Run("DuckDB", testIcebergLifecycleWithDuckDB)
t.Run("PyIceberg", testIcebergLifecycleWithPyIceberg)
}
func testIcebergLifecycleWithDuckDB(t *testing.T) {
env := shared
bucket := "lifecycle-duckdb-" + randomSuffix()
namespace, table := "sales", "events"
env.createTableBucket(t, bucket, "ICEBERG")
inserts := make([]string, 0, batches)
for i := 0; i < batches; i++ {
inserts = append(inserts, fmt.Sprintf(
"INSERT INTO cat.%s.%s SELECT g, TIMESTAMPTZ '2026-03-01 00:00:00' + INTERVAL (g %% 600) MINUTE, "+
"'cat-' || (g %% %d), 'v-' || (g %% %d) FROM generate_series(%d, %d) t(g);",
namespace, table, categories, values, i*rowsPerBatch+1, (i+1)*rowsPerBatch))
}
before := env.duckdb(t, bucket, "write", strings.Join([]string{
fmt.Sprintf("CREATE SCHEMA cat.%s;", namespace),
// Partitioned, the way the table in the report was. Compaction bins
// per partition, so this is also what puts more than one bin in play.
fmt.Sprintf("CREATE TABLE cat.%s.%s(id int, ts timestamptz, category text, value text) PARTITIONED BY (month(ts));", namespace, table),
strings.Join(inserts, "\n"),
duckdbTallySQL(namespace, table),
}, "\n"))
assertSeeded(t, before)
env.maintainIcebergTable(t, bucket, namespace+"/"+table)
after := env.duckdb(t, bucket, "verify", duckdbTallySQL(namespace, table))
assertSameData(t, before, after)
env.duckdb(t, bucket, "drop", fmt.Sprintf("DROP TABLE cat.%s.%s;\nDROP SCHEMA cat.%s;", namespace, table, namespace))
if env.entryExists(t, fmt.Sprintf("/buckets/%s/%s/%s/", bucket, namespace, table)) {
t.Fatal("the dropped table's data is still on disk")
}
}
func testIcebergLifecycleWithPyIceberg(t *testing.T) {
env := shared
bucket := "lifecycle-pyiceberg-" + randomSuffix()
namespace, table := "sales", "events"
env.createTableBucket(t, bucket, "ICEBERG")
buildClientImage(t, pyicebergImage, "Dockerfile.pyiceberg")
before := env.pyiceberg(t, "write", bucket, namespace, table)
assertSeeded(t, before)
env.maintainIcebergTable(t, bucket, namespace+"/"+table)
after := env.pyiceberg(t, "verify", bucket, namespace, table)
assertSameData(t, before, after)
env.pyiceberg(t, "drop", bucket, namespace, table)
if env.entryExists(t, fmt.Sprintf("/buckets/%s/%s/%s/", bucket, namespace, table)) {
t.Fatal("the dropped table's data is still on disk")
}
}
// maintainIcebergTable runs the worker's whole maintenance cycle against the
// live filer, in the order a scheduled worker would: merge the small files,
// drop the snapshots that referenced them, sweep what nothing references any
// more, then fold the manifests together.
func (env *environment) maintainIcebergTable(t *testing.T, bucket, tablePath string) {
t.Helper()
client := env.filerClient(t)
handler := iceberg.NewHandler(nil)
config := iceberg.Config{
TargetFileSizeBytes: 256 << 20,
MinInputFiles: 2,
MaxCommitRetries: 3,
SnapshotRetentionMs: 1,
MaxSnapshotsToKeep: 1,
OrphanOlderThanHours: 1,
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
operations := []struct {
name string
run func() (string, map[string]int64, error)
}{
{"compact", func() (string, map[string]int64, error) {
return handler.CompactDataFiles(ctx, client, bucket, tablePath, config)
}},
{"expire", func() (string, map[string]int64, error) {
return handler.ExpireSnapshots(ctx, client, bucket, tablePath, config)
}},
{"orphans", func() (string, map[string]int64, error) {
return handler.RemoveOrphans(ctx, client, bucket, tablePath, config)
}},
{"manifests", func() (string, map[string]int64, error) {
return handler.RewriteManifests(ctx, client, bucket, tablePath, config)
}},
}
for _, operation := range operations {
result, metrics, err := operation.run()
if err != nil {
t.Fatalf("%s: %v", operation.name, err)
}
t.Logf("%s: %s %v", operation.name, result, metrics)
// A compaction that merged nothing leaves the read below checking a
// file the worker never wrote, which proves nothing at all.
if operation.name == "compact" && metrics[iceberg.MetricFilesMerged] < batches {
t.Fatalf("compaction merged %d files, want all %d written by the client: %s",
metrics[iceberg.MetricFilesMerged], batches, result)
}
}
}
// duckdb runs a script against the catalog and returns whatever tally it
// printed. The prelude is the reporter's own ATTACH, SigV4 and all.
func (env *environment) duckdb(t *testing.T, bucket, phase, body string) tally {
t.Helper()
script := fmt.Sprintf(`INSTALL iceberg;
LOAD iceberg;
CREATE SECRET s3_secret (TYPE S3, KEY_ID '%s', SECRET '%s', ENDPOINT 'host.docker.internal:%d', URL_STYLE 'path', USE_SSL false);
ATTACH 's3://%s' AS cat (TYPE ICEBERG, ENDPOINT '%s', AUTHORIZATION_TYPE SigV4, SECRET 's3_secret', SIGV4_SERVICE 's3', SIGV4_REGION 'us-east-1', ACCESS_DELEGATION_MODE 'none', READ_ONLY false);
%s
`, accessKey, secretKey, env.s3Port, bucket, env.containerURL(env.icebergPort), body)
name := fmt.Sprintf("duckdb-%s-%s.sql", bucket, phase)
if err := os.WriteFile(filepath.Join(env.dataDir, name), []byte(script), 0644); err != nil {
t.Fatalf("write the DuckDB script: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), clientTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, "docker", "run", "--rm",
"-v", fmt.Sprintf("%s:/test", env.dataDir),
"--add-host", "host.docker.internal:host-gateway",
"--entrypoint", "duckdb",
duckdbImage,
"-init", "/test/"+name,
"-c", "SELECT 1",
)
out, err := cmd.CombinedOutput()
t.Logf("DuckDB %s:\n%s", phase, out)
if err != nil {
if phase == firstPhase && lacksIcebergExtension(string(out)) {
t.Skipf("this DuckDB image has no iceberg extension: %v", err)
}
t.Fatalf("DuckDB %s: %v", phase, err)
}
if !strings.Contains(body, "TALLY") {
return tally{}
}
return parseDuckDBTally(t, string(out))
}
func duckdbTallySQL(namespace, table string) string {
// The digest covers whole rows - every column, not just the two the
// cardinalities watch - because counting each column on its own passes a
// merge that keeps every column's cardinality and hands the values to the
// wrong rows. ts goes in as microseconds so the session's timezone cannot
// change how it renders between the two runs.
return fmt.Sprintf("SELECT 'TALLY ' || count(*) || ' ' || count(DISTINCT category) || ' ' || count(DISTINCT value)"+
" || ' ' || md5(string_agg(id || '|' || epoch_us(ts) || '|' || category || '|' || value, chr(10) ORDER BY id)) AS marker"+
" FROM cat.%s.%s;",
namespace, table)
}
var duckdbTallyPattern = regexp.MustCompile(`TALLY (\d+) (\d+) (\d+) ([0-9a-f]{32})`)
func parseDuckDBTally(t *testing.T, out string) tally {
t.Helper()
fields := duckdbTallyPattern.FindStringSubmatch(out)
if fields == nil {
t.Fatalf("DuckDB printed no tally:\n%s", out)
}
number := func(s string) int {
value, err := strconv.Atoi(s)
if err != nil {
t.Fatalf("parse %q: %v", s, err)
}
return value
}
return tally{
Rows: number(fields[1]),
Categories: number(fields[2]),
Values: number(fields[3]),
Digest: fields[4],
}
}
// lacksIcebergExtension reports whether DuckDB never got as far as this
// repository's code, which is the only failure worth skipping over.
//
// The extension ships separately from the image, so an environment that cannot
// fetch it has nothing to say about compaction. Everything past LOAD - a parse
// error on the ATTACH, a refusal from the catalog, a bad read - is ours, and
// has to fail: this is the one test covering the PLAIN_DICTIONARY encoding, and
// a skip nobody reads is how the corruption it was written for shipped.
func lacksIcebergExtension(out string) bool {
for _, marker := range []string{
"iceberg extension is not available",
"Failed to download extension",
`Extension "iceberg" not found`,
"Unknown extension",
} {
if strings.Contains(out, marker) {
return true
}
}
return false
}
func (env *environment) pyiceberg(t *testing.T, phase, bucket, namespace, table string) tally {
t.Helper()
out := env.runClient(t, pyicebergImage, phase, "/app/iceberg_lifecycle.py",
"--catalog-url", env.containerURL(env.icebergPort),
"--s3-endpoint", env.containerURL(env.s3Port),
"--bucket", bucket,
"--namespace", namespace,
"--table", table,
)
if phase == "drop" {
return tally{}
}
return decodeTally(t, out)
}
// runClient runs one phase of a python client and returns its stdout.
func (env *environment) runClient(t *testing.T, image, phase, script string, args ...string) string {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), clientTimeout)
defer cancel()
run := []string{"run", "--rm",
"--add-host", "host.docker.internal:host-gateway",
"-e", "AWS_ACCESS_KEY_ID=" + accessKey,
"-e", "AWS_SECRET_ACCESS_KEY=" + secretKey,
"-e", "AWS_REGION=us-east-1",
"-e", "AWS_ALLOW_HTTP=true",
image, "python3", script, "--phase", phase,
"--access-key", accessKey, "--secret-key", secretKey,
}
cmd := exec.CommandContext(ctx, "docker", append(run, args...)...)
var stderr strings.Builder
cmd.Stderr = &stderr
out, err := cmd.Output()
if stderr.Len() > 0 {
t.Logf("%s %s (stderr):\n%s", image, phase, stderr.String())
}
if err != nil {
t.Fatalf("%s %s: %v\n%s", image, phase, err, out)
}
t.Logf("%s %s: %s", image, phase, strings.TrimSpace(string(out)))
return string(out)
}
func decodeTally(t *testing.T, out string) tally {
t.Helper()
// The clients print the tally last; anything a library logged before it
// is not JSON and not ours.
lines := strings.Split(strings.TrimSpace(out), "\n")
var decoded tally
if err := json.Unmarshal([]byte(lines[len(lines)-1]), &decoded); err != nil {
t.Fatalf("decode the tally from %q: %v", out, err)
}
return decoded
}
func buildClientImage(t *testing.T, image, dockerfile string) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
defer cancel()
cmd := exec.CommandContext(ctx, "docker", "build", "-t", image, "-f", dockerfile, ".")
cmd.Dir = shared.testDir
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("build %s: %v\n%s", image, err, out)
}
}
// assertSeeded checks the client wrote what this suite assumes it wrote. Every
// comparison below is against these numbers, so a client that quietly seeded
// one row would otherwise make the whole test vacuous.
func assertSeeded(t *testing.T, seeded tally) {
t.Helper()
if want := rowsPerBatch * batches; seeded.Rows != want {
t.Fatalf("seeded %d rows, want %d", seeded.Rows, want)
}
if seeded.Categories != categories || seeded.Values != values {
t.Fatalf("seeded %d categories and %d values, want %d and %d",
seeded.Categories, seeded.Values, categories, values)
}
}
// assertSameData is the test. Maintenance rewrites files; it must not change a
// single row, and the cardinalities are called out separately because that is
// the shape the failure took: a dictionary column collapsed onto one entry,
// read back without an error, and answered wrongly.
func assertSameData(t *testing.T, before, after tally) {
t.Helper()
if after.Rows != before.Rows {
t.Errorf("maintenance changed the row count: %d -> %d", before.Rows, after.Rows)
}
if after.Categories != before.Categories {
t.Errorf("maintenance collapsed the category column: %d distinct values -> %d",
before.Categories, after.Categories)
}
if after.Values != before.Values {
t.Errorf("maintenance collapsed the value column: %d distinct values -> %d",
before.Values, after.Values)
}
if after.Digest != before.Digest {
t.Errorf("maintenance changed the rows: digest %s -> %s", before.Digest, after.Digest)
}
}

View File

@@ -0,0 +1,168 @@
#!/usr/bin/env python3
"""Lance half of the table lifecycle, run one phase per invocation.
The Go test calls this for each step and maintains the table in between, so the
tally taken before maintenance and the tally taken after are directly
comparable. That comparison is the test: the Iceberg side of this suite exists
because compaction once rewrote a table's dictionary columns onto a single
value and every check we had still passed, and a Lance dataset is rewritten by
the same kind of job.
The maintain phase is a fallback. When the Rust worker can be built, the Go test
runs its handlers against this table instead and skips this phase - what runs
here are the same two lance calls the handlers make.
"""
import argparse
import hashlib
import json
import sys
import warnings
from datetime import timedelta
warnings.filterwarnings("ignore")
import lance
import lance_namespace as ln
import pyarrow as pa
# Low enough cardinality that these two columns are dictionary-encoded.
CATEGORIES = 7
VALUES = 13
ROWS_PER_BATCH = 4000
BATCHES = 3
DIM = 8
def rows(start, count):
ids = list(range(start, start + count))
return pa.table(
{
"id": pa.array(ids, type=pa.int64()),
"category": pa.array([f"cat-{i % CATEGORIES}" for i in ids]),
"value": pa.array([f"v-{i % VALUES}" for i in ids]),
"vector": pa.array(
[[float(i % 97) + d for d in range(DIM)] for i in ids],
type=pa.list_(pa.float32(), DIM),
),
}
)
def tally(dataset):
"""What the table holds, in a form two runs can be compared by.
The cardinalities catch a column collapsed onto one value; the digest
catches a rewrite that keeps the values and moves them to the wrong rows.
Every column goes into the digest, the vectors included - compaction
rewrites whole fragments, so leaving a column out leaves a place for it to
go wrong unnoticed. Fragments come along because a compaction that merged
nothing would otherwise let this test pass without having tested anything.
"""
scanned = dataset.to_table(columns=["id", "category", "value", "vector"])
ids = scanned.column("id").to_pylist()
categories = scanned.column("category").to_pylist()
values = scanned.column("value").to_pylist()
vectors = scanned.column("vector").to_pylist()
serialized = (
f"{i}|{c}|{v}|{w}"
for i, c, v, w in zip(ids, categories, values, vectors, strict=True)
)
digest = hashlib.md5(
"\n".join(sorted(serialized)).encode(), usedforsecurity=False
).hexdigest()
return {
"rows": scanned.num_rows,
"categories": len(set(categories)),
"values": len(set(values)),
"digest": digest,
"fragments": len(dataset.get_fragments()),
"version": dataset.version,
}
def resolve(args):
"""Ask the namespace where the table lives, the way the worker does."""
namespace = ln.connect("rest", {"uri": args.namespace_url})
table_id = [args.bucket, args.namespace, args.table]
described = namespace.describe_table(ln.DescribeTableRequest(id=table_id))
return namespace, table_id, described.location, storage_options(args, described)
def storage_options(args, described=None):
# The namespace vends an endpoint correct for its own host; this container
# reaches the same gateway by another name. Credentials are filled in
# because a deployment without STS vends none.
options = dict((described.storage_options or {}) if described else {})
options["aws_endpoint"] = args.s3_endpoint
options["allow_http"] = "true"
options.setdefault("aws_access_key_id", args.access_key)
options.setdefault("aws_secret_access_key", args.secret_key)
options.setdefault("aws_region", "us-east-1")
return options
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--phase", required=True, choices=["write", "maintain", "verify", "drop"])
parser.add_argument("--namespace-url", required=True)
parser.add_argument("--s3-endpoint", required=True)
parser.add_argument("--bucket", required=True)
parser.add_argument("--namespace", required=True)
parser.add_argument("--table", required=True)
parser.add_argument("--access-key", default="any")
parser.add_argument("--secret-key", default="any")
args = parser.parse_args()
if args.phase == "write":
namespace = ln.connect("rest", {"uri": args.namespace_url})
for parent in ([args.bucket], [args.bucket, args.namespace]):
namespace.create_namespace(ln.CreateNamespaceRequest(id=parent, mode="EXIST_OK"))
table_id = [args.bucket, args.namespace, args.table]
declared = namespace.declare_table(ln.DeclareTableRequest(id=table_id))
options = storage_options(args)
# A fragment per append, so the compaction that follows has something
# to merge.
for i in range(BATCHES):
lance.write_dataset(
rows(i * ROWS_PER_BATCH + 1, ROWS_PER_BATCH),
declared.location,
storage_options=options,
mode="overwrite" if i == 0 else "append",
)
print(json.dumps(tally(lance.dataset(declared.location, storage_options=options))))
return 0
if args.phase == "maintain":
_, _, location, options = resolve(args)
dataset = lance.dataset(location, storage_options=options)
dataset.optimize.compact_files()
dataset = lance.dataset(location, storage_options=options)
dataset.cleanup_old_versions(older_than=timedelta(seconds=0), delete_unverified=True)
print(json.dumps(tally(lance.dataset(location, storage_options=options))))
return 0
if args.phase == "verify":
_, _, location, options = resolve(args)
print(json.dumps(tally(lance.dataset(location, storage_options=options))))
return 0
namespace, table_id, location, options = resolve(args)
namespace.drop_table(ln.DropTableRequest(id=table_id))
try:
lance.dataset(location, storage_options=options)
except ValueError as err:
# pylance turns every load failure into a ValueError, so the message is
# the only thing separating a dataset that is gone from credentials
# that stopped working halfway through the test.
if "was not found" in str(err):
return 0
print(f"FAIL: reading the dropped dataset failed for another reason: {err}",
file=sys.stderr)
return 1
print("FAIL: the dataset is still readable after a drop", file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,130 @@
package lifecycle
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"testing"
"github.com/seaweedfs/seaweedfs/test/testutil"
)
const lanceImage = "seaweedfs-lifecycle-lance"
// TestLanceTableLifecycle is the Iceberg test's counterpart. A Lance table is
// declared through the namespace, written a fragment at a time, compacted, its
// superseded versions dropped, and read again - and the read has to answer
// exactly what the write put there. Compaction rewrites fragments the way
// Iceberg compaction rewrites parquet files, and the failure that suite exists
// for is the kind that reads back without an error and answers wrongly.
func TestLanceTableLifecycle(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
if shared == nil {
t.Skip("no cluster")
}
if !testutil.HasDocker() {
t.Skip("Docker not available")
}
env := shared
bucket := "lifecycle-lance-" + randomSuffix()
namespace, table := "ml", "events"
env.createTableBucket(t, bucket, "LANCE")
buildClientImage(t, lanceImage, "Dockerfile.lance")
before := env.lance(t, "write", bucket, namespace, table)
assertSeeded(t, before)
if before.Fragments != batches {
t.Fatalf("the client wrote %d fragments, want one per append (%d)", before.Fragments, batches)
}
env.maintainLanceTable(t, bucket, namespace, table)
after := env.lance(t, "verify", bucket, namespace, table)
assertSameData(t, before, after)
// Without this the comparison above would pass on a table nothing touched.
if after.Fragments >= before.Fragments {
t.Fatalf("maintenance merged nothing: %d fragments before, %d after",
before.Fragments, after.Fragments)
}
env.lance(t, "drop", bucket, namespace, table)
if env.entryExists(t, fmt.Sprintf("/buckets/%s/%s/%s/", bucket, namespace, table)) {
t.Fatal("the dropped table's data is still on disk")
}
}
// maintainLanceTable compacts the table and drops what compaction superseded.
//
// Lance maintenance lives in the Rust worker, so where its toolchain is around
// the handlers themselves do the work against this table. Where it is not, the
// same two lance calls those handlers wrap run in the client container instead.
// The lifecycle is checked either way; only the layer above the format changes.
// WEED_LANCE_MAINTENANCE picks one - CI sets "library", because a cold build of
// the lance crate costs more than the layer it is checking.
func (env *environment) maintainLanceTable(t *testing.T, bucket, namespace, table string) {
t.Helper()
if !maintainWithWorker(t) {
env.lance(t, "maintain", bucket, namespace, table)
return
}
ctx, cancel := context.WithTimeout(context.Background(), clientTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, "cargo", "test",
"-p", "weed-lance-worker", "--test", "lifecycle", "--", "--nocapture")
cmd.Dir = filepath.Join(env.rootDir, "seaweed-worker")
cmd.Env = append(cmd.Environ(),
fmt.Sprintf("WEED_LANCE_NAMESPACE=http://127.0.0.1:%d", env.lancePort),
fmt.Sprintf("WEED_LANCE_TABLE=%s$%s$%s", bucket, namespace, table),
"AWS_ACCESS_KEY_ID="+accessKey,
"AWS_SECRET_ACCESS_KEY="+secretKey,
"AWS_REGION=us-east-1",
fmt.Sprintf("AWS_ENDPOINT_URL=http://127.0.0.1:%d", env.s3Port),
)
out, err := cmd.CombinedOutput()
t.Logf("lance worker:\n%s", out)
if err != nil {
t.Fatalf("the Lance maintenance worker failed: %v", err)
}
}
// maintainWithWorker says whether to maintain through the Rust worker.
func maintainWithWorker(t *testing.T) bool {
t.Helper()
switch os.Getenv("WEED_LANCE_MAINTENANCE") {
case "library":
t.Log("maintaining through the lance library, as WEED_LANCE_MAINTENANCE asks")
return false
case "worker":
return true
}
if _, err := exec.LookPath("cargo"); err != nil {
t.Log("cargo is not installed, maintaining through the lance library rather than the worker")
return false
}
return true
}
func (env *environment) lance(t *testing.T, phase, bucket, namespace, table string) tally {
t.Helper()
out := env.runClient(t, lanceImage, phase, "/app/lance_lifecycle.py",
"--namespace-url", env.containerURL(env.lancePort),
"--s3-endpoint", env.containerURL(env.s3Port),
"--bucket", bucket,
"--namespace", namespace,
"--table", table,
)
if phase == "drop" {
return tally{}
}
return decodeTally(t, out)
}

View File

@@ -0,0 +1,967 @@
// Package maintenance contains integration tests for the iceberg table
// maintenance plugin worker. Tests start a real weed mini cluster, create
// tables via the S3 Tables API, populate Iceberg metadata via the filer
// gRPC API, and then exercise the iceberg.Handler operations against the
// live filer.
package maintenance
import (
"bytes"
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"net/http"
"os"
"path"
"path/filepath"
"strconv"
"strings"
"sync"
"testing"
"time"
"github.com/apache/iceberg-go"
"github.com/apache/iceberg-go/table"
"github.com/aws/aws-sdk-go-v2/aws"
v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/parquet-go/parquet-go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"github.com/seaweedfs/seaweedfs/test/testutil"
"github.com/seaweedfs/seaweedfs/weed/command"
"github.com/seaweedfs/seaweedfs/weed/filer"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
icebergHandler "github.com/seaweedfs/seaweedfs/weed/worker/tasks/iceberg"
)
// ---------------------------------------------------------------------------
// Cluster lifecycle (mirrors test/s3tables/table-buckets pattern)
// ---------------------------------------------------------------------------
type testCluster struct {
dataDir string
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
filerGrpcPort int
s3Port int
s3Endpoint string
isRunning bool
}
var shared *testCluster
func TestMain(m *testing.M) {
flag.Parse()
if testing.Short() {
os.Exit(m.Run())
}
testDir, err := os.MkdirTemp("", "seaweed-iceberg-maint-*")
if err != nil {
fmt.Fprintf(os.Stderr, "SKIP: failed to create temp dir: %v\n", err)
os.Exit(0)
}
cluster, err := startCluster(testDir, nil)
if err != nil {
fmt.Fprintf(os.Stderr, "SKIP: failed to start cluster: %v\n", err)
os.RemoveAll(testDir)
os.Exit(0)
}
shared = cluster
code := m.Run()
shared.stop()
os.RemoveAll(testDir)
os.Exit(code)
}
func startCluster(testDir string, extraArgs []string) (*testCluster, error) {
ports, err := testutil.AllocatePorts(10)
if err != nil {
return nil, err
}
masterPort, masterGrpc := ports[0], ports[1]
volumePort, volumeGrpc := ports[2], ports[3]
filerPort, filerGrpc := ports[4], ports[5]
s3Port, s3Grpc := ports[6], ports[7]
adminPort, adminGrpc := ports[8], ports[9]
_ = os.Remove(filepath.Join(testDir, "mini.options"))
// Empty security.toml disables JWT auth.
if err := os.WriteFile(filepath.Join(testDir, "security.toml"), []byte("# test\n"), 0644); err != nil {
return nil, err
}
if os.Getenv("AWS_ACCESS_KEY_ID") == "" {
os.Setenv("AWS_ACCESS_KEY_ID", "admin")
}
if os.Getenv("AWS_SECRET_ACCESS_KEY") == "" {
os.Setenv("AWS_SECRET_ACCESS_KEY", "admin")
}
ctx, cancel := context.WithCancel(context.Background())
c := &testCluster{
dataDir: testDir,
ctx: ctx,
cancel: cancel,
filerGrpcPort: filerGrpc,
s3Port: s3Port,
s3Endpoint: fmt.Sprintf("http://127.0.0.1:%d", s3Port),
}
c.wg.Add(1)
go func() {
defer c.wg.Done()
oldDir, _ := os.Getwd()
oldArgs := os.Args
defer func() { os.Chdir(oldDir); os.Args = oldArgs }()
os.Chdir(testDir)
args := []string{
"-dir=" + testDir,
"-master.dir=" + testDir,
"-master.port=" + strconv.Itoa(masterPort),
"-master.port.grpc=" + strconv.Itoa(masterGrpc),
"-volume.port=" + strconv.Itoa(volumePort),
"-volume.port.grpc=" + strconv.Itoa(volumeGrpc),
"-volume.port.public=" + strconv.Itoa(volumePort),
"-volume.publicUrl=127.0.0.1:" + strconv.Itoa(volumePort),
"-filer.port=" + strconv.Itoa(filerPort),
"-filer.port.grpc=" + strconv.Itoa(filerGrpc),
"-s3.port=" + strconv.Itoa(s3Port),
"-s3.port.grpc=" + strconv.Itoa(s3Grpc),
"-admin.port=" + strconv.Itoa(adminPort),
"-admin.port.grpc=" + strconv.Itoa(adminGrpc),
"-webdav.port=0",
"-admin.ui=false",
"-master.volumeSizeLimitMB=32",
"-ip=127.0.0.1",
"-master.peers=none",
"-s3.iam.readOnly=false",
}
args = append(args, extraArgs...)
os.Args = append([]string{"weed"}, args...)
glog.MaxSize = 1024 * 1024
for _, cmd := range command.Commands {
if cmd.Name() == "mini" && cmd.Run != nil {
cmd.Flag.Parse(os.Args[1:])
command.MiniClusterCtx = ctx
cmd.Run(cmd, cmd.Flag.Args())
command.MiniClusterCtx = nil
return
}
}
}()
if err := waitReady(c.s3Endpoint, 30*time.Second); err != nil {
cancel()
return nil, err
}
c.isRunning = true
return c, nil
}
func (c *testCluster) stop() {
if c.cancel != nil {
c.cancel()
}
if c.isRunning {
time.Sleep(500 * time.Millisecond)
}
done := make(chan struct{})
go func() { c.wg.Wait(); close(done) }()
select {
case <-done:
case <-time.After(2 * time.Second):
}
}
func (c *testCluster) filerConn(t *testing.T) (*grpc.ClientConn, filer_pb.SeaweedFilerClient) {
t.Helper()
addr := fmt.Sprintf("127.0.0.1:%d", c.filerGrpcPort)
conn, err := grpc.NewClient(addr, grpc.WithTransportCredentials(insecure.NewCredentials()))
require.NoError(t, err)
t.Cleanup(func() { conn.Close() })
return conn, filer_pb.NewSeaweedFilerClient(conn)
}
func waitReady(endpoint string, timeout time.Duration) error {
client := &http.Client{Timeout: 1 * time.Second}
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
resp, err := client.Get(endpoint)
if err == nil {
resp.Body.Close()
time.Sleep(500 * time.Millisecond)
return nil
}
time.Sleep(200 * time.Millisecond)
}
return fmt.Errorf("timeout waiting for %s", endpoint)
}
func randomSuffix() string {
b := make([]byte, 4)
rand.Read(b)
return fmt.Sprintf("%x", b)
}
// ---------------------------------------------------------------------------
// Helpers for populating Iceberg table state via filer gRPC
// ---------------------------------------------------------------------------
func newTestSchema() *iceberg.Schema {
return iceberg.NewSchema(0,
iceberg.NestedField{ID: 1, Type: iceberg.PrimitiveTypes.Int64, Name: "id", Required: true},
iceberg.NestedField{ID: 2, Type: iceberg.PrimitiveTypes.String, Name: "name", Required: false},
iceberg.NestedField{ID: 3, Type: iceberg.PrimitiveTypes.String, Name: "payload", Required: false},
)
}
type compactRow struct {
ID int64 `parquet:"id"`
Name string `parquet:"name"`
Payload string `parquet:"payload"`
}
func buildParquetData(t *testing.T, prefix string, rowCount int) []byte {
t.Helper()
rows := make([]compactRow, rowCount)
for i := range rows {
rows[i] = compactRow{
ID: int64(i + 1),
Name: fmt.Sprintf("%s-%06d", prefix, i),
Payload: fmt.Sprintf("%s-%06d-%s", prefix, i, strings.Repeat(fmt.Sprintf("%04d", i%10000), 128)),
}
}
var buf bytes.Buffer
writer := parquet.NewGenericWriter[compactRow](&buf)
_, err := writer.Write(rows)
require.NoError(t, err)
require.NoError(t, writer.Close())
return buf.Bytes()
}
func buildMetadata(t *testing.T, snapshots []table.Snapshot) table.Metadata {
t.Helper()
schema := newTestSchema()
meta, err := table.NewMetadata(schema, iceberg.UnpartitionedSpec, table.UnsortedSortOrder, "s3://test/table", nil)
require.NoError(t, err)
if len(snapshots) == 0 {
return meta
}
// Iceberg validates that snapshot timestamps >= metadata lastUpdatedMs.
// Offset all timestamps so they're safely after the metadata was created.
baseMs := time.Now().UnixMilli() + 100
for i := range snapshots {
snapshots[i].TimestampMs = baseMs + int64(i)
}
builder, err := table.MetadataBuilderFromBase(meta, "s3://test/table")
require.NoError(t, err)
var lastID int64
for i := range snapshots {
s := snapshots[i]
require.NoError(t, builder.AddSnapshot(&s))
lastID = s.SnapshotID
}
require.NoError(t, builder.SetSnapshotRef(table.MainBranch, lastID, table.BranchRef))
result, err := builder.Build()
require.NoError(t, err)
return result
}
// s3put performs an S3-signed PUT request. When key is empty it creates a
// bucket; otherwise it uploads an object.
func s3put(t *testing.T, s3Endpoint, bucket, key string, body []byte) {
t.Helper()
urlPath := "/" + bucket
if key != "" {
urlPath += "/" + key
}
var reader *bytes.Reader
if body != nil {
reader = bytes.NewReader(body)
} else {
reader = bytes.NewReader(nil)
}
req, err := http.NewRequest(http.MethodPut, s3Endpoint+urlPath, reader)
require.NoError(t, err)
req.Header.Set("Host", req.URL.Host)
hash := sha256.Sum256(body)
err = v4.NewSigner().SignHTTP(context.Background(),
aws.Credentials{AccessKeyID: "admin", SecretAccessKey: "admin"},
req, hex.EncodeToString(hash[:]), "s3", "us-east-1", time.Now())
require.NoError(t, err)
resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(req)
require.NoError(t, err)
defer resp.Body.Close()
if resp.StatusCode >= 400 {
b, _ := io.ReadAll(resp.Body)
require.FailNowf(t, "s3put failed", "s3put(%s/%s) → %d: %s", bucket, key, resp.StatusCode, string(b))
}
}
func newS3Client(t *testing.T, endpoint string) *s3.Client {
t.Helper()
cfg, err := config.LoadDefaultConfig(context.Background(),
config.WithRegion("us-east-1"),
config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider("admin", "admin", "")),
)
require.NoError(t, err)
return s3.NewFromConfig(cfg, func(o *s3.Options) {
o.BaseEndpoint = aws.String(endpoint)
o.UsePathStyle = true
})
}
func s3putObject(t *testing.T, client *s3.Client, bucket, key string, body []byte) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
_, err := client.PutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(bucket),
Key: aws.String(key),
Body: bytes.NewReader(body),
})
require.NoError(t, err)
}
type testFilerClient struct {
client filer_pb.SeaweedFilerClient
}
func (c testFilerClient) WithFilerClient(_ bool, fn func(filer_pb.SeaweedFilerClient) error) error {
return fn(c.client)
}
func (c testFilerClient) AdjustedUrl(location *filer_pb.Location) string {
if location == nil {
return ""
}
if location.PublicUrl != "" {
return location.PublicUrl
}
return location.Url
}
func (c testFilerClient) GetDataCenter() string {
return ""
}
func readFile(t *testing.T, client filer_pb.SeaweedFilerClient, dir, name string) []byte {
t.Helper()
entry := lookupEntry(t, client, dir, name)
require.NotNil(t, entry, "readFile(%s, %s): entry missing", dir, name)
if len(entry.Content) > 0 || len(entry.Chunks) == 0 {
return entry.Content
}
reader := filer.NewFileReader(testFilerClient{client: client}, entry)
if closer, ok := reader.(io.Closer); ok {
defer closer.Close()
}
content, err := io.ReadAll(reader)
require.NoError(t, err, "readFile(%s, %s): read chunked content", dir, name)
return content
}
// createBucketViaS3 creates a regular S3 bucket via the S3 PUT Bucket API.
// populateTableViaFiler creates the Iceberg directory structure, metadata xattr,
// manifests, and data files for a table directly in the filer.
// The table bucket, namespace, and table must already exist (created via S3
// Tables API).
func populateTableViaFiler(
t *testing.T,
client filer_pb.SeaweedFilerClient,
s3Endpoint, bucketName, namespace, tableName string,
snapshots []table.Snapshot,
) table.Metadata {
t.Helper()
ctx := context.Background()
meta := buildMetadata(t, snapshots)
fullJSON, err := json.Marshal(meta)
require.NoError(t, err)
tablePath := path.Join(namespace, tableName)
bucketsPath := s3tables.TablesPath
tableFilerPath := path.Join(bucketsPath, bucketName, tablePath)
metaDir := path.Join(tableFilerPath, "metadata")
dataDir := path.Join(tableFilerPath, "data")
// Build the table metadata xattr
internalMeta := map[string]interface{}{
"metadataVersion": 1,
"metadataLocation": "metadata/v1.metadata.json",
"metadata": map[string]interface{}{
"fullMetadata": json.RawMessage(fullJSON),
},
}
xattr, err := json.Marshal(internalMeta)
require.NoError(t, err)
// Create the S3 bucket via PUT, then create the directory tree via S3 PUT
// object with zero-byte content (the filer creates intermediate dirs).
s3put(t, s3Endpoint, bucketName, "", nil) // create bucket
s3put(t, s3Endpoint, bucketName, namespace+"/.dir", []byte{}) // create ns dir
s3put(t, s3Endpoint, bucketName, tablePath+"/.dir", []byte{}) // create table dir
s3put(t, s3Endpoint, bucketName, tablePath+"/metadata/.dir", []byte{}) // create metadata dir
s3put(t, s3Endpoint, bucketName, tablePath+"/data/.dir", []byte{}) // create data dir
// Now set the table bucket and table xattrs via filer gRPC.
// Mark bucket as table bucket.
bucketEntry := lookupEntry(t, client, bucketsPath, bucketName)
require.NotNil(t, bucketEntry, "bucket should exist after S3 PUT")
if bucketEntry.Extended == nil {
bucketEntry.Extended = make(map[string][]byte)
}
bucketEntry.Extended[s3tables.ExtendedKeyTableBucket] = []byte("true")
_, err = client.UpdateEntry(ctx, &filer_pb.UpdateEntryRequest{
Directory: bucketsPath, Entry: bucketEntry,
})
require.NoError(t, err)
// Set table metadata xattr.
nsDir := path.Join(bucketsPath, bucketName, namespace)
tableEntry := lookupEntry(t, client, nsDir, tableName)
require.NotNil(t, tableEntry, "table dir should exist after S3 PUT")
if tableEntry.Extended == nil {
tableEntry.Extended = make(map[string][]byte)
}
tableEntry.Extended[s3tables.ExtendedKeyMetadata] = xattr
_, err = client.UpdateEntry(ctx, &filer_pb.UpdateEntryRequest{
Directory: nsDir, Entry: tableEntry,
})
require.NoError(t, err)
// Write manifest + manifest list + data file for each snapshot
schema := meta.CurrentSchema()
spec := meta.PartitionSpec()
version := meta.Version()
for _, snap := range snapshots {
if snap.ManifestList == "" {
continue
}
snapID := snap.SnapshotID
dataFileName := fmt.Sprintf("snap-%d-data.parquet", snapID)
dfBuilder, err := iceberg.NewDataFileBuilder(
spec, iceberg.EntryContentData,
path.Join("data", dataFileName), iceberg.ParquetFile,
map[int]any{}, nil, nil, 10, 4096,
)
require.NoError(t, err)
entry := iceberg.NewManifestEntry(
iceberg.EntryStatusADDED, &snapID, nil, nil, dfBuilder.Build(),
)
// Manifest
manifestName := fmt.Sprintf("manifest-%d.avro", snapID)
var manifestBuf bytes.Buffer
mf, err := iceberg.WriteManifest(
path.Join("metadata", manifestName), &manifestBuf,
version, spec, schema, snapID, []iceberg.ManifestEntry{entry},
)
require.NoError(t, err)
writeFile(t, ctx, client, metaDir, manifestName, manifestBuf.Bytes())
// Manifest list
mlName := path.Base(snap.ManifestList)
var mlBuf bytes.Buffer
parent := snap.ParentSnapshotID
seqNum := snap.SequenceNumber
require.NoError(t, iceberg.WriteManifestList(
version, &mlBuf, snapID, parent, &seqNum, 0,
[]iceberg.ManifestFile{mf},
))
writeFile(t, ctx, client, metaDir, mlName, mlBuf.Bytes())
// Data file (dummy content)
writeFile(t, ctx, client, dataDir, dataFileName, []byte("fake-parquet"))
}
// Wait for snapshot timestamps (set slightly in the future by
// buildMetadata) to move into the past so expiration checks work.
time.Sleep(200 * time.Millisecond)
return meta
}
func writeFile(t *testing.T, ctx context.Context, client filer_pb.SeaweedFilerClient, dir, name string, content []byte) {
t.Helper()
resp, err := client.CreateEntry(ctx, &filer_pb.CreateEntryRequest{
Directory: dir,
Entry: &filer_pb.Entry{
Name: name,
Attributes: &filer_pb.FuseAttributes{
Mtime: time.Now().Unix(), Crtime: time.Now().Unix(),
FileMode: uint32(0644), FileSize: uint64(len(content)),
},
Content: content,
},
})
require.NoError(t, err, "writeFile(%s, %s): rpc error", dir, name)
require.Empty(t, resp.Error, "writeFile(%s, %s): resp error", dir, name)
}
func upsertFile(t *testing.T, ctx context.Context, client filer_pb.SeaweedFilerClient, dir, name string, content []byte) {
t.Helper()
entry := lookupEntry(t, client, dir, name)
if entry == nil {
writeFile(t, ctx, client, dir, name, content)
return
}
if entry.Attributes == nil {
entry.Attributes = &filer_pb.FuseAttributes{}
}
entry.Content = content
entry.Chunks = nil
entry.Attributes.Mtime = time.Now().Unix()
entry.Attributes.FileMode = uint32(0644)
entry.Attributes.FileSize = uint64(len(content))
resp, err := client.UpdateEntry(ctx, &filer_pb.UpdateEntryRequest{
Directory: dir,
Entry: entry,
})
require.NoError(t, err, "upsertFile(%s, %s): rpc error", dir, name)
require.NotNil(t, resp, "upsertFile(%s, %s): nil response", dir, name)
}
func lookupEntry(t *testing.T, client filer_pb.SeaweedFilerClient, dir, name string) *filer_pb.Entry {
t.Helper()
resp, err := filer_pb.LookupEntry(context.Background(), client, &filer_pb.LookupDirectoryEntryRequest{
Directory: dir, Name: name,
})
if err != nil {
if errors.Is(err, filer_pb.ErrNotFound) {
return nil
}
require.NoError(t, err, "lookupEntry(%s, %s): unexpected error", dir, name)
}
return resp.Entry
}
// ---------------------------------------------------------------------------
// Integration tests
// ---------------------------------------------------------------------------
func TestIcebergMaintenanceIntegration(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
t.Run("ExpireSnapshots", testExpireSnapshots)
t.Run("CompactDataFiles", testCompactDataFiles)
t.Run("RemoveOrphans", testRemoveOrphans)
t.Run("RewriteManifests", testRewriteManifests)
t.Run("FullMaintenanceCycle", testFullMaintenanceCycle)
}
func testExpireSnapshots(t *testing.T) {
_, client := shared.filerConn(t)
suffix := randomSuffix()
bucket := "maint-expire-" + suffix
ns := "ns"
tbl := "tbl"
now := time.Now().UnixMilli()
snapshots := []table.Snapshot{
{SnapshotID: 1, TimestampMs: now, ManifestList: "metadata/snap-1.avro"},
{SnapshotID: 2, TimestampMs: now + 1, ManifestList: "metadata/snap-2.avro"},
{SnapshotID: 3, TimestampMs: now + 2, ManifestList: "metadata/snap-3.avro"},
}
populateTableViaFiler(t, client, shared.s3Endpoint, bucket, ns, tbl, snapshots)
handler := icebergHandler.NewHandler(nil)
config := icebergHandler.Config{
SnapshotRetentionMs: 0, // instant expiry — everything eligible
MaxSnapshotsToKeep: 1, // keep only the current snapshot
MaxCommitRetries: 3,
}
result, _, err := handler.ExpireSnapshots(context.Background(), client, bucket, path.Join(ns, tbl), config)
require.NoError(t, err)
assert.Contains(t, result, "expired")
t.Logf("ExpireSnapshots result: %s", result)
// Verify metadata was updated
entry := lookupEntry(t, client, path.Join(s3tables.TablesPath, bucket, ns), tbl)
require.NotNil(t, entry)
xattr := entry.Extended[s3tables.ExtendedKeyMetadata]
require.NotEmpty(t, xattr)
// Parse updated metadata to verify snapshot count
var internalMeta struct {
MetadataVersion int `json:"metadataVersion"`
}
require.NoError(t, json.Unmarshal(xattr, &internalMeta))
assert.Greater(t, internalMeta.MetadataVersion, 1, "metadata version should have been incremented")
}
func testCompactDataFiles(t *testing.T) {
_, client := shared.filerConn(t)
suffix := randomSuffix()
bucket := "maint-compact-" + suffix
ns := "ns"
tbl := "tbl"
now := time.Now().UnixMilli()
snapshots := []table.Snapshot{
{SnapshotID: 1, TimestampMs: now, ManifestList: "metadata/snap-1.avro", SequenceNumber: 1},
}
meta := populateTableViaFiler(t, client, shared.s3Endpoint, bucket, ns, tbl, snapshots)
ctx := context.Background()
tablePath := path.Join(ns, tbl)
dataDir := path.Join(s3tables.TablesPath, bucket, tablePath, "data")
metaDir := path.Join(s3tables.TablesPath, bucket, tablePath, "metadata")
type parquetFile struct {
name string
recordCount int64
fileSize int64
}
s3Client := newS3Client(t, shared.s3Endpoint)
var files []parquetFile
for i := 1; i <= 3; i++ {
name := fmt.Sprintf("compact-input-%d.parquet", i)
content := buildParquetData(t, fmt.Sprintf("compact-%d", i), 2000)
s3putObject(t, s3Client, bucket, path.Join(tablePath, "data", name), content)
entry := lookupEntry(t, client, dataDir, name)
require.NotNil(t, entry, "uploaded parquet file should exist")
require.NotEmpty(t, entry.Chunks, "uploaded parquet file should be chunk-backed")
files = append(files, parquetFile{
name: name,
recordCount: 2000,
fileSize: int64(len(content)),
})
}
schema := meta.CurrentSchema()
spec := meta.PartitionSpec()
version := meta.Version()
snapID := snapshots[0].SnapshotID
entries := make([]iceberg.ManifestEntry, 0, len(files))
for _, file := range files {
dfBuilder, err := iceberg.NewDataFileBuilder(
spec,
iceberg.EntryContentData,
path.Join("data", file.name),
iceberg.ParquetFile,
map[int]any{},
nil, nil,
file.recordCount,
file.fileSize,
)
require.NoError(t, err)
entries = append(entries, iceberg.NewManifestEntry(
iceberg.EntryStatusADDED, &snapID, nil, nil, dfBuilder.Build(),
))
}
manifestName := "manifest-compact.avro"
var manifestBuf bytes.Buffer
manifest, err := iceberg.WriteManifest(
path.Join("metadata", manifestName), &manifestBuf,
version, spec, schema, snapID, entries,
)
require.NoError(t, err)
writeFile(t, ctx, client, metaDir, manifestName, manifestBuf.Bytes())
var manifestListBuf bytes.Buffer
seqNum := snapshots[0].SequenceNumber
require.NoError(t, iceberg.WriteManifestList(
version, &manifestListBuf, snapID, snapshots[0].ParentSnapshotID, &seqNum, 0,
[]iceberg.ManifestFile{manifest},
))
upsertFile(t, ctx, client, metaDir, path.Base(snapshots[0].ManifestList), manifestListBuf.Bytes())
handler := icebergHandler.NewHandler(nil)
config := icebergHandler.Config{
TargetFileSizeBytes: 16 * 1024 * 1024,
MinInputFiles: 2,
MaxCommitRetries: 3,
}
result, metrics, err := handler.CompactDataFiles(ctx, client, bucket, tablePath, config)
require.NoError(t, err)
assert.Contains(t, result, "compacted")
require.NotNil(t, metrics, "expected non-nil metrics from CompactDataFiles")
assert.Contains(t, metrics, icebergHandler.MetricFilesMerged)
assert.Contains(t, metrics, icebergHandler.MetricFilesWritten)
assert.Contains(t, metrics, icebergHandler.MetricBins)
assert.Contains(t, metrics, icebergHandler.MetricDurationMs)
t.Logf("CompactDataFiles result: %s, metrics: %v", result, metrics)
var compacted *filer_pb.Entry
listErr := filer_pb.SeaweedList(ctx, client, dataDir, "", func(entry *filer_pb.Entry, isLast bool) error {
// Compacted output uses compact-{snapID}-{newSnapID}-{binIdx}.parquet.
// The test inputs use compact-input-{n}.parquet and must not satisfy this check.
if strings.HasPrefix(entry.Name, "compact-") &&
!strings.HasPrefix(entry.Name, "compact-input-") &&
strings.HasSuffix(entry.Name, ".parquet") {
compacted = entry
}
return nil
}, "", false, 1000)
require.NoError(t, listErr)
require.NotNil(t, compacted, "compaction should create a merged parquet file")
tableEntry := lookupEntry(t, client, path.Join(s3tables.TablesPath, bucket, ns), tbl)
require.NotNil(t, tableEntry)
xattr := tableEntry.Extended[s3tables.ExtendedKeyMetadata]
require.NotEmpty(t, xattr)
var internalMeta struct {
MetadataVersion int `json:"metadataVersion"`
MetadataLocation string `json:"metadataLocation,omitempty"`
}
require.NoError(t, json.Unmarshal(xattr, &internalMeta))
assert.Greater(t, internalMeta.MetadataVersion, 1, "metadata version should advance after compaction")
metadataName := path.Base(internalMeta.MetadataLocation)
require.NotEmpty(t, metadataName, "compaction should point table metadata at a committed metadata file")
metadataBytes := readFile(t, client, metaDir, metadataName)
committedMeta, err := table.ParseMetadataBytes(metadataBytes)
require.NoError(t, err)
currentSnap := committedMeta.CurrentSnapshot()
require.NotNil(t, currentSnap, "committed metadata should include a current snapshot")
assert.Equal(t, snapshots[0].SequenceNumber+1, currentSnap.SequenceNumber, "compaction should advance the snapshot sequence number")
manifestListName := path.Base(currentSnap.ManifestList)
require.NotEmpty(t, manifestListName, "committed snapshot should reference a manifest list")
manifestListBytes := readFile(t, client, metaDir, manifestListName)
manifestFiles, err := iceberg.ReadManifestList(bytes.NewReader(manifestListBytes))
require.NoError(t, err)
require.Len(t, manifestFiles, 1, "compaction should commit a single replacement manifest")
assert.Equal(t, snapshots[0].SequenceNumber+1, manifestFiles[0].SequenceNum(), "new manifest should use the committed snapshot sequence number")
assert.Equal(t, snapshots[0].SequenceNumber+1, manifestFiles[0].MinSequenceNum(), "compaction manifest should inherit the committed sequence number for added data")
manifestName = path.Base(manifestFiles[0].FilePath())
manifestBytes := readFile(t, client, metaDir, manifestName)
manifestEntries, err := iceberg.ReadManifest(manifestFiles[0], bytes.NewReader(manifestBytes), false)
require.NoError(t, err)
require.Len(t, manifestEntries, len(files)+1, "compaction should replace the input files with one merged output")
deletedPaths := make(map[string]struct{}, len(files))
addedPaths := make(map[string]struct{})
for _, entry := range manifestEntries {
switch entry.Status() {
case iceberg.EntryStatusADDED:
addedPaths[entry.DataFile().FilePath()] = struct{}{}
assert.Equal(t, snapshots[0].SequenceNumber+1, entry.SequenceNum(), "added entries should inherit the new snapshot sequence number")
fileSeqNum := entry.FileSequenceNum()
require.NotNil(t, fileSeqNum, "added entries should carry a file sequence number")
assert.Equal(t, snapshots[0].SequenceNumber+1, *fileSeqNum)
case iceberg.EntryStatusDELETED:
deletedPaths[entry.DataFile().FilePath()] = struct{}{}
assert.Equal(t, snapshots[0].SequenceNumber, entry.SequenceNum(), "deleted entries should preserve the original data sequence number")
fileSeqNum := entry.FileSequenceNum()
require.NotNil(t, fileSeqNum, "deleted entries should preserve file sequence numbers")
assert.Equal(t, snapshots[0].SequenceNumber, *fileSeqNum)
default:
t.Fatalf("unexpected manifest entry status %v for %s", entry.Status(), entry.DataFile().FilePath())
}
}
require.Len(t, addedPaths, 1, "compaction should add exactly one merged parquet file")
assert.Contains(t, addedPaths, fmt.Sprintf("s3://%s/%s/data/%s", bucket, tablePath, compacted.Name))
require.Len(t, deletedPaths, len(files), "compaction should delete every original small input file")
for _, file := range files {
assert.Contains(t, deletedPaths, path.Join("data", file.name))
}
}
func testRemoveOrphans(t *testing.T) {
_, client := shared.filerConn(t)
suffix := randomSuffix()
bucket := "maint-orphan-" + suffix
ns := "ns"
tbl := "tbl"
now := time.Now().UnixMilli()
snapshots := []table.Snapshot{
{SnapshotID: 1, TimestampMs: now, ManifestList: "metadata/snap-1.avro"},
}
populateTableViaFiler(t, client, shared.s3Endpoint, bucket, ns, tbl, snapshots)
ctx := context.Background()
tablePath := path.Join(ns, tbl)
tableFilerPath := path.Join(s3tables.TablesPath, bucket, tablePath)
dataDir := path.Join(tableFilerPath, "data")
metaDir := path.Join(tableFilerPath, "metadata")
// Create orphan files (old enough to be removed)
oldTime := time.Now().Add(-200 * time.Hour).Unix()
writeOrphan := func(dir, name string) {
resp, err := client.CreateEntry(ctx, &filer_pb.CreateEntryRequest{
Directory: dir,
Entry: &filer_pb.Entry{
Name: name,
Attributes: &filer_pb.FuseAttributes{
Mtime: oldTime, Crtime: oldTime,
FileMode: uint32(0644), FileSize: 100,
},
Content: []byte("orphan"),
},
})
require.NoError(t, err, "writeOrphan(%s, %s): rpc error", dir, name)
require.Empty(t, resp.Error, "writeOrphan(%s, %s): resp error", dir, name)
}
writeOrphan(dataDir, "orphan-data.parquet")
writeOrphan(metaDir, "orphan-meta.avro")
// Create a recent orphan (should NOT be removed)
writeFile(t, ctx, client, dataDir, "recent-orphan.parquet", []byte("new"))
handler := icebergHandler.NewHandler(nil)
config := icebergHandler.Config{
OrphanOlderThanHours: 72,
MaxCommitRetries: 3,
}
result, _, err := handler.RemoveOrphans(ctx, client, bucket, tablePath, config)
require.NoError(t, err)
assert.Contains(t, result, "removed")
t.Logf("RemoveOrphans result: %s", result)
// Verify orphans were removed
assert.Nil(t, lookupEntry(t, client, dataDir, "orphan-data.parquet"), "old orphan data file should be deleted")
assert.Nil(t, lookupEntry(t, client, metaDir, "orphan-meta.avro"), "old orphan meta file should be deleted")
// Verify recent orphan was kept
assert.NotNil(t, lookupEntry(t, client, dataDir, "recent-orphan.parquet"), "recent orphan should be kept")
}
func testRewriteManifests(t *testing.T) {
_, client := shared.filerConn(t)
suffix := randomSuffix()
bucket := "maint-rewrite-" + suffix
ns := "ns"
tbl := "tbl"
// Each snapshot gets its own manifest list with one manifest. The current
// snapshot (latest) therefore has only 1 manifest — below the rewrite
// threshold. This tests the threshold check against a real filer.
now := time.Now().UnixMilli()
snapshots := []table.Snapshot{
{SnapshotID: 1, TimestampMs: now, ManifestList: "metadata/snap-1.avro"},
}
populateTableViaFiler(t, client, shared.s3Endpoint, bucket, ns, tbl, snapshots)
handler := icebergHandler.NewHandler(nil)
config := icebergHandler.Config{
MinManifestsToRewrite: 5, // threshold higher than 1 manifest
MaxCommitRetries: 3,
}
tablePath := path.Join(ns, tbl)
result, _, err := handler.RewriteManifests(context.Background(), client, bucket, tablePath, config)
require.NoError(t, err)
assert.Contains(t, result, "below threshold")
t.Logf("RewriteManifests result: %s", result)
}
func testFullMaintenanceCycle(t *testing.T) {
_, client := shared.filerConn(t)
suffix := randomSuffix()
bucket := "maint-full-" + suffix
ns := "ns"
tbl := "tbl"
now := time.Now().UnixMilli()
snapshots := []table.Snapshot{
{SnapshotID: 1, TimestampMs: now, ManifestList: "metadata/snap-1.avro"},
{SnapshotID: 2, TimestampMs: now + 1, ManifestList: "metadata/snap-2.avro"},
{SnapshotID: 3, TimestampMs: now + 2, ManifestList: "metadata/snap-3.avro"},
}
populateTableViaFiler(t, client, shared.s3Endpoint, bucket, ns, tbl, snapshots)
ctx := context.Background()
tablePath := path.Join(ns, tbl)
// Add an orphan
dataDir := path.Join(s3tables.TablesPath, bucket, tablePath, "data")
oldTime := time.Now().Add(-200 * time.Hour).Unix()
orphanResp, err := client.CreateEntry(ctx, &filer_pb.CreateEntryRequest{
Directory: dataDir,
Entry: &filer_pb.Entry{
Name: "orphan.parquet",
Attributes: &filer_pb.FuseAttributes{
Mtime: oldTime, Crtime: oldTime,
FileMode: uint32(0644), FileSize: 100,
},
Content: []byte("orphan"),
},
})
require.NoError(t, err)
require.Empty(t, orphanResp.Error)
handler := icebergHandler.NewHandler(nil)
// Step 1: Expire snapshots
expireConfig := icebergHandler.Config{
SnapshotRetentionMs: 0, // instant expiry
MaxSnapshotsToKeep: 1,
MaxCommitRetries: 3,
}
result, _, err := handler.ExpireSnapshots(ctx, client, bucket, tablePath, expireConfig)
require.NoError(t, err)
assert.Contains(t, result, "expired")
t.Logf("Step 1 (expire): %s", result)
// Step 2: Remove orphans
orphanConfig := icebergHandler.Config{
OrphanOlderThanHours: 72,
MaxCommitRetries: 3,
}
result, _, err = handler.RemoveOrphans(ctx, client, bucket, tablePath, orphanConfig)
require.NoError(t, err)
t.Logf("Step 2 (orphans): %s", result)
// The orphan and the unreferenced files from expired snapshots should be gone
assert.Contains(t, result, "removed")
// Step 3: Verify metadata is consistent
entry := lookupEntry(t, client, path.Join(s3tables.TablesPath, bucket, ns), tbl)
require.NotNil(t, entry)
xattr := entry.Extended[s3tables.ExtendedKeyMetadata]
require.NotEmpty(t, xattr)
var internalMeta struct {
MetadataVersion int `json:"metadataVersion"`
}
require.NoError(t, json.Unmarshal(xattr, &internalMeta))
assert.Greater(t, internalMeta.MetadataVersion, 1, "metadata version should have advanced through the cycle")
t.Logf("Final metadata version: %d", internalMeta.MetadataVersion)
}

View File

@@ -0,0 +1,380 @@
package polaris
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/seaweedfs/seaweedfs/test/testutil"
)
const (
polarisImage = "apache/polaris:latest"
polarisRealm = "POLARIS"
polarisRootClientID = "root"
polarisRootClientSecret = "s3cr3t"
polarisRegion = "us-east-1"
polarisRoleArn = "arn:aws:iam::000000000000:role/PolarisVendedRole"
polarisSigningKey = "dGVzdC1zaWduaW5nLWtleS1mb3Itc3RzLWludGVncmF0aW9uLXRlc3Rz" // gitleaks:allow - test signing key
)
type TestEnvironment struct {
seaweedDir string
weedBinary string
dataDir string
bindIP string
s3Port int
s3GrpcPort int
masterPort int
masterGrpcPort int
filerPort int
filerGrpcPort int
volumePort int
volumeGrpcPort int
polarisPort int
polarisAdminPort int
weedProcess *exec.Cmd
weedCancel context.CancelFunc
polarisContainer string
accessKey string
secretKey string
}
func NewTestEnvironment(t *testing.T) *TestEnvironment {
t.Helper()
if !testutil.HasDocker() {
t.Skip("Docker is required for Polaris integration tests")
}
wd, err := os.Getwd()
if err != nil {
t.Fatalf("Failed to get working directory: %v", err)
}
seaweedDir := wd
for i := 0; i < 6; i++ {
if _, err := os.Stat(filepath.Join(seaweedDir, "go.mod")); err == nil {
break
}
seaweedDir = filepath.Dir(seaweedDir)
}
weedBinary := filepath.Join(seaweedDir, "weed", "weed")
if _, err := os.Stat(weedBinary); err != nil {
weedBinary = "weed"
if _, err := exec.LookPath(weedBinary); err != nil {
t.Skip("weed binary not found, skipping integration test")
}
}
dataDir, err := os.MkdirTemp("", "seaweed-polaris-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
bindIP := testutil.FindBindIP()
// Allocate all ports in a single batch to prevent the OS from recycling
// a released port, which can cause two services to get the same port.
ports := testutil.MustAllocatePorts(t, 10)
masterPort, masterGrpcPort := ports[0], ports[1]
volumePort, volumeGrpcPort := ports[2], ports[3]
filerPort, filerGrpcPort := ports[4], ports[5]
s3Port, s3GrpcPort := ports[6], ports[7]
polarisPort, polarisAdminPort := ports[8], ports[9]
return &TestEnvironment{
seaweedDir: seaweedDir,
weedBinary: weedBinary,
dataDir: dataDir,
bindIP: bindIP,
s3Port: s3Port,
s3GrpcPort: s3GrpcPort,
masterPort: masterPort,
masterGrpcPort: masterGrpcPort,
filerPort: filerPort,
filerGrpcPort: filerGrpcPort,
volumePort: volumePort,
volumeGrpcPort: volumeGrpcPort,
polarisPort: polarisPort,
polarisAdminPort: polarisAdminPort,
accessKey: "admin",
secretKey: "admin",
}
}
func (env *TestEnvironment) StartSeaweedFS(t *testing.T) {
t.Helper()
iamConfigPath := filepath.Join(env.dataDir, "iam.json")
iamConfig := fmt.Sprintf(`{
"identities": [
{
"name": "admin",
"credentials": [
{
"accessKey": "%s",
"secretKey": "%s"
}
],
"actions": ["Admin", "Read", "List", "Tagging", "Write"]
}
],
"sts": {
"tokenDuration": "12h",
"maxSessionLength": "24h",
"issuer": "seaweedfs-sts",
"signingKey": "%s"
},
"roles": [
{
"roleName": "PolarisVendedRole",
"roleArn": "%s",
"trustPolicy": {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": "*",
"Action": "sts:AssumeRole"
}
]
},
"attachedPolicies": ["FullAccess"]
}
],
"policies": [
{
"name": "FullAccess",
"document": {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "*",
"Resource": "*"
}
]
}
}
]
}`, env.accessKey, env.secretKey, polarisSigningKey, polarisRoleArn)
if err := os.WriteFile(iamConfigPath, []byte(iamConfig), 0644); err != nil {
t.Fatalf("Failed to create IAM config: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
env.weedCancel = cancel
cmd := exec.CommandContext(ctx, env.weedBinary, "-v", "4", "mini",
"-master.port", fmt.Sprintf("%d", env.masterPort),
"-master.port.grpc", fmt.Sprintf("%d", env.masterGrpcPort),
"-volume.port", fmt.Sprintf("%d", env.volumePort),
"-volume.port.grpc", fmt.Sprintf("%d", env.volumeGrpcPort),
"-filer.port", fmt.Sprintf("%d", env.filerPort),
"-filer.port.grpc", fmt.Sprintf("%d", env.filerGrpcPort),
"-s3.port", fmt.Sprintf("%d", env.s3Port),
"-s3.port.grpc", fmt.Sprintf("%d", env.s3GrpcPort),
"-s3.config", iamConfigPath,
"-s3.iam.config", iamConfigPath,
"-s3.iam.readOnly=false",
"-ip", env.bindIP,
"-ip.bind", "0.0.0.0",
"-dir", env.dataDir,
)
cmd.Dir = env.dataDir
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
t.Fatalf("Failed to start SeaweedFS: %v", err)
}
env.weedProcess = cmd
if !testutil.WaitForService(fmt.Sprintf("http://localhost:%d/status", env.s3Port), 30*time.Second) {
t.Fatalf("S3 API failed to become ready")
}
}
func (env *TestEnvironment) StartPolaris(t *testing.T) {
t.Helper()
containerName := fmt.Sprintf("seaweed-polaris-%d", time.Now().UnixNano())
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
defer cancel()
cmd := exec.CommandContext(ctx, "docker", "run", "-d", "--rm",
"--name", containerName,
"--add-host", "host.docker.internal:host-gateway",
"-p", fmt.Sprintf("%d:8181", env.polarisPort),
"-p", fmt.Sprintf("%d:8182", env.polarisAdminPort),
"-e", fmt.Sprintf("AWS_REGION=%s", polarisRegion),
"-e", fmt.Sprintf("AWS_ACCESS_KEY_ID=%s", env.accessKey),
"-e", fmt.Sprintf("AWS_SECRET_ACCESS_KEY=%s", env.secretKey),
"-e", fmt.Sprintf("POLARIS_BOOTSTRAP_CREDENTIALS=%s,%s,%s", polarisRealm, polarisRootClientID, polarisRootClientSecret),
"-e", fmt.Sprintf("polaris.realm-context.realms=%s", polarisRealm),
"-e", "quarkus.otel.sdk.disabled=true",
polarisImage,
)
output, err := cmd.CombinedOutput()
if err != nil {
if ctx.Err() == context.DeadlineExceeded {
t.Fatalf("Timed out waiting for Polaris container: %v\nOutput:\n%s", ctx.Err(), string(output))
}
t.Fatalf("Failed to start Polaris: %v\nOutput:\n%s", err, string(output))
}
env.polarisContainer = containerName
if !testutil.WaitForService(fmt.Sprintf("http://localhost:%d/q/health", env.polarisAdminPort), 60*time.Second) {
logCtx, logCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer logCancel()
logs, _ := exec.CommandContext(logCtx, "docker", "logs", env.polarisContainer).CombinedOutput()
t.Fatalf("Polaris failed to become ready\nLogs:\n%s", string(logs))
}
}
func (env *TestEnvironment) Cleanup(t *testing.T) {
t.Helper()
if env.weedCancel != nil {
env.weedCancel()
}
if env.weedProcess != nil {
time.Sleep(1 * time.Second)
_ = env.weedProcess.Wait()
}
if env.polarisContainer != "" {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
_ = exec.CommandContext(ctx, "docker", "rm", "-f", env.polarisContainer).Run()
}
if env.dataDir != "" {
_ = os.RemoveAll(env.dataDir)
}
}
func (env *TestEnvironment) polarisEndpoint() string {
return fmt.Sprintf("http://127.0.0.1:%d", env.polarisPort)
}
func (env *TestEnvironment) s3Endpoint() string {
return fmt.Sprintf("http://127.0.0.1:%d", env.s3Port)
}
func (env *TestEnvironment) s3InternalEndpoint() string {
return fmt.Sprintf("http://host.docker.internal:%d", env.s3Port)
}
type polarisHTTPClient struct {
baseURL string
realm string
token string
httpClient *http.Client
}
func newPolarisHTTPClient(baseURL, realm, token string) *polarisHTTPClient {
return &polarisHTTPClient{
baseURL: baseURL,
realm: realm,
token: token,
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
}
}
func (c *polarisHTTPClient) doJSON(ctx context.Context, method, path string, body interface{}, out interface{}) error {
return c.doJSONWithHeaders(ctx, method, path, body, out, nil)
}
func (c *polarisHTTPClient) doJSONWithHeaders(ctx context.Context, method, path string, body interface{}, out interface{}, headers map[string]string) error {
var reader io.Reader
if body != nil {
encoded, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("encode request body: %w", err)
}
reader = bytes.NewReader(encoded)
}
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, reader)
if err != nil {
return fmt.Errorf("create request: %w", err)
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
req.Header.Set("Accept", "application/json")
if c.realm != "" {
req.Header.Set("Polaris-Realm", c.realm)
}
if c.token != "" {
req.Header.Set("Authorization", "Bearer "+c.token)
}
for key, value := range headers {
req.Header.Set(key, value)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
bodyBytes, readErr := io.ReadAll(resp.Body)
if readErr != nil {
return fmt.Errorf("request failed with status %d and could not read response body: %w", resp.StatusCode, readErr)
}
return fmt.Errorf("request failed with status %d: %s", resp.StatusCode, strings.TrimSpace(string(bodyBytes)))
}
if out == nil {
return nil
}
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
return fmt.Errorf("decode response: %w", err)
}
return nil
}
func newS3Client(ctx context.Context, endpoint, region string, creds aws.Credentials, pathStyle bool) (*s3.Client, error) {
resolver := aws.EndpointResolverWithOptionsFunc(func(service, region string, options ...interface{}) (aws.Endpoint, error) {
if service == s3.ServiceID {
return aws.Endpoint{
URL: endpoint,
SigningRegion: region,
HostnameImmutable: true,
}, nil
}
return aws.Endpoint{}, &aws.EndpointNotFoundError{}
})
cfg, err := config.LoadDefaultConfig(ctx,
config.WithRegion(region),
config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(creds.AccessKeyID, creds.SecretAccessKey, creds.SessionToken)),
config.WithEndpointResolverWithOptions(resolver),
)
if err != nil {
return nil, err
}
return s3.NewFromConfig(cfg, func(o *s3.Options) {
o.UsePathStyle = pathStyle
}), nil
}

View File

@@ -0,0 +1,764 @@
package polaris
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"testing"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/s3"
s3types "github.com/aws/aws-sdk-go-v2/service/s3/types"
)
type polarisSession struct {
catalogName string
bucketName string
token string
baseLocation string
}
type polarisTableSetup struct {
namespace string
table string
dataKeyPrefix string
s3Client *s3.Client
}
type polarisCatalogClient struct {
http *polarisHTTPClient
catalog string
}
type polarisCredentials struct {
ClientID string `json:"clientId"`
ClientSecret string `json:"clientSecret"`
}
type createPrincipalResponse struct {
Credentials polarisCredentials `json:"credentials"`
}
type createCatalogRequest struct {
Catalog polarisCatalog `json:"catalog"`
}
type polarisCatalog struct {
Name string `json:"name"`
Type string `json:"type"`
ReadOnly bool `json:"readOnly"`
Properties map[string]string `json:"properties"`
StorageConfigInfo polarisStorageConfig `json:"storageConfigInfo"`
}
type polarisStorageConfig struct {
StorageType string `json:"storageType"`
AllowedLocations []string `json:"allowedLocations"`
Endpoint string `json:"endpoint"`
EndpointInternal string `json:"endpointInternal"`
StsEndpoint string `json:"stsEndpoint"`
PathStyleAccess bool `json:"pathStyleAccess"`
RoleArn string `json:"roleArn"`
Region string `json:"region"`
}
type createNamespaceRequest struct {
Namespace []string `json:"namespace"`
}
type createTableRequest struct {
Name string `json:"name"`
Location string `json:"location"`
Schema icebergSchema `json:"schema"`
PartitionSpec icebergPartition `json:"partition-spec"`
SortOrder icebergSortOrder `json:"sort-order"`
Properties map[string]string `json:"properties"`
}
type icebergSchema struct {
Type string `json:"type"`
SchemaID int `json:"schema-id"`
Fields []icebergSchemaField `json:"fields"`
}
type icebergSchemaField struct {
ID int `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Required bool `json:"required"`
}
type icebergPartition struct {
SpecID int `json:"spec-id"`
Fields []icebergPartitionField `json:"fields"`
}
type icebergPartitionField struct {
SourceID int `json:"source-id"`
FieldID int `json:"field-id"`
Name string `json:"name"`
Transform string `json:"transform"`
}
type icebergSortOrder struct {
OrderID int `json:"order-id"`
Fields []icebergSortField `json:"fields"`
}
type icebergSortField struct {
SourceID int `json:"source-id"`
Direction string `json:"direction"`
NullOrder string `json:"null-order"`
}
type loadTableResponse struct {
Config map[string]string `json:"config"`
StorageCredentials []storageCredential `json:"storage-credentials"`
}
type loadCredentialsResponse struct {
StorageCredentials []storageCredential `json:"storage-credentials"`
}
type storageCredential struct {
Prefix string `json:"prefix"`
Config map[string]string `json:"config"`
}
func bootstrapPolarisTest(t *testing.T, env *TestEnvironment) (context.Context, context.CancelFunc, polarisSession, *polarisTableSetup, func()) {
t.Helper()
t.Logf(">>> Starting SeaweedFS with Polaris configuration...")
env.StartSeaweedFS(t)
t.Logf(">>> SeaweedFS started.")
t.Logf(">>> Starting Polaris...")
env.StartPolaris(t)
t.Logf(">>> Polaris started.")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
session := newPolarisSession(t, ctx, env)
setup, cleanup := setupPolarisTable(t, ctx, env, session)
return ctx, cancel, session, setup, cleanup
}
func TestPolarisIntegration(t *testing.T) {
env := NewTestEnvironment(t)
defer env.Cleanup(t)
ctx, cancel, session, setup, cleanup := bootstrapPolarisTest(t, env)
defer cancel()
defer cleanup()
objectKey := fmt.Sprintf("%s/hello-%d.txt", setup.dataKeyPrefix, time.Now().UnixNano())
payload := []byte("polaris")
if _, err := setup.s3Client.PutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(session.bucketName),
Key: aws.String(objectKey),
Body: bytes.NewReader(payload),
}); err != nil {
t.Fatalf("PutObject failed: %v", err)
}
listObjects, err := setup.s3Client.ListObjectsV2(ctx, &s3.ListObjectsV2Input{
Bucket: aws.String(session.bucketName),
Prefix: aws.String(objectKey),
})
if err != nil {
t.Fatalf("ListObjectsV2 failed: %v", err)
}
found := false
for _, obj := range listObjects.Contents {
if aws.ToString(obj.Key) == objectKey {
found = true
break
}
}
if !found {
t.Fatalf("Object %s not found in list", objectKey)
}
getResp, err := setup.s3Client.GetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(session.bucketName),
Key: aws.String(objectKey),
})
if err != nil {
t.Fatalf("GetObject failed: %v", err)
}
body, err := io.ReadAll(getResp.Body)
_ = getResp.Body.Close()
if err != nil {
t.Fatalf("Read object body failed: %v", err)
}
if !bytes.Equal(body, payload) {
t.Fatalf("Unexpected object payload: got %q want %q", string(body), string(payload))
}
if _, err := setup.s3Client.DeleteObject(ctx, &s3.DeleteObjectInput{
Bucket: aws.String(session.bucketName),
Key: aws.String(objectKey),
}); err != nil {
t.Fatalf("DeleteObject failed: %v", err)
}
}
func TestPolarisTableIntegration(t *testing.T) {
env := NewTestEnvironment(t)
defer env.Cleanup(t)
ctx, cancel, session, setup, cleanup := bootstrapPolarisTest(t, env)
defer cancel()
defer cleanup()
objectKey := fmt.Sprintf("%s/part-%d.parquet", setup.dataKeyPrefix, time.Now().UnixNano())
createResp, err := setup.s3Client.CreateMultipartUpload(ctx, &s3.CreateMultipartUploadInput{
Bucket: aws.String(session.bucketName),
Key: aws.String(objectKey),
})
if err != nil {
t.Fatalf("CreateMultipartUpload failed: %v", err)
}
uploadID := aws.ToString(createResp.UploadId)
multipartCompleted := false
defer func() {
if uploadID == "" || multipartCompleted {
return
}
_, _ = setup.s3Client.AbortMultipartUpload(ctx, &s3.AbortMultipartUploadInput{
Bucket: aws.String(session.bucketName),
Key: aws.String(objectKey),
UploadId: aws.String(uploadID),
})
}()
partSize := 5 * 1024 * 1024
part1 := bytes.Repeat([]byte("a"), partSize)
part2 := bytes.Repeat([]byte("b"), 1024*1024)
part1Resp, err := setup.s3Client.UploadPart(ctx, &s3.UploadPartInput{
Bucket: aws.String(session.bucketName),
Key: aws.String(objectKey),
UploadId: aws.String(uploadID),
PartNumber: aws.Int32(1),
Body: bytes.NewReader(part1),
})
if err != nil {
t.Fatalf("UploadPart 1 failed: %v", err)
}
part2Resp, err := setup.s3Client.UploadPart(ctx, &s3.UploadPartInput{
Bucket: aws.String(session.bucketName),
Key: aws.String(objectKey),
UploadId: aws.String(uploadID),
PartNumber: aws.Int32(2),
Body: bytes.NewReader(part2),
})
if err != nil {
t.Fatalf("UploadPart 2 failed: %v", err)
}
_, err = setup.s3Client.CompleteMultipartUpload(ctx, &s3.CompleteMultipartUploadInput{
Bucket: aws.String(session.bucketName),
Key: aws.String(objectKey),
UploadId: aws.String(uploadID),
MultipartUpload: &s3types.CompletedMultipartUpload{
Parts: []s3types.CompletedPart{
{
ETag: part1Resp.ETag,
PartNumber: aws.Int32(1),
},
{
ETag: part2Resp.ETag,
PartNumber: aws.Int32(2),
},
},
},
})
if err != nil {
t.Fatalf("CompleteMultipartUpload failed: %v", err)
}
multipartCompleted = true
headResp, err := setup.s3Client.HeadObject(ctx, &s3.HeadObjectInput{
Bucket: aws.String(session.bucketName),
Key: aws.String(objectKey),
})
if err != nil {
t.Fatalf("HeadObject after multipart upload failed: %v", err)
}
expectedSize := int64(len(part1) + len(part2))
if headResp.ContentLength == nil || *headResp.ContentLength != expectedSize {
t.Fatalf("Unexpected content length: got %d want %d", aws.ToInt64(headResp.ContentLength), expectedSize)
}
}
func newPolarisSession(t *testing.T, ctx context.Context, env *TestEnvironment) polarisSession {
t.Helper()
adminCreds := aws.Credentials{
AccessKeyID: env.accessKey,
SecretAccessKey: env.secretKey,
Source: "polaris-admin",
}
adminS3, err := newS3Client(ctx, env.s3Endpoint(), polarisRegion, adminCreds, true)
if err != nil {
t.Fatalf("Create admin S3 client failed: %v", err)
}
bucketName := fmt.Sprintf("polaris-bucket-%d", time.Now().UnixNano())
if _, err := adminS3.CreateBucket(ctx, &s3.CreateBucketInput{
Bucket: aws.String(bucketName),
}); err != nil {
t.Fatalf("CreateBucket failed: %v", err)
}
policyDoc := map[string]interface{}{
"Version": "2012-10-17",
"Statement": []map[string]interface{}{
{
"Sid": "AllowPolarisVendedAccess",
"Effect": "Allow",
"Principal": "*",
"Action": "s3:*",
"Resource": []string{
fmt.Sprintf("arn:aws:s3:::%s", bucketName),
fmt.Sprintf("arn:aws:s3:::%s/polaris/*", bucketName),
},
},
},
}
policyBytes, err := json.Marshal(policyDoc)
if err != nil {
t.Fatalf("Failed to marshal bucket policy: %v", err)
}
if _, err := adminS3.PutBucketPolicy(ctx, &s3.PutBucketPolicyInput{
Bucket: aws.String(bucketName),
Policy: aws.String(string(policyBytes)),
}); err != nil {
t.Fatalf("PutBucketPolicy failed: %v", err)
}
rootToken, err := fetchPolarisToken(ctx, env.polarisEndpoint(), polarisRootClientID, polarisRootClientSecret)
if err != nil {
t.Fatalf("Polaris root token request failed: %v", err)
}
managementClient := newPolarisHTTPClient(env.polarisEndpoint(), polarisRealm, rootToken)
catalogName := fmt.Sprintf("polaris_catalog_%d", time.Now().UnixNano())
baseLocation := fmt.Sprintf("s3://%s/polaris", bucketName)
catalogRequest := createCatalogRequest{
Catalog: polarisCatalog{
Name: catalogName,
Type: "INTERNAL",
ReadOnly: false,
Properties: map[string]string{
"default-base-location": baseLocation,
},
StorageConfigInfo: polarisStorageConfig{
StorageType: "S3",
AllowedLocations: []string{baseLocation},
Endpoint: env.s3Endpoint(),
EndpointInternal: env.s3InternalEndpoint(),
StsEndpoint: env.s3InternalEndpoint(),
PathStyleAccess: true,
RoleArn: polarisRoleArn,
Region: polarisRegion,
},
},
}
if err := managementClient.doJSON(ctx, http.MethodPost, "/api/management/v1/catalogs", catalogRequest, nil); err != nil {
t.Fatalf("Create catalog failed: %v", err)
}
principalName := fmt.Sprintf("polaris_user_%d", time.Now().UnixNano())
principalRoleName := fmt.Sprintf("polaris_principal_role_%d", time.Now().UnixNano())
catalogRoleName := fmt.Sprintf("polaris_catalog_role_%d", time.Now().UnixNano())
var principalResp createPrincipalResponse
if err := managementClient.doJSON(ctx, http.MethodPost, "/api/management/v1/principals", map[string]interface{}{
"principal": map[string]interface{}{
"name": principalName,
"properties": map[string]string{},
},
}, &principalResp); err != nil {
t.Fatalf("Create principal failed: %v", err)
}
if principalResp.Credentials.ClientID == "" || principalResp.Credentials.ClientSecret == "" {
t.Fatalf("Missing principal credentials in response")
}
if err := managementClient.doJSON(ctx, http.MethodPost, "/api/management/v1/principal-roles", map[string]interface{}{
"principalRole": map[string]interface{}{
"name": principalRoleName,
"properties": map[string]string{},
},
}, nil); err != nil {
t.Fatalf("Create principal role failed: %v", err)
}
if err := managementClient.doJSON(ctx, http.MethodPost, fmt.Sprintf("/api/management/v1/catalogs/%s/catalog-roles", url.PathEscape(catalogName)), map[string]interface{}{
"catalogRole": map[string]interface{}{
"name": catalogRoleName,
"properties": map[string]string{},
},
}, nil); err != nil {
t.Fatalf("Create catalog role failed: %v", err)
}
if err := managementClient.doJSON(ctx, http.MethodPut, fmt.Sprintf("/api/management/v1/principals/%s/principal-roles", url.PathEscape(principalName)), map[string]interface{}{
"principalRole": map[string]interface{}{
"name": principalRoleName,
},
}, nil); err != nil {
t.Fatalf("Assign principal role failed: %v", err)
}
if err := managementClient.doJSON(ctx, http.MethodPut, fmt.Sprintf("/api/management/v1/principal-roles/%s/catalog-roles/%s", url.PathEscape(principalRoleName), url.PathEscape(catalogName)), map[string]interface{}{
"catalogRole": map[string]interface{}{
"name": catalogRoleName,
},
}, nil); err != nil {
t.Fatalf("Assign catalog role failed: %v", err)
}
if err := managementClient.doJSON(ctx, http.MethodPut, fmt.Sprintf("/api/management/v1/catalogs/%s/catalog-roles/%s/grants", url.PathEscape(catalogName), url.PathEscape(catalogRoleName)), map[string]interface{}{
"type": "catalog",
"privilege": "CATALOG_MANAGE_CONTENT",
}, nil); err != nil {
t.Fatalf("Grant catalog privilege failed: %v", err)
}
userToken, err := fetchPolarisToken(ctx, env.polarisEndpoint(), principalResp.Credentials.ClientID, principalResp.Credentials.ClientSecret)
if err != nil {
t.Fatalf("Polaris user token request failed: %v", err)
}
return polarisSession{
catalogName: catalogName,
bucketName: bucketName,
token: userToken,
baseLocation: baseLocation,
}
}
func setupPolarisTable(t *testing.T, ctx context.Context, env *TestEnvironment, session polarisSession) (*polarisTableSetup, func()) {
t.Helper()
catalogClient := newPolarisCatalogClient(env.polarisEndpoint(), polarisRealm, session.token, session.catalogName)
namespace := fmt.Sprintf("polaris_ns_%d", time.Now().UnixNano())
table := fmt.Sprintf("polaris_table_%d", time.Now().UnixNano())
if err := catalogClient.CreateNamespace(ctx, namespace); err != nil {
t.Fatalf("CreateNamespace failed: %v", err)
}
location := fmt.Sprintf("%s/%s/%s", session.baseLocation, namespace, table)
if err := catalogClient.CreateTable(ctx, namespace, table, location); err != nil {
t.Fatalf("CreateTable failed: %v", err)
}
loadResp, err := catalogClient.LoadTable(ctx, namespace, table)
if err != nil {
t.Fatalf("LoadTable failed: %v", err)
}
credsResp, err := catalogClient.LoadCredentials(ctx, namespace, table)
if err != nil {
t.Fatalf("LoadCredentials failed: %v", err)
}
if len(credsResp.StorageCredentials) == 0 {
t.Fatalf("LoadCredentials returned no storage credentials")
}
credentialSource := &loadTableResponse{
Config: loadResp.Config,
StorageCredentials: credsResp.StorageCredentials,
}
dataPrefix := location + "/data"
creds, endpoint, region, pathStyle, err := extractS3Credentials(credentialSource, dataPrefix, env.s3Endpoint(), polarisRegion)
if err != nil {
t.Fatalf("Extract vended credentials failed: %v", err)
}
dataKeyPrefix, err := s3URIToKeyPrefix(dataPrefix, session.bucketName)
if err != nil {
t.Fatalf("Invalid data prefix %s: %v", dataPrefix, err)
}
s3Client, err := newS3Client(ctx, endpoint, region, creds, pathStyle)
if err != nil {
t.Fatalf("Create vended S3 client failed: %v", err)
}
cleanup := func() {
cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cleanupCancel()
if err := catalogClient.DeleteTable(cleanupCtx, namespace, table); err != nil {
t.Logf("DeleteTable failed: %v", err)
}
if err := catalogClient.DeleteNamespace(cleanupCtx, namespace); err != nil {
t.Logf("DeleteNamespace failed: %v", err)
}
}
return &polarisTableSetup{
namespace: namespace,
table: table,
dataKeyPrefix: dataKeyPrefix,
s3Client: s3Client,
}, cleanup
}
func fetchPolarisToken(ctx context.Context, baseURL, clientID, clientSecret string) (string, error) {
form := url.Values{}
form.Set("grant_type", "client_credentials")
form.Set("client_id", clientID)
form.Set("client_secret", clientSecret)
form.Set("scope", "PRINCIPAL_ROLE:ALL")
req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+"/api/catalog/v1/oauth/tokens", strings.NewReader(form.Encode()))
if err != nil {
return "", fmt.Errorf("create token request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("token request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, readErr := io.ReadAll(resp.Body)
if readErr != nil {
return "", fmt.Errorf("token request failed with status %d and reading body: %w", resp.StatusCode, readErr)
}
return "", fmt.Errorf("token request failed with status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
var tokenResp struct {
AccessToken string `json:"access_token"`
}
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
return "", fmt.Errorf("decode token response: %w", err)
}
if tokenResp.AccessToken == "" {
return "", fmt.Errorf("missing access token in response")
}
return tokenResp.AccessToken, nil
}
func newPolarisCatalogClient(baseURL, realm, token, catalog string) *polarisCatalogClient {
return &polarisCatalogClient{
http: newPolarisHTTPClient(baseURL, realm, token),
catalog: catalog,
}
}
func (c *polarisCatalogClient) CreateNamespace(ctx context.Context, namespace string) error {
path := fmt.Sprintf("/api/catalog/v1/%s/namespaces", url.PathEscape(c.catalog))
req := createNamespaceRequest{Namespace: []string{namespace}}
return c.http.doJSON(ctx, http.MethodPost, path, req, nil)
}
func (c *polarisCatalogClient) DeleteNamespace(ctx context.Context, namespace string) error {
path := fmt.Sprintf("/api/catalog/v1/%s/namespaces/%s", url.PathEscape(c.catalog), url.PathEscape(namespace))
return c.http.doJSON(ctx, http.MethodDelete, path, nil, nil)
}
func (c *polarisCatalogClient) CreateTable(ctx context.Context, namespace, table, location string) error {
path := fmt.Sprintf("/api/catalog/v1/%s/namespaces/%s/tables", url.PathEscape(c.catalog), url.PathEscape(namespace))
req := createTableRequest{
Name: table,
Location: location,
Schema: icebergSchema{
Type: "struct",
SchemaID: 0,
Fields: []icebergSchemaField{
{
ID: 1,
Name: "id",
Type: "long",
Required: false,
},
},
},
PartitionSpec: icebergPartition{
SpecID: 0,
Fields: []icebergPartitionField{},
},
SortOrder: icebergSortOrder{
OrderID: 0,
Fields: []icebergSortField{},
},
Properties: map[string]string{
"format-version": "2",
},
}
return c.http.doJSON(ctx, http.MethodPost, path, req, nil)
}
func (c *polarisCatalogClient) DeleteTable(ctx context.Context, namespace, table string) error {
path := fmt.Sprintf("/api/catalog/v1/%s/namespaces/%s/tables/%s", url.PathEscape(c.catalog), url.PathEscape(namespace), url.PathEscape(table))
return c.http.doJSON(ctx, http.MethodDelete, path, nil, nil)
}
func (c *polarisCatalogClient) LoadTable(ctx context.Context, namespace, table string) (*loadTableResponse, error) {
path := fmt.Sprintf("/api/catalog/v1/%s/namespaces/%s/tables/%s", url.PathEscape(c.catalog), url.PathEscape(namespace), url.PathEscape(table))
var resp loadTableResponse
headers := map[string]string{
"X-Iceberg-Access-Delegation": "vended-credentials",
}
if err := c.http.doJSONWithHeaders(ctx, http.MethodGet, path, nil, &resp, headers); err != nil {
return nil, err
}
return &resp, nil
}
func (c *polarisCatalogClient) LoadCredentials(ctx context.Context, namespace, table string) (*loadCredentialsResponse, error) {
path := fmt.Sprintf("/api/catalog/v1/%s/namespaces/%s/tables/%s/credentials", url.PathEscape(c.catalog), url.PathEscape(namespace), url.PathEscape(table))
var resp loadCredentialsResponse
if err := c.http.doJSON(ctx, http.MethodGet, path, nil, &resp); err != nil {
return nil, err
}
return &resp, nil
}
func extractS3Credentials(load *loadTableResponse, targetPrefix, fallbackEndpoint, fallbackRegion string) (aws.Credentials, string, string, bool, error) {
credentialConfig, err := selectStorageConfig(load, targetPrefix)
if err != nil {
return aws.Credentials{}, "", "", false, err
}
lookupConfig := func(key string) string {
if load != nil && load.Config != nil {
if val, ok := load.Config[key]; ok && strings.TrimSpace(val) != "" {
return strings.TrimSpace(val)
}
}
if val, ok := credentialConfig[key]; ok && strings.TrimSpace(val) != "" {
return strings.TrimSpace(val)
}
return ""
}
accessKey := strings.TrimSpace(credentialConfig["s3.access-key-id"])
secretKey := strings.TrimSpace(credentialConfig["s3.secret-access-key"])
sessionToken := strings.TrimSpace(credentialConfig["s3.session-token"])
if accessKey == "" || secretKey == "" {
return aws.Credentials{}, "", "", false, fmt.Errorf("missing s3.access-key-id or s3.secret-access-key in selected storage credential")
}
endpoint := lookupConfig("s3.endpoint")
if endpoint == "" {
endpoint = fallbackEndpoint
}
if endpoint != "" && !strings.HasPrefix(endpoint, "http") {
endpoint = "http://" + endpoint
}
region := lookupConfig("client.region")
if region == "" {
region = fallbackRegion
}
pathStyle := true
if value := lookupConfig("s3.path-style-access"); value != "" {
pathStyle = strings.EqualFold(value, "true")
}
return aws.Credentials{
AccessKeyID: accessKey,
SecretAccessKey: secretKey,
SessionToken: sessionToken,
Source: "polaris-vended",
}, endpoint, region, pathStyle, nil
}
func selectStorageConfig(load *loadTableResponse, targetPrefix string) (map[string]string, error) {
if load == nil {
return nil, fmt.Errorf("load table response is nil")
}
switch len(load.StorageCredentials) {
case 0:
if load.Config == nil {
return nil, fmt.Errorf("polaris returned no storage credentials or config")
}
return load.Config, nil
case 1:
cred := load.StorageCredentials[0]
if cred.Config == nil {
return nil, fmt.Errorf("storage credential for prefix %s returned nil config", cred.Prefix)
}
return cred.Config, nil
default:
if targetPrefix == "" {
return nil, fmt.Errorf("multiple storage credentials (%d) returned but no target prefix provided", len(load.StorageCredentials))
}
normalizedTarget := normalizePrefix(targetPrefix)
if normalizedTarget == "" {
return nil, fmt.Errorf("target prefix %q normalized to empty string", targetPrefix)
}
var bestConfig map[string]string
bestLen := -1
for _, cred := range load.StorageCredentials {
if cred.Config == nil {
continue
}
prefix := normalizePrefix(cred.Prefix)
if prefix == "" {
if bestLen < 0 {
bestLen = 0
bestConfig = cred.Config
}
continue
}
if normalizedTarget == prefix || strings.HasPrefix(normalizedTarget, prefix+"/") {
if len(prefix) > bestLen {
bestLen = len(prefix)
bestConfig = cred.Config
}
}
}
if bestConfig != nil {
return bestConfig, nil
}
return nil, fmt.Errorf("none of the %d storage credentials matched prefix %s", len(load.StorageCredentials), targetPrefix)
}
}
func normalizePrefix(prefix string) string {
p := strings.TrimSpace(prefix)
p = strings.TrimSuffix(p, "/")
return p
}
func s3URIToKeyPrefix(uri, bucket string) (string, error) {
prefix := "s3://" + bucket + "/"
if !strings.HasPrefix(uri, prefix) {
return "", fmt.Errorf("uri %q does not match bucket %q", uri, bucket)
}
keyPrefix := strings.TrimPrefix(uri, prefix)
keyPrefix = strings.TrimPrefix(keyPrefix, "/")
if keyPrefix == "" {
return "", fmt.Errorf("empty key prefix in uri %q", uri)
}
return keyPrefix, nil
}

View File

@@ -0,0 +1,587 @@
package sts_integration
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/test/testutil"
)
// TestEnvironment mirrors the one in trino_catalog_test.go but simplified
type TestEnvironment struct {
seaweedDir string
weedBinary string
dataDir string
bindIP string
s3Port int
s3GrpcPort int
masterPort int
masterGrpcPort int
filerPort int
filerGrpcPort int
volumePort int
volumeGrpcPort int
weedProcess *exec.Cmd
weedCancel context.CancelFunc
dockerAvailable bool
accessKey string
secretKey string
}
const testSTSIntegrationSigningKey = "dGVzdC1zaWduaW5nLWtleS1mb3Itc3RzLWludGVncmF0aW9uLXRlc3Rz" // gitleaks:allow - test-signing-key-for-sts-integration-tests
func TestSTSIntegration(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
env := NewTestEnvironment(t)
defer env.Cleanup(t)
if !env.dockerAvailable {
t.Skip("Docker not available, skipping STS integration test")
}
fmt.Printf(">>> Starting SeaweedFS...\n")
env.StartSeaweedFS(t)
fmt.Printf(">>> SeaweedFS started.\n")
// Run python script in docker to test STS
runPythonSTSClient(t, env)
}
// TestSTSAssumeRoleAuthorization covers the AssumeRole authorization model:
// a non-admin caller may assume a role its trust policy admits, an explicit
// identity-side deny blocks it, and a session policy's explicit deny blocks
// role chaining.
func TestSTSAssumeRoleAuthorization(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
env := NewTestEnvironment(t)
defer env.Cleanup(t)
if !env.dockerAvailable {
t.Skip("Docker not available, skipping STS integration test")
}
fmt.Printf(">>> Starting SeaweedFS...\n")
env.StartSeaweedFS(t)
fmt.Printf(">>> SeaweedFS started.\n")
runPythonAuthzClient(t, env)
}
func NewTestEnvironment(t *testing.T) *TestEnvironment {
t.Helper()
wd, err := os.Getwd()
if err != nil {
t.Fatalf("Failed to get working directory: %v", err)
}
seaweedDir := wd
for i := 0; i < 6; i++ {
if _, err := os.Stat(filepath.Join(seaweedDir, "go.mod")); err == nil {
break
}
seaweedDir = filepath.Dir(seaweedDir)
}
weedBinary := filepath.Join(seaweedDir, "weed", "weed")
info, err := os.Stat(weedBinary)
if err != nil || info.IsDir() {
weedBinary = "weed"
if _, err := exec.LookPath(weedBinary); err != nil {
t.Skip("weed binary not found, skipping integration test")
}
}
if !testutil.HasDocker() {
t.Skip("Docker not available, skipping integration test")
}
// Create a unique temporary directory for this test run
dataDir, err := os.MkdirTemp("", "seaweed-sts-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
// The Cleanup method will remove this directory, so no need for defer here.
bindIP := testutil.FindBindIP()
// Allocate all ports in a single batch to prevent the OS from recycling
// a released port, which can cause two services to get the same port.
ports := testutil.MustAllocatePorts(t, 8)
masterPort, masterGrpcPort := ports[0], ports[1]
volumePort, volumeGrpcPort := ports[2], ports[3]
filerPort, filerGrpcPort := ports[4], ports[5]
s3Port, s3GrpcPort := ports[6], ports[7]
return &TestEnvironment{
seaweedDir: seaweedDir,
weedBinary: weedBinary,
dataDir: dataDir,
bindIP: bindIP,
s3Port: s3Port,
s3GrpcPort: s3GrpcPort,
masterPort: masterPort,
masterGrpcPort: masterGrpcPort,
filerPort: filerPort,
filerGrpcPort: filerGrpcPort,
volumePort: volumePort,
volumeGrpcPort: volumeGrpcPort,
dockerAvailable: testutil.HasDocker(),
accessKey: "admin",
secretKey: "adminadmin",
}
}
func (env *TestEnvironment) StartSeaweedFS(t *testing.T) {
t.Helper()
iamConfigPath := filepath.Join(env.dataDir, "iam.json")
// Note: signingKey must be base64 encoded for []byte JSON unmarshaling
iamConfig := fmt.Sprintf(`{
"identities": [
{
"name": "admin",
"credentials": [
{ "accessKey": "%s", "secretKey": "%s" }
],
"actions": ["Admin", "Read", "Write", "List", "Tagging"]
},
{
"name": "nonadmin",
"credentials": [
{ "accessKey": "nonadmin_key", "secretKey": "nonadmin_secret" }
],
"actions": ["Read", "Write", "List", "Tagging"]
},
{
"name": "denied",
"credentials": [
{ "accessKey": "denied_key", "secretKey": "denied_secret" }
],
"actions": ["Read", "Write", "List", "Tagging"],
"policyNames": ["DenyAssumeRole"]
}
],
"sts": {
"tokenDuration": "1h",
"maxSessionLength": "12h",
"issuer": "seaweedfs-sts",
"signingKey": "%s"
},
"policy": {
"defaultEffect": "Deny",
"storeType": "memory"
},
"policies": [
{
"name": "S3FullAccessPolicy",
"document": {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:*"],
"Resource": ["*"]
}
]
}
},
{
"name": "DenyAssumeRole",
"document": {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Deny",
"Action": ["sts:AssumeRole"],
"Resource": ["*"]
}
]
}
}
],
"roles": [
{
"roleName": "TestRole",
"roleArn": "arn:aws:iam::role/TestRole",
"attachedPolicies": ["S3FullAccessPolicy"],
"trustPolicy": {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": "*",
"Action": ["sts:AssumeRole"]
}
]
}
}
]
}`, env.accessKey, env.secretKey, testSTSIntegrationSigningKey)
if err := os.WriteFile(iamConfigPath, []byte(iamConfig), 0644); err != nil {
t.Fatalf("Failed to create IAM config: %v", err)
}
// Create empty security.toml
securityToml := filepath.Join(env.dataDir, "security.toml")
if err := os.WriteFile(securityToml, []byte("# Empty security config for testing\n"), 0644); err != nil {
t.Fatalf("Failed to create security.toml: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
env.weedCancel = cancel
cmd := exec.CommandContext(ctx, env.weedBinary, "mini",
"-master.port", fmt.Sprintf("%d", env.masterPort),
"-master.port.grpc", fmt.Sprintf("%d", env.masterGrpcPort),
"-volume.port", fmt.Sprintf("%d", env.volumePort),
"-volume.port.grpc", fmt.Sprintf("%d", env.volumeGrpcPort),
"-filer.port", fmt.Sprintf("%d", env.filerPort),
"-filer.port.grpc", fmt.Sprintf("%d", env.filerGrpcPort),
"-s3.port", fmt.Sprintf("%d", env.s3Port),
"-s3.port.grpc", fmt.Sprintf("%d", env.s3GrpcPort),
"-s3.config", iamConfigPath,
"-s3.iam.config", iamConfigPath,
"-s3.iam.readOnly", "false",
"-ip", env.bindIP,
"-ip.bind", "0.0.0.0",
"-dir", env.dataDir,
)
cmd.Dir = env.dataDir
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
t.Fatalf("Failed to start SeaweedFS: %v", err)
}
env.weedProcess = cmd
// Wait for S3 API to be ready
if !testutil.WaitForService(fmt.Sprintf("http://localhost:%d/status", env.s3Port), 30*time.Second) {
t.Fatalf("S3 API failed to become ready")
}
}
func (env *TestEnvironment) Start(t *testing.T) {
if !testutil.HasDocker() {
t.Skip("Docker not available")
}
}
func (env *TestEnvironment) Cleanup(t *testing.T) {
t.Helper()
if env.weedCancel != nil {
env.weedCancel()
}
if env.weedProcess != nil {
time.Sleep(1 * time.Second)
_ = env.weedProcess.Wait()
}
if env.dataDir != "" {
_ = os.RemoveAll(env.dataDir)
}
}
func runPythonSTSClient(t *testing.T, env *TestEnvironment) {
t.Helper()
// Write python script to temp dir
scriptContent := fmt.Sprintf(`
import boto3
import botocore.config
from botocore.exceptions import ClientError
import json
import sys
import time
import urllib.error
import urllib.request
print("Starting STS inline session policy test...")
primary_endpoint = "http://host.docker.internal:%d"
fallback_endpoint = "http://%s:%d"
access_key = "%s"
secret_key = "%s"
region = "us-east-1"
try:
def wait_for_endpoint(url, timeout=30):
deadline = time.time() + timeout
while time.time() < deadline:
try:
with urllib.request.urlopen(url, timeout=2):
return True
except urllib.error.HTTPError:
return True
except Exception:
time.sleep(1)
return False
def select_endpoint(urls):
for url in urls:
if wait_for_endpoint(url):
return url
raise Exception("No reachable S3 endpoint from container")
endpoint_url = select_endpoint([primary_endpoint, fallback_endpoint])
print(f"Using endpoint {endpoint_url}")
config = botocore.config.Config(
retries={'max_attempts': 0},
s3={'addressing_style': 'path'}
)
admin_s3 = boto3.client(
's3',
endpoint_url=endpoint_url,
aws_access_key_id=access_key,
aws_secret_access_key=secret_key,
region_name=region,
config=config
)
bucket = f"sts-inline-policy-{int(time.time() * 1000)}"
key = "allowed.txt"
print(f"Creating bucket {bucket} with admin credentials")
admin_s3.create_bucket(Bucket=bucket)
admin_s3.put_object(Bucket=bucket, Key=key, Body=b"ok")
sts = boto3.client(
'sts',
endpoint_url=endpoint_url,
aws_access_key_id=access_key,
aws_secret_access_key=secret_key,
region_name=region,
config=config
)
role_arn = "arn:aws:iam::role/TestRole"
session_name = "test-session"
session_policy = json.dumps({
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:ListBucket"],
"Resource": [f"arn:aws:s3:::{bucket}"]
},
{
"Effect": "Allow",
"Action": ["s3:GetObject"],
"Resource": [f"arn:aws:s3:::{bucket}/*"]
}
]
})
print(f"Calling AssumeRole on {role_arn} with inline session policy")
response = sts.assume_role(
RoleArn=role_arn,
RoleSessionName=session_name,
Policy=session_policy
)
creds = response['Credentials']
vended_s3 = boto3.client(
's3',
endpoint_url=endpoint_url,
aws_access_key_id=creds['AccessKeyId'],
aws_secret_access_key=creds['SecretAccessKey'],
aws_session_token=creds['SessionToken'],
region_name=region,
config=config
)
print("Listing objects (allowed)")
list_resp = vended_s3.list_objects_v2(Bucket=bucket)
keys = [obj.get('Key') for obj in list_resp.get('Contents', [])]
if key not in keys:
print(f"FAILED: Expected to see {key} in list_objects_v2 results")
sys.exit(1)
print("Getting object (allowed)")
body = vended_s3.get_object(Bucket=bucket, Key=key)['Body'].read()
if body != b"ok":
print("FAILED: Unexpected object content")
sys.exit(1)
print("Putting object (expected to be denied)")
try:
vended_s3.put_object(Bucket=bucket, Key="denied.txt", Body=b"no")
print("FAILED: PutObject unexpectedly succeeded")
sys.exit(1)
except ClientError as e:
error_code = e.response.get('Error', {}).get('Code', '')
if error_code != 'AccessDenied':
print(f"FAILED: Expected AccessDenied, got {error_code}")
sys.exit(1)
print("PutObject correctly denied by inline session policy")
print("SUCCESS: Inline session policy downscoping verified")
sys.exit(0)
except Exception as e:
print(f"FAILED: {e}")
if hasattr(e, 'response'):
print(f"Response: {e.response}")
import traceback
traceback.print_exc()
sys.exit(1)
`, env.s3Port, env.bindIP, env.s3Port, env.accessKey, env.secretKey)
scriptPath := filepath.Join(env.dataDir, "sts_test.py")
if err := os.WriteFile(scriptPath, []byte(scriptContent), 0644); err != nil {
t.Fatalf("Failed to write python script: %v", err)
}
containerName := "seaweed-sts-client-" + fmt.Sprintf("%d", time.Now().UnixNano())
cmd := exec.Command("docker", "run", "--rm",
"--name", containerName,
"--add-host", "host.docker.internal:host-gateway",
"-v", fmt.Sprintf("%s:/work", env.dataDir),
"python:3",
"/bin/bash", "-c", "pip install boto3 && python /work/sts_test.py",
)
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("Python STS client failed: %v\nOutput:\n%s", err, string(output))
}
t.Logf("Python STS client output:\n%s", string(output))
}
func runPythonAuthzClient(t *testing.T, env *TestEnvironment) {
t.Helper()
scriptContent := fmt.Sprintf(`
import boto3
import botocore.config
from botocore.exceptions import ClientError
import json
import sys
import time
import urllib.error
import urllib.request
print("Starting STS AssumeRole authorization test...")
primary_endpoint = "http://host.docker.internal:%d"
fallback_endpoint = "http://%s:%d"
region = "us-east-1"
role_arn = "arn:aws:iam::role/TestRole"
def wait_for_endpoint(url, timeout=30):
deadline = time.time() + timeout
while time.time() < deadline:
try:
with urllib.request.urlopen(url, timeout=2):
return True
except urllib.error.HTTPError:
return True
except Exception:
time.sleep(1)
return False
def select_endpoint(urls):
for url in urls:
if wait_for_endpoint(url):
return url
raise Exception("No reachable S3 endpoint from container")
def fail(msg):
print("FAILED: " + msg)
sys.exit(1)
try:
endpoint_url = select_endpoint([primary_endpoint, fallback_endpoint])
print("Using endpoint " + endpoint_url)
config = botocore.config.Config(retries={'max_attempts': 0}, s3={'addressing_style': 'path'})
def sts_client(ak, sk, token=None):
return boto3.client('sts', endpoint_url=endpoint_url, aws_access_key_id=ak,
aws_secret_access_key=sk, aws_session_token=token,
region_name=region, config=config)
# 1) Non-admin caller may assume a role its trust policy admits, without the
# Admin action or an identity-side sts:AssumeRole grant.
print("1) non-admin AssumeRole (expect success)")
resp = sts_client("nonadmin_key", "nonadmin_secret").assume_role(
RoleArn=role_arn, RoleSessionName="nonadmin")
if not resp.get("Credentials", {}).get("AccessKeyId"):
fail("non-admin AssumeRole did not return credentials")
print(" ok")
# 2) A caller whose attached policy explicitly denies sts:AssumeRole is blocked,
# even though the trust policy admits it.
print("2) explicit identity deny (expect AccessDenied)")
try:
sts_client("denied_key", "denied_secret").assume_role(
RoleArn=role_arn, RoleSessionName="denied")
fail("explicit-deny caller unexpectedly assumed the role")
except ClientError as e:
code = e.response.get("Error", {}).get("Code", "")
if code != "AccessDenied":
fail("expected AccessDenied for identity deny, got " + code)
print(" ok")
# 3) A session whose inline policy denies sts:AssumeRole cannot chain into
# another role, even though the trust policy admits the session.
print("3) session-policy deny blocks role chaining (expect AccessDenied)")
deny_session = json.dumps({"Version": "2012-10-17", "Statement": [
{"Effect": "Deny", "Action": "sts:AssumeRole", "Resource": "*"}]})
hop1 = sts_client("nonadmin_key", "nonadmin_secret").assume_role(
RoleArn=role_arn, RoleSessionName="hop1", Policy=deny_session)["Credentials"]
chained = sts_client(hop1["AccessKeyId"], hop1["SecretAccessKey"], hop1["SessionToken"])
try:
chained.assume_role(RoleArn=role_arn, RoleSessionName="hop2")
fail("chained session unexpectedly assumed the role despite session-policy deny")
except ClientError as e:
code = e.response.get("Error", {}).get("Code", "")
if code != "AccessDenied":
fail("expected AccessDenied for session-policy deny, got " + code)
print(" ok")
print("SUCCESS: AssumeRole authorization scenarios verified")
sys.exit(0)
except Exception as e:
print("FAILED: " + str(e))
if hasattr(e, "response"):
print("Response: " + str(e.response))
import traceback
traceback.print_exc()
sys.exit(1)
`, env.s3Port, env.bindIP, env.s3Port)
scriptPath := filepath.Join(env.dataDir, "sts_authz_test.py")
if err := os.WriteFile(scriptPath, []byte(scriptContent), 0644); err != nil {
t.Fatalf("Failed to write python script: %v", err)
}
containerName := "seaweed-sts-authz-" + fmt.Sprintf("%d", time.Now().UnixNano())
cmd := exec.Command("docker", "run", "--rm",
"--name", containerName,
"--add-host", "host.docker.internal:host-gateway",
"-v", fmt.Sprintf("%s:/work", env.dataDir),
"python:3",
"/bin/bash", "-c", "pip install boto3 && python /work/sts_authz_test.py",
)
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("Python STS authz client failed: %v\nOutput:\n%s", err, string(output))
}
t.Logf("Python STS authz client output:\n%s", string(output))
}
// Helpers copied from trino_catalog_test.go

View File

@@ -0,0 +1,431 @@
package s3tables
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
)
func getFirstNamespace(namespace []string) (string, error) {
if len(namespace) == 0 {
return "", fmt.Errorf("namespace must not be empty")
}
return namespace[0], nil
}
func (c *S3TablesClient) doRestRequest(method, path string, body interface{}) (*http.Response, error) {
var bodyBytes []byte
var err error
if body != nil {
bodyBytes, err = json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("failed to marshal request body: %w", err)
}
}
req, err := http.NewRequest(method, c.endpoint+path, bytes.NewReader(bodyBytes))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
if body != nil {
req.Header.Set("Content-Type", "application/x-amz-json-1.1")
}
if err := c.signRequest(req, bodyBytes); err != nil {
return nil, err
}
return c.client.Do(req)
}
func (c *S3TablesClient) doTargetRequest(operation string, body interface{}) (*http.Response, error) {
var bodyBytes []byte
var err error
if body != nil {
bodyBytes, err = json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("failed to marshal request body: %w", err)
}
}
req, err := http.NewRequest(http.MethodPost, c.endpoint+"/", bytes.NewReader(bodyBytes))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.URL.RawPath = "/"
req.Header.Set("Content-Type", "application/x-amz-json-1.1")
req.Header.Set("X-Amz-Target", "S3Tables."+operation)
if err := c.signRequest(req, bodyBytes); err != nil {
return nil, err
}
return c.client.Do(req)
}
func (c *S3TablesClient) doTargetRequestAndDecode(operation string, reqBody interface{}, respBody interface{}) error {
resp, err := c.doTargetRequest(operation, reqBody)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, readErr := io.ReadAll(resp.Body)
if readErr != nil {
return fmt.Errorf("%s failed with status %d and could not read error response body: %v", operation, resp.StatusCode, readErr)
}
var errResp s3tables.S3TablesError
if err := json.Unmarshal(bodyBytes, &errResp); err != nil {
return fmt.Errorf("%s failed with status %d, could not decode error response: %v. Body: %s", operation, resp.StatusCode, err, string(bodyBytes))
}
return fmt.Errorf("%s failed: %s - %s", operation, errResp.Type, errResp.Message)
}
if respBody != nil {
if err := json.NewDecoder(resp.Body).Decode(respBody); err != nil {
return fmt.Errorf("failed to decode %s response: %w", operation, err)
}
}
return nil
}
func (c *S3TablesClient) signRequest(req *http.Request, body []byte) error {
creds := aws.Credentials{
AccessKeyID: c.accessKey,
SecretAccessKey: c.secretKey,
}
if req.Host == "" {
req.Host = req.URL.Host
}
req.Header.Set("Host", req.URL.Host)
payloadHash := sha256.Sum256(body)
return v4.NewSigner().SignHTTP(context.Background(), creds, req, hex.EncodeToString(payloadHash[:]), "s3tables", c.region, time.Now())
}
func (c *S3TablesClient) doRestRequestAndDecode(operation, method, path string, reqBody interface{}, respBody interface{}) error {
resp, err := c.doRestRequest(method, path, reqBody)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, readErr := io.ReadAll(resp.Body)
if readErr != nil {
return fmt.Errorf("%s failed with status %d and could not read error response body: %v", operation, resp.StatusCode, readErr)
}
var errResp s3tables.S3TablesError
if err := json.Unmarshal(bodyBytes, &errResp); err != nil {
return fmt.Errorf("%s failed with status %d, could not decode error response: %v. Body: %s", operation, resp.StatusCode, err, string(bodyBytes))
}
return fmt.Errorf("%s failed: %s - %s", operation, errResp.Type, errResp.Message)
}
if respBody != nil {
if err := json.NewDecoder(resp.Body).Decode(respBody); err != nil {
return fmt.Errorf("failed to decode %s response: %w", operation, err)
}
}
return nil
}
// Table Bucket operations
func (c *S3TablesClient) CreateTableBucket(name string, tags map[string]string) (*s3tables.CreateTableBucketResponse, error) {
req := &s3tables.CreateTableBucketRequest{
Name: name,
Tags: tags,
}
var result s3tables.CreateTableBucketResponse
if err := c.doRestRequestAndDecode("CreateTableBucket", http.MethodPut, "/buckets", req, &result); err != nil {
return nil, err
}
return &result, nil
}
func (c *S3TablesClient) GetTableBucket(arn string) (*s3tables.GetTableBucketResponse, error) {
path := "/buckets/" + url.PathEscape(arn)
var result s3tables.GetTableBucketResponse
if err := c.doRestRequestAndDecode("GetTableBucket", http.MethodGet, path, nil, &result); err != nil {
return nil, err
}
return &result, nil
}
func (c *S3TablesClient) ListTableBuckets(prefix, continuationToken string, maxBuckets int) (*s3tables.ListTableBucketsResponse, error) {
query := url.Values{}
if prefix != "" {
query.Set("prefix", prefix)
}
if continuationToken != "" {
query.Set("continuationToken", continuationToken)
}
if maxBuckets > 0 {
query.Set("maxBuckets", strconv.Itoa(maxBuckets))
}
path := "/buckets"
if encoded := query.Encode(); encoded != "" {
path = path + "?" + encoded
}
var result s3tables.ListTableBucketsResponse
if err := c.doRestRequestAndDecode("ListTableBuckets", http.MethodGet, path, nil, &result); err != nil {
return nil, err
}
return &result, nil
}
func (c *S3TablesClient) DeleteTableBucket(arn string) error {
path := "/buckets/" + url.PathEscape(arn)
return c.doRestRequestAndDecode("DeleteTableBucket", http.MethodDelete, path, nil, nil)
}
// Namespace operations
func (c *S3TablesClient) CreateNamespace(bucketARN string, namespace []string) (*s3tables.CreateNamespaceResponse, error) {
if len(namespace) == 0 {
return nil, fmt.Errorf("CreateNamespace requires namespace")
}
req := &s3tables.CreateNamespaceRequest{
Namespace: namespace,
}
path := "/namespaces/" + url.PathEscape(bucketARN)
var result s3tables.CreateNamespaceResponse
if err := c.doRestRequestAndDecode("CreateNamespace", http.MethodPut, path, req, &result); err != nil {
return nil, err
}
return &result, nil
}
func (c *S3TablesClient) GetNamespace(bucketARN string, namespace []string) (*s3tables.GetNamespaceResponse, error) {
name, err := getFirstNamespace(namespace)
if err != nil {
return nil, fmt.Errorf("GetNamespace requires namespace: %w", err)
}
path := "/namespaces/" + url.PathEscape(bucketARN) + "/" + url.PathEscape(name)
var result s3tables.GetNamespaceResponse
if err := c.doRestRequestAndDecode("GetNamespace", http.MethodGet, path, nil, &result); err != nil {
return nil, err
}
return &result, nil
}
func (c *S3TablesClient) ListNamespaces(bucketARN, prefix, continuationToken string, maxNamespaces int) (*s3tables.ListNamespacesResponse, error) {
query := url.Values{}
if prefix != "" {
query.Set("prefix", prefix)
}
if continuationToken != "" {
query.Set("continuationToken", continuationToken)
}
if maxNamespaces > 0 {
query.Set("maxNamespaces", strconv.Itoa(maxNamespaces))
}
path := "/namespaces/" + url.PathEscape(bucketARN)
if encoded := query.Encode(); encoded != "" {
path = path + "?" + encoded
}
var result s3tables.ListNamespacesResponse
if err := c.doRestRequestAndDecode("ListNamespaces", http.MethodGet, path, nil, &result); err != nil {
return nil, err
}
return &result, nil
}
func (c *S3TablesClient) DeleteNamespace(bucketARN string, namespace []string) error {
name, err := getFirstNamespace(namespace)
if err != nil {
return fmt.Errorf("DeleteNamespace requires namespace: %w", err)
}
path := "/namespaces/" + url.PathEscape(bucketARN) + "/" + url.PathEscape(name)
return c.doRestRequestAndDecode("DeleteNamespace", http.MethodDelete, path, nil, nil)
}
// Table operations
func (c *S3TablesClient) CreateTable(bucketARN string, namespace []string, name, format string, metadata *s3tables.TableMetadata, tags map[string]string) (*s3tables.CreateTableResponse, error) {
nameSpace, err := getFirstNamespace(namespace)
if err != nil {
return nil, fmt.Errorf("CreateTable requires namespace: %w", err)
}
req := &s3tables.CreateTableRequest{
Name: name,
Format: format,
Metadata: metadata,
Tags: tags,
}
path := "/tables/" + url.PathEscape(bucketARN) + "/" + url.PathEscape(nameSpace)
var result s3tables.CreateTableResponse
if err := c.doRestRequestAndDecode("CreateTable", http.MethodPut, path, req, &result); err != nil {
return nil, err
}
return &result, nil
}
func (c *S3TablesClient) GetTable(bucketARN string, namespace []string, name string) (*s3tables.GetTableResponse, error) {
nameSpace, err := getFirstNamespace(namespace)
if err != nil {
return nil, fmt.Errorf("GetTable requires namespace: %w", err)
}
query := url.Values{}
query.Set("tableBucketARN", bucketARN)
query.Set("namespace", nameSpace)
query.Set("name", name)
path := "/get-table?" + query.Encode()
var result s3tables.GetTableResponse
if err := c.doRestRequestAndDecode("GetTable", http.MethodGet, path, nil, &result); err != nil {
return nil, err
}
return &result, nil
}
func (c *S3TablesClient) ListTables(bucketARN string, namespace []string, prefix, continuationToken string, maxTables int) (*s3tables.ListTablesResponse, error) {
query := url.Values{}
if len(namespace) > 0 {
nameSpace, err := getFirstNamespace(namespace)
if err != nil {
return nil, fmt.Errorf("ListTables requires namespace: %w", err)
}
query.Set("namespace", nameSpace)
}
if prefix != "" {
query.Set("prefix", prefix)
}
if continuationToken != "" {
query.Set("continuationToken", continuationToken)
}
if maxTables > 0 {
query.Set("maxTables", strconv.Itoa(maxTables))
}
path := "/tables/" + url.PathEscape(bucketARN)
if encoded := query.Encode(); encoded != "" {
path = path + "?" + encoded
}
var result s3tables.ListTablesResponse
if err := c.doRestRequestAndDecode("ListTables", http.MethodGet, path, nil, &result); err != nil {
return nil, err
}
return &result, nil
}
func (c *S3TablesClient) DeleteTable(bucketARN string, namespace []string, name string) error {
nameSpace, err := getFirstNamespace(namespace)
if err != nil {
return fmt.Errorf("DeleteTable requires namespace: %w", err)
}
path := "/tables/" + url.PathEscape(bucketARN) + "/" + url.PathEscape(nameSpace) + "/" + url.PathEscape(name)
return c.doRestRequestAndDecode("DeleteTable", http.MethodDelete, path, nil, nil)
}
// Policy operations
func (c *S3TablesClient) PutTableBucketPolicy(bucketARN, policy string) error {
req := &s3tables.PutTableBucketPolicyRequest{
ResourcePolicy: policy,
}
path := "/buckets/" + url.PathEscape(bucketARN) + "/policy"
return c.doRestRequestAndDecode("PutTableBucketPolicy", http.MethodPut, path, req, nil)
}
func (c *S3TablesClient) GetTableBucketPolicy(bucketARN string) (*s3tables.GetTableBucketPolicyResponse, error) {
path := "/buckets/" + url.PathEscape(bucketARN) + "/policy"
var result s3tables.GetTableBucketPolicyResponse
if err := c.doRestRequestAndDecode("GetTableBucketPolicy", http.MethodGet, path, nil, &result); err != nil {
return nil, err
}
return &result, nil
}
func (c *S3TablesClient) DeleteTableBucketPolicy(bucketARN string) error {
path := "/buckets/" + url.PathEscape(bucketARN) + "/policy"
return c.doRestRequestAndDecode("DeleteTableBucketPolicy", http.MethodDelete, path, nil, nil)
}
// Table Policy operations
func (c *S3TablesClient) PutTablePolicy(bucketARN string, namespace []string, name, policy string) error {
nameSpace, err := getFirstNamespace(namespace)
if err != nil {
return fmt.Errorf("PutTablePolicy requires namespace: %w", err)
}
req := &s3tables.PutTablePolicyRequest{
ResourcePolicy: policy,
}
path := "/tables/" + url.PathEscape(bucketARN) + "/" + url.PathEscape(nameSpace) + "/" + url.PathEscape(name) + "/policy"
return c.doRestRequestAndDecode("PutTablePolicy", http.MethodPut, path, req, nil)
}
func (c *S3TablesClient) GetTablePolicy(bucketARN string, namespace []string, name string) (*s3tables.GetTablePolicyResponse, error) {
nameSpace, err := getFirstNamespace(namespace)
if err != nil {
return nil, fmt.Errorf("GetTablePolicy requires namespace: %w", err)
}
path := "/tables/" + url.PathEscape(bucketARN) + "/" + url.PathEscape(nameSpace) + "/" + url.PathEscape(name) + "/policy"
var result s3tables.GetTablePolicyResponse
if err := c.doRestRequestAndDecode("GetTablePolicy", http.MethodGet, path, nil, &result); err != nil {
return nil, err
}
return &result, nil
}
func (c *S3TablesClient) DeleteTablePolicy(bucketARN string, namespace []string, name string) error {
nameSpace, err := getFirstNamespace(namespace)
if err != nil {
return fmt.Errorf("DeleteTablePolicy requires namespace: %w", err)
}
path := "/tables/" + url.PathEscape(bucketARN) + "/" + url.PathEscape(nameSpace) + "/" + url.PathEscape(name) + "/policy"
return c.doRestRequestAndDecode("DeleteTablePolicy", http.MethodDelete, path, nil, nil)
}
// Tagging operations
func (c *S3TablesClient) TagResource(resourceARN string, tags map[string]string) error {
req := &s3tables.TagResourceRequest{
Tags: tags,
}
path := "/tag/" + url.PathEscape(resourceARN)
return c.doRestRequestAndDecode("TagResource", http.MethodPost, path, req, nil)
}
func (c *S3TablesClient) ListTagsForResource(resourceARN string) (*s3tables.ListTagsForResourceResponse, error) {
path := "/tag/" + url.PathEscape(resourceARN)
var result s3tables.ListTagsForResourceResponse
if err := c.doRestRequestAndDecode("ListTagsForResource", http.MethodGet, path, nil, &result); err != nil {
return nil, err
}
return &result, nil
}
func (c *S3TablesClient) UntagResource(resourceARN string, tagKeys []string) error {
if len(tagKeys) == 0 {
return fmt.Errorf("tagKeys cannot be empty")
}
query := url.Values{}
for _, key := range tagKeys {
query.Add("tagKeys", key)
}
path := "/tag/" + url.PathEscape(resourceARN)
if encoded := query.Encode(); encoded != "" {
path = path + "?" + encoded
}
return c.doRestRequestAndDecode("UntagResource", http.MethodDelete, path, nil, nil)
}

View File

@@ -0,0 +1,802 @@
package s3tables
import (
"context"
"fmt"
"net/http"
"os"
"path/filepath"
"strconv"
"testing"
"time"
cryptorand "crypto/rand"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"flag"
"github.com/seaweedfs/seaweedfs/test/testutil"
"github.com/seaweedfs/seaweedfs/weed/command"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
)
// TestMain starts a single default weed mini cluster for the whole package and
// tears it down after all tests have completed. Tests that require a different
// cluster configuration (e.g. TestS3TablesCreateBucketIAMPolicy) start their
// own cluster independently.
func TestMain(m *testing.M) {
flag.Parse()
if testing.Short() {
// Tests self-skip with t.Skip when -short is set; no cluster needed.
os.Exit(m.Run())
}
// Create a temporary T-less context so we can use t.TempDir-equivalent.
testDir, err := os.MkdirTemp("", "seaweed-s3tables-shared-*")
if err != nil {
fmt.Fprintf(os.Stderr, "SKIP: failed to create shared temp dir: %v\n", err)
os.Exit(0)
}
cluster, err := startMiniClusterInDir(testDir, nil)
if err != nil {
fmt.Fprintf(os.Stderr, "SKIP: failed to start shared weed mini cluster: %v\n", err)
os.RemoveAll(testDir)
os.Exit(0)
}
sharedCluster = cluster
code := m.Run()
sharedCluster.Stop()
os.RemoveAll(testDir)
os.Exit(code)
}
func TestS3TablesIntegration(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
// Reuse the shared cluster started by TestMain.
client := NewS3TablesClient(sharedCluster.s3Endpoint, testRegion, testAccessKey, testSecretKey)
// Run test suite
t.Run("TableBucketLifecycle", func(t *testing.T) {
testTableBucketLifecycle(t, client)
})
t.Run("NamespaceLifecycle", func(t *testing.T) {
testNamespaceLifecycle(t, client)
})
t.Run("TableLifecycle", func(t *testing.T) {
testTableLifecycle(t, client)
})
t.Run("TableBucketPolicy", func(t *testing.T) {
testTableBucketPolicy(t, client)
})
t.Run("TablePolicy", func(t *testing.T) {
testTablePolicy(t, client)
})
t.Run("Tagging", func(t *testing.T) {
testTagging(t, client)
})
t.Run("TargetOperations", func(t *testing.T) {
testTargetOperations(t, client)
})
}
func TestS3TablesCreateBucketIAMPolicy(t *testing.T) {
if testing.Short() {
t.Skip("Skipping IAM integration test in short mode")
}
t.Setenv("AWS_ACCESS_KEY_ID", "env-admin")
t.Setenv("AWS_SECRET_ACCESS_KEY", "env-secret")
allowedBucket := "tables-allowed"
deniedBucket := "tables-denied"
iamConfigDir := t.TempDir()
iamConfigPath := filepath.Join(iamConfigDir, "iam_config.json")
iamConfig := fmt.Sprintf(`{
"sts": {
"tokenDuration": "1h",
"maxSessionLength": "12h",
"issuer": "seaweedfs-sts",
"signingKey": "%s"
},
"accounts": [
{
"id": "%s",
"displayName": "tables-integration"
}
],
"identities": [
{
"name": "admin",
"credentials": [
{
"accessKey": "%s",
"secretKey": "%s"
}
],
"account": {
"id": "%s",
"displayName": "tables-integration"
},
"policyNames": ["S3TablesBucketPolicy"]
}
],
"policy": {
"defaultEffect": "Deny",
"storeType": "memory"
},
"policies": [
{
"name": "S3TablesBucketPolicy",
"document": {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3tables:CreateTableBucket"],
"Resource": [
"arn:aws:s3tables:*:*:bucket/%s",
"arn:aws:s3:::%s"
]
}
]
}
}
]
}`, testIAMSigningKey, testAccountID, testAccessKey, testSecretKey, testAccountID, allowedBucket, allowedBucket)
require.NoError(t, os.WriteFile(iamConfigPath, []byte(iamConfig), 0644))
cluster, err := startMiniClusterWithExtraArgs(t, []string{
"-s3.config=" + iamConfigPath,
"-s3.iam.config=" + iamConfigPath,
})
require.NoError(t, err, "failed to start cluster with IAM config")
defer cluster.Stop()
client := NewS3TablesClient(cluster.s3Endpoint, testRegion, testAccessKey, testSecretKey)
_, err = client.CreateTableBucket(deniedBucket, nil)
require.Error(t, err, "denied bucket creation should fail")
assert.Contains(t, err.Error(), "AccessDenied")
allowedResp, err := client.CreateTableBucket(allowedBucket, nil)
require.NoError(t, err, "allowed bucket creation should succeed")
defer func() {
_ = client.DeleteTableBucket(allowedResp.ARN)
}()
}
func testTableBucketLifecycle(t *testing.T, client *S3TablesClient) {
bucketName := "test-bucket-" + randomString(8)
// Create table bucket
createResp, err := client.CreateTableBucket(bucketName, nil)
require.NoError(t, err, "Failed to create table bucket")
assert.Contains(t, createResp.ARN, bucketName)
t.Logf("Created table bucket: %s", createResp.ARN)
// Get table bucket
getResp, err := client.GetTableBucket(createResp.ARN)
require.NoError(t, err, "Failed to get table bucket")
assert.Equal(t, bucketName, getResp.Name)
t.Logf("Got table bucket: %s", getResp.Name)
// List table buckets
listResp, err := client.ListTableBuckets("", "", 0)
require.NoError(t, err, "Failed to list table buckets")
found := false
for _, b := range listResp.TableBuckets {
if b.Name == bucketName {
found = true
break
}
}
assert.True(t, found, "Created bucket should appear in list")
t.Logf("Listed table buckets, found %d buckets", len(listResp.TableBuckets))
// Delete table bucket
err = client.DeleteTableBucket(createResp.ARN)
require.NoError(t, err, "Failed to delete table bucket")
t.Logf("Deleted table bucket: %s", bucketName)
// Verify bucket is deleted
_, err = client.GetTableBucket(createResp.ARN)
assert.Error(t, err, "Bucket should not exist after deletion")
}
func testNamespaceLifecycle(t *testing.T, client *S3TablesClient) {
bucketName := "test-ns-bucket-" + randomString(8)
namespaceName := "test_namespace"
// Create table bucket first
createBucketResp, err := client.CreateTableBucket(bucketName, nil)
require.NoError(t, err, "Failed to create table bucket")
defer client.DeleteTableBucket(createBucketResp.ARN)
bucketARN := createBucketResp.ARN
// Create namespace
createNsResp, err := client.CreateNamespace(bucketARN, []string{namespaceName})
require.NoError(t, err, "Failed to create namespace")
assert.Equal(t, []string{namespaceName}, createNsResp.Namespace)
t.Logf("Created namespace: %s", namespaceName)
// Get namespace
getNsResp, err := client.GetNamespace(bucketARN, []string{namespaceName})
require.NoError(t, err, "Failed to get namespace")
assert.Equal(t, []string{namespaceName}, getNsResp.Namespace)
t.Logf("Got namespace: %v", getNsResp.Namespace)
// List namespaces
listNsResp, err := client.ListNamespaces(bucketARN, "", "", 0)
require.NoError(t, err, "Failed to list namespaces")
found := false
for _, ns := range listNsResp.Namespaces {
if len(ns.Namespace) > 0 && ns.Namespace[0] == namespaceName {
found = true
break
}
}
assert.True(t, found, "Created namespace should appear in list")
t.Logf("Listed namespaces, found %d namespaces", len(listNsResp.Namespaces))
// Delete namespace
err = client.DeleteNamespace(bucketARN, []string{namespaceName})
require.NoError(t, err, "Failed to delete namespace")
t.Logf("Deleted namespace: %s", namespaceName)
// Verify namespace is deleted
_, err = client.GetNamespace(bucketARN, []string{namespaceName})
assert.Error(t, err, "Namespace should not exist after deletion")
}
func testTableLifecycle(t *testing.T, client *S3TablesClient) {
bucketName := "test-table-bucket-" + randomString(8)
namespaceName := "test_ns"
tableName := "test_table"
// Create table bucket
createBucketResp, err := client.CreateTableBucket(bucketName, nil)
require.NoError(t, err, "Failed to create table bucket")
defer client.DeleteTableBucket(createBucketResp.ARN)
bucketARN := createBucketResp.ARN
// Create namespace
_, err = client.CreateNamespace(bucketARN, []string{namespaceName})
require.NoError(t, err, "Failed to create namespace")
defer client.DeleteNamespace(bucketARN, []string{namespaceName})
// Create table with Iceberg schema
icebergMetadata := &s3tables.TableMetadata{
Iceberg: &s3tables.IcebergMetadata{
Schema: s3tables.IcebergSchema{
Fields: []s3tables.IcebergSchemaField{
{Name: "id", Type: "int", Required: true},
{Name: "name", Type: "string"},
{Name: "value", Type: "int"},
},
},
},
}
createTableResp, err := client.CreateTable(bucketARN, []string{namespaceName}, tableName, "ICEBERG", icebergMetadata, nil)
require.NoError(t, err, "Failed to create table")
assert.NotEmpty(t, createTableResp.TableARN)
assert.NotEmpty(t, createTableResp.VersionToken)
t.Logf("Created table: %s (version: %s)", createTableResp.TableARN, createTableResp.VersionToken)
// Get table
getTableResp, err := client.GetTable(bucketARN, []string{namespaceName}, tableName)
require.NoError(t, err, "Failed to get table")
assert.Equal(t, tableName, getTableResp.Name)
assert.Equal(t, "ICEBERG", getTableResp.Format)
t.Logf("Got table: %s (format: %s)", getTableResp.Name, getTableResp.Format)
// List tables
listTablesResp, err := client.ListTables(bucketARN, []string{namespaceName}, "", "", 0)
require.NoError(t, err, "Failed to list tables")
found := false
for _, tbl := range listTablesResp.Tables {
if tbl.Name == tableName {
found = true
break
}
}
assert.True(t, found, "Created table should appear in list")
t.Logf("Listed tables, found %d tables", len(listTablesResp.Tables))
// Delete table
err = client.DeleteTable(bucketARN, []string{namespaceName}, tableName)
require.NoError(t, err, "Failed to delete table")
t.Logf("Deleted table: %s", tableName)
// Verify table is deleted
_, err = client.GetTable(bucketARN, []string{namespaceName}, tableName)
assert.Error(t, err, "Table should not exist after deletion")
}
func testTableBucketPolicy(t *testing.T, client *S3TablesClient) {
bucketName := "test-policy-bucket-" + randomString(8)
// Create table bucket
createBucketResp, err := client.CreateTableBucket(bucketName, nil)
require.NoError(t, err, "Failed to create table bucket")
defer client.DeleteTableBucket(createBucketResp.ARN)
bucketARN := createBucketResp.ARN
// Put bucket policy
policy := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*","Action":"s3tables:*","Resource":"*"}]}`
err = client.PutTableBucketPolicy(bucketARN, policy)
require.NoError(t, err, "Failed to put table bucket policy")
t.Logf("Put table bucket policy")
// Get bucket policy
getPolicyResp, err := client.GetTableBucketPolicy(bucketARN)
require.NoError(t, err, "Failed to get table bucket policy")
assert.Equal(t, policy, getPolicyResp.ResourcePolicy)
t.Logf("Got table bucket policy")
// Delete bucket policy
err = client.DeleteTableBucketPolicy(bucketARN)
require.NoError(t, err, "Failed to delete table bucket policy")
t.Logf("Deleted table bucket policy")
// Verify policy is deleted
_, err = client.GetTableBucketPolicy(bucketARN)
assert.Error(t, err, "Policy should not exist after deletion")
}
func testTablePolicy(t *testing.T, client *S3TablesClient) {
bucketName := "test-table-policy-bucket-" + randomString(8)
namespaceName := "test_ns"
tableName := "test_table"
// Create table bucket
createBucketResp, err := client.CreateTableBucket(bucketName, nil)
require.NoError(t, err, "Failed to create table bucket")
defer client.DeleteTableBucket(createBucketResp.ARN)
bucketARN := createBucketResp.ARN
// Create namespace
_, err = client.CreateNamespace(bucketARN, []string{namespaceName})
require.NoError(t, err, "Failed to create namespace")
defer client.DeleteNamespace(bucketARN, []string{namespaceName})
// Create table
icebergMetadata := &s3tables.TableMetadata{
Iceberg: &s3tables.IcebergMetadata{
Schema: s3tables.IcebergSchema{
Fields: []s3tables.IcebergSchemaField{
{Name: "id", Type: "int", Required: true},
{Name: "name", Type: "string"},
},
},
},
}
createTableResp, err := client.CreateTable(bucketARN, []string{namespaceName}, tableName, "ICEBERG", icebergMetadata, nil)
require.NoError(t, err, "Failed to create table")
defer client.DeleteTable(bucketARN, []string{namespaceName}, tableName)
t.Logf("Created table: %s", createTableResp.TableARN)
// Verify no policy exists initially
_, err = client.GetTablePolicy(bucketARN, []string{namespaceName}, tableName)
assert.Error(t, err, "Policy should not exist initially")
t.Logf("Verified no policy exists initially")
// Put table policy
policy := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*","Action":"s3tables:*","Resource":"*"}]}`
err = client.PutTablePolicy(bucketARN, []string{namespaceName}, tableName, policy)
require.NoError(t, err, "Failed to put table policy")
t.Logf("Put table policy")
// Get table policy
getPolicyResp, err := client.GetTablePolicy(bucketARN, []string{namespaceName}, tableName)
require.NoError(t, err, "Failed to get table policy")
assert.Equal(t, policy, getPolicyResp.ResourcePolicy)
t.Logf("Got table policy")
// Delete table policy
err = client.DeleteTablePolicy(bucketARN, []string{namespaceName}, tableName)
require.NoError(t, err, "Failed to delete table policy")
t.Logf("Deleted table policy")
// Verify policy is deleted
_, err = client.GetTablePolicy(bucketARN, []string{namespaceName}, tableName)
assert.Error(t, err, "Policy should not exist after deletion")
t.Logf("Verified policy deletion")
}
func testTagging(t *testing.T, client *S3TablesClient) {
bucketName := "test-tag-bucket-" + randomString(8)
// Create table bucket with tags
initialTags := map[string]string{"Environment": "test"}
createBucketResp, err := client.CreateTableBucket(bucketName, initialTags)
require.NoError(t, err, "Failed to create table bucket")
defer client.DeleteTableBucket(createBucketResp.ARN)
bucketARN := createBucketResp.ARN
// List tags
listTagsResp, err := client.ListTagsForResource(bucketARN)
require.NoError(t, err, "Failed to list tags")
assert.Equal(t, "test", listTagsResp.Tags["Environment"])
t.Logf("Listed tags: %v", listTagsResp.Tags)
// Add more tags
newTags := map[string]string{"Department": "Engineering"}
err = client.TagResource(bucketARN, newTags)
require.NoError(t, err, "Failed to tag resource")
t.Logf("Added tags")
// Verify tags
listTagsResp, err = client.ListTagsForResource(bucketARN)
require.NoError(t, err, "Failed to list tags")
assert.Equal(t, "test", listTagsResp.Tags["Environment"])
assert.Equal(t, "Engineering", listTagsResp.Tags["Department"])
t.Logf("Verified tags: %v", listTagsResp.Tags)
// Remove a tag
err = client.UntagResource(bucketARN, []string{"Environment"})
require.NoError(t, err, "Failed to untag resource")
t.Logf("Removed tag")
// Verify tag is removed
listTagsResp, err = client.ListTagsForResource(bucketARN)
require.NoError(t, err, "Failed to list tags")
_, hasEnvironment := listTagsResp.Tags["Environment"]
assert.False(t, hasEnvironment, "Environment tag should be removed")
assert.Equal(t, "Engineering", listTagsResp.Tags["Department"])
t.Logf("Verified tag removal")
}
func testTargetOperations(t *testing.T, client *S3TablesClient) {
bucketName := "test-target-bucket-" + randomString(8)
var createResp s3tables.CreateTableBucketResponse
err := client.doTargetRequestAndDecode("CreateTableBucket", &s3tables.CreateTableBucketRequest{
Name: bucketName,
}, &createResp)
require.NoError(t, err, "Failed to create table bucket via target")
defer client.doTargetRequestAndDecode("DeleteTableBucket", &s3tables.DeleteTableBucketRequest{
TableBucketARN: createResp.ARN,
}, nil)
var listResp s3tables.ListTableBucketsResponse
err = client.doTargetRequestAndDecode("ListTableBuckets", &s3tables.ListTableBucketsRequest{}, &listResp)
require.NoError(t, err, "Failed to list table buckets via target")
found := false
for _, b := range listResp.TableBuckets {
if b.Name == bucketName {
found = true
break
}
}
assert.True(t, found, "Created bucket should appear in target list")
var getResp s3tables.GetTableBucketResponse
err = client.doTargetRequestAndDecode("GetTableBucket", &s3tables.GetTableBucketRequest{
TableBucketARN: createResp.ARN,
}, &getResp)
require.NoError(t, err, "Failed to get table bucket via target")
assert.Equal(t, bucketName, getResp.Name)
namespaceName := "target_ns"
var createNsResp s3tables.CreateNamespaceResponse
err = client.doTargetRequestAndDecode("CreateNamespace", &s3tables.CreateNamespaceRequest{
TableBucketARN: createResp.ARN,
Namespace: []string{namespaceName},
}, &createNsResp)
require.NoError(t, err, "Failed to create namespace via target")
defer client.doTargetRequestAndDecode("DeleteNamespace", &s3tables.DeleteNamespaceRequest{
TableBucketARN: createResp.ARN,
Namespace: []string{namespaceName},
}, nil)
var listNsResp s3tables.ListNamespacesResponse
err = client.doTargetRequestAndDecode("ListNamespaces", &s3tables.ListNamespacesRequest{
TableBucketARN: createResp.ARN,
}, &listNsResp)
require.NoError(t, err, "Failed to list namespaces via target")
tableName := "target_table"
var createTableResp s3tables.CreateTableResponse
err = client.doTargetRequestAndDecode("CreateTable", &s3tables.CreateTableRequest{
TableBucketARN: createResp.ARN,
Namespace: []string{namespaceName},
Name: tableName,
Format: "ICEBERG",
}, &createTableResp)
require.NoError(t, err, "Failed to create table via target")
defer client.doTargetRequestAndDecode("DeleteTable", &s3tables.DeleteTableRequest{
TableBucketARN: createResp.ARN,
Namespace: []string{namespaceName},
Name: tableName,
}, nil)
var listTablesResp s3tables.ListTablesResponse
err = client.doTargetRequestAndDecode("ListTables", &s3tables.ListTablesRequest{
TableBucketARN: createResp.ARN,
Namespace: []string{namespaceName},
}, &listTablesResp)
require.NoError(t, err, "Failed to list tables via target")
var getTableResp s3tables.GetTableResponse
err = client.doTargetRequestAndDecode("GetTable", &s3tables.GetTableRequest{
TableBucketARN: createResp.ARN,
Namespace: []string{namespaceName},
Name: tableName,
}, &getTableResp)
require.NoError(t, err, "Failed to get table via target")
assert.Equal(t, tableName, getTableResp.Name)
policy := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*","Action":"s3tables:*","Resource":"*"}]}`
err = client.doTargetRequestAndDecode("PutTableBucketPolicy", &s3tables.PutTableBucketPolicyRequest{
TableBucketARN: createResp.ARN,
ResourcePolicy: policy,
}, nil)
require.NoError(t, err, "Failed to put bucket policy via target")
var getPolicyResp s3tables.GetTableBucketPolicyResponse
err = client.doTargetRequestAndDecode("GetTableBucketPolicy", &s3tables.GetTableBucketPolicyRequest{
TableBucketARN: createResp.ARN,
}, &getPolicyResp)
require.NoError(t, err, "Failed to get bucket policy via target")
assert.Equal(t, policy, getPolicyResp.ResourcePolicy)
err = client.doTargetRequestAndDecode("DeleteTableBucketPolicy", &s3tables.DeleteTableBucketPolicyRequest{
TableBucketARN: createResp.ARN,
}, nil)
require.NoError(t, err, "Failed to delete bucket policy via target")
err = client.doTargetRequestAndDecode("TagResource", &s3tables.TagResourceRequest{
ResourceARN: createResp.ARN,
Tags: map[string]string{"Environment": "test"},
}, nil)
require.NoError(t, err, "Failed to tag resource via target")
var listTagsResp s3tables.ListTagsForResourceResponse
err = client.doTargetRequestAndDecode("ListTagsForResource", &s3tables.ListTagsForResourceRequest{
ResourceARN: createResp.ARN,
}, &listTagsResp)
require.NoError(t, err, "Failed to list tags via target")
assert.Equal(t, "test", listTagsResp.Tags["Environment"])
err = client.doTargetRequestAndDecode("UntagResource", &s3tables.UntagResourceRequest{
ResourceARN: createResp.ARN,
TagKeys: []string{"Environment"},
}, nil)
require.NoError(t, err, "Failed to untag resource via target")
}
// Helper functions
// startMiniClusterInDir starts a weed mini instance using testDir as the data
// directory. It does not require a *testing.T so it can be called from TestMain.
// extraArgs are appended to the default mini command flags.
func startMiniClusterInDir(testDir string, extraArgs []string) (*TestCluster, error) {
// We need 10 unique ports: Master(2), Volume(2), Filer(2), S3(2), Admin(2)
ports, err := testutil.AllocatePorts(10)
if err != nil {
return nil, fmt.Errorf("failed to find available ports: %v", err)
}
masterPort := ports[0]
masterGrpcPort := ports[1]
volumePort := ports[2]
volumeGrpcPort := ports[3]
filerPort := ports[4]
filerGrpcPort := ports[5]
s3Port := ports[6]
s3GrpcPort := ports[7]
adminPort := ports[8]
adminGrpcPort := ports[9]
// Ensure no configuration file from previous runs
configFile := filepath.Join(testDir, "mini.options")
_ = os.Remove(configFile)
// Create context with timeout
ctx, cancel := context.WithCancel(context.Background())
s3Endpoint := fmt.Sprintf("http://127.0.0.1:%d", s3Port)
cluster := &TestCluster{
dataDir: testDir,
ctx: ctx,
cancel: cancel,
masterPort: masterPort,
volumePort: volumePort,
filerPort: filerPort,
s3Port: s3Port,
s3Endpoint: s3Endpoint,
}
// Create empty security.toml to disable JWT authentication in tests
securityToml := filepath.Join(testDir, "security.toml")
if err = os.WriteFile(securityToml, []byte("# Empty security config for testing\n"), 0644); err != nil {
cancel()
return nil, fmt.Errorf("failed to create security.toml: %v", err)
}
// Ensure AWS credentials are set (don't use t.Setenv here — we are in TestMain).
if os.Getenv("AWS_ACCESS_KEY_ID") == "" {
os.Setenv("AWS_ACCESS_KEY_ID", "admin")
}
if os.Getenv("AWS_SECRET_ACCESS_KEY") == "" {
os.Setenv("AWS_SECRET_ACCESS_KEY", "admin")
}
// Start weed mini in a goroutine by calling the command directly
cluster.wg.Add(1)
go func() {
defer cluster.wg.Done()
// Save current directory and args, restore on exit.
oldDir, _ := os.Getwd()
oldArgs := os.Args
defer func() {
os.Chdir(oldDir)
os.Args = oldArgs
}()
// Change to test directory so mini picks up security.toml.
os.Chdir(testDir)
baseArgs := []string{
"-dir=" + testDir,
"-master.dir=" + testDir,
"-master.port=" + strconv.Itoa(masterPort),
"-master.port.grpc=" + strconv.Itoa(masterGrpcPort),
"-volume.port=" + strconv.Itoa(volumePort),
"-volume.port.grpc=" + strconv.Itoa(volumeGrpcPort),
"-volume.port.public=" + strconv.Itoa(volumePort),
"-volume.publicUrl=127.0.0.1:" + strconv.Itoa(volumePort),
"-filer.port=" + strconv.Itoa(filerPort),
"-filer.port.grpc=" + strconv.Itoa(filerGrpcPort),
"-s3.port=" + strconv.Itoa(s3Port),
"-s3.port.grpc=" + strconv.Itoa(s3GrpcPort),
"-admin.port=" + strconv.Itoa(adminPort),
"-admin.port.grpc=" + strconv.Itoa(adminGrpcPort),
"-webdav.port=0", // Disable WebDAV
"-admin.ui=false", // Disable admin UI
"-master.volumeSizeLimitMB=32", // Small volumes for testing
"-ip=127.0.0.1",
"-master.peers=none", // Faster startup
"-s3.iam.readOnly=false", // Enable IAM write operations for tests
}
if len(extraArgs) > 0 {
baseArgs = append(baseArgs, extraArgs...)
}
os.Args = append([]string{"weed"}, baseArgs...)
// Suppress most logging during tests
glog.MaxSize = 1024 * 1024
// Find and run the mini command
for _, cmd := range command.Commands {
if cmd.Name() == "mini" && cmd.Run != nil {
cmd.Flag.Parse(os.Args[1:])
args := cmd.Flag.Args()
command.MiniClusterCtx = ctx
cmd.Run(cmd, args)
command.MiniClusterCtx = nil
return
}
}
}()
// Wait for S3 service to be ready
if err = waitForS3Ready(cluster.s3Endpoint, 30*time.Second); err != nil {
cancel()
return nil, fmt.Errorf("S3 service failed to start: %v", err)
}
cluster.isRunning = true
return cluster, nil
}
// startMiniClusterWithExtraArgs starts a weed mini instance for a single test.
// It uses t.TempDir() for data isolation and t.Setenv for credential scoping.
func startMiniClusterWithExtraArgs(t *testing.T, extraArgs []string) (*TestCluster, error) {
t.Helper()
testDir := t.TempDir()
// Scope credentials to the test so they are restored after test completion.
if os.Getenv("AWS_ACCESS_KEY_ID") == "" {
t.Setenv("AWS_ACCESS_KEY_ID", "admin")
}
if os.Getenv("AWS_SECRET_ACCESS_KEY") == "" {
t.Setenv("AWS_SECRET_ACCESS_KEY", "admin")
}
cluster, err := startMiniClusterInDir(testDir, extraArgs)
if err != nil {
return nil, err
}
cluster.t = t
t.Logf("Test cluster started successfully at %s", cluster.s3Endpoint)
return cluster, nil
}
// Stop stops the test cluster
func (c *TestCluster) Stop() {
if c.cancel != nil {
c.cancel()
}
// Give services time to shut down gracefully
if c.isRunning {
time.Sleep(500 * time.Millisecond)
}
// Wait for the mini goroutine to finish
done := make(chan struct{})
go func() {
c.wg.Wait()
close(done)
}()
timer := time.NewTimer(2 * time.Second)
defer timer.Stop()
select {
case <-done:
// Goroutine finished
case <-timer.C:
// Timeout - goroutine doesn't respond to context cancel.
// This may indicate the mini cluster didn't shut down cleanly.
if c.t != nil {
c.t.Log("Warning: Test cluster shutdown timed out after 2 seconds")
} else {
fmt.Println("Warning: Test cluster shutdown timed out after 2 seconds")
}
}
}
// waitForS3Ready waits for the S3 service to be ready
func waitForS3Ready(endpoint string, timeout time.Duration) error {
client := &http.Client{Timeout: 1 * time.Second}
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
resp, err := client.Get(endpoint)
if err == nil {
resp.Body.Close()
// Wait a bit more to ensure service is fully ready
time.Sleep(500 * time.Millisecond)
return nil
}
time.Sleep(200 * time.Millisecond)
}
return fmt.Errorf("timeout waiting for S3 service at %s", endpoint)
}
// randomString generates a random string for unique naming
func randomString(length int) string {
const charset = "abcdefghijklmnopqrstuvwxyz0123456789"
b := make([]byte, length)
if _, err := cryptorand.Read(b); err != nil {
panic("failed to generate random string: " + err.Error())
}
for i := range b {
b[i] = charset[int(b[i])%len(charset)]
}
return string(b)
}

View File

@@ -0,0 +1,59 @@
package s3tables
import (
"context"
"net/http"
"sync"
"testing"
"time"
)
// sharedCluster is the single default TestCluster shared across all tests
// that do not require a specialised cluster configuration.
// It is initialised by TestMain and must not be modified by individual tests.
var sharedCluster *TestCluster
// TestCluster manages the weed mini instance for integration testing
type TestCluster struct {
t *testing.T
dataDir string
ctx context.Context
cancel context.CancelFunc
isRunning bool
startOnce sync.Once
wg sync.WaitGroup
masterPort int
volumePort int
filerPort int
s3Port int
s3Endpoint string
}
// S3TablesClient is a simple client for S3 Tables API
type S3TablesClient struct {
endpoint string
region string
accessKey string
secretKey string
client *http.Client
}
// NewS3TablesClient creates a new S3 Tables client
func NewS3TablesClient(endpoint, region, accessKey, secretKey string) *S3TablesClient {
return &S3TablesClient{
endpoint: endpoint,
region: region,
accessKey: accessKey,
secretKey: secretKey,
client: &http.Client{Timeout: 30 * time.Second},
}
}
// Test configuration constants
const (
testRegion = "us-west-2"
testAccessKey = "admin"
testSecretKey = "admin"
testAccountID = "111122223333"
testIAMSigningKey = "dGVzdC1zaWduaW5nLWtleS1mb3Itc3RzLWludGVncmF0aW9uLXRlc3Rz"
)

View File

@@ -0,0 +1,11 @@
FROM python:3.11-slim
RUN pip install --no-cache-dir \
"deltalake==0.21.0" \
"pyarrow==17.0.0" \
"requests==2.32.3"
WORKDIR /app
COPY delta_rs_writer.py /app/delta_rs_writer.py
ENTRYPOINT ["python3", "/app/delta_rs_writer.py"]

View File

@@ -0,0 +1,104 @@
# Unity Catalog OSS integration tests
These tests run Unity Catalog OSS in Docker against an embedded SeaweedFS
S3 endpoint. The `server.properties` mirrors the upstream playground at
[`mds-in-a-box/unitycatalog-playground`](https://github.com/data-engineering-helpers/mds-in-a-box/tree/main/unitycatalog-playground).
| Test | Variant | Status |
| --- | --- | --- |
| `TestUnityCatalogDeltaIntegration` | static keys, `aws.masterRoleArn=` empty | passes; covers catalog/schema/EXTERNAL Delta CRUD against SeaweedFS-backed warehouse and asserts that UC's `/temporary-table-credentials` *cannot* vend usable creds with this configuration -- exactly the gap the playground reports. |
| `TestUnityCatalogMasterRoleIntegration` | `aws.masterRoleArn=arn:aws:iam::000000000000:role/UnityCatalogVendedRole` | passes; proves SeaweedFS' STS endpoint accepts `sts:AssumeRole` for the role UC would use (Go SDK round-trip), and that UC starts and accepts CRUD when wired with the master-role config. UC's own StsClient still talks to real AWS regardless of `aws.endpoint` / `AWS_ENDPOINT_URL_STS` (UC bug, see below); that hop is logged via `t.Logf` rather than asserted. |
| `TestUnityCatalogDeltaRsRoundTrip` | static keys + `delta-rs` Python client | passes; resolves table metadata through UC and writes/reads a real Delta table at the registered `storage_location` using `python:3.11-slim + deltalake` with the SeaweedFS test credentials. |
## Prerequisites
- Docker available locally (the tests call `docker run` / `docker build` directly).
- A `weed` binary at the repo root (`weed/weed`) or on `$PATH`.
## Run
```bash
go test -timeout 15m \
-run 'TestUnityCatalog' \
./test/s3tables/unity_catalog/...
```
Pin a specific Unity Catalog image (defaults to
`unitycatalog/unitycatalog:v0.4.0`):
```bash
UC_IMAGE=unitycatalog/unitycatalog:main \
go test -timeout 15m -run TestUnityCatalogDeltaIntegration \
./test/s3tables/unity_catalog/...
```
The tests self-skip when Docker is unavailable or no `weed` binary is on
the path; running under `-short` also skips them.
## Why the static-key path can't vend usable creds
UC OSS' `AwsCredentialVendor.createPerBucketCredentialGenerator`:
```java
if (config.getSessionToken() != null && !config.getSessionToken().isEmpty()) {
return new AwsCredentialGenerator.StaticAwsCredentialGenerator(config);
}
return createStsCredentialGenerator(config);
```
With `aws.masterRoleArn=` empty and `s3.sessionToken.0=` empty (this
test's configuration), `/temporary-table-credentials` short-circuits with
`"S3 bucket configuration not found."` before UC fires any STS call.
Setting a stub `s3.sessionToken.0` switches UC to
`StaticAwsCredentialGenerator` and the endpoint returns the static keys,
but the response carries that stub session token -- SeaweedFS won't
recognize it on the next S3 call, so the vended creds aren't usable for
table I/O. Clients have to fall back to the static keys directly.
With `aws.masterRoleArn` set, UC's `AwsCredentialGenerator.StsAwsCredentialGenerator`
builds the StsClient with only `.region(...)` and `.credentialsProvider(...)` --
no `.endpointOverride()`. The SDK's generic env-var resolution doesn't kick in
for that builder shape, so even with `AWS_ENDPOINT_URL_STS=...` (or the
matching `aws.endpointUrlSts` Java property, or the catch-all
`AWS_ENDPOINT_URL=...`) the StsClient still targets real AWS and gets back
`InvalidClientTokenId`. Verified by pointing the env var at port 1: UC reports
the same AWS-issued 403 that it reports against SeaweedFS, and a sniffer in
front of SeaweedFS' STS port records zero traffic. SeaweedFS' STS handler
itself works -- the Go SDK round-trip in `assumeRoleViaSeaweedFS` proves that
against the same SeaweedFS instance.
UC's own AWS credential-vending tests don't catch this because they mock
`StsClient` away entirely -- `BaseCRUDTestWithMockCredentials` injects a
custom `stsClientBuilderSupplier` returning an `EchoAwsStsClient` that
synthesizes credentials in-process, and `CloudCredentialVendorTest` uses
`Mockito.mockStatic(StsClient.class)`. No test ever exercises the wire
path between UC's Java SDK and a real STS endpoint, so the missing
`endpointOverride` slipped through.
Fix is upstream in
[unitycatalog/unitycatalog#1532](https://github.com/unitycatalog/unitycatalog/pull/1532),
which adds an `aws.endpoint` property and applies it to both the StsClient
and the S3Client builders. Until that lands, the master-role test logs
the failure but does not assert it.
## What the tests actually validate today
- Unity Catalog accepts a SeaweedFS-backed `server.properties` and starts.
- Catalog / schema / EXTERNAL Delta table CRUD all work against the
SeaweedFS warehouse via the UC REST API.
- SeaweedFS' STS endpoint correctly issues `sts:AssumeRole` credentials
for the `UnityCatalogVendedRole` and those credentials are accepted on
S3 round-trips (Go AWS SDK).
- Delta-RS resolves a UC table's `storage_location` and can write/read Delta
data through the SeaweedFS S3 endpoint with the test credentials.
## What is still pending
Nothing on the SeaweedFS side. The remaining gap (UC's StsClient ignoring
endpoint config) needs a UC OSS patch upstream.
## MANAGED tables
Not exercised. UC OSS gates them behind `server.managed-table.enabled=true`
and a two-step staging flow (`POST /staging-tables` then `POST /tables`);
EXTERNAL Delta is the simpler path and what these tests cover.

View File

@@ -0,0 +1,184 @@
package unity_catalog
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
type ucClient struct {
base string
http *http.Client
}
func newUCClient(base string) *ucClient {
return &ucClient{base: base + ucAPIBase, http: &http.Client{Timeout: 30 * time.Second}}
}
type ucCreateCatalog struct {
Name string `json:"name"`
Comment string `json:"comment,omitempty"`
StorageRoot string `json:"storage_root,omitempty"`
}
type ucCatalogInfo struct {
Name string `json:"name"`
StorageRoot string `json:"storage_root"`
ID string `json:"id"`
}
type ucCreateSchema struct {
Name string `json:"name"`
CatalogName string `json:"catalog_name"`
}
type ucSchemaInfo struct {
Name string `json:"name"`
CatalogName string `json:"catalog_name"`
FullName string `json:"full_name"`
SchemaID string `json:"schema_id"`
}
type ucColumn struct {
Name string `json:"name"`
TypeText string `json:"type_text"`
TypeJSON string `json:"type_json,omitempty"`
TypeName string `json:"type_name"`
Position int `json:"position"`
Nullable bool `json:"nullable"`
}
type ucCreateTable struct {
Name string `json:"name"`
CatalogName string `json:"catalog_name"`
SchemaName string `json:"schema_name"`
TableType string `json:"table_type"`
DataSourceFormat string `json:"data_source_format"`
Columns []ucColumn `json:"columns"`
StorageLocation string `json:"storage_location,omitempty"`
}
type ucTableInfo struct {
Name string `json:"name"`
CatalogName string `json:"catalog_name"`
SchemaName string `json:"schema_name"`
TableType string `json:"table_type"`
DataSourceFormat string `json:"data_source_format"`
Columns []ucColumn `json:"columns"`
StorageLocation string `json:"storage_location"`
TableID string `json:"table_id"`
}
type ucListTablesResp struct {
Tables []ucTableInfo `json:"tables"`
}
type ucAwsCreds struct {
AccessKeyID string `json:"access_key_id"`
SecretAccessKey string `json:"secret_access_key"`
SessionToken string `json:"session_token"`
}
type ucTempCreds struct {
AwsTempCredentials *ucAwsCreds `json:"aws_temp_credentials"`
ExpirationTime int64 `json:"expiration_time"`
}
func (c *ucClient) createCatalog(ctx context.Context, in ucCreateCatalog) (*ucCatalogInfo, error) {
var out ucCatalogInfo
return &out, c.do(ctx, http.MethodPost, "/catalogs", in, &out)
}
func (c *ucClient) deleteCatalog(ctx context.Context, name string) error {
q := url.Values{"force": []string{"true"}}
return c.do(ctx, http.MethodDelete, "/catalogs/"+url.PathEscape(name)+"?"+q.Encode(), nil, nil)
}
func (c *ucClient) createSchema(ctx context.Context, in ucCreateSchema) (*ucSchemaInfo, error) {
var out ucSchemaInfo
return &out, c.do(ctx, http.MethodPost, "/schemas", in, &out)
}
func (c *ucClient) deleteSchema(ctx context.Context, fullName string) error {
q := url.Values{"force": []string{"true"}}
return c.do(ctx, http.MethodDelete, "/schemas/"+url.PathEscape(fullName)+"?"+q.Encode(), nil, nil)
}
func (c *ucClient) createTable(ctx context.Context, in ucCreateTable) (*ucTableInfo, error) {
var out ucTableInfo
return &out, c.do(ctx, http.MethodPost, "/tables", in, &out)
}
func (c *ucClient) getTable(ctx context.Context, fullName string) (*ucTableInfo, error) {
var out ucTableInfo
return &out, c.do(ctx, http.MethodGet, "/tables/"+url.PathEscape(fullName), nil, &out)
}
func (c *ucClient) listTables(ctx context.Context, catalog, schema string) ([]ucTableInfo, error) {
var out ucListTablesResp
q := url.Values{
"catalog_name": []string{catalog},
"schema_name": []string{schema},
}
path := "/tables?" + q.Encode()
if err := c.do(ctx, http.MethodGet, path, nil, &out); err != nil {
return nil, err
}
return out.Tables, nil
}
func (c *ucClient) deleteTable(ctx context.Context, fullName string) error {
return c.do(ctx, http.MethodDelete, "/tables/"+url.PathEscape(fullName), nil, nil)
}
func (c *ucClient) generateTemporaryTableCredentials(ctx context.Context, tableID, op string) (*ucTempCreds, error) {
body := map[string]string{"table_id": tableID, "operation": op}
var out ucTempCreds
return &out, c.do(ctx, http.MethodPost, "/temporary-table-credentials", body, &out)
}
func (c *ucClient) do(ctx context.Context, method, path string, in any, out any) error {
var body io.Reader
if in != nil {
buf, err := json.Marshal(in)
if err != nil {
return fmt.Errorf("marshal %s %s: %w", method, path, err)
}
body = bytes.NewReader(buf)
}
req, err := http.NewRequestWithContext(ctx, method, c.base+path, body)
if err != nil {
return err
}
if in != nil {
req.Header.Set("Content-Type", "application/json")
}
req.Header.Set("Accept", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
// 10 MiB cap is well above any reasonable Unity Catalog response and
// keeps a runaway server from OOMing the test runner.
respBytes, err := io.ReadAll(io.LimitReader(resp.Body, 10*1024*1024))
if err != nil {
return fmt.Errorf("read %s %s: %w", method, path, err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("%s %s: status %d: %s", method, path, resp.StatusCode, strings.TrimSpace(string(respBytes)))
}
if out == nil || len(respBytes) == 0 {
return nil
}
if err := json.Unmarshal(respBytes, out); err != nil {
return fmt.Errorf("decode %s %s: %w (body=%s)", method, path, err, string(respBytes))
}
return nil
}

View File

@@ -0,0 +1,125 @@
package unity_catalog
import (
"context"
"fmt"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/seaweedfs/seaweedfs/test/testutil"
)
// TestUnityCatalogDeltaRsRoundTrip writes and reads a real Delta table at
// the registered storage_location using the delta-rs Python library inside
// a Docker container. The script resolves table metadata from UC and uses the
// test SeaweedFS credentials as delta-rs storage_options.
func TestUnityCatalogDeltaRsRoundTrip(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in -short mode")
}
if !testutil.HasDocker() {
t.Skip("docker not available")
}
env := newTestEnv(t)
defer env.cleanup(t)
t.Log(">>> starting SeaweedFS for delta-rs test...")
env.startSeaweedFS(t, "")
t.Log(">>> SeaweedFS ready")
ctx, cancel := context.WithTimeout(context.Background(), 12*time.Minute)
defer cancel()
s3c := env.newHostS3Client(t, ctx)
if _, err := s3c.CreateBucket(ctx, &s3.CreateBucketInput{Bucket: aws.String(ucWarehouse)}); err != nil {
t.Fatalf("create warehouse bucket: %v", err)
}
t.Log(">>> starting Unity Catalog server...")
env.startUnityCatalog(t, ctx, ucServerOpts{})
t.Log(">>> Unity Catalog ready")
uc := newUCClient(fmt.Sprintf("http://127.0.0.1:%d", env.ucHostPort))
suffix := time.Now().UnixNano()
catalogName := fmt.Sprintf("seaweed_uc_delta_rs_%d", suffix)
schemaName := fmt.Sprintf("ns_%d", suffix)
tableName := fmt.Sprintf("delta_rs_%d", suffix)
tableLocation := fmt.Sprintf("s3://%s/%s/%s/%s", ucWarehouse, ucWarehouseKey, schemaName, tableName)
defer func() {
_ = uc.deleteTable(context.Background(), catalogName+"."+schemaName+"."+tableName)
_ = uc.deleteSchema(context.Background(), catalogName+"."+schemaName)
_ = uc.deleteCatalog(context.Background(), catalogName)
}()
if _, err := uc.createCatalog(ctx, ucCreateCatalog{
Name: catalogName,
StorageRoot: fmt.Sprintf("s3://%s/%s", ucWarehouse, ucWarehouseKey),
}); err != nil {
t.Fatalf("create catalog: %v", err)
}
if _, err := uc.createSchema(ctx, ucCreateSchema{Name: schemaName, CatalogName: catalogName}); err != nil {
t.Fatalf("create schema: %v", err)
}
created, err := uc.createTable(ctx, ucCreateTable{
Name: tableName,
CatalogName: catalogName,
SchemaName: schemaName,
TableType: "EXTERNAL",
DataSourceFormat: "DELTA",
Columns: []ucColumn{
{Name: "id", TypeText: "long", TypeName: "LONG", TypeJSON: `{"name":"id","type":"long","nullable":true,"metadata":{}}`, Position: 0, Nullable: true},
{Name: "value", TypeText: "string", TypeName: "STRING", TypeJSON: `{"name":"value","type":"string","nullable":true,"metadata":{}}`, Position: 1, Nullable: true},
},
StorageLocation: tableLocation,
})
if err != nil {
t.Fatalf("create table: %v", err)
}
testDir, err := filepath.Abs(".")
if err != nil {
t.Fatalf("abs: %v", err)
}
imageTag := fmt.Sprintf("seaweed-uc-delta-rs:%d", time.Now().UnixNano())
build := exec.CommandContext(ctx, "docker", "build", "-t", imageTag, "-f", "Dockerfile.delta-rs", ".")
build.Dir = testDir
if out, err := build.CombinedOutput(); err != nil {
t.Fatalf("docker build delta-rs image: %v\n%s", err, out)
}
defer func() { _ = exec.Command("docker", "rmi", "-f", imageTag).Run() }()
ucURLForContainer := fmt.Sprintf("http://host.docker.internal:%d", env.ucHostPort)
s3EndpointForContainer := fmt.Sprintf("http://host.docker.internal:%d", env.s3Port)
cmd := exec.CommandContext(ctx, "docker", "run", "--rm",
"--add-host", "host.docker.internal:host-gateway",
"-e", "UC_URL="+ucURLForContainer,
"-e", "S3_ENDPOINT="+s3EndpointForContainer,
"-e", "UC_CATALOG="+catalogName,
"-e", "UC_SCHEMA="+schemaName,
"-e", "UC_TABLE="+tableName,
"-e", "UC_TABLE_ID="+created.TableID,
"-e", "AWS_ACCESS_KEY_ID="+env.accessKey,
"-e", "AWS_SECRET_ACCESS_KEY="+env.secretKey,
imageTag,
)
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("delta-rs container failed: %v\noutput:\n%s", err, out)
}
got := string(out)
if !strings.Contains(got, "DELTA_RS_OK rows=3") {
t.Fatalf("expected DELTA_RS_OK rows=3 in output; got:\n%s", got)
}
t.Logf("delta-rs output: %s", strings.TrimSpace(got))
}

View File

@@ -0,0 +1,137 @@
"""Round-trip a Delta table through Unity Catalog using delta-rs.
Reads connection settings from environment variables:
UC_URL Unity Catalog REST base, e.g. http://host.docker.internal:8080
S3_ENDPOINT SeaweedFS S3 endpoint reachable from the container
UC_CATALOG Catalog name to operate against
UC_SCHEMA Schema name
UC_TABLE Table name
UC_TABLE_ID Optional: pre-fetched table_id; falls back to GET /tables/{full_name}
AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY
Optional: direct S3 credentials. If omitted, the script
asks UC for temporary table credentials.
Steps:
1. GET the table info from UC.
2. Resolve S3 credentials from env or UC temporary-table-credentials.
3. write_deltalake() a small pyarrow Table to storage_location.
4. Read it back with DeltaTable() and assert row count.
Prints `DELTA_RS_OK rows=<n>` on success and exits 0; any failure exits 1.
"""
from __future__ import annotations
import os
import sys
import urllib.parse
import pyarrow as pa
import requests
from deltalake import DeltaTable, write_deltalake
def must_env(name: str) -> str:
value = os.environ.get(name)
if not value:
print(f"ERROR: required env var {name} is not set", file=sys.stderr)
sys.exit(2)
return value
def main() -> int:
uc_url = must_env("UC_URL").rstrip("/")
s3_endpoint = must_env("S3_ENDPOINT")
catalog = must_env("UC_CATALOG")
schema = must_env("UC_SCHEMA")
table = must_env("UC_TABLE")
table_id = os.environ.get("UC_TABLE_ID", "").strip()
full_name = f"{catalog}.{schema}.{table}"
api = f"{uc_url}/api/2.1/unity-catalog"
encoded_full_name = urllib.parse.quote(full_name, safe="")
r = requests.get(f"{api}/tables/{encoded_full_name}", timeout=10)
r.raise_for_status()
info = r.json()
if not table_id:
table_id = info.get("table_id") or ""
storage_location = info.get("storage_location") or ""
if not table_id:
print("ERROR: table_id is empty in UC response", file=sys.stderr)
return 1
if not storage_location:
print("ERROR: storage_location is empty in UC response", file=sys.stderr)
return 1
access_key = os.environ.get("AWS_ACCESS_KEY_ID", "").strip()
secret_key = os.environ.get("AWS_SECRET_ACCESS_KEY", "").strip()
session_token = os.environ.get("AWS_SESSION_TOKEN", "").strip()
if bool(access_key) != bool(secret_key):
print(
"ERROR: AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY must be set together",
file=sys.stderr,
)
return 1
if not access_key:
creds_resp = requests.post(
f"{api}/temporary-table-credentials",
json={"table_id": table_id, "operation": "READ_WRITE"},
timeout=15,
)
creds_resp.raise_for_status()
creds = creds_resp.json().get("aws_temp_credentials") or {}
access_key = creds.get("access_key_id") or ""
secret_key = creds.get("secret_access_key") or ""
session_token = creds.get("session_token") or ""
if not access_key or not secret_key:
print(
"ERROR: missing aws_temp_credentials; "
f"response status {creds_resp.status_code}",
file=sys.stderr,
)
return 1
storage_options = {
"AWS_ACCESS_KEY_ID": access_key,
"AWS_SECRET_ACCESS_KEY": secret_key,
"AWS_REGION": "us-east-1",
"AWS_ENDPOINT_URL": s3_endpoint,
"AWS_ALLOW_HTTP": "true",
# delta-rs requires this for non-S3 backends that lack atomic rename.
"AWS_S3_ALLOW_UNSAFE_RENAME": "true",
}
if session_token:
storage_options["AWS_SESSION_TOKEN"] = session_token
arrow_table = pa.table(
{
"id": pa.array([1, 2, 3], type=pa.int64()),
"value": pa.array(["alpha", "beta", "gamma"], type=pa.string()),
}
)
write_deltalake(
storage_location,
arrow_table,
storage_options=storage_options,
mode="overwrite",
)
dt = DeltaTable(storage_location, storage_options=storage_options)
read_back = dt.to_pyarrow_table()
rows = read_back.num_rows
print(f"DELTA_RS_OK rows={rows} version={dt.version()}")
return 0 if rows == arrow_table.num_rows else 1
if __name__ == "__main__":
exit_code = main()
sys.stdout.flush()
sys.stderr.flush()
# deltalake/pyarrow can abort during native teardown after a successful
# round-trip in the short-lived test container. Preserve main's result.
os._exit(exit_code)

View File

@@ -0,0 +1,201 @@
package unity_catalog
import (
"context"
"fmt"
"testing"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/aws-sdk-go-v2/service/sts"
"github.com/seaweedfs/seaweedfs/test/testutil"
)
// TestUnityCatalogMasterRoleIntegration covers the master-role configuration
// the upstream playground notes as not-yet-working
// (https://github.com/data-engineering-helpers/mds-in-a-box/blob/main/unitycatalog-playground/etc/conf/server.properties#L45).
//
// It verifies two things end-to-end:
//
// 1. SeaweedFS' STS endpoint accepts sts:AssumeRole for the
// UnityCatalogVendedRole, returning real STS-vended credentials. This
// mirrors what the lakekeeper / polaris tests already exercise via the Go
// AWS SDK and is the SeaweedFS-side prerequisite for UC's master-role
// flow.
//
// 2. Unity Catalog OSS starts and accepts catalog/schema/EXTERNAL Delta
// table CRUD when configured with `aws.masterRoleArn` set to that role
// and AWS_ENDPOINT_URL_STS pointed at SeaweedFS.
//
// What it does NOT verify is the third hop -- UC's Java StsClient
// reaching SeaweedFS' STS handler. UC's
// AwsCredentialGenerator.StsAwsCredentialGenerator builds the StsClient
// without an endpointOverride and doesn't propagate aws.endpoint to it, so
// the SDK's generic AWS_ENDPOINT_URL_STS resolution doesn't kick in and the
// request always targets real AWS, returning InvalidClientTokenId. Verified
// by pointing AWS_ENDPOINT_URL_STS at port 1: same 403 either way, and a
// sniffer in front of SeaweedFS records zero traffic. UC's own tests mock
// StsClient out (BaseCRUDTestWithMockCredentials' EchoAwsStsClient,
// Mockito.mockStatic in CloudCredentialVendorTest), so this never showed
// up upstream. Fix is in unitycatalog/unitycatalog#1532; that bit is
// logged via t.Logf and not asserted.
func TestUnityCatalogMasterRoleIntegration(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in -short mode")
}
if !testutil.HasDocker() {
t.Skip("docker not available")
}
env := newTestEnv(t)
defer env.cleanup(t)
t.Log(">>> starting SeaweedFS with STS-enabled IAM config...")
env.startSeaweedFS(t, stsEnabledIAMConfig(env.accessKey, env.secretKey))
t.Log(">>> SeaweedFS ready")
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Minute)
defer cancel()
s3c := env.newHostS3Client(t, ctx)
if _, err := s3c.CreateBucket(ctx, &s3.CreateBucketInput{Bucket: aws.String(ucWarehouse)}); err != nil {
t.Fatalf("create warehouse bucket: %v", err)
}
// Slice 1: prove SeaweedFS STS works for the role UC would use.
t.Run("SeaweedFsAssumesUnityCatalogVendedRole", func(t *testing.T) {
stsEndpoint := fmt.Sprintf("http://127.0.0.1:%d", env.s3Port)
creds, err := assumeRoleViaSeaweedFS(ctx, stsEndpoint, env.accessKey, env.secretKey, ucVendedRoleArn)
if err != nil {
t.Fatalf("AssumeRole on SeaweedFS STS: %v", err)
}
if creds.SessionToken == "" {
t.Fatalf("expected non-empty session_token from STS, got %+v", creds)
}
if creds.AccessKeyID == env.accessKey {
t.Fatalf("STS returned the static admin access key; AssumeRole did not vend a new one")
}
// Confirm the vended creds actually work for an S3 round-trip.
s3v := env.newHostS3ClientWithCreds(t, ctx, creds.AccessKeyID, creds.SecretAccessKey, creds.SessionToken)
probeKey := "ns-probe/master-role-probe.txt"
if _, err := s3v.PutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(ucWarehouse),
Key: aws.String(probeKey),
Body: nil,
}); err != nil {
t.Fatalf("PutObject with sts-vended creds: %v", err)
}
_, _ = s3v.DeleteObject(ctx, &s3.DeleteObjectInput{
Bucket: aws.String(ucWarehouse),
Key: aws.String(probeKey),
})
})
// Slice 2: UC starts with master-role config and CRUD works.
stsEndpoint := fmt.Sprintf("http://host.docker.internal:%d", env.s3Port)
t.Log(">>> starting Unity Catalog server (master role)...")
env.startUnityCatalog(t, ctx, ucServerOpts{
MasterRoleArn: ucVendedRoleArn,
ExtraEnv: map[string]string{
"AWS_ENDPOINT_URL": stsEndpoint,
"AWS_ENDPOINT_URL_STS": stsEndpoint,
"AWS_ACCESS_KEY_ID": env.accessKey,
"AWS_SECRET_ACCESS_KEY": env.secretKey,
"AWS_REGION": "us-east-1",
},
})
t.Log(">>> Unity Catalog ready")
uc := newUCClient(fmt.Sprintf("http://127.0.0.1:%d", env.ucHostPort))
suffix := time.Now().UnixNano()
catalogName := fmt.Sprintf("seaweed_uc_role_%d", suffix)
schemaName := fmt.Sprintf("ns_%d", suffix)
tableName := fmt.Sprintf("events_%d", suffix)
tableLocation := fmt.Sprintf("s3://%s/%s/%s/%s", ucWarehouse, ucWarehouseKey, schemaName, tableName)
defer func() {
_ = uc.deleteTable(context.Background(), catalogName+"."+schemaName+"."+tableName)
_ = uc.deleteSchema(context.Background(), catalogName+"."+schemaName)
_ = uc.deleteCatalog(context.Background(), catalogName)
}()
t.Run("UnityCatalogAcceptsMasterRoleConfig", func(t *testing.T) {
if _, err := uc.createCatalog(ctx, ucCreateCatalog{
Name: catalogName,
StorageRoot: fmt.Sprintf("s3://%s/%s", ucWarehouse, ucWarehouseKey),
}); err != nil {
t.Fatalf("create catalog: %v", err)
}
if _, err := uc.createSchema(ctx, ucCreateSchema{Name: schemaName, CatalogName: catalogName}); err != nil {
t.Fatalf("create schema: %v", err)
}
created, err := uc.createTable(ctx, ucCreateTable{
Name: tableName,
CatalogName: catalogName,
SchemaName: schemaName,
TableType: "EXTERNAL",
DataSourceFormat: "DELTA",
Columns: []ucColumn{
{Name: "id", TypeText: "long", TypeName: "LONG", TypeJSON: `{"name":"id","type":"long","nullable":true,"metadata":{}}`, Position: 0, Nullable: true},
},
StorageLocation: tableLocation,
})
if err != nil {
t.Fatalf("create external delta table: %v", err)
}
if created.TableID == "" {
t.Fatalf("expected table_id in CreateTable response, got %+v", created)
}
// The actual UC -> SeaweedFS STS handoff is the still-unsolved slice.
// Log what happens, but don't fail the test on it: SeaweedFS' STS is
// known-good (slice 1 above), and UC's CRUD is known-good (this slice).
_, ucCredErr := uc.generateTemporaryTableCredentials(ctx, created.TableID, "READ_WRITE")
if ucCredErr != nil {
t.Logf("UC StsClient still hits real AWS (UC bug, fix in unitycatalog/unitycatalog#1532): %v", ucCredErr)
} else {
t.Logf("UC StsClient honors endpoint override; unitycatalog/unitycatalog#1532 (or equivalent) is in this image.")
}
})
}
// assumeRoleViaSeaweedFS calls sts:AssumeRole against SeaweedFS' STS endpoint
// using the Go AWS SDK, mirroring how lakekeeper/polaris tests prove
// SeaweedFS' STS path works.
func assumeRoleViaSeaweedFS(ctx context.Context, endpoint, accessKey, secretKey, roleArn string) (aws.Credentials, error) {
cfg, err := config.LoadDefaultConfig(ctx,
config.WithRegion("us-east-1"),
config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(accessKey, secretKey, "")),
)
if err != nil {
return aws.Credentials{}, fmt.Errorf("load aws config: %w", err)
}
// Override the STS endpoint via service-specific BaseEndpoint instead of
// the deprecated EndpointResolverWithOptions wired through the global
// config. This is the supported path in aws-sdk-go-v2 since v1.21.
client := sts.NewFromConfig(cfg, func(o *sts.Options) {
o.BaseEndpoint = aws.String(endpoint)
})
resp, err := client.AssumeRole(ctx, &sts.AssumeRoleInput{
RoleArn: aws.String(roleArn),
RoleSessionName: aws.String("uc-master-role-test"),
})
if err != nil {
return aws.Credentials{}, err
}
if resp.Credentials == nil {
return aws.Credentials{}, fmt.Errorf("AssumeRole returned no credentials")
}
return aws.Credentials{
AccessKeyID: aws.ToString(resp.Credentials.AccessKeyId),
SecretAccessKey: aws.ToString(resp.Credentials.SecretAccessKey),
SessionToken: aws.ToString(resp.Credentials.SessionToken),
Source: "seaweedfs-sts",
}, nil
}

View File

@@ -0,0 +1,379 @@
package unity_catalog
import (
"context"
"fmt"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/seaweedfs/seaweedfs/test/testutil"
)
const (
ucImageDefault = "unitycatalog/unitycatalog:v0.4.0"
ucContainerCfg = "/home/unitycatalog/etc/conf/server.properties"
ucStartupGrace = 90 * time.Second
ucAPIBase = "/api/2.1/unity-catalog"
ucWarehouse = "lakehouse"
ucWarehouseKey = "warehouse"
// Role used by the master-role STS-vended variant of the test. The trust
// policy is wide open so any caller can assume it; in production UC
// would set this ARN as `aws.masterRoleArn`.
ucVendedRoleArn = "arn:aws:iam::000000000000:role/UnityCatalogVendedRole"
ucVendedRoleName = "UnityCatalogVendedRole"
)
type testEnv struct {
seaweedDir string
weedBinary string
dataDir string
bindIP string
s3Port int
s3GrpcPort int
masterPort int
masterGrpcPort int
filerPort int
filerGrpcPort int
volumePort int
volumeGrpcPort int
weedProcess *exec.Cmd
weedCancel context.CancelFunc
accessKey string
secretKey string
ucImage string
ucContainerID string
ucHostPort int
}
// ucServerOpts customizes the Unity Catalog server.properties and runtime
// environment. Defaults match the upstream playground (static keys, no master
// role).
type ucServerOpts struct {
// MasterRoleArn populates aws.masterRoleArn. Empty means UC falls back to
// static aws.accessKey / aws.secretKey for storage operations.
MasterRoleArn string
// ExtraEnv adds environment variables to the UC container, useful for
// AWS_ENDPOINT_URL_STS-style overrides.
ExtraEnv map[string]string
}
func newTestEnv(t *testing.T) *testEnv {
t.Helper()
wd, err := os.Getwd()
if err != nil {
t.Fatalf("getwd: %v", err)
}
seaweedDir := wd
for i := 0; i < 8; i++ {
if _, err := os.Stat(filepath.Join(seaweedDir, "go.mod")); err == nil {
break
}
seaweedDir = filepath.Dir(seaweedDir)
}
weedBinary := filepath.Join(seaweedDir, "weed", "weed")
if _, err := os.Stat(weedBinary); err != nil {
weedBinary = "weed"
if _, err := exec.LookPath(weedBinary); err != nil {
t.Skip("weed binary not found, skipping integration test")
}
}
dataDir, err := os.MkdirTemp("", "seaweed-uc-test-*")
if err != nil {
t.Fatalf("mkdtemp: %v", err)
}
bindIP := testutil.FindBindIP()
ports := testutil.MustAllocatePorts(t, 9)
masterPort, masterGrpcPort := ports[0], ports[1]
volumePort, volumeGrpcPort := ports[2], ports[3]
filerPort, filerGrpcPort := ports[4], ports[5]
s3Port, s3GrpcPort := ports[6], ports[7]
ucHostPort := ports[8]
image := os.Getenv("UC_IMAGE")
if image == "" {
image = ucImageDefault
}
return &testEnv{
seaweedDir: seaweedDir,
weedBinary: weedBinary,
dataDir: dataDir,
bindIP: bindIP,
s3Port: s3Port,
s3GrpcPort: s3GrpcPort,
masterPort: masterPort,
masterGrpcPort: masterGrpcPort,
filerPort: filerPort,
filerGrpcPort: filerGrpcPort,
volumePort: volumePort,
volumeGrpcPort: volumeGrpcPort,
accessKey: "admin",
secretKey: "admin",
ucImage: image,
ucHostPort: ucHostPort,
}
}
// startSeaweedFS starts a `weed mini` instance. If iamJSON is empty, a minimal
// admin-only IAM config is used. When iamJSON is non-empty, it is passed to
// both -s3.config and -s3.iam.config so the STS handler is enabled (mirroring
// the lakekeeper test).
func (env *testEnv) startSeaweedFS(t *testing.T, iamJSON string) {
t.Helper()
enableSTS := iamJSON != ""
if iamJSON == "" {
iamJSON = fmt.Sprintf(`{
"identities": [
{
"name": "admin",
"credentials": [{"accessKey": %q, "secretKey": %q}],
"actions": ["Admin", "Read", "List", "Tagging", "Write"]
}
]
}`, env.accessKey, env.secretKey)
}
iamConfigPath := filepath.Join(env.dataDir, "iam.json")
if err := os.WriteFile(iamConfigPath, []byte(iamJSON), 0644); err != nil {
t.Fatalf("write iam config: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
env.weedCancel = cancel
weedArgs := []string{"-v", "4", "mini",
"-master.port", fmt.Sprintf("%d", env.masterPort),
"-master.port.grpc", fmt.Sprintf("%d", env.masterGrpcPort),
"-volume.port", fmt.Sprintf("%d", env.volumePort),
"-volume.port.grpc", fmt.Sprintf("%d", env.volumeGrpcPort),
"-filer.port", fmt.Sprintf("%d", env.filerPort),
"-filer.port.grpc", fmt.Sprintf("%d", env.filerGrpcPort),
"-s3.port", fmt.Sprintf("%d", env.s3Port),
"-s3.port.grpc", fmt.Sprintf("%d", env.s3GrpcPort),
"-s3.config", iamConfigPath,
"-ip", env.bindIP,
"-ip.bind", "0.0.0.0",
"-dir", env.dataDir,
}
if enableSTS {
weedArgs = append(weedArgs, "-s3.iam.config", iamConfigPath, "-s3.iam.readOnly=false")
}
cmd := exec.CommandContext(ctx, env.weedBinary, weedArgs...)
cmd.Dir = env.dataDir
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
t.Fatalf("start weed mini: %v", err)
}
env.weedProcess = cmd
if !testutil.WaitForService(fmt.Sprintf("http://127.0.0.1:%d/status", env.s3Port), testutil.SeaweedMiniStartupTimeout) {
t.Fatalf("S3 API at 127.0.0.1:%d did not become ready", env.s3Port)
}
}
// startUnityCatalog launches the Unity Catalog OSS server in Docker against
// the running SeaweedFS instance. It mirrors the upstream playground's
// server.properties layout and bind-mounts only that single file (matching
// docker-compose.yaml from the playground).
func (env *testEnv) startUnityCatalog(t *testing.T, ctx context.Context, opts ucServerOpts) {
t.Helper()
s3EndpointForContainer := fmt.Sprintf("http://host.docker.internal:%d", env.s3Port)
props := strings.Join([]string{
"server.env=test",
"server.authorization=disable",
"server.cookie-timeout=PT1H",
"server.managed-table.enabled=false",
fmt.Sprintf("aws.masterRoleArn=%s", opts.MasterRoleArn),
fmt.Sprintf("aws.accessKey=%s", env.accessKey),
fmt.Sprintf("aws.secretKey=%s", env.secretKey),
"aws.region=us-east-1",
fmt.Sprintf("aws.endpoint=%s", s3EndpointForContainer),
// UC keys perBucketS3Configs by NormalizedURL.from(bucketPath) and
// looks up using the storageBase, which is "s3://<bucket>" (scheme +
// authority only). The playground's "s3://lakehouse/warehouse" never
// matches because of that asymmetry; the bucket-only form works.
fmt.Sprintf("s3.bucketPath.0=s3://%s", ucWarehouse),
"s3.region.0=us-east-1",
fmt.Sprintf("s3.awsRoleArn.0=%s", opts.MasterRoleArn),
fmt.Sprintf("s3.accessKey.0=%s", env.accessKey),
fmt.Sprintf("s3.secretKey.0=%s", env.secretKey),
fmt.Sprintf("s3.endpoint.0=%s", s3EndpointForContainer),
"",
}, "\n")
confDir := filepath.Join(env.dataDir, "uc-conf")
if err := os.MkdirAll(confDir, 0755); err != nil {
t.Fatalf("mkdir uc-conf: %v", err)
}
propsPath := filepath.Join(confDir, "server.properties")
if err := os.WriteFile(propsPath, []byte(props), 0644); err != nil {
t.Fatalf("write server.properties: %v", err)
}
containerName := fmt.Sprintf("seaweed-unity-catalog-%d", time.Now().UnixNano())
args := []string{
"run", "-d", "--rm",
"--name", containerName,
"--add-host", "host.docker.internal:host-gateway",
"-p", fmt.Sprintf("%d:8080", env.ucHostPort),
"-v", fmt.Sprintf("%s:%s:ro", propsPath, ucContainerCfg),
"-e", "JAVA_OPTS=-Xmx1g",
}
for k, v := range opts.ExtraEnv {
args = append(args, "-e", fmt.Sprintf("%s=%s", k, v))
}
args = append(args, env.ucImage)
out, err := exec.CommandContext(ctx, "docker", args...).CombinedOutput()
if err != nil {
t.Fatalf("docker run unity-catalog: %v\n%s", err, out)
}
// docker run -d prefixes pull progress / warnings before the final container
// ID line when the image isn't cached locally. Trim to the last whitespace-
// separated token so the ID survives a fresh CI runner.
fields := strings.Fields(string(out))
if len(fields) == 0 {
t.Fatalf("docker run produced no output; combined: %q", string(out))
}
env.ucContainerID = fields[len(fields)-1]
t.Logf("unity-catalog container id: %s", env.ucContainerID)
probe := fmt.Sprintf("http://127.0.0.1:%d%s/catalogs", env.ucHostPort, ucAPIBase)
deadline := time.Now().Add(ucStartupGrace)
var lastErr error
for time.Now().Before(deadline) {
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, probe, nil)
resp, err := (&http.Client{Timeout: 3 * time.Second}).Do(req)
if err == nil {
resp.Body.Close()
if resp.StatusCode == http.StatusOK {
return
}
lastErr = fmt.Errorf("status %d", resp.StatusCode)
} else {
lastErr = err
}
select {
case <-ctx.Done():
t.Fatalf("ctx done while waiting for unity catalog: %v", ctx.Err())
case <-time.After(time.Second):
}
}
logs, _ := exec.Command("docker", "logs", "--tail", "200", env.ucContainerID).CombinedOutput()
t.Fatalf("unity catalog did not become ready: %v\nrecent logs:\n%s", lastErr, logs)
}
func (env *testEnv) cleanup(t *testing.T) {
if env.ucContainerID != "" {
if logs, err := exec.Command("docker", "logs", "--tail", "200", env.ucContainerID).CombinedOutput(); err == nil {
t.Logf("unity-catalog tail logs:\n%s", logs)
}
_ = exec.Command("docker", "rm", "-f", env.ucContainerID).Run()
}
if env.weedCancel != nil {
env.weedCancel()
}
if env.weedProcess != nil {
_ = env.weedProcess.Wait()
}
if env.dataDir != "" {
_ = os.RemoveAll(env.dataDir)
}
}
// -- S3 client helpers ---------------------------------------------------------
func (env *testEnv) newHostS3Client(t *testing.T, ctx context.Context) *s3.Client {
t.Helper()
return env.newHostS3ClientWithCreds(t, ctx, env.accessKey, env.secretKey, "")
}
func (env *testEnv) newHostS3ClientWithCreds(t *testing.T, ctx context.Context, ak, sk, token string) *s3.Client {
t.Helper()
endpoint := fmt.Sprintf("http://127.0.0.1:%d", env.s3Port)
cfg, err := config.LoadDefaultConfig(ctx,
config.WithRegion("us-east-1"),
config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(ak, sk, token)),
)
if err != nil {
t.Fatalf("load aws config: %v", err)
}
return s3.NewFromConfig(cfg, func(o *s3.Options) {
o.BaseEndpoint = aws.String(endpoint)
o.UsePathStyle = true
})
}
// stsEnabledIAMConfig returns an iam.json that defines an admin user, the
// UnityCatalogVendedRole role with a permissive trust policy, and a FullAccess
// policy attached to it. This is the SeaweedFS-side counterpart of the
// `aws.masterRoleArn` configuration on the Unity Catalog server.
func stsEnabledIAMConfig(accessKey, secretKey string) string {
return fmt.Sprintf(`{
"identities": [
{
"name": "admin",
"credentials": [{"accessKey": %q, "secretKey": %q}],
"actions": ["Admin", "Read", "List", "Tagging", "Write"]
}
],
"sts": {
"tokenDuration": "12h",
"maxSessionLength": "24h",
"issuer": "seaweedfs-sts",
"signingKey": "dGVzdC1zaWduaW5nLWtleS1mb3Itc3RzLWludGVncmF0aW9uLXRlc3Rz"
},
"roles": [
{
"roleName": %q,
"roleArn": %q,
"trustPolicy": {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": "*",
"Action": "sts:AssumeRole"
}
]
},
"attachedPolicies": ["FullAccess"]
}
],
"policies": [
{
"name": "FullAccess",
"document": {
"Version": "2012-10-17",
"Statement": [
{"Effect": "Allow", "Action": "*", "Resource": "*"}
]
}
}
]
}`, accessKey, secretKey, ucVendedRoleName, ucVendedRoleArn)
}

View File

@@ -0,0 +1,183 @@
// Package unity_catalog provides integration tests that run Unity Catalog
// OSS in Docker against a SeaweedFS S3 backend. The base test mirrors the
// configuration used by the upstream UC playground at
// https://github.com/data-engineering-helpers/mds-in-a-box/tree/main/unitycatalog-playground:
// static aws.accessKey/aws.secretKey, no master role, EXTERNAL Delta tables.
package unity_catalog
import (
"context"
"fmt"
"testing"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/seaweedfs/seaweedfs/test/testutil"
)
// TestUnityCatalogDeltaIntegration brings up SeaweedFS, runs Unity Catalog OSS
// in Docker against it, and exercises catalog/schema/table CRUD plus
// temporary-table-credentials and storage I/O via vended credentials. The UC
// server is configured with static keys (aws.masterRoleArn empty), matching
// the playground's working configuration when targeting SeaweedFS.
func TestUnityCatalogDeltaIntegration(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in -short mode")
}
if !testutil.HasDocker() {
t.Skip("docker not available")
}
env := newTestEnv(t)
defer env.cleanup(t)
t.Log(">>> starting SeaweedFS for Unity Catalog test...")
env.startSeaweedFS(t, "")
t.Log(">>> SeaweedFS ready")
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Minute)
defer cancel()
s3c := env.newHostS3Client(t, ctx)
if _, err := s3c.CreateBucket(ctx, &s3.CreateBucketInput{Bucket: aws.String(ucWarehouse)}); err != nil {
t.Fatalf("create warehouse bucket: %v", err)
}
t.Log(">>> starting Unity Catalog server (static keys)...")
env.startUnityCatalog(t, ctx, ucServerOpts{})
t.Log(">>> Unity Catalog ready")
uc := newUCClient(fmt.Sprintf("http://127.0.0.1:%d", env.ucHostPort))
suffix := time.Now().UnixNano()
catalogName := fmt.Sprintf("seaweed_uc_%d", suffix)
schemaName := fmt.Sprintf("ns_%d", suffix)
tableName := fmt.Sprintf("events_%d", suffix)
tableLocation := fmt.Sprintf("s3://%s/%s/%s/%s", ucWarehouse, ucWarehouseKey, schemaName, tableName)
defer func() {
_ = uc.deleteTable(context.Background(), catalogName+"."+schemaName+"."+tableName)
_ = uc.deleteSchema(context.Background(), catalogName+"."+schemaName)
_ = uc.deleteCatalog(context.Background(), catalogName)
}()
t.Run("CreateCatalog", func(t *testing.T) {
got, err := uc.createCatalog(ctx, ucCreateCatalog{
Name: catalogName,
Comment: "seaweedfs integration test",
StorageRoot: fmt.Sprintf("s3://%s/%s", ucWarehouse, ucWarehouseKey),
})
if err != nil {
t.Fatalf("create catalog: %v", err)
}
if got.Name != catalogName {
t.Fatalf("catalog name = %q, want %q", got.Name, catalogName)
}
})
t.Run("CreateSchema", func(t *testing.T) {
got, err := uc.createSchema(ctx, ucCreateSchema{
Name: schemaName,
CatalogName: catalogName,
})
if err != nil {
t.Fatalf("create schema: %v", err)
}
if got.Name != schemaName || got.CatalogName != catalogName {
t.Fatalf("schema = %+v, want name=%q catalog=%q", got, schemaName, catalogName)
}
})
var createdTable ucTableInfo
t.Run("CreateExternalDeltaTable", func(t *testing.T) {
columns := []ucColumn{
{Name: "id", TypeText: "long", TypeName: "LONG", TypeJSON: `{"name":"id","type":"long","nullable":true,"metadata":{}}`, Position: 0, Nullable: true},
{Name: "value", TypeText: "string", TypeName: "STRING", TypeJSON: `{"name":"value","type":"string","nullable":true,"metadata":{}}`, Position: 1, Nullable: true},
}
got, err := uc.createTable(ctx, ucCreateTable{
Name: tableName,
CatalogName: catalogName,
SchemaName: schemaName,
TableType: "EXTERNAL",
DataSourceFormat: "DELTA",
Columns: columns,
StorageLocation: tableLocation,
})
if err != nil {
t.Fatalf("create table: %v", err)
}
if got.TableType != "EXTERNAL" || got.DataSourceFormat != "DELTA" {
t.Fatalf("table types = %s/%s, want EXTERNAL/DELTA", got.TableType, got.DataSourceFormat)
}
if got.StorageLocation == "" {
t.Fatalf("table storage_location is empty in response: %+v", got)
}
createdTable = *got
})
t.Run("GetTable", func(t *testing.T) {
full := catalogName + "." + schemaName + "." + tableName
got, err := uc.getTable(ctx, full)
if err != nil {
t.Fatalf("get table: %v", err)
}
if got.Name != tableName {
t.Fatalf("get table name = %q, want %q", got.Name, tableName)
}
})
t.Run("ListTables", func(t *testing.T) {
tables, err := uc.listTables(ctx, catalogName, schemaName)
if err != nil {
t.Fatalf("list tables: %v", err)
}
found := false
for _, tbl := range tables {
if tbl.Name == tableName {
found = true
break
}
}
if !found {
t.Fatalf("created table %q not found in list", tableName)
}
})
t.Run("TemporaryTableCredentialsRejected", func(t *testing.T) {
// With aws.masterRoleArn empty AND no s3.sessionToken.0 set, UC OSS
// always tries to AssumeRole via its internal StsClient (see
// AwsCredentialVendor.createPerBucketCredentialGenerator). Against a
// non-AWS endpoint, that call doesn't reach a real STS, so UC returns
// "S3 bucket configuration not found." or an STS-side error. This is
// the gap users hit at <https://github.com/data-engineering-helpers/mds-in-a-box/blob/main/unitycatalog-playground/etc/conf/server.properties#L45>:
// "with simple S3 access and secret keys, Unity Catalog does not seem
// to work."
//
// The assertion is therefore inverted: we expect a non-nil error from
// /temporary-table-credentials with this configuration. A future
// variant can pin s3.sessionToken.0 (UC's StaticAwsCredentialGenerator
// path) once SeaweedFS' SigV4 path tolerates the vended session token.
if createdTable.TableID == "" {
t.Fatalf("created table has empty table_id; cannot request temporary credentials")
}
_, err := uc.generateTemporaryTableCredentials(ctx, createdTable.TableID, "READ_WRITE")
if err == nil {
t.Fatalf("expected /temporary-table-credentials to fail with the static-key playground configuration; it succeeded unexpectedly")
}
t.Logf("expected failure (UC static-key path requires AWS STS): %v", err)
})
t.Run("DeleteTableSchemaCatalog", func(t *testing.T) {
if err := uc.deleteTable(ctx, catalogName+"."+schemaName+"."+tableName); err != nil {
t.Fatalf("delete table: %v", err)
}
if err := uc.deleteSchema(ctx, catalogName+"."+schemaName); err != nil {
t.Fatalf("delete schema: %v", err)
}
if err := uc.deleteCatalog(ctx, catalogName); err != nil {
t.Fatalf("delete catalog: %v", err)
}
})
}