package hostops import ( "context" "crypto/rand" "encoding/hex" "sync/atomic" "time" "github.com/carlosframework/platform/internal/release" ) type appMutationPermitKey struct{} // AppMutationPermit is effect-time authority for one app incarnation. Its // constructor is withAppMutationEffect, called only from EffectRunner.Issue // after the effect is durably issued. type AppMutationPermit struct { writer *ControlWriter account, app, appID string epoch uint64 operation, effect string owner RegistrationOwner exclusive bool sqlOnly bool sqlJoined atomic.Bool live atomic.Bool now func() time.Time } // PermitScope is the validated, read-only view the SQL boundary loads into // its TEMP permit relation. type PermitScope struct { Account, App, AppID string Epoch uint64 Operation, Effect string } func withAppMutationEffect(ctx context.Context, r EffectRunner, e Effect, epoch uint64) (context.Context, func()) { p := &AppMutationPermit{writer: r.w, account: r.account, app: r.app, appID: r.appID, epoch: epoch, operation: e.OperationID, effect: e.ID, owner: r.owner, exclusive: r.exclusive, sqlOnly: e.Action == sqlIngestAction, now: r.now} p.live.Store(true) inherited, _ := ctx.Value(appMutationPermitKey{}).([]*AppMutationPermit) permits := append(append([]*AppMutationPermit(nil), inherited...), p) return context.WithValue(ctx, appMutationPermitKey{}, permits), func() { p.live.Store(false) } } // HasPermitContext is a cheap pre-check for logging and fixtures. It is never // authority. func HasPermitContext(ctx context.Context) bool { permits, _ := ctx.Value(appMutationPermitKey{}).([]*AppMutationPermit) for _, p := range permits { if p != nil && p.live.Load() { return true } } return false } func (p *AppMutationPermit) valid(a AppAdmission) bool { if p == nil || !p.live.Load() || p.now == nil || a.AppID != p.appID || a.Epoch != p.epoch { return false } e, ok := a.Effects[p.effect] if !ok || e.State != "issued" || e.AppID != p.appID || e.OperationID != p.operation || e.Owner != p.owner { return false } if p.exclusive { op := a.Op return a.State == "closed" && op != nil && op.ID == p.operation && op.AppID == p.appID } r, ok := a.Registrations[p.operation] // Each guarded write is checked against a live registration. A lapsed // registration issues nothing further; the durable issued effect still // blocks purge until it settles. return ok && r.AppID == p.appID && r.Epoch == p.epoch && r.Owner == p.owner && RegistrationLive(r, p.now()) } // RequireAppMutation validates effect-time authority for one exact app // incarnation. It tries every inherited permit (a shared operation carries a // complete app set), reads admission at most once, and never opens // admission, creates a registration or changes canonical state. func RequireAppMutation(ctx context.Context, s release.Store, account, app, appID string) error { if err := ctx.Err(); err != nil { return err } if appID == "" { return ErrPending } permits, _ := ctx.Value(appMutationPermitKey{}).([]*AppMutationPermit) var admission *AppAdmission var readErr error for _, p := range permits { if p == nil || p.sqlOnly || !p.live.Load() || p.account != account || p.app != app || p.appID != appID { continue } if admission == nil && readErr == nil { a, err := ReadAdmission(ctx, s, account, app, appID) if err != nil { readErr = err } else { admission = &a.Value } } if readErr != nil { // A failed read cannot validate any permit for this app. return readErr } if p.valid(*admission) && p.live.Load() { return nil } } return ErrPending } // LivePermits returns every permit in ctx that is valid now, each validated // against a fresh admission read. The SQL boundary loads exactly this list. func LivePermits(ctx context.Context, s release.Store) ([]PermitScope, error) { if err := ctx.Err(); err != nil { return nil, err } permits, _ := ctx.Value(appMutationPermitKey{}).([]*AppMutationPermit) type appKey struct{ account, app, appID string } cache := map[appKey]*AppAdmission{} var out []PermitScope for _, p := range permits { if p == nil || p.sqlOnly || !p.live.Load() { continue } k := appKey{p.account, p.app, p.appID} a, ok := cache[k] if !ok { obs, err := ReadAdmission(ctx, s, p.account, p.app, p.appID) if err != nil { return nil, err } a = &obs.Value cache[k] = a } if p.valid(*a) { out = append(out, PermitScope{Account: p.account, App: p.app, AppID: p.appID, Epoch: p.epoch, Operation: p.operation, Effect: p.effect}) } } return out, nil } // PreparedSQLPermit contains only process-local authority. Preparing it does // the last remote read, before SQLite is acquired. Its deadline uses Go's // monotonic time component; changing wall clock cannot extend it. type PreparedSQLPermit struct { settle func(string, error) error permit *AppMutationPermit ctx context.Context deadline time.Time scope PermitScope receipt SQLReceiptScope } func PrepareSQLPermit(ctx context.Context, s release.Store, account, app, appID string) (*PreparedSQLPermit, error) { if err := ctx.Err(); err != nil { return nil, err } a, err := ReadAdmission(ctx, s, account, app, appID) if err != nil { return nil, err } permits, _ := ctx.Value(appMutationPermitKey{}).([]*AppMutationPermit) for _, p := range permits { if p == nil || p.account != account || p.app != app || p.appID != appID || !p.valid(a.Value) { continue } sc := PermitScope{Account: account, App: app, AppID: appID, Epoch: p.epoch, Operation: p.operation, Effect: p.effect} q := &PreparedSQLPermit{permit: p, ctx: ctx, deadline: time.Now().Add(SQLTransactionLifetime), scope: sc} e := a.Value.Effects[p.effect] if e.Action == sqlIngestAction { q.receipt = SQLReceiptScope{Database: e.Inputs["sql_database"], Account: account, App: app, AppID: appID, Epoch: p.epoch, Operation: p.operation, Effect: p.effect, Owner: p.owner} } return q, nil } return nil, ErrPending } func (p *PreparedSQLPermit) Check(ctx context.Context) error { if p == nil || p.permit == nil || !p.permit.live.Load() { return ErrPending } if err := p.ctx.Err(); err != nil { return err } if err := ctx.Err(); err != nil { return err } if !time.Now().Before(p.deadline) { return context.DeadlineExceeded } return nil } func (p *PreparedSQLPermit) Receipt() SQLReceiptScope { return p.receipt } // BindTransaction gives SQL running under a lifecycle/mail effect its own // receipt-bound child. Specialized recovery cannot complete the parent // operation while that child is issued. Call before taking any SQL handle. func (p *PreparedSQLPermit) BindTransaction(database string) error { if err := p.Check(p.ctx); err != nil { return err } if p.receipt.Database != "" { if p.receipt.Database != database { return ErrIdentity } return nil } if p.permit.writer == nil || database == "" { return ErrContract } var nonce [24]byte if _, err := rand.Read(nonce[:]); err != nil { return err } id := hex.EncodeToString(nonce[:]) old := p.permit runner := EffectRunner{w: old.writer, account: old.account, app: old.app, appID: old.appID, operation: old.operation, owner: old.owner, now: old.now, exclusive: old.exclusive} ctx, settle, err := runner.Issue(p.ctx, Effect{ID: id, Resource: "app-sql:" + id, Action: sqlIngestAction, Inputs: map[string]string{"sql_database": database, "sql_parent": old.effect, "sql_recover_after": old.now().Add(SQLTransactionLifetime + RegistrationSkew).UTC().Format(time.RFC3339Nano)}}) if err != nil { return err } permits, _ := ctx.Value(appMutationPermitKey{}).([]*AppMutationPermit) p.permit = permits[len(permits)-1] p.ctx = ctx p.settle = settle p.receipt = SQLReceiptScope{Database: database, Account: old.account, App: old.app, AppID: old.appID, Operation: old.operation, Effect: id, Epoch: old.epoch, Owner: old.owner} return nil } // FinishTransaction is called only after SQL handles are revoked and joined. func (p *PreparedSQLPermit) FinishTransaction(err error) error { if err == nil { p.permit.sqlJoined.Store(true) } if p.settle == nil { return nil } settle := p.settle p.settle = nil return settle("sql-joined", err) }