Merge origin/main (3407d114, claim C5/C7) into moves-phase1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
36 files changed,
+3987
−24
cmd/console/main.go+4 −0internal/console/hostclaimbackfill.go+65 −0internal/console/hostclaimbackfill_test.go+84 −0internal/console/hostclaimops.go+63 −0internal/console/hostclaimops_test.go+31 −0internal/console/server.go+4 −1internal/domains/claims.go+10 −7internal/host/agent.go+6 −2internal/host/claimbackfill.go+32 −0internal/host/claimbackfill_test.go+230 −0internal/hostactors/censuskey.go+59 −0internal/hostactors/roster.go+15 −11internal/hostclaim/adopt.go+1 −1internal/hostclaim/claim.go+5 −1internal/hostclaim/create.go+2 −1internal/hostclaim/fence.go+109 −0internal/hostclaim/relocating.go+142 −0internal/hostclaim/relocating_test.go+251 −0internal/hostfence/claim_fence.go+38 −0internal/hostfence/claim_fence_test.go+72 −0internal/hostops/backfill.go+380 −0internal/hostops/backfill_controller.go+123 −0internal/hostops/backfill_publish.go+162 −0internal/hostops/backfill_test.go+357 −0internal/hostops/operations.go+304 −0internal/hostops/operations_test.go+629 −0internal/hostops/purge.go+196 −0internal/hostops/record.go+182 −0internal/hostops/recovery.go+216 −0internal/hostops/remote.go+47 −0internal/hostops/retirement.go+69 −0internal/provision/claim_export.go+3 −0internal/provision/provision.go+1 −0internal/provision/retirement.go+53 −0internal/provision/retirement_test.go+41 −0internal/provision/sidecar.go+1 −0
diff --git a/cmd/console/main.go b/cmd/console/main.go| index c02393d23..11923395b 100644 |
| --- a/cmd/console/main.go |
| +++ b/cmd/console/main.go |
| @@ -429,6 +429,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.Printf("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..92fc5881b |
| --- /dev/null |
| +++ b/internal/console/hostclaimbackfill.go |
| @@ -0,0 +1,65 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "crypto/rand" |
| + "encoding/hex" |
| + "errors" |
| + "log" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/hostactors" |
| + "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 { |
| + s.cfg.HostClaimOps = nil |
| + log.Printf("console: claim recovery: %v", hostops.ErrContract) |
| + return nil |
| + } |
| + 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...) |
| +} |
diff --git a/internal/console/hostclaimbackfill_test.go b/internal/console/hostclaimbackfill_test.go| new file mode 100644 |
| index 000000000..e51440e14 |
| --- /dev/null |
| +++ b/internal/console/hostclaimbackfill_test.go |
| @@ -0,0 +1,84 @@ |
| +package console |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "crypto/ed25519" |
| + "crypto/rand" |
| + "log" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/hostops" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +func TestConfigureClaimBackfillMissingContractDoesNotFailStartup(t *testing.T) { |
| + for _, missing := range []string{"store", "bucket", "public-key"} { |
| + t.Run(missing, func(t *testing.T) { |
| + s := &Server{cfg: Config{HostClaim: true, Releases: release.NewDirStore(t.TempDir()), Bucket: "bucket", ReleasePub: make(ed25519.PublicKey, ed25519.PublicKeySize)}} |
| + switch missing { |
| + case "store": |
| + s.cfg.Releases = nil |
| + case "bucket": |
| + s.cfg.Bucket = "" |
| + case "public-key": |
| + s.cfg.ReleasePub = nil |
| + } |
| + var logs bytes.Buffer |
| + previous := log.Writer() |
| + log.SetOutput(&logs) |
| + t.Cleanup(func() { log.SetOutput(previous) }) |
| + if err := s.ConfigureClaimBackfill(); err != nil { |
| + t.Errorf("configuration failed startup: %v", err) |
| + } |
| + if s.cfg.HostClaimOps != nil || !strings.Contains(logs.String(), hostops.ErrContract.Error()) { |
| + t.Fatalf("ops=%v log=%q", s.cfg.HostClaimOps, logs.String()) |
| + } |
| + s.StartClaimRecoveryPass(context.Background()) |
| + }) |
| + } |
| +} |
| + |
| +func TestClaimBackfillQuietWithoutAdoptingPlan(t *testing.T) { |
| + for _, phase := range []string{"absent", "collecting", "done", "future"} { |
| + t.Run(phase, func(t *testing.T) { |
| + pub, key, err := ed25519.GenerateKey(rand.Reader) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + store := release.NewDirStore(t.TempDir()) |
| + if phase != "absent" { |
| + raw, err := release.SignRootEnvelope(release.RootSigner(key), hostops.BackfillPlan{V: 1, ID: "backfill", Phase: phase, CreatedAt: time.Now()}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := store.Put(context.Background(), hostops.BackfillKey, raw, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + s := &Server{cfg: Config{HostClaim: true, Releases: store, Bucket: "bucket", ReleasePub: pub}} |
| + if err := s.ConfigureClaimBackfill(); err != nil { |
| + t.Fatal(err) |
| + } |
| + ops := s.cfg.HostClaimOps |
| + if ops == nil { |
| + t.Fatal("backfill not configured") |
| + } |
| + if err := s.ConfigureClaimBackfill(); err != nil || s.cfg.HostClaimOps != ops { |
| + t.Fatal("controller replaced", err) |
| + } |
| + var logs bytes.Buffer |
| + previous := log.Writer() |
| + log.SetOutput(&logs) |
| + t.Cleanup(func() { log.SetOutput(previous) }) |
| + ctx, cancel := context.WithCancel(context.Background()) |
| + cancel() |
| + s.StartClaimRecoveryPass(ctx) |
| + if logs.Len() != 0 { |
| + t.Fatalf("inactive backfill logged: %s", &logs) |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/console/hostclaimops.go b/internal/console/hostclaimops.go| new file mode 100644 |
| index 000000000..ef9aa7647 |
| --- /dev/null |
| +++ b/internal/console/hostclaimops.go |
| @@ -0,0 +1,63 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "log" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/hostops" |
| + "github.com/carlosframework/platform/internal/provision" |
| +) |
| + |
| +func (s *Server) retireHostRecord(ctx context.Context, ref provision.RetireRef) error { |
| + var claims provision.ClaimRetirer |
| + if s.cfg.HostClaimOps != nil { |
| + claims = s.cfg.HostClaimOps |
| + } |
| + return provision.RetireRecord(ctx, s.cfg.Releases, ref, provision.RetireOptions{HostClaim: s.cfg.HostClaim, Claims: claims}) |
| +} |
| +func (s *Server) adoptHostRecord(ctx context.Context, key string) (hostclaim.Observed, error) { |
| + if !s.cfg.HostClaim || s.cfg.HostClaimOps == nil { |
| + return hostclaim.Observed{}, hostops.ErrContract |
| + } |
| + rec, err := hostops.ReadRecord(ctx, s.cfg.Releases, key) |
| + if err != nil { |
| + return hostclaim.Observed{}, err |
| + } |
| + return s.cfg.HostClaimOps.Adopt(ctx, rec) |
| +} |
| + |
| +const claimRecoveryInterval = 2 * time.Minute |
| + |
| +// Called by serve, never NewServer. Tests can invoke the one-pass method. |
| +func (s *Server) StartClaimRecoveryPass(ctx context.Context) { |
| + if !s.cfg.HostClaim { |
| + return |
| + } |
| + 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.RunClaimRecoveryPass(ctx); err != nil { |
| + log.Printf("console: claim recovery: %v", err) |
| + } |
| + select { |
| + case <-ctx.Done(): |
| + return |
| + case <-t.C: |
| + } |
| + } |
| +} |
| +func (s *Server) claimRecoveryPass(ctx context.Context) error { |
| + if !s.cfg.HostClaim { |
| + return nil |
| + } |
| + if s.cfg.HostClaimRecovery == nil { |
| + return hostops.ErrContract |
| + } |
| + return s.cfg.HostClaimRecovery.Pass(ctx) |
| +} |
diff --git a/internal/console/hostclaimops_test.go b/internal/console/hostclaimops_test.go| new file mode 100644 |
| index 000000000..841d8f0f4 |
| --- /dev/null |
| +++ b/internal/console/hostclaimops_test.go |
| @@ -0,0 +1,31 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/hostops" |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +func TestClaimRecoveryAndRetirementAreInert(t *testing.T) { |
| + s := &Server{cfg: Config{Releases: release.NewDirStore(t.TempDir())}} |
| + ctx := context.Background() |
| + if err := s.claimRecoveryPass(ctx); err != nil { |
| + t.Fatal(err) |
| + } |
| + s.StartClaimRecoveryPass(ctx) // Must return without a ticker or store reads. |
| + ref := provision.RetireRef{Family: "instance", Account: "bab", App: "jam", Host: "shop.bab.example.com"} |
| + if err := s.retireHostRecord(ctx, ref); err != nil { |
| + t.Fatal(err) |
| + } |
| + s.cfg.HostClaim = true |
| + if err := s.claimRecoveryPass(ctx); !errors.Is(err, hostops.ErrContract) { |
| + t.Fatal(err) |
| + } |
| + if err := s.retireHostRecord(ctx, ref); !errors.Is(err, provision.ErrClaimRetirer) { |
| + t.Fatal(err) |
| + } |
| +} |
diff --git a/internal/console/server.go b/internal/console/server.go| index a6e495272..56c3cbd40 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -14,6 +14,7 @@ import ( |
| "encoding/json" |
| "errors" |
| "fmt" |
| + "github.com/carlosframework/platform/internal/hostops" |
| "github.com/carlosframework/platform/internal/lease" |
| "html/template" |
| "log" |
| @@ -470,7 +471,9 @@ const ( |
| // Config is everything the server reads its world through. |
| type Config struct { |
| - HostConsoleID string |
| + HostClaimOps *hostops.Controller |
| + HostClaimRecovery *hostops.Recovery |
| + HostConsoleID string |
| HostClaim bool |
| DBVersions lease.VersionedStore |
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 ef5f97bbd..bd96fc692 100644 |
| --- a/internal/host/agent.go |
| +++ b/internal/host/agent.go |
| @@ -551,7 +551,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 |
| @@ -2772,7 +2772,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 { |
| @@ -2780,6 +2780,7 @@ func instancesLoop(reg *registry.Registry) { |
| return |
| } |
| cfg := provisionTickConfig(os.Getenv) |
| + backfill := newClaimBackfill(writes, store, cfg.ReleasePub, os.Getenv("CARLOS_DEPLOYMENT_BUCKET")) |
| log.Printf("agent: instances loop armed (5min + jitter), region %q pools %v", cfg.Region, cfg.Pools) |
| // Platform-only: a fleet box (CARLOS_FLEET set) never reads other |
| // fleets' bindings — it only ever WRITES its own (fleetbind.go's |
| @@ -2793,6 +2794,9 @@ func instancesLoop(reg *registry.Registry) { |
| for { |
| time.Sleep(delay) |
| delay = 5*time.Minute + rand.N(time.Minute) |
| + if err := backfill(ctx); 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..51f7e7a2d |
| --- /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 newClaimBackfill(writes hostwrite.Config, s release.Store, pub ed25519.PublicKey, bucket string) func(context.Context) error { |
| + if !writes.Enabled { |
| + return func(context.Context) error { return nil } |
| + } |
| + if s == nil || len(pub) == 0 || bucket == "" { |
| + return func(context.Context) error { 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 |
| +} |
diff --git a/internal/host/claimbackfill_test.go b/internal/host/claimbackfill_test.go| new file mode 100644 |
| index 000000000..c2d57e2a9 |
| --- /dev/null |
| +++ b/internal/host/claimbackfill_test.go |
| @@ -0,0 +1,230 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "crypto/ed25519" |
| + "crypto/rand" |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "encoding/json" |
| + "errors" |
| + "go/ast" |
| + "go/parser" |
| + "go/token" |
| + "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/hostops" |
| + "github.com/carlosframework/platform/internal/hostwrite" |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +func TestClaimBackfillRunsOnlyOnInstancesCadence(t *testing.T) { |
| + file, err := parser.ParseFile(token.NewFileSet(), "agent.go", nil, 0) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, decl := range file.Decls { |
| + fn, ok := decl.(*ast.FuncDecl) |
| + if !ok { |
| + continue |
| + } |
| + switch fn.Name.Name { |
| + case "fastConvergeLoop": |
| + ast.Inspect(fn.Body, func(n ast.Node) bool { |
| + call, ok := n.(*ast.CallExpr) |
| + if !ok { |
| + return true |
| + } |
| + id, ok := call.Fun.(*ast.Ident) |
| + if ok && (id.Name == "reconcileBackfill" || id.Name == "newClaimBackfill") { |
| + t.Error("backfill runs on the serial promote path") |
| + } |
| + return true |
| + }) |
| + case "instancesLoop": |
| + constructors := 0 |
| + for _, stmt := range fn.Body.List { |
| + loop, inLoop := stmt.(*ast.ForStmt) |
| + var node ast.Node = stmt |
| + if inLoop { |
| + node = loop.Body |
| + } |
| + ast.Inspect(node, func(n ast.Node) bool { |
| + call, ok := n.(*ast.CallExpr) |
| + if !ok { |
| + return true |
| + } |
| + id, ok := call.Fun.(*ast.Ident) |
| + if ok && id.Name == "newClaimBackfill" { |
| + constructors++ |
| + if inLoop { |
| + t.Error("backfill controller constructed per tick") |
| + } |
| + } |
| + return true |
| + }) |
| + } |
| + if constructors != 1 { |
| + t.Errorf("instances loop has %d persistent backfill constructors, want 1", constructors) |
| + } |
| + } |
| + } |
| +} |
| + |
| +type backfillPassStore struct { |
| + release.Store |
| + gets, censusReads int |
| + failClaim error |
| +} |
| + |
| +func (s *backfillPassStore) Get(ctx context.Context, key string) ([]byte, string, error) { |
| + s.gets++ |
| + if strings.HasPrefix(key, "control/host-census/") { |
| + s.censusReads++ |
| + } |
| + return s.Store.Get(ctx, key) |
| +} |
| + |
| +func (s *backfillPassStore) Put(ctx context.Context, key string, body []byte, tag string) (string, error) { |
| + if strings.HasPrefix(key, "control/hosts/") && s.failClaim != nil { |
| + err := s.failClaim |
| + s.failClaim = nil |
| + return "", err |
| + } |
| + return s.Store.Put(ctx, key, body, tag) |
| +} |
| + |
| +func TestClaimBackfillPassInertAndMissingContract(t *testing.T) { |
| + store := &backfillPassStore{Store: release.NewDirStore(t.TempDir())} |
| + ctx := context.Background() |
| + if err := newClaimBackfill(hostwrite.Config{}, store, nil, "")(ctx); err != nil || store.gets != 0 { |
| + t.Fatal("flag off accessed backfill", err, store.gets) |
| + } |
| + for _, missing := range []string{"store", "key", "bucket"} { |
| + var s release.Store = store |
| + pub := make(ed25519.PublicKey, ed25519.PublicKeySize) |
| + bucket := "bucket" |
| + switch missing { |
| + case "store": |
| + s = nil |
| + case "key": |
| + pub = nil |
| + case "bucket": |
| + bucket = "" |
| + } |
| + if err := newClaimBackfill(hostwrite.Config{Enabled: true}, s, pub, bucket)(ctx); !errors.Is(err, hostops.ErrContract) || store.gets != 0 { |
| + t.Fatalf("missing %s: err=%v reads=%d", missing, err, store.gets) |
| + } |
| + } |
| +} |
| + |
| +func TestClaimBackfillPassKeepsCensusBetweenRetries(t *testing.T) { |
| + ctx := context.Background() |
| + store := &backfillPassStore{Store: release.NewDirStore(t.TempDir())} |
| + pub, key, err := ed25519.GenerateKey(rand.Reader) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + boxPub, boxKey, err := ed25519.GenerateKey(rand.Reader) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + now := time.Now().UTC() |
| + put := func(path string, value any, signed bool) { |
| + t.Helper() |
| + raw, err := json.Marshal(value) |
| + if signed { |
| + raw, err = release.SignRootEnvelope(release.RootSigner(key), value) |
| + } |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := store.Put(ctx, path, raw, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + enrollment := strings.Repeat("a", 32) |
| + roster := hostactors.Roster{V: 1, DeploymentBucket: "bucket", Revision: 1, |
| + Regions: map[string]hostactors.Region{"ie": {}}, |
| + Boxes: map[string]hostactors.Box{"box-a": {CensusPublicKey: boxPub, Enrollment: enrollment, Namespace: "platform", BoxID: "box-a", Label: "box-a", Region: "ie", Pool: "default", Cloud: hostactors.Cloud{Provider: "aws", Account: "123", Region: "ie", InstanceID: "i-box-a"}, EvidenceSHA256: strings.Repeat("c", 64), State: "active"}}, |
| + Consoles: map[string]hostactors.Console{}, Holds: map[string]hostactors.Reservation{}, Activations: map[string]hostactors.Reservation{}} |
| + put(hostactors.Key, roster, true) |
| + r, err := hostops.PrepareInstance(provision.Record{Schema: 1, Account: "bab", App: "jam", Host: "legacy.example.com", Slug: "shop", Channel: "edge", Region: "ie", Acquisition: "acquisition"}, now, "ie") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := store.Put(ctx, r.Holder.Key, r.Body, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + r, err = hostops.ReadRecord(ctx, 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: "acquisition", RecordIncarnation: inc, OwnershipState: "committed"}, false) |
| + report := hostops.CensusReport{Box: "box-a", Enrollment: enrollment, BootID: "boot", Reservation: "backfill", DirectoryRevision: 1, CapturedAt: now, Complete: true, Installations: []hostops.CensusInstallation{{Host: r.Holder.Host, Account: "bab", App: "jam", Family: "instance", Key: r.Holder.Key, Incarnation: inc, Local: true, Route: true}}} |
| + canonical, err := json.Marshal(report) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + reportPath := "control/host-census/backfill/box-a.json" |
| + put(reportPath, hostops.CensusEnvelope{Report: report, Signature: ed25519.Sign(boxKey, canonical)}, false) |
| + raw, _, err := store.Get(ctx, reportPath) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + digest := sha256.Sum256(raw) |
| + put(hostops.BackfillKey, hostops.BackfillPlan{V: 1, ID: "backfill", Phase: "adopting", DirectoryRevision: 1, CreatedAt: now, Reports: map[string]string{"box-a": hex.EncodeToString(digest[:])}, Hosts: map[string]hostops.BackfillHost{r.Holder.Host: {Key: r.Holder.Key, ETag: r.ETag, Acquisition: "acquisition", Region: "ie", Box: "box-a"}}}, true) |
| + store.censusReads = 0 |
| + pass := newClaimBackfill(hostwrite.Config{Enabled: true}, store, pub, "bucket") |
| + fail := errors.New("claim write failed") |
| + store.failClaim = fail |
| + if err := pass(ctx); !errors.Is(err, fail) { |
| + t.Fatalf("first pass: %v", err) |
| + } |
| + if err := pass(ctx); err != nil { |
| + t.Fatalf("retry: %v", err) |
| + } |
| + if store.censusReads != 1 { |
| + t.Fatalf("census reads=%d, want one across both passes", store.censusReads) |
| + } |
| + obs, err := hostclaim.Read(ctx, store, r.Holder.Host) |
| + if err != nil || obs.Absent || obs.Claim.Holder.Acquisition != "acquisition" || obs.Claim.Op.Exec != "box-a" { |
| + t.Fatalf("retry did not adopt: %+v %v", obs, err) |
| + } |
| +} |
| + |
| +func TestClaimBackfillPassQuietWithoutAdoptingPlan(t *testing.T) { |
| + for _, phase := range []string{"absent", "collecting", "done", "future"} { |
| + t.Run(phase, func(t *testing.T) { |
| + ctx := context.Background() |
| + pub, key, err := ed25519.GenerateKey(rand.Reader) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + store := &backfillPassStore{Store: release.NewDirStore(t.TempDir())} |
| + if phase != "absent" { |
| + raw, err := release.SignRootEnvelope(release.RootSigner(key), hostops.BackfillPlan{V: 1, ID: "backfill", Phase: phase, CreatedAt: time.Now()}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := store.Put(ctx, hostops.BackfillKey, raw, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + pass := newClaimBackfill(hostwrite.Config{Enabled: true}, store, pub, "bucket") |
| + if err := pass(ctx); err != nil || store.gets != 1 { |
| + t.Fatalf("inactive backfill: err=%v reads=%d", err, store.gets) |
| + } |
| + }) |
| + } |
| +} |
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..e20b19823 |
| --- /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) { |
| + p, tag, err := b.readPlan(ctx) |
| + if err != nil { |
| + return p, "", err |
| + } |
| + if p.Phase != "adopting" { |
| + return p, "", ErrContract |
| + } |
| + return p, tag, nil |
| +} |
| +func (b *Backfill) readPlan(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.CreatedAt.IsZero() || (p.Phase == "adopting" && (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 |
| + } |
| + } |
| + 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..08ebd8567 |
| --- /dev/null |
| +++ b/internal/hostops/backfill_controller.go |
| @@ -0,0 +1,123 @@ |
| +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 { |
| + plan, _, err := backfill.readPlan(ctx) |
| + if errors.Is(err, os.ErrNotExist) { |
| + return nil |
| + } |
| + if err != nil { |
| + return err |
| + } |
| + if plan.Phase != "adopting" { |
| + return nil |
| + } |
| + } |
| + 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..496d55747 |
| --- /dev/null |
| +++ b/internal/hostops/backfill_test.go |
| @@ -0,0 +1,357 @@ |
| +package hostops |
| + |
| +import ( |
| + "context" |
| + "crypto/ed25519" |
| + "crypto/rand" |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "encoding/json" |
| + "errors" |
| + "reflect" |
| + "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) { |
| + testReconcileFrozenBackfill(t, false, "") |
| +} |
| + |
| +func TestReconcileFrozenBackfillRepairsLegacyHolder(t *testing.T) { |
| + testReconcileFrozenBackfill(t, true, "") |
| +} |
| + |
| +func testReconcileFrozenBackfill(t *testing.T, legacy bool, scenario string) { |
| + t.Helper() |
| + 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"} |
| + } |
| + if scenario == "two-box-region" || scenario == "two-owners" { |
| + box := roster.Boxes["box-b"] |
| + box.Region, box.Cloud.Region = "ie", "ie" |
| + roster.Boxes["box-b"] = box |
| + } |
| + if scenario == "inactive-owner" || scenario == "wrong-region" { |
| + box := roster.Boxes["box-a"] |
| + if scenario == "inactive-owner" { |
| + box.State = "retired" |
| + } else { |
| + box.Region, box.Cloud.Region = "us", "us" |
| + } |
| + roster.Boxes["box-a"] = box |
| + } |
| + 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); err != nil { |
| + 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) |
| + } |
| + var prior hostclaim.Observed |
| + if legacy { |
| + nh := r.Holder |
| + nh.Acquisition = "" |
| + nh.Region = "ie" |
| + prior, err = hostclaim.Update(ctx, f.store, obs, func(claim *hostclaim.Claim) error { |
| + if err := hostclaim.Adopt(nh, "box-a", []string{"us"}, []string{"box-b"}, "legacy-discovery", f.c.NewID)(claim); err != nil { |
| + return err |
| + } |
| + claim.Holder.Stamp = 17 |
| + claim.Holder.Generation = "legacy-generation" |
| + return nil |
| + }) |
| + if err != nil { |
| + t.Fatal(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" || scenario == "two-owners" { |
| + 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 scenario == "two-owners" || scenario == "inactive-owner" || scenario == "wrong-region" { |
| + if err = c.ReconcileClaims(ctx); !errors.Is(err, ErrIdentity) { |
| + t.Fatalf("ineligible census owner: %v", err) |
| + } |
| + obs, err = hostclaim.Read(ctx, f.store, r.Holder.Host) |
| + if err != nil || !obs.Absent { |
| + t.Fatalf("ineligible owner adopted: %+v %v", obs, err) |
| + } |
| + return |
| + } |
| + 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) |
| + } |
| + wantDestinations := map[string]hostclaim.Obligation{ |
| + "park-replica": {Region: "us", Owner: "region:us"}, |
| + "park-local": {Box: "box-b", Owner: "box:box-b"}, |
| + } |
| + for _, obligation := range obs.Claim.Obligations { |
| + want, ok := wantDestinations[obligation.Kind] |
| + if !ok || obligation.Region != want.Region || obligation.Box != want.Box || obligation.Owner != want.Owner { |
| + t.Fatalf("wrong discovery destination: %+v; want %+v", obligation, want) |
| + } |
| + delete(wantDestinations, obligation.Kind) |
| + } |
| + if len(wantDestinations) != 0 { |
| + t.Fatalf("missing discovery destinations: %+v", wantDestinations) |
| + } |
| + if legacy { |
| + want := *prior.Claim |
| + holder := *want.Holder |
| + holder.Acquisition = "ownership-acquisition" |
| + want.Holder = &holder |
| + want.Gen++ |
| + if obs.ETag == prior.ETag || !reflect.DeepEqual(obs.Claim, &want) { |
| + t.Fatalf("legacy repair changed more than acquisition and claim generation: got %+v (holder %+v), want %+v (holder %+v)", obs.Claim, obs.Claim.Holder, &want, want.Holder) |
| + } |
| + } |
| + 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") |
| + } |
| +} |
| + |
| +func TestReconcileBackfillCensusOwnerEligibility(t *testing.T) { |
| + for _, scenario := range []string{"two-box-region", "two-owners", "inactive-owner", "wrong-region"} { |
| + t.Run(scenario, func(t *testing.T) { |
| + testReconcileFrozenBackfill(t, false, scenario) |
| + }) |
| + } |
| +} |
| + |
| +func TestReconcileBackfillPlanPhases(t *testing.T) { |
| + for _, phase := range []string{"absent", "collecting", "done", "future", "adopting", "bad-signature", "read-error"} { |
| + t.Run(phase, func(t *testing.T) { |
| + f := newFixture(t) |
| + pub, key, err := ed25519.GenerateKey(rand.Reader) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if phase != "absent" && phase != "read-error" { |
| + p := BackfillPlan{V: 1, ID: "backfill", Phase: phase, CreatedAt: f.now, Hosts: map[string]BackfillHost{}} |
| + raw, err := release.SignRootEnvelope(release.RootSigner(key), p) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err = f.store.Put(f.ctx, BackfillKey, raw, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if phase == "bad-signature" { |
| + pub = make(ed25519.PublicKey, ed25519.PublicKeySize) |
| + } |
| + if phase == "read-error" { |
| + f.store.failGet = BackfillKey |
| + } |
| + c := NewBackfillController(f.store, hostactors.Directory{Store: f.store, PublicKey: pub, Bucket: "bucket"}, f.c.Now, f.c.NewID) |
| + lists, adopts := 0, 0 |
| + f.store.afterList = func(string, []string) error { lists++; return nil } |
| + err = ReconcileClaims(f.ctx, c, func(context.Context, string) (hostclaim.Observed, error) { adopts++; return hostclaim.Observed{}, nil }) |
| + wantError := phase == "adopting" || phase == "bad-signature" || phase == "read-error" |
| + if (err != nil) != wantError || lists != 0 || adopts != 0 { |
| + t.Fatalf("phase %s: err=%v lists=%d adopts=%d", phase, err, lists, adopts) |
| + } |
| + if !wantError && phase != "absent" { |
| + if _, _, _, _, err := c.Authority.Adoption(f.ctx, Record{}); err == nil { |
| + t.Fatal("inactive plan granted direct adoption") |
| + } |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/hostops/operations.go b/internal/hostops/operations.go| new file mode 100644 |
| index 000000000..8313b80e6 |
| --- /dev/null |
| +++ b/internal/hostops/operations.go |
| @@ -0,0 +1,304 @@ |
| +package hostops |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "os" |
| + "slices" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// These callbacks are authority providers, not optional best-effort hints. |
| +// Production construction waits for the decisions named in this plan. |
| +type Authority interface { |
| + Create(context.Context, hostclaim.Observed, Record, string) error |
| + Executor(context.Context, hostclaim.Observed, Record) (string, error) |
| + StartDelete(context.Context, hostclaim.Observed, Record) error |
| + FirstPhase(context.Context, hostclaim.Observed) error |
| + Inventory(context.Context, hostclaim.Observed) ([]string, []string, error) |
| + Adoption(context.Context, Record) (region, box string, otherRegions, otherBoxes []string, err error) |
| + PurgeRetirement(context.Context, hostclaim.Observed) error |
| +} |
| + |
| +type Controller struct { |
| + Store release.Store |
| + Authority Authority |
| + Now func() time.Time |
| + NewID func() string |
| +} |
| + |
| +func (c Controller) ready() error { |
| + if c.Store == nil || c.Authority == nil || c.Now == nil || c.NewID == nil { |
| + return ErrContract |
| + } |
| + return nil |
| +} |
| + |
| +// Attempt consumes the claim observation taken BEFORE app registration. |
| +// Create's caller owns registration renewal/removal. A failed CAS is never retried here. |
| +func (c Controller) Attempt(ctx context.Context, before hostclaim.Observed, r Record, id, by string) (hostclaim.Observed, error) { |
| + if err := c.ready(); err != nil { |
| + return hostclaim.Observed{}, err |
| + } |
| + if before.Host != r.Holder.Host || r.Holder.CreatedAt.IsZero() || id == "" { |
| + return hostclaim.Observed{}, ErrIdentity |
| + } |
| + // Last pre-CAS read of registration, open admission and purge reservation. |
| + if err := c.Authority.Create(ctx, before, r, id); err != nil { |
| + return hostclaim.Observed{}, err |
| + } |
| + obs, err := hostclaim.Update(ctx, c.Store, before, hostclaim.BeginCreate(r.Holder, r.Body, id, by, c.Now())) |
| + if err != nil { |
| + return hostclaim.Observed{}, err |
| + } |
| + // No raw record effect precedes the durable claim intent. |
| + if _, err = c.Store.Put(ctx, r.Holder.Key, r.Body, ""); err != nil { |
| + if !errors.Is(err, release.ErrCASConflict) { |
| + return obs, err |
| + } |
| + existing, rerr := ReadRecord(ctx, c.Store, r.Holder.Key) |
| + if rerr != nil { |
| + return obs, rerr |
| + } |
| + if !matches(existing, obs.Claim.Holder) { |
| + removed, rerr := RemoveOrphan(ctx, c.Store, existing) |
| + if rerr != nil { |
| + return obs, rerr |
| + } |
| + if !removed { |
| + return obs, ErrPending |
| + } |
| + if _, err = c.Store.Put(ctx, r.Holder.Key, r.Body, ""); err != nil { |
| + return obs, err |
| + } |
| + } |
| + } |
| + return c.Complete(ctx, obs.Host, id) |
| +} |
| + |
| +func (c Controller) Complete(ctx context.Context, host, id string) (hostclaim.Observed, error) { |
| + if err := c.ready(); err != nil { |
| + return hostclaim.Observed{}, err |
| + } |
| + obs, err := hostclaim.Read(ctx, c.Store, host) |
| + if err != nil { |
| + return obs, err |
| + } |
| + if obs.Absent || obs.Claim.Op.Kind != hostclaim.KindCreating || obs.Claim.Op.ID != id { |
| + return obs, ErrPending |
| + } |
| + r, err := ReadRecord(ctx, c.Store, obs.Claim.Holder.Key) |
| + if err != nil { |
| + return obs, err |
| + } |
| + if !matches(r, obs.Claim.Holder) { |
| + return obs, ErrIdentity |
| + } |
| + box, err := c.Authority.Executor(ctx, obs, r) |
| + if err != nil { |
| + return obs, err |
| + } |
| + return hostclaim.Update(ctx, c.Store, obs, hostclaim.CompleteCreate(id, box)) |
| +} |
| + |
| +func (c Controller) RecoverCreate(ctx context.Context, host string) error { |
| + if err := c.ready(); err != nil { |
| + return err |
| + } |
| + obs, err := hostclaim.Read(ctx, c.Store, host) |
| + if err != nil { |
| + return err |
| + } |
| + if obs.Absent || obs.Claim.Op.Kind != hostclaim.KindCreating { |
| + return nil |
| + } |
| + if obs.Claim.Op.StartedAt.IsZero() || !c.Now().After(obs.Claim.Op.StartedAt.Add(5*time.Minute)) { |
| + return ErrPending |
| + } |
| + r, err := ReadRecord(ctx, c.Store, obs.Claim.Holder.Key) |
| + if errors.Is(err, os.ErrNotExist) { |
| + _, err = hostclaim.Update(ctx, c.Store, obs, hostclaim.CancelCreate(obs.Claim.Op.ID, c.Now())) |
| + return err |
| + } |
| + if err != nil { |
| + return err |
| + } |
| + if !matches(r, obs.Claim.Holder) { |
| + return ErrPending |
| + } |
| + _, err = c.Complete(ctx, host, obs.Claim.Op.ID) |
| + return err |
| +} |
| + |
| +// Adopt is the shared IO wrapper around the one hostclaim.Adopt transition. |
| +func (c Controller) Adopt(ctx context.Context, r Record) (hostclaim.Observed, error) { |
| + if err := c.ready(); 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 { |
| + 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 |
| + } |
| + _, box, _, _, err := c.Authority.Adoption(ctx, r) |
| + if err != nil { |
| + return obs, err |
| + } |
| + if obs.Claim.Op.Kind != hostclaim.KindServing || obs.Claim.Op.Exec != box || !slices.Contains(obs.Claim.Touched.Boxes, box) { |
| + return obs, ErrIdentity |
| + } |
| + fresh, err := ReadRecord(ctx, c.Store, r.Holder.Key) |
| + if err != nil { |
| + return obs, err |
| + } |
| + if fresh.ETag != r.ETag { |
| + return obs, ErrPending |
| + } |
| + 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 { |
| + return obs, err |
| + } |
| + // Re-read the exact record after discovery; never adopt a stale listing body. |
| + fresh, err := ReadRecord(ctx, c.Store, r.Holder.Key) |
| + if err != nil { |
| + return obs, err |
| + } |
| + if fresh.ETag != r.ETag { |
| + return obs, ErrPending |
| + } |
| + n := fresh.Holder |
| + n.Region = region |
| + return hostclaim.Update(ctx, c.Store, obs, hostclaim.Adopt(n, box, regions, boxes, c.NewID(), c.NewID)) |
| +} |
| + |
| +// BeginRetire only starts the claim operation. Its successful return does not |
| +// mean the record is gone or the host is free; the caller must run/retry Retire. |
| +func (c Controller) BeginRetire(ctx context.Context, key, by, expectedInc string) (hostclaim.Observed, error) { |
| + if err := c.ready(); err != nil { |
| + return hostclaim.Observed{}, err |
| + } |
| + r, err := ReadRecord(ctx, c.Store, key) |
| + if err != nil { |
| + return hostclaim.Observed{}, err |
| + } |
| + inc, ierr := r.Incarnation() |
| + if ierr != nil { |
| + return hostclaim.Observed{}, ierr |
| + } |
| + if expectedInc == "" || inc != expectedInc { |
| + return hostclaim.Observed{}, ErrIdentity |
| + } |
| + 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 |
| + } |
| + regions, boxes, err := c.Authority.Inventory(ctx, obs) |
| + if err != nil { |
| + return obs, err |
| + } |
| + return hostclaim.Update(ctx, c.Store, obs, hostclaim.BeginDelete(c.NewID(), by, r.ETag, c.Now(), regions, boxes)) |
| +} |
| + |
| +func (c Controller) Retire(ctx context.Context, host, id string) error { |
| + if err := c.ready(); err != nil { |
| + return err |
| + } |
| + obs, err := hostclaim.Read(ctx, c.Store, host) |
| + if err != nil { |
| + return err |
| + } |
| + if obs.Absent || obs.Claim.Op.ID != id { |
| + return ErrPending |
| + } |
| + op := obs.Claim.Op |
| + if op.Kind != hostclaim.KindDeleting && op.Kind != hostclaim.KindPurging { |
| + return ErrPending |
| + } |
| + if obs.Claim.Holder == nil || op.Intent["key"] != obs.Claim.Holder.Key || op.Intent["etag"] == "" { |
| + return ErrIdentity |
| + } |
| + if err := c.Authority.FirstPhase(ctx, obs); err != nil { |
| + return err |
| + } |
| + if op.Kind == hostclaim.KindPurging { |
| + if err := c.Authority.PurgeRetirement(ctx, obs); err != nil { |
| + return err |
| + } |
| + } |
| + regions, boxes, err := c.Authority.Inventory(ctx, obs) |
| + if err != nil { |
| + return err |
| + } |
| + // Validate record identity before deleting even the stored version. |
| + r, rerr := ReadRecord(ctx, c.Store, op.Intent["key"]) |
| + if rerr != nil && !errors.Is(rerr, os.ErrNotExist) { |
| + return rerr |
| + } |
| + if rerr == nil { |
| + if r.ETag != op.Intent["etag"] || !matches(r, obs.Claim.Holder) { |
| + return ErrPending |
| + } |
| + if err := release.DeleteIfMatch(ctx, c.Store, r.Holder.Key, r.ETag); err != nil { |
| + return err |
| + } |
| + } |
| + // A success/412 alone never certifies absence. Re-read before parking. |
| + _, _, err = c.Store.Get(ctx, op.Intent["key"]) |
| + if !errors.Is(err, os.ErrNotExist) { |
| + if err != nil { |
| + return err |
| + } |
| + return ErrPending |
| + } |
| + // The old observation binds the entire sequence to this operation. |
| + _, err = hostclaim.Update(ctx, c.Store, obs, hostclaim.ToParking(id, regions, boxes, c.NewID)) |
| + return err |
| +} |
| + |
| +func (c Controller) Free(ctx context.Context, host, id string) error { |
| + if err := c.ready(); err != nil { |
| + return err |
| + } |
| + obs, err := hostclaim.Read(ctx, c.Store, host) |
| + if err != nil { |
| + return err |
| + } |
| + if obs.Absent { |
| + return ErrPending |
| + } |
| + _, err = hostclaim.Update(ctx, c.Store, obs, hostclaim.FreeAfterPark(id)) |
| + return err |
| +} |
diff --git a/internal/hostops/operations_test.go b/internal/hostops/operations_test.go| new file mode 100644 |
| index 000000000..42ebf9d11 |
| --- /dev/null |
| +++ b/internal/hostops/operations_test.go |
| @@ -0,0 +1,629 @@ |
| +package hostops |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "os" |
| + "reflect" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +type authority struct{ err error } |
| + |
| +func (a *authority) Create(context.Context, hostclaim.Observed, Record, string) error { return a.err } |
| +func (a *authority) Executor(context.Context, hostclaim.Observed, Record) (string, error) { |
| + return "box-a", a.err |
| +} |
| +func (a *authority) StartDelete(context.Context, hostclaim.Observed, Record) error { return a.err } |
| +func (a *authority) FirstPhase(context.Context, hostclaim.Observed) error { return a.err } |
| +func (a *authority) Inventory(context.Context, hostclaim.Observed) ([]string, []string, error) { |
| + return []string{"ie", "us"}, []string{"box-a", "box-b"}, a.err |
| +} |
| +func (a *authority) Adoption(context.Context, Record) (string, string, []string, []string, error) { |
| + return "ie", "box-a", []string{"us"}, []string{"box-b"}, a.err |
| +} |
| +func (a *authority) PurgeRetirement(context.Context, hostclaim.Observed) error { return a.err } |
| + |
| +type faultStore struct { |
| + release.Store |
| + failGet string |
| + afterList func(string, []string) error |
| + beforePut func(string) error |
| + beforeDelete func(string, string) error |
| + bareDeletes int |
| +} |
| + |
| +func (s *faultStore) List(ctx context.Context, prefix string) ([]string, error) { |
| + keys, err := s.Store.List(ctx, prefix) |
| + if err == nil && s.afterList != nil { |
| + err = s.afterList(prefix, keys) |
| + } |
| + return keys, err |
| +} |
| + |
| +func (s *faultStore) Get(ctx context.Context, key string) ([]byte, string, error) { |
| + if key == s.failGet { |
| + return nil, "", errors.New("read failed") |
| + } |
| + return s.Store.Get(ctx, key) |
| +} |
| +func (s *faultStore) Put(ctx context.Context, key string, b []byte, e string) (string, error) { |
| + if s.beforePut != nil { |
| + if err := s.beforePut(key); err != nil { |
| + return "", err |
| + } |
| + } |
| + return s.Store.Put(ctx, key, b, e) |
| +} |
| +func (s *faultStore) Delete(context.Context, string) error { |
| + s.bareDeletes++ |
| + return errors.New("bare delete forbidden") |
| +} |
| +func (s *faultStore) DeleteIfMatch(ctx context.Context, key, e string) error { |
| + if s.beforeDelete != nil { |
| + if err := s.beforeDelete(key, e); err != nil { |
| + return err |
| + } |
| + } |
| + return release.DeleteIfMatch(ctx, s.Store, key, e) |
| +} |
| + |
| +type fixture struct { |
| + ctx context.Context |
| + store *faultStore |
| + c Controller |
| + auth *authority |
| + now time.Time |
| + serial int |
| +} |
| + |
| +func newFixture(t *testing.T) *fixture { |
| + t.Helper() |
| + f := &fixture{ctx: context.Background(), now: time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC), auth: &authority{}} |
| + f.store = &faultStore{Store: release.NewDirStore(t.TempDir())} |
| + f.c = Controller{Store: f.store, Authority: f.auth, Now: func() time.Time { return f.now }, NewID: func() string { f.serial++; return fmt.Sprintf("id-%d", f.serial) }} |
| + return f |
| +} |
| +func (f *fixture) record(t *testing.T, host string) Record { |
| + t.Helper() |
| + r, err := PrepareInstance(provision.Record{Schema: 1, Account: "bab", App: "jam", Host: host, Slug: "shop", Channel: "edge", Region: "ie"}, f.now, "ie") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return r |
| +} |
| +func (f *fixture) serving(t *testing.T, host string) hostclaim.Observed { |
| + t.Helper() |
| + r := f.record(t, host) |
| + before, err := hostclaim.Read(f.ctx, f.store, host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + obs, err := f.c.Attempt(f.ctx, before, r, f.c.NewID(), "test") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return obs |
| +} |
| +func TestAttemptUsesPreRegistrationObservation(t *testing.T) { |
| + f := newFixture(t) |
| + for _, host := range []string{"one.bab.example.com", "two.bab.example.com"} { |
| + before, err := hostclaim.Read(f.ctx, f.store, host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + fenced, err := hostclaim.Update(f.ctx, f.store, before, FencePurge("purge", f.now)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err = hostclaim.Update(f.ctx, f.store, fenced, ReleasePurgeFence("purge")); err != nil { |
| + t.Fatal(err) |
| + } |
| + r := f.record(t, host) |
| + if _, err = f.c.Attempt(f.ctx, before, r, "paused", "test"); !errors.Is(err, hostclaim.ErrConflict) { |
| + t.Fatalf("late claim: %v", err) |
| + } |
| + if _, _, err = f.store.Get(f.ctx, r.Holder.Key); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("record landed: %v", err) |
| + } |
| + } |
| + if f.store.bareDeletes != 0 { |
| + t.Fatal("deleted claim") |
| + } |
| +} |
| +func TestRecoverCreateAbsentAndLateOrphan(t *testing.T) { |
| + f := newFixture(t) |
| + host := "shop.bab.example.com" |
| + r := f.record(t, host) |
| + before, _ := hostclaim.Read(f.ctx, f.store, host) |
| + if _, err := hostclaim.Update(f.ctx, f.store, before, hostclaim.BeginCreate(r.Holder, r.Body, "create", "test", f.now)); err != nil { |
| + t.Fatal(err) |
| + } |
| + f.now = f.now.Add(5 * time.Minute) |
| + if err := f.c.RecoverCreate(f.ctx, host); !errors.Is(err, ErrPending) { |
| + t.Fatal(err) |
| + } |
| + f.now = f.now.Add(time.Nanosecond) |
| + if err := f.c.RecoverCreate(f.ctx, host); err != nil { |
| + t.Fatal(err) |
| + } |
| + obs, _ := hostclaim.Read(f.ctx, f.store, host) |
| + if !obs.Claim.Free() || len(obs.Claim.CancelledCreates) != 1 || len(obs.Claim.RetiredIncarnations) != 1 { |
| + t.Fatalf("cancel: %+v", obs.Claim) |
| + } |
| + f.now = f.now.Add(2 * time.Hour) |
| + if _, err := f.store.Put(f.ctx, r.Holder.Key, r.Body, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + late, err := ReadRecord(f.ctx, f.store, r.Holder.Key) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + removed, err := RemoveOrphan(f.ctx, f.store, late) |
| + if err != nil || !removed { |
| + t.Fatalf("late orphan: %t %v", removed, err) |
| + } |
| +} |
| +func TestCreateRecoveryReadErrorsNeverCancel(t *testing.T) { |
| + f := newFixture(t) |
| + r := f.record(t, "shop.bab.example.com") |
| + before, _ := hostclaim.Read(f.ctx, f.store, r.Holder.Host) |
| + obs, err := hostclaim.Update(f.ctx, f.store, before, hostclaim.BeginCreate(r.Holder, r.Body, "create", "test", f.now)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + f.now = f.now.Add(6 * time.Minute) |
| + f.store.failGet = r.Holder.Key |
| + if err := f.c.RecoverCreate(f.ctx, r.Holder.Host); err == nil { |
| + t.Fatal("read error accepted") |
| + } |
| + got, _ := hostclaim.Read(f.ctx, f.store, r.Holder.Host) |
| + if got.ETag != obs.ETag { |
| + t.Fatal("acted after read error") |
| + } |
| + f.store.failGet = "" |
| + if _, err := f.store.Put(f.ctx, r.Holder.Key, r.Body, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := f.c.RecoverCreate(f.ctx, r.Holder.Host); err != nil { |
| + t.Fatal(err) |
| + } |
| + got, _ = hostclaim.Read(f.ctx, f.store, r.Holder.Host) |
| + if got.Claim.Op.Kind != hostclaim.KindServing || got.Claim.Holder.Stamp != got.Claim.Gen { |
| + t.Fatal("not completed") |
| + } |
| +} |
| +func TestRetireWaitsForExactRecordAndAuthority(t *testing.T) { |
| + f := newFixture(t) |
| + obs := f.serving(t, "shop.bab.example.com") |
| + key := obs.Claim.Holder.Key |
| + deleting, err := f.c.BeginRetire(f.ctx, key, "test", obs.Claim.Holder.Incarnation) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + f.auth.err = ErrPending |
| + if err := f.c.Retire(f.ctx, obs.Host, deleting.Claim.Op.ID); !errors.Is(err, ErrPending) { |
| + t.Fatal(err) |
| + } |
| + if _, _, err := f.store.Get(f.ctx, key); err != nil { |
| + t.Fatal("record deleted before barrier") |
| + } |
| + f.auth.err = nil |
| + f.store.beforeDelete = func(k, e string) error { return release.ErrCASConflict } |
| + if err := f.c.Retire(f.ctx, obs.Host, deleting.Claim.Op.ID); !errors.Is(err, release.ErrCASConflict) { |
| + t.Fatal(err) |
| + } |
| + current, _ := hostclaim.Read(f.ctx, f.store, obs.Host) |
| + if current.Claim.Op.Kind != hostclaim.KindDeleting { |
| + t.Fatal("parked after 412") |
| + } |
| + f.store.beforeDelete = nil |
| + if err := f.c.Retire(f.ctx, obs.Host, deleting.Claim.Op.ID); err != nil { |
| + t.Fatal(err) |
| + } |
| + current, _ = hostclaim.Read(f.ctx, f.store, obs.Host) |
| + if current.Claim.Op.Kind != hostclaim.KindParking || len(current.Claim.Obligations) != 2 { |
| + t.Fatalf("parking: %+v", current.Claim) |
| + } |
| + if err := f.c.Free(f.ctx, obs.Host, deleting.Claim.Op.ID); err == nil { |
| + t.Fatal("freed with chores") |
| + } |
| + for _, ob := range current.Claim.Obligations { |
| + var err error |
| + current, err = hostclaim.Update(f.ctx, f.store, current, hostclaim.CompleteObligation(ob.Op, ob.ID)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if err := f.c.Free(f.ctx, obs.Host, deleting.Claim.Op.ID); err != nil { |
| + t.Fatal(err) |
| + } |
| + if f.store.bareDeletes != 0 { |
| + t.Fatal("bare delete") |
| + } |
| +} |
| +func TestAdoptionIsNotOrphanAndHasDiscovery(t *testing.T) { |
| + f := newFixture(t) |
| + r := f.record(t, "shop.bab.example.com") |
| + if _, err := f.store.Put(f.ctx, r.Holder.Key, r.Body, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + read, err := ReadRecord(f.ctx, f.store, r.Holder.Key) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if removed, err := RemoveOrphan(f.ctx, f.store, read); err != nil || removed { |
| + t.Fatal("legacy treated as orphan") |
| + } |
| + obs, err := f.c.Adopt(f.ctx, read) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if obs.Claim.Touched.Complete || len(obs.Claim.Obligations) != 2 || obs.Claim.Holder.Location != "instances/"+r.Holder.Host || obs.Claim.Holder.WriterBox != "box-a" { |
| + t.Fatalf("adopt: %+v", obs.Claim) |
| + } |
| + if _, err := f.c.BeginRetire(f.ctx, r.Holder.Key, "test", obs.Claim.Holder.Incarnation); err == nil { |
| + t.Fatal("delete overlapped discovery") |
| + } |
| +} |
| +func TestPurgeJournalPhasesAndFenceRetention(t *testing.T) { |
| + f := newFixture(t) |
| + p, _, err := ReservePurge(f.ctx, f.store, "bab", "jam", "purge") |
| + if err != nil || p.Phase != "reserved" { |
| + t.Fatal(err) |
| + } |
| + if _, _, err := ReservePurge(f.ctx, f.store, "bab", "jam", "other"); !errors.Is(err, ErrPending) { |
| + t.Fatal(err) |
| + } |
| + if err := RemovePurge(f.ctx, f.store, "bab", "jam", "purge"); !errors.Is(err, ErrPending) { |
| + t.Fatal(err) |
| + } |
| + if err := AdvancePurge(f.ctx, f.store, "bab", "jam", "purge", "reserved", "done"); err == nil { |
| + t.Fatal("skipped phases") |
| + } |
| + for i := 0; i < len(phases)-1; i++ { |
| + if err := AdvancePurge(f.ctx, f.store, "bab", "jam", "purge", phases[i], phases[i+1]); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if err := RemovePurge(f.ctx, f.store, "bab", "jam", "purge"); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| +func TestPurgeRetiresUnderOwnOperation(t *testing.T) { |
| + f := newFixture(t) |
| + obs := f.serving(t, "shop.bab.example.com") |
| + rec, err := ReadRecord(f.ctx, f.store, obs.Claim.Holder.Key) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + obs, err = hostclaim.Update(f.ctx, f.store, obs, BeginPurge("purge", "test", f.now, []string{"ie"}, []string{"box-a"})) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + obs, err = hostclaim.Update(f.ctx, f.store, obs, PurgeRecordIntent("purge", rec.Holder.Key, rec.ETag)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + f.auth.err = ErrPending |
| + if err := f.c.Retire(f.ctx, obs.Host, "purge"); !errors.Is(err, ErrPending) { |
| + t.Fatal(err) |
| + } |
| + f.auth.err = nil |
| + if err := f.c.Retire(f.ctx, obs.Host, "purge"); err != nil { |
| + t.Fatal(err) |
| + } |
| + got, _ := hostclaim.Read(f.ctx, f.store, obs.Host) |
| + if got.Claim.Op.Kind != hostclaim.KindParking || got.Claim.Op.ID != "purge" { |
| + t.Fatal("purge identity lost") |
| + } |
| +} |
| +func TestPrepareRejectsCallerTimeAndSidecarIdentity(t *testing.T) { |
| + f := newFixture(t) |
| + if _, err := PrepareInstance(provision.Record{CreatedAt: f.now}, f.now, "ie"); err == nil { |
| + t.Fatal("accepted caller time") |
| + } |
| + r, err := PrepareSidecar(provision.SidecarRecord{Account: "bab", App: "jam", Name: "job", Host: provision.SidecarHost("job", "jam", "bab"), Region: "ie"}, f.now, "ie") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + before, _ := hostclaim.Read(f.ctx, f.store, r.Holder.Host) |
| + obs, err := f.c.Attempt(f.ctx, before, r, "sidecar", "test") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if obs.Claim.Holder.Family != "sidecar" { |
| + t.Fatal("sidecar became instance") |
| + } |
| +} |
| +func TestRecoveryFreshReadsAndRequiredHooks(t *testing.T) { |
| + f := newFixture(t) |
| + if err := (Recovery{Controller: f.c}).Pass(f.ctx); !errors.Is(err, ErrContract) { |
| + t.Fatal(err) |
| + } |
| + for _, tc := range []struct { |
| + name string |
| + replace bool |
| + wantCalls int |
| + }{ |
| + {name: "advanced purge", replace: true, wantCalls: 1}, |
| + {name: "released purge", wantCalls: 0}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + f := newFixture(t) |
| + host := "shop.bab.example.com" |
| + before, err := hostclaim.Read(f.ctx, f.store, host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + before, err = hostclaim.Update(f.ctx, f.store, before, FencePurge("purge-old", f.now)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + startedAt := f.now |
| + f.now = f.now.Add(6 * time.Minute) |
| + key, err := hostclaim.Key(host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var fresh hostclaim.Observed |
| + listCalls := 0 |
| + f.store.afterList = func(prefix string, keys []string) error { |
| + listCalls++ |
| + if prefix != "control/hosts/" || len(keys) != 1 || keys[0] != key { |
| + t.Fatalf("discovery = %q %v, want the populated claim", prefix, keys) |
| + } |
| + var err error |
| + fresh, err = hostclaim.Update(f.ctx, f.store.Store, before, ReleasePurgeFence("purge-old")) |
| + if err == nil && tc.replace { |
| + fresh, err = hostclaim.Update(f.ctx, f.store.Store, fresh, FencePurge("purge-new", startedAt)) |
| + } |
| + return err |
| + } |
| + purgeCalls, recordSweeps, purgeSweeps, admissionSweeps := 0, 0, 0, 0 |
| + r := Recovery{ |
| + Controller: f.c, |
| + Purge: func(_ context.Context, got hostclaim.Observed) error { |
| + purgeCalls++ |
| + if !tc.replace { |
| + t.Fatal("dispatched a purge released after discovery") |
| + } |
| + if !reflect.DeepEqual(got, fresh) { |
| + t.Fatalf("dispatched stale claim: gen=%d op=%q etag=%q; want gen=%d op=%q etag=%q", got.Claim.Gen, got.Claim.Op.ID, got.ETag, fresh.Claim.Gen, fresh.Claim.Op.ID, fresh.ETag) |
| + } |
| + return nil |
| + }, |
| + Placement: func(context.Context, hostclaim.Observed) error { |
| + t.Fatal("dispatched placement for a purge claim") |
| + return nil |
| + }, |
| + SweepRecords: func(context.Context) error { recordSweeps++; return nil }, |
| + SweepPurges: func(context.Context) error { purgeSweeps++; return nil }, |
| + SweepAdmissions: func(context.Context) error { admissionSweeps++; return nil }, |
| + } |
| + if err := r.Pass(f.ctx); err != nil { |
| + t.Fatal(err) |
| + } |
| + if listCalls != 1 || fresh.Claim.Gen <= before.Claim.Gen || fresh.ETag == before.ETag { |
| + t.Fatal("claim did not advance between discovery and recovery") |
| + } |
| + if purgeCalls != tc.wantCalls { |
| + t.Fatalf("purge calls = %d, want %d", purgeCalls, tc.wantCalls) |
| + } |
| + if recordSweeps != 1 || purgeSweeps != 1 || admissionSweeps != 1 { |
| + t.Fatalf("sweeps: records=%d purges=%d admissions=%d; want one each", recordSweeps, purgeSweeps, admissionSweeps) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestAppsPreserveLegacyPrefixesAndFindClaimlessPurge(t *testing.T) { |
| + f := newFixture(t) |
| + for key, body := range map[string][]byte{ |
| + release.LegacyChannelKey("my-app", "stable"): []byte("legacy channel"), |
| + release.LegacyChannelKey("myapp", "stable"): []byte("legacy channel"), |
| + release.LegacyManifestKey("myapp", "v1"): []byte("legacy manifest"), |
| + release.LegacyArtifactKey("myapp", "v1", "app"): []byte("legacy artifact"), |
| + } { |
| + if _, err := f.store.Put(f.ctx, key, body, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if _, _, err := ReservePurge(f.ctx, f.store, "bab", "doomed", "purge"); err != nil { |
| + t.Fatal(err) |
| + } |
| + hosts, err := ListClaims(f.ctx, f.store) |
| + if err != nil || len(hosts) != 0 { |
| + t.Fatalf("claims = %v, %v; want none", hosts, err) |
| + } |
| + if err := f.c.SweepAllRecords(f.ctx); err != nil { |
| + t.Fatalf("record sweep with legacy prefixes: %v", err) |
| + } |
| + var replayed []App |
| + err = SweepAllPurges(f.ctx, f.store, func(_ context.Context, app App, p Purge) error { |
| + if p.ID != "purge" { |
| + t.Fatalf("purge ID = %q", p.ID) |
| + } |
| + replayed = append(replayed, app) |
| + return nil |
| + }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(replayed) != 1 || replayed[0] != (App{Account: "bab", Name: "doomed"}) { |
| + t.Fatalf("replayed = %+v", replayed) |
| + } |
| +} |
| + |
| +func TestOrphanConditionalDeleteCannotEraseReplacement(t *testing.T) { |
| + f := newFixture(t) |
| + r := f.record(t, "shop.bab.example.com") |
| + if _, err := f.store.Put(f.ctx, r.Holder.Key, r.Body, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + old, err := ReadRecord(f.ctx, f.store, r.Holder.Key) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + absent, _ := hostclaim.Read(f.ctx, f.store, r.Holder.Host) |
| + if _, err := hostclaim.Update(f.ctx, f.store, absent, func(*hostclaim.Claim) error { return nil }); err != nil { |
| + t.Fatal(err) |
| + } |
| + replacement := append([]byte(nil), r.Body...) |
| + replacement = append(replacement, ' ') |
| + f.store.beforeDelete = func(key, etag string) error { |
| + _, err := f.store.Store.Put(f.ctx, key, replacement, etag) |
| + return err |
| + } |
| + if removed, err := RemoveOrphan(f.ctx, f.store, old); removed || !errors.Is(err, release.ErrCASConflict) { |
| + t.Fatalf("removed=%t err=%v", removed, err) |
| + } |
| + if _, _, err := f.store.Get(f.ctx, r.Holder.Key); err != nil { |
| + t.Fatal("replacement lost") |
| + } |
| +} |
| + |
| +func TestPlacementStartKeepsInstallationAndExcludesDelete(t *testing.T) { |
| + f := newFixture(t) |
| + obs := f.serving(t, "shop.bab.example.com") |
| + stamp := obs.Claim.Holder.Stamp |
| + placed, err := hostclaim.Update(f.ctx, f.store, obs, BeginPlacement("place", "test", "fleet/customer", f.now, []string{"ie", "us"}, []string{"box-a", "box-b"})) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if placed.Claim.Op.Exec != obs.Claim.Op.Exec || placed.Claim.Holder.Stamp != stamp || placed.Claim.Op.Intent["target"] != "fleet/customer" { |
| + t.Fatal("placement changed authority") |
| + } |
| + rec, err := ReadRecord(f.ctx, f.store, obs.Claim.Holder.Key) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := hostclaim.Update(f.ctx, f.store, placed, hostclaim.BeginDelete("delete", "test", rec.ETag, f.now, nil, nil)); err == nil { |
| + t.Fatal("delete overlapped placement") |
| + } |
| +} |
| + |
| +func TestFenceReleasePreservesHistoryAndRefusesAnotherPurge(t *testing.T) { |
| + f := newFixture(t) |
| + before, _ := hostclaim.Read(f.ctx, f.store, "shop.bab.example.com") |
| + obs, err := hostclaim.Update(f.ctx, f.store, before, func(c *hostclaim.Claim) error { |
| + c.Lost = []string{"lost-box"} |
| + c.QuietAfter = map[string]time.Time{"ie": f.now.Add(time.Hour)} |
| + return FencePurge("purge", f.now)(c) |
| + }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := hostclaim.Update(f.ctx, f.store, obs, ReleasePurgeFence("other")); err == nil { |
| + t.Fatal("released other's fence") |
| + } |
| + obs, err = hostclaim.Update(f.ctx, f.store, obs, ReleasePurgeFence("purge")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if !obs.Claim.Free() || len(obs.Claim.Lost) != 1 || obs.Claim.QuietAfter["ie"].IsZero() { |
| + t.Fatal("lost history") |
| + } |
| +} |
| + |
| +type repairAuthority struct { |
| + Authority |
| + afterAdoption func() |
| +} |
| + |
| +func (a repairAuthority) Adoption(ctx context.Context, r Record) (string, string, []string, []string, error) { |
| + region, box, regions, boxes, err := a.Authority.Adoption(ctx, r) |
| + if a.afterAdoption != nil { |
| + a.afterAdoption() |
| + } |
| + return region, box, regions, boxes, err |
| +} |
| + |
| +func TestAdoptLegacyRepairRequiresCensusIdentityAndFreshRecord(t *testing.T) { |
| + for _, scenario := range []string{"wrong-executor", "not-serving", "owner-not-touched", "changed-record", "missing-record", "record-read-error"} { |
| + t.Run(scenario, func(t *testing.T) { |
| + f := newFixture(t) |
| + before := f.serving(t, "legacy.example.com") |
| + var err error |
| + before, err = hostclaim.Update(f.ctx, f.store, before, func(c *hostclaim.Claim) error { |
| + switch scenario { |
| + case "wrong-executor": |
| + c.Op.Exec = "box-b" |
| + c.Touched.Boxes = append(c.Touched.Boxes, "box-b") |
| + case "not-serving": |
| + c.Op.Kind, c.Op.ID = hostclaim.KindMoving, "move" |
| + case "owner-not-touched": |
| + c.Touched.Boxes = []string{"box-b"} |
| + } |
| + return nil |
| + }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + r, err := ReadRecord(f.ctx, f.store, before.Claim.Holder.Key) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var body provision.Record |
| + if err := json.Unmarshal(r.Body, &body); err != nil { |
| + t.Fatal(err) |
| + } |
| + body.Acquisition = "ownership-acquisition" |
| + raw, err := json.Marshal(body) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := f.store.Put(f.ctx, r.Holder.Key, raw, r.ETag); err != nil { |
| + t.Fatal(err) |
| + } |
| + r, err = ReadRecord(f.ctx, f.store, r.Holder.Key) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + want := ErrIdentity |
| + if scenario == "changed-record" { |
| + want = ErrPending |
| + } else if scenario == "missing-record" { |
| + want = os.ErrNotExist |
| + } |
| + f.c.Authority = repairAuthority{Authority: f.auth, afterAdoption: func() { |
| + switch scenario { |
| + case "changed-record": |
| + if _, err := f.store.Put(f.ctx, r.Holder.Key, append(raw, ' '), r.ETag); err != nil { |
| + t.Fatal(err) |
| + } |
| + case "missing-record": |
| + if err := f.store.DeleteIfMatch(f.ctx, r.Holder.Key, r.ETag); err != nil { |
| + t.Fatal(err) |
| + } |
| + case "record-read-error": |
| + f.store.failGet = r.Holder.Key |
| + } |
| + }} |
| + writes := 0 |
| + f.store.beforePut = func(string) error { writes++; return nil } |
| + _, err = f.c.Adopt(f.ctx, r) |
| + if scenario == "record-read-error" { |
| + if err == nil || err.Error() != "read failed" { |
| + t.Errorf("repair error = %v, want record read failure", err) |
| + } |
| + } else if !errors.Is(err, want) { |
| + t.Errorf("repair error = %v, want %v", err, want) |
| + } |
| + if scenario == "changed-record" { |
| + writes-- |
| + } |
| + after, readErr := hostclaim.Read(f.ctx, f.store, before.Host) |
| + if readErr != nil || writes != 0 || after.ETag != before.ETag || !reflect.DeepEqual(after.Claim, before.Claim) { |
| + t.Fatalf("refused repair changed claim: writes=%d, read=%v", writes, readErr) |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/hostops/purge.go b/internal/hostops/purge.go| new file mode 100644 |
| index 000000000..ba2ae03d5 |
| --- /dev/null |
| +++ b/internal/hostops/purge.go |
| @@ -0,0 +1,196 @@ |
| +package hostops |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "os" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/controlname" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +type Purge struct { |
| + ID string `json:"id"` |
| + Phase string `json:"phase"` |
| +} |
| + |
| +var phases = []string{"reserved", "adopted", "claimed", "torn-down", "retired", "done"} |
| + |
| +func phaseIndex(s string) int { |
| + for i, p := range phases { |
| + if s == p { |
| + return i |
| + } |
| + } |
| + return -1 |
| +} |
| +func PurgeKey(acct, app string) (string, error) { |
| + if !controlname.ValidAccount(acct) || !controlname.ValidApp(app) { |
| + return "", ErrIdentity |
| + } |
| + return "apps/" + acct + "/" + app + "/purge.json", nil |
| +} |
| +func ReadPurge(ctx context.Context, s release.Store, acct, app string) (Purge, string, error) { |
| + key, err := PurgeKey(acct, app) |
| + if err != nil { |
| + return Purge{}, "", err |
| + } |
| + body, etag, err := s.Get(ctx, key) |
| + if err != nil { |
| + return Purge{}, "", err |
| + } |
| + var p Purge |
| + if err := decode(body, &p); err != nil { |
| + return Purge{}, "", err |
| + } |
| + if p.ID == "" || phaseIndex(p.Phase) < 0 || etag == "" { |
| + return Purge{}, "", ErrIdentity |
| + } |
| + return p, etag, nil |
| +} |
| +func ReservePurge(ctx context.Context, s release.Store, acct, app, id string) (Purge, string, error) { |
| + key, err := PurgeKey(acct, app) |
| + if err != nil || id == "" { |
| + return Purge{}, "", ErrIdentity |
| + } |
| + p := Purge{ID: id, Phase: "reserved"} |
| + body, err := json.Marshal(p) |
| + if err != nil { |
| + return Purge{}, "", err |
| + } |
| + etag, err := s.Put(ctx, key, body, "") |
| + if errors.Is(err, release.ErrCASConflict) { |
| + existing, tag, rerr := ReadPurge(ctx, s, acct, app) |
| + if rerr != nil { |
| + return Purge{}, "", rerr |
| + } |
| + if existing.ID != id { |
| + return existing, tag, ErrPending |
| + } |
| + return existing, tag, nil |
| + } |
| + return p, etag, err |
| +} |
| + |
| +// AdvancePurge preserves additional journal fields owned by the final durable |
| +// host-set schema. Preconditions for the next phase belong to the coordinator. |
| +func AdvancePurge(ctx context.Context, s release.Store, acct, app, id, from, to string) error { |
| + if phaseIndex(from) < 0 || phaseIndex(to) != phaseIndex(from)+1 { |
| + return ErrIdentity |
| + } |
| + key, err := PurgeKey(acct, app) |
| + if err != nil { |
| + return err |
| + } |
| + body, etag, err := s.Get(ctx, key) |
| + if err != nil { |
| + return err |
| + } |
| + var p Purge |
| + if err := decode(body, &p); err != nil { |
| + return err |
| + } |
| + if p.ID != id || p.Phase != from || etag == "" { |
| + return ErrPending |
| + } |
| + var fields map[string]json.RawMessage |
| + if err := decode(body, &fields); err != nil { |
| + return err |
| + } |
| + fields["phase"], err = json.Marshal(to) |
| + if err != nil { |
| + return err |
| + } |
| + body, err = json.Marshal(fields) |
| + if err != nil { |
| + return err |
| + } |
| + _, err = s.Put(ctx, key, body, etag) |
| + return err |
| +} |
| + |
| +// Only the completed coordinator may call this, after all fence-release writes. |
| +func RemovePurge(ctx context.Context, s release.Store, acct, app, id string) error { |
| + p, etag, err := ReadPurge(ctx, s, acct, app) |
| + if errors.Is(err, os.ErrNotExist) { |
| + return nil |
| + } |
| + if err != nil { |
| + return err |
| + } |
| + if p.ID != id || p.Phase != "done" { |
| + return ErrPending |
| + } |
| + key, err := PurgeKey(acct, app) |
| + if err != nil { |
| + return err |
| + } |
| + return release.DeleteIfMatch(ctx, s, key, etag) |
| +} |
| + |
| +func FencePurge(id string, now time.Time) func(*hostclaim.Claim) error { |
| + return func(c *hostclaim.Claim) error { |
| + if id == "" || now.IsZero() { |
| + return ErrIdentity |
| + } |
| + if !c.Free() || len(c.Obligations) != 0 { |
| + return ErrPending |
| + } |
| + c.Op = hostclaim.Op{Kind: hostclaim.KindPurging, ID: id, StartedAt: now.UTC()} |
| + return nil |
| + } |
| +} |
| +func ReleasePurgeFence(id string) func(*hostclaim.Claim) error { |
| + return func(c *hostclaim.Claim) error { |
| + if id == "" || c.Holder != nil || c.Op.Kind != hostclaim.KindPurging || c.Op.ID != id || len(c.Obligations) != 0 { |
| + return ErrPending |
| + } |
| + c.Op = hostclaim.Op{} |
| + return nil |
| + } |
| +} |
| +func BeginPurge(id, by string, now time.Time, regions, boxes []string) func(*hostclaim.Claim) error { |
| + return func(c *hostclaim.Claim) error { |
| + if id == "" || now.IsZero() { |
| + return ErrIdentity |
| + } |
| + if err := hostclaim.StartGuard(c, regions, boxes); err != nil { |
| + return err |
| + } |
| + c.Op = hostclaim.Op{Kind: hostclaim.KindPurging, ID: id, By: by, StartedAt: now.UTC(), Exec: c.Op.Exec, Participants: c.Op.Participants} |
| + return nil |
| + } |
| +} |
| +func PurgeRecordIntent(id, key, etag string) func(*hostclaim.Claim) error { |
| + return func(c *hostclaim.Claim) error { |
| + if id == "" || etag == "" || c.Op.Kind != hostclaim.KindPurging || c.Op.ID != id || c.Holder == nil || c.Holder.Key != key { |
| + return ErrIdentity |
| + } |
| + if c.Op.Intent == nil { |
| + c.Op.Intent = map[string]string{} |
| + } |
| + if old := c.Op.Intent["etag"]; old != "" && old != etag { |
| + return ErrPending |
| + } |
| + c.Op.Intent["key"], c.Op.Intent["etag"] = key, etag |
| + return nil |
| + } |
| +} |
| + |
| +// No completion transition is supplied until placement's executor/evidence |
| +// contract is resolved. This start transition alone grants no new execution. |
| +func BeginPlacement(id, by, target string, now time.Time, regions, boxes []string) func(*hostclaim.Claim) error { |
| + return func(c *hostclaim.Claim) error { |
| + if id == "" || now.IsZero() { |
| + return ErrIdentity |
| + } |
| + if err := hostclaim.StartGuard(c, regions, boxes); err != nil { |
| + return err |
| + } |
| + c.Op = hostclaim.Op{Kind: hostclaim.KindPlacing, ID: id, By: by, StartedAt: now.UTC(), Exec: c.Op.Exec, Participants: c.Op.Participants, Intent: map[string]string{"target": target}} |
| + return nil |
| + } |
| +} |
diff --git a/internal/hostops/record.go b/internal/hostops/record.go| new file mode 100644 |
| index 000000000..25223712f |
| --- /dev/null |
| +++ b/internal/hostops/record.go |
| @@ -0,0 +1,182 @@ |
| +package hostops |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "io" |
| + "os" |
| + "strings" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/controlname" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +var ( |
| + ErrPending = errors.New("hostops: pending") |
| + ErrContract = errors.New("hostops: required contract unavailable") |
| + ErrIdentity = errors.New("hostops: identity mismatch") |
| +) |
| + |
| +type Record struct { |
| + Holder hostclaim.NewHolder |
| + Body []byte |
| + ETag string |
| +} |
| + |
| +func decode(body []byte, out any) error { |
| + d := json.NewDecoder(bytes.NewReader(body)) |
| + if err := d.Decode(out); err != nil { |
| + return err |
| + } |
| + if _, err := d.Token(); err != io.EOF { |
| + return ErrIdentity |
| + } |
| + return nil |
| +} |
| + |
| +func ReadRecord(ctx context.Context, s release.Store, key string) (Record, error) { |
| + parts := strings.Split(key, "/") |
| + if len(parts) != 5 || parts[0] != "apps" || !controlname.ValidAccount(parts[1]) || !controlname.ValidApp(parts[2]) || !strings.HasSuffix(parts[4], ".json") { |
| + return Record{}, ErrIdentity |
| + } |
| + body, etag, err := s.Get(ctx, key) |
| + if err != nil { |
| + return Record{}, err |
| + } |
| + if etag == "" { |
| + return Record{}, ErrIdentity |
| + } |
| + 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 |
| + if err := decode(body, &r); err != nil { |
| + return Record{}, err |
| + } |
| + if r.Account != n.Account || r.App != n.App || key != provision.RecordKey(r.Account, r.App, r.Host) || !controlname.ValidHostShape(r.Host) { |
| + return Record{}, ErrIdentity |
| + } |
| + // DecodeRecord is also the ordinary instance reader's shape validator. |
| + if _, err := provision.DecodeRecord(body); err != nil { |
| + return Record{}, err |
| + } |
| + n.Family, n.Host, n.RecordRegion, n.CreatedAt = "instance", r.Host, r.Region, r.CreatedAt |
| + case "sidecars": |
| + var r provision.SidecarRecord |
| + if err := decode(body, &r); err != nil { |
| + return Record{}, err |
| + } |
| + if r.Account != n.Account || r.App != n.App || !controlname.ValidLabel(r.Name) || key != provision.SidecarRecordKey(r.Account, r.App, r.Name) || r.Host != provision.SidecarHost(r.Name, r.App, r.Account) { |
| + return Record{}, ErrIdentity |
| + } |
| + n.Family, n.Host, n.Name, n.RecordRegion, n.CreatedAt = "sidecar", r.Host, r.Name, r.Region, r.CreatedAt |
| + default: |
| + return Record{}, ErrIdentity |
| + } |
| + return Record{Holder: n, Body: body, ETag: etag}, nil |
| +} |
| + |
| +func (r Record) Incarnation() (string, error) { |
| + n := r.Holder |
| + return hostclaim.Incarnation(n.Account, n.App, n.Host, n.CreatedAt) |
| +} |
| + |
| +func matches(r Record, h *hostclaim.Holder) bool { |
| + if h == nil { |
| + return false |
| + } |
| + n := r.Holder |
| + inc, err := r.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 |
| + } |
| + obs, err := hostclaim.Read(ctx, s, r.Holder.Host) |
| + if err != nil { |
| + return false, err |
| + } |
| + if !hostclaim.IsOrphan(inc, obs) { |
| + return false, nil |
| + } |
| + if err := release.DeleteIfMatch(ctx, s, r.Holder.Key, r.ETag); err != nil { |
| + return false, err |
| + } |
| + return true, nil |
| +} |
| + |
| +func Records(ctx context.Context, s release.Store, acct, app string) ([]Record, error) { |
| + if !controlname.ValidAccount(acct) || !controlname.ValidApp(app) { |
| + return nil, ErrIdentity |
| + } |
| + var out []Record |
| + for _, prefix := range []string{provision.RecordsPrefix(acct, app), provision.SidecarsPrefix(acct, app)} { |
| + keys, err := s.List(ctx, prefix) |
| + if err != nil { |
| + return nil, err |
| + } |
| + for _, key := range keys { |
| + tail, ok := strings.CutPrefix(key, prefix) |
| + if !ok { |
| + return nil, ErrIdentity |
| + } |
| + if strings.Contains(tail, "/") || !strings.HasSuffix(tail, ".json") { |
| + continue |
| + } |
| + r, err := ReadRecord(ctx, s, key) |
| + if errors.Is(err, os.ErrNotExist) { |
| + continue |
| + } |
| + if err != nil { |
| + return nil, err |
| + } |
| + out = append(out, r) |
| + } |
| + } |
| + return out, nil |
| +} |
| + |
| +// PrepareInstance accepts only an unstamped internal request, never a caller time. |
| +func PrepareInstance(r provision.Record, now time.Time, region string) (Record, error) { |
| + if !r.CreatedAt.IsZero() || now.IsZero() || region == "" || len(r.Maintenance) != 0 { |
| + return Record{}, ErrIdentity |
| + } |
| + r.CreatedAt = now.UTC() |
| + body, err := json.Marshal(r) |
| + if err != nil { |
| + return Record{}, err |
| + } |
| + 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 |
| +} |
| + |
| +func PrepareSidecar(r provision.SidecarRecord, now time.Time, region string) (Record, error) { |
| + if !r.CreatedAt.IsZero() || now.IsZero() || region == "" { |
| + return Record{}, ErrIdentity |
| + } |
| + r.CreatedAt = now.UTC() |
| + body, err := json.Marshal(r) |
| + if err != nil { |
| + return Record{}, err |
| + } |
| + 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| new file mode 100644 |
| index 000000000..26a9f2929 |
| --- /dev/null |
| +++ b/internal/hostops/recovery.go |
| @@ -0,0 +1,216 @@ |
| +package hostops |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "os" |
| + "strings" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// ListClaims is discovery only. Every action subsequently reads its own key. |
| +func ListClaims(ctx context.Context, s release.Store) ([]string, error) { |
| + keys, err := s.List(ctx, "control/hosts/") |
| + if err != nil { |
| + return nil, err |
| + } |
| + var hosts []string |
| + for _, key := range keys { |
| + tail, ok := strings.CutPrefix(key, "control/hosts/") |
| + if !ok { |
| + return nil, ErrIdentity |
| + } |
| + if strings.Contains(tail, "/") || !strings.HasSuffix(tail, ".json") { |
| + continue |
| + } |
| + host := strings.TrimSuffix(tail, ".json") |
| + if _, err := hostclaim.Key(host); err != nil { |
| + return nil, err |
| + } |
| + hosts = append(hosts, host) |
| + } |
| + return hosts, nil |
| +} |
| + |
| +type Recovery struct { |
| + SweepAdmissions func(context.Context) error |
| + Controller Controller |
| + // These are required integrations, not no-op defaults. |
| + Purge func(context.Context, hostclaim.Observed) error |
| + Placement func(context.Context, hostclaim.Observed) error |
| + SweepRecords func(context.Context) error |
| + SweepPurges func(context.Context) error |
| +} |
| + |
| +func (r Recovery) Pass(ctx context.Context) error { |
| + c := r.Controller |
| + if err := c.ready(); err != nil { |
| + return err |
| + } |
| + if r.Purge == nil || r.Placement == nil || r.SweepRecords == nil || r.SweepPurges == nil || r.SweepAdmissions == nil { |
| + return ErrContract |
| + } |
| + hosts, err := ListClaims(ctx, c.Store) |
| + if err != nil { |
| + return err |
| + } |
| + var all []error |
| + for _, host := range hosts { |
| + if err := ctx.Err(); err != nil { |
| + return errors.Join(append(all, err)...) |
| + } |
| + obs, err := hostclaim.Read(ctx, c.Store, host) |
| + if err != nil { |
| + all = append(all, err) |
| + continue |
| + } |
| + if obs.Absent { |
| + continue |
| + } |
| + op := obs.Claim.Op |
| + // Zero time is unknown age, never old enough. Freeing empty parking is safe |
| + // independently of age; this includes Plan 4's holderless sweep retries. |
| + if op.Kind == hostclaim.KindParking && len(obs.Claim.Obligations) == 0 { |
| + err = c.Free(ctx, host, op.ID) |
| + } else if op.Kind != "" && op.Kind != hostclaim.KindServing && !op.StartedAt.IsZero() && c.Now().After(op.StartedAt.Add(5*time.Minute)) { |
| + switch op.Kind { |
| + case hostclaim.KindCreating: |
| + err = c.RecoverCreate(ctx, host) |
| + case hostclaim.KindDeleting: |
| + err = c.Retire(ctx, host, op.ID) |
| + case hostclaim.KindPurging: |
| + err = r.Purge(ctx, obs) |
| + case hostclaim.KindPlacing: |
| + err = r.Placement(ctx, obs) |
| + } |
| + } |
| + if err != nil && !errors.Is(err, ErrPending) { |
| + all = append(all, err) |
| + } |
| + } |
| + // Records have their own uncapped scan: late orphans outlive the one-hour |
| + // cancellation hint; records without claims need the shared adoption path. |
| + if err := r.SweepRecords(ctx); err != nil { |
| + all = append(all, err) |
| + } |
| + // Reservations can exist without any claim (crash just after reserve). |
| + if err := r.SweepPurges(ctx); err != nil { |
| + all = append(all, err) |
| + } |
| + if err := r.SweepAdmissions(ctx); err != nil { |
| + all = append(all, err) |
| + } |
| + return errors.Join(all...) |
| +} |
| + |
| +type App struct{ Account, Name string } |
| + |
| +func Apps(ctx context.Context, s release.Store) ([]App, error) { |
| + accounts, err := s.ListDirs(ctx, "apps/") |
| + if err != nil { |
| + return nil, err |
| + } |
| + var out []App |
| + for _, prefix := range accounts { |
| + account := strings.TrimSuffix(strings.TrimPrefix(prefix, "apps/"), "/") |
| + if prefix != "apps/"+account+"/" { |
| + return nil, ErrIdentity |
| + } |
| + // The retained legacy layout is apps/<app>/..., so its first segment is |
| + // an app name, not necessarily an account. Skip names that are valid apps |
| + // but invalid accounts before treating this as a qualified account root. |
| + // A letters-only legacy name can pass both validators, so preserve its |
| + // children as candidates and let the exact record and purge parsers |
| + // validate them. The path alone cannot tell that case from a real account |
| + // containing apps with those legal names; never infer deletion authority |
| + // over retained legacy content from this prefix shape. |
| + if !release.ValidAccount(account) { |
| + if release.ValidApp(account) && prefix == release.LegacyAppPrefix(account) { |
| + continue |
| + } |
| + return nil, ErrIdentity |
| + } |
| + apps, err := s.ListDirs(ctx, prefix) |
| + if err != nil { |
| + return nil, err |
| + } |
| + for _, ap := range apps { |
| + app := strings.TrimSuffix(strings.TrimPrefix(ap, prefix), "/") |
| + if _, err := PurgeKey(account, app); err != nil || ap != prefix+app+"/" { |
| + return nil, ErrIdentity |
| + } |
| + out = append(out, App{Account: account, Name: app}) |
| + } |
| + } |
| + return out, nil |
| +} |
| + |
| +func (c Controller) SweepAllRecords(ctx context.Context) error { |
| + if err := c.ready(); err != nil { |
| + return err |
| + } |
| + apps, err := Apps(ctx, c.Store) |
| + if err != nil { |
| + return err |
| + } |
| + var all []error |
| + for _, app := range apps { |
| + records, err := Records(ctx, c.Store, app.Account, app.Name) |
| + if err != nil { |
| + all = append(all, err) |
| + continue |
| + } |
| + for _, listed := range records { |
| + record, err := ReadRecord(ctx, c.Store, listed.Holder.Key) |
| + if errors.Is(err, os.ErrNotExist) { |
| + continue |
| + } |
| + if err != nil { |
| + all = append(all, err) |
| + continue |
| + } |
| + obs, err := hostclaim.Read(ctx, c.Store, record.Holder.Host) |
| + if err != nil { |
| + all = append(all, err) |
| + continue |
| + } |
| + if obs.Absent { |
| + err = ErrPending |
| + } else { |
| + _, err = RemoveOrphan(ctx, c.Store, record) |
| + } |
| + if err != nil { |
| + all = append(all, err) |
| + } |
| + } |
| + } |
| + return errors.Join(all...) |
| +} |
| + |
| +func SweepAllPurges(ctx context.Context, s release.Store, replay func(context.Context, App, Purge) error) error { |
| + if replay == nil { |
| + return ErrContract |
| + } |
| + apps, err := Apps(ctx, s) |
| + if err != nil { |
| + return err |
| + } |
| + var all []error |
| + for _, app := range apps { |
| + p, _, err := ReadPurge(ctx, s, app.Account, app.Name) |
| + if errors.Is(err, os.ErrNotExist) { |
| + continue |
| + } |
| + if err != nil { |
| + all = append(all, err) |
| + continue |
| + } |
| + if err := replay(ctx, app, p); err != nil { |
| + all = append(all, err) |
| + } |
| + } |
| + return errors.Join(all...) |
| +} |
diff --git a/internal/hostops/remote.go b/internal/hostops/remote.go| new file mode 100644 |
| index 000000000..e1c44b737 |
| --- /dev/null |
| +++ b/internal/hostops/remote.go |
| @@ -0,0 +1,47 @@ |
| +package hostops |
| + |
| +import ( |
| + "context" |
| + |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/park" |
| +) |
| + |
| +type ParkScopeProvider func(context.Context, hostclaim.Observed, hostclaim.Obligation) (hostclaim.ParkScope, error) |
| + |
| +// RunOwnedRemote is called only by the appointed regional worker. It never |
| +// takes another region's chore or acknowledges local work on another box. |
| +func RunOwnedRemote(ctx context.Context, e *park.Engine, w park.Work, scope ParkScopeProvider) error { |
| + if e == nil || e.Claims == nil || e.Region == "" || scope == nil || e.Authorize == nil || e.View == nil || e.Leases == nil { |
| + return ErrContract |
| + } |
| + obs, err := hostclaim.Read(ctx, e.Claims, w.Host) |
| + if err != nil { |
| + return err |
| + } |
| + if obs.Absent { |
| + return ErrPending |
| + } |
| + for _, ob := range obs.Claim.Obligations { |
| + if ob.ID != w.ID || ob.Op != w.Op { |
| + continue |
| + } |
| + if ob.Region != e.Region || ob.Owner != "region:"+e.Region || (ob.Kind != "park-replica" && ob.Kind != "park-generation") { |
| + return ErrIdentity |
| + } |
| + if err := e.Authorize(ctx, obs, ob); err != nil { |
| + return err |
| + } |
| + if ob.Park == nil { |
| + p, err := scope(ctx, obs, ob) |
| + if err != nil { |
| + return err |
| + } |
| + if err := e.Bind(ctx, w, p); err != nil { |
| + return err |
| + } |
| + } |
| + return e.Run(ctx, w) |
| + } |
| + return ErrPending |
| +} |
diff --git a/internal/hostops/retirement.go b/internal/hostops/retirement.go| new file mode 100644 |
| index 000000000..f0ee2979d |
| --- /dev/null |
| +++ b/internal/hostops/retirement.go |
| @@ -0,0 +1,69 @@ |
| +package hostops |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "os" |
| + |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/provision" |
| +) |
| + |
| +func (c Controller) RetireRecord(ctx context.Context, ref provision.RetireRef) error { |
| + if err := c.ready(); err != nil { |
| + return err |
| + } |
| + key := provision.RecordKey(ref.Account, ref.App, ref.Host) |
| + if ref.Family == "sidecar" { |
| + key = provision.SidecarRecordKey(ref.Account, ref.App, ref.Name) |
| + } |
| + rec, rerr := ReadRecord(ctx, c.Store, key) |
| + if rerr != nil && !errors.Is(rerr, os.ErrNotExist) { |
| + return rerr |
| + } |
| + obs, err := hostclaim.Read(ctx, c.Store, ref.Host) |
| + if err != nil { |
| + return err |
| + } |
| + if obs.Absent { |
| + if errors.Is(rerr, os.ErrNotExist) { |
| + return nil |
| + } |
| + return ErrPending |
| + } |
| + h := obs.Claim.Holder |
| + if h == nil { |
| + if rerr == nil { |
| + _, err = RemoveOrphan(ctx, c.Store, rec) |
| + return err |
| + } |
| + if obs.Claim.Free() { |
| + return nil |
| + } |
| + return ErrPending |
| + } |
| + if h.Key != key || h.Family != ref.Family || h.Account != ref.Account || h.App != ref.App || (ref.Incarnation != "" && ref.Incarnation != h.Incarnation) { |
| + return ErrIdentity |
| + } |
| + if rerr == nil && !matches(rec, h) { |
| + return ErrIdentity |
| + } |
| + switch obs.Claim.Op.Kind { |
| + case hostclaim.KindServing: |
| + if rerr != nil { |
| + return ErrPending |
| + } // Old-console deletion needs operator recovery. |
| + obs, err = c.BeginRetire(ctx, key, ref.By, h.Incarnation) |
| + if err != nil { |
| + return err |
| + } |
| + return c.Retire(ctx, ref.Host, obs.Claim.Op.ID) |
| + case hostclaim.KindDeleting: |
| + return c.Retire(ctx, ref.Host, obs.Claim.Op.ID) |
| + case hostclaim.KindParking: |
| + // Record withdrawal completed. Chores still block reuse in the claim. |
| + return nil |
| + default: |
| + return ErrPending // A purge is driven by its journal, never this path. |
| + } |
| +} |
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 a20198f96..37ac2e594 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/retirement.go b/internal/provision/retirement.go| new file mode 100644 |
| index 000000000..aab7a2564 |
| --- /dev/null |
| +++ b/internal/provision/retirement.go |
| @@ -0,0 +1,53 @@ |
| +package provision |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + |
| + "github.com/carlosframework/platform/internal/controlname" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +type RetireRef struct { |
| + Family, Account, App, Host, Name, By string |
| + // Required by rollbacks: never retire a successor to the failed attempt. |
| + Incarnation string |
| +} |
| +type ClaimRetirer interface { |
| + RetireRecord(context.Context, RetireRef) error |
| +} |
| +type RetireOptions struct { |
| + HostClaim bool |
| + Claims ClaimRetirer |
| +} |
| + |
| +var ErrClaimRetirer = errors.New("provision: claim retirement unavailable") |
| + |
| +func RetireRecord(ctx context.Context, s release.Store, ref RetireRef, opt RetireOptions) error { |
| + if !opt.HostClaim { |
| + switch ref.Family { |
| + case "instance": |
| + return DeleteRecord(ctx, s, ref.Account, ref.App, ref.Host) |
| + case "sidecar": |
| + return DeleteSidecarRecord(ctx, s, ref.Account, ref.App, ref.Name) |
| + default: |
| + return ErrClaimRetirer |
| + } |
| + } |
| + if opt.Claims == nil || !controlname.ValidAccount(ref.Account) || !controlname.ValidApp(ref.App) || !controlname.ValidHostShape(ref.Host) { |
| + return ErrClaimRetirer |
| + } |
| + switch ref.Family { |
| + case "instance": |
| + if ref.Name != "" { |
| + return ErrClaimRetirer |
| + } |
| + case "sidecar": |
| + if !controlname.ValidLabel(ref.Name) || ref.Host != SidecarHost(ref.Name, ref.App, ref.Account) { |
| + return ErrClaimRetirer |
| + } |
| + default: |
| + return ErrClaimRetirer |
| + } |
| + return opt.Claims.RetireRecord(ctx, ref) |
| +} |
diff --git a/internal/provision/retirement_test.go b/internal/provision/retirement_test.go| new file mode 100644 |
| index 000000000..e0a4f45f4 |
| --- /dev/null |
| +++ b/internal/provision/retirement_test.go |
| @@ -0,0 +1,41 @@ |
| +package provision |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +type retireSpy struct{ calls int } |
| + |
| +func (s *retireSpy) RetireRecord(context.Context, RetireRef) error { |
| + s.calls++ |
| + return errors.New("claim path") |
| +} |
| +func TestRetireRecordFlagOffAndRequiredProvider(t *testing.T) { |
| + ctx := context.Background() |
| + store := release.NewDirStore(t.TempDir()) |
| + spy := &retireSpy{} |
| + for _, family := range []string{"instance", "sidecar"} { |
| + ref := RetireRef{Family: family, Account: "bab", App: "jam", Host: "shop.bab.example.com"} |
| + if family == "sidecar" { |
| + ref.Name = "job" |
| + ref.Host = SidecarHost(ref.Name, ref.App, ref.Account) |
| + } |
| + if err := RetireRecord(ctx, store, ref, RetireOptions{Claims: spy}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if spy.calls != 0 { |
| + t.Fatal("flag off called claims") |
| + } |
| + if err := RetireRecord(ctx, store, ref, RetireOptions{HostClaim: true}); !errors.Is(err, ErrClaimRetirer) { |
| + t.Fatal(err) |
| + } |
| + } |
| + ref := RetireRef{Family: "instance", Account: "bab", App: "jam", Host: "shop.bab.example.com"} |
| + if err := RetireRecord(ctx, store, ref, RetireOptions{HostClaim: true, Claims: spy}); err == nil || spy.calls != 1 { |
| + t.Fatal("claim path not required") |
| + } |
| +} |
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"` |