| 1 | package console |
| 2 | |
| 3 | import ( |
| 4 | "encoding/base64" |
| 5 | "encoding/json" |
| 6 | "fmt" |
| 7 | "net/http" |
| 8 | "net/url" |
| 9 | "sort" |
| 10 | "time" |
| 11 | |
| 12 | "github.com/carlosframework/platform/internal/provision" |
| 13 | "github.com/carlosframework/platform/internal/rolloutwords" |
| 14 | "github.com/carlosframework/platform/internal/status" |
| 15 | ) |
| 16 | |
| 17 | type instanceDetail struct { |
| 18 | Row InstanceRow |
| 19 | TargetVersion string |
| 20 | TargetErr string |
| 21 | Aliases []string |
| 22 | // Layout is the WORD the box's layout pass classified this host as |
| 23 | // (flat, nested, fenced, conflict, foreign, external) — never the |
| 24 | // reason, which can carry a path (R10). The approved fallback text |
| 25 | // when no box's report names this host. |
| 26 | Layout string |
| 27 | // Rollout is this host's rollout as its box last reported it (spec |
| 28 | // §9.2); nil when no fresh box reports one, and the page is as before. |
| 29 | // App and Channel name the `promote --retry` the hold suggests. |
| 30 | Rollout *rolloutView |
| 31 | App, Channel string |
| 32 | // Updates is the instance's update settings section (settings spec §5); |
| 33 | // nil when the host has no instance record or it could not be read. |
| 34 | Updates *updatesPageView |
| 35 | } |
| 36 | |
| 37 | // retryable is whether the box's report holds this host back in a way a |
| 38 | // retry can clear: the host is held, and the box either armed its scheduler |
| 39 | // or declined the flag while still honouring its failure book |
| 40 | // (rolloutwords.UnavailableDeclines). A refusing box, and one whose code |
| 41 | // this console does not know, are never retried. The CLI door |
| 42 | // (retryHostBuild) and the page both ask this, so they cannot disagree. |
| 43 | func (r *rolloutView) retryable() bool { |
| 44 | return r != nil && r.HeldBy != "" && (r.Unavailable == "" || rolloutwords.UnavailableDeclines(r.Unavailable)) |
| 45 | } |
| 46 | |
| 47 | // retryRefusal is why a retryable host's report cannot take a signed grant |
| 48 | // yet: errRetryUnsupported for a box with no retry_v or no book identity, |
| 49 | // errRetryUnsaved for a held host at epoch 0, whose book owes a save. nil |
| 50 | // means the door may sign. The page and retryHostBuild both ask it, so they |
| 51 | // cannot drift. |
| 52 | func (r *rolloutView) retryRefusal() error { |
| 53 | switch { |
| 54 | case r.retryV == 0 || r.heldBook == "": |
| 55 | return errRetryUnsupported |
| 56 | case r.heldEpoch == 0: |
| 57 | return errRetryUnsaved |
| 58 | } |
| 59 | return nil |
| 60 | } |
| 61 | |
| 62 | // CanRetry is whether the page offers Retry: the host is retryable, and the |
| 63 | // report lets the door sign a grant the box accepts (retryRefusal is nil). |
| 64 | func (r *rolloutView) CanRetry() bool { |
| 65 | return r.retryable() && r.retryRefusal() == nil |
| 66 | } |
| 67 | |
| 68 | // CanRetry forwards to the rollout view, since the template's dot is the |
| 69 | // instanceDetail. |
| 70 | func (d *instanceDetail) CanRetry() bool { return d.Rollout.CanRetry() } |
| 71 | |
| 72 | // HoldText is the Failure hold row (spec §9.2), or "" when nothing holds |
| 73 | // this host back that a retry can clear (and always for a box that could |
| 74 | // not arm its scheduler and did not decline the flag, whose view carries no |
| 75 | // hold). A hold-mode failure that left the instance down (attempt down) has |
| 76 | // nothing serving it, so its row says so rather than naming a build it |
| 77 | // "stays on" (Astra plan 6 R1 #8). A box that declined the flag holds the |
| 78 | // build too but will not start the instance on it, so its row says that |
| 79 | // instead; its Reason is empty and is never formatted. |
| 80 | func (d *instanceDetail) HoldText() string { |
| 81 | r := d.Rollout |
| 82 | if !r.retryable() { |
| 83 | return "" |
| 84 | } |
| 85 | if r.Unavailable != "" { |
| 86 | if r.Attempt == status.AttemptDown { |
| 87 | return fmt.Sprintf(swapCopy.HoldDeclinedDownText, r.HeldBy) |
| 88 | } |
| 89 | return fmt.Sprintf(swapCopy.HoldDeclinedText, r.HeldBy) |
| 90 | } |
| 91 | if r.Attempt == status.AttemptDown { |
| 92 | return fmt.Sprintf(swapCopy.HoldDownText, r.HeldBy) |
| 93 | } |
| 94 | stays := r.Serving |
| 95 | if stays == "" { |
| 96 | stays = r.Good |
| 97 | } |
| 98 | return fmt.Sprintf(swapCopy.HoldText, r.HeldBy, rolloutwords.Reason(r.Reason), stays) |
| 99 | } |
| 100 | |
| 101 | // PromoteRetryText names the app-wide retry (spec rev 14 §8.4) beside the |
| 102 | // one-instance Retry button. |
| 103 | func (d *instanceDetail) PromoteRetryText() string { |
| 104 | if d.HoldText() == "" { |
| 105 | return "" |
| 106 | } |
| 107 | return fmt.Sprintf(swapCopy.PromoteRetry, d.App, d.Rollout.HeldBy, d.Channel) |
| 108 | } |
| 109 | |
| 110 | // RolloutNote explains a serving build that differs from the installed one |
| 111 | // when no failure holds it: rolling out, or asleep until the next request. |
| 112 | // Plan 5 reports Serving "" for an asleep instance, so asleep compares the |
| 113 | // installed build with Good, the build it last ran. A box that could not arm |
| 114 | // its scheduler reports neither, so it gets no note. |
| 115 | func (d *instanceDetail) RolloutNote() string { |
| 116 | r := d.Rollout |
| 117 | switch { |
| 118 | case r == nil || r.Unavailable != "" || r.HeldBy != "": |
| 119 | return "" |
| 120 | case !r.Awake && r.Installed != r.Good: |
| 121 | return fmt.Sprintf(swapCopy.Asleep, r.Installed) |
| 122 | case r.Awake && r.Installed != r.Serving: |
| 123 | return fmt.Sprintf(swapCopy.Rolling, r.Installed, r.Serving) |
| 124 | } |
| 125 | return "" |
| 126 | } |
| 127 | |
| 128 | // layoutUnknownCopy is console.instance.layout.unknown (approved copy): |
| 129 | // what a member sees when no box's snapshot has reported this host's |
| 130 | // layout at all — a box running code from before layout reporting |
| 131 | // existed, or one whose activator has not armed a pass yet. |
| 132 | const layoutUnknownCopy = "not reported yet" |
| 133 | |
| 134 | // layoutWordFor is R10's join: the first box whose layout report names |
| 135 | // host wins, and only the word is carried through — the reason is an |
| 136 | // operator-only detail (it can carry a path), never shown to a member. |
| 137 | func layoutWordFor(boxes []status.Box, host string) string { |
| 138 | for _, b := range boxes { |
| 139 | if b.Metrics == nil || b.Metrics.Layout == nil { |
| 140 | continue |
| 141 | } |
| 142 | if row, ok := b.Metrics.Layout.Rows[host]; ok { |
| 143 | return row.Layout |
| 144 | } |
| 145 | } |
| 146 | return layoutUnknownCopy |
| 147 | } |
| 148 | |
| 149 | type instanceGroup struct { |
| 150 | Channel string |
| 151 | TargetVersion string |
| 152 | TargetErr string |
| 153 | Total int |
| 154 | Rows []InstanceRow |
| 155 | } |
| 156 | |
| 157 | func ownedInstanceRoutes(routes []RouteView, account string) []RouteView { |
| 158 | var out []RouteView |
| 159 | for _, rt := range routes { |
| 160 | if rt.Account == account && !rt.Platform { |
| 161 | out = append(out, rt) |
| 162 | } |
| 163 | } |
| 164 | return out |
| 165 | } |
| 166 | |
| 167 | func instanceChannels(rows []InstanceRow) []string { |
| 168 | seen := map[string]bool{} |
| 169 | for _, row := range rows { |
| 170 | seen[row.Channel] = true |
| 171 | } |
| 172 | out := make([]string, 0, len(seen)) |
| 173 | for channel := range seen { |
| 174 | out = append(out, channel) |
| 175 | } |
| 176 | sort.Strings(out) |
| 177 | return out |
| 178 | } |
| 179 | |
| 180 | func groupInstanceRows(rows []InstanceRow, channels []ChannelView, all []InstanceRow) []instanceGroup { |
| 181 | targets := map[string]ChannelView{} |
| 182 | for _, ch := range channels { |
| 183 | targets[ch.Name] = ch |
| 184 | } |
| 185 | counts := map[string]int{} |
| 186 | for _, row := range all { |
| 187 | counts[row.Channel]++ |
| 188 | } |
| 189 | grouped := map[string][]InstanceRow{} |
| 190 | for _, row := range rows { |
| 191 | grouped[row.Channel] = append(grouped[row.Channel], row) |
| 192 | } |
| 193 | names := instanceChannels(rows) |
| 194 | out := make([]instanceGroup, 0, len(names)) |
| 195 | for _, name := range names { |
| 196 | ch := targets[name] |
| 197 | out = append(out, instanceGroup{Channel: name, TargetVersion: ch.Version, TargetErr: ch.Err, Total: counts[name], Rows: grouped[name]}) |
| 198 | } |
| 199 | return out |
| 200 | } |
| 201 | |
| 202 | func instanceQuerySuffix(q instanceQuery) string { |
| 203 | v := url.Values{} |
| 204 | if q.Q != "" { |
| 205 | v.Set("q", q.Q) |
| 206 | } |
| 207 | if q.Health != "" { |
| 208 | v.Set("health", healthSlug(q.Health)) |
| 209 | } |
| 210 | if q.Channel != "" { |
| 211 | v.Set("channel", q.Channel) |
| 212 | } |
| 213 | if q.Group { |
| 214 | v.Set("group", "channel") |
| 215 | } |
| 216 | if q.Cursor != "" { |
| 217 | v.Set("cursor", q.Cursor) |
| 218 | } |
| 219 | if len(v) == 0 { |
| 220 | return "" |
| 221 | } |
| 222 | return "?" + v.Encode() |
| 223 | } |
| 224 | |
| 225 | func encodePageCursor(row InstanceRow, grouped bool) string { |
| 226 | if !grouped { |
| 227 | return encodeInstanceCursor(row.Host) |
| 228 | } |
| 229 | b, _ := json.Marshal(instanceCursor{Host: row.Host, Channel: row.Channel, Grouped: true}) |
| 230 | return base64.RawURLEncoding.EncodeToString(b) |
| 231 | } |
| 232 | |
| 233 | func decodePageCursor(raw string, grouped bool) (instanceCursor, bool) { |
| 234 | b, err := base64.RawURLEncoding.DecodeString(raw) |
| 235 | var cur instanceCursor |
| 236 | if err != nil || json.Unmarshal(b, &cur) != nil || cur.Host == "" || cur.Grouped != grouped { |
| 237 | return cur, false |
| 238 | } |
| 239 | return cur, true |
| 240 | } |
| 241 | |
| 242 | func (s *Server) handleAppInstance(w http.ResponseWriter, r *http.Request) { |
| 243 | p, acct, ok := s.appPage(w, r, "instances") |
| 244 | if !ok { |
| 245 | return |
| 246 | } |
| 247 | host := r.PathValue("host") |
| 248 | if !provision.ValidHostShape(host) { |
| 249 | http.NotFound(w, r) |
| 250 | return |
| 251 | } |
| 252 | var selected *RouteView |
| 253 | for _, rt := range p.Detail.Routes { |
| 254 | if rt.Host == host && rt.Account == acct.Sqid && !rt.Platform { |
| 255 | copy := rt |
| 256 | selected = © |
| 257 | break |
| 258 | } |
| 259 | } |
| 260 | if selected == nil { |
| 261 | http.NotFound(w, r) |
| 262 | return |
| 263 | } |
| 264 | view := *p.Detail |
| 265 | view.Routes = []RouteView{*selected} |
| 266 | metrics := s.appResourceMetrics(r.Context(), &view, acct) |
| 267 | rows := assembleInstanceRows(&view, acct, metrics) |
| 268 | if len(rows) == 0 { |
| 269 | http.NotFound(w, r) |
| 270 | return |
| 271 | } |
| 272 | detail := &instanceDetail{Row: rows[0], Layout: layoutUnknownCopy} |
| 273 | for _, ch := range p.Detail.Channels { |
| 274 | if ch.Name == selected.Channel { |
| 275 | detail.TargetVersion, detail.TargetErr = ch.Version, ch.Err |
| 276 | break |
| 277 | } |
| 278 | } |
| 279 | // One LoadBoxes read (plus fleet boxes, through the same systemBoxes |
| 280 | // helper the console's other box readers use) — nil Releases or a read |
| 281 | // error both leave the approved unknown text already set above, the |
| 282 | // same soft-degrade the alias lookup just below gives a broken registry. |
| 283 | detail.App, detail.Channel = p.AppName, selected.Channel |
| 284 | if s.cfg.Releases != nil { |
| 285 | if boxes, _, err := s.systemBoxes(r.Context(), false); err == nil { |
| 286 | detail.Layout = layoutWordFor(boxes, host) |
| 287 | } |
| 288 | // The rollout view comes from the join the list and the retry door |
| 289 | // read (loadRolloutJoin: platform boxes and this account's fleets), |
| 290 | // not from systemBoxes, which reads every account's fleets. |
| 291 | join, _ := s.loadRolloutJoin(r.Context(), acct.Sqid, time.Now(), nil) |
| 292 | detail.Rollout = join.rollout(host, acct.Sqid, p.AppName) |
| 293 | // The Updates section reads the same join, so its status line and |
| 294 | // the Installed and Serving facts above come from one reading. |
| 295 | detail.Updates = s.updatesPage(r.Context(), acct, p.AppName, host, detail, join.update(host, acct.Sqid, p.AppName), p.Role == RoleOwner) |
| 296 | } |
| 297 | if reg, err := openRegistry(s.cfg.Registry); err == nil { |
| 298 | if aliases, err := reg.ListAliases(); err == nil { |
| 299 | for _, alias := range aliases { |
| 300 | if alias.Host == host && alias.Account == acct.Sqid && alias.App == p.AppName { |
| 301 | detail.Aliases = append(detail.Aliases, alias.Alias) |
| 302 | } |
| 303 | } |
| 304 | sort.Strings(detail.Aliases) |
| 305 | } |
| 306 | } |
| 307 | p.InstanceDetail = detail |
| 308 | p.InstanceQuery = parseInstanceQuery(r.URL.Query()) |
| 309 | p.InstanceListURL = p.Nav + "/apps/" + p.AppName + "/instances" + instanceQuerySuffix(p.InstanceQuery) |
| 310 | s.render(w, http.StatusOK, "app_instance", p) |
| 311 | } |
| 312 | |