| 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. |
| 5 | package console |
| 6 | |
| 7 | import ( |
| 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. |
| 28 | type 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. |
| 76 | func (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. |
| 91 | func (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. |
| 111 | type MismatchedRoute struct{ Host, Account string } |
| 112 | |
| 113 | // AccountMismatch reports whether the server found account-review candidates. |
| 114 | func (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. |
| 120 | type 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. |
| 146 | func (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. |
| 164 | func (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. |
| 182 | type 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. |
| 220 | func 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. |
| 254 | func 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. |
| 266 | func 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. |
| 275 | func 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). |
| 281 | func 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. |
| 315 | type 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. |
| 326 | type 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. |
| 373 | func 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. |
| 381 | type 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). |
| 420 | type 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. |
| 441 | type 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. |
| 463 | type 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. |
| 484 | func 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. |
| 511 | type 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 | |
| 527 | var ( |
| 528 | regMu sync.Mutex |
| 529 | regCache = map[string]*registry.Registry{} |
| 530 | ) |
| 531 | |
| 532 | func 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. |
| 552 | func 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. |
| 647 | func 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. |
| 677 | func 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. |
| 698 | func 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. |
| 711 | func 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. |
| 761 | func 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. |
| 814 | func 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. |
| 841 | type 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. |
| 865 | func 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. |
| 898 | func 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. |
| 914 | func 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. |
| 922 | const 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. |
| 928 | const 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. |
| 941 | func 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 | |