test(s3tables): add an OLake Iceberg catalog integration test (#11441)

OLake (github.com/datazip-inc/olake) is a CDC and ingestion engine that writes
Apache Iceberg. It covers two paths none of the existing catalog suites reach.

It is a strict Java Iceberg client. OLake does not write Iceberg from Go — its
Go process spawns a Java sidecar over gRPC and writes through the official
Apache Iceberg library, because the Go library has no equality deletes and CDC
needs them. That makes it the client class weed/s3api/iceberg's metadata
compliance backfill exists to serve: the one that fails with "Cannot parse
missing long current-snapshot-id" when spec-required keys are omitted.

And it produces equality deletes. Its upsert path commits operation=overwrite
with an equality-delete file and a delete manifest. ClickHouse, Doris, Trino,
Spark and DuckDB all only append, so nothing else in this directory exercises
a delete manifest at all.

Six subtests: the destination check reaches SUCCEEDED and actually loads the
REST catalog; discover enumerates the source; a full sync commits a snapshot;
PyIceberg reads back what the Java writer committed; an update plus a re-sync
records an overwrite carrying equality deletes with a delete manifest in the
current snapshot; and the catalog does not rewrite manifests the official Java
writer produced, which gives the manifest-repair path a negative test to go
with the ClickHouse positive one.

What it deliberately does not assert is a delete-applied read. PyIceberg
refuses to scan a table carrying equality deletes (apache/iceberg#6568) while
reading its metadata fine, and an engine that can apply them costs a
multi-gigabyte image. Recording the commit correctly is the catalog's
contract; applying deletes on read is the engine's. The README says so, says
the read half was verified by hand once with StarRocks, and warns against
later "upgrading" this to a PyIceberg rows read — which would either fail or,
if PyIceberg ever starts skipping deletes instead of raising, pass by not
looking.

The workflow job asserts the suite actually ran rather than trusting a green
exit, for the same reason: at least one top-level PASS and zero SKIP.

No product change — the destination config is the generic catalog_type=rest
with the standard OAuth2 client-credentials flow, and s3_path_style is not even
set, since OLake turns it on itself whenever s3_endpoint is non-empty.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ping Qiu
2026-09-25 07:39:27 +08:00
committed by GitHub
co-authored by Claude Opus 5
parent 11791fad6a
commit 635f69a821
5 changed files with 943 additions and 0 deletions
+111
View File
@@ -439,6 +439,117 @@ jobs:
path: test/s3tables/catalog_clickhouse/test-output.log
retention-days: 3
olake-iceberg-catalog-tests:
name: OLake Iceberg Catalog Integration Tests (${{ matrix.tag }})
runs-on: ubuntu-22.04
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
include:
# Pinned baseline, and latest so new OLake releases are exercised
# without a code change. OLake's Iceberg writer is a Java sidecar
# whose Iceberg version moves independently of the Go release, so
# the latest leg is the one that catches library drift.
- olake-image: olakego/source-postgres:v0.10.1
tag: "v0.10.1"
- olake-image: olakego/source-postgres:latest
tag: latest
steps:
- name: Check out code
uses: actions/checkout@v7
- name: Set up Go
uses: actions/setup-go@v7
with:
go-version-file: 'go.mod'
id: go
- name: Configure Docker Hub mirror
run: |
echo '{"registry-mirrors": ["https://mirror.gcr.io"]}' | sudo tee /etc/docker/daemon.json
sudo systemctl restart docker
- name: Pre-pull images
run: |
pull() { for i in 1 2 3; do docker pull "$1" && return 0; sleep 15; done; return 1; }
pull ${{ matrix.olake-image }}
pull postgres:16
pull python:3.11-slim
- name: Run go mod tidy
run: go mod tidy
- name: Install SeaweedFS
run: |
go install -buildvcs=false ./weed
- name: Run OLake Iceberg Catalog Integration Tests
timeout-minutes: 25
working-directory: test/s3tables/catalog_olake
env:
OLAKE_IMAGE: ${{ matrix.olake-image }}
run: |
set -x
set -o pipefail
echo "=== System Information ==="
uname -a
free -h
df -h
docker info
echo "=== Starting OLake Iceberg Catalog Tests ==="
go test -v -timeout 20m . 2>&1 | tee test-output.log || {
echo "OLake Iceberg catalog integration tests failed"
exit 1
}
# The suite skips itself when Docker is unavailable, so a green job is not
# by itself evidence that anything ran. Assert execution explicitly.
- name: Assert the suite actually ran
working-directory: test/s3tables/catalog_olake
run: |
log=test-output.log
if [ ! -f "$log" ]; then
echo "::error::no test-output.log; the suite did not run"
exit 1
fi
passes=$(grep -c '^--- PASS' "$log" || true)
skips=$(grep -c '^--- SKIP' "$log" || true)
echo "top-level PASS=$passes SKIP=$skips"
if [ "$skips" -gt 0 ]; then
echo "::error::the OLake suite skipped $skips top-level test(s); the environment it needs was not provisioned, so this job proves nothing"
grep '^--- SKIP' "$log" | head -20
exit 1
fi
if [ "$passes" -lt 1 ]; then
echo "::error::the OLake suite recorded no passing top-level test"
exit 1
fi
- name: Show test output on failure
if: failure()
working-directory: test/s3tables/catalog_olake
run: |
echo "=== Test Output ==="
if [ -f test-output.log ]; then
tail -200 test-output.log
fi
echo "=== Process information ==="
ps aux | grep -E "(weed|test|docker|olake|postgres)" || true
echo "=== Containers ==="
docker ps -a | head -30 || true
- name: Upload test logs on failure
if: failure()
uses: actions/upload-artifact@v7
with:
name: olake-iceberg-catalog-test-logs-${{ matrix.tag }}
path: test/s3tables/catalog_olake/test-output.log
retention-days: 3
polaris-integration-tests:
name: Polaris Integration Tests
runs-on: ubuntu-22.04
@@ -0,0 +1,14 @@
# Strict Iceberg reader used by the OLake integration test. PyIceberg is
# deliberate here: it rejects manifests that omit the spec's field ids and
# parquet without either field ids or a name mapping, so a passing read proves
# the catalog served something every engine can consume -- not just something
# OLake itself can read back.
FROM python:3.11-slim
WORKDIR /app
RUN pip install --no-cache-dir "pyiceberg[s3fs]==0.11.1" "pyarrow==25.0.0"
COPY inspect_table.py /app/
ENTRYPOINT ["python3", "/app/inspect_table.py"]
+104
View File
@@ -0,0 +1,104 @@
# OLake Iceberg Catalog Integration Test
An integration test for [OLake](https://github.com/datazip-inc/olake) against
SeaweedFS's Iceberg REST Catalog, in the same shape as `catalog_clickhouse`.
## Why OLake, given we already test five engines
Two things here are covered by nothing else in this directory.
**It is a strict Java Iceberg client.** OLake does not write Iceberg from Go —
its Go process spawns a Java sidecar over gRPC and writes through the official
Apache Iceberg library, because the Go library has no equality deletes and CDC
needs them. So this test exercises the client class that
`weed/s3api/iceberg/metadata_compliance.go` exists to serve: the one
that fails with *"Cannot parse missing long current-snapshot-id"* when the
catalog omits spec-required keys that `iceberg-go` strips via `omitempty`.
**It produces equality deletes.** OLake is a CDC tool. Its upsert path commits
`operation=overwrite` with an equality-delete file, and a delete manifest
alongside the data manifests. ClickHouse, StarRocks, Doris and DuckDB all only
append.
## What it asserts
`TestOLakeIcebergCatalog` runs six subtests against a `weed mini` cluster with
a pre-created table bucket and a Postgres source:
| Subtest | What a failure means |
|---|---|
| `CheckDestination` | `olake check` did not reach `SUCCEEDED`, or it passed without ever loading `org.apache.iceberg.rest.RESTSessionCatalog` — the second case means the destination was never actually contacted. |
| `Discover` | OLake could not enumerate the source table, or wrote no `streams.json`. |
| `FullSyncAppendsRows` | The sync read fewer than the three seeded rows, or committed no Iceberg snapshot. |
| `StrictReaderSeesRows` | PyIceberg could not read back what the Java writer committed, or the values differ. This is the data path, not just metadata. |
| `UpsertProducesEqualityDelete` | After an `UPDATE` and a re-sync, no snapshot recorded an overwrite carrying equality deletes, or the current snapshot has no delete manifest. |
| `CompliantWriterNeedsNoRepair` | The catalog rewrote a manifest the official Iceberg Java writer produced. That is a regression in the repair gate, not a problem with OLake. |
## The one thing this test deliberately does not check
It does **not** assert that a reader sees the updated row and no duplicate.
PyIceberg refuses to scan a table carrying equality deletes
([apache/iceberg#6568](https://github.com/apache/iceberg/issues/6568)) while
reading its metadata perfectly well — so a rows-mode read after the upsert would
raise, not pass. The alternative is an engine that applies equality deletes,
which for StarRocks means a 3 GB image and 12 GB of RAM in CI.
The line drawn instead: **recording the commit correctly is the catalog's
contract; applying deletes on read is the query engine's.** The metadata
assertions cover our half.
This was verified once by hand outside CI, on 2026-09-23, with StarRocks 4.1.4
attached to the same catalog: after the upsert it read 3 rows / 3 distinct ids
with `id=1` showing the updated value and `_op_type=u`. If someone later wants
that inside the gate, add a reader that supports equality deletes — **do not**
"upgrade" this test to a PyIceberg rows-mode read after the upsert. It would not
pass; and if PyIceberg ever starts silently skipping deletes instead of raising,
it would pass by not looking.
## What the config proves
Nothing in the destination config is SeaweedFS-specific:
```json
{
"catalog_type": "rest",
"rest_catalog_url": "http://HOST:ICEBERG_PORT",
"iceberg_s3_path": "s3://olake-tables",
"s3_endpoint": "http://HOST:S3_PORT",
"rest_auth_type": "oauth2",
"oauth2_uri": "http://HOST:ICEBERG_PORT/v1/oauth/tokens",
"credential": "ACCESS_KEY:SECRET_KEY"
}
```
`catalog_type` is the generic `rest`, auth is the standard OAuth2
client-credentials flow, and `s3_path_style` does not even need setting —
OLake turns it on by itself whenever `s3_endpoint` is non-empty. As of
OLake v0.10.1 this works with no change on either side.
## Running it
```sh
go test ./test/s3tables/catalog_olake/ -run TestOLakeIcebergCatalog -v -timeout 25m
```
Needs Docker and a `weed` binary (at `weed/weed` under the repo root, or on
`PATH`). Takes about 35 seconds. It skips rather than fails when Docker is
absent, and `SEAWEEDFS_SKIP_OLAKE_TESTS=1` skips it outright.
Overrides: `OLAKE_IMAGE` (default `olakego/source-postgres:latest`),
`POSTGRES_IMAGE` (default `postgres:16`).
## In CI
Runs as `olake-iceberg-catalog-tests` in `.github/workflows/s3-tables-tests.yml`,
on a matrix of a pinned image plus `latest` — the same shape the ClickHouse job
uses, and for the same reason: OLake's Iceberg writer is a Java sidecar whose
library version moves independently of the Go release, so the `latest` leg is
what catches drift in the client rather than in OLake itself.
The job asserts the suite actually ran — at least one top-level `--- PASS` and
zero `--- SKIP` — rather than trusting a green exit. This suite skips itself
when Docker is unavailable, and a skipped suite reporting success is how a gate
quietly stops being one.
@@ -0,0 +1,91 @@
#!/usr/bin/env python3
"""Inspect an Iceberg table written by OLake, through the SeaweedFS REST catalog.
Two modes, because a strict reader cannot do both:
rows -- scan the table and print "id,region,amount" per row, ordered by
id. Only valid while the table has no equality deletes.
snapshots -- print one line per snapshot with its operation and delete-file
counters, plus the manifest content kinds of the current
snapshot.
The split exists because PyIceberg refuses to scan a table carrying equality
deletes (apache/iceberg#6568) while reading its metadata perfectly well. OLake
is a CDC tool, so its upsert path produces exactly those deletes -- asserting
the commit landed is the catalog's concern, and applying deletes on read is the
query engine's.
"""
import argparse
import sys
from pyiceberg.catalog import load_catalog
def main() -> int:
p = argparse.ArgumentParser()
p.add_argument("mode", choices=["rows", "snapshots"])
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,))
if args.mode == "rows":
data = table.scan().to_arrow().to_pydict()
# amount is decimal(10,2) in Postgres but arrives here as a float, whose
# repr drops trailing zeros (120.5, not 120.50). Format it to the source
# scale so the expected values in the Go test stay readable.
for row_id, region, amount in sorted(
zip(data["id"], data["region"], data["amount"])
):
print("%s,%s,%.2f" % (row_id, region, float(amount)))
return 0
print("format-version=%d" % table.metadata.format_version)
ids = table.metadata.schemas[-1].identifier_field_ids
print("identifier-field-ids=%s" % ",".join(str(i) for i in ids))
for snap in table.metadata.snapshots:
s = snap.summary
print(
"snapshot operation=%s total-delete-files=%s added-delete-files=%s "
"added-equality-deletes=%s total-records=%s"
% (
s.operation,
s.get("total-delete-files", "0"),
s.get("added-delete-files", "0"),
s.get("added-equality-deletes", "0"),
s.get("total-records", "0"),
)
)
current = table.current_snapshot()
kinds = [str(m.content).rsplit(".", 1)[-1] for m in current.manifests(table.io)]
print("current-manifest-kinds=%s" % ",".join(kinds))
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,623 @@
// Package catalog_olake provides an integration test for OLake
// (github.com/datazip-inc/olake) against the SeaweedFS Iceberg REST Catalog.
//
// OLake matters here for two reasons that no other engine in this directory
// covers. First, it does not write Iceberg from Go: it spawns a Java sidecar
// over gRPC and writes through the official Apache Iceberg library, so this is
// a strict Java client -- the class that iceberg/metadata_compliance.go exists
// to serve. Second, it is a CDC tool, so its upsert path emits equality
// deletes, which neither the ClickHouse nor the Doris test exercises.
package catalog_olake
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/test/testutil"
)
const (
olakeDefaultImage = "olakego/source-postgres:latest"
postgresDefaultImage = "postgres:16"
readerImage = "seaweedfs-olake-reader:test"
tableBucketName = "olake-tables"
sourceDatabase = "olakedb"
sourceUser = "olake"
sourcePassword = "olakepw"
// OLake derives the destination namespace from the source: it joins the
// connector name, database and schema. Asserting the derived name rather
// than configuring one keeps the test honest about what OLake actually
// does with our catalog.
expectedNamespace = "postgres_olakedb_public"
expectedTable = "orders"
postgresStartTimeout = 90 * time.Second
olakeRunTimeout = 6 * time.Minute
)
type TestEnvironment struct {
seaweedDir string
weedBinary string
dataDir string
configDir string
bindIP string
masterPort int
masterGrpcPort int
volumePort int
volumeGrpcPort int
filerPort int
filerGrpcPort int
s3Port int
s3GrpcPort int
icebergPort int
postgresPort int
accessKey string
secretKey string
weedProcess *exec.Cmd
weedCancel func()
postgresContainer string
}
func TestOLakeIcebergCatalog(t *testing.T) {
requireOLakeRuntime(t)
env := NewTestEnvironment(t)
defer env.Cleanup(t)
env.StartSeaweedFS(t)
env.startPostgres(t)
env.seedSource(t)
buildReaderImage(t)
env.writeOLakeConfigs(t)
t.Run("CheckDestination", func(t *testing.T) {
out := env.runOLake(t, "check",
"--config", "/mnt/config/source.json",
"--destination", "/mnt/config/destination.json")
if !strings.Contains(out, `"status":"SUCCEEDED"`) {
t.Fatalf("olake check did not report SUCCEEDED.\n%s", tailLines(out, 40))
}
// The Java sidecar, not the Go process, is what talks to our catalog.
// If this line is missing the check passed without exercising the
// Iceberg REST path at all.
if !strings.Contains(out, "org.apache.iceberg.rest.RESTSessionCatalog") {
t.Errorf("check passed but never loaded the Iceberg REST catalog; "+
"the destination may not have been contacted.\n%s", tailLines(out, 40))
}
})
t.Run("Discover", func(t *testing.T) {
out := env.runOLake(t, "discover",
"--config", "/mnt/config/source.json",
"--destination", "/mnt/config/destination.json")
if !strings.Contains(out, `"stream_name":"`+expectedTable+`"`) {
t.Fatalf("discover did not report the %s stream.\n%s", expectedTable, tailLines(out, 20))
}
if _, err := os.Stat(filepath.Join(env.configDir, "streams.json")); err != nil {
t.Fatalf("discover did not write streams.json: %v", err)
}
})
t.Run("FullSyncAppendsRows", func(t *testing.T) {
out := env.runOLake(t, "sync",
"--config", "/mnt/config/source.json",
"--destination", "/mnt/config/destination.json",
"--streams", "/mnt/config/streams.json",
"--state", "/mnt/config/state.json")
if !strings.Contains(out, "Total records read: 3") {
t.Fatalf("sync did not read the three seeded rows.\n%s", tailLines(out, 30))
}
if !strings.Contains(out, "Committed snapshot") {
t.Fatalf("sync never committed an Iceberg snapshot.\n%s", tailLines(out, 30))
}
})
t.Run("StrictReaderSeesRows", func(t *testing.T) {
rows := env.readTable(t, "rows")
want := []string{"1,us-east,120.50", "2,us-west,87.20", "3,eu-west,210.00"}
got := nonEmptyLines(rows)
if len(got) != len(want) {
t.Fatalf("PyIceberg read %d rows, want %d.\n%s", len(got), len(want), rows)
}
for i := range want {
if got[i] != want[i] {
t.Errorf("row %d = %q, want %q", i, got[i], want[i])
}
}
})
t.Run("UpsertProducesEqualityDelete", func(t *testing.T) {
// Push the row past the incremental cursor so the next sync re-emits it
// as an update rather than skipping it.
env.execSQL(t, "UPDATE orders SET amount=999.99, region='ap-south' WHERE id=1")
out := env.runOLake(t, "sync",
"--config", "/mnt/config/source.json",
"--destination", "/mnt/config/destination.json",
"--streams", "/mnt/config/streams.json",
"--state", "/mnt/config/state.json")
if !strings.Contains(out, "delete files") {
t.Fatalf("re-sync committed no delete files.\n%s", tailLines(out, 30))
}
meta := env.readTable(t, "snapshots")
if !strings.Contains(meta, "format-version=2") {
t.Errorf("expected Iceberg format-version 2, got:\n%s", meta)
}
if !strings.Contains(meta, "identifier-field-ids=") ||
strings.Contains(meta, "identifier-field-ids=\n") {
t.Errorf("table carries no identifier fields, so OLake could not "+
"have upserted:\n%s", meta)
}
var sawOverwrite bool
for _, line := range nonEmptyLines(meta) {
if !strings.HasPrefix(line, "snapshot operation=") {
continue
}
if strings.Contains(line, "operation=Operation.OVERWRITE") &&
!strings.Contains(line, "added-equality-deletes=0") {
sawOverwrite = true
}
}
if !sawOverwrite {
t.Errorf("no snapshot recorded an overwrite carrying equality "+
"deletes:\n%s", meta)
}
if !strings.Contains(meta, "current-manifest-kinds=") ||
!strings.Contains(meta, "DELETES") {
t.Errorf("current snapshot has no delete manifest:\n%s", meta)
}
// Deliberately NOT asserted here: that a reader sees the updated value
// and no duplicate row. PyIceberg refuses to scan a table carrying
// equality deletes (apache/iceberg#6568), so a rows-mode read would
// fail rather than pass, and swapping in an engine that can apply them
// costs this test a multi-gigabyte image. Applying deletes on read is
// the engine's contract; recording the commit correctly is ours, and
// that is what the assertions above cover. See the README.
})
t.Run("CompliantWriterNeedsNoRepair", func(t *testing.T) {
names := env.listTableMetadata(t)
if len(names) == 0 {
t.Fatalf("no metadata files found for %s.%s", expectedNamespace, expectedTable)
}
for _, n := range names {
if strings.HasPrefix(n, "repaired-") {
t.Errorf("catalog repaired a manifest written by OLake (%s); "+
"the official Iceberg Java writer is expected to be "+
"spec-compliant, so this is a regression in the repair "+
"gate rather than in OLake", n)
}
}
})
}
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 info, err := os.Stat(weedBinary); err != nil || info.IsDir() {
weedBinary = filepath.Join(seaweedDir, "weed", "weed", "weed")
if info, err := os.Stat(weedBinary); 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-olake-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
configDir := filepath.Join(dataDir, "olake")
if err := os.MkdirAll(configDir, 0755); err != nil {
t.Fatalf("Failed to create config dir: %v", err)
}
// 9 for the mini cluster, 1 for the Postgres source mapped on the host.
ports := testutil.MustAllocatePorts(t, 10)
return &TestEnvironment{
seaweedDir: seaweedDir,
weedBinary: weedBinary,
dataDir: dataDir,
configDir: configDir,
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],
icebergPort: ports[8],
postgresPort: ports[9],
accessKey: "AKIAIOSFODNN7EXAMPLE",
secretKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
}
}
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)
}
cmd := exec.Command(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,
// Pre-create the table bucket the way an operator would, rather than
// reaching for the S3 Tables control plane from the test.
"-tableBucket", tableBucketName,
"-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("Failed to start SeaweedFS: %v", err)
}
env.weedProcess = cmd
env.weedCancel = func() {
if cmd.Process != nil {
_ = cmd.Process.Kill()
}
}
url := fmt.Sprintf("http://%s:%d/v1/config", env.bindIP, env.icebergPort)
if !waitForService(url, 45*time.Second) {
t.Fatalf("Iceberg REST API did not become ready at %s", url)
}
}
func (env *TestEnvironment) startPostgres(t *testing.T) {
t.Helper()
name := fmt.Sprintf("olake-pg-%d", env.postgresPort)
_ = exec.Command("docker", "rm", "-f", name).Run()
cmd := exec.Command("docker", "run", "-d", "--name", name,
"-e", "POSTGRES_USER="+sourceUser,
"-e", "POSTGRES_PASSWORD="+sourcePassword,
"-e", "POSTGRES_DB="+sourceDatabase,
"-p", fmt.Sprintf("%d:5432", env.postgresPort),
postgresImage(),
)
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("Failed to start Postgres: %v\n%s", err, out)
}
env.postgresContainer = name
deadline := time.Now().Add(postgresStartTimeout)
for time.Now().Before(deadline) {
probe := exec.Command("docker", "exec", name,
"pg_isready", "-U", sourceUser, "-d", sourceDatabase)
if err := probe.Run(); err == nil {
return
}
time.Sleep(2 * time.Second)
}
t.Fatalf("Postgres did not become ready within %s", postgresStartTimeout)
}
func (env *TestEnvironment) seedSource(t *testing.T) {
t.Helper()
env.execSQL(t, `
CREATE TABLE orders (
id int PRIMARY KEY,
region text,
amount numeric(10,2),
order_ts timestamp
);
INSERT INTO orders VALUES
(1,'us-east',120.50,'2026-07-27 09:15'),
(2,'us-west', 87.20,'2026-07-27 09:20'),
(3,'eu-west',210.00,'2026-07-27 09:31');
ALTER TABLE orders REPLICA IDENTITY FULL;`)
}
func (env *TestEnvironment) execSQL(t *testing.T, sqlText string) {
t.Helper()
cmd := exec.Command("docker", "exec", env.postgresContainer,
"psql", "-v", "ON_ERROR_STOP=1", "-U", sourceUser, "-d", sourceDatabase,
"-c", sqlText)
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("psql failed: %v\n%s", err, out)
}
}
// writeOLakeConfigs writes the source and destination configs. The destination
// is the point of this test: nothing in it is SeaweedFS-specific. catalog_type
// is the generic "rest", auth is the standard OAuth2 client-credentials flow,
// and path-style access is not even set here because OLake turns it on by
// itself whenever s3_endpoint is non-empty.
func (env *TestEnvironment) writeOLakeConfigs(t *testing.T) {
t.Helper()
source := map[string]any{
"host": env.bindIP,
"port": env.postgresPort,
"database": sourceDatabase,
"username": sourceUser,
"password": sourcePassword,
"jdbc_url_params": map[string]any{},
"ssl": map[string]any{"mode": "disable"},
"update_method": map[string]any{"type": "Standalone"},
"max_threads": 2,
"retry_count": 0,
}
catalogURL := fmt.Sprintf("http://%s:%d", env.bindIP, env.icebergPort)
destination := map[string]any{
"type": "ICEBERG",
"writer": map[string]any{
"catalog_type": "rest",
"rest_catalog_url": catalogURL,
"catalog_name": "olake",
"iceberg_s3_path": "s3://" + tableBucketName,
"s3_endpoint": fmt.Sprintf("http://%s:%d", env.bindIP, env.s3Port),
"s3_use_ssl": false,
"s3_path_style": true,
"aws_region": "us-east-1",
"aws_access_key": env.accessKey,
"aws_secret_key": env.secretKey,
"rest_auth_type": "oauth2",
"oauth2_uri": catalogURL + "/v1/oauth/tokens",
"credential": env.accessKey + ":" + env.secretKey,
},
}
writeJSON(t, filepath.Join(env.configDir, "source.json"), source)
writeJSON(t, filepath.Join(env.configDir, "destination.json"), destination)
if err := os.WriteFile(filepath.Join(env.configDir, "state.json"), []byte("{}\n"), 0644); err != nil {
t.Fatalf("Failed to write state.json: %v", err)
}
}
func (env *TestEnvironment) runOLake(t *testing.T, args ...string) string {
t.Helper()
full := append([]string{
"run", "--rm",
"-v", dockerMount(env.configDir) + ":/mnt/config",
olakeImage(),
}, args...)
cmd := exec.Command("docker", full...)
done := make(chan struct{})
var out []byte
var err error
go func() {
out, err = cmd.CombinedOutput()
close(done)
}()
select {
case <-done:
case <-time.After(olakeRunTimeout):
if cmd.Process != nil {
_ = cmd.Process.Kill()
}
t.Fatalf("olake %s did not finish within %s", args[0], olakeRunTimeout)
}
if err != nil {
t.Fatalf("olake %s failed: %v\n%s", args[0], err, tailLines(string(out), 40))
}
return string(out)
}
func (env *TestEnvironment) readTable(t *testing.T, mode string) string {
t.Helper()
cmd := exec.Command("docker", "run", "--rm", readerImage, mode,
"--catalog-url", fmt.Sprintf("http://%s:%d", env.bindIP, env.icebergPort),
"--warehouse", "s3://"+tableBucketName,
"--prefix", tableBucketName,
"--s3-endpoint", fmt.Sprintf("http://%s:%d", env.bindIP, env.s3Port),
"--access-key", env.accessKey,
"--secret-key", env.secretKey,
"--namespace", expectedNamespace,
"--table", expectedTable,
)
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("reader (%s) failed: %v\n%s", mode, err, tailLines(string(out), 30))
}
return string(out)
}
// listTableMetadata lists the table's metadata directory through the filer so
// the repair assertion looks at real objects rather than at what the catalog
// reports about itself.
func (env *TestEnvironment) listTableMetadata(t *testing.T) []string {
t.Helper()
url := fmt.Sprintf("http://%s:%d/buckets/%s/%s/%s/metadata/?limit=200",
env.bindIP, env.filerPort, tableBucketName, expectedNamespace, expectedTable)
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
t.Fatalf("Failed to build filer request: %v", err)
}
req.Header.Set("Accept", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("filer listing failed: %v", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
t.Fatalf("filer listing returned %d: %s", resp.StatusCode, body)
}
var parsed struct {
Entries []struct {
FullPath string `json:"FullPath"`
} `json:"Entries"`
}
if err := json.Unmarshal(body, &parsed); err != nil {
t.Fatalf("Failed to parse filer listing: %v\n%s", err, body)
}
names := make([]string, 0, len(parsed.Entries))
for _, e := range parsed.Entries {
names = append(names, e.FullPath[strings.LastIndex(e.FullPath, "/")+1:])
}
return names
}
func (env *TestEnvironment) Cleanup(t *testing.T) {
t.Helper()
if env.postgresContainer != "" {
_ = exec.Command("docker", "rm", "-f", env.postgresContainer).Run()
}
if env.weedCancel != nil {
env.weedCancel()
}
if env.weedProcess != nil {
_ = env.weedProcess.Wait()
}
if env.dataDir != "" {
_ = os.RemoveAll(env.dataDir)
}
}
func buildReaderImage(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",
"-f", filepath.Join(wd, "Dockerfile.reader"),
"-t", readerImage, wd)
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("Failed to build reader image: %v\n%s", err, tailLines(string(out), 30))
}
}
func olakeImage() string {
if v := os.Getenv("OLAKE_IMAGE"); v != "" {
return v
}
return olakeDefaultImage
}
func postgresImage() string {
if v := os.Getenv("POSTGRES_IMAGE"); v != "" {
return v
}
return postgresDefaultImage
}
func requireOLakeRuntime(t *testing.T) {
t.Helper()
if os.Getenv("SEAWEEDFS_SKIP_OLAKE_TESTS") != "" {
t.Skip("SEAWEEDFS_SKIP_OLAKE_TESTS set")
}
if _, err := exec.LookPath("docker"); err != nil {
t.Skip("docker not available, skipping OLake integration test")
}
if err := exec.Command("docker", "info").Run(); err != nil {
t.Skip("docker daemon not reachable, skipping OLake integration test")
}
}
func waitForService(url string, timeout time.Duration) bool {
deadline := time.Now().Add(timeout)
client := &http.Client{Timeout: 3 * time.Second}
for time.Now().Before(deadline) {
resp, err := client.Get(url)
if err == nil {
resp.Body.Close()
if resp.StatusCode < 500 {
return true
}
}
time.Sleep(time.Second)
}
return false
}
func writeJSON(t *testing.T, path string, v any) {
t.Helper()
body, err := json.MarshalIndent(v, "", " ")
if err != nil {
t.Fatalf("Failed to marshal %s: %v", path, err)
}
if err := os.WriteFile(path, append(body, '\n'), 0644); err != nil {
t.Fatalf("Failed to write %s: %v", path, err)
}
}
// dockerMount normalises a host path for a -v bind mount. Docker Desktop
// accepts forward slashes on Windows; the native separator it does not.
func dockerMount(path string) string {
return strings.ReplaceAll(path, `\`, "/")
}
func nonEmptyLines(s string) []string {
var out []string
for _, line := range strings.Split(s, "\n") {
if trimmed := strings.TrimSpace(line); trimmed != "" {
out = append(out, trimmed)
}
}
return out
}
func tailLines(s string, n int) string {
lines := strings.Split(strings.TrimRight(s, "\n"), "\n")
if len(lines) > n {
lines = lines[len(lines)-n:]
}
return strings.Join(lines, "\n")
}