Files
seaweedFS/weed/s3api/s3lifecycle/evaluator_test.go
Chris Lu 54dd4f091d s3lifecycle: add lifecycle rule evaluator package and extend XML types (#8807)
* s3api: extend lifecycle XML types with NoncurrentVersionExpiration, AbortIncompleteMultipartUpload

Add missing S3 lifecycle rule types to the XML data model:
- NoncurrentVersionExpiration with NoncurrentDays and NewerNoncurrentVersions
- NoncurrentVersionTransition with NoncurrentDays and StorageClass
- AbortIncompleteMultipartUpload with DaysAfterInitiation
- Filter.ObjectSizeGreaterThan and ObjectSizeLessThan
- And.ObjectSizeGreaterThan and ObjectSizeLessThan
- Filter.UnmarshalXML to properly parse Tag, And, and size filter elements

Each new type follows the existing set-field pattern for conditional
XML marshaling. No behavior changes - these types are not yet wired
into handlers or the lifecycle worker.

* s3lifecycle: add lifecycle rule evaluator package

New package weed/s3api/s3lifecycle/ provides a pure-function lifecycle
rule evaluation engine. The evaluator accepts flattened Rule structs and
ObjectInfo metadata, and returns the appropriate Action.

Components:
- evaluator.go: Evaluate() for per-object actions with S3 priority
  ordering (delete marker > noncurrent version > current expiration),
  ShouldExpireNoncurrentVersion() with NewerNoncurrentVersions support,
  EvaluateMPUAbort() for multipart upload rules
- filter.go: prefix, tag, and size-based filter matching
- tags.go: ExtractTags() extracts S3 tags from filer Extended metadata,
  HasTagRules() for scan-time optimization
- version_time.go: GetVersionTimestamp() extracts timestamps from
  SeaweedFS version IDs (both old and new format)

Comprehensive test coverage: 54 tests covering all action types,
filter combinations, edge cases, and version ID formats.

* s3api: add UnmarshalXML for Expiration, Transition, ExpireDeleteMarker

Add UnmarshalXML methods that set the internal 'set' flag during XML
parsing. Previously these flags were only set programmatically, causing
XML round-trip to drop elements. This ensures lifecycle configurations
stored as XML survive unmarshal/marshal cycles correctly.

Add comprehensive XML round-trip tests for all lifecycle rule types
including NoncurrentVersionExpiration, AbortIncompleteMultipartUpload,
Filter with Tag/And/size constraints, and a complete Terraform-style
lifecycle configuration.

* s3lifecycle: address review feedback

- Fix version_time.go overflow: guard timestampPart > MaxInt64 before
  the inversion subtraction to prevent uint64 wrap
- Make all expiry checks inclusive (!now.Before instead of now.After)
  so actions trigger at the exact scheduled instant
- Add NoncurrentIndex to ObjectInfo so Evaluate() can properly handle
  NewerNoncurrentVersions via ShouldExpireNoncurrentVersion()
- Add test for high-bit overflow version ID

* s3lifecycle: guard ShouldExpireNoncurrentVersion against zero SuccessorModTime

Add early return when obj.IsLatest or obj.SuccessorModTime.IsZero()
to prevent premature expiration of versions with uninitialized
successor timestamps (zero value would compute to epoch, always expired).

---------

Co-authored-by: Copilot <copilot@github.com>
2026-03-28 11:10:31 -07:00

496 lines
14 KiB
Go

package s3lifecycle
import (
"testing"
"time"
)
var now = time.Date(2026, 3, 27, 12, 0, 0, 0, time.UTC)
func TestEvaluate_ExpirationDays(t *testing.T) {
rules := []Rule{{
ID: "expire-30d", Status: "Enabled",
ExpirationDays: 30,
}}
t.Run("object_older_than_days_is_expired", func(t *testing.T) {
obj := ObjectInfo{
Key: "data/file.txt", IsLatest: true,
ModTime: now.Add(-31 * 24 * time.Hour),
}
result := Evaluate(rules, obj, now)
assertAction(t, ActionDeleteObject, result.Action)
assertEqual(t, "expire-30d", result.RuleID)
})
t.Run("object_younger_than_days_is_not_expired", func(t *testing.T) {
obj := ObjectInfo{
Key: "data/file.txt", IsLatest: true,
ModTime: now.Add(-10 * 24 * time.Hour),
}
result := Evaluate(rules, obj, now)
assertAction(t, ActionNone, result.Action)
})
t.Run("non_latest_version_not_affected_by_expiration_days", func(t *testing.T) {
obj := ObjectInfo{
Key: "data/file.txt", IsLatest: false,
ModTime: now.Add(-60 * 24 * time.Hour),
}
result := Evaluate(rules, obj, now)
assertAction(t, ActionNone, result.Action)
})
t.Run("delete_marker_not_affected_by_expiration_days", func(t *testing.T) {
obj := ObjectInfo{
Key: "data/file.txt", IsLatest: true, IsDeleteMarker: true,
ModTime: now.Add(-60 * 24 * time.Hour), NumVersions: 3,
}
result := Evaluate(rules, obj, now)
assertAction(t, ActionNone, result.Action)
})
}
func TestEvaluate_ExpirationDate(t *testing.T) {
expirationDate := time.Date(2026, 3, 15, 0, 0, 0, 0, time.UTC)
rules := []Rule{{
ID: "expire-date", Status: "Enabled",
ExpirationDate: expirationDate,
}}
t.Run("object_expired_after_date", func(t *testing.T) {
obj := ObjectInfo{
Key: "file.txt", IsLatest: true,
ModTime: now.Add(-60 * 24 * time.Hour),
}
result := Evaluate(rules, obj, now)
assertAction(t, ActionDeleteObject, result.Action)
})
t.Run("object_not_expired_before_date", func(t *testing.T) {
obj := ObjectInfo{
Key: "file.txt", IsLatest: true,
ModTime: now.Add(-1 * time.Hour),
}
beforeDate := time.Date(2026, 3, 10, 0, 0, 0, 0, time.UTC)
result := Evaluate(rules, obj, beforeDate)
assertAction(t, ActionNone, result.Action)
})
}
func TestEvaluate_ExpiredObjectDeleteMarker(t *testing.T) {
rules := []Rule{{
ID: "cleanup-markers", Status: "Enabled",
ExpiredObjectDeleteMarker: true,
}}
t.Run("sole_delete_marker_is_expired", func(t *testing.T) {
obj := ObjectInfo{
Key: "file.txt", IsLatest: true, IsDeleteMarker: true,
NumVersions: 1,
}
result := Evaluate(rules, obj, now)
assertAction(t, ActionExpireDeleteMarker, result.Action)
})
t.Run("delete_marker_with_other_versions_not_expired", func(t *testing.T) {
obj := ObjectInfo{
Key: "file.txt", IsLatest: true, IsDeleteMarker: true,
NumVersions: 3,
}
result := Evaluate(rules, obj, now)
assertAction(t, ActionNone, result.Action)
})
t.Run("non_latest_delete_marker_not_expired", func(t *testing.T) {
obj := ObjectInfo{
Key: "file.txt", IsLatest: false, IsDeleteMarker: true,
NumVersions: 1,
}
result := Evaluate(rules, obj, now)
assertAction(t, ActionNone, result.Action)
})
t.Run("non_delete_marker_not_affected", func(t *testing.T) {
obj := ObjectInfo{
Key: "file.txt", IsLatest: true, IsDeleteMarker: false,
NumVersions: 1,
}
result := Evaluate(rules, obj, now)
assertAction(t, ActionNone, result.Action)
})
}
func TestEvaluate_NoncurrentVersionExpiration(t *testing.T) {
rules := []Rule{{
ID: "expire-noncurrent", Status: "Enabled",
NoncurrentVersionExpirationDays: 30,
}}
t.Run("old_noncurrent_version_is_expired", func(t *testing.T) {
obj := ObjectInfo{
Key: "file.txt", IsLatest: false,
SuccessorModTime: now.Add(-45 * 24 * time.Hour),
}
result := Evaluate(rules, obj, now)
assertAction(t, ActionDeleteVersion, result.Action)
})
t.Run("recent_noncurrent_version_is_not_expired", func(t *testing.T) {
obj := ObjectInfo{
Key: "file.txt", IsLatest: false,
SuccessorModTime: now.Add(-10 * 24 * time.Hour),
}
result := Evaluate(rules, obj, now)
assertAction(t, ActionNone, result.Action)
})
t.Run("latest_version_not_affected", func(t *testing.T) {
obj := ObjectInfo{
Key: "file.txt", IsLatest: true,
ModTime: now.Add(-60 * 24 * time.Hour),
}
result := Evaluate(rules, obj, now)
assertAction(t, ActionNone, result.Action)
})
}
func TestShouldExpireNoncurrentVersion(t *testing.T) {
rule := Rule{
ID: "noncurrent-rule", Status: "Enabled",
NoncurrentVersionExpirationDays: 30,
NewerNoncurrentVersions: 2,
}
t.Run("old_version_beyond_count_is_expired", func(t *testing.T) {
obj := ObjectInfo{
Key: "file.txt", IsLatest: false,
SuccessorModTime: now.Add(-45 * 24 * time.Hour),
}
// noncurrentIndex=2 means this is the 3rd noncurrent version (0-indexed)
// With NewerNoncurrentVersions=2, indices 0 and 1 are kept.
if !ShouldExpireNoncurrentVersion(rule, obj, 2, now) {
t.Error("expected version at index 2 to be expired")
}
})
t.Run("old_version_within_count_is_kept", func(t *testing.T) {
obj := ObjectInfo{
Key: "file.txt", IsLatest: false,
SuccessorModTime: now.Add(-45 * 24 * time.Hour),
}
// noncurrentIndex=1 is within the keep threshold (NewerNoncurrentVersions=2).
if ShouldExpireNoncurrentVersion(rule, obj, 1, now) {
t.Error("expected version at index 1 to be kept")
}
})
t.Run("recent_version_beyond_count_is_kept", func(t *testing.T) {
obj := ObjectInfo{
Key: "file.txt", IsLatest: false,
SuccessorModTime: now.Add(-5 * 24 * time.Hour),
}
// Even at index 5 (beyond count), if too young, it's kept.
if ShouldExpireNoncurrentVersion(rule, obj, 5, now) {
t.Error("expected recent version to be kept regardless of index")
}
})
t.Run("disabled_rule_never_expires", func(t *testing.T) {
disabled := Rule{
ID: "disabled", Status: "Disabled",
NoncurrentVersionExpirationDays: 1,
}
obj := ObjectInfo{
Key: "file.txt", IsLatest: false,
SuccessorModTime: now.Add(-365 * 24 * time.Hour),
}
if ShouldExpireNoncurrentVersion(disabled, obj, 10, now) {
t.Error("disabled rule should never expire")
}
})
}
func TestEvaluate_PrefixFilter(t *testing.T) {
rules := []Rule{{
ID: "logs-only", Status: "Enabled",
Prefix: "logs/",
ExpirationDays: 7,
}}
t.Run("matching_prefix", func(t *testing.T) {
obj := ObjectInfo{
Key: "logs/app.log", IsLatest: true,
ModTime: now.Add(-10 * 24 * time.Hour),
}
result := Evaluate(rules, obj, now)
assertAction(t, ActionDeleteObject, result.Action)
})
t.Run("non_matching_prefix", func(t *testing.T) {
obj := ObjectInfo{
Key: "data/file.txt", IsLatest: true,
ModTime: now.Add(-10 * 24 * time.Hour),
}
result := Evaluate(rules, obj, now)
assertAction(t, ActionNone, result.Action)
})
}
func TestEvaluate_TagFilter(t *testing.T) {
rules := []Rule{{
ID: "temp-only", Status: "Enabled",
ExpirationDays: 1,
FilterTags: map[string]string{"env": "temp"},
}}
t.Run("matching_tags", func(t *testing.T) {
obj := ObjectInfo{
Key: "file.txt", IsLatest: true,
ModTime: now.Add(-5 * 24 * time.Hour),
Tags: map[string]string{"env": "temp", "project": "foo"},
}
result := Evaluate(rules, obj, now)
assertAction(t, ActionDeleteObject, result.Action)
})
t.Run("missing_tag", func(t *testing.T) {
obj := ObjectInfo{
Key: "file.txt", IsLatest: true,
ModTime: now.Add(-5 * 24 * time.Hour),
Tags: map[string]string{"project": "foo"},
}
result := Evaluate(rules, obj, now)
assertAction(t, ActionNone, result.Action)
})
t.Run("wrong_tag_value", func(t *testing.T) {
obj := ObjectInfo{
Key: "file.txt", IsLatest: true,
ModTime: now.Add(-5 * 24 * time.Hour),
Tags: map[string]string{"env": "prod"},
}
result := Evaluate(rules, obj, now)
assertAction(t, ActionNone, result.Action)
})
t.Run("nil_object_tags", func(t *testing.T) {
obj := ObjectInfo{
Key: "file.txt", IsLatest: true,
ModTime: now.Add(-5 * 24 * time.Hour),
}
result := Evaluate(rules, obj, now)
assertAction(t, ActionNone, result.Action)
})
}
func TestEvaluate_SizeFilter(t *testing.T) {
rules := []Rule{{
ID: "large-files", Status: "Enabled",
ExpirationDays: 7,
FilterSizeGreaterThan: 1024 * 1024, // > 1 MB
FilterSizeLessThan: 100 * 1024 * 1024, // < 100 MB
}}
t.Run("matching_size", func(t *testing.T) {
obj := ObjectInfo{
Key: "file.bin", IsLatest: true,
ModTime: now.Add(-10 * 24 * time.Hour),
Size: 10 * 1024 * 1024, // 10 MB
}
result := Evaluate(rules, obj, now)
assertAction(t, ActionDeleteObject, result.Action)
})
t.Run("too_small", func(t *testing.T) {
obj := ObjectInfo{
Key: "file.bin", IsLatest: true,
ModTime: now.Add(-10 * 24 * time.Hour),
Size: 512, // 512 bytes
}
result := Evaluate(rules, obj, now)
assertAction(t, ActionNone, result.Action)
})
t.Run("too_large", func(t *testing.T) {
obj := ObjectInfo{
Key: "file.bin", IsLatest: true,
ModTime: now.Add(-10 * 24 * time.Hour),
Size: 200 * 1024 * 1024, // 200 MB
}
result := Evaluate(rules, obj, now)
assertAction(t, ActionNone, result.Action)
})
}
func TestEvaluate_CombinedFilters(t *testing.T) {
rules := []Rule{{
ID: "combined", Status: "Enabled",
Prefix: "logs/",
ExpirationDays: 7,
FilterTags: map[string]string{"env": "dev"},
FilterSizeGreaterThan: 100,
}}
t.Run("all_filters_match", func(t *testing.T) {
obj := ObjectInfo{
Key: "logs/app.log", IsLatest: true,
ModTime: now.Add(-10 * 24 * time.Hour),
Size: 1024,
Tags: map[string]string{"env": "dev"},
}
result := Evaluate(rules, obj, now)
assertAction(t, ActionDeleteObject, result.Action)
})
t.Run("prefix_doesnt_match", func(t *testing.T) {
obj := ObjectInfo{
Key: "data/app.log", IsLatest: true,
ModTime: now.Add(-10 * 24 * time.Hour),
Size: 1024,
Tags: map[string]string{"env": "dev"},
}
result := Evaluate(rules, obj, now)
assertAction(t, ActionNone, result.Action)
})
t.Run("tag_doesnt_match", func(t *testing.T) {
obj := ObjectInfo{
Key: "logs/app.log", IsLatest: true,
ModTime: now.Add(-10 * 24 * time.Hour),
Size: 1024,
Tags: map[string]string{"env": "prod"},
}
result := Evaluate(rules, obj, now)
assertAction(t, ActionNone, result.Action)
})
t.Run("size_doesnt_match", func(t *testing.T) {
obj := ObjectInfo{
Key: "logs/app.log", IsLatest: true,
ModTime: now.Add(-10 * 24 * time.Hour),
Size: 50, // too small
Tags: map[string]string{"env": "dev"},
}
result := Evaluate(rules, obj, now)
assertAction(t, ActionNone, result.Action)
})
}
func TestEvaluate_DisabledRule(t *testing.T) {
rules := []Rule{{
ID: "disabled", Status: "Disabled",
ExpirationDays: 1,
}}
obj := ObjectInfo{
Key: "file.txt", IsLatest: true,
ModTime: now.Add(-365 * 24 * time.Hour),
}
result := Evaluate(rules, obj, now)
assertAction(t, ActionNone, result.Action)
}
func TestEvaluate_MultipleRules_Priority(t *testing.T) {
t.Run("delete_marker_takes_priority_over_expiration", func(t *testing.T) {
rules := []Rule{
{ID: "expire", Status: "Enabled", ExpirationDays: 1},
{ID: "marker", Status: "Enabled", ExpiredObjectDeleteMarker: true},
}
obj := ObjectInfo{
Key: "file.txt", IsLatest: true, IsDeleteMarker: true,
NumVersions: 1, ModTime: now.Add(-10 * 24 * time.Hour),
}
result := Evaluate(rules, obj, now)
assertAction(t, ActionExpireDeleteMarker, result.Action)
assertEqual(t, "marker", result.RuleID)
})
t.Run("first_matching_expiration_rule_wins", func(t *testing.T) {
rules := []Rule{
{ID: "rule1", Status: "Enabled", ExpirationDays: 30, Prefix: "logs/"},
{ID: "rule2", Status: "Enabled", ExpirationDays: 7},
}
obj := ObjectInfo{
Key: "logs/app.log", IsLatest: true,
ModTime: now.Add(-31 * 24 * time.Hour),
}
result := Evaluate(rules, obj, now)
assertAction(t, ActionDeleteObject, result.Action)
assertEqual(t, "rule1", result.RuleID)
})
}
func TestEvaluate_EmptyPrefix(t *testing.T) {
rules := []Rule{{
ID: "all", Status: "Enabled",
ExpirationDays: 30,
}}
obj := ObjectInfo{
Key: "any/path/file.txt", IsLatest: true,
ModTime: now.Add(-31 * 24 * time.Hour),
}
result := Evaluate(rules, obj, now)
assertAction(t, ActionDeleteObject, result.Action)
}
func TestEvaluateMPUAbort(t *testing.T) {
rules := []Rule{{
ID: "abort-mpu", Status: "Enabled",
AbortMPUDaysAfterInitiation: 7,
}}
t.Run("old_upload_is_aborted", func(t *testing.T) {
result := EvaluateMPUAbort(rules, "uploads/file.bin", now.Add(-10*24*time.Hour), now)
assertAction(t, ActionAbortMultipartUpload, result.Action)
})
t.Run("recent_upload_is_not_aborted", func(t *testing.T) {
result := EvaluateMPUAbort(rules, "uploads/file.bin", now.Add(-3*24*time.Hour), now)
assertAction(t, ActionNone, result.Action)
})
t.Run("prefix_scoped_abort", func(t *testing.T) {
prefixRules := []Rule{{
ID: "abort-logs", Status: "Enabled",
Prefix: "logs/",
AbortMPUDaysAfterInitiation: 1,
}}
result := EvaluateMPUAbort(prefixRules, "data/file.bin", now.Add(-5*24*time.Hour), now)
assertAction(t, ActionNone, result.Action)
})
}
func TestExpectedExpiryTime(t *testing.T) {
ref := time.Date(2026, 3, 1, 15, 30, 0, 0, time.UTC)
t.Run("30_days", func(t *testing.T) {
// S3 spec: expires at midnight UTC of day 32 (ref + 31 days, truncated).
expiry := expectedExpiryTime(ref, 30)
expected := time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC)
if !expiry.Equal(expected) {
t.Errorf("expected %v, got %v", expected, expiry)
}
})
t.Run("zero_days_returns_ref", func(t *testing.T) {
expiry := expectedExpiryTime(ref, 0)
if !expiry.Equal(ref) {
t.Errorf("expected %v, got %v", ref, expiry)
}
})
}
func assertAction(t *testing.T, expected, actual Action) {
t.Helper()
if expected != actual {
t.Errorf("expected action %d, got %d", expected, actual)
}
}
func assertEqual(t *testing.T, expected, actual string) {
t.Helper()
if expected != actual {
t.Errorf("expected %q, got %q", expected, actual)
}
}