mirror of
https://github.com/pgsty/minio.git
synced 2026-11-10 01:31:52 +03:00
b10fd0e9c0
An expiry of all versions of a key purges exactly that key, and the purge can refuse it or stop early. Those outcomes were hard to see, and the scanner reported a key it had queued as if it were already gone. - Refusals and failures of a purge are counted in expiry_incomplete_total with their own reasons: tier_unsupported (a version is in a remote tier), budget (stopped after progress; the next expiry continues), changed (the latest version a batch matched changed) and history_unavailable. They are traced with the reason and send no removal event. A partial purge keeps its audit record, now with the reason. A tier refusal is also logged, once per key and hour, in words. - A drive that cannot be read or decoded during the purge's own drive check now returns errDeleteAllHistoryUnavailable, which names the cause and stays a write quorum error (503) for API callers. - The scanner does not queue an expiry of all versions whose latest version is hot when the pool holds a version in a remote tier: the purge would refuse it each cycle. It counts each such observation as tier_unsupported_skipped, once per drive scanner and cycle. A tiered version only another pool holds is unknown here, so the purge still decides; a transitioned latest version keeps the transitioned expiry. The tier predicate is shared with the purge's own check, for FileInfo and ObjectInfo, and excludes free versions. - The scanner accounts every version of a key under an expiry of all versions at its size, queued or not, instead of as already removed. Usage, and so hard quota, drops after a fresh scan of the subtree once the purge succeeded. The alerts on many or large versions count the versions of a key that is not queued. - A lifecycle expiry never deletes a prefix: DeleteObject refuses DeletePrefix with Expiration.Expire as InvalidArgument. No caller sends it since expiries of all versions became purges; a force delete of a prefix is unchanged. Not done from the plan: queuing an MRF heal per version when a drive's xl.meta fails to decode. Object heal does not rewrite metadata that fails its CRC check (it reports the drive as unknown and skips it), so those heals would not repair the drive. Such a key stays refused until its metadata on that drive is repaired. Tests: TestPurgeAllVersionsRefusalReasons, TestPurgeAllVersionsRefusesCorruptDrive, TestScannerSkipsDeleteAllForTieredKey and TestPoolsDeleteObjectRejectsExpirePrefix are new. Tests that expected the scanner to queue a refused purge, the predictive accounting, or the old prefix options are converted. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Feng Ruohang <rh@vonng.com>
1427 lines
57 KiB
Go
1427 lines
57 KiB
Go
// 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 <http://www.gnu.org/licenses/>.
|
|
|
|
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 = `<Rule><ID>ncv</ID><Status>Enabled</Status><Filter><Prefix>ncv-</Prefix></Filter><NoncurrentVersionExpiration><NoncurrentDays>1</NoncurrentDays></NoncurrentVersionExpiration></Rule>`
|
|
|
|
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)
|
|
}
|
|
}
|