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>
4 files changed,
+185
−29
internal/console/templates/app_overview.html+15 −4internal/console/templates/app_releases.html+17 −4internal/console/view.go+41 −21internal/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") |