carlos / platform Public

activator: a placed seed restores plainly; the seed file pinned by identity (Plan 4 Task 3 review)

A placed seed stays on its row for life, so a placed row's wake now skips
the claim and layout refusals and the own-replica listing and runs
restorePlain, staging fallback included, failing the wake rather than
reporting fresh. A moved placed host promotes its staged replica instead
of asking for the deleted snapshot on every wake.

The fetched snapshot is pinned by file identity: reopened beneath the
instance directory and compared, checked through its descriptor, compared
again after the check and after the rename (a refusal there removes the
marker and the database). The listing failure and the nothing-restored
failure log with the seed id. Tests for the placed path, a swapped temp
file, a WAL-mode snapshot, a marker with no database, unplaceable states,
a malformed column, and read- versus write-side failures.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 950e953fec06b636ea68829021a31225cdae47a8 parent e9e8803
2 files changed, +373 −32
  • internal/activator/seedplace.go +102 −30
  • internal/activator/seedplace_linux_test.go +271 −2

CI passed — run details

diff --git a/internal/activator/seedplace.go b/internal/activator/seedplace.go
index aa30ba116..5801cba2f 100644
--- a/internal/activator/seedplace.go
+++ b/internal/activator/seedplace.go
@@ -10,6 +10,7 @@ import (
"log"
"os"
"path/filepath"
+ "strconv"
"strings"
"github.com/carlosframework/platform/internal/instancefs"
@@ -80,17 +81,40 @@ func seedPlaceFailed(host string, err error) error {
// instancefs, so no symlink there is followed.
func (b *ExecBackend) seededRestore(ctx context.Context, rt registry.Route, src *seeds.RowSeed) (bool, error) {
host := rt.Host
- if err := seeds.Check(host, src.ID); err != nil {
- return false, fmt.Errorf("seed fallback for %s: %w", host, err)
- }
fail := func(err error) (bool, error) {
- if cause := errors.Unwrap(err); cause != nil {
- log.Printf("activator: %s: seed %s not placed: %v: %v", host, src.ID, err, cause)
+ var spe *SeedPlacementError
+ if errors.As(err, &spe) && spe.Err != nil {
+ log.Printf("activator: %s: seed %s: %v: %v", host, src.ID, err, spe.Err)
} else {
- log.Printf("activator: %s: seed %s not placed: %v", host, src.ID, err)
+ log.Printf("activator: %s: seed %s: %v", host, src.ID, err)
}
return false, err
}
+ if err := seeds.Check(host, src.ID); err != nil {
+ return fail(fmt.Errorf("seed fallback for %s: %w", host, err))
+ }
+ // restoreOwn restores the host's own lineage, the staging fallback
+ // included, and never reports fresh: a host that has (or was listed as
+ // having) a replica of its own and finds none must not boot empty. The
+ // next wake asks again.
+ restoreOwn := func() (bool, error) {
+ fresh, err := b.restorePlain(ctx, rt)
+ if err != nil {
+ return false, err
+ }
+ if fresh {
+ return fail(fmt.Errorf("seed fallback for %s: found no replica of its own to restore", host))
+ }
+ return false, nil
+ }
+ // A placed seed stays on the row for life, and placed is set only once
+ // the host's own replica exists. So a placed row's wake is a plain
+ // restore on any box: no layout or claim refusal, no listing on every
+ // wake, and the staging fallback still promotes a moved host's replica
+ // (the snapshot was deleted at placement; it is never read again).
+ if src.State == seeds.StatePlaced {
+ return restoreOwn()
+ }
if b.ClaimArmed {
return fail(seedRefusedClaim(host))
}
@@ -110,21 +134,13 @@ func (b *ExecBackend) seededRestore(ctx context.Context, rt registry.Route, src
// would place the snapshot over a lineage that exists.
has, err := hasOwnReplica(ctx, b.Cfg.Bucket, b.Cfg.hibernatePrefix(host), b.Cfg.Region)
if err != nil {
- return false, fmt.Errorf("seed fallback for %s: cannot tell whether it has a replica of its own: %w", host, err)
+ return fail(fmt.Errorf("seed fallback for %s: cannot tell whether it has a replica of its own: %w", host, err))
}
if has {
- fresh, err := b.restorePlain(ctx, rt)
- if err != nil {
+ if _, err := restoreOwn(); err != nil {
return false, err
}
- if fresh {
- // Listed a replica, then restored none: never boot this row
- // empty. The next wake asks again.
- return false, fmt.Errorf("seed fallback for %s: its replica was listed but nothing was restored", host)
- }
- if src.State != seeds.StatePlaced {
- b.finishPlacement(ctx, rt, src)
- }
+ b.finishPlacement(ctx, rt, src)
return false, nil
}
@@ -215,6 +231,14 @@ func purgeSeedTarget(dir, base string) error {
// renames the snapshot onto dir/base. The marker goes first, so a database
// at that path always has one. The temp file and any sidecar the check left
// are removed on every path.
+//
+// The instance directory is the tenant's uid's to write, so the file is
+// pinned by identity: the one fetchSeed wrote is reopened beneath dir and
+// must be the same file, the integrity check reads it through that
+// descriptor, it must still be the entry at tmp after the check, and the
+// entry renamed onto dir/base must still be it. Anything else is refused,
+// and a refusal after the rename removes the marker and the database, so no
+// unchecked file is ever kept as this seed.
func (b *ExecBackend) placeSeed(ctx context.Context, host string, src *seeds.RowSeed, dir, base string) error {
tmp := seedTempName(src.ID)
defer func() {
@@ -222,11 +246,21 @@ func (b *ExecBackend) placeSeed(ctx context.Context, host string, src *seeds.Row
_ = instancefs.RemoveAllBeneath(dir, n)
}
}()
- if err := b.fetchSeed(ctx, host, src, dir, tmp); err != nil {
+ wrote, err := b.fetchSeed(ctx, host, src, dir, tmp)
+ if err != nil {
return err
}
- if err := checkSeedDB(ctx, filepath.Join(dir, tmp)); err != nil {
- return seedNotDB(host, err)
+ f, err := openSameFile(dir, tmp, wrote)
+ if err != nil {
+ return seedPlaceFailed(host, err)
+ }
+ cerr := checkSeedDB(ctx, "/proc/self/fd/"+strconv.FormatUint(uint64(f.Fd()), 10))
+ f.Close()
+ if cerr != nil {
+ return seedNotDB(host, cerr)
+ }
+ if err := sameFileAt(dir, tmp, wrote); err != nil {
+ return seedPlaceFailed(host, err)
}
if err := instancefs.WriteAtomic(dir, base+SeedMarkerSuffix, []byte(src.ID+"\n"), 0o600); err != nil {
return seedPlaceFailed(host, err)
@@ -234,37 +268,75 @@ func (b *ExecBackend) placeSeed(ctx context.Context, host string, src *seeds.Row
if err := instancefs.RenameBetween(dir, tmp, dir, base); err != nil {
return seedPlaceFailed(host, err)
}
+ if err := sameFileAt(dir, base, wrote); err != nil {
+ _ = instancefs.RemoveAllBeneath(dir, base+SeedMarkerSuffix)
+ _ = instancefs.RemoveAllBeneath(dir, base)
+ return seedPlaceFailed(host, err)
+ }
return nil
}
+// errSwapped is a seed file that is no longer the one fetchSeed wrote.
+var errSwapped = errors.New("the fetched snapshot was replaced in the instance directory")
+
+// openSameFile opens dir/name beneath dir, read-only, and refuses it unless
+// it is the file want describes.
+func openSameFile(dir, name string, want os.FileInfo) (*os.File, error) {
+ f, err := instancefs.OpenBeneath(dir, name, os.O_RDONLY, 0)
+ if err != nil {
+ return nil, err
+ }
+ fi, err := f.Stat()
+ if err != nil {
+ f.Close()
+ return nil, err
+ }
+ if !os.SameFile(fi, want) {
+ f.Close()
+ return nil, errSwapped
+ }
+ return f, nil
+}
+
+// sameFileAt reports errSwapped unless dir/name is the file want describes.
+func sameFileAt(dir, name string, want os.FileInfo) error {
+ f, err := openSameFile(dir, name, want)
+ if err != nil {
+ return err
+ }
+ return f.Close()
+}
+
// fetchSeed copies the snapshot into dir/tmp, checking its length and
-// sha256 against the row as it writes. It reads at most one byte more than
-// the row records, so an oversized object is refused without being read.
-func (b *ExecBackend) fetchSeed(ctx context.Context, host string, src *seeds.RowSeed, dir, tmp string) error {
+// sha256 against the row as it writes, and returns the identity of the file
+// it wrote. It reads at most one byte more than the row records, so an
+// oversized object is refused without being read.
+func (b *ExecBackend) fetchSeed(ctx context.Context, host string, src *seeds.RowSeed, dir, tmp string) (os.FileInfo, error) {
rc, err := b.Seeds.Open(ctx, host, src.ID)
if err != nil {
- return seedUnread(host, err)
+ return nil, seedUnread(host, err)
}
defer rc.Close()
f, err := instancefs.OpenBeneath(dir, tmp, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if err != nil {
- return seedPlaceFailed(host, err)
+ return nil, seedPlaceFailed(host, err)
}
h := sha256.New()
r := &readErrs{r: io.LimitReader(rc, src.Bytes+1)}
n, cerr := io.Copy(io.MultiWriter(f, h), r)
serr := f.Sync()
+ fi, iderr := f.Stat()
ferr := f.Close()
if r.err != nil {
- return seedUnread(host, r.err)
+ return nil, seedUnread(host, r.err)
}
- if cerr != nil || serr != nil || ferr != nil {
- return seedPlaceFailed(host, errors.Join(cerr, serr, ferr))
+ if cerr != nil || serr != nil || iderr != nil || ferr != nil {
+ return nil, seedPlaceFailed(host, errors.Join(cerr, serr, iderr, ferr))
}
if n != src.Bytes || hex.EncodeToString(h.Sum(nil)) != src.SHA256 {
- return seedMismatch(host)
+ return nil, seedMismatch(host)
}
- return nil
+ return fi, nil
}
// readErrs keeps the read side's error apart from the write side's, which
diff --git a/internal/activator/seedplace_linux_test.go b/internal/activator/seedplace_linux_test.go
index ec0fbeade..13e2381bc 100644
--- a/internal/activator/seedplace_linux_test.go
+++ b/internal/activator/seedplace_linux_test.go
@@ -6,6 +6,7 @@ import (
"bytes"
"context"
"crypto/sha256"
+ "database/sql"
"encoding/hex"
"errors"
"io"
@@ -23,6 +24,8 @@ type fakeSeedObjects struct {
err error
opens int
read int64 // bytes the fallback pulled from the object
+ // midway, when set, fails the read after the first 100 bytes.
+ midway error
}
func (f *fakeSeedObjects) Open(_ context.Context, host, id string) (io.ReadCloser, error) {
@@ -34,9 +37,17 @@ func (f *fakeSeedObjects) Open(_ context.Context, host, id string) (io.ReadClose
if !ok {
return nil, os.ErrNotExist
}
- return io.NopCloser(&countingReader{r: bytes.NewReader(b), n: &f.read}), nil
+ var r io.Reader = bytes.NewReader(b)
+ if f.midway != nil {
+ r = io.MultiReader(bytes.NewReader(b[:100]), &failingReader{f.midway})
+ }
+ return io.NopCloser(&countingReader{r: r, n: &f.read}), nil
}
+type failingReader struct{ err error }
+
+func (f *failingReader) Read([]byte) (int, error) { return 0, f.err }
+
type countingReader struct {
r io.Reader
n *int64
@@ -287,7 +298,7 @@ func TestSeededRestoreUsesItsOwnReplicaOnceItHasOne(t *testing.T) {
}
defer func() { restoreReplica = oldRestore }()
b, rt := nestedBackend(t, &fakeControl{}, &fakeUnit{})
- rt, objs, _ := seededRoute(t, b, rt, seeds.StatePlaced)
+ rt, objs, _ := seededRoute(t, b, rt, seeds.StateReady)
if fresh, err := b.Restore(context.Background(), rt); err != nil || fresh || restored != 1 || objs.opens != 0 {
t.Fatalf("fresh %v err %v restored %d opens %d; want the own replica, not the seed", fresh, err, restored, objs.opens)
}
@@ -318,3 +329,261 @@ func TestMemberSafeWakeReasonPassesTheSeedSentence(t *testing.T) {
}
func fmtWrap(err error) error { return errors.Join(errors.New("restore shop.example.com"), err) }
+
+// countOwnReplicaLists stubs hasOwnReplica and counts its calls.
+func countOwnReplicaLists(t *testing.T, has bool) *int {
+ t.Helper()
+ n := 0
+ old := hasOwnReplica
+ hasOwnReplica = func(context.Context, string, string, string) (bool, error) { n++; return has, nil }
+ t.Cleanup(func() { hasOwnReplica = old })
+ return &n
+}
+
+// A placed seed stays on the row for life. After a move, the destination's
+// replica is staged under moves/<host>/, which only the plain path's staging
+// fallback promotes: the wake must take it, never the deleted snapshot.
+func TestSeededRestoreOfAPlacedSeedPromotesAStagedReplica(t *testing.T) {
+ lists := countOwnReplicaLists(t, false)
+ b, rt := nestedBackend(t, &fakeControl{}, &fakeUnit{})
+ rt, objs, _ := seededRoute(t, b, rt, seeds.StatePlaced)
+ b.ClaimArmed = true // placed: no claim refusal either
+ f := newFakeMirrorStore()
+ b.Mirror = f
+ f.put(b.Cfg.Bucket, MovesPrefix(rt.Host)+"generations/a/snapshot", "e1")
+ oldRestore := restoreReplica
+ var restoredFrom []string
+ restoreReplica = func(_ context.Context, dbPath, bucket, prefix, _ string) (bool, error) {
+ if got, _ := f.List(context.Background(), bucket, prefix); len(got) == 0 {
+ return false, nil
+ }
+ restoredFrom = append(restoredFrom, prefix)
+ return true, writeMinimalSQLite(dbPath)
+ }
+ defer func() { restoreReplica = oldRestore }()
+ os.MkdirAll(filepath.Dir(rt.DBPath), 0o700) // lease.Restore's own MkdirAll
+ fresh, err := b.Restore(context.Background(), rt)
+ if err != nil || fresh || objs.opens != 0 || *lists != 0 || len(restoredFrom) != 1 {
+ t.Fatalf("fresh %v err %v opens %d lists %d restored %v; want the promoted staged replica", fresh, err, objs.opens, *lists, restoredFrom)
+ }
+}
+
+func TestSeededRestoreOfAPlacedSeedKeepsTheWarmCopyWithoutListing(t *testing.T) {
+ lists := countOwnReplicaLists(t, true)
+ b, rt := nestedBackend(t, &fakeControl{}, &fakeUnit{})
+ rt, objs, _ := seededRoute(t, b, rt, seeds.StatePlaced)
+ b.ClaimArmed = true
+ oldRestore := restoreReplica
+ restoreReplica = func(context.Context, string, string, string, string) (bool, error) {
+ t.Fatal("a warm copy was restored over")
+ return false, nil
+ }
+ defer func() { restoreReplica = oldRestore }()
+ os.MkdirAll(filepath.Dir(rt.DBPath), 0o700)
+ if err := writeMinimalSQLite(rt.DBPath); err != nil {
+ t.Fatal(err)
+ }
+ if fresh, err := b.Restore(context.Background(), rt); err != nil || fresh || objs.opens != 0 || *lists != 0 {
+ t.Fatalf("fresh %v err %v opens %d lists %d; want the warm copy, no listing, no fetch", fresh, err, objs.opens, *lists)
+ }
+}
+
+func TestSeededRestoreOfAPlacedSeedWithNothingToRestoreFails(t *testing.T) {
+ countOwnReplicaLists(t, false)
+ b, rt := nestedBackend(t, &fakeControl{}, &fakeUnit{})
+ rt, objs, _ := seededRoute(t, b, rt, seeds.StatePlaced)
+ oldRestore := restoreReplica
+ restoreReplica = func(context.Context, string, string, string, string) (bool, error) { return false, nil }
+ defer func() { restoreReplica = oldRestore }()
+ if fresh, err := b.Restore(context.Background(), rt); err == nil || fresh || objs.opens != 0 {
+ t.Fatalf("fresh %v err %v opens %d; want a failed wake, never fresh", fresh, err, objs.opens)
+ }
+}
+
+// A crash after the marker and before the rename leaves a marker naming
+// this seed and no database: the snapshot is fetched again.
+func TestSeededRestoreRefetchesWhenOnlyTheMarkerSurvived(t *testing.T) {
+ noOwnReplica(t)
+ b, rt := nestedBackend(t, &fakeControl{}, &fakeUnit{})
+ rt, objs, id := seededRoute(t, b, rt, seeds.StateReady)
+ os.MkdirAll(filepath.Dir(rt.DBPath), 0o700)
+ os.WriteFile(rt.DBPath+SeedMarkerSuffix, []byte(id+"\n"), 0o600)
+ if fresh, err := b.Restore(context.Background(), rt); err != nil || fresh || objs.opens != 1 {
+ t.Fatalf("fresh %v err %v opens %d; want one fetch", fresh, err, objs.opens)
+ }
+ if got, _ := os.ReadFile(rt.DBPath); !bytes.Equal(got, objs.body[id]) {
+ t.Fatal("the snapshot is not at the row's path")
+ }
+}
+
+func TestSeededRestoreLeavesAnUnplaceableSeedToThePlainPath(t *testing.T) {
+ for _, state := range []string{seeds.StateAborted, seeds.StateAwaitingUpload, seeds.StateCommitted} {
+ t.Run(state, func(t *testing.T) {
+ lists := countOwnReplicaLists(t, false)
+ b, rt := nestedBackend(t, &fakeControl{}, &fakeUnit{})
+ rt, objs, _ := seededRoute(t, b, rt, state)
+ oldRestore := restoreReplica
+ restored := 0
+ restoreReplica = func(context.Context, string, string, string, string) (bool, error) { restored++; return false, nil }
+ defer func() { restoreReplica = oldRestore }()
+ fresh, err := b.Restore(context.Background(), rt)
+ if err != nil || !fresh || restored != 1 || objs.opens != 0 || *lists != 0 {
+ t.Fatalf("fresh %v err %v restored %d opens %d lists %d; want the plain path", fresh, err, restored, objs.opens, *lists)
+ }
+ })
+ }
+}
+
+func TestSeededRestoreFailsOnAMalformedSeedColumn(t *testing.T) {
+ for _, col := range []string{`{"id":`, `{"id":"../x","kind":"replica","state":"ready"}`} {
+ lists := countOwnReplicaLists(t, false)
+ b, rt := nestedBackend(t, &fakeControl{}, &fakeUnit{})
+ objs := &fakeSeedObjects{}
+ b.Seeds = objs
+ oldRestore := restoreReplica
+ restoreReplica = func(context.Context, string, string, string, string) (bool, error) { return false, nil }
+ rt.SeedSource = col
+ fresh, err := b.Restore(context.Background(), rt)
+ restoreReplica = oldRestore
+ if err == nil || fresh || objs.opens != 0 || *lists != 0 {
+ t.Fatalf("column %q: fresh %v err %v; want a failed wake", col, fresh, err)
+ }
+ }
+}
+
+func TestSeededRestoreTellsAReadFailureFromAWriteFailure(t *testing.T) {
+ noOwnReplica(t)
+ t.Run("read", func(t *testing.T) {
+ b, rt := nestedBackend(t, &fakeControl{}, &fakeUnit{})
+ rt, objs, _ := seededRoute(t, b, rt, seeds.StateReady)
+ objs.midway = errors.New("connection reset")
+ var spe *SeedPlacementError
+ if _, err := b.Restore(context.Background(), rt); !errors.As(err, &spe) || !strings.Contains(spe.Sentence, "could not be read") {
+ t.Fatalf("Restore = %v; want SD-N16", err)
+ }
+ })
+ t.Run("write", func(t *testing.T) {
+ if os.Geteuid() == 0 {
+ t.Skip("root writes through a read-only directory")
+ }
+ b, rt := nestedBackend(t, &fakeControl{}, &fakeUnit{})
+ rt, _, _ = seededRoute(t, b, rt, seeds.StateReady)
+ dir := filepath.Dir(rt.DBPath)
+ os.MkdirAll(dir, 0o700)
+ os.Chmod(dir, 0o500)
+ defer os.Chmod(dir, 0o700)
+ var spe *SeedPlacementError
+ if _, err := b.Restore(context.Background(), rt); !errors.As(err, &spe) || !strings.Contains(spe.Sentence, "could not be put in place") {
+ t.Fatalf("Restore = %v; want SD-N19", err)
+ }
+ })
+}
+
+// A same-uid process that swaps the fetched file around its check gets a
+// refusal, and nothing is left at the row's path to be kept as the seed.
+// Swapped before the check, the check cannot read the file it was given
+// (SQLite resolves the descriptor to the original's name, now gone);
+// swapped after it, the identity check refuses the entry at the temp name.
+func TestSeededRestoreRefusesASwappedTempFile(t *testing.T) {
+ noOwnReplica(t)
+ for _, c := range []struct {
+ name string
+ swapAfter bool
+ wantPhrases []string
+ }{
+ {"before the check", false, []string{"integrity check", "could not be put in place"}},
+ {"after the check", true, []string{"could not be put in place"}},
+ } {
+ t.Run(c.name, func(t *testing.T) {
+ b, rt := nestedBackend(t, &fakeControl{}, &fakeUnit{})
+ rt, _, id := seededRoute(t, b, rt, seeds.StateReady)
+ dir := filepath.Dir(rt.DBPath)
+ other := filepath.Join(t.TempDir(), "other.db")
+ if err := writeMinimalSQLite(other); err != nil {
+ t.Fatal(err)
+ }
+ swap := func() {
+ tmp := filepath.Join(dir, ".seed-"+id+".tmp")
+ os.Remove(tmp)
+ os.Symlink(other, tmp)
+ }
+ old := checkSeedDB
+ readSwapped := false
+ checkSeedDB = func(ctx context.Context, path string) error {
+ if !c.swapAfter {
+ swap()
+ // The check is handed the fetched file itself, not a
+ // name the swap can redirect.
+ fi, err1 := os.Stat(path)
+ fo, err2 := os.Stat(other)
+ readSwapped = err1 == nil && err2 == nil && os.SameFile(fi, fo)
+ }
+ err := old(ctx, path)
+ if c.swapAfter {
+ swap()
+ }
+ return err
+ }
+ defer func() { checkSeedDB = old }()
+ var spe *SeedPlacementError
+ _, err := b.Restore(context.Background(), rt)
+ if readSwapped {
+ t.Fatal("the integrity check was pointed at the swapped-in file")
+ }
+ if !errors.As(err, &spe) {
+ t.Fatalf("Restore = %v; want a refusal", err)
+ }
+ matched := false
+ for _, w := range c.wantPhrases {
+ matched = matched || strings.Contains(spe.Sentence, w)
+ }
+ if !matched {
+ t.Fatalf("Restore = %q; want one of %q", spe.Sentence, c.wantPhrases)
+ }
+ for _, p := range []string{rt.DBPath, rt.DBPath + SeedMarkerSuffix} {
+ if _, err := os.Lstat(p); !os.IsNotExist(err) {
+ t.Errorf("%s exists after a refused swap", p)
+ }
+ }
+ if got, _ := os.ReadFile(other); len(got) == 0 {
+ t.Error("the swapped-in file's target was touched")
+ }
+ })
+ }
+}
+
+// Litestream's sources are WAL-mode databases, so a snapshot's header says
+// WAL. The check reads it through a descriptor and must still pass, and
+// leave nothing behind.
+func TestSeededRestorePlacesAWALModeSnapshot(t *testing.T) {
+ noOwnReplica(t)
+ b, rt := nestedBackend(t, &fakeControl{}, &fakeUnit{})
+ snap := filepath.Join(t.TempDir(), "wal.db")
+ db, err := sql.Open("sqlite", snap)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, q := range []string{"PRAGMA journal_mode=WAL", "create table t(x)", "insert into t values(1)", "PRAGMA wal_checkpoint(TRUNCATE)"} {
+ if _, err := db.Exec(q); err != nil {
+ t.Fatal(err)
+ }
+ }
+ db.Close()
+ body, _ := os.ReadFile(snap)
+ if body[18] != 2 || body[19] != 2 {
+ t.Fatalf("header bytes 18-19 = %d %d, want WAL", body[18], body[19])
+ }
+ id, _ := seeds.NewID()
+ sum := sha256.Sum256(body)
+ rt.SeedSource = seeds.RowSeed{ID: id, Kind: seeds.KindReplica, SHA256: hex.EncodeToString(sum[:]), Bytes: int64(len(body)), State: seeds.StateReady}.Encode()
+ b.Seeds = &fakeSeedObjects{body: map[string][]byte{id: body}}
+ if fresh, err := b.Restore(context.Background(), rt); err != nil || fresh {
+ t.Fatalf("Restore = fresh %v, %v", fresh, err)
+ }
+ ents, _ := os.ReadDir(filepath.Dir(rt.DBPath))
+ for _, e := range ents {
+ if strings.HasPrefix(e.Name(), ".seed-") {
+ t.Errorf("%s left behind", e.Name())
+ }
+ }
+}