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