hostops: record, transition and recovery primitives (claim Plan 5 Task 2)
Co-Authored-By: Codex <noreply@openai.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
5 files changed,
+899
−0
internal/hostops/operations.go+267 −0internal/hostops/purge.go+196 −0internal/hostops/record.go+173 −0internal/hostops/recovery.go+216 −0internal/hostops/remote.go+47 −0
diff --git a/internal/hostops/operations.go b/internal/hostops/operations.go| new file mode 100644 |
| index 000000000..4409ef9b5 |
| --- /dev/null |
| +++ b/internal/hostops/operations.go |
| @@ -0,0 +1,267 @@ |
| +package hostops |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "os" |
| + "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, ErrIdentity |
| + } |
| + return obs, 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 := c.Adopt(ctx, r) |
| + if err != nil { |
| + return obs, err |
| + } |
| + 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/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..7616db1b0 |
| --- /dev/null |
| +++ b/internal/hostops/record.go |
| @@ -0,0 +1,173 @@ |
| +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 |
| + } |
| + n := hostclaim.NewHolder{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.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) { |
| + 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{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{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..c134a69b1 |
| --- /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 = c.Adopt(ctx, record) |
| + } 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 |
| +} |