mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-09-20 13:30:46 +02:00
* fix(iceberg): route catalog clients to the right bucket and vend S3 endpoint DuckDB ATTACH 's3://<bucket>/' AS cat (TYPE 'ICEBERG', ...) was failing with "schema does not exist" because GET /v1/config ignored the warehouse query parameter and returned no overrides.prefix, so subsequent requests fell through to the hard-coded "warehouse" default bucket instead of the one the client attached. LoadTable also returned an empty config, forcing clients to discover the S3 endpoint out-of-band and producing 403s on direct iceberg_scan calls. - handleConfig now echoes overrides.prefix = bucket and defaults.warehouse when ?warehouse=s3://<bucket>/ is supplied. - getBucketFromPrefix honors a warehouse query parameter as a fallback for clients that skip the /v1/config handshake. - LoadTable responses advertise s3.endpoint and s3.path-style-access so clients can reach data files without separate configuration. Refs #9103 * address review feedback on iceberg S3 endpoint vending - deriveS3AdvertisedEndpoint is now a method on S3Options; honors externalUrl / S3_EXTERNAL_URL, switches to https when -s3.key.file is set, uses the https port when configured, and brackets IPv6 literals via util.JoinHostPort. - handleCreateTable returns s.buildFileIOConfig() in both its staged and final LoadTableResult branches so create and load flows see the same FileIO hints. - Add unit test coverage for the endpoint derivation scenarios. * address CI and review feedback for #9109 - DuckDB integration test now runs under its own newOAuthTestEnv (with a valid IAM config) so the OAuth2 client_credentials flow DuckDB requires actually works; the shared env has no registered credentials, which was the cause of the CI failure. Helper createTableWithToken was added to create tables via Bearer auth. - Tighten TestIssue9103_LoadTableDoesNotVendS3FileIOCredentials to also assert s3.path-style-access = "true", so a partial regression where the endpoint is vended but path-style is dropped still fails. - deriveS3AdvertisedEndpoint now logs a startup hint when it infers the host from os.Hostname because the bind IP is a wildcard, pointing operators at -s3.externalUrl / S3_EXTERNAL_URL for reverse-proxy deployments where the inferred name is not externally reachable. - handleConfig has a comment explaining that any sub-path in the warehouse URL is dropped because catalog routing is bucket-scoped. * fix(iceberg): make advertised S3 endpoint strictly opt-in; add region The wildcard-bind fallback to os.Hostname() in deriveS3AdvertisedEndpoint was hijacking correctly-configured clients: on the CI runner it produced http://runnervmrc6n4:<port>, which Spark (running in Docker) could not resolve, so Spark iceberg tests began failing after the endpoint started being vended in LoadTable responses. Change the rule so advertising is opt-in and never guesses a host that might not be routable: - -s3.externalUrl / S3_EXTERNAL_URL wins (covers reverse-proxy). - Otherwise, only an explicit, non-wildcard -s3.bindIp is used. - Wildcard / empty bind returns "" so no FileIO endpoint is vended and existing clients keep using their own configuration. buildFileIOConfig additionally vends s3.region (defaulting to the same value baked into table bucket ARNs) whenever it vends an endpoint, so DuckDB's attach does not fail with "No region was provided via the vended credentials" when the operator has opted in. The DuckDB issue-9103 integration test runs under an env with a wildcard bind, so it explicitly sets AWS_REGION in the docker run to pick up the same default. The HTTP-level LoadTable-vending test was dropped because its expectation is now conditional and already covered by unit tests in iceberg_issue_9103_test.go.
284 lines
9.9 KiB
Go
284 lines
9.9 KiB
Go
// 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://<bucket>/
|
|
// calls GET /v1/config?warehouse=s3://<bucket>/ 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
|
|
}
|