| 1 | // The expiry pass (temporary static sites spec §5): the only actor that |
| 2 | // expires or early-purges anything. Its own ticker, beside economicspass.go |
| 3 | // and scanpass.go. Every step takes the per-app mutex keep, ttl, restore, |
| 4 | // trash and the purge door take, settles first, and re-reads the row: the |
| 5 | // candidate lists taken before the lock are only hints. |
| 6 | package console |
| 7 | |
| 8 | import ( |
| 9 | "context" |
| 10 | "errors" |
| 11 | "log" |
| 12 | "time" |
| 13 | |
| 14 | "github.com/carlosframework/platform/internal/mail" |
| 15 | "github.com/carlosframework/platform/internal/release" |
| 16 | ) |
| 17 | |
| 18 | const ( |
| 19 | expiryPassInterval = 5 * time.Minute |
| 20 | // expiryActor is who the pass's trash markers and audit rows name. |
| 21 | expiryActor = "expiry" |
| 22 | ) |
| 23 | |
| 24 | // StartExpiryPass runs the pass now and then every five minutes until ctx is |
| 25 | // done, StartScanPass's shape. It runs whether or not CARLOS_TEMPORARY_APPS is |
| 26 | // set: apps created while it was on must still expire after it is turned off. |
| 27 | func (s *Server) StartExpiryPass(ctx context.Context) { |
| 28 | if s.cfg.Releases == nil { |
| 29 | return |
| 30 | } |
| 31 | t := time.NewTicker(expiryPassInterval) |
| 32 | defer t.Stop() |
| 33 | for { |
| 34 | s.expiryPass(ctx, time.Now()) |
| 35 | select { |
| 36 | case <-ctx.Done(): |
| 37 | return |
| 38 | case <-t.C: |
| 39 | } |
| 40 | } |
| 41 | } |
| 42 | |
| 43 | // expiryPass is one pass at the given clock. |
| 44 | // |
| 45 | // It lists apps/ once, for the tripwire and the settle sweep. With no |
| 46 | // evidence (spec §2, a tripped pass) it expires nothing and finalises nothing |
| 47 | // from absence, but it still runs the sweep's presence branch and still |
| 48 | // retries early purges: PurgeAppIfTrashedAt acts only on a marker whose clock |
| 49 | // matches the row, and a wrong, empty bucket has no such marker. |
| 50 | func (s *Server) expiryPass(ctx context.Context, now time.Time) { |
| 51 | rows, err := s.store.TemporaryAppRows() |
| 52 | if err != nil { |
| 53 | log.Printf("console: expiry pass: list temporary apps: %v", err) |
| 54 | return |
| 55 | } |
| 56 | due, err := s.store.PurgeDueApps(now) |
| 57 | if err != nil { |
| 58 | log.Printf("console: expiry pass: list purges due: %v", err) |
| 59 | return |
| 60 | } |
| 61 | if len(rows) == 0 && len(due) == 0 { |
| 62 | return |
| 63 | } |
| 64 | keys, listed, evidence := s.listEvidence(ctx) |
| 65 | ev := func(context.Context) bool { return evidence } |
| 66 | if !evidence { |
| 67 | log.Printf("console: expiry pass: the apps/ listing holds nothing but trash markers, or could not be read; " + |
| 68 | "expiring nothing and finalising nothing this pass. Is CARLOS_DEPLOYMENT_BUCKET pointing at the right bucket?") |
| 69 | } |
| 70 | if listed { |
| 71 | s.settleSweep(ctx, rows, keys, now, ev) |
| 72 | } |
| 73 | if evidence { |
| 74 | for _, r := range rows { |
| 75 | if !r.Deleted && r.ExpiresAt > 0 && r.ExpiresAt <= now.Unix() { |
| 76 | s.expireOne(ctx, r, now, ev) |
| 77 | } |
| 78 | } |
| 79 | } |
| 80 | for _, r := range due { |
| 81 | s.purgeOne(ctx, r, now, ev) |
| 82 | } |
| 83 | } |
| 84 | |
| 85 | // settleSweep (spec §5, astra 8.1) compares every temporary row with the |
| 86 | // listing already taken: a row whose trash state disagrees with its marker, |
| 87 | // or that is mid-restore, is settled under its lock. This repairs a trash door |
| 88 | // that wrote the marker and failed its row update while the expiry is still |
| 89 | // far off. No extra listing; a GET only for the rows that disagree. |
| 90 | func (s *Server) settleSweep(ctx context.Context, rows []AppRow, keys []string, now time.Time, ev evidenceFunc) { |
| 91 | markers := make(map[string]bool) |
| 92 | for _, k := range keys { |
| 93 | if isTrashMarkerKey(k) { |
| 94 | markers[k] = true |
| 95 | } |
| 96 | } |
| 97 | for _, r := range rows { |
| 98 | if markers[release.AppTombstoneKey(r.Sqid, r.App)] == r.Deleted && !r.Restoring { |
| 99 | continue |
| 100 | } |
| 101 | func() { |
| 102 | defer s.lockApp(r.Sqid, r.App)() |
| 103 | s.settleAndReport(ctx, r, now, ev) |
| 104 | }() |
| 105 | } |
| 106 | } |
| 107 | |
| 108 | // settleAndReport runs settleApp for the pass, under the caller's lockApp, |
| 109 | // and says what it finished. A restore or purge that a door started and this |
| 110 | // pass finished is otherwise silent: a door retry that arrives afterwards |
| 111 | // answers "not in the trash" and records nothing (Task 7 review). There is |
| 112 | // no approved audit copy for a catch-up, so it is a log line; a finished |
| 113 | // restore also bumps the serial so hosts bring the routes back promptly. |
| 114 | func (s *Server) settleAndReport(ctx context.Context, r AppRow, now time.Time, ev evidenceFunc) { |
| 115 | before, err := s.store.AppLife(r.AccountID, r.App) |
| 116 | if err != nil { |
| 117 | log.Printf("console: expiry pass: read %s/%s: %v", r.Sqid, r.App, err) |
| 118 | return |
| 119 | } |
| 120 | if err := s.settleApp(ctx, r.AccountID, r.Sqid, r.App, now, ev); err != nil { |
| 121 | log.Printf("console: expiry pass: settle %s/%s: %v", r.Sqid, r.App, err) |
| 122 | return |
| 123 | } |
| 124 | if before == nil || !before.Trashed() { |
| 125 | return |
| 126 | } |
| 127 | after, err := s.store.AppLife(r.AccountID, r.App) |
| 128 | switch { |
| 129 | case err != nil: |
| 130 | log.Printf("console: expiry pass: re-read %s/%s: %v", r.Sqid, r.App, err) |
| 131 | case after == nil: |
| 132 | log.Printf("console: expiry pass: finished a purge of %s/%s made elsewhere; the name is free", r.Sqid, r.App) |
| 133 | case !after.Trashed(): |
| 134 | log.Printf("console: expiry pass: finished a restore of %s/%s made elsewhere; it is live again", r.Sqid, r.App) |
| 135 | s.bumpSerial(ctx) |
| 136 | } |
| 137 | } |
| 138 | |
| 139 | // expireOne is spec §5 "Expire", steps 1-4, for one candidate. |
| 140 | func (s *Server) expireOne(ctx context.Context, r AppRow, now time.Time, ev evidenceFunc) { |
| 141 | defer s.lockApp(r.Sqid, r.App)() |
| 142 | if err := s.settleApp(ctx, r.AccountID, r.Sqid, r.App, now, ev); err != nil { |
| 143 | log.Printf("console: expiry pass: settle %s/%s: %v", r.Sqid, r.App, err) |
| 144 | return |
| 145 | } |
| 146 | life, err := s.store.AppLife(r.AccountID, r.App) |
| 147 | if err != nil { |
| 148 | log.Printf("console: expiry pass: read %s/%s: %v", r.Sqid, r.App, err) |
| 149 | return |
| 150 | } |
| 151 | // A keep that ran after the listing set expires_at = 0, which fails > 0. |
| 152 | if life == nil || life.ExpiresAt <= 0 || life.ExpiresAt > now.Unix() || life.Trashed() || life.Restoring { |
| 153 | return |
| 154 | } |
| 155 | acct := &Account{ID: r.AccountID, Sqid: r.Sqid} |
| 156 | if life.ControlPlane { |
| 157 | log.Printf("console: expiry pass: %s/%s is marked as the control plane; not expiring it", r.Sqid, r.App) |
| 158 | s.audit(acct, r.App, expiryActor, "expire-refused", "did not move "+r.App+" to the trash: it is marked as the control plane") |
| 159 | return |
| 160 | } |
| 161 | if err := release.TrashApp(ctx, s.cfg.Releases, r.Sqid, r.App, expiryActor, now); err != nil { |
| 162 | log.Printf("console: expiry pass: trash %s/%s: %v", r.Sqid, r.App, err) |
| 163 | return // step 3 wrote nothing; the next pass tries again |
| 164 | } |
| 165 | // Create-only: a marker that already existed keeps its clock, the same |
| 166 | // re-read handleAPITrashApp does. |
| 167 | tomb, err := release.ReadAppTombstone(ctx, s.cfg.Releases, r.Sqid, r.App) |
| 168 | if err != nil || tomb == nil { |
| 169 | log.Printf("console: expiry pass: re-read marker %s/%s: %v", r.Sqid, r.App, err) |
| 170 | return |
| 171 | } |
| 172 | if ok, err := s.store.ExpireAppRowContext(ctx, r.AccountID, r.App, tomb.DeletedAt); err != nil || !ok { |
| 173 | // The marker stands and the row is live. The next door or pass to |
| 174 | // touch the app settles it, with the temporary window (spec §5). |
| 175 | log.Printf("console: expiry pass: mark %s/%s trashed: changed=%v err=%v", r.Sqid, r.App, ok, err) |
| 176 | return |
| 177 | } |
| 178 | s.audit(acct, r.App, expiryActor, "expire", "moved "+r.App+" to the trash: it reached its expiry") |
| 179 | s.bumpSerial(ctx) |
| 180 | } |
| 181 | |
| 182 | // purgeOne is spec §5 "Early purge" for one candidate. |
| 183 | func (s *Server) purgeOne(ctx context.Context, r AppRow, now time.Time, ev evidenceFunc) { |
| 184 | defer s.lockApp(r.Sqid, r.App)() |
| 185 | if err := s.settleApp(ctx, r.AccountID, r.Sqid, r.App, now, ev); err != nil { |
| 186 | log.Printf("console: expiry pass: settle %s/%s: %v", r.Sqid, r.App, err) |
| 187 | return |
| 188 | } |
| 189 | life, err := s.store.AppLife(r.AccountID, r.App) |
| 190 | if err != nil { |
| 191 | log.Printf("console: expiry pass: read %s/%s: %v", r.Sqid, r.App, err) |
| 192 | return |
| 193 | } |
| 194 | if life == nil || !life.Trashed() || life.PurgeAfter <= 0 || life.PurgeAfter > now.Unix() || life.PurgeFor <= 0 { |
| 195 | return // restored, purged, or never due: settling answered it |
| 196 | } |
| 197 | acct := &Account{ID: r.AccountID, Sqid: r.Sqid} |
| 198 | // The backstops (spec §5). §4 makes both unreachable; if either is ever |
| 199 | // reached, the app waits for a person at the console's purge door. |
| 200 | if life.ControlPlane { |
| 201 | log.Printf("console: expiry pass: %s/%s is marked as the control plane; not purging it", r.Sqid, r.App) |
| 202 | s.audit(acct, r.App, expiryActor, "purge-refused", "did not delete "+r.App+": it is marked as the control plane") |
| 203 | return |
| 204 | } |
| 205 | switch estate, err := s.mailEstate(ctx, acct, r.App); { |
| 206 | case err != nil: |
| 207 | log.Printf("console: expiry pass: read email estate %s/%s: %v; not purging", r.Sqid, r.App, err) |
| 208 | return |
| 209 | case estate: |
| 210 | log.Printf("console: expiry pass: %s/%s has email estate; not purging it", r.Sqid, r.App) |
| 211 | s.audit(acct, r.App, expiryActor, "purge-refused", "did not delete "+r.App+": it has email set up. Delete it from the Trash page") |
| 212 | return |
| 213 | } |
| 214 | err = release.PurgeAppIfTrashedAt(ctx, s.cfg.Releases, s.cfg.ReleasePub, r.Sqid, r.App, time.Unix(life.PurgeFor, 0), now) |
| 215 | switch { |
| 216 | case err == nil: |
| 217 | if err := s.store.PurgeAppRow(r.AccountID, r.App); err != nil { |
| 218 | log.Printf("console: expiry pass: release the name %s/%s: %v", r.Sqid, r.App, err) |
| 219 | return |
| 220 | } |
| 221 | s.audit(acct, r.App, expiryActor, "purge", "deleted "+r.App+" permanently, 3 days after it went to the trash") |
| 222 | case errors.Is(err, release.ErrNotTrashed): |
| 223 | // Not "restored" (spec §5, astra 2.4): a finished purge whose row |
| 224 | // write failed, a box purge, a headless restore and a crashed |
| 225 | // restore all look the same at the marker. settleApp tells them |
| 226 | // apart by listing the prefix, under the tripwire. |
| 227 | log.Printf("console: expiry pass: %s/%s left the trash before its purge; settling it", r.Sqid, r.App) |
| 228 | s.settleAndReport(ctx, r, now, ev) |
| 229 | default: |
| 230 | // Interrupted, a clock mismatch, a production flag, store estate: |
| 231 | // purge_after stays, and the next pass resumes or refuses again. |
| 232 | log.Printf("console: expiry pass: early purge %s/%s: %v", r.Sqid, r.App, err) |
| 233 | } |
| 234 | } |
| 235 | |
| 236 | // mailEstate is the purge door's email test (ownership.go, handlePurgeApp): |
| 237 | // a provisioned sender, a standalone credential, or any domain claim. Unlike |
| 238 | // the door, an unreadable domain list is an error here: the pass acts on its |
| 239 | // own authority and fails toward keeping. |
| 240 | func (s *Server) mailEstate(ctx context.Context, acct *Account, app string) (bool, error) { |
| 241 | rec, _, err := mail.Get(ctx, s.cfg.Releases, acct.Sqid, app) |
| 242 | if err != nil { |
| 243 | return false, err |
| 244 | } |
| 245 | if rec != nil && (rec.Provisioned != nil || len(rec.Standalone) > 0) { |
| 246 | return true, nil |
| 247 | } |
| 248 | doms, err := s.mailAppDomains(ctx, acct, app) |
| 249 | if err != nil { |
| 250 | return false, err |
| 251 | } |
| 252 | return len(doms) > 0, nil |
| 253 | } |
| 254 | |