carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1// The derived read-model: what a signed-in owner sees. Assembled fresh per
2// page from the platform's two sources of truth — the host registry (routes)
3// and the deployment bucket (signed channel pointers) — and never written
4// back: slice 1 of the console is read-only against platform state.
5package console
6
7import (
8 "context"
9 "crypto/ed25519"
10 "encoding/json"
11 "errors"
12 "log"
13 "os"
14 "sort"
15 "strconv"
16 "strings"
17 "sync"
18 "time"
19
20 "golang.org/x/net/publicsuffix"
21
22 "github.com/carlosframework/platform/internal/registry"
23 "github.com/carlosframework/platform/internal/release"
24 "github.com/carlosframework/platform/internal/status"
25)
26
27// AppView is everything the console shows about one app.
28type AppView struct {
29 App string
30 Routes []RouteView
31 Domains []DomainView
32 Channels []ChannelView
33 Canaries []CanaryView
34 // Ladder is the four rungs with what sits on each (ladder.go) — the same
35 // channels and routes as the fields above, arranged as the pipeline they
36 // actually are rather than as two alphabetical lists.
37 Ladder []LadderRung
38 // Production is the app's production-flag state (release.ProductionFlag)
39 // as the console shows it — see ProductionView.
40 Production ProductionView
41 // RouteAccount is the account the app's ROUTES say it belongs to, when
42 // that disagrees with the account whose page this is; "" when they agree
43 // or there are no routes. It is the first mismatched account (kept for
44 // any caller other than the banner — see AccountMismatch) — the banner
45 // itself reads Mismatched/AllMismatched below.
46 RouteAccount string
47 // Mismatched is every non-platform route stamped to another account;
48 // AllMismatched is whether that is ALL of them — only then is "this app
49 // is not deploying" a claim the page can back.
50 Mismatched []MismatchedRoute
51 AllMismatched bool
52 // Pipeline is the app's declared release pipeline (release pipelines v2
53 // §2) — nil for a legacy app that has never opted in, or when the read
54 // failed (best-effort for a VIEW: a page that cannot read the object
55 // degrades to the legacy ladder rather than failing outright; a WRITE
56 // door such as handlePromote reads its own copy and refuses on error
57 // instead, which is the place that distinction is load-bearing).
58 //
59 // Deliberately NOT populated by Assemble (view.go's own tagByRelease
60 // precedent: VersionView.Tag is never read there either — every caller
61 // that wants it joins separately). Assemble builds a whole account's app
62 // list for the account home page, which never renders Ladder or
63 // Pipeline at all; a pipeline read per app there would be a bucket GET
64 // nobody looks at. appPage (server.go) is the one place every APP page
65 // funnels through, so it is the one place that reads this, and rebuilds
66 // Ladder — and corrects each ChannelView's BakedUntil, which the legacy
67 // per-app table below computes and which means nothing for a pipelined
68 // channel's own window (Task 9 review) — once it has one.
69 Pipeline *release.Pipeline
70}
71
72// OneChannelPipeline reports whether this app has opted into a pipeline with
73// exactly one channel — spec §5's "hide the vocabulary entirely" case: no
74// ladder card, no promote form, no channel table on the app's pages at all,
75// only the releases list and the live marker.
76func (v AppView) OneChannelPipeline() bool {
77 return v.Pipeline != nil && len(v.Pipeline.Channels) == 1
78}
79
80// RollbackChannels is the app's OWN declared channels that have somewhere to
81// roll back to — for the one-channel page, which hides the channel table the
82// rollback control normally rides in but must not hide the control itself: a
83// rollback is a pointer write, not channel vocabulary, and a one-channel app
84// is precisely the app with no promotion step to fall back on when a bad
85// build lands (spec §5 hides the VOCABULARY, not the actions).
86//
87// Declared channels only: canary/* is retired, never rolled back (its control
88// is the canary table's Retire button), and a channel the pipeline does not
89// name is not this app's to offer here. A row that failed to read, or has no
90// history, is left out — there is nothing to go back to.
91func (v AppView) RollbackChannels() []ChannelView {
92 if v.Pipeline == nil {
93 return nil
94 }
95 declared := make(map[string]bool, len(v.Pipeline.Channels))
96 for _, c := range v.Pipeline.Channels {
97 declared[c.Name] = true
98 }
99 var out []ChannelView
100 for _, ch := range v.Channels {
101 if ch.Err != "" || !ch.HasHistory || !declared[ch.Name] {
102 continue
103 }
104 out = append(out, ch)
105 }
106 return out
107}
108
109// MismatchedRoute is a viewer-safe account-review candidate assembled by the
110// server after authorization. Assemble itself exposes owned routes only.
111type MismatchedRoute struct{ Host, Account string }
112
113// AccountMismatch reports whether the server found account-review candidates.
114func (v AppView) AccountMismatch() bool { return len(v.Mismatched) > 0 }
115
116// DomainView is one row of the app page's Domains table — the product
117// surface for what routes always were. Unknowns stay empty strings here and
118// render as the em-dash convention; Guidance is non-empty ONLY for a custom
119// domain that isn't pointing at this deployment yet.
120type DomainView struct {
121 Domain, Type, DNS, TLS string
122 Guidance string
123 // State, Reason and Detail are the derived lifecycle words (#299 §2a) —
124 // the SAME three domainState() gives `carlos domains list` and the API
125 // doors, from the same fleet-wide readings, so the console page and the
126 // CLI can never tell a member two stories about one hostname. That was
127 // the whole complaint #299 opened with.
128 //
129 // Empty on a row that has not been through settingsDomains: the shared
130 // view builder (domainView, below) folds one box's own registry copy and
131 // has no fleet fold to derive a state from. Readers must treat "" as
132 // "this build did not derive one here", never as a verdict.
133 State, Reason, Detail string
134 // Claimed is Settings-only (settingsDomains, appsettings.go): true when
135 // this row corresponds to a domain claim this app holds — routed or not
136 // — which is what the Settings page uses to decide whether a detach
137 // form belongs beside the row. A plain domainView row (no claim looked
138 // up) always carries the zero value.
139 Claimed bool
140}
141
142// Status is the one word the Domains table shows: the derived lifecycle
143// state where there is one, and otherwise the raw DNS word this box
144// stamped. Never both — a Status column and a DNS column side by side would
145// be two answers to one question, which is what #299 was opened about.
146func (d DomainView) Status() string {
147 if d.State != "" {
148 return d.State
149 }
150 return d.DNS
151}
152
153// Explain is the sentence under a Domains row, or "" for no sentence.
154//
155// A healthy row gets none: "live" beside a current renewal date says
156// everything, and a paragraph repeating it under every working domain is
157// noise that trains people to skip the paragraph under the broken one.
158// Everything else gets the state's own detail — the same sentence the CLI
159// prints, verbatim — followed by the DNS instructions when there are any.
160//
161// A row with no state at all keeps exactly today's behaviour: guidance
162// alone. That is the platform-domain rows and anything the shared view
163// builder produced without a fleet fold.
164func (d DomainView) Explain() string {
165 if d.State == "" {
166 return d.Guidance
167 }
168 if d.State == StateLive || d.Detail == "" {
169 return ""
170 }
171 if d.Guidance == "" {
172 return d.Detail
173 }
174 return d.Detail + " " + d.Guidance
175}
176
177// DNSTargets is where this deployment tells a member to point a custom
178// domain. Two facts, threaded together because the guidance is one
179// sentence and needs both: the edge addresses an A record names, and —
180// only on a deployment that STEERS (multi-region ingress spec, Part 4) —
181// the steering hostname a non-apex host may CNAME to instead.
182type DNSTargets struct {
183 // EdgeIPs is PLATFORM_EDGE_IPS unioned with the deployment's live fold
184 // of every box's edges file (Server.edgeIPs). nil is legal: guidance
185 // then says "this deployment's edge IP" rather than inventing an
186 // address.
187 //
188 // Once a deployment steers, this list must name EVERY armed edge's EIP,
189 // not just the local box's: the DNS check that stamps "pointing"
190 // compares a resolved address against it, and a steered name resolves
191 // to whichever edge the resolver is nearest. The fold is what makes
192 // that true without the operator re-setting an env var on every box
193 // each time a region arms — and it is the SAME fold the domains
194 // reconcile pass judges "pointing" with, so what a member is told to do
195 // and what the platform then grades them on cannot drift apart.
196 EdgeIPs []string
197 // SteeringHost is the deployment's steering hostname (edge.<apps-domain>,
198 // itself latency-routed with one record per armed edge). "" — every
199 // deployment that does not set CARLOS_STEERING_ZONE_ID, which is all of
200 // them today — keeps the guidance byte-for-byte what it has always been.
201 SteeringHost string
202}
203
204// domainGuidance is the point-your-DNS-here instruction a custom host that
205// is not pointing at this deployment yet gets shown — shared by domainView
206// (a routed custom host) and claimDomainView (a claim with no route yet), so
207// the two rows never drift into different wording for the same instruction.
208//
209// With steering armed the CNAME is the RECOMMENDED form for a subdomain:
210// it inherits the steering hostname's latency records and its health
211// checks, so visitors reach the nearest healthy edge and a dead edge stops
212// being handed out within ~90s. An apex cannot carry a CNAME at all (the
213// spec's honesty rule, Part 4: "apexes cannot CNAME, so apex guidance
214// remains A-records-to-all-EIPs — round-robin without latency bias,
215// documented as such in attach guidance"), so an apex is told the truth
216// about what its A records do rather than being offered an impossible
217// option. Apex-ness is decided by the public suffix list, not by counting
218// labels: "example.co.uk" is an apex and "app.example.com" is not, and both
219// have three.
220func domainGuidance(host string, t DNSTargets) string {
221 target := "this deployment's edge IP"
222 if len(t.EdgeIPs) > 0 {
223 target = strings.Join(t.EdgeIPs, ", ")
224 }
225 aRecord := "Point an A record for " + host + " at " + target + "."
226 switch {
227 case t.SteeringHost == "":
228 // No steering: exactly the sentence this deployment has always shown.
229 case isApexDomain(host):
230 aRecord += " " + host + " is an apex domain and cannot carry a CNAME, so its A records round-robin across the edges without latency bias."
231 default:
232 aRecord = "Point a CNAME for " + host + " at " + t.SteeringHost +
233 " — visitors then reach the nearest healthy edge, and a failed edge is withdrawn automatically." +
234 " An A record at " + target + " works too, but round-robins without latency bias."
235 }
236 return aRecord + " Checks run every few minutes."
237}
238
239// isApexDomain reports whether host is the registrable domain itself — the
240// one place a CNAME is illegal (RFC 1034: a CNAME cannot coexist with the
241// SOA and NS records an apex must carry). golang.org/x/net/publicsuffix is
242// already in this module's dependency set (the edge's IDNA path pulls
243// x/net), and the PSL is the only correct answer here: label counting calls
244// "example.co.uk" a subdomain and would hand an apex a CNAME it cannot use.
245//
246// The error case — EffectiveTLDPlusOne returns one when the name IS a public
247// suffix ("com", "co.uk") or is empty — is treated as an APEX, which keeps
248// guidance on the A-record form for input no member should be attaching
249// anyway. Note what this does NOT do: an unlisted TLD is not an error case.
250// publicsuffix falls back to the implicit "*" rule, so "example.internal"
251// comes back as its own eTLD+1 and reads as an apex, while
252// "app.example.internal" reads as a subdomain and is offered the CNAME —
253// which is the right answer for both, and not extra conservatism.
254func isApexDomain(host string) bool {
255 h := strings.ToLower(strings.TrimSuffix(strings.TrimSpace(host), "."))
256 etld1, err := publicsuffix.EffectiveTLDPlusOne(h)
257 if err != nil {
258 return true
259 }
260 return etld1 == h
261}
262
263// verifyGuidance is the prove-you-control-it instruction for a pending claim
264// (#215) — the sentence form of its TXT record, shared by the domains door
265// and the Settings page so the two never word it differently.
266func verifyGuidance(v dnsRecord) string {
267 return "Add a TXT record at " + v.Name + " with the value " + v.Value +
268 " to prove you control this hostname, then attach it again or run carlos domains verify."
269}
270
271// claimDomainView folds a domain claim that has no colocated registry row
272// yet into its own Domains placeholder row — an alias claim (which never
273// gets a route row; see domainattach.go) or a first-route claim before the
274// provisioning reconciler has materialized its route.
275func claimDomainView(host string, t DNSTargets) DomainView {
276 return DomainView{Domain: host, Type: "custom · attaching", Claimed: true, Guidance: domainGuidance(host, t)}
277}
278
279// domainView folds one registry route into its Domains row. dns carries the
280// deployment's edge addresses and steering hostname (see DNSTargets).
281func domainView(rt registry.Route, dns DNSTargets) DomainView {
282 typ := "custom"
283 if rt.Platform {
284 typ = "platform"
285 }
286 // Canary labeling is preserved: a canary follower's row says so, right
287 // in its type cell, whichever type it is.
288 if strings.HasPrefix(rt.Channel, "canary/") {
289 typ += " · " + rt.Channel
290 }
291 // TLS: the route's status is the issuance signal (issuing/ready/failed);
292 // a known cert expiry adds the renew-by date.
293 tls := rt.Status
294 if tls == "" {
295 tls = "ready" // registry contract: empty status means ready
296 }
297 if rt.CertExpiresAt > 0 {
298 tls += " · renews " + time.Unix(rt.CertExpiresAt, 0).UTC().Format("2006-01-02")
299 }
300 dv := DomainView{Domain: rt.Host, Type: typ, DNS: rt.DNSStatus, TLS: tls}
301 // Guidance only for custom + non-pointing: platform domains are ours to
302 // fix, and a pointing custom domain needs no pointing instructions.
303 // "unresolved" is excluded too — that means OUR lookup failed, not that
304 // the member's record is missing or wrong, so telling them to (re)point
305 // it would blame them for our outage.
306 if !rt.Platform && rt.DNSStatus != "pointing" && rt.DNSStatus != "unresolved" {
307 dv.Guidance = domainGuidance(rt.Host, dns)
308 }
309 return dv
310}
311
312// CanaryView is one canary on the app page: a canary/<name> channel plus
313// the hosts (routes) currently following it. A canary with no hosts is a
314// pointer waiting for a route — real, serving nothing yet.
315type CanaryView struct {
316 Name string // the short name: "rc" for channel canary/rc
317 Channel string // the full channel name
318 Version string
319 PromotedAt time.Time
320 Hosts []string
321 // Err mirrors ChannelView.Err: an unreadable pointer is one bad row.
322 Err string
323}
324
325// RouteView is one row of the routes table.
326type RouteView struct {
327 Host, Kind, Status, Version, Channel string
328 Hibernate bool
329 Platform bool
330 Restartable bool
331 // Account is what the ROUTE says its account is (release.RouteAccount:
332 // the stamped account, or the ops account when unstamped). Routes are
333 // grouped by app NAME here, and app names are unique per account rather
334 // than globally (Store.AppAccounts) — so a view's Routes can legitimately
335 // contain another account's routes, and anything drawing a tenant
336 // boundary off this list must compare this field against the account
337 // being viewed. See handleApp's owner-metrics allowlist.
338 Account string
339 // Environment is the named config bundle this route is wired to — ""
340 // means the app's default bundle alone (registry.Route's own
341 // convention; see internal/appconfig.LoadMerged). Rendered as "default"
342 // rather than left blank, in ladder.html.
343 Environment string
344 // Caps is the operator-granted capability tokens for this route on this
345 // box (registry.Route.Caps: "" = nothing granted), rendered human-legible
346 // ("host-role, port:25" rather than the storage column's "host-role,port:25").
347 // Read-only: grants are written by `carlos route -grant/-revoke` on the
348 // box, never from the console (spec §7 "nothing here touches the
349 // console's gates" — this adds no route, no handler, no write path).
350 //
351 // Blank on any route whose Account is not the account being viewed: see
352 // Assemble's byApp comment. Routes are grouped by app NAME, so this slice
353 // can hold another tenant's rows, and a grant is the one field here that
354 // names privileged ports and box authority.
355 Caps string
356 // Unit is the bespoke systemd unit bound to this route
357 // (registry.Route.Unit, #110): set when an operator-run service owns the
358 // route's process. The sweep samples stamped units (Task 5), so a stamped
359 // route's silence is meaningful — see expectsReadings.
360 Unit string
361 // Backing mirrors registry.Route.Backing: "" = an exec child the
362 // activator spawns, registry.BackingUnit = a carlos-app@<host> unit.
363 Backing string
364 // Provisioned mirrors registry.Route.Provisioned: minted from a bucket
365 // instance record, so the activator runs its process by construction.
366 Provisioned bool
367}
368
369// humanizeCaps renders a registry.Route.Caps column for a person: the
370// storage shape is comma-separated with no spaces (caps.Grants.String()'s
371// canonical form, chosen so a re-grant of the same set writes the same
372// bytes); a reader gets "host-role, port:25" instead.
373func humanizeCaps(caps string) string {
374 if caps == "" {
375 return ""
376 }
377 return strings.Join(strings.Split(caps, ","), ", ")
378}
379
380// ChannelView is one row of the channels table.
381type ChannelView struct {
382 Name, Version string
383 PromotedAt time.Time
384 // BakedUntil is zero when the channel is live. Otherwise it is
385 // PromotedAt + the channel's DEFAULT hold (release.DefaultHolds) — an
386 // APPROXIMATION the UI labels "bakes until ~": the authoritative bake
387 // clock is each box's own first-seen record (release/bake.go), which the
388 // console cannot see, and per-deployment hold overrides are not read here.
389 BakedUntil time.Time
390 // HasHistory is whether the pointer records previous versions — i.e.
391 // whether a rollback has somewhere to go.
392 HasHistory bool
393 // Actor is who promoted the version this channel now holds, and Hotfix
394 // whether they bypassed the ladder to do it — both read from the
395 // pointer's own signed audit trail. Actor is "" for every pointer written
396 // before the trail existed, which is most of the live fleet: that is
397 // missing information, not an error, and the page says so.
398 Actor string
399 Hotfix bool
400 // FastTrack is the pointer's bake waiver (release.ChannelPointer) — the
401 // badge is permanent, like the hotfix badge.
402 FastTrack bool
403 // Tag is the semver tag pointing at this channel's current release, if
404 // any (release versioning spec, 2026-08-08). assembleChannels — the one
405 // builder every app-page tab AND the account home page's app list share
406 // — deliberately leaves this "": a full ListTags sweep is one List plus
407 // one GetTag per tag, and every render except the releases tab throws
408 // that read away unread. handleAppReleases fills it in, per row, after
409 // Assemble returns, from the one tag read that render already needs for
410 // its own Tags section. "" for a release nobody tagged, for every
411 // channel below stable (no tag ever names one), and on every OTHER tab.
412 Tag string
413 // Err degrades a missing or invalid channel pointer to one row's error
414 // text instead of a failed page.
415 Err string
416}
417
418// ProductionView is the app's production-flag state as the console shows it
419// (2026-08-04 console bake controls spec §7).
420type ProductionView struct {
421 // On is the flag as the bucket has it; the default (no object) is off.
422 On bool
423 // TurningOff marks an off-flag younger than the stable window while the
424 // app still has held channels: hosts may still be ratcheting.
425 // EffectiveAt is the ~estimate shown (set_at + the stable hold at the
426 // MAXIMUM jitter, 1.25x) — each box's own clock and jitter govern,
427 // exactly as BakedUntil is only ever an approximation, and this one errs
428 // late on purpose (see assembleProduction).
429 TurningOff bool
430 EffectiveAt time.Time
431 SetAt time.Time
432 // Nudge: the app looks production-wired while bake is off. Detection
433 // recommends; it never flips the flag.
434 Nudge bool
435 // Err is a flag object that exists but will not verify — shown, and
436 // boxes treat it as production (they keep baking).
437 Err string
438}
439
440// VersionView is one shipped release on the app page.
441type VersionView struct {
442 Version, Kind, BuiltAt string
443 // Err degrades an unreadable manifest to one row's error text.
444 Err string
445 // Label is the manifest's own one-line summary (release versioning
446 // spec, 2026-08-08) — "" for every release shipped before the field
447 // existed, and for one whose manifest failed to read (Err is set
448 // instead).
449 Label string
450 // Notes is the manifest's longer freeform changelog entry, alongside
451 // Label — shown on the releases page inside a <details> so the table
452 // stays scannable. Same "" rules as Label.
453 Notes string
454 // Tag is the tag name pointing at this release, if any. Versions itself
455 // never reads ListTags — every caller that wants Tag populated joins
456 // against tagByRelease's map itself (the CLI API's releases handler in
457 // apiversion.go, and the console releases page in handleAppReleases).
458 Tag string
459}
460
461// TrashedAppView is one row of the Trash page: the app, who put it there,
462// when, and the date the platform will destroy it on its own.
463type TrashedAppView struct {
464 App string
465 // Actor is who trashed it, read from the BUCKET marker rather than the
466 // row — the marker is where the deletion was actually recorded, and the
467 // row only knows a timestamp. "" is missing information (an unreadable or
468 // absent marker), rendered as the em-dash convention, never guessed at.
469 Actor string
470 DeletedAt time.Time
471 PurgesAt time.Time
472}
473
474// TrashedApps folds an account's trashed rows together with what the bucket
475// says about each one. Best effort by design: a marker that will not read
476// costs this page an actor and nothing else, and the ROW's timestamp stands
477// in for the marker's.
478//
479// The asymmetry with PurgeApp is deliberate. Reading a page is not
480// destroying anything, so it may proceed on partial information; destruction
481// re-reads the marker itself and refuses without one. A Trash page that went
482// blank whenever the bucket hiccuped would hide the restore button at exactly
483// the moment somebody needed it.
484func TrashedApps(ctx context.Context, s release.Store, acct string, rows []TrashedApp) []TrashedAppView {
485 out := make([]TrashedAppView, 0, len(rows))
486 for _, row := range rows {
487 v := TrashedAppView{App: row.App, DeletedAt: row.DeletedAt}
488 if s != nil {
489 switch tomb, err := release.ReadAppTombstone(ctx, s, acct, row.App); {
490 case err != nil:
491 log.Printf("console: trash marker %s/%s: %v", acct, row.App, err)
492 case tomb != nil:
493 // The marker's clock is the ONE clock this feature has: the
494 // auto-purge pass destroys at the marker's DeletedAt +
495 // TrashCooloff, so the date shown here must come from there
496 // too or the page would promise a deadline nobody honours.
497 v.Actor, v.DeletedAt = tomb.Actor, tomb.DeletedAt
498 }
499 }
500 // One deadline, everywhere (temporary static sites spec §5): the
501 // row's purge_after when the app was temporary at trash time, else
502 // the marker's clock plus the 30-day cooloff.
503 v.PurgesAt = effectivePurgeAt(v.DeletedAt.Unix(), row.PurgeAfter)
504 out = append(out, v)
505 }
506 return out
507}
508
509// consoleRegistry lets requests and passes borrow a process-owned handle.
510// Close is deliberately absent: closing it would break every later caller.
511type consoleRegistry interface {
512 Get(string) (*registry.Route, error)
513 List() ([]registry.Route, error)
514 ListAliases() ([]registry.RouteAlias, error)
515 ListPaths() ([]registry.RoutePath, error)
516 SetPlacement(string, string) error
517 CreateFleet(registry.Fleet) (bool, error)
518 GetFleet(string) (*registry.Fleet, error)
519 ListFleets() ([]registry.Fleet, error)
520 AddFleetBox(registry.FleetBox) error
521 ListFleetBoxes(string) ([]registry.FleetBox, error)
522 SetFleetBoxToken(string, string, string) error
523 SetFleetBoxTags(string, string, string) error
524 SetFleetBoxStatus(string, string, string) error
525}
526
527var (
528 regMu sync.Mutex
529 regCache = map[string]*registry.Registry{}
530)
531
532func openRegistry(path string) (consoleRegistry, error) {
533 regMu.Lock()
534 defer regMu.Unlock()
535 if r, ok := regCache[path]; ok {
536 return r, nil
537 }
538 r, err := registry.Open(path)
539 if err != nil {
540 return nil, err
541 }
542 regCache[path] = r
543 return r, nil
544}
545
546// Assemble builds the view for the given (owned) apps: routes from the
547// registry at regPath, channels from the release store s verified against
548// pub, Domains rows with guidance targeting dns (the zero value = neither
549// edge addresses nor a steering hostname known). Every app in apps gets an
550// AppView, even an empty one; per-channel failures degrade to
551// ChannelView.Err.
552func Assemble(regPath string, s release.Store, pub ed25519.PublicKey, acct string, apps []string, dns DNSTargets) ([]AppView, error) {
553 ctx := context.Background()
554 reg, err := openRegistry(regPath)
555 if err != nil {
556 return nil, err
557 }
558 routes, err := reg.List()
559 if err != nil {
560 return nil, err
561 }
562 // Routes are scoped here, before any downstream view is assembled. App names
563 // are account-local, so grouping by name first would mix legitimate sibling
564 // apps and leak their host, environment, capability and health details into
565 // one another's pages. Account-review candidates are derived separately by
566 // the server, after it knows the viewer's memberships.
567 byApp := map[string][]RouteView{}
568 domainsByApp := map[string][]DomainView{}
569 for _, rt := range routes {
570 rtAcct := release.RouteAccount(rt)
571 if rtAcct != acct {
572 continue
573 }
574 ch := rt.Channel
575 if ch == "" {
576 ch = "stable" // registry contract: empty channel means stable
577 }
578 // The scoped guard described above: grants render only on rows the
579 // viewing account owns. Fails closed — a route whose stamped account
580 // disagrees with the account being viewed (the half-migrated state)
581 // shows no grants rather than someone else's.
582 capsFor := ""
583 if rtAcct == acct {
584 capsFor = humanizeCaps(rt.Caps)
585 }
586 // One RouteView and one DomainView per route, appended together:
587 // AppView.Routes and AppView.Domains are PARALLEL slices, and
588 // assembleOverview leans on that to account-filter domain rows
589 // (DomainView carries no account of its own). Splitting these
590 // appends apart breaks that join silently.
591 byApp[rt.App] = append(byApp[rt.App], RouteView{
592 Host: rt.Host, Kind: rt.Kind, Status: rt.Status,
593 Version: rt.Version, Channel: ch, Hibernate: rt.Hibernate,
594 Platform: rt.Platform,
595 Account: rtAcct,
596 Environment: rt.Environment,
597 Caps: capsFor,
598 Unit: rt.Unit, Backing: rt.Backing, Provisioned: rt.Provisioned,
599 Restartable: release.Adoptable(rt),
600 })
601 domainsByApp[rt.App] = append(domainsByApp[rt.App], domainView(rt, dns))
602 }
603 now := time.Now()
604 sorted := append([]string(nil), apps...)
605 sort.Strings(sorted)
606 var out []AppView
607 seen := map[string]bool{}
608 for _, app := range sorted {
609 if app == "" || seen[app] {
610 continue
611 }
612 seen[app] = true
613 channels := assembleChannels(ctx, s, pub, acct, app, now)
614 pv := assembleProduction(ctx, s, pub, acct, app, channels, byApp[app], now)
615 if pv.Err == "" && !pv.On {
616 // No production flag (or off): nothing bakes, so no estimate. An
617 // Err keeps the estimate — boxes fail toward baking.
618 for i := range channels {
619 channels[i].BakedUntil = time.Time{}
620 }
621 }
622 for i := range channels {
623 if channels[i].FastTrack {
624 channels[i].BakedUntil = time.Time{} // waived — nothing to wait out
625 }
626 }
627 out = append(out, AppView{
628 App: app,
629 Routes: byApp[app], // List orders by host already
630 Domains: domainsByApp[app],
631 Channels: channels,
632 Canaries: assembleCanaries(channels, byApp[app]),
633 // nil pipeline here, always: this Ladder is the legacy shape.
634 // Pipeline itself is deliberately NOT read here — see
635 // AppView.Pipeline's doc comment — so appPage (server.go) is
636 // where a single app's view gets pipeline-aware, and rebuilds
637 // Ladder from the corrected Channels once it has one.
638 Ladder: assembleLadder(channels, byApp[app], nil, now),
639 Production: pv,
640 })
641 }
642 return out, nil
643}
644
645// assembleCanaries derives the canary list: every canary/* channel, with the
646// hosts whose routes follow it.
647func assembleCanaries(channels []ChannelView, routes []RouteView) []CanaryView {
648 var out []CanaryView
649 for _, ch := range channels {
650 if !strings.HasPrefix(ch.Name, "canary/") {
651 continue
652 }
653 cv := CanaryView{
654 Name: strings.TrimPrefix(ch.Name, "canary/"),
655 Channel: ch.Name,
656 Version: ch.Version,
657 PromotedAt: ch.PromotedAt,
658 Err: ch.Err,
659 }
660 for _, rt := range routes {
661 if rt.Channel == ch.Name {
662 cv.Hosts = append(cv.Hosts, rt.Host)
663 }
664 }
665 out = append(out, cv)
666 }
667 return out
668}
669
670// tagIndex folds an already-read tag list into a release version -> tag
671// version map (release versioning spec, 2026-08-08) — the pure join, with
672// no store read of its own. This is the ONE place the fold lives: a caller
673// that already has a []release.Tag in hand (handleAppReleases, which reads
674// tags once for both its Tags section and this map) calls this directly
675// rather than re-listing just to build the same map tagByRelease below
676// would.
677func tagIndex(tags []release.Tag) map[string]string {
678 m := make(map[string]string, len(tags))
679 for _, t := range tags {
680 m[t.Release] = t.Version
681 }
682 return m
683}
684
685// tagByRelease is tagIndex plus the read, for a caller with no tag slice of
686// its own yet (apiversion.go's handleAPIReleasesList). Unlike a page
687// render's usual degrade-and-continue posture, a listing failure here is
688// RETURNED rather than swallowed into an empty map: an API caller that
689// asked for tags and silently got none back would see a wrong 200 instead
690// of the 502 a broken store deserves.
691//
692// Deliberately NOT called from assembleChannels: that builder is shared by
693// every app-page tab and the account home page's whole app list, and a full
694// ListTags sweep (one List, one GetTag per tag) thrown away by every
695// caller except the releases tab would be a full tag-history read on every
696// page view. handleAppReleases fills ChannelView.Tag itself, after Assemble
697// returns, from the one read that render already needs.
698func tagByRelease(ctx context.Context, s release.Store, acct, app string) (map[string]string, error) {
699 tags, err := release.ListTags(ctx, s, acct, app)
700 if err != nil {
701 return nil, err
702 }
703 return tagIndex(tags), nil
704}
705
706// assembleChannels lists the app's account-qualified channel pointers and
707// resolves each one. An app whose objects are still at the legacy unqualified
708// keys lists as empty here, exactly as the fleet sees it — the console showing
709// channels the boxes cannot follow is the disagreement the retired
710// read-fallback used to create.
711func assembleChannels(ctx context.Context, s release.Store, pub ed25519.PublicKey, acct, app string, now time.Time) []ChannelView {
712 prefix := release.ChannelsPrefix(acct, app)
713 keys, err := s.List(ctx, prefix)
714 if err != nil {
715 return []ChannelView{{Name: "(all)", Err: "listing channels: " + err.Error()}}
716 }
717 var names []string
718 for _, k := range keys {
719 name := strings.TrimPrefix(k, prefix)
720 if !strings.HasSuffix(name, ".json") {
721 continue
722 }
723 names = append(names, strings.TrimSuffix(name, ".json"))
724 }
725 sort.Strings(names)
726 var out []ChannelView
727 for _, name := range names {
728 p, err := release.Channel(ctx, s, pub, acct, app, name)
729 if err != nil {
730 out = append(out, ChannelView{Name: name, Err: err.Error()})
731 continue
732 }
733 cv := ChannelView{
734 Name: name, Version: p.Version, PromotedAt: p.PromotedAt,
735 HasHistory: len(p.History) > 0,
736 FastTrack: p.FastTrack,
737 }
738 // The trail is newest first, but a rollback carries it forward
739 // without appending: the entry that explains the CURRENT version is
740 // the newest one naming it, not simply the newest one. No match — an
741 // old pointer, or a version that arrived by rollback — leaves Actor
742 // empty, which the page renders as "not recorded".
743 for _, a := range p.Audit {
744 if a.Version == p.Version {
745 cv.Actor, cv.Hotfix = a.Actor, a.Hotfix
746 break
747 }
748 }
749 if hold := release.HoldFor(release.DefaultHolds, name); hold > 0 {
750 if until := p.PromotedAt.Add(hold); now.Before(until) {
751 cv.BakedUntil = until
752 }
753 }
754 out = append(out, cv)
755 }
756 return out
757}
758
759// assembleProduction reads the app's flag and derives the console's view of
760// it: on/off, the turning-off ratchet estimate, and the prod-wiring nudge.
761func assembleProduction(ctx context.Context, s release.Store, pub ed25519.PublicKey, acct, app string, channels []ChannelView, routes []RouteView, now time.Time) ProductionView {
762 pv := ProductionView{}
763 flag, err := release.GetProduction(ctx, s, pub, acct, app)
764 switch {
765 case err != nil:
766 pv.Err = err.Error()
767 pv.On = true // what the boxes will do: fail toward baking
768 return pv
769 case flag != nil:
770 pv.On = flag.Production
771 pv.SetAt = flag.SetAt
772 }
773 held := false
774 for _, cv := range channels {
775 if cv.Err == "" && release.HoldFor(release.DefaultHolds, cv.Name) > 0 {
776 held = true
777 break
778 }
779 }
780 if flag != nil && !flag.Production && held {
781 // The estimate errs LATE, deliberately. A box ratchets from its OWN
782 // first sighting of the off-flag, not from set_at, and it waits
783 // hold*(1+jitter) with jitter in [0, 0.25) — so set_at + the bare hold
784 // is the earliest any box could possibly honor it, and the console
785 // would go on to claim "off — hosts adopt immediately" for up to
786 // another 19 hours while the slowest boxes were still baking. Quoting
787 // the maximum (1.25x) means the line is late rather than wrong; each
788 // box's own clock still governs, as the wording says.
789 hold := release.HoldFor(release.DefaultHolds, release.RungStable)
790 if until := flag.SetAt.Add(time.Duration(float64(hold) * 1.25)); now.Before(until) {
791 pv.TurningOff, pv.EffectiveAt = true, until
792 }
793 }
794 if !pv.On {
795 stableRoute := false
796 for _, rt := range routes {
797 if rung, ok := release.Rung(rt.Channel); ok && rung == release.RungStable {
798 stableRoute = true
799 break
800 }
801 }
802 pv.Nudge = stableRoute || len(prodWiredCanaryHosts(routes)) > 0
803 }
804 return pv
805}
806
807// FleetView reads status/fleet.json off the console's release.Store handle
808// and decodes it into status.Fleet — the exact shape internal/status's
809// public page renders, so the operator view and the public page never
810// disagree about what a field means. A snapshot that hasn't been published
811// yet (no status-lease holder has run, or a brand-new deployment) is not an
812// error: it returns a nil Fleet so the page can render its "no snapshots
813// yet" empty state instead of a 500.
814func FleetView(ctx context.Context, s release.Store) (*status.Fleet, error) {
815 body, _, err := s.Get(ctx, "status/fleet.json")
816 if err != nil {
817 if errors.Is(err, os.ErrNotExist) {
818 return nil, nil
819 }
820 return nil, err
821 }
822 var f status.Fleet
823 if err := json.Unmarshal(body, &f); err != nil {
824 return nil, err
825 }
826 return &f, nil
827}
828
829// HostRow is one box's PRIVATE detail line on the operator fleet page: the
830// raw block status.Box carries and fleet.json deliberately never publishes
831// (internal/status's package comment), plus whether the box is still
832// reporting at all.
833//
834// Metrics is nil for a box that is STALE as well as for one that never
835// reported any — suppression happens here, in the view model, not in the
836// template. That is the point: an unreachable host's last happy numbers must
837// never reach the page, and the only way to be sure of that is for the page
838// to never be handed them. A template condition would be one careless
839// {{if}}-rearrangement away from printing a dead box's memory as a current
840// figure.
841type HostRow struct {
842 Label string
843 Region string
844 // Stale is status.BoxStale — the fleet's one staleness rule, measured
845 // against status.SnapshotPeriod, so this table and the published boxes
846 // table above it can never disagree about which host is dark.
847 Stale bool
848 // LastSeen is the snapshot's own GeneratedAt and Dark is how long ago that
849 // was, pre-rendered ("1h2m"). Dark is set only for a stale row: it is the
850 // one thing an unreachable host is allowed to say about itself.
851 LastSeen time.Time
852 Dark string
853 Metrics *status.BoxMetrics
854}
855
856// HostRows reads every per-box snapshot (status.LoadBoxes — the same loader
857// and the same skip-corrupt rule the public merge uses) and turns it into the
858// operator page's private host table, sorted by label so the order is stable
859// across refreshes.
860//
861// A nil store is not an error: a deployment configured without a bucket
862// handle simply has no host detail to show, and the fleet page renders
863// without the section rather than failing. Same posture for the caller on a
864// read error — see handleFleet.
865func HostRows(ctx context.Context, s release.Store, now time.Time) ([]HostRow, error) {
866 if s == nil {
867 return nil, nil
868 }
869 boxes, err := status.LoadBoxes(ctx, s)
870 if err != nil {
871 return nil, err
872 }
873 rows := make([]HostRow, 0, len(boxes))
874 for _, b := range boxes {
875 row := HostRow{
876 Label: b.Label,
877 Region: b.Region,
878 Stale: status.BoxStale(b, now, status.SnapshotPeriod),
879 LastSeen: b.GeneratedAt,
880 Metrics: b.Metrics,
881 }
882 if row.Stale {
883 row.Dark = humanAge(now.Sub(b.GeneratedAt))
884 row.Metrics = nil // an unreachable host shows no numbers at all
885 }
886 rows = append(rows, row)
887 }
888 sort.Slice(rows, func(i, j int) bool { return rows[i].Label < rows[j].Label })
889 return rows, nil
890}
891
892// humanAge renders how long a box has been dark at the resolution an operator
893// actually reads it at: minutes up to an hour, then hours and minutes, then
894// days. Never seconds — a snapshot is only ever called stale after three
895// missed five-minute ticks, so second-level precision would be false detail —
896// and never a negative age: a box whose clock runs ahead of the console's
897// reads as "0m", not as a time traveller.
898func humanAge(d time.Duration) string {
899 if d < 0 {
900 d = 0
901 }
902 switch {
903 case d < time.Hour:
904 return strconv.Itoa(int(d.Minutes())) + "m"
905 case d < 24*time.Hour:
906 return strconv.Itoa(int(d.Hours())) + "h" + strconv.Itoa(int(d.Minutes())%60) + "m"
907 default:
908 return strconv.Itoa(int(d.Hours())/24) + "d" + strconv.Itoa(int(d.Hours())%24) + "h"
909 }
910}
911
912// untilText renders a future horizon the way the cards speak: "~4h", never
913// a timestamp. Past or zero is "now" — the wait is over, not unknown.
914func untilText(t time.Time) string {
915 if t.IsZero() || !t.After(time.Now()) {
916 return "now"
917 }
918 return "~" + humanAge(time.Until(t))
919}
920
921// versionsCap bounds the app page's release list.
922const versionsCap = 20
923
924// tagsCap bounds the releases tab's Tags section the same way versionsCap
925// bounds the table above it. release.ListTags is already semver-descending,
926// so this is a plain slice truncate — the newest tagsCap tags, not an
927// arbitrary subset.
928const tagsCap = 20
929
930// Versions lists an app's shipped releases — the distinct version dirs under
931// apps/<app>/releases/ with each manifest's kind and build stamp — newest
932// first by built_at (best effort: RFC3339 parses win, then version string),
933// capped at versionsCap. Unreadable manifests degrade to a row error.
934//
935// Manifests are read only for the versionsCap versions that will render:
936// release.VersionCandidates ranks the listing by manifest mod time when the
937// store carries stamps (both real stores do), and this fetches just the
938// survivors — fetch-then-sort made this page O(every version ever shipped)
939// in sequential bucket GETs, ~8s at tito's 318. A plain Store (test fakes
940// only) still yields every version, fetched and sorted exactly as before.
941func Versions(s release.Store, acct, app string) ([]VersionView, error) {
942 ctx := context.Background()
943 cands, _, err := release.VersionCandidates(ctx, s, acct, app, versionsCap)
944 if err != nil {
945 return nil, err
946 }
947 var out []VersionView
948 for _, v := range cands {
949 m, err := release.GetManifest(ctx, s, acct, app, v)
950 if err != nil {
951 out = append(out, VersionView{Version: v, Err: err.Error()})
952 continue
953 }
954 out = append(out, VersionView{Version: v, Kind: m.Kind, BuiltAt: m.BuiltAt, Label: m.Label, Notes: m.Notes})
955 }
956 parse := func(s string) time.Time {
957 t, _ := time.Parse(time.RFC3339, s)
958 return t
959 }
960 sort.SliceStable(out, func(i, j int) bool {
961 ti, tj := parse(out[i].BuiltAt), parse(out[j].BuiltAt)
962 if !ti.Equal(tj) {
963 return ti.After(tj)
964 }
965 return out[i].Version > out[j].Version
966 })
967 if len(out) > versionsCap {
968 out = out[:versionsCap]
969 }
970 return out, nil
971}
972