carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package host
2
3import (
4 "context"
5 "crypto/sha256"
6 "database/sql"
7 "encoding/hex"
8 "encoding/json"
9 "errors"
10 "fmt"
11 "io"
12 "log/slog"
13 "net/http/httptest"
14 "os"
15 "path/filepath"
16 "strconv"
17 "strings"
18 "testing"
19 "time"
20
21 "github.com/benbjohnson/litestream"
22 lsfile "github.com/benbjohnson/litestream/file"
23 "github.com/carlosframework/platform/internal/genselect"
24 "github.com/carlosframework/platform/internal/hostbox"
25 "github.com/carlosframework/platform/internal/hostclaim"
26 execlease "github.com/carlosframework/platform/internal/hostexec"
27 "github.com/carlosframework/platform/internal/hostfiles"
28 "github.com/carlosframework/platform/internal/hostguard"
29 "github.com/carlosframework/platform/internal/hostrelocate"
30 "github.com/carlosframework/platform/internal/lease"
31 "github.com/carlosframework/platform/internal/lease/leasetest"
32 "github.com/carlosframework/platform/internal/moves"
33 "github.com/carlosframework/platform/internal/release"
34 "github.com/superfly/ltx"
35)
36
37const buildHost = "shop.example"
38
39// buildFixture is a relocation target at installing on an empty box: the
40// claim journals a pin of a real litestream replica sealed into a
41// versioned bucket (served over a loopback S3 endpoint, so the replica
42// lease is lease.Acquire's own), a retained files manifest with one entry,
43// an empty guard directory, and main's Local recorded.
44type buildFixture struct {
45 t *testing.T
46 claims *release.DirStore
47 files *hookedStore
48 bucket *leasetest.VersionedBucket
49 versions *hookedVersions
50 a ClaimAssembly
51 cfg RelocationAssemblyConfig
52 obs hostclaim.Observed
53 locals []hostbox.Selection
54 localErr error
55 // localHook, when set, is asked on every Local check that localErr
56 // passes: a test loses the lease at one write group.
57 localHook func(hostbox.Selection) error
58 verifier PreparedFromInstallManifest
59}
60
61// hookedStore and hookedVersions run after, once a write has landed, so a
62// test can move the build's authority between two durable writes.
63type hookedStore struct {
64 release.Store
65 afterPut func(key string)
66}
67
68func (s *hookedStore) Put(ctx context.Context, key string, body []byte, etag string) (string, error) {
69 out, err := s.Store.Put(ctx, key, body, etag)
70 if err == nil && s.afterPut != nil {
71 s.afterPut(key)
72 }
73 return out, err
74}
75
76type hookedVersions struct {
77 lease.VersionedStore
78 afterPut func(key string)
79}
80
81func (s *hookedVersions) PutIfAbsent(ctx context.Context, bucket, key string, body []byte) (string, error) {
82 out, err := s.VersionedStore.PutIfAbsent(ctx, bucket, key, body)
83 if err == nil && s.afterPut != nil {
84 s.afterPut(key)
85 }
86 return out, err
87}
88
89// buildOptions shape a buildFixture: a pin with or without a database, the
90// data root (a fresh one with a hostguard directory when empty), and the
91// pinned manifest's entries and platform items, each stored as an object
92// under the source generation's prefix. NoFiles pins a host that never
93// published files instead (no digest, no retained copy: the pinner's
94// evidenced empty files set), and Files and Platform are ignored.
95type buildOptions struct {
96 DB bool
97 Data string
98 Files map[string]string
99 Platform map[string]string
100 NoFiles bool
101}
102
103func newBuildFixture(t *testing.T, withDB bool) *buildFixture {
104 return newBuildFixtureWith(t, buildOptions{DB: withDB, Files: map[string]string{"public/hello.txt": "hello"}})
105}
106
107func newBuildFixtureWith(t *testing.T, o buildOptions) *buildFixture {
108 t.Helper()
109 ctx := t.Context()
110 withDB := o.DB
111 f := &buildFixture{t: t, claims: release.NewDirStore(t.TempDir()), bucket: leasetest.NewVersionedBucket()}
112 f.bucket.AddBucket("data")
113 // P's files manifests and the pinned objects live in the versioned data
114 // bucket, as production's files store and versioned client are one bucket.
115 f.files = &hookedStore{Store: bucketStore{b: f.bucket, bucket: "data"}}
116 f.versions = &hookedVersions{VersionedStore: f.bucket}
117 handler, _ := leasetest.NewS3Server(f.bucket, "data")
118 srv := httptest.NewServer(handler) // 127.0.0.1 only
119 t.Cleanup(srv.Close)
120 t.Setenv("CARLOS_S3_ENDPOINT", srv.URL)
121 t.Setenv("AWS_ACCESS_KEY_ID", "test-key")
122 t.Setenv("AWS_SECRET_ACCESS_KEY", "test-secret")
123 t.Setenv("AWS_SESSION_TOKEN", "")
124 t.Setenv("AWS_PROFILE", "")
125 t.Setenv("AWS_REGION", "")
126
127 created := time.Date(2026, 10, 7, 1, 0, 0, 0, time.UTC)
128 enrollment := strings.Repeat("a", 32)
129 nh := hostclaim.NewHolder{Family: "instance", Key: "apps/acct/app/instances/" + buildHost + ".json", Account: "acct", App: "app", Host: buildHost, Region: "ie", RecordRegion: "ie", CreatedAt: created}
130 body := []byte(`{"host":"` + buildHost + `","account":"acct","app":"app","region":"ie","created_at":"2026-10-07T01:00:00Z"}`)
131 obs, err := hostclaim.Read(ctx, f.claims, buildHost)
132 if err != nil {
133 t.Fatal(err)
134 }
135 for _, tr := range []func(*hostclaim.Claim) error{
136 hostclaim.BeginCreateOn(nh, body, "c1", "console", created, hostclaim.ExecutorSelection{Box: "src", Enrollment: enrollment}),
137 hostclaim.CompleteCreate("c1", "src"),
138 hostclaim.AdmitWriter(hostclaim.Admission{Box: "src", Mode: "cold", NewGeneration: "g1", Region: "ie", KeepStamp: true}),
139 } {
140 if obs, err = hostclaim.Update(ctx, f.claims, obs, tr); err != nil {
141 t.Fatal(err)
142 }
143 }
144 location := obs.Claim.Holder.Location
145 const op = "rel-1"
146
147 pin := hostrelocate.PinObject{V: 1, Host: buildHost, Op: op, ClaimGen: obs.Claim.Gen, PinnedAt: created, Release: "v1"}
148 if withDB {
149 files, n := sealedReplica(t, f.bucket, moves.GenerationPrefix(location, "g1"))
150 pin.DB = &genselect.Plan{Generation: "g1", Files: files, MaxTXID: n, MaxLevel0TXID: n}
151 } else {
152 pin.Empty = "session-0"
153 }
154 srcPrefix := location + "/files/g-g1/"
155 pinned := hostfiles.Manifest{V: hostfiles.ManifestVersion, Seq: 5, Platform: map[string]hostfiles.Entry{}, Entries: map[string]hostfiles.Entry{}}
156 object := func(body string, mode uint32) hostfiles.Entry {
157 sum := sha256.Sum256([]byte(body))
158 sha := hex.EncodeToString(sum[:])
159 key := srcPrefix + "objects/" + fmt.Sprintf("%016d-%s", 5, sha)
160 if _, err := f.files.Store.Put(ctx, key, []byte(body), ""); err != nil {
161 t.Fatal(err)
162 }
163 return hostfiles.Entry{Object: key, SHA256: sha, Size: int64(len(body)), Mode: mode, Seq: 5}
164 }
165 for rel, body := range o.Files {
166 pinned.Entries[rel] = object(body, 0o644)
167 }
168 for name, body := range o.Platform {
169 pinned.Platform[name] = object(body, 0o600)
170 }
171 pinnedRaw, err := json.Marshal(pinned)
172 if err != nil {
173 t.Fatal(err)
174 }
175 pinnedSum := sha256.Sum256(pinnedRaw)
176 retained := hostrelocate.RetainedManifestKey(buildHost, op, hex.EncodeToString(pinnedSum[:]))
177 if _, err := f.claims.Put(ctx, retained, pinnedRaw, ""); err != nil {
178 t.Fatal(err)
179 }
180 pin.Files = hostrelocate.FilesPin{Prefix: srcPrefix, Seq: 5, SHA256: hex.EncodeToString(pinnedSum[:]), Origins: []string{srcPrefix}, Retained: retained}
181 filesManifest, filesSeq := retained, int64(5)
182 if o.NoFiles {
183 pin.Files = hostrelocate.FilesPin{Prefix: srcPrefix, Origins: []string{srcPrefix}}
184 filesManifest, filesSeq = hostfiles.ManifestKey(srcPrefix), 0
185 }
186 pinRaw, err := json.Marshal(pin)
187 if err != nil {
188 t.Fatal(err)
189 }
190 pinKey := hostrelocate.PinKey(buildHost, op, obs.Claim.Gen)
191 if _, err := f.claims.Put(ctx, pinKey, pinRaw, ""); err != nil {
192 t.Fatal(err)
193 }
194 pinSum := sha256.Sum256(pinRaw)
195 ref := hostclaim.RecoveryPin{Key: pinKey, SHA256: hex.EncodeToString(pinSum[:]), Generation: "g1", FilesManifest: filesManifest, FilesSeq: filesSeq, PinnedAt: created, Release: "v1", Empty: pin.DB == nil}
196 if pin.DB != nil {
197 ref.MaxTXID = pin.DB.MaxTXID
198 }
199 refRaw, err := json.Marshal(ref)
200 if err != nil {
201 t.Fatal(err)
202 }
203 // The installing CAS: this box executes the op, the holder's stamp is
204 // the install stamp, P is named (hostclaim's install()).
205 if obs, err = hostclaim.Update(ctx, f.claims, obs, func(c *hostclaim.Claim) error {
206 stamp := c.Gen + 1
207 c.Holder.Stamp = stamp
208 c.Op = hostclaim.Op{Kind: hostclaim.KindRelocating, ID: op, Phase: hostclaim.PhaseInstalling, Exec: "dst", Participants: []string{"src", "dst"},
209 Intent: map[string]string{"region": "ie", "source_box": "src", "target_box": "dst"},
210 Journal: map[string]string{"pin": string(refRaw), "prepared": "p1", "install_stamp": strconv.FormatUint(stamp, 10), "install_release": "v1"}}
211 return nil
212 }); err != nil {
213 t.Fatal(err)
214 }
215 f.obs = obs
216
217 data := o.Data
218 if data == "" {
219 data = t.TempDir()
220 }
221 if err := os.MkdirAll(filepath.Join(data, "hostguard"), 0o770); err != nil {
222 t.Fatal(err)
223 }
224 f.a = ClaimAssembly{Claims: f.claims, Guard: hostguard.Guard{DataDir: data, LockDir: t.TempDir(), Claims: hostguard.StoreReader{Store: f.claims}}}
225 f.cfg = RelocationAssemblyConfig{Data: f.files, Versions: f.versions, Bucket: "data", Region: "ie", LeaseRegion: "eu-west-1", DataDir: data,
226 WorkDir: filepath.Join(data, "relocate", "build"), Now: func() time.Time { return created.Add(time.Hour) },
227 Source: hostbox.WorkSource{Box: "dst", Region: "ie", Local: func(_ context.Context, c *hostclaim.Claim, s hostbox.Selection) error {
228 f.locals = append(f.locals, s)
229 if f.localErr == nil && f.localHook != nil {
230 return f.localHook(s)
231 }
232 return f.localErr
233 }}}
234 f.verifier = PreparedFromInstallManifest{Claims: f.claims, Files: f.files, Data: f.bucket, Bucket: "data"}
235 return f
236}
237
238// sealedReplica writes a real SQLite database through litestream's file
239// replica and seals every LTX file into the bucket under prefix, keyed as
240// genselect lists a generation (<prefix><level>/<min>-<max>.ltx, prefix
241// moves.GenerationPrefix).
242func sealedReplica(t *testing.T, b *leasetest.VersionedBucket, prefix string) ([]genselect.File, uint64) {
243 t.Helper()
244 ctx := t.Context()
245 dir := t.TempDir()
246 path := filepath.Join(dir, "src.db")
247 write := func(n int) {
248 d, err := sql.Open("sqlite", path)
249 if err != nil {
250 t.Fatal(err)
251 }
252 defer d.Close()
253 if _, err := d.Exec(`CREATE TABLE IF NOT EXISTS t (id INTEGER PRIMARY KEY, body TEXT)`); err != nil {
254 t.Fatal(err)
255 }
256 for range n {
257 if _, err := d.Exec(`INSERT INTO t (body) VALUES (?)`, strings.Repeat("x", 100)); err != nil {
258 t.Fatal(err)
259 }
260 }
261 }
262 write(1)
263 client := lsfile.NewReplicaClient(filepath.Join(dir, "replica"))
264 db := litestream.NewDB(path)
265 db.Logger = slog.New(slog.NewTextHandler(io.Discard, nil))
266 db.SetMetaPath(filepath.Join(dir, "meta"))
267 db.MonitorInterval = time.Hour
268 db.Replica = litestream.NewReplicaWithClient(db, client)
269 db.Replica.SyncInterval = time.Hour
270 if err := db.Open(); err != nil {
271 t.Fatal(err)
272 }
273 for range 2 {
274 if err := db.SyncAndWait(ctx); err != nil {
275 t.Fatal(err)
276 }
277 write(2)
278 }
279 if err := db.SyncAndWait(ctx); err != nil {
280 t.Fatal(err)
281 }
282 if err := db.Close(ctx); err != nil {
283 t.Fatal(err)
284 }
285 var out []genselect.File
286 var n uint64
287 for level := 0; level <= litestream.SnapshotLevel; level++ {
288 itr, err := client.LTXFiles(ctx, level, 0, false)
289 if err != nil {
290 t.Fatal(err)
291 }
292 infos, err := ltx.SliceFileIterator(itr)
293 if err != nil {
294 t.Fatal(err)
295 }
296 for _, info := range infos {
297 rc, err := client.OpenLTXFile(ctx, info.Level, info.MinTXID, info.MaxTXID, 0, 0)
298 if err != nil {
299 t.Fatal(err)
300 }
301 body, err := io.ReadAll(rc)
302 rc.Close()
303 if err != nil {
304 t.Fatal(err)
305 }
306 key := prefix + fmt.Sprintf("%04x/", level) + ltx.FormatFilename(info.MinTXID, info.MaxTXID)
307 v := b.Put("data", key, body)
308 out = append(out, genselect.File{Key: key, VersionID: v, Level: level, MinTXID: uint64(info.MinTXID), MaxTXID: uint64(info.MaxTXID), Size: int64(len(body))})
309 if level == 0 && uint64(info.MaxTXID) > n {
310 n = uint64(info.MaxTXID)
311 }
312 }
313 }
314 if n == 0 {
315 t.Fatal("the source replica has no level-0 files")
316 }
317 return out, n
318}
319
320func (f *buildFixture) build() error {
321 return f.a.relocationBuild(f.cfg)(f.t.Context(), f.obs)
322}
323
324func (f *buildFixture) prefix() string {
325 return moves.GenerationPrefix(f.obs.Claim.Holder.Location, "p1")
326}
327
328func (f *buildFixture) installManifestExists() bool {
329 _, ok := f.bucket.Current("data", moves.InstallManifestKey(f.prefix()))
330 return ok
331}
332
333// moveExec moves the op to another executor, as a replaced target sees it.
334func (f *buildFixture) moveExec() {
335 f.t.Helper()
336 o, err := hostclaim.Read(context.Background(), f.claims, buildHost)
337 if err != nil {
338 f.t.Fatal(err)
339 }
340 if _, err := hostclaim.Update(context.Background(), f.claims, o, func(c *hostclaim.Claim) error {
341 c.Op.Exec, c.Op.Intent["target_box"] = "other", "other"
342 c.Op.Participants = []string{"src", "other"}
343 return nil
344 }); err != nil {
345 f.t.Fatal(err)
346 }
347}
348
349func (f *buildFixture) retireStamp() {
350 f.t.Helper()
351 if err := f.a.Guard.RetireInstallation(context.Background(), buildHost, func(*hostclaim.Claim) error { return nil }); err != nil {
352 f.t.Fatal(err)
353 }
354}
355
356// Prepared is done only when P's files manifest and, for a non-empty pin,
357// its install manifest both verify; an unconfigured verifier reports
358// nothing built. Done carries what ToInstalled requires: P's manifest at
359// sequence 0 built from the pinned sequence, and the install manifest at
360// the pin's N.
361func TestPreparedFromInstallManifestNeedsBothManifests(t *testing.T) {
362 if _, done, err := (PreparedFromInstallManifest{}).Verify(t.Context(), hostclaim.Observed{Claim: &hostclaim.Claim{Holder: &hostclaim.Holder{}}}); done || err == nil {
363 t.Fatalf("an unconfigured verifier reported P built: %v", err)
364 }
365 f := newBuildFixture(t, true)
366 if _, done, err := f.verifier.Verify(t.Context(), f.obs); done || err != nil {
367 t.Fatalf("before the build: done=%v %v", done, err)
368 }
369 if err := f.build(); err != nil {
370 t.Fatal(err)
371 }
372 a, done, err := f.verifier.Verify(t.Context(), f.obs)
373 if err != nil || !done {
374 t.Fatalf("after the build: done=%v %v", done, err)
375 }
376 var ref hostclaim.RecoveryPin
377 _ = json.Unmarshal([]byte(f.obs.Claim.Op.Journal["pin"]), &ref)
378 if a.Generation != "p1" || a.FilesSeq != 0 || a.SourceFilesSeq != 5 || len(a.FilesManifestSHA256) != 64 ||
379 a.InstallManifest != moves.InstallManifestKey(f.prefix()) || len(a.SHA256) != 64 || a.SnapshotTXID != ref.MaxTXID {
380 t.Fatalf("authority: %+v", a)
381 }
382 // An empty bootstrap needs only P's files manifest.
383 empty := newBuildFixture(t, false)
384 if err := empty.build(); err != nil {
385 t.Fatal(err)
386 }
387 if a, done, err := empty.verifier.Verify(t.Context(), empty.obs); err != nil || !done || a.InstallManifest != "" || a.SnapshotTXID != 0 {
388 t.Fatalf("empty bootstrap: %+v done=%v %v", a, done, err)
389 }
390 if empty.installManifestExists() {
391 t.Fatal("an empty pin built a snapshot")
392 }
393}
394
395// A crash after the files manifest and before the install manifest, and a
396// store holding the install manifest without P's files manifest, each
397// leave P unverified, never an error that would stop the worker.
398func TestPreparedRefusesAPartialBuild(t *testing.T) {
399 t.Run("files manifest only", func(t *testing.T) {
400 f := newBuildFixture(t, true)
401 // The process dies after P's files manifest: the next authority
402 // check refuses, as a crash would stop it.
403 f.files.afterPut = func(string) { f.localErr = errors.New("crashed") }
404 if err := f.build(); err == nil {
405 t.Fatal("the stopped build succeeded")
406 }
407 if _, _, err := f.files.Get(t.Context(), hostfiles.ManifestKey(f.obs.Claim.Holder.Location+"/files/g-p1/")); err != nil {
408 t.Fatalf("P's files manifest was not written first: %v", err)
409 }
410 if _, done, err := f.verifier.Verify(t.Context(), f.obs); done || err != nil {
411 t.Fatalf("done=%v err=%v", done, err)
412 }
413 })
414 t.Run("install manifest only", func(t *testing.T) {
415 f := newBuildFixture(t, true)
416 if err := f.build(); err != nil {
417 t.Fatal(err)
418 }
419 v := f.verifier
420 v.Files = release.NewDirStore(t.TempDir()) // P's files manifest is not there
421 if _, done, err := v.Verify(t.Context(), f.obs); done || err != nil {
422 t.Fatalf("done=%v err=%v", done, err)
423 }
424 })
425 t.Run("a files manifest that is not the pinned entries", func(t *testing.T) {
426 f := newBuildFixture(t, true)
427 bad := hostfiles.Manifest{V: hostfiles.ManifestVersion, Entries: map[string]hostfiles.Entry{}, Platform: map[string]hostfiles.Entry{}}
428 raw, _ := json.Marshal(bad)
429 if _, err := f.files.Store.Put(t.Context(), hostfiles.ManifestKey(f.obs.Claim.Holder.Location+"/files/g-p1/"), raw, ""); err != nil {
430 t.Fatal(err)
431 }
432 if _, done, err := f.verifier.Verify(t.Context(), f.obs); done || !errors.Is(err, hostrelocate.ErrRefused) {
433 t.Fatalf("done=%v err=%v", done, err)
434 }
435 })
436}
437
438// A build whose authority moved under it stops before the next durable
439// write: after P's files manifest the executor moved; after the snapshot
440// PUT the executor moved, or the guard retired the stamp. No install
441// manifest is written for a box that no longer builds.
442func TestRelocationBuildStopsWhenAuthorityChanges(t *testing.T) {
443 for _, tc := range []struct {
444 name string
445 hook func(f *buildFixture)
446 after string
447 }{
448 {"after the files manifest", func(f *buildFixture) { f.moveExec() }, "files"},
449 {"after the snapshot PUT", func(f *buildFixture) { f.moveExec() }, "snapshot"},
450 {"after the stamp is retired", func(f *buildFixture) { f.retireStamp() }, "snapshot"},
451 } {
452 t.Run(tc.name, func(t *testing.T) {
453 f := newBuildFixture(t, true)
454 fired := false
455 fire := func(string) {
456 if !fired {
457 fired = true
458 tc.hook(f)
459 }
460 }
461 if tc.after == "files" {
462 f.files.afterPut = fire
463 } else {
464 f.versions.afterPut = func(key string) {
465 if strings.HasPrefix(key, f.prefix()) && strings.HasSuffix(key, ".ltx") {
466 fire(key)
467 }
468 }
469 }
470 if err := f.build(); !errors.Is(err, hostrelocate.ErrRefused) || !fired {
471 t.Fatalf("the build went on after its authority changed: %v (fired %v)", err, fired)
472 }
473 if f.installManifestExists() {
474 t.Fatal("the install manifest was written for a box that no longer builds")
475 }
476 })
477 }
478}
479
480// Ruling 59: the replica lease, the claim and the guard all stay valid;
481// only main's Local refuses (the execution lease lost), and the next write
482// is refused with its error.
483func TestRelocationBuildStopsWhenOnlyTheExecutionLeaseIsLost(t *testing.T) {
484 f := newBuildFixture(t, true)
485 f.versions.afterPut = func(key string) {
486 if strings.HasSuffix(key, ".ltx") {
487 f.localErr = execlease.ErrLost
488 }
489 }
490 if err := f.build(); !errors.Is(err, execlease.ErrLost) {
491 t.Fatalf("the build went on without its execution lease: %v", err)
492 }
493 if f.installManifestExists() {
494 t.Fatal("the install manifest was written after the execution lease was lost")
495 }
496 if c, err := hostguard.Capture(f.a.Guard.DataDir, buildHost); err != nil || c.Stamp != f.obs.Claim.Holder.Stamp {
497 t.Fatalf("the guard moved: %+v %v", c, err)
498 }
499}
500
501// A replay from installing (a crash after the build, before ToInstalled)
502// is a success that changes nothing: the same files manifest bytes, the
503// same install manifest version.
504func TestRelocationBuildReplaysToTheSameBytes(t *testing.T) {
505 f := newBuildFixture(t, true)
506 if err := f.build(); err != nil {
507 t.Fatal(err)
508 }
509 key := moves.InstallManifestKey(f.prefix())
510 first := f.bucket.LatestID("data", key)
511 filesKey := hostfiles.ManifestKey(f.obs.Claim.Holder.Location + "/files/g-p1/")
512 before, _, err := f.files.Get(t.Context(), filesKey)
513 if err != nil {
514 t.Fatal(err)
515 }
516 if err := f.build(); err != nil {
517 t.Fatalf("replay: %v", err)
518 }
519 after, _, err := f.files.Get(t.Context(), filesKey)
520 if err != nil || string(after) != string(before) || f.bucket.LatestID("data", key) != first {
521 t.Fatalf("the replay rewrote P: %v", err)
522 }
523 if _, done, err := f.verifier.Verify(t.Context(), f.obs); !done || err != nil {
524 t.Fatalf("after the replay: done=%v %v", done, err)
525 }
526}
527
528// Finding 2, ruling 43: the build activates the target's guard at
529// install_stamp from an empty box, under main's Local, before Capture;
530// never for another executor, never past a refusing Local, never at
531// another phase, never a retired stamp. The build's host-named scratch is
532// gone once P is built.
533func TestRelocationBuildActivatesTheGuardFromAnEmptyTarget(t *testing.T) {
534 t.Run("from an empty box", func(t *testing.T) {
535 f := newBuildFixture(t, true)
536 if err := f.build(); err != nil {
537 t.Fatal(err)
538 }
539 c, err := hostguard.Capture(f.a.Guard.DataDir, buildHost)
540 if err != nil || c.Incarnation != f.obs.Claim.Holder.Incarnation || c.Stamp != f.obs.Claim.Holder.Stamp {
541 t.Fatalf("capture %+v %v", c, err)
542 }
543 if len(f.locals) == 0 || f.locals[0].Action != relocationActionInstall || f.locals[0].Captured.Stamp != f.obs.Claim.Holder.Stamp || f.locals[0].Box != "dst" {
544 t.Fatalf("main's Local was not asked for the install: %+v", f.locals)
545 }
546 if _, err := os.Stat(filepath.Join(f.cfg.WorkDir, "rel-1")); !errors.Is(err, os.ErrNotExist) {
547 t.Fatalf("the build's scratch survived: %v", err)
548 }
549 })
550 for _, tc := range []struct {
551 name string
552 change func(f *buildFixture)
553 want error
554 }{
555 {"another executor", func(f *buildFixture) {
556 f.moveExec()
557 f.obs, _ = hostclaim.Read(context.Background(), f.claims, buildHost)
558 }, hostrelocate.ErrRefused},
559 {"main's Local refuses", func(f *buildFixture) { f.localErr = execlease.ErrLost }, execlease.ErrLost},
560 {"installed, not installing", func(f *buildFixture) {
561 o, _ := hostclaim.Read(context.Background(), f.claims, buildHost)
562 f.obs, _ = hostclaim.Update(context.Background(), f.claims, o, func(c *hostclaim.Claim) error { c.Op.Phase = hostclaim.PhaseInstalled; return nil })
563 }, hostrelocate.ErrRefused},
564 } {
565 t.Run(tc.name, func(t *testing.T) {
566 f := newBuildFixture(t, true)
567 tc.change(f)
568 before := f.bucket.Snapshot()
569 if err := f.build(); !errors.Is(err, tc.want) {
570 t.Fatalf("got %v, want %v", err, tc.want)
571 }
572 if entries, _ := os.ReadDir(filepath.Join(f.a.Guard.DataDir, "hostguard")); len(entries) != 0 {
573 t.Fatalf("the guard was written: %v", entries)
574 }
575 if f.bucket.Snapshot() != before {
576 t.Fatal("the bucket was written")
577 }
578 })
579 }
580 t.Run("a retired stamp", func(t *testing.T) {
581 f := newBuildFixture(t, true)
582 if _, err := f.a.relocationActivate(f.cfg)(t.Context(), f.obs); err != nil {
583 t.Fatal(err)
584 }
585 f.retireStamp()
586 before := f.bucket.Snapshot()
587 if err := f.build(); !errors.Is(err, hostguard.ErrRetiredStamp) {
588 t.Fatalf("a retired stamp was reactivated: %v", err)
589 }
590 if f.bucket.Snapshot() != before {
591 t.Fatal("the bucket was written")
592 }
593 })
594}
595
596// A pin whose files digest names no retained copy is refused, never read
597// as a host that published no files (absence is never evidence). The
598// pinned manifest here is itself empty, so only that check tells the two
599// apart.
600func TestPreparedRefusesAFilesDigestWithoutItsRetainedCopy(t *testing.T) {
601 ctx := t.Context()
602 f := newBuildFixtureWith(t, buildOptions{})
603 if err := f.build(); err != nil {
604 t.Fatal(err)
605 }
606 if _, done, err := f.verifier.Verify(ctx, f.obs); !done || err != nil {
607 t.Fatalf("the built empty pin: done=%v %v", done, err)
608 }
609 var ref hostclaim.RecoveryPin
610 if err := json.Unmarshal([]byte(f.obs.Claim.Op.Journal["pin"]), &ref); err != nil {
611 t.Fatal(err)
612 }
613 pin, err := hostrelocate.ReadPin(ctx, f.claims, ref)
614 if err != nil {
615 t.Fatal(err)
616 }
617 if pin.Files.SHA256 == "" || pin.Files.Retained == "" {
618 t.Fatalf("the fixture's pin has no retained manifest: %+v", pin.Files)
619 }
620 pin.Files.Retained = ""
621 raw, err := json.Marshal(pin)
622 if err != nil {
623 t.Fatal(err)
624 }
625 ref.Key += ".forged"
626 if _, err := f.claims.Put(ctx, ref.Key, raw, ""); err != nil {
627 t.Fatal(err)
628 }
629 sum := sha256.Sum256(raw)
630 ref.SHA256 = hex.EncodeToString(sum[:])
631 refRaw, err := json.Marshal(ref)
632 if err != nil {
633 t.Fatal(err)
634 }
635 c := *f.obs.Claim
636 c.Op.Journal = map[string]string{}
637 for k, v := range f.obs.Claim.Op.Journal {
638 c.Op.Journal[k] = v
639 }
640 c.Op.Journal["pin"] = string(refRaw)
641 obs := f.obs
642 obs.Claim = &c
643 if _, done, err := f.verifier.Verify(ctx, obs); done || !errors.Is(err, hostrelocate.ErrRefused) {
644 t.Fatalf("done=%v err=%v", done, err)
645 }
646}
647
648// staleObs is the build's observation with change applied to a copy of its
649// claim, as a worker holding an older read sees it; the stored claim is
650// unchanged.
651func (f *buildFixture) staleObs(change func(*hostclaim.Claim)) hostclaim.Observed {
652 c := *f.obs.Claim
653 h := *c.Holder
654 c.Holder = &h
655 c.Op.Journal = map[string]string{}
656 for k, v := range f.obs.Claim.Op.Journal {
657 c.Op.Journal[k] = v
658 }
659 change(&c)
660 o := f.obs
661 o.Claim = &c
662 return o
663}
664
665// Rulings 43 and 52: the guard is activated, and P built, only for the
666// installation the installing CAS issued. A stored claim whose holder stamp
667// is not the install stamp, that lost this box, that is stuck or whose P
668// was opened writes no guard record and nothing in the bucket. An
669// observation older than the claim, at another install stamp, of another
670// incarnation, or naming another P or pin, writes nothing in the bucket.
671func TestRelocationBuildRefusesAnInstallationTheClaimDidNotIssue(t *testing.T) {
672 for _, tc := range []struct {
673 name string
674 stored func(*hostclaim.Claim) // a change to the stored claim
675 stale func(*buildFixture, *hostclaim.Claim) // a change to the observation only
676 want error
677 }{
678 {name: "a holder stamp that is not the install stamp", stored: func(c *hostclaim.Claim) { c.Holder.Stamp++ }, want: hostrelocate.ErrRefused},
679 {name: "this box declared lost", stored: func(c *hostclaim.Claim) { c.Lost = append(c.Lost, "dst") }, want: hostrelocate.ErrRefused},
680 {name: "the op stuck", stored: func(c *hostclaim.Claim) { c.Op.Journal["stuck"] = "an operator's" }, want: hostrelocate.ErrRefused},
681 {name: "P opened", stored: func(c *hostclaim.Claim) { c.Op.Journal["prepared_opened"] = c.Op.Journal["prepared"] }, want: hostrelocate.ErrRefused},
682 {name: "an observation at another install stamp", stale: func(_ *buildFixture, c *hostclaim.Claim) {
683 c.Op.Journal["install_stamp"] = strconv.FormatUint(c.Holder.Stamp+1, 10)
684 }, want: hostguard.ErrStampMismatch},
685 {name: "an observation of another incarnation", stale: func(_ *buildFixture, c *hostclaim.Claim) { c.Holder.Incarnation = "another" }, want: hostguard.ErrStampMismatch},
686 {name: "an observation naming another P", stale: func(_ *buildFixture, c *hostclaim.Claim) { c.Op.Journal["prepared"] = "p2" }, want: hostrelocate.ErrRefused},
687 {name: "an observation naming another pin", stale: func(f *buildFixture, c *hostclaim.Claim) {
688 // The same pin bytes at another key: ReadPin accepts them, the
689 // journal's text differs.
690 var ref hostclaim.RecoveryPin
691 if err := json.Unmarshal([]byte(c.Op.Journal["pin"]), &ref); err != nil {
692 f.t.Fatal(err)
693 }
694 raw, _, err := f.claims.Get(context.Background(), ref.Key)
695 if err != nil {
696 f.t.Fatal(err)
697 }
698 ref.Key += ".copy"
699 if _, err := f.claims.Put(context.Background(), ref.Key, raw, ""); err != nil {
700 f.t.Fatal(err)
701 }
702 refRaw, _ := json.Marshal(ref)
703 c.Op.Journal["pin"] = string(refRaw)
704 }, want: hostrelocate.ErrRefused},
705 } {
706 t.Run(tc.name, func(t *testing.T) {
707 f := newBuildFixture(t, true)
708 if tc.stored != nil {
709 o, err := hostclaim.Read(t.Context(), f.claims, buildHost)
710 if err != nil {
711 t.Fatal(err)
712 }
713 if f.obs, err = hostclaim.Update(t.Context(), f.claims, o, func(c *hostclaim.Claim) error { tc.stored(c); return nil }); err != nil {
714 t.Fatal(err)
715 }
716 }
717 if tc.stale != nil {
718 f.obs = f.staleObs(func(c *hostclaim.Claim) { tc.stale(f, c) })
719 }
720 before := f.bucket.Snapshot()
721 if err := f.build(); !errors.Is(err, tc.want) {
722 t.Fatalf("got %v, want %v", err, tc.want)
723 }
724 if f.bucket.Snapshot() != before {
725 t.Fatal("the bucket was written")
726 }
727 if tc.stored != nil {
728 if entries, _ := os.ReadDir(filepath.Join(f.a.Guard.DataDir, "hostguard")); len(entries) != 0 {
729 t.Fatalf("the guard was written: %v", entries)
730 }
731 }
732 })
733 }
734}
735
736// relocationBuildP checks its authority before it takes the replica lease
737// (a stale build never writes the lease object on holder.location), again
738// once the lease is held, before P's files manifest, and before the
739// database build, so a refusal at each stops what follows it.
740func TestRelocationBuildPChecksItsAuthorityBeforeEachStep(t *testing.T) {
741 refused := errors.New("sentinel: the authority refused")
742 for _, tc := range []struct {
743 name string
744 at int // the auth call that refuses
745 }{
746 {"before the replica lease", 1},
747 {"before P's files manifest", 2},
748 {"before the database build", 3},
749 } {
750 t.Run(tc.name, func(t *testing.T) {
751 f := newBuildFixture(t, true)
752 calls := 0
753 auth := func(context.Context) error {
754 if calls++; calls == tc.at {
755 return refused
756 }
757 return nil
758 }
759 before := f.bucket.Snapshot()
760 if err := f.a.relocationBuildP(f.cfg)(t.Context(), f.obs, auth); !errors.Is(err, refused) || calls != tc.at {
761 t.Fatalf("got %v after %d checks", err, calls)
762 }
763 if tc.at == 1 && f.bucket.Snapshot() != before {
764 t.Fatal("the replica lease was taken without authority")
765 }
766 _, _, err := f.files.Get(t.Context(), hostfiles.ManifestKey(f.obs.Claim.Holder.Location+"/files/g-p1/"))
767 if wrote := err == nil; wrote != (tc.at > 2) {
768 t.Fatalf("P's files manifest written=%v (%v)", wrote, err)
769 }
770 if _, err := os.Stat(filepath.Join(f.cfg.WorkDir, "rel-1")); !errors.Is(err, os.ErrNotExist) {
771 t.Fatalf("the database build started: %v", err)
772 }
773 if f.installManifestExists() {
774 t.Fatal("the install manifest was written")
775 }
776 })
777 }
778}
779
780// lossyLease is a real replica lease whose loss the test signals.
781type lossyLease struct {
782 *lease.Held
783 lost chan struct{}
784}
785
786func (l lossyLease) Lost() <-chan struct{} { return l.lost }
787
788// The replica lease lost while the claim, the guard and main's Local all
789// still admit the build stops it before the database build.
790func TestRelocationBuildPStopsWhenTheReplicaLeaseIsLost(t *testing.T) {
791 f := newBuildFixture(t, true)
792 lost := make(chan struct{})
793 f.a.acquireReplicaLease = func(ctx context.Context, bucket, prefix, region string) (replicaLease, error) {
794 h, err := lease.Acquire(ctx, bucket, prefix, region)
795 if err != nil {
796 return nil, err
797 }
798 return lossyLease{Held: h, lost: lost}, nil
799 }
800 calls := 0
801 auth := func(ctx context.Context) error {
802 if calls++; calls == 3 { // after P's files manifest
803 close(lost)
804 <-ctx.Done() // the build has seen the loss
805 }
806 return nil
807 }
808 if err := f.a.relocationBuildP(f.cfg)(t.Context(), f.obs, auth); !errors.Is(err, context.Canceled) {
809 t.Fatalf("the build went on without its replica lease: %v", err)
810 }
811 if _, err := os.Stat(filepath.Join(f.cfg.WorkDir, "rel-1")); !errors.Is(err, os.ErrNotExist) {
812 t.Fatalf("the database build started: %v", err)
813 }
814 if f.installManifestExists() {
815 t.Fatal("the install manifest was written after the replica lease was lost")
816 }
817}
818
819// Verify accepts only this pin's build of P: P's files manifest at
820// sequence 0, and an install manifest of this op and host at the pin's N,
821// whose integrity check passed and whose snapshot and anchor versions
822// exist. Anything else built at P is refused, never reported done.
823func TestPreparedRefusesAnythingButThisPinsBuild(t *testing.T) {
824 for _, tc := range []struct {
825 name string
826 files func(*hostfiles.Manifest)
827 im func(*buildFixture, *moves.InstallManifest)
828 }{
829 {name: "P's files manifest not at sequence 0", files: func(m *hostfiles.Manifest) { m.Seq = 5 }},
830 {name: "another op", im: func(_ *buildFixture, m *moves.InstallManifest) { m.Op = "rel-2" }},
831 {name: "another host", im: func(_ *buildFixture, m *moves.InstallManifest) { m.Host = "other.example" }},
832 {name: "another N", im: func(f *buildFixture, m *moves.InstallManifest) {
833 // A complete build at N-1: its snapshot and anchor exist, at
834 // their keys for N-1. Only the pin's N tells it apart.
835 copyAt := func(from, to string) string {
836 body, ok := f.bucket.Current("data", from)
837 if !ok {
838 f.t.Fatalf("%s is not there", from)
839 }
840 return f.bucket.Put("data", to, body)
841 }
842 n := m.N - 1
843 m.SnapshotVersion = copyAt(m.SnapshotKey, moves.SnapshotKey(f.prefix(), n))
844 m.AnchorVersion = copyAt(m.AnchorKey, moves.AnchorKey(f.prefix(), n))
845 m.N, m.SnapshotKey, m.AnchorKey = n, moves.SnapshotKey(f.prefix(), n), moves.AnchorKey(f.prefix(), n)
846 }},
847 {name: "a failed integrity check", im: func(_ *buildFixture, m *moves.InstallManifest) { m.IntegrityCheck = "corrupt" }},
848 {name: "a snapshot version that is not there", im: func(_ *buildFixture, m *moves.InstallManifest) { m.SnapshotVersion = "missing" }},
849 {name: "an anchor version that is not there", im: func(_ *buildFixture, m *moves.InstallManifest) { m.AnchorVersion = "missing" }},
850 } {
851 t.Run(tc.name, func(t *testing.T) {
852 ctx := t.Context()
853 f := newBuildFixture(t, true)
854 if err := f.build(); err != nil {
855 t.Fatal(err)
856 }
857 if _, done, err := f.verifier.Verify(ctx, f.obs); !done || err != nil {
858 t.Fatalf("the untouched build: done=%v %v", done, err)
859 }
860 if tc.files != nil {
861 key := hostfiles.ManifestKey(f.obs.Claim.Holder.Location + "/files/g-p1/")
862 raw, etag, err := f.files.Get(ctx, key)
863 if err != nil {
864 t.Fatal(err)
865 }
866 var m hostfiles.Manifest
867 if err := json.Unmarshal(raw, &m); err != nil {
868 t.Fatal(err)
869 }
870 tc.files(&m)
871 if raw, err = json.Marshal(m); err != nil {
872 t.Fatal(err)
873 }
874 if _, err := f.files.Store.Put(ctx, key, raw, etag); err != nil {
875 t.Fatal(err)
876 }
877 }
878 if tc.im != nil {
879 im, _, err := readInstallManifest(ctx, f.bucket, "data", f.prefix())
880 if err != nil {
881 t.Fatal(err)
882 }
883 tc.im(f, &im)
884 raw, err := json.Marshal(im)
885 if err != nil {
886 t.Fatal(err)
887 }
888 f.bucket.Put("data", moves.InstallManifestKey(f.prefix()), raw)
889 }
890 if _, done, err := f.verifier.Verify(ctx, f.obs); done || !errors.Is(err, hostrelocate.ErrRefused) {
891 t.Fatalf("done=%v err=%v", done, err)
892 }
893 })
894 }
895}
896