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
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:
321
test/s3tables/Makefile
Normal file
321
test/s3tables/Makefile
Normal 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
11
test/s3tables/README.txt
Normal 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
|
||||
559
test/s3tables/admin/admin_iceberg_pages_test.go
Normal file
559
test/s3tables/admin/admin_iceberg_pages_test.go
Normal 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.")
|
||||
})
|
||||
}
|
||||
13
test/s3tables/catalog/Dockerfile.lance
Normal file
13
test/s3tables/catalog/Dockerfile.lance
Normal 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"]
|
||||
13
test/s3tables/catalog/Dockerfile.pyiceberg
Normal file
13
test/s3tables/catalog/Dockerfile.pyiceberg
Normal 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"]
|
||||
37
test/s3tables/catalog/Makefile
Normal file
37
test/s3tables/catalog/Makefile
Normal 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-*
|
||||
33
test/s3tables/catalog/docker-compose.test.yaml
Normal file
33
test/s3tables/catalog/docker-compose.test.yaml
Normal 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}
|
||||
211
test/s3tables/catalog/duckdb_deterministic_location_test.go
Normal file
211
test/s3tables/catalog/duckdb_deterministic_location_test.go
Normal 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]
|
||||
}
|
||||
477
test/s3tables/catalog/duckdb_oauth_test.go
Normal file
477
test/s3tables/catalog/duckdb_oauth_test.go
Normal 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)
|
||||
}
|
||||
215
test/s3tables/catalog/duckdb_vended_credentials_test.go
Normal file
215
test/s3tables/catalog/duckdb_vended_credentials_test.go
Normal 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
|
||||
}
|
||||
585
test/s3tables/catalog/iceberg_catalog_test.go
Normal file
585
test/s3tables/catalog/iceberg_catalog_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
283
test/s3tables/catalog/issue_9103_test.go
Normal file
283
test/s3tables/catalog/issue_9103_test.go
Normal 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
|
||||
}
|
||||
63
test/s3tables/catalog/lance_client_test.go
Normal file
63
test/s3tables/catalog/lance_client_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
378
test/s3tables/catalog/lance_namespace_test.go
Normal file
378
test/s3tables/catalog/lance_namespace_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
137
test/s3tables/catalog/pyiceberg_test.go
Normal file
137
test/s3tables/catalog/pyiceberg_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
36
test/s3tables/catalog/pyiceberg_test_helpers_test.go
Normal file
36
test/s3tables/catalog/pyiceberg_test_helpers_test.go
Normal 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)
|
||||
}
|
||||
106
test/s3tables/catalog/test_lance_namespace.py
Executable file
106
test/s3tables/catalog/test_lance_namespace.py
Executable 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())
|
||||
252
test/s3tables/catalog/test_rest_catalog.py
Normal file
252
test/s3tables/catalog/test_rest_catalog.py
Normal 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())
|
||||
224
test/s3tables/catalog/test_rest_catalog_auth.py
Normal file
224
test/s3tables/catalog/test_rest_catalog_auth.py
Normal 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())
|
||||
12
test/s3tables/catalog_clickhouse/Dockerfile.writer
Normal file
12
test/s3tables/catalog_clickhouse/Dockerfile.writer
Normal 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"]
|
||||
98
test/s3tables/catalog_clickhouse/README.md
Normal file
98
test/s3tables/catalog_clickhouse/README.md
Normal 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.
|
||||
88
test/s3tables/catalog_clickhouse/append_rows.py
Normal file
88
test/s3tables/catalog_clickhouse/append_rows.py
Normal 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())
|
||||
892
test/s3tables/catalog_clickhouse/clickhouse_catalog_test.go
Normal file
892
test/s3tables/catalog_clickhouse/clickhouse_catalog_test.go
Normal 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)
|
||||
}
|
||||
54
test/s3tables/catalog_clickhouse/read_rows.py
Normal file
54
test/s3tables/catalog_clickhouse/read_rows.py
Normal 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())
|
||||
12
test/s3tables/catalog_doris/Dockerfile.writer
Normal file
12
test/s3tables/catalog_doris/Dockerfile.writer
Normal 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"]
|
||||
80
test/s3tables/catalog_doris/README.md
Normal file
80
test/s3tables/catalog_doris/README.md
Normal 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.
|
||||
87
test/s3tables/catalog_doris/append_rows.py
Normal file
87
test/s3tables/catalog_doris/append_rows.py
Normal 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())
|
||||
943
test/s3tables/catalog_doris/doris_catalog_test.go
Normal file
943
test/s3tables/catalog_doris/doris_catalog_test.go
Normal 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)
|
||||
}
|
||||
12
test/s3tables/catalog_dremio/Dockerfile.writer
Normal file
12
test/s3tables/catalog_dremio/Dockerfile.writer
Normal 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"]
|
||||
55
test/s3tables/catalog_dremio/README.md
Normal file
55
test/s3tables/catalog_dremio/README.md
Normal 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.
|
||||
87
test/s3tables/catalog_dremio/append_rows.py
Normal file
87
test/s3tables/catalog_dremio/append_rows.py
Normal 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())
|
||||
1146
test/s3tables/catalog_dremio/dremio_catalog_test.go
Normal file
1146
test/s3tables/catalog_dremio/dremio_catalog_test.go
Normal file
File diff suppressed because it is too large
Load Diff
15
test/s3tables/catalog_duckdb_lance/Dockerfile.seed
Normal file
15
test/s3tables/catalog_duckdb_lance/Dockerfile.seed
Normal 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"]
|
||||
66
test/s3tables/catalog_duckdb_lance/README.md
Normal file
66
test/s3tables/catalog_duckdb_lance/README.md
Normal 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.
|
||||
48
test/s3tables/catalog_duckdb_lance/duckdb_lance_ops.sql
Normal file
48
test/s3tables/catalog_duckdb_lance/duckdb_lance_ops.sql
Normal 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__';
|
||||
390
test/s3tables/catalog_duckdb_lance/duckdb_lance_test.go
Normal file
390
test/s3tables/catalog_duckdb_lance/duckdb_lance_test.go
Normal 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)
|
||||
}
|
||||
79
test/s3tables/catalog_duckdb_lance/seed_table.py
Normal file
79
test/s3tables/catalog_duckdb_lance/seed_table.py
Normal 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())
|
||||
22
test/s3tables/catalog_lancedb/Dockerfile.client
Normal file
22
test/s3tables/catalog_lancedb/Dockerfile.client
Normal 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"]
|
||||
56
test/s3tables/catalog_lancedb/README.md
Normal file
56
test/s3tables/catalog_lancedb/README.md
Normal 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.
|
||||
291
test/s3tables/catalog_lancedb/lancedb_catalog_test.go
Normal file
291
test/s3tables/catalog_lancedb/lancedb_catalog_test.go
Normal 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)
|
||||
}
|
||||
244
test/s3tables/catalog_lancedb/lancedb_ops.py
Normal file
244
test/s3tables/catalog_lancedb/lancedb_ops.py
Normal 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())
|
||||
79
test/s3tables/catalog_risingwave/risingwave_catalog_test.go
Normal file
79
test/s3tables/catalog_risingwave/risingwave_catalog_test.go
Normal 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!")
|
||||
}
|
||||
219
test/s3tables/catalog_risingwave/risingwave_dml_test.go
Normal file
219
test/s3tables/catalog_risingwave/risingwave_dml_test.go
Normal 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)
|
||||
}
|
||||
438
test/s3tables/catalog_risingwave/setup_test.go
Normal file
438
test/s3tables/catalog_risingwave/setup_test.go
Normal 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)
|
||||
}
|
||||
361
test/s3tables/catalog_spark/setup_test.go
Normal file
361
test/s3tables/catalog_spark/setup_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
135
test/s3tables/catalog_spark/spark_deterministic_location_test.go
Normal file
135
test/s3tables/catalog_spark/spark_deterministic_location_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
356
test/s3tables/catalog_spark/spark_operations_test.go
Normal file
356
test/s3tables/catalog_spark/spark_operations_test.go
Normal 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")
|
||||
}
|
||||
63
test/s3tables/catalog_spark_lance/README.md
Normal file
63
test/s3tables/catalog_spark_lance/README.md
Normal 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.
|
||||
153
test/s3tables/catalog_spark_lance/spark_lance_ops.py
Normal file
153
test/s3tables/catalog_spark_lance/spark_lance_ops.py
Normal 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())
|
||||
339
test/s3tables/catalog_spark_lance/spark_lance_test.go
Normal file
339
test/s3tables/catalog_spark_lance/spark_lance_test.go
Normal 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)
|
||||
}
|
||||
248
test/s3tables/catalog_trino/trino_blog_operations_test.go
Normal file
248
test/s3tables/catalog_trino/trino_blog_operations_test.go
Normal 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 ""
|
||||
}
|
||||
616
test/s3tables/catalog_trino/trino_catalog_test.go
Normal file
616
test/s3tables/catalog_trino/trino_catalog_test.go
Normal 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)
|
||||
}
|
||||
208
test/s3tables/catalog_trino/trino_crud_operations_test.go
Normal file
208
test/s3tables/catalog_trino/trino_crud_operations_test.go
Normal 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")
|
||||
}
|
||||
121
test/s3tables/catalog_trino/trino_deterministic_location_test.go
Normal file
121
test/s3tables/catalog_trino/trino_deterministic_location_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
76
test/s3tables/catalog_trino/trino_issue_9074_repro_test.go
Normal file
76
test/s3tables/catalog_trino/trino_issue_9074_repro_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
120
test/s3tables/catalog_trino/trino_recreate_table_test.go
Normal file
120
test/s3tables/catalog_trino/trino_recreate_table_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
690
test/s3tables/lakekeeper/lakekeeper_test.go
Normal file
690
test/s3tables/lakekeeper/lakekeeper_test.go
Normal 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
|
||||
}
|
||||
17
test/s3tables/lifecycle/Dockerfile.lance
Normal file
17
test/s3tables/lifecycle/Dockerfile.lance
Normal 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"]
|
||||
10
test/s3tables/lifecycle/Dockerfile.pyiceberg
Normal file
10
test/s3tables/lifecycle/Dockerfile.pyiceberg
Normal 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"]
|
||||
77
test/s3tables/lifecycle/README.md
Normal file
77
test/s3tables/lifecycle/README.md
Normal 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.
|
||||
277
test/s3tables/lifecycle/cluster_test.go
Normal file
277
test/s3tables/lifecycle/cluster_test.go
Normal 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)
|
||||
}
|
||||
164
test/s3tables/lifecycle/iceberg_lifecycle.py
Normal file
164
test/s3tables/lifecycle/iceberg_lifecycle.py
Normal 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()
|
||||
389
test/s3tables/lifecycle/iceberg_lifecycle_test.go
Normal file
389
test/s3tables/lifecycle/iceberg_lifecycle_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
168
test/s3tables/lifecycle/lance_lifecycle.py
Normal file
168
test/s3tables/lifecycle/lance_lifecycle.py
Normal 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())
|
||||
130
test/s3tables/lifecycle/lance_lifecycle_test.go
Normal file
130
test/s3tables/lifecycle/lance_lifecycle_test.go
Normal 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)
|
||||
}
|
||||
967
test/s3tables/maintenance/maintenance_integration_test.go
Normal file
967
test/s3tables/maintenance/maintenance_integration_test.go
Normal 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)
|
||||
}
|
||||
380
test/s3tables/polaris/polaris_env_test.go
Normal file
380
test/s3tables/polaris/polaris_env_test.go
Normal 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
|
||||
}
|
||||
764
test/s3tables/polaris/polaris_test.go
Normal file
764
test/s3tables/polaris/polaris_test.go
Normal 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
|
||||
}
|
||||
587
test/s3tables/sts_integration/sts_integration_test.go
Normal file
587
test/s3tables/sts_integration/sts_integration_test.go
Normal 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
|
||||
431
test/s3tables/table-buckets/client.go
Normal file
431
test/s3tables/table-buckets/client.go
Normal 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)
|
||||
}
|
||||
802
test/s3tables/table-buckets/s3tables_integration_test.go
Normal file
802
test/s3tables/table-buckets/s3tables_integration_test.go
Normal 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)
|
||||
}
|
||||
59
test/s3tables/table-buckets/setup.go
Normal file
59
test/s3tables/table-buckets/setup.go
Normal 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"
|
||||
)
|
||||
11
test/s3tables/unity_catalog/Dockerfile.delta-rs
Normal file
11
test/s3tables/unity_catalog/Dockerfile.delta-rs
Normal 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"]
|
||||
104
test/s3tables/unity_catalog/README.md
Normal file
104
test/s3tables/unity_catalog/README.md
Normal 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.
|
||||
184
test/s3tables/unity_catalog/client_test.go
Normal file
184
test/s3tables/unity_catalog/client_test.go
Normal 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
|
||||
}
|
||||
125
test/s3tables/unity_catalog/delta_rs_test.go
Normal file
125
test/s3tables/unity_catalog/delta_rs_test.go
Normal 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))
|
||||
}
|
||||
137
test/s3tables/unity_catalog/delta_rs_writer.py
Normal file
137
test/s3tables/unity_catalog/delta_rs_writer.py
Normal 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)
|
||||
201
test/s3tables/unity_catalog/master_role_test.go
Normal file
201
test/s3tables/unity_catalog/master_role_test.go
Normal 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
|
||||
}
|
||||
379
test/s3tables/unity_catalog/setup_test.go
Normal file
379
test/s3tables/unity_catalog/setup_test.go
Normal 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)
|
||||
}
|
||||
183
test/s3tables/unity_catalog/unity_catalog_test.go
Normal file
183
test/s3tables/unity_catalog/unity_catalog_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user