mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-24 23:10:41 +02:00
* test(s3/lifecycle): integration coverage for versioning + filters
First integration-test bundle building on the existing single-test
backdating harness. Each scenario follows the same shape: create
bucket, set lifecycle, PUT object, backdate mtime via filer
UpdateEntry, run the shell command for one shard sweep, assert
S3-side state.
Five new tests:
- TestLifecycleVersionedBucketCreatesDeleteMarker: Expiration on a
versioned bucket must produce a delete marker (latest after worker
runs is a marker) AND keep the original version directly addressable
by versionId. ListObjectVersions confirms IsLatest=true on the
marker.
- TestLifecycleNoncurrentVersionExpiration: NoncurrentVersionExpiration
fires only on demoted versions. PUT v1, PUT v2 (so v1 → noncurrent),
backdate v1, run worker. v1 must be gone, v2 still current.
- TestLifecycleExpiredDeleteMarkerCleanup: combined rule (noncurrent +
expired-delete-marker) cleans up a sole-survivor marker. PUT v1,
DELETE (creates marker), backdate both, run worker. Every version
AND marker must be gone for the key.
- TestLifecycleDisabledRuleSkipsObject: rule with Status=Disabled
must not produce dispatches even on a backdated match. Negative
test for the engine's enabled-status gate.
- TestLifecycleTagFilter: rule with And{Prefix, Tag} only matches
objects carrying the tag. Two backdated objects (one tagged, one
not) — only the tagged one is removed.
Helpers extracted to keep each test focused: putVersioningEnabled,
putNoncurrentExpirationLifecycle, putExpiredDeleteMarkerLifecycle,
backdateVersionedMtime (ages a specific .versions/v_<id> entry),
runLifecycleShard (one-shot shell invocation with FATAL guard).
* test(s3/lifecycle): tighten noncurrent expiration diagnostics
Local run showed TestLifecycleNoncurrentVersionExpiration failing
with a bare 404 on HEAD(latest), not enough to tell whether v2 was
deleted, the bare-key pointer was removed, or a delete marker was
synthesized. Strengthen the test to:
- HEAD by versionId=v2 first, so we pin "v2 file still on disk"
separately from "the latest pointer resolves to v2"
- on HEAD(latest) failure, log ListObjectVersions output (versions +
markers, with IsLatest) so the next failure shows which side the
bug is on rather than just NotFound
* test(s3/lifecycle): integration coverage for AbortIncompleteMultipartUpload
Exercises the lifecycleAbortMPU handler path that the prefix-based
expiration tests can't reach — routing keys off of .uploads/<id>/
directory events, not regular object events, and the dispatcher uses
a different RPC path (rm on the .uploads/<id>/ folder).
Setup: AbortIncompleteMultipartUpload rule with DaysAfterInitiation=1,
CreateMultipartUpload, UploadPart (so the directory carries the
right shape), backdate the .uploads/<uploadID>/ directory entry 30
days, run the worker. The upload must drop out of
ListMultipartUploads.
Helpers added: putAbortMPULifecycle, backdateUploadDir.
* test(s3/lifecycle): integration coverage for NewerNoncurrentVersions
NewerNoncurrentVersions=N keeps the N most recent noncurrent versions
and expires the rest. Distinct from per-version NoncurrentDays —
depends on per-version rank, not just per-version age — and routes
through routePointerTransition's "needs full expansion" path.
Setup: PUT v1, v2, v3, v4 on a versioned bucket (v4 current; v1-v3
noncurrent), backdate v1+v2+v3 so all satisfy the NoncurrentDays>=1
floor, run the worker. Expect v1+v2 expired (older noncurrent),
v3 (newest noncurrent within keep=1) and v4 (current) preserved.
Helper added: putNewerNoncurrentLifecycle.
* test(s3/lifecycle): integration coverage for suspended-versioning Expiration
Suspended versioning takes a distinct code path in lifecycleDispatch:
the VersioningSuspended branch first deletes the null version (via
deleteSpecificObjectVersion(versionId="null")) and then writes a
fresh delete marker on top. Other branches (Enabled → only writes a
marker; Off → straight rm) miss this two-step.
Setup: enable versioning, PUT v1 (real versionId), suspend
versioning, PUT again (creates the null version, demotes v1 to
noncurrent), set the Expiration rule, backdate the null at the
bare path. Expect: latest is now a fresh delete marker, the
"null" version is gone from ListObjectVersions, and v1 (noncurrent
under Enabled) still addressable directly — suspended Expiration
must only touch the null, not other versions.
Helper added: putVersioningSuspended.
* test(s3/lifecycle): integration coverage for multi-bucket sweep
A single shell-driven shard sweep must process every bucket carrying
lifecycle config, not just the first one alphabetically. Pinned
because the scheduler iterates the buckets directory and a regression
that returns early after the first match would silently disable
lifecycle for every later bucket.
Two buckets, each with their own prefix-expiration rule and a
backdated object. Both must be expired after the same sweep.
* test(s3/lifecycle): integration coverage for ObjectSizeGreaterThan filter
ObjectSizeGreaterThan is a strict > gate (filterAllows uses
ev.Size <= rule.FilterSizeGreaterThan to reject). Pinned at the
boundary: an object whose size equals the threshold must remain;
only an object strictly larger expires. Catches a > vs >= flip.
Two backdated objects on the same prefix, sizes 100 and 150 with
threshold=100 — boundary survives, larger expires.
* test(s3/lifecycle): scrub bucket lifecycle config + versions on cleanup
Tests share one weed mini server. Two pollution modes were producing
order-dependent failures:
- A later test's shard sweep would still load the prior test's
lifecycle config (the worker reads every bucket's XML from filer
state, and DeleteBucket alone doesn't drop lifecycle config
cleanly on this codebase).
- Versioned-bucket tests left versions + delete markers behind that
ListObjectsV2 can't see, so the existing best-effort empty-then-
delete didn't actually empty those buckets.
- The AbortMPU test intentionally leaves an in-flight upload; without
an explicit AbortMultipartUpload the bucket DELETE hits NotEmpty.
Cleanup now runs DeleteBucketLifecycle, ListObjectVersions →
DeleteObject(versionId), ListObjectsV2 → DeleteObject (catches what
ListObjectVersions missed), ListMultipartUploads → AbortMultipartUpload,
then DeleteBucket. Best-effort throughout so a half-torn-down bucket
doesn't fail the cleanup chain.
* test(s3/lifecycle): backdate both versions for NoncurrentDays clock
Per codex review: NoncurrentDays is clocked from the SUCCESSOR
version's mtime (when the displaced version became noncurrent), not
from the displaced version's own mtime. Backdating only v1 left the
clock (v2's mtime) at "now" and the rule never fired — the test was
wrong, not the production path.
Backdate v1=31d and v2=30d so v1 sits past the 1-day threshold
relative to v2, the noncurrent rule fires, and v2 stays current.
* test(s3/lifecycle): assert specific NotFound on multi-bucket deletion
Per codex review: TestLifecycleMultipleBucketsInOneSweep treated any
HeadObject error as "deleted", which lets a transport failure or
dead endpoint mask a real bug. Recognize NoSuchKey/NotFound/HTTP-404
specifically via a small isS3NotFound helper so the assertion
actually proves deletion happened, not just that the call broke.
* test(s3/lifecycle): gofmt size-filter test
* test(s3/lifecycle): integration coverage for Object Lock skip
Object Lock retention must override the lifecycle rule. The handler's
enforceObjectLockProtections check (s3api_internal_lifecycle.go:47)
returns an error when retention is active; the dispatcher then
classifies the outcome as SKIPPED_OBJECT_LOCK and the object stays.
No existing integration test reaches that outcome.
Setup: bucket created with ObjectLockEnabledForBucket=true, expiration
rule on prefix "lock/", two backdated objects under the same prefix —
one with GOVERNANCE retention until 1h from now, one without. After
the worker runs, the unlocked object expires (positive control); the
locked one survives.
Custom cleanup uses BypassGovernanceRetention so the test can drop
the locked version when the test finishes — otherwise the retention
window keeps the bucket from being deleted.
* test(s3/lifecycle): integration coverage for config update between sweeps
An operator changes the lifecycle rule between two shell-driven
sweeps. The second sweep must respect the NEW rule, not a cached
copy of the old one. Each runLifecycleShard invocation spawns a
fresh weed shell subprocess, so cached engine state from a previous
sweep doesn't persist — but a regression that caches rules across
PutBucketLifecycleConfiguration calls within the S3 server itself
would still surface here.
Sweep 1: rule prefix="first/", PUT + backdate firstKey, run worker
→ firstKey expires.
Update rule to prefix="second/", PUT + backdate secondKey AND a
new key under the OLD prefix ("first/post-update.txt"). Sweep 2
must expire only the second-prefix object; the post-update old-
prefix one must survive — config replacement, not merge.
* test(s3/lifecycle): integration coverage for ExpirationDate (past)
Rules with Expiration{Date: <past>} route through ScanAtDate in the
engine (decideMode's ActionKindExpirationDate case) — a separate
compile + dispatch branch from the EventDriven delay-group path the
Days-based tests exercise.
Past date + in-prefix object → must expire. Out-of-prefix object →
must remain. Object also backdated as defense-in-depth so the
assertion doesn't depend on whether the dispatcher consults
MinTriggerAge for date kinds.
* test(s3/lifecycle): integration coverage for bootstrap walk on existing objects
Production scenario: operator enables lifecycle on a bucket that
already holds objects from before the policy. The worker must
discover them via the bootstrap walk (BucketBootstrapper) — there
were no meta-log events to observe because the objects predate the
rule. Without the bootstrap path, only NEW writes would ever match.
Setup: PUT 5 objects (no lifecycle config yet) + 1 out-of-prefix
survivor, backdate all, THEN set the Expiration rule, run the
worker. Every in-prefix pre-existing object must be expired; the
out-of-prefix one must remain.
* test(s3/lifecycle): integration coverage for DeleteBucketLifecycle stops dispatching
Operator UX: after DeleteBucketLifecycle, the worker must observe the
removal on the next sweep and stop expiring objects under the now-gone
rule. A regression that caches old configs across
PutBucketLifecycleConfiguration → DeleteBucketLifecycle would keep
silently dropping objects.
Setup: positive control (rule active, backdated obj expires) →
DeleteBucketLifecycle → PUT + backdate a fresh object → second
sweep. The fresh object must remain.
* test(s3/lifecycle): integration coverage for empty bucket sweep no-op
A bucket carrying lifecycle config but no objects must produce a
successful sweep — no hangs, no errors, no dispatches. Pinned
because the bootstrap walker iterates bucket directories, and an
empty directory is a corner of that traversal that's easy to break
(slice-bounds bug on the first listing returning zero entries).
Asserts: worker logs "loaded lifecycle for" and "shards 0-15
complete", no FATAL output, bucket still exists after the sweep.
* test(s3/lifecycle): fix Object Lock backdate path + skip unwired ScanAtDate
ObjectLock: enabling Object Lock on a bucket implicitly enables
versioning, so PUT objects land at .versions/v_<id>, not at the bare
key. The test was calling backdateMtime (bare path) and failing in
the helper with "filer: no entry is found". Switch to
backdateVersionedMtime with the versionId returned by PutObject.
ExpirationDate: ScanAtDate dispatch path isn't wired to the run-shard
shell command yet — the bootstrap walker explicitly skips actions in
ModeScanAtDate (walker.go:141 says "SCAN_AT_DATE runs its own date-
triggered bootstrap" but no such bootstrap exists in the scheduler or
shell). Skip with a t.Skip + explanation so the test activates the
moment the date-triggered path lands.
* fix(s3/lifecycle): wire ExpirationDate dispatch through bootstrap walker
The walker explicitly skipped ModeScanAtDate actions on the comment
"SCAN_AT_DATE runs its own date-triggered bootstrap" — but no such
bootstrap exists in the scheduler or shell layer. The result: rules
with Expiration{Date: ...} compiled correctly, populated the
snapshot's dateActions map, and were never dispatched.
ExpirationDate is silently a no-op in production.
EvaluateAction already handles ActionKindExpirationDate correctly
(rejects when now.Before(rule.ExpirationDate), otherwise emits
ActionDeleteObject). The walker just needed to fall through instead
of skipping. Pre-date walks become no-ops via EvaluateAction's date
check; post-date walks expire eligible objects.
Un-skip TestLifecycleExpirationDateInThePast — it now exercises the
fixed path end-to-end.
* test(s3/lifecycle): integration coverage for multiple rules per bucket
A single bucket carries two independent Expiration rules with disjoint
prefix filters and different Days thresholds. Each rule must fire
only on its prefix; objects outside both prefixes must survive.
Pinned because Compile builds one CompiledAction per rule per kind
all sharing the same bucket index — a bug that lets one rule's
prefix or threshold leak into another (e.g. last-write-wins on a
shared map) would silently expire wrong objects.
Setup: rule A with prefix=logs/ Days=1, rule B with prefix=tmp/
Days=7. Three backdated objects: logs/access.log, tmp/scratch.bin,
data/keep.bin. After the worker runs, logs/ + tmp/ are gone;
data/ — outside both rule prefixes — survives.
* fix(s3/lifecycle): mark ScanAtDate actions active in Compile
Two layers were silently filtering ScanAtDate actions out of routing:
the walker's mode skip (fixed in e785f59d6) and Compile only marking
ModeEventDriven actions active. MatchPath / MatchOriginalWrite both
require IsActive() to emit a key, so a ScanAtDate action that's never
marked active never reaches a dispatch path even after the walker
falls through.
ScanAtDate's only dispatch path is the bootstrap walk's MatchPath
call — there's no bootstrap-completion rendezvous to wait on. Make
the active flag include ModeScanAtDate alongside the
EventDriven+BootstrapComplete combination.
ExpirationDate-based rules now actually fire end-to-end. The
TestLifecycleExpirationDateInThePast integration test exercises this.
* fix(s3/lifecycle): route date kinds via ComputeDueAt
ExpirationDate has MinTriggerAge=0, so router computed
dueTime = info.ModTime + 0 = info.ModTime. For a backdated entry
that mtime is BEFORE rule.ExpirationDate, so EvaluateAction's
now.Before(rule.ExpirationDate) check returned ActionNone and the
date rule never fired through the event-driven path.
ComputeDueAt already knows the per-kind shape — rule.ExpirationDate
for date kinds, ModTime+Days for the rest — so use it as the
single source of truth for dueTime in Route's main loop.
* test(s3/lifecycle): pin bootstrap walker date dispatch
The original TestWalk_DateActionsSkipped pinned the pre-e785f59d6
behavior that the regular walker skipped ExpirationDate. That
walker was rewired to fire date rules whose date has passed (the
SCAN_AT_DATE bootstrap was never wired); update the test to match.
Split into two: post-date entries dispatch, pre-date entries don't.
* test(s3/lifecycle): drop unused putExpiredDeleteMarkerLifecycle
The helper was never called — TestLifecycleExpiredDeleteMarkerCleanup
constructs a combined noncurrent + expired-marker rule inline, which
the helper doesn't cover. The blank-assignment workaround was just
hiding dead code; remove both.
* test(s3/lifecycle): tighten HeadObject termination check to typed not-found
Generic err != nil also passes on transport/auth/timeouts, letting
the test go green without proving the lifecycle action actually
fired. Switch the three Eventuallyf HeadObject predicates to
isS3NotFound, matching the pattern already in the multi-bucket and
expiration-date tests.
* test(s3/lifecycle): guard ListObjectVersions diagnostic against nil
When ListObjectVersions errors, listOut is nil and the diagnostic
log path panics on listOut.Versions before the real assertion fires.
Branch on (listErr != nil || listOut == nil) so the failure log is
robust whatever ListObjectVersions returned.
442 lines
16 KiB
Go
442 lines
16 KiB
Go
// Versioning + delete-marker + noncurrent integration scenarios for the
|
|
// event-driven lifecycle worker. Same backdating trick as the base test:
|
|
// AWS rejects Days < 1, so the test ages the object via the filer's
|
|
// UpdateEntry RPC and runs the shell command once with -runtime 10s.
|
|
package lifecycle
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/aws/aws-sdk-go-v2/aws"
|
|
"github.com/aws/aws-sdk-go-v2/service/s3"
|
|
"github.com/aws/aws-sdk-go-v2/service/s3/types"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// putVersioningEnabled flips Status=Enabled on the bucket so subsequent
|
|
// PUTs produce versioned siblings rather than overwriting.
|
|
func putVersioningEnabled(t *testing.T, c *s3.Client, bucket string) {
|
|
t.Helper()
|
|
_, err := c.PutBucketVersioning(context.Background(), &s3.PutBucketVersioningInput{
|
|
Bucket: aws.String(bucket),
|
|
VersioningConfiguration: &types.VersioningConfiguration{Status: types.BucketVersioningStatusEnabled},
|
|
})
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
// putNoncurrentExpirationLifecycle sets a NoncurrentVersionExpiration rule
|
|
// targeting a prefix. Days=1 because AWS rejects 0; the test backdates
|
|
// the noncurrent version.
|
|
func putNoncurrentExpirationLifecycle(t *testing.T, c *s3.Client, bucket, prefix string, days int32) {
|
|
t.Helper()
|
|
_, err := c.PutBucketLifecycleConfiguration(context.Background(), &s3.PutBucketLifecycleConfigurationInput{
|
|
Bucket: aws.String(bucket),
|
|
LifecycleConfiguration: &types.BucketLifecycleConfiguration{
|
|
Rules: []types.LifecycleRule{
|
|
{
|
|
ID: aws.String("expire-noncurrent"),
|
|
Status: types.ExpirationStatusEnabled,
|
|
Filter: &types.LifecycleRuleFilter{Prefix: aws.String(prefix)},
|
|
NoncurrentVersionExpiration: &types.NoncurrentVersionExpiration{
|
|
NoncurrentDays: aws.Int32(days),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
// backdateVersionedMtime ages a specific .versions/v_<id> entry. The
|
|
// version files live under <buckets>/<bucket>/<key>.versions/v_<versionId>.
|
|
func backdateVersionedMtime(t *testing.T, fc filer_pb.SeaweedFilerClient, bucket, key, versionID string, daysOld int) {
|
|
t.Helper()
|
|
dir := bucketsPath + "/" + bucket + "/" + key + ".versions"
|
|
name := "v_" + versionID
|
|
resp, err := fc.LookupDirectoryEntry(context.Background(), &filer_pb.LookupDirectoryEntryRequest{
|
|
Directory: dir, Name: name,
|
|
})
|
|
require.NoError(t, err, "lookup version %s/%s", dir, name)
|
|
require.NotNil(t, resp.Entry)
|
|
require.NotNil(t, resp.Entry.Attributes)
|
|
|
|
resp.Entry.Attributes.Mtime = time.Now().Add(-time.Duration(daysOld) * 24 * time.Hour).Unix()
|
|
resp.Entry.Attributes.MtimeNs = 0
|
|
_, err = fc.UpdateEntry(context.Background(), &filer_pb.UpdateEntryRequest{
|
|
Directory: dir, Entry: resp.Entry,
|
|
})
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
func runLifecycleShard(t *testing.T) string {
|
|
t.Helper()
|
|
out := runShellCommand(t, fmt.Sprintf(
|
|
"s3.lifecycle.run-shard -shards 0-15 -s3 %s -events 0 -dispatch 200ms -checkpoint 5s -runtime 10s",
|
|
envOr("S3_GRPC_ENDPOINT", defaultS3GrpcEndpoint),
|
|
))
|
|
require.NotContains(t, out, "FATAL", "shell output:\n%s", out)
|
|
return out
|
|
}
|
|
|
|
// TestLifecycleVersionedBucketCreatesDeleteMarker: an Expiration rule on a
|
|
// versioned bucket must produce a delete marker (latest version after
|
|
// expiration) rather than removing the version itself. Pre-fix this would
|
|
// have unconditionally deleted the live version.
|
|
func TestLifecycleVersionedBucketCreatesDeleteMarker(t *testing.T) {
|
|
c := s3Client(t)
|
|
fc, fcClose := filerClient(t)
|
|
defer fcClose()
|
|
|
|
bucket := uniqueBucket("vexp")
|
|
mustCreateBucket(t, c, bucket)
|
|
putVersioningEnabled(t, c, bucket)
|
|
putExpirationLifecycle(t, c, bucket, "expire/", 1)
|
|
|
|
const key = "expire/old.txt"
|
|
putObject(t, c, bucket, key, "v1")
|
|
|
|
// Look up the version we just created so we can backdate it. The bare
|
|
// filer entry under <bucket>/<key> is the latest-version pointer in
|
|
// SeaweedFS's versioned layout.
|
|
headOut, err := c.HeadObject(context.Background(), &s3.HeadObjectInput{
|
|
Bucket: aws.String(bucket), Key: aws.String(key),
|
|
})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, headOut.VersionId)
|
|
versionID := aws.ToString(headOut.VersionId)
|
|
require.NotEmpty(t, versionID)
|
|
backdateVersionedMtime(t, fc, bucket, key, versionID, 30)
|
|
|
|
out := runLifecycleShard(t)
|
|
t.Logf("shell output:\n%s", out)
|
|
|
|
// HEAD without versionId returns the latest version. After the worker
|
|
// runs, the latest must be a delete marker (NoSuchKey on plain HEAD).
|
|
require.Eventuallyf(t, func() bool {
|
|
_, err := c.HeadObject(context.Background(), &s3.HeadObjectInput{
|
|
Bucket: aws.String(bucket), Key: aws.String(key),
|
|
})
|
|
return isS3NotFound(err)
|
|
}, 30*time.Second, 500*time.Millisecond, "expected delete marker to become latest for %s/%s", bucket, key)
|
|
|
|
// The original version must still be addressable directly — Expiration
|
|
// on a versioned bucket creates a marker, it doesn't drop the version.
|
|
directHead, err := c.HeadObject(context.Background(), &s3.HeadObjectInput{
|
|
Bucket: aws.String(bucket), Key: aws.String(key), VersionId: aws.String(versionID),
|
|
})
|
|
require.NoError(t, err, "original version must still exist")
|
|
require.NotNil(t, directHead)
|
|
|
|
// ListObjectVersions should now show a delete marker dominating the
|
|
// version. The IsLatest=true marker proves the worker created a fresh
|
|
// marker rather than aging the existing version.
|
|
listOut, err := c.ListObjectVersions(context.Background(), &s3.ListObjectVersionsInput{
|
|
Bucket: aws.String(bucket), Prefix: aws.String(key),
|
|
})
|
|
require.NoError(t, err)
|
|
var sawMarker bool
|
|
for _, m := range listOut.DeleteMarkers {
|
|
if aws.ToString(m.Key) == key && aws.ToBool(m.IsLatest) {
|
|
sawMarker = true
|
|
break
|
|
}
|
|
}
|
|
require.True(t, sawMarker, "ListObjectVersions must show a delete marker as latest for %s", key)
|
|
}
|
|
|
|
// TestLifecycleNoncurrentVersionExpiration: NoncurrentVersionExpiration
|
|
// only fires on noncurrent versions. PUT v1, PUT v2 (so v1 → noncurrent),
|
|
// backdate v1, run worker. v1 must be removed; v2 stays current.
|
|
func TestLifecycleNoncurrentVersionExpiration(t *testing.T) {
|
|
c := s3Client(t)
|
|
fc, fcClose := filerClient(t)
|
|
defer fcClose()
|
|
|
|
bucket := uniqueBucket("noncurrent")
|
|
mustCreateBucket(t, c, bucket)
|
|
putVersioningEnabled(t, c, bucket)
|
|
putNoncurrentExpirationLifecycle(t, c, bucket, "v/", 1)
|
|
|
|
const key = "v/obj.txt"
|
|
// First PUT.
|
|
put1, err := c.PutObject(context.Background(), &s3.PutObjectInput{
|
|
Bucket: aws.String(bucket), Key: aws.String(key), Body: strings.NewReader("v1"),
|
|
})
|
|
require.NoError(t, err)
|
|
v1 := aws.ToString(put1.VersionId)
|
|
|
|
// Second PUT promotes v1 to noncurrent.
|
|
put2, err := c.PutObject(context.Background(), &s3.PutObjectInput{
|
|
Bucket: aws.String(bucket), Key: aws.String(key), Body: strings.NewReader("v2"),
|
|
})
|
|
require.NoError(t, err)
|
|
v2 := aws.ToString(put2.VersionId)
|
|
require.NotEqual(t, v1, v2)
|
|
|
|
// NoncurrentDays is clocked from the SUCCESSOR's mtime — i.e. when
|
|
// the version became noncurrent — not from the displaced version's
|
|
// own mtime. Backdating only v1 leaves the noncurrent clock (v2's
|
|
// mtime) at "now" and the rule never fires. Age both with v1 older
|
|
// than v2 so the ordering is realistic.
|
|
backdateVersionedMtime(t, fc, bucket, key, v1, 31)
|
|
backdateVersionedMtime(t, fc, bucket, key, v2, 30)
|
|
|
|
out := runLifecycleShard(t)
|
|
t.Logf("shell output:\n%s", out)
|
|
|
|
// v1 must be gone.
|
|
require.Eventuallyf(t, func() bool {
|
|
_, err := c.HeadObject(context.Background(), &s3.HeadObjectInput{
|
|
Bucket: aws.String(bucket), Key: aws.String(key), VersionId: aws.String(v1),
|
|
})
|
|
return isS3NotFound(err)
|
|
}, 30*time.Second, 500*time.Millisecond, "noncurrent v1 must be expired")
|
|
|
|
// v2 must still be addressable BY VERSION ID — pinning that the
|
|
// noncurrent dispatch didn't accidentally remove the current
|
|
// version's file.
|
|
directHead, err := c.HeadObject(context.Background(), &s3.HeadObjectInput{
|
|
Bucket: aws.String(bucket), Key: aws.String(key), VersionId: aws.String(v2),
|
|
})
|
|
require.NoError(t, err, "current version v2 (versionId=%s) must still be addressable directly", v2)
|
|
|
|
// And HEAD without versionId (i.e., the latest) must hit v2 — pinning
|
|
// that the bare-key pointer wasn't removed and a delete marker
|
|
// wasn't fabricated as a side effect of the noncurrent delete. On
|
|
// failure we dump ListObjectVersions so the failure log shows
|
|
// exactly what's there (versions vs delete markers vs bare).
|
|
currentHead, err := c.HeadObject(context.Background(), &s3.HeadObjectInput{
|
|
Bucket: aws.String(bucket), Key: aws.String(key),
|
|
})
|
|
if err != nil {
|
|
listOut, listErr := c.ListObjectVersions(context.Background(), &s3.ListObjectVersionsInput{
|
|
Bucket: aws.String(bucket), Prefix: aws.String(key),
|
|
})
|
|
if listErr != nil || listOut == nil {
|
|
t.Logf("HeadObject(latest) failed: %v\nListObjectVersions err=%v", err, listErr)
|
|
} else {
|
|
t.Logf("HeadObject(latest) failed: %v\nListObjectVersions err=%v versions=%d markers=%d", err, listErr, len(listOut.Versions), len(listOut.DeleteMarkers))
|
|
for _, v := range listOut.Versions {
|
|
t.Logf(" version key=%s id=%s isLatest=%v", aws.ToString(v.Key), aws.ToString(v.VersionId), aws.ToBool(v.IsLatest))
|
|
}
|
|
for _, m := range listOut.DeleteMarkers {
|
|
t.Logf(" marker key=%s id=%s isLatest=%v", aws.ToString(m.Key), aws.ToString(m.VersionId), aws.ToBool(m.IsLatest))
|
|
}
|
|
}
|
|
require.NoError(t, err, "HEAD(latest) on %s/%s must succeed; v2 (%s) must remain current", bucket, key, v2)
|
|
}
|
|
require.Equal(t, v2, aws.ToString(currentHead.VersionId), "latest pointer must still resolve to v2")
|
|
_ = directHead
|
|
}
|
|
|
|
// TestLifecycleExpiredDeleteMarkerCleanup: a sole-survivor delete marker
|
|
// must be removed by an ExpiredObjectDeleteMarker=true rule. Setup: PUT v1,
|
|
// DELETE (creates a marker that becomes latest, with v1 as noncurrent),
|
|
// expire v1 via NoncurrentVersionExpiration so the marker is alone, then
|
|
// the marker rule fires.
|
|
//
|
|
// We exercise this in two passes: pass 1 (Noncurrent rule on a sibling
|
|
// prefix) cleans v1; pass 2 (ExpiredObjectDeleteMarker rule, also on the
|
|
// same prefix) removes the now-orphaned marker. Since the lifecycle XML
|
|
// only carries one set of rules per bucket, we wire both.
|
|
func TestLifecycleExpiredDeleteMarkerCleanup(t *testing.T) {
|
|
c := s3Client(t)
|
|
fc, fcClose := filerClient(t)
|
|
defer fcClose()
|
|
|
|
bucket := uniqueBucket("marker")
|
|
mustCreateBucket(t, c, bucket)
|
|
putVersioningEnabled(t, c, bucket)
|
|
|
|
// Combined rule: noncurrent expiration + delete-marker cleanup, both
|
|
// on the same prefix.
|
|
_, err := c.PutBucketLifecycleConfiguration(context.Background(), &s3.PutBucketLifecycleConfigurationInput{
|
|
Bucket: aws.String(bucket),
|
|
LifecycleConfiguration: &types.BucketLifecycleConfiguration{
|
|
Rules: []types.LifecycleRule{
|
|
{
|
|
ID: aws.String("expire-noncurrent"),
|
|
Status: types.ExpirationStatusEnabled,
|
|
Filter: &types.LifecycleRuleFilter{Prefix: aws.String("m/")},
|
|
NoncurrentVersionExpiration: &types.NoncurrentVersionExpiration{
|
|
NoncurrentDays: aws.Int32(1),
|
|
},
|
|
},
|
|
{
|
|
ID: aws.String("expire-marker"),
|
|
Status: types.ExpirationStatusEnabled,
|
|
Filter: &types.LifecycleRuleFilter{Prefix: aws.String("m/")},
|
|
Expiration: &types.LifecycleExpiration{
|
|
ExpiredObjectDeleteMarker: aws.Bool(true),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
const key = "m/obj.txt"
|
|
put1, err := c.PutObject(context.Background(), &s3.PutObjectInput{
|
|
Bucket: aws.String(bucket), Key: aws.String(key), Body: strings.NewReader("v1"),
|
|
})
|
|
require.NoError(t, err)
|
|
v1 := aws.ToString(put1.VersionId)
|
|
|
|
// Plain DELETE with versioning on creates a marker; the marker becomes
|
|
// latest, v1 demotes to noncurrent.
|
|
_, err = c.DeleteObject(context.Background(), &s3.DeleteObjectInput{
|
|
Bucket: aws.String(bucket), Key: aws.String(key),
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Backdate v1 so the noncurrent rule expires it.
|
|
backdateVersionedMtime(t, fc, bucket, key, v1, 30)
|
|
|
|
// Backdate the delete marker too. ListObjectVersions tells us the
|
|
// marker's versionId.
|
|
listOut, err := c.ListObjectVersions(context.Background(), &s3.ListObjectVersionsInput{
|
|
Bucket: aws.String(bucket), Prefix: aws.String(key),
|
|
})
|
|
require.NoError(t, err)
|
|
var markerID string
|
|
for _, m := range listOut.DeleteMarkers {
|
|
if aws.ToString(m.Key) == key {
|
|
markerID = aws.ToString(m.VersionId)
|
|
break
|
|
}
|
|
}
|
|
require.NotEmpty(t, markerID, "expected to find the delete marker we just created")
|
|
backdateVersionedMtime(t, fc, bucket, key, markerID, 30)
|
|
|
|
out := runLifecycleShard(t)
|
|
t.Logf("shell output:\n%s", out)
|
|
|
|
// Both v1 and the marker must be gone — the .versions/<key>/ folder
|
|
// should hold nothing for this key.
|
|
require.Eventuallyf(t, func() bool {
|
|
listOut, err := c.ListObjectVersions(context.Background(), &s3.ListObjectVersionsInput{
|
|
Bucket: aws.String(bucket), Prefix: aws.String(key),
|
|
})
|
|
if err != nil {
|
|
return false
|
|
}
|
|
for _, v := range listOut.Versions {
|
|
if aws.ToString(v.Key) == key {
|
|
return false
|
|
}
|
|
}
|
|
for _, m := range listOut.DeleteMarkers {
|
|
if aws.ToString(m.Key) == key {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}, 30*time.Second, 500*time.Millisecond, "every version and marker for %s must be gone", key)
|
|
}
|
|
|
|
// TestLifecycleDisabledRuleSkipsObject: a rule with Status=Disabled must
|
|
// not produce dispatches, even on a backdated object that would otherwise
|
|
// match. Negative test for the engine's enabled-status gate.
|
|
func TestLifecycleDisabledRuleSkipsObject(t *testing.T) {
|
|
c := s3Client(t)
|
|
fc, fcClose := filerClient(t)
|
|
defer fcClose()
|
|
|
|
bucket := uniqueBucket("disabled")
|
|
mustCreateBucket(t, c, bucket)
|
|
_, err := c.PutBucketLifecycleConfiguration(context.Background(), &s3.PutBucketLifecycleConfigurationInput{
|
|
Bucket: aws.String(bucket),
|
|
LifecycleConfiguration: &types.BucketLifecycleConfiguration{
|
|
Rules: []types.LifecycleRule{{
|
|
ID: aws.String("disabled-rule"),
|
|
Status: types.ExpirationStatusDisabled,
|
|
Filter: &types.LifecycleRuleFilter{Prefix: aws.String("d/")},
|
|
Expiration: &types.LifecycleExpiration{Days: aws.Int32(1)},
|
|
}},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
const key = "d/obj.txt"
|
|
putObject(t, c, bucket, key, "still-here")
|
|
backdateMtime(t, fc, bucket, key, 30)
|
|
|
|
out := runLifecycleShard(t)
|
|
t.Logf("shell output:\n%s", out)
|
|
|
|
// Wait the same window; the object MUST still exist after.
|
|
time.Sleep(2 * time.Second) // give the worker a chance to (incorrectly) act
|
|
_, err = c.HeadObject(context.Background(), &s3.HeadObjectInput{
|
|
Bucket: aws.String(bucket), Key: aws.String(key),
|
|
})
|
|
require.NoError(t, err, "Disabled rule must not delete the object")
|
|
}
|
|
|
|
// TestLifecycleTagFilter: a rule with a tag filter must only match objects
|
|
// carrying that tag. Two backdated objects, one tagged, one not — only the
|
|
// tagged one is removed.
|
|
func TestLifecycleTagFilter(t *testing.T) {
|
|
c := s3Client(t)
|
|
fc, fcClose := filerClient(t)
|
|
defer fcClose()
|
|
|
|
bucket := uniqueBucket("tagfilter")
|
|
mustCreateBucket(t, c, bucket)
|
|
_, err := c.PutBucketLifecycleConfiguration(context.Background(), &s3.PutBucketLifecycleConfigurationInput{
|
|
Bucket: aws.String(bucket),
|
|
LifecycleConfiguration: &types.BucketLifecycleConfiguration{
|
|
Rules: []types.LifecycleRule{{
|
|
ID: aws.String("tag-only"),
|
|
Status: types.ExpirationStatusEnabled,
|
|
Filter: &types.LifecycleRuleFilter{
|
|
And: &types.LifecycleRuleAndOperator{
|
|
Prefix: aws.String("t/"),
|
|
Tags: []types.Tag{
|
|
{Key: aws.String("env"), Value: aws.String("temp")},
|
|
},
|
|
},
|
|
},
|
|
Expiration: &types.LifecycleExpiration{Days: aws.Int32(1)},
|
|
}},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
const taggedKey = "t/tagged.txt"
|
|
const untaggedKey = "t/untagged.txt"
|
|
putObject(t, c, bucket, taggedKey, "tagged")
|
|
putObject(t, c, bucket, untaggedKey, "untagged")
|
|
|
|
// Stamp the tag on one of them.
|
|
_, err = c.PutObjectTagging(context.Background(), &s3.PutObjectTaggingInput{
|
|
Bucket: aws.String(bucket), Key: aws.String(taggedKey),
|
|
Tagging: &types.Tagging{TagSet: []types.Tag{
|
|
{Key: aws.String("env"), Value: aws.String("temp")},
|
|
}},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
backdateMtime(t, fc, bucket, taggedKey, 30)
|
|
backdateMtime(t, fc, bucket, untaggedKey, 30)
|
|
|
|
out := runLifecycleShard(t)
|
|
t.Logf("shell output:\n%s", out)
|
|
|
|
require.Eventuallyf(t, func() bool {
|
|
_, err := c.HeadObject(context.Background(), &s3.HeadObjectInput{
|
|
Bucket: aws.String(bucket), Key: aws.String(taggedKey),
|
|
})
|
|
return isS3NotFound(err)
|
|
}, 30*time.Second, 500*time.Millisecond, "tagged object must be expired")
|
|
|
|
_, err = c.HeadObject(context.Background(), &s3.HeadObjectInput{
|
|
Bucket: aws.String(bucket), Key: aws.String(untaggedKey),
|
|
})
|
|
require.NoError(t, err, "untagged object must remain")
|
|
}
|