diff --git a/weed/plugin/worker/lifecycle/detection.go b/weed/plugin/worker/lifecycle/detection.go index e88e680ca..d8267b2f0 100644 --- a/weed/plugin/worker/lifecycle/detection.go +++ b/weed/plugin/worker/lifecycle/detection.go @@ -10,11 +10,15 @@ import ( "github.com/seaweedfs/seaweedfs/weed/glog" "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" "github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb" + "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants" "github.com/seaweedfs/seaweedfs/weed/util/wildcard" ) +const lifecycleXMLKey = "s3-bucket-lifecycle-configuration-xml" + // detectBucketsWithLifecycleRules scans all S3 buckets to find those -// with lifecycle (TTL) rules configured in filer.conf. +// with lifecycle rules, either TTL entries in filer.conf or lifecycle +// XML stored in bucket metadata. func (h *Handler) detectBucketsWithLifecycleRules( ctx context.Context, filerClient filer_pb.SeaweedFilerClient, @@ -53,25 +57,38 @@ func (h *Handler) detectBucketsWithLifecycleRules( continue } - // Derive the collection name for this bucket. + // Check for lifecycle rules from two sources: + // 1. filer.conf TTLs (legacy Expiration.Days fast path) + // 2. Stored lifecycle XML in bucket metadata (full rule support) collection := bucketName ttls := fc.GetCollectionTtls(collection) - if len(ttls) == 0 { + + hasLifecycleXML := entry.Extended != nil && len(entry.Extended[lifecycleXMLKey]) > 0 + versioningStatus := "" + if entry.Extended != nil { + versioningStatus = string(entry.Extended[s3_constants.ExtVersioningKey]) + } + + ruleCount := int64(len(ttls)) + if !hasLifecycleXML && ruleCount == 0 { continue } - glog.V(2).Infof("s3_lifecycle: bucket %s has %d lifecycle rule(s)", bucketName, len(ttls)) + glog.V(2).Infof("s3_lifecycle: bucket %s has %d TTL rule(s), lifecycle_xml=%v, versioning=%s", + bucketName, ruleCount, hasLifecycleXML, versioningStatus) proposal := &plugin_pb.JobProposal{ ProposalId: fmt.Sprintf("s3_lifecycle:%s", bucketName), JobType: jobType, - Summary: fmt.Sprintf("Lifecycle management for bucket %s (%d rules)", bucketName, len(ttls)), + Summary: fmt.Sprintf("Lifecycle management for bucket %s", bucketName), DedupeKey: fmt.Sprintf("s3_lifecycle:%s", bucketName), Parameters: map[string]*plugin_pb.ConfigValue{ - "bucket": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: bucketName}}, - "buckets_path": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: bucketsPath}}, - "collection": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: collection}}, - "rule_count": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(len(ttls))}}, + "bucket": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: bucketName}}, + "buckets_path": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: bucketsPath}}, + "collection": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: collection}}, + "rule_count": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: ruleCount}}, + "has_lifecycle_xml": {Kind: &plugin_pb.ConfigValue_BoolValue{BoolValue: hasLifecycleXML}}, + "versioning_status": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: versioningStatus}}, }, Labels: map[string]string{ "bucket": bucketName, diff --git a/weed/plugin/worker/lifecycle/detection_test.go b/weed/plugin/worker/lifecycle/detection_test.go new file mode 100644 index 000000000..d9ff86688 --- /dev/null +++ b/weed/plugin/worker/lifecycle/detection_test.go @@ -0,0 +1,132 @@ +package lifecycle + +import ( + "testing" + + "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" + "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants" +) + +func TestBucketHasLifecycleXML(t *testing.T) { + tests := []struct { + name string + extended map[string][]byte + want bool + }{ + { + name: "has_lifecycle_xml", + extended: map[string][]byte{lifecycleXMLKey: []byte("")}, + want: true, + }, + { + name: "empty_lifecycle_xml", + extended: map[string][]byte{lifecycleXMLKey: {}}, + want: false, + }, + { + name: "no_lifecycle_xml", + extended: map[string][]byte{"other-key": []byte("value")}, + want: false, + }, + { + name: "nil_extended", + extended: nil, + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.extended != nil && len(tt.extended[lifecycleXMLKey]) > 0 + if got != tt.want { + t.Errorf("hasLifecycleXML = %v, want %v", got, tt.want) + } + }) + } +} + +func TestBucketVersioningStatus(t *testing.T) { + tests := []struct { + name string + extended map[string][]byte + want string + }{ + { + name: "versioning_enabled", + extended: map[string][]byte{ + s3_constants.ExtVersioningKey: []byte("Enabled"), + }, + want: "Enabled", + }, + { + name: "versioning_suspended", + extended: map[string][]byte{ + s3_constants.ExtVersioningKey: []byte("Suspended"), + }, + want: "Suspended", + }, + { + name: "no_versioning", + extended: map[string][]byte{}, + want: "", + }, + { + name: "nil_extended", + extended: nil, + want: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var got string + if tt.extended != nil { + got = string(tt.extended[s3_constants.ExtVersioningKey]) + } + if got != tt.want { + t.Errorf("versioningStatus = %q, want %q", got, tt.want) + } + }) + } +} + +func TestDetectionProposalParameters(t *testing.T) { + // Verify that bucket entries with lifecycle XML or TTL rules produce + // proposals with the expected parameters. + t.Run("bucket_with_lifecycle_xml_and_versioning", func(t *testing.T) { + entry := &filer_pb.Entry{ + Name: "my-bucket", + IsDirectory: true, + Extended: map[string][]byte{ + lifecycleXMLKey: []byte(`Enabled`), + s3_constants.ExtVersioningKey: []byte("Enabled"), + }, + } + + hasXML := entry.Extended != nil && len(entry.Extended[lifecycleXMLKey]) > 0 + versioning := "" + if entry.Extended != nil { + versioning = string(entry.Extended[s3_constants.ExtVersioningKey]) + } + + if !hasXML { + t.Error("expected hasLifecycleXML=true") + } + if versioning != "Enabled" { + t.Errorf("expected versioning=Enabled, got %q", versioning) + } + }) + + t.Run("bucket_without_lifecycle_or_ttl_is_skipped", func(t *testing.T) { + entry := &filer_pb.Entry{ + Name: "empty-bucket", + IsDirectory: true, + Extended: map[string][]byte{}, + } + + hasXML := entry.Extended != nil && len(entry.Extended[lifecycleXMLKey]) > 0 + ttlCount := 0 // simulated: no TTL rules in filer.conf + + if hasXML || ttlCount > 0 { + t.Error("expected bucket to be skipped (no lifecycle XML, no TTLs)") + } + }) +}