From e7be6bb2f8e79243c2428e295f51ff3c8ccde1e7 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Fri, 10 Jul 2026 22:09:39 -0700 Subject: [PATCH] filer: allow clearing a bucket read-only flag stuck after quota removal (#10310) * filer: allow clearing a bucket read-only flag stuck after quota removal Once s3.bucket.quota.enforce marks a bucket read-only, removing or disabling the quota orphans the flag: enforcement skips buckets with a non-positive quota, and fs.configure merges booleans with OR so -readOnly=false could never turn it off. The only way out was deleting the whole path rule. - s3.bucket.quota -op=remove/disable and the admin server quota update now lift the read-only flag on the bucket's path rule - fs.configure now honors explicitly passed false boolean flags (-readOnly, -fsync, -worm) instead of OR-merging them away * filer: ClearBucketReadOnly reports unchanged when the save fails --- weed/admin/dash/bucket_management.go | 9 ++++++ weed/filer/filer_conf.go | 43 +++++++++++++++++++++++++++ weed/filer/filer_conf_test.go | 21 +++++++++++++ weed/shell/command_fs_configure.go | 18 +++++++++++ weed/shell/command_s3_bucket_quota.go | 19 ++++++++++++ 5 files changed, 110 insertions(+) diff --git a/weed/admin/dash/bucket_management.go b/weed/admin/dash/bucket_management.go index 6749eb389..10e70e9dc 100644 --- a/weed/admin/dash/bucket_management.go +++ b/weed/admin/dash/bucket_management.go @@ -9,6 +9,7 @@ import ( "time" "github.com/gorilla/mux" + "github.com/seaweedfs/seaweedfs/weed/filer" "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" "github.com/seaweedfs/seaweedfs/weed/s3api" "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants" @@ -397,6 +398,14 @@ func (s *AdminServer) SetBucketQuota(bucketName string, quotaBytes int64, quotaE return fmt.Errorf("failed to update bucket quota: %w", err) } + if quota <= 0 { + // with no active quota, quota enforcement can no longer clear a + // read-only flag it turned on, so lift it here + if _, err := filer.ClearBucketReadOnly(context.Background(), client, "/buckets", bucketName); err != nil { + return fmt.Errorf("failed to clear bucket read-only flag: %w", err) + } + } + return nil }) } diff --git a/weed/filer/filer_conf.go b/weed/filer/filer_conf.go index f80c63f63..cf16b01b0 100644 --- a/weed/filer/filer_conf.go +++ b/weed/filer/filer_conf.go @@ -262,6 +262,49 @@ func (fc *FilerConf) ApplyBucketQuotaReadOnly(locationPrefix string, usedSize, q return locConf.ReadOnly, true } +// ClearReadOnly clears the read-only flag on the rule at exactly locationPrefix, +// reporting whether the flag was set. This is the explicit unlock for a flag that +// ApplyBucketQuotaReadOnly can no longer clear once the quota is gone. +func (fc *FilerConf) ClearReadOnly(locationPrefix string) (changed bool) { + locConf, found := fc.GetLocationConf(locationPrefix) + if !found || !locConf.ReadOnly { + return false + } + locConf.ReadOnly = false + fc.SetLocationConf(locConf) + return true +} + +// ClearBucketReadOnly lifts the read-only flag that quota enforcement may have +// left on the bucket's path rule, saving the updated configuration back to the +// filer. It reports whether anything was cleared. +func ClearBucketReadOnly(ctx context.Context, client filer_pb.SeaweedFilerClient, bucketsPath, bucketName string) (changed bool, err error) { + data, err := ReadInsideFiler(ctx, client, DirectoryEtcSeaweedFS, FilerConfName) + if err == filer_pb.ErrNotFound || (err == nil && len(data) == 0) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("read %s/%s: %v", DirectoryEtcSeaweedFS, FilerConfName, err) + } + fc := NewFilerConf() + if err = fc.LoadFromBytes(data); err != nil { + return false, fmt.Errorf("parse %s/%s: %v", DirectoryEtcSeaweedFS, FilerConfName, err) + } + // join the rule key exactly as s3.bucket.quota.enforce writes it, so the + // exact-match lookup finds the rule even for a non-canonical bucketsPath + if !fc.ClearReadOnly(bucketsPath + "/" + bucketName + "/") { + return false, nil + } + var buf bytes.Buffer + if err = fc.ToText(&buf); err != nil { + return false, err + } + if err = SaveInsideFiler(ctx, client, DirectoryEtcSeaweedFS, FilerConfName, buf.Bytes()); err != nil { + return false, err + } + return true, nil +} + func (fc *FilerConf) GetCollectionTtls(collection string) (ttls map[string]string) { ttls = make(map[string]string) fc.rules.Walk(func(key []byte, value *filer_pb.FilerConf_PathConf) bool { diff --git a/weed/filer/filer_conf_test.go b/weed/filer/filer_conf_test.go index e0d430a98..8cdefc348 100644 --- a/weed/filer/filer_conf_test.go +++ b/weed/filer/filer_conf_test.go @@ -161,3 +161,24 @@ func TestApplyBucketQuotaReadOnly(t *testing.T) { _, 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) +} diff --git a/weed/shell/command_fs_configure.go b/weed/shell/command_fs_configure.go index fac4ed17e..238827769 100644 --- a/weed/shell/command_fs_configure.go +++ b/weed/shell/command_fs_configure.go @@ -41,6 +41,9 @@ func (c *commandFsConfigure) Help() string { # apply the changes fs.configure -locationPrefix=/my/folder -collection=abc -apply + # example: unlock a bucket that quota enforcement made read-only + fs.configure -locationPrefix=/buckets/my_bucket/ -readOnly=false -apply + # delete the changes fs.configure -locationPrefix=/my/folder -delete -apply @@ -131,6 +134,21 @@ func (c *commandFsConfigure) Do(args []string, commandEnv *CommandEnv, writer io fc.DeleteLocationConf(*locationPrefix) } else { fc.AddLocationConf(locConf) + // AddLocationConf merges boolean fields with OR, which can never turn + // a flag off; let an explicitly passed false win, e.g. -readOnly=false + // to reopen a bucket that quota enforcement locked + if mergedConf, found := fc.GetLocationConf(*locationPrefix); found { + fsConfigureCommand.Visit(func(f *flag.Flag) { + switch f.Name { + case "readOnly": + mergedConf.ReadOnly = *isReadOnly + case "fsync": + mergedConf.Fsync = *fsync + case "worm": + mergedConf.Worm = *worm + } + }) + } } } diff --git a/weed/shell/command_s3_bucket_quota.go b/weed/shell/command_s3_bucket_quota.go index 05910c099..5590e8dbc 100644 --- a/weed/shell/command_s3_bucket_quota.go +++ b/weed/shell/command_s3_bucket_quota.go @@ -6,6 +6,7 @@ import ( "fmt" "io" + "github.com/seaweedfs/seaweedfs/weed/filer" "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" ) @@ -25,6 +26,9 @@ func (c *commandS3BucketQuota) Help() string { Example: s3.bucket.quota -name= -op=set -sizeMB=1024 + + Removing or disabling the quota also clears the read-only flag that + s3.bucket.quota.enforce may have set on the bucket. ` } @@ -65,6 +69,7 @@ func (c *commandS3BucketQuota) Do(args []string, commandEnv *CommandEnv, writer } bucketEntry := lookupResp.Entry + clearReadOnly := false switch *operationName { case "set": bucketEntry.Quota = *sizeMB * 1024 * 1024 @@ -73,6 +78,7 @@ func (c *commandS3BucketQuota) Do(args []string, commandEnv *CommandEnv, writer return nil case "remove": bucketEntry.Quota = 0 + clearReadOnly = true case "enable": if bucketEntry.Quota < 0 { bucketEntry.Quota = -bucketEntry.Quota @@ -81,6 +87,7 @@ func (c *commandS3BucketQuota) Do(args []string, commandEnv *CommandEnv, writer if bucketEntry.Quota > 0 { bucketEntry.Quota = -bucketEntry.Quota } + clearReadOnly = true } if err := filer_pb.UpdateEntry(context.Background(), client, &filer_pb.UpdateEntryRequest{ @@ -92,6 +99,18 @@ func (c *commandS3BucketQuota) Do(args []string, commandEnv *CommandEnv, writer println("updated quota for bucket", *bucketName) + if clearReadOnly { + // with the quota removed or disabled, s3.bucket.quota.enforce can no + // longer clear a read-only flag it turned on, so lift it here + cleared, err := filer.ClearBucketReadOnly(ctx, client, filerBucketsPath, *bucketName) + if err != nil { + return err + } + if cleared { + fmt.Fprintf(writer, "cleared read-only for bucket %s\n", *bucketName) + } + } + return nil })