Files
seaweedfs/weed/server/filer_server_handlers_write.go
T
Chris LuandGitHub 0f718f8509 filer: add a placement overlay seam for the write path (#10437)
* filer: add a placement overlay seam for the write path

New volumes take their disk type, replication, and data center from the
explicit request or the matched filer.conf rule. That leaves no way for a
feature to steer a whole collection onto a medium without an operator
writing an fs.configure rule by hand.

Add a generic PlacementOverlay hook on the filer: a func that maps a
collection to a placement override, installed by a factory the way the
plugin-worker handlers register. detectStorageOption consults it between
the explicit request value and the filer.conf rule, so it overrides the
rule but yields to a value the caller asked for.

The seam names no feature concepts, so it stays generic; a downstream
build registers the overlay it wants (e.g. a storage-class Landing tier).

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

* filer: address review on the placement overlay seam

Honor ResolvePlacement's ok flag explicitly rather than relying on empty
values falling through the util.Nvl chain, and log at V(4) when the
overlay steers a collection. Document that RegisterPlacementOverlay is
init-only, so the unsynchronized read in NewFiler cannot race the write.

Claude-Session: https://claude.ai/code/session_01Ks16jnt4S7gdDk8cheQ3xu
2026-07-25 11:20:41 -07:00

350 lines
11 KiB
Go

package weed_server
import (
"context"
"errors"
"fmt"
"net/http"
"os"
"strings"
"time"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/operation"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/security"
"github.com/seaweedfs/seaweedfs/weed/stats"
"github.com/seaweedfs/seaweedfs/weed/storage/needle"
"github.com/seaweedfs/seaweedfs/weed/util"
"github.com/seaweedfs/seaweedfs/weed/util/constants"
util_http "github.com/seaweedfs/seaweedfs/weed/util/http"
)
var (
OS_UID = uint32(os.Getuid())
OS_GID = uint32(os.Getgid())
ErrReadOnly = errors.New("read only")
)
type FilerPostResult struct {
Name string `json:"name,omitempty"`
Size int64 `json:"size,omitempty"`
Error string `json:"error,omitempty"`
}
func (fs *FilerServer) assignNewFileInfo(ctx context.Context, so *operation.StorageOption, expectedDataSize uint64) (fileId, urlLocation string, auth security.EncodedJwt, err error) {
stats.FilerHandlerCounter.WithLabelValues(stats.ChunkAssign).Inc()
start := time.Now()
defer func() {
stats.FilerRequestHistogram.WithLabelValues(stats.ChunkAssign).Observe(time.Since(start).Seconds())
}()
ar, altRequest := so.ToAssignRequests(1)
ar.ExpectedDataSize = expectedDataSize
if altRequest != nil {
altRequest.ExpectedDataSize = expectedDataSize
}
// Use a context that ignores cancellation from the request context
assignCtx := context.WithoutCancel(ctx)
assignResult, ae := operation.Assign(assignCtx, fs.filer.GetMaster, fs.grpcDialOption, ar, altRequest)
if ae != nil {
glog.ErrorfCtx(ctx, "failing to assign a file id: %v", ae)
err = ae
return
}
fileId = assignResult.Fid
assignUrl := assignResult.Url
// Prefer same data center
if fs.option.DataCenter != "" {
for _, repl := range assignResult.Replicas {
if repl.DataCenter == fs.option.DataCenter {
assignUrl = repl.Url
break
}
}
}
urlLocation = "http://" + assignUrl + "/" + assignResult.Fid
if so.Fsync {
urlLocation += "?fsync=true"
}
auth = assignResult.Auth
return
}
func (fs *FilerServer) PostHandler(w http.ResponseWriter, r *http.Request, contentLength int64) {
ctx := r.Context()
destination := r.RequestURI
headerDestination := r.Header.Get(s3_constants.SeaweedStorageDestinationHeader)
if headerDestination != "" {
destination = headerDestination
}
// The destination header picks storage rules for a logical destination, but
// the entry is written at r.URL.Path. Enforce the read-only/quota rule on the
// actual write path too, so the header cannot route a write into a read-only
// location.
if headerDestination != "" && fs.filer.FilerConf.MatchStorageRule(r.URL.Path).ReadOnly {
writeJsonError(w, r, http.StatusInsufficientStorage, ErrReadOnly)
return
}
query := r.URL.Query()
so, err := fs.detectStorageOption0(ctx, destination,
query.Get("collection"),
query.Get("replication"),
query.Get("ttl"),
query.Get("disk"),
query.Get("fsync"),
query.Get("dataCenter"),
query.Get("rack"),
query.Get("dataNode"),
query.Get("saveInside"),
)
if err != nil {
if errors.Is(err, ErrReadOnly) {
writeJsonError(w, r, http.StatusInsufficientStorage, err)
} else {
glog.V(1).InfolnCtx(ctx, "post", r.RequestURI, ":", err.Error())
w.WriteHeader(http.StatusInternalServerError)
}
return
}
if util.FullPath(r.URL.Path).IsLongerFileName(so.MaxFileNameLength) {
glog.V(1).InfolnCtx(ctx, "post", r.RequestURI, ": ", "entry name too long")
w.WriteHeader(http.StatusRequestURITooLong)
return
}
// When DiskType is empty,use filer's -disk
if so.DiskType == "" {
so.DiskType = fs.option.DiskType
}
if strings.HasPrefix(r.URL.Path, "/etc") {
so.SaveInside = true
}
if query.Has("mv.from") {
fs.move(ctx, w, r, so)
} else if query.Has("cp.from") {
fs.copy(ctx, w, r, so)
} else {
fs.autoChunk(ctx, w, r, contentLength, so)
}
util_http.CloseRequest(r)
}
func (fs *FilerServer) move(ctx context.Context, w http.ResponseWriter, r *http.Request, so *operation.StorageOption) {
src := r.URL.Query().Get("mv.from")
dst := r.URL.Path
glog.V(2).InfofCtx(ctx, "FilerServer.move %v to %v", src, dst)
var err error
if src, err = clearName(src); err != nil {
writeJsonError(w, r, http.StatusBadRequest, err)
return
}
if dst, err = clearName(dst); err != nil {
writeJsonError(w, r, http.StatusBadRequest, err)
return
}
src = strings.TrimRight(src, "/")
if src == "" {
err = fmt.Errorf("invalid source '/'")
writeJsonError(w, r, http.StatusBadRequest, err)
return
}
srcPath := util.FullPath(src)
dstPath := util.FullPath(dst)
if dstPath.IsLongerFileName(so.MaxFileNameLength) {
err = fmt.Errorf("dst name to long")
writeJsonError(w, r, http.StatusBadRequest, err)
return
}
srcEntry, err := fs.filer.FindEntry(ctx, srcPath)
if err != nil {
err = fmt.Errorf("failed to get src entry '%s', err: %s", src, err)
writeJsonError(w, r, http.StatusBadRequest, err)
return
}
wormEnforced, err := fs.wormEnforcedForEntry(ctx, src)
if err != nil {
writeJsonError(w, r, http.StatusInternalServerError, err)
return
} else if wormEnforced {
// you cannot move a worm file or directory
err = fmt.Errorf("cannot move write-once entry from '%s' to '%s': %s", src, dst, constants.ErrMsgOperationNotPermitted)
writeJsonError(w, r, http.StatusForbidden, err)
return
}
oldDir, oldName := srcPath.DirAndName()
newDir, newName := dstPath.DirAndName()
newName = util.Nvl(newName, oldName)
dstEntry, err := fs.filer.FindEntry(ctx, util.FullPath(strings.TrimRight(dst, "/")))
if err != nil && err != filer_pb.ErrNotFound {
err = fmt.Errorf("failed to get dst entry '%s', err: %s", dst, err)
writeJsonError(w, r, http.StatusInternalServerError, err)
return
}
if err == nil && !dstEntry.IsDirectory() && srcEntry.IsDirectory() {
err = fmt.Errorf("move: cannot overwrite non-directory '%s' with directory '%s'", dst, src)
writeJsonError(w, r, http.StatusBadRequest, err)
return
}
_, err = fs.AtomicRenameEntry(ctx, &filer_pb.AtomicRenameEntryRequest{
OldDirectory: oldDir,
OldName: oldName,
NewDirectory: newDir,
NewName: newName,
})
if err != nil {
err = fmt.Errorf("failed to move entry from '%s' to '%s', err: %s", src, dst, err)
writeJsonError(w, r, http.StatusBadRequest, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// curl -X DELETE http://localhost:8888/path/to
// curl -X DELETE http://localhost:8888/path/to?recursive=true
// curl -X DELETE http://localhost:8888/path/to?recursive=true&ignoreRecursiveError=true
// curl -X DELETE http://localhost:8888/path/to?recursive=true&skipChunkDeletion=true
func (fs *FilerServer) DeleteHandler(w http.ResponseWriter, r *http.Request) {
isRecursive := r.FormValue("recursive") == "true"
if !isRecursive && fs.option.recursiveDelete {
if r.FormValue("recursive") != "false" {
isRecursive = true
}
}
ignoreRecursiveError := r.FormValue("ignoreRecursiveError") == "true"
skipChunkDeletion := r.FormValue("skipChunkDeletion") == "true"
objectPath := r.URL.Path
if len(r.URL.Path) > 1 && strings.HasSuffix(objectPath, "/") {
objectPath = objectPath[0 : len(objectPath)-1]
}
wormEnforced, err := fs.wormEnforcedForEntry(context.TODO(), objectPath)
if err != nil {
writeJsonError(w, r, http.StatusInternalServerError, err)
return
} else if wormEnforced {
writeJsonError(w, r, http.StatusForbidden, errors.New(constants.ErrMsgOperationNotPermitted))
return
}
err = fs.filer.DeleteEntryMetaAndData(context.Background(), util.FullPath(objectPath), isRecursive, ignoreRecursiveError, !skipChunkDeletion, false, nil, 0)
if err != nil && err != filer_pb.ErrNotFound {
glog.V(1).Infoln("deleting", objectPath, ":", err.Error())
writeJsonError(w, r, http.StatusInternalServerError, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (fs *FilerServer) detectStorageOption(ctx context.Context, requestURI, qCollection, qReplication string, ttlSeconds int32, diskType, dataCenter, rack, dataNode string) (*operation.StorageOption, error) {
rule := fs.filer.FilerConf.MatchStorageRule(requestURI)
if rule.ReadOnly {
// Name the read-only prefix so the caller knows which path is locked and why.
// MatchStorageRule leaves LocationPrefix empty when several rules merge; fall back to the request path.
prefix := rule.LocationPrefix
if prefix == "" {
// requestURI may carry a query string on the HTTP path; keep only the path.
prefix, _, _ = strings.Cut(requestURI, "?")
}
return nil, fmt.Errorf("%w: %s (e.g. bucket over quota)", ErrReadOnly, prefix)
}
// Use local variable instead of mutating shared rule
maxFileNameLength := rule.MaxFileNameLength
if maxFileNameLength == 0 {
maxFileNameLength = fs.filer.MaxFilenameLength
}
// required by buckets folder
bucketDefaultCollection := ""
if strings.HasPrefix(requestURI, fs.filer.DirBucketsPath+"/") {
bucketDefaultCollection = fs.filer.DetectBucket(util.FullPath(requestURI))
}
if ttlSeconds == 0 {
ttl, err := needle.ReadTTL(rule.GetTtl())
if err != nil {
glog.ErrorfCtx(ctx, "fail to parse %s ttl setting %s: %v", rule.LocationPrefix, rule.Ttl, err)
}
ttlSeconds = int32(ttl.Minutes()) * 60
}
collection := util.Nvl(qCollection, rule.Collection, bucketDefaultCollection, fs.option.Collection)
// A placement overlay steers a bound collection's new volumes onto a tier.
// It sits between the explicit request and the filer.conf rule: it overrides
// the rule but yields to a value the caller asked for outright. Only a
// steered collection contributes values; an unsteered one clears them so it
// falls straight through to the rule.
overlayDisk, overlayReplication, overlayDataCenter, overlaySteered := fs.filer.ResolvePlacement(collection)
if !overlaySteered {
overlayDisk, overlayReplication, overlayDataCenter = "", "", ""
} else {
glog.V(4).InfofCtx(ctx, "placement overlay steers collection %s: disk=%q replication=%q dataCenter=%q",
collection, overlayDisk, overlayReplication, overlayDataCenter)
}
return &operation.StorageOption{
Replication: util.Nvl(qReplication, overlayReplication, rule.Replication, fs.option.DefaultReplication),
Collection: collection,
DataCenter: util.Nvl(dataCenter, overlayDataCenter, rule.DataCenter, fs.option.DataCenter),
Rack: util.Nvl(rack, rule.Rack, fs.option.Rack),
DataNode: util.Nvl(dataNode, rule.DataNode, fs.option.DataNode),
TtlSeconds: ttlSeconds,
DiskType: util.Nvl(diskType, overlayDisk, rule.DiskType),
Fsync: rule.Fsync,
VolumeGrowthCount: rule.VolumeGrowthCount,
MaxFileNameLength: maxFileNameLength,
}, nil
}
func (fs *FilerServer) detectStorageOption0(ctx context.Context, requestURI, qCollection, qReplication string, qTtl string, diskType string, fsync string, dataCenter, rack, dataNode, saveInside string) (*operation.StorageOption, error) {
ttl, err := needle.ReadTTL(qTtl)
if err != nil {
glog.ErrorfCtx(ctx, "fail to parse ttl %s: %v", qTtl, err)
}
so, err := fs.detectStorageOption(ctx, requestURI, qCollection, qReplication, int32(ttl.Minutes())*60, diskType, dataCenter, rack, dataNode)
if so != nil {
if fsync == "false" {
so.Fsync = false
} else if fsync == "true" {
so.Fsync = true
}
if saveInside == "true" {
so.SaveInside = true
} else {
so.SaveInside = false
}
}
return so, err
}