carlos / platform Public

hostops, hostclaim, hostfence, console, host: frozen-backfill adoption, the recovery entry point, fence verification and the pre-flip relocating conversion (claim Plan 5 rev 2 Tasks 5-6, C5/C7)

Inert with CARLOS_HOST_CLAIM unset. Adoption only through the frozen #232
backfill reconcile; the recovery pass never adopts. ConvertMovingToRelocating
holds at relocating/pinning, with no activation.
Co-Authored-By: Codex <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 ad4d749b2fc94e598290b999680c413f4bedbbf3 parent 443bd26
27 files changed, +1776 −38
  • cmd/console/main.go +4 −0
  • internal/console/hostclaimbackfill.go +82 −0
  • internal/console/hostclaimops.go +2 −2
  • internal/domains/claims.go +10 −7
  • internal/host/agent.go +10 −3
  • internal/host/claimbackfill.go +32 −0
  • internal/hostactors/censuskey.go +59 −0
  • internal/hostactors/roster.go +15 −11
  • internal/hostclaim/adopt.go +1 −1
  • internal/hostclaim/claim.go +5 −1
  • internal/hostclaim/create.go +2 −1
  • internal/hostclaim/fence.go +109 −0
  • internal/hostclaim/relocating.go +142 −0
  • internal/hostclaim/relocating_test.go +251 −0
  • internal/hostfence/claim_fence.go +38 −0
  • internal/hostfence/claim_fence_test.go +72 −0
  • internal/hostops/backfill.go +380 −0
  • internal/hostops/backfill_controller.go +119 −0
  • internal/hostops/backfill_publish.go +162 −0
  • internal/hostops/backfill_test.go +233 −0
  • internal/hostops/operations.go +28 −3
  • internal/hostops/record.go +13 −4
  • internal/hostops/recovery.go +1 −1
  • internal/hostops/retirement.go +1 −4
  • internal/provision/claim_export.go +3 −0
  • internal/provision/provision.go +1 −0
  • internal/provision/sidecar.go +1 −0

CI passed — run details

