carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package hostclaim
2
3import (
4 "context"
5 "crypto/sha256"
6 "encoding/hex"
7 "encoding/json"
8 "errors"
9 "reflect"
10 "slices"
11 "strconv"
12 "strings"
13 "testing"
14 "time"
15
16 "github.com/carlosframework/platform/internal/boxid"
17 "github.com/carlosframework/platform/internal/release"
18 "github.com/carlosframework/platform/internal/termfence"
19)
20
21type c7Authority struct {
22 want termfence.Record
23 err error
24}
25
26func (a c7Authority) ValidateFence(_ context.Context, f termfence.Record) (FenceBinding, error) {
27 if a.err != nil {
28 return FenceBinding{}, a.err
29 }
30 if f != a.want {
31 return FenceBinding{}, ErrFence
32 }
33 return FenceBinding{Region: "ie", RosterRevision: 7, RosterETag: "roster-etag"}, nil
34}
35
36type c7Guard struct {
37 calls int
38 rejectAt int
39 region string
40}
41
42func (g *c7Guard) CheckMovingBeforeFlip(_ context.Context, o Observed, id string, ref FenceRef) (string, error) {
43 g.calls++
44 if id != o.Claim.Op.ID || ref.Enrollment != o.Claim.Op.Intent["source_enrollment"] {
45 return "", ErrFence
46 }
47 if g.rejectAt > 0 && g.calls >= g.rejectAt {
48 return "", ErrWrongState
49 }
50 if g.region != "" {
51 return g.region, nil
52 }
53 return "ie", nil
54}
55
56// c7Admission is what the claim-mode request door proves (Task 7).
57var c7Admission = MoveAdmission{AppID: "app-canonical", AdmissionID: "move-1", SourceEnrollment: strings.Repeat("a", 32), TargetEnrollment: strings.Repeat("c", 32)}
58
59type c7Store struct {
60 release.Store
61 puts int
62}
63
64func (s *c7Store) Put(ctx context.Context, key string, body []byte, match string) (string, error) {
65 s.puts++
66 return s.Store.Put(ctx, key, body, match)
67}
68func c7Fixture(t *testing.T) (*c7Store, Observed, FenceRef, c7Authority) {
69 t.Helper()
70 ctx := context.Background()
71 s := &c7Store{Store: release.NewDirStore(t.TempDir())}
72 o := servingClaim(t, ctx, s)
73 var err error
74 o, err = Update(ctx, s, o, func(c *Claim) error {
75 c.Holder.Generation, c.Holder.Stamp = "gen-current", 17
76 c.Op = Op{ID: "move-1", Kind: KindMoving, Phase: "releasing", Exec: "adi-knitting",
77 // The intent BeginAdmittedMove writes: the move's own keys plus its admission.
78 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},
79 Journal: map[string]string{"pin": "keep"}, Participants: []string{"adi-knitting", "bri-knitting"}}
80 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}}}
81 return nil
82 })
83 if err != nil {
84 t.Fatal(err)
85 }
86 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)}}
87 raw, err := json.Marshal(f)
88 if err != nil {
89 t.Fatal(err)
90 }
91 digest := sha256.Sum256(raw)
92 ref := FenceRef{Enrollment: f.Enrollment, Key: "control/box-fences/" + f.Enrollment + ".json", SHA256: hex.EncodeToString(digest[:])}
93 if _, err := s.Put(ctx, ref.Key, raw, ""); err != nil {
94 t.Fatal(err)
95 }
96 s.puts = 0
97 return s, o, ref, c7Authority{want: f}
98}
99
100func TestC7ConversionPreservesOwnershipAndChores(t *testing.T) {
101 s, before, ref, a := c7Fixture(t)
102 guard := &c7Guard{}
103 after, err := ConvertMovingToRelocating(context.Background(), s, before, "move-1", ref, a, guard)
104 if err != nil {
105 t.Fatal(err)
106 }
107 c := after.Claim
108 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 {
109 t.Fatalf("missing conversion authority: %+v", c.Op.Journal)
110 }
111 if s.puts != 1 || c.Gen != before.Claim.Gen+1 || guard.calls != 2 {
112 t.Fatalf("writes=%d gen=%d checks=%d", s.puts, c.Gen, guard.calls)
113 }
114 if !reflect.DeepEqual(c.Holder, before.Claim.Holder) || !reflect.DeepEqual(c.Obligations, before.Claim.Obligations) {
115 t.Fatal("holder or staging retirement changed")
116 }
117 if c.Op.ID != "move-1" || c.Op.Kind != KindRelocating || c.Op.Phase != "pinning" || c.Op.Exec != "none" || c.Op.Intent["region"] != "ie" {
118 t.Fatalf("wrong conversion: %+v", c.Op)
119 }
120 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"}) {
121 t.Fatalf("target or barrier: %+v", c.Op)
122 }
123 var saved FenceRef
124 if err := json.Unmarshal([]byte(c.Op.Journal["fence.adi-knitting"]), &saved); err != nil || saved != ref {
125 t.Fatalf("journal reference: %+v %v", saved, err)
126 }
127 if owner, err := RelocationRecoveryOwner(c); err != nil || owner != "region:ie" {
128 t.Fatalf("owner=%q error=%v", owner, err)
129 }
130 if _, err := ConvertMovingToRelocating(context.Background(), s, before, "move-1", ref, a, &c7Guard{}); !errors.Is(err, ErrConflict) {
131 t.Fatalf("stale replay: %v", err)
132 }
133}
134
135func TestC7ConversionRefusesWithoutWriting(t *testing.T) {
136 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"} {
137 t.Run(tc, func(t *testing.T) {
138 s, o, ref, a := c7Fixture(t)
139 id := "move-1"
140 var guard PreFlipGuard = &c7Guard{}
141 switch tc {
142 case "landing-record":
143 guard = &c7Guard{rejectAt: 1}
144 case "flip-during-verify":
145 guard = &c7Guard{rejectAt: 2}
146 case "landing-claim":
147 o.Claim.Op.Kind = KindLanding
148 case "wrong-op":
149 id = "other"
150 case "wrong-source":
151 o.Claim.Op.Intent["source"] = "other"
152 case "wrong-enrollment":
153 o.Claim.Op.Intent["source_enrollment"] = strings.Repeat("d", 32)
154 case "wrong-region":
155 // The record's region (the guard's) differs from the fence binding.
156 guard = &c7Guard{region: "uk"}
157 case "foreign-exec":
158 o.Claim.Op.Exec = "target"
159 case "journal-rebind":
160 o.Claim.Op.Journal["fence.adi-knitting"] = `{"enrollment":"other"}`
161 case "authority":
162 a.err = ErrFence
163 case "digest":
164 ref.SHA256 = strings.Repeat("0", 64)
165 case "no-guard":
166 guard = nil
167 case "missing-admission":
168 delete(o.Claim.Op.Intent, "admission_id")
169 case "admission-not-op":
170 // A registration under another id is drained while the move runs.
171 o.Claim.Op.Intent["admission_id"] = "admission-existing"
172 case "missing-app":
173 delete(o.Claim.Op.Intent, "app_id")
174 case "missing-enrollment":
175 delete(o.Claim.Op.Intent, "source_enrollment")
176 case "missing-source":
177 delete(o.Claim.Op.Intent, "source")
178 case "legacy-source-box-only":
179 // The pre-C7 shape is no longer an input.
180 o.Claim.Op.Intent["source_box"] = o.Claim.Op.Intent["source"]
181 o.Claim.Op.Intent["source_region"] = "ie"
182 delete(o.Claim.Op.Intent, "source")
183 }
184 raw, _ := json.Marshal(o.Claim)
185 if _, err := ConvertMovingToRelocating(context.Background(), s, o, id, ref, a, guard); err == nil {
186 t.Fatal("accepted")
187 }
188 unchanged, _ := json.Marshal(o.Claim)
189 if s.puts != 0 || string(raw) != string(unchanged) {
190 t.Fatal("refusal mutated claim")
191 }
192 })
193 }
194}
195
196func TestC7VerifyFenceReReadsExactObject(t *testing.T) {
197 ctx := context.Background()
198 s, _, ref, a := c7Fixture(t)
199 if _, err := VerifyFence(ctx, s, a, ref); err != nil {
200 t.Fatal(err)
201 }
202 for _, tc := range []string{"bad-key", "bad-enrollment", "bad-digest", "missing", "changed-bytes", "authority"} {
203 t.Run(tc, func(t *testing.T) {
204 s, _, ref, a := c7Fixture(t)
205 switch tc {
206 case "bad-key":
207 ref.Key = "control/box-fences/other.json"
208 case "bad-enrollment":
209 ref.Enrollment = "../escape"
210 case "bad-digest":
211 ref.SHA256 = strings.Repeat("f", 64)
212 case "missing":
213 if err := s.Delete(ctx, ref.Key); err != nil {
214 t.Fatal(err)
215 }
216 case "changed-bytes":
217 raw, etag, err := s.Get(ctx, ref.Key)
218 if err != nil {
219 t.Fatal(err)
220 }
221 if _, err = s.Put(ctx, ref.Key, append(raw, ' '), etag); err != nil {
222 t.Fatal(err)
223 }
224 case "authority":
225 a.err = ErrFence
226 }
227 if _, err := VerifyFence(ctx, s, a, ref); err == nil {
228 t.Fatal("accepted")
229 }
230 })
231 }
232}
233
234func TestC7RelocatingPhaseValidation(t *testing.T) {
235 for _, phase := range []string{"starting", "releasing", "pinning", "installing", "installed", "probing", "probe-failed", "serving", "unknown"} {
236 t.Run(phase, func(t *testing.T) {
237 _, o, _, _ := c7Fixture(t)
238 c := o.Claim
239 c.Op.Kind, c.Op.Phase = KindRelocating, phase
240 // A relocating op's own intent (region HA's shape), not a move's.
241 c.Op.Intent = map[string]string{"region": "ie", "source_box": "adi-knitting", "target_box": "bri-knitting"}
242 switch phase {
243 case "starting", "releasing":
244 c.Op.Exec = "adi-knitting"
245 case "pinning":
246 c.Op.Exec = "none"
247 default:
248 c.Op.Exec = "bri-knitting"
249 }
250 if err := c.Validate(); (err != nil) != (phase == "unknown") {
251 t.Fatalf("phase %s: %v", phase, err)
252 }
253 if phase == "pinning" {
254 c.Op.Exec = "bri-knitting"
255 if c.Validate() == nil {
256 t.Fatal("pinning granted execution")
257 }
258 }
259 })
260 }
261}
262
263func TestC7JournalFenceCannotNameAnotherBox(t *testing.T) {
264 s, o, ref, a := c7Fixture(t)
265 raw, err := json.Marshal(ref)
266 if err != nil {
267 t.Fatal(err)
268 }
269 o.Claim.Op.Journal["fence.adi-knitting"] = string(raw)
270 if err := VerifyOperationFence(context.Background(), s, a, o.Claim.Op, "adi-knitting"); err != nil {
271 t.Fatal(err)
272 }
273 o.Claim.Op.Journal["fence.other"] = string(raw)
274 if err := VerifyOperationFence(context.Background(), s, a, o.Claim.Op, "other"); err == nil {
275 t.Fatal("journal key rebound to another box")
276 }
277}
278
279// The C7 contract with moves under Task 7: a claim BeginAdmittedMove produced
280// converts; the same move begun without its admission does not.
281func TestC7ConvertsOnlyAnAdmittedBeginMove(t *testing.T) {
282 for _, admitted := range []bool{true, false} {
283 t.Run(map[bool]string{true: "admitted", false: "unadmitted"}[admitted], func(t *testing.T) {
284 s, o, ref, a := c7Fixture(t)
285 ctx := context.Background()
286 var err error
287 o, err = Update(ctx, s, o, func(c *Claim) error {
288 c.Op = Op{Kind: KindServing, Exec: "adi-knitting"}
289 c.Obligations = nil
290 if admitted {
291 return BeginAdmittedMove("move-1", "member", "adi-knitting", "bri-knitting", "uk", time.Now(), c7Admission)(c)
292 }
293 return BeginMove("move-1", "member", "adi-knitting", "bri-knitting", "uk", time.Now())(c)
294 })
295 if err != nil {
296 t.Fatal(err)
297 }
298 s.puts = 0
299 after, err := ConvertMovingToRelocating(ctx, s, o, "move-1", ref, a, &c7Guard{})
300 if !admitted {
301 if !errors.Is(err, ErrWrongState) || s.puts != 0 {
302 t.Fatalf("unadmitted move converted: %v puts=%d", err, s.puts)
303 }
304 return
305 }
306 if err != nil {
307 t.Fatal(err)
308 }
309 c := after.Claim
310 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 {
311 t.Fatalf("not converted: %+v", c.Op)
312 }
313 })
314 }
315}
316
317func TestBeginAdmittedMoveIsOneClosure(t *testing.T) {
318 now := time.Now()
319 serving := func() *Claim {
320 return &Claim{V: 2, Host: testHost, Gen: 3, Holder: &Holder{Family: "instance"}, Op: Op{Kind: KindServing, Exec: "adi-knitting"}}
321 }
322 c := serving()
323 if err := BeginAdmittedMove("move-1", "ops", "adi-knitting", "bri-knitting", "uk", now, c7Admission)(c); err != nil {
324 t.Fatal(err)
325 }
326 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}
327 if !reflect.DeepEqual(c.Op.Intent, want) || c.Op.Kind != KindMoving || c.Op.Exec != "adi-knitting" {
328 t.Fatalf("intent: %+v", c.Op)
329 }
330 for name, bad := range map[string]MoveAdmission{
331 "no-app": {AdmissionID: "move-1", SourceEnrollment: c7Admission.SourceEnrollment, TargetEnrollment: c7Admission.TargetEnrollment},
332 "no-registration": {AppID: "a", SourceEnrollment: c7Admission.SourceEnrollment, TargetEnrollment: c7Admission.TargetEnrollment},
333 "other-op": {AppID: "a", AdmissionID: "registration", SourceEnrollment: c7Admission.SourceEnrollment, TargetEnrollment: c7Admission.TargetEnrollment},
334 "bad-source": {AppID: "a", AdmissionID: "move-1", SourceEnrollment: "short", TargetEnrollment: c7Admission.TargetEnrollment},
335 "upper-target": {AppID: "a", AdmissionID: "move-1", SourceEnrollment: c7Admission.SourceEnrollment, TargetEnrollment: strings.Repeat("C", 32)},
336 "same-enrollment": {AppID: "a", AdmissionID: "move-1", SourceEnrollment: c7Admission.SourceEnrollment, TargetEnrollment: c7Admission.SourceEnrollment},
337 } {
338 c := serving()
339 before, _ := json.Marshal(c)
340 if err := BeginAdmittedMove("move-1", "ops", "adi-knitting", "bri-knitting", "uk", now, bad)(c); !errors.Is(err, ErrMalformed) {
341 t.Fatalf("%s: %v", name, err)
342 }
343 if after, _ := json.Marshal(c); string(after) != string(before) {
344 t.Fatalf("%s: refusal changed the claim", name)
345 }
346 }
347}
348
349// J3 (Paul's ruling J3/F5 on amadan #236; the model's
350// fence-converts-committed-landing): a landing whose record committed and
351// whose target is then terminated becomes a failover in the target's
352// region.
353
354const (
355 j3Source = "ie-box"
356 j3Target = "uk-box"
357 j3Spare = "uk-box2"
358 j3SourceEnr = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
359 j3TargetEnr = "cccccccccccccccccccccccccccccccc"
360 j3SpareEnr = "dddddddddddddddddddddddddddddddd"
361)
362
363// j3Authority binds every fence to the target's region, uk.
364type j3Authority struct{ region string }
365
366func (a j3Authority) ValidateFence(_ context.Context, f termfence.Record) (FenceBinding, error) {
367 if f.Box != j3Target || f.Enrollment != j3TargetEnr {
368 return FenceBinding{}, ErrFence
369 }
370 region := a.region
371 if region == "" {
372 region = "uk"
373 }
374 return FenceBinding{Region: region, RosterRevision: 11, RosterETag: "roster-uk"}, nil
375}
376
377// j3Guard is the record: committed (moves §7.5) unless refuse says
378// otherwise, read on the calls counted.
379type j3Guard struct {
380 calls int
381 rejectAt int
382 to string
383}
384
385func (g *j3Guard) CheckLandingCommitted(_ context.Context, o Observed, id string) (CommittedLanding, error) {
386 g.calls++
387 if id != o.Claim.Op.ID {
388 return CommittedLanding{}, ErrWrongState
389 }
390 if g.rejectAt > 0 && g.calls >= g.rejectAt {
391 return CommittedLanding{}, ErrWrongState // the record is landing or rolling back
392 }
393 to := g.to
394 if to == "" {
395 to = "uk"
396 }
397 src, stg := commitScopes(o.Claim)
398 return CommittedLanding{FromRegion: "ie", ToRegion: to, SourceScope: src, StagingScope: stg}, nil
399}
400
401// j3Landing is a stored admitted landing at phase (probing, probed, or
402// committed: the claim followed the record's commit), its source box
403// holding paths of the stay the move leaves so the commit owes its
404// park-local, plus an unrelated chore of another operation.
405func j3Landing(t *testing.T, phase string) (*c7Store, Observed) {
406 t.Helper()
407 ctx := context.Background()
408 c := probedLanding(t)
409 c.Op.Intent["app_id"], c.Op.Intent["admission_id"] = "app-canonical", moveA
410 c.Op.Intent["source_enrollment"], c.Op.Intent["target_enrollment"] = j3SourceEnr, j3TargetEnr
411 // The probe's cold admission made its generation holder.generation and
412 // the target the writer (claim rev 6.9).
413 c.Holder.WriterBox, c.Holder.Generation = j3Target, moveA
414 c.Inventory = map[string]InventoryEntry{j3Source: {Layout: "flat", Paths: map[string]uint64{"/data/a": 3}}}
415 c.Obligations = []Obligation{{ID: "older", Op: "op_old", Kind: "park-generation", Region: "uk", Generation: "gen-1", Retirement: "r-old", Owner: "region:uk"}}
416 switch phase {
417 case PhaseLandingProbing:
418 c.Op.Phase = PhaseLandingProbing
419 case PhaseLandingCommitted:
420 src, stg := commitScopes(c)
421 if err := KeepWriterSession(moveA, Commit(moveA, "ie", j3Source, "uk", src, stg, counter()))(c); err != nil {
422 t.Fatal(err)
423 }
424 }
425 s := &c7Store{Store: release.NewDirStore(t.TempDir())}
426 o, err := Update(ctx, s, Observed{Host: c.Host, Absent: true}, func(n *Claim) error {
427 *n = *c
428 n.Gen = 0
429 return nil
430 })
431 if err != nil {
432 t.Fatal(err)
433 }
434 s.puts = 0
435 return s, o
436}
437
438func j3Fence(t *testing.T, s release.Store) FenceRef {
439 t.Helper()
440 f := termfence.Record{V: 1, Box: j3Target, Enrollment: j3TargetEnr, Cloud: boxid.Cloud{Provider: "aws", Account: "123", Region: "eu-west-2", InstanceID: "i-target"},
441 State: "terminated", ObservedAt: "2026-10-10T08:00:00Z", Via: "describe", RequestID: "request", WrittenBy: termfence.WrittenBy{Box: "uk-worker", Enrollment: strings.Repeat("b", 32)}}
442 raw, err := json.Marshal(f)
443 if err != nil {
444 t.Fatal(err)
445 }
446 sum := sha256.Sum256(raw)
447 ref := FenceRef{Enrollment: f.Enrollment, Key: "control/box-fences/" + f.Enrollment + ".json", SHA256: hex.EncodeToString(sum[:])}
448 if _, err := s.Put(context.Background(), ref.Key, raw, ""); err != nil {
449 t.Fatal(err)
450 }
451 return ref
452}
453
454func TestJ3CommittedLandingBecomesAFailoverInTheTargetsRegion(t *testing.T) {
455 ctx := context.Background()
456 for _, phase := range []string{PhaseLandingCommitted, PhaseLandingProbed, PhaseLandingProbing} {
457 t.Run("claim at "+phase, func(t *testing.T) {
458 s, before := j3Landing(t, phase)
459 ref := j3Fence(t, s)
460 s.puts = 0
461 // The obligations the claim owes once it follows the commit, as
462 // moves' own commit writes them with the same ids.
463 want := cloneForTest(t, before.Claim)
464 if want.Op.Phase == PhaseLandingProbing {
465 want.Op.Phase = PhaseLandingProbed
466 }
467 if want.Op.Phase == PhaseLandingProbed {
468 src, stg := commitScopes(want)
469 if err := KeepWriterSession(moveA, Commit(moveA, "ie", j3Source, "uk", src, stg, counter()))(want); err != nil {
470 t.Fatal(err)
471 }
472 }
473 guard := &j3Guard{}
474 after, err := ConvertCommittedLanding(ctx, s, before, moveA, j3Target, ref, j3Authority{}, guard, nil, counter())
475 if err != nil {
476 t.Fatal(err)
477 }
478 c := after.Claim
479 if s.puts != 1 || c.Gen != before.Claim.Gen+1 || guard.calls != 2 {
480 t.Fatalf("writes=%d gen=%d record reads=%d", s.puts, c.Gen, guard.calls)
481 }
482 // Every cleanup obligation is kept byte for byte: the source's
483 // location park, its park-local, the staging lane's park, and
484 // the unrelated chore.
485 if string(mustJSONAny(t, c.Obligations)) != string(mustJSONAny(t, want.Obligations)) {
486 t.Fatalf("obligations:\n got %s\nwant %s", mustJSONAny(t, c.Obligations), mustJSONAny(t, want.Obligations))
487 }
488 if k := obligationKinds(c); k["park-replica/ie"] != 1 || k["park-replica/uk"] != 1 || k["park-local/"+j3Source] != 1 || k["park-generation/uk"] != 1 {
489 t.Fatalf("the commit's chores: %v", k)
490 }
491 if !reflect.DeepEqual(c.Holder, want.Holder) || !reflect.DeepEqual(c.Touched, before.Claim.Touched) || !reflect.DeepEqual(c.Inventory, before.Claim.Inventory) {
492 t.Fatal("holder, touched or inventory changed")
493 }
494 // A failover in uk with no executor and no target, its source the
495 // dead target, the op id kept.
496 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) {
497 t.Fatalf("op: %+v", c.Op)
498 }
499 wantIntent := map[string]string{"region": "uk", "source_box": j3Target, "source_enrollment": j3TargetEnr,
500 "move_source_box": j3Source, "move_source_enrollment": j3SourceEnr, "move_to_region": "uk",
501 "app_id": "app-canonical", "admission_id": moveA,
502 "stamp_from": strconv.FormatUint(before.Claim.Holder.Stamp, 10), "generation_from": before.Claim.Holder.Generation}
503 if !reflect.DeepEqual(c.Op.Intent, wantIntent) {
504 t.Fatalf("intent %v", c.Op.Intent)
505 }
506 // The move's journal is kept whole.
507 for k, v := range want.Op.Journal {
508 if c.Op.Journal[k] != v {
509 t.Fatalf("journal %s: %q, was %q", k, c.Op.Journal[k], v)
510 }
511 }
512 if c.Op.Journal["conversion"] != "committed-landing" || c.Op.Journal["conversion_landing_phase"] != phase || c.Op.Journal["move_from_region"] != "ie" ||
513 c.Op.Journal["conversion_claim_etag"] != before.ETag || c.Op.Journal["roster_revision"] != "11" || c.Op.Journal["roster_etag"] != "roster-uk" {
514 t.Fatalf("conversion record: %v", c.Op.Journal)
515 }
516 // The fence is consumed as FenceSource consumes it: lost, the
517 // reference and its observation time, the loss retained.
518 var saved FenceRef
519 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" {
520 t.Fatalf("fence journal: %+v %v", saved, err)
521 }
522 if !slices.Contains(c.Lost, j3Target) || len(c.EvidenceRefs) != 1 || c.EvidenceRefs[0].Box != j3Target || c.EvidenceRefs[0].Role != "source" ||
523 c.EvidenceRefs[0].Kind != KindRelocating || c.EvidenceRefs[0].Fence != ref || !slices.Contains(c.LossRefs, c.EvidenceRefs[0].ID) {
524 t.Fatalf("lost %v evidence %+v loss_refs %v", c.Lost, c.EvidenceRefs, c.LossRefs)
525 }
526 if owner, err := RelocationRecoveryOwner(c); err != nil || owner != "region:uk" {
527 t.Fatalf("owner=%q error=%v", owner, err)
528 }
529 // A stale replay conflicts; a replay of the fresh claim finds no
530 // landing (the sibling conversions' behaviour).
531 if _, err := ConvertCommittedLanding(ctx, s, before, moveA, j3Target, ref, j3Authority{}, &j3Guard{}, nil, counter()); !errors.Is(err, ErrConflict) {
532 t.Fatalf("stale replay: %v", err)
533 }
534 if _, err := ConvertCommittedLanding(ctx, s, after, moveA, j3Target, ref, j3Authority{}, &j3Guard{}, nil, counter()); !errors.Is(err, ErrWrongState) {
535 t.Fatalf("replay over the failover: %v", err)
536 }
537 // It proceeds as an initial failover: the target is named in
538 // starting and acknowledges before any pin.
539 if _, err := Update(ctx, s, after, PinFailover(moveA)); !errors.Is(err, ErrRelocation) {
540 t.Fatalf("pinned before a target was named: %v", err)
541 }
542 o := step(t, s, after, NameTarget(moveA, Target{j3Spare, j3SpareEnr}))
543 if _, err := Update(ctx, s, o, PinFailover(moveA)); !errors.Is(err, ErrRelocation) {
544 t.Fatalf("pinned before the target acknowledged: %v", err)
545 }
546 o = step(t, s, o, AcknowledgeExecution(j3Spare, ExecutionAcknowledgement{Op: moveA, Enrollment: j3SpareEnr, BootID: "boot", ControllerID: "ctl", LeaseETag: "lease", Expires: time.Now().Add(time.Hour)}))
547 o = step(t, s, o, PinFailover(moveA))
548 if o.Claim.Op.Phase != PhasePinning || o.Claim.Op.Exec != "none" || !reflect.DeepEqual(o.Claim.Op.Participants, []string{j3Spare}) {
549 t.Fatalf("after PinFailover: %+v", o.Claim.Op)
550 }
551 if string(mustJSONAny(t, o.Claim.Obligations)) != string(mustJSONAny(t, want.Obligations)) {
552 t.Fatal("the failover's own steps changed an obligation")
553 }
554 })
555 }
556}
557
558func TestJ3ConversionRefusesWithoutWriting(t *testing.T) {
559 for _, tc := range []struct{ name string }{
560 {"record-not-committed"}, {"record-not-committed-claim-committed"}, {"record-rolled-back-during-verify"}, {"record-elsewhere"}, {"claim-before-probing"}, {"not-landing"},
561 {"wrong-op"}, {"wrong-box"}, {"source-box"}, {"fence-region"}, {"stale-fence"}, {"fence-gone"}, {"target-lost"}, {"journal-rebind"},
562 {"exec-elsewhere"}, {"no-guard"}, {"no-ids"}, {"missing-admission"}, {"missing-enrollment"}, {"authority"},
563 } {
564 t.Run(tc.name, func(t *testing.T) {
565 ctx := context.Background()
566 phase := PhaseLandingProbed
567 if tc.name == "record-not-committed-claim-committed" {
568 // The claim says committed; the record does not. The record
569 // is the evidence, so the conversion refuses.
570 phase = PhaseLandingCommitted
571 }
572 s, o := j3Landing(t, phase)
573 ref := j3Fence(t, s)
574 s.puts = 0
575 id, box := moveA, j3Target
576 var guard CommittedLandingGuard = &j3Guard{}
577 var authority FenceAuthority = j3Authority{}
578 newID := counter()
579 switch tc.name {
580 case "record-not-committed", "record-not-committed-claim-committed":
581 guard = &j3Guard{rejectAt: 1} // moves' declared-loss rollback owns the fence
582 case "record-rolled-back-during-verify":
583 guard = &j3Guard{rejectAt: 2}
584 case "record-elsewhere":
585 guard = &j3Guard{to: "us"}
586 case "claim-before-probing":
587 o.Claim.Op.Phase = "restored"
588 case "not-landing":
589 o.Claim.Op.Kind = KindRestoring
590 case "wrong-op":
591 id = moveB
592 case "wrong-box":
593 box = j3Spare
594 case "source-box":
595 box = j3Source
596 case "fence-region":
597 authority = j3Authority{region: "ie"}
598 case "stale-fence":
599 ref.SHA256 = strings.Repeat("0", 64)
600 case "fence-gone":
601 ref.Key = "control/box-fences/" + strings.Repeat("e", 32) + ".json"
602 case "target-lost":
603 o.Claim.Lost = []string{j3Target}
604 case "journal-rebind":
605 o.Claim.Op.Journal["fence."+j3Target] = `{"enrollment":"other"}`
606 case "exec-elsewhere":
607 o.Claim.Op.Exec = j3Source
608 case "no-guard":
609 guard = nil
610 case "no-ids":
611 newID = nil
612 case "missing-admission":
613 delete(o.Claim.Op.Intent, "admission_id")
614 case "missing-enrollment":
615 delete(o.Claim.Op.Intent, "target_enrollment")
616 case "authority":
617 authority = c7Authority{err: ErrFence}
618 }
619 raw := mustJSON(t, o.Claim)
620 _, err := ConvertCommittedLanding(ctx, s, o, id, box, ref, authority, guard, nil, newID)
621 if err == nil {
622 t.Fatal("accepted")
623 }
624 t.Logf("refused: %v", err)
625 if strings.HasPrefix(tc.name, "record-") && tc.name != "record-elsewhere" && !errors.Is(err, ErrWrongState) {
626 t.Fatalf("an uncommitted record: %v", err)
627 }
628 if s.puts != 0 || string(raw) != string(mustJSON(t, o.Claim)) {
629 t.Fatal("refusal wrote or mutated the claim")
630 }
631 if stored, rerr := Read(ctx, s, o.Host); rerr != nil || stored.ETag != o.ETag {
632 t.Fatalf("stored claim moved: %v", rerr)
633 }
634 })
635 }
636}
637
638func mustJSONAny(t *testing.T, v any) []byte {
639 t.Helper()
640 b, err := json.Marshal(v)
641 if err != nil {
642 t.Fatal(err)
643 }
644 return b
645}
646