| index 4305a7ecc..3095526ff 100644 |
| --- a/internal/hostrelocate/worker_test.go |
| +++ b/internal/hostrelocate/worker_test.go |
| @@ -4,6 +4,8 @@ import ( |
| "context" |
| "crypto/ed25519" |
| "crypto/rand" |
| + "crypto/sha256" |
| + "encoding/hex" |
| "encoding/json" |
| "errors" |
| "fmt" |
| @@ -883,39 +885,93 @@ func TestWorkerRefusesAConvertedMoveWhoseBoundFenceIsMissing(t *testing.T) { |
| } |
| } |
| |
| +// committedGuard is moves' record for the landing: committed, from uk |
| +// (the move's source region) to ie, this worker's region. |
| +type committedGuard struct{ op string } |
| + |
| +func (g committedGuard) CheckLandingCommitted(_ context.Context, o hostclaim.Observed, id string) (hostclaim.CommittedLanding, error) { |
| + if id != g.op || o.Claim.Op.ID != g.op { |
| + return hostclaim.CommittedLanding{}, hostclaim.ErrWrongState |
| + } |
| + h := o.Claim.Holder |
| + at := time.Date(2026, 10, 7, 8, 0, 0, 0, time.UTC) |
| + return hostclaim.CommittedLanding{FromRegion: "uk", ToRegion: "ie", |
| + SourceScope: hostclaim.ParkScope{Location: h.Location, Incarnation: h.Incarnation, EvidenceAt: at, RequestLimit: 120 * time.Second}, |
| + StagingScope: hostclaim.ParkScope{Location: "moves/" + host + "/" + g.op, Incarnation: h.Incarnation, EvidenceAt: at, RequestLimit: 120 * time.Second}}, nil |
| +} |
| + |
| +// convertedLanding drives the host through an admitted move from src (in |
| +// uk, for the move) to dst through the claim's own transitions to a probed |
| +// landing, terminates dst, and converts it with ConvertCommittedLanding: |
| +// the claim the worker then finds is the conversion's real output. |
| +func (f *fixture) convertedLanding() hostclaim.Observed { |
| + f.t.Helper() |
| + ctx := context.Background() |
| + const id = "mv_abcdefghijklmnopqrstuvwxyz" |
| + o := f.claim() |
| + o = f.step(o, hostclaim.BeginAdmittedMove(id, "ops", "src", "dst", "ie", f.now, hostclaim.MoveAdmission{AppID: "app-canonical", AdmissionID: id, SourceEnrollment: enrollment("src"), TargetEnrollment: enrollment("dst")})) |
| + o = f.step(o, hostclaim.ExecToNone(id, "src", "moves/"+host+"/"+id+"/stopped-source.json", hostclaim.JournalEvidenceSource)) |
| + o = f.step(o, hostclaim.SetJournal(id, map[string]string{hostclaim.JournalSeal: `{"n":7}`, hostclaim.JournalCutSession: "1"})) |
| + o = f.step(o, hostclaim.SetMovePhase(id, hostclaim.KindMoving, hostclaim.PhaseMoving, hostclaim.PhaseSealed)) |
| + o = f.step(o, hostclaim.Flip(id, "dst", o.Claim.Holder.Stamp, o.Claim.Holder.Generation)) |
| + for _, p := range [][2]string{{"checking", "parking"}, {"parking", "building"}, {"building", "published"}, {"published", "local"}, {"local", "configured"}, {"configured", "restored"}, {"restored", "probing"}, {"probing", "probed"}} { |
| + o = f.step(o, hostclaim.SetMovePhase(id, hostclaim.KindLanding, p[0], p[1])) |
| + } |
| + // The probe's cold admission on dst (claim rev 6.9): its generation is |
| + // holder.generation and dst the writer. |
| + o = f.step(o, func(c *hostclaim.Claim) error { |
| + h := c.Holder |
| + h.Session++ |
| + h.Admissions = append(h.Admissions, hostclaim.AdmissionRecord{Region: "ie", PredecessorRegion: "uk", Session: h.Session, Box: "dst", Generation: "gen-landed", Predecessor: h.Generation, Mode: "cold", Coverage: "sealed"}) |
| + h.Generation, h.WriterBox = "gen-landed", "dst" |
| + return nil |
| + }) |
| + f.terminate("dst") |
| + enr := enrollment("dst") |
| + raw, _, err := f.s.Get(ctx, hostfence.FenceKey(enr)) |
| + if err != nil { |
| + f.t.Fatal(err) |
| + } |
| + sum := sha256.Sum256(raw) |
| + ref := hostclaim.FenceRef{Enrollment: enr, Key: hostfence.FenceKey(enr), SHA256: hex.EncodeToString(sum[:])} |
| + n := 0 |
| + out, err := hostclaim.ConvertCommittedLanding(ctx, f.s, o, id, "dst", ref, f.w.Fences, committedGuard{op: id}, nil, func() string { n++; return fmt.Sprintf("mv-chore-%d", n) }) |
| + if err != nil { |
| + f.t.Fatal(err) |
| + } |
| + return out |
| +} |
| + |
| // J3: a committed landing converted to a failover (ConvertCommittedLanding) |
| // starts with no target. The worker names one in starting; it acknowledges |
| // before PinFailover, and the pin is selected after that, as an initial |
| -// failover's is; installing then copies the pin's release (R9). |
| +// failover's is; installing then copies the pin's release (R9). The move's |
| +// cleanup obligations ride through untouched. |
| func TestWorkerNamesAConvertedLandingsTargetBeforeItsPin(t *testing.T) { |
| f := newFixture(t) |
| - ctx := context.Background() |
| - f.terminate("src") |
| - if _, err := f.w.Start(ctx, host, Request{Reason: hostclaim.ReasonFailover, Source: "src", Target: "dst"}); err != nil { |
| - t.Fatal(err) |
| + converted := f.convertedLanding() |
| + c := converted.Claim |
| + if c.Op.Kind != hostclaim.KindRelocating || c.Op.Phase != hostclaim.PhaseStarting || c.Op.Exec != "none" || c.Op.Intent["source_box"] != "dst" || |
| + c.Op.Intent["move_source_box"] != "src" || c.Op.Journal["conversion"] != "committed-landing" || len(c.Op.Participants) != 0 || len(c.Obligations) == 0 { |
| + t.Fatalf("not the conversion's output: %+v obligations %d", c.Op, len(c.Obligations)) |
| } |
| - // The shape the conversion leaves: starting, op.exec none, the dead box |
| - // the source (on lost, its fence consumed), no target, no participant. |
| - f.participant(func(c *hostclaim.Claim) error { |
| - delete(c.Op.Intent, "target_box") |
| - delete(c.Op.Intent, "target_enrollment") |
| - c.Op.Participants = nil |
| - c.Op.Journal["conversion"] = "committed-landing" |
| - return nil |
| - }) |
| + chores, _ := json.Marshal(c.Obligations) |
| o := f.worker(nil) |
| - if o.Claim.Op.Phase != hostclaim.PhaseStarting || o.Claim.Op.Intent["target_box"] != "dst" || !slices.Equal(o.Claim.Op.Participants, []string{"dst"}) || o.Claim.Op.Journal["pin"] != "" { |
| + if o.Claim.Op.Phase != hostclaim.PhaseStarting || o.Claim.Op.Intent["target_box"] != "spa" || !slices.Equal(o.Claim.Op.Participants, []string{"spa"}) || o.Claim.Op.Journal["pin"] != "" { |
| t.Fatalf("named in starting: %+v", o.Claim.Op) |
| } |
| f.worker(ErrNotYet) // the target has not acknowledged: no pin yet |
| - f.ack("dst") |
| + f.ack("spa") |
| if o = f.worker(nil); o.Claim.Op.Phase != hostclaim.PhasePinning { |
| t.Fatalf("phase %s", o.Claim.Op.Phase) |
| } |
| if o = f.worker(nil); o.Claim.Op.Journal["pin"] == "" { |
| t.Fatal("not pinned") |
| } |
| - if o = f.worker(nil); o.Claim.Op.Phase != hostclaim.PhaseInstalling || o.Claim.Op.Exec != "dst" || o.Claim.Op.Journal["install_release"] != "v1.4.2" { |
| + if o = f.worker(nil); o.Claim.Op.Phase != hostclaim.PhaseInstalling || o.Claim.Op.Exec != "spa" || o.Claim.Op.Journal["install_release"] != "v1.4.2" { |
| t.Fatalf("installing: %+v", o.Claim.Op) |
| } |
| + if after, _ := json.Marshal(o.Claim.Obligations); string(after) != string(chores) { |
| + t.Fatalf("the failover changed an obligation:\n%s\n%s", after, chores) |
| + } |
| } |