Files
seaweedfs/weed/s3api/iceberg/utils.go
T
Chris LuandGitHub c191b2fe01 iceberg: let clients select their table bucket as the catalog warehouse (#10549)
* iceberg: accept bare bucket names and ARNs as the catalog warehouse

Only s3://<bucket>/ was recognized. A warehouse spelled as a bare table
bucket name or as the s3tables bucket ARN -- the two forms users reach for
first, the latter being what AWS S3 Tables itself takes -- was silently
dropped, so every call landed on the default "warehouse" bucket and failed
with "table bucket warehouse not found".

* iceberg: report a missing table bucket as 404, not 500

Pointing a client at a table bucket that does not exist -- which every
client with no warehouse set does, since the default bucket "warehouse"
rarely exists -- returned InternalServerError with a message naming a
bucket the client never asked for. Answer 404 and say how to select one.

* admin: show the warehouse in the PyIceberg example

The example connected without one, so it always resolved to the default
table bucket and every client that copied it failed on the first call.

* test: pin bearer auth against a table bucket that exists

The subtest called the catalog with no warehouse and accepted 500 as proof
that auth had passed, since the default bucket does not exist. A missing
table bucket now answers 404, which the test read as an auth failure. Give
it a real table bucket so only 200 passes.

* test: assert the missing-bucket guidance reaches the client

The status and error type were checked but not the message, which is the
part of the mapping that tells a user how to select a table bucket.

* test: encode the warehouse query value

The ARN case pasted raw colons and slashes into the query string. Go's
parser tolerates them, so the test passed without modelling how a client
actually sends the request.
2026-08-03 13:25:37 -07:00

294 lines
10 KiB
Go

package iceberg
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"strconv"
"strings"
"github.com/apache/iceberg-go"
"github.com/gorilla/mux"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
)
// parseNamespace parses the namespace from path parameter.
// Iceberg uses unit separator (0x1F) for multi-level namespaces.
// Note: mux already decodes URL-encoded path parameters, so we only split by unit separator.
func parseNamespace(encoded string) []string {
if encoded == "" {
return nil
}
parts := strings.Split(encoded, "\x1F")
// Filter empty parts
result := make([]string, 0, len(parts))
for _, p := range parts {
if p != "" {
result = append(result, p)
}
}
return result
}
// encodeNamespace encodes namespace parts using the Iceberg REST protocol's
// unit separator (0x1F) convention. This is only appropriate for protocol-level
// encoding (e.g. URL path parameters), NOT for filesystem/S3 paths.
func encodeNamespace(parts []string) string {
return strings.Join(parts, "\x1F")
}
// flattenNamespacePath joins namespace parts with "." for use in S3 location
// and filer paths, matching the S3 Tables storage layer convention.
func flattenNamespacePath(parts []string) string {
return strings.Join(parts, ".")
}
func parseS3Location(location string) (bucketName, tablePath string, err error) {
if !strings.HasPrefix(location, "s3://") {
return "", "", fmt.Errorf("unsupported location: %s", location)
}
trimmed := strings.TrimPrefix(location, "s3://")
trimmed = strings.TrimSuffix(trimmed, "/")
if trimmed == "" {
return "", "", fmt.Errorf("invalid location: %s", location)
}
parts := strings.SplitN(trimmed, "/", 2)
bucketName = parts[0]
if bucketName == "" {
return "", "", fmt.Errorf("invalid location bucket: %s", location)
}
if len(parts) == 2 {
tablePath = parts[1]
}
return bucketName, tablePath, nil
}
func tableLocationFromMetadataLocation(metadataLocation string) string {
trimmed := strings.TrimSuffix(metadataLocation, "/")
if idx := strings.LastIndex(trimmed, "/metadata/"); idx != -1 {
return trimmed[:idx]
}
return trimmed
}
// writeJSON writes a JSON response.
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if v != nil {
data, err := json.Marshal(v)
if err != nil {
glog.Errorf("Iceberg: failed to encode response: %v", err)
return
}
w.Write(data)
}
}
// writeError writes an Iceberg error response.
func writeError(w http.ResponseWriter, status int, errType, message string) {
resp := ErrorResponse{
Error: ErrorModel{
Message: message,
Type: errType,
Code: status,
},
}
writeJSON(w, status, resp)
}
// nameValidationError reports whether err is an S3 Tables namespace or table
// name validation failure. Such names are client input (the S3 Tables charset
// is stricter than the Iceberg REST spec), so the catalog answers 400 rather
// than treating it as a server fault.
func nameValidationError(err error) bool {
if err == nil {
return false
}
// Match the validator's own phrasings rather than a bare "namespace name"/
// "table name" so unrelated faults (e.g. "failed to resolve table name")
// aren't misreported as client errors. Lowercased for resilience to
// capitalization changes.
msg := strings.ToLower(err.Error())
for _, marker := range []string{
"invalid namespace name", "namespace name must", "namespace name cannot",
"invalid table name", "table name must", "table name cannot",
} {
if strings.Contains(msg, marker) {
return true
}
}
return false
}
// writeManagerError maps a residual error to a response: name validation
// failures and rejected schemas come from the request body, so they are client
// errors (400) and must carry the reason -- a variant field without
// format-version 3 otherwise reads as a bare 500 with "variant is not supported
// until v3" only in the log. Anything else is a server fault (500).
func writeManagerError(w http.ResponseWriter, err error) {
switch {
case nameValidationError(err),
errors.Is(err, iceberg.ErrInvalidSchema),
errors.Is(err, iceberg.ErrInvalidPartitionSpec),
errors.Is(err, iceberg.ErrInvalidTypeString),
errors.Is(err, iceberg.ErrInvalidTransform),
errors.Is(err, iceberg.ErrInvalidArgument):
writeError(w, http.StatusBadRequest, "BadRequestException", err.Error())
return
}
// A missing table bucket means the catalog the client selected does not
// exist, not a server fault. The storage-layer message names the resolved
// bucket, which for a client that sent no warehouse at all is the default
// one it never asked for, so say how to select a real table bucket.
var tableErr *s3tables.S3TablesError
if errors.As(err, &tableErr) && tableErr.Type == s3tables.ErrCodeNoSuchBucket {
writeError(w, http.StatusNotFound, "NoSuchNamespaceException",
fmt.Sprintf("%s: each table bucket is a separate catalog, select one with warehouse=s3://<table-bucket>/ or /v1/<table-bucket>/", tableErr.Message))
return
}
writeError(w, http.StatusInternalServerError, "InternalServerError", err.Error())
}
// resolveWarehouseBucket maps a client-supplied warehouse value to a table
// bucket name. Clients spell the warehouse three ways: the s3://<bucket>/
// location this catalog advertises, the table bucket ARN that AWS S3 Tables
// uses, and the bare bucket name. Accepting only the first sends every other
// spelling to the default bucket, where the request fails naming a bucket the
// client never asked for. Returns "" when the value names no usable bucket so
// the caller keeps its own default.
func resolveWarehouseBucket(warehouse string) string {
warehouse = strings.TrimSpace(warehouse)
var bucket string
switch {
case warehouse == "":
return ""
case strings.HasPrefix(warehouse, "s3://"):
parsed, _, err := parseS3Location(warehouse)
if err != nil {
return ""
}
bucket = parsed
case strings.HasPrefix(warehouse, "arn:"):
parsed, err := s3tables.ParseBucketNameFromARN(warehouse)
if err != nil {
return ""
}
bucket = parsed
default:
// Bare name, possibly with a sub-path that bucket-scoped routing ignores.
bucket, _, _ = strings.Cut(strings.TrimSuffix(warehouse, "/"), "/")
}
if !s3tables.IsValidBucketName(bucket) {
return ""
}
return bucket
}
// getBucketFromPrefix extracts table bucket name from prefix parameter.
// For now, we use the prefix as the table bucket name.
//
// The Iceberg REST spec lets clients identify a catalog either by embedding
// its prefix in the URL (/v1/{prefix}/...) or by passing ?warehouse= as a
// query parameter. Clients that skip the /v1/config handshake (or ignore its
// overrides) still routinely send the warehouse parameter on every request,
// so honor it as a fallback before the env-var default.
func getBucketFromPrefix(r *http.Request) string {
vars := mux.Vars(r)
if prefix := vars["prefix"]; prefix != "" {
return prefix
}
if bucket := resolveWarehouseBucket(r.URL.Query().Get("warehouse")); bucket != "" {
return bucket
}
if bucket := os.Getenv("S3TABLES_DEFAULT_BUCKET"); bucket != "" {
return bucket
}
// Default bucket if no prefix - use "warehouse" for Iceberg
return "warehouse"
}
// buildTableBucketARN builds an ARN for a table bucket.
func buildTableBucketARN(bucketName string) string {
arn, _ := s3tables.BuildBucketARN(s3tables.DefaultRegion, s3_constants.AccountAdminId, bucketName)
return arn
}
const (
defaultListPageSize = 1000
maxListPageSize = 1000
)
func getPaginationQueryParam(r *http.Request, primary, fallback string) string {
if v := strings.TrimSpace(r.URL.Query().Get(primary)); v != "" {
return v
}
return strings.TrimSpace(r.URL.Query().Get(fallback))
}
func parsePagination(r *http.Request) (pageToken string, pageSize int, err error) {
pageToken = getPaginationQueryParam(r, "pageToken", "page-token")
pageSize = defaultListPageSize
pageSizeValue := getPaginationQueryParam(r, "pageSize", "page-size")
if pageSizeValue == "" {
return pageToken, pageSize, nil
}
parsedPageSize, parseErr := strconv.Atoi(pageSizeValue)
if parseErr != nil || parsedPageSize <= 0 {
return pageToken, 0, fmt.Errorf("invalid pageSize %q: must be a positive integer", pageSizeValue)
}
if parsedPageSize > maxListPageSize {
return pageToken, 0, fmt.Errorf("invalid pageSize %q: must be <= %d", pageSizeValue, maxListPageSize)
}
return pageToken, parsedPageSize, nil
}
func normalizeNamespaceProperties(properties map[string]string) map[string]string {
if properties == nil {
return map[string]string{}
}
return properties
}
// namespaceLocationProperty is the well-known Iceberg key used to advertise a
// default base path for tables created in a namespace.
const namespaceLocationProperty = "location"
// defaultNamespaceLocation returns the s3 path under which tables in the
// namespace should be stored when the namespace itself does not carry an
// explicit "location" property. The flattened namespace path mirrors the on-
// disk layout used by handleCreateTable so a deferred client-side commit ends
// up at the same place a server-computed one would.
func defaultNamespaceLocation(bucket string, namespace []string) string {
if bucket == "" || len(namespace) == 0 {
return ""
}
return fmt.Sprintf("s3://%s/%s", bucket, flattenNamespacePath(namespace))
}
// withDefaultNamespaceLocation populates the Iceberg "location" property on a
// namespace response when the storage layer does not carry one. Trino's REST
// catalog falls back to an eager createTable code path when the namespace
// has no location, which races our metadata write against Trino's emptiness
// check and surfaces as a "Cannot create a table on a non-empty location"
// error on the very first CREATE TABLE. Advertising a default location lets
// Trino take the deferred-transaction path and pick a unique per-table path.
// See issue #9074.
func withDefaultNamespaceLocation(properties map[string]string, bucket string, namespace []string) map[string]string {
properties = normalizeNamespaceProperties(properties)
if _, ok := properties[namespaceLocationProperty]; ok {
return properties
}
if def := defaultNamespaceLocation(bucket, namespace); def != "" {
properties[namespaceLocationProperty] = def
}
return properties
}