fix(offsite): 桶内记录写入主机,演练机只读不写也不给生产 owner 发告警,take-over 显式接管

This commit is contained in:
Lemon-miaow committed 2026-09-27 07:55:40 +08:00
1 parent c9e5e2fe3e
commit 149ab0be66
15 files changed
+1327 -56

No files matched your search

+16 -4
View File
@@ -8,6 +8,7 @@ import (
"regexp"
"sort"
"strings"
"time"
)
// keyMark is the one object the bucket holds in the clear: the KeyID of the
@@ -137,13 +138,24 @@ func CheckKey(ctx context.Context, b Bucket, key []byte) (KeyFit, error) {
return KeyUnused, nil
}
// ClaimKey is CheckKey, then records key's id in a bucket that has none, so
// that every later check reads the id.
func ClaimKey(ctx context.Context, b Bucket, key []byte) error {
// claim is the check before a run writes anything: CheckKey, then the lease
// (nil checks none), then key's id recorded in a bucket that has none, so
// that every later check reads the id. The key goes first, so a host with the
// wrong key never records itself as the writer, and the lease before the key
// id, so a standby host writes nothing at all.
func claim(ctx context.Context, b Bucket, key []byte, lease *Lease, now time.Time) error {
fit, err := CheckKey(ctx, b, key)
if err != nil || fit == KeyRecorded {
if err != nil {
return err
}
if lease != nil {
if err := lease.Acquire(ctx, b, fit == KeyUnused, now); err != nil {
return err
}
}
if fit == KeyRecorded {
return nil
}
id := KeyID(key) + "\n"
if err := b.Put(ctx, keyMark, strings.NewReader(id), int64(len(id))); err != nil {
return fmt.Errorf("record the key id in %s: %w", keyMark, err)
+13 -6
View File
@@ -5,6 +5,7 @@ import (
"context"
"errors"
"fmt"
"os"
"strings"
"testing"
"time"
@@ -160,36 +161,36 @@ func TestCheckKey(t *testing.T) {
}
}
func TestClaimKey(t *testing.T) {
func TestClaim(t *testing.T) {
key, other := testKey(t), testKey(t)
marker := func(b *memBucket) string { return string(b.objs[keyMark]) }
b := newMemBucket()
if err := ClaimKey(context.Background(), b, key); err != nil {
if err := claim(context.Background(), b, key, nil, time.Time{}); err != nil {
t.Fatal(err)
}
if marker(b) != KeyID(key)+"\n" {
t.Fatalf("empty bucket: marker = %q, want %s", marker(b), KeyID(key))
}
if err := ClaimKey(context.Background(), b, key); err != nil || b.puts != 1 {
if err := claim(context.Background(), b, key, nil, time.Time{}); err != nil || b.puts != 1 {
t.Errorf("second claim: err %v, puts %d; want the marker written once", err, b.puts)
}
if fit, err := CheckKey(context.Background(), b, key); fit != KeyRecorded || err != nil {
t.Errorf("after the claim: CheckKey = %v, %v", fit, err)
}
if err := ClaimKey(context.Background(), b, other); !errors.Is(err, ErrKeyMismatch) || marker(b) != KeyID(key)+"\n" {
if err := claim(context.Background(), b, other, nil, time.Time{}); !errors.Is(err, ErrKeyMismatch) || marker(b) != KeyID(key)+"\n" {
t.Errorf("another key: err %v, marker %q; want a refusal that leaves the marker", err, marker(b))
}
b = newMemBucket()
putAt(b, "worlds/1.fenc", seal(t, []byte("1"), key), 0)
if err := ClaimKey(context.Background(), b, key); err != nil || marker(b) != KeyID(key)+"\n" {
if err := claim(context.Background(), b, key, nil, time.Time{}); err != nil || marker(b) != KeyID(key)+"\n" {
t.Errorf("unmarked bucket the key opens: err %v, marker %q", err, marker(b))
}
b = newMemBucket()
putAt(b, "worlds/1.fenc", seal(t, []byte("1"), other), 0)
if err := ClaimKey(context.Background(), b, key); !errors.Is(err, ErrKeyMismatch) || b.puts != 0 {
if err := claim(context.Background(), b, key, nil, time.Time{}); !errors.Is(err, ErrKeyMismatch) || b.puts != 0 {
t.Errorf("unmarked bucket under another key: err %v, puts %d; want a refusal that writes nothing", err, b.puts)
}
}
@@ -214,6 +215,9 @@ func TestSyncRefusesAnotherKeysBucket(t *testing.T) {
for i, name := range []string{"felis-db-20260901T030000Z-daily.tar", "felis-db-20260902T030000Z-daily.tar", "felis-db-20260903T030000Z-daily.tar"} {
putAt(b, DBKey(name), seal(t, []byte(name), other), i)
}
// A new host: the wrong key must not record it as the writer either.
l := testLease(t, "")
s.Lease = &l
before := len(b.objs)
_, err := s.Run(context.Background())
@@ -226,6 +230,9 @@ func TestSyncRefusesAnotherKeysBucket(t *testing.T) {
if !cat.rows[0].offsite.IsZero() {
t.Error("the refused run recorded alpha as copied")
}
if _, err := os.Stat(l.IDFile); !errors.Is(err, os.ErrNotExist) {
t.Errorf("the refused run made this host an id: %v", err)
}
})
}
}
+40
View File
@@ -32,6 +32,46 @@ type Status struct {
// with another key (ErrKeyMismatch): no later run copies anything until
// the key is fixed, so the watchdog reports it at once.
KeyMismatch bool `json:"key_mismatch,omitempty"`
// Standby and Displaced are a run refused because another host, Writer,
// writes the bucket (ErrStandby, ErrDisplaced).
Standby bool `json:"standby,omitempty"`
Displaced bool `json:"displaced,omitempty"`
Writer *Writer `json:"writer,omitempty"`
// Format is StatusFormat in every record this release writes; 0 is a
// record from before writers were recorded, whose host had been copying
// to the bucket (Lease.Inherited).
Format int `json:"format,omitempty"`
// Inherited carries Lease.Inherited over runs that ended before the host
// recorded itself (an unreachable bucket on the first run after the
// upgrade), until it has an id.
Inherited bool `json:"inherited,omitempty"`
}
// StatusFormat marks a status record that knows about felis-writer.
const StatusFormat = 2
// StandsBy is the host this one stands by for: the last run was refused
// because that host writes the bucket, and it wrote it within WriterLive of
// now. While it keeps writing, this host is a rehearsal (or a rebuild not yet
// taken over), and the owners in its restored database are that host's: the
// watchdog here mails them nothing.
func (st *Status) StandsBy(now time.Time) *Writer {
if st == nil || !st.Standby || st.Writer == nil || now.Sub(st.Writer.At) > WriterLive {
return nil
}
return st.Writer
}
// HostLease is the Lease of the host whose status file is statusFile: its id
// next to it, and Inherited when that file was written by an older release
// (or carries Inherited from one).
func HostLease(statusFile string) Lease {
host, _ := os.Hostname()
l := Lease{IDFile: filepath.Join(filepath.Dir(statusFile), HostIDFile), Host: host}
if prev, err := ReadStatus(statusFile); err == nil && prev != nil && (prev.Format == 0 || prev.Inherited) {
l.Inherited = true
}
return l
}
// ReadStatus reads the status file. A missing file is (nil, nil): no sync has
+5 -2
View File
@@ -104,6 +104,8 @@ type Syncer struct {
UploadsDir string
// UploadGrace and MinRate bound one object's upload: UploadGrace plus the
// time the object takes at MinRate bytes a second. Zero takes the defaults.
// Lease names this host in felis-writer (writer.go); nil checks no writer.
Lease *Lease
UploadGrace time.Duration
MinRate int64
Now func() time.Time
@@ -201,8 +203,9 @@ func (s *Syncer) Run(ctx context.Context) (Result, error) {
}
// Before anything is written or pruned: objects sealed with another key
// are copies only that key opens, and DBKeep would prune them.
if err := ClaimKey(ctx, s.Bucket, s.Key); err != nil {
// are copies only that key opens, and DBKeep would prune them; a bucket
// another host writes is that host's to prune.
if err := claim(ctx, s.Bucket, s.Key, s.Lease, s.now()); err != nil {
return res, err
}
remoteWorlds, err := s.listSizes(ctx, worldsDir)
+263
View File
@@ -0,0 +1,263 @@
package offsite
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
"unicode"
)
// writerMark names the host that writes the bucket. A rehearsal on a spare
// machine restores the production host's /etc/felis, [offsite] and its key
// included: without the record the spare would copy its bundles into the
// production prefix and prune the production host's by DBKeep. The writer
// rewrites it on every run; a host restored from its backup finds another
// host named there and stands by, writing nothing, until `felis offsite
// take-over`.
const writerMark = "felis-writer"
// WriterLive is how recently the writer must have run for a standby host to
// count it as alive. Both run hourly, so a writer seen within it is one that
// ran in the last two hours.
const WriterLive = 3 * time.Hour
// HostIDFile is the file, next to the status file, holding this host's id. It
// is written when the host first writes the bucket and never leaves the host
// (a bundle carries /etc/felis only), so a host restored from a bundle has
// none.
const HostIDFile = "host-id"
var (
// ErrStandby is a run refused because another host writes the bucket and
// this one never has.
ErrStandby = errors.New("offsite: another host writes this bucket")
// ErrDisplaced is a run refused because another host took the bucket
// over from this one.
ErrDisplaced = errors.New("offsite: another host took this bucket over")
)
const takeOverHint = "sudo felis offsite take-over -yes (docs/troubleshooting.md §16)"
// Writer is the felis-writer record.
type Writer struct {
HostID string `json:"host_id"`
Host string `json:"host"`
At time.Time `json:"at"`
}
func (w *Writer) String() string {
return fmt.Sprintf("host %s (id %s)", w.Host, w.HostID)
}
// WriterError is a run refused because another host writes the bucket.
type WriterError struct {
// Kind is ErrStandby or ErrDisplaced.
Kind error
// Writer is the host named in the bucket; nil for a bucket that holds
// another host's copies and names no writer.
Writer *Writer
}
func (e *WriterError) Error() string {
switch {
case e.Kind == ErrDisplaced:
return fmt.Sprintf("%v: %s writes it now (last at %s), and this host copies nothing there any more; if that host is a rehearsal machine, take the bucket back here: %s",
e.Kind, e.Writer, e.Writer.At.UTC().Format(time.RFC3339), takeOverHint)
case e.Writer != nil:
return fmt.Sprintf("%v: %s writes it (last at %s); this host was built from its backup and copies nothing there, until it replaces that host for good: %s",
e.Kind, e.Writer, e.Writer.At.UTC().Format(time.RFC3339), takeOverHint)
default:
return fmt.Sprintf("%v: the bucket holds copies this host did not write and names no host writing it; this host copies nothing there, until it replaces that host for good: %s",
e.Kind, takeOverHint)
}
}
func (e *WriterError) Unwrap() error { return e.Kind }
// Role is what a host's next run does with the bucket.
type Role int
const (
// RoleWrites: the bucket names this host.
RoleWrites Role = iota + 1
// RoleClaims: the bucket names no host, and this one records itself.
RoleClaims
// RoleStandby: another host writes the bucket, and this one never has.
RoleStandby
// RoleDisplaced: another host took the bucket over from this one.
RoleDisplaced
)
// Lease is this host's side of felis-writer.
type Lease struct {
// IDFile is this host's id (HostIDFile next to the status file).
IDFile string
// Host is this host's name, shown to the others.
Host string
// Inherited is a host that copied to the bucket before writers were
// recorded: a status file an older release wrote. It claims a bucket
// that names no writer.
Inherited bool
}
// BucketWriter reads the felis-writer record, nil when there is none.
func BucketWriter(ctx context.Context, b Bucket) (*Writer, error) {
rc, err := b.Get(ctx, writerMark)
if errors.Is(err, ErrNotFound) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("read %s: %w", writerMark, err)
}
defer rc.Close()
raw, err := io.ReadAll(io.LimitReader(rc, 1024))
if err != nil {
return nil, fmt.Errorf("read %s: %w", writerMark, err)
}
var w Writer
if json.Unmarshal(raw, &w) != nil || !keyIDPattern.MatchString(w.HostID) {
return nil, fmt.Errorf("offsite: %s in the bucket is not a record Felis wrote", writerMark)
}
w.Host = printable(w.Host)
return &w, nil
}
// Plan says what this host's next run does with the bucket, and the host the
// bucket names (nil for none). empty is a bucket holding no sealed object
// (CheckKey's KeyUnused), which any host may claim.
func (l Lease) Plan(ctx context.Context, b Bucket, empty bool) (Role, *Writer, error) {
w, err := BucketWriter(ctx, b)
if err != nil {
return 0, nil, err
}
mine, err := l.ID()
if err != nil {
return 0, nil, err
}
switch {
case w != nil && w.HostID == mine:
return RoleWrites, w, nil
case w != nil && mine != "":
return RoleDisplaced, w, nil
case w != nil:
return RoleStandby, w, nil
case mine != "" || l.Inherited || empty:
return RoleClaims, nil, nil
default:
return RoleStandby, nil, nil
}
}
// Acquire is Plan before a run writes anything: a host that writes or claims
// the bucket records itself there at now; one that stands by or was displaced
// gets a *WriterError and writes nothing.
func (l Lease) Acquire(ctx context.Context, b Bucket, empty bool, now time.Time) error {
role, w, err := l.Plan(ctx, b, empty)
if err != nil {
return err
}
switch role {
case RoleStandby:
return &WriterError{Kind: ErrStandby, Writer: w}
case RoleDisplaced:
return &WriterError{Kind: ErrDisplaced, Writer: w}
}
return l.record(ctx, b, now)
}
// TakeOver records this host as the bucket's writer whatever the bucket named,
// and returns the writer it replaced (nil for none). That host's next run is
// refused with ErrDisplaced.
func (l Lease) TakeOver(ctx context.Context, b Bucket, now time.Time) (*Writer, error) {
prev, err := BucketWriter(ctx, b)
if err != nil {
return nil, err
}
return prev, l.record(ctx, b, now)
}
// record writes this host into felis-writer, creating its id first: a host
// whose id is on disk but not in the bucket claims the bucket on its next run,
// where one named in the bucket without an id on disk would stand by for
// itself.
func (l Lease) record(ctx context.Context, b Bucket, now time.Time) error {
id, err := l.ID()
if err != nil {
return err
}
if id == "" {
if id, err = l.newID(); err != nil {
return err
}
}
raw, err := json.Marshal(Writer{HostID: id, Host: printable(l.Host), At: now.UTC()})
if err != nil {
return err
}
raw = append(raw, '\n')
if err := b.Put(ctx, writerMark, strings.NewReader(string(raw)), int64(len(raw))); err != nil {
return fmt.Errorf("record this host in %s: %w", writerMark, err)
}
return nil
}
// ID is this host's id, "" when it has never written a bucket.
func (l Lease) ID() (string, error) {
raw, err := os.ReadFile(l.IDFile)
if errors.Is(err, os.ErrNotExist) {
return "", nil
}
if err != nil {
return "", fmt.Errorf("read this host's id: %w", err)
}
id := strings.TrimSpace(string(raw))
if !keyIDPattern.MatchString(id) {
return "", fmt.Errorf("offsite: %s is not a host id Felis wrote; remove it and run sudo felis offsite take-over -yes", l.IDFile)
}
return id, nil
}
func (l Lease) newID() (string, error) {
var b [8]byte
if _, err := rand.Read(b[:]); err != nil {
return "", err
}
id := hex.EncodeToString(b[:])
if err := os.MkdirAll(filepath.Dir(l.IDFile), 0o700); err != nil {
return "", fmt.Errorf("record this host's id: %w", err)
}
tmp := l.IDFile + ".tmp"
if err := os.WriteFile(tmp, []byte(id+"\n"), 0o600); err != nil {
return "", fmt.Errorf("record this host's id: %w", err)
}
if err := os.Rename(tmp, l.IDFile); err != nil {
return "", fmt.Errorf("record this host's id: %w", err)
}
return id, nil
}
// printable keeps a host name fit for a message: at most 64 printable runes,
// "unknown" for none.
func printable(s string) string {
s = strings.Map(func(r rune) rune {
if unicode.IsPrint(r) {
return r
}
return -1
}, s)
if r := []rune(s); len(r) > 64 {
s = string(r[:64])
}
if strings.TrimSpace(s) == "" {
return "unknown"
}
return s
}
+313
View File
@@ -0,0 +1,313 @@
package offsite
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
const (
myID = "aaaaaaaaaaaaaaaa"
otherID = "bbbbbbbbbbbbbbbb"
)
func putWriter(t *testing.T, b *memBucket, id, host string, at time.Time) {
t.Helper()
raw, err := json.Marshal(Writer{HostID: id, Host: host, At: at})
if err != nil {
t.Fatal(err)
}
b.objs[writerMark] = raw
}
// testLease is a lease whose id file is in a temp dir, holding id unless it
// is "".
func testLease(t *testing.T, id string) Lease {
t.Helper()
l := Lease{IDFile: filepath.Join(t.TempDir(), HostIDFile), Host: "spare-1"}
if id != "" {
if err := os.WriteFile(l.IDFile, []byte(id+"\n"), 0o600); err != nil {
t.Fatal(err)
}
}
return l
}
func TestLeasePlan(t *testing.T) {
at := now.Add(-20 * time.Minute)
for _, tc := range []struct {
what string
mine string
inherited bool
writer string // the id felis-writer names, "" for none
empty bool
want Role
}{
{"an empty bucket, a new host", "", false, "", true, RoleClaims},
{"another host's copies, no writer named, a new host", "", false, "", false, RoleStandby},
{"another host's copies, no writer named, a host that copied before writers were recorded", "", true, "", false, RoleClaims},
{"no writer named, a host that wrote before", myID, false, "", false, RoleClaims},
{"this host named", myID, false, myID, false, RoleWrites},
{"another host named, a host that wrote before", myID, false, otherID, false, RoleDisplaced},
{"another host named, a new host", "", false, otherID, false, RoleStandby},
{"another host named, a host that copied before writers were recorded", "", true, otherID, false, RoleStandby},
{"another host named over an empty bucket", "", false, otherID, true, RoleStandby},
} {
t.Run(tc.what, func(t *testing.T) {
b := newMemBucket()
if tc.writer != "" {
putWriter(t, b, tc.writer, "prod-1", at)
}
l := testLease(t, tc.mine)
l.Inherited = tc.inherited
role, w, err := l.Plan(context.Background(), b, tc.empty)
if err != nil || role != tc.want {
t.Fatalf("Plan = %v, %v; want %v", role, err, tc.want)
}
if (w != nil) != (tc.writer != "") || (w != nil && (w.HostID != tc.writer || w.Host != "prod-1" || !w.At.Equal(at))) {
t.Errorf("Plan named %+v, want the bucket's writer %q", w, tc.writer)
}
if b.puts != 0 {
t.Errorf("Plan wrote %d objects", b.puts)
}
})
}
b := newMemBucket()
b.objs[writerMark] = []byte("hello")
if _, _, err := testLease(t, "").Plan(context.Background(), b, true); err == nil || !strings.Contains(err.Error(), "not a record Felis wrote") {
t.Errorf("a record Felis did not write: err = %v", err)
}
putWriter(t, b, "../../etc", "prod-1", at)
if _, _, err := testLease(t, "").Plan(context.Background(), b, true); err == nil {
t.Error("a record with an id Felis does not make was taken")
}
b = newMemBucket()
b.getErr = map[string]error{writerMark: errors.New("connection reset")}
if _, _, err := testLease(t, "").Plan(context.Background(), b, true); err == nil || !strings.Contains(err.Error(), "connection reset") {
t.Errorf("an unreadable record: err = %v, want the read error", err)
}
b = newMemBucket()
if _, _, err := testLease(t, "not-an-id").Plan(context.Background(), b, true); err == nil || !strings.Contains(err.Error(), "not a host id Felis wrote") {
t.Errorf("a damaged id file: err = %v", err)
}
}
func TestLeaseAcquire(t *testing.T) {
ctx := context.Background()
// A new host claims an empty bucket: its id is created and recorded.
b := newMemBucket()
l := testLease(t, "")
if err := l.Acquire(ctx, b, true, now); err != nil {
t.Fatal(err)
}
id, err := l.ID()
if err != nil || !keyIDPattern.MatchString(id) {
t.Fatalf("id after the claim = %q, %v", id, err)
}
if fi, err := os.Stat(l.IDFile); err != nil || fi.Mode().Perm() != 0o600 {
t.Errorf("id file: %v, %v", fi, err)
}
w, err := BucketWriter(ctx, b)
if err != nil || w == nil || w.HostID != id || w.Host != "spare-1" || !w.At.Equal(now) {
t.Fatalf("record after the claim = %+v, %v", w, err)
}
// Its next run keeps the id and moves the time on.
later := now.Add(time.Hour)
if err := l.Acquire(ctx, b, false, later); err != nil {
t.Fatal(err)
}
if w, _ := BucketWriter(ctx, b); w.HostID != id || !w.At.Equal(later) {
t.Errorf("record after the next run = %+v, want %s at %s", w, id, later)
}
// A host restored from its backup stands by: nothing written, no id made.
spare := testLease(t, "")
puts := b.puts
err = spare.Acquire(ctx, b, false, later)
var we *WriterError
if !errors.Is(err, ErrStandby) || !errors.As(err, &we) || we.Writer == nil || we.Writer.HostID != id {
t.Fatalf("spare: err = %v, want ErrStandby naming %s", err, id)
}
if b.puts != puts {
t.Error("the standby host wrote to the bucket")
}
if _, err := os.Stat(spare.IDFile); !errors.Is(err, os.ErrNotExist) {
t.Errorf("the standby host made an id: %v", err)
}
// The spare takes over; the first host is displaced.
prev, err := spare.TakeOver(ctx, b, later)
if err != nil || prev == nil || prev.HostID != id {
t.Fatalf("TakeOver = %+v, %v; want the first host replaced", prev, err)
}
spareID, _ := spare.ID()
if spareID == "" || spareID == id {
t.Fatalf("spare id after the take-over = %q", spareID)
}
puts = b.puts
err = l.Acquire(ctx, b, false, later)
if !errors.Is(err, ErrDisplaced) || !errors.As(err, &we) || we.Writer.HostID != spareID {
t.Fatalf("first host after the take-over: err = %v, want ErrDisplaced naming %s", err, spareID)
}
if b.puts != puts {
t.Error("the displaced host wrote to the bucket")
}
// A host name is kept printable and short for the messages that show it.
b = newMemBucket()
l = testLease(t, "")
l.Host = "evil\x1b[2J\n" + strings.Repeat("x", 80)
if err := l.Acquire(ctx, b, true, now); err != nil {
t.Fatal(err)
}
if w, _ := BucketWriter(ctx, b); w.Host != "evil[2J"+strings.Repeat("x", 57) {
t.Errorf("recorded host = %q", w.Host)
}
// A record that cannot be written fails the run.
b = newMemBucket()
b.putErr[writerMark] = errors.New("access denied")
if err := testLease(t, "").Acquire(ctx, b, true, now); err == nil || !strings.Contains(err.Error(), "access denied") {
t.Errorf("unwritable record: err = %v", err)
}
}
func TestWriterErrorSays(t *testing.T) {
w := &Writer{HostID: otherID, Host: "prod-1", At: now}
for _, tc := range []struct {
err *WriterError
kind error
says []string
}{
{&WriterError{Kind: ErrStandby, Writer: w}, ErrStandby, []string{"host prod-1 (id " + otherID + ")", "2026-09-24T12:00:00Z", "built from its backup", "take-over -yes"}},
{&WriterError{Kind: ErrStandby}, ErrStandby, []string{"names no host writing it", "take-over -yes"}},
{&WriterError{Kind: ErrDisplaced, Writer: w}, ErrDisplaced, []string{"host prod-1 (id " + otherID + ") writes it now", "rehearsal machine", "take-over -yes"}},
} {
msg := tc.err.Error()
if !errors.Is(tc.err, tc.kind) {
t.Errorf("%q is not %v", msg, tc.kind)
}
for _, s := range tc.says {
if !strings.Contains(msg, s) {
t.Errorf("%q lacks %q", msg, s)
}
}
}
}
func TestStandsBy(t *testing.T) {
w := &Writer{HostID: otherID, Host: "prod-1", At: now.Add(-2 * time.Hour)}
for _, tc := range []struct {
what string
st *Status
at time.Time
want bool
}{
{"a standby run, the writer seen two hours ago", &Status{Standby: true, Writer: w}, now, true},
{"a standby run, the writer gone quiet", &Status{Standby: true, Writer: w}, now.Add(WriterLive), false},
{"a standby run, no writer named", &Status{Standby: true}, now, false},
{"a displaced run", &Status{Displaced: true, Writer: w}, now, false},
{"a run that copied", &Status{Writer: w}, now, false},
{"no run yet", nil, now, false},
} {
if got := tc.st.StandsBy(tc.at); (got != nil) != tc.want {
t.Errorf("%s: StandsBy = %+v, want %v", tc.what, got, tc.want)
}
}
}
func TestHostLease(t *testing.T) {
dir := t.TempDir()
status := filepath.Join(dir, "status.json")
if l := HostLease(status); l.IDFile != filepath.Join(dir, HostIDFile) || l.Inherited || l.Host == "" {
t.Errorf("no status yet: %+v", l)
}
for _, tc := range []struct {
what string
st Status
want bool
}{
{"a status an older release wrote", Status{LastAttempt: now}, true},
{"a status this release wrote", Status{LastAttempt: now, Format: StatusFormat}, false},
{"a status that carries the older release's claim", Status{LastAttempt: now, Format: StatusFormat, Inherited: true}, true},
} {
if err := WriteStatus(status, tc.st); err != nil {
t.Fatal(err)
}
if got := HostLease(status).Inherited; got != tc.want {
t.Errorf("%s: Inherited = %v, want %v", tc.what, got, tc.want)
}
}
if err := os.WriteFile(status, []byte("{"), 0o600); err != nil {
t.Fatal(err)
}
if HostLease(status).Inherited {
t.Error("an unreadable status counted as an older release's")
}
}
// TestSyncStandsBy: a host restored from the writer's backup copies nothing
// into the bucket, prunes nothing, and records nothing as copied, whether the
// bucket names that host or holds its copies without naming anyone.
func TestSyncStandsBy(t *testing.T) {
for _, named := range []bool{true, false} {
t.Run(fmt.Sprintf("named=%v", named), func(t *testing.T) {
cat := &fakeCatalog{rows: []*row{
{WorldBackup: WorldBackup{ID: "b1", Server: "alpha", Ref: "/a/alpha-1.tar.gz"}, status: "present"},
}}
s, b := newSyncer(t, cat)
delete(b.objs, keyMark)
if named {
putWriter(t, b, otherID, "prod-1", now.Add(-30*time.Minute))
}
writeFile(t, s.ArchiveDir, "alpha-1.tar.gz", 100)
writeFile(t, s.DBDir, "felis-db-20260924T030000Z-daily.tar", 50)
for i, name := range []string{"felis-db-20260901T030000Z-daily.tar", "felis-db-20260902T030000Z-daily.tar", "felis-db-20260903T030000Z-daily.tar"} {
putAt(b, DBKey(name), seal(t, []byte(name), s.Key), i)
}
l := testLease(t, "")
s.Lease = &l
before := len(b.objs)
_, err := s.Run(context.Background())
if !errors.Is(err, ErrStandby) {
t.Fatalf("Run error = %v, want ErrStandby", err)
}
if len(b.started) != 0 || len(b.removed) != 0 || len(b.objs) != before {
t.Errorf("the standby run began puts %v and removed %v", b.started, b.removed)
}
if !cat.rows[0].offsite.IsZero() {
t.Error("the standby run recorded alpha as copied")
}
})
}
}
// TestSyncRecordsTheWriter: the first run records this host before the key
// id and the first upload.
func TestSyncRecordsTheWriter(t *testing.T) {
s, b := newSyncer(t, &fakeCatalog{})
delete(b.objs, keyMark)
writeFile(t, s.DBDir, "felis-db-20260924T030000Z-daily.tar", 50)
l := testLease(t, "")
s.Lease = &l
if _, err := s.Run(context.Background()); err != nil {
t.Fatal(err)
}
id, _ := l.ID()
if w, err := BucketWriter(context.Background(), b); err != nil || w == nil || w.HostID != id {
t.Fatalf("record = %+v, %v; want %s", w, err, id)
}
if len(b.started) != 3 || b.started[0] != writerMark || b.started[1] != keyMark {
t.Errorf("puts began in the order %v, want the writer, the key id, then the bundle", b.started)
}
}