// The derived read-model: what a signed-in owner sees. Assembled fresh per // page from the platform's two sources of truth — the host registry (routes) // and the deployment bucket (signed channel pointers) — and never written // back: slice 1 of the console is read-only against platform state. package console import ( "context" "crypto/ed25519" "encoding/json" "errors" "log" "os" "sort" "strconv" "strings" "sync" "time" "golang.org/x/net/publicsuffix" "github.com/carlosframework/platform/internal/registry" "github.com/carlosframework/platform/internal/release" "github.com/carlosframework/platform/internal/status" ) // AppView is everything the console shows about one app. type AppView struct { App string Routes []RouteView Domains []DomainView Channels []ChannelView Canaries []CanaryView // Ladder is the four rungs with what sits on each (ladder.go) — the same // channels and routes as the fields above, arranged as the pipeline they // actually are rather than as two alphabetical lists. Ladder []LadderRung // Production is the app's production-flag state (release.ProductionFlag) // as the console shows it — see ProductionView. Production ProductionView // RouteAccount is the account the app's ROUTES say it belongs to, when // that disagrees with the account whose page this is; "" when they agree // or there are no routes. It is the first mismatched account (kept for // any caller other than the banner — see AccountMismatch) — the banner // itself reads Mismatched/AllMismatched below. RouteAccount string // Mismatched is every non-platform route stamped to another account; // AllMismatched is whether that is ALL of them — only then is "this app // is not deploying" a claim the page can back. Mismatched []MismatchedRoute AllMismatched bool // Pipeline is the app's declared release pipeline (release pipelines v2 // §2) — nil for a legacy app that has never opted in, or when the read // failed (best-effort for a VIEW: a page that cannot read the object // degrades to the legacy ladder rather than failing outright; a WRITE // door such as handlePromote reads its own copy and refuses on error // instead, which is the place that distinction is load-bearing). // // Deliberately NOT populated by Assemble (view.go's own tagByRelease // precedent: VersionView.Tag is never read there either — every caller // that wants it joins separately). Assemble builds a whole account's app // list for the account home page, which never renders Ladder or // Pipeline at all; a pipeline read per app there would be a bucket GET // nobody looks at. appPage (server.go) is the one place every APP page // funnels through, so it is the one place that reads this, and rebuilds // Ladder — and corrects each ChannelView's BakedUntil, which the legacy // per-app table below computes and which means nothing for a pipelined // channel's own window (Task 9 review) — once it has one. Pipeline *release.Pipeline } // OneChannelPipeline reports whether this app has opted into a pipeline with // exactly one channel — spec §5's "hide the vocabulary entirely" case: no // ladder card, no promote form, no channel table on the app's pages at all, // only the releases list and the live marker. func (v AppView) OneChannelPipeline() bool { return v.Pipeline != nil && len(v.Pipeline.Channels) == 1 } // RollbackChannels is the app's OWN declared channels that have somewhere to // roll back to — for the one-channel page, which hides the channel table the // rollback control normally rides in but must not hide the control itself: a // rollback is a pointer write, not channel vocabulary, and a one-channel app // is precisely the app with no promotion step to fall back on when a bad // build lands (spec §5 hides the VOCABULARY, not the actions). // // Declared channels only: canary/* is retired, never rolled back (its control // is the canary table's Retire button), and a channel the pipeline does not // name is not this app's to offer here. A row that failed to read, or has no // history, is left out — there is nothing to go back to. func (v AppView) RollbackChannels() []ChannelView { if v.Pipeline == nil { return nil } declared := make(map[string]bool, len(v.Pipeline.Channels)) for _, c := range v.Pipeline.Channels { declared[c.Name] = true } var out []ChannelView for _, ch := range v.Channels { if ch.Err != "" || !ch.HasHistory || !declared[ch.Name] { continue } out = append(out, ch) } return out } // MismatchedRoute is a viewer-safe account-review candidate assembled by the // server after authorization. Assemble itself exposes owned routes only. type MismatchedRoute struct{ Host, Account string } // AccountMismatch reports whether the server found account-review candidates. func (v AppView) AccountMismatch() bool { return len(v.Mismatched) > 0 } // DomainView is one row of the app page's Domains table — the product // surface for what routes always were. Unknowns stay empty strings here and // render as the em-dash convention; Guidance is non-empty ONLY for a custom // domain that isn't pointing at this deployment yet. type DomainView struct { Domain, Type, DNS, TLS string Guidance string // State, Reason and Detail are the derived lifecycle words (#299 §2a) — // the SAME three domainState() gives `carlos domains list` and the API // doors, from the same fleet-wide readings, so the console page and the // CLI can never tell a member two stories about one hostname. That was // the whole complaint #299 opened with. // // Empty on a row that has not been through settingsDomains: the shared // view builder (domainView, below) folds one box's own registry copy and // has no fleet fold to derive a state from. Readers must treat "" as // "this build did not derive one here", never as a verdict. State, Reason, Detail string // Claimed is Settings-only (settingsDomains, appsettings.go): true when // this row corresponds to a domain claim this app holds — routed or not // — which is what the Settings page uses to decide whether a detach // form belongs beside the row. A plain domainView row (no claim looked // up) always carries the zero value. Claimed bool } // Status is the one word the Domains table shows: the derived lifecycle // state where there is one, and otherwise the raw DNS word this box // stamped. Never both — a Status column and a DNS column side by side would // be two answers to one question, which is what #299 was opened about. func (d DomainView) Status() string { if d.State != "" { return d.State } return d.DNS } // Explain is the sentence under a Domains row, or "" for no sentence. // // A healthy row gets none: "live" beside a current renewal date says // everything, and a paragraph repeating it under every working domain is // noise that trains people to skip the paragraph under the broken one. // Everything else gets the state's own detail — the same sentence the CLI // prints, verbatim — followed by the DNS instructions when there are any. // // A row with no state at all keeps exactly today's behaviour: guidance // alone. That is the platform-domain rows and anything the shared view // builder produced without a fleet fold. func (d DomainView) Explain() string { if d.State == "" { return d.Guidance } if d.State == StateLive || d.Detail == "" { return "" } if d.Guidance == "" { return d.Detail } return d.Detail + " " + d.Guidance } // DNSTargets is where this deployment tells a member to point a custom // domain. Two facts, threaded together because the guidance is one // sentence and needs both: the edge addresses an A record names, and — // only on a deployment that STEERS (multi-region ingress spec, Part 4) — // the steering hostname a non-apex host may CNAME to instead. type DNSTargets struct { // EdgeIPs is PLATFORM_EDGE_IPS unioned with the deployment's live fold // of every box's edges file (Server.edgeIPs). nil is legal: guidance // then says "this deployment's edge IP" rather than inventing an // address. // // Once a deployment steers, this list must name EVERY armed edge's EIP, // not just the local box's: the DNS check that stamps "pointing" // compares a resolved address against it, and a steered name resolves // to whichever edge the resolver is nearest. The fold is what makes // that true without the operator re-setting an env var on every box // each time a region arms — and it is the SAME fold the domains // reconcile pass judges "pointing" with, so what a member is told to do // and what the platform then grades them on cannot drift apart. EdgeIPs []string // SteeringHost is the deployment's steering hostname (edge., // itself latency-routed with one record per armed edge). "" — every // deployment that does not set CARLOS_STEERING_ZONE_ID, which is all of // them today — keeps the guidance byte-for-byte what it has always been. SteeringHost string } // domainGuidance is the point-your-DNS-here instruction a custom host that // is not pointing at this deployment yet gets shown — shared by domainView // (a routed custom host) and claimDomainView (a claim with no route yet), so // the two rows never drift into different wording for the same instruction. // // With steering armed the CNAME is the RECOMMENDED form for a subdomain: // it inherits the steering hostname's latency records and its health // checks, so visitors reach the nearest healthy edge and a dead edge stops // being handed out within ~90s. An apex cannot carry a CNAME at all (the // spec's honesty rule, Part 4: "apexes cannot CNAME, so apex guidance // remains A-records-to-all-EIPs — round-robin without latency bias, // documented as such in attach guidance"), so an apex is told the truth // about what its A records do rather than being offered an impossible // option. Apex-ness is decided by the public suffix list, not by counting // labels: "example.co.uk" is an apex and "app.example.com" is not, and both // have three. func domainGuidance(host string, t DNSTargets) string { target := "this deployment's edge IP" if len(t.EdgeIPs) > 0 { target = strings.Join(t.EdgeIPs, ", ") } aRecord := "Point an A record for " + host + " at " + target + "." switch { case t.SteeringHost == "": // No steering: exactly the sentence this deployment has always shown. case isApexDomain(host): aRecord += " " + host + " is an apex domain and cannot carry a CNAME, so its A records round-robin across the edges without latency bias." default: aRecord = "Point a CNAME for " + host + " at " + t.SteeringHost + " — visitors then reach the nearest healthy edge, and a failed edge is withdrawn automatically." + " An A record at " + target + " works too, but round-robins without latency bias." } return aRecord + " Checks run every few minutes." } // isApexDomain reports whether host is the registrable domain itself — the // one place a CNAME is illegal (RFC 1034: a CNAME cannot coexist with the // SOA and NS records an apex must carry). golang.org/x/net/publicsuffix is // already in this module's dependency set (the edge's IDNA path pulls // x/net), and the PSL is the only correct answer here: label counting calls // "example.co.uk" a subdomain and would hand an apex a CNAME it cannot use. // // The error case — EffectiveTLDPlusOne returns one when the name IS a public // suffix ("com", "co.uk") or is empty — is treated as an APEX, which keeps // guidance on the A-record form for input no member should be attaching // anyway. Note what this does NOT do: an unlisted TLD is not an error case. // publicsuffix falls back to the implicit "*" rule, so "example.internal" // comes back as its own eTLD+1 and reads as an apex, while // "app.example.internal" reads as a subdomain and is offered the CNAME — // which is the right answer for both, and not extra conservatism. func isApexDomain(host string) bool { h := strings.ToLower(strings.TrimSuffix(strings.TrimSpace(host), ".")) etld1, err := publicsuffix.EffectiveTLDPlusOne(h) if err != nil { return true } return etld1 == h } // verifyGuidance is the prove-you-control-it instruction for a pending claim // (#215) — the sentence form of its TXT record, shared by the domains door // and the Settings page so the two never word it differently. func verifyGuidance(v dnsRecord) string { return "Add a TXT record at " + v.Name + " with the value " + v.Value + " to prove you control this hostname, then attach it again or run carlos domains verify." } // claimDomainView folds a domain claim that has no colocated registry row // yet into its own Domains placeholder row — an alias claim (which never // gets a route row; see domainattach.go) or a first-route claim before the // provisioning reconciler has materialized its route. func claimDomainView(host string, t DNSTargets) DomainView { return DomainView{Domain: host, Type: "custom · attaching", Claimed: true, Guidance: domainGuidance(host, t)} } // domainView folds one registry route into its Domains row. dns carries the // deployment's edge addresses and steering hostname (see DNSTargets). func domainView(rt registry.Route, dns DNSTargets) DomainView { typ := "custom" if rt.Platform { typ = "platform" } // Canary labeling is preserved: a canary follower's row says so, right // in its type cell, whichever type it is. if strings.HasPrefix(rt.Channel, "canary/") { typ += " · " + rt.Channel } // TLS: the route's status is the issuance signal (issuing/ready/failed); // a known cert expiry adds the renew-by date. tls := rt.Status if tls == "" { tls = "ready" // registry contract: empty status means ready } if rt.CertExpiresAt > 0 { tls += " · renews " + time.Unix(rt.CertExpiresAt, 0).UTC().Format("2006-01-02") } dv := DomainView{Domain: rt.Host, Type: typ, DNS: rt.DNSStatus, TLS: tls} // Guidance only for custom + non-pointing: platform domains are ours to // fix, and a pointing custom domain needs no pointing instructions. // "unresolved" is excluded too — that means OUR lookup failed, not that // the member's record is missing or wrong, so telling them to (re)point // it would blame them for our outage. if !rt.Platform && rt.DNSStatus != "pointing" && rt.DNSStatus != "unresolved" { dv.Guidance = domainGuidance(rt.Host, dns) } return dv } // CanaryView is one canary on the app page: a canary/ channel plus // the hosts (routes) currently following it. A canary with no hosts is a // pointer waiting for a route — real, serving nothing yet. type CanaryView struct { Name string // the short name: "rc" for channel canary/rc Channel string // the full channel name Version string PromotedAt time.Time Hosts []string // Err mirrors ChannelView.Err: an unreadable pointer is one bad row. Err string } // RouteView is one row of the routes table. type RouteView struct { Host, Kind, Status, Version, Channel string Hibernate bool Platform bool Restartable bool // Account is what the ROUTE says its account is (release.RouteAccount: // the stamped account, or the ops account when unstamped). Routes are // grouped by app NAME here, and app names are unique per account rather // than globally (Store.AppAccounts) — so a view's Routes can legitimately // contain another account's routes, and anything drawing a tenant // boundary off this list must compare this field against the account // being viewed. See handleApp's owner-metrics allowlist. Account string // Environment is the named config bundle this route is wired to — "" // means the app's default bundle alone (registry.Route's own // convention; see internal/appconfig.LoadMerged). Rendered as "default" // rather than left blank, in ladder.html. Environment string // Caps is the operator-granted capability tokens for this route on this // box (registry.Route.Caps: "" = nothing granted), rendered human-legible // ("host-role, port:25" rather than the storage column's "host-role,port:25"). // Read-only: grants are written by `carlos route -grant/-revoke` on the // box, never from the console (spec §7 "nothing here touches the // console's gates" — this adds no route, no handler, no write path). // // Blank on any route whose Account is not the account being viewed: see // Assemble's byApp comment. Routes are grouped by app NAME, so this slice // can hold another tenant's rows, and a grant is the one field here that // names privileged ports and box authority. Caps string // Unit is the bespoke systemd unit bound to this route // (registry.Route.Unit, #110): set when an operator-run service owns the // route's process. The sweep samples stamped units (Task 5), so a stamped // route's silence is meaningful — see expectsReadings. Unit string // Backing mirrors registry.Route.Backing: "" = an exec child the // activator spawns, registry.BackingUnit = a carlos-app@ unit. Backing string // Provisioned mirrors registry.Route.Provisioned: minted from a bucket // instance record, so the activator runs its process by construction. Provisioned bool } // humanizeCaps renders a registry.Route.Caps column for a person: the // storage shape is comma-separated with no spaces (caps.Grants.String()'s // canonical form, chosen so a re-grant of the same set writes the same // bytes); a reader gets "host-role, port:25" instead. func humanizeCaps(caps string) string { if caps == "" { return "" } return strings.Join(strings.Split(caps, ","), ", ") } // ChannelView is one row of the channels table. type ChannelView struct { Name, Version string PromotedAt time.Time // BakedUntil is zero when the channel is live. Otherwise it is // PromotedAt + the channel's DEFAULT hold (release.DefaultHolds) — an // APPROXIMATION the UI labels "bakes until ~": the authoritative bake // clock is each box's own first-seen record (release/bake.go), which the // console cannot see, and per-deployment hold overrides are not read here. BakedUntil time.Time // HasHistory is whether the pointer records previous versions — i.e. // whether a rollback has somewhere to go. HasHistory bool // Actor is who promoted the version this channel now holds, and Hotfix // whether they bypassed the ladder to do it — both read from the // pointer's own signed audit trail. Actor is "" for every pointer written // before the trail existed, which is most of the live fleet: that is // missing information, not an error, and the page says so. Actor string Hotfix bool // FastTrack is the pointer's bake waiver (release.ChannelPointer) — the // badge is permanent, like the hotfix badge. FastTrack bool // Tag is the semver tag pointing at this channel's current release, if // any (release versioning spec, 2026-08-08). assembleChannels — the one // builder every app-page tab AND the account home page's app list share // — deliberately leaves this "": a full ListTags sweep is one List plus // one GetTag per tag, and every render except the releases tab throws // that read away unread. handleAppReleases fills it in, per row, after // Assemble returns, from the one tag read that render already needs for // its own Tags section. "" for a release nobody tagged, for every // channel below stable (no tag ever names one), and on every OTHER tab. Tag string // Err degrades a missing or invalid channel pointer to one row's error // text instead of a failed page. Err string } // ProductionView is the app's production-flag state as the console shows it // (2026-08-04 console bake controls spec §7). type ProductionView struct { // On is the flag as the bucket has it; the default (no object) is off. On bool // TurningOff marks an off-flag younger than the stable window while the // app still has held channels: hosts may still be ratcheting. // EffectiveAt is the ~estimate shown (set_at + the stable hold at the // MAXIMUM jitter, 1.25x) — each box's own clock and jitter govern, // exactly as BakedUntil is only ever an approximation, and this one errs // late on purpose (see assembleProduction). TurningOff bool EffectiveAt time.Time SetAt time.Time // Nudge: the app looks production-wired while bake is off. Detection // recommends; it never flips the flag. Nudge bool // Err is a flag object that exists but will not verify — shown, and // boxes treat it as production (they keep baking). Err string } // VersionView is one shipped release on the app page. type VersionView struct { Version, Kind, BuiltAt string // Err degrades an unreadable manifest to one row's error text. Err string // Label is the manifest's own one-line summary (release versioning // spec, 2026-08-08) — "" for every release shipped before the field // existed, and for one whose manifest failed to read (Err is set // instead). Label string // Notes is the manifest's longer freeform changelog entry, alongside // Label — shown on the releases page inside a
so the table // stays scannable. Same "" rules as Label. Notes string // Tag is the tag name pointing at this release, if any. Versions itself // never reads ListTags — every caller that wants Tag populated joins // against tagByRelease's map itself (the CLI API's releases handler in // apiversion.go, and the console releases page in handleAppReleases). Tag string } // TrashedAppView is one row of the Trash page: the app, who put it there, // when, and the date the platform will destroy it on its own. type TrashedAppView struct { App string // Actor is who trashed it, read from the BUCKET marker rather than the // row — the marker is where the deletion was actually recorded, and the // row only knows a timestamp. "" is missing information (an unreadable or // absent marker), rendered as the em-dash convention, never guessed at. Actor string DeletedAt time.Time PurgesAt time.Time } // TrashedApps folds an account's trashed rows together with what the bucket // says about each one. Best effort by design: a marker that will not read // costs this page an actor and nothing else, and the ROW's timestamp stands // in for the marker's. // // The asymmetry with PurgeApp is deliberate. Reading a page is not // destroying anything, so it may proceed on partial information; destruction // re-reads the marker itself and refuses without one. A Trash page that went // blank whenever the bucket hiccuped would hide the restore button at exactly // the moment somebody needed it. func TrashedApps(ctx context.Context, s release.Store, acct string, rows []TrashedApp) []TrashedAppView { out := make([]TrashedAppView, 0, len(rows)) for _, row := range rows { v := TrashedAppView{App: row.App, DeletedAt: row.DeletedAt} if s != nil { switch tomb, err := release.ReadAppTombstone(ctx, s, acct, row.App); { case err != nil: log.Printf("console: trash marker %s/%s: %v", acct, row.App, err) case tomb != nil: // The marker's clock is the ONE clock this feature has: the // auto-purge pass destroys at the marker's DeletedAt + // TrashCooloff, so the date shown here must come from there // too or the page would promise a deadline nobody honours. v.Actor, v.DeletedAt = tomb.Actor, tomb.DeletedAt } } // One deadline, everywhere (temporary static sites spec §5): the // row's purge_after when the app was temporary at trash time, else // the marker's clock plus the 30-day cooloff. v.PurgesAt = effectivePurgeAt(v.DeletedAt.Unix(), row.PurgeAfter) out = append(out, v) } return out } // consoleRegistry lets requests and passes borrow a process-owned handle. // Close is deliberately absent: closing it would break every later caller. type consoleRegistry interface { Get(string) (*registry.Route, error) List() ([]registry.Route, error) ListAliases() ([]registry.RouteAlias, error) ListPaths() ([]registry.RoutePath, error) SetPlacement(string, string) error CreateFleet(registry.Fleet) (bool, error) GetFleet(string) (*registry.Fleet, error) ListFleets() ([]registry.Fleet, error) AddFleetBox(registry.FleetBox) error ListFleetBoxes(string) ([]registry.FleetBox, error) SetFleetBoxToken(string, string, string) error SetFleetBoxTags(string, string, string) error SetFleetBoxStatus(string, string, string) error } var ( regMu sync.Mutex regCache = map[string]*registry.Registry{} ) func openRegistry(path string) (consoleRegistry, error) { regMu.Lock() defer regMu.Unlock() if r, ok := regCache[path]; ok { return r, nil } r, err := registry.Open(path) if err != nil { return nil, err } regCache[path] = r return r, nil } // Assemble builds the view for the given (owned) apps: routes from the // registry at regPath, channels from the release store s verified against // pub, Domains rows with guidance targeting dns (the zero value = neither // edge addresses nor a steering hostname known). Every app in apps gets an // AppView, even an empty one; per-channel failures degrade to // ChannelView.Err. func Assemble(regPath string, s release.Store, pub ed25519.PublicKey, acct string, apps []string, dns DNSTargets) ([]AppView, error) { ctx := context.Background() reg, err := openRegistry(regPath) if err != nil { return nil, err } routes, err := reg.List() if err != nil { return nil, err } // Routes are scoped here, before any downstream view is assembled. App names // are account-local, so grouping by name first would mix legitimate sibling // apps and leak their host, environment, capability and health details into // one another's pages. Account-review candidates are derived separately by // the server, after it knows the viewer's memberships. byApp := map[string][]RouteView{} domainsByApp := map[string][]DomainView{} for _, rt := range routes { rtAcct := release.RouteAccount(rt) if rtAcct != acct { continue } ch := rt.Channel if ch == "" { ch = "stable" // registry contract: empty channel means stable } // The scoped guard described above: grants render only on rows the // viewing account owns. Fails closed — a route whose stamped account // disagrees with the account being viewed (the half-migrated state) // shows no grants rather than someone else's. capsFor := "" if rtAcct == acct { capsFor = humanizeCaps(rt.Caps) } // One RouteView and one DomainView per route, appended together: // AppView.Routes and AppView.Domains are PARALLEL slices, and // assembleOverview leans on that to account-filter domain rows // (DomainView carries no account of its own). Splitting these // appends apart breaks that join silently. byApp[rt.App] = append(byApp[rt.App], RouteView{ Host: rt.Host, Kind: rt.Kind, Status: rt.Status, Version: rt.Version, Channel: ch, Hibernate: rt.Hibernate, Platform: rt.Platform, Account: rtAcct, Environment: rt.Environment, Caps: capsFor, Unit: rt.Unit, Backing: rt.Backing, Provisioned: rt.Provisioned, Restartable: release.Adoptable(rt), }) domainsByApp[rt.App] = append(domainsByApp[rt.App], domainView(rt, dns)) } now := time.Now() sorted := append([]string(nil), apps...) sort.Strings(sorted) var out []AppView seen := map[string]bool{} for _, app := range sorted { if app == "" || seen[app] { continue } seen[app] = true channels := assembleChannels(ctx, s, pub, acct, app, now) pv := assembleProduction(ctx, s, pub, acct, app, channels, byApp[app], now) if pv.Err == "" && !pv.On { // No production flag (or off): nothing bakes, so no estimate. An // Err keeps the estimate — boxes fail toward baking. for i := range channels { channels[i].BakedUntil = time.Time{} } } for i := range channels { if channels[i].FastTrack { channels[i].BakedUntil = time.Time{} // waived — nothing to wait out } } out = append(out, AppView{ App: app, Routes: byApp[app], // List orders by host already Domains: domainsByApp[app], Channels: channels, Canaries: assembleCanaries(channels, byApp[app]), // nil pipeline here, always: this Ladder is the legacy shape. // Pipeline itself is deliberately NOT read here — see // AppView.Pipeline's doc comment — so appPage (server.go) is // where a single app's view gets pipeline-aware, and rebuilds // Ladder from the corrected Channels once it has one. Ladder: assembleLadder(channels, byApp[app], nil, now), Production: pv, }) } return out, nil } // assembleCanaries derives the canary list: every canary/* channel, with the // hosts whose routes follow it. func assembleCanaries(channels []ChannelView, routes []RouteView) []CanaryView { var out []CanaryView for _, ch := range channels { if !strings.HasPrefix(ch.Name, "canary/") { continue } cv := CanaryView{ Name: strings.TrimPrefix(ch.Name, "canary/"), Channel: ch.Name, Version: ch.Version, PromotedAt: ch.PromotedAt, Err: ch.Err, } for _, rt := range routes { if rt.Channel == ch.Name { cv.Hosts = append(cv.Hosts, rt.Host) } } out = append(out, cv) } return out } // tagIndex folds an already-read tag list into a release version -> tag // version map (release versioning spec, 2026-08-08) — the pure join, with // no store read of its own. This is the ONE place the fold lives: a caller // that already has a []release.Tag in hand (handleAppReleases, which reads // tags once for both its Tags section and this map) calls this directly // rather than re-listing just to build the same map tagByRelease below // would. func tagIndex(tags []release.Tag) map[string]string { m := make(map[string]string, len(tags)) for _, t := range tags { m[t.Release] = t.Version } return m } // tagByRelease is tagIndex plus the read, for a caller with no tag slice of // its own yet (apiversion.go's handleAPIReleasesList). Unlike a page // render's usual degrade-and-continue posture, a listing failure here is // RETURNED rather than swallowed into an empty map: an API caller that // asked for tags and silently got none back would see a wrong 200 instead // of the 502 a broken store deserves. // // Deliberately NOT called from assembleChannels: that builder is shared by // every app-page tab and the account home page's whole app list, and a full // ListTags sweep (one List, one GetTag per tag) thrown away by every // caller except the releases tab would be a full tag-history read on every // page view. handleAppReleases fills ChannelView.Tag itself, after Assemble // returns, from the one read that render already needs. func tagByRelease(ctx context.Context, s release.Store, acct, app string) (map[string]string, error) { tags, err := release.ListTags(ctx, s, acct, app) if err != nil { return nil, err } return tagIndex(tags), nil } // assembleChannels lists the app's account-qualified channel pointers and // resolves each one. An app whose objects are still at the legacy unqualified // keys lists as empty here, exactly as the fleet sees it — the console showing // channels the boxes cannot follow is the disagreement the retired // read-fallback used to create. func assembleChannels(ctx context.Context, s release.Store, pub ed25519.PublicKey, acct, app string, now time.Time) []ChannelView { prefix := release.ChannelsPrefix(acct, app) keys, err := s.List(ctx, prefix) if err != nil { return []ChannelView{{Name: "(all)", Err: "listing channels: " + err.Error()}} } var names []string for _, k := range keys { name := strings.TrimPrefix(k, prefix) if !strings.HasSuffix(name, ".json") { continue } names = append(names, strings.TrimSuffix(name, ".json")) } sort.Strings(names) var out []ChannelView for _, name := range names { p, err := release.Channel(ctx, s, pub, acct, app, name) if err != nil { out = append(out, ChannelView{Name: name, Err: err.Error()}) continue } cv := ChannelView{ Name: name, Version: p.Version, PromotedAt: p.PromotedAt, HasHistory: len(p.History) > 0, FastTrack: p.FastTrack, } // The trail is newest first, but a rollback carries it forward // without appending: the entry that explains the CURRENT version is // the newest one naming it, not simply the newest one. No match — an // old pointer, or a version that arrived by rollback — leaves Actor // empty, which the page renders as "not recorded". for _, a := range p.Audit { if a.Version == p.Version { cv.Actor, cv.Hotfix = a.Actor, a.Hotfix break } } if hold := release.HoldFor(release.DefaultHolds, name); hold > 0 { if until := p.PromotedAt.Add(hold); now.Before(until) { cv.BakedUntil = until } } out = append(out, cv) } return out } // assembleProduction reads the app's flag and derives the console's view of // it: on/off, the turning-off ratchet estimate, and the prod-wiring nudge. func assembleProduction(ctx context.Context, s release.Store, pub ed25519.PublicKey, acct, app string, channels []ChannelView, routes []RouteView, now time.Time) ProductionView { pv := ProductionView{} flag, err := release.GetProduction(ctx, s, pub, acct, app) switch { case err != nil: pv.Err = err.Error() pv.On = true // what the boxes will do: fail toward baking return pv case flag != nil: pv.On = flag.Production pv.SetAt = flag.SetAt } held := false for _, cv := range channels { if cv.Err == "" && release.HoldFor(release.DefaultHolds, cv.Name) > 0 { held = true break } } if flag != nil && !flag.Production && held { // The estimate errs LATE, deliberately. A box ratchets from its OWN // first sighting of the off-flag, not from set_at, and it waits // hold*(1+jitter) with jitter in [0, 0.25) — so set_at + the bare hold // is the earliest any box could possibly honor it, and the console // would go on to claim "off — hosts adopt immediately" for up to // another 19 hours while the slowest boxes were still baking. Quoting // the maximum (1.25x) means the line is late rather than wrong; each // box's own clock still governs, as the wording says. hold := release.HoldFor(release.DefaultHolds, release.RungStable) if until := flag.SetAt.Add(time.Duration(float64(hold) * 1.25)); now.Before(until) { pv.TurningOff, pv.EffectiveAt = true, until } } if !pv.On { stableRoute := false for _, rt := range routes { if rung, ok := release.Rung(rt.Channel); ok && rung == release.RungStable { stableRoute = true break } } pv.Nudge = stableRoute || len(prodWiredCanaryHosts(routes)) > 0 } return pv } // FleetView reads status/fleet.json off the console's release.Store handle // and decodes it into status.Fleet — the exact shape internal/status's // public page renders, so the operator view and the public page never // disagree about what a field means. A snapshot that hasn't been published // yet (no status-lease holder has run, or a brand-new deployment) is not an // error: it returns a nil Fleet so the page can render its "no snapshots // yet" empty state instead of a 500. func FleetView(ctx context.Context, s release.Store) (*status.Fleet, error) { body, _, err := s.Get(ctx, "status/fleet.json") if err != nil { if errors.Is(err, os.ErrNotExist) { return nil, nil } return nil, err } var f status.Fleet if err := json.Unmarshal(body, &f); err != nil { return nil, err } return &f, nil } // HostRow is one box's PRIVATE detail line on the operator fleet page: the // raw block status.Box carries and fleet.json deliberately never publishes // (internal/status's package comment), plus whether the box is still // reporting at all. // // Metrics is nil for a box that is STALE as well as for one that never // reported any — suppression happens here, in the view model, not in the // template. That is the point: an unreachable host's last happy numbers must // never reach the page, and the only way to be sure of that is for the page // to never be handed them. A template condition would be one careless // {{if}}-rearrangement away from printing a dead box's memory as a current // figure. type HostRow struct { Label string Region string // Stale is status.BoxStale — the fleet's one staleness rule, measured // against status.SnapshotPeriod, so this table and the published boxes // table above it can never disagree about which host is dark. Stale bool // LastSeen is the snapshot's own GeneratedAt and Dark is how long ago that // was, pre-rendered ("1h2m"). Dark is set only for a stale row: it is the // one thing an unreachable host is allowed to say about itself. LastSeen time.Time Dark string Metrics *status.BoxMetrics } // HostRows reads every per-box snapshot (status.LoadBoxes — the same loader // and the same skip-corrupt rule the public merge uses) and turns it into the // operator page's private host table, sorted by label so the order is stable // across refreshes. // // A nil store is not an error: a deployment configured without a bucket // handle simply has no host detail to show, and the fleet page renders // without the section rather than failing. Same posture for the caller on a // read error — see handleFleet. func HostRows(ctx context.Context, s release.Store, now time.Time) ([]HostRow, error) { if s == nil { return nil, nil } boxes, err := status.LoadBoxes(ctx, s) if err != nil { return nil, err } rows := make([]HostRow, 0, len(boxes)) for _, b := range boxes { row := HostRow{ Label: b.Label, Region: b.Region, Stale: status.BoxStale(b, now, status.SnapshotPeriod), LastSeen: b.GeneratedAt, Metrics: b.Metrics, } if row.Stale { row.Dark = humanAge(now.Sub(b.GeneratedAt)) row.Metrics = nil // an unreachable host shows no numbers at all } rows = append(rows, row) } sort.Slice(rows, func(i, j int) bool { return rows[i].Label < rows[j].Label }) return rows, nil } // humanAge renders how long a box has been dark at the resolution an operator // actually reads it at: minutes up to an hour, then hours and minutes, then // days. Never seconds — a snapshot is only ever called stale after three // missed five-minute ticks, so second-level precision would be false detail — // and never a negative age: a box whose clock runs ahead of the console's // reads as "0m", not as a time traveller. func humanAge(d time.Duration) string { if d < 0 { d = 0 } switch { case d < time.Hour: return strconv.Itoa(int(d.Minutes())) + "m" case d < 24*time.Hour: return strconv.Itoa(int(d.Hours())) + "h" + strconv.Itoa(int(d.Minutes())%60) + "m" default: return strconv.Itoa(int(d.Hours())/24) + "d" + strconv.Itoa(int(d.Hours())%24) + "h" } } // untilText renders a future horizon the way the cards speak: "~4h", never // a timestamp. Past or zero is "now" — the wait is over, not unknown. func untilText(t time.Time) string { if t.IsZero() || !t.After(time.Now()) { return "now" } return "~" + humanAge(time.Until(t)) } // versionsCap bounds the app page's release list. const versionsCap = 20 // tagsCap bounds the releases tab's Tags section the same way versionsCap // bounds the table above it. release.ListTags is already semver-descending, // so this is a plain slice truncate — the newest tagsCap tags, not an // arbitrary subset. const tagsCap = 20 // Versions lists an app's shipped releases — the distinct version dirs under // apps//releases/ with each manifest's kind and build stamp — newest // first by built_at (best effort: RFC3339 parses win, then version string), // capped at versionsCap. Unreadable manifests degrade to a row error. // // Manifests are read only for the versionsCap versions that will render: // release.VersionCandidates ranks the listing by manifest mod time when the // store carries stamps (both real stores do), and this fetches just the // survivors — fetch-then-sort made this page O(every version ever shipped) // in sequential bucket GETs, ~8s at tito's 318. A plain Store (test fakes // only) still yields every version, fetched and sorted exactly as before. func Versions(s release.Store, acct, app string) ([]VersionView, error) { ctx := context.Background() cands, _, err := release.VersionCandidates(ctx, s, acct, app, versionsCap) if err != nil { return nil, err } var out []VersionView for _, v := range cands { m, err := release.GetManifest(ctx, s, acct, app, v) if err != nil { out = append(out, VersionView{Version: v, Err: err.Error()}) continue } out = append(out, VersionView{Version: v, Kind: m.Kind, BuiltAt: m.BuiltAt, Label: m.Label, Notes: m.Notes}) } parse := func(s string) time.Time { t, _ := time.Parse(time.RFC3339, s) return t } sort.SliceStable(out, func(i, j int) bool { ti, tj := parse(out[i].BuiltAt), parse(out[j].BuiltAt) if !ti.Equal(tj) { return ti.After(tj) } return out[i].Version > out[j].Version }) if len(out) > versionsCap { out = out[:versionsCap] } return out, nil }