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 four `carlos schedule` doors (spec §8). What is pinned here, in the
4// order it would hurt if lost: a recorded schedule actually lands in the
5// bucket AND is announced (a declaration nobody hears is a schedule that
6// fires an hour late at best); a rejected one lands NOTHING and announces
7// nothing; the list answers with `[]` rather than `null` for an app that has
8// declared nothing (the CLI ranges over both arrays); and a stranger — or a
9// member naming a schedule that does not exist — gets the same bytes back as
10// someone probing for an app that isn't there.
11
12import (
13 "bytes"
14 "context"
15 "encoding/json"
16 "errors"
17 "net/http"
18 "os"
19 "testing"
20 "time"
21
22 "github.com/carlosframework/platform/internal/release"
23 "github.com/carlosframework/platform/internal/schedule"
24)
25
26// scheduleFixture is restartFixture's twin: an app in the ops account and a
27// MEMBER token for it. Declaring recurring work is operating an app, not
28// publishing a release, so member-level is the whole gate.
29func scheduleFixture(t *testing.T, code string) (*consoleFixture, string) {
30 t.Helper()
31 fx := newConsoleFixture(t)
32 now := time.Now()
33 if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil {
34 t.Fatal(err)
35 }
36 if err := fx.st.AddMember(OpsAccountID, "alice@keymail.dev", RoleMember, now); err != nil {
37 t.Fatal(err)
38 }
39 return fx, cliToken(t, fx, "alice@keymail.dev", code)
40}
41
42// declaredSchedules reads schedules.json straight from the bucket — what the
43// boxes will converge from, as opposed to what the door said it did.
44func declaredSchedules(t *testing.T, fx *consoleFixture, app string) (schedule.Declaration, string) {
45 t.Helper()
46 d, etag, err := schedule.ReadDeclaration(context.Background(), fx.rel, release.OpsAccount, app)
47 if err != nil {
48 t.Fatalf("read declaration: %v", err)
49 }
50 return d, etag
51}
52
53// runEtag is what every box watches for `run`: "" when nobody has ever asked.
54func runEtag(t *testing.T, fx *consoleFixture, app string) string {
55 t.Helper()
56 _, etag, err := fx.rel.Get(context.Background(), schedule.RunKey(release.OpsAccount, app))
57 if err != nil {
58 if errors.Is(err, os.ErrNotExist) {
59 return ""
60 }
61 t.Fatalf("read run signal: %v", err)
62 }
63 return etag
64}
65
66func decodeScheduleList(t *testing.T, out []byte) apiScheduleList {
67 t.Helper()
68 var res apiScheduleList
69 if err := json.Unmarshal(out, &res); err != nil {
70 t.Fatalf("schedule body %s: %v", out, err)
71 }
72 return res
73}
74
75// TestAPIScheduleSetRecordsAndAnnouncesIt is the write door whole: the object
76// a box converges from holds the schedule, the serial says "look now", and
77// the answer names the canonical account and the declaration as stored.
78func TestAPIScheduleSetRecordsAndAnnouncesIt(t *testing.T) {
79 fx, token := scheduleFixture(t, "code-schedule-set")
80 if _, etag := declaredSchedules(t, fx, "titogo"); etag != "" {
81 t.Fatalf("schedules.json exists before any request: %q", etag)
82 }
83
84 body := `{"name":"sync","every":"6h","path":"/jobs/sync"}`
85 status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/schedules", token, body)
86 if status != http.StatusOK {
87 t.Fatalf("set = %d %s, want 200", status, out)
88 }
89 res := decodeScheduleList(t, out)
90 if res.Account != "bab" || res.App != "titogo" {
91 t.Fatalf("set body = %+v, want bab/titogo", res)
92 }
93 if len(res.Schedules) != 1 || res.Schedules[0].Name != "sync" || res.Schedules[0].Path != "/jobs/sync" {
94 t.Fatalf("set body schedules = %+v, want the one just recorded", res.Schedules)
95 }
96 if res.UpdatedAt == "" {
97 t.Error("set body carries no updated_at — the CLI has nothing to show for the write")
98 }
99 // The write door answers about the DECLARATION; instance reports belong
100 // to the list door, and must be [] rather than null either way.
101 if res.Instances == nil {
102 t.Error("set body instances is null; the CLI ranges over it")
103 }
104
105 d, etag := declaredSchedules(t, fx, "titogo")
106 if etag == "" {
107 t.Fatal("no schedules.json in the bucket after a 200 — no box will ever converge it")
108 }
109 sc, ok := d.Find("sync")
110 if !ok {
111 t.Fatalf("bucket declaration = %+v, want sync in it", d.Schedules)
112 }
113 if sc.Path != "/jobs/sync" || sc.Every != "6h0m0s" {
114 t.Errorf("stored schedule = %+v, want the normalised every/path", sc)
115 }
116 if got := serial(t, fx); got != 1 {
117 t.Fatalf("serial after a set = %d, want 1 (otherwise boxes wait out the hourly floor)", got)
118 }
119
120 // A second set REPLACES by name rather than appending, and announces again.
121 status, out = postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/schedules", token,
122 `{"name":"sync","cron":"0 8 * * *","path":"/jobs/sync2"}`)
123 if status != http.StatusOK {
124 t.Fatalf("second set = %d %s", status, out)
125 }
126 d, _ = declaredSchedules(t, fx, "titogo")
127 if len(d.Schedules) != 1 || d.Schedules[0].Path != "/jobs/sync2" || d.Schedules[0].Every != "" {
128 t.Fatalf("declaration after replace = %+v, want one schedule on cron", d.Schedules)
129 }
130 if got := serial(t, fx); got != 2 {
131 t.Fatalf("serial after two sets = %d, want 2", got)
132 }
133}
134
135// TestAPIScheduleSetRefusesAnImpossibleInterval: validation happens before
136// anything is written, so a typo costs a round trip and nothing else — no
137// object, no announcement, and the validator's own words back (a fixed
138// sentence would make the member guess which field was wrong).
139func TestAPIScheduleSetRefusesAnImpossibleInterval(t *testing.T) {
140 fx, token := scheduleFixture(t, "code-schedule-set-bad")
141
142 status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/schedules", token,
143 `{"name":"sync","every":"30s","path":"/jobs/sync"}`)
144 if status != http.StatusBadRequest {
145 t.Fatalf("set every=30s = %d %s, want 400", status, out)
146 }
147 if !bytes.Contains(out, []byte("shortest interval")) {
148 t.Errorf("400 body = %s, want the validator's sentence", out)
149 }
150 if _, etag := declaredSchedules(t, fx, "titogo"); etag != "" {
151 t.Error("a refused schedule was written to the bucket")
152 }
153 if got := serial(t, fx); got != 0 {
154 t.Fatalf("serial after a refused set = %d, want 0 — the fleet was woken for nothing", got)
155 }
156}
157
158// TestAPIScheduleSetRefusesPastTheCap: the per-app ceiling answers as the
159// member's problem (400, with the number), not as a bucket failure.
160func TestAPIScheduleSetRefusesPastTheCap(t *testing.T) {
161 fx, token := scheduleFixture(t, "code-schedule-cap")
162 ctx, now := context.Background(), time.Now()
163 for i := 0; i < schedule.MaxSchedulesPerApp; i++ {
164 sc := schedule.Schedule{Name: "job-" + string(rune('a'+i)), Every: "1h", Path: "/jobs/x"}
165 if _, err := schedule.UpsertSchedule(ctx, fx.rel, release.OpsAccount, "titogo", sc, now); err != nil {
166 t.Fatalf("seed %d: %v", i, err)
167 }
168 }
169 status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/schedules", token,
170 `{"name":"onemore","every":"1h","path":"/jobs/x"}`)
171 if status != http.StatusBadRequest {
172 t.Fatalf("set past the cap = %d %s, want 400", status, out)
173 }
174 if !bytes.Contains(out, []byte("at most")) {
175 t.Errorf("cap refusal = %s, want it to name the ceiling", out)
176 }
177 if got := serial(t, fx); got != 0 {
178 t.Fatalf("serial after a refused set = %d, want 0", got)
179 }
180}
181
182// TestAPIScheduleListJoinsDeclarationAndInstanceReports: the declaration says
183// what was ASKED; the status objects say what each instance will actually do
184// next. `carlos schedule ls` is only honest for a member with no box access
185// if this door carries both.
186func TestAPIScheduleListJoinsDeclarationAndInstanceReports(t *testing.T) {
187 fx, token := scheduleFixture(t, "code-schedule-ls")
188
189 // An app that has declared nothing answers with EMPTY ARRAYS, never null.
190 status, raw := getRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/schedules", token)
191 if status != http.StatusOK {
192 t.Fatalf("list before anything = %d %s, want 200", status, raw)
193 }
194 if !bytes.Contains([]byte(raw), []byte(`"schedules":[]`)) || !bytes.Contains([]byte(raw), []byte(`"instances":[]`)) {
195 t.Fatalf("empty list body = %s, want [] for both arrays (the CLI ranges over them)", raw)
196 }
197
198 if status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/schedules", token,
199 `{"name":"sync","every":"6h","path":"/jobs/sync"}`); status != http.StatusOK {
200 t.Fatalf("set = %d %s", status, out)
201 }
202 // A box's report, written the way a box writes it.
203 st := schedule.Status{
204 Host: "x.bab.oncarlos.com",
205 Box: "carlos-ie-1",
206 ReportedAt: time.Now().UTC().Format(time.RFC3339),
207 Timers: []schedule.TimerStatus{{
208 Name: "sync", Kind: "schedule", Spec: "every 6h0m0s", Path: "/jobs/sync",
209 NextAt: 1787004000, LastAt: 1786982400, LastStatus: "200", LastMs: 3210,
210 }},
211 }
212 if err := schedule.WriteStatus(context.Background(), fx.rel, release.OpsAccount, "titogo", st); err != nil {
213 t.Fatal(err)
214 }
215
216 status, raw = getRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/schedules", token)
217 if status != http.StatusOK {
218 t.Fatalf("list = %d %s, want 200", status, raw)
219 }
220 res := decodeScheduleList(t, []byte(raw))
221 if len(res.Schedules) != 1 || res.Schedules[0].Name != "sync" {
222 t.Fatalf("list schedules = %+v, want the declaration", res.Schedules)
223 }
224 if len(res.Instances) != 1 {
225 t.Fatalf("list instances = %+v, want the one report a box wrote", res.Instances)
226 }
227 got := res.Instances[0]
228 if got.Host != "x.bab.oncarlos.com" || got.Box != "carlos-ie-1" {
229 t.Errorf("instance report = %+v, want the box's own", got)
230 }
231 if len(got.Timers) != 1 || got.Timers[0].NextAt != 1787004000 || got.Timers[0].LastStatus != "200" {
232 t.Errorf("instance timers = %+v, want the box's next/last verbatim", got.Timers)
233 }
234}
235
236// TestAPIScheduleRemoveDropsAndAnnounces, and the 404 for a name that was
237// never declared is the SAME BYTES as the 404 for an app that is not there:
238// a name is not an account, but a probe is a probe.
239func TestAPIScheduleRemoveDropsAndAnnounces(t *testing.T) {
240 fx, token := scheduleFixture(t, "code-schedule-rm")
241 if status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/schedules", token,
242 `{"name":"sync","every":"6h","path":"/jobs/sync"}`); status != http.StatusOK {
243 t.Fatalf("set = %d %s", status, out)
244 }
245
246 status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/schedules/sync/delete", token, "")
247 if status != http.StatusOK {
248 t.Fatalf("delete = %d %s, want 200", status, out)
249 }
250 res := decodeScheduleList(t, out)
251 if len(res.Schedules) != 0 {
252 t.Errorf("delete body schedules = %+v, want none left", res.Schedules)
253 }
254 if res.Schedules == nil {
255 t.Error("delete body schedules is null; the CLI ranges over it")
256 }
257 if res.Instances == nil {
258 t.Error("delete body instances is null; the CLI ranges over it")
259 }
260 d, _ := declaredSchedules(t, fx, "titogo")
261 if _, ok := d.Find("sync"); ok {
262 t.Error("sync is still in the bucket declaration after a 200")
263 }
264 if got := serial(t, fx); got != 2 {
265 t.Fatalf("serial after set+delete = %d, want 2", got)
266 }
267
268 // An undeclared name, byte-for-byte against an app that is not there.
269 unknownName, nameBody := postRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/schedules/nosuch/delete", map[string]string{}, token)
270 unknownApp, appBody := postRaw(t, fx.ts.URL+"/api/cli/apps/bab/nosuchapp/schedules/sync/delete", map[string]string{}, token)
271 if unknownName != http.StatusNotFound || unknownApp != http.StatusNotFound {
272 t.Fatalf("unknown name = %d, unknown app = %d, want 404 both", unknownName, unknownApp)
273 }
274 if !bytes.Equal([]byte(nameBody), []byte(appBody)) {
275 t.Errorf("undeclared-name 404 body %q differs from unknown-app 404 body %q", nameBody, appBody)
276 }
277 // An invalid name is the same answer: nothing about it reaches the store.
278 if code, body := postRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/schedules/NOT_A_NAME/delete", map[string]string{}, token); code != http.StatusNotFound || body != appBody {
279 t.Errorf("invalid name = %d %q, want the unknown-app 404", code, body)
280 }
281 if got := serial(t, fx); got != 2 {
282 t.Fatalf("serial after three refusals = %d, want 2 — a refusal must not wake the fleet", got)
283 }
284}
285
286// TestAPIScheduleRunTouchesTheSignal: the etag movement IS the signal, so a
287// second run must move it again, and a run for a name nobody declared must
288// not touch it at all.
289func TestAPIScheduleRunTouchesTheSignal(t *testing.T) {
290 fx, token := scheduleFixture(t, "code-schedule-run")
291 if status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/schedules", token,
292 `{"name":"sync","every":"6h","path":"/jobs/sync"}`); status != http.StatusOK {
293 t.Fatalf("set = %d %s", status, out)
294 }
295 if got := runEtag(t, fx, "titogo"); got != "" {
296 t.Fatalf("run signal exists before any run: %q", got)
297 }
298
299 status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/schedules/sync/run", token, "")
300 if status != http.StatusOK {
301 t.Fatalf("run = %d %s, want 200", status, out)
302 }
303 var res struct {
304 Requested bool `json:"requested"`
305 Account string `json:"account"`
306 App string `json:"app"`
307 Name string `json:"name"`
308 }
309 if err := json.Unmarshal(out, &res); err != nil {
310 t.Fatalf("run body %s: %v", out, err)
311 }
312 if !res.Requested || res.Account != "bab" || res.App != "titogo" || res.Name != "sync" {
313 t.Fatalf("run body = %+v, want requested for bab/titogo sync", res)
314 }
315 first := runEtag(t, fx, "titogo")
316 if first == "" {
317 t.Fatal("no run signal in the bucket after a 200 — no box will ever fire it")
318 }
319 if got := serial(t, fx); got != 2 {
320 t.Fatalf("serial after set+run = %d, want 2", got)
321 }
322
323 if status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/schedules/sync/run", token, ""); status != http.StatusOK {
324 t.Fatalf("second run = %d %s", status, out)
325 }
326 if second := runEtag(t, fx, "titogo"); second == first {
327 t.Fatalf("run etag %q unchanged after a second run — change detection is by etag", second)
328 }
329 before := runEtag(t, fx, "titogo")
330
331 // Undeclared: the byte-identical 404, and the signal stays where it was.
332 code, body := postRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/schedules/nosuch/run", map[string]string{}, token)
333 _, appBody := postRaw(t, fx.ts.URL+"/api/cli/apps/bab/nosuchapp/schedules/sync/run", map[string]string{}, token)
334 if code != http.StatusNotFound {
335 t.Fatalf("run of an undeclared name = %d %s, want 404", code, body)
336 }
337 if !bytes.Equal([]byte(body), []byte(appBody)) {
338 t.Errorf("undeclared-name 404 body %q differs from unknown-app 404 body %q", body, appBody)
339 }
340 if after := runEtag(t, fx, "titogo"); after != before {
341 t.Errorf("a refused run touched the signal: %q -> %q", before, after)
342 }
343 if got := serial(t, fx); got != 3 {
344 t.Fatalf("serial after two runs and a refusal = %d, want 3", got)
345 }
346}
347
348// TestAPIScheduleStrangerLearnsNothing: a non-member's refusal is
349// byte-identical with an unknown app's on the read door, the same gate in the
350// same place as every other door here.
351func TestAPIScheduleStrangerLearnsNothing(t *testing.T) {
352 fx, _ := scheduleFixture(t, "code-schedule-stranger")
353 eve := cliToken(t, fx, "eve@keymail.dev", "code-schedule-eve")
354
355 status, body := getRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/schedules", eve)
356 if status != http.StatusNotFound {
357 t.Fatalf("list as non-member = %d, want 404", status)
358 }
359 _, probe := getRaw(t, fx.ts.URL+"/api/cli/apps/bab/nosuchapp/schedules", eve)
360 if !bytes.Equal([]byte(body), []byte(probe)) {
361 t.Errorf("non-member 404 body %q differs from unknown-app 404 body %q", body, probe)
362 }
363 if body != "404 page not found\n" {
364 t.Errorf("list as non-member body = %q, want the stdlib http.NotFound output", body)
365 }
366 if status, _ := getRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/schedules", ""); status != http.StatusUnauthorized {
367 t.Errorf("list with no token = %d, want 401", status)
368 }
369}
370
371// TestAPIScheduleWritesAreRateLimited: all three mutating doors spend the
372// shared per-person write budget, before they touch the bucket. `run` is the
373// one that most needs it — its whole effect is to make an app do work now.
374func TestAPIScheduleWritesAreRateLimited(t *testing.T) {
375 fx, token := scheduleFixture(t, "code-schedule-limit")
376 fx.srv.cli.write = newRateLimiter(1, time.Minute)
377 // The one request the budget allows, which also leaves `sync` declared —
378 // so the three refusals below are the BUDGET refusing, not a missing name.
379 if status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/schedules", token,
380 `{"name":"sync","every":"6h","path":"/jobs/sync"}`); status != http.StatusOK {
381 t.Fatalf("set = %d %s", status, out)
382 }
383
384 for _, u := range []string{
385 "/api/cli/apps/bab/titogo/schedules",
386 "/api/cli/apps/bab/titogo/schedules/sync/delete",
387 "/api/cli/apps/bab/titogo/schedules/sync/run",
388 } {
389 if status, out := postBody(t, fx.ts.URL+u, token, `{"name":"sync","every":"6h","path":"/jobs/sync"}`); status != http.StatusTooManyRequests {
390 t.Errorf("%s on an exhausted budget = %d %s, want 429", u, status, out)
391 }
392 }
393 // The READ door is unlimited, like every other read here.
394 if status, _ := getRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/schedules", token); status != http.StatusOK {
395 t.Errorf("list on an exhausted write budget = %d, want 200", status)
396 }
397}
398
399// TestAPIScheduleWithoutABucketIs503: the declaration IS a bucket object. A
400// console with nowhere to put it must say so rather than answer as if a box
401// would ever see it.
402func TestAPIScheduleWithoutABucketIs503(t *testing.T) {
403 fx, token := scheduleFixture(t, "code-schedule-nobucket")
404 fx.srv.cfg.Releases = nil
405
406 for _, c := range []struct{ method, path string }{
407 {http.MethodGet, "/api/cli/apps/bab/titogo/schedules"},
408 {http.MethodPost, "/api/cli/apps/bab/titogo/schedules"},
409 {http.MethodPost, "/api/cli/apps/bab/titogo/schedules/sync/delete"},
410 {http.MethodPost, "/api/cli/apps/bab/titogo/schedules/sync/run"},
411 } {
412 var status int
413 if c.method == http.MethodGet {
414 status, _ = getRaw(t, fx.ts.URL+c.path, token)
415 } else {
416 status, _ = postBody(t, fx.ts.URL+c.path, token, `{"name":"sync","every":"6h","path":"/jobs/sync"}`)
417 }
418 if status != http.StatusServiceUnavailable {
419 t.Errorf("%s %s with no bucket = %d, want 503", c.method, c.path, status)
420 }
421 }
422}
423
424// scheduleListBrokenStore cannot LIST the status prefix at all: the shape a
425// bucket outage takes on the read door.
426type scheduleListBrokenStore struct{ release.Store }
427
428func (b scheduleListBrokenStore) List(context.Context, string) ([]string, error) {
429 return nil, errors.New(brokenStoreErr)
430}
431
432// A total failure to read the instance reports is a 502 with a fixed
433// sentence, never a 200 that reads as "no instance has reported yet": a
434// member with no box access would take a broken console for a fleet that
435// has not caught up. The bucket's own error text stays in the log.
436func TestAPIScheduleListTotalStatusFailureIs502(t *testing.T) {
437 fx, token := scheduleFixture(t, "code-schedule-ls-broken")
438 fx.srv.cfg.Releases = scheduleListBrokenStore{fx.rel}
439 status, raw := getRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/schedules", token)
440 if status != http.StatusBadGateway {
441 t.Fatalf("list with an unlistable status prefix = %d %s, want 502", status, raw)
442 }
443 if !bytes.Contains([]byte(raw), []byte("could not read instance reports")) || bytes.Contains([]byte(raw), []byte("carlos-prod-secret-bucket")) {
444 t.Fatalf("502 body = %s, want the fixed sentence and none of the store's text", raw)
445 }
446}
447
448// One unreadable report among readable ones is the PARTIAL case: the door
449// answers 200 with what is knowable and logs the rest, so one box writing
450// rubbish never blanks the list for every other instance of the app.
451func TestAPIScheduleListPartialStatusFailureIs200(t *testing.T) {
452 fx, token := scheduleFixture(t, "code-schedule-ls-partial")
453 good := schedule.Status{Host: "good.bab.oncarlos.com", Box: "carlos-ie-1", ReportedAt: time.Now().UTC().Format(time.RFC3339)}
454 if err := schedule.WriteStatus(context.Background(), fx.rel, release.OpsAccount, "titogo", good); err != nil {
455 t.Fatal(err)
456 }
457 if _, err := fx.rel.Put(context.Background(), schedule.StatusKey(release.OpsAccount, "titogo", "bad.bab.oncarlos.com"), []byte("{not json"), ""); err != nil {
458 t.Fatal(err)
459 }
460 status, raw := getRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/schedules", token)
461 if status != http.StatusOK {
462 t.Fatalf("list with one bad report = %d %s, want 200", status, raw)
463 }
464 res := decodeScheduleList(t, []byte(raw))
465 if len(res.Instances) != 1 || res.Instances[0].Host != "good.bab.oncarlos.com" {
466 t.Fatalf("instances = %+v, want the one readable report", res.Instances)
467 }
468}
469