mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-09-13 18:10:49 +02:00
* feat(s3/lifecycle/router): emit ABORT_MPU events for .uploads/<id> init dirs Detect a meta-log event at exactly .uploads/<upload_id> (a directory) and build the ObjectInfo from its destination key (entry.Extended[key]) so a rule with Filter.Prefix=foo/ matches an MPU uploading to foo/bar. Sub-events under .uploads/<id>/<part> ride a different mtime and would over-fire the ABORT_MPU schedule, so they're rejected explicitly. m.ObjectKey stays as ev.Key (.uploads/<upload_id>) — the dispatcher needs the upload directory path, not the destination key, to actually remove the in-flight upload. * feat(s3api): wire LifecycleDelete ABORT_MPU to remove the upload dir Replaces the retryLater stub. Validates the .uploads/<upload_id> shape of req.ObjectPath (so a malformed event can't escalate to a wider rm), then deletes the upload directory under <bucket>/.uploads/<id>. Maps NotFound to NOOP_RESOLVED, transport errors to RETRY_LATER, success to DONE. * refactor(s3api): drop redundant exists check before lifecycle ABORT_MPU rm s3a.rm already does a NotFound-returning lookup, so the pre-check just adds a round-trip. Map filer_pb.ErrNotFound to NOOP_RESOLVED on rm, keep transport errors as RETRY_LATER. * refactor(s3/lifecycle/router): use s3_constants for MPU paths + Extended key Drop the hardcoded ".uploads/" and "key" string literals; the symbols already exist as s3_constants.MultipartUploadsFolder and ExtMultipartObjectKey, and the server side reaches them through the same constants. Keeping the test helpers tied to those names also makes the negative-result tests meaningful — they'd otherwise still pass if the lookup constant drifted. * fix(s3api): close lifecycle ABORT_MPU traversal + NOT_FOUND gaps Two issues with the recent ABORT_MPU plumbing: - "." and ".." passed the no-slash check but resolve to the bucket root via util.JoinPath, so .uploads/.. could rm the wrong directory. - filer.DeleteEntry suppresses ErrNotFound and returns success, so the rm path can't distinguish missing from deleted; the previous version reported DONE for an already-aborted upload instead of NOOP_RESOLVED. Reject the two reserved names explicitly and restore the existence pre-check so the outcome map stays correct. Add a table-test covering the rejected paths. * fix(s3/lifecycle/bootstrap): walk MPU init dirs by destination key A real MPU init record is a directory under .uploads/<id> created by mkdir; the bootstrap walker was skipping every directory entry, so an MPU that existed before the meta-log subscription was never aborted. Even with the skip relaxed, MatchPath used the .uploads/<id> path, so a rule with Filter.Prefix=logs/ would never fire on an MPU uploading to logs/foo.txt. Add Entry.DestKey, let IsMPUInit directories through, and use DestKey for both MatchPath and ObjectInfo.Key. A bare init directory with no DestKey means metadata hasn't landed yet — skip rather than guess. * fix(s3/lifecycle): gate (kind, info) shape so MPU init only fires ABORT_MPU An MPU init record carries IsMPUInit=true and IsLatest=false. Without gating, the router and bootstrap walker matched it against every active ActionKey for the bucket, so NONCURRENT_DAYS / NEWER_NONCURRENT fired (IsLatest=false reads as a noncurrent version). The dispatcher would then BLOCK on empty version_id and freeze the cursor. Add a shape gate at both call sites: - IsMPUInit + non-ABORT_MPU kind → continue - regular object + ABORT_MPU kind → continue Plus a defense-in-depth check at the top of EvaluateAction so future callers can't reintroduce the bug. Tests cover all three layers. * test(s3/lifecycle): tighten dual-action coverage at the call sites - Walk multi-action: replace the kinds-as-set check with an exact-shape DeepEqual on (path, kind) tuples. The set check would have missed an MPU init wrongly firing NONCURRENT_DAYS — exactly the regression the (kind, info) gate fixes. - Router: add a converse case for the dual ExpirationDays + AbortIncompleteMultipartUpload rule. A regular current-version object must fire only EXPIRATION_DAYS; without the gate the dispatcher would also receive ABORT_MPU and rm the object via the MPU code path.
211 lines
8.8 KiB
Go
211 lines
8.8 KiB
Go
package s3api
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
|
|
"github.com/seaweedfs/seaweedfs/weed/glog"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/s3_lifecycle_pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3lifecycle"
|
|
)
|
|
|
|
// LifecycleDelete executes one (rule, action) verdict: re-fetch, identity
|
|
// CAS, object-lock check, dispatch by kind. Errors surface as outcomes;
|
|
// reader cursors and pending state are the worker's concern.
|
|
func (s3a *S3ApiServer) LifecycleDelete(ctx context.Context, req *s3_lifecycle_pb.LifecycleDeleteRequest) (*s3_lifecycle_pb.LifecycleDeleteResponse, error) {
|
|
if req == nil || req.Bucket == "" || req.ObjectPath == "" {
|
|
return blocked("FATAL_EVENT_ERROR: empty bucket or object_path"), nil
|
|
}
|
|
|
|
// MPU init lives at .uploads/<id>/; not handled by getObjectEntry.
|
|
if req.ActionKind == s3_lifecycle_pb.ActionKind_ABORT_MPU {
|
|
return s3a.lifecycleAbortMPU(ctx, req)
|
|
}
|
|
|
|
entry, err := s3a.getObjectEntry(req.Bucket, req.ObjectPath, req.VersionId)
|
|
if err != nil {
|
|
if errors.Is(err, filer_pb.ErrNotFound) || errors.Is(err, ErrObjectNotFound) || errors.Is(err, ErrVersionNotFound) || errors.Is(err, ErrLatestVersionNotFound) {
|
|
return noopResolved("NOT_FOUND"), nil
|
|
}
|
|
glog.V(1).Infof("lifecycle: live fetch %s/%s@%s: %v", req.Bucket, req.ObjectPath, req.VersionId, err)
|
|
return retryLater("TRANSPORT_ERROR: " + err.Error()), nil
|
|
}
|
|
|
|
if !identityMatches(computeEntryIdentity(entry), req.ExpectedIdentity) {
|
|
return noopResolved("STALE_IDENTITY"), nil
|
|
}
|
|
|
|
// Lifecycle never bypasses governance/compliance; the http.Request is
|
|
// only read when bypass is allowed, so nil is safe here.
|
|
if err := s3a.enforceObjectLockProtections(nil, req.Bucket, req.ObjectPath, req.VersionId, false); err != nil {
|
|
glog.V(2).Infof("lifecycle: SKIPPED_OBJECT_LOCK %s/%s@%s: %v", req.Bucket, req.ObjectPath, req.VersionId, err)
|
|
return &s3_lifecycle_pb.LifecycleDeleteResponse{
|
|
Outcome: s3_lifecycle_pb.LifecycleDeleteOutcome_SKIPPED_OBJECT_LOCK,
|
|
Reason: err.Error(),
|
|
}, nil
|
|
}
|
|
|
|
return s3a.lifecycleDispatch(ctx, req, entry)
|
|
}
|
|
|
|
func (s3a *S3ApiServer) lifecycleDispatch(ctx context.Context, req *s3_lifecycle_pb.LifecycleDeleteRequest, entry *filer_pb.Entry) (*s3_lifecycle_pb.LifecycleDeleteResponse, error) {
|
|
switch req.ActionKind {
|
|
case s3_lifecycle_pb.ActionKind_EXPIRATION_DAYS, s3_lifecycle_pb.ActionKind_EXPIRATION_DATE:
|
|
// Current-version expiration: Enabled -> delete marker; Suspended
|
|
// -> delete null + new marker; Off -> remove. Filer errors classify
|
|
// as RETRY_LATER; the worker's budget promotes to BLOCKED.
|
|
state, vErr := s3a.getVersioningState(req.Bucket)
|
|
if vErr != nil {
|
|
if errors.Is(vErr, filer_pb.ErrNotFound) {
|
|
return noopResolved("BUCKET_NOT_FOUND"), nil
|
|
}
|
|
return retryLater("TRANSPORT_ERROR: versioning lookup: " + vErr.Error()), nil
|
|
}
|
|
switch state {
|
|
case s3_constants.VersioningEnabled:
|
|
if _, err := s3a.createDeleteMarker(req.Bucket, req.ObjectPath); err != nil {
|
|
return retryLater("TRANSPORT_ERROR: createDeleteMarker: " + err.Error()), nil
|
|
}
|
|
return done(), nil
|
|
case s3_constants.VersioningSuspended:
|
|
// Best-effort null delete; NotFound is benign.
|
|
if err := s3a.deleteSpecificObjectVersion(req.Bucket, req.ObjectPath, "null"); err != nil {
|
|
if !errors.Is(err, filer_pb.ErrNotFound) && !errors.Is(err, ErrVersionNotFound) {
|
|
return retryLater("TRANSPORT_ERROR: deleteNullVersion: " + err.Error()), nil
|
|
}
|
|
}
|
|
if _, err := s3a.createDeleteMarker(req.Bucket, req.ObjectPath); err != nil {
|
|
return retryLater("TRANSPORT_ERROR: createDeleteMarker: " + err.Error()), nil
|
|
}
|
|
return done(), nil
|
|
default:
|
|
err := s3a.WithFilerClient(false, func(c filer_pb.SeaweedFilerClient) error {
|
|
return s3a.deleteUnversionedObjectWithClient(c, req.Bucket, req.ObjectPath)
|
|
})
|
|
if err != nil {
|
|
if errors.Is(err, filer_pb.ErrNotFound) || errors.Is(err, ErrObjectNotFound) {
|
|
return noopResolved("NOT_FOUND_AT_DELETE"), nil
|
|
}
|
|
return retryLater("TRANSPORT_ERROR: deleteUnversioned: " + err.Error()), nil
|
|
}
|
|
return done(), nil
|
|
}
|
|
|
|
case s3_lifecycle_pb.ActionKind_NONCURRENT_DAYS,
|
|
s3_lifecycle_pb.ActionKind_NEWER_NONCURRENT,
|
|
s3_lifecycle_pb.ActionKind_EXPIRED_DELETE_MARKER:
|
|
// EXPIRED_DELETE_MARKER targets the marker version itself.
|
|
if req.VersionId == "" {
|
|
return blocked("FATAL_EVENT_ERROR: version_id required for noncurrent / delete-marker delete"), nil
|
|
}
|
|
if err := s3a.deleteSpecificObjectVersion(req.Bucket, req.ObjectPath, req.VersionId); err != nil {
|
|
if errors.Is(err, filer_pb.ErrNotFound) || errors.Is(err, ErrVersionNotFound) || errors.Is(err, ErrObjectNotFound) {
|
|
return noopResolved("NOT_FOUND_AT_DELETE"), nil
|
|
}
|
|
return retryLater("TRANSPORT_ERROR: deleteSpecificVersion: " + err.Error()), nil
|
|
}
|
|
return done(), nil
|
|
|
|
case s3_lifecycle_pb.ActionKind_ABORT_MPU:
|
|
return blocked("FATAL_EVENT_ERROR: ABORT_MPU dispatched after fetch"), nil
|
|
|
|
default:
|
|
return blocked("FATAL_EVENT_ERROR: unknown action_kind " + req.ActionKind.String()), nil
|
|
}
|
|
}
|
|
|
|
func (s3a *S3ApiServer) lifecycleAbortMPU(ctx context.Context, req *s3_lifecycle_pb.LifecycleDeleteRequest) (*s3_lifecycle_pb.LifecycleDeleteResponse, error) {
|
|
// req.ObjectPath is `.uploads/<upload_id>` (set by the router from the
|
|
// init directory's bucket-relative path); reject anything that isn't
|
|
// exactly that shape so a malformed event can't escalate to a wider rm.
|
|
const uploadsPrefix = s3_constants.MultipartUploadsFolder + "/"
|
|
if !strings.HasPrefix(req.ObjectPath, uploadsPrefix) {
|
|
return blocked("FATAL_EVENT_ERROR: ABORT_MPU object_path missing .uploads/ prefix"), nil
|
|
}
|
|
uploadID := req.ObjectPath[len(uploadsPrefix):]
|
|
// Reject "." and ".." explicitly: util.JoinPath in the filer cleans
|
|
// path components, so .uploads/.. would resolve to the bucket root.
|
|
if uploadID == "" || uploadID == "." || uploadID == ".." || strings.ContainsRune(uploadID, '/') {
|
|
return blocked("FATAL_EVENT_ERROR: ABORT_MPU object_path malformed: " + req.ObjectPath), nil
|
|
}
|
|
|
|
uploadsFolder := s3a.genUploadsFolder(req.Bucket)
|
|
// Pre-check existence: filer.DeleteEntry suppresses ErrNotFound and
|
|
// returns success, so without this check an already-aborted upload
|
|
// would report DONE instead of the correct NOOP_RESOLVED.
|
|
exists, err := s3a.exists(uploadsFolder, uploadID, true)
|
|
if err != nil {
|
|
if errors.Is(err, filer_pb.ErrNotFound) {
|
|
return noopResolved("NOT_FOUND"), nil
|
|
}
|
|
return retryLater("TRANSPORT_ERROR: exists: " + err.Error()), nil
|
|
}
|
|
if !exists {
|
|
return noopResolved("NOT_FOUND"), nil
|
|
}
|
|
if err := s3a.rm(uploadsFolder, uploadID, true, true); err != nil {
|
|
if errors.Is(err, filer_pb.ErrNotFound) {
|
|
return noopResolved("NOT_FOUND_AT_DELETE"), nil
|
|
}
|
|
glog.V(1).Infof("lifecycle abort_mpu %s/%s: %v", req.Bucket, req.ObjectPath, err)
|
|
return retryLater("TRANSPORT_ERROR: rm: " + err.Error()), nil
|
|
}
|
|
return done(), nil
|
|
}
|
|
|
|
// computeEntryIdentity captures (mtime, size, head fid, sorted-Extended hash):
|
|
// an overwrite changes mtime/size/fid; a metadata edit changes Extended; a
|
|
// snapshot-restore that preserves mtime+size still differs in head_fid.
|
|
func computeEntryIdentity(entry *filer_pb.Entry) *s3_lifecycle_pb.EntryIdentity {
|
|
if entry == nil {
|
|
return nil
|
|
}
|
|
id := &s3_lifecycle_pb.EntryIdentity{}
|
|
if entry.Attributes != nil {
|
|
// FuseAttributes splits the timestamp across Mtime (seconds) and
|
|
// MtimeNs (nanosecond component); EntryIdentity.MtimeNs is the
|
|
// combined nanoseconds-since-epoch value.
|
|
id.MtimeNs = entry.Attributes.Mtime*int64(1e9) + int64(entry.Attributes.MtimeNs)
|
|
id.Size = int64(entry.Attributes.FileSize)
|
|
}
|
|
if len(entry.GetChunks()) > 0 {
|
|
id.HeadFid = entry.GetChunks()[0].GetFileIdString()
|
|
}
|
|
id.ExtendedHash = s3lifecycle.HashExtended(entry.Extended)
|
|
return id
|
|
}
|
|
|
|
func identityMatches(live, want *s3_lifecycle_pb.EntryIdentity) bool {
|
|
if want == nil {
|
|
// No CAS witness (early bootstrap); skip.
|
|
return true
|
|
}
|
|
if live == nil {
|
|
return false
|
|
}
|
|
if live.MtimeNs != want.MtimeNs || live.Size != want.Size {
|
|
return false
|
|
}
|
|
if live.HeadFid != want.HeadFid {
|
|
return false
|
|
}
|
|
return bytes.Equal(live.ExtendedHash, want.ExtendedHash)
|
|
}
|
|
|
|
func done() *s3_lifecycle_pb.LifecycleDeleteResponse {
|
|
return &s3_lifecycle_pb.LifecycleDeleteResponse{Outcome: s3_lifecycle_pb.LifecycleDeleteOutcome_DONE}
|
|
}
|
|
func noopResolved(reason string) *s3_lifecycle_pb.LifecycleDeleteResponse {
|
|
return &s3_lifecycle_pb.LifecycleDeleteResponse{Outcome: s3_lifecycle_pb.LifecycleDeleteOutcome_NOOP_RESOLVED, Reason: reason}
|
|
}
|
|
func blocked(reason string) *s3_lifecycle_pb.LifecycleDeleteResponse {
|
|
return &s3_lifecycle_pb.LifecycleDeleteResponse{Outcome: s3_lifecycle_pb.LifecycleDeleteOutcome_BLOCKED, Reason: reason}
|
|
}
|
|
func retryLater(reason string) *s3_lifecycle_pb.LifecycleDeleteResponse {
|
|
return &s3_lifecycle_pb.LifecycleDeleteResponse{Outcome: s3_lifecycle_pb.LifecycleDeleteOutcome_RETRY_LATER, Reason: reason}
|
|
}
|