package console import ( "context" "errors" "log" "time" "github.com/carlosframework/platform/internal/release" ) // errNotInTrash: after settling, the app is not in the Trash (restored or // purged elsewhere since the door looked). var errNotInTrash = errors.New("console: the app is not in the trash") // restoreFailure says which half of a restore failed. Marker true means the // bucket half (or anything before it) failed and nothing was restored; false // means the marker is gone and only this console's row did not follow, which // the next settle repairs. type restoreFailure struct { Marker bool Err error } func (e *restoreFailure) Error() string { return e.Err.Error() } func (e *restoreFailure) Unwrap() error { return e.Err } // restoredTemporaryNote is what a restore says about serving a tmp- app again // (Astra plan round 4). The usual advice, `carlos add `, cannot help: // appRail (cli.go, Task 3) refuses every tmp- name, kept ones included. It // does not need to. Trash drops a temporary app's routes but keeps its // instance records, restore leaves them alone, and once the marker is gone // provision's reconcile mints the routes again from those records // (internal/provision/reconcile.go:903, pinned box-side by // TestReconcileDeclinesTrashedApp). The serial bump makes that seconds. Keyed // on the name, not the row, because appRail is: a kept tmp- app is told the // same thing. func restoredTemporaryNote(app string) string { return app + " is restored with its releases and channels intact. Its instance records were kept, " + "so hosts bring its routes back on their next pass." } // restoreApp is the one restore every console door runs (temporary static // sites spec §6): the CLI door, the browser door, and nothing else. The // expiry pass's catch-up is settleApp's restore branch. For a temporary row: // // 1. settleApp; cap check counting this app as a new live one // 2. expires_at < now+1h: expires_at = now+7d (still trashed, so harmless) // 3. restoring = 1: from here the app holds a cap slot // 4. release.RestoreApp (delete the marker) // 5. one statement: deleted_at = restoring = purge_after = purge_for = 0 // // A permanent row takes the same path without steps 1's cap check and 2. // Locks: account then app, the plan's one lock order. func (s *Server) restoreApp(ctx context.Context, acct *Account, app string, now time.Time) error { if s.cfg.Releases == nil { return &restoreFailure{Marker: true, Err: errors.New("console: no deployment bucket on this host")} } if s.cfg.HostClaim { return s.restoreClaimApp(ctx, acct, app, now) } defer s.lockAccount(acct.Sqid)() defer s.lockApp(acct.Sqid, app)() // The row as it stood before the settle, read under the lock. A trashed // row that the settle makes live had no marker and still had its objects: // an earlier restore whose step 5 failed (or a restore made elsewhere). // That is this restore, finished, and the door answers it as one, with // its audit row and serial bump, not as "not in the trash". prior, err := s.store.AppLife(acct.ID, app) if err != nil { return &restoreFailure{Marker: true, Err: err} } if err := s.settleApp(ctx, acct.ID, acct.Sqid, app, now, s.doorEvidence); err != nil { return &restoreFailure{Marker: true, Err: err} } life, err := s.store.AppLife(acct.ID, app) if err != nil { return &restoreFailure{Marker: true, Err: err} } if prior != nil && prior.Trashed() && life != nil && !life.Trashed() { return nil } if life == nil || !life.Trashed() { return errNotInTrash } // Plans 3a: restoring brings a running thing back, so it meets the cap. if err := s.checkRestore(ctx, acct, app); err != nil { if isPlanCap(err) { return err } return &restoreFailure{Marker: true, Err: err} } if life.Temporary() { if err := s.temporaryCapCheck(acct.ID); err != nil { var capErr *temporaryCapError if errors.As(err, &capErr) { return capErr } return &restoreFailure{Marker: true, Err: err} } if _, err := s.store.BumpTrashedExpiryContext(ctx, acct.ID, app, now); err != nil { return &restoreFailure{Marker: true, Err: err} } } if _, err := s.store.SetRestoringContext(ctx, acct.ID, app, true); err != nil { return &restoreFailure{Marker: true, Err: err} } if err := release.RestoreApp(ctx, s.cfg.Releases, acct.Sqid, app); err != nil { if _, cerr := s.store.SetRestoringContext(ctx, acct.ID, app, false); cerr != nil { log.Printf("console: restore %s/%s: release restoring after a failed marker delete: %v", acct.Sqid, app, cerr) } return &restoreFailure{Marker: true, Err: err} } if err := s.store.FinishRestoreRow(acct.ID, app, now); err != nil { return &restoreFailure{Marker: false, Err: err} } return nil } // lockedRefusalKind is why a destructive door must stop after taking the // lock. The door, not this helper, chooses the words: each maps these onto // sentences it already uses, so settling under the lock changes no existing // copy. type lockedRefusalKind int const ( lockedUnreadable lockedRefusalKind = iota + 1 // settle or the re-read failed: nothing was changed lockedGone // the row no longer exists lockedWrongState // purge: not in the Trash (restored while waiting); trash: trashed with no marker to confirm its clock lockedControlPlane // marked as the control plane while waiting ) // lockedRefusal is recheckUnderLock's answer when a door must stop. Err is // the settle error for lockedUnreadable (logged, and handed to purgeAdvice by // the purge door); Msg and Status are controlPlaneRefusal's, for // lockedControlPlane. type lockedRefusal struct { Kind lockedRefusalKind Err error Msg string Status int } // recheckUnderLock is what every destructive door (API trash, browser trash, // the purge door) runs after it takes s.lockApp(acct.Sqid, app) and before it // writes or tears down anything. Everything the door checked before the lock // is only a hint (temporary static sites spec §2): the control-plane setter // takes the same lock, and a row can be trashed, restored or purged while the // request waits. So: settle the row against the marker, re-read it, and ask // again. // // purge is true for the purge door, which acts only on a trashed row. Settling // is what makes it correct after a trash whose row update failed: the marker // is there and the row is live, and without the settle PurgeApp would sweep // the objects while PurgeAppRow (guarded deleted_at != 0) kept the live row // for ever. // // For the trash doors (purge false) a trashed row is NOT a refusal when its // marker is there: that is a trash already done, and the door's ordinary // path answers it idempotently with the marker's own deadline. A trashed row // with no marker can only survive settling when the tripwire held it (spec // §2); writing a fresh marker there would give the app a second clock, so it // refuses as unconfirmed. // // The production flag and the purge's own preconditions (PurgeReady, the // email and store estate) are not re-asked here because each door already // reads them after this call, under the lock. Nothing about a temporary row // stops a trash or a purge. func (s *Server) recheckUnderLock(ctx context.Context, acct *Account, app, verb string, purge bool, now time.Time) (*AppLife, *lockedRefusal) { if err := s.settleApp(ctx, acct.ID, acct.Sqid, app, now, s.doorEvidence); err != nil { log.Printf("console: %s %s/%s: settle: %v", verb, acct.Sqid, app, err) return nil, &lockedRefusal{Kind: lockedUnreadable, Err: err} } life, err := s.store.AppLife(acct.ID, app) if err != nil { log.Printf("console: %s %s/%s: re-read: %v", verb, acct.Sqid, app, err) return nil, &lockedRefusal{Kind: lockedUnreadable, Err: err} } if life == nil { return nil, &lockedRefusal{Kind: lockedGone} } switch { case purge && (!life.Trashed() || life.Restoring): return nil, &lockedRefusal{Kind: lockedWrongState, Err: release.ErrNotTrashed} case !purge && life.Restoring: return nil, &lockedRefusal{Kind: lockedWrongState} case !purge && life.Trashed(): tomb, err := release.ReadAppTombstone(ctx, s.cfg.Releases, acct.Sqid, app) if err != nil { log.Printf("console: %s %s/%s: re-read marker: %v", verb, acct.Sqid, app, err) return nil, &lockedRefusal{Kind: lockedUnreadable, Err: err} } if tomb == nil { return nil, &lockedRefusal{Kind: lockedWrongState} } // Already trashed, marker and all: the door's success path answers. } if msg, status := s.controlPlaneRefusal(acct, app, verb); msg != "" { return nil, &lockedRefusal{Kind: lockedControlPlane, Msg: msg, Status: status} } return life, nil }