carlos / platform Public

console: review fixes — identity ops follow their credentials, cards pin what they promised

From the branch review's ten verified findings. Rename and move now carry
the app's cli_tokens with them inside the same transaction (bound
(account, name), they would otherwise 404 the running app while keeping
authority over the freed name) and refuse a destination name that still
has a purged predecessor's credentials; rename's audit sweep is scoped
to rows written since this app's row was created, so a dead same-named
app's history stays its own; renaming to the reserved "new" is refused
before it wedges the app behind the create form's literal route; and the
emptiness checks re-run after commit, undoing the rename if a ship or
route landed under the old name mid-flight.

The confirm cards now pin what they showed: move carries the target
account sqid, rollback its target version, fast-track the version whose
window it offers to waive — a confirmed POST whose pin no longer matches
freshly-read state refuses and sends the human back to look. Pinless
POSTs (the CLI shape) behave exactly as before.

Dashboard truthfulness: the Instances tile counts the account's ROUTES,
so a failed metrics sweep reads "readings unavailable" instead of
claiming a live fleet has no hosts; the Domains tile filters by the
viewing account through the Routes/Domains parallel slices (now
documented as load-bearing in Assemble), so another tenant's failed cert
stops waving here. Delete hard-fails when the tombstone re-read fails,
matching the API door's clock discipline instead of stamping a restore
date the purger won't honor. The Settings mismatch repair renders for
members too (operator command included) with only the button owner-only,
and the unreadable-production-flag banners say WHY the flag is
unreadable again.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 863327e0a4afc014a032d1a42853e4848fd976ec parent d2f33eb
10 files changed, +273 −81
  • internal/console/appsettings.go +58 −9
  • internal/console/confirm.go +6 −1
  • internal/console/overview.go +57 −24
  • internal/console/overview_test.go +58 −20
  • internal/console/server.go +24 −1
  • internal/console/store.go +49 −10
  • internal/console/templates/app_overview.html +4 −4
  • internal/console/templates/app_settings.html +11 −11
  • internal/console/templates/ladder.html +1 −1
  • internal/console/view.go +5 −0
