carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package hostrelocate
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "strconv"
8 "strings"
9 "testing"
10 "time"
11
12 "github.com/carlosframework/platform/internal/hostclaim"
13)
14
15// fakeBox is one box's local effects. Its writes to the claim stand in for
16// what the real effects leave in storage (P built, a probe admitted).
17type fakeBox struct {
18 f *fixture
19 box string
20 certified []bool
21 // opened counts controllers opened; restarted makes Ready report none
22 // until the next Acknowledge, as a restarted agent would.
23 opened int
24 restarted bool
25 effects []string
26 op string
27 finished bool
28 // failProbes makes every probe admission fail readiness.
29 failProbes bool
30 // retainedErr is a damaged journal entry Retained reports.
31 retainedErr error
32 // retired: a scoped cleanup removed the local install; restores,
33 // reinstalls and probedMissing count what EnsureLocal and Probe saw.
34 retired bool
35 restores, reinstalls, probedMissing int
36 // receiptLost: the box's durable local install receipt is gone.
37 receiptLost bool
38 // preflightErr is root refusing the relocating kind, or
39 // ErrLaterAttemptUnlaunchable (rulings 48, 58).
40 preflightErr error
41 // endProbeErr is a stop root keeps refusing (ruling 67).
42 endProbeErr error
43 // writesOnInstall: the install writes the claim (the typed inventory),
44 // as the production install's RecordInventory does (ruling 62).
45 writesOnInstall bool
46 // healthy stands for the box's durable attempt journal: the sessions
47 // whose probe passed and settled (final review A2). passedErr is a
48 // journal that cannot be read. flaky, when set, is armed by Probe so
49 // the claim access after it fails once.
50 healthy map[uint64]bool
51 passedErr error
52 flaky *flakyStore
53}
54
55func (b *fakeBox) Acknowledge(_ context.Context, o hostclaim.Observed) (hostclaim.ExecutionAcknowledgement, error) {
56 b.opened++
57 b.restarted, b.finished, b.op = false, false, o.Claim.Op.ID
58 return hostclaim.ExecutionAcknowledgement{Op: o.Claim.Op.ID, Enrollment: enrollment(b.box), BootID: "boot", ControllerID: "ctl-" + b.box, LeaseETag: "lease", Expires: b.f.now.Add(15 * 60e9)}, nil
59}
60func (b *fakeBox) Retained() ([]Participation, error) {
61 if b.opened == 0 || b.finished {
62 return nil, b.retainedErr
63 }
64 return []Participation{{Host: host, Op: b.op}}, b.retainedErr
65}
66func (b *fakeBox) Finish(ctx context.Context, h, op string) error {
67 b.finished = true
68 b.effects = append(b.effects, "finish "+op)
69 // Production's Finish is the target's hold release, which ends by
70 // marking the completed relocation discharged (region HA ruling 76).
71 obs, err := hostclaim.Read(ctx, b.f.s, h)
72 if err != nil {
73 return err
74 }
75 done, ok, err := hostclaim.ReadCompletedRelocation(obs.Claim)
76 if err == nil && ok && done.Op == op && done.Stop.Box == b.box && !done.Discharged {
77 _, err = hostclaim.Update(ctx, b.f.s, obs, hostclaim.MarkCompletedDischarged(op))
78 }
79 return err
80}
81func (b *fakeBox) Ready(context.Context, hostclaim.Observed) error {
82 b.effects = append(b.effects, "ready")
83 if b.restarted || b.opened == 0 {
84 return ErrNoController
85 }
86 return nil
87}
88func (b *fakeBox) Release(_ context.Context, o hostclaim.Observed) (hostclaim.Release, error) {
89 h := o.Claim.Holder
90 return hostclaim.Release{ReleasedBy: b.box, Enrollment: enrollment(b.box), Generation: h.Generation, Session: h.Session, FinalTXID: 40, ManifestSeq: 7, Receipt: "r-" + b.box, ReceiptSHA256: strings.Repeat("d", 64)}, nil
91}
92func (b *fakeBox) Build(context.Context, hostclaim.Observed) error {
93 b.effects = append(b.effects, "build")
94 b.f.participant(func(c *hostclaim.Claim) error { c.Op.Journal["test.built"] = c.Op.Journal["prepared"]; return nil })
95 return nil
96}
97func (b *fakeBox) InstallLocal(_ context.Context, o hostclaim.Observed) (hostclaim.LocalInstall, error) {
98 b.effects = append(b.effects, "install")
99 if b.writesOnInstall {
100 b.f.participant(func(c *hostclaim.Claim) error { c.Op.Journal["test.inventory"] = b.box; return nil })
101 }
102 n, _ := strconv.ParseUint(o.Claim.Op.Journal["install_stamp"], 10, 64)
103 return hostclaim.LocalInstall{Box: b.box, Enrollment: enrollment(b.box), InstallStamp: n, FilesManifestVersion: 1, JournalSHA256: strings.Repeat("e", 64)}, nil
104}
105func (b *fakeBox) EnsureLocal(_ context.Context, o hostclaim.Observed) error {
106 b.effects = append(b.effects, "ensure-local")
107 if hostclaim.PreparedOpened(o.Claim) {
108 b.reinstalls++ // never: an opened P is not reinstalled
109 }
110 if b.retired {
111 b.retired, b.restores = false, b.restores+1
112 }
113 return nil
114}
115func (b *fakeBox) VerifyReceipt(context.Context, hostclaim.Observed) (bool, error) {
116 b.effects = append(b.effects, "verify-receipt")
117 return !b.receiptLost, nil
118}
119func (b *fakeBox) Probe(ctx context.Context, o hostclaim.Observed, certified bool) (uint64, bool, error) {
120 if b.retired {
121 b.probedMissing++ // a probe admission over a missing local install
122 }
123 b.certified = append(b.certified, certified)
124 c := o.Claim
125 next, err := hostclaim.Update(ctx, b.f.s, o, hostclaim.AdmitWriter(hostclaim.Admission{ObservedOpID: c.Op.ID, ObservedPhase: c.Op.Phase, ObservedSession: c.Holder.Session, ObservedGeneration: c.Holder.Generation,
126 Box: b.box, Mode: "cold", NewGeneration: c.Op.Journal["prepared"], KeepStamp: true, PredecessorCertified: certified, Region: "ie", PredecessorRegion: "ie"}))
127 if err != nil {
128 return 0, false, err
129 }
130 if b.failProbes {
131 return next.Claim.Holder.Session, false, nil
132 }
133 if b.healthy == nil {
134 b.healthy = map[uint64]bool{}
135 }
136 b.healthy[next.Claim.Holder.Session] = true
137 if b.flaky != nil {
138 b.flaky.armed = true
139 }
140 return next.Claim.Holder.Session, true, nil
141}
142func (b *fakeBox) PassedProbe(_ context.Context, o hostclaim.Observed) (uint64, bool, error) {
143 b.effects = append(b.effects, "passed-probe")
144 if b.passedErr != nil {
145 return 0, false, b.passedErr
146 }
147 s := o.Claim.Holder.Session
148 return s, b.healthy[s], nil
149}
150
151func (f *fixture) clock() time.Time { return f.now }
152
153// run steps the worker and both participants until the host serves again.
154func run(t *testing.T, f *fixture, parts ...Participant) hostclaim.Observed {
155 t.Helper()
156 ctx := context.Background()
157 for i := 0; i < 60; i++ {
158 if o := f.claim(); o.Claim.Op.Kind == hostclaim.KindServing {
159 return o
160 }
161 if _, err := f.w.Step(ctx, host); err != nil && !errors.Is(err, ErrNotYet) {
162 t.Fatalf("worker: %v", err)
163 }
164 for _, p := range parts {
165 if _, err := p.Step(ctx, host); err != nil && !errors.Is(err, ErrNotYet) {
166 t.Fatalf("%s: %v", p.Box, err)
167 }
168 }
169 }
170 t.Fatalf("no progress: %+v", f.claim().Claim.Op)
171 return hostclaim.Observed{}
172}
173
174func TestADrainCompletesWithBothParticipants(t *testing.T) {
175 f := newFixture(t)
176 src, dst := &fakeBox{f: f, box: "src"}, &fakeBox{f: f, box: "dst"}
177 if _, err := f.w.Start(context.Background(), host, Request{Reason: hostclaim.ReasonDrain, Source: "src", Target: "dst"}); err != nil {
178 t.Fatal(err)
179 }
180 o := run(t, f, Participant{Claims: f.s, Box: "src", Enrollment: enrollment("src"), Effects: src, Now: f.clock}, Participant{Claims: f.s, Box: "dst", Enrollment: enrollment("dst"), Effects: dst, Now: f.clock})
181 if o.Claim.Op.Exec != "dst" || len(dst.certified) != 1 || !dst.certified[0] {
182 t.Fatalf("exec %s certified %v", o.Claim.Op.Exec, dst.certified)
183 }
184 last := o.Claim.Holder.Admissions[len(o.Claim.Holder.Admissions)-1]
185 if last.WritesLost != nil {
186 t.Fatalf("a drain declared a loss: %+v", last)
187 }
188}
189
190func TestAFailoverCompletesAndDeclaresTheSource(t *testing.T) {
191 f := newFixture(t)
192 f.terminate("src")
193 dst := &fakeBox{f: f, box: "dst"}
194 if _, err := f.w.Start(context.Background(), host, Request{Reason: hostclaim.ReasonFailover, Source: "src", Target: "dst"}); err != nil {
195 t.Fatal(err)
196 }
197 o := run(t, f, Participant{Claims: f.s, Box: "dst", Enrollment: enrollment("dst"), Effects: dst, Now: f.clock})
198 last := o.Claim.Holder.Admissions[len(o.Claim.Holder.Admissions)-1]
199 if dst.certified[0] || last.WritesLost == nil || last.WritesLost.Box != "src" {
200 t.Fatalf("certified %v last %+v", dst.certified, last)
201 }
202}
203
204func TestALostParticipantDoesNothing(t *testing.T) {
205 f := newFixture(t)
206 f.terminate("src")
207 if _, err := f.w.Start(context.Background(), host, Request{Reason: hostclaim.ReasonFailover, Source: "src", Target: "dst"}); err != nil {
208 t.Fatal(err)
209 }
210 src := &fakeBox{f: f, box: "src"}
211 before := f.claim().ETag
212 if _, err := (Participant{Claims: f.s, Box: "src", Enrollment: enrollment("src"), Effects: src, Now: f.clock}).Step(context.Background(), host); !errors.Is(err, ErrNotYet) || f.claim().ETag != before {
213 t.Fatalf("a fenced source acted: %v", err)
214 }
215}
216
217func TestAParticipantChecksItsControllerBeforeEveryEffectAndReopensAfterARestart(t *testing.T) {
218 f := newFixture(t)
219 ctx := context.Background()
220 src, dst := &fakeBox{f: f, box: "src"}, &fakeBox{f: f, box: "dst"}
221 if _, err := f.w.Start(ctx, host, Request{Reason: hostclaim.ReasonDrain, Source: "src", Target: "dst"}); err != nil {
222 t.Fatal(err)
223 }
224 ps := Participant{Claims: f.s, Box: "src", Enrollment: enrollment("src"), Effects: src, Now: f.clock}
225 pd := Participant{Claims: f.s, Box: "dst", Enrollment: enrollment("dst"), Effects: dst, Now: f.clock}
226 for _, p := range []Participant{ps, pd} {
227 if _, err := p.Step(ctx, host); err != nil {
228 t.Fatal(err)
229 }
230 }
231 f.worker(nil) // releasing
232 // The target's agent restarts: its next step opens a controller and
233 // journals nothing; the one after acts.
234 dst.restarted = true
235 before := f.claim().ETag
236 if _, err := pd.Step(ctx, host); !errors.Is(err, ErrNotYet) || dst.opened != 2 || f.claim().ETag != before {
237 t.Fatalf("restart: %v opened=%d wrote=%v", err, dst.opened, f.claim().ETag != before)
238 }
239 if n := len(f.claim().Claim.Op.Journal["exec_ack.dst"]); n == 0 {
240 t.Fatal("the first acknowledgement was dropped")
241 }
242 // The source releases: Ready ran before the effect.
243 src.effects = nil
244 if _, err := ps.Step(ctx, host); err != nil {
245 t.Fatal(err)
246 }
247 if len(src.effects) == 0 || src.effects[0] != "ready" {
248 t.Fatalf("release ran without checking the controller: %v", src.effects)
249 }
250 // A lost controller refuses the effect and writes nothing.
251 o := run(t, f, ps, pd)
252 if o.Claim.Op.Exec != "dst" {
253 t.Fatalf("exec %s", o.Claim.Op.Exec)
254 }
255 for i, e := range dst.effects {
256 if e == "build" && (i == 0 || dst.effects[i-1] != "ready") {
257 t.Fatalf("build without a readiness check first: %v", dst.effects)
258 }
259 }
260}
261
262func TestAParticipantGivesUpAfterThreeProbeAttempts(t *testing.T) {
263 f := newFixture(t)
264 ctx := context.Background()
265 src, dst := &fakeBox{f: f, box: "src"}, &fakeBox{f: f, box: "dst", failProbes: true}
266 if _, err := f.w.Start(ctx, host, Request{Reason: hostclaim.ReasonDrain, Source: "src", Target: "dst"}); err != nil {
267 t.Fatal(err)
268 }
269 ps := Participant{Claims: f.s, Box: "src", Enrollment: enrollment("src"), Effects: src, Now: f.clock}
270 pd := Participant{Claims: f.s, Box: "dst", Enrollment: enrollment("dst"), Effects: dst, Now: f.clock}
271 for i := 0; i < 40; i++ {
272 o := f.claim()
273 if o.Claim.Op.Phase == hostclaim.PhaseProbeFailed {
274 if n := len(dst.certified); n != hostclaim.MaxProbeAttempts {
275 t.Fatalf("%d probe admissions, want %d", n, hostclaim.MaxProbeAttempts)
276 }
277 if journal := o.Claim.Op.Journal; journal["probe_attempts.dst"] != "3" {
278 t.Fatalf("attempts journaled: %v", journal["probe_attempts.dst"])
279 }
280 return
281 }
282 f.w.Step(ctx, host)
283 ps.Step(ctx, host)
284 pd.Step(ctx, host)
285 }
286 t.Fatalf("never probe-failed: %+v", f.claim().Claim.Op)
287}
288
289func TestAParticipantFinishesItsControllerOnceTheOperationIsOver(t *testing.T) {
290 f := newFixture(t)
291 ctx := context.Background()
292 src, dst := &fakeBox{f: f, box: "src"}, &fakeBox{f: f, box: "dst"}
293 if _, err := f.w.Start(ctx, host, Request{Reason: hostclaim.ReasonDrain, Source: "src", Target: "dst"}); err != nil {
294 t.Fatal(err)
295 }
296 ps := Participant{Claims: f.s, Box: "src", Enrollment: enrollment("src"), Effects: src, Now: f.clock}
297 pd := Participant{Claims: f.s, Box: "dst", Enrollment: enrollment("dst"), Effects: dst, Now: f.clock}
298 run(t, f, ps, pd)
299 if err := pd.Sweep(ctx); err != nil || !dst.finished {
300 t.Fatalf("the serving target kept its relocation controller: %v finished=%v", err, dst.finished)
301 }
302 // A damaged journal entry beside it is reported, and the readable
303 // participation is still finished.
304 src.retainedErr = errors.New("a damaged journal entry")
305 if err := ps.Sweep(ctx); !errors.Is(err, src.retainedErr) || !src.finished {
306 t.Fatalf("the former source kept its controller, or the damaged entry was hidden: %v finished=%v", err, src.finished)
307 }
308 src.retainedErr = nil
309 // A second relocation opens a fresh controller.
310 spa := &fakeBox{f: f, box: "spa"}
311 if _, err := f.w.Start(ctx, host, Request{Reason: hostclaim.ReasonDrain, Source: "dst", Target: "spa"}); err != nil {
312 t.Fatal(err)
313 }
314 before := dst.opened
315 run(t, f, pd, Participant{Claims: f.s, Box: "spa", Enrollment: enrollment("spa"), Effects: spa, Now: f.clock})
316 if dst.opened != before+1 {
317 t.Fatalf("the second relocation did not open its own controller: %d -> %d", before, dst.opened)
318 }
319 // No third relocation starts from spa until spa has finished the
320 // second one (region HA ruling 77); then, mid-operation, Sweep keeps
321 // the controller.
322 pspa := Participant{Claims: f.s, Box: "spa", Enrollment: enrollment("spa"), Effects: spa, Now: f.clock}
323 if _, err := f.w.Start(ctx, host, Request{Reason: hostclaim.ReasonDrain, Source: "spa", Target: "sp2"}); !errors.Is(err, hostclaim.ErrPendingChore) {
324 t.Fatalf("a successor started before spa finished: %v", err)
325 }
326 if err := pspa.Sweep(ctx); err != nil || !spa.finished {
327 t.Fatalf("spa kept the second relocation's controller: %v", err)
328 }
329 if _, err := f.w.Start(ctx, host, Request{Reason: hostclaim.ReasonDrain, Source: "spa", Target: "sp2"}); err != nil {
330 t.Fatal(err)
331 }
332 spa.finished = false
333 if _, err := pspa.Step(ctx, host); err != nil {
334 t.Fatal(err)
335 }
336 if err := pspa.Sweep(ctx); err != nil || spa.finished {
337 t.Fatalf("a running operation's controller was finished: %v", err)
338 }
339}
340
341// Round 14 finding 2: the local install is journaled, the lease is lost,
342// and the scoped cleanup retires the unopened P's local install. Before
343// probing, and before P's first admission, the participant has the
344// install verified and restored; once P is opened it is never touched.
345func TestAParticipantRestoresARetiredLocalInstallBeforeTheFirstAdmission(t *testing.T) {
346 f := newFixture(t)
347 ctx := context.Background()
348 src, dst := &fakeBox{f: f, box: "src"}, &fakeBox{f: f, box: "dst"}
349 if _, err := f.w.Start(ctx, host, Request{Reason: hostclaim.ReasonDrain, Source: "src", Target: "dst"}); err != nil {
350 t.Fatal(err)
351 }
352 ps := Participant{Claims: f.s, Box: "src", Enrollment: enrollment("src"), Effects: src, Now: f.clock}
353 pd := Participant{Claims: f.s, Box: "dst", Enrollment: enrollment("dst"), Effects: dst, Now: f.clock}
354 for i := 0; i < 40 && f.claim().Claim.Op.Journal["local_install"] == ""; i++ {
355 _, _ = f.w.Step(ctx, host)
356 ps.Step(ctx, host)
357 pd.Step(ctx, host)
358 }
359 if f.claim().Claim.Op.Journal["local_install"] == "" {
360 t.Fatalf("never installed locally: %+v", f.claim().Claim.Op)
361 }
362 dst.retired = true // the lease was lost; the scoped cleanup retired it
363 run(t, f, ps, pd)
364 if dst.restores != 1 || dst.probedMissing != 0 || dst.reinstalls != 0 {
365 t.Fatalf("restores %d, probes over a missing install %d, reinstalls after P %d", dst.restores, dst.probedMissing, dst.reinstalls)
366 }
367}
368
369// Round 15 finding 1: after P's first admission the local install is
370// never remade, and a box that has lost its receipt cannot rebuild it from
371// P: the next admission is refused and the operation is stuck (§5.8,
372// B2-5), across a restart. With the receipt intact, the later attempt goes
373// ahead and nothing is reinstalled or reverted: the files, env and timers
374// the admitted writers changed are theirs.
375func TestAParticipantIsStuckWhenItsReceiptIsLostAfterPOpens(t *testing.T) {
376 for _, lost := range []bool{true, false} {
377 t.Run(fmt.Sprintf("receipt lost %v", lost), func(t *testing.T) {
378 f := newFixture(t)
379 ctx := context.Background()
380 src, dst := &fakeBox{f: f, box: "src"}, &fakeBox{f: f, box: "dst", failProbes: true}
381 if _, err := f.w.Start(ctx, host, Request{Reason: hostclaim.ReasonDrain, Source: "src", Target: "dst"}); err != nil {
382 t.Fatal(err)
383 }
384 ps := Participant{Claims: f.s, Box: "src", Enrollment: enrollment("src"), Effects: src, Now: f.clock}
385 pd := Participant{Claims: f.s, Box: "dst", Enrollment: enrollment("dst"), Effects: dst, Now: f.clock}
386 for i := 0; i < 60 && len(dst.certified) == 0; i++ {
387 _, _ = f.w.Step(ctx, host)
388 ps.Step(ctx, host)
389 pd.Step(ctx, host)
390 }
391 if len(dst.certified) != 1 || !hostclaim.PreparedOpened(f.claim().Claim) {
392 t.Fatalf("P was not opened: %d admissions", len(dst.certified))
393 }
394 dst.receiptLost, dst.restarted = lost, true // and the agent restarts
395 dst.effects = nil
396 for i := 0; i < 6; i++ {
397 pd.Step(ctx, host)
398 }
399 c := f.claim().Claim
400 for _, e := range dst.effects {
401 if e == "ensure-local" || e == "install" {
402 t.Fatalf("the local install was touched after P opened: %v", dst.effects)
403 }
404 }
405 if lost {
406 if c.Op.Journal["stuck"] == "" || len(dst.certified) != 1 {
407 t.Fatalf("a lost receipt after P opened: stuck %q, admissions %d", c.Op.Journal["stuck"], len(dst.certified))
408 }
409 return
410 }
411 if c.Op.Journal["stuck"] != "" || len(dst.certified) < 2 {
412 t.Fatalf("an intact receipt: stuck %q, admissions %d", c.Op.Journal["stuck"], len(dst.certified))
413 }
414 })
415 }
416}
417
418// The scoped cleanup can also retire the unopened P's local install after
419// the target has entered probing and before P's first admission: the
420// probing step restores it before it counts or makes an attempt.
421func TestAParticipantRestoresALocalInstallRetiredAfterItEntersProbing(t *testing.T) {
422 f := newFixture(t)
423 ctx := context.Background()
424 src, dst := &fakeBox{f: f, box: "src"}, &fakeBox{f: f, box: "dst"}
425 if _, err := f.w.Start(ctx, host, Request{Reason: hostclaim.ReasonDrain, Source: "src", Target: "dst"}); err != nil {
426 t.Fatal(err)
427 }
428 ps := Participant{Claims: f.s, Box: "src", Enrollment: enrollment("src"), Effects: src, Now: f.clock}
429 pd := Participant{Claims: f.s, Box: "dst", Enrollment: enrollment("dst"), Effects: dst, Now: f.clock}
430 for i := 0; i < 40 && f.claim().Claim.Op.Phase != hostclaim.PhaseRelocatingProbing; i++ {
431 _, _ = f.w.Step(ctx, host)
432 ps.Step(ctx, host)
433 pd.Step(ctx, host)
434 }
435 if c := f.claim().Claim; c.Op.Phase != hostclaim.PhaseRelocatingProbing || hostclaim.PreparedOpened(c) || len(dst.certified) != 0 {
436 t.Fatalf("not probing before P's first admission: %s, %d admissions", c.Op.Phase, len(dst.certified))
437 }
438 dst.retired = true // the lease was lost after probing began
439 run(t, f, ps, pd)
440 if dst.restores != 1 || dst.probedMissing != 0 || dst.reinstalls != 0 {
441 t.Fatalf("restores %d, probes over a missing install %d, reinstalls after P %d", dst.restores, dst.probedMissing, dst.reinstalls)
442 }
443}
444
445func (b *fakeBox) Preflight(context.Context, hostclaim.Observed) error {
446 b.effects = append(b.effects, "preflight")
447 return b.preflightErr
448}
449
450func (b *fakeBox) EndProbe(_ context.Context, o hostclaim.Observed) (hostclaim.ProbeStop, error) {
451 b.effects = append(b.effects, "end-probe")
452 if b.endProbeErr != nil {
453 return hostclaim.ProbeStop{}, b.endProbeErr
454 }
455 return hostclaim.ProbeStop{Box: b.box, Session: o.Claim.Holder.Session, Receipt: "fence-" + b.box, ReceiptSHA256: strings.Repeat("a", 64)}, nil
456}
457