hostclaim: final review fixes (union write set, effective vs record region, CompleteCreate keeps authority, tests)
Co-Authored-By: Codex (gpt-6-astra) <noreply@openai.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
8 files changed,
+342
−23
internal/hostclaim/adopt.go+1 −1internal/hostclaim/adopt_test.go+67 −1internal/hostclaim/create.go+19 −7internal/hostclaim/create_test.go+111 −3internal/hostclaim/delete.go+16 −2internal/hostclaim/delete_test.go+89 −6internal/hostclaim/ops_test.go+20 −0internal/hostclaim/store_test.go+19 −3
CI passed — run details
diff --git a/internal/hostclaim/adopt.go b/internal/hostclaim/adopt.go| index eb304b5ef..29c5cb8ad 100644 |
| --- a/internal/hostclaim/adopt.go |
| +++ b/internal/hostclaim/adopt.go |
| @@ -9,7 +9,7 @@ func Adopt(nh NewHolder, servingBox string, otherRegions, otherBoxes []string, o |
| if c.Gen != 0 || c.Holder != nil { |
| return fmt.Errorf("%w: adoption is only for a host that never had a claim", ErrWrongState) |
| } |
| - if nh.Host != c.Host || !keyMatches(nh) || servingBox == "" || opID == "" { |
| + if nh.Host != c.Host || !keyMatches(nh) || nh.Region == "" || servingBox == "" || opID == "" { |
| return fmt.Errorf("%w: adoption inputs are not this host's", ErrWrongState) |
| } |
| inc, err := Incarnation(nh.Account, nh.App, nh.Host, nh.CreatedAt) |
diff --git a/internal/hostclaim/adopt_test.go b/internal/hostclaim/adopt_test.go| index 30b096015..1fb81cfda 100644 |
| --- a/internal/hostclaim/adopt_test.go |
| +++ b/internal/hostclaim/adopt_test.go |
| @@ -3,6 +3,7 @@ package hostclaim |
| import ( |
| "context" |
| "errors" |
| + "reflect" |
| "testing" |
| "time" |
| @@ -13,12 +14,18 @@ func TestAdoptAndDiscoveryCompletion(t *testing.T) { |
| ctx := context.Background() |
| s := release.NewDirStore(t.TempDir()) |
| nh := newHolder() |
| - obs, _ := Read(ctx, s, testHost) |
| + obs, err := Read(ctx, s, testHost) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| o, err := Update(ctx, s, obs, Adopt(nh, "adi-knitting", []string{"uk"}, []string{"b2"}, "op_adopt", counter())) |
| if err != nil { |
| t.Fatal(err) |
| } |
| c := o.Claim |
| + if !reflect.DeepEqual(c.Touched.Regions, []string{"ie"}) || !reflect.DeepEqual(c.Touched.Boxes, []string{"adi-knitting"}) { |
| + t.Fatalf("adopted touched locations = %+v", c.Touched) |
| + } |
| if c.Holder.Location != "instances/"+testHost || c.Touched.Complete || c.Touched.DiscoveryOp != "op_adopt" || |
| c.Holder.Stamp != c.Gen || c.Op.Exec != "adi-knitting" || len(c.Obligations) != 2 { |
| t.Fatalf("adopt = %+v", c) |
| @@ -38,4 +45,63 @@ func TestAdoptAndDiscoveryCompletion(t *testing.T) { |
| if _, err := Update(ctx, s, o, Adopt(nh, "adi-knitting", nil, nil, "op_adopt2", counter())); !errors.Is(err, ErrWrongState) { |
| t.Fatalf("re-adopt = %v", err) |
| } |
| + o, err = Update(ctx, s, o, BeginDelete("op_d", "console", `"e"`, time.Now(), []string{"ie", "uk"}, []string{"adi-knitting", "b2"})) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + o, err = Update(ctx, s, o, ToParking("op_d", []string{"ie", "uk"}, []string{"adi-knitting", "b2"}, counter())) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + assertParkingTargets(t, o.Claim.Obligations, []Obligation{ |
| + {Op: "op_d", Kind: "park-replica", Region: "ie", Owner: "region:ie"}, |
| + {Op: "op_d", Kind: "park-local", Box: "adi-knitting", Owner: "box:adi-knitting"}, |
| + }) |
| +} |
| + |
| +func TestAdoptRefusesEmptyEffectiveRegion(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + nh := newHolder() |
| + nh.Region = "" |
| + obs, err := Read(ctx, s, testHost) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := Update(ctx, s, obs, Adopt(nh, "B0", nil, nil, "op_adopt", counter())); !errors.Is(err, ErrWrongState) { |
| + t.Errorf("empty effective region = %v, want ErrWrongState", err) |
| + } |
| + after, err := Read(ctx, s, testHost) |
| + if err != nil || !after.Absent { |
| + t.Fatalf("read after refused adopt = %+v, %v; want absent", after, err) |
| + } |
| +} |
| + |
| +func TestAdoptWithoutDiscoveryTargets(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + obs, err := Read(ctx, s, testHost) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + o, err := Update(ctx, s, obs, Adopt(newHolder(), "B0", nil, nil, "op_adopt", counter())) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + want := Touched{Complete: true, Regions: []string{"ie"}, Boxes: []string{"B0"}} |
| + if !reflect.DeepEqual(o.Claim.Touched, want) || len(o.Claim.Obligations) != 0 { |
| + t.Fatalf("adopt without discovery targets = %+v", o.Claim) |
| + } |
| + o, err = Update(ctx, s, o, BeginDelete("op_d", "console", `"e"`, time.Now(), nil, nil)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + o, err = Update(ctx, s, o, ToParking("op_d", nil, nil, counter())) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + assertParkingTargets(t, o.Claim.Obligations, []Obligation{ |
| + {Op: "op_d", Kind: "park-replica", Region: "ie", Owner: "region:ie"}, |
| + {Op: "op_d", Kind: "park-local", Box: "B0", Owner: "box:B0"}, |
| + }) |
| } |
diff --git a/internal/hostclaim/create.go b/internal/hostclaim/create.go| index 242e3d09c..d42b76eca 100644 |
| --- a/internal/hostclaim/create.go |
| +++ b/internal/hostclaim/create.go |
| @@ -10,8 +10,10 @@ import ( |
| var ErrRetiredIncarnation = errors.New("hostclaim: incarnation was retired on this host") |
| type NewHolder struct { |
| - Family, Key, Account, App, Name, Host, Region string |
| - CreatedAt time.Time |
| + Family, Key, Account, App, Name, Host string |
| + RecordRegion string // Region encoded in the record; empty means home region. |
| + Region string // Resolved storage region; must be non-empty. |
| + CreatedAt time.Time |
| } |
| func keyMatches(nh NewHolder) bool { |
| @@ -21,7 +23,7 @@ func keyMatches(nh NewHolder) bool { |
| // recordMatches checks the record body that create persists as its |
| // reconstruction intent is this holder's record: valid JSON naming the |
| // same host, account, app, sidecar name (empty for instances) and region, |
| -// and the same CreatedAt. The region seeds touched.regions, so it must agree. |
| +// and the same CreatedAt. |
| func recordMatches(nh NewHolder, body []byte) bool { |
| var r struct { |
| Name string `json:"name"` // sidecar records only |
| @@ -35,7 +37,7 @@ func recordMatches(nh NewHolder, body []byte) bool { |
| return false |
| } |
| return r.Host == nh.Host && r.Account == nh.Account && r.App == nh.App && r.Name == nh.Name && |
| - r.Region == nh.Region && r.CreatedAt.Equal(nh.CreatedAt) |
| + r.Region == nh.RecordRegion && r.CreatedAt.Equal(nh.CreatedAt) |
| } |
| func retire(c *Claim, inc string) { |
| @@ -47,7 +49,7 @@ func retire(c *Claim, inc string) { |
| func BeginCreate(nh NewHolder, recordBody []byte, opID, by string, now time.Time) func(*Claim) error { |
| return func(c *Claim) error { |
| - if nh.Host != c.Host || !keyMatches(nh) { |
| + if nh.Host != c.Host || !keyMatches(nh) || nh.Region == "" { |
| return fmt.Errorf("%w: create inputs are not this host's", ErrWrongState) |
| } |
| if !c.Free() { |
| @@ -85,6 +87,9 @@ func CompleteCreate(opID, execBox string) func(*Claim) error { |
| if c.Op.Kind != KindCreating || execBox == "" { |
| return fmt.Errorf("%w: kind %q", ErrWrongState, c.Op.Kind) |
| } |
| + if err := requireNoCreateAuthority(c); err != nil { |
| + return err |
| + } |
| c.Op = Op{Kind: KindServing, Exec: execBox, Participants: []string{execBox}} |
| c.Holder.Stamp = c.Gen + 1 // the resulting gen: Update sets gen = observed+1 |
| if !set(c.Touched.Boxes)[execBox] { // the executor will hold local state (§2 touched) |
| @@ -102,8 +107,8 @@ func CancelCreate(opID string, now time.Time) func(*Claim) error { |
| if c.Op.Kind != KindCreating { |
| return fmt.Errorf("%w: kind %q", ErrWrongState, c.Op.Kind) |
| } |
| - if c.Op.Exec != "" || len(c.Op.Participants) > 0 || c.Holder.Stamp != 0 { |
| - return fmt.Errorf("%w: create carries execution authority", ErrWrongState) |
| + if err := requireNoCreateAuthority(c); err != nil { |
| + return err |
| } |
| inc, key := c.Holder.Incarnation, c.Holder.Key |
| retire(c, inc) |
| @@ -112,3 +117,10 @@ func CancelCreate(opID string, now time.Time) func(*Claim) error { |
| return nil |
| } |
| } |
| + |
| +func requireNoCreateAuthority(c *Claim) error { |
| + if c.Op.Exec != "" || len(c.Op.Participants) > 0 || c.Holder.Stamp != 0 { |
| + return fmt.Errorf("%w: create carries execution authority", ErrWrongState) |
| + } |
| + return nil |
| +} |
diff --git a/internal/hostclaim/create_test.go b/internal/hostclaim/create_test.go| index a84822520..53968923e 100644 |
| --- a/internal/hostclaim/create_test.go |
| +++ b/internal/hostclaim/create_test.go |
| @@ -12,7 +12,7 @@ import ( |
| func newHolder() NewHolder { |
| return NewHolder{Family: "instance", Key: "apps/bab/jam/instances/" + testHost + ".json", |
| - Account: "bab", App: "jam", Host: testHost, Region: "ie", |
| + Account: "bab", App: "jam", Host: testHost, Region: "ie", RecordRegion: "ie", |
| CreatedAt: time.Date(2026, 10, 4, 1, 0, 0, 0, time.UTC)} |
| } |
| @@ -73,7 +73,7 @@ func TestBeginCreateRefusesAnotherHostOrKey(t *testing.T) { |
| } |
| // A sidecar key cannot claim an instance-shaped host (P1, plan review round 2). |
| sc := NewHolder{Family: "sidecar", Key: "apps/bab/jam/sidecars/shop.json", Account: "bab", App: "jam", Name: "shop", |
| - Host: testHost, Region: "ie", CreatedAt: newHolder().CreatedAt} |
| + Host: testHost, Region: "ie", RecordRegion: "ie", CreatedAt: newHolder().CreatedAt} |
| if _, err := Update(ctx, s, reg, BeginCreate(sc, record, "op_c1", "console", now)); !errors.Is(err, ErrWrongState) { |
| t.Fatalf("sidecar key on a non-sidecar host = %v", err) |
| } |
| @@ -85,7 +85,7 @@ func TestBeginCreateSidecar(t *testing.T) { |
| now := time.Date(2026, 10, 4, 1, 0, 1, 0, time.UTC) |
| host := "bot.jam.bab.sidecar.internal" |
| sc := NewHolder{Family: "sidecar", Key: "apps/bab/jam/sidecars/bot.json", Account: "bab", App: "jam", Name: "bot", |
| - Host: host, Region: "ie", CreatedAt: time.Date(2026, 10, 4, 1, 0, 0, 0, time.UTC)} |
| + Host: host, Region: "ie", RecordRegion: "ie", CreatedAt: time.Date(2026, 10, 4, 1, 0, 0, 0, time.UTC)} |
| body := []byte(`{"name":"bot","host":"` + host + `","account":"bab","app":"jam","region":"ie","created_at":"2026-10-04T01:00:00Z"}`) |
| reg, _ := Read(ctx, s, host) |
| if _, err := Update(ctx, s, reg, BeginCreate(sc, body, "op_s1", "console", now)); err != nil { |
| @@ -168,3 +168,111 @@ func TestCancelCreateRefusesExecutionAuthority(t *testing.T) { |
| }) |
| } |
| } |
| + |
| +func TestCreateHomeRegionParksEffectiveRegion(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + nh := newHolder() |
| + nh.RecordRegion = "" |
| + body := bytes.Replace(record, []byte(`"region":"ie"`), []byte(`"region":""`), 1) |
| + obs, err := Read(ctx, s, testHost) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + o, err := Update(ctx, s, obs, BeginCreate(nh, body, "op_c", "console", time.Now())) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(o.Claim.Touched.Regions) != 1 || o.Claim.Touched.Regions[0] != "ie" || o.Claim.Op.Intent["record"] != string(body) { |
| + t.Fatalf("home-region create = %+v", o.Claim) |
| + } |
| + o, err = Update(ctx, s, o, CompleteCreate("op_c", "B0")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + o, err = Update(ctx, s, o, BeginDelete("op_d", "console", `"e"`, time.Now(), nil, nil)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + o, err = Update(ctx, s, o, ToParking("op_d", nil, nil, counter())) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + assertParkingTargets(t, o.Claim.Obligations, []Obligation{ |
| + {Op: "op_d", Kind: "park-replica", Region: "ie", Owner: "region:ie"}, |
| + {Op: "op_d", Kind: "park-local", Box: "B0", Owner: "box:B0"}, |
| + }) |
| +} |
| + |
| +func TestBeginCreateRefusesEmptyEffectiveRegion(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + nh := newHolder() |
| + nh.Region, nh.RecordRegion = "", "" |
| + body := bytes.Replace(record, []byte(`"region":"ie"`), []byte(`"region":""`), 1) |
| + obs, err := Read(ctx, s, testHost) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := Update(ctx, s, obs, BeginCreate(nh, body, "op_c", "console", time.Now())); !errors.Is(err, ErrWrongState) { |
| + t.Errorf("empty effective region = %v, want ErrWrongState", err) |
| + } |
| + after, err := Read(ctx, s, testHost) |
| + if err != nil || !after.Absent { |
| + t.Fatalf("read after refused create = %+v, %v; want absent", after, err) |
| + } |
| +} |
| + |
| +func TestCompleteCreateRefusesExecutionAuthority(t *testing.T) { |
| + for _, tc := range []struct { |
| + name string |
| + exec string |
| + participants []string |
| + stamp uint64 |
| + }{ |
| + {name: "executor", exec: "adi-knitting"}, |
| + {name: "participants", participants: []string{"adi-knitting"}}, |
| + {name: "stamp", stamp: 1}, |
| + {name: "all", exec: "adi-knitting", participants: []string{"adi-knitting"}, stamp: 1}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + now := time.Date(2026, 10, 4, 1, 0, 1, 0, time.UTC) |
| + reg, err := Read(ctx, s, testHost) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + o, err := Update(ctx, s, reg, func(c *Claim) error { |
| + if err := BeginCreate(newHolder(), record, "op_c1", "console", now)(c); err != nil { |
| + return err |
| + } |
| + c.Op.Exec = tc.exec |
| + c.Op.Participants = tc.participants |
| + c.Holder.Stamp = tc.stamp |
| + return nil |
| + }) |
| + if err != nil { |
| + t.Fatalf("store creating claim with authority: %v", err) |
| + } |
| + key, err := Key(testHost) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + before, beforeETag, err := s.Get(ctx, key) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := Update(ctx, s, o, CompleteCreate("op_c1", "new-executor")); !errors.Is(err, ErrWrongState) { |
| + t.Errorf("complete with authority = %v, want ErrWrongState", err) |
| + } |
| + after, afterETag, err := s.Get(ctx, key) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if !bytes.Equal(before, after) || beforeETag != afterETag { |
| + t.Errorf("complete changed stored claim: before ETag %q, after ETag %q", beforeETag, afterETag) |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/hostclaim/delete.go b/internal/hostclaim/delete.go| index 342b9f80c..22b1f996b 100644 |
| --- a/internal/hostclaim/delete.go |
| +++ b/internal/hostclaim/delete.go |
| @@ -5,13 +5,27 @@ import ( |
| "time" |
| ) |
| -// WriteSet is where a delete parks: the touched set, or the whole |
| +// WriteSet is where a delete parks: the touched set, plus the whole |
| // deployment while discovery is incomplete (§5.2 step 3, §9). |
| func WriteSet(c *Claim, allRegions, allBoxes []string) (regions, boxes []string) { |
| if c.Touched.Complete { |
| return c.Touched.Regions, c.Touched.Boxes |
| } |
| - return allRegions, allBoxes |
| + return union(c.Touched.Regions, allRegions), union(c.Touched.Boxes, allBoxes) |
| +} |
| + |
| +func union(known, supplied []string) []string { |
| + var result []string |
| + seen := make(map[string]bool) |
| + for _, locations := range [][]string{known, supplied} { |
| + for _, location := range locations { |
| + if !seen[location] { |
| + result = append(result, location) |
| + seen[location] = true |
| + } |
| + } |
| + } |
| + return result |
| } |
| func BeginDelete(opID, by, recordETag string, now time.Time, allRegions, allBoxes []string) func(*Claim) error { |
diff --git a/internal/hostclaim/delete_test.go b/internal/hostclaim/delete_test.go| index 3813ebd48..a20467fe6 100644 |
| --- a/internal/hostclaim/delete_test.go |
| +++ b/internal/hostclaim/delete_test.go |
| @@ -19,7 +19,10 @@ func counter() func() string { |
| func servingClaim(t *testing.T, ctx context.Context, s release.Store) Observed { |
| t.Helper() |
| now := time.Date(2026, 10, 4, 1, 0, 1, 0, time.UTC) |
| - reg, _ := Read(ctx, s, testHost) |
| + reg, err := Read(ctx, s, testHost) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| o, err := Update(ctx, s, reg, BeginCreate(newHolder(), record, "op_c1", "console", now)) |
| if err != nil { |
| t.Fatal(err) |
| @@ -69,13 +72,27 @@ func TestIncompleteTouchedParksEverywhere(t *testing.T) { |
| s := release.NewDirStore(t.TempDir()) |
| now := time.Date(2026, 10, 4, 2, 0, 0, 0, time.UTC) |
| o := servingClaim(t, ctx, s) |
| - o, _ = Update(ctx, s, o, func(c *Claim) error { c.Touched.Complete = false; return nil }) |
| + o, err := Update(ctx, s, o, func(c *Claim) error { c.Touched.Complete = false; return nil }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| regions, boxes := []string{"ie", "uk", "us"}, []string{"b1", "b2"} |
| - o, _ = Update(ctx, s, o, BeginDelete("op_d1", "console", `"e"`, now, regions, boxes)) |
| - o, err := Update(ctx, s, o, ToParking("op_d1", regions, boxes, counter())) |
| - if err != nil || len(o.Claim.Obligations) != 5 { |
| - t.Fatalf("incomplete touched = %d obligations, %v; want 3 regions + 2 boxes", len(o.Claim.Obligations), err) |
| + o, err = Update(ctx, s, o, BeginDelete("op_d1", "console", `"e"`, now, regions, boxes)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + o, err = Update(ctx, s, o, ToParking("op_d1", regions, boxes, counter())) |
| + if err != nil { |
| + t.Fatal(err) |
| } |
| + assertParkingTargets(t, o.Claim.Obligations, []Obligation{ |
| + {Op: "op_d1", Kind: "park-replica", Region: "ie", Owner: "region:ie"}, |
| + {Op: "op_d1", Kind: "park-replica", Region: "uk", Owner: "region:uk"}, |
| + {Op: "op_d1", Kind: "park-replica", Region: "us", Owner: "region:us"}, |
| + {Op: "op_d1", Kind: "park-local", Box: "adi-knitting", Owner: "box:adi-knitting"}, |
| + {Op: "op_d1", Kind: "park-local", Box: "b1", Owner: "box:b1"}, |
| + {Op: "op_d1", Kind: "park-local", Box: "b2", Owner: "box:b2"}, |
| + }) |
| } |
| func TestToParkingRefusesAPurgeFence(t *testing.T) { |
| @@ -118,3 +135,69 @@ func TestAppendParkObligations(t *testing.T) { |
| t.Fatalf("next id = %q, want id_9", got) |
| } |
| } |
| + |
| +func assertParkingTargets(t *testing.T, obligations, want []Obligation) { |
| + t.Helper() |
| + got := append([]Obligation(nil), obligations...) |
| + for i := range got { |
| + got[i].ID, got[i].Retirement = "", "" |
| + } |
| + if !reflect.DeepEqual(got, want) { |
| + t.Fatalf("parking targets = %+v, want %+v", got, want) |
| + } |
| +} |
| + |
| +func TestWriteSetIncompleteUnion(t *testing.T) { |
| + c := &Claim{Touched: Touched{ |
| + Regions: []string{"ie", "ie", "uk"}, Boxes: []string{"B0", "B0", "B2"}, |
| + }} |
| + regions, boxes := WriteSet(c, []string{"us", "uk", "us", "ie"}, []string{"B3", "B2", "B3", "B0"}) |
| + if !reflect.DeepEqual(regions, []string{"ie", "uk", "us"}) || !reflect.DeepEqual(boxes, []string{"B0", "B2", "B3"}) { |
| + t.Fatalf("write set = %v, %v; want [ie uk us], [B0 B2 B3]", regions, boxes) |
| + } |
| +} |
| + |
| +func TestIncompleteAdoptionPreservesParkingTargets(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + obs, err := Read(ctx, s, testHost) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + newID := counter() |
| + o, err := Update(ctx, s, obs, Adopt(newHolder(), "B0", []string{"uk"}, []string{"B2"}, "op_adopt", newID)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + regions := []string{"ie"} |
| + o, err = Update(ctx, s, o, BeginDelete("op_d", "console", `"e"`, time.Now(), regions, nil)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + o, err = Update(ctx, s, o, ToParking("op_d", regions, nil, newID)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + assertParkingTargets(t, o.Claim.Obligations, []Obligation{ |
| + {Op: "op_adopt", Kind: "park-replica", Region: "uk", Owner: "region:uk"}, |
| + {Op: "op_adopt", Kind: "park-local", Box: "B2", Owner: "box:B2"}, |
| + {Op: "op_d", Kind: "park-replica", Region: "ie", Owner: "region:ie"}, |
| + {Op: "op_d", Kind: "park-local", Box: "B0", Owner: "box:B0"}, |
| + }) |
| + for _, ob := range o.Claim.Obligations { |
| + if _, err := Update(ctx, s, o, FreeAfterPark("op_d")); !errors.Is(err, ErrWrongState) { |
| + t.Fatalf("free with %d chores remaining = %v", len(o.Claim.Obligations), err) |
| + } |
| + o, err = Update(ctx, s, o, CompleteObligation(ob.Op, ob.ID)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + o, err = Update(ctx, s, o, FreeAfterPark("op_d")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if !o.Claim.Free() { |
| + t.Fatalf("not free after all chores: %+v", o.Claim) |
| + } |
| +} |
diff --git a/internal/hostclaim/ops_test.go b/internal/hostclaim/ops_test.go| index 91c8396ee..53fbcea17 100644 |
| --- a/internal/hostclaim/ops_test.go |
| +++ b/internal/hostclaim/ops_test.go |
| @@ -31,3 +31,23 @@ func TestStartGuard(t *testing.T) { |
| t.Fatalf("start elsewhere = %v", err) |
| } |
| } |
| + |
| +func TestStartGuardLocalBox(t *testing.T) { |
| + busy := &Claim{Holder: &Holder{Incarnation: "i"}, Op: Op{Kind: KindServing}, |
| + Obligations: []Obligation{{Kind: "park-local", Box: "B0"}}} |
| + for _, tc := range []struct { |
| + name string |
| + boxes []string |
| + want error |
| + }{ |
| + {name: "overlap", boxes: []string{"B0"}, want: ErrPendingChore}, |
| + {name: "elsewhere", boxes: []string{"B2"}}, |
| + {name: "no boxes"}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + if err := StartGuard(busy, nil, tc.boxes); !errors.Is(err, tc.want) { |
| + t.Fatalf("start with boxes %v = %v, want %v", tc.boxes, err, tc.want) |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/hostclaim/store_test.go b/internal/hostclaim/store_test.go| index e9f4dd3a3..988969ec8 100644 |
| --- a/internal/hostclaim/store_test.go |
| +++ b/internal/hostclaim/store_test.go |
| @@ -2,7 +2,9 @@ package hostclaim |
| import ( |
| "context" |
| + "encoding/json" |
| "errors" |
| + "strings" |
| "testing" |
| "github.com/carlosframework/platform/internal/release" |
| @@ -17,20 +19,34 @@ func TestReadRefusesMalformed(t *testing.T) { |
| } |
| key, _ := Key(testHost) |
| good := `{"v":2,"host":"` + testHost + `","gen":1,"op":{},"touched":{"complete":false}}` |
| + unknownChore := servingClaim(t, ctx, s).Claim |
| + unknownChore.Op = Op{Kind: KindParking, ID: "x"} |
| + unknownChore.Obligations = []Obligation{{ID: "o", Op: "x", Kind: "park-replica", Region: "ie", Retirement: "r", Owner: "region:ie"}} |
| + if err := unknownChore.Validate(); err != nil { |
| + t.Fatalf("invalid fixture before changing chore kind: %v", err) |
| + } |
| + unknownChore.Obligations[0].Kind = "teleport" |
| + unknownChoreBody, err := json.Marshal(unknownChore) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| for name, body := range map[string]string{ |
| "unknown field": `{"v":2,"host":"` + testHost + `","gen":1,"op":{},"touched":{"complete":false},"surprise":1}`, |
| "trailing data": good + ` {"v":2}`, |
| "wrong host": `{"v":2,"host":"other.bab.oncarlos.com","gen":1,"op":{},"touched":{"complete":false}}`, |
| "free with chores": `{"v":2,"host":"` + testHost + `","gen":1,"op":{},"touched":{"complete":false},` + |
| `"obligations":[{"id":"o","op":"x","kind":"park-local","box":"b","retirement":"r","owner":"box:b"}]}`, |
| - "unknown chore kind": `{"v":2,"host":"` + testHost + `","gen":1,"op":{"kind":"purging","id":"p"},"touched":{"complete":false},` + |
| - `"obligations":[{"id":"o","op":"x","kind":"teleport","region":"ie","retirement":"r","owner":"region:ie"}]}`, |
| + "unknown chore kind": string(unknownChoreBody), |
| } { |
| s2 := release.NewDirStore(t.TempDir()) |
| if _, err := s2.Put(ctx, key, []byte(body), ""); err != nil { |
| t.Fatal(err) |
| } |
| - if _, err := Read(ctx, s2, testHost); !errors.Is(err, ErrMalformed) { |
| + _, err := Read(ctx, s2, testHost) |
| + if name == "unknown chore kind" && (err == nil || !strings.Contains(err.Error(), `obligation kind "teleport"`)) { |
| + t.Errorf("%s: read = %v, want obligation-kind rejection", name, err) |
| + } |
| + if !errors.Is(err, ErrMalformed) { |
| t.Errorf("%s: read = %v, want ErrMalformed", name, err) |
| } |
| } |