carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

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.
6package console
7
8import (
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
18const (
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.
27func (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.
50func (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.
90func (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.
114func (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.
140func (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.
183func (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.
240func (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