mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-08-17 12:46:59 +00:00
* iceberg: withhold the S3 endpoint from credential-vending clients A client that sends X-Iceberg-Access-Delegation: vended-credentials builds its storage credential out of the LoadTable config and drops the one it was configured with. We vend no credentials, so the endpoint we advertised left DuckDB signing nothing: every metadata and data file came back 403, and its attempt to refresh the empty credential 404ed on stage-created tables. Answer those clients with no config at all so they keep their own credentials. Clients that do not ask for delegation still get the endpoint. * iceberg: mark load responses as varying on the delegation header The FileIO config in a table or view load response now depends on whether the client asked for vended credentials, so a cache between us and the client must key on that header rather than on the URL alone. * test: cover the DuckDB vended-credentials access pattern Runs weed mini with -s3.externalUrl, which is what makes the catalog advertise an endpoint at all, and checks both halves: a plain LoadTable still gets the endpoint, while one asking for vended credentials never gets an endpoint without the credentials to sign with. The DuckDB round trip creates a table from a query and reads it back, which is the flow that failed with 403 on every data file.
322 lines
11 KiB
Go
322 lines
11 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
|
|
}
|
|
|
|
// accessDelegationHeader is how a client asks the catalog to hand back storage
|
|
// credentials along with the table metadata.
|
|
const accessDelegationHeader = "X-Iceberg-Access-Delegation"
|
|
|
|
// wantsVendedCredentials reports whether the client asked for vended
|
|
// credentials. The header carries a comma-separated list of mechanisms.
|
|
func wantsVendedCredentials(r *http.Request) bool {
|
|
if r == nil {
|
|
return false
|
|
}
|
|
for _, value := range r.Header.Values(accessDelegationHeader) {
|
|
for _, mechanism := range strings.Split(value, ",") {
|
|
if strings.EqualFold(strings.TrimSpace(mechanism), "vended-credentials") {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// writeLoadResult writes a table or view load response. The FileIO config it
|
|
// carries depends on the delegation the client asked for, so a cache between
|
|
// us and the client must key on that header and not on the URL alone.
|
|
func writeLoadResult(w http.ResponseWriter, status int, v interface{}) {
|
|
w.Header().Set("Vary", accessDelegationHeader)
|
|
writeJSON(w, status, v)
|
|
}
|
|
|
|
// 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
|
|
}
|