console: environment beside every route, honest prod-wired badging
RouteView/LadderRoute both gain Environment (plumbed straight off registry.Route.Environment via view.go's Assemble — Task 2's column, no new registry read). Every route on the app page's ladder now shows the environment it is bound to, beside the channel it is listed under, in a new neutral .chip-env chip with a vendored Lucide "layers" icon — "default" for an unbound route. prodWiredCanaryHosts drops the apex-host guess (the placeholder marked delete-me-when-environments-land) for spec §4a's real definition: a canary/* route is prod-wired iff it binds the same environment as (any of) the app's stable route(s) — both binding default counts as a match. An app with no stable route flags nothing, since there is no production environment to compare against. Still one function; callers unchanged. Tests: environment renders for both a bound and an unbound route; TestProdWiredCanaryIsBadgedDistinctly rewritten around the new same-env/ different-env definition (its old apex-host premise no longer holds); a new no-stable-route case; a member sees the same environment and prod-wired chrome an owner does (visibility, not permission).
6 files changed,
+160
−63
internal/console/icons.go+8 −0internal/console/ladder.go+42 −47internal/console/ladder_test.go+99 −16internal/console/templates/ladder.html+1 −0internal/console/templates/layout.html+4 −0internal/console/view.go+6 −0
diff --git a/internal/console/icons.go b/internal/console/icons.go| index 0956bb164..6e5a22618 100644 |
| --- a/internal/console/icons.go |
| +++ b/internal/console/icons.go |
| @@ -77,6 +77,13 @@ const ( |
| iconTriangleAlert template.HTML = iconOpen + |
| `<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/>` + |
| `<path d="M12 9v4"/><path d="M12 17h.01"/></svg>` |
| + |
| + // A route's bound environment, beside the rung it is listed under |
| + // (Lucide: layers). |
| + iconLayers template.HTML = iconOpen + |
| + `<path d="m12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83z"/>` + |
| + `<path d="m6.08 9.5-3.5 1.6a1 1 0 0 0 0 1.81l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83l-3.5-1.59"/>` + |
| + `<path d="m6.08 14.5-3.5 1.6a1 1 0 0 0 0 1.81l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83l-3.5-1.59"/></svg>` |
| ) |
| // icons maps each Lucide slug to its vendored markup. Slugs are the Lucide |
| @@ -88,6 +95,7 @@ var icons = map[string]template.HTML{ |
| "arrow-down": iconArrowDown, |
| "flask-conical": iconFlask, |
| "triangle-alert": iconTriangleAlert, |
| + "layers": iconLayers, |
| } |
| // icon renders one chrome icon by its Lucide slug: {{icon "chevron-down"}}. |
diff --git a/internal/console/ladder.go b/internal/console/ladder.go| index cb05575a0..ee7a05749 100644 |
| --- a/internal/console/ladder.go |
| +++ b/internal/console/ladder.go |
| @@ -9,16 +9,18 @@ package console |
| // reads — the app's channel pointers and its registry routes — so it can |
| // disagree with neither. |
| // |
| -// Two things it deliberately is not. It is not a second promote path: the |
| -// controls post to the app's one promote endpoint, which enforces the ladder |
| -// and the passkey gate for everybody, so a hand-written POST meets the same |
| -// doors as a button. And it is not an environment view — environments do not |
| -// exist yet (spec §4, deferred), which is why the one place this file guesses |
| -// at "production" is quarantined in prodWiredCanaryHosts below. |
| +// One thing it deliberately is not: a second promote path. The controls post |
| +// to the app's one promote endpoint, which enforces the ladder and the |
| +// passkey gate for everybody, so a hand-written POST meets the same doors as |
| +// a button. |
| +// |
| +// Every route also shows the environment it is wired to (spec §4a) beside |
| +// the channel it is listed under — "default" for a route with no binding — |
| +// and prodWiredCanaryHosts below answers "production" honestly from that: |
| +// no more inferring it from which host looks like the apex. |
| import ( |
| "sort" |
| - "strings" |
| "time" |
| "github.com/carlosframework/platform/internal/release" |
| @@ -59,10 +61,14 @@ type LadderChannel struct { |
| // LadderRoute is one host under the channel it follows. |
| type LadderRoute struct { |
| Host string |
| + // Environment is the named config bundle this route is wired to — "" |
| + // (rendered "default" in ladder.html) means the app's default bundle |
| + // alone. Independent of the channel it is listed under (spec §4a). |
| + Environment string |
| // Canary is whether this host follows a canary channel at all. |
| Canary bool |
| - // ProdWired is the conspicuous case: a canary in front of everybody. See |
| - // prodWiredCanaryHosts for exactly how much this claim is worth. |
| + // ProdWired is the conspicuous case: a canary bound to the same |
| + // environment as the app's stable route. See prodWiredCanaryHosts. |
| ProdWired bool |
| } |
| @@ -104,9 +110,10 @@ func assembleLadder(channels []ChannelView, routes []RouteView) []LadderRung { |
| for _, rt := range routes { |
| rung, ok := release.Rung(rt.Channel) |
| byChannel[rt.Channel] = append(byChannel[rt.Channel], LadderRoute{ |
| - Host: rt.Host, |
| - Canary: ok && rung == release.RungCanary, |
| - ProdWired: prod[rt.Host], |
| + Host: rt.Host, |
| + Environment: rt.Environment, |
| + Canary: ok && rung == release.RungCanary, |
| + ProdWired: prod[rt.Host], |
| }) |
| } |
| entries := map[string][]LadderChannel{} |
| @@ -174,51 +181,39 @@ func stepUp(from, to string, chans []LadderChannel) *LadderStep { |
| return s |
| } |
| -// prodWiredCanaryHosts is THE INFERENCE, and it is alone in this file on |
| -// purpose. |
| -// |
| -// Spec §5 wants a canary wired to a production environment flagged |
| -// conspicuously, because an unreviewed build serving everybody must not be |
| -// discoverable only by reading the registry. But environments (spec §4) do |
| -// not exist yet — they are a subsystem with their own plan — so there is |
| -// nothing on a route that says "production" today. |
| +// prodWiredCanaryHosts is spec §4a's honest definition of "prod-wired", |
| +// replacing the apex-host guess this function used before named |
| +// environments existed (2026-08-02-pipelines-design.md §4a, last |
| +// paragraph): a canary/* route is prod-wired when it binds the SAME |
| +// ENVIRONMENT as the app's stable route — an unbound route binds "" (the |
| +// default bundle), so two unbound routes count as matching, exactly as the |
| +// spec says "or both bind default". |
| // |
| -// Until there is, production is inferred from the route being the app's |
| -// PRIMARY host: the apex it is reached at, picked as fewest labels, then |
| -// shortest, then alphabetical. A canary on `keymail.dev` is in front of |
| -// everybody; a canary on `rc.keymail.dev` is a side door somebody opened on |
| -// purpose. It is a heuristic and it is allowed to be wrong in both directions |
| -// — it flags, it never restricts. |
| +// An app can have more than one stable-channel route (e.g. two hosts both |
| +// following `stable`); this treats stable's set of bound environments as |
| +// "production", so a canary matching ANY of them is flagged. An app with no |
| +// stable route at all has no production environment to be wired in front |
| +// of, so nothing is flagged — that is the correct answer, not a gap. |
| // |
| -// WHEN ENVIRONMENTS LAND: delete the heuristic and answer from the route's |
| -// environment instead. This function is the whole of what has to change; its |
| -// callers only ever ask "is this host a prod-wired canary". |
| +// It flags, it never restricts: callers only ever ask "is this host a |
| +// prod-wired canary". |
| func prodWiredCanaryHosts(routes []RouteView) map[string]bool { |
| - primary := "" |
| - better := func(a, b string) bool { |
| - la, lb := strings.Count(a, "."), strings.Count(b, ".") |
| - if la != lb { |
| - return la < lb |
| - } |
| - if len(a) != len(b) { |
| - return len(a) < len(b) |
| - } |
| - return a < b |
| - } |
| + stableEnvs := map[string]bool{} |
| for _, rt := range routes { |
| - if rt.Host == "" { |
| - continue |
| - } |
| - if primary == "" || better(rt.Host, primary) { |
| - primary = rt.Host |
| + if rung, ok := release.Rung(rt.Channel); ok && rung == release.RungStable { |
| + stableEnvs[rt.Environment] = true |
| } |
| } |
| out := map[string]bool{} |
| + if len(stableEnvs) == 0 { |
| + return out |
| + } |
| for _, rt := range routes { |
| - if rt.Host != primary { |
| + rung, ok := release.Rung(rt.Channel) |
| + if !ok || rung != release.RungCanary { |
| continue |
| } |
| - if rung, ok := release.Rung(rt.Channel); ok && rung == release.RungCanary { |
| + if stableEnvs[rt.Environment] { |
| out[rt.Host] = true |
| } |
| } |
diff --git a/internal/console/ladder_test.go b/internal/console/ladder_test.go| index b9bac411a..165978ae7 100644 |
| --- a/internal/console/ladder_test.go |
| +++ b/internal/console/ladder_test.go |
| @@ -209,33 +209,116 @@ func TestLadderRefusesASkip(t *testing.T) { |
| } |
| } |
| -// A canary is badged; a canary on the app's primary host is badged |
| -// unmissably, because that is an unreviewed build serving everybody |
| -// (spec §5). Both badges are chrome, so both are icons, not glyphs. |
| +// A canary is badged; a canary that binds the SAME ENVIRONMENT as the app's |
| +// stable route is badged unmissably, because that is an unreviewed build |
| +// wired to production glue (spec §4a, §5) — whichever host it happens to be |
| +// on. A canary bound to a genuinely different environment is the ordinary, |
| +// unremarkable case. Both badges are chrome, so both are icons, not glyphs. |
| func TestProdWiredCanaryIsBadgedDistinctly(t *testing.T) { |
| fx := newConsoleFixture(t) |
| - // The app's apex host follows a canary channel — and a side host follows |
| - // the same one, which is the ordinary, unremarkable case. |
| - fx.addRoute(t, registry.Route{Host: "kass.carlosapp.dev", Kind: "instance", Status: "ready", |
| - Version: "v2", App: "kass", Channel: "canary/x", Addr: "127.0.0.1:9001"}) |
| + // The fixture's own kass.carlosapp.dev is already on stable, unbound |
| + // (Environment ""). A canary that is ALSO unbound binds the same |
| + // environment as stable ("default" both times) and is prod-wired; one |
| + // bound to a real, different environment is not — regardless of which |
| + // host either is on. |
| fx.addRoute(t, registry.Route{Host: "rc.kass.carlosapp.dev", Kind: "instance", Status: "ready", |
| Version: "v2", App: "kass", Channel: "canary/x", Addr: "127.0.0.1:9002"}) |
| + fx.addRoute(t, registry.Route{Host: "side.kass.carlosapp.dev", Kind: "instance", Status: "ready", |
| + Version: "v2", App: "kass", Channel: "canary/x", Environment: "sandbox", Addr: "127.0.0.1:9003"}) |
| c := signInPaul(t, fx) |
| _, _, body := get(t, c, fx.u("/apps/kass")) |
| block := rungBlock(t, body, "canary") |
| - apex := routeLine(t, block, "kass.carlosapp.dev") |
| - if !strings.Contains(apex, "chip-prod") { |
| - t.Errorf("the app's primary host on a canary channel is not flagged:\n%s", apex) |
| + sameEnv := routeLine(t, block, "rc.kass.carlosapp.dev") |
| + if !strings.Contains(sameEnv, "chip-prod") { |
| + t.Errorf("a canary binding the same (default) environment as stable is not flagged:\n%s", sameEnv) |
| } |
| - side := routeLine(t, block, "rc.kass.carlosapp.dev") |
| - if !strings.Contains(side, "chip-canary") { |
| - t.Errorf("a canary route carries no canary badge:\n%s", side) |
| + diffEnv := routeLine(t, block, "side.kass.carlosapp.dev") |
| + if !strings.Contains(diffEnv, "chip-canary") { |
| + t.Errorf("a canary route carries no canary badge:\n%s", diffEnv) |
| } |
| - if strings.Contains(side, "chip-prod") { |
| - t.Errorf("a side canary is flagged as production-wired:\n%s", side) |
| + if strings.Contains(diffEnv, "chip-prod") { |
| + t.Errorf("a canary bound to a DIFFERENT environment than stable is flagged as production-wired:\n%s", diffEnv) |
| } |
| - if !strings.Contains(apex, "<svg") || !strings.Contains(side, "<svg") { |
| + if !strings.Contains(sameEnv, "<svg") || !strings.Contains(diffEnv, "<svg") { |
| t.Error("the badges are text glyphs, not icons") |
| } |
| } |
| + |
| +// No stable route at all means no production environment to be wired in |
| +// front of — a canary is never flagged in that case, whatever it binds. |
| +func TestProdWiredCanaryNeedsAStableRouteToCompareAgainst(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + reg, err := registry.Open(fx.regPath) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + // Remove the fixture's two stable-channel kass routes so the app has no |
| + // stable route left to compare a canary against. |
| + if err := reg.Delete("kass.carlosapp.dev"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.Delete("docs.kass.carlosapp.dev"); err != nil { |
| + t.Fatal(err) |
| + } |
| + fx.addRoute(t, registry.Route{Host: "rc.kass.carlosapp.dev", Kind: "instance", Status: "ready", |
| + Version: "v2", App: "kass", Channel: "canary/x", Addr: "127.0.0.1:9004"}) |
| + |
| + c := signInPaul(t, fx) |
| + _, _, body := get(t, c, fx.u("/apps/kass")) |
| + block := rungBlock(t, body, "canary") |
| + line := routeLine(t, block, "rc.kass.carlosapp.dev") |
| + if strings.Contains(line, "chip-prod") { |
| + t.Errorf("a canary is flagged prod-wired on an app with no stable route:\n%s", line) |
| + } |
| +} |
| + |
| +// Every route on the page shows the environment it is bound to, beside the |
| +// channel it is listed under — "default" for an unbound route, the bundle |
| +// name for a bound one (spec §4a, plan Task 5). |
| +func TestLadderShowsEachRoutesEnvironment(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + fx.addRoute(t, registry.Route{Host: "eu.kass.carlosapp.dev", Kind: "instance", Status: "ready", |
| + Version: "v1", App: "kass", Channel: "stable", Environment: "eu-prod", Addr: "127.0.0.1:9011"}) |
| + |
| + c := signInPaul(t, fx) |
| + _, _, body := get(t, c, fx.u("/apps/kass")) |
| + block := rungBlock(t, body, "stable") |
| + |
| + unbound := routeLine(t, block, "kass.carlosapp.dev") |
| + if !strings.Contains(unbound, "chip-env") || !strings.Contains(unbound, "default") { |
| + t.Errorf("an unbound route does not show \"default\" as its environment:\n%s", unbound) |
| + } |
| + bound := routeLine(t, block, "eu.kass.carlosapp.dev") |
| + if !strings.Contains(bound, "chip-env") || !strings.Contains(bound, "eu-prod") { |
| + t.Errorf("a bound route does not show its environment name:\n%s", bound) |
| + } |
| +} |
| + |
| +// Visibility, not permission (spec §5): a member sees the same environment |
| +// and prod-wired chrome an owner does. Only the promote control is |
| +// owner-gated — see TestLadderPromoteControlIsOwnerOnly. |
| +func TestLadderEnvironmentAndProdWiredVisibleToNonOwner(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + fx.addRoute(t, registry.Route{Host: "rc.kass.carlosapp.dev", Kind: "instance", Status: "ready", |
| + Version: "v2", App: "kass", Channel: "canary/x", Addr: "127.0.0.1:9002"}) |
| + if err := fx.st.AddMember(OpsAccountID, "sam@keymail.dev", RoleMember, time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + fx.setUser("sam@keymail.dev") |
| + cm := newBrowser(t) |
| + signUp(t, fx, cm, "code-sam-envs") |
| + status, _, body := get(t, cm, fx.u("/apps/kass")) |
| + if status != http.StatusOK { |
| + t.Fatalf("member app page = %d, want 200", status) |
| + } |
| + canaryBlock := rungBlock(t, body, "canary") |
| + line := routeLine(t, canaryBlock, "rc.kass.carlosapp.dev") |
| + if !strings.Contains(line, "chip-prod") { |
| + t.Errorf("a member does not see the prod-wired badge:\n%s", line) |
| + } |
| + stableBlock := rungBlock(t, body, "stable") |
| + if !strings.Contains(stableBlock, "chip-env") { |
| + t.Errorf("a member does not see route environment chrome:\n%s", stableBlock) |
| + } |
| +} |
diff --git a/internal/console/templates/ladder.html b/internal/console/templates/ladder.html| index b7ade3e01..2f8da0342 100644 |
| --- a/internal/console/templates/ladder.html |
| +++ b/internal/console/templates/ladder.html |
| @@ -22,6 +22,7 @@ touching what any host is wired to.</p> |
| <ul class="routes"> |
| {{range .Routes}} |
| <li><a href="https://{{.Host}}">{{.Host}}</a> |
| + <span class="chip chip-env">{{icon "layers"}} {{if .Environment}}{{.Environment}}{{else}}default{{end}}</span> |
| {{if .ProdWired}}<span class="chip chip-prod">{{icon "triangle-alert"}} canary in front of everybody</span> |
| {{else if .Canary}}<span class="chip chip-canary">{{icon "flask-conical"}} canary</span>{{end}} |
| </li> |
diff --git a/internal/console/templates/layout.html b/internal/console/templates/layout.html| index e79152697..8d4185ec2 100644 |
| --- a/internal/console/templates/layout.html |
| +++ b/internal/console/templates/layout.html |
| @@ -146,6 +146,10 @@ |
| every user is the one thing on this page a reader must not skim past. */ |
| .chip-canary { border: 1px solid var(--accent); color: var(--accent); } |
| .chip-prod { background: var(--bad); color: var(--bg); } |
| + /* A route's bound environment — a fact, not a warning, so it is bordered |
| + like .chip-canary but in a neutral tone: it sits on every route, not |
| + just the ones worth a second look. */ |
| + .chip-env { border: 1px solid var(--rule); color: var(--muted); } |
| .chip .icon { vertical-align: -0.1em; margin-right: 0.2em; } |
| .chip-up { background: var(--accent-soft); color: #2f7a4f; } |
| .chip-down { background: var(--accent-soft); color: var(--bad); } |
diff --git a/internal/console/view.go b/internal/console/view.go| index 76fb54068..77e916929 100644 |
| --- a/internal/console/view.go |
| +++ b/internal/console/view.go |
| @@ -107,6 +107,11 @@ type CanaryView struct { |
| type RouteView struct { |
| Host, Kind, Status, Version, Channel string |
| Hibernate bool |
| + // Environment is the named config bundle this route is wired to — "" |
| + // means the app's default bundle alone (registry.Route's own |
| + // convention; see internal/appconfig.LoadMerged). Rendered as "default" |
| + // rather than left blank, in ladder.html. |
| + Environment string |
| } |
| // ChannelView is one row of the channels table. |
| @@ -199,6 +204,7 @@ func Assemble(regPath string, s release.Store, pub ed25519.PublicKey, acct strin |
| 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, |
| + Environment: rt.Environment, |
| }) |
| domainsByApp[rt.App] = append(domainsByApp[rt.App], domainView(rt, edgeIPs)) |
| } |