console: trash doors bump the serial
Restore already announced through bumpSerial in ~2s; trashing an app (handleAPITrashApp, handleAppDelete) writes the same box-consumed trash marker but was waiting on the hourly pass — asymmetric and wrong. Purge (handlePurgeApp) only deletes bucket objects for an app whose routes and minting were already stopped at trash time, so it stays a no-bump door; a test now pins that decision instead of just documenting it.
5 files changed,
+74
−3
internal/console/apicli.go+6 −0internal/console/appsettings.go+7 −0internal/console/deletion_test.go+8 −0internal/console/ownership.go+11 −3internal/console/serialbump_test.go+42 −0
diff --git a/internal/console/apicli.go b/internal/console/apicli.go| index e9b0d65d1..db48921fd 100644 |
| --- a/internal/console/apicli.go |
| +++ b/internal/console/apicli.go |
| @@ -591,6 +591,12 @@ func (s *Server) handleAPITrashApp(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| s.audit(acct, app, addr, "trash", "moved "+app+" to the trash (cli)") |
| + // Box-consumed (carlos-deploy §3), symmetric with the restore door below: |
| + // the trash marker is what stops instance minting and drops the app's |
| + // routes (provision/reconcile.go, domains/reconcile.go). Without the bump |
| + // a trash through this door sits invisible to the fleet until the hourly |
| + // floor comes round, while restore already announces in seconds. |
| + s.bumpSerial(ctx) |
| writeJSON(w, http.StatusOK, map[string]any{ |
| "result": "trashed", |
| "restorable_until": deletedAt.Add(release.TrashCooloff).UTC().Format(time.RFC3339), |
diff --git a/internal/console/appsettings.go b/internal/console/appsettings.go| index fcbac4414..3320245fe 100644 |
| --- a/internal/console/appsettings.go |
| +++ b/internal/console/appsettings.go |
| @@ -324,6 +324,13 @@ func (s *Server) handleAppDelete(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| s.audit(acct, app, addr, "trash", "moved "+app+" to the trash") |
| + // Box-consumed (carlos-deploy §3), symmetric with the web restore door |
| + // (ownership.go, handleRestoreApp): the trash marker is what stops |
| + // instance minting and drops the app's routes (provision/reconcile.go, |
| + // domains/reconcile.go). Without the bump a trash through this door sits |
| + // invisible to the fleet until the hourly floor comes round, while |
| + // restore already announces in seconds. |
| + s.bumpSerial(ctx) |
| s.setFlash(w, "ok", "Moved "+app+" to the trash — restorable there until "+ |
| deletedAt.Add(release.TrashCooloff).UTC().Format("2006-01-02")+"; hosts stop serving it on their next pass.") |
| http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/", http.StatusSeeOther) |
diff --git a/internal/console/deletion_test.go b/internal/console/deletion_test.go| index 6e8813d5d..5ffffeaab 100644 |
| --- a/internal/console/deletion_test.go |
| +++ b/internal/console/deletion_test.go |
| @@ -379,6 +379,11 @@ func TestDeleteNowDestroysEverythingAndFreesTheName(t *testing.T) { |
| t.Fatal("objects were destroyed before the passkey confirmation") |
| } |
| + // Purge does not announce: by the time it runs, kass's routes and minting |
| + // were already stopped at trash time — there is nothing left a box's fast |
| + // pass converges on (see handlePurgeApp, ownership.go). |
| + before := serial(t, fx) |
| + |
| // With it, the held action resumes and lands. |
| status, loc, body := completeStepUp(t, fx, c, loc) |
| if status != http.StatusSeeOther { |
| @@ -387,6 +392,9 @@ func TestDeleteNowDestroysEverythingAndFreesTheName(t *testing.T) { |
| if keys := appKeys(t, fx, "kass"); len(keys) != 0 { |
| t.Fatalf("the bucket still holds %v", keys) |
| } |
| + if got := serial(t, fx); got != before { |
| + t.Fatalf("serial after a purge = %d, want unchanged (%d) — purge announces nothing", got, before) |
| + } |
| if has, err := fx.st.HasTrashedAppIn(OpsAccountID, "kass"); err != nil || has { |
| t.Fatalf("HasTrashedAppIn after purge = %v %v, want the row gone", has, err) |
| } |
diff --git a/internal/console/ownership.go b/internal/console/ownership.go| index 799d8f017..13574dc54 100644 |
| --- a/internal/console/ownership.go |
| +++ b/internal/console/ownership.go |
| @@ -246,9 +246,10 @@ func (s *Server) handleRestoreApp(w http.ResponseWriter, r *http.Request) { |
| // Box-consumed (carlos-deploy §3): removing the trash marker re-enables |
| // instance minting for this app, and minting from records is leg 1 of the |
| // fast pass's reconcile. Without the bump a restore is invisible to the |
| - // fleet until the hourly floor comes round. Restore is the first door on |
| - // this page to announce its way out of that wait — the trash (purge) side |
| - // still relies on the next hourly pass; it does not bump. |
| + // fleet until the hourly floor comes round. Trash (handleAppDelete) bumps |
| + // the same way now — symmetric in and out; purge (handlePurgeApp, below) |
| + // still does not, because by then the fleet has nothing left to converge |
| + // on. |
| s.bumpSerial(r.Context()) |
| // The anti-ghost rule, said where the person can read it: restoring brings |
| // back releases and channels and no routes at all (spec §3), so an app |
| @@ -316,6 +317,13 @@ func (s *Server) handlePurgeApp(w http.ResponseWriter, r *http.Request) { |
| fail(purgeAdvice(err, app)) |
| return |
| } |
| + // No bumpSerial here, deliberately: PurgeApp only deletes objects under |
| + // apps/<sqid>/<app>/ (releases, channels, tags, the production flag, the |
| + // trash marker itself) for an app whose routes were already torn down and |
| + // whose minting was already stopped at TRASH time (domains/reconcile.go |
| + // leg 1c, provision/reconcile.go's trashedFor). There is nothing left |
| + // here that a box's fast pass converges on — purge is bucket-and-name |
| + // cleanup, invisible to the fleet either way. |
| // The row LAST, mirroring the bucket-first rule the rest of this feature |
| // keeps: the row is the name reservation, and freeing a name while objects |
| // survived would let the next claim ship into another app's prefix. |
diff --git a/internal/console/serialbump_test.go b/internal/console/serialbump_test.go| index af46aabff..a12eb61ab 100644 |
| --- a/internal/console/serialbump_test.go |
| +++ b/internal/console/serialbump_test.go |
| @@ -248,6 +248,22 @@ func TestAPIRollbackBumpsSerial(t *testing.T) { |
| } |
| } |
| +// TestAPITrashBumpsSerial: trash through the CLI/API door writes the marker |
| +// that stops instance minting and drops the app's routes (leg 1 of the fast |
| +// pass's reconcile) — the same reason restore bumps, mirrored on the way in. |
| +func TestAPITrashBumpsSerial(t *testing.T) { |
| + fx, token := trashFixture(t, "code-serial-trash") |
| + |
| + before := serial(t, fx) |
| + status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/delete", token, "") |
| + if status != http.StatusOK { |
| + t.Fatalf("trash = %d %s", status, out) |
| + } |
| + if got := serial(t, fx); got != before+1 { |
| + t.Fatalf("serial after an API trash = %d, want %d", got, before+1) |
| + } |
| +} |
| + |
| // TestAPIRestoreBumpsSerial: restore through the CLI/API door re-enables |
| // instance minting for the app (removing the trash marker), which is leg 1 of |
| // the fast pass's reconcile — the same reason the web door (TestWebRestoreBumpsSerial) |
| @@ -405,6 +421,32 @@ func TestWebRestoreBumpsSerial(t *testing.T) { |
| } |
| } |
| +// TestWebTrashBumpsSerial: the web delete door writes the same trash marker |
| +// the API door does, behind its own passkey ceremony — the announcement must |
| +// survive the step-up round trip, not just the plain-write path. |
| +func TestWebTrashBumpsSerial(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + c := signInPaul(t, fx) |
| + csrf := sessionToken(t, fx, c) |
| + |
| + before := serial(t, fx) |
| + status, loc, _ := postForm(t, c, fx.u("/apps/kass/delete"), url.Values{"csrf": {csrf}, "confirm": {"kass"}}) |
| + if status != http.StatusSeeOther || !strings.HasPrefix(loc, fx.fk.srv.URL) { |
| + t.Fatalf("un-stepped-up delete = %d -> %q, want a redirect into Keymail", status, loc) |
| + } |
| + if got := serial(t, fx); got != before { |
| + t.Fatalf("serial before the passkey confirmation = %d, want unchanged (%d)", got, before) |
| + } |
| + |
| + status, loc, body := completeStepUp(t, fx, c, loc) |
| + if status != http.StatusSeeOther || loc != fx.path("/") { |
| + t.Fatalf("resumed delete = %d -> %q, want 303 to the account home:\n%s", status, loc, body) |
| + } |
| + if got := serial(t, fx); got != before+1 { |
| + t.Fatalf("serial after a web trash = %d, want %d", got, before+1) |
| + } |
| +} |
| + |
| // cancelAwareStore is a bucket that honours context cancellation, the way the |
| // AWS SDK does and the DirStore (local disk, no ctx checks at all) does not — |
| // without it, a canceled-context test here would pass for the wrong reason. |