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 "encoding/json"
6 "errors"
7 "os"
8 "path/filepath"
9 "reflect"
10 "strings"
11 "testing"
12 "time"
13
14 "github.com/carlosframework/platform/internal/activator"
15 "github.com/carlosframework/platform/internal/genselect"
16 "github.com/carlosframework/platform/internal/hostbox"
17 "github.com/carlosframework/platform/internal/hostclaim"
18 "github.com/carlosframework/platform/internal/hostfiles"
19 "github.com/carlosframework/platform/internal/hostguard"
20 "github.com/carlosframework/platform/internal/hostwrite"
21 "github.com/carlosframework/platform/internal/moves"
22 "github.com/carlosframework/platform/internal/moves/archive"
23 "github.com/carlosframework/platform/internal/provision"
24 "github.com/superfly/ltx"
25)
26
27// The recovery wake owners (moves Plan 4 Task 9; ruling Q7). The fixture is
28// the landing probe's (newPOFixture) turned into the source's recovery: the
29// source ie-box executes the move's restoring op at probing (a rollback,
30// dirty or clean) or its reverting op at probing (a revert), and the row is
31// held by the rollback id or the move id.
32
33const (
34 rcDirty = "dirty"
35 rcClean = "clean"
36 rcRevert = "revert"
37 // rcLegacy is a clean return of a legacy source: src_gen is the origin
38 // (""), its files at the location's root and no generation.json.
39 rcLegacy = "legacy"
40 // rcS is the source installation's stamp S the flip (or the revert)
41 // journalled.
42 rcS = 15
43)
44
45// rcFiles is a fake C8 files manifest provider (Task 6 builds the real
46// one): it records the call and runs under the start's work.
47type rcFiles struct {
48 f *poFixture
49 err error
50 calls int
51}
52
53func (r *rcFiles) Establish(ctx context.Context, o hostclaim.Observed, in ClaimLaunchInputs, work hostwrite.Work) error {
54 r.calls++
55 r.f.order = append(r.f.order, "establish:"+o.Claim.Holder.Generation)
56 if r.err != nil {
57 return r.err
58 }
59 return work.With(ctx, in.Identity, func() error { return nil })
60}
61
62type rcFixture struct {
63 *poFixture
64 kind string
65 c8 *rcFiles
66 inputs *provision.MoveInputs
67 manifest moves.InstallManifest // the dirty return's
68 seal moves.Seal // the return seal
69}
70
71// rcInputs are the inputs the recovery probe recorded: neither the move's
72// pins nor the landing's.
73func rcInputs(env []string) *provision.MoveInputs {
74 return &provision.MoveInputs{BundleETags: map[string]string{"default": "e-recovered"}, OverlayETag: "e-overlay-2", Release: "v1.3.0", EnvReport: localEnvDigest(poEnvKey, env)}
75}
76
77func newRCFixture(t *testing.T, kind string, opts ...poOption) *rcFixture {
78 t.Helper()
79 po := newPOFixture(t, opts...)
80 f := &rcFixture{poFixture: po, kind: kind, c8: &rcFiles{f: po}}
81 // The recovery runs on the source box, which is never the home box
82 // here: no alias, the fleet's designation only.
83 f.owners.Box, f.owners.Region = f.srcBox, f.srcRegion
84 f.owners.HomeRegion, f.owners.FleetHomeRegion = "", f.home
85 f.owners.Files = f.c8
86 f.launch.Version = "v1.3.0"
87 f.inputs = rcInputs(f.launch.Env)
88 f.renderETags, f.renderOverlay = f.inputs.BundleETags, f.inputs.OverlayETag
89 at := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
90
91 // The source's own generation in its region, and the marker naming it.
92 if kind == rcLegacy {
93 f.bucket.Put("data", "instances/"+poHost+"/0009/"+ltx.FormatFilename(1, 30), f.ltxHeader(1, 30))
94 } else {
95 f.bucket.Put("data", "instances/"+poHost+"/g-src/0009/"+ltx.FormatFilename(1, 30), f.ltxHeader(1, 30))
96 f.bucket.Put("data", "instances/"+poHost+"/generation.json", mustJSON(t, genselect.Marker{Op: "src", Incarnation: f.inc, Host: poHost, Box: f.srcBox, InstalledAt: at}))
97 }
98
99 c := f.recoveryClaim()
100 f.rec.Region = f.srcRegion
101 f.rec.Move.LandingPhase = provision.LandRestored
102 f.route.Status, f.route.HoldOp = "restoring", rpRB
103 switch kind {
104 case rcRevert:
105 f.rec.Move.State, f.rec.Move.LandingPhase = provision.MoveReverting, ""
106 f.rec.Move.RecoveryInputs = f.inputs
107 f.route.HoldOp = poOp
108 default:
109 f.rec.Move.State = provision.MoveRollingBack
110 f.rec.Move.Rollback = &provision.MoveRollback{ID: rpRB, Dirty: kind == rcDirty, TargetPhase: provision.RbTargetReleased, SourcePhase: provision.RbSourceProbing, RecoveryInputs: f.inputs}
111 }
112 if kind == rcDirty {
113 // The return generation the source's install built from the return
114 // lane, and its manifest in the rollback's journal (installGeneration).
115 f.rec.Move.Rollback.TargetPhase = provision.RbTargetReturned
116 key := "instances/" + poHost + "/g-" + rpRB + "/0009/" + ltx.FormatFilename(1, 50)
117 version := f.bucket.Put("data", key, f.ltxHeader(1, 50))
118 f.manifest = moves.InstallManifest{V: 1, Op: rpRB, Host: poHost, N: 50, SnapshotKey: key, SnapshotVersion: version, SnapshotCRC64: 9, DBSHA256: strings.Repeat("e", 64)}
119 f.bucket.Put("data", moves.InstallManifestKey(moves.GenerationPrefix("instances/"+poHost, rpRB)), mustJSON(t, f.manifest))
120 j, err := moves.ReadJournal(f.dataDir, poHost, rpRB)
121 if err != nil {
122 t.Fatal(err)
123 }
124 body, _ := json.Marshal(f.manifest)
125 j.Mark(landStepInstall, string(body))
126 // The install receipt: the return's tree at the live paths.
127 filesID, err := pathIdentity(f.files)
128 if err != nil {
129 t.Fatal(err)
130 }
131 envID, err := pathIdentity(filepath.Join(f.dataDir, poHost+".env"))
132 if err != nil {
133 t.Fatal(err)
134 }
135 placed, _ := json.Marshal(stagedFiles{Files: filesID, Env: envID})
136 j.Mark(landStepFilesPlaced, string(placed))
137 if err := j.Commit(f.dataDir); err != nil {
138 t.Fatal(err)
139 }
140 f.seal = moves.Seal{V: moves.SealVersion, N: 50, FromDB: true, SrcCovered: true, SealedAt: at, LeaseUntil: at.Add(time.Minute),
141 Replica: []moves.SealedObject{{Key: "moves/" + poHost + "/" + rpRB + "/replica/0009/" + ltx.FormatFilename(1, 50), VersionID: "r1", Size: int64(ltx.HeaderSize)}},
142 Files: []moves.SealedObject{{Key: "moves/" + poHost + "/" + rpRB + "/files/archive.sealed", VersionID: "r2", Size: 10}, {Key: "moves/" + poHost + "/" + rpRB + "/files/archive.json", VersionID: "r3", Size: 10}}}
143 c.Op.Journal[moves.JournalReturnSeal] = f.seal.Encode()
144 f.writeLocal(rpRB, rpRB, f.inc)
145 } else {
146 // The source's own files and warm database: no moves files marker.
147 if err := os.Remove(filepath.Join(f.files, archive.MarkerName)); err != nil {
148 t.Fatal(err)
149 }
150 if kind == rcLegacy {
151 if err := os.Remove(f.route.DBPath + ".gen"); err != nil {
152 t.Fatal(err)
153 }
154 } else if err := hostbox.WriteGenerationMarker(f.route.DBPath+".gen", "src"); err != nil {
155 t.Fatal(err)
156 }
157 }
158 // The tree the recovery restores, as its baselines record it: a dirty
159 // return's is the return baseline the target journalled, a clean
160 // return's or a revert's the source's stopped baseline.
161 digest, err := f.owners.filesDigest(poHost)
162 if err != nil {
163 t.Fatal(err)
164 }
165 if kind == rcDirty {
166 raw, err := moves.EncodeReturnBaseline(provision.MoveSource{FinalTXID: 50, Files: &provision.MoveFilesDigest{Plain: digest, Sealed: "sealed"}, TimersSHA256: "timers", Evidence: "moves/ev"})
167 if err != nil {
168 t.Fatal(err)
169 }
170 c.Op.Journal[moves.JournalReturnBaseline] = raw
171 } else {
172 f.rec.Move.Source = &provision.MoveSource{FinalTXID: 30, Files: &provision.MoveFilesDigest{Plain: digest}, TimersSHA256: "timers"}
173 }
174 if err := c.Validate(); err != nil {
175 t.Fatal(err)
176 }
177 f.putClaim(c)
178 // Plan 15: the source generation the clean kinds bridge from is proven
179 // only when the claim records it; record it as the #250 backfill does.
180 if kind != rcLegacy {
181 recordOrigins(t, f.claims, poHost, hostclaim.OriginTuple{Incarnation: f.inc, Location: "instances/" + poHost, Region: f.srcRegion, Generation: "src"})
182 }
183 return f
184}
185
186// recoveryClaim is the claim the recovery runs under. A return: restoring at
187// probing, the source executing again at S (ReturnToSource); the holder is
188// still the target's session 20 in the landing's generation, or src_gen for
189// a clean return. A revert: reverting at rev_phase probing, the holder never
190// left the source.
191func (f *rcFixture) recoveryClaim() *hostclaim.Claim {
192 h := &hostclaim.Holder{Family: "instance", Key: "apps/bab/jam/instances/" + poHost + ".json", Account: "bab", App: "jam", CreatedAt: time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC), Incarnation: f.inc, Location: "instances/" + poHost,
193 Generation: poOp, Stamp: rcS, Session: 20, WriterBox: f.dstBox,
194 Admissions: []hostclaim.AdmissionRecord{{Session: 15, Box: f.srcBox, Generation: "src", Region: f.srcRegion, Mode: "cold"},
195 {Session: 20, Box: f.dstBox, Generation: poOp, Predecessor: "src", Region: f.dstEngine, PredecessorRegion: f.srcRegion, Mode: "cold", Coverage: "sealed"}}}
196 journal := map[string]string{hostclaim.JournalSourceStamp: "15", hostclaim.JournalSrcGen: "src", hostclaim.JournalCutSession: "15", hostclaim.JournalEvidenceSource: "moves/bab/jam/" + poHost + "/" + poOp + "/stopped-source.json"}
197 intent := map[string]string{"to": f.dstTo, "source": f.srcBox, "target": f.dstBox, "rollback": rpRB}
198 if f.home != "" {
199 intent["home_region"] = f.home
200 }
201 op := hostclaim.Op{Kind: hostclaim.KindRestoring, ID: poOp, Phase: hostclaim.PhaseProbing, Exec: f.srcBox, Participants: []string{f.srcBox, f.dstBox},
202 Intent: intent, Journal: journal}
203 gen := uint64(30)
204 switch f.kind {
205 case rcClean:
206 h.Generation = "src"
207 case rcLegacy:
208 // The source never named a generation: its sessions predate
209 // admission records, and the landing's admission came from the origin.
210 h.Generation, h.Admissions = "", h.Admissions[1:]
211 h.Admissions[0].Predecessor = ""
212 journal[hostclaim.JournalSrcGen] = ""
213 case rcRevert:
214 h.Generation, h.Session, h.WriterBox, h.Admissions = "src", 15, f.srcBox, h.Admissions[:1]
215 op.Kind = hostclaim.KindReverting
216 delete(op.Intent, "rollback")
217 journal[hostclaim.JournalRevPhase] = hostclaim.PhaseProbing
218 gen = 18
219 }
220 return &hostclaim.Claim{V: 2, Host: poHost, Gen: gen, Holder: h, Op: op}
221}
222
223// policy is the recovery probe adapter's start policy: a probe carrying
224// exactly the recorded inputs.
225func (f *rcFixture) policy() activator.StartPolicy {
226 in := *f.inputs
227 return activator.StartPolicy{MaxStarts: 3, Window: 30 * time.Second, Probe: true, Inputs: &in}
228}
229
230func (f *rcFixture) selectAdmission(o hostclaim.Observed) ClaimLaunchInputs {
231 f.t.Helper()
232 in, err := f.owners.Select(f.t.Context(), f.route, o, f.policy())
233 if err != nil {
234 f.t.Fatal(err)
235 }
236 return in
237}
238
239// rcMutations are admissions Select never makes; AdmissionEvidence refuses
240// every one.
241var rcMutations = map[string]func(*hostclaim.Admission, *genselect.Plan){
242 "stamp kept": func(a *hostclaim.Admission, _ *genselect.Plan) { a.KeepStamp = true },
243 "another region": func(a *hostclaim.Admission, _ *genselect.Plan) { a.Region = "uk" },
244 "another box": func(a *hostclaim.Admission, _ *genselect.Plan) { a.Box = "uk-box" },
245 "a stale baseline": func(a *hostclaim.Admission, p *genselect.Plan) { p.MaxTXID, a.RemoteMaxTXID = p.MaxTXID-1, p.MaxTXID-1 },
246 "another session": func(a *hostclaim.Admission, _ *genselect.Plan) { a.ObservedSession = 14 },
247 "warm": func(a *hostclaim.Admission, _ *genselect.Plan) { a.Mode = "warm" },
248 "a loss id": func(a *hostclaim.Admission, _ *genselect.Plan) { a.LossID = "loss" },
249 "writes lost": func(a *hostclaim.Admission, _ *genselect.Plan) {
250 a.AdditionalWritesLost = []hostclaim.WritesLost{{Box: "x"}}
251 },
252 "certified": func(a *hostclaim.Admission, _ *genselect.Plan) { a.PredecessorCertified = true },
253 "another phase": func(a *hostclaim.Admission, _ *genselect.Plan) { a.ObservedPhase = hostclaim.PhaseClaimed },
254 "another op": func(a *hostclaim.Admission, _ *genselect.Plan) { a.ObservedOpID = "mv_other" },
255 "the move id as gen": func(a *hostclaim.Admission, _ *genselect.Plan) { a.NewGeneration = poOp },
256 "a remote max moved": func(a *hostclaim.Admission, _ *genselect.Plan) { a.RemoteMaxTXID++ },
257 "another coverage": func(a *hostclaim.Admission, _ *genselect.Plan) { a.Coverage = "bogus" },
258 "another predecessor": func(a *hostclaim.Admission, _ *genselect.Plan) { a.PredecessorRegion = "de" },
259}
260
261func (f *rcFixture) refusesMutations(o hostclaim.Observed, in ClaimLaunchInputs) {
262 f.t.Helper()
263 for name, mutate := range rcMutations {
264 a, p := in.Admission, *in.Baseline
265 mutate(&a, &p)
266 if err := f.owners.AdmissionEvidence(f.t.Context(), o, a, &p); !errors.Is(err, hostbox.ErrChanged) {
267 f.t.Fatalf("%s: %v", name, err)
268 }
269 }
270}
271
272// Plan 4 Task 9: a dirty return's probe is a cold admission into the return
273// generation (the rollback id), sealed, from the return generation's pin at
274// the return seal's maximum, and it keeps no stamp: the admission moves
275// holder.stamp from S to the new session and holder.generation to the
276// return generation, so S is retired (a selection captured at S is refused).
277func TestDirtyReturnProbeIssuesANewStamp(t *testing.T) {
278 f := newRCFixture(t, rcDirty)
279 ctx := t.Context()
280 o := f.read()
281 in := f.selectAdmission(o)
282 want := hostclaim.Admission{ObservedOpID: poOp, ObservedPhase: hostclaim.PhaseProbing, ObservedSession: 20, ObservedGeneration: poOp, Box: "ie-box", Mode: "cold",
283 NewGeneration: rpRB, Coverage: "sealed", Region: "ie", PredecessorRegion: "uk", RemoteMaxTXID: 50}
284 if !reflect.DeepEqual(in.Admission, want) {
285 t.Fatalf("admission:\n got %+v\nwant %+v", in.Admission, want)
286 }
287 if !in.Prepared || in.Baseline == nil || in.Baseline.Generation != rpRB || in.Baseline.MaxTXID != f.seal.N || in.Baseline.Pin == nil || in.Baseline.Pin.SnapshotVersion != f.manifest.SnapshotVersion {
288 t.Fatalf("baseline: prepared=%v %+v", in.Prepared, in.Baseline)
289 }
290 if in.Identity != mpLaunchIdentity(f.inputs) || in.Version != "v1.3.0" || in.DBPath != f.route.DBPath || in.ReadyPath != "/healthz" || !contains(in.Env, "CARLOS_MOVE_PROBE=1") {
291 t.Fatalf("launch inputs: %+v", in)
292 }
293 if err := f.owners.AdmissionEvidence(ctx, o, in.Admission, in.Baseline); err != nil {
294 t.Fatal(err)
295 }
296 f.refusesMutations(o, in)
297 unsealed := in.Admission
298 unsealed.Coverage = ""
299 if err := f.owners.AdmissionEvidence(ctx, o, unsealed, in.Baseline); !errors.Is(err, hostbox.ErrChanged) {
300 t.Fatalf("an unsealed dirty admission: %v", err)
301 }
302
303 admitted := f.admit(o, in)
304 h := admitted.Claim.Holder
305 last := h.Admissions[len(h.Admissions)-1]
306 if h.Stamp == rcS || h.Stamp != h.Session || h.Generation != rpRB || h.WriterBox != "ie-box" || last.Coverage != "sealed" || last.WritesLost != nil || last.Predecessor != poOp {
307 t.Fatalf("the admission: holder %+v, last %+v", h, last)
308 }
309 policy := MovesClaimPolicy(f.owners.Records)
310 s := hostbox.Selection{Host: poHost, Action: movesActionSpawn, Box: "ie-box", Region: "ie", Kind: hostclaim.KindRestoring, Op: poOp, Phase: hostclaim.PhaseProbing,
311 Captured: hostguard.Captured{Incarnation: f.inc, Stamp: rcS}}
312 rpRefused(t, policy(ctx, admitted.Claim, s, in.Identity), "the capture is not the holder's")
313 s.Captured.Stamp = h.Stamp
314 if err := policy(ctx, admitted.Claim, s, in.Identity); err != nil {
315 t.Fatalf("the new stamp's spawn: %v", err)
316 }
317
318 // A restarted attempt over the admitted return generation is a fresh
319 // generation with no coverage, restored from the return generation's
320 // pin while it has published nothing.
321 again, err := f.owners.Select(ctx, f.route, admitted, f.policy())
322 if err != nil {
323 t.Fatal(err)
324 }
325 if a := again.Admission; a.NewGeneration != "fresh1" || a.KeepStamp || a.Coverage != "" || a.ObservedGeneration != rpRB || a.PredecessorRegion != "ie" || again.Prepared ||
326 again.Baseline == nil || again.Baseline.Generation != rpRB || again.Baseline.MaxTXID != 50 {
327 t.Fatalf("restarted: %+v prepared=%v baseline %+v", a, again.Prepared, again.Baseline)
328 }
329 if err := f.owners.AdmissionEvidence(ctx, admitted, again.Admission, again.Baseline); err != nil {
330 t.Fatal(err)
331 }
332 reused := again.Admission
333 reused.NewGeneration = rpRB
334 if err := f.owners.AdmissionEvidence(ctx, admitted, reused, again.Baseline); !errors.Is(err, hostbox.ErrChanged) {
335 t.Fatalf("the return generation twice: %v", err)
336 }
337}
338
339// The dirty return's installation, its real admission and the real C8
340// materialization together (whole-branch fixes, item 2): local built the
341// prepared manifest at the source installation's stamp S (the claim's
342// journalled source_stamp, under which the return's local ran) and
343// journalled its binding; the recovery's cold admission issues a new
344// stamp; Establish validates the binding's immutable installation stamp
345// against S, separately from the admitted session's, and establishes P's
346// manifest with the minted env file. A binding at the admitted session's
347// stamp, or any other, is not the installation's and is refused.
348func TestDirtyReturnEstablishesThePreparedManifestThroughC8(t *testing.T) {
349 f := newRCFixture(t, rcDirty)
350 ctx := t.Context()
351 f.owners.Files = f.realC8()
352 pm := f.preparedManifestJournalled(rpRB, poOp, rcS)
353 o := f.read()
354 in := f.selectAdmission(o)
355 admitted := f.admitThroughWriter(o, in)
356 h := admitted.Claim.Holder
357 if h.Stamp == rcS || !in.Prepared {
358 t.Fatalf("the dirty admission kept the stamp or is not prepared: %+v %v", h, in.Prepared)
359 }
360 if err := f.owners.Materialize(ctx, admitted, in, poWork{action: "spawn", f: f.poFixture}); err != nil {
361 t.Fatalf("the dirty return's materialization: %v", err)
362 }
363 // The env file is the prepared item's (the recovery's start gate holds
364 // the restored tree, env included, to the recorded digest) and there is
365 // no adoption item, so the establishment reads the prepared bytes back
366 // at sequence 0 and publishes nothing; what it proved is the binding.
367 m, found, err := hostfiles.Load(ctx, casBucketStore{b: f.bucket, bucket: "data"}, movesFilesPrefix("instances/"+poHost, rpRB))
368 if err != nil || !found || m.Seq != 0 || m.Platform[hostfiles.EnvItem].SHA256 == "" {
369 t.Fatalf("P's manifest after the dirty return's probe: %+v %v %v", m, found, err)
370 }
371 if raw, _ := f.bucket.Current("data", pm.Key); sha(raw) != pm.SHA256 || f.bucket.LatestID("data", pm.Key) != pm.Version {
372 t.Fatal("the prepared manifest is not the journalled version")
373 }
374 // A binding at the admitted session's stamp, or another, is refused.
375 for name, stamp := range map[string]uint64{"the admitted session's stamp": h.Stamp, "another stamp": 99} {
376 g := newRCFixture(t, rcDirty)
377 g.owners.Files = g.realC8()
378 g.preparedManifestJournalled(rpRB, poOp, stamp)
379 o := g.read()
380 in := g.selectAdmission(o)
381 admitted := g.admitThroughWriter(o, in)
382 if err := g.owners.Materialize(ctx, admitted, in, poWork{action: "spawn", f: g.poFixture}); !errors.Is(err, errLandingChanged) {
383 t.Fatalf("%s: %v", name, err)
384 }
385 }
386}
387
388// The no-mint rule (platform-1e, whole-branch fixes): a recovery probe
389// preserves the restored environment byte for byte and never mints a
390// CARLOS_INSTANCE_KEY; the real C8 then carries exactly those bytes as
391// the generation's env item. A later change that minted during recovery
392// fails here, loudly, rather than exempting <host>.env from verification.
393func TestRecoveryProbeNeverMintsAnInstanceKey(t *testing.T) {
394 for _, kind := range []string{rcDirty, rcClean} {
395 t.Run(kind, func(t *testing.T) {
396 f := newRCFixture(t, kind)
397 ctx := t.Context()
398 f.owners.Files = f.realC8()
399 envPath := filepath.Join(f.dataDir, poHost+".env")
400 restored, err := os.ReadFile(envPath)
401 if err != nil {
402 t.Fatal(err)
403 }
404 if strings.Contains(string(restored), activator.InstanceKeyVar+"=") {
405 t.Fatalf("the restored env already carries a key: %q", restored)
406 }
407 gen := "src"
408 if kind == rcDirty {
409 gen = rpRB
410 }
411 // The manifest the recovery establishes from: the source's, or the
412 // dirty return's prepared one, built from the restored tree.
413 pm := f.preparedManifestJournalled(gen, poOp, rcS)
414 o := f.read()
415 in := f.selectAdmission(o)
416 admitted := f.admitThroughWriter(o, in)
417 if kind == rcClean {
418 // The restore placed the admitted generation's database (the
419 // start check's, not C8's).
420 if err := hostbox.WriteGenerationMarker(f.route.DBPath+".gen", admitted.Claim.Holder.Generation); err != nil {
421 t.Fatal(err)
422 }
423 }
424 if err := f.owners.Materialize(ctx, admitted, in, poWork{action: "spawn", f: f.poFixture}); err != nil {
425 t.Fatalf("the recovery's materialization: %v", err)
426 }
427 after, err := os.ReadFile(envPath)
428 if err != nil {
429 t.Fatal(err)
430 }
431 if string(after) != string(restored) {
432 t.Fatalf("the recovery changed the restored env:\n%q\n%q", restored, after)
433 }
434 if strings.Contains(string(after), activator.InstanceKeyVar+"=") {
435 t.Fatalf("the recovery minted a key: %q", after)
436 }
437 m, found, err := hostfiles.Load(ctx, casBucketStore{b: f.bucket, bucket: "data"}, movesFilesPrefix("instances/"+poHost, admitted.Claim.Holder.Generation))
438 if err != nil || !found || m.Platform[hostfiles.EnvItem].SHA256 != sha(restored) {
439 t.Fatalf("the env item is not the restored env: %+v %v %v (prepared %s)", m.Platform[hostfiles.EnvItem], found, err, pm.Key)
440 }
441 })
442 }
443}
444
445// Plan 4 Task 9: a clean return's probe is a cold admission into a fresh
446// generation restored from src_gen in this region, its coverage
447// clean-return: the admission records no writes lost.
448func TestCleanReturnProbeIsCoveredCleanReturn(t *testing.T) {
449 f := newRCFixture(t, rcClean)
450 ctx := t.Context()
451 o := f.read()
452 in := f.selectAdmission(o)
453 want := hostclaim.Admission{ObservedOpID: poOp, ObservedPhase: hostclaim.PhaseProbing, ObservedSession: 20, ObservedGeneration: "src", Box: "ie-box", Mode: "cold",
454 NewGeneration: "fresh1", Coverage: "clean-return", Region: "ie", PredecessorRegion: "uk", RemoteMaxTXID: 30}
455 if !reflect.DeepEqual(in.Admission, want) {
456 t.Fatalf("admission:\n got %+v\nwant %+v", in.Admission, want)
457 }
458 if in.Prepared || in.Baseline == nil || in.Baseline.Generation != "src" || in.Baseline.MaxTXID != 30 {
459 t.Fatalf("baseline: prepared=%v %+v", in.Prepared, in.Baseline)
460 }
461 if err := f.owners.AdmissionEvidence(ctx, o, in.Admission, in.Baseline); err != nil {
462 t.Fatal(err)
463 }
464 f.refusesMutations(o, in)
465 for _, coverage := range []string{"", "sealed"} {
466 a := in.Admission
467 a.Coverage = coverage
468 if err := f.owners.AdmissionEvidence(ctx, o, a, in.Baseline); !errors.Is(err, hostbox.ErrChanged) {
469 t.Fatalf("coverage %q: %v", coverage, err)
470 }
471 }
472 admitted := f.admit(o, in)
473 h := admitted.Claim.Holder
474 last := h.Admissions[len(h.Admissions)-1]
475 if last.Coverage != "clean-return" || last.WritesLost != nil || len(last.AdditionalWritesLost) != 0 || h.Generation != "fresh1" || h.Stamp == rcS {
476 t.Fatalf("the admission: holder %+v, last %+v", h, last)
477 }
478
479 // A clean return whose holder does not name src_gen is not this return.
480 t.Run("the holder is not at src_gen", func(t *testing.T) {
481 f := newRCFixture(t, rcClean)
482 c := f.recoveryClaim()
483 c.Holder.Generation = poOp
484 f.putClaim(c)
485 if _, err := f.owners.Select(t.Context(), f.route, f.read(), f.policy()); !errors.Is(err, hostbox.ErrChanged) {
486 t.Fatal(err)
487 }
488 })
489 // Fix round 1, ruling (4): AdmitWriter records the target's last
490 // admission as the recovery admission's predecessor, so with that
491 // admission covered the ordinary selection of a restart that published
492 // no root ends in ErrCovered. Until the recovery has a root, a clean
493 // return's restart restores the recorded source baseline (src_gen, by
494 // exact version); after its first root ordinary selection resumes.
495 restart := func(t *testing.T) (*rcFixture, hostclaim.Observed, ClaimLaunchInputs) {
496 f := newRCFixture(t, rcClean)
497 c := f.recoveryClaim()
498 c.Holder.Admissions[1].Covered = true
499 f.putClaim(c)
500 o := f.read()
501 in := f.selectAdmission(o)
502 admitted := f.admitThroughWriter(o, in)
503 if err := hostbox.WriteGenerationMarker(f.route.DBPath+".gen", "fresh1"); err != nil {
504 t.Fatal(err)
505 }
506 if err := f.owners.Materialize(t.Context(), admitted, in, poWork{action: "spawn", f: f.poFixture}); err != nil {
507 t.Fatal(err)
508 }
509 return f, admitted, in
510 }
511 t.Run("a restart before the first root restores src_gen", func(t *testing.T) {
512 f, admitted, first := restart(t)
513 ctx := t.Context()
514 again, err := f.owners.Select(ctx, f.route, admitted, f.policy())
515 if err != nil {
516 t.Fatal(err)
517 }
518 if a := again.Admission; a.NewGeneration != "fresh2" || a.Coverage != "" || a.ObservedGeneration != "fresh1" || a.RemoteMaxTXID != 30 || again.Prepared ||
519 again.Baseline.Generation != "src" || !reflect.DeepEqual(again.Baseline.Files, first.Baseline.Files) {
520 t.Fatalf("restart: %+v baseline %+v", a, again.Baseline)
521 }
522 if err := f.owners.AdmissionEvidence(ctx, admitted, again.Admission, again.Baseline); err != nil {
523 t.Fatal(err)
524 }
525 restarted := f.admit(admitted, again)
526 // The recovery's first root: ordinary selection resumes.
527 f.bucket.Put("data", "instances/"+poHost+"/g-fresh2/0009/"+ltx.FormatFilename(1, 31), f.ltxHeader(1, 31))
528 third, err := f.owners.Select(ctx, f.route, restarted, f.policy())
529 if err != nil {
530 t.Fatal(err)
531 }
532 if third.Baseline.Generation != "fresh2" || third.Admission.RemoteMaxTXID != 31 || third.Admission.NewGeneration != "fresh3" {
533 t.Fatalf("after the first root: %+v baseline %+v", third.Admission, third.Baseline)
534 }
535 if err := f.owners.AdmissionEvidence(ctx, restarted, third.Admission, third.Baseline); err != nil {
536 t.Fatal(err)
537 }
538 })
539 t.Run("the source baseline is the recorded one, by exact version", func(t *testing.T) {
540 f, admitted, _ := restart(t)
541 f.bucket.Put("data", "instances/"+poHost+"/g-src/0009/"+ltx.FormatFilename(1, 30), f.ltxHeader(1, 30))
542 if _, err := f.owners.Select(t.Context(), f.route, admitted, f.policy()); !errors.Is(err, hostbox.ErrChanged) {
543 t.Fatalf("another version of src_gen: %v", err)
544 }
545 })
546 // Fix round 2: the source baseline is persisted before the restore (the
547 // activator restores before Materialize), at the admission's evidence,
548 // so a crash between the restore and Materialize still pins it.
549 t.Run("a crash between the restore and Materialize keeps the pin", func(t *testing.T) {
550 f := newRCFixture(t, rcClean)
551 c := f.recoveryClaim()
552 c.Holder.Admissions[1].Covered = true
553 f.putClaim(c)
554 ctx := t.Context()
555 o := f.read()
556 in := f.selectAdmission(o)
557 admitted := f.admitThroughWriter(o, in)
558 // The activator restores in.Baseline here; the box crashes before
559 // Materialize. src_gen then shows another version.
560 f.bucket.Put("data", "instances/"+poHost+"/g-src/0009/"+ltx.FormatFilename(1, 30), f.ltxHeader(1, 30))
561 if _, err := f.owners.Select(ctx, f.route, admitted, f.policy()); !errors.Is(err, hostbox.ErrChanged) {
562 t.Fatalf("the restart after the crash: %v", err)
563 }
564 })
565 // Fix round 2: a legacy source's src_gen is the origin, "" in the claim
566 // and hostclaim.RootGeneration in a selection; the source baseline and
567 // its pin compare the two as one.
568 t.Run("a legacy source's clean return", func(t *testing.T) {
569 f := newRCFixture(t, rcLegacy)
570 ctx := t.Context()
571 o := f.read()
572 in := f.selectAdmission(o)
573 if a := in.Admission; a.NewGeneration != "fresh1" || a.Coverage != "clean-return" || a.ObservedGeneration != "" || a.RemoteMaxTXID != 30 ||
574 in.Baseline.Generation != hostclaim.RootGeneration {
575 t.Fatalf("admission %+v baseline %+v", a, in.Baseline)
576 }
577 admitted := f.admitThroughWriter(o, in)
578 again, err := f.owners.Select(ctx, f.route, admitted, f.policy())
579 if err != nil {
580 t.Fatal(err)
581 }
582 if again.Baseline.Generation != hostclaim.RootGeneration || again.Admission.RemoteMaxTXID != 30 || again.Admission.Coverage != "" {
583 t.Fatalf("restart %+v baseline %+v", again.Admission, again.Baseline)
584 }
585 if err := f.owners.AdmissionEvidence(ctx, admitted, again.Admission, again.Baseline); err != nil {
586 t.Fatal(err)
587 }
588 // The origin's pin was journalled at the first admission.
589 f.bucket.Put("data", "instances/"+poHost+"/0009/"+ltx.FormatFilename(1, 30), f.ltxHeader(1, 30))
590 if _, err := f.owners.Select(ctx, f.route, admitted, f.policy()); !errors.Is(err, hostbox.ErrChanged) {
591 t.Fatalf("another version of the origin: %v", err)
592 }
593 })
594 // A declared or fenced return (Q6) asks no coverage: the claim journals
595 // the fence and the record carries it as its loss.
596 t.Run("a fenced return asks no coverage", func(t *testing.T) {
597 f := newRCFixture(t, rcClean)
598 c := f.recoveryClaim()
599 c.Op.Journal["fence.uk-box"] = `{"key":"fences/uk-box"}`
600 f.putClaim(c)
601 f.rec.Move.Rollback.LossRef = "fences/uk-box"
602 o := f.read()
603 in := f.selectAdmission(o)
604 if in.Admission.Coverage != "" || in.Admission.NewGeneration != "fresh1" {
605 t.Fatalf("admission %+v", in.Admission)
606 }
607 if err := f.owners.AdmissionEvidence(t.Context(), o, in.Admission, in.Baseline); err != nil {
608 t.Fatal(err)
609 }
610 covered := in.Admission
611 covered.Coverage = "clean-return"
612 if err := f.owners.AdmissionEvidence(t.Context(), o, covered, in.Baseline); !errors.Is(err, hostbox.ErrChanged) {
613 t.Fatal(err)
614 }
615 // The record and the claim disagree: wait.
616 f.rec.Move.Rollback.LossRef = ""
617 if _, err := f.owners.Select(t.Context(), f.route, o, f.policy()); !errors.Is(err, hostbox.ErrChanged) {
618 t.Fatal(err)
619 }
620 })
621}
622
623// A revert's probe (ruling Q7): a cold admission into a fresh generation
624// restored from the holder's own generation, which a revert never changed,
625// with no coverage: the admission records the source's writes as possibly
626// lost (uncovered), and keeps no stamp.
627func TestRevertProbeAdmitsUncovered(t *testing.T) {
628 f := newRCFixture(t, rcRevert)
629 ctx := t.Context()
630 o := f.read()
631 in := f.selectAdmission(o)
632 want := hostclaim.Admission{ObservedOpID: poOp, ObservedPhase: hostclaim.PhaseProbing, ObservedSession: 15, ObservedGeneration: "src", Box: "ie-box", Mode: "cold",
633 NewGeneration: "fresh1", Region: "ie", PredecessorRegion: "ie", RemoteMaxTXID: 30}
634 if !reflect.DeepEqual(in.Admission, want) {
635 t.Fatalf("admission:\n got %+v\nwant %+v", in.Admission, want)
636 }
637 if in.Prepared || in.Baseline == nil || in.Baseline.Generation != "src" || in.Identity != mpLaunchIdentity(f.inputs) {
638 t.Fatalf("inputs: %+v", in)
639 }
640 if err := f.owners.AdmissionEvidence(ctx, o, in.Admission, in.Baseline); err != nil {
641 t.Fatal(err)
642 }
643 f.refusesMutations(o, in)
644 for _, coverage := range []string{"clean-return", "sealed"} {
645 a := in.Admission
646 a.Coverage = coverage
647 if err := f.owners.AdmissionEvidence(ctx, o, a, in.Baseline); !errors.Is(err, hostbox.ErrChanged) {
648 t.Fatalf("coverage %q: %v", coverage, err)
649 }
650 }
651 admitted := f.admit(o, in)
652 h := admitted.Claim.Holder
653 last := h.Admissions[len(h.Admissions)-1]
654 if last.Coverage != "" || last.WritesLost == nil || last.WritesLost.Box != "ie-box" || last.WritesLost.Session != 15 || last.WritesLost.Generation != "src" || last.WritesLost.RemoteMaxTXID != 30 ||
655 h.Generation != "fresh1" || h.Stamp == rcS {
656 t.Fatalf("the admission: holder %+v, last %+v", h, last)
657 }
658
659 // The revert's inputs are exactly the recorded ones, and its row is held
660 // by the move id.
661 for name, mutate := range map[string]func(*rcFixture, *activator.StartPolicy){
662 "other inputs": func(_ *rcFixture, p *activator.StartPolicy) { p.Inputs.Release = "v9" },
663 "no inputs": func(_ *rcFixture, p *activator.StartPolicy) { p.Inputs = nil },
664 "nothing recorded": func(f *rcFixture, _ *activator.StartPolicy) { f.rec.Move.RecoveryInputs = nil },
665 "not a probe": func(_ *rcFixture, p *activator.StartPolicy) { p.Probe = false },
666 "a rollback's hold": func(f *rcFixture, _ *activator.StartPolicy) { f.route.HoldOp = rpRB },
667 "reopened": func(f *rcFixture, _ *activator.StartPolicy) { now := time.Now(); f.rec.Move.ReopenedAt = &now },
668 "another live release": func(f *rcFixture, _ *activator.StartPolicy) { f.launch.Version = "v1.2.3" },
669 } {
670 t.Run(name, func(t *testing.T) {
671 f := newRCFixture(t, rcRevert)
672 p := f.policy()
673 mutate(f, &p)
674 if _, err := f.owners.Select(t.Context(), f.route, f.read(), p); err == nil {
675 t.Fatal("selected")
676 }
677 })
678 }
679}
680
681// Ruling Q7: the moves owner set dispatches by the observed op's kind. A
682// landing reaches the landing probe's owners, a restoring or reverting op
683// the recovery owners, and the moving kind nothing; without C8 the
684// recovery kinds refuse, typed, while the landing is unchanged.
685func TestMovesOwnersDispatchByKind(t *testing.T) {
686 ctx := t.Context()
687 dispatch := func(m *movesProbeOwners) ClaimOwnerDispatch {
688 owners := m.MovesOwners(&movesProbeAttempts{}).ClaimKindOwners
689 return ClaimOwnerDispatch{Moves: &owners}
690 }
691
692 landing := newPOFixture(t)
693 landing.owners.Files = &rcFiles{f: landing}
694 in, err := dispatch(landing.owners).Select(ctx, landing.route, landing.read(), poProbe)
695 if err != nil || in.Admission.NewGeneration != poOp || in.Admission.Region != "uk" {
696 t.Fatalf("landing: %+v %v", in.Admission, err)
697 }
698
699 for _, kind := range []string{rcDirty, rcClean, rcRevert} {
700 f := newRCFixture(t, kind)
701 d := dispatch(f.owners)
702 o := f.read()
703 in, err := d.Select(ctx, f.route, o, f.policy())
704 if err != nil {
705 t.Fatalf("%s: %v", kind, err)
706 }
707 if in.Admission.Region != "ie" || in.Admission.Box != "ie-box" || (kind == rcDirty) != (in.Admission.NewGeneration == rpRB) {
708 t.Fatalf("%s: %+v", kind, in.Admission)
709 }
710 if err := d.AdmissionEvidence(ctx, o, in.Admission, in.Baseline); err != nil {
711 t.Fatalf("%s evidence: %v", kind, err)
712 }
713 if err := d.EmptyAuthority(ctx, o, in); !errors.Is(err, hostbox.ErrUnsupported) {
714 t.Fatalf("%s empty authority: %v", kind, err)
715 }
716
717 // Without C8 the recovery refuses before reading anything.
718 f.owners.Files = nil
719 f.launches = 0
720 _, err = d.Select(ctx, f.route, o, f.policy())
721 var missing *ClaimDependencyError
722 if !errors.As(err, &missing) || !strings.Contains(missing.Error(), "C8") || f.launches != 0 {
723 t.Fatalf("%s without C8: %v", kind, err)
724 }
725 }
726 landing.owners.Files = nil
727 if _, err := dispatch(landing.owners).Select(ctx, landing.route, landing.read(), poProbe); err != nil {
728 t.Fatalf("the landing without C8: %v", err)
729 }
730
731 // The moving kind reaches no owner.
732 moving := newPOFixture(t)
733 c := moving.poFlipClaim()
734 c.Op.Kind, c.Op.Phase = hostclaim.KindMoving, hostclaim.PhaseSealed
735 moving.putClaim(c)
736 if _, err := dispatch(moving.owners).Select(ctx, moving.route, moving.read(), poProbe); !errors.Is(err, hostbox.ErrUnsupported) {
737 t.Fatalf("moving: %v", err)
738 }
739}
740
741// Ruling Q7's Materialize: before every start of the admitted recovery
742// probe, the held render and the held release of the recorded inputs, each
743// under its own held work at the admitted capture, then the return
744// generation's verified marker (dirty), then C8's Establish under the
745// start's work, then the start check. A changed render, a refused held
746// work or a refused Establish stops the start.
747func TestRecoveryMaterializeRendersTheRecordedInputsUnderHeldWork(t *testing.T) {
748 ctx := t.Context()
749 start := func(t *testing.T, kind string) (*rcFixture, hostclaim.Observed, ClaimLaunchInputs) {
750 f := newRCFixture(t, kind)
751 o := f.read()
752 in := f.selectAdmission(o)
753 admitted := f.admitThroughWriter(o, in)
754 if kind != rcDirty {
755 // The restore rewrites <dbPath>.gen to the admitted generation.
756 if err := hostbox.WriteGenerationMarker(f.route.DBPath+".gen", admitted.Claim.Holder.Generation); err != nil {
757 t.Fatal(err)
758 }
759 }
760 f.order = nil
761 return f, admitted, in
762 }
763 cfg := func(f *rcFixture) string {
764 return HeldConfigIdentity(f.inputs.BundleETags, f.inputs.OverlayETag).Config
765 }
766 launch := func(f *rcFixture) string { id := mpLaunchIdentity(f.inputs); return id.Config + "|" + id.Release }
767
768 t.Run("dirty", func(t *testing.T) {
769 f, o, in := start(t, rcDirty)
770 if err := f.owners.Materialize(ctx, o, in, poWork{action: "spawn", f: f.poFixture}); err != nil {
771 t.Fatal(err)
772 }
773 // The admitted writer's fence first, then the held render and release
774 // each under its own selection, then C8 under the start's work.
775 rcInOrder(t, f.order, "spawn:"+launch(f), "render", "select:held-config", "held-config:"+cfg(f)+"|", "select:held-release", "release:v1.3.0", "held-release:|v1.3.0",
776 "establish:"+rpRB, "spawn:"+launch(f))
777 if f.c8.calls != 1 {
778 t.Fatalf("C8 ran %d times", f.c8.calls)
779 }
780 // The verified marker names the return generation with its pin.
781 marker, err := f.owners.readMarker(ctx, o.Claim.Holder)
782 if err != nil || marker == nil || marker.Op != rpRB || marker.Pin != manifestPin(f.manifest) || marker.Box != "ie-box" {
783 t.Fatalf("marker %+v %v", marker, err)
784 }
785 if err := f.owners.Publish(ctx, o, in); err != nil {
786 t.Fatal(err)
787 }
788 if err := f.owners.PublicationEvidence(ctx, o); err != nil {
789 t.Fatalf("the return generation's publication: %v", err)
790 }
791 })
792 t.Run("clean", func(t *testing.T) {
793 f, o, in := start(t, rcClean)
794 if err := f.owners.Materialize(ctx, o, in, poWork{action: "spawn", f: f.poFixture}); err != nil {
795 t.Fatal(err)
796 }
797 rcInOrder(t, f.order, "render", "held-config:"+cfg(f)+"|", "release:v1.3.0", "held-release:|v1.3.0", "establish:fresh1")
798 if f.c8.calls != 1 {
799 t.Fatalf("C8 ran %d times", f.c8.calls)
800 }
801 // No prepared generation: the marker still names src_gen until the
802 // fenced publication.
803 if marker, err := f.owners.readMarker(ctx, o.Claim.Holder); err != nil || marker.Op != "src" {
804 t.Fatalf("marker %+v %v", marker, err)
805 }
806 if err := f.owners.Publish(ctx, o, in); !errors.Is(err, hostbox.ErrChanged) {
807 t.Fatalf("an unfenced publication of a fresh generation: %v", err)
808 }
809 // The fenced publication after the session's own first root.
810 f.bucket.Put("data", "instances/"+poHost+"/g-fresh1/0009/"+ltx.FormatFilename(1, 31), f.ltxHeader(1, 31))
811 checks := 0
812 pub := ClaimPublication{Sync: func(context.Context) error { return nil }, Check: func(context.Context) error { checks++; return nil }}
813 receipt, err := f.owners.PublishFenced(ctx, o, in, pub)
814 if err != nil || receipt.MarkerVersion == "" || checks == 0 {
815 t.Fatalf("PublishFenced: %+v %v (checks %d)", receipt, err, checks)
816 }
817 if marker, err := f.owners.readMarker(ctx, o.Claim.Holder); err != nil || marker.Op != "fresh1" || marker.Box != "ie-box" {
818 t.Fatalf("marker %+v %v", marker, err)
819 }
820 if err := f.owners.PublicationEvidence(ctx, o); err != nil {
821 t.Fatalf("the fresh generation's publication: %v", err)
822 }
823 })
824 t.Run("the render is not the recorded inputs", func(t *testing.T) {
825 f, o, in := start(t, rcRevert)
826 f.renderETags = map[string]string{"default": "e-moved-on"}
827 if err := f.owners.Materialize(ctx, o, in, poWork{action: "spawn", f: f.poFixture}); !errors.Is(err, hostbox.ErrChanged) {
828 t.Fatal(err)
829 }
830 if contains(f.order, "select:held-config") || f.c8.calls != 0 {
831 t.Fatalf("selected or established after a changed render: %q", f.order)
832 }
833 })
834 t.Run("the launch environment is not the recorded one", func(t *testing.T) {
835 f, o, in := start(t, rcClean)
836 in.Env = append(in.Env, "B=2")
837 if err := f.owners.Materialize(ctx, o, in, poWork{action: "spawn", f: f.poFixture}); !errors.Is(err, hostbox.ErrChanged) {
838 t.Fatal(err)
839 }
840 if f.c8.calls != 0 {
841 t.Fatal("established")
842 }
843 })
844 t.Run("another identity", func(t *testing.T) {
845 f, o, in := start(t, rcClean)
846 in.Identity = mpLaunchIdentity(mpPins())
847 if err := f.owners.Materialize(ctx, o, in, poWork{action: "spawn", f: f.poFixture}); !errors.Is(err, hostbox.ErrChanged) {
848 t.Fatal(err)
849 }
850 if len(f.order) != 0 {
851 t.Fatalf("ran %q", f.order)
852 }
853 })
854 t.Run("the start's work is refused", func(t *testing.T) {
855 f, o, in := start(t, rcDirty)
856 if err := f.owners.Materialize(ctx, o, in, poWork{action: "spawn", f: f.poFixture, shut: true}); !errors.Is(err, hostwrite.ErrUnavailable) {
857 t.Fatal(err)
858 }
859 if contains(f.order, "render") || f.c8.calls != 0 {
860 t.Fatalf("ran %q", f.order)
861 }
862 })
863 t.Run("C8 refuses", func(t *testing.T) {
864 f, o, in := start(t, rcClean)
865 f.c8.err = errors.New("the bucket cannot be reached")
866 if err := f.owners.Materialize(ctx, o, in, poWork{action: "spawn", f: f.poFixture}); !errors.Is(err, f.c8.err) {
867 t.Fatal(err)
868 }
869 })
870 t.Run("not the admitted session", func(t *testing.T) {
871 f := newRCFixture(t, rcClean)
872 o := f.read()
873 in := f.selectAdmission(o)
874 if err := f.owners.Materialize(ctx, o, in, poWork{action: "spawn", f: f.poFixture}); !errors.Is(err, hostbox.ErrChanged) {
875 t.Fatal(err)
876 }
877 if len(f.order) != 0 {
878 t.Fatalf("ran %q", f.order)
879 }
880 })
881}
882
883// rcInOrder requires each of want in order, as a subsequence of got.
884func rcInOrder(t *testing.T, got []string, want ...string) {
885 t.Helper()
886 i := 0
887 for _, g := range got {
888 if i < len(want) && g == want[i] {
889 i++
890 }
891 }
892 if i != len(want) {
893 t.Fatalf("order:\n got %q\nwant, in order, %q (missing from %q)", got, want, want[i])
894 }
895}
896
897// Fix round 3 (ruling 3, Astra's rule): the placed tree's journalled
898// digest is verified only at the start gate, before the recovery's first
899// authorized start. From then on the tree is the recovery's own: ownership
900// rests on its admitted process lineage under the claim session's fence,
901// and its writes are authoritative (spec §7.8). The relabel checks the
902// fence, the placement identity (a replaced tree) and the label's
903// predecessor and incarnation, never the content. A later attempt over the
904// same tree is admitted through the same lineage, so an earlier attempt's
905// writes never block it; an admission the lineage cannot prove is refused.
906func TestRecoveryPublicationRelabelsTheRecoverysOwnTree(t *testing.T) {
907 changeAFile := func(t *testing.T, f *rcFixture) {
908 if err := os.WriteFile(filepath.Join(f.files, "a.txt"), []byte("written by the recovery's app"), 0o600); err != nil {
909 t.Fatal(err)
910 }
911 }
912 replace := func(t *testing.T, f *rcFixture) {
913 label, err := os.ReadFile(filepath.Join(f.files, archive.MarkerName))
914 if err != nil {
915 t.Fatal(err)
916 }
917 if err := os.Rename(f.files, f.files+"-replaced"); err != nil {
918 t.Fatal(err)
919 }
920 if err := os.Mkdir(f.files, 0o700); err != nil {
921 t.Fatal(err)
922 }
923 for name, body := range map[string][]byte{"a.txt": []byte("the landing's file"), archive.MarkerName: label} {
924 if err := os.WriteFile(filepath.Join(f.files, name), body, 0o600); err != nil {
925 t.Fatal(err)
926 }
927 }
928 }
929 unproven := func(t *testing.T, f *rcFixture) {
930 err := moves.UpdateJournal(f.dataDir, poHost, rpRB, func(j *moves.Journal) error {
931 j.Unmark(rcStepLineage + "fresh1")
932 return nil
933 })
934 if err != nil {
935 t.Fatal(err)
936 }
937 }
938 for _, tc := range []struct {
939 name string
940 earlier, before, during func(t *testing.T, f *rcFixture)
941 ok bool
942 }{
943 {name: "the tree as placed", ok: true},
944 {name: "a restart after an earlier attempt wrote files", earlier: changeAFile, ok: true},
945 {name: "authorized writes before the publication", before: changeAFile, ok: true},
946 {name: "authorized writes during the publication", during: changeAFile, ok: true},
947 {name: "the tree is replaced before the publication", before: replace},
948 {name: "the tree is replaced during the publication", during: replace},
949 {name: "an admission the lineage cannot prove", before: unproven},
950 } {
951 t.Run(tc.name, func(t *testing.T) {
952 f := newRCFixture(t, rcDirty)
953 ctx := t.Context()
954 work := poWork{action: "spawn", f: f.poFixture}
955 o := f.read()
956 in := f.selectAdmission(o)
957 admitted := f.admitThroughWriter(o, in)
958 if err := f.owners.Materialize(ctx, admitted, in, work); err != nil {
959 t.Fatal(err)
960 }
961 if err := f.owners.Publish(ctx, admitted, in); err != nil {
962 t.Fatal(err)
963 }
964 if tc.earlier != nil {
965 tc.earlier(t, f)
966 }
967 // A restart over the published return generation.
968 again, err := f.owners.Select(ctx, f.route, admitted, f.policy())
969 if err != nil {
970 t.Fatal(err)
971 }
972 restarted := f.admitThroughWriter(admitted, again)
973 if err := hostbox.WriteGenerationMarker(f.route.DBPath+".gen", "fresh1"); err != nil {
974 t.Fatal(err)
975 }
976 if err := f.owners.Materialize(ctx, restarted, again, work); err != nil {
977 t.Fatal(err)
978 }
979 f.bucket.Put("data", "instances/"+poHost+"/g-fresh1/0009/"+ltx.FormatFilename(1, 51), f.ltxHeader(1, 51))
980 if tc.before != nil {
981 tc.before(t, f)
982 }
983 pub := ClaimPublication{Check: func(context.Context) error { return nil }, Sync: func(context.Context) error {
984 if tc.during != nil {
985 tc.during(t, f)
986 }
987 return nil
988 }}
989 _, err = f.owners.PublishFenced(ctx, restarted, again, pub)
990 raw, rerr := os.ReadFile(filepath.Join(f.files, archive.MarkerName))
991 if rerr != nil {
992 t.Fatal(rerr)
993 }
994 var label moves.FilesGen
995 if err := json.Unmarshal(raw, &label); err != nil {
996 t.Fatal(err)
997 }
998 if tc.ok {
999 if err != nil || label.Op != "fresh1" {
1000 t.Fatalf("publication %v, label %+v", err, label)
1001 }
1002 return
1003 }
1004 if !errors.Is(err, hostbox.ErrChanged) || label.Op != rpRB {
1005 t.Fatalf("publication %v, label %+v: want refused and still %s", err, label, rpRB)
1006 }
1007 })
1008 }
1009}
1010
1011// Fix round 3: before the recovery's first authorized start, the start gate
1012// requires the tree to be the one the recovery restored: a dirty return's
1013// the return baseline's files digest, a clean return's or a revert's the
1014// source's stopped baseline. A file changed before the first start is
1015// refused, nothing is established, and no placement is journalled, so the
1016// next attempt checks again.
1017func TestRecoveryStartGateRefusesATreeChangedBeforeTheFirstStart(t *testing.T) {
1018 for _, kind := range []string{rcDirty, rcClean, rcRevert} {
1019 t.Run(kind, func(t *testing.T) {
1020 f := newRCFixture(t, kind)
1021 ctx := t.Context()
1022 o := f.read()
1023 in := f.selectAdmission(o)
1024 admitted := f.admitThroughWriter(o, in)
1025 if kind != rcDirty {
1026 if err := hostbox.WriteGenerationMarker(f.route.DBPath+".gen", admitted.Claim.Holder.Generation); err != nil {
1027 t.Fatal(err)
1028 }
1029 }
1030 if err := os.WriteFile(filepath.Join(f.files, "a.txt"), []byte("tampered before the first start"), 0o600); err != nil {
1031 t.Fatal(err)
1032 }
1033 err := f.owners.Materialize(ctx, admitted, in, poWork{action: "spawn", f: f.poFixture})
1034 if !errors.Is(err, hostbox.ErrChanged) || f.c8.calls != 0 {
1035 t.Fatalf("materialize %v, C8 ran %d times", err, f.c8.calls)
1036 }
1037 j, jerr := moves.ReadJournal(f.dataDir, poHost, map[string]string{rcDirty: rpRB, rcClean: rpRB, rcRevert: poOp}[kind])
1038 if jerr != nil {
1039 t.Fatal(jerr)
1040 }
1041 if _, ok := j.Steps[rcStepFilesPlaced]; ok {
1042 t.Fatal("a refused tree was journalled as placed")
1043 }
1044 })
1045 }
1046}
1047
1048// admitThroughWriter admits through the claim's writer, as the wake does:
1049// the owners' AdmissionEvidence, then the CAS.
1050func (f *rcFixture) admitThroughWriter(o hostclaim.Observed, in ClaimLaunchInputs) hostclaim.Observed {
1051 f.t.Helper()
1052 w := hostbox.Writer{Claims: f.claims, Data: f.bucket, Bucket: "data", Region: f.srcRegion, Box: f.srcBox, AdmissionEvidence: f.owners.AdmissionEvidence}
1053 admitted, err := w.Admit(f.t.Context(), o, in.Admission, in.Baseline)
1054 if err != nil {
1055 f.t.Fatal(err)
1056 }
1057 return admitted
1058}
1059
1060// Fix round 4: the invariant lineage relies on (see its comment). While the
1061// op is restoring or reverting, the claim admits no writer outside the
1062// recovery owners: the writer refuses any warm admission (the reviewer's
1063// counterexample keeps G under an outside session); only the executor may
1064// admit; and the dispatch hands every cold admission of the op to the
1065// recovery owners' evidence, which refuses one they would not select (an
1066// outside owner's shape) and journals the link of any it accepts, so even a
1067// hand-built admission that passes is in the recovery's lineage.
1068func TestRecoveryOpAdmitsNoOutsideWriter(t *testing.T) {
1069 for _, kind := range []string{rcClean, rcRevert} {
1070 t.Run(kind, func(t *testing.T) {
1071 f := newRCFixture(t, kind)
1072 ctx := t.Context()
1073 o := f.read()
1074 in := f.selectAdmission(o)
1075 admitted := f.admitThroughWriter(o, in)
1076 outside := 0
1077 serving := ClaimKindOwners{Inputs: f.owners, PublicationEvidence: func(context.Context, hostclaim.Observed) error { return nil },
1078 AdmissionEvidence: func(context.Context, hostclaim.Observed, hostclaim.Admission, *genselect.Plan) error {
1079 outside++
1080 return nil
1081 }}
1082 moves := f.owners.MovesOwners(&movesProbeAttempts{}).ClaimKindOwners
1083 dispatch := ClaimOwnerDispatch{Serving: serving, Moves: &moves}
1084 w := hostbox.Writer{Claims: f.claims, Data: f.bucket, Bucket: "data", Region: "ie", Box: "ie-box", AdmissionEvidence: dispatch.AdmissionEvidence}
1085 h := admitted.Claim.Holder
1086 plan, err := genselect.Select(ctx, f.bucket, f.owners.request(poHost, h))
1087 if err != nil {
1088 t.Fatal(err)
1089 }
1090 restart := hostclaim.Admission{ObservedOpID: poOp, ObservedPhase: hostclaim.PhaseProbing, ObservedSession: h.Session, ObservedGeneration: h.Generation,
1091 Box: "ie-box", Region: "ie", PredecessorRegion: "ie", Mode: "cold", NewGeneration: "outside1", RemoteMaxTXID: plan.MaxTXID}
1092 unmoved := func() {
1093 t.Helper()
1094 now := f.read().Claim.Holder
1095 if now.Session != h.Session || now.Generation != h.Generation || now.WriterBox != h.WriterBox || len(now.Admissions) != len(h.Admissions) {
1096 t.Fatalf("the holder moved: %+v", now)
1097 }
1098 }
1099
1100 // The counterexample's outside warm admission keeping G.
1101 warm := restart
1102 warm.Mode, warm.NewGeneration, warm.PredecessorCertified = "warm", "", true
1103 if _, err := w.Admit(ctx, admitted, warm, nil); !errors.Is(err, hostbox.ErrUnsupported) {
1104 t.Fatalf("a warm admission: %v", err)
1105 }
1106 // An outside owner's shape (serving's: no op, no phase), and one
1107 // claiming coverage the recovery's restart never claims.
1108 for name, mutate := range map[string]func(*hostclaim.Admission){
1109 "serving's shape": func(a *hostclaim.Admission) { a.ObservedOpID, a.ObservedPhase = "", "" },
1110 "claimed coverage": func(a *hostclaim.Admission) { a.Coverage = "clean-return" },
1111 "a kept stamp": func(a *hostclaim.Admission) { a.KeepStamp = true },
1112 } {
1113 a := restart
1114 mutate(&a)
1115 if _, err := w.Admit(ctx, admitted, a, plan); err == nil {
1116 t.Fatalf("%s was admitted", name)
1117 }
1118 }
1119 if outside != 0 {
1120 t.Fatalf("the dispatch reached another owner's evidence %d times", outside)
1121 }
1122 // Another box is never the executor.
1123 other := restart
1124 other.Box = "uk-box"
1125 if _, err := hostclaim.Update(ctx, f.claims, admitted, hostclaim.AdmitWriter(other)); !errors.Is(err, hostclaim.ErrWrongState) {
1126 t.Fatalf("another box: %v", err)
1127 }
1128 unmoved()
1129
1130 // Any cold admission the op does take passed the recovery's
1131 // evidence, which journalled its link: it is in the lineage.
1132 taken, err := w.Admit(ctx, admitted, restart, plan)
1133 if err != nil {
1134 t.Fatal(err)
1135 }
1136 v, err := f.owners.recovery().recovery(ctx, taken)
1137 if err != nil {
1138 t.Fatal(err)
1139 }
1140 if err := f.owners.recovery().lineage(poHost, v); err != nil {
1141 t.Fatalf("an admission the evidence passed: %v", err)
1142 }
1143 })
1144 }
1145}
1146