package console import ( "encoding/base64" "encoding/json" "fmt" "net/http" "net/url" "sort" "time" "github.com/carlosframework/platform/internal/provision" "github.com/carlosframework/platform/internal/rolloutwords" "github.com/carlosframework/platform/internal/status" ) type instanceDetail struct { Row InstanceRow TargetVersion string TargetErr string Aliases []string // Layout is the WORD the box's layout pass classified this host as // (flat, nested, fenced, conflict, foreign, external) — never the // reason, which can carry a path (R10). The approved fallback text // when no box's report names this host. Layout string // Rollout is this host's rollout as its box last reported it (spec // §9.2); nil when no fresh box reports one, and the page is as before. // App and Channel name the `promote --retry` the hold suggests. Rollout *rolloutView App, Channel string // Updates is the instance's update settings section (settings spec §5); // nil when the host has no instance record or it could not be read. Updates *updatesPageView } // retryable is whether the box's report holds this host back in a way a // retry can clear: the host is held, and the box either armed its scheduler // or declined the flag while still honouring its failure book // (rolloutwords.UnavailableDeclines). A refusing box, and one whose code // this console does not know, are never retried. The CLI door // (retryHostBuild) and the page both ask this, so they cannot disagree. func (r *rolloutView) retryable() bool { return r != nil && r.HeldBy != "" && (r.Unavailable == "" || rolloutwords.UnavailableDeclines(r.Unavailable)) } // retryRefusal is why a retryable host's report cannot take a signed grant // yet: errRetryUnsupported for a box with no retry_v or no book identity, // errRetryUnsaved for a held host at epoch 0, whose book owes a save. nil // means the door may sign. The page and retryHostBuild both ask it, so they // cannot drift. func (r *rolloutView) retryRefusal() error { switch { case r.retryV == 0 || r.heldBook == "": return errRetryUnsupported case r.heldEpoch == 0: return errRetryUnsaved } return nil } // CanRetry is whether the page offers Retry: the host is retryable, and the // report lets the door sign a grant the box accepts (retryRefusal is nil). func (r *rolloutView) CanRetry() bool { return r.retryable() && r.retryRefusal() == nil } // CanRetry forwards to the rollout view, since the template's dot is the // instanceDetail. func (d *instanceDetail) CanRetry() bool { return d.Rollout.CanRetry() } // HoldText is the Failure hold row (spec §9.2), or "" when nothing holds // this host back that a retry can clear (and always for a box that could // not arm its scheduler and did not decline the flag, whose view carries no // hold). A hold-mode failure that left the instance down (attempt down) has // nothing serving it, so its row says so rather than naming a build it // "stays on" (Astra plan 6 R1 #8). A box that declined the flag holds the // build too but will not start the instance on it, so its row says that // instead; its Reason is empty and is never formatted. func (d *instanceDetail) HoldText() string { r := d.Rollout if !r.retryable() { return "" } if r.Unavailable != "" { if r.Attempt == status.AttemptDown { return fmt.Sprintf(swapCopy.HoldDeclinedDownText, r.HeldBy) } return fmt.Sprintf(swapCopy.HoldDeclinedText, r.HeldBy) } if r.Attempt == status.AttemptDown { return fmt.Sprintf(swapCopy.HoldDownText, r.HeldBy) } stays := r.Serving if stays == "" { stays = r.Good } return fmt.Sprintf(swapCopy.HoldText, r.HeldBy, rolloutwords.Reason(r.Reason), stays) } // PromoteRetryText names the app-wide retry (spec rev 14 §8.4) beside the // one-instance Retry button. func (d *instanceDetail) PromoteRetryText() string { if d.HoldText() == "" { return "" } return fmt.Sprintf(swapCopy.PromoteRetry, d.App, d.Rollout.HeldBy, d.Channel) } // RolloutNote explains a serving build that differs from the installed one // when no failure holds it: rolling out, or asleep until the next request. // Plan 5 reports Serving "" for an asleep instance, so asleep compares the // installed build with Good, the build it last ran. A box that could not arm // its scheduler reports neither, so it gets no note. func (d *instanceDetail) RolloutNote() string { r := d.Rollout switch { case r == nil || r.Unavailable != "" || r.HeldBy != "": return "" case !r.Awake && r.Installed != r.Good: return fmt.Sprintf(swapCopy.Asleep, r.Installed) case r.Awake && r.Installed != r.Serving: return fmt.Sprintf(swapCopy.Rolling, r.Installed, r.Serving) } return "" } // layoutUnknownCopy is console.instance.layout.unknown (approved copy): // what a member sees when no box's snapshot has reported this host's // layout at all — a box running code from before layout reporting // existed, or one whose activator has not armed a pass yet. const layoutUnknownCopy = "not reported yet" // layoutWordFor is R10's join: the first box whose layout report names // host wins, and only the word is carried through — the reason is an // operator-only detail (it can carry a path), never shown to a member. func layoutWordFor(boxes []status.Box, host string) string { for _, b := range boxes { if b.Metrics == nil || b.Metrics.Layout == nil { continue } if row, ok := b.Metrics.Layout.Rows[host]; ok { return row.Layout } } return layoutUnknownCopy } type instanceGroup struct { Channel string TargetVersion string TargetErr string Total int Rows []InstanceRow } func ownedInstanceRoutes(routes []RouteView, account string) []RouteView { var out []RouteView for _, rt := range routes { if rt.Account == account && !rt.Platform { out = append(out, rt) } } return out } func instanceChannels(rows []InstanceRow) []string { seen := map[string]bool{} for _, row := range rows { seen[row.Channel] = true } out := make([]string, 0, len(seen)) for channel := range seen { out = append(out, channel) } sort.Strings(out) return out } func groupInstanceRows(rows []InstanceRow, channels []ChannelView, all []InstanceRow) []instanceGroup { targets := map[string]ChannelView{} for _, ch := range channels { targets[ch.Name] = ch } counts := map[string]int{} for _, row := range all { counts[row.Channel]++ } grouped := map[string][]InstanceRow{} for _, row := range rows { grouped[row.Channel] = append(grouped[row.Channel], row) } names := instanceChannels(rows) out := make([]instanceGroup, 0, len(names)) for _, name := range names { ch := targets[name] out = append(out, instanceGroup{Channel: name, TargetVersion: ch.Version, TargetErr: ch.Err, Total: counts[name], Rows: grouped[name]}) } return out } func instanceQuerySuffix(q instanceQuery) string { v := url.Values{} if q.Q != "" { v.Set("q", q.Q) } if q.Health != "" { v.Set("health", healthSlug(q.Health)) } if q.Channel != "" { v.Set("channel", q.Channel) } if q.Group { v.Set("group", "channel") } if q.Cursor != "" { v.Set("cursor", q.Cursor) } if len(v) == 0 { return "" } return "?" + v.Encode() } func encodePageCursor(row InstanceRow, grouped bool) string { if !grouped { return encodeInstanceCursor(row.Host) } b, _ := json.Marshal(instanceCursor{Host: row.Host, Channel: row.Channel, Grouped: true}) return base64.RawURLEncoding.EncodeToString(b) } func decodePageCursor(raw string, grouped bool) (instanceCursor, bool) { b, err := base64.RawURLEncoding.DecodeString(raw) var cur instanceCursor if err != nil || json.Unmarshal(b, &cur) != nil || cur.Host == "" || cur.Grouped != grouped { return cur, false } return cur, true } func (s *Server) handleAppInstance(w http.ResponseWriter, r *http.Request) { p, acct, ok := s.appPage(w, r, "instances") if !ok { return } host := r.PathValue("host") if !provision.ValidHostShape(host) { http.NotFound(w, r) return } var selected *RouteView for _, rt := range p.Detail.Routes { if rt.Host == host && rt.Account == acct.Sqid && !rt.Platform { copy := rt selected = © break } } if selected == nil { http.NotFound(w, r) return } view := *p.Detail view.Routes = []RouteView{*selected} metrics := s.appResourceMetrics(r.Context(), &view, acct) rows := assembleInstanceRows(&view, acct, metrics) if len(rows) == 0 { http.NotFound(w, r) return } detail := &instanceDetail{Row: rows[0], Layout: layoutUnknownCopy} for _, ch := range p.Detail.Channels { if ch.Name == selected.Channel { detail.TargetVersion, detail.TargetErr = ch.Version, ch.Err break } } // One LoadBoxes read (plus fleet boxes, through the same systemBoxes // helper the console's other box readers use) — nil Releases or a read // error both leave the approved unknown text already set above, the // same soft-degrade the alias lookup just below gives a broken registry. detail.App, detail.Channel = p.AppName, selected.Channel if selected.remote { // An instance on another box: everything the page shows about it // came through remoteFacts when its row was built. The box joins // below (layout, rollout, updates) read reports by hostname with // their own, weaker tenant rules, so none of them runs for it; the // page shows the unknown layout and no rollout or updates section. detail.Aliases = append(detail.Aliases, selected.aliases...) } else if s.cfg.Releases != nil { if boxes, _, err := s.systemBoxes(r.Context(), false); err == nil { detail.Layout = layoutWordFor(boxes, host) } // The rollout view comes from the join the list and the retry door // read (loadRolloutJoin: platform boxes and this account's fleets), // not from systemBoxes, which reads every account's fleets. join, _ := s.loadRolloutJoin(r.Context(), acct.Sqid, time.Now(), nil) detail.Rollout = join.rollout(host, acct.Sqid, p.AppName) // The Updates section reads the same join, so its status line and // the Installed and Serving facts above come from one reading. detail.Updates = s.updatesPage(r.Context(), acct, p.AppName, host, detail, join.update(host, acct.Sqid, p.AppName), p.Role == RoleOwner) } if reg, err := openRegistry(s.cfg.Registry); err == nil && !selected.remote { if aliases, err := reg.ListAliases(); err == nil { for _, alias := range aliases { if alias.Host == host && alias.Account == acct.Sqid && alias.App == p.AppName { detail.Aliases = append(detail.Aliases, alias.Alias) } } sort.Strings(detail.Aliases) } } p.InstanceDetail = detail p.InstanceQuery = parseInstanceQuery(r.URL.Query()) p.InstanceListURL = p.Nav + "/apps/" + p.AppName + "/instances" + instanceQuerySuffix(p.InstanceQuery) s.render(w, http.StatusOK, "app_instance", p) }