| 1 | package console |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "database/sql" |
| 6 | "errors" |
| 7 | "os" |
| 8 | "time" |
| 9 | |
| 10 | "github.com/carlosframework/platform/internal/hostops" |
| 11 | "github.com/carlosframework/platform/internal/release" |
| 12 | ) |
| 13 | |
| 14 | // materializeClaimApp is a claim-enabled console's projection of one app's |
| 15 | // canonical state into its own SQL row. Flag-off materialization never calls |
| 16 | // it. It reads canonical admission and the marker, and writes only through |
| 17 | // Store.WithAppMutation with a grant derived from that canonical evidence: |
| 18 | // |
| 19 | // in-flight purge journal -> hold |
| 20 | // last_purge retires this row's app_id -> replay that purge's SQL cleanup |
| 21 | // (completed retirement, or a successor incarnation that replaced it) |
| 22 | // closed by any operation -> hold (the operation owns the app) |
| 23 | // another incarnation, no evidence -> hold |
| 24 | // exact marker, row live -> trash with the marker's clock |
| 25 | // exact marker, row restoring -> release the stale restoring slot |
| 26 | // no marker, completed receipt -> replay that receipt's restore |
| 27 | // no marker, no receipt -> hold: absence alone never restores |
| 28 | // |
| 29 | // Restore and trash projection acquire a short exclusive app mutation before |
| 30 | // checking evidence and writing SQL. The receipt alone cannot exclude a new |
| 31 | // operation on another console. Purge replay is narrower: positive retirement |
| 32 | // evidence grants deletion of only the retired incarnation's local rows. |
| 33 | // The admission sweep finishes interrupted restore effects. A missing marker |
| 34 | // without a receipt never restores a row. |
| 35 | func materializeClaimApp(ctx context.Context, bucket release.Store, st *Store, accountID int64, account, app string, now time.Time) (bool, error) { |
| 36 | a, err := hostops.ReadCanonicalAdmission(ctx, bucket, account, app) |
| 37 | if errors.Is(err, os.ErrNotExist) { |
| 38 | return true, nil |
| 39 | } |
| 40 | if err != nil { |
| 41 | return true, err |
| 42 | } |
| 43 | id, err := st.ClaimAppID(accountID, app) |
| 44 | if errors.Is(err, sql.ErrNoRows) { |
| 45 | return true, nil |
| 46 | } |
| 47 | if err != nil { |
| 48 | return true, err |
| 49 | } |
| 50 | if a.Value.LastPurge != nil && purgeRetired(a.Value, id, a.Value.LastPurge.ID) { |
| 51 | return projectClaimApp(ctx, bucket, st, accountID, account, app, now) |
| 52 | } |
| 53 | if a.Value.AppID != id || a.Value.State != "open" || a.Value.Op != nil { |
| 54 | return true, nil |
| 55 | } |
| 56 | err = release.WithAppMutation(ctx, bucket, account, app, "projection", func(ctx context.Context) error { |
| 57 | _, err := projectClaimApp(ctx, bucket, st, accountID, account, app, now) |
| 58 | return err |
| 59 | }) |
| 60 | if errors.Is(err, hostops.ErrPending) { |
| 61 | return true, nil |
| 62 | } |
| 63 | return true, err |
| 64 | } |
| 65 | func projectClaimApp(ctx context.Context, bucket release.Store, st *Store, accountID int64, account, app string, now time.Time) (bool, error) { |
| 66 | if err := purgeJournalAbsent(ctx, bucket, account, app); err != nil { |
| 67 | if errors.Is(err, hostops.ErrPending) { |
| 68 | return true, nil |
| 69 | } |
| 70 | return true, err |
| 71 | } |
| 72 | a, err := hostops.ReadCanonicalAdmission(ctx, bucket, account, app) |
| 73 | if errors.Is(err, os.ErrNotExist) { |
| 74 | // No canonical identity: nothing here may be written until the signed |
| 75 | // backfill or enrollment binds one. |
| 76 | return true, nil |
| 77 | } |
| 78 | if err != nil { |
| 79 | return true, err |
| 80 | } |
| 81 | id, err := st.ClaimAppID(accountID, app) |
| 82 | if errors.Is(err, sql.ErrNoRows) { |
| 83 | return true, nil |
| 84 | } |
| 85 | if err != nil { |
| 86 | return true, err // an unbound row waits for explicit identity binding |
| 87 | } |
| 88 | scope := claimSQLScope{AccountID: accountID, App: app, AppID: id} |
| 89 | if lp := a.Value.LastPurge; lp != nil && purgeRetired(a.Value, id, lp.ID) { |
| 90 | // The retired incarnation's terminal replay, keyed on its own app_id, |
| 91 | // so it still runs on a console that lagged past a recreation. The |
| 92 | // revision is the canonical object's, which only grows across |
| 93 | // recreation. |
| 94 | op := lp.ID |
| 95 | return true, st.WithAppMutation(ctx, []sqlGrant{purgeReceiptGrant(bucket, account, scope, op)}, func(v *Store) error { |
| 96 | if err := v.BindClaimAppOperation(accountID, app, id, op, a.Value.Revision); err != nil { |
| 97 | return err |
| 98 | } |
| 99 | if err := v.MarkClaimAppRetired(accountID, app, id, now); err != nil { |
| 100 | return err |
| 101 | } |
| 102 | return v.PurgeClaimAppRow(ctx, accountID, app, id, op) |
| 103 | }) |
| 104 | } |
| 105 | if id != a.Value.AppID || hostops.RequireAppMutation(ctx, bucket, account, app, id) != nil { |
| 106 | // An exclusive operation owns the app, a retirement names some other |
| 107 | // incarnation, or an open successor must never be projected onto an |
| 108 | // old incarnation's row without last_purge naming it. |
| 109 | return true, nil |
| 110 | } |
| 111 | life, err := st.AppLife(accountID, app) |
| 112 | if err != nil || life == nil { |
| 113 | return true, err |
| 114 | } |
| 115 | tomb, tag, err := readTombstoneVersion(ctx, bucket, account, app) |
| 116 | if err != nil { |
| 117 | return true, err |
| 118 | } |
| 119 | if tomb != nil { |
| 120 | switch { |
| 121 | case !life.Trashed(): |
| 122 | return true, st.WithAppMutation(ctx, []sqlGrant{tombstoneGrant(ctx, bucket, account, scope, tag)}, func(v *Store) error { |
| 123 | _, err := v.TrashClaimAppRow(accountID, app, id, tomb.DeletedAt) |
| 124 | return err |
| 125 | }) |
| 126 | case life.Restoring: |
| 127 | return true, st.WithAppMutation(ctx, []sqlGrant{tombstoneGrant(ctx, bucket, account, scope, tag)}, func(v *Store) error { |
| 128 | _, err := v.SetClaimRestoring(accountID, app, id, false) |
| 129 | return err |
| 130 | }) |
| 131 | } |
| 132 | return true, nil |
| 133 | } |
| 134 | if !life.Trashed() { |
| 135 | return true, nil |
| 136 | } |
| 137 | receipt := a.Value.LastRestore |
| 138 | if receipt == nil || !restoreReceiptCurrent(a.Value, *receipt) || receipt.AppID != id || life.DeletedAt != receipt.DeletedAt { |
| 139 | // No completed receipt for this incarnation, or the row's trash is |
| 140 | // not the one the receipt removed (a later trash): hold. |
| 141 | return true, nil |
| 142 | } |
| 143 | r := *receipt |
| 144 | return true, st.WithAppMutation(ctx, []sqlGrant{restoreReceiptGrant(ctx, bucket, account, scope, r)}, func(v *Store) error { |
| 145 | _, err := v.FinishClaimRestoreRow(accountID, app, id, r.DeletedAt, now) |
| 146 | return err |
| 147 | }) |
| 148 | } |
| 149 | |