carlos / platform Public

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.
Paul Campbell 9ea4a0c5eb0036da940c2040ef05aa754a530632 parent b8226c4
5 files changed, +74 −3
  • internal/console/apicli.go +6 −0
  • internal/console/appsettings.go +7 −0
  • internal/console/deletion_test.go +8 −0
  • internal/console/ownership.go +11 −3
  • internal/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.