carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1// The host-retry door (zero-downtime spec §8.4, §9.1): `carlos instances
2// retry <host>` clears one instance's failure hold, and its next attempt
3// runs as a canary. The app-wide retry is `carlos promote --retry`, which
4// re-signs the channel pointer.
5//
6// The request rides the app's host-retry log (release.AppendHostRetry,
7// readiness round 2, Task 3), which no older console rewrites and no older
8// box reads. Every box can write that log (Astra impl-6 finding 5), so the
9// entry carries a grant the root key signs here (release.HostRetryGrant,
10// spec §11; Paul's ruling on plan 6 Q4, amadan #272), naming the held build
11// and its restriction's epoch and book as the box reported them, and a box
12// clears nothing on an entry without one. The door writes only for a box
13// whose report says it can take the grant (retryHostBuild): an older box,
14// or one whose failure book is not saved yet, is refused with a message
15// and nothing is written. A console with no key (read-only) answers 503
16// and writes nothing. ROLE: operate, as `carlos restart` is.
17package console
18
19import (
20 "context"
21 "errors"
22 "fmt"
23 "log"
24 "net/http"
25 "os"
26 "time"
27
28 "github.com/carlosframework/platform/internal/provision"
29 "github.com/carlosframework/platform/internal/release"
30)
31
32const (
33 apiRetryStore = "could not write the retry request. Try again"
34 apiRetryHostAudit = "requested a retry of %s on %s (%s)"
35 apiRetryNotHeld = "%s is not held back by a failed build, as far as its box last reported, so there is nothing to retry"
36 // apiRetryNoKey is a read-only console's answer: a retry is a grant the
37 // root key signs (spec §11), and there is no key here to sign it.
38 apiRetryNoKey = "no signing key on this host. this console cannot request a retry"
39 // apiRetryUnsupported answers a box whose report has no retry_v or no
40 // held_book: it reads no grant from the host-retry log, so a retry
41 // written for it would clear nothing.
42 apiRetryUnsupported = "this instance's box is too old to take a retry. update the box, then try again"
43 // apiRetryUnsaved answers a held host reported at epoch 0: its box's
44 // failure book owes a save, and no grant clears anything until it lands.
45 apiRetryUnsaved = "this instance's box has not saved its failure yet. try again in a minute"
46)
47
48var (
49 errRetryNoRecord = errors.New("retry: no such instance")
50 errRetryNotHeld = errors.New("retry: not held")
51 errRetryNoKey = errors.New("retry: no signing key on this console")
52 // errRetryUnsupported is a held host whose box cannot take a retry:
53 // its report has no retry_v (a box built before the host-retry log) or
54 // no held_book (one built before the book's identity).
55 errRetryUnsupported = errors.New("retry: the box cannot take a retry")
56 // errRetryUnsaved is a held host whose box reports retry_v but epoch 0:
57 // its failure book is not saved (readiness round 2, Task 1), and a grant
58 // signed now would name no epoch the box accepts.
59 errRetryUnsaved = errors.New("retry: the box has not saved its failure book")
60)
61
62// retryHostBuild is the build holding host back, and the epoch of that
63// restriction and the failure book it counts in (plan review finding 13;
64// final review round 1, finding 3), as its box reports them. The record
65// must exist in this account and app (the instance family's 404), and the
66// report must be this app's own (boxJoin.rollout), read through the same
67// loader as the list and the instance page (loadRolloutJoin). A box that
68// could not arm its scheduler is not held, except one that declined the
69// flag (a decline code, rolloutwords.UnavailableDeclines) and reports the
70// host held by the failure book it still honours: that box verifies and
71// applies a signed retry too (Task 10, plan review #5). A refusing box, or
72// one whose code this console does not know, is never retried here.
73//
74// Then the box's report is asked whether it can take the grant (readiness
75// round 2, Task 3), and a retry it could not take is refused with a
76// message rather than written to clear nothing: a report with no retry_v
77// is a box built before the host-retry log, which never reads it, and one
78// with no held_book is older than the book's identity
79// (errRetryUnsupported); a held host at epoch 0 is a box whose failure
80// book owes a save, and no grant clears anything until it lands
81// (errRetryUnsaved).
82func (s *Server) retryHostBuild(ctx context.Context, acct *Account, app, host string) (heldRestriction, error) {
83 if _, err := provision.GetRecord(ctx, s.cfg.Releases, acct.Sqid, app, host); err != nil {
84 if errors.Is(err, os.ErrNotExist) {
85 return heldRestriction{}, errRetryNoRecord
86 }
87 return heldRestriction{}, err
88 }
89 join, _ := s.loadRolloutJoin(ctx, acct.Sqid, time.Now(), nil)
90 ro := join.rollout(host, acct.Sqid, app)
91 if !ro.retryable() {
92 return heldRestriction{}, errRetryNotHeld
93 }
94 if err := ro.retryRefusal(); err != nil {
95 return heldRestriction{}, err
96 }
97 return heldRestriction{build: ro.HeldBy, book: ro.heldBook, epoch: ro.heldEpoch, declined: ro.Unavailable != ""}, nil
98}
99
100// heldRestriction is what a retry grant names: the build holding the host
101// back, and that restriction's epoch and book as its box reported them.
102// declined marks a box that declined the flag: it runs no canary, so the
103// browser's confirmation says so.
104type heldRestriction struct {
105 build, book string
106 epoch uint64
107 declined bool
108}
109
110// requestHostRetry signs the retry grant for held (its build, book and
111// epoch) with the root key, appends it to the app's host-retry log
112// (release.AppendHostRetry), and announces it on the serial. With no key
113// it writes nothing (errRetryNoKey): an unsigned entry is one every box
114// drops.
115func (s *Server) requestHostRetry(ctx context.Context, acct, app, host string, held heldRestriction) (release.RestartRequest, error) {
116 if s.cfg.Key == nil {
117 return release.RestartRequest{}, errRetryNoKey
118 }
119 signed, err := release.SignHostRetry(release.RootSigner(s.cfg.Key), acct, app, host, held.build, held.book, held.epoch, time.Now())
120 if err != nil {
121 return release.RestartRequest{}, err
122 }
123 req, err := release.AppendHostRetry(ctx, s.cfg.Releases, acct, app, host, signed)
124 if err != nil {
125 return release.RestartRequest{}, err
126 }
127 s.bumpSerial(ctx)
128 return req, nil
129}
130
131func (s *Server) handleAPIInstanceRetry(w http.ResponseWriter, r *http.Request) {
132 addr, acct, app, ok := s.apiAccountApp(w, r)
133 if !ok {
134 return
135 }
136 host := r.PathValue("host")
137 if !provision.ValidHostShape(host) {
138 http.NotFound(w, r)
139 return
140 }
141 p, _ := principalFrom(r.Context())
142 if !s.allowWrite(p, time.Now()) {
143 jsonError(w, http.StatusTooManyRequests, "too many requests. wait a minute and try again")
144 return
145 }
146 if s.cfg.Releases == nil {
147 jsonError(w, http.StatusServiceUnavailable,
148 "no deployment bucket on this host. this console cannot request a retry")
149 return
150 }
151 if s.cfg.Key == nil {
152 jsonError(w, http.StatusServiceUnavailable, apiRetryNoKey)
153 return
154 }
155 var req struct {
156 Message string `json:"message"`
157 }
158 if !readJSON(w, r, &req) {
159 return
160 }
161 message, ok := checkedActivityMessage(w, req.Message)
162 if !ok {
163 return
164 }
165 ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
166 defer cancel()
167 held, err := s.retryHostBuild(ctx, acct, app, host)
168 switch {
169 case errors.Is(err, errRetryNoRecord):
170 http.NotFound(w, r)
171 return
172 case errors.Is(err, errRetryNotHeld):
173 jsonError(w, http.StatusConflict, fmt.Sprintf(apiRetryNotHeld, host))
174 return
175 case errors.Is(err, errRetryUnsupported):
176 jsonError(w, http.StatusConflict, apiRetryUnsupported)
177 return
178 case errors.Is(err, errRetryUnsaved):
179 jsonError(w, http.StatusConflict, apiRetryUnsaved)
180 return
181 case err != nil:
182 log.Printf("console: api instance retry %s/%s/%s: %v", acct.Sqid, app, host, err)
183 jsonError(w, http.StatusBadGateway, "could not read the instance")
184 return
185 }
186 build := held.build
187 rr, err := s.requestHostRetry(ctx, acct.Sqid, app, host, held)
188 if err != nil {
189 log.Printf("console: api instance retry %s/%s/%s: %v", acct.Sqid, app, host, err)
190 jsonError(w, http.StatusBadGateway, apiRetryStore)
191 return
192 }
193 s.auditActivity(acct, app, addr, Activity{Action: "retry", TargetType: "instance", Target: host,
194 Message: message, Detail: fmt.Sprintf(apiRetryHostAudit, build, host, "cli")})
195 writeJSON(w, http.StatusOK, map[string]any{"result": "requested", "id": rr.ID, "version": build, "host": host})
196}
197