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:
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())
|
||||
Reference in New Issue
Block a user