mirror of
https://github.com/pgsty/minio.git
synced 2026-09-16 07:24:05 +03:00
62cf066ff5
An independent adversarial review of the bucket metadata convergence work found three defects it had introduced. GetBucketInfo overwrote the physical creation probe with cached metadata, which a bucket that never held a configuration legitimately lacks. The new creation-time requirement then failed every policy, tag, SSE, quota, versioning and Object Lock write on such a bucket, with no operator recovery path, and initial synchronization skipped it silently. Return the physical result unchanged when metadata is not requested, as ListBuckets already does, recover the time during initial synchronization, and pass it to MakeBucketHook so peers adopt the same bucket generation. Replication status compared parsed policies statement by statement while heal compares the canonical key. An upgraded peer that stored an equivalent statement order was therefore reported as mismatched forever, and heal never had anything to write. Compare the key heal compares; per-site presence counting is unchanged. Heal diagnostics shared one log key across four conditions, so a real peer RPC failure could be deduplicated away by an earlier message, and they were logged at error level for the normal transient of a peer that does not have the bucket yet. Give each reason its own key at warning level, report only a field state that exists and still cannot be ordered, and diagnose nothing when no site holds a state to propagate. The recovery test now runs against the real ObjectLayer; the stub it replaced returned the expected time and hid the defect. The policy status test uses a statement order the canonical encoder reorders, and adoption coverage is extended past a real field time. Signed-off-by: Feng Ruohang <rh@vonng.com>
238 lines
9.5 KiB
Go
238 lines
9.5 KiB
Go
// Copyright (c) 2015-2026 MinIO, Inc.
|
|
//
|
|
// This file is part of MinIO Object Storage stack
|
|
//
|
|
// This program is free software: you can redistribute it and/or modify
|
|
// it under the terms of the GNU Affero General Public License as published by
|
|
// the Free Software Foundation, either version 3 of the License, or
|
|
// (at your option) any later version.
|
|
//
|
|
// This program is distributed in the hope that it will be useful,
|
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
// GNU Affero General Public License for more details.
|
|
//
|
|
// You should have received a copy of the GNU Affero General Public License
|
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
|
|
package cmd
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"os"
|
|
"strconv"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/minio/madmin-go/v3"
|
|
"github.com/minio/minio/internal/auth"
|
|
)
|
|
|
|
func TestBucketMetadataTombstoneExportAndInitialSync(t *testing.T) {
|
|
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{t: t, objAPITest: func(obj ObjectLayer, backend, bucket string, _ http.Handler, cred auth.Credentials, t *testing.T) {
|
|
t.Run(backend, func(t *testing.T) {
|
|
recordBucketConfigPeer(t, cred)
|
|
old := globalSiteReplicationMetadataTombstones
|
|
defer func() { globalSiteReplicationMetadataTombstones = old }()
|
|
created := UTCNow().Add(-time.Hour)
|
|
deletedAt := created.Add(time.Minute)
|
|
for _, enabled := range []bool{false, true} {
|
|
globalSiteReplicationMetadataTombstones = enabled
|
|
meta := newBucketMetadata(bucket)
|
|
meta.Created = created
|
|
meta.defaultTimestamps()
|
|
for _, file := range []string{bucketPolicyConfig, bucketTaggingConfig, bucketSSEConfig, bucketQuotaConfigFile} {
|
|
_, at := replicatedBucketConfig(&meta, file)
|
|
*at = deletedAt
|
|
}
|
|
if err := globalBucketMetadataSys.save(t.Context(), meta); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Force a disk reload instead of accepting the just-published cache.
|
|
globalBucketMetadataSys.Remove(bucket)
|
|
info, err := globalSiteReplicationSys.SiteReplicationMetaInfo(t.Context(), obj, madmin.SRStatusOptions{Buckets: true})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
exported := info.Buckets[bucket]
|
|
if !exported.PolicyUpdatedAt.Equal(deletedAt) {
|
|
t.Fatal("Policy tombstone hidden by gate")
|
|
}
|
|
for _, at := range []time.Time{exported.TagConfigUpdatedAt, exported.SSEConfigUpdatedAt, exported.QuotaConfigUpdatedAt} {
|
|
if enabled && !at.Equal(deletedAt) || !enabled && !at.IsZero() {
|
|
t.Fatalf("gate=%v timestamp=%v", enabled, at)
|
|
}
|
|
}
|
|
for file, data := range bucketConfigTestData(bucket) {
|
|
event, send, err := initialBucketConfigReplicationEvent(meta, file)
|
|
wantSend := enabled && !bucketConfigUpdateOnly(file)
|
|
if err != nil || send != wantSend || send && !event.UpdatedAt.Equal(deletedAt) {
|
|
t.Fatalf("%s initial gate=%v: %+v %v %v", file, enabled, event, send, err)
|
|
}
|
|
baseline := newBucketMetadata(bucket)
|
|
baseline.Created = created
|
|
baseline.defaultTimestamps()
|
|
if _, send, err := initialBucketConfigReplicationEvent(baseline, file); err != nil || send {
|
|
t.Fatalf("empty baseline sent: %s", file)
|
|
}
|
|
value, _ := replicatedBucketConfig(&baseline, file)
|
|
*value = data
|
|
event, send, err = initialBucketConfigReplicationEvent(baseline, file)
|
|
if err != nil || !send || !event.UpdatedAt.Equal(created) {
|
|
t.Fatalf("historical baseline-live omitted: %s %v", file, err)
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}})
|
|
}
|
|
|
|
func TestPeerBucketMetadataLegacyAndGeneration(t *testing.T) {
|
|
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{t: t, objAPITest: func(obj ObjectLayer, backend, bucket string, _ http.Handler, cred auth.Credentials, t *testing.T) {
|
|
t.Run(backend, func(t *testing.T) {
|
|
old := globalSiteReplicationMetadataTombstones
|
|
defer func() { globalSiteReplicationMetadataTombstones = old }()
|
|
created := UTCNow().Add(-time.Hour)
|
|
for _, enabled := range []bool{false, true} {
|
|
globalSiteReplicationMetadataTombstones = enabled
|
|
for file, data := range bucketConfigTestData(bucket) {
|
|
meta := newBucketMetadata(bucket)
|
|
meta.Created = created
|
|
meta.defaultTimestamps()
|
|
if err := globalBucketMetadataSys.save(t.Context(), meta); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
counter := &bucketConfigWriteCounter{ObjectLayer: obj}
|
|
setObjectLayer(counter)
|
|
event := newBucketConfigReplicationEvent(bucket, file, bucketConfigState{data: data, at: created.Add(-time.Second)})
|
|
rec := applySRBucketMetaViaAdmin(t, cred, event)
|
|
if rec.Code != http.StatusOK || counter.writes.Load() != 0 {
|
|
t.Fatalf("pre-creation apply: %s %d writes=%d", file, rec.Code, counter.writes.Load())
|
|
}
|
|
// A live baseline may initialize. The same-time nil cannot delete it.
|
|
event.UpdatedAt = created
|
|
rec = applySRBucketMetaViaAdmin(t, cred, event)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("baseline apply: %s %s", file, rec.Body.String())
|
|
}
|
|
before := counter.writes.Load()
|
|
rec = applySRBucketMetaViaAdmin(t, cred, madmin.SRBucketMeta{Type: event.Type, Bucket: bucket, UpdatedAt: created})
|
|
if rec.Code != http.StatusOK || counter.writes.Load() != before {
|
|
t.Fatalf("nil baseline cleared configuration: %s", file)
|
|
}
|
|
event.UpdatedAt = time.Time{}
|
|
for range 2 {
|
|
rec = applySRBucketMetaViaAdmin(t, cred, event)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("legacy-zero rejected: %s %s", file, rec.Body.String())
|
|
}
|
|
}
|
|
meta, err := loadBucketMetadata(t.Context(), obj, bucket)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
value, at := replicatedBucketConfig(&meta, file)
|
|
if len(*value) == 0 || !at.After(created) {
|
|
t.Fatalf("legacy-zero not reclocked: %s %v", file, *at)
|
|
}
|
|
setObjectLayer(obj)
|
|
}
|
|
}
|
|
})
|
|
}})
|
|
}
|
|
|
|
type bucketMetadataCreatedObjectLayer struct {
|
|
ObjectLayer
|
|
missing bool
|
|
}
|
|
|
|
func (o bucketMetadataCreatedObjectLayer) GetBucketInfo(ctx context.Context, bucket string, opts BucketOptions) (BucketInfo, error) {
|
|
if opts.NoMetadata {
|
|
if o.missing {
|
|
return BucketInfo{}, BucketNotFound{Bucket: bucket}
|
|
}
|
|
// A physical bucket that reports no creation time either.
|
|
return BucketInfo{Name: bucket}, nil
|
|
}
|
|
return o.ObjectLayer.GetBucketInfo(ctx, bucket, opts)
|
|
}
|
|
|
|
func TestPeerBucketMetadataUnknownCreated(t *testing.T) {
|
|
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{t: t, objAPITest: func(obj ObjectLayer, backend, bucket string, _ http.Handler, _ auth.Credentials, t *testing.T) {
|
|
t.Run(backend, func(t *testing.T) {
|
|
defer setObjectLayer(obj)
|
|
data := bucketConfigTestData(bucket)[bucketTaggingConfig]
|
|
for _, mode := range []string{"unknown", "missing"} {
|
|
t.Run(mode, func(t *testing.T) {
|
|
setObjectLayer(obj)
|
|
if err := globalBucketMetadataSys.save(t.Context(), newBucketMetadata(bucket)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
counter := &bucketConfigWriteCounter{ObjectLayer: bucketMetadataCreatedObjectLayer{ObjectLayer: obj, missing: mode == "missing"}}
|
|
setObjectLayer(counter)
|
|
stamp := UTCNow()
|
|
_, err := globalBucketMetadataSys.updateAndParseMetadata(t.Context(), bucket, bucketTaggingConfig, data, false, false, &stamp)
|
|
if err == nil || counter.writes.Load() != 0 {
|
|
t.Fatalf("unknown generation was invented: %v writes=%d", err, counter.writes.Load())
|
|
}
|
|
})
|
|
}
|
|
})
|
|
}})
|
|
}
|
|
|
|
// setPhysicalBucketCreated stamps the bucket directory on every local drive,
|
|
// which is what StatVol reports as the physical creation time.
|
|
func setPhysicalBucketCreated(t *testing.T, bucket string, at time.Time) {
|
|
t.Helper()
|
|
globalLocalDrivesMu.RLock()
|
|
drives := cloneDrives(globalLocalDrivesMap)
|
|
globalLocalDrivesMu.RUnlock()
|
|
if len(drives) == 0 {
|
|
t.Fatal("no local drives registered")
|
|
}
|
|
for _, drive := range drives {
|
|
if err := os.Chtimes(pathJoin(drive.Endpoint().Path, bucket), at, at); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Recovery has to run against the real ObjectLayer: a stub GetBucketInfo
|
|
// returning the expected time would hide the cached zero creation time
|
|
// overwriting it, which is what a bucket that never held a configuration has.
|
|
func TestBucketMetadataPhysicalCreatedRecovery(t *testing.T) {
|
|
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{t: t, objAPITest: func(obj ObjectLayer, backend, bucket string, _ http.Handler, _ auth.Credentials, t *testing.T) {
|
|
// One known time on every drive, so the recovered value can be neither
|
|
// confused with UTCNow() nor dependent on which drive answers first.
|
|
physical := UTCNow().Add(-3 * time.Hour).Truncate(time.Second)
|
|
for _, missing := range []bool{false, true} {
|
|
for _, file := range replicatedBucketConfigs {
|
|
t.Run(backend+"/"+file+"/missing="+strconv.FormatBool(missing), func(t *testing.T) {
|
|
ctx := t.Context()
|
|
setPhysicalBucketCreated(t, bucket, physical)
|
|
if err := globalBucketMetadataSys.save(ctx, newBucketMetadata(bucket)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if missing {
|
|
if err := deleteConfig(ctx, obj, pathJoin(bucketMetaPrefix, bucket, bucketMetadataFile)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
data := bucketConfigTestData(bucket)[file]
|
|
at, err := globalBucketMetadataSys.Update(ctx, bucket, file, data)
|
|
if err != nil {
|
|
t.Fatalf("bucket without a recorded creation time cannot update %s: %v", file, err)
|
|
}
|
|
got, err := readBucketMetadata(ctx, obj, bucket)
|
|
if err != nil || !got.Created.Equal(physical) || !at.After(physical) {
|
|
t.Fatalf("physical creation not persisted: created=%v physical=%v updated=%v err=%v", got.Created, physical, at, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}})
|
|
}
|