mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-24 06:50:40 +02:00
* s3tables: share the UpdateTable authorization gate Extract the validation, load, and permission check from handleUpdateTable into authorizeUpdateTable so the table an update was authorized against is the same snapshot the mutation writes to. Manager gains AuthorizeUpdateTable, mirroring AuthorizeCreateTable, so callers can ask the question without updating anything. * s3tables: share the UpdateView authorization gate Extract the lookup, policy load, and permission check from handleUpdateView into authorizeUpdateView, mirroring authorizeUpdateTable, and expose it as Manager.AuthorizeUpdateView for callers that need the answer without the update. * s3api: scope vended table credentials to read-only on request VendTableCredentials takes a readOnly flag and tablePrefixSessionPolicy emits only s3:GetObject for the table prefix when it is set, keeping the conditioned ListBucket and unconditioned GetBucketLocation statements that reads still need. * iceberg: vend read-only credentials when the caller may not write LoadTable and friends vend storage credentials only for the authority the caller holds: the vend now checks UpdateTable/UpdateView permission and a caller that may only read the catalog entry gets a read-only session. Paths that just passed a write gate (stage-create, view commit) keep the full session. * lance: vend read-only credentials when the caller may not write storage_options vends the same read-only session as the Iceberg catalog when the caller lacks UpdateTable on the table. * lance: keep write credentials for callers authorized to declare A Lance commit is a direct S3 write that never revisits the catalog, so a caller that just passed CreateTable still gets a writable session; the UpdateTable gate applies to describe, the read path. * iceberg, lance: log a failed write check instead of only reading it as denied
454 lines
16 KiB
Go
454 lines
16 KiB
Go
package iceberg
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"path"
|
|
"strings"
|
|
|
|
"github.com/apache/iceberg-go"
|
|
"github.com/apache/iceberg-go/view"
|
|
"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"
|
|
)
|
|
|
|
// handleListViews lists views in a namespace.
|
|
func (s *Server) handleListViews(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)
|
|
identityName := s3_constants.GetIdentityNameFromContext(r)
|
|
|
|
pageToken, pageSize, err := parsePagination(r)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "BadRequestException", err.Error())
|
|
return
|
|
}
|
|
|
|
listReq := &s3tables.ListViewsRequest{
|
|
TableBucketARN: bucketARN,
|
|
Namespace: namespace,
|
|
ContinuationToken: pageToken,
|
|
MaxViews: pageSize,
|
|
}
|
|
var listResp s3tables.ListViewsResponse
|
|
err = s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
|
mgrClient := s3tables.NewManagerClient(client)
|
|
return s.tablesManager.Execute(r.Context(), mgrClient, "ListViews", 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: ListViews error: %v", err)
|
|
writeManagerError(w, err)
|
|
return
|
|
}
|
|
|
|
identifiers := make([]TableIdentifier, 0, len(listResp.Views))
|
|
for _, v := range listResp.Views {
|
|
identifiers = append(identifiers, TableIdentifier{Namespace: namespace, Name: v.Name})
|
|
}
|
|
writeJSON(w, http.StatusOK, ListViewsResponse{
|
|
NextPageToken: listResp.ContinuationToken,
|
|
Identifiers: identifiers,
|
|
})
|
|
}
|
|
|
|
// handleCreateView creates a new view and writes its v1 metadata.json.
|
|
func (s *Server) handleCreateView(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 CreateViewRequest
|
|
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", "view name is required")
|
|
return
|
|
}
|
|
if req.Schema == nil {
|
|
writeError(w, http.StatusBadRequest, "BadRequestException", "view schema is required")
|
|
return
|
|
}
|
|
if req.ViewVersion == nil {
|
|
writeError(w, http.StatusBadRequest, "BadRequestException", "view-version is required")
|
|
return
|
|
}
|
|
|
|
bucketName := getBucketFromPrefix(r)
|
|
bucketARN := buildTableBucketARN(bucketName)
|
|
identityName := s3_constants.GetIdentityNameFromContext(r)
|
|
|
|
viewPath := path.Join(flattenNamespacePath(namespace), req.Name)
|
|
location := strings.TrimSuffix(req.Location, "/")
|
|
if location == "" {
|
|
if req.Properties != nil {
|
|
if warehouse := strings.TrimSuffix(req.Properties["warehouse"], "/"); warehouse != "" {
|
|
location = fmt.Sprintf("%s/%s", warehouse, viewPath)
|
|
}
|
|
}
|
|
if location == "" {
|
|
if warehouse := strings.TrimSuffix(os.Getenv("ICEBERG_WAREHOUSE"), "/"); warehouse != "" {
|
|
location = fmt.Sprintf("%s/%s", warehouse, viewPath)
|
|
}
|
|
}
|
|
if location == "" {
|
|
location = fmt.Sprintf("s3://%s/%s", bucketName, viewPath)
|
|
}
|
|
} else {
|
|
parsedBucket, parsedPath, err := parseS3Location(location)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "BadRequestException", "Invalid view location: "+err.Error())
|
|
return
|
|
}
|
|
if parsedPath == "" {
|
|
location = fmt.Sprintf("s3://%s/%s", parsedBucket, viewPath)
|
|
}
|
|
}
|
|
|
|
metadataBucket, metadataPath, err := parseS3Location(location)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "InternalServerError", "Invalid view location: "+err.Error())
|
|
return
|
|
}
|
|
if metadataBucket != bucketName {
|
|
writeError(w, http.StatusBadRequest, "BadRequestException", "view location must be within bucket "+bucketName)
|
|
return
|
|
}
|
|
if !isValidTablePath(metadataPath) {
|
|
writeError(w, http.StatusBadRequest, "BadRequestException", "invalid view location path")
|
|
return
|
|
}
|
|
|
|
metadata, err := view.NewMetadata(req.ViewVersion, req.Schema, location, viewProperties(req.Properties))
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "BadRequestException", "Failed to build view metadata: "+err.Error())
|
|
return
|
|
}
|
|
|
|
metadataFileName := "v1.metadata.json"
|
|
metadataLocation := fmt.Sprintf("%s/metadata/%s", location, metadataFileName)
|
|
metadataBytes, err := json.Marshal(metadata)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to serialize view metadata: "+err.Error())
|
|
return
|
|
}
|
|
|
|
// Authoritative existence check before touching storage: a registered view
|
|
// short-circuits with its stored definition (idempotent CreateView) so we
|
|
// never overwrite the persisted metadata of an existing view.
|
|
if existsResp, existsErr := s.getView(r, namespace, req.Name); existsErr == nil {
|
|
result, buildErr := s.buildViewResponse(r, existsResp, bucketName, namespace, req.Name)
|
|
if buildErr != nil {
|
|
writeError(w, http.StatusInternalServerError, "InternalServerError", buildErr.Error())
|
|
return
|
|
}
|
|
writeLoadResult(w, http.StatusOK, result)
|
|
return
|
|
} else if !isViewNotFound(existsErr) {
|
|
glog.V(1).Infof("Iceberg: CreateView existence check failed for %s.%s: %v", flattenNamespacePath(namespace), req.Name, existsErr)
|
|
writeManagerError(w, existsErr)
|
|
return
|
|
}
|
|
|
|
createReq := &s3tables.CreateViewRequest{
|
|
TableBucketARN: bucketARN,
|
|
Namespace: namespace,
|
|
Name: req.Name,
|
|
Metadata: &s3tables.TableMetadata{
|
|
Iceberg: &s3tables.IcebergMetadata{TableUUID: metadata.ViewUUID().String()},
|
|
FullMetadata: metadataBytes,
|
|
},
|
|
MetadataLocation: metadataLocation,
|
|
MetadataVersion: 1,
|
|
}
|
|
var createResp s3tables.CreateViewResponse
|
|
err = s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
|
mgrClient := s3tables.NewManagerClient(client)
|
|
return s.tablesManager.Execute(r.Context(), mgrClient, "CreateView", createReq, &createResp, identityName)
|
|
})
|
|
if err != nil {
|
|
if isViewAlreadyExists(err) {
|
|
writeError(w, http.StatusConflict, "AlreadyExistsException", err.Error())
|
|
return
|
|
}
|
|
var viewErr *s3tables.S3TablesError
|
|
if errors.As(err, &viewErr) && viewErr.Type == s3tables.ErrCodeNoSuchNamespace {
|
|
writeError(w, http.StatusNotFound, "NoSuchNamespaceException", fmt.Sprintf("Namespace does not exist: %v", namespace))
|
|
return
|
|
}
|
|
glog.V(1).Infof("Iceberg: CreateView error: %v", err)
|
|
writeManagerError(w, err)
|
|
return
|
|
}
|
|
|
|
// Persist the metadata file only after the catalog registers the view, so a
|
|
// missing namespace or name collision fails before any bytes hit storage.
|
|
if err := s.saveMetadataFile(r.Context(), metadataBucket, metadataPath, metadataFileName, metadataBytes, false); err != nil {
|
|
// Roll back the registered view so it doesn't linger pointing at metadata
|
|
// that was never written.
|
|
if dropErr := s.dropView(r, namespace, req.Name); dropErr != nil {
|
|
glog.V(1).Infof("Iceberg: failed to roll back view %s.%s after metadata write error: %v", flattenNamespacePath(namespace), req.Name, dropErr)
|
|
}
|
|
writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to save view metadata file: "+err.Error())
|
|
return
|
|
}
|
|
|
|
finalLocation := createResp.MetadataLocation
|
|
if finalLocation == "" {
|
|
finalLocation = metadataLocation
|
|
}
|
|
config, _ := s.buildFileIOConfig(r, location, func() bool {
|
|
return s.mayUpdateView(r, bucketName, namespace, req.Name)
|
|
})
|
|
writeLoadResult(w, http.StatusOK, ViewResponse{
|
|
MetadataLocation: finalLocation,
|
|
Metadata: metadata,
|
|
Config: config,
|
|
})
|
|
}
|
|
|
|
// handleLoadView loads view metadata.
|
|
func (s *Server) handleLoadView(w http.ResponseWriter, r *http.Request) {
|
|
vars := mux.Vars(r)
|
|
namespace := parseNamespace(vars["namespace"])
|
|
viewName := vars["view"]
|
|
if len(namespace) == 0 || viewName == "" {
|
|
writeError(w, http.StatusBadRequest, "BadRequestException", "Namespace and view name are required")
|
|
return
|
|
}
|
|
|
|
getResp, err := s.getView(r, namespace, viewName)
|
|
if err != nil {
|
|
if isViewNotFound(err) {
|
|
writeError(w, http.StatusNotFound, "NoSuchViewException", fmt.Sprintf("View does not exist: %s", viewName))
|
|
return
|
|
}
|
|
glog.V(1).Infof("Iceberg: LoadView error: %v", err)
|
|
writeManagerError(w, err)
|
|
return
|
|
}
|
|
|
|
result, err := s.buildViewResponse(r, getResp, getBucketFromPrefix(r), namespace, viewName)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "InternalServerError", err.Error())
|
|
return
|
|
}
|
|
writeLoadResult(w, http.StatusOK, result)
|
|
}
|
|
|
|
// handleViewExists checks if a view exists.
|
|
func (s *Server) handleViewExists(w http.ResponseWriter, r *http.Request) {
|
|
vars := mux.Vars(r)
|
|
namespace := parseNamespace(vars["namespace"])
|
|
viewName := vars["view"]
|
|
if len(namespace) == 0 || viewName == "" {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
return
|
|
}
|
|
if _, err := s.getView(r, namespace, viewName); err != nil {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// handleDropView deletes a view.
|
|
func (s *Server) handleDropView(w http.ResponseWriter, r *http.Request) {
|
|
vars := mux.Vars(r)
|
|
namespace := parseNamespace(vars["namespace"])
|
|
viewName := vars["view"]
|
|
if len(namespace) == 0 || viewName == "" {
|
|
writeError(w, http.StatusBadRequest, "BadRequestException", "Namespace and view name are required")
|
|
return
|
|
}
|
|
|
|
var storedMetadataLocation string
|
|
if getResp, err := s.getView(r, namespace, viewName); err == nil {
|
|
storedMetadataLocation = getResp.MetadataLocation
|
|
}
|
|
|
|
err := s.dropView(r, namespace, viewName)
|
|
if err != nil {
|
|
if isViewNotFound(err) {
|
|
writeError(w, http.StatusNotFound, "NoSuchViewException", fmt.Sprintf("View does not exist: %s", viewName))
|
|
return
|
|
}
|
|
glog.V(1).Infof("Iceberg: DropView error: %v", err)
|
|
writeManagerError(w, err)
|
|
return
|
|
}
|
|
|
|
if storedMetadataLocation != "" {
|
|
if viewLoc := tableLocationFromMetadataLocation(storedMetadataLocation); viewLoc != "" {
|
|
if dataBucket, dataPath, parseErr := parseS3Location(viewLoc); parseErr == nil {
|
|
if cleanupErr := s.cleanupStaleTableLocation(r.Context(), dataBucket, dataPath); cleanupErr != nil {
|
|
glog.V(1).Infof("Iceberg: failed to purge dropped view location s3://%s/%s: %v", dataBucket, dataPath, cleanupErr)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// dropView removes a view registration from the catalog.
|
|
func (s *Server) dropView(r *http.Request, namespace []string, viewName string) error {
|
|
deleteReq := &s3tables.DeleteViewRequest{
|
|
TableBucketARN: buildTableBucketARN(getBucketFromPrefix(r)),
|
|
Namespace: namespace,
|
|
Name: viewName,
|
|
}
|
|
identityName := s3_constants.GetIdentityNameFromContext(r)
|
|
return s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
|
mgrClient := s3tables.NewManagerClient(client)
|
|
return s.tablesManager.Execute(r.Context(), mgrClient, "DeleteView", deleteReq, nil, identityName)
|
|
})
|
|
}
|
|
|
|
// getView fetches a view's stored metadata pointer from the catalog.
|
|
func (s *Server) getView(r *http.Request, namespace []string, viewName string) (s3tables.GetViewResponse, error) {
|
|
getReq := &s3tables.GetViewRequest{
|
|
TableBucketARN: buildTableBucketARN(getBucketFromPrefix(r)),
|
|
Namespace: namespace,
|
|
Name: viewName,
|
|
}
|
|
var getResp s3tables.GetViewResponse
|
|
identityName := s3_constants.GetIdentityNameFromContext(r)
|
|
err := s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
|
mgrClient := s3tables.NewManagerClient(client)
|
|
return s.tablesManager.Execute(r.Context(), mgrClient, "GetView", getReq, &getResp, identityName)
|
|
})
|
|
return getResp, err
|
|
}
|
|
|
|
// buildViewResponse parses the stored view metadata into a ViewResponse.
|
|
func (s *Server) buildViewResponse(r *http.Request, getResp s3tables.GetViewResponse, bucketName string, namespace []string, viewName string) (ViewResponse, error) {
|
|
if getResp.Metadata == nil || len(getResp.Metadata.FullMetadata) == 0 {
|
|
return ViewResponse{}, fmt.Errorf("view %s has no metadata", viewName)
|
|
}
|
|
metadata, err := view.ParseMetadataBytes(getResp.Metadata.FullMetadata)
|
|
if err != nil {
|
|
return ViewResponse{}, fmt.Errorf("failed to parse view metadata: %w", err)
|
|
}
|
|
config, _ := s.buildFileIOConfig(r, tableLocationFromMetadataLocation(getResp.MetadataLocation), func() bool {
|
|
return s.mayUpdateView(r, bucketName, namespace, viewName)
|
|
})
|
|
return ViewResponse{
|
|
MetadataLocation: getResp.MetadataLocation,
|
|
Metadata: metadata,
|
|
Config: config,
|
|
}, nil
|
|
}
|
|
|
|
// viewProperties returns a non-nil copy of props for view metadata construction.
|
|
func viewProperties(props iceberg.Properties) iceberg.Properties {
|
|
out := make(iceberg.Properties, len(props))
|
|
for k, v := range props {
|
|
out[k] = v
|
|
}
|
|
return out
|
|
}
|
|
|
|
func isViewNotFound(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
var viewErr *s3tables.S3TablesError
|
|
if errors.As(err, &viewErr) {
|
|
if viewErr.Type == s3tables.ErrCodeNoSuchView || viewErr.Type == s3tables.ErrCodeNoSuchNamespace {
|
|
return true
|
|
}
|
|
}
|
|
return strings.Contains(strings.ToLower(err.Error()), "not found")
|
|
}
|
|
|
|
func isViewAlreadyExists(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
var viewErr *s3tables.S3TablesError
|
|
if errors.As(err, &viewErr) && viewErr.Type == s3tables.ErrCodeViewAlreadyExists {
|
|
return true
|
|
}
|
|
return strings.Contains(strings.ToLower(err.Error()), "already exists")
|
|
}
|
|
|
|
// handleRenameView moves a view's catalog pointer to a new namespace/name,
|
|
// the view counterpart of POST /v1/{prefix}/tables/rename.
|
|
func (s *Server) handleRenameView(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)
|
|
identityName := s3_constants.GetIdentityNameFromContext(r)
|
|
|
|
renameReq := &s3tables.RenameTableRequest{
|
|
TableBucketARN: buildTableBucketARN(bucketName),
|
|
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, "RenameView", renameReq, nil, identityName)
|
|
})
|
|
|
|
if err != nil {
|
|
var viewErr *s3tables.S3TablesError
|
|
if errors.As(err, &viewErr) {
|
|
switch viewErr.Type {
|
|
case s3tables.ErrCodeNoSuchView:
|
|
writeError(w, http.StatusNotFound, "NoSuchViewException", fmt.Sprintf("View 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.ErrCodeViewAlreadyExists:
|
|
writeError(w, http.StatusConflict, "AlreadyExistsException", fmt.Sprintf("View already exists: %s", req.Destination.Name))
|
|
return
|
|
case s3tables.ErrCodeInvalidRequest:
|
|
writeError(w, http.StatusBadRequest, "BadRequestException", viewErr.Message)
|
|
return
|
|
}
|
|
}
|
|
glog.V(1).Infof("Iceberg: RenameView error: %v", err)
|
|
writeManagerError(w, err)
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|