From 551e700e64eb0d5e9a0870c1e12b0734eefa4656 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Sat, 9 May 2026 19:46:40 -0700 Subject: [PATCH] test(s3/lifecycle): cover scheduler configload surface (#9395) * test(s3/lifecycle): cover scheduler configload surface LoadCompileInputs is the bridge between the filer's bucket directory and the engine snapshot the scheduler compiles every refresh; a missed or misclassified bucket silently disables lifecycle for that prefix until the next refresh. Tests pin: empty bucket dir, files at the bucket level skipped, buckets without the lifecycle XML extended key skipped, empty-bytes XML skipped, valid XML becomes a CompileInput, versioning attr propagates to CompileInput.Versioned, malformed XML surfaces as a ParseError without aborting the walk, and pagination across the 1024 page boundary preserves bucket order. Also covers the IsBucketVersioned (case + whitespace tolerance, rejection of garbage values) and AllActivePriorStates (one entry per (bucket, ruleHash, actionKind), bucket-keyed isolation) helpers. * test(s3/lifecycle): tighten configload pagination boundary check Switch the bucket-count check to require.Len so a regression that returns the wrong number of buckets fails fast before the boundary asserts panic on out-of-range index. Add explicit assertions on the last entry of page 1 (b01023) and the first entry of page 2 (b01024) so a pagination-loop bug that drops or duplicates the seam is caught directly rather than only via the count check. --- .../s3lifecycle/scheduler/configload_test.go | 236 ++++++++++++++++++ 1 file changed, 236 insertions(+) create mode 100644 weed/s3api/s3lifecycle/scheduler/configload_test.go diff --git a/weed/s3api/s3lifecycle/scheduler/configload_test.go b/weed/s3api/s3lifecycle/scheduler/configload_test.go new file mode 100644 index 000000000..5929ea2ba --- /dev/null +++ b/weed/s3api/s3lifecycle/scheduler/configload_test.go @@ -0,0 +1,236 @@ +package scheduler + +import ( + "context" + "fmt" + "testing" + + "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" + "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants" + "github.com/seaweedfs/seaweedfs/weed/s3api/s3lifecycle" + "github.com/seaweedfs/seaweedfs/weed/s3api/s3lifecycle/engine" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Tests for LoadCompileInputs / IsBucketVersioned / AllActivePriorStates. +// LoadCompileInputs is the bridge between the filer's bucket directory +// and the engine snapshot the scheduler compiles every refresh — +// pagination, parse-error surfacing, and the empty/missing-XML skip +// semantics matter because a missed bucket silently disables lifecycle +// for that prefix until the next refresh. + +const testBucketsRoot = "/buckets" + +// minimalLifecycleXML is the smallest XML that ParseCanonical accepts as +// a non-empty rule set; the actual rule details are exercised by the +// lifecycle_xml package's own tests. +const minimalLifecycleXML = ` + + r1 + Enabled + logs/ + 30 + +` + +func bucketEntry(name string, extended map[string][]byte) *filer_pb.Entry { + return dirEntry(name, extended) +} + +func TestIsBucketVersioned_MissingExtendedReturnsFalse(t *testing.T) { + assert.False(t, IsBucketVersioned(&filer_pb.Entry{})) + assert.False(t, IsBucketVersioned(&filer_pb.Entry{Extended: map[string][]byte{}})) +} + +func TestIsBucketVersioned_AcceptsEnabledAndSuspended(t *testing.T) { + // Casing and surrounding whitespace are normalized so the worker + // tolerates whatever casing the writer chose. + for _, raw := range []string{"Enabled", "ENABLED", "enabled", " enabled ", "Suspended", "suspended"} { + t.Run(raw, func(t *testing.T) { + e := &filer_pb.Entry{Extended: map[string][]byte{s3_constants.ExtVersioningKey: []byte(raw)}} + assert.True(t, IsBucketVersioned(e), "value %q must classify as versioned", raw) + }) + } +} + +func TestIsBucketVersioned_RejectsOtherValues(t *testing.T) { + for _, raw := range []string{"", "Disabled", "Off", "true", "1", "garbage"} { + t.Run(raw, func(t *testing.T) { + e := &filer_pb.Entry{Extended: map[string][]byte{s3_constants.ExtVersioningKey: []byte(raw)}} + assert.False(t, IsBucketVersioned(e), "value %q must NOT classify as versioned", raw) + }) + } +} + +func TestAllActivePriorStates_EmptyInputs(t *testing.T) { + assert.Empty(t, AllActivePriorStates(nil)) + assert.Empty(t, AllActivePriorStates([]engine.CompileInput{})) +} + +func TestAllActivePriorStates_SeedsAllActionsAsActive(t *testing.T) { + // Every (bucket, ruleHash, actionKind) tuple from the inputs must + // receive a PriorState with BootstrapComplete=true and + // Mode=ModeEventDriven so the scheduler dispatches matched events + // immediately rather than waiting for a per-action bootstrap walk. + rule := &s3lifecycle.Rule{ + ID: "r1", + Status: s3lifecycle.StatusEnabled, + ExpirationDays: 30, + } + inputs := []engine.CompileInput{{Bucket: "b1", Rules: []*s3lifecycle.Rule{rule}}} + prior := AllActivePriorStates(inputs) + + hash := s3lifecycle.RuleHash(rule) + kinds := s3lifecycle.RuleActionKinds(rule) + require.NotEmpty(t, kinds, "expiration-days rule must yield at least one action kind") + for _, kind := range kinds { + key := s3lifecycle.ActionKey{Bucket: "b1", RuleHash: hash, ActionKind: kind} + state, ok := prior[key] + require.True(t, ok, "prior state missing for action kind %v", kind) + assert.True(t, state.BootstrapComplete, "bootstrap complete for kind %v", kind) + assert.Equal(t, engine.ModeEventDriven, state.Mode, "mode for kind %v", kind) + } + assert.Len(t, prior, len(kinds), "exactly one prior-state entry per action kind") +} + +func TestAllActivePriorStates_KeysSeparatedByBucket(t *testing.T) { + // Two buckets sharing identical rules must still get distinct + // PriorState entries, otherwise refresh would conflate their state. + rule := &s3lifecycle.Rule{ID: "r1", Status: s3lifecycle.StatusEnabled, ExpirationDays: 30} + inputs := []engine.CompileInput{ + {Bucket: "alpha", Rules: []*s3lifecycle.Rule{rule}}, + {Bucket: "beta", Rules: []*s3lifecycle.Rule{rule}}, + } + prior := AllActivePriorStates(inputs) + hash := s3lifecycle.RuleHash(rule) + for _, kind := range s3lifecycle.RuleActionKinds(rule) { + assert.Contains(t, prior, s3lifecycle.ActionKey{Bucket: "alpha", RuleHash: hash, ActionKind: kind}) + assert.Contains(t, prior, s3lifecycle.ActionKey{Bucket: "beta", RuleHash: hash, ActionKind: kind}) + } +} + +func TestLoadCompileInputs_EmptyBucketDir(t *testing.T) { + // No buckets at all -> no inputs, no parse errors, no transport error. + client := &fakeFilerClient{tree: map[string][]*filer_pb.Entry{testBucketsRoot: {}}} + inputs, perr, err := LoadCompileInputs(context.Background(), client, testBucketsRoot) + require.NoError(t, err) + assert.Empty(t, inputs) + assert.Empty(t, perr) +} + +func TestLoadCompileInputs_FilesAtBucketLevelSkipped(t *testing.T) { + // A bare file at the buckets root isn't a bucket; the loader must + // skip it without touching the parser. + client := &fakeFilerClient{tree: map[string][]*filer_pb.Entry{ + testBucketsRoot: {fileEntry("stray.txt")}, + }} + inputs, perr, err := LoadCompileInputs(context.Background(), client, testBucketsRoot) + require.NoError(t, err) + assert.Empty(t, inputs) + assert.Empty(t, perr) +} + +func TestLoadCompileInputs_BucketWithoutLifecycleConfigSkipped(t *testing.T) { + // Buckets that don't carry the lifecycle XML extended attribute are + // silently skipped — operators may have many buckets without rules. + client := &fakeFilerClient{tree: map[string][]*filer_pb.Entry{ + testBucketsRoot: {bucketEntry("b1", nil)}, + }} + inputs, perr, err := LoadCompileInputs(context.Background(), client, testBucketsRoot) + require.NoError(t, err) + assert.Empty(t, inputs) + assert.Empty(t, perr) +} + +func TestLoadCompileInputs_BucketWithEmptyLifecycleXMLSkipped(t *testing.T) { + // Empty bytes under the lifecycle key shouldn't reach the parser. + client := &fakeFilerClient{tree: map[string][]*filer_pb.Entry{ + testBucketsRoot: {bucketEntry("b1", map[string][]byte{ + BucketLifecycleConfigurationXMLKey: nil, + })}, + }} + inputs, perr, err := LoadCompileInputs(context.Background(), client, testBucketsRoot) + require.NoError(t, err) + assert.Empty(t, inputs) + assert.Empty(t, perr) +} + +func TestLoadCompileInputs_ValidConfigBecomesInput(t *testing.T) { + client := &fakeFilerClient{tree: map[string][]*filer_pb.Entry{ + testBucketsRoot: {bucketEntry("b1", map[string][]byte{ + BucketLifecycleConfigurationXMLKey: []byte(minimalLifecycleXML), + })}, + }} + inputs, perr, err := LoadCompileInputs(context.Background(), client, testBucketsRoot) + require.NoError(t, err) + assert.Empty(t, perr) + require.Len(t, inputs, 1) + assert.Equal(t, "b1", inputs[0].Bucket) + assert.False(t, inputs[0].Versioned, "no versioning attr -> not versioned") + assert.NotEmpty(t, inputs[0].Rules) +} + +func TestLoadCompileInputs_VersioningAttrPropagates(t *testing.T) { + client := &fakeFilerClient{tree: map[string][]*filer_pb.Entry{ + testBucketsRoot: {bucketEntry("b1", map[string][]byte{ + BucketLifecycleConfigurationXMLKey: []byte(minimalLifecycleXML), + s3_constants.ExtVersioningKey: []byte("Enabled"), + })}, + }} + inputs, perr, err := LoadCompileInputs(context.Background(), client, testBucketsRoot) + require.NoError(t, err) + assert.Empty(t, perr) + require.Len(t, inputs, 1) + assert.True(t, inputs[0].Versioned) +} + +func TestLoadCompileInputs_MalformedXMLBecomesParseError(t *testing.T) { + // Malformed XML must not abort the whole walk — surfacing it as a + // ParseError lets operators see the bucket-level failure while + // other buckets keep loading. + client := &fakeFilerClient{tree: map[string][]*filer_pb.Entry{ + testBucketsRoot: { + bucketEntry("bad", map[string][]byte{ + BucketLifecycleConfigurationXMLKey: []byte("