diff --git a/internal/console/appsettings.go b/internal/console/appsettings.go
index 637793fba..fcbac4414 100644
--- a/internal/console/appsettings.go
+++ b/internal/console/appsettings.go
@@ -139,13 +139,19 @@ func (s *Server) handleAppRename(w http.ResponseWriter, r *http.Request) {
s.setFlash(w, kind, msg)
http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/apps/"+url.PathEscape(app)+"/settings", http.StatusSeeOther)
}
- // Lowercased before validation, matching the create form: the input
- // accepts what the server accepts, and the server speaks lowercase.
- to := strings.ToLower(strings.TrimSpace(r.FormValue("to")))
+ // Normalized exactly as the create doors normalize (normalizeAppName),
+ // and refused exactly where claimApp refuses: "new" is the create form's
+ // own literal route, and an app renamed onto it would be unreachable —
+ // the mux prefers the literal, so no console page could ever show it.
+ to := normalizeAppName(r.FormValue("to"))
if !release.ValidApp(to) {
back("err", "App names are lowercase letters, digits and dashes.")
return
}
+ if to == "new" {
+ back("err", "That name is reserved.")
+ return
+ }
if to == app {
back("err", "That is already this app's name.")
return
@@ -197,6 +203,29 @@ func (s *Server) handleAppRename(w http.ResponseWriter, r *http.Request) {
back("err", renameAdvice(err, to))
return
}
+ // The emptiness checks above cannot join the database transaction — the
+ // bucket and the registry are other stores — so a `carlos ship` or
+ // `carlos add` racing this request can land under the old name between
+ // check and commit. Re-checking AFTER the commit closes most of that
+ // window: on detection the rename is undone and the human told, which
+ // converges toward the safe state (the console record naming what the
+ // bucket and routes name) instead of silently splitting the identity.
+ stale := false
+ if empty, err := s.appDeployEmpty(ctx, acct.Sqid, app); err == nil && !empty {
+ stale = true
+ }
+ if wired, err := s.appHasRoutes(app); err == nil && wired {
+ stale = true
+ }
+ if stale {
+ if err := s.store.RenameAppIn(acct.ID, to, app); err != nil {
+ log.Printf("console: rename %s/%s -> %s: mid-rename ship detected and UNDO FAILED: %v — console record and platform state now disagree", acct.Sqid, app, to, err)
+ back("err", "A ship or route landed under "+app+" while renaming, and undoing hit an error — the record may disagree with the platform. Re-check this page.")
+ return
+ }
+ back("err", "A ship or route landed under "+app+" while renaming — the rename was undone; the name stays.")
+ return
+ }
s.audit(acct, to, addr, "rename", "renamed "+app+" to "+to)
s.setFlash(w, "ok", "Renamed "+app+" to "+to+".")
http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/apps/"+url.PathEscape(to)+"/settings", http.StatusSeeOther)
@@ -211,6 +240,8 @@ func renameAdvice(err error, to string) string {
return "This account already has an app (or a trashed app) named " + to + "."
case strings.Contains(msg, "legacy owner grant"):
return "One of these names carries a legacy grant — renaming across it is an operator act."
+ case strings.Contains(msg, "app credentials"):
+ return to + " still has app credentials from a previous app of that name — revoke them on the tokens page first."
default:
return "Could not rename — nothing changed. Try again."
}
@@ -274,11 +305,19 @@ func (s *Server) handleAppDelete(w http.ResponseWriter, r *http.Request) {
}
// The marker's clock is the feature's one clock: TrashApp is create-only,
// so a racing or repeated delete keeps the ORIGINAL DeletedAt, and the
- // row must carry that, not this request's time.
- deletedAt := now
- if tomb, err := release.ReadAppTombstone(ctx, s.cfg.Releases, acct.Sqid, app); err == nil && tomb != nil {
- deletedAt = tomb.DeletedAt
- }
+ // row must carry that, not this request's time. A re-read that fails
+ // therefore HARD-FAILS with nothing written to the row — the same rule as
+ // the API door: a row stamped with this request's clock would promise a
+ // restore deadline the purge pass does not honor, and it never self-heals
+ // (materialize short-circuits on a trashed row). The marker is already
+ // written, so a retry simply finishes the job.
+ tomb, err := release.ReadAppTombstone(ctx, s.cfg.Releases, acct.Sqid, app)
+ if err != nil || tomb == nil {
+ log.Printf("console: trash %s/%s: re-read marker: %v", acct.Sqid, app, err)
+ back("err", "The trash marker was written but could not be read back, so this console's record was left alone — delete again to finish.")
+ return
+ }
+ deletedAt := tomb.DeletedAt
if err := s.store.TrashAppRow(acct.ID, app, deletedAt); err != nil {
log.Printf("console: trash row %s/%s: %v", acct.Sqid, app, err)
back("err", "The trash marker was written, but this console's record did not update — reload; if the app still shows, try deleting again.")
@@ -331,6 +370,12 @@ func (s *Server) handleAppMoveAccount(w http.ResponseWriter, r *http.Request) {
back("err", "You are not a member of "+dest.Name+" ("+target+"), so moving the record there would strand the app for you. Ask an owner of that account, or an operator, to finish the move.")
return
}
+ // The card PINS its target: the confirmed POST carries the sqid the card
+ // named, and a mismatch with the freshly re-derived target refuses. The
+ // window between card and confirmed POST includes a passkey round trip —
+ // long enough for an operator to re-stamp the routes to some OTHER
+ // account the viewer also belongs to, and the record must never move to
+ // an account nobody saw on a card.
if !confirmed(r) {
cv := &ConfirmView{
Title: "Move " + app + " to " + dest.Name + "?",
@@ -341,7 +386,7 @@ func (s *Server) handleAppMoveAccount(w http.ResponseWriter, r *http.Request) {
Note: "You'll confirm it's really you with your passkey.",
Action: "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(app) + "/move",
Fields: [][2]string{
- {"csrf", raw},
+ {"csrf", raw}, {"to", dest.Sqid},
},
Button: "Move to " + dest.Name,
CancelTab: "settings",
@@ -349,6 +394,10 @@ func (s *Server) handleAppMoveAccount(w http.ResponseWriter, r *http.Request) {
s.renderConfirm(w, r, addr, raw, acct, role, app, cv)
return
}
+ if pinned := strings.TrimSpace(r.FormValue("to")); pinned != "" && pinned != dest.Sqid {
+ back("err", "This app's routes changed while you were confirming — review the move again.")
+ return
+ }
if !s.requireFreshAuth(w, r, addr, raw) {
return
}
diff --git a/internal/console/confirm.go b/internal/console/confirm.go
index 62d496227..c088c7b76 100644
--- a/internal/console/confirm.go
+++ b/internal/console/confirm.go
@@ -159,8 +159,10 @@ func (s *Server) confirmRollback(w http.ResponseWriter, r *http.Request, addr, r
},
Note: "Rollback never bakes — hosts adopt on their next pass.",
Action: "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(app) + "/rollback",
+ // target pins what the card promised: the confirmed POST refuses if
+ // the channel's history has moved on since this was rendered.
Fields: [][2]string{
- {"csrf", raw}, {"channel", channel}, {"back", r.FormValue("back")},
+ {"csrf", raw}, {"channel", channel}, {"target", target}, {"back", r.FormValue("back")},
},
Button: "Roll back to " + target,
Danger: true,
@@ -192,6 +194,9 @@ func (s *Server) confirmFastTrack(w http.ResponseWriter, r *http.Request, addr,
channel+" holds "+cur.Version+", promoted "+ago(cur.PromotedAt)+", still in its bake window.",
"Fast-tracking waives the rest of the window: hosts adopt "+cur.Version+" on their next pass.")
cv.Button = "Fast-track " + cur.Version
+ // Pin the version the card names — the waiver must not land on a
+ // build promoted after this was rendered.
+ cv.Fields = append(cv.Fields, [2]string{"version", cur.Version})
} else {
cv.Lines = append(cv.Lines, "Waives the rest of "+channel+"'s bake window: hosts adopt its current version on their next pass.")
}
diff --git a/internal/console/overview.go b/internal/console/overview.go
index bdeaef9f2..caa504880 100644
--- a/internal/console/overview.go
+++ b/internal/console/overview.go
@@ -24,12 +24,18 @@ type OverviewStats struct {
LiveErr string
// The app's instances (routes owned by the viewed account), and how many
- // of them the last metrics sweep actually saw. Asleep counts hibernating
- // non-reporters — expected quiet, not missing.
+ // of them the last metrics sweep actually saw. The TOTAL comes from the
+ // routes, never the sweep: a failed sweep costs the readings, and must
+ // not make the dashboard claim a live fleet has no hosts. Asleep counts
+ // hibernating non-reporters — expected quiet, not missing.
InstanceTotal int
InstanceReporting int
InstanceAsleep int
+ // MetricsUnavailable: the sweep itself failed (nil metrics), which is
+ // "readings unavailable", not "zero instances reporting".
+ MetricsUnavailable bool
+
// Current resource use summed across every reporting instance. Nil when
// nothing reports — the tiles render the em-dash, never zeros.
CPUSum *float64
@@ -48,10 +54,16 @@ type OverviewStats struct {
}
// assembleOverview derives the dashboard from what handleApp already loaded.
-// metrics may be nil (a failed sweep costs the CPU/memory tiles, never the
-// page); versions may be nil the same way.
-func assembleOverview(view *AppView, metrics []InstanceMetrics, versions []VersionView) OverviewStats {
- st := OverviewStats{}
+// metrics may be nil (a failed sweep costs the readings and says so, never
+// the page or the instance count); versions may be nil the same way.
+//
+// The tenant boundary is drawn from the ROUTES, on every number here: routes
+// are grouped by app NAME across accounts (Assemble's byApp), so both the
+// instance count and the domains count must skip rows stamped with another
+// account — a same-named app elsewhere must not inflate this dashboard or
+// wave its failed cert on it.
+func assembleOverview(view *AppView, acct string, metrics []InstanceMetrics, versions []VersionView) OverviewStats {
+ st := OverviewStats{MetricsUnavailable: metrics == nil}
for _, ch := range view.Channels {
if ch.Name != "stable" {
continue
@@ -66,30 +78,51 @@ func assembleOverview(view *AppView, metrics []InstanceMetrics, versions []Versi
st.LiveActor = ch.Actor
break
}
+ reporting := make(map[string]bool, len(metrics))
for _, m := range metrics {
- st.InstanceTotal++
- if m.Reporting() {
- st.InstanceReporting++
- if m.CPUFrac != nil {
- if st.CPUSum == nil {
- st.CPUSum = new(float64)
- }
- *st.CPUSum += *m.CPUFrac
+ if !m.Reporting() {
+ continue
+ }
+ reporting[m.Host] = true
+ if m.CPUFrac != nil {
+ if st.CPUSum == nil {
+ st.CPUSum = new(float64)
}
- if m.MemBytes != nil {
- if st.MemSum == nil {
- st.MemSum = new(int64)
- }
- *st.MemSum += *m.MemBytes
+ *st.CPUSum += *m.CPUFrac
+ }
+ if m.MemBytes != nil {
+ if st.MemSum == nil {
+ st.MemSum = new(int64)
}
- } else if m.Hibernate {
+ *st.MemSum += *m.MemBytes
+ }
+ }
+ for _, rt := range view.Routes {
+ if rt.Account != acct {
+ continue
+ }
+ st.InstanceTotal++
+ if reporting[rt.Host] {
+ st.InstanceReporting++
+ } else if rt.Hibernate {
st.InstanceAsleep++
}
}
- st.DomainTotal = len(view.Domains)
- for _, d := range view.Domains {
- if domainNeedsAttention(d) {
- st.DomainAttention++
+ // Routes and Domains are parallel slices — Assemble appends one of each
+ // per registry route, in the same loop — which is what lets the domain
+ // rows be account-filtered here at all (DomainView carries no account of
+ // its own). Guarded rather than assumed, and the guard fails toward
+ // counting nothing: a miscount that says 0 invites a look, one that
+ // includes another tenant's failure does not.
+ if len(view.Domains) == len(view.Routes) {
+ for i, rt := range view.Routes {
+ if rt.Account != acct {
+ continue
+ }
+ st.DomainTotal++
+ if domainNeedsAttention(view.Domains[i]) {
+ st.DomainAttention++
+ }
}
}
st.ReleaseCount = len(versions)
diff --git a/internal/console/overview_test.go b/internal/console/overview_test.go
index ae22b241a..cab8018de 100644
--- a/internal/console/overview_test.go
+++ b/internal/console/overview_test.go
@@ -29,7 +29,7 @@ func TestOverviewLiveVersionFromStable(t *testing.T) {
{Name: "beta", Version: "v9"},
{Name: "stable", Version: "v7", PromotedAt: promoted, Actor: "kim@keymail.dev"},
}}
- st := assembleOverview(view, nil, nil)
+ st := assembleOverview(view, "bab", nil, nil)
if !st.HasStable {
t.Fatal("an app with a stable pointer is not reported live")
}
@@ -44,7 +44,7 @@ func TestOverviewLiveVersionFromStable(t *testing.T) {
}
// No stable at all: the honest "not live yet" state, and NOT an error.
- st = assembleOverview(&AppView{App: "kass", Channels: []ChannelView{{Name: "edge", Version: "v9"}}}, nil, nil)
+ st = assembleOverview(&AppView{App: "kass", Channels: []ChannelView{{Name: "edge", Version: "v9"}}}, "bab", nil, nil)
if st.HasStable || st.LiveVersion != "" {
t.Fatalf("a never-promoted app reads as live: %+v", st)
}
@@ -60,7 +60,7 @@ func TestOverviewUnreadableStableIsUnknownNotOffline(t *testing.T) {
view := &AppView{App: "kass", Channels: []ChannelView{
{Name: "stable", Err: "signature does not verify"},
}}
- st := assembleOverview(view, nil, nil)
+ st := assembleOverview(view, "bab", nil, nil)
if st.HasStable {
t.Fatal("an unreadable pointer was reported as a known live version")
}
@@ -74,8 +74,19 @@ func TestOverviewUnreadableStableIsUnknownNotOffline(t *testing.T) {
// The tiles render an em-dash for "no readings", never zeros — so the sums
// have to stay nil when nothing reported, and must cover only the instances
-// that actually did.
+// that actually did. The instance COUNT comes from the routes, so a failed
+// sweep (nil metrics) reads as readings-unavailable, never as a hostless app,
+// and another account's same-named routes count for nothing at all.
func TestOverviewSumsOnlyReportingInstances(t *testing.T) {
+ view := &AppView{App: "kass", Routes: []RouteView{
+ {Host: "a", Account: "bab"},
+ {Host: "b", Account: "bab"},
+ {Host: "c", Account: "bab"},
+ {Host: "d", Account: "bab", Hibernate: true},
+ {Host: "e", Account: "bab"},
+ // Another account's same-named app: not this dashboard's instance.
+ {Host: "theirs", Account: "cdef"},
+ }}
metrics := []InstanceMetrics{
{Host: "a", Box: "box-1", CPUFrac: f64(0.25), MemBytes: i64(100)},
{Host: "b", Box: "box-1", CPUFrac: f64(0.5), MemBytes: i64(200)},
@@ -85,9 +96,9 @@ func TestOverviewSumsOnlyReportingInstances(t *testing.T) {
{Host: "d", CPUFrac: f64(99), MemBytes: i64(99), Hibernate: true},
{Host: "e"},
}
- st := assembleOverview(&AppView{App: "kass"}, metrics, nil)
+ st := assembleOverview(view, "bab", metrics, nil)
if st.InstanceTotal != 5 {
- t.Fatalf("InstanceTotal = %d, want 5", st.InstanceTotal)
+ t.Fatalf("InstanceTotal = %d, want 5 (the routes, not the sweep, and never another account's)", st.InstanceTotal)
}
if st.InstanceReporting != 3 {
t.Fatalf("InstanceReporting = %d, want 3", st.InstanceReporting)
@@ -101,10 +112,14 @@ func TestOverviewSumsOnlyReportingInstances(t *testing.T) {
if st.MemSum == nil || *st.MemSum != 300 {
t.Fatalf("MemSum = %v, want 300", st.MemSum)
}
+ if st.MetricsUnavailable {
+ t.Fatal("a sweep that ran reads as unavailable")
+ }
// Nobody reported: nil, not zero. A zero here would render as "0%" and
// claim an idle app where there is simply no reading.
- quiet := assembleOverview(&AppView{App: "kass"}, []InstanceMetrics{{Host: "a"}, {Host: "b"}}, nil)
+ quietView := &AppView{App: "kass", Routes: []RouteView{{Host: "a", Account: "bab"}, {Host: "b", Account: "bab"}}}
+ quiet := assembleOverview(quietView, "bab", []InstanceMetrics{{Host: "a"}, {Host: "b"}}, nil)
if quiet.CPUSum != nil || quiet.MemSum != nil {
t.Fatalf("sums = %v %v with nothing reporting, want nil so the tiles show the em-dash",
quiet.CPUSum, quiet.MemSum)
@@ -112,25 +127,48 @@ func TestOverviewSumsOnlyReportingInstances(t *testing.T) {
if quiet.InstanceReporting != 0 || quiet.InstanceTotal != 2 {
t.Fatalf("quiet counts = %d/%d, want 0/2", quiet.InstanceReporting, quiet.InstanceTotal)
}
+
+ // The sweep FAILED (nil metrics): the count must survive — "readings
+ // unavailable" is not "no hosts yet".
+ failed := assembleOverview(quietView, "bab", nil, nil)
+ if failed.InstanceTotal != 2 {
+ t.Fatalf("InstanceTotal = %d after a failed sweep, want the routes' 2", failed.InstanceTotal)
+ }
+ if !failed.MetricsUnavailable {
+ t.Fatal("a failed sweep does not read as unavailable")
+ }
}
// Attention means someone has to do something: DNS that is not pointing here,
// or issuance that failed. A cert mid-issuance is patience, not attention —
// badging it would make every new domain look broken for its first minute.
func TestOverviewDomainAttentionCountsGuidanceAndFailedTLS(t *testing.T) {
- view := &AppView{App: "kass", Domains: []DomainView{
- {Domain: "a.example.com", TLS: "ready · renews 2027-01-01"},
- {Domain: "b.example.com", TLS: "issuing"},
- {Domain: "c.example.com", TLS: ""},
- {Domain: "d.example.com", TLS: "failed: dns-01 timeout"},
- {Domain: "e.example.com", TLS: "ready", Guidance: "Point an A record for e.example.com at ..."},
- }}
- st := assembleOverview(view, nil, nil)
+ // Domains are counted through their PARALLEL routes (that is where the
+ // account lives), so the view carries both — including another tenant's
+ // same-named row, whose failed cert must not wave on this dashboard.
+ view := &AppView{App: "kass",
+ Routes: []RouteView{
+ {Host: "a.example.com", Account: "bab"},
+ {Host: "b.example.com", Account: "bab"},
+ {Host: "c.example.com", Account: "bab"},
+ {Host: "d.example.com", Account: "bab"},
+ {Host: "e.example.com", Account: "bab"},
+ {Host: "f.example.com", Account: "cdef"},
+ },
+ Domains: []DomainView{
+ {Domain: "a.example.com", TLS: "ready · renews 2027-01-01"},
+ {Domain: "b.example.com", TLS: "issuing"},
+ {Domain: "c.example.com", TLS: ""},
+ {Domain: "d.example.com", TLS: "failed: dns-01 timeout"},
+ {Domain: "e.example.com", TLS: "ready", Guidance: "Point an A record for e.example.com at ..."},
+ {Domain: "f.example.com", TLS: "failed: dns-01 timeout"},
+ }}
+ st := assembleOverview(view, "bab", nil, nil)
if st.DomainTotal != 5 {
- t.Fatalf("DomainTotal = %d, want 5", st.DomainTotal)
+ t.Fatalf("DomainTotal = %d, want 5 — the other account's domain is not this tenant's", st.DomainTotal)
}
if st.DomainAttention != 2 {
- t.Fatalf("DomainAttention = %d, want 2 (the failed cert and the unpointed domain)", st.DomainAttention)
+ t.Fatalf("DomainAttention = %d, want 2 (the failed cert and the unpointed domain, never the other tenant's)", st.DomainAttention)
}
// The one-bit read on its own, so a miscount above is attributable.
for _, tc := range []struct {
@@ -157,7 +195,7 @@ func TestOverviewReleasesCapped(t *testing.T) {
for i := range full {
full[i] = VersionView{Version: "v" + string(rune('a'+i))}
}
- st := assembleOverview(&AppView{App: "kass"}, nil, full)
+ st := assembleOverview(&AppView{App: "kass"}, "bab", nil, full)
if st.ReleaseCount != versionsCap || !st.ReleasesCapped {
t.Fatalf("count/capped = %d/%v, want %d/true", st.ReleaseCount, st.ReleasesCapped, versionsCap)
}
@@ -165,11 +203,11 @@ func TestOverviewReleasesCapped(t *testing.T) {
t.Fatalf("LatestVersion = %q, want the head of the newest-first list (%q)", st.LatestVersion, full[0].Version)
}
- short := assembleOverview(&AppView{App: "kass"}, nil, full[:versionsCap-1])
+ short := assembleOverview(&AppView{App: "kass"}, "bab", nil, full[:versionsCap-1])
if short.ReleasesCapped {
t.Fatalf("a list one short of the cap was reported capped (%d)", short.ReleaseCount)
}
- none := assembleOverview(&AppView{App: "kass"}, nil, nil)
+ none := assembleOverview(&AppView{App: "kass"}, "bab", nil, nil)
if none.ReleaseCount != 0 || none.ReleasesCapped || none.LatestVersion != "" {
t.Fatalf("empty release list = %+v, want zeros and no version", none)
}
diff --git a/internal/console/server.go b/internal/console/server.go
index f0208a93a..644f5cbb9 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -1380,7 +1380,7 @@ func (s *Server) handleApp(w http.ResponseWriter, r *http.Request) {
}
p.Versions = versions
metrics := s.appResourceMetrics(r.Context(), p.Detail, acct)
- p.Overview = assembleOverview(p.Detail, metrics, versions)
+ p.Overview = assembleOverview(p.Detail, acct.Sqid, metrics, versions)
if events, err := s.store.Events(acct.ID, app, overviewActivityCap); err != nil {
log.Printf("console: events %s: %v", app, err)
} else {
@@ -1835,6 +1835,14 @@ func (s *Server) handleRollback(w http.ResponseWriter, r *http.Request) {
s.confirmRollback(w, r, addr, raw, acct, role, app, channel, cur.Version, target)
return
}
+ // The card pinned where it promised to go; a channel whose history moved
+ // since then must be looked at again, not rolled to wherever it points
+ // now. Checked only when the pin is present: a pinless POST (the CLI
+ // shape) rolls back the current head exactly as it always has.
+ if pinned := strings.TrimSpace(r.FormValue("target")); pinned != "" && pinned != target {
+ back("err", "Channel "+channel+" changed while you were confirming — review the rollback again.")
+ return
+ }
if err := release.Rollback(ctx, s.cfg.Releases, s.cfg.Key, acct.Sqid, app, channel); err != nil {
log.Printf("console: rollback %s %s: %v", app, channel, err)
back("err", "Rollback failed: "+err.Error())
@@ -1968,6 +1976,21 @@ func (s *Server) handleFastTrack(w http.ResponseWriter, r *http.Request) {
}
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
+ // The card pinned the version whose window it offered to waive; if the
+ // channel has moved since, the waiver must not land on the newcomer.
+ // Pin-present-only, like the rollback pin: a pinless POST waives the
+ // current head, which is FastTrack's own contract.
+ if pinned := strings.TrimSpace(r.FormValue("version")); pinned != "" {
+ cur, err := release.Channel(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app, channel)
+ if err != nil {
+ back("err", "Could not read channel "+channel+".")
+ return
+ }
+ if cur.Version != pinned {
+ back("err", "Channel "+channel+" changed while you were confirming — review the fast-track again.")
+ return
+ }
+ }
ver, err := release.FastTrack(ctx, s.cfg.Releases, s.cfg.Key, acct.Sqid, app, channel, addr)
if err != nil {
log.Printf("console: fast-track %s %s: %v", app, channel, err)
diff --git a/internal/console/store.go b/internal/console/store.go
index 1a4512c60..6c76b98c6 100644
--- a/internal/console/store.go
+++ b/internal/console/store.go
@@ -1231,10 +1231,28 @@ func (s *Store) MoveApp(app string, from, to int64) error {
if atSrc == 0 {
return fmt.Errorf("console: account %d does not claim %q", from, app)
}
+ // App-kind CLI tokens are bound (account, name), so they must follow the
+ // app or the running deployment's credential 404s while retaining
+ // authority over the name in the SOURCE account. Tokens already at the
+ // destination under this name are a purged predecessor's leftovers —
+ // re-homing onto them would hand this app a credential nobody minted for
+ // it, so that refuses, exactly as RenameAppIn refuses.
+ var staleTokens int
+ if err := tx.QueryRow(`SELECT COUNT(*) FROM cli_tokens WHERE kind = ? AND account_id = ? AND app = ?`,
+ cliKindApp, to, app).Scan(&staleTokens); err != nil {
+ return err
+ }
+ if staleTokens > 0 {
+ return fmt.Errorf("console: the destination account still has app credentials for %q from a previous app — revoke them first", app)
+ }
if _, err := tx.Exec(`UPDATE apps SET account_id = ? WHERE app = ? AND account_id = ?`,
to, app, from); err != nil {
return err
}
+ if _, err := tx.Exec(`UPDATE cli_tokens SET account_id = ? WHERE kind = ? AND account_id = ? AND app = ?`,
+ to, cliKindApp, from, app); err != nil {
+ return err
+ }
return tx.Commit()
}
@@ -1250,15 +1268,30 @@ func (s *Store) MoveApp(app string, from, to int64) error {
// keyed by bare name across ALL accounts, so rewriting them could hand
// one account's grant to another, and renaming ONTO one would let the new
// name inherit a grant nobody issued it. Claim-proof stays claim-proof.
+// - The new name must carry no app-kind CLI tokens. A purged app's tokens
+// outlive its row, and a credential that suddenly binds to a NEW app is
+// authority nobody minted.
//
-// Audit history follows the app: its story belongs to the app, not to a name
-// that no longer resolves. The rename itself is the caller's audit line.
+// What follows the app: its audit rows — but only those written since THIS
+// app's row was created, because a purged predecessor of the same name left
+// its rows behind and its history is not this app's; and its app-kind CLI
+// tokens, which are bound (account, name) and would otherwise 404 the
+// running app while retaining authority over the freed name. The rename
+// itself is the caller's audit line.
func (s *Store) RenameAppIn(accountID int64, from, to string) error {
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
+ var createdAt int64
+ err = tx.QueryRow(`SELECT created_at FROM apps WHERE account_id = ? AND app = ? AND deleted_at = 0`,
+ accountID, from).Scan(&createdAt)
+ if err == sql.ErrNoRows {
+ return fmt.Errorf("console: this account does not claim %q", from)
+ } else if err != nil {
+ return err
+ }
var legacy int
if err := tx.QueryRow(`SELECT COUNT(*) FROM owners WHERE app IN (?, ?)`, from, to).Scan(&legacy); err != nil {
return err
@@ -1273,20 +1306,26 @@ func (s *Store) RenameAppIn(accountID int64, from, to string) error {
if taken > 0 {
return fmt.Errorf("console: this account already has an app (or a trashed app) named %q", to)
}
- res, err := tx.Exec(`UPDATE apps SET app = ? WHERE account_id = ? AND app = ? AND deleted_at = 0`,
- to, accountID, from)
- if err != nil {
+ var staleTokens int
+ if err := tx.QueryRow(`SELECT COUNT(*) FROM cli_tokens WHERE kind = ? AND account_id = ? AND app = ?`,
+ cliKindApp, accountID, to).Scan(&staleTokens); err != nil {
return err
}
- if n, err := res.RowsAffected(); err != nil {
- return err
- } else if n == 0 {
- return fmt.Errorf("console: this account does not claim %q", from)
+ if staleTokens > 0 {
+ return fmt.Errorf("console: %q still has app credentials from a previous app of that name — revoke them first", to)
}
- if _, err := tx.Exec(`UPDATE audit SET app = ? WHERE account_id = ? AND app = ?`,
+ if _, err := tx.Exec(`UPDATE apps SET app = ? WHERE account_id = ? AND app = ? AND deleted_at = 0`,
to, accountID, from); err != nil {
return err
}
+ if _, err := tx.Exec(`UPDATE audit SET app = ? WHERE account_id = ? AND app = ? AND created_at >= ?`,
+ to, accountID, from, createdAt); err != nil {
+ return err
+ }
+ if _, err := tx.Exec(`UPDATE cli_tokens SET app = ? WHERE kind = ? AND account_id = ? AND app = ?`,
+ to, cliKindApp, accountID, from); err != nil {
+ return err
+ }
return tx.Commit()
}
diff --git a/internal/console/templates/app_overview.html b/internal/console/templates/app_overview.html
index 4500abd08..2afa9ea83 100644
--- a/internal/console/templates/app_overview.html
+++ b/internal/console/templates/app_overview.html
@@ -31,17 +31,17 @@
<a class="tile" href="{{$.Nav}}/apps/{{$.AppName}}/instances">
<h2>Instances</h2>
<p class="tile-value">{{.InstanceTotal}}</p>
- <p class="tile-detail">{{if eq .InstanceTotal 0}}no hosts yet{{else}}{{.InstanceReporting}} reporting{{if .InstanceAsleep}} · {{.InstanceAsleep}} asleep{{end}}{{end}}</p>
+ <p class="tile-detail">{{if eq .InstanceTotal 0}}no hosts yet{{else if .MetricsUnavailable}}readings unavailable{{else}}{{.InstanceReporting}} reporting{{if .InstanceAsleep}} · {{.InstanceAsleep}} asleep{{end}}{{end}}</p>
</a>
<a class="tile" href="{{$.Nav}}/apps/{{$.AppName}}/instances">
<h2>CPU</h2>
<p class="tile-value">{{pct .CPUSum}}</p>
- <p class="tile-detail">{{if .CPUSum}}across {{.InstanceReporting}} reporting{{else}}no recent readings{{end}}</p>
+ <p class="tile-detail">{{if .CPUSum}}across {{.InstanceReporting}} reporting{{else if .MetricsUnavailable}}readings unavailable{{else}}no recent readings{{end}}</p>
</a>
<a class="tile" href="{{$.Nav}}/apps/{{$.AppName}}/instances">
<h2>Memory</h2>
<p class="tile-value">{{bytesOrDash .MemSum}}</p>
- <p class="tile-detail">{{if .MemSum}}across {{.InstanceReporting}} reporting{{else}}no recent readings{{end}}</p>
+ <p class="tile-detail">{{if .MemSum}}across {{.InstanceReporting}} reporting{{else if .MetricsUnavailable}}readings unavailable{{else}}no recent readings{{end}}</p>
</a>
<a class="tile" href="{{$.Nav}}/apps/{{$.AppName}}/settings">
<h2>Domains</h2>
@@ -57,7 +57,7 @@
{{end}}
{{with .Detail.Production}}
{{if .Err}}
-<p class="small error">Supply-chain bake: flag unreadable — hosts keep baking. Repair it on the <a href="{{$.Nav}}/apps/{{$.AppName}}/releases">Releases</a> page.</p>
+<p class="small error">Supply-chain bake: flag unreadable ({{.Err}}) — hosts keep baking. Repair it on the <a href="{{$.Nav}}/apps/{{$.AppName}}/releases">Releases</a> page.</p>
{{else if .On}}
<p class="small">Supply-chain bake: <strong>on</strong> — releases hold on beta and stable before hosts adopt. Managed on <a href="{{$.Nav}}/apps/{{$.AppName}}/releases">Releases</a>.</p>
{{else if .TurningOff}}
diff --git a/internal/console/templates/app_settings.html b/internal/console/templates/app_settings.html
index a299350a2..4e24ef77b 100644
--- a/internal/console/templates/app_settings.html
+++ b/internal/console/templates/app_settings.html
@@ -56,32 +56,32 @@ the certificate is minted on the first request and renews on its own.
Attaching a domain from this page, without the box, is on the platform's
roadmap.</p>
-{{if .IsOwner}}
{{if and .Settings .Settings.MismatchTarget}}
<h2>Account</h2>
-{{if and .Settings.TargetKnown .Settings.ViewerInTarget}}
<p>This app's hosts read releases from account
<code>{{.Settings.MismatchTarget}}</code>, but the console's record says
-{{.Account.Name}} — so promotes from here reach nothing. Move the record to
-where the platform already deploys from:</p>
+{{.Account.Name}} — so promotes from here reach nothing.</p>
+{{if and .IsOwner .Settings.TargetKnown .Settings.ViewerInTarget}}
+<p>Move the record to where the platform already deploys from:</p>
<form method="post" action="{{.Base}}/apps/{{.Detail.App}}/move">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<button type="submit" class="btn-sm">Move to {{.Settings.MismatchTarget}}</button>
</form>
{{else}}
-<p class="small">This app's hosts read releases from account
-<code>{{.Settings.MismatchTarget}}</code>{{if not .Settings.TargetKnown}},
-which this console does not know{{else}}, which you are not a member of{{end}}
-— finishing the move is an operator act: <code>console migrate-app -app
-{{.Detail.App}} -to {{.Settings.MismatchTarget}}</code> on the console's box.</p>
+<p class="small">{{if not .IsOwner}}Moving the record is the account owner's
+to do{{else if not .Settings.TargetKnown}}The routes name an account this
+console does not know{{else}}You are not a member of that account{{end}} —
+the operator path is <code>console migrate-app -app {{.Detail.App}} -to
+{{.Settings.MismatchTarget}}</code> on the console's box.</p>
{{end}}
-{{else}}
+{{else}}{{if .IsOwner}}
<h2>Account</h2>
<p class="small">This app deploys from {{.Account.Name}}. Moving an app to a
different account moves its bucket objects and re-stamps its routes —
<code>carlos accounts migrate</code>, an operator act; once run, this page
offers the final console-record step.</p>
-{{end}}
+{{end}}{{end}}
+{{if .IsOwner}}
<h2 class="danger-head">Delete this app</h2>
<p class="small">Deleting puts {{.Detail.App}} in the account's
diff --git a/internal/console/templates/ladder.html b/internal/console/templates/ladder.html
index 81be54174..26e69c263 100644
--- a/internal/console/templates/ladder.html
+++ b/internal/console/templates/ladder.html
@@ -5,7 +5,7 @@ promotion moves a signed pointer, and hosts follow their channel.</p>
{{with .Detail.Production}}
<div class="bake">
{{if .Err}}
- <p class="small error">Supply-chain bake: flag unreadable — hosts keep baking.
+ <p class="small error">Supply-chain bake: flag unreadable: {{.Err}} — hosts keep baking.
{{if $.CanPromote}}
<form method="post" action="{{$.Base}}/apps/{{$.Detail.App}}/production" class="inline">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
diff --git a/internal/console/view.go b/internal/console/view.go
index b3071e974..b9de2b31e 100644
--- a/internal/console/view.go
+++ b/internal/console/view.go
@@ -345,6 +345,11 @@ func Assemble(regPath string, s release.Store, pub ed25519.PublicKey, acct strin
if rtAcct == acct {
capsFor = humanizeCaps(rt.Caps)
}
+ // One RouteView and one DomainView per route, appended together:
+ // AppView.Routes and AppView.Domains are PARALLEL slices, and
+ // assembleOverview leans on that to account-filter domain rows
+ // (DomainView carries no account of its own). Splitting these
+ // appends apart breaks that join silently.
byApp[rt.App] = append(byApp[rt.App], RouteView{
Host: rt.Host, Kind: rt.Kind, Status: rt.Status,
Version: rt.Version, Channel: ch, Hibernate: rt.Hibernate,