package maintenanceguard import ( "context" "encoding/json" "errors" "math" "os" ) type OperationKind string const ( MaintenanceOperation OperationKind = "maintenance" TrashOperation OperationKind = "trash" RestoreOperation OperationKind = "restore" PurgeOperation OperationKind = "purge" ) func (k OperationKind) valid() bool { return k == MaintenanceOperation || k == TrashOperation || k == RestoreOperation || k == PurgeOperation } type Lifecycle struct { Version int `json:"version"` Account string `json:"account"` App string `json:"app"` Generation int64 `json:"generation"` State string `json:"state"` OperationID string `json:"operation_id"` Kind OperationKind `json:"kind"` ClaimGeneration int64 `json:"claim_generation"` CompletionDigest string `json:"completion_digest,omitempty"` } func (l Lifecycle) valid() bool { if _, e := appBase(l.Account, l.App); e != nil { return false } if l.Version != Version || l.Generation <= 0 || !ValidID(l.OperationID) || !l.Kind.valid() || l.ClaimGeneration <= 0 { return false } switch l.State { case "claimed": return l.Generation == l.ClaimGeneration && l.CompletionDigest == "" case "idle": return l.ClaimGeneration < math.MaxInt64 && l.Generation == l.ClaimGeneration+1 && ValidDigest(l.CompletionDigest) } return false } type claimIntent struct { Version int `json:"version"` Account string `json:"account"` App string `json:"app"` OperationID string `json:"operation_id"` Kind OperationKind `json:"kind"` Generation int64 `json:"generation"` } func intent(l Lifecycle) claimIntent { return claimIntent{Version, l.Account, l.App, l.OperationID, l.Kind, l.ClaimGeneration} } func claimKey(l Lifecycle, completed bool) string { p, _ := appBase(l.Account, l.App) suffix := "claim" if completed { suffix = "completion" } return p + "operations/" + l.OperationID + "/" + suffix + ".json" } type claimCompletion struct { Claim claimIntent `json:"claim"` Generation int64 `json:"generation"` } type ClaimSnapshot struct { repository *Repository value Lifecycle etag string exists bool } func (s ClaimSnapshot) Exists() bool { return s.exists } func (s ClaimSnapshot) Value() Lifecycle { return s.value } func (r *Repository) ReadLifecycle(ctx context.Context, account, app string) (ClaimSnapshot, error) { key, e := LifecycleKey(account, app) if e != nil { return ClaimSnapshot{}, e } raw, etag, e := r.store.Get(ctx, key) if errors.Is(e, os.ErrNotExist) { return ClaimSnapshot{repository: r, value: Lifecycle{Account: account, App: app}}, nil } if e != nil { return ClaimSnapshot{}, e } var l Lifecycle if decode(raw, &l) != nil || !l.valid() || l.Account != account || l.App != app || !cleanETag(etag) { return ClaimSnapshot{}, ErrInvalid } b, _, e := r.store.Get(ctx, claimKey(l, false)) var i claimIntent if e != nil || decode(b, &i) != nil || i != intent(l) { return ClaimSnapshot{}, ErrUnavailable } if l.State == "idle" { b, _, e = r.store.Get(ctx, claimKey(l, true)) var c claimCompletion if e != nil || decode(b, &c) != nil || c.Claim != i || c.Generation != l.Generation || digest(b) != l.CompletionDigest { return ClaimSnapshot{}, ErrUnavailable } } return ClaimSnapshot{r, l, etag, true}, nil } // AcquireClaim excludes competing app lifecycle operations. The caller must // authorize the operation first; acquisition alone cannot start maintenance. // Immutable intent may survive a lost CAS, but grants no claim or side effect. func (r *Repository) AcquireClaim(ctx context.Context, account, app, id string, kind OperationKind) (ClaimSnapshot, error) { if !ValidID(id) || !kind.valid() { return ClaimSnapshot{}, ErrInvalid } old, e := r.ReadLifecycle(ctx, account, app) if e != nil { return ClaimSnapshot{}, e } if old.exists && old.value.State != "idle" { return ClaimSnapshot{}, ErrBusy } generation := int64(1) if old.exists { if old.value.Generation == math.MaxInt64 { return ClaimSnapshot{}, ErrConflict } generation = old.value.Generation + 1 } l := Lifecycle{Version: Version, Account: account, App: app, Generation: generation, State: "claimed", OperationID: id, Kind: kind, ClaimGeneration: generation} b, _ := json.Marshal(intent(l)) if e = r.putImmutable(ctx, claimKey(l, false), b); e != nil { return ClaimSnapshot{}, e } raw, _ := json.Marshal(l) key, _ := LifecycleKey(account, app) etag, e := r.store.Put(ctx, key, raw, old.etag) if e != nil { return ClaimSnapshot{}, e } if !cleanETag(etag) { return ClaimSnapshot{}, ErrUnavailable } return ClaimSnapshot{r, l, etag, true}, nil } // RecoverClaim observes only the exact still-held operation. It does not expire // a claim, release it, reapprove it, or rerun any command. func (r *Repository) RecoverClaim(ctx context.Context, account, app, id string, kind OperationKind) (ClaimSnapshot, error) { if !ValidID(id) || !kind.valid() { return ClaimSnapshot{}, ErrInvalid } s, e := r.ReadLifecycle(ctx, account, app) if e != nil { return ClaimSnapshot{}, e } if !s.exists || s.value.State != "claimed" || s.value.OperationID != id || s.value.Kind != kind { return ClaimSnapshot{}, ErrConflict } return s, nil } func (r *Repository) currentClaim(ctx context.Context, s ClaimSnapshot) error { if s.repository != r || !s.exists || s.value.State != "claimed" || !s.value.valid() || !cleanETag(s.etag) { return ErrInvalid } now, e := r.ReadLifecycle(ctx, s.value.Account, s.value.App) if e != nil { return e } if !now.exists || now.etag != s.etag || now.value != s.value { return ErrConflict } return nil } // ReleaseClaim requires the caller's completed-operation proof. Maintenance // callers must first verify both resumed projections and healthy serving under // the reviewed lease boundary. This CAS does not establish those facts itself. func (r *Repository) ReleaseClaim(ctx context.Context, s ClaimSnapshot) (ClaimSnapshot, error) { if e := r.currentClaim(ctx, s); e != nil { return ClaimSnapshot{}, e } if s.value.Generation == math.MaxInt64 { return ClaimSnapshot{}, ErrConflict } l := s.value l.Generation++ l.State = "idle" completion, _ := json.Marshal(claimCompletion{intent(l), l.Generation}) if e := r.putImmutable(ctx, claimKey(l, true), completion); e != nil { return ClaimSnapshot{}, e } l.CompletionDigest = digest(completion) raw, _ := json.Marshal(l) key, _ := LifecycleKey(l.Account, l.App) etag, e := r.store.Put(ctx, key, raw, s.etag) if e != nil { return ClaimSnapshot{}, e } if !cleanETag(etag) { return ClaimSnapshot{}, ErrUnavailable } return ClaimSnapshot{r, l, etag, true}, nil }