// 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 (
"bytes"
"context"
"crypto/rand"
"errors"
"fmt"
"maps"
"net/http"
"net/http/httptest"
"reflect"
"runtime"
"strings"
"sync/atomic"
"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/versioning"
xhttp "github.com/minio/minio/internal/http"
)
// restoreGenerationDisk fails metadata writes or updates of the object, or
// metadata reads of uploads, while its toggles are set.
type restoreGenerationDisk struct {
StorageAPI
bucket, object string
write, update, uploadRead *atomic.Bool
}
func (d restoreGenerationDisk) WriteMetadata(ctx context.Context, orig, vol, path string, fi FileInfo) error {
if d.write != nil && d.write.Load() && vol == d.bucket && path == d.object {
return errFaultyDisk
}
return d.StorageAPI.WriteMetadata(ctx, orig, vol, path, fi)
}
func (d restoreGenerationDisk) UpdateMetadata(ctx context.Context, vol, path string, fi FileInfo, opts UpdateMetadataOpts) error {
if d.update != nil && d.update.Load() && vol == d.bucket && path == d.object {
return errFaultyDisk
}
return d.StorageAPI.UpdateMetadata(ctx, vol, path, fi, opts)
}
func (d restoreGenerationDisk) ReadVersion(ctx context.Context, orig, vol, path, version string, opts ReadOptions) (FileInfo, error) {
if d.uploadRead != nil && d.uploadRead.Load() && vol == minioMetaMultipartBucket {
return FileInfo{}, errFaultyDisk
}
return d.StorageAPI.ReadVersion(ctx, orig, vol, path, version, opts)
}
// restoreGenerationOffline makes a drive's object metadata unreadable and
// unwritable while set, its uploads still readable.
type restoreGenerationOffline struct {
StorageAPI
bucket, object string
fail *atomic.Bool
}
func (d restoreGenerationOffline) ReadXL(ctx context.Context, bucket, object string, data bool) (RawFileInfo, error) {
if d.fail.Load() && bucket == d.bucket && object == d.object {
return RawFileInfo{}, errFaultyDisk
}
return d.StorageAPI.ReadXL(ctx, bucket, object, data)
}
func (d restoreGenerationOffline) ReadVersion(ctx context.Context, orig, bucket, object, version string, opts ReadOptions) (FileInfo, error) {
if d.fail.Load() && bucket == d.bucket && object == d.object {
return FileInfo{}, errFaultyDisk
}
return d.StorageAPI.ReadVersion(ctx, orig, bucket, object, version, opts)
}
func (d restoreGenerationOffline) WriteMetadata(ctx context.Context, orig, bucket, object string, fi FileInfo) error {
if d.fail.Load() && bucket == d.bucket && object == d.object {
return errFaultyDisk
}
return d.StorageAPI.WriteMetadata(ctx, orig, bucket, object, fi)
}
// restoreCaptureOptions returns a background restore that records its
// options instead of restoring. When the background restore returns, the POST
// handler releases the restore lock it handed over. A test that restores later
// with the recorded options would race that release and could run with a lock
// context canceled under it. So the lock is released here, before the options
// are recorded, and the later restore takes it again, as a queued worker whose
// lock ended does.
func restoreCaptureOptions(captured chan<- ObjectOptions) func(context.Context, string, string, ObjectOptions) error {
return func(_ context.Context, _, _ string, opts ObjectOptions) error {
opts.Transition.owner.release()
captured <- opts
return nil
}
}
// restoreGenerationMetadata returns the version's metadata on each drive.
func restoreGenerationMetadata(t *testing.T, disks []StorageAPI, r *restoreTest, versionID string) []map[string]string {
t.Helper()
metas := make([]map[string]string, len(disks))
for i, d := range disks {
fi, err := d.ReadVersion(t.Context(), "", r.bucket, r.object, versionID, ReadOptions{})
if err != nil {
t.Fatal(err)
}
metas[i] = fi.Metadata
}
return metas
}
// An Object Lock update acknowledged on a quorum, which one drive missed,
// survives POST restore: POST patches the restore fields onto the quorum's
// generation and never writes the stale drive's metadata anywhere.
func TestRestoreStatusUsesQuorumGeneration(t *testing.T) {
for _, kind := range []string{"legal-hold", "retention"} {
t.Run(kind, func(t *testing.T) {
r := newRestoreSafetyTest(t, 1, versioning.Enabled)
initConfigSubsystem(t.Context(), r.z)
enableBucketObjectLock(t, r.bucket)
oi, _ := nativeRestoreSeed(t, r, 0, false, false)
if err := r.z.RestoreTransitionedObject(t.Context(), r.bucket, r.object, nativeRestoreOptions(r, oi.VersionID)); err != nil {
t.Fatal(err)
}
initial := []byte(`OFF`)
updated := []byte(`ON`)
key := strings.ToLower(xhttp.AmzObjectLockLegalHold)
if kind == "retention" {
initial = retentionBody("COMPLIANCE", UTCNow().Add(24*time.Hour))
updated = retentionBody("COMPLIANCE", UTCNow().Add(48*time.Hour))
key = strings.ToLower(xhttp.AmzObjectLockRetainUntilDate)
}
if rec := nativeRestoreLockRequest(t.Context(), t, r, oi.VersionID, kind, initial); rec.Code != http.StatusOK {
t.Fatal(rec.Body.String())
}
er := r.z.serverPools[0].getHashedSet(r.object)
disks := er.getDisks()
var fail atomic.Bool
wrapped := append([]StorageAPI(nil), disks...)
wrapped[0] = restoreGenerationDisk{StorageAPI: disks[0], bucket: r.bucket, object: r.object, update: &fail}
er.getDisks = func() []StorageAPI { return wrapped }
fail.Store(true)
if rec := nativeRestoreLockRequest(t.Context(), t, r, oi.VersionID, kind, updated); rec.Code != http.StatusOK {
t.Fatalf("protection update: %d %s", rec.Code, rec.Body.String())
}
fail.Store(false)
before := restoreGenerationMetadata(t, disks, r, oi.VersionID)
acknowledged := before[1][key]
if before[0][key] == acknowledged {
t.Fatal("fixture: drive 0 did not miss the update")
}
for i := 1; i < len(disks); i++ {
if before[i][key] != acknowledged {
t.Fatal("fixture: the update did not reach the other drives")
}
}
if rec := restoreIsolationPOST(t, r, r.z, oi.VersionID); rec.Code != http.StatusOK {
t.Fatalf("POST: %d %s", rec.Code, rec.Body.String())
}
after := restoreGenerationMetadata(t, disks, r, oi.VersionID)
for i := 1; i < len(disks); i++ {
if after[i][key] != acknowledged {
t.Errorf("drive %d: POST wrote %q over the acknowledged %q", i, after[i][key], acknowledged)
}
if after[i][xhttp.AmzRestoreRequestDate] == before[i][xhttp.AmzRestoreRequestDate] {
t.Errorf("drive %d: POST did not record its request", i)
}
}
// The stale drive is left to heal; reads that pick it are not
// changed here.
if after[0][key] != before[0][key] || after[0][xhttp.AmzRestoreRequestDate] != before[0][xhttp.AmzRestoreRequestDate] {
t.Error("POST wrote the stale drive instead of leaving it to heal")
}
})
}
}
// The attempt is part of a version's metadata generation. A POST whose
// marker reached all drives but one is acknowledged: its restore owns the
// version, and the reset of an older attempt leaves the newer marker alone.
func TestRestoreAttemptGenerationFencing(t *testing.T) {
for _, mode := range []string{"worker", "old-reset", "degraded-old-reset"} {
t.Run(mode, func(t *testing.T) {
r := newRestoreSafetyTest(t, 1, versioning.Enabled)
initConfigSubsystem(t.Context(), r.z)
oi, body := restoreIsolationSeed(t, r, 0, false, false)
er := r.z.serverPools[0].getHashedSet(r.object)
disks := er.getDisks()
var fail, offline atomic.Bool
wrapped := append([]StorageAPI(nil), disks...)
wrapped[0] = restoreGenerationDisk{StorageAPI: disks[0], bucket: r.bucket, object: r.object, write: &fail}
wrapped[1] = restoreGenerationOffline{StorageAPI: disks[1], bucket: r.bucket, object: r.object, fail: &offline}
er.getDisks = func() []StorageAPI { return wrapped }
captured := make(chan ObjectOptions, 2)
layer := newRestoreIsolationLayer(r)
layer.restore = restoreCaptureOptions(captured)
var older ObjectOptions
if mode != "worker" {
if rec := restoreIsolationPOST(t, r, layer, oi.VersionID); rec.Code != http.StatusOK {
t.Fatal(rec.Body.String())
}
older = <-captured
layer.wait(t)
}
fail.Store(true)
rec := restoreIsolationPOST(t, r, layer, oi.VersionID)
fail.Store(false)
if rec.Code != http.StatusOK {
t.Fatalf("POST on all drives but one: %d %s", rec.Code, rec.Body.String())
}
newer := <-captured
layer.wait(t)
id := newer.Transition.attempt.ID
if mode == "worker" {
if err := r.z.RestoreTransitionedObject(t.Context(), r.bucket, r.object, newer); err != nil {
t.Fatalf("restore of the acknowledged attempt: %v", err)
}
if _, got, err := r.read(oi.VersionID); err != nil || !bytes.Equal(got, body) {
t.Fatalf("restored contents: %v", err)
}
return
}
if mode == "degraded-old-reset" {
// One drive of the newer attempt is unreadable: the drives
// that answer no longer show which attempt is acknowledged.
offline.Store(true)
}
if err := r.z.resetRestoreStatus(r.bucket, r.object, older); err != nil {
t.Fatal(err)
}
offline.Store(false)
after := restoreGenerationMetadata(t, disks, r, oi.VersionID)
for i := 1; i < len(disks); i++ {
if after[i][restoreAttemptKey] != id || !strings.Contains(after[i][xhttp.AmzRestore], "true") {
t.Errorf("drive %d: the older attempt's reset cleared the newer marker: %q %q", i, after[i][restoreAttemptKey], after[i][xhttp.AmzRestore])
}
}
if after[0][restoreAttemptKey] != older.Transition.attempt.ID {
t.Error("the reset wrote the stale drive")
}
// The newer attempt still owns the version and restores it.
if err := r.z.RestoreTransitionedObject(t.Context(), r.bucket, r.object, newer); err != nil {
t.Fatalf("restore of the newer attempt after the older reset: %v", err)
}
if _, got, err := r.read(oi.VersionID); err != nil || !bytes.Equal(got, body) {
t.Fatalf("restored contents: %v", err)
}
})
}
}
// POST restores the version it resolved with that version's identity. A
// replacement under the same version ID between its read and its status
// write is refused, and nothing restores it.
func TestRestorePOSTRejectsReplacedTarget(t *testing.T) {
for _, state := range []versioning.State{"", versioning.Suspended} {
t.Run(fmt.Sprintf("versioning=%q", state), func(t *testing.T) {
r := newRestoreSafetyTest(t, 1, state)
initConfigSubsystem(t.Context(), r.z)
oi, _ := restoreIsolationSeed(t, r, 0, true, false)
layer := newRestoreIsolationLayer(r)
var replacement ObjectInfo
layer.afterRead = func() {
data := []byte("replacement after POST resolved its target")
var err error
replacement, err = r.z.PutObject(t.Context(), r.bucket, r.object,
mustGetPutObjReader(t, bytes.NewReader(data), int64(len(data)), "", ""), ObjectOptions{VersionSuspended: state == versioning.Suspended})
if err != nil {
t.Fatal(err)
}
opts := r.opts(nullVersionID)
opts.MTime = replacement.ModTime
opts.Transition = TransitionOptions{Tier: "WARM", ETag: replacement.ETag}
if err := r.z.serverPools[0].TransitionObject(t.Context(), r.bucket, r.object, opts); err != nil {
t.Fatal(err)
}
}
if rec := restoreIsolationPOST(t, r, layer, ""); rec.Code < 400 {
layer.wait(t)
t.Errorf("POST accepted a replacement of its target: %d", rec.Code)
}
after, err := r.z.GetObjectInfo(t.Context(), r.bucket, r.object, r.opts(nullVersionID))
if err != nil {
t.Fatal(err)
}
if after.ETag != replacement.ETag || after.UserDefined[xhttp.AmzRestore] != "" || after.ETag == oi.ETag {
t.Errorf("replacement %q changed by POST: etag %q restore %q", replacement.ETag, after.ETag, after.UserDefined[xhttp.AmzRestore])
}
})
}
}
// A POST on a version whose restore holds the object lock answers within
// the two-second takeover bound: its first read does not wait for the lock.
func TestRestorePOSTOngoingBound(t *testing.T) {
for _, mode := range []string{"pools=1", "pools=2", "stale-minority", "failed-activity-read"} {
t.Run(mode, func(t *testing.T) {
pools := 1
if mode == "pools=2" {
pools = 2
}
r := newRestoreSafetyTest(t, pools, versioning.Enabled)
initConfigSubsystem(t.Context(), r.z)
oi, _ := restoreIsolationSeed(t, r, 0, false, false)
tier := newRestoreIsolationGateTier(0, nil)
globalTierConfigMgr.drivercache["WARM"] = tier
defer tier.unblock()
var fail atomic.Bool
if mode == "stale-minority" {
// The first read may pick a drive that missed the marker.
er := r.z.serverPools[0].getHashedSet(r.object)
disks := er.getDisks()
wrapped := append([]StorageAPI(nil), disks...)
wrapped[0] = restoreGenerationDisk{StorageAPI: disks[0], bucket: r.bucket, object: r.object, write: &fail}
er.getDisks = func() []StorageAPI { return wrapped }
fail.Store(true)
}
layer := newRestoreIsolationLayer(r)
rec := restoreIsolationPOST(t, r, layer, oi.VersionID)
fail.Store(false)
if rec.Code != http.StatusOK {
t.Fatal(rec.Body.String())
}
restoreIsolationEntered(t, tier)
var unreadable atomic.Bool
second := newRestoreIsolationLayer(r)
if mode == "failed-activity-read" {
// Once POST resolved its target, nothing tells whether it
// is restoring.
er := r.z.serverPools[0].getHashedSet(r.object)
disks := er.getDisks()
wrapped := make([]StorageAPI, len(disks))
for i, d := range disks {
wrapped[i] = restoreGenerationOffline{StorageAPI: d, bucket: r.bucket, object: r.object, fail: &unreadable}
}
er.getDisks = func() []StorageAPI { return wrapped }
second.afterRead = func() { unreadable.Store(true) }
}
done := make(chan *httptest.ResponseRecorder, 1)
start := time.Now()
go func() { done <- restoreIsolationPOST(t, r, second, oi.VersionID) }()
select {
case rec := <-done:
want := rec.Code == http.StatusConflict
if mode == "failed-activity-read" {
want = rec.Code >= 500
}
if !want || time.Since(start) > restoreTakeoverWait+time.Second {
t.Errorf("POST during a running restore: %d after %s, want a bounded refusal", rec.Code, time.Since(start))
}
case <-time.After(restoreTakeoverWait + 2*time.Second):
t.Error("POST during a running restore waited for the object lock")
unreadable.Store(false)
tier.unblock()
<-done
}
unreadable.Store(false)
tier.unblock()
if err := layer.wait(t); err != nil {
t.Fatal(err)
}
})
}
}
// A POST whose status write fails on drives that stay unwritten puts the
// generation it read back, and a split that a failed put-back leaves
// differs only in restore fields: the attempt's reset and a later POST both
// resolve it, while every other field still needs the quorum.
func TestRestorePOSTStatusWriteSplit(t *testing.T) {
for _, mode := range []string{"put-back", "split-reset", "split-post"} {
t.Run(mode, func(t *testing.T) {
r := newRestoreSafetyTest(t, 1, versioning.Enabled)
initConfigSubsystem(t.Context(), r.z)
oi, body := restoreIsolationSeed(t, r, 0, false, false)
er := r.z.serverPools[0].getHashedSet(r.object)
disks := er.getDisks()
var fail atomic.Bool
wrapped := append([]StorageAPI(nil), disks...)
for i := range 2 {
wrapped[i] = restoreGenerationDisk{StorageAPI: disks[i], bucket: r.bucket, object: r.object, write: &fail}
}
er.getDisks = func() []StorageAPI { return wrapped }
before := restoreGenerationMetadata(t, disks, r, oi.VersionID)
var attempt *restoreAttempt
if mode == "put-back" {
fail.Store(true)
rec := restoreIsolationPOST(t, r, r.z, oi.VersionID)
fail.Store(false)
if rec.Code < 400 {
t.Fatalf("POST acknowledged a status write on two of four drives: %d", rec.Code)
}
if after := restoreGenerationMetadata(t, disks, r, oi.VersionID); !reflect.DeepEqual(before, after) {
t.Error("failed status write was not put back")
}
} else {
// A status write and its put-back both failed on two drives.
attempt = newRestoreAttempt(oi)
for _, d := range disks[2:] {
fi, err := d.ReadVersion(t.Context(), "", r.bucket, r.object, oi.VersionID, ReadOptions{ReadData: true})
if err != nil {
t.Fatal(err)
}
fi.Metadata[xhttp.AmzRestore] = ongoingRestoreObj().String()
fi.Metadata[restoreAttemptKey] = attempt.ID
if err := d.WriteMetadata(t.Context(), "", r.bucket, r.object, fi); err != nil {
t.Fatal(err)
}
}
if _, err := er.readRestoreGeneration(t.Context(), r.bucket, r.object, oi.VersionID); err != nil {
t.Fatalf("a split in restore fields only has no generation: %v", err)
}
}
if mode == "split-reset" {
opts := nativeRestoreOptions(r, oi.VersionID)
opts.Transition.attempt = attempt
if err := r.z.resetRestoreStatus(r.bucket, r.object, opts); err != nil {
t.Fatalf("reset of the split attempt: %v", err)
}
for i, meta := range restoreGenerationMetadata(t, disks, r, oi.VersionID) {
if meta[xhttp.AmzRestore] != "" || meta[restoreAttemptKey] != "" {
t.Errorf("drive %d keeps the split attempt: %q %q", i, meta[xhttp.AmzRestore], meta[restoreAttemptKey])
}
}
}
layer := newRestoreIsolationLayer(r)
if rec := restoreIsolationPOST(t, r, layer, oi.VersionID); rec.Code != http.StatusOK {
t.Fatalf("POST after the failed status write: %d %s", rec.Code, rec.Body.String())
}
if err := layer.wait(t); err != nil {
t.Fatal(err)
}
if _, got, err := r.read(oi.VersionID); err != nil || !bytes.Equal(got, body) {
t.Fatalf("restore contents: %v", err)
}
})
}
}
// A split in any other field is not resolved: the generation fails closed.
func TestRestoreGenerationSplitNeedsQuorum(t *testing.T) {
r := newRestoreSafetyTest(t, 1, versioning.Enabled)
oi, _ := restoreIsolationSeed(t, r, 0, false, false)
er := r.z.serverPools[0].getHashedSet(r.object)
for i, d := range er.getDisks()[2:] {
fi, err := d.ReadVersion(t.Context(), "", r.bucket, r.object, oi.VersionID, ReadOptions{ReadData: true})
if err != nil {
t.Fatal(err)
}
fi.Metadata[xhttp.AmzRestore] = ongoingRestoreObj().String()
fi.Metadata[strings.ToLower(xhttp.AmzObjectLockLegalHold)] = fmt.Sprint("ON", i)
if err := d.WriteMetadata(t.Context(), "", r.bucket, r.object, fi); err != nil {
t.Fatal(err)
}
}
if _, err := er.readRestoreGeneration(t.Context(), r.bucket, r.object, oi.VersionID); !errors.Is(err, errErasureReadQuorum) {
t.Fatalf("split outside the restore fields: %v, want read quorum", err)
}
if rec := restoreIsolationPOST(t, r, r.z, oi.VersionID); rec.Code < 400 {
t.Errorf("POST over a split outside the restore fields: %d", rec.Code)
}
}
// restoreGenerationCleanupDisk misses restored-copy cleanups and metadata
// writes of the object while its toggles are set.
type restoreGenerationCleanupDisk struct {
StorageAPI
bucket, object string
cleanup, metaWrites *atomic.Bool
}
func (d restoreGenerationCleanupDisk) DeleteVersion(ctx context.Context, bucket, object string, fi FileInfo, force bool, opts DeleteOptions) error {
if bucket == d.bucket && object == d.object && fi.ExpireRestored && d.cleanup.Load() {
return errFaultyDisk
}
return d.StorageAPI.DeleteVersion(ctx, bucket, object, fi, force, opts)
}
func (d restoreGenerationCleanupDisk) WriteMetadata(ctx context.Context, orig, bucket, object string, fi FileInfo) error {
if bucket == d.bucket && object == d.object && d.metaWrites.Load() {
return errFaultyDisk
}
return d.StorageAPI.WriteMetadata(ctx, orig, bucket, object, fi)
}
// Restored-copy expiry decides on the version's authoritative generation. A
// drive that missed a cleanup and the POST after it keeps an expired header,
// which must not clear the quorum's ongoing restore; drives split between
// an expired and a renewed header authorize nothing.
func TestExpireRestoredUsesQuorumGeneration(t *testing.T) {
for _, pools := range []int{1, 2} {
for _, mode := range []string{"after-partial-cleanup", "split-renewal"} {
t.Run(fmt.Sprintf("pools=%d/%s", pools, mode), func(t *testing.T) {
r := newRestoreSafetyTest(t, pools, versioning.Enabled)
initConfigSubsystem(t.Context(), r.z)
oi := r.put("cache", false, true)
r.restore(oi)
stale, err := r.z.GetObjectInfo(t.Context(), r.bucket, r.object, r.opts(oi.VersionID))
if err != nil {
t.Fatal(err)
}
er := r.z.serverPools[0].getHashedSet(r.object)
disks := er.getDisks()
expire := func() error {
return expireTransitionedObject(t.Context(), r.z, &stale, lifecycle.Event{Action: lifecycle.DeleteRestoredAction}, lcEventSrc_Scanner)
}
if mode == "split-renewal" {
// A renewal reached two drives; the other two keep the
// expired header.
dataDir := r.fileInfo(oi.VersionID).DataDir
for _, d := range disks[2:] {
fi, err := d.ReadVersion(t.Context(), "", r.bucket, r.object, oi.VersionID, ReadOptions{ReadData: true})
if err != nil {
t.Fatal(err)
}
fi.Metadata[xhttp.AmzRestore] = completedRestoreObj(UTCNow().Add(48 * time.Hour)).String()
if err := d.WriteMetadata(t.Context(), "", r.bucket, r.object, fi); err != nil {
t.Fatal(err)
}
}
if err := expire(); err == nil || r.dataDirs(dataDir) != len(disks) {
t.Errorf("expiry on drives split between expired and renewed: %v, %d/%d cache dirs", err, r.dataDirs(dataDir), len(disks))
}
return
}
var cleanup, metaWrites atomic.Bool
wrapped := append([]StorageAPI(nil), disks...)
wrapped[0] = restoreGenerationCleanupDisk{StorageAPI: disks[0], bucket: r.bucket, object: r.object, cleanup: &cleanup, metaWrites: &metaWrites}
er.getDisks = func() []StorageAPI { return wrapped }
cleanup.Store(true)
err = expire()
cleanup.Store(false)
if err != nil {
t.Fatalf("cleanup on all drives but one: %v", err)
}
captured := make(chan ObjectOptions, 1)
layer := newRestoreIsolationLayer(r)
layer.restore = restoreCaptureOptions(captured)
metaWrites.Store(true)
rec := restoreIsolationPOST(t, r, layer, oi.VersionID)
metaWrites.Store(false)
if rec.Code != http.StatusOK {
t.Fatalf("POST after the cleanup: %d %s", rec.Code, rec.Body.String())
}
opts := <-captured
layer.wait(t)
// The same cleanup, queued twice, runs again.
if err := expire(); err == nil {
t.Error("a drive's expired header cleared the quorum's ongoing restore")
}
if err := r.z.RestoreTransitionedObject(t.Context(), r.bucket, r.object, opts); err != nil {
t.Fatalf("restore of the acknowledged attempt: %v", err)
}
if _, got, err := r.read(oi.VersionID); err != nil || !bytes.Equal(got, r.bodies["cache"]) {
t.Fatalf("restored contents: %v", err)
}
})
}
}
}
// restoreGenerationHidden hides a drive's copy of the object version while
// set, as a drive that lost it and that heal writes again.
type restoreGenerationHidden struct {
StorageAPI
bucket, object string
hidden *atomic.Bool
}
func (d restoreGenerationHidden) ReadVersion(ctx context.Context, orig, bucket, object, version string, opts ReadOptions) (FileInfo, error) {
if d.hidden.Load() && bucket == d.bucket && object == d.object {
return FileInfo{}, errFileNotFound
}
return d.StorageAPI.ReadVersion(ctx, orig, bucket, object, version, opts)
}
// Heal copies the restore fields to a drive only as a quorum of the drives
// holds them. While they disagree, healing the version waits: it must not
// create a quorum for an expired header over a renewal, nor for a
// superseded attempt. Fields a quorum agrees on are copied.
func TestRestoreHealNeedsRestoreQuorum(t *testing.T) {
for _, mode := range []string{"expired-over-renewal", "superseded-attempt", "agreed"} {
t.Run(mode, func(t *testing.T) {
r := newRestoreSafetyTest(t, 1, versioning.Enabled)
initConfigSubsystem(t.Context(), r.z)
er := r.z.serverPools[0].getHashedSet(r.object)
disks := er.getDisks()
setRestore := func(versionID string, drives []StorageAPI, fields map[string]string) {
for _, d := range drives {
fi, err := d.ReadVersion(t.Context(), "", r.bucket, r.object, versionID, ReadOptions{ReadData: true})
if err != nil {
t.Fatal(err)
}
maps.Copy(fi.Metadata, fields)
if err := d.WriteMetadata(t.Context(), "", r.bucket, r.object, fi); err != nil {
t.Fatal(err)
}
}
}
var versionID string
var stale ObjectInfo
var older ObjectOptions
hide := 3
switch mode {
case "expired-over-renewal", "agreed":
oi := r.put("cache", false, true)
r.restore(oi)
versionID = oi.VersionID
var err error
if stale, err = r.z.GetObjectInfo(t.Context(), r.bucket, r.object, r.opts(versionID)); err != nil {
t.Fatal(err)
}
renewed := map[string]string{xhttp.AmzRestore: completedRestoreObj(UTCNow().Add(48 * time.Hour)).String()}
if mode == "agreed" {
// Three drives renewed, the hidden one expired.
setRestore(versionID, disks[:3], renewed)
} else {
// No quorum: two drives expired an hour ago, one renewed,
// and the hidden one expired a day ago.
setRestore(versionID, disks[:2], map[string]string{xhttp.AmzRestore: completedRestoreObj(UTCNow().Add(-time.Hour)).String()})
setRestore(versionID, disks[2:3], renewed)
}
case "superseded-attempt":
oi, _ := restoreIsolationSeed(t, r, 0, false, false)
versionID = oi.VersionID
captured := make(chan ObjectOptions, 1)
layer := newRestoreIsolationLayer(r)
layer.restore = restoreCaptureOptions(captured)
if rec := restoreIsolationPOST(t, r, layer, versionID); rec.Code != http.StatusOK {
t.Fatal(rec.Body.String())
}
older = <-captured
layer.wait(t)
// A newer attempt reached two drives.
setRestore(versionID, disks[2:], map[string]string{xhttp.AmzRestore: ongoingRestoreObj().String(), restoreAttemptKey: newRestoreAttempt(oi).ID})
hide = 2
}
before := restoreGenerationMetadata(t, disks, r, versionID)
var hidden atomic.Bool
wrapped := append([]StorageAPI(nil), disks...)
wrapped[hide] = restoreGenerationHidden{StorageAPI: disks[hide], bucket: r.bucket, object: r.object, hidden: &hidden}
er.getDisks = func() []StorageAPI { return wrapped }
hidden.Store(true)
_, healErr := er.HealObject(t.Context(), r.bucket, r.object, versionID, madmin.HealOpts{ScanMode: madmin.HealNormalScan})
hidden.Store(false)
er.getDisks = func() []StorageAPI { return disks }
after := restoreGenerationMetadata(t, disks, r, versionID)
if mode == "agreed" {
if healErr != nil || after[hide][xhttp.AmzRestore] != after[0][xhttp.AmzRestore] {
t.Errorf("heal did not copy the restore fields a quorum holds: %v, %q", healErr, after[hide][xhttp.AmzRestore])
}
return
}
if healErr == nil {
t.Error("heal proceeded on drives that disagree on the restore fields")
}
if after[hide][xhttp.AmzRestore] != before[hide][xhttp.AmzRestore] || after[hide][restoreAttemptKey] != before[hide][restoreAttemptKey] {
t.Errorf("heal wrote a minority restore state: %q -> %q", before[hide][xhttp.AmzRestore], after[hide][xhttp.AmzRestore])
}
if mode == "expired-over-renewal" {
dataDir := r.fileInfo(versionID).DataDir
if err := expireTransitionedObject(t.Context(), r.z, &stale, lifecycle.Event{Action: lifecycle.DeleteRestoredAction}, lcEventSrc_Scanner); err == nil || r.dataDirs(dataDir) != len(disks) {
t.Errorf("expiry after heal: %v, %d/%d cache dirs", err, r.dataDirs(dataDir), len(disks))
}
return
}
if err := r.z.RestoreTransitionedObject(t.Context(), r.bucket, r.object, older); !errors.Is(err, errRestoreSuperseded) {
t.Errorf("superseded attempt after heal: %v", err)
}
})
}
}
// restoreGenerationRand draws UUIDs from crypto/rand for the whole test
// binary, installed before any goroutine runs. While a pause is set, it holds
// the UUID that stale cleanup draws in its deadline-bound worker, for the
// trash path right before its rename, until the pause is released.
var restoreGenerationRand = &restoreGenerationRandReader{}
func init() { uuid.SetRand(restoreGenerationRand) }
type restoreGenerationRandReader struct {
pause atomic.Pointer[restoreGenerationPause]
}
type restoreGenerationPause struct {
entered, release chan struct{}
}
func (r *restoreGenerationRandReader) Read(b []byte) (int, error) {
if p := r.pause.Load(); p != nil && restoreGenerationInCleanupRename() && r.pause.CompareAndSwap(p, nil) {
close(p.entered)
<-p.release
}
return rand.Read(b)
}
// restoreGenerationInCleanupRename reports whether the caller runs in stale
// cleanup's deadline-bound worker.
func restoreGenerationInCleanupRename() bool {
pcs := make([]uintptr, 32)
frames := runtime.CallersFrames(pcs[:runtime.Callers(3, pcs)])
cleanup, worker := false, false
for {
f, more := frames.Next()
cleanup = cleanup || strings.Contains(f.Function, "cleanupStaleUploadsOnDisk")
worker = worker || strings.Contains(f.Function, "ioutil.WithDeadline")
if !more {
return cleanup && worker
}
}
}