package hostops import ( "context" "errors" "os" "github.com/carlosframework/platform/internal/release" ) // ProjectionGrant is positive bucket evidence that lets one console replay // a per-console SQL projection of a settled app operation. It is minted // only here; it cannot create an operation, reopen admission or write app // content. The SQL view accepts exactly the statement family Kind names, // with the app_id predicate fixed to AppID. type ProjectionGrant struct { kind, account, app, appID, operation string epoch uint64 deletedAt int64 } const ( ProjectionPurge = "purge" ProjectionRestore = "restore" ProjectionTrash = "trash" ) func (g ProjectionGrant) Kind() string { return g.kind } func (g ProjectionGrant) Account() string { return g.account } func (g ProjectionGrant) App() string { return g.app } func (g ProjectionGrant) AppID() string { return g.appID } func (g ProjectionGrant) Operation() string { return g.operation } func (g ProjectionGrant) Epoch() uint64 { return g.epoch } func (g ProjectionGrant) DeletedAt() int64 { return g.deletedAt } func (g ProjectionGrant) Valid() bool { return g.kind != "" && g.appID != "" } func (w *ControlWriter) canonical(ctx context.Context, account, app, appID string) (AdmissionObserved, error) { if err := w.ready(); err != nil { return AdmissionObserved{}, err } a, err := ReadCanonicalAdmission(ctx, w.raw, account, app) if err != nil { return AdmissionObserved{}, err } if a.Value.AppID != appID { return AdmissionObserved{}, ErrIdentity } return a, nil } // PurgeProjection: the incarnation is retired and its journal is gone. func (w *ControlWriter) PurgeProjection(ctx context.Context, account, app, appID string) (ProjectionGrant, error) { a, err := ReadCanonicalAdmission(ctx, w.raw, account, app) if err != nil { return ProjectionGrant{}, err } if !CompletedAdmission(a.Value) || a.Value.LastPurge.AppID != appID { return ProjectionGrant{}, ErrPending } if err = w.purgeAbsent(ctx, account, app); err != nil { return ProjectionGrant{}, err } return ProjectionGrant{kind: ProjectionPurge, account: account, app: app, appID: appID, operation: a.Value.LastPurge.ID, epoch: a.Value.Epoch}, nil } // RestoreProjection: the exact receipt for this incarnation is complete, no // tombstone and no purge journal exist, and ctx holds a live permit for the // app. The open-admission projections (restore, trash) run inside their own // short exclusive app-mutation ("projection"), so no purge can start between // this evidence read and the SQL write. Epochs only grow after the receipt. func (w *ControlWriter) RestoreProjection(ctx context.Context, account, app, appID string) (ProjectionGrant, error) { a, err := w.canonical(ctx, account, app, appID) if err != nil { return ProjectionGrant{}, err } if err = RequireAppMutation(ctx, w.raw, account, app, appID); err != nil { return ProjectionGrant{}, err } if err = w.purgeAbsent(ctx, account, app); err != nil { return ProjectionGrant{}, err } r := a.Value.LastRestore if CompletedAdmission(a.Value) || r == nil || r.State != "complete" || r.AppID != appID || r.Epoch > a.Value.Epoch || r.DeletedAt <= 0 { return ProjectionGrant{}, ErrPending } if _, _, err = w.raw.Get(ctx, release.AppTombstoneKey(account, app)); !errors.Is(err, os.ErrNotExist) { if err != nil { return ProjectionGrant{}, err } return ProjectionGrant{}, ErrPending } return ProjectionGrant{kind: ProjectionRestore, account: account, app: app, appID: appID, operation: r.ID, epoch: a.Value.Epoch, deletedAt: r.DeletedAt}, nil } // TrashProjection: a valid tombstone is present for the live incarnation. func (w *ControlWriter) TrashProjection(ctx context.Context, account, app, appID string) (ProjectionGrant, error) { a, err := w.canonical(ctx, account, app, appID) if err != nil { return ProjectionGrant{}, err } if CompletedAdmission(a.Value) { return ProjectionGrant{}, ErrPending } if err = RequireAppMutation(ctx, w.raw, account, app, appID); err != nil { return ProjectionGrant{}, err } tomb, err := release.ReadAppTombstone(ctx, w.raw, account, app) if err != nil { return ProjectionGrant{}, err } if tomb == nil || tomb.DeletedAt.IsZero() { return ProjectionGrant{}, ErrPending } return ProjectionGrant{kind: ProjectionTrash, account: account, app: app, appID: appID, epoch: a.Value.Epoch, deletedAt: tomb.DeletedAt.Unix()}, nil }