carlos / platform Public

console: account-mismatch banner names routes, claims "not deploying" only when true

Reworks the half-migrated account-mismatch state from one app-wide
first-writer-wins account string to a per-route list, so the banner can
name which routes disagree and only claims "this app is not deploying"
when EVERY non-platform route mismatches.

- MismatchedRoute{Host, Account} + AppView.Mismatched/AllMismatched
- AccountMismatch() becomes len(v.Mismatched) > 0
- Assemble replaces the routeAccount map with mismatchedByApp +
  routableByApp, keeping the platform-route exclusion and RouteAccount's
  "first mismatched account" semantics for other callers
- Both app_overview.html and app_releases.html get the identical new
  banner: the AllMismatched alarm names the affected hosts, the partial
  case downgrades to a non-error banner listing just the routes that
  won't follow promotes
- Three new tests (TestMismatchListsRoutes, TestMismatchAllRoutes,
  TestMismatchIgnoresPlatformRoutes) via the fixture-registry pattern
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell cd100e92ed6d271b16acb146ce32b0a743b8b3d7 parent e56552c
4 files changed, +185 −29
  • internal/console/templates/app_overview.html +15 −4
  • internal/console/templates/app_releases.html +17 −4
  • internal/console/view.go +41 −21
  • internal/console/view_test.go +112 −0
