package hostops import ( "bytes" "context" "crypto/ed25519" "encoding/json" "errors" "io" "math" "os" "reflect" "strings" "time" "github.com/carlosframework/platform/internal/hostclaim" "github.com/carlosframework/platform/internal/release" ) // Lifecycle claims (control/hosts/.json) are the claim engine's CAS // root. The built Tasks 2-6 engine writes them through the data handle, so // the data boundary cannot refuse them outright. It admits a claim write // only under a lifecycle grant carried in ctx, and validates the exact // old->new claim against the grant's scope. A grant is minted only by a // writer-issued handle (its own app/operation) or by RecoveryPass (stale // operations and frozen-backfill adoption only). A handler holding // Config.Releases and no grant cannot write any claim. type lifecycleGrantKey struct{} type lifecycleGrant struct { kind string // "app" or "recovery" account, app, appID string operation string // exclusive: the grant is an OperationHandle's (its op must still hold // admission); otherwise a StartHandle's (its registration must be live // at the handle's epoch with the handle's owner). exclusive bool owner RegistrationOwner epoch uint64 w *ControlWriter // registration and incarnation are a StartHandle's registration kind // and saved holder incarnation. A reset-wake-retry grant admits only // the exact ResetWakeRetry transition for that incarnation (review R2). registration, incarnation string } // Lifecycle returns ctx carrying this registration's claim grant. The grant // is checked against admission on every claim write: it dies with the // registration. func (h *StartHandle) Lifecycle(ctx context.Context) context.Context { if h.kind == RegistrationIngest { return ctx } return withLifecycleGrant(ctx, lifecycleGrant{kind: "app", account: h.account, app: h.app, appID: h.appID, operation: h.id, owner: h.owner, epoch: h.epoch, w: h.w, registration: h.kind, incarnation: h.incarnation}) } // Lifecycle returns ctx carrying this operation's claim grant. The grant is // checked against admission on every claim write: it dies with the op. func (h *OperationHandle) Lifecycle(ctx context.Context) context.Context { return withLifecycleGrant(ctx, lifecycleGrant{kind: "app", account: h.account, app: h.app, appID: h.appID, operation: h.id, exclusive: true, owner: h.owner, w: h.w}) } // grantLive re-reads admission: an app grant authorizes nothing once its // registration lapsed or was drained, or once its operation ended. func grantLive(ctx context.Context, raw release.Store, g lifecycleGrant) (bool, error) { a, err := ReadAdmission(ctx, raw, g.account, g.app, g.appID) if errors.Is(err, os.ErrNotExist) { return false, nil } if err != nil { return false, err } v := a.Value if g.exclusive { return v.State == "closed" && v.Op != nil && v.Op.ID == g.operation && v.Op.AppID == g.appID, nil } r, ok := v.Registrations[g.operation] return ok && r.Owner == g.owner && r.AppID == g.appID && r.Epoch == g.epoch && v.Epoch == g.epoch && RegistrationLive(r, g.w.now()), nil } func withLifecycleGrant(ctx context.Context, g lifecycleGrant) context.Context { inherited, _ := ctx.Value(lifecycleGrantKey{}).([]lifecycleGrant) return context.WithValue(ctx, lifecycleGrantKey{}, append(append([]lifecycleGrant(nil), inherited...), g)) } // RecoveryPass runs the built recovery pass with a recovery grant. That // grant only lets a stale operation advance or clear (never install a new // holder or a new operation) and lets frozen backfill adopt an absent // claim for an existing matching record. func (w *ControlWriter) RecoveryPass(ctx context.Context, r Recovery) error { return w.RunRecovery(ctx, r.Pass) } // RunRecovery runs one console recovery body (frozen-backfill reconcile and // the recovery pass) under the recovery grant. The grant is constrained by // recoveryGrantAllows, so a caller gains nothing beyond what recovery itself // may do: advance or clear a stale operation, or adopt for a signed plan. func (w *ControlWriter) RunRecovery(ctx context.Context, body func(context.Context) error) error { if err := w.ready(); err != nil { return err } if body == nil { return ErrContract } scope := &moveRecoveryPassScope{} scope.live.Store(true) defer scope.live.Store(false) ctx = context.WithValue(ctx, moveRecoveryPassKey{}, scope) return body(withLifecycleGrant(ctx, lifecycleGrant{kind: "recovery", w: w})) } // decodeClaim is strict (unknown fields and trailing data refuse) and // validates the decoded claim, as hostclaim.Read does. func decodeClaim(b []byte) (*hostclaim.Claim, error) { if len(b) == 0 { return nil, nil } dec := json.NewDecoder(bytes.NewReader(b)) dec.DisallowUnknownFields() var c hostclaim.Claim if err := dec.Decode(&c); err != nil { return nil, err } if _, err := dec.Token(); err != io.EOF { return nil, ErrIdentity } if err := c.Validate(); err != nil { return nil, err } return &c, nil } // claimTransitionShape is review P1-1's per-write validation: same host as // the key, generation exactly old+1 (1 from absence), same schema version. func claimTransitionShape(host string, old, next *hostclaim.Claim) bool { if next.Host != host { return false } if old == nil { return next.Gen == 1 } return old.Host == host && next.V == old.V && old.Gen < math.MaxUint64 && next.Gen == old.Gen+1 } func holderOf(c *hostclaim.Claim) *hostclaim.Holder { if c == nil { return nil } return c.Holder } func opOf(c *hostclaim.Claim) hostclaim.Op { if c == nil { return hostclaim.Op{} } return c.Op } // authorizeClaimWrite is the classifier's lifecycle-claim rule. func authorizeClaimWrite(ctx context.Context, raw release.Store, host string, t MutationTarget) error { grants, _ := ctx.Value(lifecycleGrantKey{}).([]lifecycleGrant) if len(grants) == 0 { return ErrPending } old, err := decodeClaim(t.Before) if err != nil { return ErrIdentity } next, err := decodeClaim(t.After) if err != nil || next == nil { return ErrIdentity } if !claimTransitionShape(host, old, next) || (old == nil && next.Origins != nil) || (old != nil && !reflect.DeepEqual(old.Origins, next.Origins)) { return ErrIdentity } for _, g := range grants { if g.w == nil || !operationHomeMatches(g.w.homeRegion, old) || !operationHomeMatches(g.w.homeRegion, next) { continue } switch g.kind { case "app": if !appGrantAllows(g, old, next) || g.registration == RegistrationWakeRetryReset && !resetWakeRetryTransition(g.incarnation, old, next) { continue } if live, err := grantLive(ctx, raw, g); err != nil { return err } else if live { return nil } case "recovery": if ok, err := recoveryGrantAllows(ctx, raw, g.w, host, old, next); err != nil { return err } else if ok { return nil } } } return ErrPending } func sameApp(h *hostclaim.Holder, g lifecycleGrant) bool { return h != nil && h.Account == g.account && h.App == g.app } func appGrantAllows(g lifecycleGrant, old, next *hostclaim.Claim) bool { oh, nh := holderOf(old), holderOf(next) // Installing (or replacing) a holder: only this grant's own app. if nh != nil && (oh == nil || oh.Incarnation != nh.Incarnation) && !sameApp(nh, g) { return false } if oh != nil && !sameApp(oh, g) { // A foreign holder is never replaced or removed by an app grant // (v2 let a write that merely NAMED its own op ID in next replace // it), and only an op this grant already owns on that claim may be // advanced or cleared. Installing this grant's op over a foreign // holder is refused. if nh == nil || nh.Account != oh.Account || nh.App != oh.App || nh.Incarnation != oh.Incarnation { return false } return opOf(old).ID == g.operation && (opOf(next).ID == g.operation || opOf(next).ID == "") } if oh == nil { // No holder: never overwrite another operation's op (a foreign // purge fence or creating op); stale ops are recovery's. if id := opOf(old).ID; id != "" && id != g.operation { return false } } return sameApp(oh, g) || sameApp(nh, g) || opOf(old).ID == g.operation || opOf(next).ID == g.operation } // resetWakeRetryTransition reports whether old->next is exactly what // hostclaim.ResetWakeRetry writes for the registered holder incarnation: // the same claim with gen advanced and the holder's wake-retry episode // replaced by a fresh open episode that carries the operator request. func resetWakeRetryTransition(incarnation string, old, next *hostclaim.Claim) bool { oh, nh := holderOf(old), holderOf(next) if incarnation == "" || oh == nil || nh == nil || oh.Incarnation != incarnation || oh.WakeRetry == nil || nh.WakeRetry == nil { return false } r := nh.WakeRetry if r.EpisodeID == "" || r.EpisodeID == oh.WakeRetry.EpisodeID || r.State != "open" || r.ResetRequest == "" || r.AttemptsReserved != 0 || !r.NotBefore.IsZero() || r.Attempt != nil { return false } want, holder := *old, *oh holder.WakeRetry = r want.Holder, want.Gen = &holder, next.Gen a, err := json.Marshal(&want) if err != nil { return false } b, err := json.Marshal(next) return err == nil && bytes.Equal(a, b) } func recoveryGrantAllows(ctx context.Context, raw release.Store, w *ControlWriter, host string, old, next *hostclaim.Claim) (bool, error) { oh, nh := holderOf(old), holderOf(next) if old != nil { if moveRecoveryKind(old.Op.Kind) { return false, nil } op := old.Op stale := op.Kind == hostclaim.KindParking && len(old.Obligations) == 0 || op.Kind != "" && op.Kind != hostclaim.KindServing && !op.StartedAt.IsZero() && w.now().After(op.StartedAt.Add(5*time.Minute)) if stale && (nh == nil || (oh != nil && oh.Incarnation == nh.Incarnation)) && (next.Op.ID == op.ID || next.Op.ID == "") { return true, nil } } // Frozen-backfill adoption: an absent or free claim gains the holder of an // existing matching record, only while a signed plan is adopting. if nh == nil || (old != nil && (oh != nil || old.Op.Kind != "")) { return false, nil } rawPlan, _, err := raw.Get(ctx, BackfillKey) if errors.Is(err, os.ErrNotExist) { return false, nil } if err != nil { return false, err } var plan BackfillPlan if release.VerifyRootEnvelope(w.pub, rawPlan, &plan) != nil || plan.Phase != "adopting" { return false, nil } if !strings.HasPrefix(nh.Key, "apps/"+nh.Account+"/"+nh.App+"/") { return false, ErrIdentity } r, err := ReadRecord(ctx, raw, nh.Key) if errors.Is(err, os.ErrNotExist) { return false, nil } if err != nil { return false, err } return r.Holder.Host == host && matches(r, nh), nil } // authorizeSignedControl admits a root-signed control object (the roster, // the backfill plan) only with a valid root envelope. func authorizeSignedControl(pub ed25519.PublicKey, t MutationTarget) error { if t.Method != "Put" { return ErrIdentity } var v json.RawMessage if release.VerifyRootEnvelope(pub, t.After, &v) != nil { return ErrIdentity } return nil }