// Copyright (c) 2026 Feng Ruohang // // This file is part of Silo 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 . package cmd import ( "context" "crypto/sha256" "encoding/json" "errors" "fmt" "maps" "slices" "strings" "testing" "time" "github.com/google/uuid" "github.com/minio/madmin-go/v3" "github.com/minio/minio/internal/bucket/lifecycle" "github.com/minio/minio/internal/bucket/replication" "github.com/minio/minio/internal/bucket/versioning" "github.com/minio/minio/internal/config/storageclass" "github.com/minio/minio/internal/event" xhttp "github.com/minio/minio/internal/http" "github.com/minio/minio/internal/pubsub" ) // An all-pools ILM approval is only as sound as the history each set reports // and the protection it reads under the lock. These cases leave drives with // stale, partial or unreadable metadata, as failed or interrupted writes and // offline drives do, and check that no expiry deletes what they hide. const authorityNoncurrentRule = `ncvEnabledncv-1` var authorityHoldKey = strings.ToLower(xhttp.AmzObjectLockLegalHold) func newLockedSplitILM(t *testing.T, rules string) *splitILM { t.Helper() return &splitILM{migrationILM: newMigrationILM(t, versioning.Enabled, true, rules), ids: map[string]string{}, bodies: map[string]string{}} } // authorityParity pins new objects to 12+4 on the 16 drives of a set. Other // tests can leave another storage class configured. Without one, 4 is the // default; Update cannot restore an unconfigured class, so leave it then. func authorityParity(t *testing.T) { t.Helper() if globalStorageClass.GetParityForSC("") < 0 { return } previous := globalStorageClass globalStorageClass.Update(storageclass.Config{Standard: storageclass.StorageClass{Parity: 4}}) t.Cleanup(func() { globalStorageClass.Update(previous) }) } // authorityDisk fails the history scan (ReadXL) or the version removal of // one key on a drive. type authorityDisk struct { StorageAPI bucket, key string failRead, failWrite bool } func (d authorityDisk) ReadXL(ctx context.Context, bucket, key string, data bool) (RawFileInfo, error) { if d.failRead && bucket == d.bucket && key == d.key { return RawFileInfo{}, errDiskNotFound } return d.StorageAPI.ReadXL(ctx, bucket, key, data) } func (d authorityDisk) DeleteVersion(ctx context.Context, volume, path string, fi FileInfo, forceDelMarker bool, opts DeleteOptions) error { if d.failWrite && volume == d.bucket && path == d.key { return errDiskNotFound } return d.StorageAPI.DeleteVersion(ctx, volume, path, fi, forceDelMarker, opts) } func (d authorityDisk) DeleteVersions(ctx context.Context, volume string, versions []FileInfoVersions, opts DeleteOptions) []error { errs := make([]error, len(versions)) var pass []FileInfoVersions var idx []int for i, v := range versions { if d.failWrite && volume == d.bucket && v.Name == d.key { errs[i] = errDiskNotFound continue } pass = append(pass, v) idx = append(idx, i) } if len(pass) > 0 { for j, err := range d.StorageAPI.DeleteVersions(ctx, volume, pass, opts) { errs[idx[j]] = err } } return errs } // wrapAuthorityDisks makes the drives of pool that drive selects fail reads // or writes of key. func wrapAuthorityDisks(t *testing.T, s *splitILM, pool int, key string, failRead, failWrite bool, drive func(int) bool) { t.Helper() p := s.z.serverPools[pool] p.erasureDisksMu.Lock() prior := p.erasureDisks wrapped := make([][]StorageAPI, len(prior)) for i, disks := range prior { wrapped[i] = append([]StorageAPI(nil), disks...) for j := range disks { if drive(j) { wrapped[i][j] = authorityDisk{StorageAPI: disks[j], bucket: s.bucket, key: key, failRead: failRead, failWrite: failWrite} } } } p.erasureDisks = wrapped p.erasureDisksMu.Unlock() t.Cleanup(func() { p.erasureDisksMu.Lock() p.erasureDisks = prior p.erasureDisksMu.Unlock() }) } func authorityDrives(from, to int) func(int) bool { return func(i int) bool { return i >= from && i < to } } // removeAuthorityCopies removes version id of key from the drives of pool // that drive selects, as a write that did not reach them leaves it. func removeAuthorityCopies(t *testing.T, s *splitILM, pool int, key, id string, drive func(int) bool) { t.Helper() for i, disk := range s.z.serverPools[pool].getHashedSet(key).getDisks() { if !drive(i) { continue } fi := FileInfo{VersionID: id} fi.SetSkipTierFreeVersion() if err := disk.DeleteVersion(t.Context(), s.bucket, key, fi, false, DeleteOptions{}); err != nil { t.Fatal(err) } } } func authorityHeader(t *testing.T, disk StorageAPI, bucket, key, id string) xlMetaV2VersionHeader { t.Helper() rf, err := disk.ReadXL(t.Context(), bucket, key, false) if err != nil { t.Fatal(err) } var xl xlMetaV2 if err := xl.LoadOrConvert(rf.Buf); err != nil { t.Fatal(err) } for _, ver := range xl.versions { if uuid.UUID(ver.header.VersionID).String() == id { return ver.header } } t.Fatalf("drive has no version %s", id) return xlMetaV2VersionHeader{} } func authorityExpireVersion(t *testing.T, s *splitILM, key, id string) error { t.Helper() _, err := s.z.DeleteObject(t.Context(), s.bucket, key, ObjectOptions{Versioned: true, VersionID: id, Expiration: ExpirationOptions{Expire: true}}) return err } func authorityPresent(t *testing.T, s *splitILM, pool int, key, id string) { t.Helper() if _, err := s.z.serverPools[pool].GetObjectInfo(t.Context(), s.bucket, key, ObjectOptions{VersionID: id}); err != nil { t.Fatalf("pool %d lost version %s: %v", pool, id, err) } } // minorityRevision writes the versions old and new of key to pool 0, applies // metadata to old, and leaves its last drive with the revision before, as a // drive that missed the update does. It reports whether that stale revision // sorts before the fifteen others. func minorityRevision(t *testing.T, s *splitILM, key string, metadata map[string]string) (id string, staleFirst bool) { t.Helper() ctx := t.Context() s.write(key, splitAt("old", 5, 0)) s.write(key, splitAt("new", 3, 0)) id = s.ids[key+" old"] disks := s.z.serverPools[0].getHashedSet(key).getDisks() last := disks[len(disks)-1] stale, err := last.ReadVersion(ctx, "", s.bucket, key, id, ReadOptions{}) if err != nil { t.Fatal(err) } setLifecycleCopyMetadata(t, s, key, id, 0, metadata, false) if err := last.WriteMetadata(ctx, "", s.bucket, key, stale); err != nil { t.Fatal(err) } return id, authorityHeader(t, last, s.bucket, key, id).sortsBefore(authorityHeader(t, disks[0], s.bucket, key, id)) } // One drive missed a legal hold that the other fifteen committed. Its stale // revision must not be selected, and must not keep the fifteen from // deciding, whichever signature sorts first. func TestPoolsLifecycleMinorityLockRevision(t *testing.T) { authorityParity(t) s := newLockedSplitILM(t, authorityNoncurrentRule) ctx := t.Context() for attempt := range 32 { key := fmt.Sprintf("ncv-lock-%d", attempt) stamp := UTCNow().Format(time.RFC3339Nano) id, staleFirst := minorityRevision(t, s, key, poolLockMetadata("", "ON", stamp, stamp)) versions, events, err := s.z.lifecycleEventsAllPools(ctx, s.bucket, key) switch { case err != nil: t.Errorf("attempt %d (stale sorts first: %t): history: %v", attempt, staleFirst, err) case len(versions) != 2: t.Fatalf("attempt %d: merged %d versions", attempt, len(versions)) case versions[1].UserDefined[authorityHoldKey] != "ON" || events[1].Action != lifecycle.NoneAction: t.Errorf("attempt %d (stale sorts first: %t): merged hold=%q event=%s", attempt, staleFirst, versions[1].UserDefined[authorityHoldKey], events[1].Action) } if err := authorityExpireVersion(t, s, key, id); !errors.Is(err, errLifecycleNotApplicable) { t.Errorf("attempt %d (stale sorts first: %t): expiry of a held version: %v", attempt, staleFirst, err) } oi, err := s.z.serverPools[0].GetObjectInfo(ctx, s.bucket, key, ObjectOptions{VersionID: id}) if err != nil || oi.UserDefined[authorityHoldKey] != "ON" { t.Fatalf("held version changed: hold=%q err=%v", oi.UserDefined[authorityHoldKey], err) } if staleFirst { return } } t.Fatal("no attempt where the stale revision sorts first") } // The same layout without protection: a drive missed an ordinary metadata // update of a version that expires. The fifteen still decide its expiry. func TestPoolsLifecycleMinorityStaleRevision(t *testing.T) { authorityParity(t) s := newSplitILM(t, versioning.Enabled, splitILMRules) ctx := t.Context() for attempt := range 32 { key := fmt.Sprintf("ncv-stale-%d", attempt) id, staleFirst := minorityRevision(t, s, key, map[string]string{"x-amz-meta-note": fmt.Sprint(attempt)}) _, events, err := s.z.lifecycleEventsAllPools(ctx, s.bucket, key) if err != nil || len(events) != 2 || events[1].Action != lifecycle.DeleteVersionAction { t.Errorf("attempt %d (stale sorts first: %t): events=%v err=%v", attempt, staleFirst, events, err) } if err := authorityExpireVersion(t, s, key, id); err != nil { t.Fatalf("attempt %d (stale sorts first: %t): expiry: %v", attempt, staleFirst, err) } for i, disk := range s.z.serverPools[0].getHashedSet(key).getDisks() { if _, err := disk.ReadVersion(ctx, "", s.bucket, key, id, ReadOptions{}); !errors.Is(err, errFileVersionNotFound) { t.Fatalf("attempt %d: drive %d still holds the expired version: %v", attempt, i, err) } } if staleFirst { return } } t.Fatal("no attempt where the stale revision sorts first") } // authorityRevision encodes a 12+4 object version as drive index holds it, // with the legal hold status hold. func authorityRevision(t *testing.T, id [16]byte, modTime int64, index int, hold string) xlMetaV2ShallowVersion { t.Helper() v := xlMetaV2Version{Type: ObjectType, ObjectV2: &xlMetaV2Object{ VersionID: id, DataDir: [16]byte{1}, ErasureAlgorithm: ReedSolomon, ErasureM: 12, ErasureN: 4, ErasureBlockSize: blockSizeV2, ErasureIndex: index, ErasureDist: make([]uint8, 16), BitrotChecksumAlgo: HighwayHash, PartNumbers: []int{1}, PartSizes: []int64{5}, Size: 5, ModTime: modTime, MetaUser: map[string]string{authorityHoldKey: hold, "etag": "e", "content-type": "text/plain"}, MetaSys: map[string][]byte{"x-minio-internal-a": {1}, "x-minio-internal-b": {2}, "x-minio-internal-c": {3}}, }} for i := range v.ObjectV2.ErasureDist { v.ObjectV2.ErasureDist[i] = uint8(i + 1) } meta, err := v.MarshalMsg(nil) if err != nil { t.Fatal(err) } return xlMetaV2ShallowVersion{header: v.header(), meta: meta} } // The header Signature is a 32-bit fold of weak hashes, so two revisions can // share a header. Revisions are told apart by their metadata: drives that // hold byte-for-byte different metadata under one header are not one // revision, and the merged history only carries the accepted metadata. func TestPoolsLifecycleRevisionIdentity(t *testing.T) { newID, oldID := [16]byte{2}, [16]byte{1} history := func(off func(drive int) bool) [][]xlMetaV2ShallowVersion { metas := make([][]xlMetaV2ShallowVersion, 16) for i := range metas { old := authorityRevision(t, oldID, 1, i+1, "ON") if off(i) { // The stale revision under the header of the held one. header := old.header old = authorityRevision(t, oldID, 1, i+1, "OFF") old.header = header } metas[i] = []xlMetaV2ShallowVersion{authorityRevision(t, newID, 2, i+1, ""), old} } return metas } metaVs := slices.Repeat([]uint8{xlMetaVersion}, 16) readQuorum := func(xlMetaV2ShallowVersion, uint8) int { return 12 } t.Run("revision-key", func(t *testing.T) { on, onOther := authorityRevision(t, oldID, 1, 1, "ON"), authorityRevision(t, oldID, 1, 7, "ON") off := authorityRevision(t, oldID, 1, 1, "OFF") off.header = on.header if versionRevision(on, xlMetaVersion) != versionRevision(onOther, xlMetaVersion) { t.Error("one revision on two drives has two keys") } if versionRevision(on, xlMetaVersion) == versionRevision(off, xlMetaVersion) { t.Error("different metadata under one header has one key") } }) t.Run("stale-minority-first", func(t *testing.T) { merged, err := authoritativeHistory(t.Context(), history(func(i int) bool { return i == 0 }), metaVs, xlMetaVersion, 9, readQuorum) if err != nil || len(merged) != 2 { t.Fatalf("history: err=%v versions=%d", err, len(merged)) } var v xlMetaV2Version if _, err := v.unmarshalV(xlMetaVersion, merged[1].meta); err != nil { t.Fatal(err) } if hold := v.ObjectV2.MetaUser[authorityHoldKey]; hold != "ON" { t.Fatalf("merged history carries hold=%q", hold) } }) t.Run("split-8-8", func(t *testing.T) { if merged, err := authoritativeHistory(t.Context(), history(func(i int) bool { return i%2 == 0 }), metaVs, xlMetaVersion, 9, readQuorum); !errors.Is(err, errErasureReadQuorum) { t.Fatalf("8/8 metadata split: %d versions, err=%v", len(merged), err) } }) // The user and the system map are separate sections of the digest: an // entry that moves between them changes what the evaluator reads. t.Run("map-boundary", func(t *testing.T) { for _, entry := range [][2]string{ {authorityHoldKey, "ON"}, {strings.ToLower(xhttp.AmzObjectLockRetainUntilDate), "2099-01-01T00:00:00Z"}, {xhttp.AmzObjectTagging, "expire=yes"}, } { ver := authorityRevision(t, oldID, 1, 1, "") var v xlMetaV2Version if _, err := v.unmarshalV(xlMetaVersion, ver.meta); err != nil { t.Fatal(err) } v.ObjectV2.MetaUser, v.ObjectV2.MetaSys = map[string]string{entry[0]: entry[1]}, nil user, err := v.MarshalMsg(nil) if err != nil { t.Fatal(err) } v.ObjectV2.MetaUser, v.ObjectV2.MetaSys = nil, map[string][]byte{entry[0]: []byte(entry[1])} sys, err := v.MarshalMsg(nil) if err != nil { t.Fatal(err) } if versionRevision(xlMetaV2ShallowVersion{header: ver.header, meta: user}, xlMetaVersion) == versionRevision(xlMetaV2ShallowVersion{header: ver.header, meta: sys}, xlMetaVersion) { t.Errorf("%s moved between the maps keeps the revision", entry[0]) } } }) t.Run("canceled", func(t *testing.T) { ctx, cancel := context.WithCancel(t.Context()) cancel() if _, err := authoritativeHistory(ctx, history(func(int) bool { return false }), metaVs, xlMetaVersion, 9, readQuorum); !errors.Is(err, context.Canceled) { t.Fatalf("canceled history: %v", err) } }) } // authorityMovedHold serves a drive whose metadata of version id carries the // legal hold in the system map instead of the user map, under the header it // had: the evaluator no longer reads the hold there. type authorityMovedHold struct { StorageAPI bucket, key, id string } func (d authorityMovedHold) ReadXL(ctx context.Context, bucket, key string, data bool) (RawFileInfo, error) { rf, err := d.StorageAPI.ReadXL(ctx, bucket, key, data) if err != nil || bucket != d.bucket || key != d.key { return rf, err } var xl xlMetaV2 if err = xl.LoadOrConvert(rf.Buf); err != nil { return rf, err } for i := range xl.versions { if uuid.UUID(xl.versions[i].header.VersionID).String() != d.id { continue } var v xlMetaV2Version if _, err = v.unmarshalV(xl.metaV, xl.versions[i].meta); err != nil { return rf, err } v.ObjectV2.MetaSys[authorityHoldKey] = []byte(v.ObjectV2.MetaUser[authorityHoldKey]) delete(v.ObjectV2.MetaUser, authorityHoldKey) if xl.versions[i].meta, err = v.MarshalMsg(nil); err != nil { return rf, err } } rf.Buf, err = xl.AppendTo(nil) return rf, err } // One drive's metadata moves the hold of a held version to the system map // under the same header. Its revision is not the held one, and the bulk // expiry keeps the version. func TestPoolsLifecycleMovedHoldKeepsVersion(t *testing.T) { s := newLockedSplitILM(t, authorityNoncurrentRule) ctx := t.Context() const key = "ncv-moved-hold" s.write(key, splitAt("old", 5, 0)) s.write(key, splitAt("new", 3, 0)) id := s.ids[key+" old"] stamp := UTCNow().Format(time.RFC3339Nano) setLifecycleCopyMetadata(t, s, key, id, 0, map[string]string{authorityHoldKey: "ON", ReservedMetadataPrefixLower + ObjectLockLegalHoldTimestamp: stamp}, false) p := s.z.serverPools[0] p.erasureDisksMu.Lock() prior := p.erasureDisks wrapped := make([][]StorageAPI, len(prior)) for i, disks := range prior { wrapped[i] = append([]StorageAPI(nil), disks...) wrapped[i][0] = authorityMovedHold{StorageAPI: disks[0], bucket: s.bucket, key: key, id: id} } p.erasureDisks = wrapped p.erasureDisksMu.Unlock() t.Cleanup(func() { p.erasureDisksMu.Lock(); p.erasureDisks = prior; p.erasureDisksMu.Unlock() }) versions, events, err := s.z.lifecycleEventsAllPools(ctx, s.bucket, key) if err != nil || versions[1].UserDefined[authorityHoldKey] != "ON" || events[1].Action != lifecycle.NoneAction { t.Errorf("history: events=%v err=%v", events, err) } if err := authorityDeletePath(t, s, key, id, "bulk"); !errors.Is(err, errLifecycleNotApplicable) { t.Errorf("bulk expiry of a held version: %v", err) } authorityPresent(t, s, 0, key, id) } // A legacy version can carry several content checksums for each part. The // map is encoded in iteration order, and must not change the revision of // identical metadata; a different checksum does change it (control). func TestPoolsLifecycleLegacyPartChecksums(t *testing.T) { legacy := func(index int, sha256Value string) []byte { m := getSampleXLMeta(1) m.Stat.ModTime, m.Stat.Size, m.Parts[0].Size = time.Unix(1700000000, 0).UTC(), 20, 20 m.Erasure.DataBlocks, m.Erasure.ParityBlocks, m.Erasure.Index = 12, 4, index m.Erasure.Distribution = []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} m.Parts[0].Checksums = map[string]string{"CRC32": "AAAAAA==", "CRC32C": "AAAAAQ==", "SHA1": "AAAAAg==", "SHA256": sha256Value} buf, err := json.Marshal(m) if err != nil { t.Fatal(err) } return buf } revision := func(buf []byte) revisionKey { var xl xlMetaV2 if err := xl.LoadOrConvert(buf); err != nil { t.Fatal(err) } return versionRevision(xl.versions[0], xl.metaV) } keys := make(map[revisionKey]bool) for i := range 32 { keys[revision(legacy(i%16+1, "AAAAAw=="))] = true } if len(keys) != 1 { t.Fatalf("identical legacy metadata has %d revisions", len(keys)) } if revision(legacy(1, "AAAABA==")) == revision(legacy(1, "AAAAAw==")) { t.Fatal("a different part checksum keeps the revision") } disks := make([]StorageAPI, 16) for i := range disks { disks[i] = authorityRawDisk{buf: legacy(i+1, "AAAAAw==")} } er := erasureObjects{getDisks: func() []StorageAPI { return disks }, defaultParityCount: 4} if versions, err := er.objectVersions(t.Context(), "bucket", "key"); err != nil || len(versions) != 1 { t.Fatalf("healthy legacy history: %d versions, err=%v", len(versions), err) } } // authorityRawDisk serves the same xl.meta for every key. type authorityRawDisk struct { StorageAPI buf []byte } func (d authorityRawDisk) ReadXL(context.Context, string, string, bool) (RawFileInfo, error) { return RawFileInfo{Buf: d.buf}, nil } // A legacy (xl.json) version stores the bitrot checksum of each drive's own // shard. Healthy drives differ there and still hold one revision. func TestPoolsLifecycleLegacyHistory(t *testing.T) { for _, distinct := range []bool{false, true} { name := "same-checksums" // control if distinct { name = "per-shard-checksums" } t.Run(name, func(t *testing.T) { disks := make([]StorageAPI, 16) for i := range disks { m := getSampleXLMeta(1) m.Stat.ModTime, m.Stat.Size, m.Parts[0].Size = time.Unix(1700000000, 0).UTC(), 20, 20 m.Erasure.DataBlocks, m.Erasure.ParityBlocks, m.Erasure.Index = 12, 4, i+1 m.Erasure.Distribution = []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} shard := []byte{1, 1} if distinct { shard[0] = byte(i + 1) } sum := sha256.Sum256(shard) m.Erasure.Checksums[0] = ChecksumInfo{PartNumber: 1, Algorithm: SHA256, Hash: sum[:]} buf, err := json.Marshal(m) if err != nil { t.Fatal(err) } disks[i] = authorityRawDisk{buf: buf} } er := erasureObjects{getDisks: func() []StorageAPI { return disks }, defaultParityCount: 4} if versions, err := er.objectVersions(t.Context(), "bucket", "key"); err != nil || len(versions) != 1 { t.Fatalf("healthy legacy history: %d versions, err=%v", len(versions), err) } }) } } // authorityDeleteAll is the expiry of all versions of a key as the expiry // worker sends it: a purge of all versions, approved by the lifecycle. var authorityDeleteAll = ObjectOptions{DeleteAllVersions: true, Expiration: ExpirationOptions{Expire: true}} // A fresh version committed to 15 of 16 drives, with eight other drives // unreadable: only seven answering drives show it. func TestPoolsLifecyclePartialHistory(t *testing.T) { s := newSplitILM(t, versioning.Enabled, splitILMRules) ctx := t.Context() const key = "eav-partial-history" s.write(key, splitAt("old", 5, 0, 1)) s.write(key, splitAt("fresh", 0, 1)) id := s.ids[key+" fresh"] removeAuthorityCopies(t, s, 1, key, id, authorityDrives(0, 1)) wrapAuthorityDisks(t, s, 1, key, true, false, authorityDrives(1, 9)) if _, _, err := s.z.lifecycleEventsAllPools(ctx, s.bucket, key); !isErrReadQuorum(err) { t.Errorf("partial history: %v", err) } _, err := s.z.DeleteObject(ctx, s.bucket, key, authorityDeleteAll) if !isErrWriteQuorum(err) { t.Errorf("DeleteAll on a partial history: %v", err) } authorityPresent(t, s, 1, key, id) } // Eight drives hold one revision of a version and eight another. Neither is // authoritative, so neither may decide the expiry. func TestPoolsLifecycleConflictingRevisions(t *testing.T) { s := newLockedSplitILM(t, authorityNoncurrentRule) ctx := t.Context() const key = "ncv-split-revision" s.write(key, splitAt("old", 5, 0)) s.write(key, splitAt("new", 3, 0)) id := s.ids[key+" old"] disks := s.z.serverPools[0].getHashedSet(key).getDisks() half := len(disks) / 2 stale := make([]FileInfo, len(disks)) for i := half; i < len(disks); i++ { fi, err := disks[i].ReadVersion(ctx, "", s.bucket, key, id, ReadOptions{}) if err != nil { t.Fatal(err) } stale[i] = fi } stamp := UTCNow().Format(time.RFC3339Nano) setLifecycleCopyMetadata(t, s, key, id, 0, poolLockMetadata("", "ON", stamp, stamp), false) for i := half; i < len(disks); i++ { if err := disks[i].WriteMetadata(ctx, "", s.bucket, key, stale[i]); err != nil { t.Fatal(err) } } if _, _, err := s.z.lifecycleEventsAllPools(ctx, s.bucket, key); !isErrReadQuorum(err) { t.Errorf("8/8 revisions: %v", err) } if err := authorityExpireVersion(t, s, key, id); !isErrWriteQuorum(err) { t.Errorf("expiry on 8/8 revisions: %v", err) } for i, disk := range disks { if _, err := disk.ReadVersion(ctx, "", s.bucket, key, id, ReadOptions{}); err != nil { t.Fatalf("drive %d lost the version: %v", i, err) } } } // Every drive answers and the old version is complete, but a fresh version // that some drives show reached too few of them to be authoritative. func TestPoolsLifecycleMissingVersion(t *testing.T) { s := newSplitILM(t, versioning.Enabled, splitILMRules) ctx := t.Context() const key = "eav-missing-version" s.write(key, splitAt("old", 5, 0, 1)) s.write(key, splitAt("fresh", 0, 1)) id := s.ids[key+" fresh"] removeAuthorityCopies(t, s, 1, key, id, authorityDrives(0, 9)) if _, _, err := s.z.lifecycleEventsAllPools(ctx, s.bucket, key); !isErrReadQuorum(err) { t.Errorf("history without an observed version: %v", err) } _, err := s.z.DeleteObject(ctx, s.bucket, key, authorityDeleteAll) if !isErrWriteQuorum(err) { t.Errorf("DeleteAll: %v", err) } disks := s.z.serverPools[1].getHashedSet(key).getDisks() for i := 9; i < len(disks); i++ { if _, err := disks[i].ReadVersion(ctx, "", s.bucket, key, id, ReadOptions{}); err != nil { t.Fatalf("drive %d lost the fresh version: %v", i, err) } } } // The completeness checks also hold where a set has fewer answering drives // than a read quorum or no version reaches one. func TestPoolsLifecycleHistoryEarlyReturns(t *testing.T) { // Seven drives hold the key, the other nine do not have it at all. t.Run("minority-copy-only", func(t *testing.T) { s := newSplitILM(t, versioning.Enabled, splitILMRules) ctx := t.Context() const key = "eav-minority-only" s.write(key, splitAt("old", 5, 0)) s.write(key, splitAt("fresh", 0, 1)) id := s.ids[key+" fresh"] removeAuthorityCopies(t, s, 1, key, id, authorityDrives(0, 9)) disks := s.z.serverPools[1].getHashedSet(key).getDisks() if _, err := disks[0].ReadXL(ctx, s.bucket, key, false); !errors.Is(err, errFileNotFound) { t.Fatalf("fixture: drive 0 still holds the key: %v", err) } if _, _, err := s.z.lifecycleEventsAllPools(ctx, s.bucket, key); !isErrReadQuorum(err) { t.Errorf("minority copy: %v", err) } if _, err := s.z.DeleteObject(ctx, s.bucket, key, authorityDeleteAll); !isErrWriteQuorum(err) { t.Errorf("DeleteAll: %v", err) } for i := 9; i < len(disks); i++ { if _, err := disks[i].ReadVersion(ctx, "", s.bucket, key, id, ReadOptions{}); err != nil { t.Fatalf("drive %d lost the fresh version: %v", i, err) } } }) // Control: two versions on seven drives each, none reaches a quorum. t.Run("disjoint-minorities", func(t *testing.T) { s := newSplitILM(t, versioning.Enabled, splitILMRules) ctx := t.Context() const key = "eav-disjoint" s.write(key, splitAt("old", 5, 0)) s.write(key, splitAt("a", 2, 1)) s.write(key, splitAt("b", 1, 1)) removeAuthorityCopies(t, s, 1, key, s.ids[key+" a"], authorityDrives(7, 16)) removeAuthorityCopies(t, s, 1, key, s.ids[key+" b"], func(i int) bool { return i < 7 || i >= 14 }) if _, _, err := s.z.lifecycleEventsAllPools(ctx, s.bucket, key); !isErrReadQuorum(err) { t.Fatalf("disjoint minorities: %v", err) } if _, err := s.z.DeleteObject(ctx, s.bucket, key, authorityDeleteAll); !isErrWriteQuorum(err) { t.Fatalf("DeleteAll: %v", err) } }) } // A set without an unknown drive less than half of its drives still tells // its history; half of them unknown could hide a version. func TestPoolsLifecycleUnknownDriveThreshold(t *testing.T) { for _, tc := range []struct { name string pool int // the pool with unreadable drives unknown int refused bool }{ {"history/7", 0, 7, false}, {"history/8", 0, 8, true}, {"absent/7", 1, 7, false}, {"absent/8", 1, 8, true}, } { t.Run(tc.name, func(t *testing.T) { s := newSplitILM(t, versioning.Enabled, splitILMRules) ctx := t.Context() const key = "ncv-threshold" // Empty versions need only a simple majority of the drives, so // seven unknown drives leave enough copies. var ids []string for _, days := range []int{5, 3} { id := mustGetUUID() putConsistencyObject(t, s.z, s.bucket, key, 0, "", ObjectOptions{Versioned: true, VersionID: id, MTime: UTCNow().AddDate(0, 0, -days)}) ids = append(ids, id) } wrapAuthorityDisks(t, s, tc.pool, key, true, false, authorityDrives(0, tc.unknown)) err := authorityExpireVersion(t, s, key, ids[0]) if tc.refused { if !isErrWriteQuorum(err) { t.Errorf("%d unknown drives: %v", tc.unknown, err) } authorityPresent(t, s, 0, key, ids[0]) return } if err != nil { t.Fatalf("%d unknown drives: %v", tc.unknown, err) } if _, err := s.z.serverPools[0].GetObjectInfo(ctx, s.bucket, key, ObjectOptions{VersionID: ids[0]}); !isErrVersionNotFound(err) { t.Fatalf("expired version remains: %v", err) } }) } } // Control: free versions are tier GC records. Residue of one on a few drives // must not block an expiry. func TestPoolsLifecycleFreeVersionResidue(t *testing.T) { for _, tc := range []struct { name string residue int // the pool with the residue }{ {"beside-history", 0}, {"alone", 1}, } { t.Run(tc.name, func(t *testing.T) { s := newSplitILM(t, versioning.Enabled, splitILMRules) ctx := t.Context() const key = "ncv-free-residue" s.write(key, splitTiered("gone", 6, tc.residue)) s.write(key, splitAt("old", 5, 0)) s.write(key, splitAt("new", 3, 0)) gone := s.ids[key+" gone"] disks := s.z.serverPools[tc.residue].getHashedSet(key).getDisks() fvID := mustGetUUID() for i, disk := range disks { fi := FileInfo{VersionID: gone} if i < 3 { fi.SetTierFreeVersionID(fvID) } else { fi.SetSkipTierFreeVersion() } if err := disk.DeleteVersion(ctx, s.bucket, key, fi, false, DeleteOptions{}); err != nil { t.Fatal(err) } } if h := authorityHeader(t, disks[0], s.bucket, key, fvID); !h.FreeVersion() { t.Fatalf("fixture lacks a free version: %v", h) } versions, events, err := s.z.lifecycleEventsAllPools(ctx, s.bucket, key) if err != nil || len(versions) != 2 || events[1].Action != lifecycle.DeleteVersionAction { t.Fatalf("history with free-version residue: %d versions, events=%v, err=%v", len(versions), events, err) } if err := authorityExpireVersion(t, s, key, s.ids[key+" old"]); err != nil { t.Fatalf("expiry beside free-version residue: %v", err) } }) } } // An interrupted 12+4 write left nine matching copies of a newer version: // a majority of the drives, but less than its read quorum. It must not make // the last committed version noncurrent. func TestPoolsLifecycleReducedParityPartialVersion(t *testing.T) { for _, path := range []string{"single", "bulk"} { t.Run(path, func(t *testing.T) { authorityParity(t) s := newSplitILM(t, versioning.Enabled, splitILMRules) ctx := t.Context() const key = "ncv-partial-write" s.write(key, splitAt("committed", 5, 0)) s.write(key, splitAt("partial", 3, 0)) committed, partial := s.ids[key+" committed"], s.ids[key+" partial"] oi, err := s.z.serverPools[0].GetObjectInfo(ctx, s.bucket, key, ObjectOptions{VersionID: partial}) if err != nil || oi.DataBlocks != 12 || oi.ParityBlocks != 4 { t.Fatalf("fixture is not 12+4: data=%d parity=%d err=%v", oi.DataBlocks, oi.ParityBlocks, err) } removeAuthorityCopies(t, s, 0, key, partial, authorityDrives(9, 16)) if _, _, err := s.z.lifecycleEventsAllPools(ctx, s.bucket, key); !isErrReadQuorum(err) { t.Errorf("history with a partial version: %v", err) } if err := authorityDeletePath(t, s, key, committed, path); !isErrWriteQuorum(err) { t.Errorf("expiry of the last committed version: %v", err) } authorityPresent(t, s, 0, key, committed) }) } } // Control: two drives do not answer, every version still has its quorum. func TestPoolsLifecycleDegradedHistory(t *testing.T) { authorityParity(t) s := newSplitILM(t, versioning.Enabled, splitILMRules) ctx := t.Context() const key = "ncv-degraded" s.write(key, splitAt("old", 5, 0)) s.write(key, splitAt("new", 3, 0)) id := s.ids[key+" old"] wrapAuthorityDisks(t, s, 0, key, true, false, authorityDrives(0, 2)) if err := authorityExpireVersion(t, s, key, id); err != nil { t.Fatalf("degraded expiry: %v", err) } if _, err := s.z.serverPools[0].GetObjectInfo(ctx, s.bucket, key, ObjectOptions{VersionID: id}); !isErrVersionNotFound(err) { t.Fatalf("expired version remains: %v", err) } } // authorityPoolLock sets the Object Lock metadata of the copy of version id // in pool. func authorityPoolLock(t *testing.T, s *splitILM, key, id string, pool int, metadata map[string]string) { t.Helper() metadata = maps.Clone(metadata) if metadata[strings.ToLower(xhttp.AmzObjectLockMode)] != "" { metadata[strings.ToLower(xhttp.AmzObjectLockRetainUntilDate)] = UTCNow().AddDate(1, 0, 0).Format(time.RFC3339) } else { metadata[strings.ToLower(xhttp.AmzObjectLockRetainUntilDate)] = "" } setLifecycleCopyMetadata(t, s, key, id, pool, metadata, false) } // authorityDeletePath expires version id by the single path or by the bulk // path, which takes the ordinary or the tiered route as the version is. func authorityDeletePath(t *testing.T, s *splitILM, key, id, path string) error { t.Helper() if path == "single" { return authorityExpireVersion(t, s, key, id) } _, errs := s.z.DeleteObjects(t.Context(), s.bucket, []ObjectToDelete{{ObjectV: ObjectV{ObjectName: key, VersionID: id}}}, ObjectOptions{Versioned: true, Expiration: ExpirationOptions{Expire: true}}) return errs[0] } var authorityPaths = []string{"single", "bulk", "tiered-bulk"} // The copies of a version in two pools carry protection revisions that do not // tell which is the newest. Refuse instead of guessing. func TestPoolsLifecycleAmbiguousProtection(t *testing.T) { stamp := UTCNow().Add(-time.Hour).Format(time.RFC3339Nano) old := UTCNow().Add(-2 * time.Hour).Format(time.RFC3339Nano) for _, tc := range []struct { name string copies [2]map[string]string }{ {"hold-equal-stamps", [2]map[string]string{poolLockMetadata("", "OFF", old, stamp), poolLockMetadata("", "ON", old, stamp)}}, {"hold-missing-stamp", [2]map[string]string{poolLockMetadata("", "ON", old, ""), poolLockMetadata("", "OFF", old, stamp)}}, {"hold-malformed-stamp", [2]map[string]string{poolLockMetadata("", "ON", old, "yesterday"), poolLockMetadata("", "OFF", old, stamp)}}, {"retention-equal-stamps", [2]map[string]string{poolLockMetadata("", "OFF", stamp, old), poolLockMetadata("GOVERNANCE", "OFF", stamp, old)}}, } { for _, path := range authorityPaths { t.Run(tc.name+"/"+path, func(t *testing.T) { s := newLockedSplitILM(t, authorityNoncurrentRule) const key = "ncv-ambiguous" v := splitAt("old", 5, 0, 1) v.tiered = path == "tiered-bulk" s.write(key, v) s.write(key, splitAt("new", 3, 0, 1)) id := s.ids[key+" old"] for pool, metadata := range tc.copies { authorityPoolLock(t, s, key, id, pool, metadata) } if _, _, err := s.z.lifecycleEventsAllPools(t.Context(), s.bucket, key); !errors.Is(err, errLifecycleNotApplicable) { t.Errorf("ambiguous protection: %v", err) } if err := authorityDeletePath(t, s, key, id, path); !errors.Is(err, errLifecycleNotApplicable) { t.Errorf("expiry with ambiguous protection: %v", err) } for pool := range s.z.serverPools { authorityPresent(t, s, pool, key, id) } }) } } } // Protection stored under canonical-case keys counts as the evaluator reads // it, in the comparison of copies, in the merged version and in the set. func TestPoolsLifecycleCanonicalProtection(t *testing.T) { older := UTCNow().Add(-2 * time.Hour).Format(time.RFC3339Nano) recent := UTCNow().Add(-time.Hour).Format(time.RFC3339Nano) until := UTCNow().AddDate(1, 0, 0).Format(time.RFC3339) hold := func(status, stamp string) map[string]string { return map[string]string{xhttp.AmzObjectLockLegalHold: status, ReservedMetadataPrefixLower + ObjectLockLegalHoldTimestamp: stamp} } retention := func(mode, stamp string) map[string]string { m := map[string]string{ReservedMetadataPrefixLower + ObjectLockRetentionTimestamp: stamp} if mode != "" { m[xhttp.AmzObjectLockMode], m[xhttp.AmzObjectLockRetainUntilDate] = mode, until } return m } for _, tc := range []struct { name string copies [2]map[string]string expires bool }{ {"hold-equal-stamps", [2]map[string]string{hold("OFF", recent), hold("ON", recent)}, false}, {"hold-newer", [2]map[string]string{hold("OFF", older), hold("ON", recent)}, false}, {"retention-equal-stamps", [2]map[string]string{retention("", recent), retention("COMPLIANCE", recent)}, false}, {"retention-newer", [2]map[string]string{retention("", older), retention("GOVERNANCE", recent)}, false}, {"hold-released", [2]map[string]string{hold("ON", older), hold("OFF", recent)}, true}, // control } { for _, path := range []string{"single", "bulk"} { t.Run(tc.name+"/"+path, func(t *testing.T) { s := newLockedSplitILM(t, authorityNoncurrentRule) const key = "ncv-canonical" id, mtime := mustGetUUID(), UTCNow().AddDate(0, 0, -5) for pool, metadata := range tc.copies { putConsistencyObject(t, s.z, s.bucket, key, pool, "old", ObjectOptions{Versioned: true, VersionID: id, MTime: mtime, UserDefined: maps.Clone(metadata)}) } s.write(key, splitAt("new", 3, 0, 1)) _, events, err := s.z.lifecycleEventsAllPools(t.Context(), s.bucket, key) if !tc.expires && err == nil && events[1].Action != lifecycle.NoneAction { t.Errorf("protected version approved: %s", events[1].Action) } err = authorityDeletePath(t, s, key, id, path) if tc.expires { if err != nil { t.Fatalf("released version: %v", err) } if _, err := s.z.serverPools[0].GetObjectInfo(t.Context(), s.bucket, key, ObjectOptions{VersionID: id}); !isErrVersionNotFound(err) { t.Fatalf("released version remains: %v", err) } return } if !errors.Is(err, errLifecycleNotApplicable) { t.Errorf("expiry of a protected version: %v", err) } for pool := range s.z.serverPools { authorityPresent(t, s, pool, key, id) } }) } } } // Control: a strictly newer release in another pool still expires the // version by the ordinary bulk path too, beside the single and tiered bulk // paths of TestPoolsLifecycleDuplicateLockMetadata. func TestPoolsLifecycleProtectionReleaseBulk(t *testing.T) { old := UTCNow().Add(-2 * time.Hour).Format(time.RFC3339Nano) recent := UTCNow().Add(-time.Hour).Format(time.RFC3339Nano) for _, tc := range []struct { name string copies [2]map[string]string }{ {"hold-released", [2]map[string]string{poolLockMetadata("", "ON", old, old), poolLockMetadata("", "OFF", old, recent)}}, {"retention-released", [2]map[string]string{poolLockMetadata("GOVERNANCE", "OFF", old, old), poolLockMetadata("", "OFF", recent, old)}}, } { t.Run(tc.name, func(t *testing.T) { s := newLockedSplitILM(t, authorityNoncurrentRule) const key = "ncv-released" s.write(key, splitAt("old", 5, 0, 1)) s.write(key, splitAt("new", 3, 0, 1)) id := s.ids[key+" old"] for pool, metadata := range tc.copies { authorityPoolLock(t, s, key, id, pool, metadata) } if err := authorityDeletePath(t, s, key, id, "bulk"); err != nil { t.Fatalf("released version: %v", err) } for pool := range s.z.serverPools { if _, err := s.z.serverPools[pool].GetObjectInfo(t.Context(), s.bucket, key, ObjectOptions{VersionID: id}); !isErrVersionNotFound(err) { t.Errorf("pool %d still holds the released version: %v", pool, err) } } }) } } // A set takes an all-pools approval only with the protection it was approved // with. Protection that the set reads and the approval does not strictly // supersede keeps the version. func TestPoolsLifecycleCheckedExpirySafeguard(t *testing.T) { old := UTCNow().Add(-2 * time.Hour).Format(time.RFC3339Nano) recent := UTCNow().Add(-time.Hour).Format(time.RFC3339Nano) for _, tc := range []struct { name string local map[string]string approved objectLockState deleted bool }{ {"hold-newer-locally", poolLockMetadata("", "ON", old, recent), objectLockState{legalHold: "OFF", legalHoldTimestamp: old}, false}, {"hold-missing-local-stamp", poolLockMetadata("", "ON", old, ""), objectLockState{legalHold: "OFF", legalHoldTimestamp: recent}, false}, {"hold-equal-stamps", poolLockMetadata("", "ON", old, recent), objectLockState{legalHold: "OFF", legalHoldTimestamp: recent}, false}, {"hold-missing-approved-stamp", poolLockMetadata("", "ON", old, old), objectLockState{legalHold: "OFF"}, false}, {"retention-newer-locally", poolLockMetadata("GOVERNANCE", "OFF", recent, old), objectLockState{retentionTimestamp: old, legalHold: "OFF", legalHoldTimestamp: old}, false}, // Controls: the approval strictly supersedes the local protection. {"hold-released", poolLockMetadata("", "ON", old, old), objectLockState{legalHold: "OFF", legalHoldTimestamp: recent}, true}, {"retention-released", poolLockMetadata("GOVERNANCE", "OFF", old, old), objectLockState{retentionTimestamp: recent, legalHold: "OFF", legalHoldTimestamp: old}, true}, } { t.Run(tc.name, func(t *testing.T) { s := newLockedSplitILM(t, authorityNoncurrentRule) ctx := t.Context() const key = "ncv-safeguard" s.write(key, splitAt("old", 5, 0)) s.write(key, splitAt("new", 3, 0)) id := s.ids[key+" old"] authorityPoolLock(t, s, key, id, 0, tc.local) opts := ObjectOptions{Versioned: true, VersionID: id, Expiration: ExpirationOptions{Expire: true, CheckedAllPools: true}} opts.Expiration.ApprovedProtection = tc.approved _, err := s.z.serverPools[0].DeleteObject(ctx, s.bucket, key, opts) if !tc.deleted { if !errors.Is(err, errLifecycleNotApplicable) { t.Errorf("protected version: %v", err) } authorityPresent(t, s, 0, key, id) return } if err != nil { t.Fatalf("released version: %v", err) } if _, err := s.z.serverPools[0].GetObjectInfo(ctx, s.bucket, key, ObjectOptions{VersionID: id}); !isErrVersionNotFound(err) { t.Fatalf("released version remains: %v", err) } }) } } // A pending purge vetoes an approved expiry in the set, also when the read // returns it with MethodNotAllowed, as for a delete marker. func TestPoolsLifecycleCheckedExpiryPendingPurge(t *testing.T) { for _, marker := range []bool{false, true} { name := "version" if marker { name = "delete-marker" } t.Run(name, func(t *testing.T) { s := newSplitILM(t, versioning.Enabled, splitILMRules) ctx := t.Context() const key = "ncv-pending-purge" label := "old" s.write(key, splitAt("first", 6, 0)) if marker { label = "dm" s.write(key, splitMarker(label, 5, 0)) } else { s.write(key, splitAt(label, 5, 0)) } s.write(key, splitAt("new", 3, 0)) id := s.ids[key+" "+label] target := newILMTestTarget(t) setILMTestReplicationConfig(t, s.bucket, target.arn, target.bucket) oi, err := s.z.serverPools[0].GetObjectInfo(ctx, s.bucket, key, ObjectOptions{VersionID: id}) if oi.DeleteMarker != marker || (err != nil) != marker || marker && !isErrMethodNotAllowed(err) { t.Fatalf("fixture: marker=%t err=%v", oi.DeleteMarker, err) } _, err = s.z.serverPools[0].DeleteObject(ctx, s.bucket, key, ObjectOptions{ VersionID: id, Versioned: true, MTime: oi.ModTime, DeleteReplication: ReplicationState{ VersionPurgeStatusInternal: target.arn + "=PENDING;", PurgeTargets: map[string]VersionPurgeStatusType{target.arn: replication.VersionPurgePending}, }, }) if err != nil { t.Fatal(err) } pending, err := s.z.serverPools[0].GetObjectInfo(ctx, s.bucket, key, ObjectOptions{VersionID: id}) if !isErrMethodNotAllowed(err) || pending.VersionPurgeStatus != replication.VersionPurgePending { t.Fatalf("fixture: purge=%q err=%v", pending.VersionPurgeStatus, err) } _, err = s.z.serverPools[0].DeleteObject(ctx, s.bucket, key, ObjectOptions{Versioned: true, VersionID: id, Expiration: ExpirationOptions{Expire: true, CheckedAllPools: true}}) if !errors.Is(err, errLifecycleNotApplicable) { t.Errorf("approved expiry of a pending purge: %v", err) } after, err := s.z.serverPools[0].GetObjectInfo(ctx, s.bucket, key, ObjectOptions{VersionID: id}) if !isErrMethodNotAllowed(err) || after.VersionPurgeStatus != replication.VersionPurgePending { t.Fatalf("pending purge changed: purge=%q err=%v", after.VersionPurgeStatus, err) } }) } } // The copy of a version in another pool carries a pending purge that the // merge would drop with the replication state of the first copy. func TestPoolsLifecycleDuplicatePendingPurge(t *testing.T) { for _, path := range []string{"single", "bulk"} { t.Run(path, func(t *testing.T) { s := newSplitILM(t, versioning.Enabled, splitILMRules) ctx := t.Context() const key = "ncv-pending-copy" s.write(key, splitAt("old", 5, 0, 1)) s.write(key, splitAt("new", 3, 0, 1)) id := s.ids[key+" old"] target := newILMTestTarget(t) setILMTestReplicationConfig(t, s.bucket, target.arn, target.bucket) oi, err := s.z.serverPools[1].GetObjectInfo(ctx, s.bucket, key, ObjectOptions{VersionID: id}) if err != nil { t.Fatal(err) } _, err = s.z.serverPools[1].DeleteObject(ctx, s.bucket, key, ObjectOptions{ VersionID: id, Versioned: true, MTime: oi.ModTime, DeleteReplication: ReplicationState{ VersionPurgeStatusInternal: target.arn + "=PENDING;", PurgeTargets: map[string]VersionPurgeStatusType{target.arn: replication.VersionPurgePending}, }, }) if err != nil { t.Fatal(err) } files, err := s.z.serverPools[1].getHashedSet(key).objectVersions(ctx, s.bucket, key) if err != nil || len(files) != 2 || files[1].ToObjectInfo(s.bucket, key, true).VersionPurgeStatus != replication.VersionPurgePending { t.Fatalf("fixture lacks the pending copy: %d versions, err=%v", len(files), err) } if _, _, err := s.z.lifecycleEventsAllPools(ctx, s.bucket, key); !errors.Is(err, errLifecycleNotApplicable) { t.Errorf("copies with a different purge state: %v", err) } if err := authorityDeletePath(t, s, key, id, path); !errors.Is(err, errLifecycleNotApplicable) { t.Errorf("expiry beside a pending purge: %v", err) } stored, err := s.z.serverPools[0].GetObjectInfo(ctx, s.bucket, key, ObjectOptions{VersionID: id}) if err != nil || stored.VersionPurgeStatus != "" { t.Fatalf("first copy changed: purge=%q err=%v", stored.VersionPurgeStatus, err) } if _, err := s.z.serverPools[1].GetObjectInfo(ctx, s.bucket, key, ObjectOptions{VersionID: id}); !isErrMethodNotAllowed(err) { t.Fatalf("pending copy changed: %v", err) } }) } } // Copies of a version compare their per-target replication state as parsed: // the order of the serialized targets does not count, a target does. func TestPoolsLifecycleDuplicateReplicationTargets(t *testing.T) { for _, tc := range []struct { name string statuses [2]string refused bool }{ {"reordered", [2]string{"arn:a=COMPLETED;arn:b=COMPLETED;", "arn:b=COMPLETED;arn:a=COMPLETED;"}, false}, // control {"other-targets", [2]string{"arn:a=COMPLETED;", "arn:a=COMPLETED;arn:b=COMPLETED;"}, true}, {"other-status", [2]string{"arn:a=COMPLETED;arn:b=COMPLETED;", "arn:a=COMPLETED;arn:b=FAILED;"}, true}, } { t.Run(tc.name, func(t *testing.T) { s := newSplitILM(t, versioning.Enabled, splitILMRules) ctx := t.Context() const key = "ncv-replicated-copies" id, mtime := mustGetUUID(), UTCNow().AddDate(0, 0, -5) for pool, status := range tc.statuses { putConsistencyObject(t, s.z, s.bucket, key, pool, "old", ObjectOptions{ Versioned: true, VersionID: id, MTime: mtime, UserDefined: map[string]string{ReservedMetadataPrefixLower + ReplicationStatus: status}, }) } s.write(key, splitAt("new", 3, 0, 1)) for pool, status := range tc.statuses { oi, err := s.z.serverPools[pool].GetObjectInfo(ctx, s.bucket, key, ObjectOptions{VersionID: id}) if err != nil || oi.ReplicationStatusInternal != status { t.Fatalf("fixture pool %d: status=%q err=%v", pool, oi.ReplicationStatusInternal, err) } } _, events, err := s.z.lifecycleEventsAllPools(ctx, s.bucket, key) if tc.refused { if !errors.Is(err, errLifecycleNotApplicable) { t.Errorf("copies with other replication targets: %v", err) } if err := authorityExpireVersion(t, s, key, id); !errors.Is(err, errLifecycleNotApplicable) { t.Errorf("expiry: %v", err) } authorityPresent(t, s, 0, key, id) return } if err != nil || events[1].Action != lifecycle.DeleteVersionAction { t.Fatalf("equivalent copies: events=%v err=%v", events, err) } if err := authorityExpireVersion(t, s, key, id); err != nil { t.Fatalf("expiry of equivalent copies: %v", err) } }) } } // Null versions written in two pools of a suspended bucket share a version // ID without being one write. Copies with another ModTime are older writes: // the newest decides, unless an older one is still protected. Copies with // the same ModTime are duplicates and must still agree. func TestPoolsLifecycleNullGenerations(t *testing.T) { hold := func(status, stamp string) map[string]string { return map[string]string{authorityHoldKey: status, ReservedMetadataPrefixLower + ObjectLockLegalHoldTimestamp: stamp} } older := UTCNow().Add(-2 * time.Hour).Format(time.RFC3339Nano) recent := UTCNow().Add(-time.Hour).Format(time.RFC3339Nano) for _, tc := range []struct { name string lock bool old, new map[string]string sameModTime bool wantNewest bool // evaluated on the newer write, else refused replicateOld bool }{ {"older-unprotected", false, hold("OFF", ""), nil, false, true, true}, {"older-held", true, hold("ON", older), hold("OFF", recent), false, false, false}, {"same-modtime-duplicates", false, hold("OFF", ""), nil, true, false, false}, } { t.Run(tc.name, func(t *testing.T) { s := &splitILM{migrationILM: newMigrationILM(t, versioning.Suspended, tc.lock, splitILMRules), ids: map[string]string{}, bodies: map[string]string{}} ctx := t.Context() const key = "exp-null-generations" oldTime, newTime := UTCNow().AddDate(0, 0, -5), UTCNow().AddDate(0, 0, -3) if tc.sameModTime { newTime = oldTime } oldMeta := maps.Clone(tc.old) if tc.replicateOld { oldMeta[ReservedMetadataPrefixLower+ReplicationStatus] = "arn:a=COMPLETED;" } putConsistencyObject(t, s.z, s.bucket, key, 0, "old", ObjectOptions{VersionSuspended: true, MTime: oldTime, UserDefined: oldMeta}) newest := putConsistencyObject(t, s.z, s.bucket, key, 1, "new", ObjectOptions{VersionSuspended: true, MTime: newTime, UserDefined: maps.Clone(tc.new)}) versions, events, err := s.z.lifecycleEventsAllPools(ctx, s.bucket, key) opts := ObjectOptions{VersionSuspended: true, Expiration: ExpirationOptions{Expire: true}} if !tc.wantNewest { if !errors.Is(err, errLifecycleNotApplicable) { t.Errorf("history: %d versions, events=%v, err=%v", len(versions), events, err) } if _, err := s.z.DeleteObject(ctx, s.bucket, key, opts); !errors.Is(err, errLifecycleNotApplicable) { t.Errorf("expiry: %v", err) } for pool := range s.z.serverPools { oi, err := s.z.serverPools[pool].GetObjectInfo(ctx, s.bucket, key, ObjectOptions{VersionID: nullVersionID}) if err != nil || oi.DeleteMarker { t.Fatalf("pool %d lost its null version: marker=%t err=%v", pool, oi.DeleteMarker, err) } } return } if err != nil || len(versions) != 1 || !versions[0].ModTime.Equal(newest.ModTime) || events[0].Action != lifecycle.DeleteAction { t.Fatalf("history: %d versions, events=%v, err=%v", len(versions), events, err) } if _, err := s.z.DeleteObject(ctx, s.bucket, key, opts); err != nil { t.Fatalf("expiry of the newest null write: %v", err) } }) } } // authoritySideEffects collects the version IDs that removal notifications // and ILM expiry audit traces report. func authoritySideEffects(t *testing.T, run func()) (notified, traced []string) { t.Helper() notified, traced, _ = authoritySideEffectsAll(t, run) return notified, traced } // authoritySideEffectsAll also returns, by version ID, the result an ILM // trace reports for an expiry that did not remove its version: "refused" or // "failed". Such traces are not removal traces. func authoritySideEffectsAll(t *testing.T, run func()) (notified, traced []string, incomplete map[string]string) { t.Helper() done := make(chan struct{}) defer close(done) events := make(chan event.Event, 16) if err := globalHTTPListen.Subscribe(pubsub.MaskFromMaskable(event.ObjectRemovedDelete), events, done, nil); err != nil { t.Fatal(err) } traces := make(chan madmin.TraceInfo, 16) if err := globalTrace.Subscribe(madmin.TraceILM, traces, done, nil); err != nil { t.Fatal(err) } run() incomplete = map[string]string{} // Both publishers deliver synchronously, so everything sent is buffered. for { select { case ev := <-events: notified = append(notified, ev.S3.Object.VersionID) case tr := <-traces: if tr.FuncName != ILMExpiry { continue } if r := tr.Custom["result"]; r != "" { incomplete[tr.Custom["version-id"]] = r continue } traced = append(traced, tr.Custom["version-id"]) default: slices.Sort(notified) slices.Sort(traced) return notified, traced, incomplete } } } // A batch expiry that another pool cannot read is not a removal. func TestPoolsLifecycleBatchQuorumNotAnnounced(t *testing.T) { s := newSplitILM(t, versioning.Enabled, splitILMRules) const key = "ncv-quorum-event" s.write(key, splitAt("old", 5, 0)) s.write(key, splitAt("new", 3, 0)) id := s.ids[key+" old"] lifecycleReadDisks(t, s, key, 1, errDiskNotFound) result := &lifecycleBatchResult{ObjectLayer: s.z} notified, traced, incomplete := authoritySideEffectsAll(t, func() { deleteObjectVersions(t.Context(), result, s.bucket, []ObjectToDelete{{ObjectV: ObjectV{ObjectName: key, VersionID: id}}}, []lifecycle.Event{{Action: lifecycle.DeleteVersionAction}}) }) if incomplete[id] != "failed" { t.Errorf("unapplied expiry not reported as failed: %v", incomplete) } if !isErrWriteQuorum(result.errs[0]) { t.Fatalf("fixture result: %v", result.errs[0]) } authorityPresent(t, s, 0, key, id) if len(notified) != 0 || len(traced) != 0 { t.Fatalf("unapplied expiry announced: notifications=%v traces=%v", notified, traced) } } // Each entry of a batch gets the side effects of its own result: a completed // or already absent delete is announced and audited, a refused or failed one // is not. func TestPoolsLifecycleBatchSideEffects(t *testing.T) { for _, path := range []string{"single-pool", "bulk", "tiered-bulk"} { t.Run(path, func(t *testing.T) { s := newSplitILM(t, versioning.Enabled, splitILMRules) const key, failing = "ncv-side-effects", "ncv-side-effects-failing" for _, k := range []string{key, failing} { v := splitAt("old", 5, 0) v.tiered = path == "tiered-bulk" s.write(k, v) s.write(k, splitAt("new", 3, 0)) } if path == "single-pool" { prior := s.z.serverPools t.Cleanup(func() { s.z.serverPools = prior }) s.z.serverPools = s.z.serverPools[:1] } wrapAuthorityDisks(t, s, 0, failing, false, true, authorityDrives(0, 9)) success, absent := s.ids[key+" old"], mustGetUUID() entries := []ObjectToDelete{ {ObjectV: ObjectV{ObjectName: key, VersionID: success}}, {ObjectV: ObjectV{ObjectName: failing, VersionID: s.ids[failing+" old"]}}, {ObjectV: ObjectV{ObjectName: key, VersionID: absent}}, } if path != "single-pool" { // A single pool has no all-pools check to refuse a bulk entry. entries = append(entries, ObjectToDelete{ObjectV: ObjectV{ObjectName: key, VersionID: s.ids[key+" new"]}}) } evts := make([]lifecycle.Event, len(entries)) for i := range evts { evts[i] = lifecycle.Event{Action: lifecycle.DeleteVersionAction} } result := &lifecycleBatchResult{ObjectLayer: s.z} notified, traced, incomplete := authoritySideEffectsAll(t, func() { deleteObjectVersions(t.Context(), result, s.bucket, entries, evts) }) if incomplete[entries[1].VersionID] != "failed" { t.Errorf("failed entry: %v", incomplete) } if len(entries) > 3 && incomplete[entries[3].VersionID] != "refused" { t.Errorf("refused entry: %v", incomplete) } if result.errs[0] != nil || !isErrWriteQuorum(result.errs[1]) || result.errs[2] != nil && !isErrVersionNotFound(result.errs[2]) || len(entries) > 3 && !errors.Is(result.errs[3], errLifecycleNotApplicable) { t.Fatalf("fixture results: %v", result.errs) } want := []string{success, absent} slices.Sort(want) if !slices.Equal(notified, want) { t.Errorf("removal notifications for %v, want %v", notified, want) } if !slices.Equal(traced, want) { t.Errorf("ILM expiry audit for %v, want %v", traced, want) } }) } } // A single pool keeps the explicit refusal rather than a not-found result. func TestPoolsLifecycleSinglePoolRefusal(t *testing.T) { s := newSplitILM(t, versioning.Enabled, splitILMRules) s.write("exp-new", splitAt("fresh", 0, 0)) prior := s.z.serverPools t.Cleanup(func() { s.z.serverPools = prior }) s.z.serverPools = s.z.serverPools[:1] _, err := s.z.DeleteObject(t.Context(), s.bucket, "exp-new", ObjectOptions{Versioned: true, Expiration: ExpirationOptions{Expire: true}}) if !errors.Is(err, errLifecycleNotApplicable) || isErrObjectNotFound(err) || isErrVersionNotFound(err) { t.Fatalf("single-pool stale expiry: %v", err) } } // Migration leaves a version to the logical expiry. When that expiry is // refused at retirement while the source still holds the version, the source // is kept: a refusal is never absence. Decommission and rebalance both // retire through removeMovedVersions. func TestPoolsMigrationLifecycleRefusedExpiryKeepsSource(t *testing.T) { s := newSplitILM(t, versioning.Enabled, splitILMRules) ctx := t.Context() const key = "exp-moved" s.write(key, splitAt("old", 5, 0, 1)) s.write(key, splitAt("new", 0.01, 1)) id := s.ids[key+" old"] source, err := s.z.serverPools[0].GetObjectInfo(ctx, s.bucket, key, ObjectOptions{VersionID: id}) if err != nil { t.Fatal(err) } _, updatedAt, err := globalBucketMetadataSys.GetLifecycleConfig(s.bucket) if err != nil { t.Fatal(err) } version := FileInfo{Name: key, VersionID: id, ModTime: source.ModTime} err = s.z.removeMovedVersions(ctx, s.bucket, key, 0, []FileInfo{version}, []migrationExpiredVersion{{ version: version, event: lifecycle.Event{Action: lifecycle.DeleteAction}, lifecycleUpdatedAt: updatedAt, source: lcEventSrc_Decom, }}) if _, ok := err.(PreConditionFailed); !ok { t.Fatalf("retirement after a refused expiry: %v", err) } authorityPresent(t, s, 0, key, id) if views := s.views(); !slices.Equal(views[0][key], []string{"old"}) || !slices.Equal(views[1][key], []string{"new", "old"}) { t.Fatalf("refused expiry changed the pools: %v", views) } }