| 1 | package hostclaim |
| 2 | |
| 3 | import ( |
| 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 | |
| 21 | type c7Authority struct { |
| 22 | want termfence.Record |
| 23 | err error |
| 24 | } |
| 25 | |
| 26 | func (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 | |
| 36 | type c7Guard struct { |
| 37 | calls int |
| 38 | rejectAt int |
| 39 | region string |
| 40 | } |
| 41 | |
| 42 | func (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). |
| 57 | var c7Admission = MoveAdmission{AppID: "app-canonical", AdmissionID: "move-1", SourceEnrollment: strings.Repeat("a", 32), TargetEnrollment: strings.Repeat("c", 32)} |
| 58 | |
| 59 | type c7Store struct { |
| 60 | release.Store |
| 61 | puts int |
| 62 | } |
| 63 | |
| 64 | func (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 | } |
| 68 | func 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 | |
| 100 | func 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 | |
| 135 | func 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 | |
| 196 | func 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 | |
| 234 | func 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 | |
| 263 | func 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. |
| 281 | func 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 | |
| 317 | func 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 | |
| 354 | const ( |
| 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. |
| 364 | type j3Authority struct{ region string } |
| 365 | |
| 366 | func (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. |
| 379 | type j3Guard struct { |
| 380 | calls int |
| 381 | rejectAt int |
| 382 | to string |
| 383 | } |
| 384 | |
| 385 | func (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. |
| 405 | func 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 | |
| 438 | func 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 | |
| 454 | func 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 | |
| 558 | func 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 | |
| 638 | func 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 | |