// 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) }