carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3// deploywatch.go is `carlos deploy`'s watch once boxes report their rollouts
4// (zero-downtime spec §9.1): completion is tied to the build being watched,
5// instance by instance, box by box. A host its box reports on is watched
6// through that report and never requested over HTTP, because a request would
7// wake an asleep instance the spec counts as done once its box has installed
8// the build. A host no box reports on is watched as it always was
9// (checkTarget). With no report at all, the watch IS watchVersion.
10
11import (
12 "context"
13 "encoding/json"
14 "errors"
15 "fmt"
16 "io"
17 "net/http"
18 "net/url"
19 "sort"
20 "strings"
21 "sync"
22 "time"
23
24 "github.com/carlosframework/platform/internal/rolloutwords"
25 "github.com/carlosframework/platform/internal/status"
26)
27
28const (
29 watchIntro = "watching %s roll out box by box: an awake instance counts once the new build has taken over, an asleep one once its box has installed it"
30 watchWordSwapped = "swapped"
31 watchWordInstalled = "installed"
32 watchAsleepTail = "starts on its next request"
33 watchWordFailed = "failed"
34 watchWordHeld = "held"
35 watchHeldTail = "stayed on %s because %s failed on this box"
36 watchBox = "box %s: %s"
37 watchCountSwapped = "%d swapped"
38 watchCountAsleep = "%d asleep with it installed"
39 watchCountFailed = "%d failed"
40 watchCountHeld = "%d stayed on their last good build"
41 watchFailedSummary = "build %s did not reach every instance: %d failed and %d stayed on their last good build. Fix it and deploy a new build, or try this one again: carlos promote --app %s --retry %s %s"
42 watchHoldDownSummary = "an instance that swaps in hold mode is down, with nothing to fall back to: carlos rollback --app %s"
43 watchTimeout = "timed out waiting for build %s:"
44 watchPendingNotInstalled = "its box has not installed this build yet"
45 watchPendingOther = "its box has %s installed, not this build"
46 watchPendingQueued = "queued"
47 watchPendingRunning = "starting beside the running build"
48 watchPendingSettling = "settling: making sure it stays up for 10 seconds"
49 watchPendingWaiting = "waiting for its box to start it"
50 watchPendingDark = "its box stopped reporting"
51 watchPendingUnlisted = "the console's instance list was cut short before it, so its state is unknown"
52 watchBoxBusy = "box %s is still working through this app's instances"
53 watchListFailed = "the console's instance list could not be read, so the rollout could not be followed"
54 watchPendingUnread = "the console could not read every box's report, so its state is unknown"
55)
56
57// watchList is one answer of the console's instance list, as the watch
58// reads it. incomplete is the console's rollout_incomplete: some box's
59// report could not be read, so a host with no report may yet have one.
60type watchList struct {
61 recs []apiInstanceRecord
62 truncated bool
63 incomplete bool
64}
65
66// listStatusError is a list the console answered with a status other than
67// 200. The status decides what the watch makes of it (listAbsent).
68type listStatusError struct {
69 status int
70 err error
71}
72
73func (e *listStatusError) Error() string { return e.err.Error() }
74func (e *listStatusError) Unwrap() error { return e.err }
75
76// listAbsent reports whether a failed list is evidence enough to fall back
77// to today's URL watch (Astra plan 6 R1 #4, R2 #4). A 4xx says the console
78// has no list for this caller: a console from before the door, an app with
79// no instances (404). A 403 counts too, on purpose: a Publish-only token can
80// deploy (promote admits Publish) but cannot read the list (GET instances
81// admits Instance and Deploy), so such a deploy gets exactly the watch it
82// got before reports existed. Not a 401: the deploy has just authenticated
83// with this token, so a 401 is a session that lapsed or a console that
84// stumbled, and says nothing about the boxes. Not a 429 either. Those, a
85// 5xx, a network error and an unreadable answer are retried until the
86// deadline.
87func listAbsent(err error) bool {
88 var se *listStatusError
89 return errors.As(err, &se) && se.status >= 400 && se.status < 500 &&
90 se.status != http.StatusTooManyRequests && se.status != http.StatusUnauthorized
91}
92
93// watchInstanceList is the watch's read of the instance list. It is
94// instanceList's request, kept apart because the watch needs what that
95// method drops: the status of a refusal, and rollout_incomplete.
96func (s *apiSession) watchInstanceList(ctx context.Context, a apiAcct, app string) (watchList, error) {
97 status, body, err := s.getJSON(ctx, apiPath(a, app, "/instances"))
98 if err != nil {
99 return watchList{}, err
100 }
101 if status != http.StatusOK {
102 return watchList{}, &listStatusError{status: status, err: apiErrorFromBody(status, body)}
103 }
104 var res struct {
105 Instances []apiInstanceRecord `json:"instances"`
106 Truncated bool `json:"truncated"`
107 RolloutIncomplete bool `json:"rollout_incomplete"`
108 }
109 if err := json.Unmarshal(body, &res); err != nil {
110 return watchList{}, fmt.Errorf("instances list: unreadable response: %w", err)
111 }
112 return watchList{recs: res.Instances, truncated: res.Truncated, incomplete: res.RolloutIncomplete}, nil
113}
114
115// deployWatchListEvery is how often the production watch reads the instance
116// list (controller ruling R5). The list is rate limited per token (60 a
117// minute, shared with the deploy's own calls), and each read costs the
118// console a records list and a box join, so it keeps its own cadence; a host
119// watched by URL is still polled every deployWatchPollEvery, as before. A
120// failing first list is asked again on the same cadence until the deadline.
121const (
122 deployWatchListEvery = 5 * time.Second
123 deployWatchPollEvery = 2 * time.Second
124)
125
126// rolloutState is one targeted instance's place in the rollout of the
127// watched build (spec §9.1): pending, or one of four final states.
128type rolloutState string
129
130const (
131 rolloutPending rolloutState = "pending"
132 rolloutSwapped rolloutState = "swapped"
133 rolloutAsleep rolloutState = "asleep" // installed, awaiting first wake
134 rolloutFailed rolloutState = "failed"
135 rolloutHeld rolloutState = "held"
136)
137
138func (s rolloutState) final() bool {
139 return s == rolloutSwapped || s == rolloutAsleep || s == rolloutFailed || s == rolloutHeld
140}
141
142// attemptOpen reports whether a box's attempt word is still in progress:
143// queued, running or settling, or a word this binary does not know (a newer
144// box), which is safer read as unfinished than as done. Failed and down are
145// outcomes, not attempts in progress.
146func attemptOpen(a string) bool {
147 return a != "" && a != status.AttemptFailed && a != status.AttemptDown
148}
149
150// classifyRollout decides one instance's state for build ver from its box's
151// report. In order:
152// - A box that cannot arm its scheduler (Unavailable) reports no per-host
153// state, so its awake=false means nothing: pending (R6).
154// - Until the box reports ver installed the instance is pending, however
155// idle its scheduler.
156// - A failure counts against ver only when the failed attempt was ver's,
157// and a hold-mode instance left down by ver is failed too (spec §9.1 "a
158// hold failure"). The console's join (Task 8) leaves the failed attempt
159// only on the host the build failed on, so this is checked before
160// held_by, which that host carries too.
161// - Held by ver, with no failure of its own: a sibling's failure record
162// holds it on its good build.
163// - A transient host (its process is starting or stopping) is pending: it
164// is neither asleep nor swapped, and its serving build says nothing
165// settled (Astra R3 section 5 item 10). A failure or a hold it already
166// carries still stands, as above.
167// - An attempt in progress is pending whether or not the instance is awake
168// (R8): a canary serving ver has not swapped until its settle closes, and
169// an asleep instance whose first wake is under observation is not asleep.
170// - Serving ver with nothing in progress: swapped. Asleep with nothing in
171// progress: installed, awaiting first wake.
172func classifyRollout(r *apiRollout, ver string) rolloutState {
173 switch {
174 case r.Unavailable != "":
175 return rolloutPending
176 case r.Installed != ver:
177 return rolloutPending
178 case r.AttemptBuild == ver && (r.Attempt == status.AttemptFailed || r.Attempt == status.AttemptDown):
179 return rolloutFailed
180 case r.HeldBy == ver:
181 return rolloutHeld
182 case r.Transient:
183 return rolloutPending
184 case attemptOpen(r.Attempt):
185 return rolloutPending
186 case r.Serving == ver:
187 return rolloutSwapped
188 case !r.Awake && r.Serving == "" && r.Attempt == "":
189 return rolloutAsleep
190 }
191 return rolloutPending
192}
193
194// follows reports whether the watch follows an instance through r: a report
195// with per-host state, or one from a box that cannot arm its scheduler for
196// any reason but a known decline: a refusing box's instances wait, and a
197// code this binary does not know is a newer box's problem, so neither may be
198// woken by a poll. A box that declined overlap runs today's stop-then-start
199// paths, so its report is no report: the instance is watched by URL, like a
200// flag-off box's (R6; fix round 1).
201func follows(r *apiRollout) bool {
202 return r != nil && !rolloutwords.UnavailableDeclines(r.Unavailable)
203}
204
205type watchMode int
206
207const (
208 modeUnknown watchMode = iota // not yet decided: a truncated list left it out, or an incomplete join had no report for it
209 modeRollout // watched through its box's report, for the rest of the watch
210 modeURL // watched by URL, as watchVersion does, until a report comes
211)
212
213type watchedHost struct {
214 t watchTarget
215 mode watchMode
216 // ro is the latest report the watch followed. It is kept when the box
217 // goes dark, so the host still counts on its box.
218 ro *apiRollout
219 // dark is set while the latest complete list carries no report for a
220 // host the watch follows (R1). It never sends the host to a URL poll.
221 dark bool
222 // unread is set while the host is undecided because the console's
223 // rollout join was incomplete, not because the list was cut short.
224 unread bool
225 // absent is set while the latest answered list does not carry the host
226 // at all: cut short, or a record the console could not read (Astra plan
227 // 6 R3). An absent host is never polled by URL, even if it was being
228 // polled before; it is cleared the next time the host is listed.
229 absent bool
230 state rolloutState
231 // fin is the report the host went final on. A final state survives
232 // missing observations, and later reports that agree the build is
233 // installed: what it printed and what it counts come from fin. A report
234 // that the box has another build installed clears it (apply).
235 fin *apiRollout
236 urlLive bool
237 urlDiag string
238 printed bool
239}
240
241// urlPolling is whether this sweep requests the host's URL: it is watched by
242// URL, is not yet live, and the console still lists it.
243func (h *watchedHost) urlPolling() bool {
244 return h.mode == modeURL && !h.urlLive && !h.absent
245}
246
247func (h *watchedHost) done() bool {
248 switch h.mode {
249 case modeURL:
250 return h.urlLive
251 case modeRollout:
252 return h.state.final()
253 }
254 return false
255}
256
257// rolloutWatch is one watch. list is the console's instance list, asked to
258// answer by the watch's deadline; check is one URL poll (checkTarget in
259// production); onURL runs once, the first time any host is watched by
260// URL, so the probe-or-header note prints only when it is true. channel names
261// the `promote --retry` a failure suggests. interval is the URL poll cadence
262// and listEvery the list's (R5); a zero listEvery lists on every sweep.
263type rolloutWatch struct {
264 w io.Writer
265 app, ver, channel string
266 timeout, interval time.Duration
267 listEvery time.Duration
268 list func(deadline time.Time) (watchList, error)
269 check func(t watchTarget, n int64) (bool, string)
270 onURL func()
271 diag *watchDiagnostics
272}
273
274func (rw rolloutWatch) run(targets []watchTarget) error {
275 // One deadline governs the whole watch (Astra plan 6 R1 #2): every list
276 // request, every wait between lists, and what is left for watchVersion.
277 deadline := time.Now().Add(rw.timeout)
278 // A failed list (a 5xx, a rate limit, a console restart, the network)
279 // says nothing about the boxes, so it is asked again on the list's
280 // cadence until the deadline, and never read as "no reports" (Astra plan
281 // 6 R1 #4: a URL poll would wake an asleep instance on a reporting box).
282 l, err := rw.list(deadline)
283 for err != nil && !listAbsent(err) {
284 left := time.Until(deadline)
285 if left <= 0 {
286 return rw.listFailed(err)
287 }
288 time.Sleep(min(max(rw.listEvery, rw.interval), left))
289 if time.Until(deadline) <= 0 {
290 return rw.listFailed(err)
291 }
292 l, err = rw.list(deadline)
293 }
294 lastList := time.Now()
295 // Today's watch, byte for byte (R10), only on affirmative evidence that
296 // no target is on a reporting box: the console has no list for the app
297 // (listAbsent), or every target is listed, the console read every box's
298 // report, and none of the targets has one the watch follows (legacyOnly).
299 if err != nil || legacyOnly(l, targets) {
300 if rw.onURL != nil {
301 rw.onURL()
302 }
303 return watchVersion(rw.w, targets, rw.ver, time.Until(deadline), rw.interval, rw.diag)
304 }
305 var hosts []*watchedHost
306 seen := map[string]bool{}
307 for _, t := range targets {
308 if !seen[t.Host] {
309 seen[t.Host] = true
310 hosts = append(hosts, &watchedHost{t: t, state: rolloutPending})
311 }
312 }
313 c := colorFor(rw.w)
314 mutedf(rw.w, watchIntro, rw.ver)
315 rw.apply(hosts, l)
316 boxPrinted := map[string]bool{}
317 announcedURL := false
318 var n int64
319 for {
320 urlPending := false
321 for _, h := range hosts {
322 if h.urlPolling() {
323 urlPending = true
324 }
325 }
326 if urlPending && !announcedURL {
327 announcedURL = true
328 if rw.onURL != nil {
329 rw.onURL()
330 }
331 }
332 var wg sync.WaitGroup
333 for _, h := range hosts {
334 if !h.urlPolling() {
335 continue
336 }
337 n++
338 wg.Add(1)
339 go func(h *watchedHost, n int64) {
340 defer wg.Done()
341 h.urlLive, h.urlDiag = rw.check(h.t, n)
342 }(h, n)
343 }
344 wg.Wait()
345 for _, h := range hosts {
346 if h.done() && !h.printed {
347 h.printed = true
348 rw.printHost(c, h)
349 }
350 }
351 boxes, labels := groupByBox(hosts)
352 boxesDone := true
353 for _, l := range labels {
354 if !boxDone(boxes[l]) {
355 boxesDone = false
356 continue
357 }
358 if !boxPrinted[l] {
359 boxPrinted[l] = true
360 fmt.Fprintln(rw.w, boxSummary(l, boxes[l]))
361 }
362 }
363 hostsDone := true
364 for _, h := range hosts {
365 hostsDone = hostsDone && h.done()
366 }
367 if hostsDone && boxesDone {
368 return rw.result(hosts)
369 }
370 if time.Now().After(deadline) {
371 return rw.timedOut(hosts, boxes, labels)
372 }
373 time.Sleep(rw.wait(hosts, lastList, deadline))
374 if time.Since(lastList) >= rw.listEvery {
375 l, err = rw.list(deadline)
376 lastList = time.Now()
377 // A list that failed (a rate limit, a console restart) changes
378 // nothing: every host keeps its mode and state until a list
379 // answers.
380 if err == nil {
381 rw.apply(hosts, l)
382 }
383 }
384 }
385}
386
387// wait is how long the loop sleeps: the URL poll interval while any host is
388// watched by URL, otherwise until the next list is due; never past the
389// deadline, so the timeout report comes on time.
390func (rw rolloutWatch) wait(hosts []*watchedHost, lastList, deadline time.Time) time.Duration {
391 d := rw.interval
392 urlPending := false
393 for _, h := range hosts {
394 urlPending = urlPending || h.urlPolling()
395 }
396 if !urlPending {
397 d = max(d, time.Until(lastList.Add(rw.listEvery)))
398 }
399 if left := time.Until(deadline); left > 0 && left < d {
400 d = left
401 }
402 return d
403}
404
405// legacyOnly is the evidence the fallback to watchVersion needs (Astra plan
406// 6 R1 #4, #7): every target is on the list, the console's rollout join read
407// every box, and no target has a report the watch follows. It is decided on
408// the targets alone. A list cut short for the whole app neither causes nor
409// blocks it, and a report on an instance this deploy does not target says
410// nothing about the targets' boxes.
411func legacyOnly(l watchList, targets []watchTarget) bool {
412 if l.incomplete {
413 return false
414 }
415 listed := make(map[string]*apiRollout, len(l.recs))
416 for _, r := range l.recs {
417 listed[r.Host] = r.Rollout
418 }
419 for _, t := range targets {
420 ro, ok := listed[t.Host]
421 if !ok || follows(ro) {
422 return false
423 }
424 }
425 return true
426}
427
428// listFailed is the timeout when no list answered before the deadline:
429// nothing was followed and nothing was polled, so it names the list.
430func (rw rolloutWatch) listFailed(err error) error {
431 var b strings.Builder
432 fmt.Fprintf(&b, watchTimeout, rw.ver)
433 fmt.Fprintf(&b, "\n %s: %v", watchListFailed, err)
434 if rw.diag != nil && rw.diag.logsHint != "" {
435 fmt.Fprintf(&b, "\nfull timeline: %s", rw.diag.logsHint)
436 }
437 return errors.New(b.String())
438}
439
440// apply folds one answered list into the hosts.
441//
442// - A host with a report the watch follows is watched through it from now
443// on (R4), also when it was polled by URL, even live: the report is the
444// box's word about which build runs there, and a URL answer is not
445// (Astra plan 6 R1 #3). Its report is refreshed every list even once it
446// is final, because its box is done only on the box's latest scheduler
447// word.
448// - A final state persists only across MISSING observations: a host
449// absent from a truncated list, a dark box. An explicit report that the
450// host's box has another build installed takes a swapped, asleep or held
451// host back to pending, named by what is installed (pendingDetail): the
452// watched build no longer runs there, and Review Focus 4 forbids success
453// for a replaced build. A failure stays failed: the build did fail.
454// - A host missing from the list keeps its mode and state (R3), whether
455// the cap cut it off or the console could not read its record: nothing
456// says it uses the old path, and switching it to URL polling would wake
457// it if it is asleep. It is also no longer polled if it was (absent),
458// until it is listed again (Astra plan 6 R3).
459// - A host with no followed report on a list whose rollout join was
460// incomplete keeps its mode and state too: its box's report may be the
461// one the console could not read (Astra plan 6 R1 #4). An undecided host
462// stays undecided, and is named so in a timeout.
463// - A host the watch follows whose report is gone is dark (R1): pending if
464// it was not final, never polled.
465// - Any other host is watched by URL, exactly as watchVersion would.
466func (rw rolloutWatch) apply(hosts []*watchedHost, l watchList) {
467 listed := make(map[string]*apiRollout, len(l.recs))
468 for i := range l.recs {
469 listed[l.recs[i].Host] = l.recs[i].Rollout
470 }
471 for _, h := range hosts {
472 ro, ok := listed[h.t.Host]
473 h.absent = !ok
474 switch {
475 case follows(ro):
476 if h.mode != modeRollout {
477 // What a URL answer printed ("live") stands only if the
478 // report agrees; otherwise the report's own word prints
479 // once it is final.
480 h.state, h.fin = rolloutPending, nil
481 }
482 h.mode, h.ro, h.dark, h.unread = modeRollout, ro, false, false
483 switch {
484 case !h.state.final():
485 h.state = classifyRollout(ro, rw.ver)
486 if h.state.final() {
487 fin := *ro
488 h.fin = &fin
489 }
490 h.printed = h.printed && h.state == rolloutSwapped
491 case h.state != rolloutFailed && ro.Installed != rw.ver:
492 h.state, h.fin, h.printed = rolloutPending, nil, false
493 }
494 case !ok:
495 // Absent from this list: cut short, or a record the console could
496 // not read. Neither says the host uses the old path, so it keeps
497 // its mode and state and is never moved to URL watching here
498 // (Astra plan 6 R3). On an untruncated list the console left it
499 // out because it could not read something, which is what
500 // watchPendingUnread says.
501 h.unread = !l.truncated
502 case h.mode == modeURL && h.urlLive:
503 // Live by URL is final too, until a report says otherwise.
504 case l.incomplete:
505 h.unread = h.mode == modeUnknown
506 case h.mode == modeRollout:
507 h.dark = true
508 default:
509 h.mode = modeURL
510 }
511 }
512}
513
514func groupByBox(hosts []*watchedHost) (map[string][]*watchedHost, []string) {
515 boxes := map[string][]*watchedHost{}
516 var labels []string
517 for _, h := range hosts {
518 if h.mode != modeRollout || h.ro == nil {
519 continue
520 }
521 if _, ok := boxes[h.ro.Box]; !ok {
522 labels = append(labels, h.ro.Box)
523 }
524 boxes[h.ro.Box] = append(boxes[h.ro.Box], h)
525 }
526 sort.Strings(labels)
527 return boxes, labels
528}
529
530// boxDone is spec §9.1's rule: every targeted instance on the box is final,
531// and its scheduler has nothing queued, running or settling for the app, by
532// the latest word the watch has from the box.
533func boxDone(hs []*watchedHost) bool {
534 for _, h := range hs {
535 if !h.state.final() || !h.ro.SchedulerIdle {
536 return false
537 }
538 }
539 return true
540}
541
542func boxSummary(label string, hs []*watchedHost) string {
543 counts := map[rolloutState]int{}
544 for _, h := range hs {
545 counts[h.state]++
546 }
547 var parts []string
548 for _, p := range []struct {
549 s rolloutState
550 format string
551 }{{rolloutSwapped, watchCountSwapped}, {rolloutAsleep, watchCountAsleep}, {rolloutFailed, watchCountFailed}, {rolloutHeld, watchCountHeld}} {
552 if counts[p.s] > 0 {
553 parts = append(parts, fmt.Sprintf(p.format, counts[p.s]))
554 }
555 }
556 return fmt.Sprintf(watchBox, label, strings.Join(parts, ", "))
557}
558
559// stayedOn is the build a held instance is still on: what it serves, or,
560// asleep, its last good build.
561func stayedOn(r *apiRollout) string {
562 if r.Serving != "" {
563 return r.Serving
564 }
565 return r.Good
566}
567
568func (rw rolloutWatch) printHost(c colors, h *watchedHost) {
569 host := h.t.Host
570 if h.mode == modeURL {
571 fmt.Fprintf(rw.w, "%s https://%s\n", c.ok("live"), host)
572 return
573 }
574 switch h.state {
575 case rolloutSwapped:
576 fmt.Fprintf(rw.w, "%s https://%s\n", c.ok(watchWordSwapped), host)
577 case rolloutAsleep:
578 fmt.Fprintf(rw.w, "%s https://%s (%s)\n", c.ok(watchWordInstalled), host, watchAsleepTail)
579 case rolloutFailed:
580 fmt.Fprintf(rw.w, "%s https://%s: %s\n", c.warn(watchWordFailed), host, rolloutwords.Reason(h.fin.Reason))
581 case rolloutHeld:
582 fmt.Fprintf(rw.w, "%s https://%s: %s\n", c.warn(watchWordHeld), host, fmt.Sprintf(watchHeldTail, stayedOn(h.fin), rw.ver))
583 }
584}
585
586func (rw rolloutWatch) result(hosts []*watchedHost) error {
587 failed, held, down := 0, 0, false
588 for _, h := range hosts {
589 if h.mode != modeRollout {
590 continue
591 }
592 switch h.state {
593 case rolloutFailed:
594 failed++
595 down = down || h.fin.Attempt == status.AttemptDown
596 case rolloutHeld:
597 held++
598 }
599 }
600 if failed == 0 && held == 0 {
601 return nil
602 }
603 msg := fmt.Sprintf(watchFailedSummary, rw.ver, failed, held, rw.app, rw.ver, rw.channel)
604 if down {
605 msg += "\n" + fmt.Sprintf(watchHoldDownSummary, rw.app)
606 }
607 return errors.New(msg)
608}
609
610// pendingDetail is what a timeout says about a host still pending.
611func (rw rolloutWatch) pendingDetail(h *watchedHost) string {
612 r := h.ro
613 switch {
614 case h.mode == modeUnknown && h.unread:
615 return watchPendingUnread
616 case h.mode == modeUnknown:
617 return watchPendingUnlisted
618 case h.mode == modeURL && h.absent && h.urlDiag == "":
619 // Dropped off the list before any poll said anything.
620 if h.unread {
621 return watchPendingUnread
622 }
623 return watchPendingUnlisted
624 case h.mode == modeURL:
625 return h.urlDiag
626 case h.dark:
627 return watchPendingDark
628 case r.Unavailable != "":
629 return rolloutwords.Unavailable(r.Unavailable)
630 case r.Installed == "":
631 return watchPendingNotInstalled
632 case r.Installed != rw.ver:
633 return fmt.Sprintf(watchPendingOther, r.Installed)
634 case r.Attempt == status.AttemptQueued:
635 return watchPendingQueued
636 case r.Attempt == status.AttemptRunning:
637 return watchPendingRunning
638 case r.Attempt == status.AttemptSettling:
639 return watchPendingSettling
640 }
641 return watchPendingWaiting
642}
643
644func (rw rolloutWatch) timedOut(hosts []*watchedHost, boxes map[string][]*watchedHost, labels []string) error {
645 var b strings.Builder
646 fmt.Fprintf(&b, watchTimeout, rw.ver)
647 var pending []*watchedHost
648 for _, h := range hosts {
649 if !h.done() {
650 pending = append(pending, h)
651 }
652 }
653 sort.Slice(pending, func(i, j int) bool { return pending[i].t.Host < pending[j].t.Host })
654 for _, h := range pending {
655 fmt.Fprintf(&b, "\n %s: %s", h.t.Host, rw.pendingDetail(h))
656 // The reason a wake failed, from the platform log stream, as
657 // watchVersion gives it: only for a host polled by URL, whose poll
658 // is what woke it.
659 if h.mode == modeURL && rw.diag != nil && rw.diag.wakeReason != nil {
660 if r := rw.diag.wakeReason(h.t.Host); r != "" {
661 fmt.Fprintf(&b, "\n %s", r)
662 }
663 }
664 }
665 for _, l := range labels {
666 allFinal := true
667 for _, h := range boxes[l] {
668 allFinal = allFinal && h.state.final()
669 }
670 if allFinal && !boxDone(boxes[l]) {
671 fmt.Fprintf(&b, "\n "+watchBoxBusy, l)
672 }
673 }
674 if rw.diag != nil && rw.diag.logsHint != "" {
675 fmt.Fprintf(&b, "\nfull timeline: %s", rw.diag.logsHint)
676 }
677 return errors.New(b.String())
678}
679
680// watchDeployed is the watch for an instance (non-static) deploy of ver onto
681// channel.
682func watchDeployed(ctx context.Context, sess *apiSession, acct apiAcct, app, ver, channel string, hosts []string, probe string, timeout time.Duration) error {
683 targets := make([]watchTarget, 0, len(hosts))
684 for _, h := range hosts {
685 targets = append(targets, watchTarget{Host: h, Origin: watchOrigin(h), Probe: probe})
686 }
687 return newDeployWatch(ctx, sess, acct, app, ver, channel, probe, timeout).run(targets)
688}
689
690// newDeployWatch is watchDeployed's watch, built apart so a test can read
691// its cadence without a console.
692func newDeployWatch(ctx context.Context, sess *apiSession, acct apiAcct, app, ver, channel, probe string, timeout time.Duration) rolloutWatch {
693 client := &http.Client{Timeout: 10 * time.Second}
694 return rolloutWatch{
695 w: stdout, app: app, ver: ver, channel: channel, timeout: timeout,
696 interval: deployWatchPollEvery, listEvery: deployWatchListEvery,
697 list: func(deadline time.Time) (watchList, error) {
698 lctx, cancel := context.WithDeadline(ctx, deadline)
699 defer cancel()
700 return sess.watchInstanceList(lctx, acct, app)
701 },
702 check: func(t watchTarget, n int64) (bool, string) { return checkTarget(client, t, ver, n) },
703 onURL: func() { printWatchNote(probe, false, app) },
704 diag: deployDiagnostics(ctx, sess, acct, app),
705 }
706}
707
708// deployDiagnostics is the timeout report's help, moved out of runDeploy
709// unchanged.
710func deployDiagnostics(ctx context.Context, sess *apiSession, acct apiAcct, app string) *watchDiagnostics {
711 return &watchDiagnostics{
712 // A timed-out host's wake reason, from the platform log stream —
713 // scoped tight (one host, errors only, the watch's own recent past)
714 // so a diagnosis this may print was caused by THIS deploy, not an
715 // incident from earlier in the day.
716 wakeReason: func(host string) string {
717 q := url.Values{"stream": {"platform"}, "level": {"error"},
718 "host": {host}, "since": {"15m"}, "limit": {"1"}}
719 res, err := sess.logsGet(ctx, acct, app, q)
720 if err != nil || len(res.Records) == 0 {
721 return ""
722 }
723 return res.Records[len(res.Records)-1].Msg
724 },
725 logsHint: fmt.Sprintf("carlos logs --app %s --account %s --stream platform", app, acct.Seg),
726 }
727}
728
729// printWatchNote is runDeploy's probe-or-header note, moved out unchanged.
730func printWatchNote(probe string, staticDeploy bool, app string) {
731 if probe != "" {
732 mutedf(stdout, "watching %s for the build %s reports itself\n", probe, app)
733 return
734 }
735 // Said out loud, every time, because the silence is what the bug was
736 // made of (discussion #13). With no probe this watch can only ask
737 // the ROUTER what release was adopted; it cannot ask the process
738 // what it is running. A deploy that goes green here has proved that
739 // adoption happened, not that anything restarted — and an app whose
740 // instance failed to cycle looks identical. It is a one-line fix
741 // per app, so the line names it rather than describing the problem
742 // and leaving the reader to find the verb.
743 mutedf(stdout, "watching the X-Carlos-Version header: it reports the release ADOPTED, not the build running\n")
744 // The advisory below assumes a process to probe. A static route has
745 // none — no /api/version, no exec child to cycle — so the suggested
746 // command would only teach the operator to configure a probe that
747 // 404s, which then fails and times out every future `carlos deploy`
748 // for that site. Static deploys get the line above (still true: the
749 // header reports adoption, not a running build) and stop there.
750 if !staticDeploy {
751 mutedf(stdout, " a probe proves the process actually cycled — carlos apps build-probe --app %s /api/version\n", app)
752 }
753}
754