// The expiry pass (temporary static sites spec §5): the only actor that // expires or early-purges anything. Its own ticker, beside economicspass.go // and scanpass.go. Every step takes the per-app mutex keep, ttl, restore, // trash and the purge door take, settles first, and re-reads the row: the // candidate lists taken before the lock are only hints. package console import ( "context" "errors" "log" "time" "github.com/carlosframework/platform/internal/mail" "github.com/carlosframework/platform/internal/release" ) const ( expiryPassInterval = 5 * time.Minute // expiryActor is who the pass's trash markers and audit rows name. expiryActor = "expiry" ) // StartExpiryPass runs the pass now and then every five minutes until ctx is // done, StartScanPass's shape. It runs whether or not CARLOS_TEMPORARY_APPS is // set: apps created while it was on must still expire after it is turned off. func (s *Server) StartExpiryPass(ctx context.Context) { if s.cfg.Releases == nil { return } t := time.NewTicker(expiryPassInterval) defer t.Stop() for { s.expiryPass(ctx, time.Now()) select { case <-ctx.Done(): return case <-t.C: } } } // expiryPass is one pass at the given clock. // // It lists apps/ once, for the tripwire and the settle sweep. With no // evidence (spec §2, a tripped pass) it expires nothing and finalises nothing // from absence, but it still runs the sweep's presence branch and still // retries early purges: PurgeAppIfTrashedAt acts only on a marker whose clock // matches the row, and a wrong, empty bucket has no such marker. func (s *Server) expiryPass(ctx context.Context, now time.Time) { rows, err := s.store.TemporaryAppRows() if err != nil { log.Printf("console: expiry pass: list temporary apps: %v", err) return } due, err := s.store.PurgeDueApps(now) if err != nil { log.Printf("console: expiry pass: list purges due: %v", err) return } if len(rows) == 0 && len(due) == 0 { return } keys, listed, evidence := s.listEvidence(ctx) ev := func(context.Context) bool { return evidence } if !evidence { log.Printf("console: expiry pass: the apps/ listing holds nothing but trash markers, or could not be read; " + "expiring nothing and finalising nothing this pass. Is CARLOS_DEPLOYMENT_BUCKET pointing at the right bucket?") } if listed { s.settleSweep(ctx, rows, keys, now, ev) } if evidence { for _, r := range rows { if !r.Deleted && r.ExpiresAt > 0 && r.ExpiresAt <= now.Unix() { s.expireOne(ctx, r, now, ev) } } } for _, r := range due { s.purgeOne(ctx, r, now, ev) } } // settleSweep (spec §5, astra 8.1) compares every temporary row with the // listing already taken: a row whose trash state disagrees with its marker, // or that is mid-restore, is settled under its lock. This repairs a trash door // that wrote the marker and failed its row update while the expiry is still // far off. No extra listing; a GET only for the rows that disagree. func (s *Server) settleSweep(ctx context.Context, rows []AppRow, keys []string, now time.Time, ev evidenceFunc) { markers := make(map[string]bool) for _, k := range keys { if isTrashMarkerKey(k) { markers[k] = true } } for _, r := range rows { if markers[release.AppTombstoneKey(r.Sqid, r.App)] == r.Deleted && !r.Restoring { continue } func() { defer s.lockApp(r.Sqid, r.App)() s.settleAndReport(ctx, r, now, ev) }() } } // settleAndReport runs settleApp for the pass, under the caller's lockApp, // and says what it finished. A restore or purge that a door started and this // pass finished is otherwise silent: a door retry that arrives afterwards // answers "not in the trash" and records nothing (Task 7 review). There is // no approved audit copy for a catch-up, so it is a log line; a finished // restore also bumps the serial so hosts bring the routes back promptly. func (s *Server) settleAndReport(ctx context.Context, r AppRow, now time.Time, ev evidenceFunc) { before, err := s.store.AppLife(r.AccountID, r.App) if err != nil { log.Printf("console: expiry pass: read %s/%s: %v", r.Sqid, r.App, err) return } if err := s.settleApp(ctx, r.AccountID, r.Sqid, r.App, now, ev); err != nil { log.Printf("console: expiry pass: settle %s/%s: %v", r.Sqid, r.App, err) return } if before == nil || !before.Trashed() { return } after, err := s.store.AppLife(r.AccountID, r.App) switch { case err != nil: log.Printf("console: expiry pass: re-read %s/%s: %v", r.Sqid, r.App, err) case after == nil: log.Printf("console: expiry pass: finished a purge of %s/%s made elsewhere; the name is free", r.Sqid, r.App) case !after.Trashed(): log.Printf("console: expiry pass: finished a restore of %s/%s made elsewhere; it is live again", r.Sqid, r.App) s.bumpSerial(ctx) } } // expireOne is spec §5 "Expire", steps 1-4, for one candidate. func (s *Server) expireOne(ctx context.Context, r AppRow, now time.Time, ev evidenceFunc) { defer s.lockApp(r.Sqid, r.App)() if err := s.settleApp(ctx, r.AccountID, r.Sqid, r.App, now, ev); err != nil { log.Printf("console: expiry pass: settle %s/%s: %v", r.Sqid, r.App, err) return } life, err := s.store.AppLife(r.AccountID, r.App) if err != nil { log.Printf("console: expiry pass: read %s/%s: %v", r.Sqid, r.App, err) return } // A keep that ran after the listing set expires_at = 0, which fails > 0. if life == nil || life.ExpiresAt <= 0 || life.ExpiresAt > now.Unix() || life.Trashed() || life.Restoring { return } acct := &Account{ID: r.AccountID, Sqid: r.Sqid} if life.ControlPlane { log.Printf("console: expiry pass: %s/%s is marked as the control plane; not expiring it", r.Sqid, r.App) s.audit(acct, r.App, expiryActor, "expire-refused", "did not move "+r.App+" to the trash: it is marked as the control plane") return } if err := release.TrashApp(ctx, s.cfg.Releases, r.Sqid, r.App, expiryActor, now); err != nil { log.Printf("console: expiry pass: trash %s/%s: %v", r.Sqid, r.App, err) return // step 3 wrote nothing; the next pass tries again } // Create-only: a marker that already existed keeps its clock, the same // re-read handleAPITrashApp does. tomb, err := release.ReadAppTombstone(ctx, s.cfg.Releases, r.Sqid, r.App) if err != nil || tomb == nil { log.Printf("console: expiry pass: re-read marker %s/%s: %v", r.Sqid, r.App, err) return } if ok, err := s.store.ExpireAppRowContext(ctx, r.AccountID, r.App, tomb.DeletedAt); err != nil || !ok { // The marker stands and the row is live. The next door or pass to // touch the app settles it, with the temporary window (spec §5). log.Printf("console: expiry pass: mark %s/%s trashed: changed=%v err=%v", r.Sqid, r.App, ok, err) return } s.audit(acct, r.App, expiryActor, "expire", "moved "+r.App+" to the trash: it reached its expiry") s.bumpSerial(ctx) } // purgeOne is spec §5 "Early purge" for one candidate. func (s *Server) purgeOne(ctx context.Context, r AppRow, now time.Time, ev evidenceFunc) { defer s.lockApp(r.Sqid, r.App)() if err := s.settleApp(ctx, r.AccountID, r.Sqid, r.App, now, ev); err != nil { log.Printf("console: expiry pass: settle %s/%s: %v", r.Sqid, r.App, err) return } life, err := s.store.AppLife(r.AccountID, r.App) if err != nil { log.Printf("console: expiry pass: read %s/%s: %v", r.Sqid, r.App, err) return } if life == nil || !life.Trashed() || life.PurgeAfter <= 0 || life.PurgeAfter > now.Unix() || life.PurgeFor <= 0 { return // restored, purged, or never due: settling answered it } acct := &Account{ID: r.AccountID, Sqid: r.Sqid} // The backstops (spec §5). §4 makes both unreachable; if either is ever // reached, the app waits for a person at the console's purge door. if life.ControlPlane { log.Printf("console: expiry pass: %s/%s is marked as the control plane; not purging it", r.Sqid, r.App) s.audit(acct, r.App, expiryActor, "purge-refused", "did not delete "+r.App+": it is marked as the control plane") return } switch estate, err := s.mailEstate(ctx, acct, r.App); { case err != nil: log.Printf("console: expiry pass: read email estate %s/%s: %v; not purging", r.Sqid, r.App, err) return case estate: log.Printf("console: expiry pass: %s/%s has email estate; not purging it", r.Sqid, r.App) s.audit(acct, r.App, expiryActor, "purge-refused", "did not delete "+r.App+": it has email set up. Delete it from the Trash page") return } err = release.PurgeAppIfTrashedAt(ctx, s.cfg.Releases, s.cfg.ReleasePub, r.Sqid, r.App, time.Unix(life.PurgeFor, 0), now) switch { case err == nil: if err := s.store.PurgeAppRow(r.AccountID, r.App); err != nil { log.Printf("console: expiry pass: release the name %s/%s: %v", r.Sqid, r.App, err) return } s.audit(acct, r.App, expiryActor, "purge", "deleted "+r.App+" permanently, 3 days after it went to the trash") case errors.Is(err, release.ErrNotTrashed): // Not "restored" (spec §5, astra 2.4): a finished purge whose row // write failed, a box purge, a headless restore and a crashed // restore all look the same at the marker. settleApp tells them // apart by listing the prefix, under the tripwire. log.Printf("console: expiry pass: %s/%s left the trash before its purge; settling it", r.Sqid, r.App) s.settleAndReport(ctx, r, now, ev) default: // Interrupted, a clock mismatch, a production flag, store estate: // purge_after stays, and the next pass resumes or refuses again. log.Printf("console: expiry pass: early purge %s/%s: %v", r.Sqid, r.App, err) } } // mailEstate is the purge door's email test (ownership.go, handlePurgeApp): // a provisioned sender, a standalone credential, or any domain claim. Unlike // the door, an unreadable domain list is an error here: the pass acts on its // own authority and fails toward keeping. func (s *Server) mailEstate(ctx context.Context, acct *Account, app string) (bool, error) { rec, _, err := mail.Get(ctx, s.cfg.Releases, acct.Sqid, app) if err != nil { return false, err } if rec != nil && (rec.Provisioned != nil || len(rec.Standalone) > 0) { return true, nil } doms, err := s.mailAppDomains(ctx, acct, app) if err != nil { return false, err } return len(doms) > 0, nil }