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
3// The console half of the deploy signal (carlos-deploy spec §3): every bucket
4// write a box's fast loop consumes bumps control/serial, so a box notices in
5// seconds instead of waiting out its hourly floor. The properties pinned here,
6// in the order they'd hurt if lost: the write's own success never depends on
7// the bump landing, the bump DOES land on each door that moves something a box
8// reads, and the promote API says whether what it just moved is held by a bake
9// window — so the CLI can report the hold without a second round trip.
10
11import (
12 "context"
13 "encoding/json"
14 "net/http"
15 "net/url"
16 "strings"
17 "testing"
18 "time"
19
20 "github.com/carlosframework/platform/internal/release"
21)
22
23// serial reads the bucket's serial counter — 0 when the object was never
24// written, which is exactly what "nothing bumped" looks like.
25func serial(t *testing.T, fx *consoleFixture) int64 {
26 t.Helper()
27 doc, _, err := release.ReadSerial(context.Background(), fx.rel)
28 if err != nil {
29 t.Fatalf("read serial: %v", err)
30 }
31 return doc.Serial
32}
33
34// TestAPIPromoteBumpsSerial: the CLI's promote door moves a channel pointer —
35// the archetypal box-consumed write — so the serial goes from absent (0) to 1.
36func TestAPIPromoteBumpsSerial(t *testing.T) {
37 fx := newConsoleFixture(t)
38 if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", time.Now()); err != nil {
39 t.Fatal(err)
40 }
41 shipVersion(t, fx, "titogo", "abc1234")
42 token := cliToken(t, fx, "paul@keymail.dev", "code-serial-promote")
43 if got := serial(t, fx); got != 0 {
44 t.Fatalf("serial before promote = %d, want 0", got)
45 }
46
47 status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/titogo/promote",
48 map[string]string{"version": "abc1234", "channel": "canary/x"}, token)
49 if status != http.StatusOK {
50 t.Fatalf("promote = %d %v", status, out)
51 }
52 if got := serial(t, fx); got != 1 {
53 t.Fatalf("serial after promote = %d, want 1", got)
54 }
55}
56
57// TestAPIPromoteNoOpDoesNotBump: a promotion that moved nothing announces
58// nothing. The serial is "the bucket has news"; a no-op is not news, and a
59// bump for it would wake every box in the fleet for a pointer that did not
60// change.
61func TestAPIPromoteNoOpDoesNotBump(t *testing.T) {
62 fx := newConsoleFixture(t)
63 if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", time.Now()); err != nil {
64 t.Fatal(err)
65 }
66 shipVersion(t, fx, "titogo", "abc1234")
67 seedChannel(t, fx, "titogo", "canary/x", "abc1234")
68 token := cliToken(t, fx, "paul@keymail.dev", "code-serial-noop")
69
70 status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/titogo/promote",
71 map[string]string{"version": "abc1234", "channel": "canary/x"}, token)
72 if status != http.StatusOK || str(t, out, "result") != "noop" {
73 t.Fatalf("promote = %d %v, want 200 noop", status, out)
74 }
75 if got := serial(t, fx); got != 0 {
76 t.Fatalf("serial after a no-op promote = %d, want 0", got)
77 }
78}
79
80// TestAPIPromoteRefusedDoesNotBump: a refusal wrote nothing, so it announces
81// nothing — the bump sits strictly after the write, never before the gates.
82func TestAPIPromoteRefusedDoesNotBump(t *testing.T) {
83 fx := newConsoleFixture(t)
84 if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", time.Now()); err != nil {
85 t.Fatal(err)
86 }
87 shipVersion(t, fx, "titogo", "abc1234")
88 token := cliToken(t, fx, "paul@keymail.dev", "code-serial-refused")
89
90 status, _ := postRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/promote",
91 map[string]string{"version": "abc1234", "channel": "stable"}, token)
92 if status != http.StatusForbidden {
93 t.Fatalf("stable promote = %d, want 403", status)
94 }
95 if got := serial(t, fx); got != 0 {
96 t.Fatalf("serial after a refused promote = %d, want 0", got)
97 }
98}
99
100// held decodes only the field this task adds, which is also the shape the CLI
101// reads: an absent "held" and an explicit false are the same answer.
102func held(t *testing.T, body string) bool {
103 t.Helper()
104 var out struct {
105 Held bool `json:"held"`
106 }
107 if err := json.Unmarshal([]byte(body), &out); err != nil {
108 t.Fatalf("decode %q: %v", body, err)
109 }
110 return out.Held
111}
112
113// TestAPIPromoteHeldOnProductionBeta: beta is the one bakeable rung this door
114// can move, and a PRODUCTION app's beta head waits out the window before any
115// host adopts it. The response says so, so `carlos deploy` can report the hold
116// without a second call.
117func TestAPIPromoteHeldOnProductionBeta(t *testing.T) {
118 fx := newConsoleFixture(t)
119 if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", time.Now()); err != nil {
120 t.Fatal(err)
121 }
122 shipVersion(t, fx, "titogo", "abc1234")
123 // The ladder: beta is entered from edge only.
124 seedChannel(t, fx, "titogo", "edge", "abc1234")
125 if err := release.SetProduction(context.Background(), fx.rel, fx.priv,
126 release.OpsAccount, "titogo", true, "test"); err != nil {
127 t.Fatal(err)
128 }
129 token := cliToken(t, fx, "paul@keymail.dev", "code-serial-held")
130
131 status, body := postRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/promote",
132 map[string]string{"version": "abc1234", "channel": "beta"}, token)
133 if status != http.StatusOK {
134 t.Fatalf("beta promote = %d %s", status, body)
135 }
136 if !held(t, body) {
137 t.Fatalf("beta promote on a production app = %s, want held true", body)
138 }
139 // The existing fields are untouched — `held` is additive.
140 if !strings.Contains(body, `"result":"promoted"`) {
141 t.Fatalf("promote body lost its result field: %s", body)
142 }
143}
144
145// TestAPIPromoteNotHeldOffProduction: the same beta promotion on an app that
146// has NOT declared production adopts immediately — nothing bakes — so held is
147// false. Canary never bakes at all, in either state.
148func TestAPIPromoteNotHeldOffProduction(t *testing.T) {
149 fx := newConsoleFixture(t)
150 if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", time.Now()); err != nil {
151 t.Fatal(err)
152 }
153 shipVersion(t, fx, "titogo", "abc1234")
154 seedChannel(t, fx, "titogo", "edge", "abc1234")
155 token := cliToken(t, fx, "paul@keymail.dev", "code-serial-notheld")
156
157 status, body := postRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/promote",
158 map[string]string{"version": "abc1234", "channel": "beta"}, token)
159 if status != http.StatusOK {
160 t.Fatalf("beta promote = %d %s", status, body)
161 }
162 if held(t, body) {
163 t.Fatalf("beta promote on a non-production app = %s, want held false", body)
164 }
165
166 // Canary on the SAME app once production is on: the rung has no window,
167 // so the flag cannot make it held.
168 if err := release.SetProduction(context.Background(), fx.rel, fx.priv,
169 release.OpsAccount, "titogo", true, "test"); err != nil {
170 t.Fatal(err)
171 }
172 status, body = postRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/promote",
173 map[string]string{"version": "abc1234", "channel": "canary/x"}, token)
174 if status != http.StatusOK {
175 t.Fatalf("canary promote = %d %s", status, body)
176 }
177 if held(t, body) {
178 t.Fatalf("canary promote on a production app = %s, want held false — canaries never bake", body)
179 }
180}
181
182// TestAPIPromoteNoOpReportsHeld: the re-run case. `carlos deploy` twice with
183// the same version — a CI retry, a repeated command — is a NO-OP, and on a
184// production app whose beta head is mid-bake it is a no-op that is STILL HELD.
185// A no-op answer without the field decodes as held:false and sends the client
186// off to watch for an adopt a day away, which is the exact false failure the
187// field exists to prevent.
188func TestAPIPromoteNoOpReportsHeld(t *testing.T) {
189 fx := newConsoleFixture(t)
190 if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", time.Now()); err != nil {
191 t.Fatal(err)
192 }
193 shipVersion(t, fx, "titogo", "abc1234")
194 seedChannel(t, fx, "titogo", "beta", "abc1234")
195 seedChannel(t, fx, "titogo", "canary/x", "abc1234")
196 if err := release.SetProduction(context.Background(), fx.rel, fx.priv,
197 release.OpsAccount, "titogo", true, "test"); err != nil {
198 t.Fatal(err)
199 }
200 token := cliToken(t, fx, "paul@keymail.dev", "code-serial-noopheld")
201
202 // beta already carries this version: a no-op, and held.
203 status, body := postRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/promote",
204 map[string]string{"version": "abc1234", "channel": "beta"}, token)
205 if status != http.StatusOK || !strings.Contains(body, `"result":"noop"`) {
206 t.Fatalf("repeat beta promote = %d %s, want 200 noop", status, body)
207 }
208 if !held(t, body) {
209 t.Fatalf("no-op beta promote on a production app = %s, want held true", body)
210 }
211 // The same re-run on a rung with no window is a no-op that is NOT held —
212 // so the field tracks the bake, not merely the no-op.
213 status, body = postRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/promote",
214 map[string]string{"version": "abc1234", "channel": "canary/x"}, token)
215 if status != http.StatusOK || !strings.Contains(body, `"result":"noop"`) {
216 t.Fatalf("repeat canary promote = %d %s, want 200 noop", status, body)
217 }
218 if held(t, body) {
219 t.Fatalf("no-op canary promote = %s, want held false — canaries never bake", body)
220 }
221 // Still no announcement: neither call moved a pointer.
222 if got := serial(t, fx); got != 0 {
223 t.Fatalf("serial after two no-op promotes = %d, want 0", got)
224 }
225}
226
227// TestAPIRollbackBumpsSerial: a rollback moves the same pointer a promote
228// does, and is the one people are in the biggest hurry about.
229func TestAPIRollbackBumpsSerial(t *testing.T) {
230 fx := newConsoleFixture(t)
231 if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", time.Now()); err != nil {
232 t.Fatal(err)
233 }
234 shipVersion(t, fx, "titogo", "aaa1111")
235 shipVersion(t, fx, "titogo", "bbb2222")
236 seedChannel(t, fx, "titogo", "canary/x", "aaa1111")
237 seedChannel(t, fx, "titogo", "canary/x", "bbb2222")
238 token := cliToken(t, fx, "paul@keymail.dev", "code-serial-rollback")
239
240 before := serial(t, fx)
241 status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/titogo/rollback",
242 map[string]string{"channel": "canary/x"}, token)
243 if status != http.StatusOK {
244 t.Fatalf("rollback = %d %v", status, out)
245 }
246 if got := serial(t, fx); got != before+1 {
247 t.Fatalf("serial after rollback = %d, want %d", got, before+1)
248 }
249}
250
251// TestAPITrashBumpsSerial: trash through the CLI/API door writes the marker
252// that stops instance minting and drops the app's routes (leg 1 of the fast
253// pass's reconcile) — the same reason restore bumps, mirrored on the way in.
254func TestAPITrashBumpsSerial(t *testing.T) {
255 fx, token := trashFixture(t, "code-serial-trash")
256
257 before := serial(t, fx)
258 status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/delete", token, "")
259 if status != http.StatusOK {
260 t.Fatalf("trash = %d %s", status, out)
261 }
262 if got := serial(t, fx); got != before+1 {
263 t.Fatalf("serial after an API trash = %d, want %d", got, before+1)
264 }
265}
266
267// TestAPIRestoreBumpsSerial: restore through the CLI/API door re-enables
268// instance minting for the app (removing the trash marker), which is leg 1 of
269// the fast pass's reconcile — the same reason the web door (TestWebRestoreBumpsSerial)
270// bumps. Without it, an app restored over the API stays invisible to the
271// fleet until the hourly floor comes round.
272func TestAPIRestoreBumpsSerial(t *testing.T) {
273 fx, token := trashFixture(t, "code-serial-restore")
274 if status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/delete", token, ""); status != http.StatusOK {
275 t.Fatalf("trash = %d %s", status, out)
276 }
277
278 before := serial(t, fx)
279 status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/restore", token, "")
280 if status != http.StatusOK {
281 t.Fatalf("restore = %d %s", status, out)
282 }
283 if got := serial(t, fx); got != before+1 {
284 t.Fatalf("serial after an API restore = %d, want %d", got, before+1)
285 }
286}
287
288// TestAPIConfigPutBumpsSerial: the config write the box's exec config-sync
289// leg consumes — the difference between an env var arriving in seconds and
290// arriving on the hour.
291func TestAPIConfigPutBumpsSerial(t *testing.T) {
292 fx, token := configFixture(t, "code-serial-config")
293 status, out := putJSON(t, configURL(fx, "titogo"),
294 map[string]any{"set": map[string]any{"PORT": map[string]any{"kind": "env", "value": "8080"}}}, token)
295 if status != http.StatusOK {
296 t.Fatalf("config put = %d %v", status, out)
297 }
298 if got := serial(t, fx); got != 1 {
299 t.Fatalf("serial after a config write = %d, want 1", got)
300 }
301}
302
303// TestAPIConfigSyncBumpsSerial: `carlos env sync` exists to make the fleet
304// converge NOW; the generation touch without the announcement would leave it
305// waiting on the hourly loop it was written to skip.
306func TestAPIConfigSyncBumpsSerial(t *testing.T) {
307 fx, token := configFixture(t, "code-serial-configsync")
308 status, out := postJSON(t, configURL(fx, "titogo")+"/sync", map[string]any{}, token)
309 if status != http.StatusOK {
310 t.Fatalf("config sync = %d %v", status, out)
311 }
312 if got := serial(t, fx); got != 1 {
313 t.Fatalf("serial after a config sync = %d, want 1", got)
314 }
315}
316
317// TestAPIInstanceCreateBumpsSerial: an instance record is what the box's
318// reconcile leg mints a route from, so the record write is box-consumed.
319func TestAPIInstanceCreateBumpsSerial(t *testing.T) {
320 fx, token := instanceFixture(t, "code-serial-instance")
321 enableInstances(t, fx, "titogo", titoManifest())
322
323 status, out := postJSON(t, instancesURL(fx, "bab", "titogo"),
324 map[string]any{"host": "acme.go.tito.io", "region": "ie"}, token)
325 if status != http.StatusCreated {
326 t.Fatalf("instance create = %d %v", status, out)
327 }
328 if got := serial(t, fx); got != 1 {
329 t.Fatalf("serial after an instance create = %d, want 1", got)
330 }
331
332 // And the delete, which retires the route the same loop reconciles.
333 req, err := http.NewRequest(http.MethodDelete, instancesURL(fx, "bab", "titogo")+"/acme.go.tito.io", nil)
334 if err != nil {
335 t.Fatal(err)
336 }
337 req.Header.Set("Authorization", "Bearer "+token)
338 res, err := http.DefaultClient.Do(req)
339 if err != nil {
340 t.Fatal(err)
341 }
342 res.Body.Close()
343 if res.StatusCode != http.StatusOK {
344 t.Fatalf("instance delete = %d", res.StatusCode)
345 }
346 if got := serial(t, fx); got != 2 {
347 t.Fatalf("serial after an instance delete = %d, want 2", got)
348 }
349}
350
351// TestAPIInstanceEnableBumpsSerial: the manifest carries the channel every
352// minted route follows, so changing it changes what boxes converge onto.
353func TestAPIInstanceEnableBumpsSerial(t *testing.T) {
354 fx, token := instanceFixture(t, "code-serial-enable")
355 fx.srv.cfg.AppsDomain = "oncarlos.com"
356
357 status, out := putJSON(t, enableURL(fx, "bab", "titogo"),
358 map[string]any{"channel": "beta"}, token)
359 if status != http.StatusOK {
360 t.Fatalf("instance enable = %d %v", status, out)
361 }
362 if got := serial(t, fx); got != 1 {
363 t.Fatalf("serial after instances enable = %d, want 1", got)
364 }
365}
366
367// TestWebPromoteBumpsSerial: the browser door writes the same pointer as the
368// API one — a promotion from the console UI must reach boxes just as fast.
369func TestWebPromoteBumpsSerial(t *testing.T) {
370 fx := newConsoleFixture(t)
371 shipVersion(t, fx, "kass", "v9")
372 c := signInPaul(t, fx)
373 csrf := sessionToken(t, fx, c)
374
375 status, _, body := postForm(t, c, fx.u("/apps/kass/promote"), url.Values{
376 "csrf": {csrf}, "channel": {"canary/x"}, "version": {"v9"}, "confirmed": {"1"},
377 })
378 if status != http.StatusSeeOther {
379 t.Fatalf("web promote = %d, want 303:\n%s", status, body)
380 }
381 if got := serial(t, fx); got != 1 {
382 t.Fatalf("serial after a web promote = %d, want 1", got)
383 }
384}
385
386// TestWebProductionBumpsSerial: the production flag is read on every box's
387// adopt path (it decides whether a channel bakes at all), so flipping it is
388// box-consumed — and turning it OFF releases held versions, which is the
389// direction people wait on.
390func TestWebProductionBumpsSerial(t *testing.T) {
391 fx := newConsoleFixture(t)
392 c := signInPaul(t, fx)
393 csrf := sessionToken(t, fx, c)
394
395 status, _, body := postForm(t, c, fx.u("/apps/kass/production"),
396 url.Values{"csrf": {csrf}, "production": {"on"}})
397 if status != http.StatusSeeOther {
398 t.Fatalf("production on = %d, want 303:\n%s", status, body)
399 }
400 if got := serial(t, fx); got != 1 {
401 t.Fatalf("serial after a production flip = %d, want 1", got)
402 }
403}
404
405// TestWebRestoreBumpsSerial: restore is a box-consumed write like every other
406// door here — pulling the trash marker re-enables instance minting for the
407// app, and minting from records is leg 1 of the fast pass's reconcile. An
408// un-announced restore leaves the app un-mintable until the hourly floor,
409// which is exactly the wait the delete side already announces its way out of.
410func TestWebRestoreBumpsSerial(t *testing.T) {
411 fx, c, csrf := trashPageFixture(t)
412
413 before := serial(t, fx)
414 status, _, body := postForm(t, c, fx.u("/trash/restore"),
415 url.Values{"csrf": {csrf}, "app": {"kass"}})
416 if status != http.StatusSeeOther {
417 t.Fatalf("restore = %d, want 303:\n%s", status, body)
418 }
419 if got := serial(t, fx); got != before+1 {
420 t.Fatalf("serial after a restore = %d, want %d", got, before+1)
421 }
422}
423
424// TestWebTrashBumpsSerial: the web delete door writes the same trash marker
425// the API door does, behind its own passkey ceremony — the announcement must
426// survive the step-up round trip, not just the plain-write path.
427func TestWebTrashBumpsSerial(t *testing.T) {
428 fx := newConsoleFixture(t)
429 c := signInPaul(t, fx)
430 csrf := sessionToken(t, fx, c)
431
432 before := serial(t, fx)
433 status, loc, _ := postForm(t, c, fx.u("/apps/kass/delete"), url.Values{"csrf": {csrf}, "confirm": {"kass"}})
434 if status != http.StatusSeeOther || !strings.HasPrefix(loc, fx.fk.srv.URL) {
435 t.Fatalf("un-stepped-up delete = %d -> %q, want a redirect into Keymail", status, loc)
436 }
437 if got := serial(t, fx); got != before {
438 t.Fatalf("serial before the passkey confirmation = %d, want unchanged (%d)", got, before)
439 }
440
441 status, loc, body := completeStepUp(t, fx, c, loc)
442 if status != http.StatusSeeOther || loc != fx.path("/") {
443 t.Fatalf("resumed delete = %d -> %q, want 303 to the account home:\n%s", status, loc, body)
444 }
445 if got := serial(t, fx); got != before+1 {
446 t.Fatalf("serial after a web trash = %d, want %d", got, before+1)
447 }
448}
449
450// cancelAwareStore is a bucket that honours context cancellation, the way the
451// AWS SDK does and the DirStore (local disk, no ctx checks at all) does not —
452// without it, a canceled-context test here would pass for the wrong reason.
453type cancelAwareStore struct{ release.Store }
454
455func (c cancelAwareStore) Get(ctx context.Context, key string) ([]byte, string, error) {
456 if err := ctx.Err(); err != nil {
457 return nil, "", err
458 }
459 return c.Store.Get(ctx, key)
460}
461
462func (c cancelAwareStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) {
463 if err := ctx.Err(); err != nil {
464 return "", err
465 }
466 return c.Store.Put(ctx, key, body, ifMatch)
467}
468
469// TestBumpSerialOutlivesTheRequest: the announcement is detached from the
470// request context (context.WithoutCancel). By the time a bump runs, the write
471// it announces has already landed and cannot be taken back — so a client that
472// hangs up in that window (a killed CI runner, a closed laptop, a proxy
473// timeout) must not cancel the announcement for a change that is permanently
474// in the bucket. Inheriting the cancellation loses it silently: the fleet
475// waits out the hourly floor for a deploy that already happened.
476func TestBumpSerialOutlivesTheRequest(t *testing.T) {
477 fx := newConsoleFixture(t)
478 fx.srv.cfg.Releases = cancelAwareStore{fx.rel}
479
480 ctx, cancel := context.WithCancel(context.Background())
481 cancel() // the client is already gone
482 fx.srv.bumpSerial(ctx)
483
484 if got := serial(t, fx); got != 1 {
485 t.Fatalf("serial after a bump on a canceled request context = %d, want 1", got)
486 }
487}
488
489// TestBumpSerialSurvivesABrokenStore: the whole contract in one test — a bump
490// against a store that cannot be written returns normally and logs. Every call
491// site depends on this: a user-facing write must never fail, or stall, because
492// its announcement did.
493func TestBumpSerialSurvivesABrokenStore(t *testing.T) {
494 fx := newConsoleFixture(t)
495 fx.srv.cfg.Releases = brokenStore{fx.rel}
496 done := make(chan struct{})
497 go func() {
498 defer close(done)
499 fx.srv.bumpSerial(context.Background())
500 }()
501 select {
502 case <-done:
503 case <-time.After(10 * time.Second):
504 t.Fatal("bumpSerial did not return — a lost bump must never stall a write")
505 }
506}
507