diff --git a/internal/console/templates/app_overview.html b/internal/console/templates/app_overview.html
index c2212bd7a..85764cdc1 100644
--- a/internal/console/templates/app_overview.html
+++ b/internal/console/templates/app_overview.html
@@ -3,15 +3,26 @@
{{if .FlashErr}}<p class="banner error">{{.FlashErr}}</p>{{end}}
{{template "appnav" .}}
{{if .Detail.AccountMismatch}}
+{{if .Detail.AllMismatched}}
<p class="banner error">
- <strong>This app is not deploying.</strong> Its hosts read releases from
- account <code>{{.Detail.RouteAccount}}</code>, but this page writes them to
- <code>{{.Account.Sqid}}</code> — so a promote here succeeds and reaches
- nothing. Finish the move on this app's
+ <strong>This app is not deploying.</strong> Every one of its addresses reads
+ releases from account <code>{{.Detail.RouteAccount}}</code>, but this page
+ writes them to <code>{{.Account.Sqid}}</code> — so a promote here succeeds
+ and reaches nothing. Affected: {{range .Detail.Mismatched}}<code>{{.Host}}</code> {{end}}
+ Finish the move on this app's
<a href="{{.Nav}}/apps/{{.AppName}}/settings">Settings</a> page.
<span class="small">If it is the routes that are in the wrong account, move
those back instead — whichever of the two is right, they have to agree.</span>
</p>
+{{else}}
+<p class="banner">
+ {{len .Detail.Mismatched}} of this app's addresses read releases from another
+ account and won't follow promotes made here:
+ {{range .Detail.Mismatched}}<code>{{.Host}}</code> (reads <code>{{.Account}}</code>) {{end}}
+ — the rest deploy normally. Fix on
+ <a href="{{.Nav}}/apps/{{.AppName}}/settings">Settings</a>.
+</p>
+{{end}}
{{end}}
{{with .Overview}}
<section class="pagehead" aria-label="App status">
diff --git a/internal/console/templates/app_releases.html b/internal/console/templates/app_releases.html
index d26a8a4ad..ae2be7564 100644
--- a/internal/console/templates/app_releases.html
+++ b/internal/console/templates/app_releases.html
@@ -3,13 +3,26 @@
{{if .FlashErr}}<p class="banner error">{{.FlashErr}}</p>{{end}}
{{template "appnav" .}}
{{if .Detail.AccountMismatch}}
+{{if .Detail.AllMismatched}}
<p class="banner error">
- <strong>This app is not deploying.</strong> Its hosts read releases from
- account <code>{{.Detail.RouteAccount}}</code>, but this page writes them to
- <code>{{.Account.Sqid}}</code> — so a promote here succeeds and reaches
- nothing. Finish the move on this app's
+ <strong>This app is not deploying.</strong> Every one of its addresses reads
+ releases from account <code>{{.Detail.RouteAccount}}</code>, but this page
+ writes them to <code>{{.Account.Sqid}}</code> — so a promote here succeeds
+ and reaches nothing. Affected: {{range .Detail.Mismatched}}<code>{{.Host}}</code> {{end}}
+ Finish the move on this app's
<a href="{{.Nav}}/apps/{{.AppName}}/settings">Settings</a> page.
+ <span class="small">If it is the routes that are in the wrong account, move
+ those back instead — whichever of the two is right, they have to agree.</span>
</p>
+{{else}}
+<p class="banner">
+ {{len .Detail.Mismatched}} of this app's addresses read releases from another
+ account and won't follow promotes made here:
+ {{range .Detail.Mismatched}}<code>{{.Host}}</code> (reads <code>{{.Account}}</code>) {{end}}
+ — the rest deploy normally. Fix on
+ <a href="{{.Nav}}/apps/{{.AppName}}/settings">Settings</a>.
+</p>
+{{end}}
{{end}}
{{with .ReleaseSummary}}
diff --git a/internal/console/view.go b/internal/console/view.go
index 8a208a6a8..a5dfbe5f0 100644
--- a/internal/console/view.go
+++ b/internal/console/view.go
@@ -38,18 +38,31 @@ type AppView struct {
Production ProductionView
// RouteAccount is the account the app's ROUTES say it belongs to, when
// that disagrees with the account whose page this is; "" when they agree
- // or there are no routes. It is the half-migrated state — see
- // AccountMismatch.
+ // or there are no routes. It is the first mismatched account (kept for
+ // any caller other than the banner — see AccountMismatch) — the banner
+ // itself reads Mismatched/AllMismatched below.
RouteAccount string
+ // Mismatched is every non-platform route stamped to another account;
+ // AllMismatched is whether that is ALL of them — only then is "this app
+ // is not deploying" a claim the page can back.
+ Mismatched []MismatchedRoute
+ AllMismatched bool
}
+// MismatchedRoute is one route whose stamped account disagrees with the
+// account whose page is being viewed: host plus where it actually reads
+// releases from. The banner names these — a warning that says WHICH routes
+// is a diagnostic; one that says "this app" is an alarm, and on tito
+// (2026-08-10) it was a false one.
+type MismatchedRoute struct{ Host, Account string }
+
// AccountMismatch reports the half-migrated state that would otherwise be
// silent: `carlos accounts migrate` re-stamped this app's routes onto another
// account, and `console migrate-app` was never run, so the console promotes
// into one account's bucket keys while every host reads another's. A promote
// then flashes "Promoted" and deploys nothing, forever, with no error
// anywhere. The page has to say so.
-func (v AppView) AccountMismatch() bool { return v.RouteAccount != "" }
+func (v AppView) AccountMismatch() bool { return len(v.Mismatched) > 0 }
// DomainView is one row of the app page's Domains table — the product
// surface for what routes always were. Unknowns stay empty strings here and
@@ -355,20 +368,24 @@ func Assemble(regPath string, s release.Store, pub ed25519.PublicKey, acct strin
// only place Caps renders at all.
byApp := map[string][]RouteView{}
domainsByApp := map[string][]DomainView{}
- // What the ROUTES say each app's account is. A disagreement with the
- // account being viewed is the half-migrated state (see AccountMismatch);
- // platform-minted routes are excluded because they are aliases that
- // reconciliation re-stamps on its own schedule, so a lagging one is noise
- // rather than a stuck deploy.
- routeAccount := map[string]string{}
+ // What the ROUTES say each app's account is, at route granularity. A
+ // disagreement with the account being viewed is the half-migrated state
+ // (see AccountMismatch); platform-minted routes are excluded because
+ // they are aliases that reconciliation re-stamps on its own schedule, so
+ // a lagging one is noise rather than a stuck deploy. routableByApp is
+ // the denominator AllMismatched needs: every non-platform route counted,
+ // so "all of them mismatch" can be told apart from "some of them".
+ mismatchedByApp := map[string][]MismatchedRoute{}
+ routableByApp := map[string]int{}
for _, rt := range routes {
ch := rt.Channel
if ch == "" {
ch = "stable" // registry contract: empty channel means stable
}
if rt.App != "" && !rt.Platform {
- if a := release.RouteAccount(rt); routeAccount[rt.App] == "" {
- routeAccount[rt.App] = a
+ routableByApp[rt.App]++
+ if a := release.RouteAccount(rt); a != acct {
+ mismatchedByApp[rt.App] = append(mismatchedByApp[rt.App], MismatchedRoute{Host: rt.Host, Account: a})
}
}
rtAcct := release.RouteAccount(rt)
@@ -406,9 +423,10 @@ func Assemble(regPath string, s release.Store, pub ed25519.PublicKey, acct strin
}
seen[app] = true
channels := assembleChannels(ctx, s, pub, acct, app, now)
+ mm := mismatchedByApp[app]
mismatch := ""
- if ra := routeAccount[app]; ra != "" && ra != acct {
- mismatch = ra
+ if len(mm) > 0 {
+ mismatch = mm[0].Account
}
pv := assembleProduction(ctx, s, pub, acct, app, channels, byApp[app], now)
if pv.Err == "" && !pv.On {
@@ -424,14 +442,16 @@ func Assemble(regPath string, s release.Store, pub ed25519.PublicKey, acct strin
}
}
out = append(out, AppView{
- App: app,
- Routes: byApp[app], // List orders by host already
- Domains: domainsByApp[app],
- Channels: channels,
- Canaries: assembleCanaries(channels, byApp[app]),
- Ladder: assembleLadder(channels, byApp[app], now),
- Production: pv,
- RouteAccount: mismatch,
+ App: app,
+ Routes: byApp[app], // List orders by host already
+ Domains: domainsByApp[app],
+ Channels: channels,
+ Canaries: assembleCanaries(channels, byApp[app]),
+ Ladder: assembleLadder(channels, byApp[app], now),
+ Production: pv,
+ RouteAccount: mismatch,
+ Mismatched: mm,
+ AllMismatched: len(mm) > 0 && len(mm) == routableByApp[app],
})
}
return out, nil
diff --git a/internal/console/view_test.go b/internal/console/view_test.go
index 46d87091b..691e5e1d6 100644
--- a/internal/console/view_test.go
+++ b/internal/console/view_test.go
@@ -304,6 +304,118 @@ func TestAssembleCarriesProcessOwnership(t *testing.T) {
}
}
+// TestMismatchListsRoutes guards the route-granularity replacement for the
+// old app-wide first-writer-wins mismatch string: an app with one route
+// stamped to another account, among others that agree with the account
+// being viewed, must name that one route — not claim the whole app is
+// broken.
+func TestMismatchListsRoutes(t *testing.T) {
+ regPath, store, _, pub := buildFixtures(t)
+ reg, err := registry.Open(regPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, rt := range []registry.Route{
+ {Host: "one.mismatchlist.test", Kind: "instance", Status: "ready",
+ App: "mismatchlist", Channel: "stable", Account: "otheracct"},
+ {Host: "two.mismatchlist.test", Kind: "instance", Status: "ready",
+ App: "mismatchlist", Channel: "stable"},
+ {Host: "three.mismatchlist.test", Kind: "instance", Status: "ready",
+ App: "mismatchlist", Channel: "stable"},
+ } {
+ if err := reg.Upsert(rt); err != nil {
+ t.Fatal(err)
+ }
+ }
+ views, err := Assemble(regPath, store, pub, release.OpsAccount, []string{"mismatchlist"}, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ v := views[0]
+ if len(v.Mismatched) != 1 || v.Mismatched[0].Host != "one.mismatchlist.test" || v.Mismatched[0].Account != "otheracct" {
+ t.Fatalf("Mismatched = %+v, want exactly [{one.mismatchlist.test otheracct}]", v.Mismatched)
+ }
+ if v.AllMismatched {
+ t.Fatalf("AllMismatched = true, want false (2 of 3 routes agree)")
+ }
+ if !v.AccountMismatch() {
+ t.Fatal("AccountMismatch() = false, want true")
+ }
+ if v.RouteAccount != "otheracct" {
+ t.Fatalf("RouteAccount = %q, want otheracct (first mismatched account)", v.RouteAccount)
+ }
+}
+
+// TestMismatchAllRoutes guards the AllMismatched claim: only when every
+// non-platform route disagrees is "this app is not deploying" a sentence
+// the page can back.
+func TestMismatchAllRoutes(t *testing.T) {
+ regPath, store, _, pub := buildFixtures(t)
+ reg, err := registry.Open(regPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, rt := range []registry.Route{
+ {Host: "one.mismatchall.test", Kind: "instance", Status: "ready",
+ App: "mismatchall", Channel: "stable", Account: "otheracct"},
+ {Host: "two.mismatchall.test", Kind: "instance", Status: "ready",
+ App: "mismatchall", Channel: "stable", Account: "otheracct"},
+ {Host: "three.mismatchall.test", Kind: "instance", Status: "ready",
+ App: "mismatchall", Channel: "stable", Account: "otheracct"},
+ } {
+ if err := reg.Upsert(rt); err != nil {
+ t.Fatal(err)
+ }
+ }
+ views, err := Assemble(regPath, store, pub, release.OpsAccount, []string{"mismatchall"}, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ v := views[0]
+ if len(v.Mismatched) != 3 {
+ t.Fatalf("Mismatched = %+v, want all 3 routes", v.Mismatched)
+ }
+ if !v.AllMismatched {
+ t.Fatal("AllMismatched = false, want true (every route disagrees)")
+ }
+}
+
+// TestMismatchIgnoresPlatformRoutes guards the exclusion carried over from
+// the old routeAccount map: a platform-minted route stamped to another
+// account is reconciliation lag, not a stuck deploy, and must contribute
+// nothing to Mismatched/AllMismatched.
+func TestMismatchIgnoresPlatformRoutes(t *testing.T) {
+ regPath, store, _, pub := buildFixtures(t)
+ reg, err := registry.Open(regPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, rt := range []registry.Route{
+ {Host: "real.mismatchplatform.test", Kind: "instance", Status: "ready",
+ App: "mismatchplatform", Channel: "stable"},
+ {Host: "alias.mismatchplatform.test", Kind: "instance", Status: "ready",
+ App: "mismatchplatform", Channel: "stable", Account: "otheracct", Platform: true},
+ } {
+ if err := reg.Upsert(rt); err != nil {
+ t.Fatal(err)
+ }
+ }
+ views, err := Assemble(regPath, store, pub, release.OpsAccount, []string{"mismatchplatform"}, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ v := views[0]
+ if len(v.Mismatched) != 0 {
+ t.Fatalf("Mismatched = %+v, want none (only route is platform-minted)", v.Mismatched)
+ }
+ if v.AllMismatched {
+ t.Fatal("AllMismatched = true, want false")
+ }
+ if v.AccountMismatch() {
+ t.Fatal("AccountMismatch() = true, want false")
+ }
+}
+
func TestVersionsNewestFirst(t *testing.T) {
_, store, _, _ := buildFixtures(t)
vs, err := Versions(store, release.OpsAccount, "kass")