| 1 | package console |
| 2 | |
| 3 | import ( |
| 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). |
| 14 | var 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. |
| 20 | type restoreFailure struct { |
| 21 | Marker bool |
| 22 | Err error |
| 23 | } |
| 24 | |
| 25 | func (e *restoreFailure) Error() string { return e.Err.Error() } |
| 26 | func (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. |
| 38 | func 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. |
| 55 | func (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. |
| 124 | type lockedRefusalKind int |
| 125 | |
| 126 | const ( |
| 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. |
| 137 | type 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. |
| 169 | func (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 | |