Files
seaweedfs/weed/filer/filer_conf_test.go
T
Chris Lu 3514925581 filer: let a nested path rule turn worm off (#10503)
* filer: let a nested path rule turn worm off

mergePathConf ORs the booleans, so worm set on a bucket could never be
lifted on a directory under it, while every string field is overridden by
the more specific rule. Make worm tri-state instead: unset inherits, set
wins. readOnly, fsync and disableChunkDeletion keep the OR, so a nested
rule still cannot escape a lock the bucket set.

Configurations written before this carry an explicit "worm": false on
every rule, because they are marshalled with EmitUnpopulated. Reading
those back as an override would quietly drop worm from nested paths, so
filer.conf is now stamped with a version and the flag is dropped to unset
when the version predates it.

* filer: copy the worm value out of the matched rule

mergePathConf aliased the pointer into the merged result, so a caller that
wrote through it would reach into the stored rule.
2026-07-31 00:34:22 -07:00

251 lines
8.3 KiB
Go

package filer
import (
"bytes"
"reflect"
"testing"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/stretchr/testify/assert"
"google.golang.org/protobuf/proto"
)
func TestFilerConf(t *testing.T) {
fc := NewFilerConf()
conf := &filer_pb.FilerConf{Locations: []*filer_pb.FilerConf_PathConf{
{
LocationPrefix: "/buckets/abc",
Collection: "abc",
},
{
LocationPrefix: "/buckets/abcd",
Collection: "abcd",
},
{
LocationPrefix: "/buckets/",
Replication: "001",
},
{
LocationPrefix: "/buckets",
ReadOnly: false,
},
{
LocationPrefix: "/buckets/xxx",
ReadOnly: true,
},
{
LocationPrefix: "/buckets/xxx/yyy",
ReadOnly: false,
},
}}
fc.doLoadConf(conf)
assert.Equal(t, "abc", fc.MatchStorageRule("/buckets/abc/jasdf").Collection)
assert.Equal(t, "abcd", fc.MatchStorageRule("/buckets/abcd/jasdf").Collection)
assert.Equal(t, "001", fc.MatchStorageRule("/buckets/abc/jasdf").Replication)
assert.Equal(t, true, fc.MatchStorageRule("/buckets/xxx/yyy/zzz").ReadOnly)
assert.Equal(t, false, fc.MatchStorageRule("/buckets/other").ReadOnly)
}
func TestWormInheritance(t *testing.T) {
fc := NewFilerConf()
fc.doLoadConf(&filer_pb.FilerConf{
Version: FilerConfVersion,
Locations: []*filer_pb.FilerConf_PathConf{
{LocationPrefix: "/buckets/b/", Worm: proto.Bool(true), Ttl: "7d"},
{LocationPrefix: "/buckets/b/quiet/", Collection: "quiet"},
{LocationPrefix: "/buckets/b/scratch/", Worm: proto.Bool(false)},
{LocationPrefix: "/buckets/b/scratch/keep/", Worm: proto.Bool(true)},
},
})
// a rule that says nothing about worm keeps inheriting it, along with the ttl
rule := fc.MatchStorageRule("/buckets/b/quiet/x")
assert.True(t, rule.GetWorm())
assert.Equal(t, "7d", rule.Ttl)
// an explicit false turns it off, and a deeper rule turns it back on
assert.False(t, fc.MatchStorageRule("/buckets/b/scratch/x").GetWorm())
assert.True(t, fc.MatchStorageRule("/buckets/b/scratch/keep/x").GetWorm())
// paths with no rule at all are unaffected
assert.False(t, fc.MatchStorageRule("/buckets/other/x").GetWorm())
}
// TestWormLegacyFalseIsNotAnOverride pins that the explicit "worm": false every
// version 0 configuration carries does not read as a per-path opt-out.
func TestWormLegacyFalseIsNotAnOverride(t *testing.T) {
const conf = `{
"locations": [
{"locationPrefix": "/buckets/b/", "worm": true},
{"locationPrefix": "/buckets/b/sub/", "collection": "sub", "worm": false}
]
}`
fc := NewFilerConf()
assert.NoError(t, fc.LoadFromBytes([]byte(conf)))
assert.True(t, fc.MatchStorageRule("/buckets/b/sub/x").GetWorm())
// the same file at the current version means what it says
fc = NewFilerConf()
assert.NoError(t, fc.LoadFromBytes([]byte(`{"version": 1,`+conf[1:])))
assert.False(t, fc.MatchStorageRule("/buckets/b/sub/x").GetWorm())
}
// TestWormSurvivesRoundTrip guards the write side: an explicit false has to be
// stamped along with a version that says to honor it.
func TestWormSurvivesRoundTrip(t *testing.T) {
fc := NewFilerConf()
fc.SetLocationConf(&filer_pb.FilerConf_PathConf{LocationPrefix: "/buckets/b/", Worm: proto.Bool(true)})
fc.SetLocationConf(&filer_pb.FilerConf_PathConf{LocationPrefix: "/buckets/b/sub/", Worm: proto.Bool(false)})
fc.SetLocationConf(&filer_pb.FilerConf_PathConf{LocationPrefix: "/buckets/b/other/", Ttl: "7d"})
var buf bytes.Buffer
assert.NoError(t, fc.ToText(&buf))
reloaded := NewFilerConf()
assert.NoError(t, reloaded.LoadFromBytes(buf.Bytes()))
assert.False(t, reloaded.MatchStorageRule("/buckets/b/sub/x").GetWorm())
assert.True(t, reloaded.MatchStorageRule("/buckets/b/other/x").GetWorm())
}
// TestClonePathConf verifies that ClonePathConf copies all exported fields.
// Uses reflection to automatically detect new fields added to the protobuf,
// ensuring the test fails if ClonePathConf is not updated for new fields.
func TestClonePathConf(t *testing.T) {
// Create a fully-populated PathConf with non-zero values for all fields
src := &filer_pb.FilerConf_PathConf{
LocationPrefix: "/test/path",
Collection: "test_collection",
Replication: "001",
Ttl: "7d",
DiskType: "ssd",
Fsync: true,
VolumeGrowthCount: 5,
ReadOnly: true,
MaxFileNameLength: 255,
DataCenter: "dc1",
Rack: "rack1",
DataNode: "node1",
DisableChunkDeletion: true,
Worm: proto.Bool(true),
WormGracePeriodSeconds: 3600,
WormRetentionTimeSeconds: 86400,
}
clone := ClonePathConf(src)
// Verify it's a different object
assert.NotSame(t, src, clone, "ClonePathConf should return a new object, not the same pointer")
// Use reflection to compare all exported fields
// This will automatically catch any new fields added to the protobuf
srcVal := reflect.ValueOf(src).Elem()
cloneVal := reflect.ValueOf(clone).Elem()
srcType := srcVal.Type()
for i := 0; i < srcType.NumField(); i++ {
field := srcType.Field(i)
// Skip unexported fields (protobuf internal fields like sizeCache, unknownFields)
if !field.IsExported() {
continue
}
srcField := srcVal.Field(i)
cloneField := cloneVal.Field(i)
// Compare field values
if !reflect.DeepEqual(srcField.Interface(), cloneField.Interface()) {
t.Errorf("Field %s not copied correctly: src=%v, clone=%v",
field.Name, srcField.Interface(), cloneField.Interface())
}
}
// Additionally verify that all exported fields in src are non-zero
// This ensures we're testing with fully populated data
for i := 0; i < srcType.NumField(); i++ {
field := srcType.Field(i)
if !field.IsExported() {
continue
}
srcField := srcVal.Field(i)
if srcField.IsZero() {
t.Errorf("Test setup error: field %s has zero value, update test to set a non-zero value", field.Name)
}
}
// Verify mutation of clone doesn't affect source
clone.Collection = "modified"
clone.ReadOnly = false
*clone.Worm = false
assert.Equal(t, "test_collection", src.Collection, "Modifying clone should not affect source Collection")
assert.Equal(t, true, src.ReadOnly, "Modifying clone should not affect source ReadOnly")
assert.Equal(t, true, src.GetWorm(), "Modifying clone should not affect source Worm")
}
func TestClonePathConfNil(t *testing.T) {
clone := ClonePathConf(nil)
assert.NotNil(t, clone, "ClonePathConf(nil) should return a non-nil empty PathConf")
assert.Equal(t, "", clone.LocationPrefix, "ClonePathConf(nil) should return empty PathConf")
}
func TestApplyBucketQuotaReadOnly(t *testing.T) {
const prefix = "/buckets/b/"
// over quota: flips to read-only
fc := NewFilerConf()
readOnly, changed := fc.ApplyBucketQuotaReadOnly(prefix, 150, 100)
assert.True(t, changed)
assert.True(t, readOnly)
assert.True(t, fc.MatchStorageRule(prefix).ReadOnly)
// still over quota: no change
_, changed = fc.ApplyBucketQuotaReadOnly(prefix, 150, 100)
assert.False(t, changed)
// back under quota: flips to writable
readOnly, changed = fc.ApplyBucketQuotaReadOnly(prefix, 50, 100)
assert.True(t, changed)
assert.False(t, readOnly)
assert.False(t, fc.MatchStorageRule(prefix).ReadOnly)
// quota disabled leaves the flag untouched, so manual locks survive
fc = NewFilerConf()
fc.ApplyBucketQuotaReadOnly(prefix, 150, 100)
readOnly, changed = fc.ApplyBucketQuotaReadOnly(prefix, 150, -1)
assert.False(t, changed)
assert.True(t, readOnly)
// under quota and not read-only: no rule churn
fc = NewFilerConf()
_, changed = fc.ApplyBucketQuotaReadOnly(prefix, 50, 100)
assert.False(t, changed)
}
func TestClearReadOnly(t *testing.T) {
const prefix = "/buckets/b/"
fc := NewFilerConf()
assert.False(t, fc.ClearReadOnly(prefix), "no rule to clear")
// locked by quota enforcement, then quota removed: still clearable
fc.ApplyBucketQuotaReadOnly(prefix, 150, 100)
assert.True(t, fc.ClearReadOnly(prefix))
assert.False(t, fc.MatchStorageRule(prefix).ReadOnly)
assert.False(t, fc.ClearReadOnly(prefix), "already writable")
// clearing the flag keeps the rule's other settings
fc = NewFilerConf()
fc.SetLocationConf(&filer_pb.FilerConf_PathConf{LocationPrefix: prefix, Ttl: "7d", ReadOnly: true})
assert.True(t, fc.ClearReadOnly(prefix))
rule := fc.MatchStorageRule(prefix)
assert.False(t, rule.ReadOnly)
assert.Equal(t, "7d", rule.Ttl)
}