diff --git a/cmd/console/main.go b/cmd/console/main.go
index c70e9ffda..dc28c9523 100644
--- a/cmd/console/main.go
+++ b/cmd/console/main.go
@@ -392,6 +392,10 @@ func serve() {
// objects and pulls the AWS bill (internal/console/economicspass.go).
// Same placement rule as the alert watcher: started here, never in
// NewServer, so constructing a Server stays goroutine-free.
+ if err := srv.ConfigureClaimBackfill(); err != nil {
+ log.Fatalf("console: claim recovery: %v", err)
+ }
+ go srv.StartClaimRecoveryPass(context.Background())
go srv.StartEconomicsPass(context.Background())
// The infrastructure inventory pass writes infra/inventory.json for
// /fleet/architecture (internal/console/infrapass.go). Same placement
diff --git a/internal/console/hostclaimbackfill.go b/internal/console/hostclaimbackfill.go
new file mode 100644
index 000000000..f4936d0c4
--- /dev/null
+++ b/internal/console/hostclaimbackfill.go
@@ -0,0 +1,82 @@
+package console
+
+import (
+ "context"
+ "crypto/rand"
+ "encoding/hex"
+ "errors"
+ "time"
+
+ "github.com/carlosframework/platform/internal/hostactors"
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/hostops"
+)
+
+func claimOperationID() string {
+ var b [16]byte
+ if _, err := rand.Read(b[:]); err != nil {
+ return ""
+ }
+ return hex.EncodeToString(b[:])
+}
+
+// Called after NewServer, before background workers. It starts no goroutine.
+func (s *Server) ConfigureClaimBackfill() error {
+ if !s.cfg.HostClaim {
+ return nil
+ }
+ if s.cfg.Releases == nil || s.cfg.Bucket == "" || len(s.cfg.ReleasePub) == 0 {
+ return hostops.ErrContract
+ }
+ if s.cfg.HostClaimOps == nil {
+ d := hostactors.Directory{Store: s.cfg.Releases, PublicKey: s.cfg.ReleasePub, Bucket: s.cfg.Bucket}
+ c := hostops.NewBackfillController(s.cfg.Releases, d, time.Now, claimOperationID)
+ s.cfg.HostClaimOps = &c
+ }
+ return nil
+}
+func (s *Server) ReconcileHostClaims(ctx context.Context) error {
+ if !s.cfg.HostClaim {
+ return nil
+ }
+ if s.cfg.HostClaimOps == nil {
+ return hostops.ErrContract
+ }
+ return hostops.ReconcileClaims(ctx, *s.cfg.HostClaimOps, s.adoptHostRecord)
+}
+
+// Exported for the moves console pass; construction and startup remain separate.
+func (s *Server) RunClaimRecoveryPass(ctx context.Context) error {
+ if !s.cfg.HostClaim {
+ return nil
+ }
+ var errs []error
+ if err := s.ReconcileHostClaims(ctx); err != nil {
+ errs = append(errs, err)
+ }
+ if s.cfg.HostClaimRecovery != nil {
+ if err := s.cfg.HostClaimRecovery.Pass(ctx); err != nil {
+ errs = append(errs, err)
+ }
+ }
+ return errors.Join(errs...)
+}
+
+// A door may only consume a claim after the backfill has established it.
+func claimedRecord(ctx context.Context, c *hostops.Controller, key string) (hostclaim.Observed, error) {
+ r, err := hostops.ReadRecord(ctx, c.Store, key)
+ if err != nil {
+ return hostclaim.Observed{}, err
+ }
+ obs, err := hostclaim.Read(ctx, c.Store, r.Holder.Host)
+ if err != nil {
+ return obs, err
+ }
+ if obs.Absent {
+ return obs, hostops.ErrPending
+ }
+ if obs.Claim.Holder == nil || obs.Claim.Holder.Key != key {
+ return obs, hostops.ErrIdentity
+ }
+ return obs, nil
+}
diff --git a/internal/console/hostclaimops.go b/internal/console/hostclaimops.go
index 1f70ef3f1..ef9aa7647 100644
--- a/internal/console/hostclaimops.go
+++ b/internal/console/hostclaimops.go
@@ -35,14 +35,14 @@ func (s *Server) StartClaimRecoveryPass(ctx context.Context) {
if !s.cfg.HostClaim {
return
}
- if s.cfg.HostClaimRecovery == nil {
+ if s.cfg.HostClaimOps == nil {
log.Printf("console: claim recovery: %v", hostops.ErrContract)
return
}
t := time.NewTicker(claimRecoveryInterval)
defer t.Stop()
for {
- if err := s.claimRecoveryPass(ctx); err != nil {
+ if err := s.RunClaimRecoveryPass(ctx); err != nil {
log.Printf("console: claim recovery: %v", err)
}
select {
diff --git a/internal/domains/claims.go b/internal/domains/claims.go
index a31cfadb2..000528466 100644
--- a/internal/domains/claims.go
+++ b/internal/domains/claims.go
@@ -35,13 +35,16 @@ func ClaimKey(host string) string { return ClaimsPrefix + host + ".json" }
// CreatedBy is omitempty because a claim minted by a box-side process, not a
// human door, has no operator to name.
type Claim struct {
- Schema int `json:"schema"` // 1
- Host string `json:"host"`
- Account string `json:"account"`
- App string `json:"app"`
- Route string `json:"route,omitempty"`
- CreatedAt string `json:"created_at"`
- CreatedBy string `json:"created_by,omitempty"`
+ Acquisition string `json:"acq,omitempty"`
+ RecordIncarnation string `json:"incarnation,omitempty"`
+ OwnershipState string `json:"ownership_state,omitempty"`
+ Schema int `json:"schema"` // 1
+ Host string `json:"host"`
+ Account string `json:"account"`
+ App string `json:"app"`
+ Route string `json:"route,omitempty"`
+ CreatedAt string `json:"created_at"`
+ CreatedBy string `json:"created_by,omitempty"`
// Wildcard and Catchall are the woodstar-carloku branch's two claim
// capabilities (its migration spec §4-5 gives them meaning; nothing in
// this package acts on either). They are deliberately orthogonal:
diff --git a/internal/host/agent.go b/internal/host/agent.go
index 3e1c577e8..2c6f45185 100644
--- a/internal/host/agent.go
+++ b/internal/host/agent.go
@@ -547,7 +547,7 @@ func runAgent(args []string, buildClaims ClaimFactory) {
// by CARLOS_HOST_METRICS — one log line and nothing else where unset.
go memwatchLoop()
go domainsLoop(reg, peekAcmeCache(args))
- go instancesLoop(reg)
+ go instancesLoop(reg, claims.Writes)
// The deployment-global election, armed BEFORE the loops it gates so a
// single-box deployment is holding well before their first tick (a
// minute away) — see housekeeper.go. Not a `go` of its own: it publishes
@@ -2116,7 +2116,11 @@ func fastConvergeLoop(reg *registry.Registry, svc *activator.Service, sd *schedu
}
defer func(start time.Time) { logSlow("serial pass stages", start, strings.Join(took, ", ")) }(time.Now())
- if err := reconcileSerialized(ctx, store, reg, provisionTickConfig(os.Getenv)); err != nil {
+ pcfg := provisionTickConfig(os.Getenv)
+ if err := reconcileBackfill(ctx, writes, store, pcfg.ReleasePub, os.Getenv("CARLOS_DEPLOYMENT_BUCKET")); err != nil {
+ log.Printf("agent: claim backfill: %v", err)
+ }
+ if err := reconcileSerialized(ctx, store, reg, pcfg); err != nil {
log.Printf("agent: fast converge reconcile: %v", err)
}
mark("reconcile")
@@ -2671,7 +2675,7 @@ func domainsDuty(pass func(context.Context) error) housekeeperDuty {
// already had a store+reg in scope) — that coupled binding materialization
// to PLATFORM_APPS_DOMAIN being set, which has nothing to do with fleets;
// this loop has no such gate and shares the same store already opened above.
-func instancesLoop(reg *registry.Registry) {
+func instancesLoop(reg *registry.Registry, writes hostwrite.Config) {
ctx := context.Background()
store, err := openDeployStore(ctx)
if err != nil {
@@ -2692,6 +2696,9 @@ func instancesLoop(reg *registry.Registry) {
for {
time.Sleep(delay)
delay = 5*time.Minute + rand.N(time.Minute)
+ if err := reconcileBackfill(ctx, writes, store, cfg.ReleasePub, os.Getenv("CARLOS_DEPLOYMENT_BUCKET")); err != nil {
+ log.Printf("agent: claim backfill: %v", err)
+ }
if err := reconcileSerialized(ctx, store, reg, cfg); err != nil {
log.Printf("agent: instances pass: %v", err)
}
diff --git a/internal/host/claimbackfill.go b/internal/host/claimbackfill.go
new file mode 100644
index 000000000..d751062f8
--- /dev/null
+++ b/internal/host/claimbackfill.go
@@ -0,0 +1,32 @@
+package host
+
+import (
+ "context"
+ "crypto/ed25519"
+ "crypto/rand"
+ "encoding/hex"
+ "time"
+
+ "github.com/carlosframework/platform/internal/hostactors"
+ "github.com/carlosframework/platform/internal/hostops"
+ "github.com/carlosframework/platform/internal/hostwrite"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func reconcileBackfill(ctx context.Context, writes hostwrite.Config, s release.Store, pub ed25519.PublicKey, bucket string) error {
+ if !writes.Enabled {
+ return nil
+ }
+ if s == nil || len(pub) == 0 || bucket == "" {
+ return hostops.ErrContract
+ }
+ d := hostactors.Directory{Store: s, PublicKey: pub, Bucket: bucket}
+ c := hostops.NewBackfillController(s, d, time.Now, func() string {
+ var b [16]byte
+ if _, err := rand.Read(b[:]); err != nil {
+ return ""
+ }
+ return hex.EncodeToString(b[:])
+ })
+ return c.ReconcileClaims(ctx)
+}
diff --git a/internal/hostactors/censuskey.go b/internal/hostactors/censuskey.go
new file mode 100644
index 000000000..f0056fa4c
--- /dev/null
+++ b/internal/hostactors/censuskey.go
@@ -0,0 +1,59 @@
+package hostactors
+
+import (
+ "bytes"
+ "context"
+ "crypto/ed25519"
+ "encoding/json"
+ "math"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// CensusKeyBinding is approved by the root enrollment coordinator after its
+// authenticated box session supplies a proof of possession. It is not an
+// unauthenticated enrollment request or a console-generated box report.
+type CensusKeyBinding struct {
+ Bucket string `json:"bucket"`
+ Box string `json:"box"`
+ Enrollment string `json:"enrollment"`
+ PublicKey ed25519.PublicKey `json:"public_key"`
+}
+
+func (d Directory) BindCensusKey(ctx context.Context, signer release.Signer, before Observed, binding CensusKeyBinding, proof []byte) (Observed, error) {
+ b, ok := before.Roster.Boxes[binding.Box]
+ if !ok || binding.Bucket != d.Bucket || binding.Enrollment != b.Enrollment || len(binding.PublicKey) != ed25519.PublicKeySize || before.ETag == "" || before.Roster.Revision == math.MaxUint64 {
+ return Observed{}, ErrRoster
+ }
+ raw, err := json.Marshal(binding)
+ if err != nil {
+ return Observed{}, err
+ }
+ if !ed25519.Verify(binding.PublicKey, raw, proof) {
+ return Observed{}, ErrRoster
+ }
+ if len(b.CensusPublicKey) != 0 {
+ if bytes.Equal(b.CensusPublicKey, binding.PublicKey) {
+ return before, nil
+ }
+ return Observed{}, ErrRoster
+ }
+ // One write-once extension to an existing enrollment. Ordinary Replace
+ // continues to reject changes to this field, including deletion/rotation.
+ next := Clone(before.Roster)
+ b.CensusPublicKey = append(ed25519.PublicKey(nil), binding.PublicKey...)
+ next.Boxes[binding.Box] = b
+ next.Revision++
+ if err = next.Validate(d.Bucket); err != nil {
+ return Observed{}, err
+ }
+ wire, err := release.SignRootEnvelope(signer, next)
+ if err != nil {
+ return Observed{}, err
+ }
+ tag, err := d.Store.Put(ctx, Key, wire, before.ETag)
+ if err != nil {
+ return Observed{}, err
+ }
+ return Observed{Roster: next, ETag: tag}, nil
+}
diff --git a/internal/hostactors/roster.go b/internal/hostactors/roster.go
index 7ef67ddb5..42bd34b19 100644
--- a/internal/hostactors/roster.go
+++ b/internal/hostactors/roster.go
@@ -23,17 +23,18 @@ var token = regexp.MustCompile(`^[0-9a-f]{32}$`)
type Cloud = boxid.Cloud
type Box struct {
- Enrollment string `json:"enrollment"`
- Namespace string `json:"namespace"`
- BoxID string `json:"box_id"`
- Label string `json:"label"`
- Region string `json:"region"`
- Pool string `json:"pool"`
- Cloud Cloud `json:"cloud"`
- EvidenceSHA256 string `json:"evidence_sha256"`
- State string `json:"state"`
- QuarantineRevision uint64 `json:"quarantine_revision"`
- LossIDs []string `json:"loss_ids"`
+ CensusPublicKey ed25519.PublicKey `json:"census_public_key,omitempty"`
+ Enrollment string `json:"enrollment"`
+ Namespace string `json:"namespace"`
+ BoxID string `json:"box_id"`
+ Label string `json:"label"`
+ Region string `json:"region"`
+ Pool string `json:"pool"`
+ Cloud Cloud `json:"cloud"`
+ EvidenceSHA256 string `json:"evidence_sha256"`
+ State string `json:"state"`
+ QuarantineRevision uint64 `json:"quarantine_revision"`
+ LossIDs []string `json:"loss_ids"`
}
type Console struct {
Compute *ComputeBinding `json:"compute,omitempty"`
@@ -94,6 +95,9 @@ func (r Roster) Validate(bucket string) error {
ids := map[string]bool{}
clouds := map[Cloud]bool{}
for key, b := range r.Boxes {
+ if len(b.CensusPublicKey) != 0 && len(b.CensusPublicKey) != ed25519.PublicKeySize {
+ return ErrRoster
+ }
if !segment(key) || b.BoxID != key || !token.MatchString(b.Enrollment) || ids[b.Enrollment] || b.Namespace == "" || b.Label == "" || b.Region == "" || b.Pool == "" || b.Cloud.Provider != "aws" || b.Cloud.Account == "" || b.Cloud.Region == "" || b.Cloud.InstanceID == "" || len(b.EvidenceSHA256) != 64 || clouds[b.Cloud] {
return ErrRoster
}
diff --git a/internal/hostclaim/adopt.go b/internal/hostclaim/adopt.go
index d4492f7a6..bc7ff4cf5 100644
--- a/internal/hostclaim/adopt.go
+++ b/internal/hostclaim/adopt.go
@@ -16,7 +16,7 @@ func Adopt(nh NewHolder, servingBox string, otherRegions, otherBoxes []string, o
if err != nil {
return err
}
- c.Holder = &Holder{Family: nh.Family, Key: nh.Key, Account: nh.Account, App: nh.App, Name: nh.Name,
+ c.Holder = &Holder{Acquisition: nh.Acquisition, 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, WriterBox: servingBox}
c.Op = Op{Kind: KindServing, Exec: servingBox, Participants: []string{servingBox}}
c.Touched = Touched{Complete: true, Regions: []string{nh.Region}, Boxes: []string{servingBox}}
diff --git a/internal/hostclaim/claim.go b/internal/hostclaim/claim.go
index 698444fe1..23d178589 100644
--- a/internal/hostclaim/claim.go
+++ b/internal/hostclaim/claim.go
@@ -37,7 +37,7 @@ const (
var knownKinds = map[string]bool{KindServing: true, KindCreating: true, KindDeleting: true, KindParking: true,
KindPurging: true, KindPlacing: true, KindMoving: true, KindLanding: true, KindReverting: true,
- KindRestoring: true, KindPushing: true}
+ KindRestoring: true, KindPushing: true, KindRelocating: true}
var (
ErrMalformed = errors.New("hostclaim: malformed claim")
@@ -45,6 +45,7 @@ var (
)
type Holder struct {
+ Acquisition string `json:"acq,omitempty"`
Family string `json:"family"` // "instance" | "sidecar"
Key string `json:"key"` // exact record key
Account string `json:"account"`
@@ -221,6 +222,9 @@ func (c *Claim) Validate() error {
if c == nil || c.V != schemaVersion || !controlname.ValidHostShape(c.Host) || c.Gen == 0 {
return fmt.Errorf("%w: header", ErrMalformed)
}
+ if err := validateRelocating(c); err != nil {
+ return err
+ }
if c.Op.Kind != "" && !knownKinds[c.Op.Kind] {
return fmt.Errorf("%w: unknown op kind %q", ErrMalformed, c.Op.Kind)
}
diff --git a/internal/hostclaim/create.go b/internal/hostclaim/create.go
index 4450413ba..c8fe97cc2 100644
--- a/internal/hostclaim/create.go
+++ b/internal/hostclaim/create.go
@@ -10,6 +10,7 @@ import (
var ErrRetiredIncarnation = errors.New("hostclaim: incarnation was retired on this host")
type NewHolder struct {
+ Acquisition string
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.
@@ -70,7 +71,7 @@ func BeginCreate(nh NewHolder, recordBody []byte, opID, by string, now time.Time
return ErrRetiredIncarnation
}
}
- c.Holder = &Holder{Family: nh.Family, Key: nh.Key, Account: nh.Account, App: nh.App, Name: nh.Name,
+ c.Holder = &Holder{Acquisition: nh.Acquisition, Family: nh.Family, Key: nh.Key, Account: nh.Account, App: nh.App, Name: nh.Name,
Incarnation: inc, CreatedAt: nh.CreatedAt, Location: "instances/" + nh.Host + "/" + inc}
c.Op = Op{Kind: KindCreating, ID: opID, By: by, StartedAt: now.UTC(),
Intent: map[string]string{"key": nh.Key, "record": string(recordBody)}}
diff --git a/internal/hostclaim/fence.go b/internal/hostclaim/fence.go
new file mode 100644
index 000000000..bb90dd9bf
--- /dev/null
+++ b/internal/hostclaim/fence.go
@@ -0,0 +1,109 @@
+package hostclaim
+
+import (
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "strings"
+
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/termfence"
+)
+
+var ErrFence = errors.New("hostclaim: invalid termination fence")
+
+type FenceRef struct {
+ Enrollment string `json:"enrollment"`
+ Key string `json:"key"`
+ SHA256 string `json:"sha256"`
+}
+
+type FenceBinding struct {
+ Region string
+ RosterRevision uint64
+ RosterETag string
+}
+
+// FenceAuthority checks and identifies one authenticated roster observation.
+// Implementations must bind both the target and reporting worker enrollments,
+// cloud account, cloud region and terminal instance identity to that roster.
+type FenceAuthority interface {
+ ValidateFence(context.Context, termfence.Record) (FenceBinding, error)
+}
+
+type VerifiedFence struct {
+ ref FenceRef
+ record termfence.Record
+ binding FenceBinding
+}
+
+func validFenceRef(ref FenceRef) bool {
+ if len(ref.Enrollment) != 32 || ref.Enrollment != strings.ToLower(ref.Enrollment) {
+ return false
+ }
+ if _, err := hex.DecodeString(ref.Enrollment); err != nil {
+ return false
+ }
+ return ref.Key == "control/box-fences/"+ref.Enrollment+".json" && isHex64(ref.SHA256) && ref.SHA256 == strings.ToLower(ref.SHA256)
+}
+
+// VerifyFence re-reads the immutable worker-owned object, including on replay.
+// A previously verified reference alone never substitutes for verification.
+func VerifyFence(ctx context.Context, s release.Store, authority FenceAuthority, ref FenceRef) (VerifiedFence, error) {
+ if s == nil || authority == nil || !validFenceRef(ref) {
+ return VerifiedFence{}, ErrFence
+ }
+ raw, etag, err := s.Get(ctx, ref.Key)
+ if err != nil {
+ return VerifiedFence{}, err
+ }
+ sum := sha256.Sum256(raw)
+ if etag == "" || hex.EncodeToString(sum[:]) != ref.SHA256 {
+ return VerifiedFence{}, ErrFence
+ }
+ var f termfence.Record
+ if json.Unmarshal(raw, &f) != nil || f.Enrollment != ref.Enrollment || f.Box == "" || f.State != "terminated" || f.V != 1 || f.WrittenBy.Box == f.Box {
+ return VerifiedFence{}, ErrFence
+ }
+ binding, err := authority.ValidateFence(ctx, f)
+ if err != nil {
+ return VerifiedFence{}, err
+ }
+ if binding.Region == "" || binding.RosterRevision == 0 || binding.RosterETag == "" {
+ return VerifiedFence{}, ErrFence
+ }
+ return VerifiedFence{ref: ref, record: f, binding: binding}, nil
+}
+
+// VerifyOperationFence binds the journal key as well as the immutable record.
+// Return and recovery paths use this form rather than trusting a decoded ref.
+func VerifyOperationFence(ctx context.Context, s release.Store, authority FenceAuthority, op Op, box string) error {
+ if !validBoxReference(box) || op.ID == "" {
+ return ErrFence
+ }
+ var ref FenceRef
+ if json.Unmarshal([]byte(op.Journal["fence."+box]), &ref) != nil {
+ return ErrFence
+ }
+ verified, err := VerifyFence(ctx, s, authority, ref)
+ if err != nil {
+ return err
+ }
+ if verified.record.Box != box {
+ return ErrFence
+ }
+ return nil
+}
+
+func VerifyFenceForBox(ctx context.Context, s release.Store, authority FenceAuthority, ref FenceRef, box string) error {
+ verified, err := VerifyFence(ctx, s, authority, ref)
+ if err != nil {
+ return err
+ }
+ if !validBoxReference(box) || verified.record.Box != box {
+ return ErrFence
+ }
+ return nil
+}
diff --git a/internal/hostclaim/relocating.go b/internal/hostclaim/relocating.go
new file mode 100644
index 000000000..b5565460c
--- /dev/null
+++ b/internal/hostclaim/relocating.go
@@ -0,0 +1,142 @@
+package hostclaim
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "strconv"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+const KindRelocating = "relocating"
+
+// PreFlipGuard verifies the durable pre-flip conversion decision in the
+// authoritative instance record. That decision's record CAS competes with
+// MoveFlip and makes the move terminal for every cutting/flip transition.
+// It preserves the old move journal for replay, and binds this op, incarnation,
+// source and exact fence reference. A snapshot of an undecided record is not
+// an implementation of this interface.
+type PreFlipGuard interface {
+ CheckMovingBeforeFlip(context.Context, Observed, string, FenceRef) (string, error)
+}
+
+// RelocationRecoveryOwner reports replay ownership, never execution authority.
+func RelocationRecoveryOwner(c *Claim) (string, error) {
+ if c == nil || c.Op.Kind != KindRelocating || c.Op.Intent["region"] == "" {
+ return "", ErrWrongState
+ }
+ return "region:" + c.Op.Intent["region"], nil
+}
+
+func validateRelocating(c *Claim) error {
+ if c.Op.Kind != KindRelocating {
+ return nil
+ }
+ if c.Holder == nil || c.Op.Intent["region"] == "" || !validBoxReference(c.Op.Intent["source_box"]) || c.Op.Intent["source_box"] == "none" {
+ return ErrMalformed
+ }
+ switch c.Op.Phase {
+ case "starting", "releasing":
+ if c.Op.Exec != c.Op.Intent["source_box"] && c.Op.Exec != "none" {
+ return ErrMalformed
+ }
+ case "pinning":
+ if c.Op.Exec != "none" {
+ return ErrMalformed
+ }
+ case "installing", "installed", "probing", "probe-failed", "serving":
+ if !validBoxReference(c.Op.Exec) || c.Op.Exec == "none" || c.Op.Exec != c.Op.Intent["target_box"] {
+ return ErrMalformed
+ }
+ default:
+ return ErrMalformed
+ }
+ return nil
+}
+
+// ConvertMovingToRelocating performs exactly one claim CAS. It deliberately
+// exposes no transition capable of granting relocation execution. Callers must
+// be claim-enabled and first persist the record conversion decision.
+func ConvertMovingToRelocating(ctx context.Context, s release.Store, observed Observed, opID string, ref FenceRef, authority FenceAuthority, guard PreFlipGuard) (Observed, error) {
+ if guard == nil || observed.Absent || observed.Claim == nil || observed.ETag == "" {
+ return Observed{}, ErrWrongState
+ }
+ before := observed.Claim
+ if opID == "" || before.Op.ID != opID {
+ return Observed{}, ErrStaleOp
+ }
+ if before.Holder == nil || before.Op.Intent["admission_id"] == "" || before.Holder.Family != "instance" || before.Op.Kind != KindMoving || before.Op.Phase == "landing" || before.Op.Phase == "committed" {
+ return Observed{}, ErrWrongState
+ }
+ // Check the instance record even while the claim still says moving: the
+ // instance flip is earlier than final commit and can precede claim landing.
+ region, err := guard.CheckMovingBeforeFlip(ctx, observed, opID, ref)
+ if err != nil {
+ return Observed{}, err
+ }
+ fence, err := VerifyFence(ctx, s, authority, ref)
+ if err != nil {
+ return Observed{}, err
+ }
+ if region == "" || region != fence.binding.Region || before.Op.Intent["source_box"] != fence.record.Box || before.Op.Intent["source_enrollment"] != ref.Enrollment || before.Op.Intent["source_region"] != region || (before.Op.Exec != fence.record.Box && before.Op.Exec != "none" && before.Op.Exec != "") {
+ return Observed{}, ErrFence
+ }
+ raw, err := json.Marshal(ref)
+ if err != nil {
+ return Observed{}, err
+ }
+ return Update(ctx, s, observed, func(c *Claim) error {
+ if c.Op.ID != opID || c.Op.Kind != KindMoving || c.Op.Phase != before.Op.Phase {
+ return ErrStaleOp
+ }
+ key := "fence." + fence.record.Box
+ if old, ok := c.Op.Journal[key]; ok && old != string(raw) {
+ return fmt.Errorf("%w: fence reference already bound", ErrFence)
+ }
+ // Recheck the durable record decision immediately before the claim CAS.
+ // Its terminal state prevents any intervening flip.
+ currentRegion, err := guard.CheckMovingBeforeFlip(ctx, observed, opID, ref)
+ if err != nil {
+ return err
+ }
+ if currentRegion != region {
+ return ErrWrongState
+ }
+ if c.Op.Journal == nil {
+ c.Op.Journal = map[string]string{}
+ }
+ c.Op.Journal[key] = string(raw)
+ c.Op.Journal["reason"] = "source-terminated-before-flip"
+ c.Op.Journal["source_stamp"] = strconv.FormatUint(c.Holder.Stamp, 10)
+ c.Op.Journal["source_generation"] = c.Holder.Generation
+ c.Op.Journal["roster_revision"] = strconv.FormatUint(fence.binding.RosterRevision, 10)
+ c.Op.Journal["roster_etag"] = fence.binding.RosterETag
+ c.Op.Journal["admission_id"] = before.Op.Intent["admission_id"]
+ c.Op.Journal["conversion_claim_gen"] = strconv.FormatUint(before.Gen, 10)
+ c.Op.Journal["conversion_claim_etag"] = observed.ETag
+ c.Op.Kind, c.Op.Phase, c.Op.Exec = KindRelocating, "pinning", "none"
+ if c.Op.Intent == nil {
+ c.Op.Intent = map[string]string{}
+ }
+ c.Op.Intent["region"] = region
+ // The original move target is in a different region. It is not a selected
+ // same-region replacement. Keep the move history without authorizing it.
+ if target := c.Op.Intent["target_box"]; target != "" {
+ c.Op.Intent["move_target_box"] = target
+ delete(c.Op.Intent, "target_box")
+ }
+ if enrollment := c.Op.Intent["target_enrollment"]; enrollment != "" {
+ c.Op.Intent["move_target_enrollment"] = enrollment
+ delete(c.Op.Intent, "target_enrollment")
+ }
+ participants := make([]string, 0, len(c.Op.Participants))
+ for _, box := range c.Op.Participants {
+ if box != fence.record.Box {
+ participants = append(participants, box)
+ }
+ }
+ c.Op.Participants = participants
+ return nil
+ })
+}
diff --git a/internal/hostclaim/relocating_test.go b/internal/hostclaim/relocating_test.go
new file mode 100644
index 000000000..88d2cf2a7
--- /dev/null
+++ b/internal/hostclaim/relocating_test.go
@@ -0,0 +1,251 @@
+package hostclaim
+
+import (
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "reflect"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/boxid"
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/termfence"
+)
+
+type c7Authority struct {
+ want termfence.Record
+ err error
+}
+
+func (a c7Authority) ValidateFence(_ context.Context, f termfence.Record) (FenceBinding, error) {
+ if a.err != nil {
+ return FenceBinding{}, a.err
+ }
+ if f != a.want {
+ return FenceBinding{}, ErrFence
+ }
+ return FenceBinding{Region: "ie", RosterRevision: 7, RosterETag: "roster-etag"}, nil
+}
+
+type c7Guard struct {
+ calls int
+ rejectAt int
+}
+
+func (g *c7Guard) CheckMovingBeforeFlip(_ context.Context, o Observed, id string, ref FenceRef) (string, error) {
+ g.calls++
+ if id != o.Claim.Op.ID || ref.Enrollment != o.Claim.Op.Intent["source_enrollment"] {
+ return "", ErrFence
+ }
+ if g.rejectAt > 0 && g.calls >= g.rejectAt {
+ return "", ErrWrongState
+ }
+ return "ie", nil
+}
+
+type c7Store struct {
+ release.Store
+ puts int
+}
+
+func (s *c7Store) Put(ctx context.Context, key string, body []byte, match string) (string, error) {
+ s.puts++
+ return s.Store.Put(ctx, key, body, match)
+}
+func c7Fixture(t *testing.T) (*c7Store, Observed, FenceRef, c7Authority) {
+ t.Helper()
+ ctx := context.Background()
+ s := &c7Store{Store: release.NewDirStore(t.TempDir())}
+ o := servingClaim(t, ctx, s)
+ var err error
+ o, err = Update(ctx, s, o, func(c *Claim) error {
+ c.Holder.Generation, c.Holder.Stamp = "gen-current", 17
+ c.Op = Op{ID: "move-1", Kind: KindMoving, Phase: "releasing", Exec: "adi-knitting",
+ Intent: map[string]string{"admission_id": "admission-existing", "source_box": "adi-knitting", "source_enrollment": strings.Repeat("a", 32), "source_region": "ie", "target_box": "bri-knitting", "target_enrollment": strings.Repeat("c", 32)},
+ Journal: map[string]string{"pin": "keep"}, Participants: []string{"adi-knitting", "bri-knitting"}}
+ 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}}}
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ 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)}}
+ raw, err := json.Marshal(f)
+ if err != nil {
+ t.Fatal(err)
+ }
+ digest := sha256.Sum256(raw)
+ ref := FenceRef{Enrollment: f.Enrollment, Key: "control/box-fences/" + f.Enrollment + ".json", SHA256: hex.EncodeToString(digest[:])}
+ if _, err := s.Put(ctx, ref.Key, raw, ""); err != nil {
+ t.Fatal(err)
+ }
+ s.puts = 0
+ return s, o, ref, c7Authority{want: f}
+}
+
+func TestC7ConversionPreservesOwnershipAndChores(t *testing.T) {
+ s, before, ref, a := c7Fixture(t)
+ guard := &c7Guard{}
+ after, err := ConvertMovingToRelocating(context.Background(), s, before, "move-1", ref, a, guard)
+ if err != nil {
+ t.Fatal(err)
+ }
+ c := after.Claim
+ if c.Op.Journal["admission_id"] != "admission-existing" || 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 {
+ t.Fatalf("missing conversion authority: %+v", c.Op.Journal)
+ }
+ if s.puts != 1 || c.Gen != before.Claim.Gen+1 || guard.calls != 2 {
+ t.Fatalf("writes=%d gen=%d checks=%d", s.puts, c.Gen, guard.calls)
+ }
+ if !reflect.DeepEqual(c.Holder, before.Claim.Holder) || !reflect.DeepEqual(c.Obligations, before.Claim.Obligations) {
+ t.Fatal("holder or staging retirement changed")
+ }
+ if c.Op.ID != "move-1" || c.Op.Kind != KindRelocating || c.Op.Phase != "pinning" || c.Op.Exec != "none" || c.Op.Intent["region"] != "ie" {
+ t.Fatalf("wrong conversion: %+v", c.Op)
+ }
+ if c.Op.Intent["target_box"] != "" || c.Op.Intent["move_target_box"] != "bri-knitting" || !reflect.DeepEqual(c.Op.Participants, []string{"bri-knitting"}) {
+ t.Fatalf("target or barrier: %+v", c.Op)
+ }
+ var saved FenceRef
+ if err := json.Unmarshal([]byte(c.Op.Journal["fence.adi-knitting"]), &saved); err != nil || saved != ref {
+ t.Fatalf("journal reference: %+v %v", saved, err)
+ }
+ if owner, err := RelocationRecoveryOwner(c); err != nil || owner != "region:ie" {
+ t.Fatalf("owner=%q error=%v", owner, err)
+ }
+ if _, err := ConvertMovingToRelocating(context.Background(), s, before, "move-1", ref, a, &c7Guard{}); !errors.Is(err, ErrConflict) {
+ t.Fatalf("stale replay: %v", err)
+ }
+}
+
+func TestC7ConversionRefusesWithoutWriting(t *testing.T) {
+ 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"} {
+ t.Run(tc, func(t *testing.T) {
+ s, o, ref, a := c7Fixture(t)
+ id := "move-1"
+ var guard PreFlipGuard = &c7Guard{}
+ switch tc {
+ case "landing-record":
+ guard = &c7Guard{rejectAt: 1}
+ case "flip-during-verify":
+ guard = &c7Guard{rejectAt: 2}
+ case "landing-claim":
+ o.Claim.Op.Kind = KindLanding
+ case "wrong-op":
+ id = "other"
+ case "wrong-source":
+ o.Claim.Op.Intent["source_box"] = "other"
+ case "wrong-enrollment":
+ o.Claim.Op.Intent["source_enrollment"] = strings.Repeat("d", 32)
+ case "wrong-region":
+ o.Claim.Op.Intent["source_region"] = "uk"
+ case "foreign-exec":
+ o.Claim.Op.Exec = "target"
+ case "journal-rebind":
+ o.Claim.Op.Journal["fence.adi-knitting"] = `{"enrollment":"other"}`
+ case "authority":
+ a.err = ErrFence
+ case "digest":
+ ref.SHA256 = strings.Repeat("0", 64)
+ case "no-guard":
+ guard = nil
+ case "missing-admission":
+ delete(o.Claim.Op.Intent, "admission_id")
+ }
+ raw, _ := json.Marshal(o.Claim)
+ if _, err := ConvertMovingToRelocating(context.Background(), s, o, id, ref, a, guard); err == nil {
+ t.Fatal("accepted")
+ }
+ unchanged, _ := json.Marshal(o.Claim)
+ if s.puts != 0 || string(raw) != string(unchanged) {
+ t.Fatal("refusal mutated claim")
+ }
+ })
+ }
+}
+
+func TestC7VerifyFenceReReadsExactObject(t *testing.T) {
+ ctx := context.Background()
+ s, _, ref, a := c7Fixture(t)
+ if _, err := VerifyFence(ctx, s, a, ref); err != nil {
+ t.Fatal(err)
+ }
+ for _, tc := range []string{"bad-key", "bad-enrollment", "bad-digest", "missing", "changed-bytes", "authority"} {
+ t.Run(tc, func(t *testing.T) {
+ s, _, ref, a := c7Fixture(t)
+ switch tc {
+ case "bad-key":
+ ref.Key = "control/box-fences/other.json"
+ case "bad-enrollment":
+ ref.Enrollment = "../escape"
+ case "bad-digest":
+ ref.SHA256 = strings.Repeat("f", 64)
+ case "missing":
+ if err := s.Delete(ctx, ref.Key); err != nil {
+ t.Fatal(err)
+ }
+ case "changed-bytes":
+ raw, etag, err := s.Get(ctx, ref.Key)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err = s.Put(ctx, ref.Key, append(raw, ' '), etag); err != nil {
+ t.Fatal(err)
+ }
+ case "authority":
+ a.err = ErrFence
+ }
+ if _, err := VerifyFence(ctx, s, a, ref); err == nil {
+ t.Fatal("accepted")
+ }
+ })
+ }
+}
+
+func TestC7RelocatingPhaseValidation(t *testing.T) {
+ for _, phase := range []string{"starting", "releasing", "pinning", "installing", "installed", "probing", "probe-failed", "serving", "unknown"} {
+ t.Run(phase, func(t *testing.T) {
+ _, o, _, _ := c7Fixture(t)
+ c := o.Claim
+ c.Op.Kind, c.Op.Phase = KindRelocating, phase
+ c.Op.Intent["region"] = "ie"
+ switch phase {
+ case "starting", "releasing":
+ c.Op.Exec = "adi-knitting"
+ case "pinning":
+ c.Op.Exec = "none"
+ default:
+ c.Op.Exec = "bri-knitting"
+ }
+ if err := c.Validate(); (err != nil) != (phase == "unknown") {
+ t.Fatalf("phase %s: %v", phase, err)
+ }
+ if phase == "pinning" {
+ c.Op.Exec = "bri-knitting"
+ if c.Validate() == nil {
+ t.Fatal("pinning granted execution")
+ }
+ }
+ })
+ }
+}
+
+func TestC7JournalFenceCannotNameAnotherBox(t *testing.T) {
+ s, o, ref, a := c7Fixture(t)
+ raw, err := json.Marshal(ref)
+ if err != nil {
+ t.Fatal(err)
+ }
+ o.Claim.Op.Journal["fence.adi-knitting"] = string(raw)
+ if err := VerifyOperationFence(context.Background(), s, a, o.Claim.Op, "adi-knitting"); err != nil {
+ t.Fatal(err)
+ }
+ o.Claim.Op.Journal["fence.other"] = string(raw)
+ if err := VerifyOperationFence(context.Background(), s, a, o.Claim.Op, "other"); err == nil {
+ t.Fatal("journal key rebound to another box")
+ }
+}
diff --git a/internal/hostfence/claim_fence.go b/internal/hostfence/claim_fence.go
new file mode 100644
index 000000000..e4e09e6f6
--- /dev/null
+++ b/internal/hostfence/claim_fence.go
@@ -0,0 +1,38 @@
+package hostfence
+
+import (
+ "context"
+ "time"
+
+ "github.com/carlosframework/platform/internal/hostactors"
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/termfence"
+)
+
+// ClaimFenceAuthority authenticates the roster without creating the reverse
+// hostclaim -> hostactors dependency. Immutable evidence remains valid after
+// its reporter retires; enrollment/cloud binding must still be retained.
+type ClaimFenceAuthority struct {
+ Directory hostactors.Directory
+ Now func() time.Time
+}
+
+var _ hostclaim.FenceAuthority = ClaimFenceAuthority{}
+
+func (a ClaimFenceAuthority) ValidateFence(ctx context.Context, f termfence.Record) (hostclaim.FenceBinding, error) {
+ if a.Now == nil || f.WrittenBy.Box == f.Box {
+ return hostclaim.FenceBinding{}, hostclaim.ErrFence
+ }
+ o, err := a.Directory.Read(ctx)
+ if err != nil {
+ return hostclaim.FenceBinding{}, err
+ }
+ if err := hostactors.ValidateTerminationFence(o, f.Box, f, a.Now()); err != nil {
+ return hostclaim.FenceBinding{}, err
+ }
+ return hostclaim.FenceBinding{Region: o.Roster.Boxes[f.Box].Region, RosterRevision: o.Roster.Revision, RosterETag: o.ETag}, nil
+}
+
+func ClaimFenceRef(ref EvidenceRef) hostclaim.FenceRef {
+ return hostclaim.FenceRef{Enrollment: ref.Enrollment, Key: ref.Key, SHA256: ref.SHA256}
+}
diff --git a/internal/hostfence/claim_fence_test.go b/internal/hostfence/claim_fence_test.go
new file mode 100644
index 000000000..6ccaa8ae0
--- /dev/null
+++ b/internal/hostfence/claim_fence_test.go
@@ -0,0 +1,72 @@
+package hostfence
+
+import (
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "testing"
+
+ "github.com/aws/aws-sdk-go-v2/service/ec2/types"
+ "github.com/carlosframework/platform/internal/awsinv"
+ "github.com/carlosframework/platform/internal/hostclaim"
+)
+
+func TestC7FenceReaderAuthenticatesRosterAndCloud(t *testing.T) {
+ for _, tc := range []string{"valid", "enrollment", "account", "cloud-region", "instance", "reporter", "nonterminal", "signature", "self-reporter"} {
+ t.Run(tc, func(t *testing.T) {
+ ctx := context.Background()
+ w, s := terminationFixture(t)
+ w.Reader = func(context.Context, string) (awsinv.ExactEC2API, error) {
+ return terminatedEC2{state: types.InstanceStateNameTerminated, owner: "123"}, nil
+ }
+ got, err := w.Describe(ctx, "dead")
+ if err != nil {
+ t.Fatal(err)
+ }
+ ref := ClaimFenceRef(got)
+ raw, etag, err := s.Get(ctx, ref.Key)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var f Termination
+ if err = json.Unmarshal(raw, &f); err != nil {
+ t.Fatal(err)
+ }
+ switch tc {
+ case "enrollment":
+ f.Enrollment = "cccccccccccccccccccccccccccccccc"
+ case "account":
+ f.Cloud.Account = "456"
+ case "cloud-region":
+ f.Cloud.Region = "eu-west-2"
+ case "instance":
+ f.Cloud.InstanceID = "i-other"
+ case "reporter":
+ f.WrittenBy.Enrollment = "cccccccccccccccccccccccccccccccc"
+ case "nonterminal":
+ f.State = "shutting-down"
+ case "self-reporter":
+ f.WrittenBy.Box = f.Box
+ f.WrittenBy.Enrollment = f.Enrollment
+ case "signature":
+ w.Directory.PublicKey = make([]byte, 32)
+ }
+ raw, err = json.Marshal(f)
+ if err != nil {
+ t.Fatal(err)
+ }
+ // Deliberately replace the object in this local adversarial fixture and
+ // recompute its digest: a digest match alone must not grant authority.
+ if _, err = s.Put(ctx, ref.Key, raw, etag); err != nil {
+ t.Fatal(err)
+ }
+ sum := sha256.Sum256(raw)
+ ref.SHA256 = hex.EncodeToString(sum[:])
+ _, err = hostclaim.VerifyFence(ctx, s, ClaimFenceAuthority{Directory: w.Directory, Now: w.Now}, ref)
+ if (err == nil) != (tc == "valid") {
+ t.Fatalf("%s: %v", tc, err)
+ }
+ })
+ }
+}
diff --git a/internal/hostops/backfill.go b/internal/hostops/backfill.go
new file mode 100644
index 000000000..b90607198
--- /dev/null
+++ b/internal/hostops/backfill.go
@@ -0,0 +1,380 @@
+package hostops
+
+import (
+ "context"
+ "crypto/ed25519"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "os"
+ "sort"
+ "sync"
+ "time"
+
+ "github.com/carlosframework/platform/internal/domains"
+ "github.com/carlosframework/platform/internal/hostactors"
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+const BackfillKey = "control/hosts-backfill.json"
+const HostsQuarantineKey = "control/hosts-quarantine.json"
+
+type CensusInstallation struct {
+ Placement string `json:"placement"`
+ Host string `json:"host"`
+ Account string `json:"account"`
+ App string `json:"app"`
+ Family string `json:"family"`
+ Key string `json:"key"`
+ Incarnation string `json:"incarnation"`
+ Stamp uint64 `json:"stamp"`
+ Local bool `json:"local"`
+ Route bool `json:"route"`
+}
+type CensusReport struct {
+ Box string `json:"box"`
+ Enrollment string `json:"enrollment"`
+ BootID string `json:"boot_id"`
+ Reservation string `json:"reservation"`
+ DirectoryRevision uint64 `json:"directory_revision"`
+ CapturedAt time.Time `json:"captured_at"`
+ Complete bool `json:"complete"`
+ Installations []CensusInstallation `json:"installations"`
+}
+type Census struct {
+ Records []Record
+ Reports map[string]CensusReport
+ Roster hostactors.Observed
+}
+type BackfillHost struct {
+ Key string `json:"key"`
+ ETag string `json:"etag"`
+ Acquisition string `json:"acq"`
+ Region string `json:"region"`
+ Box string `json:"box"`
+}
+type BackfillPlan struct {
+ V int `json:"v"`
+ ID string `json:"id"`
+ Phase string `json:"phase"`
+ DirectoryRevision uint64 `json:"directory_revision"`
+ CreatedAt time.Time `json:"created_at"`
+ Reports map[string]string `json:"reports"`
+ Hosts map[string]BackfillHost `json:"hosts"`
+}
+type HostsQuarantine struct {
+ V int `json:"v"`
+ Hosts map[string]string `json:"hosts"`
+}
+type CensusEnvelope struct {
+ Report CensusReport `json:"report"`
+ Signature []byte `json:"signature"`
+}
+
+type Backfill struct {
+ mu sync.Mutex
+ cached *Census
+ cachedTag string
+ Store release.Store
+ Directory hostactors.Directory
+}
+type ProductionAuthority struct {
+ Store release.Store
+ Directory hostactors.Directory
+ Now func() time.Time
+ ConsoleID, Enrollment, Namespace string
+ Signer release.Signer
+ Evidence hostactors.Evidence
+ Backfill *Backfill
+}
+
+func reportKey(reservation, box string) (string, error) {
+ if !safeSegment(reservation) || !safeSegment(box) {
+ return "", ErrIdentity
+ }
+ return "control/host-census/" + reservation + "/" + box + ".json", nil
+}
+func safeSegment(s string) bool {
+ if s == "" || s == "." || s == ".." {
+ return false
+ }
+ for _, c := range s {
+ if c == '/' || c == '\\' || c == 0 {
+ return false
+ }
+ }
+ return true
+}
+func CheckHostQuarantine(ctx context.Context, s release.Store, host string) error {
+ raw, _, err := s.Get(ctx, HostsQuarantineKey)
+ if errors.Is(err, os.ErrNotExist) {
+ return nil
+ }
+ if err != nil {
+ return err
+ }
+ var q HostsQuarantine
+ if decode(raw, &q) != nil || q.V != 1 || q.Hosts == nil {
+ return ErrIdentity
+ }
+ if _, ok := q.Hosts[host]; ok {
+ return ErrPending
+ }
+ return nil
+}
+func (b *Backfill) read(ctx context.Context) (BackfillPlan, string, error) {
+ var p BackfillPlan
+ raw, tag, err := b.Store.Get(ctx, BackfillKey)
+ if err != nil {
+ return p, "", err
+ }
+ if tag == "" || release.VerifyRootEnvelope(b.Directory.PublicKey, raw, &p) != nil || p.V != 1 || !safeSegment(p.ID) || p.Phase != "adopting" || p.CreatedAt.IsZero() || p.Hosts == nil || p.Reports == nil {
+ return p, "", ErrContract
+ }
+ return p, tag, nil
+}
+func (a ProductionAuthority) census(ctx context.Context, account, app, reservation string, expectedRevision uint64, digests map[string]string) (Census, error) {
+ out := Census{Reports: map[string]CensusReport{}}
+ roster, err := a.Directory.Read(ctx)
+ if err != nil {
+ return out, err
+ }
+ if expectedRevision != 0 && roster.Roster.Revision != expectedRevision {
+ return out, ErrPending
+ }
+ out.Roster = roster
+ apps, err := Apps(ctx, a.Store)
+ if err != nil {
+ return out, err
+ }
+ allRecords := map[string]Record{}
+ for _, p := range apps {
+ records, err := Records(ctx, a.Store, p.Account, p.Name)
+ if err != nil {
+ return out, err
+ }
+ for _, r := range records {
+ if _, ok := allRecords[r.Holder.Host]; ok {
+ return out, ErrIdentity
+ }
+ allRecords[r.Holder.Host] = r
+ if account == "" || (p.Account == account && p.Name == app) {
+ out.Records = append(out.Records, r)
+ }
+ }
+ }
+ for ref, box := range roster.Roster.Boxes {
+ // Retired membership is retained until its inventory is proven empty.
+ key, err := reportKey(reservation, ref)
+ if err != nil {
+ return out, err
+ }
+ raw, tag, err := a.Store.Get(ctx, key)
+ if err != nil {
+ return out, err
+ }
+ if digests != nil {
+ sum := sha256.Sum256(raw)
+ if digests[ref] != hex.EncodeToString(sum[:]) {
+ return out, ErrIdentity
+ }
+ }
+ var envelope CensusEnvelope
+ if decode(raw, &envelope) != nil {
+ return out, ErrIdentity
+ }
+ report := envelope.Report
+ canonical, e := json.Marshal(report)
+ if e != nil || len(box.CensusPublicKey) != ed25519.PublicKeySize || !ed25519.Verify(box.CensusPublicKey, canonical, envelope.Signature) {
+ return out, ErrIdentity
+ }
+ if tag == "" || !report.Complete || report.Box != ref || report.Enrollment != box.Enrollment || report.Reservation != reservation || report.DirectoryRevision != roster.Roster.Revision || report.BootID == "" || report.CapturedAt.IsZero() || report.Installations == nil {
+ return out, ErrIdentity
+ }
+ seen := map[string]bool{}
+ for _, i := range report.Installations {
+ if i.Host == "" || seen[i.Host] || (!i.Route && !i.Local) {
+ return out, ErrIdentity
+ }
+ seen[i.Host] = true
+ r, ok := allRecords[i.Host]
+ if !ok || i.Account != r.Holder.Account || i.App != r.Holder.App || i.Key != r.Holder.Key || i.Family != r.Holder.Family {
+ return out, ErrIdentity
+ }
+ inc, err := r.Incarnation()
+ if err != nil || (i.Incarnation != "" && i.Incarnation != inc) {
+ return out, ErrIdentity
+ }
+ }
+ out.Reports[ref] = report
+ }
+ if digests != nil && len(digests) != len(out.Reports) {
+ return out, ErrIdentity
+ }
+ fresh, err := a.Directory.Read(ctx)
+ if err != nil {
+ return out, err
+ }
+ if fresh.ETag != roster.ETag {
+ return out, ErrPending
+ }
+ return out, nil
+}
+func (a ProductionAuthority) Census(ctx context.Context, account, app, reservation string) (Census, error) {
+ return a.census(ctx, account, app, reservation, 0, nil)
+}
+func (a ProductionAuthority) Inventory(ctx context.Context, obs hostclaim.Observed) ([]string, []string, error) {
+ roster, err := a.Directory.Read(ctx)
+ if err != nil {
+ return nil, nil, err
+ }
+ if len(roster.Roster.Regions) == 0 {
+ return nil, nil, ErrContract
+ }
+ regions := make([]string, 0, len(roster.Roster.Regions))
+ boxes := make([]string, 0, len(roster.Roster.Boxes))
+ for r := range roster.Roster.Regions {
+ regions = append(regions, r)
+ }
+ for b := range roster.Roster.Boxes {
+ boxes = append(boxes, b)
+ }
+ sort.Strings(regions)
+ sort.Strings(boxes)
+ return regions, boxes, nil
+}
+func (a ProductionAuthority) Adoption(ctx context.Context, r Record) (region, box string, regions, boxes []string, err error) {
+ if a.Backfill == nil {
+ err = ErrContract
+ return
+ }
+ return a.Backfill.Adoption(ctx, r)
+}
+func (b *Backfill) Adoption(ctx context.Context, r Record) (region, box string, regions, boxes []string, err error) {
+ if err = CheckHostQuarantine(ctx, b.Store, r.Holder.Host); err != nil {
+ return
+ }
+ p, tag, e := b.read(ctx)
+ if e != nil {
+ err = e
+ return
+ }
+ selected, ok := p.Hosts[r.Holder.Host]
+ if !ok || selected.Key != r.Holder.Key || selected.ETag != r.ETag || selected.Acquisition == "" || selected.Region == "" || selected.Box == "" {
+ err = ErrIdentity
+ return
+ }
+ a := ProductionAuthority{Store: b.Store, Directory: b.Directory}
+ b.mu.Lock()
+ if b.cachedTag != tag {
+ b.cached = nil
+ }
+ var census Census
+ if b.cached != nil {
+ census = *b.cached
+ } else {
+ census, e = a.census(ctx, "", "", p.ID, p.DirectoryRevision, p.Reports)
+ if e == nil {
+ b.cached = &census
+ b.cachedTag = tag
+ }
+ }
+ b.mu.Unlock()
+ if e != nil {
+ err = e
+ return
+ }
+ current, e := a.Directory.Read(ctx)
+ if e != nil {
+ err = e
+ return
+ }
+ if current.ETag != census.Roster.ETag {
+ err = ErrPending
+ return
+ }
+ owners := map[string]bool{}
+ found := false
+ for _, rec := range census.Records {
+ if rec.Holder.Host == r.Holder.Host {
+ found = rec.ETag == r.ETag && rec.Holder.Key == r.Holder.Key
+ }
+ }
+ for ref, report := range census.Reports {
+ for _, i := range report.Installations {
+ if i.Host == r.Holder.Host {
+ owners[ref] = true
+ }
+ }
+ }
+ if !found || len(owners) != 1 || !owners[selected.Box] {
+ err = ErrIdentity
+ return
+ }
+ enrolled, ok := census.Roster.Roster.Boxes[selected.Box]
+ if !ok || enrolled.Region != selected.Region || enrolled.State != "active" {
+ err = ErrIdentity
+ return
+ }
+ if r.Holder.Family == "instance" {
+ var instance provision.Record
+ if json.Unmarshal(r.Body, &instance) != nil || len(instance.Replicas) != 0 || provision.ClaimEverywhere(instance) {
+ err = ErrIdentity
+ return
+ }
+ }
+ eligible := 0
+ for _, candidate := range census.Roster.Roster.Boxes {
+ if candidate.State == "active" && candidate.Region == selected.Region && candidate.Namespace == enrolled.Namespace && candidate.Pool == enrolled.Pool {
+ eligible++
+ }
+ }
+ if eligible != 1 {
+ err = ErrContract
+ return
+ }
+ raw, _, e := b.Store.Get(ctx, domains.ClaimKey(r.Holder.Host))
+ if e != nil {
+ err = e
+ return
+ }
+ var ownership domains.Claim
+ inc, e := r.Incarnation()
+ if e != nil {
+ err = e
+ return
+ }
+ if decode(raw, &ownership) != nil || ownership.Host != r.Holder.Host || ownership.Account != r.Holder.Account || ownership.App != r.Holder.App || ownership.Acquisition != selected.Acquisition || ownership.RecordIncarnation != inc || ownership.OwnershipState != "committed" || ownership.Pending {
+ err = ErrIdentity
+ return
+ }
+ if r.Holder.Acquisition != selected.Acquisition {
+ err = ErrIdentity
+ return
+ }
+ _, freshTag, e := b.read(ctx)
+ if e != nil {
+ err = e
+ return
+ }
+ if freshTag != tag {
+ err = ErrPending
+ return
+ }
+ for region := range census.Roster.Roster.Regions {
+ if region != selected.Region {
+ regions = append(regions, region)
+ }
+ }
+ for ref := range census.Roster.Roster.Boxes {
+ if ref != selected.Box {
+ boxes = append(boxes, ref)
+ }
+ }
+ sort.Strings(regions)
+ sort.Strings(boxes)
+ return selected.Region, selected.Box, regions, boxes, nil
+}
diff --git a/internal/hostops/backfill_controller.go b/internal/hostops/backfill_controller.go
new file mode 100644
index 000000000..f6dcbe5ca
--- /dev/null
+++ b/internal/hostops/backfill_controller.go
@@ -0,0 +1,119 @@
+package hostops
+
+import (
+ "context"
+ "errors"
+ "os"
+ "time"
+
+ "github.com/carlosframework/platform/internal/hostactors"
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// BackfillAuthority deliberately grants only the frozen backfill's adoption.
+// Mutation doors use ProductionAuthority once their full construction lands.
+type BackfillAuthority struct{ p ProductionAuthority }
+
+func (a BackfillAuthority) Inventory(ctx context.Context, obs hostclaim.Observed) ([]string, []string, error) {
+ return a.p.Inventory(ctx, obs)
+}
+func (a BackfillAuthority) Adoption(ctx context.Context, r Record) (string, string, []string, []string, error) {
+ return a.p.Adoption(ctx, r)
+}
+func (a BackfillAuthority) ClaimBackfill() *Backfill { return a.p.Backfill }
+
+func (a BackfillAuthority) Create(context.Context, hostclaim.Observed, Record, string) error {
+ return ErrContract
+}
+func (a BackfillAuthority) Executor(context.Context, hostclaim.Observed, Record) (string, error) {
+ return "", ErrContract
+}
+func (a BackfillAuthority) StartDelete(context.Context, hostclaim.Observed, Record) error {
+ return ErrContract
+}
+func (a BackfillAuthority) FirstPhase(context.Context, hostclaim.Observed) error { return ErrContract }
+func (a BackfillAuthority) PurgeRetirement(context.Context, hostclaim.Observed) error {
+ return ErrContract
+}
+func NewBackfillController(s release.Store, d hostactors.Directory, now func() time.Time, newID func() string) Controller {
+ b := &Backfill{Store: s, Directory: d}
+ return Controller{Store: s, Now: now, NewID: newID, Authority: BackfillAuthority{ProductionAuthority{Store: s, Directory: d, Backfill: b, Now: now}}}
+}
+
+func ReconcileClaims(ctx context.Context, c Controller, adopt func(context.Context, string) (hostclaim.Observed, error)) error {
+ if adopt == nil {
+ return ErrContract
+ }
+ var backfill *Backfill
+ if provider, ok := c.Authority.(interface{ ClaimBackfill() *Backfill }); ok {
+ backfill = provider.ClaimBackfill()
+ }
+ if backfill != nil {
+ if _, _, err := backfill.read(ctx); err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ return ErrPending
+ }
+ return err
+ }
+ }
+ apps, err := Apps(ctx, c.Store)
+ if err != nil {
+ return err
+ }
+ var all []error
+ // Collect the entire listing before assigning any hostname. A broken page
+ // cannot authorize an adoption against only the prefix read so far.
+ var records []Record
+ seen := map[string]bool{}
+ for _, app := range apps {
+ rs, err := Records(ctx, c.Store, app.Account, app.Name)
+ if err != nil {
+ return err
+ }
+ for _, r := range rs {
+ if seen[r.Holder.Host] {
+ return ErrIdentity
+ }
+ seen[r.Holder.Host] = true
+ records = append(records, r)
+ }
+ }
+ for _, listed := range records {
+ r, err := ReadRecord(ctx, c.Store, listed.Holder.Key)
+ if errors.Is(err, os.ErrNotExist) {
+ continue
+ }
+ if err != nil {
+ all = append(all, err)
+ continue
+ }
+ if err = CheckHostQuarantine(ctx, c.Store, r.Holder.Host); err != nil {
+ all = append(all, err)
+ continue
+ }
+ obs, err := hostclaim.Read(ctx, c.Store, r.Holder.Host)
+ if err != nil {
+ all = append(all, err)
+ continue
+ }
+ if !obs.Absent && (obs.Claim.Holder == nil || obs.Claim.Holder.Acquisition != "" || r.Holder.Acquisition == "") {
+ continue
+ }
+ if _, err = adopt(ctx, r.Holder.Key); err != nil {
+ all = append(all, err)
+ }
+ }
+ return errors.Join(all...)
+}
+func (c Controller) ReconcileClaims(ctx context.Context) error {
+ return ReconcileClaims(ctx, c, func(ctx context.Context, key string) (hostclaim.Observed, error) {
+ rec, err := ReadRecord(ctx, c.Store, key)
+ if err != nil {
+ return hostclaim.Observed{}, err
+ }
+ return c.Adopt(ctx, rec)
+ })
+}
+
+func (a ProductionAuthority) ClaimBackfill() *Backfill { return a.Backfill }
diff --git a/internal/hostops/backfill_publish.go b/internal/hostops/backfill_publish.go
new file mode 100644
index 000000000..9f770662c
--- /dev/null
+++ b/internal/hostops/backfill_publish.go
@@ -0,0 +1,162 @@
+package hostops
+
+import (
+ "bytes"
+ "context"
+ "crypto/ed25519"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "github.com/carlosframework/platform/internal/hostactors"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// Publish freezes exact report bytes into #232's already-fenced backfill.
+// The collecting manifest is the global create/record-writer freeze. Its ETag
+// is supplied by the #232 coordinator, which retains that freeze through done.
+func (b *Backfill) Publish(ctx context.Context, signer release.Signer, collectingETag string) error {
+ raw, tag, err := b.Store.Get(ctx, BackfillKey)
+ if err != nil {
+ return err
+ }
+ var p BackfillPlan
+ if tag != collectingETag || tag == "" || release.VerifyRootEnvelope(b.Directory.PublicKey, raw, &p) != nil || p.V != 1 || p.Phase != "collecting" || p.ID == "" || p.CreatedAt.IsZero() || p.Hosts == nil {
+ return ErrContract
+ }
+ a := ProductionAuthority{Store: b.Store, Directory: b.Directory}
+ census, err := a.census(ctx, "", "", p.ID, p.DirectoryRevision, nil)
+ if err != nil {
+ return err
+ }
+ p.Reports = map[string]string{}
+ for box, report := range census.Reports {
+ if report.CapturedAt.Before(p.CreatedAt) {
+ return ErrIdentity
+ }
+ key, err := reportKey(p.ID, box)
+ if err != nil {
+ return err
+ }
+ body, _, err := b.Store.Get(ctx, key)
+ if err != nil {
+ return err
+ }
+ var envelope CensusEnvelope
+ if json.Unmarshal(body, &envelope) != nil || envelope.Report.Reservation != report.Reservation || !envelope.Report.Complete {
+ return ErrIdentity
+ }
+ sum := sha256.Sum256(body)
+ p.Reports[box] = hex.EncodeToString(sum[:])
+ }
+ for _, r := range census.Records {
+ if err := CheckHostQuarantine(ctx, b.Store, r.Holder.Host); err != nil {
+ return err
+ }
+ selected, ok := p.Hosts[r.Holder.Host]
+ if !ok || selected.Key != r.Holder.Key || selected.ETag != r.ETag || selected.Acquisition == "" || r.Holder.Acquisition != selected.Acquisition {
+ return ErrIdentity
+ }
+ }
+ if len(p.Hosts) != len(census.Records) {
+ return ErrIdentity
+ }
+ p.Phase = "adopting"
+ body, err := release.SignRootEnvelope(signer, p)
+ if err != nil {
+ return err
+ }
+ _, err = b.Store.Put(ctx, BackfillKey, body, tag)
+ return err
+}
+
+// PublishCensusReport is called by the guarded box census collector. Each box
+// owns its private signing key; the root-signed roster binds its public key to
+// verified enrollment. The console has only that public key.
+func PublishCensusReport(ctx context.Context, store release.Store, d hostactors.Directory, report CensusReport, private ed25519.PrivateKey) error {
+ if len(private) != ed25519.PrivateKeySize {
+ return ErrContract
+ }
+ roster, err := d.Read(ctx)
+ if err != nil {
+ return err
+ }
+ box, ok := roster.Roster.Boxes[report.Box]
+ if !ok || report.Enrollment != box.Enrollment || report.DirectoryRevision != roster.Roster.Revision || !bytes.Equal(private.Public().(ed25519.PublicKey), box.CensusPublicKey) || report.BootID == "" || !report.Complete || report.Installations == nil || report.CapturedAt.IsZero() {
+ return ErrIdentity
+ }
+ canonical, err := json.Marshal(report)
+ if err != nil {
+ return err
+ }
+ raw, err := json.Marshal(CensusEnvelope{Report: report, Signature: ed25519.Sign(private, canonical)})
+ if err != nil {
+ return err
+ }
+ key, err := reportKey(report.Reservation, report.Box)
+ if err != nil {
+ return err
+ }
+ if _, err = store.Put(ctx, key, raw, ""); err == nil {
+ return nil
+ }
+ if !errors.Is(err, release.ErrCASConflict) {
+ return err
+ }
+ previous, _, err := store.Get(ctx, key)
+ if err != nil {
+ return err
+ }
+ if !bytes.Equal(previous, raw) {
+ return ErrIdentity
+ }
+ return nil
+}
+
+// AuthorizeBackfillApp is the only legacy app-admission initialization proof.
+// It accepts the same complete frozen census as host adoption, including apps
+// with no host records. Empty results from a failed listing never qualify.
+func (a ProductionAuthority) AuthorizeBackfillApp(ctx context.Context, account, app string) (string, error) {
+ if a.Backfill == nil || !safeSegment(account) || !safeSegment(app) {
+ return "", ErrContract
+ }
+ p, tag, err := a.Backfill.read(ctx)
+ if err != nil {
+ return "", err
+ }
+ census, err := a.census(ctx, account, app, p.ID, p.DirectoryRevision, p.Reports)
+ if err != nil {
+ return "", err
+ }
+ apps, err := Apps(ctx, a.Store)
+ if err != nil {
+ return "", err
+ }
+ found := false
+ for _, candidate := range apps {
+ if candidate.Account == account && candidate.Name == app {
+ found = true
+ }
+ }
+ if !found {
+ return "", ErrIdentity
+ }
+ for _, r := range census.Records {
+ planned, ok := p.Hosts[r.Holder.Host]
+ if !ok || planned.Key != r.Holder.Key || planned.ETag != r.ETag || planned.Acquisition == "" || planned.Acquisition != r.Holder.Acquisition {
+ return "", ErrIdentity
+ }
+ if err = CheckHostQuarantine(ctx, a.Store, r.Holder.Host); err != nil {
+ return "", err
+ }
+ }
+ _, fresh, err := a.Backfill.read(ctx)
+ if err != nil {
+ return "", err
+ }
+ if fresh != tag {
+ return "", ErrPending
+ }
+ return p.ID, nil
+}
diff --git a/internal/hostops/backfill_test.go b/internal/hostops/backfill_test.go
new file mode 100644
index 000000000..878cb53a2
--- /dev/null
+++ b/internal/hostops/backfill_test.go
@@ -0,0 +1,233 @@
+package hostops
+
+import (
+ "context"
+ "crypto/ed25519"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/domains"
+ "github.com/carlosframework/platform/internal/hostactors"
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func TestReconcileFrozenBackfillAndQuarantine(t *testing.T) {
+ ctx := context.Background()
+ f := newFixture(t)
+ pub, key, err := ed25519.GenerateKey(rand.Reader)
+ if err != nil {
+ t.Fatal(err)
+ }
+ sg := release.RootSigner(key)
+ roster := hostactors.Roster{V: 1, DeploymentBucket: "bucket", Revision: 1, Regions: map[string]hostactors.Region{"ie": {}, "us": {}}, Boxes: map[string]hostactors.Box{}, Consoles: map[string]hostactors.Console{}, Holds: map[string]hostactors.Reservation{}, Activations: map[string]hostactors.Reservation{}}
+ boxKeys := map[string]ed25519.PrivateKey{}
+ for i, ref := range []string{"box-a", "box-b"} {
+ boxPub, boxKey, e := ed25519.GenerateKey(rand.Reader)
+ if e != nil {
+ t.Fatal(e)
+ }
+ boxKeys[ref] = boxKey
+ region := "ie"
+ en := strings.Repeat("a", 32)
+ if i == 1 {
+ region = "us"
+ en = strings.Repeat("b", 32)
+ }
+ roster.Boxes[ref] = hostactors.Box{CensusPublicKey: boxPub, Enrollment: en, Namespace: "platform", BoxID: ref, Label: ref, Region: region, Pool: "default", Cloud: hostactors.Cloud{Provider: "aws", Account: "123", Region: region, InstanceID: "i-" + ref}, EvidenceSHA256: strings.Repeat("c", 64), State: "active"}
+ }
+ put := func(k string, v any, signed bool) string {
+ t.Helper()
+ var raw []byte
+ var e error
+ if signed {
+ raw, e = release.SignRootEnvelope(sg, v)
+ } else {
+ raw, e = json.Marshal(v)
+ }
+ if e != nil {
+ t.Fatal(e)
+ }
+ tag, e := f.store.Put(ctx, k, raw, "")
+ if e != nil {
+ t.Fatal(e)
+ }
+ return tag
+ }
+ put(hostactors.Key, roster, true)
+ d := hostactors.Directory{Store: f.store, PublicKey: pub, Bucket: "bucket"}
+ c := NewBackfillController(f.store, d, func() time.Time { return f.now }, f.c.NewID)
+ r := f.record(t, "legacy.example.com")
+ var body map[string]any
+ if err = json.Unmarshal(r.Body, &body); err != nil {
+ t.Fatal(err)
+ }
+ body["acq"] = "ownership-acquisition"
+ put(r.Holder.Key, body, false)
+ r, err = ReadRecord(ctx, f.store, r.Holder.Key)
+ if err != nil {
+ t.Fatal(err)
+ }
+ inc, err := r.Incarnation()
+ if err != nil {
+ t.Fatal(err)
+ }
+ put(domains.ClaimKey(r.Holder.Host), domains.Claim{Schema: 1, Host: r.Holder.Host, Account: "bab", App: "jam", Acquisition: "ownership-acquisition", RecordIncarnation: inc, OwnershipState: "committed"}, false)
+ if err = c.ReconcileClaims(ctx); !errors.Is(err, ErrPending) {
+ t.Fatalf("unfrozen adoption: %v", err)
+ }
+ obs, err := hostclaim.Read(ctx, f.store, r.Holder.Host)
+ if err != nil || !obs.Absent {
+ t.Fatal(obs, err)
+ }
+ p := BackfillPlan{V: 1, ID: "backfill", Phase: "adopting", DirectoryRevision: 1, CreatedAt: f.now, Reports: map[string]string{}, Hosts: map[string]BackfillHost{r.Holder.Host: {Key: r.Holder.Key, ETag: r.ETag, Acquisition: "ownership-acquisition", Region: "ie", Box: "box-a"}}}
+ for ref, b := range roster.Boxes {
+ report := CensusReport{Box: ref, Enrollment: b.Enrollment, BootID: "boot", Reservation: p.ID, DirectoryRevision: 1, CapturedAt: f.now, Complete: true, Installations: []CensusInstallation{}}
+ if ref == "box-a" {
+ report.Installations = append(report.Installations, CensusInstallation{Host: r.Holder.Host, Account: "bab", App: "jam", Family: "instance", Key: r.Holder.Key, Incarnation: inc, Local: true, Route: true})
+ }
+ key, _ := reportKey(p.ID, ref)
+ body, e := json.Marshal(report)
+ if e != nil {
+ t.Fatal(e)
+ }
+ put(key, CensusEnvelope{Report: report, Signature: ed25519.Sign(boxKeys[ref], body)}, false)
+ raw, _, err := f.store.Get(ctx, key)
+ if err != nil {
+ t.Fatal(err)
+ }
+ sum := sha256.Sum256(raw)
+ p.Reports[ref] = hex.EncodeToString(sum[:])
+ }
+ put(BackfillKey, p, true)
+ qtag := put(HostsQuarantineKey, HostsQuarantine{V: 1, Hosts: map[string]string{r.Holder.Host: "conflict"}}, false)
+ if err = c.ReconcileClaims(ctx); !errors.Is(err, ErrPending) {
+ t.Fatalf("quarantine: %v", err)
+ }
+ raw, _ := json.Marshal(HostsQuarantine{V: 1, Hosts: map[string]string{}})
+ if _, err = f.store.Put(ctx, HostsQuarantineKey, raw, qtag); err != nil {
+ t.Fatal(err)
+ }
+
+ // A digest listed by a root manifest cannot turn a forged box report into
+ // an authenticated census. Test the verifier before its cache is populated.
+ reportPath, _ := reportKey(p.ID, "box-a")
+ authentic, reportTag, e := f.store.Get(ctx, reportPath)
+ if e != nil {
+ t.Fatal(e)
+ }
+ var forged CensusEnvelope
+ if e = json.Unmarshal(authentic, &forged); e != nil {
+ t.Fatal(e)
+ }
+ forged.Signature[0] ^= 1
+ forgedRaw, _ := json.Marshal(forged)
+ forgedTag, e := f.store.Put(ctx, reportPath, forgedRaw, reportTag)
+ if e != nil {
+ t.Fatal(e)
+ }
+ sum := sha256.Sum256(forgedRaw)
+ p.Reports["box-a"] = hex.EncodeToString(sum[:])
+ _, manifestTag, e := f.store.Get(ctx, BackfillKey)
+ if e != nil {
+ t.Fatal(e)
+ }
+ signed, _ := release.SignRootEnvelope(sg, p)
+ manifestTag, e = f.store.Put(ctx, BackfillKey, signed, manifestTag)
+ if e != nil {
+ t.Fatal(e)
+ }
+ if e = c.ReconcileClaims(ctx); e == nil {
+ t.Fatal("accepted forged census")
+ }
+ if _, e = f.store.Put(ctx, reportPath, authentic, forgedTag); e != nil {
+ t.Fatal(e)
+ }
+ sum = sha256.Sum256(authentic)
+ p.Reports["box-a"] = hex.EncodeToString(sum[:])
+ signed, _ = release.SignRootEnvelope(sg, p)
+ if _, e = f.store.Put(ctx, BackfillKey, signed, manifestTag); e != nil {
+ t.Fatal(e)
+ }
+ if err = c.ReconcileClaims(ctx); err != nil {
+ t.Fatal(err)
+ }
+ obs, err = hostclaim.Read(ctx, f.store, r.Holder.Host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if obs.Absent || obs.Claim.Holder.Acquisition != "ownership-acquisition" || obs.Claim.Op.Exec != "box-a" || obs.Claim.Touched.Complete || len(obs.Claim.Obligations) != 2 {
+ t.Fatalf("bad backfill: %+v", obs.Claim)
+ }
+ before := obs.ETag
+ if err = c.ReconcileClaims(ctx); err != nil {
+ t.Fatal(err)
+ }
+ obs, err = hostclaim.Read(ctx, f.store, r.Holder.Host)
+ if err != nil || obs.ETag != before {
+ t.Fatal("non-idempotent", err)
+ }
+}
+func TestReconcileDiscardPartialCensus(t *testing.T) {
+ f := newFixture(t)
+ r := f.record(t, "legacy.example.com")
+ if _, err := f.store.Put(f.ctx, r.Holder.Key, r.Body, ""); err != nil {
+ t.Fatal(err)
+ }
+ fail := errors.New("second listing failed")
+ f.store.afterList = func(prefix string, keys []string) error {
+ if strings.Contains(prefix, "sidecars/") {
+ return fail
+ }
+ return nil
+ }
+ calls := 0
+ err := ReconcileClaims(f.ctx, f.c, func(context.Context, string) (hostclaim.Observed, error) { calls++; return hostclaim.Observed{}, nil })
+ if !errors.Is(err, fail) || calls != 0 {
+ t.Fatal(err, calls)
+ }
+}
+
+func TestClaimDeleteDoorNeverAdopts(t *testing.T) {
+ f := newFixture(t)
+ r := f.record(t, "legacy.example.com")
+ if _, err := f.store.Put(f.ctx, r.Holder.Key, r.Body, ""); err != nil {
+ t.Fatal(err)
+ }
+ inc, err := r.Incarnation()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err = f.c.BeginRetire(f.ctx, r.Holder.Key, "test", inc); !errors.Is(err, ErrPending) {
+ t.Fatal(err)
+ }
+ obs, err := hostclaim.Read(f.ctx, f.store, r.Holder.Host)
+ if err != nil || !obs.Absent {
+ t.Fatalf("door adopted: %+v %v", obs, err)
+ }
+}
+
+func TestBackfillAuthorityCannotGainMutationMethods(t *testing.T) {
+ c := NewBackfillController(nil, hostactors.Directory{}, time.Now, func() string { return "id" })
+ if _, ok := c.Authority.(interface {
+ StartCreate(context.Context, hostclaim.Observed, Record, string, string) (hostclaim.Observed, error)
+ }); ok {
+ t.Fatal("backfill gained create authority")
+ }
+ if _, ok := c.Authority.(interface {
+ StartDeleteClaim(context.Context, hostclaim.Observed, Record, string) (hostclaim.Observed, error)
+ }); ok {
+ t.Fatal("backfill gained delete authority")
+ }
+ if _, ok := c.Authority.(interface {
+ RecordEffects(context.Context, hostclaim.Observed, Record) error
+ }); ok {
+ t.Fatal("backfill gained effect authority")
+ }
+}
diff --git a/internal/hostops/operations.go b/internal/hostops/operations.go
index 4409ef9b5..aea6021d2 100644
--- a/internal/hostops/operations.go
+++ b/internal/hostops/operations.go
@@ -142,10 +142,29 @@ func (c Controller) Adopt(ctx context.Context, r Record) (hostclaim.Observed, er
return obs, err
}
if !obs.Absent {
- if !matches(r, obs.Claim.Holder) {
+ if matches(r, obs.Claim.Holder) {
+ return obs, nil
+ }
+ if obs.Claim.Holder == nil || obs.Claim.Holder.Acquisition != "" || r.Holder.Acquisition == "" {
+ return obs, ErrIdentity
+ }
+ // #232 also repairs already-claimed legacy holders while globally
+ // frozen. Adoption authority verifies the exact manifest/ownership.
+ prior := *obs.Claim.Holder
+ prior.Acquisition = r.Holder.Acquisition
+ if !matches(r, &prior) {
return obs, ErrIdentity
}
- return obs, nil
+ if _, _, _, _, err := c.Authority.Adoption(ctx, r); err != nil {
+ return obs, err
+ }
+ return hostclaim.Update(ctx, c.Store, obs, func(next *hostclaim.Claim) error {
+ if next.Holder == nil || next.Holder.Acquisition != "" {
+ return ErrPending
+ }
+ next.Holder.Acquisition = r.Holder.Acquisition
+ return nil
+ })
}
region, box, regions, boxes, err := c.Authority.Adoption(ctx, r)
if err != nil {
@@ -181,10 +200,16 @@ func (c Controller) BeginRetire(ctx context.Context, key, by, expectedInc string
if expectedInc == "" || inc != expectedInc {
return hostclaim.Observed{}, ErrIdentity
}
- obs, err := c.Adopt(ctx, r)
+ obs, err := hostclaim.Read(ctx, c.Store, r.Holder.Host)
if err != nil {
return obs, err
}
+ if obs.Absent {
+ return obs, ErrPending
+ }
+ if !matches(r, obs.Claim.Holder) {
+ return obs, ErrIdentity
+ }
if err := c.Authority.StartDelete(ctx, obs, r); err != nil {
return obs, err
}
diff --git a/internal/hostops/record.go b/internal/hostops/record.go
index 7616db1b0..25223712f 100644
--- a/internal/hostops/record.go
+++ b/internal/hostops/record.go
@@ -51,7 +51,13 @@ func ReadRecord(ctx context.Context, s release.Store, key string) (Record, error
if etag == "" {
return Record{}, ErrIdentity
}
- n := hostclaim.NewHolder{Key: key, Account: parts[1], App: parts[2]}
+ var binding struct {
+ Acquisition string `json:"acq"`
+ }
+ if err := decode(body, &binding); err != nil {
+ return Record{}, err
+ }
+ n := hostclaim.NewHolder{Acquisition: binding.Acquisition, Key: key, Account: parts[1], App: parts[2]}
switch parts[3] {
case "instances":
var r provision.Record
@@ -92,11 +98,14 @@ func matches(r Record, h *hostclaim.Holder) bool {
}
n := r.Holder
inc, err := r.Incarnation()
- return err == nil && n.Key == h.Key && n.Family == h.Family && n.Account == h.Account && n.App == h.App && n.Name == h.Name && n.CreatedAt.Equal(h.CreatedAt) && inc == h.Incarnation
+ return err == nil && n.Acquisition == h.Acquisition && n.Key == h.Key && n.Family == h.Family && n.Account == h.Account && n.App == h.App && n.Name == h.Name && n.CreatedAt.Equal(h.CreatedAt) && inc == h.Incarnation
}
// The caller reads the record first. This function performs the later claim read.
func RemoveOrphan(ctx context.Context, s release.Store, r Record) (bool, error) {
+ if err := CheckHostQuarantine(ctx, s, r.Holder.Host); err != nil {
+ return false, err
+ }
inc, err := r.Incarnation()
if err != nil {
return false, err
@@ -155,7 +164,7 @@ func PrepareInstance(r provision.Record, now time.Time, region string) (Record,
if err != nil {
return Record{}, err
}
- n := hostclaim.NewHolder{Family: "instance", Key: provision.RecordKey(r.Account, r.App, r.Host), Account: r.Account, App: r.App, Host: r.Host, RecordRegion: r.Region, Region: region, CreatedAt: r.CreatedAt}
+ n := hostclaim.NewHolder{Acquisition: r.Acquisition, Family: "instance", Key: provision.RecordKey(r.Account, r.App, r.Host), Account: r.Account, App: r.App, Host: r.Host, RecordRegion: r.Region, Region: region, CreatedAt: r.CreatedAt}
return Record{Holder: n, Body: body}, nil
}
@@ -168,6 +177,6 @@ func PrepareSidecar(r provision.SidecarRecord, now time.Time, region string) (Re
if err != nil {
return Record{}, err
}
- n := hostclaim.NewHolder{Family: "sidecar", Key: provision.SidecarRecordKey(r.Account, r.App, r.Name), Account: r.Account, App: r.App, Name: r.Name, Host: r.Host, RecordRegion: r.Region, Region: region, CreatedAt: r.CreatedAt}
+ n := hostclaim.NewHolder{Acquisition: r.Acquisition, Family: "sidecar", Key: provision.SidecarRecordKey(r.Account, r.App, r.Name), Account: r.Account, App: r.App, Name: r.Name, Host: r.Host, RecordRegion: r.Region, Region: region, CreatedAt: r.CreatedAt}
return Record{Holder: n, Body: body}, nil
}
diff --git a/internal/hostops/recovery.go b/internal/hostops/recovery.go
index c134a69b1..26a9f2929 100644
--- a/internal/hostops/recovery.go
+++ b/internal/hostops/recovery.go
@@ -178,7 +178,7 @@ func (c Controller) SweepAllRecords(ctx context.Context) error {
continue
}
if obs.Absent {
- _, err = c.Adopt(ctx, record)
+ err = ErrPending
} else {
_, err = RemoveOrphan(ctx, c.Store, record)
}
diff --git a/internal/hostops/retirement.go b/internal/hostops/retirement.go
index 573d30b40..f0ee2979d 100644
--- a/internal/hostops/retirement.go
+++ b/internal/hostops/retirement.go
@@ -29,10 +29,7 @@ func (c Controller) RetireRecord(ctx context.Context, ref provision.RetireRef) e
if errors.Is(rerr, os.ErrNotExist) {
return nil
}
- obs, err = c.Adopt(ctx, rec)
- if err != nil {
- return err
- }
+ return ErrPending
}
h := obs.Claim.Holder
if h == nil {
diff --git a/internal/provision/claim_export.go b/internal/provision/claim_export.go
new file mode 100644
index 000000000..3a41dc9e6
--- /dev/null
+++ b/internal/provision/claim_export.go
@@ -0,0 +1,3 @@
+package provision
+
+func ClaimEverywhere(rec Record) bool { return everywhereClass(rec) }
diff --git a/internal/provision/provision.go b/internal/provision/provision.go
index 2616cdfda..5d1f0b311 100644
--- a/internal/provision/provision.go
+++ b/internal/provision/provision.go
@@ -37,6 +37,7 @@ import (
// Account vs the key's segments); GetRecord and ListRecords enforce that, a
// record that lies about identity is never guessed at (design §3.2).
type Record struct {
+ Acquisition string `json:"acq,omitempty"`
// Maintenance is preserved by ordinary writers; only a future guarded
// maintenance transition may install, replace, or clear it.
Maintenance MaintenanceProjection `json:"maintenance,omitempty"`
diff --git a/internal/provision/sidecar.go b/internal/provision/sidecar.go
index d6891ea85..f49fbaa4f 100644
--- a/internal/provision/sidecar.go
+++ b/internal/provision/sidecar.go
@@ -69,6 +69,7 @@ type SidecarManifest struct {
// identity is never guessed at, the same discipline GetRecord applies to
// instances.
type SidecarRecord struct {
+ Acquisition string `json:"acq,omitempty"`
Schema int `json:"schema"` // 1
Name string `json:"name"`
Host string `json:"host"`