carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package console
2
3import (
4 "context"
5 "database/sql"
6 "errors"
7 "time"
8
9 "github.com/carlosframework/platform/internal/release"
10)
11
12const (
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.
25type 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
37func (l *AppLife) Temporary() bool { return l.ExpiresAt > 0 }
38func (l *AppLife) Trashed() bool { return l.DeletedAt != 0 }
39
40const appLifeCols = `account_id, app, created_at, deleted_at, expires_at, restoring, purge_after, purge_for, control_plane`
41
42func 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.
50func (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
61func (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).
80func (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.
86const 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.
90func (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.
96func (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.
104func (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)
109VALUES (?, ?, ?, ?, ?, ?, ?) 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.
121func (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.
134func (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 = ?
137WHERE 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`).
142func (s *Store) claimRawKeepApp(accountID int64, app string) (bool, error) {
143 return s.changed(`UPDATE apps SET expires_at = 0
144WHERE 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).
150func (s *Store) claimRawSetAppExpiry(accountID int64, app string, expiresAt time.Time) (bool, error) {
151 return s.changed(`UPDATE apps SET expires_at = ?
152WHERE 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.
159func (s *Store) claimRawBumpTrashedExpiry(accountID int64, app string, now time.Time) (bool, error) {
160 return s.changed(`UPDATE apps SET expires_at = ?
161WHERE 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).
167func (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).
177func (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
180WHERE 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).
188func (s *Store) TemporaryAppRows() ([]AppRow, error) {
189 rows, err := s.db.Query(`SELECT ` + appRowCols + `
190FROM apps p JOIN accounts a ON a.id = p.account_id
191WHERE 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).
201func (s *Store) PurgeDueApps(now time.Time) ([]AppRow, error) {
202 rows, err := s.db.Query(`SELECT `+appRowCols+`
203FROM apps p JOIN accounts a ON a.id = p.account_id
204WHERE a.deleted_at = 0 AND p.deleted_at != 0 AND p.purge_after > 0 AND p.purge_after <= ?
205ORDER 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.
215func 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}
221func (s *Store) BumpTrashedExpiry(accountID int64, app string, now time.Time) (claimResult0 bool, claimResult1 error) {
222 return s.BumpTrashedExpiryContext(context.Background(), accountID, app, now)
223}
224func (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}
231func (s *Store) ExpireAppRow(accountID int64, app string, deletedAt time.Time) (claimResult0 bool, claimResult1 error) {
232 return s.ExpireAppRowContext(context.Background(), accountID, app, deletedAt)
233}
234func (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}
241func (s *Store) KeepApp(accountID int64, app string) (claimResult0 bool, claimResult1 error) {
242 return s.KeepAppContext(context.Background(), accountID, app)
243}
244func (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}
251func (s *Store) SetAppExpiry(accountID int64, app string, expiresAt time.Time) (claimResult0 bool, claimResult1 error) {
252 return s.SetAppExpiryContext(context.Background(), accountID, app, expiresAt)
253}
254func (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}
261func (s *Store) SetRestoring(accountID int64, app string, on bool) (claimResult0 bool, claimResult1 error) {
262 return s.SetRestoringContext(context.Background(), accountID, app, on)
263}
264func (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