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 "strings"
10 "testing"
11 "time"
12
13 "github.com/carlosframework/platform/internal/activator"
14 "github.com/carlosframework/platform/internal/genselect"
15 "github.com/carlosframework/platform/internal/hostbox"
16 "github.com/carlosframework/platform/internal/hostclaim"
17 "github.com/carlosframework/platform/internal/hostguard"
18 "github.com/carlosframework/platform/internal/hostwrite"
19 "github.com/carlosframework/platform/internal/moves"
20 "github.com/carlosframework/platform/internal/moves/archive"
21 "github.com/carlosframework/platform/internal/provision"
22 "github.com/superfly/ltx"
23)
24
25var poWake = activator.StartPolicy{MaxStarts: 5, Window: 30 * time.Second}
26
27// committedAfterProbe is the owners' fixture after a passing probe and the
28// commit: the move id admitted and published, the claim at
29// landing/committed, the record committed, the row open.
30func committedAfterProbe(t *testing.T) (*poFixture, hostclaim.Observed) {
31 t.Helper()
32 f := newPOFixture(t)
33 admitted, in := f.selectAndAdmit()
34 if err := f.owners.Materialize(t.Context(), admitted, in, newFenceWork()); err != nil {
35 t.Fatal(err)
36 }
37 c := *f.read().Claim
38 c.Op.Phase = hostclaim.PhaseLandingCommitted
39 f.putClaim(&c)
40 f.rec.Move.State, f.rec.Move.LandingPhase = provision.MoveCommitted, ""
41 f.route.Status, f.route.HoldOp = "ready", ""
42 return f, f.read()
43}
44
45// Task 7 (Task 5's concern): the landing wake after the commit is a cold
46// admission into a fresh generation from the certified remote, continuing
47// the admitted session's generation; it carries the ordinary launch, no
48// probe variable, and is evidenced, materialized, start-checked and
49// published as serving's are.
50func TestMovesProbeOwnersCommittedWake(t *testing.T) {
51 f, o := committedAfterProbe(t)
52 ctx := t.Context()
53 h := o.Claim.Holder
54 in, err := f.owners.Select(ctx, f.route, o, poWake)
55 if err != nil {
56 t.Fatal(err)
57 }
58 a := in.Admission
59 if a.ObservedOpID != poOp || a.ObservedPhase != hostclaim.PhaseLandingCommitted || a.ObservedSession != h.Session || a.ObservedGeneration != poOp ||
60 a.Mode != "cold" || a.NewGeneration == "" || a.NewGeneration == poOp || a.KeepStamp || a.Coverage != "" || a.Region != "uk" || a.PredecessorRegion != "uk" {
61 t.Fatalf("the committed admission: %+v", a)
62 }
63 if in.Prepared || in.Baseline == nil || in.Baseline.Generation != poOp || in.Baseline.MaxTXID != 42 || a.RemoteMaxTXID != 42 {
64 t.Fatalf("the committed baseline: %+v", in.Baseline)
65 }
66 // Computed from the actual launch (fix round 2): its environment's
67 // report under the probe's normalisation, and its release; here they
68 // are the pins'.
69 if want := committedPinsIdentity(f.rec.Move.Inputs); in.Identity != want {
70 t.Fatalf("the committed wake's identity %+v, want %+v", in.Identity, want)
71 }
72 for _, kv := range in.Env {
73 if strings.HasPrefix(kv, "CARLOS_MOVE_PROBE=") {
74 t.Fatalf("the committed wake carries %s", kv)
75 }
76 }
77 if err := f.owners.AdmissionEvidence(ctx, o, a, in.Baseline); err != nil {
78 t.Fatal(err)
79 }
80 stale := *in.Baseline
81 stale.MaxTXID = 41
82 if err := f.owners.AdmissionEvidence(ctx, o, a, &stale); !errors.Is(err, hostbox.ErrChanged) {
83 t.Fatalf("a stale baseline: %v", err)
84 }
85 admitted := f.admit(o, in)
86 f.writeLocal(poOp, admitted.Claim.Holder.Generation, f.inc)
87 if err := f.owners.Materialize(ctx, admitted, in, newFenceWork()); err != nil {
88 t.Fatalf("materialize: %v", err)
89 }
90 root := "instances/" + poHost + "/g-" + a.NewGeneration + "/0009/" + ltx.FormatFilename(1, 43)
91 f.bucket.Put("data", root, f.ltxHeader(1, 43))
92 pub := ClaimPublication{Sync: func(context.Context) error { return nil }, Check: func(context.Context) error { return nil }}
93 if _, err := f.owners.PublishFenced(ctx, admitted, in, pub); err != nil {
94 t.Fatal(err)
95 }
96 if err := f.owners.PublicationEvidence(ctx, admitted); err != nil {
97 t.Fatal(err)
98 }
99 // A probe is never admitted at committed, and a committed wake never at
100 // probing; a record that is not committed refuses.
101 if _, err := f.owners.Select(ctx, f.route, o, poProbe); err == nil {
102 t.Fatal("a probe at committed")
103 }
104 f.rec.Move.State = provision.MoveLanding
105 if _, err := f.owners.Select(ctx, f.route, o, poWake); err == nil {
106 t.Fatal("a committed wake over a landing record")
107 }
108}
109
110// filesGen reads the local .files-gen.
111func (f *poFixture) filesGen() moves.FilesGen {
112 f.t.Helper()
113 raw, err := os.ReadFile(filepath.Join(f.files, archive.MarkerName))
114 if err != nil {
115 f.t.Fatal(err)
116 }
117 var fg moves.FilesGen
118 if err := json.Unmarshal(raw, &fg); err != nil {
119 f.t.Fatal(err)
120 }
121 return fg
122}
123
124// restore is ColdRestore's local effect on a fresh admission: <dbPath>.gen
125// names the admitted generation.
126func (f *poFixture) restore(gen string) {
127 f.t.Helper()
128 if err := hostbox.WriteGenerationMarker(f.route.DBPath+".gen", gen); err != nil {
129 f.t.Fatal(err)
130 }
131}
132
133// publishFresh puts the fresh generation's first root and publishes it
134// fenced, as the claim's sequence does once the session's root is durable.
135func (f *poFixture) publishFresh(admitted hostclaim.Observed, in ClaimLaunchInputs) error {
136 f.t.Helper()
137 gen := admitted.Claim.Holder.Generation
138 f.bucket.Put("data", "instances/"+poHost+"/g-"+gen+"/0009/"+ltx.FormatFilename(1, 44), f.ltxHeader(1, 44))
139 pub := ClaimPublication{Sync: func(context.Context) error { return nil }, Check: func(context.Context) error { return nil }}
140 _, err := f.owners.PublishFenced(f.t.Context(), admitted, in, pub)
141 return err
142}
143
144// restartedProbePublished runs a dead first attempt and a restarted attempt
145// that passes and publishes its fresh generation.
146func restartedProbePublished(t *testing.T) (*poFixture, hostclaim.Observed, ClaimLaunchInputs) {
147 t.Helper()
148 f := newPOFixture(t)
149 ctx := t.Context()
150 first, in := f.selectAndAdmit()
151 if err := f.owners.Materialize(ctx, first, in, newFenceWork()); err != nil {
152 t.Fatal(err)
153 }
154 again, err := f.owners.Select(ctx, f.route, first, poProbe)
155 if err != nil {
156 t.Fatal(err)
157 }
158 restarted := f.admit(first, again)
159 f.restore(restarted.Claim.Holder.Generation)
160 if err := f.owners.Materialize(ctx, restarted, again, newFenceWork()); err != nil {
161 t.Fatalf("the restarted probe's start: %v", err)
162 }
163 return f, restarted, again
164}
165
166// commit moves the fixture to the commit: the claim at landing/committed,
167// the record committed, the row open.
168func (f *poFixture) commit() hostclaim.Observed {
169 f.t.Helper()
170 c := *f.read().Claim
171 c.Op.Phase = hostclaim.PhaseLandingCommitted
172 f.putClaim(&c)
173 f.rec.Move.State, f.rec.Move.LandingPhase = provision.MoveCommitted, ""
174 f.route.Status, f.route.HoldOp = "ready", ""
175 return f.read()
176}
177
178// Task 7 fix round 1, finding 1: a restarted probe's successful publication
179// refreshes .files-gen to the published generation, under the writer's
180// fence, after the files on disk are proved the landing's. After the commit,
181// a process death and a wake, and then several more, each start passes
182// Task 4's unweakened start check.
183func TestMovesProbeOwnersRestartedProbeThenCommittedWakes(t *testing.T) {
184 f, restarted, again := restartedProbePublished(t)
185 ctx := t.Context()
186 if err := f.publishFresh(restarted, again); err != nil {
187 t.Fatal(err)
188 }
189 fresh := restarted.Claim.Holder.Generation
190 if fg := f.filesGen(); fg.Op != fresh || fg.Incarnation != f.inc {
191 t.Fatalf(".files-gen after the restarted probe's publication: %+v, want %s", fg, fresh)
192 }
193 o := f.commit()
194 for i := 0; i < 3; i++ {
195 in, err := f.owners.Select(ctx, f.route, o, poWake)
196 if err != nil {
197 t.Fatalf("wake %d: %v", i, err)
198 }
199 admitted := f.admit(o, in)
200 f.restore(admitted.Claim.Holder.Generation)
201 if err := f.owners.Materialize(ctx, admitted, in, newFenceWork()); err != nil {
202 t.Fatalf("wake %d's start after the probe's %s: %v", i, fresh, err)
203 }
204 if err := f.publishFresh(admitted, in); err != nil {
205 t.Fatalf("wake %d's publication: %v", i, err)
206 }
207 if fg := f.filesGen(); fg.Op != admitted.Claim.Holder.Generation {
208 t.Fatalf("wake %d's .files-gen: %+v", i, fg)
209 }
210 o = f.read()
211 }
212}
213
214// A files directory that is not the one the landing placed fails the
215// publication: no marker is written and .files-gen is not refreshed.
216func TestMovesProbeOwnersPublicationRefusesMismatchedFiles(t *testing.T) {
217 f, restarted, again := restartedProbePublished(t)
218 before := f.bucket.LatestID("data", f.markerKey())
219 // Another directory in its place: made while the placed one still
220 // exists, so it cannot reuse its inode.
221 old := f.files + ".old"
222 if err := os.Rename(f.files, old); err != nil {
223 t.Fatal(err)
224 }
225 if err := os.Mkdir(f.files, 0o700); err != nil {
226 t.Fatal(err)
227 }
228 t.Cleanup(func() { os.RemoveAll(old) })
229 f.writeLocal(poOp, restarted.Claim.Holder.Generation, f.inc)
230 if err := f.publishFresh(restarted, again); err == nil {
231 t.Fatal("published over files the landing did not place")
232 }
233 if f.bucket.LatestID("data", f.markerKey()) != before {
234 t.Fatal("a marker was written")
235 }
236 if fg := f.filesGen(); fg.Op != poOp {
237 t.Fatalf(".files-gen was refreshed: %+v", fg)
238 }
239}
240
241// platform-1e (claim Plan 10 rev 5): the moves owner's input policy binds
242// the committed wake's spawn to Move.Inputs, as MovesClaimPolicy binds the
243// landing's rows: a committed cold admission with the pins' launch identity
244// is accepted, and refused once the pins differ.
245func TestMovesProbeOwnersInputPolicyBindsTheCommittedWake(t *testing.T) {
246 f, o := committedAfterProbe(t)
247 ctx := t.Context()
248 in, err := f.owners.Select(ctx, f.route, o, poWake)
249 if err != nil {
250 t.Fatal(err)
251 }
252 admitted := f.admit(o, in)
253 c := admitted.Claim
254 sel := hostbox.Selection{Host: poHost, Action: "spawn", Box: "uk-box", Region: "uk", Captured: hostguard.Captured{Incarnation: c.Holder.Incarnation, Stamp: c.Holder.Stamp},
255 Kind: c.Op.Kind, Op: c.Op.ID, Phase: c.Op.Phase, Generation: c.Holder.Generation, Session: c.Holder.Session}
256 policy := MovesClaimPolicyWithInputs(f.owners.Records, f.owners.InputPolicy)
257 if err := f.owners.InputPolicy(ctx, c, sel, in.Identity); err != nil {
258 t.Fatalf("matching pins: %v", err)
259 }
260 if err := policy(ctx, c, sel, in.Identity); err != nil {
261 t.Fatalf("matching pins through the policy: %v", err)
262 }
263 for name, mutate := range map[string]func(){
264 "another release": func() { f.rec.Move.Inputs.Release = "v9" },
265 "another env report": func() { f.rec.Move.Inputs.EnvReport = "sha256:other" },
266 } {
267 pins := *f.rec.Move.Inputs
268 mutate()
269 if err := f.owners.InputPolicy(ctx, c, sel, in.Identity); err == nil {
270 t.Fatalf("%s: accepted", name)
271 }
272 if err := policy(ctx, c, sel, in.Identity); err == nil {
273 t.Fatalf("%s: accepted through the policy", name)
274 }
275 f.rec.Move.Inputs = &pins
276 }
277 if err := f.owners.InputPolicy(ctx, c, sel, f.launch.Identity); err == nil {
278 t.Fatal("the ordinary identity was accepted")
279 }
280 _ = genselect.Marker{}
281 _ = hostwrite.Identity{}
282}
283
284// writeFilesGenOnly writes .files-gen alone.
285func (f *poFixture) writeFilesGenOnly(op string) {
286 f.t.Helper()
287 body, _ := json.Marshal(moves.FilesGen{Op: op, Incarnation: f.inc})
288 if err := os.WriteFile(filepath.Join(f.files, archive.MarkerName), body, 0o600); err != nil {
289 f.t.Fatal(err)
290 }
291}
292
293// committedWake admits, restores and materializes one committed wake.
294func (f *poFixture) committedWake(o hostclaim.Observed) (hostclaim.Observed, ClaimLaunchInputs, error) {
295 f.t.Helper()
296 in, err := f.owners.Select(f.t.Context(), f.route, o, poWake)
297 if err != nil {
298 f.t.Fatal(err)
299 }
300 admitted := f.admit(o, in)
301 f.restore(admitted.Claim.Holder.Generation)
302 return admitted, in, f.owners.Materialize(f.t.Context(), admitted, in, newFenceWork())
303}
304
305// Task 7 fix round 2, item 1: a publication interrupted between its marker
306// write and the .files-gen refresh (local g1, published g2) is repaired
307// before the next admission's (g3) start check: the published marker names
308// g3's cold predecessor, and the files on disk are proved the landing's, so
309// .files-gen moves to g2 and the start check, unweakened, passes.
310func TestMovesProbeOwnersRepairAnInterruptedFilesGenRefresh(t *testing.T) {
311 f, restarted, again := restartedProbePublished(t)
312 if err := f.publishFresh(restarted, again); err != nil {
313 t.Fatal(err)
314 }
315 g1 := restarted.Claim.Holder.Generation
316 o := f.commit()
317 w1, in1, err := f.committedWake(o)
318 if err != nil {
319 t.Fatal(err)
320 }
321 if err := f.publishFresh(w1, in1); err != nil {
322 t.Fatal(err)
323 }
324 g2 := w1.Claim.Holder.Generation
325 // The crash: the marker names g2, .files-gen still g1.
326 f.writeFilesGenOnly(g1)
327 w2, in2, err := f.committedWake(f.read())
328 if err != nil {
329 t.Fatalf("the next admission after an interrupted refresh: %v", err)
330 }
331 if fg := f.filesGen(); fg.Op != g2 {
332 t.Fatalf(".files-gen was not repaired to %s: %+v", g2, fg)
333 }
334 if err := f.publishFresh(w2, in2); err != nil {
335 t.Fatal(err)
336 }
337 if fg := f.filesGen(); fg.Op != w2.Claim.Holder.Generation {
338 t.Fatalf("after the next publication: %+v", fg)
339 }
340}
341
342// Item 1: files that are not the landing's are never repaired over; the
343// start check refuses as before.
344func TestMovesProbeOwnersRepairRefusesMismatchedFiles(t *testing.T) {
345 f, restarted, again := restartedProbePublished(t)
346 if err := f.publishFresh(restarted, again); err != nil {
347 t.Fatal(err)
348 }
349 g1 := restarted.Claim.Holder.Generation
350 o := f.commit()
351 w1, in1, err := f.committedWake(o)
352 if err != nil {
353 t.Fatal(err)
354 }
355 if err := f.publishFresh(w1, in1); err != nil {
356 t.Fatal(err)
357 }
358 old := f.files + ".old"
359 if err := os.Rename(f.files, old); err != nil {
360 t.Fatal(err)
361 }
362 if err := os.Mkdir(f.files, 0o700); err != nil {
363 t.Fatal(err)
364 }
365 t.Cleanup(func() { os.RemoveAll(old) })
366 f.writeFilesGenOnly(g1)
367 if _, _, err := f.committedWake(f.read()); !errors.Is(err, moves.ErrStartFiles) {
368 t.Fatalf("a repair over files the landing did not place: %v", err)
369 }
370 if fg := f.filesGen(); fg.Op != g1 {
371 t.Fatalf(".files-gen was rewritten: %+v", fg)
372 }
373}
374
375// Item 2: the committed wake's input check judges the real launch. A changed
376// launch environment with the record's pins unchanged is refused, by Select
377// and by the input policy on the identity that launch would carry; matching
378// inputs are accepted.
379func TestMovesProbeOwnersCommittedWakeJudgesTheRealLaunch(t *testing.T) {
380 f, o := committedAfterProbe(t)
381 ctx := t.Context()
382 in, err := f.owners.Select(ctx, f.route, o, poWake)
383 if err != nil {
384 t.Fatalf("matching inputs: %v", err)
385 }
386 c := o.Claim
387 sel := hostbox.Selection{Host: poHost, Action: "spawn", Box: "uk-box", Region: "uk", Captured: hostguard.Captured{Incarnation: c.Holder.Incarnation, Stamp: c.Holder.Stamp},
388 Kind: c.Op.Kind, Op: c.Op.ID, Phase: c.Op.Phase, Generation: c.Holder.Generation, Session: c.Holder.Session}
389 if err := f.owners.InputPolicy(ctx, c, sel, in.Identity); err != nil {
390 t.Fatalf("matching inputs through the input policy: %v", err)
391 }
392 f.launch.Env = []string{"A=2"}
393 if _, err := f.owners.Select(ctx, f.route, o, poWake); err == nil {
394 t.Fatal("a changed launch environment was selected")
395 }
396 changed := committedLaunchIdentity(envDigest(poEnvKey, f.launch.Env), f.launch.Version)
397 if err := f.owners.InputPolicy(ctx, c, sel, changed); err == nil {
398 t.Fatal("the changed launch's identity was accepted")
399 }
400}
401
402// committedPinsIdentity is the identity a committed launch matching the pins
403// carries.
404func committedPinsIdentity(p *provision.MoveInputs) hostwrite.Identity {
405 return hostwrite.Identity{Config: committedEnvPrefix + p.EnvReport, Release: p.Release}
406}
407
408// Task 7 fix round 3: the repair verifies content, never the directory's
409// identity alone. With g2's publication interrupted before its refresh, a
410// file modified, deleted or added inside the original directory (the same
411// inode) refuses the repair: .files-gen stays g1 and the start check
412// refuses. Matching contents still recover (TestMovesProbeOwnersRepairAnInterruptedFilesGenRefresh).
413func TestMovesProbeOwnersRepairVerifiesTheFilesContent(t *testing.T) {
414 for name, change := range map[string]func(dir string) error{
415 "a modified file": func(dir string) error { return os.WriteFile(filepath.Join(dir, "a.txt"), []byte("stale"), 0o600) },
416 "a deleted file": func(dir string) error { return os.Remove(filepath.Join(dir, "a.txt")) },
417 "an added file": func(dir string) error { return os.WriteFile(filepath.Join(dir, "b.txt"), []byte("new"), 0o600) },
418 } {
419 t.Run(name, func(t *testing.T) {
420 f, restarted, again := restartedProbePublished(t)
421 if err := f.publishFresh(restarted, again); err != nil {
422 t.Fatal(err)
423 }
424 g1 := restarted.Claim.Holder.Generation
425 w1, in1, err := f.committedWake(f.commit())
426 if err != nil {
427 t.Fatal(err)
428 }
429 if err := f.publishFresh(w1, in1); err != nil {
430 t.Fatal(err)
431 }
432 f.writeFilesGenOnly(g1)
433 if err := change(f.files); err != nil {
434 t.Fatal(err)
435 }
436 if _, _, err := f.committedWake(f.read()); !errors.Is(err, moves.ErrStartFiles) {
437 t.Fatalf("a repair over changed contents: %v", err)
438 }
439 if fg := f.filesGen(); fg.Op != g1 {
440 t.Fatalf(".files-gen was repaired over changed contents: %+v", fg)
441 }
442 })
443 }
444}
445
446// publishedP is the fixture at a published committed generation P: a
447// restarted probe published (P), the commit, .files-gen naming P and P's
448// files digest journalled.
449func publishedP(t *testing.T) (*poFixture, string) {
450 t.Helper()
451 f, restarted, again := restartedProbePublished(t)
452 if err := f.publishFresh(restarted, again); err != nil {
453 t.Fatal(err)
454 }
455 f.commit()
456 return f, restarted.Claim.Holder.Generation
457}
458
459// Final review, ruling 1: a committed wake G1 that failed before its marker
460// leaves .files-gen at the published P while the next admission G2's cold
461// predecessor is G1. The repair walks G1 back to P through unpublished cold
462// admissions on this box and region and, the files matching P's recorded
463// digest, sets .files-gen to G1: G2 starts (StartCheck unchanged) and
464// publishes.
465func TestMovesProbeOwnersRecoverACommittedWakeThatFailedBeforePublishing(t *testing.T) {
466 f, p := publishedP(t)
467 if _, _, err := f.committedWake(f.read()); err != nil { // G1, then it dies before PutMarker
468 t.Fatal(err)
469 }
470 if fg := f.filesGen(); fg.Op != p {
471 t.Fatalf("the fixture: %+v", fg)
472 }
473 g2, in2, err := f.committedWake(f.read())
474 if err != nil {
475 t.Fatalf("G2 after an unpublished G1: %v", err)
476 }
477 if err := f.publishFresh(g2, in2); err != nil {
478 t.Fatal(err)
479 }
480 if fg := f.filesGen(); fg.Op != g2.Claim.Holder.Generation {
481 t.Fatalf(".files-gen after G2's publication: %+v", fg)
482 }
483}
484
485// Ruling 1: two failed wakes, G1 and G2, then G3 starts.
486func TestMovesProbeOwnersRecoverTwoFailedCommittedWakes(t *testing.T) {
487 f, _ := publishedP(t)
488 f.committedWake(f.read()) // G1, dies before PutMarker
489 f.committedWake(f.read()) // G2, dies before PutMarker
490 if _, _, err := f.committedWake(f.read()); err != nil {
491 t.Fatalf("G3 after two unpublished wakes: %v", err)
492 }
493}
494
495// Ruling 1: a file changed after P's publication refuses the walk's repair;
496// the start check refuses G2 and .files-gen stays P.
497func TestMovesProbeOwnersCommittedWalkVerifiesContent(t *testing.T) {
498 f, p := publishedP(t)
499 if _, _, err := f.committedWake(f.read()); err != nil { // G1
500 t.Fatal(err)
501 }
502 if err := os.WriteFile(filepath.Join(f.files, "a.txt"), []byte("changed after P"), 0o600); err != nil {
503 t.Fatal(err)
504 }
505 if _, _, err := f.committedWake(f.read()); !errors.Is(err, moves.ErrStartFiles) {
506 t.Fatalf("G2 over changed files: %v", err)
507 }
508 if fg := f.filesGen(); fg.Op != p {
509 t.Fatalf(".files-gen was repaired over changed files: %+v", fg)
510 }
511}
512