// Reproduction tests for https://github.com/seaweedfs/seaweedfs/issues/9103 // // The issue reports two distinct failure modes when using DuckDB against the // SeaweedFS Iceberg REST catalog: // // 1. `ATTACH 's3://test/' AS cat (TYPE 'ICEBERG', ...); SELECT * FROM cat.ovirt.disk;` // fails with "Table with name 'ovirt.disk' does not exist because schema // 'ovirt' does not exist." The namespace "ovirt" does exist in the bucket. // // 2. `SELECT * FROM iceberg_scan('s3://test/ovirt/disk');` fails with HTTP 403 // AccessDenied when DuckDB tries to glob `s3://test/ovirt/disk/metadata/v*` // because the LoadTable response does not vend S3 file-io credentials back // to the client. // // These tests reproduce the underlying catalog-level misbehavior at the REST // protocol layer so they run quickly and deterministically. A DuckDB-based // end-to-end reproduction is also included (gated on Docker availability). package catalog import ( "bytes" "encoding/json" "fmt" "io" "net/http" "net/url" "os" "os/exec" "path/filepath" "strings" "testing" "github.com/seaweedfs/seaweedfs/test/testutil" ) // TestIssue9103_ConfigDoesNotVendWarehousePrefix reproduces failure mode #2. // // Per the Iceberg REST spec, a client attaching with warehouse=s3:/// // calls GET /v1/config?warehouse=s3:/// and expects the server to // return an `overrides.prefix` that identifies the catalog namespace for // subsequent requests. Without it the client falls back to unprefixed paths // like /v1/namespaces, which on SeaweedFS resolve to the hard-coded default // bucket ("warehouse") and therefore do not list the user's namespaces. // // Currently the handler ignores the warehouse query parameter and returns // empty defaults/overrides. That is exactly what makes DuckDB's ATTACH flow // report `schema "ovirt" does not exist`. func TestIssue9103_ConfigDoesNotVendWarehousePrefix(t *testing.T) { if testing.Short() { t.Skip("Skipping integration test in short mode") } env := sharedEnv bucketName := "warehouse-9103cfg-" + randomSuffix() createTableBucket(t, env, bucketName, "") warehouse := fmt.Sprintf("s3://%s/", bucketName) u := fmt.Sprintf("%s/v1/config?warehouse=%s", env.IcebergURL(), url.QueryEscape(warehouse)) status, body, err := doIcebergJSONRequest(env, http.MethodGet, fmt.Sprintf("/v1/config?warehouse=%s", url.QueryEscape(warehouse)), nil) if err != nil { t.Fatalf("GET %s failed: %v", u, err) } if status != http.StatusOK { t.Fatalf("GET %s status = %d, want 200", u, status) } overrides, _ := body["overrides"].(map[string]any) gotPrefix, _ := overrides["prefix"].(string) if gotPrefix != bucketName { t.Fatalf("GET /v1/config?warehouse=%s: overrides.prefix = %q, want %q (catalog must echo the warehouse's table bucket so clients like DuckDB know which /v1/{prefix}/namespaces to use)", warehouse, gotPrefix, bucketName) } } // TestIssue9103_BareNamespacesListMissesNamespaceInAttachedBucket // demonstrates the downstream effect of the /v1/config bug. // // The client created the namespace "ovirt" inside bucket "test" (via // /v1/{bucket}/namespaces), but when it later issues GET /v1/namespaces (no // prefix, because /v1/config didn't give it one), that request resolves to // the default bucket and returns no namespaces. DuckDB surfaces this as // `schema "ovirt" does not exist`. func TestIssue9103_BareNamespacesListMissesNamespaceInAttachedBucket(t *testing.T) { if testing.Short() { t.Skip("Skipping integration test in short mode") } env := sharedEnv bucketName := "warehouse-9103ns-" + randomSuffix() createTableBucket(t, env, bucketName, "") namespace := "ovirt" status, _, err := doIcebergJSONRequest(env, http.MethodPost, icebergPath(bucketName, "/v1/namespaces"), map[string]any{"namespace": []string{namespace}}) if err != nil { t.Fatalf("create namespace: %v", err) } if status != http.StatusOK && status != http.StatusConflict { t.Fatalf("create namespace status = %d, want 200 or 409", status) } // Sanity: prefixed listing finds the namespace. status, prefixedBody, err := doIcebergJSONRequest(env, http.MethodGet, icebergPath(bucketName, "/v1/namespaces"), nil) if err != nil { t.Fatalf("list namespaces with prefix: %v", err) } if status != http.StatusOK { t.Fatalf("prefixed list status = %d, want 200", status) } if !containsNamespace(prefixedBody, namespace) { t.Fatalf("prefixed list missing namespace %q: %v", namespace, prefixedBody) } // Simulate what a DuckDB-style client does when /v1/config did not vend a // prefix: it falls back to the unprefixed listing. It should still be // able to discover the namespace (e.g. via a warehouse query parameter), // but today it cannot, which is the user-visible bug. bareWarehouse := "s3://" + bucketName + "/" status, bareBody, err := doIcebergJSONRequest(env, http.MethodGet, fmt.Sprintf("/v1/namespaces?warehouse=%s", url.QueryEscape(bareWarehouse)), nil) if err != nil { t.Fatalf("bare list namespaces: %v", err) } switch { case status != http.StatusOK: t.Fatalf("GET /v1/namespaces?warehouse=%s status = %d, want 200 (server ignores the warehouse query parameter and falls back to the default %q bucket, causing DuckDB to see no schemas)", bareWarehouse, status, "warehouse") case !containsNamespace(bareBody, namespace): t.Fatalf("GET /v1/namespaces?warehouse=%s did not return %q: %v (server must honor the warehouse query parameter here or vend overrides.prefix from /v1/config so the client can route to the right bucket)", bareWarehouse, namespace, bareBody) } } // TestIssue9103_DuckDBAttachCannotResolveNamespace is the end-to-end // reproduction of the issue using DuckDB in Docker, mirroring the exact // sequence of commands from the bug report. It runs under its own weed // mini with IAM configured so the OAuth2 client_credentials flow that // DuckDB's iceberg extension requires actually works (the shared env has // no credentials registered). Gated on Docker availability. func TestIssue9103_DuckDBAttachCannotResolveNamespace(t *testing.T) { if testing.Short() { t.Skip("Skipping integration test in short mode") } if !testutil.HasDocker() { t.Skip("Docker not available, skipping DuckDB integration reproduction") } env := newOAuthTestEnv(t) defer env.cleanup(t) env.start(t) bucketName := "test9103-" + randomSuffix() createTableBucketViaShell(t, env, bucketName) namespace := "ovirt" tableName := "disk" token := requestOAuthToken(t, env, env.accessKey, env.secretKey) createNamespaceWithToken(t, env, token, bucketName, namespace) createTableWithToken(t, env, token, bucketName, namespace, tableName) sql := fmt.Sprintf(` INSTALL iceberg; LOAD iceberg; CREATE SECRET test_berg ( TYPE ICEBERG, ENDPOINT 'http://host.docker.internal:%d', SCOPE 's3://%s/', CLIENT_ID '%s', CLIENT_SECRET '%s' ); CREATE SECRET s3_berg ( TYPE S3, KEY_ID '%s', SECRET '%s', ENDPOINT 'host.docker.internal:%d', URL_STYLE 'path', USE_SSL false, SCOPE 's3://%s/' ); ATTACH 's3://%s/' AS test_catalog (TYPE 'ICEBERG', secret test_berg); SELECT * FROM test_catalog.%s.%s LIMIT 0; `, env.icebergPort, bucketName, env.accessKey, env.secretKey, env.accessKey, env.secretKey, env.s3Port, bucketName, bucketName, namespace, tableName) sqlFile := filepath.Join(env.dataDir, "issue_9103.sql") if err := os.WriteFile(sqlFile, []byte(sql), 0644); err != nil { t.Fatalf("write SQL: %v", err) } cmd := exec.Command("docker", "run", "--rm", "-v", fmt.Sprintf("%s:/test", env.dataDir), "--add-host", "host.docker.internal:host-gateway", "-e", "AWS_REGION=us-east-1", "--entrypoint", "duckdb", "duckdb/duckdb:latest", "-init", "/test/issue_9103.sql", "-c", "SELECT 1", ) out, runErr := cmd.CombinedOutput() outStr := string(out) t.Logf("DuckDB output:\n%s", outStr) if strings.Contains(outStr, "iceberg extension is not available") || strings.Contains(outStr, "Failed to load") || strings.Contains(outStr, "could not fetch extension") || strings.Contains(outStr, "failed to download") { t.Skip("Iceberg extension not available in DuckDB Docker image") } if runErr != nil { if strings.Contains(outStr, "does not exist because schema") && strings.Contains(outStr, namespace) { t.Fatalf("reproduced issue #9103: DuckDB cannot see namespace %q after ATTACH 's3://%s/'; see output above", namespace, bucketName) } t.Fatalf("DuckDB run failed for an unexpected reason: %v", runErr) } } // createTableWithToken creates an Iceberg table using an OAuth bearer token. func createTableWithToken(t *testing.T, env *oauthTestEnv, token, bucketName, namespace, tableName string) { t.Helper() payload := map[string]any{ "name": tableName, "schema": map[string]any{ "type": "struct", "schema-id": 0, "fields": []map[string]any{ {"id": 1, "name": "id", "required": true, "type": "long"}, }, }, } body, err := json.Marshal(payload) if err != nil { t.Fatalf("marshal create-table payload: %v", err) } url := fmt.Sprintf("%s/v1/%s/namespaces/%s/tables", env.icebergURL(), bucketName, namespace) req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body)) if err != nil { t.Fatalf("create request: %v", err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+token) resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("create table: %v", err) } defer resp.Body.Close() respBody, _ := io.ReadAll(resp.Body) if resp.StatusCode != http.StatusOK { t.Fatalf("create table failed: status=%d body=%s", resp.StatusCode, respBody) } t.Logf("Created table %s.%s in bucket %s", namespace, tableName, bucketName) } // containsNamespace checks whether an Iceberg REST ListNamespaces response // contains the given single-level namespace. func containsNamespace(body map[string]any, name string) bool { arr, _ := body["namespaces"].([]any) for _, item := range arr { parts, _ := item.([]any) if len(parts) == 1 { if s, ok := parts[0].(string); ok && s == name { return true } } } return false }