Files
seaweedfs/weed/s3api/iceberg/handlers_table.go
T
Chris Lu 28862c866e Authorize an Iceberg table create before it writes (#10991)
* s3tables: share one CreateTable authorization gate

CreateTable and RegisterTable each carried their own copy of the name
validation, policy load and permission check. Fold them into
authorizeCreateTable, and expose it on the Manager for callers that write
into a table bucket before the table itself is registered.

Claude-Session: https://claude.ai/code/session_01QiJkka1T2NAWDWq4JQ8Vuy

* iceberg: authorize a table create before it writes

Stage-create returns before the S3Tables registration that authorizes a
create, and the plain create writes its metadata file before reaching it,
so a caller who may not create the table could still leave a staged
template, a marker and a v1.metadata.json in the target bucket - and get
vended credentials for a location of their choosing. Run the CreateTable
gate as soon as the table is known to be absent.

Claude-Session: https://claude.ai/code/session_01QiJkka1T2NAWDWq4JQ8Vuy

* iceberg: authorize a create-on-commit the same way

A commit against a table that does not exist creates it, writing the
metadata file first and only then reaching the registration that checks
the caller may create it. Denied callers saw a 500 for what is a 403.

Claude-Session: https://claude.ai/code/session_01QiJkka1T2NAWDWq4JQ8Vuy

* iceberg: pin that identity actions reach the create gate

The manager request is built from the caller's own context, so an identity
whose actions carry the permission still passes. Worth a test: a fresh
context here would silently deny every such caller.

Claude-Session: https://claude.ai/code/session_01QiJkka1T2NAWDWq4JQ8Vuy
2026-08-27 16:23:51 -07:00

1001 lines
36 KiB
Go

package iceberg
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"path"
"strconv"
"strings"
"github.com/apache/iceberg-go"
"github.com/apache/iceberg-go/table"
"github.com/google/uuid"
"github.com/gorilla/mux"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
)
var errTableNameRequired = errors.New("table name is required")
func validateCreateTableRequest(req CreateTableRequest) error {
if req.Name == "" {
return errTableNameRequired
}
return nil
}
// handleListTables lists tables in a namespace.
func (s *Server) handleListTables(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
namespace := parseNamespace(vars["namespace"])
if len(namespace) == 0 {
writeError(w, http.StatusBadRequest, "BadRequestException", "Namespace is required")
return
}
bucketName := getBucketFromPrefix(r)
bucketARN := buildTableBucketARN(bucketName)
// Extract identity from context
identityName := s3_constants.GetIdentityNameFromContext(r)
pageToken, pageSize, err := parsePagination(r)
if err != nil {
writeError(w, http.StatusBadRequest, "BadRequestException", err.Error())
return
}
listReq := &s3tables.ListTablesRequest{
TableBucketARN: bucketARN,
Namespace: namespace,
ContinuationToken: pageToken,
MaxTables: pageSize,
}
var listResp s3tables.ListTablesResponse
err = s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
mgrClient := s3tables.NewManagerClient(client)
return s.tablesManager.Execute(r.Context(), mgrClient, "ListTables", listReq, &listResp, identityName)
})
if err != nil {
if strings.Contains(err.Error(), "not found") {
writeError(w, http.StatusNotFound, "NoSuchNamespaceException", fmt.Sprintf("Namespace does not exist: %v", namespace))
return
}
glog.V(1).Infof("Iceberg: ListTables error: %v", err)
writeManagerError(w, err)
return
}
// Convert to Iceberg format
identifiers := make([]TableIdentifier, 0, len(listResp.Tables))
for _, t := range listResp.Tables {
identifiers = append(identifiers, TableIdentifier{
Namespace: namespace,
Name: t.Name,
})
}
result := ListTablesResponse{
NextPageToken: listResp.ContinuationToken,
Identifiers: identifiers,
}
writeJSON(w, http.StatusOK, result)
}
// tablePathOccupied reports whether a filer entry already exists at the catalog
// name path. A leftover directory there (e.g. data kept when another table was
// renamed to this name) means a table created at the default location would
// overwrite it, so the caller routes the new table to a unique location. A
// lookup failure other than not-found is returned so the caller can fail the
// create rather than fall back to the default path on a transient filer error.
func (s *Server) tablePathOccupied(ctx context.Context, bucketName, tablePath string) (bool, error) {
full := path.Join(s3tables.TablesPath, bucketName, tablePath)
dir, name := path.Split(full)
dir = strings.TrimSuffix(dir, "/")
occupied := false
err := s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
_, err := filer_pb.LookupEntry(ctx, client, &filer_pb.LookupDirectoryEntryRequest{
Directory: dir,
Name: name,
})
if errors.Is(err, filer_pb.ErrNotFound) {
return nil
}
if err != nil {
return err
}
occupied = true
return nil
})
return occupied, err
}
// authorizeCreateTable checks the caller may create the table, for the create
// paths that write to the bucket before the catalog registers it.
func (s *Server) authorizeCreateTable(ctx context.Context, bucketARN string, namespace []string, tableName, identityName string) error {
return s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
return s.tablesManager.AuthorizeCreateTable(ctx, s3tables.NewManagerClient(client), &s3tables.CreateTableRequest{
TableBucketARN: bucketARN,
Namespace: namespace,
Name: tableName,
}, identityName)
})
}
// handleCreateTable creates a new table.
func (s *Server) handleCreateTable(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
namespace := parseNamespace(vars["namespace"])
if len(namespace) == 0 {
writeError(w, http.StatusBadRequest, "BadRequestException", "Namespace is required")
return
}
var req CreateTableRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "BadRequestException", "Invalid request body")
return
}
if err := validateCreateTableRequest(req); err != nil {
writeError(w, http.StatusBadRequest, "BadRequestException", err.Error())
return
}
if req.StageCreate && !isStageCreateEnabled() {
writeError(w, http.StatusNotImplemented, "NotImplementedException", "stage-create is disabled")
return
}
bucketName := getBucketFromPrefix(r)
bucketARN := buildTableBucketARN(bucketName)
// Extract identity from context
identityName := s3_constants.GetIdentityNameFromContext(r)
// Generate UUID for the new table
tableUUID := uuid.New()
tablePath := path.Join(flattenNamespacePath(namespace), req.Name)
location := strings.TrimSuffix(req.Location, "/")
if location == "" {
// If a leftover directory already occupies the default location (data
// kept when another table was renamed to this name), route this table
// to a unique location so it cannot overwrite that table's files.
occupied, err := s.tablePathOccupied(r.Context(), bucketName, tablePath)
if err != nil {
writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to check table location: "+err.Error())
return
}
if occupied {
tablePath = path.Join(flattenNamespacePath(namespace), req.Name+"-"+tableUUID.String())
}
if req.Properties != nil {
if warehouse := strings.TrimSuffix(req.Properties["warehouse"], "/"); warehouse != "" {
location = fmt.Sprintf("%s/%s", warehouse, tablePath)
}
}
if location == "" {
if warehouse := strings.TrimSuffix(os.Getenv("ICEBERG_WAREHOUSE"), "/"); warehouse != "" {
location = fmt.Sprintf("%s/%s", warehouse, tablePath)
}
}
if location == "" {
location = fmt.Sprintf("s3://%s/%s", bucketName, tablePath)
}
} else {
parsedBucket, parsedPath, err := parseS3Location(location)
if err != nil {
writeError(w, http.StatusBadRequest, "BadRequestException", "Invalid table location: "+err.Error())
return
}
if parsedPath == "" {
location = fmt.Sprintf("s3://%s/%s", parsedBucket, tablePath)
}
}
// Build proper Iceberg table metadata using iceberg-go types
metadata, err := newTableMetadata(tableUUID, location, req.Schema, req.PartitionSpec, req.WriteOrder, req.Properties)
if err != nil {
glog.V(1).Infof("Iceberg: CreateTable %s metadata error: %v", req.Name, err)
writeManagerError(w, err)
return
}
// Serialize metadata to JSON
metadataBytes, err := json.Marshal(metadata)
if err != nil {
writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to serialize metadata: "+err.Error())
return
}
// Backfill Iceberg spec-required fields that iceberg-go v0.5.0 omits when
// empty (e.g. current-snapshot-id), so the persisted v*.metadata.json is
// readable by strict clients (Java/Spark/Trino) that go directly to S3.
metadataBytes = ensureMetadataSpecCompliance(metadataBytes)
tableName := req.Name
metadataFileName := "v1.metadata.json" // Initial version is always 1
metadataLocation := fmt.Sprintf("%s/metadata/%s", location, metadataFileName)
metadataBucket, metadataPath, err := parseS3Location(location)
if err != nil {
writeError(w, http.StatusInternalServerError, "InternalServerError", "Invalid table location: "+err.Error())
return
}
// location/name are client-supplied; confine the metadata write to the
// authorized catalog bucket and reject traversal segments so path.Join in
// saveMetadataFile cannot escape into another bucket.
if metadataBucket != bucketName {
writeError(w, http.StatusBadRequest, "BadRequestException", "table location must be within bucket "+bucketName)
return
}
if !isValidTablePath(metadataPath) {
writeError(w, http.StatusBadRequest, "BadRequestException", "invalid table location path")
return
}
// Authoritative existence check: ask the catalog whether a table is registered
// at this name. If it is, short-circuit with the existing table (idempotent
// CreateTable). Any leftover objects at the target path from a previous
// lifecycle of the same name are purged by handleDropTable on the way out —
// we deliberately do not clean storage here to keep CreateTable free of
// destructive side effects. See issue #9074.
existsReq := &s3tables.GetTableRequest{
TableBucketARN: bucketARN,
Namespace: namespace,
Name: tableName,
}
var existsResp s3tables.GetTableResponse
existsErr := s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
mgrClient := s3tables.NewManagerClient(client)
return s.tablesManager.Execute(r.Context(), mgrClient, "GetTable", existsReq, &existsResp, identityName)
})
if existsErr == nil {
// Table already registered. Return the existing definition so CTAS/IF NOT
// EXISTS flows see a stable response instead of a 409.
result, buildErr := s.buildLoadTableResult(r, existsResp, bucketName, namespace, tableName)
if buildErr != nil {
glog.Errorf("Iceberg: CreateTable load existing %s: %v", tableName, buildErr)
writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to build table metadata")
return
}
writeLoadResult(w, http.StatusOK, result)
return
}
if !isNoSuchTableError(existsErr) {
glog.V(1).Infof("Iceberg: CreateTable existence check failed for %s.%s: %v", flattenNamespacePath(namespace), tableName, existsErr)
writeManagerError(w, existsErr)
return
}
// Both branches below write into the table bucket, and stage-create never
// reaches the registration that carries the authorization, so the caller has
// to pass the CreateTable gate here.
if authErr := s.authorizeCreateTable(r.Context(), bucketARN, namespace, tableName, identityName); authErr != nil {
writeManagerError(w, authErr)
return
}
// Stage-create persists metadata in the internal staged area and skips S3Tables registration.
if req.StageCreate {
stagedTablePath := stageCreateStagedTablePath(namespace, tableName, tableUUID)
if err := s.saveMetadataFile(r.Context(), metadataBucket, stagedTablePath, metadataFileName, metadataBytes, false); err != nil {
writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to save staged metadata file: "+err.Error())
return
}
stagedMetadataLocation := fmt.Sprintf("s3://%s/%s/metadata/%s", metadataBucket, stagedTablePath, metadataFileName)
if markerErr := s.writeStageCreateMarker(r.Context(), bucketName, namespace, tableName, tableUUID, location, stagedMetadataLocation); markerErr != nil {
glog.V(1).Infof("Iceberg: failed to persist stage-create marker for %s.%s: %v", flattenNamespacePath(namespace), tableName, markerErr)
}
config, storageCredentials := s.buildFileIOConfig(r, location)
result := LoadTableResult{
MetadataLocation: metadataLocation,
Metadata: metadata,
Config: config,
StorageCredentials: storageCredentials,
}
writeLoadResult(w, http.StatusOK, result)
return
}
if err := s.saveMetadataFile(r.Context(), metadataBucket, metadataPath, metadataFileName, metadataBytes, false); err != nil {
writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to save metadata file: "+err.Error())
return
}
// Use S3 Tables manager to create table
createReq := &s3tables.CreateTableRequest{
TableBucketARN: bucketARN,
Namespace: namespace,
Name: tableName,
Format: "ICEBERG",
Metadata: &s3tables.TableMetadata{
Iceberg: &s3tables.IcebergMetadata{
TableUUID: tableUUID.String(),
},
FullMetadata: metadataBytes,
},
MetadataLocation: metadataLocation,
MetadataVersion: 1,
}
var createResp s3tables.CreateTableResponse
err = s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
mgrClient := s3tables.NewManagerClient(client)
return s.tablesManager.Execute(r.Context(), mgrClient, "CreateTable", createReq, &createResp, identityName)
})
if err != nil {
var tableErr *s3tables.S3TablesError
if errors.As(err, &tableErr) && tableErr.Type == s3tables.ErrCodeTableAlreadyExists {
getReq := &s3tables.GetTableRequest{
TableBucketARN: bucketARN,
Namespace: namespace,
Name: tableName,
}
var getResp s3tables.GetTableResponse
getErr := s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
mgrClient := s3tables.NewManagerClient(client)
return s.tablesManager.Execute(r.Context(), mgrClient, "GetTable", getReq, &getResp, identityName)
})
if getErr != nil {
writeError(w, http.StatusConflict, "AlreadyExistsException", err.Error())
return
}
result, buildErr := s.buildLoadTableResult(r, getResp, bucketName, namespace, tableName)
if buildErr != nil {
glog.Errorf("Iceberg: CreateTable load existing %s: %v", tableName, buildErr)
writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to build table metadata")
return
}
writeLoadResult(w, http.StatusOK, result)
return
}
if strings.Contains(err.Error(), "already exists") {
getReq := &s3tables.GetTableRequest{
TableBucketARN: bucketARN,
Namespace: namespace,
Name: tableName,
}
var getResp s3tables.GetTableResponse
getErr := s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
mgrClient := s3tables.NewManagerClient(client)
return s.tablesManager.Execute(r.Context(), mgrClient, "GetTable", getReq, &getResp, identityName)
})
if getErr != nil {
writeError(w, http.StatusConflict, "AlreadyExistsException", err.Error())
return
}
result, buildErr := s.buildLoadTableResult(r, getResp, bucketName, namespace, tableName)
if buildErr != nil {
glog.Errorf("Iceberg: CreateTable load existing %s: %v", tableName, buildErr)
writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to build table metadata")
return
}
writeLoadResult(w, http.StatusOK, result)
return
}
glog.V(1).Infof("Iceberg: CreateTable error: %v", err)
writeManagerError(w, err)
return
}
// Use returned location if available, otherwise fallback to local one
finalLocation := createResp.MetadataLocation
if finalLocation == "" {
finalLocation = metadataLocation
}
if markerErr := s.deleteStageCreateMarkers(r.Context(), bucketName, namespace, tableName); markerErr != nil {
glog.V(1).Infof("Iceberg: failed to cleanup stage-create markers for %s.%s after create: %v", flattenNamespacePath(namespace), tableName, markerErr)
}
config, storageCredentials := s.buildFileIOConfig(r, location)
result := LoadTableResult{
MetadataLocation: finalLocation,
Metadata: metadata,
Config: config,
StorageCredentials: storageCredentials,
}
writeLoadResult(w, http.StatusOK, result)
}
// handleRegisterTable registers an existing metadata.json under a new catalog
// entry without generating new metadata.
func (s *Server) handleRegisterTable(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
namespace := parseNamespace(vars["namespace"])
if len(namespace) == 0 {
writeError(w, http.StatusBadRequest, "BadRequestException", "Namespace is required")
return
}
var req RegisterTableRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "BadRequestException", "Invalid request body")
return
}
if req.Name == "" {
writeError(w, http.StatusBadRequest, "BadRequestException", errTableNameRequired.Error())
return
}
if req.MetadataLocation == "" {
writeError(w, http.StatusBadRequest, "BadRequestException", "metadata-location is required")
return
}
bucketName := getBucketFromPrefix(r)
bucketARN := buildTableBucketARN(bucketName)
identityName := s3_constants.GetIdentityNameFromContext(r)
// Read the existing metadata object before registering, so a bad location
// is rejected (400) without leaving a dangling catalog entry.
metadataBucket, tablePath, err := parseS3Location(tableLocationFromMetadataLocation(req.MetadataLocation))
if err != nil {
writeError(w, http.StatusBadRequest, "BadRequestException", "Invalid metadata-location: "+err.Error())
return
}
// metadata-location is client-supplied; confine the read to the authorized
// catalog bucket and reject traversal segments so path.Join in
// loadMetadataFile cannot escape into another bucket.
if metadataBucket != bucketName {
writeError(w, http.StatusBadRequest, "BadRequestException", "metadata-location must be within bucket "+bucketName)
return
}
if !isValidTablePath(tablePath) {
writeError(w, http.StatusBadRequest, "BadRequestException", "invalid metadata-location path")
return
}
metadataFileName := path.Base(req.MetadataLocation)
metadataBytes, err := s.loadMetadataFile(r.Context(), metadataBucket, tablePath, metadataFileName)
if err != nil {
glog.V(1).Infof("Iceberg: RegisterTable load metadata at %s: %v", req.MetadataLocation, err)
writeError(w, http.StatusBadRequest, "BadRequestException", "Cannot read metadata at "+req.MetadataLocation)
return
}
registerReq := &s3tables.RegisterTableRequest{
TableBucketARN: bucketARN,
Namespace: namespace,
Name: req.Name,
MetadataLocation: req.MetadataLocation,
}
var registerResp s3tables.RegisterTableResponse
err = s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
mgrClient := s3tables.NewManagerClient(client)
return s.tablesManager.Execute(r.Context(), mgrClient, "RegisterTable", registerReq, &registerResp, identityName)
})
if err != nil {
var tableErr *s3tables.S3TablesError
if errors.As(err, &tableErr) {
switch tableErr.Type {
case s3tables.ErrCodeNoSuchNamespace:
writeError(w, http.StatusNotFound, "NoSuchNamespaceException", fmt.Sprintf("Namespace does not exist: %v", namespace))
return
case s3tables.ErrCodeTableAlreadyExists:
writeError(w, http.StatusConflict, "AlreadyExistsException", fmt.Sprintf("Table already exists: %s", req.Name))
return
}
}
glog.V(1).Infof("Iceberg: RegisterTable error: %v", err)
writeManagerError(w, err)
return
}
getResp := s3tables.GetTableResponse{
MetadataLocation: req.MetadataLocation,
Metadata: &s3tables.TableMetadata{FullMetadata: json.RawMessage(metadataBytes)},
}
result, buildErr := s.buildLoadTableResult(r, getResp, bucketName, namespace, req.Name)
if buildErr != nil {
glog.Errorf("Iceberg: RegisterTable %s: %v", req.Name, buildErr)
writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to build table metadata")
return
}
writeLoadResult(w, http.StatusOK, result)
}
// handleLoadTable loads table metadata.
func (s *Server) handleLoadTable(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
namespace := parseNamespace(vars["namespace"])
tableName := vars["table"]
if len(namespace) == 0 || tableName == "" {
writeError(w, http.StatusBadRequest, "BadRequestException", "Namespace and table name are required")
return
}
bucketName := getBucketFromPrefix(r)
bucketARN := buildTableBucketARN(bucketName)
// Extract identity from context
identityName := s3_constants.GetIdentityNameFromContext(r)
getReq := &s3tables.GetTableRequest{
TableBucketARN: bucketARN,
Namespace: namespace,
Name: tableName,
}
var getResp s3tables.GetTableResponse
err := s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
mgrClient := s3tables.NewManagerClient(client)
return s.tablesManager.Execute(r.Context(), mgrClient, "GetTable", getReq, &getResp, identityName)
})
if err != nil {
if strings.Contains(err.Error(), "not found") {
writeError(w, http.StatusNotFound, "NoSuchTableException", fmt.Sprintf("Table does not exist: %s", tableName))
return
}
glog.V(1).Infof("Iceberg: LoadTable error: %v", err)
writeManagerError(w, err)
return
}
result, buildErr := s.buildLoadTableResult(r, getResp, bucketName, namespace, tableName)
if buildErr == nil {
// Only LoadTable defines ?snapshots=; a create response always carries
// the metadata it just wrote.
result.Metadata, buildErr = applySnapshotsParam(r, result.Metadata)
}
if buildErr != nil {
glog.Errorf("Iceberg: LoadTable %s: %v", tableName, buildErr)
writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to build table metadata")
return
}
writeLoadResult(w, http.StatusOK, result)
}
func (s *Server) buildLoadTableResult(r *http.Request, getResp s3tables.GetTableResponse, bucketName string, namespace []string, tableName string) (LoadTableResult, error) {
location := tableLocationFromMetadataLocation(getResp.MetadataLocation)
if location == "" {
location = fmt.Sprintf("s3://%s/%s", bucketName, path.Join(flattenNamespacePath(namespace), tableName))
}
tableUUID := uuid.Nil
if getResp.Metadata != nil && getResp.Metadata.Iceberg != nil && getResp.Metadata.Iceberg.TableUUID != "" {
if parsed, err := uuid.Parse(getResp.Metadata.Iceberg.TableUUID); err == nil {
tableUUID = parsed
}
}
// Use Nil UUID if not found in storage (legacy table)
// Stability is guaranteed by not generating random UUIDs on read
var metadata table.Metadata
var err error
if getResp.Metadata != nil && len(getResp.Metadata.FullMetadata) > 0 {
metadata, err = table.ParseMetadataBytes(getResp.Metadata.FullMetadata)
if err != nil {
glog.Warningf("Iceberg: Failed to parse persisted metadata for %s: %v", tableName, err)
// Attempt to reconstruct from IcebergMetadata if available, otherwise synthetic
// TODO: Extract schema/spec from getResp.Metadata.Iceberg if FullMetadata fails but partial info exists?
// For now, fallback to empty metadata
metadata, err = newTableMetadata(tableUUID, location, nil, nil, nil, nil)
}
} else {
// No full metadata, create synthetic
// TODO: If we had stored schema in IcebergMetadata, we would pass it here
metadata, err = newTableMetadata(tableUUID, location, nil, nil, nil, nil)
}
// A nil metadata would serialize as "metadata":null under HTTP 200, which no
// Iceberg client can parse. Fail the request instead.
if err != nil {
return LoadTableResult{}, fmt.Errorf("build metadata for %s: %w", tableName, err)
}
config, storageCredentials := s.buildFileIOConfig(r, location)
return LoadTableResult{
MetadataLocation: getResp.MetadataLocation,
Metadata: metadata,
Config: config,
StorageCredentials: storageCredentials,
}, nil
}
// applySnapshotsParam honours ?snapshots=refs, which asks for only the
// snapshots that branches and tags point at. Clients use it to avoid pulling a
// long snapshot history they will not read. Anything else, including the
// default, returns the metadata untouched.
func applySnapshotsParam(r *http.Request, metadata table.Metadata) (table.Metadata, error) {
if !strings.EqualFold(strings.TrimSpace(r.URL.Query().Get("snapshots")), "refs") {
return metadata, nil
}
if metadata == nil {
return metadata, nil
}
referenced := make(map[int64]struct{})
for _, ref := range metadata.Refs() {
referenced[ref.SnapshotID] = struct{}{}
}
if current := metadata.CurrentSnapshot(); current != nil {
referenced[current.SnapshotID] = struct{}{}
}
var unreferenced []int64
for _, snapshot := range metadata.Snapshots() {
if _, keep := referenced[snapshot.SnapshotID]; !keep {
unreferenced = append(unreferenced, snapshot.SnapshotID)
}
}
if len(unreferenced) == 0 {
return metadata, nil
}
builder, err := table.MetadataBuilderFromBase(metadata, "")
if err != nil {
return nil, err
}
if err := builder.RemoveSnapshots(unreferenced, false); err != nil {
return nil, err
}
return builder.Build()
}
// buildFileIOConfig returns the FileIO properties to advertise to catalog
// clients so they can read the table's data files directly from S3 without
// separately discovering the endpoint. The region defaults to the same
// value baked into table bucket ARNs so clients like DuckDB that require
// a region on attach don't need to be told it out-of-band. See issue #9103.
// It also returns, for a client that asked for credential vending, the
// credentials scoped to this table.
func (s *Server) buildFileIOConfig(r *http.Request, location string) (iceberg.Properties, []StorageCredential) {
config := make(iceberg.Properties)
if s.s3Endpoint == "" {
return config, nil
}
vended := iceberg.Properties(nil)
if wantsVendedCredentials(r) {
vended = s.vendCredentials(r, location)
// A client asking for vended credentials builds its storage credential
// out of whatever comes back here and stops using the one it was
// configured with. With nothing to vend, an endpoint on its own leaves
// it sending unsigned requests; say nothing instead and let it keep its
// own credentials.
if vended == nil {
return config, nil
}
}
config["s3.endpoint"] = s.s3Endpoint
config["s3.path-style-access"] = "true"
config["s3.region"] = s3tables.DefaultRegion
if vended == nil {
return config, nil
}
credentialConfig := make(iceberg.Properties, len(config)+len(vended))
for key, value := range config {
credentialConfig[key] = value
}
for key, value := range vended {
config[key] = value
credentialConfig[key] = value
}
return config, []StorageCredential{{Prefix: location, Config: credentialConfig}}
}
// vendCredentials mints credentials limited to this table's prefix. It returns
// nil when the deployment has no vending configured or the mint failed, which
// leaves the caller withholding the endpoint so the client keeps its own
// credentials rather than falling back to unsigned requests.
func (s *Server) vendCredentials(r *http.Request, location string) iceberg.Properties {
if s.credentialVendor == nil || location == "" {
return nil
}
bucket, prefix, err := parseS3Location(location)
if err != nil {
glog.V(1).Infof("Iceberg: cannot vend credentials for %s: %v", location, err)
return nil
}
principal := s3_constants.GetIdentityNameFromContext(r)
credentials, err := s.credentialVendor.VendTableCredentials(r.Context(), principal, bucket, prefix)
if err != nil {
glog.Warningf("Iceberg: failed to vend credentials for %s: %v", location, err)
return nil
}
if credentials == nil {
return nil
}
vended := iceberg.Properties{
"s3.access-key-id": credentials.AccessKeyID,
"s3.secret-access-key": credentials.SecretAccessKey,
"s3.session-token": credentials.SessionToken,
}
if !credentials.Expiration.IsZero() {
vended["s3.session-token-expires-at-ms"] = strconv.FormatInt(credentials.Expiration.UnixMilli(), 10)
}
return vended
}
// handleTableExists checks if a table exists.
func (s *Server) handleTableExists(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
namespace := parseNamespace(vars["namespace"])
tableName := vars["table"]
if len(namespace) == 0 || tableName == "" {
w.WriteHeader(http.StatusBadRequest)
return
}
bucketName := getBucketFromPrefix(r)
bucketARN := buildTableBucketARN(bucketName)
// Extract identity from context
identityName := s3_constants.GetIdentityNameFromContext(r)
getReq := &s3tables.GetTableRequest{
TableBucketARN: bucketARN,
Namespace: namespace,
Name: tableName,
}
var getResp s3tables.GetTableResponse
err := s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
mgrClient := s3tables.NewManagerClient(client)
return s.tablesManager.Execute(r.Context(), mgrClient, "GetTable", getReq, &getResp, identityName)
})
if err != nil {
w.WriteHeader(http.StatusNotFound)
return
}
w.WriteHeader(http.StatusNoContent)
}
// handleDropTable deletes a table.
func (s *Server) handleDropTable(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
namespace := parseNamespace(vars["namespace"])
tableName := vars["table"]
if len(namespace) == 0 || tableName == "" {
writeError(w, http.StatusBadRequest, "BadRequestException", "Namespace and table name are required")
return
}
bucketName := getBucketFromPrefix(r)
bucketARN := buildTableBucketARN(bucketName)
// Extract identity from context
identityName := s3_constants.GetIdentityNameFromContext(r)
// Resolve the table's storage location from the catalog before the
// delete, so we can purge data files afterwards. The catalog is the
// authoritative owner of this mapping — if the table is not registered,
// there is nothing to clean up and DeleteTable will return NoSuchTable.
var storedMetadataLocation string
lookupReq := &s3tables.GetTableRequest{
TableBucketARN: bucketARN,
Namespace: namespace,
Name: tableName,
}
var lookupResp s3tables.GetTableResponse
lookupErr := s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
mgrClient := s3tables.NewManagerClient(client)
return s.tablesManager.Execute(r.Context(), mgrClient, "GetTable", lookupReq, &lookupResp, identityName)
})
if lookupErr == nil {
storedMetadataLocation = lookupResp.MetadataLocation
}
deleteReq := &s3tables.DeleteTableRequest{
TableBucketARN: bucketARN,
Namespace: namespace,
Name: tableName,
}
err := s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
mgrClient := s3tables.NewManagerClient(client)
return s.tablesManager.Execute(r.Context(), mgrClient, "DeleteTable", deleteReq, nil, identityName)
})
if err != nil {
if strings.Contains(err.Error(), "not found") {
writeError(w, http.StatusNotFound, "NoSuchTableException", fmt.Sprintf("Table does not exist: %s", tableName))
return
}
glog.V(1).Infof("Iceberg: DropTable error: %v", err)
writeManagerError(w, err)
return
}
// Purge data files that lived under the dropped table's location, so a
// subsequent CREATE TABLE at the same name (issue #9074) does not trip
// Trino's "non-empty location" pre-check. We only purge when the catalog
// told us a location: if GetTable above failed, there is no mapping to
// trust and we leave storage alone.
if storedMetadataLocation != "" {
if tableLoc := tableLocationFromMetadataLocation(storedMetadataLocation); tableLoc != "" {
if dataBucket, dataPath, parseErr := parseS3Location(tableLoc); parseErr == nil {
if cleanupErr := s.cleanupStaleTableLocation(r.Context(), dataBucket, dataPath); cleanupErr != nil {
glog.V(1).Infof("Iceberg: failed to purge dropped table location s3://%s/%s: %v", dataBucket, dataPath, cleanupErr)
}
}
}
}
w.WriteHeader(http.StatusNoContent)
}
// handleRenameTable moves a table's catalog pointer to a new namespace/name.
func (s *Server) handleRenameTable(w http.ResponseWriter, r *http.Request) {
var req RenameTableRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "BadRequestException", "Invalid request body")
return
}
source := parseNamespace(encodeNamespace(req.Source.Namespace))
dest := parseNamespace(encodeNamespace(req.Destination.Namespace))
if len(source) == 0 || req.Source.Name == "" || len(dest) == 0 || req.Destination.Name == "" {
writeError(w, http.StatusBadRequest, "BadRequestException", "source and destination namespace and name are required")
return
}
bucketName := getBucketFromPrefix(r)
bucketARN := buildTableBucketARN(bucketName)
identityName := s3_constants.GetIdentityNameFromContext(r)
renameReq := &s3tables.RenameTableRequest{
TableBucketARN: bucketARN,
SourceNamespace: source,
SourceName: req.Source.Name,
DestNamespace: dest,
DestName: req.Destination.Name,
}
err := s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
mgrClient := s3tables.NewManagerClient(client)
return s.tablesManager.Execute(r.Context(), mgrClient, "RenameTable", renameReq, nil, identityName)
})
if err != nil {
var tableErr *s3tables.S3TablesError
if errors.As(err, &tableErr) {
switch tableErr.Type {
case s3tables.ErrCodeNoSuchTable:
writeError(w, http.StatusNotFound, "NoSuchTableException", fmt.Sprintf("Table does not exist: %s", req.Source.Name))
return
case s3tables.ErrCodeNoSuchNamespace:
writeError(w, http.StatusNotFound, "NoSuchNamespaceException", fmt.Sprintf("Namespace does not exist: %v", dest))
return
case s3tables.ErrCodeTableAlreadyExists:
writeError(w, http.StatusConflict, "AlreadyExistsException", fmt.Sprintf("Table already exists: %s", req.Destination.Name))
return
case s3tables.ErrCodeInvalidRequest:
writeError(w, http.StatusBadRequest, "BadRequestException", tableErr.Message)
return
}
}
glog.V(1).Infof("Iceberg: RenameTable error: %v", err)
writeManagerError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// isNoSuchTableError reports whether an error from the S3 Tables manager
// indicates the target table is not registered in the catalog.
func isNoSuchTableError(err error) bool {
if err == nil {
return false
}
var tableErr *s3tables.S3TablesError
if errors.As(err, &tableErr) {
return tableErr.Type == s3tables.ErrCodeNoSuchTable || tableErr.Type == s3tables.ErrCodeNoSuchNamespace
}
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "not found") || strings.Contains(msg, "no such table")
}
// cleanupStaleTableLocation purges any existing filer entry at the target
// table path inside the regular S3 bucket so that engines which verify an
// empty location (e.g. Trino CTAS) can proceed after a DROP. Callers must
// have confirmed via the catalog that no table is registered at this name —
// live tables must never be touched by this helper. Missing paths are not
// an error.
//
// The tablePath is validated to contain only safe, relative segments before
// being joined with the bucket prefix, so a maliciously crafted location
// (e.g. containing "..") cannot escape the bucket subtree and delete data
// outside of it.
func (s *Server) cleanupStaleTableLocation(ctx context.Context, bucketName, tablePath string) error {
tablePath = strings.Trim(tablePath, "/")
if bucketName == "" || tablePath == "" {
return nil
}
// Reject absolute paths and any traversal segments. path.Clean would
// silently collapse "foo/../bar" into "bar", masking an attempted
// escape, so we check each raw segment explicitly.
for _, segment := range strings.Split(tablePath, "/") {
if segment == "" || segment == "." || segment == ".." || strings.ContainsAny(segment, `\`) {
return fmt.Errorf("cleanupStaleTableLocation: refusing unsafe table path %q", tablePath)
}
}
parentDir := path.Join(s3_constants.DefaultBucketsPath, bucketName, path.Dir(tablePath))
name := path.Base(tablePath)
if name == "" || name == "." || name == ".." || name == "/" {
return fmt.Errorf("cleanupStaleTableLocation: refusing unsafe table path %q", tablePath)
}
return s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
err := filer_pb.DoRemove(ctx, client, parentDir, name, true, true, true, false, nil)
if err == nil || errors.Is(err, filer_pb.ErrNotFound) {
return nil
}
return err
})
}
// newTableMetadata creates a new table.Metadata object with the given parameters.
// Uses iceberg-go's MetadataBuilder pattern for proper spec compliance.
func newTableMetadata(
tableUUID uuid.UUID,
location string,
schema *iceberg.Schema,
partitionSpec *iceberg.PartitionSpec,
sortOrder *table.SortOrder,
props iceberg.Properties,
) (table.Metadata, error) {
// Add schema - use provided or create empty schema
var s *iceberg.Schema
if schema != nil {
s = schema
} else {
s = iceberg.NewSchema(0)
}
// Add partition spec
var pSpec *iceberg.PartitionSpec
if partitionSpec != nil {
pSpec = partitionSpec
} else {
unpartitioned := iceberg.NewPartitionSpecID(0)
pSpec = &unpartitioned
}
// Add sort order
var so table.SortOrder
if sortOrder != nil {
so = *sortOrder
} else {
so = table.UnsortedSortOrder
}
// Create properties map if nil
if props == nil {
props = make(iceberg.Properties)
}
// Create metadata directly using the constructor which ensures spec compliance for V2
metadata, err := table.NewMetadataWithUUID(s, pSpec, so, location, props, tableUUID)
if err != nil {
return nil, err
}
// The constructor reassigns field ids, so the default name mapping must be
// derived from the final schema rather than the request's.
raw, err := json.Marshal(metadata)
if err != nil {
return nil, err
}
if patched := refreshDefaultNameMapping(raw, metadata); !bytes.Equal(patched, raw) {
if withMapping, err := table.ParseMetadataBytes(patched); err == nil {
return withMapping, nil
}
}
return metadata, nil
}