hostclaim: one adoption routine, discovery chores and their completion (CC4, CC8)
Co-Authored-By: Codex (gpt-6-astra) <noreply@openai.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2 files changed,
+75
−0
internal/hostclaim/adopt.go+34 −0internal/hostclaim/adopt_test.go+41 −0
diff --git a/internal/hostclaim/adopt.go b/internal/hostclaim/adopt.go| new file mode 100644 |
| index 000000000..1498170bb |
| --- /dev/null |
| +++ b/internal/hostclaim/adopt.go |
| @@ -0,0 +1,34 @@ |
| +package hostclaim |
| + |
| +import "fmt" |
| + |
| +// Adopt is the one adoption routine (CC8) for a host that has never had a |
| +// claim. It writes discovery chores for every other region and box (CC4). |
| +func Adopt(nh NewHolder, servingBox string, otherRegions, otherBoxes []string, opID string, newID func() string) func(*Claim) error { |
| + return func(c *Claim) error { |
| + 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 == "" { |
| + return fmt.Errorf("%w: adoption inputs are not this host's", ErrWrongState) |
| + } |
| + inc, err := Incarnation(nh.Account, nh.App, nh.Host, nh.CreatedAt) |
| + if err != nil { |
| + return err |
| + } |
| + c.Holder = &Holder{Family: nh.Family, Key: nh.Key, Account: nh.Account, App: nh.App, Name: nh.Name, |
| + Incarnation: inc, CreatedAt: nh.CreatedAt, Location: "instances/" + nh.Host, Stamp: c.Gen + 1} |
| + c.Op = Op{Kind: KindServing, Exec: servingBox, Participants: []string{servingBox}} |
| + c.Touched = Touched{Complete: true, Regions: []string{nh.Region}, Boxes: []string{servingBox}} |
| + for _, r := range otherRegions { |
| + c.Obligations = append(c.Obligations, Obligation{ID: newID(), Op: opID, Kind: "park-replica", Region: r, Retirement: newID(), Owner: "region:" + r}) |
| + } |
| + for _, b := range otherBoxes { |
| + c.Obligations = append(c.Obligations, Obligation{ID: newID(), Op: opID, Kind: "park-local", Box: b, Retirement: newID(), Owner: "box:" + b}) |
| + } |
| + if len(c.Obligations) > 0 { |
| + c.Touched.Complete, c.Touched.DiscoveryOp = false, opID |
| + } |
| + return nil |
| + } |
| +} |
diff --git a/internal/hostclaim/adopt_test.go b/internal/hostclaim/adopt_test.go| new file mode 100644 |
| index 000000000..30b096015 |
| --- /dev/null |
| +++ b/internal/hostclaim/adopt_test.go |
| @@ -0,0 +1,41 @@ |
| +package hostclaim |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +func TestAdoptAndDiscoveryCompletion(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + nh := newHolder() |
| + obs, _ := Read(ctx, s, testHost) |
| + 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 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) |
| + } |
| + // A delete is refused while discovery chores are pending in its write set (CC11). |
| + if _, err := Update(ctx, s, o, BeginDelete("op_d", "console", `"e"`, time.Now(), []string{"ie", "uk"}, []string{"adi-knitting", "b2"})); !errors.Is(err, ErrPendingChore) { |
| + t.Fatalf("delete during discovery = %v", err) |
| + } |
| + for _, ob := range c.Obligations { |
| + if o, err = Update(ctx, s, o, CompleteObligation(ob.Op, ob.ID)); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if !o.Claim.Touched.Complete || o.Claim.Touched.DiscoveryOp != "" { |
| + t.Fatalf("discovery not completed: %+v", o.Claim.Touched) |
| + } |
| + 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) |
| + } |
| +} |