| 1 | package console |
| 2 | |
| 3 | // The app page's ladder section — the console half of the pipelines spec |
| 4 | // (§3 the ladder, §5 guard rails, §7 console surface). |
| 5 | // |
| 6 | // One screen that answers "what is on each rung, who put it there, and which |
| 7 | // hosts are serving it", plus the only promotions the ladder permits: the |
| 8 | // step from one rung to the next. It is assembled from what the page already |
| 9 | // reads — the app's channel pointers and its registry routes — so it can |
| 10 | // disagree with neither. |
| 11 | // |
| 12 | // One thing it deliberately is not: a second promote path. The controls post |
| 13 | // to the app's one promote endpoint, which enforces the ladder and the |
| 14 | // passkey gate for everybody, so a hand-written POST meets the same doors as |
| 15 | // a button. |
| 16 | // |
| 17 | // Every route also shows the environment it is wired to (spec §4a) beside |
| 18 | // the channel it is listed under — "default" for a route with no binding — |
| 19 | // and prodWiredCanaryHosts below answers "production" from that AND from |
| 20 | // which host is the app's apex: the environment says what glue a canary is |
| 21 | // wired to, the apex says whether it is in front of everybody. |
| 22 | // |
| 23 | // canaryExpiry answers a different question (spec §5 "Canaries expire"): |
| 24 | // not whether a canary is dangerous, but whether it has been sitting there |
| 25 | // so long it stopped being a canary and became infrastructure nobody |
| 26 | // remembers deploying. Visibility only — an expired canary keeps serving. |
| 27 | |
| 28 | import ( |
| 29 | "fmt" |
| 30 | "sort" |
| 31 | "strings" |
| 32 | "time" |
| 33 | |
| 34 | "github.com/carlosframework/platform/internal/release" |
| 35 | ) |
| 36 | |
| 37 | // LadderRung is one rung of the app's ladder, with everything sitting on it. |
| 38 | type LadderRung struct { |
| 39 | // Rung is the rung's name: canary, edge, beta or stable. |
| 40 | Rung string |
| 41 | // Gate is what it takes to get here, in one line (spec §3's gate table). |
| 42 | Gate string |
| 43 | // Channels are the channels belonging to this rung — one for edge, beta |
| 44 | // and stable; any number for canary, which is the rung with names. |
| 45 | Channels []LadderChannel |
| 46 | // Up describes the step to the next rung: the promote control, or the |
| 47 | // reason there is not one. nil on stable, which is the top. |
| 48 | Up *LadderStep |
| 49 | } |
| 50 | |
| 51 | // LadderChannel is one channel on a rung: what it holds and who is serving it. |
| 52 | type LadderChannel struct { |
| 53 | Channel, Version string |
| 54 | PromotedAt time.Time |
| 55 | // Actor is who promoted the version this channel holds, read from the |
| 56 | // pointer's signed audit trail. "" is the ordinary case for now, not an |
| 57 | // error: the trail is new and every pointer written before it has none. |
| 58 | Actor string |
| 59 | // Hotfix records that this promotion bypassed the ladder. |
| 60 | Hotfix bool |
| 61 | // FastTrack and BakedUntil carry the channel's waiver and its ~hold |
| 62 | // estimate into the rung row, where the fast-track control lives. |
| 63 | FastTrack bool |
| 64 | BakedUntil time.Time |
| 65 | // Err carries a pointer that would not read or verify, and a channel that |
| 66 | // hosts follow but that nothing has ever been published to. Both are one |
| 67 | // line of bad news on one rung, never a failed page. |
| 68 | Err string |
| 69 | // Routes are the hosts subscribed to this channel. |
| 70 | Routes []LadderRoute |
| 71 | } |
| 72 | |
| 73 | // LadderRoute is one host under the channel it follows. |
| 74 | type LadderRoute struct { |
| 75 | Host string |
| 76 | // Environment is the named config bundle this route is wired to — "" |
| 77 | // (rendered "default" in ladder.html) means the app's default bundle |
| 78 | // alone. Independent of the channel it is listed under (spec §4a). |
| 79 | Environment string |
| 80 | // Canary is whether this host follows a canary channel at all. |
| 81 | Canary bool |
| 82 | // ProdWired is the conspicuous case: a canary bound to the same |
| 83 | // environment as one of the app's stable routes, OR sitting on the app's |
| 84 | // apex host. See prodWiredCanaryHosts for why it is both. |
| 85 | ProdWired bool |
| 86 | // Expired is whether this route's canary/* channel has outlived |
| 87 | // release.CanaryTTL, measured from the pointer's own PromotedAt (spec |
| 88 | // §5 "Canaries expire"). Always false for a non-canary route. Visibility |
| 89 | // only: an expired canary keeps serving, keeps adopting and stays |
| 90 | // promotable — this badges it, nothing more. |
| 91 | Expired bool |
| 92 | // ExpiredFor is how long past the TTL the channel is, formatted for a |
| 93 | // human ("9d", "6h") — valid only when Expired is true. |
| 94 | ExpiredFor string |
| 95 | // Caps is the operator-granted capability tokens for this route, human |
| 96 | // legible and already formatted by RouteView (view.go's humanizeCaps); |
| 97 | // "" renders nothing (app-capabilities design §9's optional console |
| 98 | // render — read-only, no new route, no gate touched). |
| 99 | Caps string |
| 100 | } |
| 101 | |
| 102 | // LadderStep is the move from one rung to the one above it. |
| 103 | type LadderStep struct { |
| 104 | // To is the rung above. |
| 105 | To string |
| 106 | // Version is what the rung below currently holds — the version the |
| 107 | // control would promote. "" when the rung below holds nothing, or holds |
| 108 | // it on a pointer that will not read, and so there is nothing to offer. |
| 109 | Version string |
| 110 | // Note is shown instead of a control: why this step is not a promotion, |
| 111 | // or why it cannot be offered right now. |
| 112 | Note string |
| 113 | // StepUp warns that taking this step will ask for a passkey. Display |
| 114 | // only — the gate itself is server-side, in the promote handler. |
| 115 | StepUp bool |
| 116 | } |
| 117 | |
| 118 | // gates are spec §3's gate table, in the words a person reading the page |
| 119 | // needs rather than the words the table uses. |
| 120 | var gates = map[string]string{ |
| 121 | release.RungCanary: "No sign-off needed — a test version can be pointed at anything you choose, including production.", |
| 122 | release.RungEdge: "Builds land here when they merge to main — nothing is promoted onto edge.", |
| 123 | release.RungBeta: "Any account member can promote here; it's recorded.", |
| 124 | release.RungStable: "An account owner promotes here with their passkey, after the safety delay.", |
| 125 | } |
| 126 | |
| 127 | // assembleLadder folds an app's channels and routes into rungs — the four |
| 128 | // frozen ones for a legacy (pipeline-less) app, or the app's own declared |
| 129 | // channel order once it has opted into a pipeline (release pipelines v2 |
| 130 | // §2, §5). The two shapes are deliberately exclusive: pipeline == nil takes |
| 131 | // the legacy path below, BYTE-IDENTICALLY to before this pipeline-awareness |
| 132 | // existed — every existing legacy app renders exactly as it always has — |
| 133 | // and a non-nil pipeline hands off to assemblePipelineLadder entirely, |
| 134 | // which also owns the one-channel "hide the vocabulary" rule (spec §5): it |
| 135 | // returns nil for a single-channel pipeline, and the app's pages read that |
| 136 | // absence as "show no ladder card at all", not as "empty rungs". |
| 137 | // |
| 138 | // A channel outside the ladder's vocabulary (and any host following one) is |
| 139 | // not on the ladder and does not appear here — the Channels table below still |
| 140 | // lists it. A host following a rung channel that has no pointer DOES appear, |
| 141 | // as an error line: a host serving nothing is exactly the kind of thing this |
| 142 | // section exists to stop being invisible. |
| 143 | func assembleLadder(channels []ChannelView, routes []RouteView, pipeline *release.Pipeline, now time.Time) []LadderRung { |
| 144 | if pipeline != nil { |
| 145 | return assemblePipelineLadder(pipeline, channels, routes) |
| 146 | } |
| 147 | prod := prodWiredCanaryHosts(routes) |
| 148 | expiredFor := canaryExpiry(channels, now) |
| 149 | byChannel := map[string][]LadderRoute{} |
| 150 | for _, rt := range routes { |
| 151 | rung, ok := release.Rung(rt.Channel) |
| 152 | age := expiredFor[rt.Channel] |
| 153 | byChannel[rt.Channel] = append(byChannel[rt.Channel], LadderRoute{ |
| 154 | Host: rt.Host, |
| 155 | Environment: rt.Environment, |
| 156 | Canary: ok && rung == release.RungCanary, |
| 157 | ProdWired: prod[rt.Host], |
| 158 | Expired: age != "", |
| 159 | ExpiredFor: age, |
| 160 | Caps: rt.Caps, |
| 161 | }) |
| 162 | } |
| 163 | entries := map[string][]LadderChannel{} |
| 164 | for _, cv := range channels { |
| 165 | rung, ok := release.Rung(cv.Name) |
| 166 | if !ok { |
| 167 | continue |
| 168 | } |
| 169 | entries[rung] = append(entries[rung], LadderChannel{ |
| 170 | Channel: cv.Name, Version: cv.Version, PromotedAt: cv.PromotedAt, |
| 171 | Actor: cv.Actor, Hotfix: cv.Hotfix, Err: cv.Err, |
| 172 | FastTrack: cv.FastTrack, BakedUntil: cv.BakedUntil, |
| 173 | Routes: byChannel[cv.Name], |
| 174 | }) |
| 175 | delete(byChannel, cv.Name) |
| 176 | } |
| 177 | // Whatever is left is a host following a channel with no pointer behind |
| 178 | // it. Only rung channels are shown, for the reason in the doc comment. |
| 179 | for ch, rts := range byChannel { |
| 180 | rung, ok := release.Rung(ch) |
| 181 | if !ok { |
| 182 | continue |
| 183 | } |
| 184 | entries[rung] = append(entries[rung], LadderChannel{ |
| 185 | Channel: ch, Routes: rts, |
| 186 | Err: "nothing has been published here yet — these addresses are waiting for a version", |
| 187 | }) |
| 188 | } |
| 189 | out := make([]LadderRung, 0, 4) |
| 190 | ladder := []string{release.RungCanary, release.RungEdge, release.RungBeta, release.RungStable} |
| 191 | for i, rung := range ladder { |
| 192 | chans := entries[rung] |
| 193 | // Map iteration ordered nothing above; canary is the rung that can |
| 194 | // hold several, and a list that reshuffles per request is unreadable. |
| 195 | sort.Slice(chans, func(a, b int) bool { return chans[a].Channel < chans[b].Channel }) |
| 196 | lr := LadderRung{Rung: rung, Gate: gates[rung], Channels: chans} |
| 197 | if i+1 < len(ladder) { |
| 198 | lr.Up = stepUp(rung, ladder[i+1], chans) |
| 199 | } |
| 200 | out = append(out, lr) |
| 201 | } |
| 202 | return out |
| 203 | } |
| 204 | |
| 205 | // stepUp describes the move from one rung to the next: a control when the |
| 206 | // ladder permits one and there is something to promote, otherwise the reason. |
| 207 | // The rule it encodes is release.PromotionAllowed's, in the direction the page |
| 208 | // draws — nothing is promoted onto edge, and the two real steps are edge→beta |
| 209 | // and beta→stable. |
| 210 | func stepUp(from, to string, chans []LadderChannel) *LadderStep { |
| 211 | s := &LadderStep{To: to, StepUp: to == release.RungStable} |
| 212 | if to == release.RungEdge { |
| 213 | s.Note = "Nothing is promoted onto edge — merging to main is what puts a build there." |
| 214 | return s |
| 215 | } |
| 216 | // edge and beta hold exactly one channel, named for the rung. |
| 217 | for _, c := range chans { |
| 218 | if c.Err == "" && c.Version != "" { |
| 219 | s.Version = c.Version |
| 220 | break |
| 221 | } |
| 222 | } |
| 223 | if s.Version == "" { |
| 224 | s.Note = "Nothing on " + from + " to promote." |
| 225 | } |
| 226 | return s |
| 227 | } |
| 228 | |
| 229 | // assemblePipelineLadder is assembleLadder's pipeline-shaped half: one rung |
| 230 | // per declared channel, in the pipeline's own order, rather than the four |
| 231 | // frozen rungs. canary/* is deliberately absent here — it lives outside the |
| 232 | // pipeline (spec §2) and stays visible through the Canaries table the app |
| 233 | // page already draws unconditionally, independent of Ladder. |
| 234 | // |
| 235 | // A single-channel pipeline returns nil: spec §5's "hide the vocabulary |
| 236 | // entirely" rule. The app's pages read a nil (or one-channel) pipeline's |
| 237 | // AppView.OneChannelPipeline() and skip the ladder card, the promote form |
| 238 | // and the channel table outright — this function simply never hands them |
| 239 | // anything to draw in that case. |
| 240 | func assemblePipelineLadder(p *release.Pipeline, channels []ChannelView, routes []RouteView) []LadderRung { |
| 241 | if len(p.Channels) < 2 { |
| 242 | return nil |
| 243 | } |
| 244 | byChannel := map[string]ChannelView{} |
| 245 | for _, cv := range channels { |
| 246 | byChannel[cv.Name] = cv |
| 247 | } |
| 248 | prod := prodWiredCanaryHosts(routes) |
| 249 | byRoute := map[string][]LadderRoute{} |
| 250 | for _, rt := range routes { |
| 251 | byRoute[rt.Channel] = append(byRoute[rt.Channel], LadderRoute{ |
| 252 | Host: rt.Host, Environment: rt.Environment, |
| 253 | ProdWired: prod[rt.Host], Caps: rt.Caps, |
| 254 | }) |
| 255 | } |
| 256 | out := make([]LadderRung, 0, len(p.Channels)) |
| 257 | for i, c := range p.Channels { |
| 258 | var chans []LadderChannel |
| 259 | if cv, ok := byChannel[c.Name]; ok { |
| 260 | // BakedUntil is copied verbatim from cv, exactly as the legacy |
| 261 | // branch above does — it is NOT recomputed here. The caller |
| 262 | // (applyPipelineView, server.go) is the one place a pipelined |
| 263 | // app's Channels get their BakedUntil corrected to THIS |
| 264 | // channel's own window (holdWindow) in place of the legacy |
| 265 | // per-app table, precisely so this card and the Channels table |
| 266 | // reading the same Channels slice can never disagree (Task 9 |
| 267 | // review) — recomputing it again here would be a second |
| 268 | // implementation of the same answer, free to drift from the |
| 269 | // first. |
| 270 | chans = []LadderChannel{{ |
| 271 | Channel: cv.Name, Version: cv.Version, PromotedAt: cv.PromotedAt, |
| 272 | Actor: cv.Actor, Hotfix: cv.Hotfix, Err: cv.Err, |
| 273 | FastTrack: cv.FastTrack, BakedUntil: cv.BakedUntil, Routes: byRoute[c.Name], |
| 274 | }} |
| 275 | } else if rts := byRoute[c.Name]; len(rts) > 0 { |
| 276 | chans = []LadderChannel{{Channel: c.Name, Routes: rts, |
| 277 | Err: "nothing has been published here yet — these addresses are waiting for a version"}} |
| 278 | } |
| 279 | rung := LadderRung{Rung: c.Name, Gate: gateSentence(c.Rules), Channels: chans} |
| 280 | if i+1 < len(p.Channels) { |
| 281 | rung.Up = pipelineStepUp(c, p.Channels[i+1], chans) |
| 282 | } |
| 283 | out = append(out, rung) |
| 284 | } |
| 285 | return out |
| 286 | } |
| 287 | |
| 288 | // gateSentence is gates' pipelined analogue: what THIS channel's own rules |
| 289 | // demand, in the same one-line register the frozen map speaks (spec §5's |
| 290 | // UX list, in the order it gives them). The zero value — no rule at all — |
| 291 | // is "Anyone on the team promotes here.", spec §2's default-permissive |
| 292 | // posture made legible rather than left blank. |
| 293 | func gateSentence(rules release.ChannelRules) string { |
| 294 | var parts []string |
| 295 | if rules.Passkey { |
| 296 | parts = append(parts, "Needs a passkey.") |
| 297 | } |
| 298 | if n := rules.PromoteApprovals; n > 0 { |
| 299 | member := "members" |
| 300 | if n == 1 { |
| 301 | member = "member" |
| 302 | } |
| 303 | parts = append(parts, fmt.Sprintf("Needs %d %s to confirm.", n, member)) |
| 304 | } |
| 305 | if len(parts) == 0 { |
| 306 | return "Anyone on the team promotes here." |
| 307 | } |
| 308 | return strings.Join(parts, " ") |
| 309 | } |
| 310 | |
| 311 | // pipelineStepUp is stepUp's pipelined analogue: the move from one declared |
| 312 | // channel to the next one in the list, rather than to a frozen rung name. |
| 313 | // There is no "nothing is promoted onto edge" special case here — a |
| 314 | // pipeline's entry channel simply has no Up step at all (assemblePipelineLadder |
| 315 | // only builds one for i+1 < len(Channels)), so every Up this function builds |
| 316 | // describes a genuine promotion. |
| 317 | func pipelineStepUp(from, to release.PipelineChannel, curChans []LadderChannel) *LadderStep { |
| 318 | s := &LadderStep{To: to.Name, StepUp: to.Rules.Passkey} |
| 319 | if len(curChans) == 1 && curChans[0].Err == "" && curChans[0].Version != "" { |
| 320 | s.Version = curChans[0].Version |
| 321 | return s |
| 322 | } |
| 323 | s.Note = "Nothing on " + from.Name + " to promote." |
| 324 | return s |
| 325 | } |
| 326 | |
| 327 | // apexHost picks the app's primary host — fewest labels, then shortest, |
| 328 | // then alphabetical — the same "which host is the apex" answer |
| 329 | // prodWiredCanaryHosts uses. "" when there are no named routes. |
| 330 | func apexHost(routes []RouteView) string { |
| 331 | apex := "" |
| 332 | better := func(a, b string) bool { |
| 333 | la, lb := strings.Count(a, "."), strings.Count(b, ".") |
| 334 | if la != lb { |
| 335 | return la < lb |
| 336 | } |
| 337 | if len(a) != len(b) { |
| 338 | return len(a) < len(b) |
| 339 | } |
| 340 | return a < b |
| 341 | } |
| 342 | for _, rt := range routes { |
| 343 | if rt.Host == "" { |
| 344 | continue |
| 345 | } |
| 346 | if apex == "" || better(rt.Host, apex) { |
| 347 | apex = rt.Host |
| 348 | } |
| 349 | } |
| 350 | return apex |
| 351 | } |
| 352 | |
| 353 | // prodWiredCanaryHosts is spec §4a's honest definition of "prod-wired", |
| 354 | // replacing the apex-host guess this function used before named |
| 355 | // environments existed (2026-08-02-pipelines-design.md §4a, last |
| 356 | // paragraph): a canary/* route is prod-wired when it binds the SAME |
| 357 | // ENVIRONMENT as the app's stable route — an unbound route binds "" (the |
| 358 | // default bundle), so two unbound routes count as matching, exactly as the |
| 359 | // spec says "or both bind default". |
| 360 | // |
| 361 | // An app can have more than one stable-channel route (e.g. two hosts both |
| 362 | // following `stable`); this treats stable's set of bound environments as |
| 363 | // "production", so a canary matching ANY of them is flagged. An app with no |
| 364 | // stable route at all has no production environment to be wired in front of, |
| 365 | // so that signal flags nothing — the correct answer, not a gap. |
| 366 | // |
| 367 | // The apex-host heuristic this replaced is KEPT as a second signal, OR'd in |
| 368 | // (2026-08-02 review, IMPORTANT 5). The two answer different halves of §5's |
| 369 | // worry and neither is a superset of the other: the environment set says |
| 370 | // "wired to production GLUE", the apex host says "in front of production |
| 371 | // TRAFFIC". A canary on the app's apex bound to `sandbox` serves everybody |
| 372 | // and binds nothing production — the environment rule alone would say |
| 373 | // nothing about it, which is a warning the console used to give and would |
| 374 | // have silently stopped giving. Production is the app's PRIMARY host: fewest |
| 375 | // labels, then shortest, then alphabetical. |
| 376 | // |
| 377 | // It flags, it never restricts: callers only ever ask "is this host a |
| 378 | // prod-wired canary", and both signals are allowed to be wrong in the |
| 379 | // flagging direction. More flags, never fewer, is the conservative side of |
| 380 | // this particular trade. |
| 381 | func prodWiredCanaryHosts(routes []RouteView) map[string]bool { |
| 382 | stableEnvs := map[string]bool{} |
| 383 | for _, rt := range routes { |
| 384 | if rung, ok := release.Rung(rt.Channel); ok && rung == release.RungStable { |
| 385 | stableEnvs[rt.Environment] = true |
| 386 | } |
| 387 | } |
| 388 | apex := apexHost(routes) |
| 389 | out := map[string]bool{} |
| 390 | for _, rt := range routes { |
| 391 | rung, ok := release.Rung(rt.Channel) |
| 392 | if !ok || rung != release.RungCanary { |
| 393 | continue |
| 394 | } |
| 395 | if stableEnvs[rt.Environment] || rt.Host == apex { |
| 396 | out[rt.Host] = true |
| 397 | } |
| 398 | } |
| 399 | return out |
| 400 | } |
| 401 | |
| 402 | // canaryExpiry maps each canary/* channel with a readable pointer to how |
| 403 | // long past release.CanaryTTL it is, formatted for a human — spec §5 |
| 404 | // "Canaries expire". A channel that has not (yet) expired, or whose pointer |
| 405 | // carries an Err (assembleChannels degraded it to one bad row already), is |
| 406 | // simply absent from the map, which is exactly what LadderRoute.Expired |
| 407 | // reads as false. |
| 408 | // |
| 409 | // Built once per ladder assembly, keyed by channel name rather than host, |
| 410 | // because expiry is a property of the CHANNEL's PromotedAt — every route |
| 411 | // following the same canary shares one answer, computed once rather than |
| 412 | // re-derived per host. |
| 413 | // |
| 414 | // Formatting goes through release.CanaryAge + release.FormatCanaryAge — |
| 415 | // the SAME pair the adopt pass's nag calls (internal/release/adopt.go) — |
| 416 | // rather than computing a duration here and rendering it locally. That used |
| 417 | // to be two independent implementations that quietly disagreed by exactly |
| 418 | // CanaryTTL (2026-08-03 review, IMPORTANT 2): sharing the formatter is what |
| 419 | // makes "the nag and the badge cannot drift apart" a structural fact |
| 420 | // instead of a coincidence two people have to keep re-verifying. |
| 421 | func canaryExpiry(channels []ChannelView, now time.Time) map[string]string { |
| 422 | out := map[string]string{} |
| 423 | for _, cv := range channels { |
| 424 | if cv.Err != "" { |
| 425 | continue |
| 426 | } |
| 427 | // A synthetic pointer carrying only the two fields release.CanaryExpired |
| 428 | // reads: the channel's name (so it recognizes canary/*) and the |
| 429 | // promotion timestamp ChannelView already carries. No pointer re-read — |
| 430 | // expiry is computed from data the console already has in hand. |
| 431 | ptr := &release.ChannelPointer{Channel: cv.Name, PromotedAt: cv.PromotedAt} |
| 432 | if !release.CanaryExpired(ptr, now) { |
| 433 | continue |
| 434 | } |
| 435 | out[cv.Name] = release.FormatCanaryAge(release.CanaryAge(ptr, now)) |
| 436 | } |
| 437 | return out |
| 438 | } |
| 439 | |