256 lines
9.2 KiB
Go
256 lines
9.2 KiB
Go
package offsite
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// putAt stores data under key as modified at t0 plus min minutes.
|
|
func putAt(b *memBucket, key string, data []byte, min int) {
|
|
if b.modified == nil {
|
|
b.modified = map[string]time.Time{}
|
|
}
|
|
b.objs[key] = data
|
|
b.modified[key] = now.Add(time.Duration(min) * time.Minute)
|
|
}
|
|
|
|
func TestCheckKey(t *testing.T) {
|
|
key, other := testKey(t), testKey(t)
|
|
big := make([]byte, 3*segmentSize)
|
|
tailDamaged := seal(t, big, key)
|
|
tailDamaged[len(tailDamaged)-1] ^= 1
|
|
for _, tc := range []struct {
|
|
what string
|
|
bucket func(b *memBucket)
|
|
fit KeyFit
|
|
err string // "" for none
|
|
named []string // what a refusal names
|
|
skip []string // what it must leave out
|
|
}{
|
|
{what: "an empty bucket", bucket: func(b *memBucket) {}, fit: KeyUnused},
|
|
{what: "nothing Felis sealed", bucket: func(b *memBucket) { putAt(b, "db/notes.txt", []byte("hi"), 0) }, fit: KeyUnused},
|
|
{
|
|
what: "the recorded id", fit: KeyRecorded,
|
|
bucket: func(b *memBucket) {
|
|
b.objs[keyMark] = []byte(KeyID(key) + "\n")
|
|
putAt(b, "worlds/a.tar.gz.fenc", seal(t, []byte("a"), other), 0) // the id is the judge
|
|
},
|
|
},
|
|
{
|
|
what: "another recorded id",
|
|
bucket: func(b *memBucket) { b.objs[keyMark] = []byte(KeyID(other) + "\n") },
|
|
err: "sealed with another key", named: []string{KeyID(other), KeyID(key), "offsite.env"},
|
|
},
|
|
{
|
|
what: "a marker Felis did not write",
|
|
bucket: func(b *memBucket) { b.objs[keyMark] = []byte("hello") },
|
|
err: "not a key id Felis wrote",
|
|
},
|
|
{
|
|
what: "an unmarked bucket the key opens", fit: KeyOpens,
|
|
bucket: func(b *memBucket) {
|
|
putAt(b, "db/felis-db-20260101T030000Z-daily.tar.fenc", seal(t, []byte("old"), other), 0)
|
|
putAt(b, "registry/blobs/aa.fenc", seal(t, []byte("new"), key), 10)
|
|
},
|
|
},
|
|
{
|
|
what: "only the first segment is read", fit: KeyOpens,
|
|
bucket: func(b *memBucket) { putAt(b, "uploads/blobs/bb.fenc", tailDamaged, 0) },
|
|
},
|
|
{
|
|
what: "an unmarked bucket sealed with another key",
|
|
bucket: func(b *memBucket) {
|
|
for i, k := range []string{"worlds/1.fenc", "worlds/2.fenc", "db/3.fenc", "uploads/index/4.json.fenc"} {
|
|
putAt(b, k, seal(t, []byte(k), other), i)
|
|
}
|
|
},
|
|
err: "sealed with another key", named: []string{"uploads/index/4.json.fenc", "db/3.fenc", "worlds/2.fenc", KeyID(key)}, skip: []string{"worlds/1.fenc"},
|
|
},
|
|
{
|
|
what: "two refusals, then an object the key opens", fit: KeyOpens,
|
|
bucket: func(b *memBucket) {
|
|
putAt(b, "worlds/1.fenc", seal(t, []byte("1"), key), 0)
|
|
putAt(b, "worlds/2.fenc", seal(t, []byte("2"), other), 1)
|
|
putAt(b, "worlds/3.fenc", seal(t, []byte("3"), other), 2)
|
|
},
|
|
},
|
|
{
|
|
what: "damaged objects are passed over", fit: KeyOpens,
|
|
bucket: func(b *memBucket) {
|
|
putAt(b, "worlds/1.fenc", seal(t, []byte("1"), key), 0)
|
|
putAt(b, "worlds/2.fenc", []byte("partial"), 1)
|
|
},
|
|
},
|
|
{
|
|
what: "a refusal among damaged objects",
|
|
bucket: func(b *memBucket) {
|
|
putAt(b, "worlds/1.fenc", seal(t, []byte("1"), other), 0)
|
|
putAt(b, "worlds/2.fenc", []byte("partial"), 1)
|
|
},
|
|
err: "sealed with another key", named: []string{"worlds/1.fenc"},
|
|
},
|
|
{
|
|
what: "nothing it can judge",
|
|
bucket: func(b *memBucket) {
|
|
putAt(b, "worlds/1.fenc", seal(t, []byte("1"), key), 0)
|
|
for i := range keyTries {
|
|
putAt(b, fmt.Sprintf("worlds/junk-%d.fenc", i), []byte("partial"), 1+i)
|
|
}
|
|
},
|
|
err: "cannot tell which key", named: []string{"worlds/junk-"},
|
|
},
|
|
} {
|
|
t.Run(tc.what, func(t *testing.T) {
|
|
b := newMemBucket()
|
|
tc.bucket(b)
|
|
fit, err := CheckKey(context.Background(), b, key)
|
|
if b.puts != 0 {
|
|
t.Errorf("CheckKey wrote %d objects", b.puts)
|
|
}
|
|
if tc.err == "" {
|
|
if err != nil || fit != tc.fit {
|
|
t.Fatalf("CheckKey = %v, %v; want %v", fit, err, tc.fit)
|
|
}
|
|
return
|
|
}
|
|
if err == nil || !strings.Contains(err.Error(), tc.err) {
|
|
t.Fatalf("CheckKey = %v, %v; want an error saying %q", fit, err, tc.err)
|
|
}
|
|
if mismatch := tc.err == "sealed with another key"; errors.Is(err, ErrKeyMismatch) != mismatch {
|
|
t.Errorf("errors.Is(err, ErrKeyMismatch) = %v, want %v: %v", !mismatch, mismatch, err)
|
|
}
|
|
for _, s := range tc.named {
|
|
if !strings.Contains(err.Error(), s) {
|
|
t.Errorf("error lacks %q: %v", s, err)
|
|
}
|
|
}
|
|
for _, s := range tc.skip {
|
|
if strings.Contains(err.Error(), s) {
|
|
t.Errorf("error names %q: %v", s, err)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
// A bucket that does not answer is not a mismatch.
|
|
b := newMemBucket()
|
|
putAt(b, "worlds/1.fenc", seal(t, []byte("1"), other), 0)
|
|
down := errors.New("connection reset")
|
|
b.getErr = map[string]error{"worlds/1.fenc": down}
|
|
if _, err := CheckKey(context.Background(), b, key); !errors.Is(err, down) || errors.Is(err, ErrKeyMismatch) {
|
|
t.Errorf("unreachable object: err = %v, want the read error", err)
|
|
}
|
|
b.getErr = map[string]error{keyMark: down}
|
|
if _, err := CheckKey(context.Background(), b, key); !errors.Is(err, down) || errors.Is(err, ErrKeyMismatch) {
|
|
t.Errorf("unreachable marker: err = %v, want the read error", err)
|
|
}
|
|
|
|
// An object pruned between the listing and the read is passed over.
|
|
b = newMemBucket()
|
|
putAt(b, "worlds/1.fenc", seal(t, []byte("1"), key), 0)
|
|
putAt(b, "db/2.fenc", seal(t, []byte("2"), key), 1)
|
|
b.getErr = map[string]error{"db/2.fenc": fmt.Errorf("%w: db/2.fenc", ErrNotFound)}
|
|
if fit, err := CheckKey(context.Background(), b, key); fit != KeyOpens || err != nil {
|
|
t.Errorf("an object gone since the listing: CheckKey = %v, %v; want KeyOpens", fit, err)
|
|
}
|
|
}
|
|
|
|
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 := 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 := 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 := 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 := 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 := 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)
|
|
}
|
|
}
|
|
|
|
// TestSyncRefusesAnotherKeysBucket: a host whose key does not match the
|
|
// bucket's objects neither copies into it nor prunes what only the right key
|
|
// opens.
|
|
func TestSyncRefusesAnotherKeysBucket(t *testing.T) {
|
|
for _, marked := range []bool{true, false} {
|
|
t.Run(fmt.Sprintf("marked=%v", marked), 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)
|
|
other := testKey(t)
|
|
delete(b.objs, keyMark)
|
|
if marked {
|
|
b.objs[keyMark] = []byte(KeyID(other) + "\n")
|
|
}
|
|
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), 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())
|
|
if !errors.Is(err, ErrKeyMismatch) {
|
|
t.Fatalf("Run error = %v, want ErrKeyMismatch", err)
|
|
}
|
|
if len(b.started) != 0 || len(b.removed) != 0 || len(b.objs) != before {
|
|
t.Errorf("the refused run began puts %v and removed %v", b.started, b.removed)
|
|
}
|
|
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)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestSyncRecordsTheKey: the first run into a bucket records its key before
|
|
// the first upload.
|
|
func TestSyncRecordsTheKey(t *testing.T) {
|
|
s, b := newSyncer(t, &fakeCatalog{})
|
|
delete(b.objs, keyMark)
|
|
writeFile(t, s.DBDir, "felis-db-20260924T030000Z-daily.tar", 50)
|
|
if _, err := s.Run(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !bytes.Equal(b.objs[keyMark], []byte(KeyID(s.Key)+"\n")) {
|
|
t.Fatalf("marker = %q, want %s", b.objs[keyMark], KeyID(s.Key))
|
|
}
|
|
if len(b.started) != 2 || b.started[0] != keyMark {
|
|
t.Errorf("puts began in the order %v, want the marker, then the bundle", b.started)
|
|
}
|
|
}
|