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)")
+ }
+ })
+}