carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package 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
28import (
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.
38type 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.
52type 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.
74type 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.
103type 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.
120var 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.
143func 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.
210func 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.
240func 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.
293func 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.
317func 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.
330func 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.
381func 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.
421func 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