| new file mode 100644 |
| index 000000000..d7429affe |
| --- /dev/null |
| +++ b/internal/hostclaim/claim.go |
| @@ -0,0 +1,227 @@ |
| +// Package hostclaim is the host lifecycle claim (spec |
| +// 2026-10-02-host-lifecycle-claim-design.md): one object per host that |
| +// names the incarnation entitled to it, the operation running on it, where |
| +// its data lives and what is still owed. This package only reads, |
| +// validates and conditionally writes that object. It never deletes it |
| +// (C5a). |
| +package hostclaim |
| + |
| +import ( |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "errors" |
| + "fmt" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/controlname" |
| + "github.com/carlosframework/platform/internal/maintenanceguard" |
| +) |
| + |
| +const ( |
| + KindServing = "serving" |
| + KindCreating = "creating" |
| + KindDeleting = "deleting" |
| + KindParking = "parking" |
| + KindPurging = "purging" |
| + KindPlacing = "placing" |
| + KindMoving = "moving" // move-any-app |
| + KindLanding = "landing" // move-any-app |
| + KindReverting = "reverting" // move-any-app |
| + KindRestoring = "restoring" // move-any-app |
| + KindPushing = "pushing" // later push spec |
| + |
| + schemaVersion = 2 |
| + retiredCap = 32 |
| +) |
| + |
| +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} |
| + |
| +var ( |
| + ErrMalformed = errors.New("hostclaim: malformed claim") |
| + ErrInvalidHost = errors.New("hostclaim: invalid host") |
| +) |
| + |
| +type Holder struct { |
| + Family string `json:"family"` // "instance" | "sidecar" |
| + Key string `json:"key"` // exact record key |
| + Account string `json:"account"` |
| + App string `json:"app"` |
| + Name string `json:"name,omitempty"` // sidecars only |
| + Incarnation string `json:"incarnation"` |
| + CreatedAt time.Time `json:"created_at"` |
| + Location string `json:"location"` |
| + Generation string `json:"generation,omitempty"` |
| + Stamp uint64 `json:"stamp,omitempty"` |
| +} |
| + |
| +type Op struct { |
| + Kind string `json:"kind,omitempty"` |
| + ID string `json:"id,omitempty"` |
| + Phase string `json:"phase,omitempty"` |
| + Intent map[string]string `json:"intent,omitempty"` |
| + Journal map[string]string `json:"journal,omitempty"` |
| + By string `json:"by,omitempty"` |
| + StartedAt time.Time `json:"started_at,omitempty"` |
| + Exec string `json:"exec,omitempty"` |
| + Participants []string `json:"participants,omitempty"` |
| +} |
| + |
| +type Touched struct { |
| + Complete bool `json:"complete"` |
| + Regions []string `json:"regions,omitempty"` |
| + Boxes []string `json:"boxes,omitempty"` |
| + DiscoveryOp string `json:"discovery_op,omitempty"` |
| +} |
| + |
| +// InventoryEntry is one box's local paths for the host (§6.3), each tagged |
| +// with the installation stamp that produced it (§6.4). |
| +type InventoryEntry struct { |
| + Layout string `json:"layout"` // "flat" | "nested" |
| + Paths map[string]uint64 `json:"paths"` // path -> stamp |
| +} |
| + |
| +type Obligation struct { |
| + ID string `json:"id"` |
| + Op string `json:"op"` |
| + Kind string `json:"kind"` // "park-replica" | "park-local" |
| + Region string `json:"region,omitempty"` |
| + Box string `json:"box,omitempty"` |
| + Retirement string `json:"retirement"` |
| + Owner string `json:"owner"` // "region:<r>" | "box:<b>" |
| +} |
| + |
| +type CancelledCreate struct { |
| + Key string `json:"key"` |
| + Incarnation string `json:"incarnation"` |
| + Until time.Time `json:"until"` |
| +} |
| + |
| +type Claim struct { |
| + V int `json:"v"` |
| + Host string `json:"host"` |
| + Gen uint64 `json:"gen"` |
| + Holder *Holder `json:"holder,omitempty"` |
| + Op Op `json:"op"` |
| + Touched Touched `json:"touched"` |
| + Inventory map[string]InventoryEntry `json:"inventory,omitempty"` |
| + Obligations []Obligation `json:"obligations,omitempty"` |
| + Lost []string `json:"lost,omitempty"` |
| + RetiredIncarnations []string `json:"retired_incarnations,omitempty"` |
| + QuietAfter map[string]time.Time `json:"quiet_after,omitempty"` |
| + CancelledCreates []CancelledCreate `json:"cancelled_creates,omitempty"` |
| +} |
| + |
| +// Free reports whether the host has no holder and no operation. |
| +func (c *Claim) Free() bool { return c.Holder == nil && c.Op.Kind == "" } |
| + |
| +func Key(host string) (string, error) { |
| + if !controlname.ValidHostShape(host) { |
| + return "", ErrInvalidHost |
| + } |
| + return "control/hosts/" + host + ".json", nil |
| +} |
| + |
| +// Incarnation is §2's identity. A record with a zero CreatedAt (written |
| +// before CreatedAt existed) gets the domain-separated legacy identity. |
| +func Incarnation(account, app, host string, created time.Time) (string, error) { |
| + if created.IsZero() { |
| + if !controlname.ValidAccount(account) || !controlname.ValidApp(app) || !controlname.ValidHostShape(host) { |
| + return "", ErrMalformed |
| + } |
| + sum := sha256.Sum256([]byte("carlos-legacy-incarnation-v1\x00" + account + "\x00" + app + "\x00" + host)) |
| + return hex.EncodeToString(sum[:]), nil |
| + } |
| + return maintenanceguard.RecordIdentity(account, app, host, created) |
| +} |
| + |
| +// sidecarSuffix restates provision.SidecarSuffix (no import: avoids a cycle). |
| +const sidecarSuffix = ".sidecar.internal" |
| + |
| +// holderKeyMatches is the one identity check for a holder's record key and |
| +// host, shared by Validate, BeginCreate and Adopt. An instance's key is |
| +// exactly apps/<acct>/<app>/instances/<host>.json. A sidecar's key is |
| +// apps/<acct>/<app>/sidecars/<name>.json, and its host is exactly |
| +// <name>.<app>.<acct>.sidecar.internal (provision.SidecarHost). |
| +func holderKeyMatches(family, key, account, app, name, host string) bool { |
| + base := "apps/" + account + "/" + app + "/" |
| + switch family { |
| + case "instance": |
| + return name == "" && key == base+"instances/"+host+".json" |
| + case "sidecar": |
| + return controlname.ValidLabel(name) && key == base+"sidecars/"+name+".json" && |
| + host == name+"."+app+"."+account+sidecarSuffix |
| + } |
| + return false |
| +} |
| + |
| +func isHex64(s string) bool { |
| + if len(s) != 64 { |
| + return false |
| + } |
| + _, err := hex.DecodeString(s) |
| + return err == nil |
| +} |
| + |
| +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 c.Op.Kind != "" && !knownKinds[c.Op.Kind] { |
| + return fmt.Errorf("%w: unknown op kind %q", ErrMalformed, c.Op.Kind) |
| + } |
| + if c.Op.Kind != "" && c.Op.Kind != KindServing && c.Op.ID == "" { |
| + return fmt.Errorf("%w: op %q without id", ErrMalformed, c.Op.Kind) |
| + } |
| + if c.Holder == nil { |
| + if c.Op.Kind != "" && c.Op.Kind != KindPurging { // a purge fence has no holder (CC21) |
| + return fmt.Errorf("%w: op %q without holder", ErrMalformed, c.Op.Kind) |
| + } |
| + if len(c.Obligations) > 0 { |
| + return fmt.Errorf("%w: a free claim with outstanding obligations", ErrMalformed) |
| + } |
| + } else { |
| + h := c.Holder |
| + if (h.Family != "instance" && h.Family != "sidecar") || h.Key == "" || !isHex64(h.Incarnation) || |
| + !controlname.ValidAccount(h.Account) || !controlname.ValidApp(h.App) { |
| + return fmt.Errorf("%w: holder identity", ErrMalformed) |
| + } |
| + if !holderKeyMatches(h.Family, h.Key, h.Account, h.App, h.Name, c.Host) { |
| + return fmt.Errorf("%w: holder key %q is not this host's", ErrMalformed, h.Key) |
| + } |
| + if want, err := Incarnation(h.Account, h.App, c.Host, h.CreatedAt); err != nil || want != h.Incarnation { |
| + return fmt.Errorf("%w: incarnation does not match the holder's creation fields", ErrMalformed) |
| + } |
| + legacy, scoped := "instances/"+c.Host, "instances/"+c.Host+"/"+h.Incarnation |
| + if h.Location != legacy && h.Location != scoped { |
| + return fmt.Errorf("%w: holder location %q", ErrMalformed, h.Location) |
| + } |
| + if c.Op.Kind == "" { |
| + return fmt.Errorf("%w: holder without op", ErrMalformed) |
| + } |
| + } |
| + if len(c.RetiredIncarnations) > retiredCap { |
| + return fmt.Errorf("%w: retired_incarnations over cap", ErrMalformed) |
| + } |
| + seen := map[string]bool{} |
| + for _, o := range c.Obligations { |
| + if o.ID == "" || o.Op == "" || o.Retirement == "" || seen[o.ID] { |
| + return fmt.Errorf("%w: obligation ids", ErrMalformed) |
| + } |
| + seen[o.ID] = true |
| + switch o.Kind { |
| + case "park-replica": |
| + if o.Region == "" || o.Box != "" || o.Owner != "region:"+o.Region { |
| + return fmt.Errorf("%w: park-replica %q location/owner", ErrMalformed, o.ID) |
| + } |
| + case "park-local": |
| + if o.Box == "" || o.Region != "" || o.Owner != "box:"+o.Box { |
| + return fmt.Errorf("%w: park-local %q location/owner", ErrMalformed, o.ID) |
| + } |
| + default: |
| + return fmt.Errorf("%w: obligation kind %q", ErrMalformed, o.Kind) |
| + } |
| + } |
| + return nil |
| +} |