| 1 | package console |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "database/sql" |
| 6 | "errors" |
| 7 | "time" |
| 8 | |
| 9 | "github.com/carlosframework/platform/internal/release" |
| 10 | ) |
| 11 | |
| 12 | const ( |
| 13 | // temporaryTTLDefault is a temporary app's life when nobody says |
| 14 | // otherwise, and what a restore gives back to one whose expiry is under |
| 15 | // temporaryRestoreFloor away (spec §1 ruling 3, §6 step 2). |
| 16 | temporaryTTLDefault = 7 * 24 * time.Hour |
| 17 | temporaryRestoreFloor = time.Hour |
| 18 | // temporaryCooloffSeconds is release.TemporaryCooloff as the Unix-second |
| 19 | // offset the row stores. |
| 20 | temporaryCooloffSeconds = int64(release.TemporaryCooloff / time.Second) |
| 21 | ) |
| 22 | |
| 23 | // AppLife is one apps row's lifecycle columns, read together because every |
| 24 | // temporary-app decision needs them together. Times are Unix seconds. |
| 25 | type AppLife struct { |
| 26 | AccountID int64 |
| 27 | App string |
| 28 | CreatedAt int64 |
| 29 | DeletedAt int64 // 0 = live |
| 30 | ExpiresAt int64 // 0 = permanent |
| 31 | Restoring bool |
| 32 | PurgeAfter int64 // 0 = no early purge |
| 33 | PurgeFor int64 // the marker deleted_at purge_after was computed from |
| 34 | ControlPlane bool |
| 35 | } |
| 36 | |
| 37 | func (l *AppLife) Temporary() bool { return l.ExpiresAt > 0 } |
| 38 | func (l *AppLife) Trashed() bool { return l.DeletedAt != 0 } |
| 39 | |
| 40 | const appLifeCols = `account_id, app, created_at, deleted_at, expires_at, restoring, purge_after, purge_for, control_plane` |
| 41 | |
| 42 | func scanAppLife(sc interface{ Scan(...any) error }) (AppLife, error) { |
| 43 | var l AppLife |
| 44 | err := sc.Scan(&l.AccountID, &l.App, &l.CreatedAt, &l.DeletedAt, &l.ExpiresAt, &l.Restoring, |
| 45 | &l.PurgeAfter, &l.PurgeFor, &l.ControlPlane) |
| 46 | return l, err |
| 47 | } |
| 48 | |
| 49 | // AppLife reads one row's lifecycle. (nil, nil) when there is no row. |
| 50 | func (s *Store) AppLife(accountID int64, app string) (*AppLife, error) { |
| 51 | l, err := scanAppLife(s.db.QueryRow(`SELECT `+appLifeCols+` FROM apps WHERE account_id = ? AND app = ?`, accountID, app)) |
| 52 | if errors.Is(err, sql.ErrNoRows) { |
| 53 | return nil, nil |
| 54 | } |
| 55 | if err != nil { |
| 56 | return nil, err |
| 57 | } |
| 58 | return &l, nil |
| 59 | } |
| 60 | |
| 61 | func (s *Store) appLives(query string, args ...any) ([]AppLife, error) { |
| 62 | rows, err := s.db.Query(query, args...) |
| 63 | if err != nil { |
| 64 | return nil, err |
| 65 | } |
| 66 | defer rows.Close() |
| 67 | var out []AppLife |
| 68 | for rows.Next() { |
| 69 | l, err := scanAppLife(rows) |
| 70 | if err != nil { |
| 71 | return nil, err |
| 72 | } |
| 73 | out = append(out, l) |
| 74 | } |
| 75 | return out, rows.Err() |
| 76 | } |
| 77 | |
| 78 | // AppLivesIn is every row in the account, trashed included, by name: the |
| 79 | // input to GET /api/cli/apps/{account}/list (spec §7). |
| 80 | func (s *Store) AppLivesIn(accountID int64) ([]AppLife, error) { |
| 81 | return s.appLives(`SELECT `+appLifeCols+` FROM apps WHERE account_id = ? ORDER BY app`, accountID) |
| 82 | } |
| 83 | |
| 84 | // liveTemporaryWhere is the cap's definition of a live temporary app (spec |
| 85 | // §6): a row mid-restore holds its slot, so a new app cannot take it. |
| 86 | const liveTemporaryWhere = `expires_at > 0 AND (deleted_at = 0 OR restoring = 1)` |
| 87 | |
| 88 | // LiveTemporaryApps lists the rows the cap counts, soonest expiry first, so |
| 89 | // the refusal can say what each one is and when it goes. |
| 90 | func (s *Store) LiveTemporaryApps(accountID int64) ([]AppLife, error) { |
| 91 | return s.appLives(`SELECT `+appLifeCols+` FROM apps WHERE account_id = ? AND `+liveTemporaryWhere+` ORDER BY expires_at, app`, accountID) |
| 92 | } |
| 93 | |
| 94 | // CreateTemporaryAppIn is CreateTemporaryAppInRegion at the home, the way |
| 95 | // CreateAppIn is CreateAppInRegion at the home. |
| 96 | func (s *Store) CreateTemporaryAppIn(accountID int64, app, createdBy string, now, expiresAt time.Time) (bool, error) { |
| 97 | return s.CreateTemporaryAppInRegion(accountID, app, createdBy, "", "", now, expiresAt) |
| 98 | } |
| 99 | |
| 100 | // CreateTemporaryAppInRegion claims a name with its expiry AND its region in |
| 101 | // the SAME insert (spec §3; nearest-region spec §5.3): there is no moment |
| 102 | // where the row exists permanent or regionless, and a racing permanent |
| 103 | // create of the same name loses the ON CONFLICT. |
| 104 | func (s *Store) CreateTemporaryAppInRegion(accountID int64, app, createdBy, region, source string, now, expiresAt time.Time) (bool, error) { |
| 105 | if expiresAt.Unix() <= 0 { |
| 106 | return false, errors.New("console: a temporary app needs an expiry") |
| 107 | } |
| 108 | res, err := s.db.Exec(`INSERT INTO apps (app, account_id, created_by, created_at, region, region_source, expires_at) |
| 109 | VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(account_id, app) DO NOTHING`, |
| 110 | app, accountID, createdBy, now.Unix(), region, source, expiresAt.Unix()) |
| 111 | if err != nil { |
| 112 | return false, err |
| 113 | } |
| 114 | n, err := res.RowsAffected() |
| 115 | return n > 0, err |
| 116 | } |
| 117 | |
| 118 | // changed runs one conditional UPDATE and reports whether it won. Every |
| 119 | // transition below is one of these: a guard in the WHERE clause, and false |
| 120 | // when a concurrent transition got there first. |
| 121 | func (s *Store) changed(query string, args ...any) (bool, error) { |
| 122 | res, err := s.db.Exec(query, args...) |
| 123 | if err != nil { |
| 124 | return false, err |
| 125 | } |
| 126 | n, err := res.RowsAffected() |
| 127 | return n > 0, err |
| 128 | } |
| 129 | |
| 130 | // ExpireAppRow is the expiry pass's step 4 (spec §5): the marker's clock |
| 131 | // becomes deleted_at and purge_for, and purge_after is 3 days later, guarded |
| 132 | // by expires_at > 0 AND deleted_at = 0 so a keep that won the lock first |
| 133 | // (expires_at = 0) makes this a no-op. |
| 134 | func (s *Store) claimRawExpireAppRow(accountID int64, app string, deletedAt time.Time) (bool, error) { |
| 135 | at := deletedAt.Unix() |
| 136 | return s.changed(`UPDATE apps SET deleted_at = ?, purge_for = ?, purge_after = ? |
| 137 | WHERE account_id = ? AND app = ? AND expires_at > 0 AND deleted_at = 0`, |
| 138 | at, at, at+temporaryCooloffSeconds, accountID, app) |
| 139 | } |
| 140 | |
| 141 | // KeepApp makes a live temporary app permanent (`carlos apps keep`). |
| 142 | func (s *Store) claimRawKeepApp(accountID int64, app string) (bool, error) { |
| 143 | return s.changed(`UPDATE apps SET expires_at = 0 |
| 144 | WHERE account_id = ? AND app = ? AND expires_at > 0 AND deleted_at = 0 AND restoring = 0`, accountID, app) |
| 145 | } |
| 146 | |
| 147 | // SetAppExpiry moves a live temporary app's expiry (`carlos apps ttl`). It |
| 148 | // only ever moves a non-zero expiry: there is no door that makes an existing |
| 149 | // app temporary (spec §3). |
| 150 | func (s *Store) claimRawSetAppExpiry(accountID int64, app string, expiresAt time.Time) (bool, error) { |
| 151 | return s.changed(`UPDATE apps SET expires_at = ? |
| 152 | WHERE account_id = ? AND app = ? AND expires_at > 0 AND deleted_at = 0 AND restoring = 0`, |
| 153 | expiresAt.Unix(), accountID, app) |
| 154 | } |
| 155 | |
| 156 | // BumpTrashedExpiry is restore step 2 (spec §6): while the row is still |
| 157 | // trashed, an expiry under an hour away becomes a week away, so the expiry |
| 158 | // pass never sees an untrashed row with a stale expiry. |
| 159 | func (s *Store) claimRawBumpTrashedExpiry(accountID int64, app string, now time.Time) (bool, error) { |
| 160 | return s.changed(`UPDATE apps SET expires_at = ? |
| 161 | WHERE account_id = ? AND app = ? AND expires_at > 0 AND expires_at < ? AND deleted_at != 0`, |
| 162 | now.Add(temporaryTTLDefault).Unix(), accountID, app, now.Add(temporaryRestoreFloor).Unix()) |
| 163 | } |
| 164 | |
| 165 | // SetRestoring sets restore step 3's flag (only on a trashed row) or clears |
| 166 | // it (settleApp, and a failed step 4). |
| 167 | func (s *Store) claimRawSetRestoring(accountID int64, app string, on bool) (bool, error) { |
| 168 | if on { |
| 169 | return s.changed(`UPDATE apps SET restoring = 1 WHERE account_id = ? AND app = ? AND deleted_at != 0`, accountID, app) |
| 170 | } |
| 171 | return s.changed(`UPDATE apps SET restoring = 0 WHERE account_id = ? AND app = ?`, accountID, app) |
| 172 | } |
| 173 | |
| 174 | // FinishRestoreRow is restore step 5 in one statement, with step 2 folded in |
| 175 | // for the paths that reach it without having done step 2 (a direct-mode |
| 176 | // restore caught up by materialize or by the expiry pass, spec §5, §6). |
| 177 | func (s *Store) FinishRestoreRow(accountID int64, app string, now time.Time) error { |
| 178 | _, err := s.db.Exec(`UPDATE apps SET deleted_at = 0, restoring = 0, purge_after = 0, purge_for = 0, |
| 179 | expires_at = CASE WHEN expires_at > 0 AND expires_at < ? THEN ? ELSE expires_at END |
| 180 | WHERE account_id = ? AND app = ?`, |
| 181 | now.Add(temporaryRestoreFloor).Unix(), now.Add(temporaryTTLDefault).Unix(), accountID, app) |
| 182 | return err |
| 183 | } |
| 184 | |
| 185 | // TemporaryAppRows is every temporary row of every live account: the expiry |
| 186 | // pass's settle sweep and expire candidates. A list taken before the lock is |
| 187 | // only a hint (spec §2). |
| 188 | func (s *Store) TemporaryAppRows() ([]AppRow, error) { |
| 189 | rows, err := s.db.Query(`SELECT ` + appRowCols + ` |
| 190 | FROM apps p JOIN accounts a ON a.id = p.account_id |
| 191 | WHERE a.deleted_at = 0 AND p.expires_at > 0 ORDER BY a.sqid, p.app`) |
| 192 | if err != nil { |
| 193 | return nil, err |
| 194 | } |
| 195 | return scanAppRows(rows) |
| 196 | } |
| 197 | |
| 198 | // PurgeDueApps is the early-purge candidates: trashed rows whose window has |
| 199 | // passed. Keyed on purge_after alone, because the decision was fixed at trash |
| 200 | // time (spec §2). |
| 201 | func (s *Store) PurgeDueApps(now time.Time) ([]AppRow, error) { |
| 202 | rows, err := s.db.Query(`SELECT `+appRowCols+` |
| 203 | FROM apps p JOIN accounts a ON a.id = p.account_id |
| 204 | WHERE a.deleted_at = 0 AND p.deleted_at != 0 AND p.purge_after > 0 AND p.purge_after <= ? |
| 205 | ORDER BY a.sqid, p.app`, now.Unix()) |
| 206 | if err != nil { |
| 207 | return nil, err |
| 208 | } |
| 209 | return scanAppRows(rows) |
| 210 | } |
| 211 | |
| 212 | // effectivePurgeAt is the one purge deadline every display uses (spec §5, |
| 213 | // astra 4.2): purge_after when set, else deleted_at + TrashCooloff. Nothing |
| 214 | // that shows or returns a purge date computes DeletedAt + TrashCooloff itself. |
| 215 | func effectivePurgeAt(deletedAt, purgeAfter int64) time.Time { |
| 216 | if purgeAfter > 0 { |
| 217 | return time.Unix(purgeAfter, 0).UTC() |
| 218 | } |
| 219 | return time.Unix(deletedAt, 0).UTC().Add(release.TrashCooloff) |
| 220 | } |
| 221 | func (s *Store) BumpTrashedExpiry(accountID int64, app string, now time.Time) (claimResult0 bool, claimResult1 error) { |
| 222 | return s.BumpTrashedExpiryContext(context.Background(), accountID, app, now) |
| 223 | } |
| 224 | func (s *Store) BumpTrashedExpiryContext(claimContext context.Context, accountID int64, app string, now time.Time) (claimResult0 bool, claimResult1 error) { |
| 225 | claimResult1 = s.withAutoAppSQL(claimContext, accountID, app, "sql.BumpTrashedExpiry", func(claimView *Store) error { |
| 226 | claimResult0, claimResult1 = claimView.claimRawBumpTrashedExpiry(accountID, app, now) |
| 227 | return claimResult1 |
| 228 | }) |
| 229 | return |
| 230 | } |
| 231 | func (s *Store) ExpireAppRow(accountID int64, app string, deletedAt time.Time) (claimResult0 bool, claimResult1 error) { |
| 232 | return s.ExpireAppRowContext(context.Background(), accountID, app, deletedAt) |
| 233 | } |
| 234 | func (s *Store) ExpireAppRowContext(claimContext context.Context, accountID int64, app string, deletedAt time.Time) (claimResult0 bool, claimResult1 error) { |
| 235 | claimResult1 = s.withAutoAppSQL(claimContext, accountID, app, "sql.ExpireAppRow", func(claimView *Store) error { |
| 236 | claimResult0, claimResult1 = claimView.claimRawExpireAppRow(accountID, app, deletedAt) |
| 237 | return claimResult1 |
| 238 | }) |
| 239 | return |
| 240 | } |
| 241 | func (s *Store) KeepApp(accountID int64, app string) (claimResult0 bool, claimResult1 error) { |
| 242 | return s.KeepAppContext(context.Background(), accountID, app) |
| 243 | } |
| 244 | func (s *Store) KeepAppContext(claimContext context.Context, accountID int64, app string) (claimResult0 bool, claimResult1 error) { |
| 245 | claimResult1 = s.withAutoAppSQL(claimContext, accountID, app, "sql.KeepApp", func(claimView *Store) error { |
| 246 | claimResult0, claimResult1 = claimView.claimRawKeepApp(accountID, app) |
| 247 | return claimResult1 |
| 248 | }) |
| 249 | return |
| 250 | } |
| 251 | func (s *Store) SetAppExpiry(accountID int64, app string, expiresAt time.Time) (claimResult0 bool, claimResult1 error) { |
| 252 | return s.SetAppExpiryContext(context.Background(), accountID, app, expiresAt) |
| 253 | } |
| 254 | func (s *Store) SetAppExpiryContext(claimContext context.Context, accountID int64, app string, expiresAt time.Time) (claimResult0 bool, claimResult1 error) { |
| 255 | claimResult1 = s.withAutoAppSQL(claimContext, accountID, app, "sql.SetAppExpiry", func(claimView *Store) error { |
| 256 | claimResult0, claimResult1 = claimView.claimRawSetAppExpiry(accountID, app, expiresAt) |
| 257 | return claimResult1 |
| 258 | }) |
| 259 | return |
| 260 | } |
| 261 | func (s *Store) SetRestoring(accountID int64, app string, on bool) (claimResult0 bool, claimResult1 error) { |
| 262 | return s.SetRestoringContext(context.Background(), accountID, app, on) |
| 263 | } |
| 264 | func (s *Store) SetRestoringContext(claimContext context.Context, accountID int64, app string, on bool) (claimResult0 bool, claimResult1 error) { |
| 265 | claimResult1 = s.withAutoAppSQL(claimContext, accountID, app, "sql.SetRestoring", func(claimView *Store) error { |
| 266 | claimResult0, claimResult1 = claimView.claimRawSetRestoring(accountID, app, on) |
| 267 | return claimResult1 |
| 268 | }) |
| 269 | return |
| 270 | } |
| 271 | |