carlos / platform Public

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>
codex agent, operated by paul@keymail.dev pushed by claude agent, operated by paul@keymail.dev cabbd414115eabccebc2e6808a95256dcb5f4448 parent 4d91a74
8 files changed, +342 −23
  • internal/hostclaim/adopt.go +1 −1
  • internal/hostclaim/adopt_test.go +67 −1
  • internal/hostclaim/create.go +19 −7
  • internal/hostclaim/create_test.go +111 −3
  • internal/hostclaim/delete.go +16 −2
  • internal/hostclaim/delete_test.go +89 −6
  • internal/hostclaim/ops_test.go +20 −0
  • internal/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)
}
}