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 "context"
5 "encoding/json"
6 "fmt"
7 "net/http"
8 "strings"
9 "testing"
10 "time"
11
12 "github.com/carlosframework/platform/internal/release"
13 "github.com/carlosframework/platform/internal/status"
14)
15
16// lastScoped reads the newest request in the app's host-retry log, where
17// a retry's grant rides (readiness round 2, Task 3), and checks the
18// restart-scope log took none.
19func lastScoped(t *testing.T, fx *consoleFixture, app string) release.RestartRequest {
20 t.Helper()
21 g, found, err := release.LoadHostRetries(context.Background(), fx.rel, fx.sqid, app)
22 if err != nil || !found || len(g.Requests) == 0 {
23 t.Fatalf("host retries = %+v, %v, %v", g, found, err)
24 }
25 if old, found, _ := release.LoadRestartScopes(context.Background(), fx.rel, fx.sqid, app); found && len(old.Requests) != 0 {
26 t.Fatalf("a retry wrote the restart-scope log: %+v", old.Requests)
27 }
28 return g.Requests[len(g.Requests)-1]
29}
30
31// noRetryWritten checks neither log holds a request for app.
32func noRetryWritten(t *testing.T, fx *consoleFixture, app string) {
33 t.Helper()
34 ctx := context.Background()
35 g, found, err := release.LoadHostRetries(ctx, fx.rel, fx.sqid, app)
36 if err != nil {
37 t.Fatalf("load the host retries: %v", err)
38 }
39 if found && len(g.Requests) != 0 {
40 t.Fatalf("a retry was written: %+v", g.Requests)
41 }
42 g, found, err = release.LoadRestartScopes(ctx, fx.rel, fx.sqid, app)
43 if err != nil {
44 t.Fatalf("load the restart-scope log: %v", err)
45 }
46 if found && len(g.Requests) != 0 {
47 t.Fatalf("a request was written to the restart-scope log: %+v", g.Requests)
48 }
49}
50
51// heldEpoch is the restriction epoch writeHeldBox reports, and heldBook the
52// failure book it counts in (final review round 1, finding 3).
53const (
54 heldEpoch = 17
55 heldBook = "0123456789abcdef0123456789abcdef"
56)
57
58func writeHeldBox(t *testing.T, fx *consoleFixture, host, app, build string) {
59 t.Helper()
60 box := status.CollectBox(nil, status.Probes{MachineID: "box1"}, time.Now())
61 box.Metrics = &status.BoxMetrics{Rollout: &status.RolloutReport{Hosts: map[string]status.HostRollout{
62 host: {Account: fx.sqid, App: app, Installed: build, Serving: "v1", Good: "v1", Awake: true, HeldBy: build, HeldEpoch: heldEpoch, HeldBook: heldBook, RetryV: status.HostRetryV, Reason: status.ReasonHealth},
63 }}}
64 if err := status.WriteBox(context.Background(), fx.rel, box); err != nil {
65 t.Fatal(err)
66 }
67}
68
69func TestAPIInstanceRetryWritesARetryRequest(t *testing.T) {
70 fx := newConsoleFixture(t)
71 const host = "web.kass.carlosapp.dev"
72 sibling(t, fx, "kass", host, "stable")
73 token := cliToken(t, fx, "paul@keymail.dev", "code-host-retry")
74 u := fx.ts.URL + "/api/cli/apps/" + fx.sqid + "/kass/instances/" + host + "/retry"
75
76 if code, _, body := rawReq(t, http.MethodPost, u, token, `{}`); code != http.StatusConflict || !strings.Contains(body, fmt.Sprintf(apiRetryNotHeld, host)) {
77 t.Fatalf("no report = %d %s, want 409 naming %s as not held", code, body, host)
78 }
79 writeHeldBox(t, fx, host, "kass", "v2")
80 code, _, body := rawReq(t, http.MethodPost, u, token, `{}`)
81 if code != http.StatusOK {
82 t.Fatalf("retry = %d %s", code, body)
83 }
84 var out struct{ ID, Version, Host string }
85 if err := json.Unmarshal([]byte(body), &out); err != nil || out.Version != "v2" || out.Host != host || out.ID == "" {
86 t.Fatalf("answer = %s", body)
87 }
88 r := lastScoped(t, fx, "kass")
89 if r.Scope.Kind != release.RestartScopeRetry || r.Scope.Value != host || r.ID != out.ID {
90 t.Fatalf("stored = %+v", r)
91 }
92 // The entry carries the root's grant for the held build at the box's
93 // epoch (spec §11, plan review finding 13).
94 g, err := release.VerifyHostRetry(fx.pub, r.Scope.Signed, fx.sqid, "kass", host, time.Now())
95 if err != nil || g.Build != "v2" || g.Epoch != heldEpoch || g.Book != heldBook {
96 t.Fatalf("signed grant = %+v, %v; want v2 at epoch %d of book %s", g, err, heldEpoch, heldBook)
97 }
98 if n := serial(t, fx); n != 1 {
99 t.Fatalf("serial = %d", n)
100 }
101 code, _, body = rawReq(t, http.MethodPost, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/kass/instances/nope.kass.carlosapp.dev/retry", token, `{}`)
102 if code != http.StatusNotFound || body != "404 page not found\n" {
103 t.Fatalf("unknown host = %d %q", code, body)
104 }
105}
106
107func TestAPIInstanceRetryIgnoresAnotherAppsReport(t *testing.T) {
108 fx := newConsoleFixture(t)
109 const host = "web.kass.carlosapp.dev"
110 sibling(t, fx, "kass", host, "stable")
111 writeHeldBox(t, fx, host, "otherapp", "z2")
112 token := cliToken(t, fx, "paul@keymail.dev", "code-host-retry-x")
113 u := fx.ts.URL + "/api/cli/apps/" + fx.sqid + "/kass/instances/" + host + "/retry"
114 if code, _, body := rawReq(t, http.MethodPost, u, token, `{}`); code != http.StatusConflict || !strings.Contains(body, fmt.Sprintf(apiRetryNotHeld, host)) {
115 t.Fatalf("another app's hold = %d %s, want 409 naming %s as not held", code, body, host)
116 }
117}
118
119// A box that refuses every managed start reports no per-host state: its view
120// is built with Unavailable and no HeldBy, so the host is not held. This pins
121// that whole path to the 409. TestAPIInstanceRetryOfADeclinedBoxsHold covers
122// a view carrying both.
123func TestAPIInstanceRetryRefusesAnUnavailableBox(t *testing.T) {
124 fx := newConsoleFixture(t)
125 const host = "web.kass.carlosapp.dev"
126 sibling(t, fx, "kass", host, "stable")
127 box := status.CollectBox(nil, status.Probes{MachineID: "box1"}, time.Now())
128 box.Metrics = &status.BoxMetrics{
129 RouteVersions: map[string]string{host: "v2"},
130 Rollout: &status.RolloutReport{Unavailable: "no-deploy-store"},
131 }
132 if err := status.WriteBox(context.Background(), fx.rel, box); err != nil {
133 t.Fatal(err)
134 }
135 token := cliToken(t, fx, "paul@keymail.dev", "code-host-retry-u")
136 u := fx.ts.URL + "/api/cli/apps/" + fx.sqid + "/kass/instances/" + host + "/retry"
137 code, _, body := rawReq(t, http.MethodPost, u, token, `{}`)
138 if code != http.StatusConflict || !strings.Contains(body, fmt.Sprintf(apiRetryNotHeld, host)) {
139 t.Fatalf("unavailable box = %d %s, want 409 naming %s as not held", code, body, host)
140 }
141}
142
143// Task 10 Correction 1 (plan review #5): a box that declined
144// CARLOS_SWAP=overlap but honours the failure book an armed run left
145// publishes its held hosts beside its decline code, and the door signs a
146// retry for one from that report. Any other Unavailable code is still
147// refused, whatever its report carries, and a declined box's host it does
148// not report as held is not held.
149func TestAPIInstanceRetryOfADeclinedBoxsHold(t *testing.T) {
150 const host = "web.kass.carlosapp.dev"
151 write := func(t *testing.T, fx *consoleFixture, code string, held bool) {
152 t.Helper()
153 ro := &status.RolloutReport{Unavailable: code, FailureBook: "honoured"}
154 if held {
155 ro.Hosts = map[string]status.HostRollout{host: {Account: fx.sqid, App: "kass", Installed: "v2", Good: "v1",
156 Attempt: status.AttemptDown, AttemptBuild: "v2", HeldBy: "v2", HeldEpoch: heldEpoch, HeldBook: heldBook, RetryV: status.HostRetryV}}
157 }
158 box := status.CollectBox(nil, status.Probes{MachineID: "box1"}, time.Now())
159 box.Metrics = &status.BoxMetrics{RouteVersions: map[string]string{host: "v2"}, Rollout: ro}
160 if err := status.WriteBox(context.Background(), fx.rel, box); err != nil {
161 t.Fatal(err)
162 }
163 }
164 for _, tc := range []struct {
165 name string
166 code string
167 held bool
168 ok bool
169 }{
170 {"declined, held", "no-cgroup-delegation", true, true},
171 {"claims on, held", "host-claims-on", true, true},
172 {"declined, not held", "generations-off", false, false},
173 {"refusing, with hosts", "no-deploy-store", true, false},
174 {"unknown code, with hosts", "a-newer-code", true, false},
175 } {
176 t.Run(tc.name, func(t *testing.T) {
177 fx := newConsoleFixture(t)
178 sibling(t, fx, "kass", host, "stable")
179 write(t, fx, tc.code, tc.held)
180 token := cliToken(t, fx, "paul@keymail.dev", "code-host-retry-d")
181 u := fx.ts.URL + "/api/cli/apps/" + fx.sqid + "/kass/instances/" + host + "/retry"
182 code, _, body := rawReq(t, http.MethodPost, u, token, `{}`)
183 if !tc.ok {
184 if code != http.StatusConflict || !strings.Contains(body, fmt.Sprintf(apiRetryNotHeld, host)) {
185 t.Fatalf("retry = %d %s, want 409 naming %s as not held", code, body, host)
186 }
187 noRetryWritten(t, fx, "kass")
188 return
189 }
190 if code != http.StatusOK {
191 t.Fatalf("retry = %d %s, want the declined box's hold retried", code, body)
192 }
193 r := lastScoped(t, fx, "kass")
194 g, err := release.VerifyHostRetry(fx.pub, r.Scope.Signed, fx.sqid, "kass", host, time.Now())
195 if err != nil || g.Build != "v2" || g.Epoch != heldEpoch || g.Book != heldBook {
196 t.Fatalf("signed grant = %+v, %v; want v2 at epoch %d of book %s", g, err, heldEpoch, heldBook)
197 }
198 })
199 }
200 // Another app's report under the host's name is not this app's hold.
201 fx := newConsoleFixture(t)
202 sibling(t, fx, "kass", host, "stable")
203 box := status.CollectBox(nil, status.Probes{MachineID: "box1"}, time.Now())
204 box.Metrics = &status.BoxMetrics{RouteVersions: map[string]string{host: "v2"}, Rollout: &status.RolloutReport{
205 Unavailable: "no-cgroup-delegation", FailureBook: "honoured",
206 Hosts: map[string]status.HostRollout{host: {Account: fx.sqid, App: "otherapp", Installed: "v2", HeldBy: "v2", HeldEpoch: heldEpoch, HeldBook: heldBook}}}}
207 if err := status.WriteBox(context.Background(), fx.rel, box); err != nil {
208 t.Fatal(err)
209 }
210 token := cliToken(t, fx, "paul@keymail.dev", "code-host-retry-o")
211 u := fx.ts.URL + "/api/cli/apps/" + fx.sqid + "/kass/instances/" + host + "/retry"
212 if code, _, body := rawReq(t, http.MethodPost, u, token, `{}`); code != http.StatusConflict {
213 t.Fatalf("another app's declined hold = %d %s, want 409", code, body)
214 }
215}
216
217// A box that reports the host and holds nothing back: there is nothing to
218// retry, and no request may be written.
219func TestAPIInstanceRetryRefusesAHostThatIsNotHeld(t *testing.T) {
220 fx := newConsoleFixture(t)
221 const host = "web.kass.carlosapp.dev"
222 sibling(t, fx, "kass", host, "stable")
223 box := status.CollectBox(nil, status.Probes{MachineID: "box1"}, time.Now())
224 box.Metrics = &status.BoxMetrics{Rollout: &status.RolloutReport{Hosts: map[string]status.HostRollout{
225 host: {Account: fx.sqid, App: "kass", Installed: "v2", Serving: "v2", Good: "v2", Awake: true},
226 }}}
227 if err := status.WriteBox(context.Background(), fx.rel, box); err != nil {
228 t.Fatal(err)
229 }
230 token := cliToken(t, fx, "paul@keymail.dev", "code-host-retry-n")
231 u := fx.ts.URL + "/api/cli/apps/" + fx.sqid + "/kass/instances/" + host + "/retry"
232 code, _, body := rawReq(t, http.MethodPost, u, token, `{}`)
233 if code != http.StatusConflict || !strings.Contains(body, fmt.Sprintf(apiRetryNotHeld, host)) {
234 t.Fatalf("a healthy host = %d %s, want 409 naming %s as not held", code, body, host)
235 }
236 noRetryWritten(t, fx, "kass")
237}
238
239// A console with no root key (read-only) cannot sign a retry: it answers
240// 503 and writes nothing, so no box is handed an unsigned entry it would
241// drop (spec §11).
242func TestAPIInstanceRetryWithNoKeyWritesNothing(t *testing.T) {
243 fx := newConsoleFixture(t)
244 const host = "web.kass.carlosapp.dev"
245 sibling(t, fx, "kass", host, "stable")
246 writeHeldBox(t, fx, host, "kass", "v2")
247 fx.srv.cfg.Key = nil
248 token := cliToken(t, fx, "paul@keymail.dev", "code-host-retry-k")
249 u := fx.ts.URL + "/api/cli/apps/" + fx.sqid + "/kass/instances/" + host + "/retry"
250 code, _, body := rawReq(t, http.MethodPost, u, token, `{}`)
251 if code != http.StatusServiceUnavailable || !strings.Contains(body, apiRetryNoKey) {
252 t.Fatalf("no key = %d %s, want 503", code, body)
253 }
254 noRetryWritten(t, fx, "kass")
255 if n := serial(t, fx); n != 0 {
256 t.Fatalf("serial = %d, want no announcement", n)
257 }
258}
259
260// writeRetryReport publishes host held by v2 with the given retry fields, on
261// an armed box or, with code set, a declined one honouring its book.
262func writeRetryReport(t *testing.T, fx *consoleFixture, host, code string, retryV int, epoch uint64, book string) {
263 t.Helper()
264 hr := status.HostRollout{Account: fx.sqid, App: "kass", Installed: "v2", Good: "v1", HeldBy: "v2", Reason: status.ReasonHealth,
265 HeldEpoch: epoch, HeldBook: book, RetryV: retryV}
266 ro := &status.RolloutReport{Hosts: map[string]status.HostRollout{host: hr}}
267 if code != "" {
268 ro.Unavailable, ro.FailureBook = code, "honoured"
269 hr.Attempt, hr.AttemptBuild, hr.Reason = status.AttemptDown, "v2", ""
270 ro.Hosts[host] = hr
271 }
272 box := status.CollectBox(nil, status.Probes{MachineID: "box1"}, time.Now())
273 box.Metrics = &status.BoxMetrics{RouteVersions: map[string]string{host: "v2"}, Rollout: ro}
274 if err := status.WriteBox(context.Background(), fx.rel, box); err != nil {
275 t.Fatal(err)
276 }
277}
278
279// Readiness round 2, Task 3 (plan 9's transitions): the door asks the
280// box's report before it writes a retry. A box with no retry_v (built
281// before the host-retry log) or no held_book cannot take one: 409, and
282// nothing is written or announced. A box whose failure book is not saved
283// publishes epoch 0, and no grant for it could clear anything: 409, try
284// again. A box with retry_v, an epoch and a book gets the signed grant in
285// host-retry.json, and nothing in restart-scope.json. Armed and declined
286// boxes alike.
287func TestAPIInstanceRetryAsksTheBoxsReportFirst(t *testing.T) {
288 const host = "web.kass.carlosapp.dev"
289 for _, box := range []string{"", "no-cgroup-delegation"} {
290 for _, tc := range []struct {
291 name string
292 retryV int
293 epoch uint64
294 book string
295 want string // "" is the grant written
296 }{
297 {"an older box", 0, heldEpoch, heldBook, apiRetryUnsupported},
298 {"an older box with no epoch", 0, 0, heldBook, apiRetryUnsupported},
299 {"no book", status.HostRetryV, heldEpoch, "", apiRetryUnsupported},
300 {"book not saved", status.HostRetryV, 0, heldBook, apiRetryUnsaved},
301 {"a box that takes it", status.HostRetryV, heldEpoch, heldBook, ""},
302 } {
303 t.Run(box+" "+tc.name, func(t *testing.T) {
304 fx := newConsoleFixture(t)
305 sibling(t, fx, "kass", host, "stable")
306 writeRetryReport(t, fx, host, box, tc.retryV, tc.epoch, tc.book)
307 token := cliToken(t, fx, "paul@keymail.dev", "code-host-retry-v")
308 u := fx.ts.URL + "/api/cli/apps/" + fx.sqid + "/kass/instances/" + host + "/retry"
309 code, _, body := rawReq(t, http.MethodPost, u, token, `{}`)
310 if tc.want != "" {
311 if code != http.StatusConflict || !strings.Contains(body, tc.want) {
312 t.Fatalf("retry = %d %s, want 409 %q", code, body, tc.want)
313 }
314 noRetryWritten(t, fx, "kass")
315 if n := serial(t, fx); n != 0 {
316 t.Fatalf("serial = %d, want no announcement", n)
317 }
318 return
319 }
320 if code != http.StatusOK {
321 t.Fatalf("retry = %d %s", code, body)
322 }
323 r := lastScoped(t, fx, "kass")
324 g, err := release.VerifyHostRetry(fx.pub, r.Scope.Signed, fx.sqid, "kass", host, time.Now())
325 if r.Scope.Kind != release.RestartScopeRetry || r.Scope.Value != host || err != nil || g.Build != "v2" || g.Epoch != heldEpoch || g.Book != heldBook {
326 t.Fatalf("stored %+v, grant %+v, %v", r, g, err)
327 }
328 if n := serial(t, fx); n != 1 {
329 t.Fatalf("serial = %d", n)
330 }
331 })
332 }
333 }
334}
335