package hostrelocate import ( "context" "errors" "fmt" "strconv" "strings" "testing" "time" "github.com/carlosframework/platform/internal/hostclaim" ) // fakeBox is one box's local effects. Its writes to the claim stand in for // what the real effects leave in storage (P built, a probe admitted). type fakeBox struct { f *fixture box string certified []bool // opened counts controllers opened; restarted makes Ready report none // until the next Acknowledge, as a restarted agent would. opened int restarted bool effects []string op string finished bool // failProbes makes every probe admission fail readiness. failProbes bool // retainedErr is a damaged journal entry Retained reports. retainedErr error // retired: a scoped cleanup removed the local install; restores, // reinstalls and probedMissing count what EnsureLocal and Probe saw. retired bool restores, reinstalls, probedMissing int // receiptLost: the box's durable local install receipt is gone. receiptLost bool // preflightErr is root refusing the relocating kind, or // ErrLaterAttemptUnlaunchable (rulings 48, 58). preflightErr error // endProbeErr is a stop root keeps refusing (ruling 67). endProbeErr error // writesOnInstall: the install writes the claim (the typed inventory), // as the production install's RecordInventory does (ruling 62). writesOnInstall bool // healthy stands for the box's durable attempt journal: the sessions // whose probe passed and settled (final review A2). passedErr is a // journal that cannot be read. flaky, when set, is armed by Probe so // the claim access after it fails once. healthy map[uint64]bool passedErr error flaky *flakyStore } func (b *fakeBox) Acknowledge(_ context.Context, o hostclaim.Observed) (hostclaim.ExecutionAcknowledgement, error) { b.opened++ b.restarted, b.finished, b.op = false, false, o.Claim.Op.ID 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 } func (b *fakeBox) Retained() ([]Participation, error) { if b.opened == 0 || b.finished { return nil, b.retainedErr } return []Participation{{Host: host, Op: b.op}}, b.retainedErr } func (b *fakeBox) Finish(ctx context.Context, h, op string) error { b.finished = true b.effects = append(b.effects, "finish "+op) // Production's Finish is the target's hold release, which ends by // marking the completed relocation discharged (region HA ruling 76). obs, err := hostclaim.Read(ctx, b.f.s, h) if err != nil { return err } done, ok, err := hostclaim.ReadCompletedRelocation(obs.Claim) if err == nil && ok && done.Op == op && done.Stop.Box == b.box && !done.Discharged { _, err = hostclaim.Update(ctx, b.f.s, obs, hostclaim.MarkCompletedDischarged(op)) } return err } func (b *fakeBox) Ready(context.Context, hostclaim.Observed) error { b.effects = append(b.effects, "ready") if b.restarted || b.opened == 0 { return ErrNoController } return nil } func (b *fakeBox) Release(_ context.Context, o hostclaim.Observed) (hostclaim.Release, error) { h := o.Claim.Holder 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 } func (b *fakeBox) Build(context.Context, hostclaim.Observed) error { b.effects = append(b.effects, "build") b.f.participant(func(c *hostclaim.Claim) error { c.Op.Journal["test.built"] = c.Op.Journal["prepared"]; return nil }) return nil } func (b *fakeBox) InstallLocal(_ context.Context, o hostclaim.Observed) (hostclaim.LocalInstall, error) { b.effects = append(b.effects, "install") if b.writesOnInstall { b.f.participant(func(c *hostclaim.Claim) error { c.Op.Journal["test.inventory"] = b.box; return nil }) } n, _ := strconv.ParseUint(o.Claim.Op.Journal["install_stamp"], 10, 64) return hostclaim.LocalInstall{Box: b.box, Enrollment: enrollment(b.box), InstallStamp: n, FilesManifestVersion: 1, JournalSHA256: strings.Repeat("e", 64)}, nil } func (b *fakeBox) EnsureLocal(_ context.Context, o hostclaim.Observed) error { b.effects = append(b.effects, "ensure-local") if hostclaim.PreparedOpened(o.Claim) { b.reinstalls++ // never: an opened P is not reinstalled } if b.retired { b.retired, b.restores = false, b.restores+1 } return nil } func (b *fakeBox) VerifyReceipt(context.Context, hostclaim.Observed) (bool, error) { b.effects = append(b.effects, "verify-receipt") return !b.receiptLost, nil } func (b *fakeBox) Probe(ctx context.Context, o hostclaim.Observed, certified bool) (uint64, bool, error) { if b.retired { b.probedMissing++ // a probe admission over a missing local install } b.certified = append(b.certified, certified) c := o.Claim 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, Box: b.box, Mode: "cold", NewGeneration: c.Op.Journal["prepared"], KeepStamp: true, PredecessorCertified: certified, Region: "ie", PredecessorRegion: "ie"})) if err != nil { return 0, false, err } if b.failProbes { return next.Claim.Holder.Session, false, nil } if b.healthy == nil { b.healthy = map[uint64]bool{} } b.healthy[next.Claim.Holder.Session] = true if b.flaky != nil { b.flaky.armed = true } return next.Claim.Holder.Session, true, nil } func (b *fakeBox) PassedProbe(_ context.Context, o hostclaim.Observed) (uint64, bool, error) { b.effects = append(b.effects, "passed-probe") if b.passedErr != nil { return 0, false, b.passedErr } s := o.Claim.Holder.Session return s, b.healthy[s], nil } func (f *fixture) clock() time.Time { return f.now } // run steps the worker and both participants until the host serves again. func run(t *testing.T, f *fixture, parts ...Participant) hostclaim.Observed { t.Helper() ctx := context.Background() for i := 0; i < 60; i++ { if o := f.claim(); o.Claim.Op.Kind == hostclaim.KindServing { return o } if _, err := f.w.Step(ctx, host); err != nil && !errors.Is(err, ErrNotYet) { t.Fatalf("worker: %v", err) } for _, p := range parts { if _, err := p.Step(ctx, host); err != nil && !errors.Is(err, ErrNotYet) { t.Fatalf("%s: %v", p.Box, err) } } } t.Fatalf("no progress: %+v", f.claim().Claim.Op) return hostclaim.Observed{} } func TestADrainCompletesWithBothParticipants(t *testing.T) { f := newFixture(t) src, dst := &fakeBox{f: f, box: "src"}, &fakeBox{f: f, box: "dst"} if _, err := f.w.Start(context.Background(), host, Request{Reason: hostclaim.ReasonDrain, Source: "src", Target: "dst"}); err != nil { t.Fatal(err) } 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}) if o.Claim.Op.Exec != "dst" || len(dst.certified) != 1 || !dst.certified[0] { t.Fatalf("exec %s certified %v", o.Claim.Op.Exec, dst.certified) } last := o.Claim.Holder.Admissions[len(o.Claim.Holder.Admissions)-1] if last.WritesLost != nil { t.Fatalf("a drain declared a loss: %+v", last) } } func TestAFailoverCompletesAndDeclaresTheSource(t *testing.T) { f := newFixture(t) f.terminate("src") dst := &fakeBox{f: f, box: "dst"} if _, err := f.w.Start(context.Background(), host, Request{Reason: hostclaim.ReasonFailover, Source: "src", Target: "dst"}); err != nil { t.Fatal(err) } o := run(t, f, Participant{Claims: f.s, Box: "dst", Enrollment: enrollment("dst"), Effects: dst, Now: f.clock}) last := o.Claim.Holder.Admissions[len(o.Claim.Holder.Admissions)-1] if dst.certified[0] || last.WritesLost == nil || last.WritesLost.Box != "src" { t.Fatalf("certified %v last %+v", dst.certified, last) } } func TestALostParticipantDoesNothing(t *testing.T) { f := newFixture(t) f.terminate("src") if _, err := f.w.Start(context.Background(), host, Request{Reason: hostclaim.ReasonFailover, Source: "src", Target: "dst"}); err != nil { t.Fatal(err) } src := &fakeBox{f: f, box: "src"} before := f.claim().ETag 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 { t.Fatalf("a fenced source acted: %v", err) } } func TestAParticipantChecksItsControllerBeforeEveryEffectAndReopensAfterARestart(t *testing.T) { f := newFixture(t) ctx := context.Background() src, dst := &fakeBox{f: f, box: "src"}, &fakeBox{f: f, box: "dst"} if _, err := f.w.Start(ctx, host, Request{Reason: hostclaim.ReasonDrain, Source: "src", Target: "dst"}); err != nil { t.Fatal(err) } ps := Participant{Claims: f.s, Box: "src", Enrollment: enrollment("src"), Effects: src, Now: f.clock} pd := Participant{Claims: f.s, Box: "dst", Enrollment: enrollment("dst"), Effects: dst, Now: f.clock} for _, p := range []Participant{ps, pd} { if _, err := p.Step(ctx, host); err != nil { t.Fatal(err) } } f.worker(nil) // releasing // The target's agent restarts: its next step opens a controller and // journals nothing; the one after acts. dst.restarted = true before := f.claim().ETag if _, err := pd.Step(ctx, host); !errors.Is(err, ErrNotYet) || dst.opened != 2 || f.claim().ETag != before { t.Fatalf("restart: %v opened=%d wrote=%v", err, dst.opened, f.claim().ETag != before) } if n := len(f.claim().Claim.Op.Journal["exec_ack.dst"]); n == 0 { t.Fatal("the first acknowledgement was dropped") } // The source releases: Ready ran before the effect. src.effects = nil if _, err := ps.Step(ctx, host); err != nil { t.Fatal(err) } if len(src.effects) == 0 || src.effects[0] != "ready" { t.Fatalf("release ran without checking the controller: %v", src.effects) } // A lost controller refuses the effect and writes nothing. o := run(t, f, ps, pd) if o.Claim.Op.Exec != "dst" { t.Fatalf("exec %s", o.Claim.Op.Exec) } for i, e := range dst.effects { if e == "build" && (i == 0 || dst.effects[i-1] != "ready") { t.Fatalf("build without a readiness check first: %v", dst.effects) } } } func TestAParticipantGivesUpAfterThreeProbeAttempts(t *testing.T) { f := newFixture(t) ctx := context.Background() src, dst := &fakeBox{f: f, box: "src"}, &fakeBox{f: f, box: "dst", failProbes: true} if _, err := f.w.Start(ctx, host, Request{Reason: hostclaim.ReasonDrain, Source: "src", Target: "dst"}); err != nil { t.Fatal(err) } ps := Participant{Claims: f.s, Box: "src", Enrollment: enrollment("src"), Effects: src, Now: f.clock} pd := Participant{Claims: f.s, Box: "dst", Enrollment: enrollment("dst"), Effects: dst, Now: f.clock} for i := 0; i < 40; i++ { o := f.claim() if o.Claim.Op.Phase == hostclaim.PhaseProbeFailed { if n := len(dst.certified); n != hostclaim.MaxProbeAttempts { t.Fatalf("%d probe admissions, want %d", n, hostclaim.MaxProbeAttempts) } if journal := o.Claim.Op.Journal; journal["probe_attempts.dst"] != "3" { t.Fatalf("attempts journaled: %v", journal["probe_attempts.dst"]) } return } f.w.Step(ctx, host) ps.Step(ctx, host) pd.Step(ctx, host) } t.Fatalf("never probe-failed: %+v", f.claim().Claim.Op) } func TestAParticipantFinishesItsControllerOnceTheOperationIsOver(t *testing.T) { f := newFixture(t) ctx := context.Background() src, dst := &fakeBox{f: f, box: "src"}, &fakeBox{f: f, box: "dst"} if _, err := f.w.Start(ctx, host, Request{Reason: hostclaim.ReasonDrain, Source: "src", Target: "dst"}); err != nil { t.Fatal(err) } ps := Participant{Claims: f.s, Box: "src", Enrollment: enrollment("src"), Effects: src, Now: f.clock} pd := Participant{Claims: f.s, Box: "dst", Enrollment: enrollment("dst"), Effects: dst, Now: f.clock} run(t, f, ps, pd) if err := pd.Sweep(ctx); err != nil || !dst.finished { t.Fatalf("the serving target kept its relocation controller: %v finished=%v", err, dst.finished) } // A damaged journal entry beside it is reported, and the readable // participation is still finished. src.retainedErr = errors.New("a damaged journal entry") if err := ps.Sweep(ctx); !errors.Is(err, src.retainedErr) || !src.finished { t.Fatalf("the former source kept its controller, or the damaged entry was hidden: %v finished=%v", err, src.finished) } src.retainedErr = nil // A second relocation opens a fresh controller. spa := &fakeBox{f: f, box: "spa"} if _, err := f.w.Start(ctx, host, Request{Reason: hostclaim.ReasonDrain, Source: "dst", Target: "spa"}); err != nil { t.Fatal(err) } before := dst.opened run(t, f, pd, Participant{Claims: f.s, Box: "spa", Enrollment: enrollment("spa"), Effects: spa, Now: f.clock}) if dst.opened != before+1 { t.Fatalf("the second relocation did not open its own controller: %d -> %d", before, dst.opened) } // No third relocation starts from spa until spa has finished the // second one (region HA ruling 77); then, mid-operation, Sweep keeps // the controller. pspa := Participant{Claims: f.s, Box: "spa", Enrollment: enrollment("spa"), Effects: spa, Now: f.clock} if _, err := f.w.Start(ctx, host, Request{Reason: hostclaim.ReasonDrain, Source: "spa", Target: "sp2"}); !errors.Is(err, hostclaim.ErrPendingChore) { t.Fatalf("a successor started before spa finished: %v", err) } if err := pspa.Sweep(ctx); err != nil || !spa.finished { t.Fatalf("spa kept the second relocation's controller: %v", err) } if _, err := f.w.Start(ctx, host, Request{Reason: hostclaim.ReasonDrain, Source: "spa", Target: "sp2"}); err != nil { t.Fatal(err) } spa.finished = false if _, err := pspa.Step(ctx, host); err != nil { t.Fatal(err) } if err := pspa.Sweep(ctx); err != nil || spa.finished { t.Fatalf("a running operation's controller was finished: %v", err) } } // Round 14 finding 2: the local install is journaled, the lease is lost, // and the scoped cleanup retires the unopened P's local install. Before // probing, and before P's first admission, the participant has the // install verified and restored; once P is opened it is never touched. func TestAParticipantRestoresARetiredLocalInstallBeforeTheFirstAdmission(t *testing.T) { f := newFixture(t) ctx := context.Background() src, dst := &fakeBox{f: f, box: "src"}, &fakeBox{f: f, box: "dst"} if _, err := f.w.Start(ctx, host, Request{Reason: hostclaim.ReasonDrain, Source: "src", Target: "dst"}); err != nil { t.Fatal(err) } ps := Participant{Claims: f.s, Box: "src", Enrollment: enrollment("src"), Effects: src, Now: f.clock} pd := Participant{Claims: f.s, Box: "dst", Enrollment: enrollment("dst"), Effects: dst, Now: f.clock} for i := 0; i < 40 && f.claim().Claim.Op.Journal["local_install"] == ""; i++ { _, _ = f.w.Step(ctx, host) ps.Step(ctx, host) pd.Step(ctx, host) } if f.claim().Claim.Op.Journal["local_install"] == "" { t.Fatalf("never installed locally: %+v", f.claim().Claim.Op) } dst.retired = true // the lease was lost; the scoped cleanup retired it run(t, f, ps, pd) if dst.restores != 1 || dst.probedMissing != 0 || dst.reinstalls != 0 { t.Fatalf("restores %d, probes over a missing install %d, reinstalls after P %d", dst.restores, dst.probedMissing, dst.reinstalls) } } // Round 15 finding 1: after P's first admission the local install is // never remade, and a box that has lost its receipt cannot rebuild it from // P: the next admission is refused and the operation is stuck (ยง5.8, // B2-5), across a restart. With the receipt intact, the later attempt goes // ahead and nothing is reinstalled or reverted: the files, env and timers // the admitted writers changed are theirs. func TestAParticipantIsStuckWhenItsReceiptIsLostAfterPOpens(t *testing.T) { for _, lost := range []bool{true, false} { t.Run(fmt.Sprintf("receipt lost %v", lost), func(t *testing.T) { f := newFixture(t) ctx := context.Background() src, dst := &fakeBox{f: f, box: "src"}, &fakeBox{f: f, box: "dst", failProbes: true} if _, err := f.w.Start(ctx, host, Request{Reason: hostclaim.ReasonDrain, Source: "src", Target: "dst"}); err != nil { t.Fatal(err) } ps := Participant{Claims: f.s, Box: "src", Enrollment: enrollment("src"), Effects: src, Now: f.clock} pd := Participant{Claims: f.s, Box: "dst", Enrollment: enrollment("dst"), Effects: dst, Now: f.clock} for i := 0; i < 60 && len(dst.certified) == 0; i++ { _, _ = f.w.Step(ctx, host) ps.Step(ctx, host) pd.Step(ctx, host) } if len(dst.certified) != 1 || !hostclaim.PreparedOpened(f.claim().Claim) { t.Fatalf("P was not opened: %d admissions", len(dst.certified)) } dst.receiptLost, dst.restarted = lost, true // and the agent restarts dst.effects = nil for i := 0; i < 6; i++ { pd.Step(ctx, host) } c := f.claim().Claim for _, e := range dst.effects { if e == "ensure-local" || e == "install" { t.Fatalf("the local install was touched after P opened: %v", dst.effects) } } if lost { if c.Op.Journal["stuck"] == "" || len(dst.certified) != 1 { t.Fatalf("a lost receipt after P opened: stuck %q, admissions %d", c.Op.Journal["stuck"], len(dst.certified)) } return } if c.Op.Journal["stuck"] != "" || len(dst.certified) < 2 { t.Fatalf("an intact receipt: stuck %q, admissions %d", c.Op.Journal["stuck"], len(dst.certified)) } }) } } // The scoped cleanup can also retire the unopened P's local install after // the target has entered probing and before P's first admission: the // probing step restores it before it counts or makes an attempt. func TestAParticipantRestoresALocalInstallRetiredAfterItEntersProbing(t *testing.T) { f := newFixture(t) ctx := context.Background() src, dst := &fakeBox{f: f, box: "src"}, &fakeBox{f: f, box: "dst"} if _, err := f.w.Start(ctx, host, Request{Reason: hostclaim.ReasonDrain, Source: "src", Target: "dst"}); err != nil { t.Fatal(err) } ps := Participant{Claims: f.s, Box: "src", Enrollment: enrollment("src"), Effects: src, Now: f.clock} pd := Participant{Claims: f.s, Box: "dst", Enrollment: enrollment("dst"), Effects: dst, Now: f.clock} for i := 0; i < 40 && f.claim().Claim.Op.Phase != hostclaim.PhaseRelocatingProbing; i++ { _, _ = f.w.Step(ctx, host) ps.Step(ctx, host) pd.Step(ctx, host) } if c := f.claim().Claim; c.Op.Phase != hostclaim.PhaseRelocatingProbing || hostclaim.PreparedOpened(c) || len(dst.certified) != 0 { t.Fatalf("not probing before P's first admission: %s, %d admissions", c.Op.Phase, len(dst.certified)) } dst.retired = true // the lease was lost after probing began run(t, f, ps, pd) if dst.restores != 1 || dst.probedMissing != 0 || dst.reinstalls != 0 { t.Fatalf("restores %d, probes over a missing install %d, reinstalls after P %d", dst.restores, dst.probedMissing, dst.reinstalls) } } func (b *fakeBox) Preflight(context.Context, hostclaim.Observed) error { b.effects = append(b.effects, "preflight") return b.preflightErr } func (b *fakeBox) EndProbe(_ context.Context, o hostclaim.Observed) (hostclaim.ProbeStop, error) { b.effects = append(b.effects, "end-probe") if b.endProbeErr != nil { return hostclaim.ProbeStop{}, b.endProbeErr } return hostclaim.ProbeStop{Box: b.box, Session: o.Claim.Holder.Session, Receipt: "fence-" + b.box, ReceiptSHA256: strings.Repeat("a", 64)}, nil }