| new file mode 100644 |
| index 000000000..c64b3f716 |
| --- /dev/null |
| +++ b/internal/hostrelocate/participant_test.go |
| @@ -0,0 +1,392 @@ |
| +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 |
| + coverages []string |
| + // 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 |
| +} |
| + |
| +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(_ context.Context, h, op string) error { |
| + b.finished = true |
| + b.effects = append(b.effects, "finish "+op) |
| + return nil |
| +} |
| +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") |
| + 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, coverage string) (uint64, bool, error) { |
| + if b.retired { |
| + b.probedMissing++ // a probe admission over a missing local install |
| + } |
| + b.coverages = append(b.coverages, coverage) |
| + 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, Coverage: coverage, Region: "ie", PredecessorRegion: "ie"})) |
| + if err != nil { |
| + return 0, false, err |
| + } |
| + if b.failProbes { |
| + return next.Claim.Holder.Session, false, nil |
| + } |
| + return next.Claim.Holder.Session, true, 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.coverages) != 1 || dst.coverages[0] != "released" { |
| + t.Fatalf("exec %s coverages %v", o.Claim.Op.Exec, dst.coverages) |
| + } |
| + 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.coverages[0] != "" || last.WritesLost == nil || last.WritesLost.Box != "src" { |
| + t.Fatalf("coverages %v last %+v", dst.coverages, 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.coverages); 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) |
| + } |
| + // Mid-operation, Sweep keeps the controller. |
| + if _, err := f.w.Start(ctx, host, Request{Reason: hostclaim.ReasonDrain, Source: "spa", Target: "sp2"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + pspa := Participant{Claims: f.s, Box: "spa", Enrollment: enrollment("spa"), Effects: spa, Now: f.clock} |
| + 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.coverages) == 0; i++ { |
| + _, _ = f.w.Step(ctx, host) |
| + ps.Step(ctx, host) |
| + pd.Step(ctx, host) |
| + } |
| + if len(dst.coverages) != 1 || !hostclaim.PreparedOpened(f.claim().Claim) { |
| + t.Fatalf("P was not opened: %d admissions", len(dst.coverages)) |
| + } |
| + 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.coverages) != 1 { |
| + t.Fatalf("a lost receipt after P opened: stuck %q, admissions %d", c.Op.Journal["stuck"], len(dst.coverages)) |
| + } |
| + return |
| + } |
| + if c.Op.Journal["stuck"] != "" || len(dst.coverages) < 2 { |
| + t.Fatalf("an intact receipt: stuck %q, admissions %d", c.Op.Journal["stuck"], len(dst.coverages)) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// 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.PhaseProbing; i++ { |
| + _, _ = f.w.Step(ctx, host) |
| + ps.Step(ctx, host) |
| + pd.Step(ctx, host) |
| + } |
| + if c := f.claim().Claim; c.Op.Phase != hostclaim.PhaseProbing || hostclaim.PreparedOpened(c) || len(dst.coverages) != 0 { |
| + t.Fatalf("not probing before P's first admission: %s, %d admissions", c.Op.Phase, len(dst.coverages)) |
| + } |
| + 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) |
| + } |
| +} |