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 "errors"
6 "log"
7 "time"
8
9 "github.com/carlosframework/platform/internal/release"
10)
11
12// errNotInTrash: after settling, the app is not in the Trash (restored or
13// purged elsewhere since the door looked).
14var errNotInTrash = errors.New("console: the app is not in the trash")
15
16// restoreFailure says which half of a restore failed. Marker true means the
17// bucket half (or anything before it) failed and nothing was restored; false
18// means the marker is gone and only this console's row did not follow, which
19// the next settle repairs.
20type restoreFailure struct {
21 Marker bool
22 Err error
23}
24
25func (e *restoreFailure) Error() string { return e.Err.Error() }
26func (e *restoreFailure) Unwrap() error { return e.Err }
27
28// restoredTemporaryNote is what a restore says about serving a tmp- app again
29// (Astra plan round 4). The usual advice, `carlos add <host>`, cannot help:
30// appRail (cli.go, Task 3) refuses every tmp- name, kept ones included. It
31// does not need to. Trash drops a temporary app's routes but keeps its
32// instance records, restore leaves them alone, and once the marker is gone
33// provision's reconcile mints the routes again from those records
34// (internal/provision/reconcile.go:903, pinned box-side by
35// TestReconcileDeclinesTrashedApp). The serial bump makes that seconds. Keyed
36// on the name, not the row, because appRail is: a kept tmp- app is told the
37// same thing.
38func restoredTemporaryNote(app string) string {
39 return app + " is restored with its releases and channels intact. Its instance records were kept, " +
40 "so hosts bring its routes back on their next pass."
41}
42
43// restoreApp is the one restore every console door runs (temporary static
44// sites spec §6): the CLI door, the browser door, and nothing else. The
45// expiry pass's catch-up is settleApp's restore branch. For a temporary row:
46//
47// 1. settleApp; cap check counting this app as a new live one
48// 2. expires_at < now+1h: expires_at = now+7d (still trashed, so harmless)
49// 3. restoring = 1: from here the app holds a cap slot
50// 4. release.RestoreApp (delete the marker)
51// 5. one statement: deleted_at = restoring = purge_after = purge_for = 0
52//
53// A permanent row takes the same path without steps 1's cap check and 2.
54// Locks: account then app, the plan's one lock order.
55func (s *Server) restoreApp(ctx context.Context, acct *Account, app string, now time.Time) error {
56 if s.cfg.Releases == nil {
57 return &restoreFailure{Marker: true, Err: errors.New("console: no deployment bucket on this host")}
58 }
59 if s.cfg.HostClaim {
60 return s.restoreClaimApp(ctx, acct, app, now)
61 }
62 defer s.lockAccount(acct.Sqid)()
63 defer s.lockApp(acct.Sqid, app)()
64 // The row as it stood before the settle, read under the lock. A trashed
65 // row that the settle makes live had no marker and still had its objects:
66 // an earlier restore whose step 5 failed (or a restore made elsewhere).
67 // That is this restore, finished, and the door answers it as one, with
68 // its audit row and serial bump, not as "not in the trash".
69 prior, err := s.store.AppLife(acct.ID, app)
70 if err != nil {
71 return &restoreFailure{Marker: true, Err: err}
72 }
73 if err := s.settleApp(ctx, acct.ID, acct.Sqid, app, now, s.doorEvidence); err != nil {
74 return &restoreFailure{Marker: true, Err: err}
75 }
76 life, err := s.store.AppLife(acct.ID, app)
77 if err != nil {
78 return &restoreFailure{Marker: true, Err: err}
79 }
80 if prior != nil && prior.Trashed() && life != nil && !life.Trashed() {
81 return nil
82 }
83 if life == nil || !life.Trashed() {
84 return errNotInTrash
85 }
86 // Plans 3a: restoring brings a running thing back, so it meets the cap.
87 if err := s.checkRestore(ctx, acct, app); err != nil {
88 if isPlanCap(err) {
89 return err
90 }
91 return &restoreFailure{Marker: true, Err: err}
92 }
93 if life.Temporary() {
94 if err := s.temporaryCapCheck(acct.ID); err != nil {
95 var capErr *temporaryCapError
96 if errors.As(err, &capErr) {
97 return capErr
98 }
99 return &restoreFailure{Marker: true, Err: err}
100 }
101 if _, err := s.store.BumpTrashedExpiryContext(ctx, acct.ID, app, now); err != nil {
102 return &restoreFailure{Marker: true, Err: err}
103 }
104 }
105 if _, err := s.store.SetRestoringContext(ctx, acct.ID, app, true); err != nil {
106 return &restoreFailure{Marker: true, Err: err}
107 }
108 if err := release.RestoreApp(ctx, s.cfg.Releases, acct.Sqid, app); err != nil {
109 if _, cerr := s.store.SetRestoringContext(ctx, acct.ID, app, false); cerr != nil {
110 log.Printf("console: restore %s/%s: release restoring after a failed marker delete: %v", acct.Sqid, app, cerr)
111 }
112 return &restoreFailure{Marker: true, Err: err}
113 }
114 if err := s.store.FinishRestoreRow(acct.ID, app, now); err != nil {
115 return &restoreFailure{Marker: false, Err: err}
116 }
117 return nil
118}
119
120// lockedRefusalKind is why a destructive door must stop after taking the
121// lock. The door, not this helper, chooses the words: each maps these onto
122// sentences it already uses, so settling under the lock changes no existing
123// copy.
124type lockedRefusalKind int
125
126const (
127 lockedUnreadable lockedRefusalKind = iota + 1 // settle or the re-read failed: nothing was changed
128 lockedGone // the row no longer exists
129 lockedWrongState // purge: not in the Trash (restored while waiting); trash: trashed with no marker to confirm its clock
130 lockedControlPlane // marked as the control plane while waiting
131)
132
133// lockedRefusal is recheckUnderLock's answer when a door must stop. Err is
134// the settle error for lockedUnreadable (logged, and handed to purgeAdvice by
135// the purge door); Msg and Status are controlPlaneRefusal's, for
136// lockedControlPlane.
137type lockedRefusal struct {
138 Kind lockedRefusalKind
139 Err error
140 Msg string
141 Status int
142}
143
144// recheckUnderLock is what every destructive door (API trash, browser trash,
145// the purge door) runs after it takes s.lockApp(acct.Sqid, app) and before it
146// writes or tears down anything. Everything the door checked before the lock
147// is only a hint (temporary static sites spec §2): the control-plane setter
148// takes the same lock, and a row can be trashed, restored or purged while the
149// request waits. So: settle the row against the marker, re-read it, and ask
150// again.
151//
152// purge is true for the purge door, which acts only on a trashed row. Settling
153// is what makes it correct after a trash whose row update failed: the marker
154// is there and the row is live, and without the settle PurgeApp would sweep
155// the objects while PurgeAppRow (guarded deleted_at != 0) kept the live row
156// for ever.
157//
158// For the trash doors (purge false) a trashed row is NOT a refusal when its
159// marker is there: that is a trash already done, and the door's ordinary
160// path answers it idempotently with the marker's own deadline. A trashed row
161// with no marker can only survive settling when the tripwire held it (spec
162// §2); writing a fresh marker there would give the app a second clock, so it
163// refuses as unconfirmed.
164//
165// The production flag and the purge's own preconditions (PurgeReady, the
166// email and store estate) are not re-asked here because each door already
167// reads them after this call, under the lock. Nothing about a temporary row
168// stops a trash or a purge.
169func (s *Server) recheckUnderLock(ctx context.Context, acct *Account, app, verb string, purge bool, now time.Time) (*AppLife, *lockedRefusal) {
170 if err := s.settleApp(ctx, acct.ID, acct.Sqid, app, now, s.doorEvidence); err != nil {
171 log.Printf("console: %s %s/%s: settle: %v", verb, acct.Sqid, app, err)
172 return nil, &lockedRefusal{Kind: lockedUnreadable, Err: err}
173 }
174 life, err := s.store.AppLife(acct.ID, app)
175 if err != nil {
176 log.Printf("console: %s %s/%s: re-read: %v", verb, acct.Sqid, app, err)
177 return nil, &lockedRefusal{Kind: lockedUnreadable, Err: err}
178 }
179 if life == nil {
180 return nil, &lockedRefusal{Kind: lockedGone}
181 }
182 switch {
183 case purge && (!life.Trashed() || life.Restoring):
184 return nil, &lockedRefusal{Kind: lockedWrongState, Err: release.ErrNotTrashed}
185 case !purge && life.Restoring:
186 return nil, &lockedRefusal{Kind: lockedWrongState}
187 case !purge && life.Trashed():
188 tomb, err := release.ReadAppTombstone(ctx, s.cfg.Releases, acct.Sqid, app)
189 if err != nil {
190 log.Printf("console: %s %s/%s: re-read marker: %v", verb, acct.Sqid, app, err)
191 return nil, &lockedRefusal{Kind: lockedUnreadable, Err: err}
192 }
193 if tomb == nil {
194 return nil, &lockedRefusal{Kind: lockedWrongState}
195 }
196 // Already trashed, marker and all: the door's success path answers.
197 }
198 if msg, status := s.controlPlaneRefusal(acct, app, verb); msg != "" {
199 return nil, &lockedRefusal{Kind: lockedControlPlane, Msg: msg, Status: status}
200 }
201 return life, nil
202}
203