// 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"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"reflect"
"runtime"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/minio/minio/internal/bucket/lifecycle"
"github.com/minio/minio/internal/bucket/versioning"
xhttp "github.com/minio/minio/internal/http"
"github.com/minio/mux"
)
// The private attempt metadata of a native restore, as stored. A literal name
// keeps these regressions meaningful on a tree without the key.
const restoreIsolationAttemptKey = "x-minio-internal-restore-attempt"
// restoreIsolationLayer hooks the object layer a POST restore handler uses.
// afterRead runs once POST has read the version, before POST records its
// status. restore, when set, replaces the background restore; every
// background restore result is sent to done.
type restoreIsolationLayer struct {
ObjectLayer
afterRead func()
once sync.Once
restore func(ctx context.Context, bucket, object string, opts ObjectOptions) error
done chan error
}
func newRestoreIsolationLayer(r *restoreTest) *restoreIsolationLayer {
return &restoreIsolationLayer{ObjectLayer: r.z, done: make(chan error, 4)}
}
func (l *restoreIsolationLayer) GetObjectInfo(ctx context.Context, bucket, object string, opts ObjectOptions) (ObjectInfo, error) {
oi, err := l.ObjectLayer.GetObjectInfo(ctx, bucket, object, opts)
if err == nil && l.afterRead != nil {
l.once.Do(l.afterRead)
}
return oi, err
}
func (l *restoreIsolationLayer) RestoreTransitionedObject(ctx context.Context, bucket, object string, opts ObjectOptions) error {
var err error
if l.restore != nil {
err = l.restore(ctx, bucket, object, opts)
} else {
err = l.ObjectLayer.RestoreTransitionedObject(ctx, bucket, object, opts)
}
l.done <- err
return err
}
// restoreTestWait bounds how long a restore test waits for a step that does
// real drive work: metadata writes with synchronous persistence and renames
// on every drive. It is a liveness bound, not a latency contract; a loaded
// machine has stalled such a step past ten seconds.
const restoreTestWait = time.Minute
// wait returns the result of the next background restore.
func (l *restoreIsolationLayer) wait(t *testing.T) error {
t.Helper()
select {
case err := <-l.done:
return err
case <-time.After(restoreTestWait):
buf := make([]byte, 1<<20)
t.Fatalf("background restore did not finish in %v; goroutines:\n%s", restoreTestWait, buf[:runtime.Stack(buf, true)])
return nil
}
}
func restoreIsolationPOST(t *testing.T, r *restoreTest, layer ObjectLayer, versionID string) *httptest.ResponseRecorder {
t.Helper()
body := []byte(`1`)
path := "/" + r.bucket + "/" + r.object + "?restore"
if versionID != "" {
path += "&versionId=" + url.QueryEscape(versionID)
}
req, err := newTestSignedRequestV4(http.MethodPost, path, int64(len(body)), bytes.NewReader(body), globalActiveCred.AccessKey, globalActiveCred.SecretKey, nil)
if err != nil {
t.Fatal(err)
}
if err := req.ParseForm(); err != nil {
t.Fatal(err)
}
req = mux.SetURLVars(req, map[string]string{"bucket": r.bucket, "object": r.object})
rec := httptest.NewRecorder()
api := objectAPIHandlers{ObjectAPI: func() ObjectLayer { return layer }}
api.PostRestoreObjectHandler(rec, req)
return rec
}
// restoreIsolationSeed is a transitioned version without the ongoing marker
// that nativeRestoreSeed leaves for direct restores: POST sets its own.
func restoreIsolationSeed(t *testing.T, r *restoreTest, pool int, null, multipart bool) (ObjectInfo, []byte) {
t.Helper()
oi, body := nativeRestoreSeed(t, r, pool, null, multipart)
opts := r.opts(oi.VersionID)
opts.MTime = oi.ModTime
opts.EvalMetadataFn = func(o *ObjectInfo, _ error) (ReplicateDecision, error) {
o.UserDefined[xhttp.AmzRestore] = ""
return ReplicateDecision{}, nil
}
if _, err := r.z.PutObjectMetadata(t.Context(), r.bucket, r.object, opts); err != nil {
t.Fatal(err)
}
return oi, body
}
// restoreIsolationSettled waits until the version is no longer ongoing.
func restoreIsolationSettled(t *testing.T, r *restoreTest, versionID string) ObjectInfo {
t.Helper()
deadline := time.Now().Add(10 * time.Second)
for {
oi, err := r.z.GetObjectInfo(t.Context(), r.bucket, r.object, r.opts(versionID))
if err == nil && !oi.RestoreOngoing {
return oi
}
if time.Now().After(deadline) {
t.Fatalf("version stays ongoing, refusing every later POST: %q, %v", oi.UserDefined[xhttp.AmzRestore], err)
}
time.Sleep(20 * time.Millisecond)
}
}
// restoreIsolationUploads returns the upload directories of the object on
// each drive of a pool, as stored, whatever listings show.
func restoreIsolationUploads(t *testing.T, r *restoreTest, pool int) map[string]int {
t.Helper()
er := r.z.serverPools[pool].getHashedSet(r.object)
found := make(map[string]int)
for _, disk := range er.getDisks() {
entries, err := disk.ListDir(t.Context(), r.bucket, minioMetaMultipartBucket, er.getMultipartSHADir(r.bucket, r.object), -1)
if errors.Is(err, errFileNotFound) || errors.Is(err, errVolumeNotFound) {
continue
}
if err != nil {
t.Fatal(err)
}
for _, entry := range entries {
found[strings.TrimSuffix(entry, SlashSeparator)]++
}
}
return found
}
// restoreIsolationGateTier holds a download after its first bytes and can
// fail it once released.
type restoreIsolationGateTier struct {
WarmBackend
first int
entered chan struct{}
release chan struct{}
failure error
enter sync.Once
open sync.Once
}
func newRestoreIsolationGateTier(first int, failure error) *restoreIsolationGateTier {
return &restoreIsolationGateTier{
WarmBackend: globalTierConfigMgr.drivercache["WARM"],
first: first, failure: failure,
entered: make(chan struct{}), release: make(chan struct{}),
}
}
func (b *restoreIsolationGateTier) unblock() { b.open.Do(func() { close(b.release) }) }
func (b *restoreIsolationGateTier) Get(ctx context.Context, object string, version remoteVersionID, opts WarmBackendGetOpts) (io.ReadCloser, error) {
rd, err := b.WarmBackend.Get(ctx, object, version, opts)
if err != nil {
return nil, err
}
return &restoreIsolationGateReader{ReadCloser: rd, tier: b, ctx: ctx, remaining: b.first}, nil
}
type restoreIsolationGateReader struct {
io.ReadCloser
tier *restoreIsolationGateTier
ctx context.Context
remaining int
}
func (r *restoreIsolationGateReader) Read(p []byte) (int, error) {
if r.remaining > 0 {
if len(p) > r.remaining {
p = p[:r.remaining]
}
n, err := r.ReadCloser.Read(p)
r.remaining -= n
return n, err
}
r.tier.enter.Do(func() { close(r.tier.entered) })
select {
case <-r.tier.release:
case <-r.ctx.Done():
return 0, r.ctx.Err()
}
if r.tier.failure != nil {
return 0, r.tier.failure
}
return r.ReadCloser.Read(p)
}
func restoreIsolationEntered(t *testing.T, tier *restoreIsolationGateTier) {
t.Helper()
select {
case <-tier.entered:
case <-time.After(restoreTestWait):
t.Fatal("restore never reached its download gate")
}
}
// restoreIsolationFaultDisk fails metadata reads of the object while
// failReads is set, and reports writes of its metadata as failed, after
// they were made, while failWrites is set. Toggles keep the wrapper in
// place, so no restore retry races a swap of the drives.
type restoreIsolationFaultDisk struct {
StorageAPI
bucket, object string
failReads *atomic.Bool
failWrites *atomic.Bool
}
func (d restoreIsolationFaultDisk) ReadVersion(ctx context.Context, origvolume, volume, path, version string, opts ReadOptions) (FileInfo, error) {
if d.failReads.Load() && volume == d.bucket && path == d.object {
return FileInfo{}, errDiskNotFound
}
return d.StorageAPI.ReadVersion(ctx, origvolume, volume, path, version, opts)
}
func (d restoreIsolationFaultDisk) ReadXL(ctx context.Context, volume, path string, readData bool) (RawFileInfo, error) {
if d.failReads.Load() && volume == d.bucket && path == d.object {
return RawFileInfo{}, errDiskNotFound
}
return d.StorageAPI.ReadXL(ctx, volume, path, readData)
}
func (d restoreIsolationFaultDisk) WriteMetadata(ctx context.Context, origvolume, volume, path string, fi FileInfo) error {
err := d.StorageAPI.WriteMetadata(ctx, origvolume, volume, path, fi)
if err == nil && d.failWrites != nil && d.failWrites.Load() && volume == d.bucket && path == d.object {
return errFaultyDisk
}
return err
}
// installRestoreIsolationFaults wraps the drives of the object's set in a
// pool for the rest of the test; failing marks the drives whose writes fail.
func installRestoreIsolationFaults(r *restoreTest, pool int, failing int) (reads, writes *atomic.Bool) {
reads, writes = new(atomic.Bool), new(atomic.Bool)
er := r.z.serverPools[pool].getHashedSet(r.object)
disks := er.getDisks()
wrapped := make([]StorageAPI, len(disks))
for i, disk := range disks {
d := restoreIsolationFaultDisk{StorageAPI: disk, bucket: r.bucket, object: r.object, failReads: reads}
if i < failing {
d.failWrites = writes
}
wrapped[i] = d
}
er.getDisks = func() []StorageAPI { return wrapped }
return reads, writes
}
// POST records its status on a fresh read under the object lock, so an
// Object Lock update it did not see survives.
func TestRestorePOSTKeepsConcurrentProtection(t *testing.T) {
for _, pools := range []int{1, 2} {
for _, kind := range []string{"legal-hold", "retention"} {
t.Run(fmt.Sprintf("pools=%d/%s", pools, kind), func(t *testing.T) {
r := newRestoreSafetyTest(t, pools, 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.Fatalf("initial protection: %d %s", rec.Code, rec.Body.String())
}
var accepted ObjectInfo
layer := newRestoreIsolationLayer(r)
layer.afterRead = func() {
if rec := nativeRestoreLockRequest(t.Context(), t, r, oi.VersionID, kind, updated); rec.Code != http.StatusOK {
t.Fatalf("update: %d %s", rec.Code, rec.Body.String())
}
var err error
if accepted, err = r.z.GetObjectInfo(t.Context(), r.bucket, r.object, r.opts(oi.VersionID)); err != nil {
t.Fatal(err)
}
}
if rec := restoreIsolationPOST(t, r, layer, oi.VersionID); rec.Code >= 400 {
t.Fatalf("POST: %d %s", rec.Code, rec.Body.String())
}
after, err := r.z.GetObjectInfo(t.Context(), r.bucket, r.object, r.opts(oi.VersionID))
if err != nil {
t.Fatal(err)
}
if accepted.UserDefined[key] == "" || after.UserDefined[key] != accepted.UserDefined[key] {
t.Errorf("POST restore overwrote acknowledged Object Lock metadata: %q -> %q", accepted.UserDefined[key], after.UserDefined[key])
}
if !after.ModTime.Equal(oi.ModTime) || after.ETag != oi.ETag {
t.Error("POST restore changed the version identity")
}
})
}
}
}
// A restored copy removed between POST's read and its status write leads to
// a new restore, never to a completed header without the restored data.
func TestRestorePOSTRacesCacheExpiry(t *testing.T) {
for _, pools := range []int{1, 2} {
t.Run(fmt.Sprintf("pools=%d", pools), func(t *testing.T) {
r := newRestoreSafetyTest(t, pools, versioning.Enabled)
initConfigSubsystem(t.Context(), r.z)
oi := r.put("restored", false, true)
r.restore(oi)
layer := newRestoreIsolationLayer(r)
layer.afterRead = func() {
if err := expireTransitionedObject(t.Context(), r.z, &oi, lifecycle.Event{Action: lifecycle.DeleteRestoredAction}, lcEventSrc_Scanner); err != nil {
t.Fatal(err)
}
}
if rec := restoreIsolationPOST(t, r, layer, oi.VersionID); rec.Code >= 400 {
t.Fatalf("POST: %d %s", rec.Code, rec.Body.String())
}
select {
case err := <-layer.done:
if err != nil {
t.Fatalf("rehydration: %v", err)
}
case <-time.After(5 * time.Second):
t.Error("POST renewed a removed restored copy instead of restoring it")
}
_, body, err := r.read(oi.VersionID)
if err != nil || !bytes.Equal(body, r.bodies["restored"]) {
t.Errorf("version unreadable after POST raced cache expiry: %v", err)
}
})
}
}
// A restore queued with the versioning POST captured writes back the null
// version it resolved, even after versioning was enabled in between.
func TestRestoreKeepsCapturedNullIdentity(t *testing.T) {
enable := func(t *testing.T, r *restoreTest) {
meta, err := globalBucketMetadataSys.Get(r.bucket)
if err != nil {
t.Fatal(err)
}
meta.VersioningConfigXML = []byte(`Enabled`)
if err := meta.Save(t.Context(), r.z); err != nil {
t.Fatal(err)
}
globalBucketMetadataSys.Set(r.bucket, meta)
}
for _, mode := range []string{"direct", "post"} {
t.Run(mode, func(t *testing.T) {
r := newRestoreSafetyTest(t, 1, "")
initConfigSubsystem(t.Context(), r.z)
var oi ObjectInfo
var body []byte
if mode == "direct" {
oi, body = nativeRestoreSeed(t, r, 0, true, false)
// These are the options that POST captured while versioning was off.
queued := nativeRestoreOptions(r, "")
enable(t, r)
if err := r.z.RestoreTransitionedObject(t.Context(), r.bucket, r.object, queued); err != nil {
t.Fatal(err)
}
} else {
oi, body = restoreIsolationSeed(t, r, 0, true, false)
release := make(chan struct{})
layer := newRestoreIsolationLayer(r)
layer.restore = func(ctx context.Context, bucket, object string, opts ObjectOptions) error {
<-release
return r.z.RestoreTransitionedObject(ctx, bucket, object, opts)
}
if rec := restoreIsolationPOST(t, r, layer, ""); rec.Code != http.StatusOK {
t.Fatalf("POST: %d %s", rec.Code, rec.Body.String())
}
enable(t, r)
close(release)
if err := layer.wait(t); err != nil {
t.Fatal(err)
}
}
versions := nativeRestoreSnapshot(t, r)[0]
if len(versions) != 1 || versions[0].VersionID != "" {
t.Errorf("restore created a UUID alias of the queued null version: %d versions", len(versions))
}
_, got, err := r.read(oi.VersionID)
if err != nil || !bytes.Equal(got, body) {
t.Fatalf("null original: %v", err)
}
})
}
}
// A restore that fails before it reads the version, on its lock wait or on
// its first read, must not leave POST's ongoing marker for every later POST
// to refuse: bounded retries clear it once the version is readable again.
func TestRestoreRecoversStrandedMarker(t *testing.T) {
for _, pools := range []int{1, 2} {
for _, stage := range []string{"lock-wait", "initial-read"} {
t.Run(fmt.Sprintf("pools=%d/%s", pools, stage), func(t *testing.T) {
r := newRestoreSafetyTest(t, pools, versioning.Enabled)
oi, _ := nativeRestoreSeed(t, r, 0, false, false)
var err error
if stage == "lock-wait" {
lk := r.z.NewNSLock(r.bucket, r.object)
if pools == 1 {
lk = r.z.serverPools[0].getHashedSet(r.object).NewNSLock(r.bucket, r.object)
}
lkctx, lerr := lk.GetLock(t.Context(), globalOperationTimeout)
if lerr != nil {
t.Fatal(lerr)
}
ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond)
err = r.z.RestoreTransitionedObject(ctx, r.bucket, r.object, nativeRestoreOptions(r, oi.VersionID))
cancel()
lk.Unlock(lkctx)
} else {
reads, _ := installRestoreIsolationFaults(r, 0, 0)
reads.Store(true)
err = r.z.RestoreTransitionedObject(t.Context(), r.bucket, r.object, nativeRestoreOptions(r, oi.VersionID))
reads.Store(false)
}
if err == nil {
t.Fatal("restore succeeded without its lock or its version")
}
after := restoreIsolationSettled(t, r, oi.VersionID)
if !after.IsRemote() || !after.ModTime.Equal(oi.ModTime) {
t.Error("recovery changed more than the restore status")
}
})
}
}
}
// A later POST takes over a marker that no running restore holds, and the
// superseded attempt then neither downloads nor clears the newer status,
// also when both POSTs share a second, the precision of the request date.
func TestRestoreSupersededAttempt(t *testing.T) {
for _, mode := range []string{"newer-completes", "newer-fails"} {
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)
tier := globalTierConfigMgr.drivercache["WARM"].(*restoreTestTier)
release := make(chan struct{})
var releaseOnce sync.Once
unblock := func() { releaseOnce.Do(func() { close(release) }) }
t.Cleanup(unblock)
older := newRestoreIsolationLayer(r)
older.restore = func(ctx context.Context, bucket, object string, opts ObjectOptions) error {
// The queued worker loses the restore lock POST handed it,
// as when its refresh found it gone: the marker then belongs
// to no running restore.
opts.Transition.owner.release()
<-release
return r.z.RestoreTransitionedObject(ctx, bucket, object, opts)
}
newer := newRestoreIsolationLayer(r)
time.Sleep(time.Until(time.Now().Truncate(time.Second).Add(time.Second + 10*time.Millisecond)))
if rec := restoreIsolationPOST(t, r, older, oi.VersionID); rec.Code != http.StatusOK {
t.Fatalf("first POST: %d %s", rec.Code, rec.Body.String())
}
first := r.fileInfo(oi.VersionID).Metadata[xhttp.AmzRestoreRequestDate]
if mode == "newer-fails" {
globalTierConfigMgr.drivercache["WARM"] = nativeRestoreFailTier{tier, errors.New("injected remote failure")}
}
if rec := restoreIsolationPOST(t, r, newer, oi.VersionID); rec.Code != http.StatusOK {
t.Fatalf("POST over a marker no restore holds: %d %s", rec.Code, rec.Body.String())
}
second := r.fileInfo(oi.VersionID).Metadata[xhttp.AmzRestoreRequestDate]
t.Logf("request dates %q %q, same second: %v", first, second, first == second)
newerErr := newer.wait(t)
globalTierConfigMgr.drivercache["WARM"] = tier
before := r.fileInfo(oi.VersionID)
gets := tier.gets.Load()
unblock()
if err := older.wait(t); err == nil {
t.Error("superseded restore reported success")
}
if tier.gets.Load() != gets {
t.Error("superseded restore downloaded the version again")
}
after := r.fileInfo(oi.VersionID)
if !reflect.DeepEqual(before.Metadata, after.Metadata) || before.DataDir != after.DataDir {
t.Errorf("superseded restore changed the version: %q -> %q", before.Metadata[xhttp.AmzRestore], after.Metadata[xhttp.AmzRestore])
}
info, err := r.z.GetObjectInfo(t.Context(), r.bucket, r.object, r.opts(oi.VersionID))
if err != nil || info.RestoreOngoing {
t.Fatalf("version left ongoing: %q, %v", info.UserDefined[xhttp.AmzRestore], err)
}
if mode == "newer-completes" {
if newerErr != nil || info.IsRemote() {
t.Fatalf("newer restore: %v remote=%v", newerErr, info.IsRemote())
}
if _, got, err := r.read(oi.VersionID); err != nil || !bytes.Equal(got, body) || tier.gets.Load() != gets {
t.Errorf("restored contents: %v", err)
}
return
}
if newerErr == nil || !info.IsRemote() || info.UserDefined[xhttp.AmzRestore] != "" {
t.Fatalf("failed newer restore is not retryable: %v %q", newerErr, info.UserDefined[xhttp.AmzRestore])
}
if rec := restoreIsolationPOST(t, r, newer, oi.VersionID); rec.Code != http.StatusOK {
t.Fatalf("retry POST: %d %s", rec.Code, rec.Body.String())
}
if err := newer.wait(t); err != nil {
t.Fatal(err)
}
if _, got, err := r.read(oi.VersionID); err != nil || !bytes.Equal(got, body) {
t.Errorf("retried restore contents: %v", err)
}
})
}
}
// A restart loses the restore that POST started; a POST to the restarted
// server takes the stranded marker over and restores the version.
func TestRestoreRestartTakeover(t *testing.T) {
r := newRestoreSafetyTest(t, 1, versioning.Enabled)
initConfigSubsystem(t.Context(), r.z)
oi, body := restoreIsolationSeed(t, r, 0, false, true)
lost := newRestoreIsolationLayer(r)
lost.restore = func(context.Context, string, string, ObjectOptions) error { return nil }
if rec := restoreIsolationPOST(t, r, lost, oi.VersionID); rec.Code != http.StatusOK {
t.Fatalf("POST before restart: %d %s", rec.Code, rec.Body.String())
}
lost.wait(t)
if info, err := r.z.GetObjectInfo(t.Context(), r.bucket, r.object, r.opts(oi.VersionID)); err != nil || !info.RestoreOngoing {
t.Fatalf("fixture: %q %v", info.UserDefined[xhttp.AmzRestore], err)
}
endpoints := make(EndpointServerPools, len(r.z.serverPools))
for i, pool := range r.z.serverPools {
endpoints[i] = pool.endpoints
}
obj, err := newErasureServerPools(t.Context(), endpoints)
if err != nil {
t.Fatal(err)
}
restarted := obj.(*erasureServerPools)
t.Cleanup(func() { restarted.Shutdown(context.Background()) })
r.z = restarted
layer := newRestoreIsolationLayer(r)
if rec := restoreIsolationPOST(t, r, layer, oi.VersionID); rec.Code != http.StatusOK {
t.Fatalf("POST after restart: %d %s", rec.Code, rec.Body.String())
}
if err := layer.wait(t); err != nil {
t.Fatal(err)
}
info := restoreIsolationSettled(t, r, oi.VersionID)
if info.IsRemote() || info.UserDefined[restoreIsolationAttemptKey] != "" {
t.Errorf("restore after restart incomplete: remote=%v attempt=%q", info.IsRemote(), info.UserDefined[restoreIsolationAttemptKey])
}
if _, got, err := r.read(oi.VersionID); err != nil || !bytes.Equal(got, body) {
t.Errorf("contents after restart: %v", err)
}
}
// A null version replaced while its restore was queued is another version:
// the restore must not rehydrate it, even if it is transitioned as well.
func TestRestoreReplacementNullObject(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)
release := make(chan struct{})
layer := newRestoreIsolationLayer(r)
layer.restore = func(ctx context.Context, bucket, object string, opts ObjectOptions) error {
<-release
return r.z.RestoreTransitionedObject(ctx, bucket, object, opts)
}
if rec := restoreIsolationPOST(t, r, layer, ""); rec.Code != http.StatusOK {
close(release)
t.Fatalf("POST: %d %s", rec.Code, rec.Body.String())
}
replacement := []byte("replacement null object")
repl, err := r.z.serverPools[0].PutObject(t.Context(), r.bucket, r.object,
mustGetPutObjReader(t, bytes.NewReader(replacement), int64(len(replacement)), "", ""),
ObjectOptions{VersionSuspended: state == versioning.Suspended})
if err != nil {
close(release)
t.Fatal(err)
}
topts := r.opts(nullVersionID)
topts.MTime = repl.ModTime
topts.Transition = TransitionOptions{Tier: "WARM", ETag: repl.ETag}
if err := r.z.serverPools[0].TransitionObject(t.Context(), r.bucket, r.object, topts); err != nil {
close(release)
t.Fatal(err)
}
tier := globalTierConfigMgr.drivercache["WARM"].(*restoreTestTier)
before := r.fileInfo(nullVersionID)
gets := tier.gets.Load()
close(release)
layer.wait(t)
after := r.fileInfo(nullVersionID)
if tier.gets.Load() != gets || after.Metadata[xhttp.AmzRestore] != "" || !reflect.DeepEqual(before.Metadata, after.Metadata) {
t.Errorf("queued restore rehydrated the replacement null object: %q", after.Metadata[xhttp.AmzRestore])
}
if after.ModTime.Equal(oi.ModTime) || !after.ModTime.Equal(repl.ModTime) {
t.Fatal("fixture did not replace the null version")
}
if _, got, err := r.read(nullVersionID); err != nil || !bytes.Equal(got, replacement) {
t.Errorf("replacement contents: %v", err)
}
})
}
}
// POST's own status write can fail after its ongoing marker reached the
// drives; the marker must not stay for every later POST to refuse.
func TestRestorePOSTStatusWritePartialFailure(t *testing.T) {
r := newRestoreSafetyTest(t, 1, versioning.Enabled)
initConfigSubsystem(t.Context(), r.z)
oi, body := restoreIsolationSeed(t, r, 0, false, false)
_, writes := installRestoreIsolationFaults(r, 0, 2)
layer := newRestoreIsolationLayer(r)
writes.Store(true)
rec := restoreIsolationPOST(t, r, layer, oi.VersionID)
writes.Store(false)
if rec.Code < 400 {
t.Fatalf("POST acknowledged a failed status write: %d %s", rec.Code, rec.Body.String())
}
if info, err := r.z.GetObjectInfo(t.Context(), r.bucket, r.object, r.opts(oi.VersionID)); err == nil && !info.RestoreOngoing {
t.Log("the failed status write left no marker in quorum")
}
restoreIsolationSettled(t, r, oi.VersionID)
if rec := restoreIsolationPOST(t, r, layer, oi.VersionID); rec.Code != http.StatusOK {
t.Fatalf("POST after a 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.Errorf("restore after a failed status write: %v", err)
}
}
// Duplicate copies of a version in two pools can differ in whether they
// hold a restored copy. A completed restore header must never reach a copy
// without one; POST renews or restores only the copy that restores serve.
func TestRestoreDuplicatePoolCacheStates(t *testing.T) {
for _, mode := range []string{"suspended-owner", "unrestored-first", "restored-first"} {
t.Run(mode, func(t *testing.T) {
r := newRestoreSafetyTest(t, 2, versioning.Enabled)
initConfigSubsystem(t.Context(), r.z)
oi, body := restoreIsolationSeed(t, r, 0, false, false)
// The duplicate is the same stored version, metadata only.
src := r.z.serverPools[0].getHashedSet(r.object).getDisks()
dst := r.z.serverPools[1].getHashedSet(r.object).getDisks()
for i := range src {
raw, err := src[i].ReadAll(t.Context(), r.bucket, pathJoin(r.object, xlStorageFormatFile))
if err != nil {
t.Fatal(err)
}
if err := dst[i].WriteAll(t.Context(), r.bucket, pathJoin(r.object, xlStorageFormatFile), raw); err != nil {
t.Fatal(err)
}
}
owner := 0
if mode == "unrestored-first" {
owner = 1
}
if err := r.z.serverPools[owner].RestoreTransitionedObject(t.Context(), r.bucket, r.object, nativeRestoreOptions(r, oi.VersionID)); err != nil {
t.Fatal(err)
}
if mode == "suspended-owner" {
r.z.poolMetaMutex.Lock()
r.z.poolMeta.Pools[0].Decommission = &PoolDecommissionInfo{}
r.z.poolMetaMutex.Unlock()
t.Cleanup(func() {
r.z.poolMetaMutex.Lock()
r.z.poolMeta.Pools[0].Decommission = nil
r.z.poolMetaMutex.Unlock()
})
}
before := nativeRestoreSnapshot(t, r)
layer := newRestoreIsolationLayer(r)
if rec := restoreIsolationPOST(t, r, layer, oi.VersionID); rec.Code != http.StatusOK {
t.Fatalf("POST: %d %s", rec.Code, rec.Body.String())
}
if mode != "restored-first" {
select {
case err := <-layer.done:
if err != nil {
t.Fatal(err)
}
case <-time.After(5 * time.Second):
t.Error("POST did not restore the copy without restored data")
}
}
after := nativeRestoreSnapshot(t, r)
for pool := range r.z.serverPools {
gr, err := r.z.serverPools[pool].GetObjectNInfo(t.Context(), r.bucket, r.object, nil, nil, ObjectOptions{VersionID: oi.VersionID})
if err != nil {
t.Errorf("pool %d copy unreadable, header %q: %v", pool, after[pool][0].Metadata[xhttp.AmzRestore], err)
continue
}
got, err := io.ReadAll(gr)
gr.Close()
if err != nil || !bytes.Equal(got, body) {
t.Errorf("pool %d copy contents: %v", pool, err)
}
}
// The copy that is neither renewed nor restored stays as it was.
untouched := 1
if mode == "suspended-owner" {
untouched = 0
}
if !reflect.DeepEqual(before[untouched], after[untouched]) {
t.Errorf("POST changed the pool %d copy: %q -> %q", untouched, before[untouched][0].Metadata[xhttp.AmzRestore], after[untouched][0].Metadata[xhttp.AmzRestore])
}
})
}
}
// A restored copy expires only by its own reason, on a fresh read: its
// restore deadline passed and nothing else. A renewed deadline survives
// another rule falling due and an expiry checked across all pools.
func TestExpireRestoredNeedsOwnReason(t *testing.T) {
for _, mode := range []string{"other-expiry-due", "checked-all-pools", "ongoing", "renewed", "expired"} {
t.Run(mode, func(t *testing.T) {
r := newRestoreSafetyTest(t, 1, versioning.Enabled)
oi := r.put("restored", false, true)
r.restore(oi)
before := r.fileInfo(oi.VersionID)
if mode == "other-expiry-due" {
meta, err := globalBucketMetadataSys.Get(r.bucket)
if err != nil {
t.Fatal(err)
}
meta.LifecycleConfigXML = []byte(`deleteEnabled2020-01-01T00:00:00Z`)
if err := meta.Save(t.Context(), r.z); err != nil {
t.Fatal(err)
}
globalBucketMetadataSys.Set(r.bucket, meta)
}
header := completedRestoreObj(UTCNow().Add(48 * time.Hour)).String()
switch mode {
case "ongoing":
header = ongoingRestoreObj().String()
case "expired":
header = ""
}
if header != "" {
opts := r.opts(oi.VersionID)
opts.MTime = oi.ModTime
opts.EvalMetadataFn = func(o *ObjectInfo, _ error) (ReplicateDecision, error) {
o.UserDefined[xhttp.AmzRestore] = header
return ReplicateDecision{}, nil
}
if _, err := r.z.PutObjectMetadata(t.Context(), r.bucket, r.object, opts); err != nil {
t.Fatal(err)
}
}
var err error
if mode == "checked-all-pools" {
opts := r.opts(oi.VersionID)
opts.Expiration = ExpirationOptions{Expire: true, CheckedAllPools: true}
opts.Transition.ExpireRestored = true
_, err = r.z.serverPools[0].getHashedSet(r.object).DeleteObject(t.Context(), r.bucket, r.object, opts)
} else {
err = expireTransitionedObject(t.Context(), r.z, &oi, lifecycle.Event{Action: lifecycle.DeleteRestoredAction}, lcEventSrc_Scanner)
}
after := r.fileInfo(oi.VersionID)
if mode == "expired" {
// Control: an elapsed deadline is the reason itself.
if err != nil || after.Metadata[xhttp.AmzRestore] != "" || r.dataDirs(before.DataDir) != 0 {
t.Errorf("expired restored copy not removed: %v", err)
}
return
}
t.Logf("cleanup=%v header=%q", err, after.Metadata[xhttp.AmzRestore])
if err == nil || after.Metadata[xhttp.AmzRestore] != header || r.dataDirs(before.DataDir) != len(r.z.serverPools[0].getHashedSet(r.object).getDisks()) {
t.Error("cache cleanup removed a restored copy whose own deadline has not passed")
}
})
}
}
// Controls: an ordinary POST restore, a renewal, and a client's multipart
// upload of the same key while a restore runs.
func TestRestorePOSTControls(t *testing.T) {
t.Run("restore", func(t *testing.T) {
r := newRestoreSafetyTest(t, 1, versioning.Enabled)
initConfigSubsystem(t.Context(), r.z)
oi, body := restoreIsolationSeed(t, r, 0, false, true)
layer := newRestoreIsolationLayer(r)
if rec := restoreIsolationPOST(t, r, layer, oi.VersionID); rec.Code != http.StatusOK {
t.Fatalf("POST: %d %s", rec.Code, rec.Body.String())
}
if err := layer.wait(t); err != nil {
t.Fatal(err)
}
info, err := r.z.GetObjectInfo(t.Context(), r.bucket, r.object, r.opts(oi.VersionID))
if err != nil || info.IsRemote() || info.RestoreOngoing || info.UserDefined[restoreIsolationAttemptKey] != "" {
t.Fatalf("restored status: %q %q, %v", info.UserDefined[xhttp.AmzRestore], info.UserDefined[restoreIsolationAttemptKey], err)
}
if !info.ModTime.Equal(oi.ModTime) || info.ETag != oi.ETag || info.UserDefined["x-amz-meta-preserve"] != "keep-me" {
t.Error("restore changed the version identity or metadata")
}
if _, got, err := r.read(oi.VersionID); err != nil || !bytes.Equal(got, body) {
t.Errorf("restored contents: %v", err)
}
if uploads := restoreIsolationUploads(t, r, 0); len(uploads) != 0 {
t.Errorf("completed restore left its upload: %v", uploads)
}
})
t.Run("renewal", func(t *testing.T) {
r := newRestoreSafetyTest(t, 1, versioning.Enabled)
initConfigSubsystem(t.Context(), r.z)
oi, body := 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)
}
tier := globalTierConfigMgr.drivercache["WARM"].(*restoreTestTier)
before := r.fileInfo(oi.VersionID)
gets := tier.gets.Load()
layer := newRestoreIsolationLayer(r)
if rec := restoreIsolationPOST(t, r, layer, oi.VersionID); rec.Code != http.StatusOK {
t.Fatalf("POST: %d %s", rec.Code, rec.Body.String())
}
select {
case err := <-layer.done:
t.Fatalf("renewal restored again: %v", err)
case <-time.After(200 * time.Millisecond):
}
after := r.fileInfo(oi.VersionID)
if after.DataDir != before.DataDir || !after.ModTime.Equal(before.ModTime) || after.Metadata[xhttp.AmzRestore] == "" {
t.Error("renewal changed the restored copy")
}
if _, got, err := r.read(oi.VersionID); err != nil || !bytes.Equal(got, body) || tier.gets.Load() != gets {
t.Errorf("renewed copy not served locally: %v", err)
}
})
t.Run("public-multipart-same-key", func(t *testing.T) {
r := newRestoreSafetyTest(t, 1, versioning.Enabled)
initConfigSubsystem(t.Context(), r.z)
oi, body := restoreIsolationSeed(t, r, 0, false, true)
tier := newRestoreIsolationGateTier(5<<20, nil)
globalTierConfigMgr.drivercache["WARM"] = tier
defer tier.unblock()
layer := newRestoreIsolationLayer(r)
if rec := restoreIsolationPOST(t, r, layer, oi.VersionID); rec.Code != http.StatusOK {
t.Fatalf("POST: %d %s", rec.Code, rec.Body.String())
}
restoreIsolationEntered(t, tier)
upload, err := r.z.NewMultipartUpload(t.Context(), r.bucket, r.object, ObjectOptions{Versioned: true})
if err != nil {
t.Fatal(err)
}
uploads, err := r.z.ListMultipartUploads(t.Context(), r.bucket, r.object, "", "", "", 100)
listed := false
for _, u := range uploads.Uploads {
listed = listed || u.UploadID == upload.UploadID
}
if err != nil || !listed {
t.Fatalf("client upload not listed: %+v, %v", uploads.Uploads, err)
}
tier.unblock()
if err := layer.wait(t); err != nil {
t.Fatal(err)
}
// Single-pool part writes wait for the object lock of a running restore.
data := []byte("client data")
part, err := r.z.PutObjectPart(t.Context(), r.bucket, r.object, upload.UploadID, 1, mustGetPutObjReader(t, bytes.NewReader(data), int64(len(data)), "", ""), ObjectOptions{})
if err != nil {
t.Fatal(err)
}
parts, err := r.z.ListObjectParts(t.Context(), r.bucket, r.object, upload.UploadID, 0, 10, ObjectOptions{})
if err != nil || len(parts.Parts) != 1 {
t.Fatalf("client parts: %+v, %v", parts, err)
}
newer, err := r.z.CompleteMultipartUpload(t.Context(), r.bucket, r.object, upload.UploadID, []CompletePart{{PartNumber: 1, ETag: part.ETag}}, ObjectOptions{Versioned: true})
if err != nil {
t.Fatal(err)
}
if _, got, err := r.read(newer.VersionID); err != nil || !bytes.Equal(got, data) {
t.Errorf("client version: %v", err)
}
if _, got, err := r.read(oi.VersionID); err != nil || !bytes.Equal(got, body) {
t.Errorf("restored version: %v", err)
}
})
}