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>
10 files changed,
+273
−81
internal/console/appsettings.go+58 −9internal/console/confirm.go+6 −1internal/console/overview.go+57 −24internal/console/overview_test.go+58 −20internal/console/server.go+24 −1internal/console/store.go+49 −10internal/console/templates/app_overview.html+4 −4internal/console/templates/app_settings.html+11 −11internal/console/templates/ladder.html+1 −1internal/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, |