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 } } // BeginPurge starts one host's purge from serving. fencedCompletion is // hostclaim.StartGuard's (ruling 86): the completion box whose termination // fence the caller verified (ProductionAuthority.completionFence), or "". func BeginPurge(id, by string, now time.Time, regions, boxes []string, fencedCompletion string) func(*hostclaim.Claim) error { return func(c *hostclaim.Claim) error { if id == "" || now.IsZero() { return ErrIdentity } if err := hostclaim.StartGuard(c, regions, boxes, fencedCompletion); 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 } } func CompleteMetadataPlacement(id, appID string) func(*hostclaim.Claim) error { return func(c *hostclaim.Claim) error { if id == "" || appID == "" || c.Holder == nil || c.Op.Kind != hostclaim.KindPlacing || c.Op.ID != id || c.Op.Intent["app_id"] != appID { return ErrPending } c.Op = hostclaim.Op{Kind: hostclaim.KindServing, Exec: c.Op.Exec, Participants: c.Op.Participants} return nil } }