Files
seaweedfs/weed/s3api/s3api_internal_lifecycle_test.go
T
Chris LuandClaude Opus 5.5 858f8cbad8 s3 lifecycle: check internal S3 authentication before starting a pass (#12645)
* s3 lifecycle: add a LifecycleReady RPC that only authorizes the caller

LifecycleReady runs the same checkAdminAuth as LifecycleDelete and reads
no bucket or object metadata, so a lifecycle worker can confirm that an S3
front accepts its internal credentials before it starts a pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3 lifecycle: check S3 fronts with LifecycleReady before the pass

Execute used to dial the first registered S3 front and start loading
policies and walking buckets, so a missing or mismatched
jwt.filer_signing.key surfaced only as repeated per-bucket delete
failures. It now checks each front in order with LifecycleReady, using
the credentials LifecycleDelete sends, under a 10s bound that also
follows parent cancellation. The first front that passes serves the
pass; fronts that fail are reported in the job's progress and the log.
When none passes, the job fails before any listing with an error that
names the front and says whether this worker has the key set, without
key or token material. Fronts that predate LifecycleReady are checked
with an empty LifecycleDelete, which they authorize and then reject
before any lookup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 10:53:07 -08:00

555 lines
22 KiB
Go

package s3api
import (
"bytes"
"context"
"net"
"testing"
"time"
"github.com/prometheus/client_golang/prometheus/testutil"
"github.com/seaweedfs/seaweedfs/weed/cluster"
"github.com/seaweedfs/seaweedfs/weed/pb"
"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"
"github.com/seaweedfs/seaweedfs/weed/security"
stats_collect "github.com/seaweedfs/seaweedfs/weed/stats"
"github.com/stretchr/testify/require"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/peer"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/proto"
)
func TestComputeEntryIdentity_BasicFields(t *testing.T) {
entry := &filer_pb.Entry{
Attributes: &filer_pb.FuseAttributes{Mtime: 1700000000, MtimeNs: 123, FileSize: 4096},
Chunks: []*filer_pb.FileChunk{
{FileId: "1,abc"},
{FileId: "1,def"},
},
}
id := computeEntryIdentity(entry)
want := int64(1700000000)*int64(1e9) + int64(123)
if id.MtimeNs != want {
t.Fatalf("MtimeNs want %d, got %d", want, id.MtimeNs)
}
if id.Size != 4096 {
t.Fatalf("Size want 4096, got %d", id.Size)
}
if id.HeadFid != "1,abc" {
t.Fatalf("HeadFid want 1,abc, got %s", id.HeadFid)
}
}
func TestComputeEntryIdentity_NilSafeMissingChunks(t *testing.T) {
if got := computeEntryIdentity(nil); got != nil {
t.Fatalf("nil entry should return nil, got %v", got)
}
id := computeEntryIdentity(&filer_pb.Entry{})
if id == nil {
t.Fatalf("entry with nil Attributes should still produce identity")
}
if id.HeadFid != "" {
t.Fatalf("missing chunks should yield empty HeadFid, got %s", id.HeadFid)
}
}
func TestHashExtended_OrderStable(t *testing.T) {
a := map[string][]byte{"k1": []byte("v1"), "k2": []byte("v2")}
b := map[string][]byte{"k2": []byte("v2"), "k1": []byte("v1")}
if !bytes.Equal(s3lifecycle.HashExtended(a), s3lifecycle.HashExtended(b)) {
t.Fatalf("hash should be insensitive to map iteration order")
}
}
func TestHashExtended_DelimiterCollisionResistant(t *testing.T) {
// Naively concatenated: "k1=v1k2v2" could collide with "k1=v1k" / "2v2".
// Length-prefix encoding must keep them apart.
a := map[string][]byte{"k1": []byte("v1"), "k2": []byte("v2")}
b := map[string][]byte{"k1": []byte("v1k2v2")}
if bytes.Equal(s3lifecycle.HashExtended(a), s3lifecycle.HashExtended(b)) {
t.Fatalf("delimiter-forged Extended payloads must not collide")
}
}
func TestHashExtended_NilEqualsEmpty(t *testing.T) {
if got := s3lifecycle.HashExtended(nil); len(got) != 0 {
t.Fatalf("nil should produce zero-length hash, got %d bytes", len(got))
}
if got := s3lifecycle.HashExtended(map[string][]byte{}); len(got) != 0 {
t.Fatalf("empty map should produce zero-length hash, got %d bytes", len(got))
}
}
func TestIdentityMatches_NilWantTreatedAsMatch(t *testing.T) {
// Bootstrap callers that don't yet have an identity to CAS against
// pass nil expected_identity; the server treats this as "no CAS".
live := &s3_lifecycle_pb.EntryIdentity{MtimeNs: 1, Size: 2}
if !identityMatches(live, nil) {
t.Fatalf("nil want should match")
}
}
func TestIdentityMatches_NilLiveDoesNotMatch(t *testing.T) {
if identityMatches(nil, &s3_lifecycle_pb.EntryIdentity{MtimeNs: 1}) {
t.Fatalf("nil live should not match a populated want")
}
}
func TestIdentityMatches_AllFieldsCompared(t *testing.T) {
base := &s3_lifecycle_pb.EntryIdentity{MtimeNs: 100, Size: 2048, HeadFid: "1,abc", ExtendedHash: []byte{0x01, 0x02}}
cases := []struct {
name string
live *s3_lifecycle_pb.EntryIdentity
want bool
}{
{"identical", &s3_lifecycle_pb.EntryIdentity{MtimeNs: 100, Size: 2048, HeadFid: "1,abc", ExtendedHash: []byte{0x01, 0x02}}, true},
{"mtime-drift", &s3_lifecycle_pb.EntryIdentity{MtimeNs: 101, Size: 2048, HeadFid: "1,abc", ExtendedHash: []byte{0x01, 0x02}}, false},
{"size-drift", &s3_lifecycle_pb.EntryIdentity{MtimeNs: 100, Size: 2049, HeadFid: "1,abc", ExtendedHash: []byte{0x01, 0x02}}, false},
{"fid-drift", &s3_lifecycle_pb.EntryIdentity{MtimeNs: 100, Size: 2048, HeadFid: "1,xyz", ExtendedHash: []byte{0x01, 0x02}}, false},
{"extended-drift", &s3_lifecycle_pb.EntryIdentity{MtimeNs: 100, Size: 2048, HeadFid: "1,abc", ExtendedHash: []byte{0x03, 0x04}}, false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := identityMatches(c.live, base); got != c.want {
t.Fatalf("want %v, got %v", c.want, got)
}
})
}
}
func TestLifecycleDelete_RejectsEmptyRequest(t *testing.T) {
s := &S3ApiServer{}
resp, err := s.LifecycleDelete(s3LocalPeerCtx(), &s3_lifecycle_pb.LifecycleDeleteRequest{})
if err != nil {
t.Fatalf("unexpected gRPC error: %v", err)
}
if resp.Outcome != s3_lifecycle_pb.LifecycleDeleteOutcome_BLOCKED {
t.Fatalf("empty request should be BLOCKED, got %v", resp.Outcome)
}
}
func TestLifecycleAbortMPU_RejectsTraversalUploadIDs(t *testing.T) {
// "." and ".." pass the no-slash check but resolve to the bucket
// root via util.JoinPath; they must be rejected before any rm call.
s := &S3ApiServer{}
cases := []string{
"",
".uploads",
".uploads/",
".uploads/.",
".uploads/..",
".uploads/u1/extra",
}
for _, path := range cases {
t.Run(path, func(t *testing.T) {
resp, err := s.LifecycleDelete(s3LocalPeerCtx(), &s3_lifecycle_pb.LifecycleDeleteRequest{
Bucket: "bk",
ObjectPath: path,
ActionKind: s3_lifecycle_pb.ActionKind_ABORT_MPU,
})
if err != nil {
t.Fatalf("unexpected gRPC error: %v", err)
}
if resp.Outcome != s3_lifecycle_pb.LifecycleDeleteOutcome_BLOCKED {
t.Fatalf("path %q: outcome=%v reason=%q, want BLOCKED",
path, resp.Outcome, resp.Reason)
}
})
}
}
func TestLifecycleDispatch_AbortMPUAfterFetchIsBlocked(t *testing.T) {
// LifecycleDelete routes ABORT_MPU to lifecycleAbortMPU before
// getObjectEntry; reaching lifecycleDispatch with ABORT_MPU means
// some caller bypassed that route. Defensive BLOCKED so a
// regression there can't accidentally rm a real object via the
// expiration paths.
s := &S3ApiServer{}
resp, err := s.lifecycleDispatch(nil, &s3_lifecycle_pb.LifecycleDeleteRequest{
Bucket: "bk",
ObjectPath: "k",
ActionKind: s3_lifecycle_pb.ActionKind_ABORT_MPU,
}, &filer_pb.Entry{Attributes: &filer_pb.FuseAttributes{}})
if err != nil {
t.Fatalf("unexpected gRPC error: %v", err)
}
if resp.Outcome != s3_lifecycle_pb.LifecycleDeleteOutcome_BLOCKED {
t.Fatalf("ABORT_MPU at dispatch should be BLOCKED, got %v reason=%q", resp.Outcome, resp.Reason)
}
if !contains(resp.Reason, "ABORT_MPU dispatched after fetch") {
t.Fatalf("reason should name the route bypass, got %q", resp.Reason)
}
}
func TestLifecycleDispatch_UnknownActionKindIsBlocked(t *testing.T) {
// An ActionKind value the proto doesn't define yet must produce a
// FATAL outcome rather than fall through to a default delete path.
s := &S3ApiServer{}
const bogus = s3_lifecycle_pb.ActionKind(999)
resp, err := s.lifecycleDispatch(nil, &s3_lifecycle_pb.LifecycleDeleteRequest{
Bucket: "bk",
ObjectPath: "k",
ActionKind: bogus,
}, &filer_pb.Entry{Attributes: &filer_pb.FuseAttributes{}})
if err != nil {
t.Fatalf("unexpected gRPC error: %v", err)
}
if resp.Outcome != s3_lifecycle_pb.LifecycleDeleteOutcome_BLOCKED {
t.Fatalf("unknown action kind should be BLOCKED, got %v reason=%q", resp.Outcome, resp.Reason)
}
if !contains(resp.Reason, "unknown action_kind") {
t.Fatalf("reason should name the unknown kind, got %q", resp.Reason)
}
}
func TestLifecycleDispatch_NoncurrentRequiresVersionID(t *testing.T) {
// Noncurrent / EXPIRED_DELETE_MARKER target a specific version; an
// empty version_id is a writer-side bug and must be rejected before
// any filer call. This pinning keeps the early-return in place so
// a refactor doesn't accidentally let the empty-version_id path
// reach deleteSpecificObjectVersion.
s := &S3ApiServer{}
for _, kind := range []s3_lifecycle_pb.ActionKind{
s3_lifecycle_pb.ActionKind_NONCURRENT_DAYS,
s3_lifecycle_pb.ActionKind_NEWER_NONCURRENT,
s3_lifecycle_pb.ActionKind_EXPIRED_DELETE_MARKER,
} {
t.Run(kind.String(), func(t *testing.T) {
resp, err := s.lifecycleDispatch(nil, &s3_lifecycle_pb.LifecycleDeleteRequest{
Bucket: "bk",
ObjectPath: "k",
ActionKind: kind,
// VersionId intentionally empty
}, &filer_pb.Entry{Attributes: &filer_pb.FuseAttributes{}})
if err != nil {
t.Fatalf("unexpected gRPC error: %v", err)
}
if resp.Outcome != s3_lifecycle_pb.LifecycleDeleteOutcome_BLOCKED {
t.Fatalf("kind %v with empty version_id should be BLOCKED, got %v reason=%q",
kind, resp.Outcome, resp.Reason)
}
if !contains(resp.Reason, "version_id required") {
t.Fatalf("reason should name the missing version_id, got %q", resp.Reason)
}
})
}
}
// contains is a tiny helper so the tests above don't pull in strings
// just for a substring check.
func contains(haystack, needle string) bool {
if len(needle) == 0 {
return true
}
for i := 0; i+len(needle) <= len(haystack); i++ {
if haystack[i:i+len(needle)] == needle {
return true
}
}
return false
}
func TestEntryUsesMetadataOnlyDelete(t *testing.T) {
cases := []struct {
name string
entry *filer_pb.Entry
want bool
}{
{
name: "nil entry",
entry: nil,
want: false,
},
{
name: "nil attributes",
entry: &filer_pb.Entry{},
want: false,
},
{
name: "TtlSec=0 (no per-write stamp)",
entry: &filer_pb.Entry{Attributes: &filer_pb.FuseAttributes{TtlSec: 0}},
want: false,
},
{
name: "TtlSec>0 (PR 9377 stamped a fast-path TTL)",
entry: &filer_pb.Entry{Attributes: &filer_pb.FuseAttributes{TtlSec: 86400}},
want: true,
},
{
name: "TtlSec<0 should not happen but must not flip the path on",
entry: &filer_pb.Entry{Attributes: &filer_pb.FuseAttributes{TtlSec: -1}},
want: false,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := entryUsesMetadataOnlyDelete(c.entry); got != c.want {
t.Fatalf("want %v, got %v", c.want, got)
}
})
}
}
func TestRecordMetadataOnlyIf_OnlyFiresWhenOn(t *testing.T) {
// Counter must increment exactly once per (bucket, hex(rule_hash))
// when on=true, and not at all when on=false. Other lifecycle paths
// in the same suite share the global counter — use distinct bucket
// names per test so series don't bleed.
c := stats_collect.S3LifecycleMetadataOnlyCounter
bucket := "bk-counter-on"
hash := []byte{0xde, 0xad, 0xbe, 0xef, 0x01, 0x02, 0x03, 0x04}
hexHash := "deadbeef01020304"
before := testutil.ToFloat64(c.WithLabelValues(bucket, hexHash))
recordMetadataOnlyIf(true, &s3_lifecycle_pb.LifecycleDeleteRequest{
Bucket: bucket,
RuleHash: hash,
})
if got := testutil.ToFloat64(c.WithLabelValues(bucket, hexHash)); got != before+1 {
t.Fatalf("on=true should bump by 1; before=%v after=%v", before, got)
}
beforeOff := testutil.ToFloat64(c.WithLabelValues("bk-counter-off", hexHash))
recordMetadataOnlyIf(false, &s3_lifecycle_pb.LifecycleDeleteRequest{
Bucket: "bk-counter-off",
RuleHash: hash,
})
if got := testutil.ToFloat64(c.WithLabelValues("bk-counter-off", hexHash)); got != beforeOff {
t.Fatalf("on=false should not bump; before=%v after=%v", beforeOff, got)
}
}
func TestRecordMetadataOnlyIf_NilRequestSafe(t *testing.T) {
// A nil req is a defensive no-op; never panic on the prometheus
// label call which would otherwise dereference req.Bucket.
recordMetadataOnlyIf(true, nil)
}
func TestRecordMetadataOnlyIf_EmptyRuleHashCollapsesToEmptyLabel(t *testing.T) {
// Bootstrap or test paths may not stamp a rule hash; the label
// must end up as an empty string rather than panicking on
// hex.EncodeToString(nil).
c := stats_collect.S3LifecycleMetadataOnlyCounter
bucket := "bk-counter-emptyhash"
before := testutil.ToFloat64(c.WithLabelValues(bucket, ""))
recordMetadataOnlyIf(true, &s3_lifecycle_pb.LifecycleDeleteRequest{Bucket: bucket})
if got := testutil.ToFloat64(c.WithLabelValues(bucket, "")); got != before+1 {
t.Fatalf("nil rule_hash should produce empty-label series; before=%v after=%v", before, got)
}
}
func TestLifecycleDelete_RequiresAdminAuth(t *testing.T) {
s := newTestS3IamCacheServer(t, testS3IamCacheSigningKey)
_, err := s.LifecycleDelete(context.Background(), &s3_lifecycle_pb.LifecycleDeleteRequest{
Bucket: "victim", ObjectPath: "important.dat",
ActionKind: s3_lifecycle_pb.ActionKind_EXPIRATION_DAYS,
})
if got, want := status.Code(err), codes.Unauthenticated; got != want {
t.Fatalf("LifecycleDelete without token: got code %v, want %v (err=%v)", got, want, err)
}
bad := security.GenJwtForFilerAdmin(security.SigningKey("a-different-key"), 60)
_, err = s.LifecycleDelete(s3IamCacheBearerCtx(string(bad)), &s3_lifecycle_pb.LifecycleDeleteRequest{
Bucket: "victim", ObjectPath: "important.dat",
ActionKind: s3_lifecycle_pb.ActionKind_EXPIRATION_DAYS,
})
if got, want := status.Code(err), codes.Unauthenticated; got != want {
t.Fatalf("LifecycleDelete with mis-signed token: got code %v, want %v (err=%v)", got, want, err)
}
}
func TestLifecycleDelete_NoSigningKey_LocalPeerOnly(t *testing.T) {
s := newTestS3IamCacheServer(t, "")
_, err := s.LifecycleDelete(context.Background(), &s3_lifecycle_pb.LifecycleDeleteRequest{})
if got, want := status.Code(err), codes.Unauthenticated; got != want {
t.Fatalf("LifecycleDelete with no peer info: got code %v, want %v (err=%v)", got, want, err)
}
remoteCtx := peer.NewContext(context.Background(),
&peer.Peer{Addr: &net.TCPAddr{IP: net.ParseIP("203.0.113.7"), Port: 11111}})
_, err = s.LifecycleDelete(remoteCtx, &s3_lifecycle_pb.LifecycleDeleteRequest{})
if got, want := status.Code(err), codes.Unauthenticated; got != want {
t.Fatalf("LifecycleDelete from remote peer: got code %v, want %v (err=%v)", got, want, err)
}
localCtx := peer.NewContext(context.Background(),
&peer.Peer{Addr: &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: 11111}})
resp, err := s.LifecycleDelete(localCtx, &s3_lifecycle_pb.LifecycleDeleteRequest{})
if err != nil {
t.Fatalf("LifecycleDelete from loopback without signing key: unexpected error %v", err)
}
if resp == nil || resp.Outcome != s3_lifecycle_pb.LifecycleDeleteOutcome_BLOCKED {
t.Fatalf("expected BLOCKED for empty request without signing key, got %v", resp)
}
}
func TestLifecycleReady_AuthorizesLikeLifecycleDelete(t *testing.T) {
good := security.GenJwtForFilerAdmin(security.SigningKey(testS3IamCacheSigningKey), 60)
bad := security.GenJwtForFilerAdmin(security.SigningKey("a-different-key"), 60)
remoteCtx := peer.NewContext(context.Background(),
&peer.Peer{Addr: &net.TCPAddr{IP: net.ParseIP("203.0.113.7"), Port: 11111}})
cases := []struct {
name string
signingKey string
ctx context.Context
want codes.Code
}{
{"key, no token", testS3IamCacheSigningKey, context.Background(), codes.Unauthenticated},
{"key, mis-signed token", testS3IamCacheSigningKey, s3IamCacheBearerCtx(string(bad)), codes.Unauthenticated},
{"key, valid token", testS3IamCacheSigningKey, s3IamCacheBearerCtx(string(good)), codes.OK},
{"no key, remote peer", "", remoteCtx, codes.Unauthenticated},
{"no key, loopback peer", "", s3LocalPeerCtx(), codes.OK},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
s := newTestS3IamCacheServer(t, tc.signingKey)
_, err := s.LifecycleReady(tc.ctx, &s3_lifecycle_pb.LifecycleReadyRequest{})
if got := status.Code(err); got != tc.want {
t.Fatalf("LifecycleReady: got code %v, want %v (err=%v)", got, tc.want, err)
}
_, deleteErr := s.LifecycleDelete(tc.ctx, &s3_lifecycle_pb.LifecycleDeleteRequest{})
if got := status.Code(deleteErr); got != tc.want {
t.Fatalf("LifecycleDelete disagrees: got code %v, want %v (err=%v)", got, tc.want, deleteErr)
}
})
}
}
func TestLifecycleDelete_CurrentExpirationRequiresLatestVersion(t *testing.T) {
listed := &filer_pb.Entry{
Name: "v_old",
Attributes: &filer_pb.FuseAttributes{Mtime: 1_700_000_000, FileSize: 3},
Chunks: []*filer_pb.FileChunk{{FileId: "3,01637037d6", Size: 3}},
Extended: map[string][]byte{s3_constants.ExtVersionIdKey: []byte("old")},
}
newer := &filer_pb.Entry{
Name: "v_new",
Attributes: &filer_pb.FuseAttributes{Mtime: 1_700_000_100, FileSize: 5},
Chunks: []*filer_pb.FileChunk{{FileId: "3,02637037d6", Size: 5}},
Extended: map[string][]byte{s3_constants.ExtVersionIdKey: []byte("new")},
}
versionsDir := func(latestId string) *filer_pb.Entry {
d := &filer_pb.Entry{Name: "obj" + s3_constants.VersionsFolder, IsDirectory: true, Attributes: &filer_pb.FuseAttributes{}}
if latestId != "" {
d.Extended = map[string][]byte{
s3_constants.ExtLatestVersionIdKey: []byte(latestId),
s3_constants.ExtLatestVersionFileNameKey: []byte("v_" + latestId),
}
}
return d
}
nullOverwrite := &filer_pb.Entry{
Name: "obj",
Attributes: &filer_pb.FuseAttributes{Mtime: 1_700_000_200, FileSize: 7},
Chunks: []*filer_pb.FileChunk{{FileId: "3,03637037d6", Size: 7}},
Extended: map[string][]byte{s3_constants.ExtVersionIdKey: []byte("null")},
}
cases := []struct {
name string
versioning string
versionId string
listed *filer_pb.Entry
entries map[string]*filer_pb.Entry
outcome s3_lifecycle_pb.LifecycleDeleteOutcome
}{
{"newer version", s3_constants.VersioningEnabled, "old", listed, map[string]*filer_pb.Entry{
"/buckets/b/obj.versions": versionsDir("new"),
"/buckets/b/obj.versions/v_old": listed,
"/buckets/b/obj.versions/v_new": newer,
}, s3_lifecycle_pb.LifecycleDeleteOutcome_NOOP_RESOLVED},
{"null version written while suspended", s3_constants.VersioningSuspended, "old", listed, map[string]*filer_pb.Entry{
"/buckets/b/obj": nullOverwrite,
"/buckets/b/obj.versions": versionsDir(""),
"/buckets/b/obj.versions/v_old": listed,
}, s3_lifecycle_pb.LifecycleDeleteOutcome_NOOP_RESOLVED},
{"unaddressed null behind a newer version while suspended", s3_constants.VersioningSuspended, "", nullOverwrite, map[string]*filer_pb.Entry{
"/buckets/b/obj": nullOverwrite,
"/buckets/b/obj.versions": versionsDir("new"),
"/buckets/b/obj.versions/v_new": newer,
}, s3_lifecycle_pb.LifecycleDeleteOutcome_NOOP_RESOLVED},
{"still latest", s3_constants.VersioningEnabled, "old", listed, map[string]*filer_pb.Entry{
"/buckets/b/obj.versions": versionsDir("old"),
"/buckets/b/obj.versions/v_old": listed,
}, s3_lifecycle_pb.LifecycleDeleteOutcome_DONE},
}
for _, kind := range []s3_lifecycle_pb.ActionKind{s3_lifecycle_pb.ActionKind_EXPIRATION_DAYS, s3_lifecycle_pb.ActionKind_EXPIRATION_DATE} {
for _, c := range cases {
t.Run(kind.String()+"/"+c.name, func(t *testing.T) {
entries := map[string]*filer_pb.Entry{}
for k, v := range c.entries {
entries[k] = proto.Clone(v).(*filer_pb.Entry)
}
fake := &ambiguousPutFiler{entries: entries, apply: true}
s3a := newPutTestServer(t, startFakeFiler(t, fake))
s3a.bucketConfigCache = NewBucketConfigCache(time.Minute)
s3a.bucketConfigCache.Set("b", &BucketConfig{Name: "b", Versioning: c.versioning})
resp, err := s3a.LifecycleDelete(s3LocalPeerCtx(), &s3_lifecycle_pb.LifecycleDeleteRequest{
Bucket: "b",
ObjectPath: "obj",
VersionId: c.versionId,
ActionKind: kind,
ExpectedIdentity: computeEntryIdentity(c.listed),
})
require.NoError(t, err)
require.Equal(t, c.outcome, resp.Outcome, resp.Reason)
if c.outcome == s3_lifecycle_pb.LifecycleDeleteOutcome_NOOP_RESOLVED {
require.Equal(t, "VERSION_NOT_LATEST", resp.Reason)
require.Zero(t, fake.txnCount, "no delete marker or delete may be written")
require.Len(t, fake.entries, len(c.entries))
}
})
}
}
}
// Expired-marker cleanup removes a marker that a lock was recorded on: markers
// are never WORM-protected, so the routed delete carries no WORM guard.
func TestLifecycleDelete_LockedExpiredDeleteMarkerSkipsWormGuard(t *testing.T) {
marker := &filer_pb.Entry{
Name: "v_m1",
Attributes: &filer_pb.FuseAttributes{Mtime: 1_700_000_000},
Extended: map[string][]byte{
s3_constants.ExtVersionIdKey: []byte("m1"),
s3_constants.ExtDeleteMarkerKey: []byte("true"),
s3_constants.ExtLegalHoldKey: []byte(s3_constants.LegalHoldOn),
},
}
fake := &ambiguousPutFiler{apply: true, entries: map[string]*filer_pb.Entry{
"/buckets/b/obj.versions": {
Name: "obj" + s3_constants.VersionsFolder,
IsDirectory: true,
Attributes: &filer_pb.FuseAttributes{},
Extended: map[string][]byte{
s3_constants.ExtLatestVersionIdKey: []byte("m1"),
s3_constants.ExtLatestVersionFileNameKey: []byte("v_m1"),
},
},
"/buckets/b/obj.versions/v_m1": proto.Clone(marker).(*filer_pb.Entry),
}}
var conditions []*filer_pb.WriteCondition
fake.txnCheck = func(req *filer_pb.ObjectTransactionRequest) { conditions = append(conditions, req.GetCondition()) }
addr := startFakeFiler(t, fake)
s3a := newPutTestServer(t, addr)
s3a.bucketConfigCache = NewBucketConfigCache(time.Minute)
s3a.bucketConfigCache.Set("b", &BucketConfig{
Name: "b",
Versioning: s3_constants.VersioningEnabled,
ObjectLockConfig: &ObjectLockConfiguration{ObjectLockEnabled: s3_constants.ObjectLockEnabled},
})
s3a.objectWriteLockClient = cluster.NewLockClient(s3a.option.GrpcDialOption, addr)
s3a.objectWriteLockClient.SetRing([]pb.ServerAddress{addr}, 1)
resp, err := s3a.LifecycleDelete(s3LocalPeerCtx(), &s3_lifecycle_pb.LifecycleDeleteRequest{
Bucket: "b",
ObjectPath: "obj",
VersionId: "m1",
ActionKind: s3_lifecycle_pb.ActionKind_EXPIRED_DELETE_MARKER,
ExpectedIdentity: computeEntryIdentity(marker),
})
require.NoError(t, err)
require.Equal(t, s3_lifecycle_pb.LifecycleDeleteOutcome_DONE, resp.Outcome, resp.Reason)
require.Len(t, conditions, 1)
require.Nil(t, conditions[0], "marker delete must not carry WORM guards")
}