carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package console
2
3import (
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
17type 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.
43func (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.
52func (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).
64func (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.
70func (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.
80func (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.
103func (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.
115func (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.
132const 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.
137func 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
149type instanceGroup struct {
150 Channel string
151 TargetVersion string
152 TargetErr string
153 Total int
154 Rows []InstanceRow
155}
156
157func 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
167func 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
180func 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
202func 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
225func 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
233func 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
242func (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 = &copy
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