package hostclaim import ( "context" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "reflect" "slices" "strconv" "strings" "testing" "time" "github.com/carlosframework/platform/internal/boxid" "github.com/carlosframework/platform/internal/release" "github.com/carlosframework/platform/internal/termfence" ) type c7Authority struct { want termfence.Record err error } func (a c7Authority) ValidateFence(_ context.Context, f termfence.Record) (FenceBinding, error) { if a.err != nil { return FenceBinding{}, a.err } if f != a.want { return FenceBinding{}, ErrFence } return FenceBinding{Region: "ie", RosterRevision: 7, RosterETag: "roster-etag"}, nil } type c7Guard struct { calls int rejectAt int region string } func (g *c7Guard) CheckMovingBeforeFlip(_ context.Context, o Observed, id string, ref FenceRef) (string, error) { g.calls++ if id != o.Claim.Op.ID || ref.Enrollment != o.Claim.Op.Intent["source_enrollment"] { return "", ErrFence } if g.rejectAt > 0 && g.calls >= g.rejectAt { return "", ErrWrongState } if g.region != "" { return g.region, nil } return "ie", nil } // c7Admission is what the claim-mode request door proves (Task 7). var c7Admission = MoveAdmission{AppID: "app-canonical", AdmissionID: "move-1", SourceEnrollment: strings.Repeat("a", 32), TargetEnrollment: strings.Repeat("c", 32)} type c7Store struct { release.Store puts int } func (s *c7Store) Put(ctx context.Context, key string, body []byte, match string) (string, error) { s.puts++ return s.Store.Put(ctx, key, body, match) } func c7Fixture(t *testing.T) (*c7Store, Observed, FenceRef, c7Authority) { t.Helper() ctx := context.Background() s := &c7Store{Store: release.NewDirStore(t.TempDir())} o := servingClaim(t, ctx, s) var err error o, err = Update(ctx, s, o, func(c *Claim) error { c.Holder.Generation, c.Holder.Stamp = "gen-current", 17 c.Op = Op{ID: "move-1", Kind: KindMoving, Phase: "releasing", Exec: "adi-knitting", // The intent BeginAdmittedMove writes: the move's own keys plus its admission. Intent: map[string]string{"to": "uk", "source": "adi-knitting", "target": "bri-knitting", "app_id": c7Admission.AppID, "admission_id": c7Admission.AdmissionID, "source_enrollment": c7Admission.SourceEnrollment, "target_enrollment": c7Admission.TargetEnrollment}, Journal: map[string]string{"pin": "keep"}, Participants: []string{"adi-knitting", "bri-knitting"}} c.Obligations = []Obligation{{ID: "staging", Op: "move-1", Kind: "park-replica", Region: "uk", Owner: "region:uk", Retirement: "retire-1", Park: &ParkScope{Location: "moves/" + testHost + "/move-1", Incarnation: c.Holder.Incarnation, EvidenceAt: time.Now().UTC(), RequestLimit: 120 * time.Second}}} return nil }) if err != nil { t.Fatal(err) } f := termfence.Record{V: 1, Box: "adi-knitting", Enrollment: strings.Repeat("a", 32), Cloud: boxid.Cloud{Provider: "aws", Account: "123", Region: "eu-west-1", InstanceID: "i-source"}, State: "terminated", ObservedAt: "2026-10-06T00:00:00Z", Via: "describe", RequestID: "request", WrittenBy: termfence.WrittenBy{Box: "worker", Enrollment: strings.Repeat("b", 32)}} raw, err := json.Marshal(f) if err != nil { t.Fatal(err) } digest := sha256.Sum256(raw) ref := FenceRef{Enrollment: f.Enrollment, Key: "control/box-fences/" + f.Enrollment + ".json", SHA256: hex.EncodeToString(digest[:])} if _, err := s.Put(ctx, ref.Key, raw, ""); err != nil { t.Fatal(err) } s.puts = 0 return s, o, ref, c7Authority{want: f} } func TestC7ConversionPreservesOwnershipAndChores(t *testing.T) { s, before, ref, a := c7Fixture(t) guard := &c7Guard{} after, err := ConvertMovingToRelocating(context.Background(), s, before, "move-1", ref, a, guard) if err != nil { t.Fatal(err) } c := after.Claim if c.Op.Journal["admission_id"] != "move-1" || c.Op.Journal["app_id"] != "app-canonical" || c.Op.Journal["source_stamp"] != "17" || c.Op.Journal["source_generation"] != "gen-current" || c.Op.Journal["roster_revision"] != "7" || c.Op.Journal["roster_etag"] != "roster-etag" || c.Op.Journal["reason"] != "source-terminated-before-flip" || c.Op.Journal["conversion_claim_etag"] != before.ETag { t.Fatalf("missing conversion authority: %+v", c.Op.Journal) } if s.puts != 1 || c.Gen != before.Claim.Gen+1 || guard.calls != 2 { t.Fatalf("writes=%d gen=%d checks=%d", s.puts, c.Gen, guard.calls) } if !reflect.DeepEqual(c.Holder, before.Claim.Holder) || !reflect.DeepEqual(c.Obligations, before.Claim.Obligations) { t.Fatal("holder or staging retirement changed") } if c.Op.ID != "move-1" || c.Op.Kind != KindRelocating || c.Op.Phase != "pinning" || c.Op.Exec != "none" || c.Op.Intent["region"] != "ie" { t.Fatalf("wrong conversion: %+v", c.Op) } if c.Op.Intent["target_box"] != "" || c.Op.Intent["target"] != "" || c.Op.Intent["to"] != "" || c.Op.Intent["source"] != "" || c.Op.Intent["target_enrollment"] != "" || c.Op.Intent["move_target_box"] != "bri-knitting" || c.Op.Intent["move_to_region"] != "uk" || c.Op.Intent["move_target_enrollment"] != c7Admission.TargetEnrollment || c.Op.Intent["source_box"] != "adi-knitting" || !reflect.DeepEqual(c.Op.Participants, []string{"bri-knitting"}) { t.Fatalf("target or barrier: %+v", c.Op) } var saved FenceRef if err := json.Unmarshal([]byte(c.Op.Journal["fence.adi-knitting"]), &saved); err != nil || saved != ref { t.Fatalf("journal reference: %+v %v", saved, err) } if owner, err := RelocationRecoveryOwner(c); err != nil || owner != "region:ie" { t.Fatalf("owner=%q error=%v", owner, err) } if _, err := ConvertMovingToRelocating(context.Background(), s, before, "move-1", ref, a, &c7Guard{}); !errors.Is(err, ErrConflict) { t.Fatalf("stale replay: %v", err) } } func TestC7ConversionRefusesWithoutWriting(t *testing.T) { for _, tc := range []string{"landing-record", "flip-during-verify", "landing-claim", "wrong-op", "wrong-source", "wrong-enrollment", "wrong-region", "foreign-exec", "journal-rebind", "authority", "digest", "no-guard", "missing-admission", "admission-not-op", "missing-app", "missing-enrollment", "missing-source", "legacy-source-box-only"} { t.Run(tc, func(t *testing.T) { s, o, ref, a := c7Fixture(t) id := "move-1" var guard PreFlipGuard = &c7Guard{} switch tc { case "landing-record": guard = &c7Guard{rejectAt: 1} case "flip-during-verify": guard = &c7Guard{rejectAt: 2} case "landing-claim": o.Claim.Op.Kind = KindLanding case "wrong-op": id = "other" case "wrong-source": o.Claim.Op.Intent["source"] = "other" case "wrong-enrollment": o.Claim.Op.Intent["source_enrollment"] = strings.Repeat("d", 32) case "wrong-region": // The record's region (the guard's) differs from the fence binding. guard = &c7Guard{region: "uk"} case "foreign-exec": o.Claim.Op.Exec = "target" case "journal-rebind": o.Claim.Op.Journal["fence.adi-knitting"] = `{"enrollment":"other"}` case "authority": a.err = ErrFence case "digest": ref.SHA256 = strings.Repeat("0", 64) case "no-guard": guard = nil case "missing-admission": delete(o.Claim.Op.Intent, "admission_id") case "admission-not-op": // A registration under another id is drained while the move runs. o.Claim.Op.Intent["admission_id"] = "admission-existing" case "missing-app": delete(o.Claim.Op.Intent, "app_id") case "missing-enrollment": delete(o.Claim.Op.Intent, "source_enrollment") case "missing-source": delete(o.Claim.Op.Intent, "source") case "legacy-source-box-only": // The pre-C7 shape is no longer an input. o.Claim.Op.Intent["source_box"] = o.Claim.Op.Intent["source"] o.Claim.Op.Intent["source_region"] = "ie" delete(o.Claim.Op.Intent, "source") } raw, _ := json.Marshal(o.Claim) if _, err := ConvertMovingToRelocating(context.Background(), s, o, id, ref, a, guard); err == nil { t.Fatal("accepted") } unchanged, _ := json.Marshal(o.Claim) if s.puts != 0 || string(raw) != string(unchanged) { t.Fatal("refusal mutated claim") } }) } } func TestC7VerifyFenceReReadsExactObject(t *testing.T) { ctx := context.Background() s, _, ref, a := c7Fixture(t) if _, err := VerifyFence(ctx, s, a, ref); err != nil { t.Fatal(err) } for _, tc := range []string{"bad-key", "bad-enrollment", "bad-digest", "missing", "changed-bytes", "authority"} { t.Run(tc, func(t *testing.T) { s, _, ref, a := c7Fixture(t) switch tc { case "bad-key": ref.Key = "control/box-fences/other.json" case "bad-enrollment": ref.Enrollment = "../escape" case "bad-digest": ref.SHA256 = strings.Repeat("f", 64) case "missing": if err := s.Delete(ctx, ref.Key); err != nil { t.Fatal(err) } case "changed-bytes": raw, etag, err := s.Get(ctx, ref.Key) if err != nil { t.Fatal(err) } if _, err = s.Put(ctx, ref.Key, append(raw, ' '), etag); err != nil { t.Fatal(err) } case "authority": a.err = ErrFence } if _, err := VerifyFence(ctx, s, a, ref); err == nil { t.Fatal("accepted") } }) } } func TestC7RelocatingPhaseValidation(t *testing.T) { for _, phase := range []string{"starting", "releasing", "pinning", "installing", "installed", "probing", "probe-failed", "serving", "unknown"} { t.Run(phase, func(t *testing.T) { _, o, _, _ := c7Fixture(t) c := o.Claim c.Op.Kind, c.Op.Phase = KindRelocating, phase // A relocating op's own intent (region HA's shape), not a move's. c.Op.Intent = map[string]string{"region": "ie", "source_box": "adi-knitting", "target_box": "bri-knitting"} switch phase { case "starting", "releasing": c.Op.Exec = "adi-knitting" case "pinning": c.Op.Exec = "none" default: c.Op.Exec = "bri-knitting" } if err := c.Validate(); (err != nil) != (phase == "unknown") { t.Fatalf("phase %s: %v", phase, err) } if phase == "pinning" { c.Op.Exec = "bri-knitting" if c.Validate() == nil { t.Fatal("pinning granted execution") } } }) } } func TestC7JournalFenceCannotNameAnotherBox(t *testing.T) { s, o, ref, a := c7Fixture(t) raw, err := json.Marshal(ref) if err != nil { t.Fatal(err) } o.Claim.Op.Journal["fence.adi-knitting"] = string(raw) if err := VerifyOperationFence(context.Background(), s, a, o.Claim.Op, "adi-knitting"); err != nil { t.Fatal(err) } o.Claim.Op.Journal["fence.other"] = string(raw) if err := VerifyOperationFence(context.Background(), s, a, o.Claim.Op, "other"); err == nil { t.Fatal("journal key rebound to another box") } } // The C7 contract with moves under Task 7: a claim BeginAdmittedMove produced // converts; the same move begun without its admission does not. func TestC7ConvertsOnlyAnAdmittedBeginMove(t *testing.T) { for _, admitted := range []bool{true, false} { t.Run(map[bool]string{true: "admitted", false: "unadmitted"}[admitted], func(t *testing.T) { s, o, ref, a := c7Fixture(t) ctx := context.Background() var err error o, err = Update(ctx, s, o, func(c *Claim) error { c.Op = Op{Kind: KindServing, Exec: "adi-knitting"} c.Obligations = nil if admitted { return BeginAdmittedMove("move-1", "member", "adi-knitting", "bri-knitting", "uk", time.Now(), c7Admission)(c) } return BeginMove("move-1", "member", "adi-knitting", "bri-knitting", "uk", time.Now())(c) }) if err != nil { t.Fatal(err) } s.puts = 0 after, err := ConvertMovingToRelocating(ctx, s, o, "move-1", ref, a, &c7Guard{}) if !admitted { if !errors.Is(err, ErrWrongState) || s.puts != 0 { t.Fatalf("unadmitted move converted: %v puts=%d", err, s.puts) } return } if err != nil { t.Fatal(err) } c := after.Claim if c.Op.Kind != KindRelocating || c.Op.Phase != "pinning" || c.Op.Intent["source_box"] != "adi-knitting" || c.Op.Intent["region"] != "ie" || c.Op.Journal["admission_id"] != c7Admission.AdmissionID || c.Op.Journal["app_id"] != c7Admission.AppID { t.Fatalf("not converted: %+v", c.Op) } }) } } func TestBeginAdmittedMoveIsOneClosure(t *testing.T) { now := time.Now() serving := func() *Claim { return &Claim{V: 2, Host: testHost, Gen: 3, Holder: &Holder{Family: "instance"}, Op: Op{Kind: KindServing, Exec: "adi-knitting"}} } c := serving() if err := BeginAdmittedMove("move-1", "ops", "adi-knitting", "bri-knitting", "uk", now, c7Admission)(c); err != nil { t.Fatal(err) } want := map[string]string{"to": "uk", "source": "adi-knitting", "target": "bri-knitting", "app_id": c7Admission.AppID, "admission_id": c7Admission.AdmissionID, "source_enrollment": c7Admission.SourceEnrollment, "target_enrollment": c7Admission.TargetEnrollment} if !reflect.DeepEqual(c.Op.Intent, want) || c.Op.Kind != KindMoving || c.Op.Exec != "adi-knitting" { t.Fatalf("intent: %+v", c.Op) } for name, bad := range map[string]MoveAdmission{ "no-app": {AdmissionID: "move-1", SourceEnrollment: c7Admission.SourceEnrollment, TargetEnrollment: c7Admission.TargetEnrollment}, "no-registration": {AppID: "a", SourceEnrollment: c7Admission.SourceEnrollment, TargetEnrollment: c7Admission.TargetEnrollment}, "other-op": {AppID: "a", AdmissionID: "registration", SourceEnrollment: c7Admission.SourceEnrollment, TargetEnrollment: c7Admission.TargetEnrollment}, "bad-source": {AppID: "a", AdmissionID: "move-1", SourceEnrollment: "short", TargetEnrollment: c7Admission.TargetEnrollment}, "upper-target": {AppID: "a", AdmissionID: "move-1", SourceEnrollment: c7Admission.SourceEnrollment, TargetEnrollment: strings.Repeat("C", 32)}, "same-enrollment": {AppID: "a", AdmissionID: "move-1", SourceEnrollment: c7Admission.SourceEnrollment, TargetEnrollment: c7Admission.SourceEnrollment}, } { c := serving() before, _ := json.Marshal(c) if err := BeginAdmittedMove("move-1", "ops", "adi-knitting", "bri-knitting", "uk", now, bad)(c); !errors.Is(err, ErrMalformed) { t.Fatalf("%s: %v", name, err) } if after, _ := json.Marshal(c); string(after) != string(before) { t.Fatalf("%s: refusal changed the claim", name) } } } // J3 (Paul's ruling J3/F5 on amadan #236; the model's // fence-converts-committed-landing): a landing whose record committed and // whose target is then terminated becomes a failover in the target's // region. const ( j3Source = "ie-box" j3Target = "uk-box" j3Spare = "uk-box2" j3SourceEnr = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" j3TargetEnr = "cccccccccccccccccccccccccccccccc" j3SpareEnr = "dddddddddddddddddddddddddddddddd" ) // j3Authority binds every fence to the target's region, uk. type j3Authority struct{ region string } func (a j3Authority) ValidateFence(_ context.Context, f termfence.Record) (FenceBinding, error) { if f.Box != j3Target || f.Enrollment != j3TargetEnr { return FenceBinding{}, ErrFence } region := a.region if region == "" { region = "uk" } return FenceBinding{Region: region, RosterRevision: 11, RosterETag: "roster-uk"}, nil } // j3Guard is the record: committed (moves ยง7.5) unless refuse says // otherwise, read on the calls counted. type j3Guard struct { calls int rejectAt int to string } func (g *j3Guard) CheckLandingCommitted(_ context.Context, o Observed, id string) (CommittedLanding, error) { g.calls++ if id != o.Claim.Op.ID { return CommittedLanding{}, ErrWrongState } if g.rejectAt > 0 && g.calls >= g.rejectAt { return CommittedLanding{}, ErrWrongState // the record is landing or rolling back } to := g.to if to == "" { to = "uk" } src, stg := commitScopes(o.Claim) return CommittedLanding{FromRegion: "ie", ToRegion: to, SourceScope: src, StagingScope: stg}, nil } // j3Landing is a stored admitted landing at phase (probing, probed, or // committed: the claim followed the record's commit), its source box // holding paths of the stay the move leaves so the commit owes its // park-local, plus an unrelated chore of another operation. func j3Landing(t *testing.T, phase string) (*c7Store, Observed) { t.Helper() ctx := context.Background() c := probedLanding(t) c.Op.Intent["app_id"], c.Op.Intent["admission_id"] = "app-canonical", moveA c.Op.Intent["source_enrollment"], c.Op.Intent["target_enrollment"] = j3SourceEnr, j3TargetEnr // The probe's cold admission made its generation holder.generation and // the target the writer (claim rev 6.9). c.Holder.WriterBox, c.Holder.Generation = j3Target, moveA c.Inventory = map[string]InventoryEntry{j3Source: {Layout: "flat", Paths: map[string]uint64{"/data/a": 3}}} c.Obligations = []Obligation{{ID: "older", Op: "op_old", Kind: "park-generation", Region: "uk", Generation: "gen-1", Retirement: "r-old", Owner: "region:uk"}} switch phase { case PhaseLandingProbing: c.Op.Phase = PhaseLandingProbing case PhaseLandingCommitted: src, stg := commitScopes(c) if err := KeepWriterSession(moveA, Commit(moveA, "ie", j3Source, "uk", src, stg, counter()))(c); err != nil { t.Fatal(err) } } s := &c7Store{Store: release.NewDirStore(t.TempDir())} o, err := Update(ctx, s, Observed{Host: c.Host, Absent: true}, func(n *Claim) error { *n = *c n.Gen = 0 return nil }) if err != nil { t.Fatal(err) } s.puts = 0 return s, o } func j3Fence(t *testing.T, s release.Store) FenceRef { t.Helper() f := termfence.Record{V: 1, Box: j3Target, Enrollment: j3TargetEnr, Cloud: boxid.Cloud{Provider: "aws", Account: "123", Region: "eu-west-2", InstanceID: "i-target"}, State: "terminated", ObservedAt: "2026-10-10T08:00:00Z", Via: "describe", RequestID: "request", WrittenBy: termfence.WrittenBy{Box: "uk-worker", Enrollment: strings.Repeat("b", 32)}} raw, err := json.Marshal(f) if err != nil { t.Fatal(err) } sum := sha256.Sum256(raw) ref := FenceRef{Enrollment: f.Enrollment, Key: "control/box-fences/" + f.Enrollment + ".json", SHA256: hex.EncodeToString(sum[:])} if _, err := s.Put(context.Background(), ref.Key, raw, ""); err != nil { t.Fatal(err) } return ref } func TestJ3CommittedLandingBecomesAFailoverInTheTargetsRegion(t *testing.T) { ctx := context.Background() for _, phase := range []string{PhaseLandingCommitted, PhaseLandingProbed, PhaseLandingProbing} { t.Run("claim at "+phase, func(t *testing.T) { s, before := j3Landing(t, phase) ref := j3Fence(t, s) s.puts = 0 // The obligations the claim owes once it follows the commit, as // moves' own commit writes them with the same ids. want := cloneForTest(t, before.Claim) if want.Op.Phase == PhaseLandingProbing { want.Op.Phase = PhaseLandingProbed } if want.Op.Phase == PhaseLandingProbed { src, stg := commitScopes(want) if err := KeepWriterSession(moveA, Commit(moveA, "ie", j3Source, "uk", src, stg, counter()))(want); err != nil { t.Fatal(err) } } guard := &j3Guard{} after, err := ConvertCommittedLanding(ctx, s, before, moveA, j3Target, ref, j3Authority{}, guard, nil, counter()) if err != nil { t.Fatal(err) } c := after.Claim if s.puts != 1 || c.Gen != before.Claim.Gen+1 || guard.calls != 2 { t.Fatalf("writes=%d gen=%d record reads=%d", s.puts, c.Gen, guard.calls) } // Every cleanup obligation is kept byte for byte: the source's // location park, its park-local, the staging lane's park, and // the unrelated chore. if string(mustJSONAny(t, c.Obligations)) != string(mustJSONAny(t, want.Obligations)) { t.Fatalf("obligations:\n got %s\nwant %s", mustJSONAny(t, c.Obligations), mustJSONAny(t, want.Obligations)) } if k := obligationKinds(c); k["park-replica/ie"] != 1 || k["park-replica/uk"] != 1 || k["park-local/"+j3Source] != 1 || k["park-generation/uk"] != 1 { t.Fatalf("the commit's chores: %v", k) } if !reflect.DeepEqual(c.Holder, want.Holder) || !reflect.DeepEqual(c.Touched, before.Claim.Touched) || !reflect.DeepEqual(c.Inventory, before.Claim.Inventory) { t.Fatal("holder, touched or inventory changed") } // A failover in uk with no executor and no target, its source the // dead target, the op id kept. if c.Op.ID != moveA || c.Op.Kind != KindRelocating || c.Op.Phase != PhaseStarting || c.Op.Exec != "none" || len(c.Op.Participants) != 0 || !failover(c.Op) { t.Fatalf("op: %+v", c.Op) } wantIntent := map[string]string{"region": "uk", "source_box": j3Target, "source_enrollment": j3TargetEnr, "move_source_box": j3Source, "move_source_enrollment": j3SourceEnr, "move_to_region": "uk", "app_id": "app-canonical", "admission_id": moveA, "stamp_from": strconv.FormatUint(before.Claim.Holder.Stamp, 10), "generation_from": before.Claim.Holder.Generation} if !reflect.DeepEqual(c.Op.Intent, wantIntent) { t.Fatalf("intent %v", c.Op.Intent) } // The move's journal is kept whole. for k, v := range want.Op.Journal { if c.Op.Journal[k] != v { t.Fatalf("journal %s: %q, was %q", k, c.Op.Journal[k], v) } } if c.Op.Journal["conversion"] != "committed-landing" || c.Op.Journal["conversion_landing_phase"] != phase || c.Op.Journal["move_from_region"] != "ie" || c.Op.Journal["conversion_claim_etag"] != before.ETag || c.Op.Journal["roster_revision"] != "11" || c.Op.Journal["roster_etag"] != "roster-uk" { t.Fatalf("conversion record: %v", c.Op.Journal) } // The fence is consumed as FenceSource consumes it: lost, the // reference and its observation time, the loss retained. var saved FenceRef if err := json.Unmarshal([]byte(c.Op.Journal["fence."+j3Target]), &saved); err != nil || saved != ref || c.Op.Journal["fence."+j3Target+".observed_at"] != "2026-10-10T08:00:00Z" { t.Fatalf("fence journal: %+v %v", saved, err) } if !slices.Contains(c.Lost, j3Target) || len(c.EvidenceRefs) != 1 || c.EvidenceRefs[0].Box != j3Target || c.EvidenceRefs[0].Role != "source" || c.EvidenceRefs[0].Kind != KindRelocating || c.EvidenceRefs[0].Fence != ref || !slices.Contains(c.LossRefs, c.EvidenceRefs[0].ID) { t.Fatalf("lost %v evidence %+v loss_refs %v", c.Lost, c.EvidenceRefs, c.LossRefs) } if owner, err := RelocationRecoveryOwner(c); err != nil || owner != "region:uk" { t.Fatalf("owner=%q error=%v", owner, err) } // A stale replay conflicts; a replay of the fresh claim finds no // landing (the sibling conversions' behaviour). if _, err := ConvertCommittedLanding(ctx, s, before, moveA, j3Target, ref, j3Authority{}, &j3Guard{}, nil, counter()); !errors.Is(err, ErrConflict) { t.Fatalf("stale replay: %v", err) } if _, err := ConvertCommittedLanding(ctx, s, after, moveA, j3Target, ref, j3Authority{}, &j3Guard{}, nil, counter()); !errors.Is(err, ErrWrongState) { t.Fatalf("replay over the failover: %v", err) } // It proceeds as an initial failover: the target is named in // starting and acknowledges before any pin. if _, err := Update(ctx, s, after, PinFailover(moveA)); !errors.Is(err, ErrRelocation) { t.Fatalf("pinned before a target was named: %v", err) } o := step(t, s, after, NameTarget(moveA, Target{j3Spare, j3SpareEnr})) if _, err := Update(ctx, s, o, PinFailover(moveA)); !errors.Is(err, ErrRelocation) { t.Fatalf("pinned before the target acknowledged: %v", err) } o = step(t, s, o, AcknowledgeExecution(j3Spare, ExecutionAcknowledgement{Op: moveA, Enrollment: j3SpareEnr, BootID: "boot", ControllerID: "ctl", LeaseETag: "lease", Expires: time.Now().Add(time.Hour)})) o = step(t, s, o, PinFailover(moveA)) if o.Claim.Op.Phase != PhasePinning || o.Claim.Op.Exec != "none" || !reflect.DeepEqual(o.Claim.Op.Participants, []string{j3Spare}) { t.Fatalf("after PinFailover: %+v", o.Claim.Op) } if string(mustJSONAny(t, o.Claim.Obligations)) != string(mustJSONAny(t, want.Obligations)) { t.Fatal("the failover's own steps changed an obligation") } }) } } func TestJ3ConversionRefusesWithoutWriting(t *testing.T) { for _, tc := range []struct{ name string }{ {"record-not-committed"}, {"record-not-committed-claim-committed"}, {"record-rolled-back-during-verify"}, {"record-elsewhere"}, {"claim-before-probing"}, {"not-landing"}, {"wrong-op"}, {"wrong-box"}, {"source-box"}, {"fence-region"}, {"stale-fence"}, {"fence-gone"}, {"target-lost"}, {"journal-rebind"}, {"exec-elsewhere"}, {"no-guard"}, {"no-ids"}, {"missing-admission"}, {"missing-enrollment"}, {"authority"}, } { t.Run(tc.name, func(t *testing.T) { ctx := context.Background() phase := PhaseLandingProbed if tc.name == "record-not-committed-claim-committed" { // The claim says committed; the record does not. The record // is the evidence, so the conversion refuses. phase = PhaseLandingCommitted } s, o := j3Landing(t, phase) ref := j3Fence(t, s) s.puts = 0 id, box := moveA, j3Target var guard CommittedLandingGuard = &j3Guard{} var authority FenceAuthority = j3Authority{} newID := counter() switch tc.name { case "record-not-committed", "record-not-committed-claim-committed": guard = &j3Guard{rejectAt: 1} // moves' declared-loss rollback owns the fence case "record-rolled-back-during-verify": guard = &j3Guard{rejectAt: 2} case "record-elsewhere": guard = &j3Guard{to: "us"} case "claim-before-probing": o.Claim.Op.Phase = "restored" case "not-landing": o.Claim.Op.Kind = KindRestoring case "wrong-op": id = moveB case "wrong-box": box = j3Spare case "source-box": box = j3Source case "fence-region": authority = j3Authority{region: "ie"} case "stale-fence": ref.SHA256 = strings.Repeat("0", 64) case "fence-gone": ref.Key = "control/box-fences/" + strings.Repeat("e", 32) + ".json" case "target-lost": o.Claim.Lost = []string{j3Target} case "journal-rebind": o.Claim.Op.Journal["fence."+j3Target] = `{"enrollment":"other"}` case "exec-elsewhere": o.Claim.Op.Exec = j3Source case "no-guard": guard = nil case "no-ids": newID = nil case "missing-admission": delete(o.Claim.Op.Intent, "admission_id") case "missing-enrollment": delete(o.Claim.Op.Intent, "target_enrollment") case "authority": authority = c7Authority{err: ErrFence} } raw := mustJSON(t, o.Claim) _, err := ConvertCommittedLanding(ctx, s, o, id, box, ref, authority, guard, nil, newID) if err == nil { t.Fatal("accepted") } t.Logf("refused: %v", err) if strings.HasPrefix(tc.name, "record-") && tc.name != "record-elsewhere" && !errors.Is(err, ErrWrongState) { t.Fatalf("an uncommitted record: %v", err) } if s.puts != 0 || string(raw) != string(mustJSON(t, o.Claim)) { t.Fatal("refusal wrote or mutated the claim") } if stored, rerr := Read(ctx, s, o.Host); rerr != nil || stored.ETag != o.ETag { t.Fatalf("stored claim moved: %v", rerr) } }) } } func mustJSONAny(t *testing.T, v any) []byte { t.Helper() b, err := json.Marshal(v) if err != nil { t.Fatal(err) } return b }