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// A run service at the run doors (service runs spec §§ 1, 2, 5): it starts
4// and follows its own runs of the commands it was granted, and nothing else.
5
6import (
7 "context"
8 "encoding/json"
9 "fmt"
10 "go/ast"
11 "go/parser"
12 "go/token"
13 "net/http"
14 "os"
15 "slices"
16 "strings"
17 "sync"
18 "testing"
19 "time"
20
21 "github.com/carlosframework/platform/internal/hostclaim"
22 "github.com/carlosframework/platform/internal/provision"
23 "github.com/carlosframework/platform/internal/release"
24 "github.com/carlosframework/platform/internal/runs"
25 "github.com/carlosframework/platform/internal/seeds"
26 "github.com/carlosframework/platform/internal/svcrole"
27)
28
29const runServiceFor = "paul@example.com"
30
31// runServiceToken mints a run credential for app in the ops account. The
32// generic serviceToken cannot: a run credential needs its commands.
33func runServiceToken(t *testing.T, st *Store, name, app string, commands [][]string) string {
34 t.Helper()
35 raw, err := st.NewServicePrincipalScoped(OpsAccountID, name, app, svcrole.Run, nil, commands, "paul@keymail.dev", time.Now())
36 if err != nil {
37 t.Fatalf("mint run %s: %v", name, err)
38 }
39 return raw
40}
41
42// serviceMeta is a granted command, said to be for runServiceFor.
43func serviceMeta() runs.Meta {
44 return runs.Meta{Argv: []string{"import", "classic", "--slug", "x"}, TimeoutS: 60, OnBehalfOf: runServiceFor}
45}
46
47func codeOf(t *testing.T, body string) string {
48 t.Helper()
49 var e struct {
50 Code *string `json:"code"`
51 }
52 if err := json.Unmarshal([]byte(body), &e); err != nil {
53 t.Fatalf("body %q: %v", body, err)
54 }
55 if e.Code == nil {
56 return ""
57 }
58 return *e.Code
59}
60
61// startAs admits one run for bearer and returns its id.
62func (fx *runFx) startAs(t *testing.T, bearer string, meta runs.Meta) string {
63 t.Helper()
64 status, body := postRun(t, fx.runsURL(runTestHost), bearer, meta, nil)
65 if status != http.StatusAccepted {
66 t.Fatalf("create: %d %s", status, body)
67 }
68 var c runs.Created
69 if err := json.Unmarshal([]byte(body), &c); err != nil {
70 t.Fatal(err)
71 }
72 return c.ID
73}
74
75// followAs sends one follow verb ("" is the GET) on id as bearer.
76func (fx *runFx) followAs(t *testing.T, bearer, verb, id string) (int, string) {
77 t.Helper()
78 u := fx.runsURL(runTestHost) + "/" + id
79 if verb == "" {
80 status, _, body := rawReq(t, http.MethodGet, u, bearer, "")
81 return status, body
82 }
83 status, _, body := rawReq(t, http.MethodPost, u+"/"+verb, bearer, `{"last_seq":0}`)
84 return status, body
85}
86
87// unknownAnswer is what bearer is told for verb on a run id that never
88// existed, rewritten to name id: the answer a run bearer may not see must be
89// indistinguishable from it.
90func (fx *runFx) unknownAnswer(t *testing.T, bearer, verb, id string) (int, string) {
91 t.Helper()
92 never := "run_zzzzzzzzzzzzzzzzzzzzzzzzzz"
93 status, body := fx.followAs(t, bearer, verb, never)
94 return status, strings.ReplaceAll(body, never, id)
95}
96
97func (fx *runFx) noRunObjects(t *testing.T, what string) {
98 t.Helper()
99 if left, _ := fx.rel.List(context.Background(), runs.RunsPrefix(release.OpsAccount, "shop")); len(left) != 0 {
100 t.Errorf("%s left run objects %q", what, left)
101 }
102 if rec, _ := provision.GetRecord(context.Background(), fx.rel, release.OpsAccount, "shop", runTestHost); rec != nil && rec.ActiveRun != nil {
103 t.Errorf("%s admitted a run: %+v", what, rec.ActiveRun)
104 }
105}
106
107// runDoorURLs is the five run doors, filled for runTestHost and id.
108func (fx *runFx) runDoorURLs(id string) []struct{ method, u, body string } {
109 base := fx.runsURL(runTestHost)
110 return []struct{ method, u, body string }{
111 {http.MethodPost, base, ""},
112 {http.MethodGet, base + "/" + id, ""},
113 {http.MethodPost, base + "/" + id + "/cancel", `{}`},
114 {http.MethodPost, base + "/" + id + "/expire", `{}`},
115 {http.MethodPost, base + "/" + id + "/ack", `{"last_seq":0}`},
116 }
117}
118
119// TestRunRoleMatrix: the run doors take a run service and refuse every other
120// role, Admin included, with the role gate's own 403, which names the role
121// that opens them.
122func TestRunRoleMatrix(t *testing.T) {
123 fx := runFixture(t)
124 runner := runServiceToken(t, fx.st, "ci", "shop", importClassic)
125 id := "run_aaaaaaaaaaaaaaaaaaaaaaaaaa"
126 for _, d := range fx.runDoorURLs(id) {
127 status, _, body := rawReq(t, d.method, d.u, runner, d.body)
128 if status == http.StatusForbidden || status == http.StatusUnauthorized {
129 t.Errorf("run service at %s %s = %d %s, want past the role gate", d.method, d.u, status, body)
130 }
131 }
132 for _, r := range svcrole.All() {
133 if r == svcrole.Run {
134 continue
135 }
136 app, rungs := "shop", []string(nil)
137 if r == svcrole.Admin {
138 app = "" // the widest Admin there is
139 }
140 if r == svcrole.Deploy {
141 rungs = []string{"edge"}
142 }
143 tok, err := fx.st.NewServicePrincipalScoped(OpsAccountID, "role-"+string(r), app, r, rungs, nil, "paul@keymail.dev", time.Now())
144 if err != nil {
145 t.Fatalf("mint %s: %v", r, err)
146 }
147 raw, _ := json.Marshal(map[string]string{"error": fmt.Sprintf("this credential holds role %q; this door needs one of [run]", r)})
148 want := string(raw) + "\n"
149 for _, d := range fx.runDoorURLs(id) {
150 status, _, body := rawReq(t, d.method, d.u, tok, d.body)
151 if status != http.StatusForbidden || body != want {
152 t.Errorf("role %s at %s %s = %d %q, want 403 %q", r, d.method, d.u, status, body, want)
153 }
154 }
155 }
156 fx.noRunObjects(t, "the role matrix")
157}
158
159func TestRunServiceStartsAGrantedCommand(t *testing.T) {
160 fx := runFixture(t)
161 runner := runServiceToken(t, fx.st, "ci", "shop", importClassic)
162 status, body := postRun(t, fx.runsURL(runTestHost), runner, serviceMeta(), nil)
163 if status != http.StatusAccepted {
164 t.Fatalf("create: %d %s", status, body)
165 }
166 var c runs.Created
167 if err := json.Unmarshal([]byte(body), &c); err != nil || !runs.ValidID(c.ID) || c.State != provision.RunQueued {
168 t.Fatalf("created %+v, %v", c, err)
169 }
170 ctx := context.Background()
171 req, err := runs.ReadRequest(ctx, fx.rel, release.OpsAccount, "shop", c.ID)
172 if err != nil {
173 t.Fatal(err)
174 }
175 p, ok := fx.st.CLIPrincipal(runner, time.Now())
176 if !ok {
177 t.Fatal("the run credential does not resolve")
178 }
179 if req.ByService != "ci" || req.ByCredential != p.CredentialID || req.ByCredential == "" || req.OnBehalfOf != runServiceFor {
180 t.Fatalf("request by_service=%q by_credential=%q on_behalf_of=%q, want ci, %q, %s", req.ByService, req.ByCredential, req.OnBehalfOf, p.CredentialID, runServiceFor)
181 }
182 if want := "service:" + release.OpsAccount + "/ci"; req.By != want {
183 t.Errorf("request by = %q, want the audit actor %q", req.By, want)
184 }
185 rec, err := provision.GetRecord(ctx, fx.rel, release.OpsAccount, "shop", runTestHost)
186 if err != nil || rec.ActiveRun == nil || rec.ActiveRun.ID != c.ID {
187 t.Fatalf("record run %+v, %v", rec.ActiveRun, err)
188 }
189 // It follows its own run.
190 if p := fx.pageAs(t, runner, c.ID); p.ID != c.ID || p.State != provision.RunQueued {
191 t.Fatalf("own page %+v", p)
192 }
193 // A scheduled run names nobody.
194 fx.transition(t, provision.RunExpire(c.ID))
195 meta := serviceMeta()
196 meta.OnBehalfOf = ""
197 id := fx.startAs(t, runner, meta)
198 if req, err := runs.ReadRequest(ctx, fx.rel, release.OpsAccount, "shop", id); err != nil || req.OnBehalfOf != "" || req.ByService != "ci" {
199 t.Fatalf("scheduled request %+v, %v", req, err)
200 }
201}
202
203func (fx *runFx) pageAs(t *testing.T, bearer, id string) runs.Page {
204 t.Helper()
205 status, body := fx.followAs(t, bearer, "", id)
206 if status != http.StatusOK {
207 t.Fatalf("GET page: %d %s", status, body)
208 }
209 var p runs.Page
210 if err := json.Unmarshal([]byte(body), &p); err != nil {
211 t.Fatal(err)
212 }
213 return p
214}
215
216func TestRunServiceRefusedAnUngrantedCommand(t *testing.T) {
217 fx := runFixture(t)
218 runner := runServiceToken(t, fx.st, "ci", "shop", importClassic)
219 for _, argv := range [][]string{
220 {"db", "shell"},
221 {"import"}, // a missing word
222 {"import", "classicx"}, // a substring of a word
223 {"Import", "classic"}, // no case folding
224 {"sync", "import", "classic"},
225 } {
226 meta := serviceMeta()
227 meta.Argv = argv
228 status, body := postRun(t, fx.runsURL(runTestHost), runner, meta, nil)
229 if status != http.StatusForbidden || codeOf(t, body) != "command_not_granted" {
230 t.Errorf("%q: %d %s, want 403 command_not_granted", argv, status, body)
231 }
232 }
233 fx.noRunObjects(t, "an ungranted command")
234 // A person runs any command.
235 meta := okMeta()
236 meta.Argv = []string{"db", "shell"}
237 if status, body := postRun(t, fx.runsURL(runTestHost), fx.member, meta, nil); status != http.StatusAccepted {
238 t.Fatalf("a person's db shell: %d %s", status, body)
239 }
240}
241
242func TestRunServiceWrongAppRefused(t *testing.T) {
243 fx := runFixture(t)
244 if _, err := fx.st.CreateAppIn(OpsAccountID, "other", "paul@keymail.dev", time.Now()); err != nil {
245 t.Fatal(err)
246 }
247 other := runServiceToken(t, fx.st, "ci", "other", importClassic)
248 want := canonicalNotFound(t)
249 status, h, body := postRunH(t, fx.runsURL(runTestHost), other, serviceMeta(), nil)
250 assertNotFound(t, "another app's run service at create", want, status, h, body)
251 for _, d := range fx.runDoorURLs("run_aaaaaaaaaaaaaaaaaaaaaaaaaa")[1:] {
252 status, h, body := rawReq(t, d.method, d.u, other, d.body)
253 assertNotFound(t, "another app's run service at "+d.method+" "+d.u, want, status, h, body)
254 }
255 fx.noRunObjects(t, "another app's run service")
256}
257
258func TestRunRevokedServiceRefused(t *testing.T) {
259 fx := runFixture(t)
260 runner := runServiceToken(t, fx.st, "ci", "shop", importClassic)
261 id := fx.startAs(t, runner, serviceMeta())
262 if err := fx.st.RevokeServicePrincipal(OpsAccountID, "ci", time.Now()); err != nil {
263 t.Fatal(err)
264 }
265 // The 401 a fabricated token gets, byte for byte.
266 _, _, want := rawReq(t, http.MethodGet, fx.ts.URL+"/api/cli/apps", "not-a-token", "")
267 if status, got := postRun(t, fx.runsURL(runTestHost), runner, serviceMeta(), nil); status != http.StatusUnauthorized || got != want {
268 t.Errorf("revoked at create = %d %q, want 401 %q", status, got, want)
269 }
270 for _, d := range fx.runDoorURLs(id)[1:] {
271 status, _, got := rawReq(t, d.method, d.u, runner, d.body)
272 if status != http.StatusUnauthorized || got != want {
273 t.Errorf("revoked at %s %s = %d %q, want 401 %q", d.method, d.u, status, got, want)
274 }
275 }
276}
277
278func TestRunServiceCannotSeeAPersonsRun(t *testing.T) {
279 fx := runFixture(t)
280 runner := runServiceToken(t, fx.st, "ci", "shop", importClassic)
281 id := fx.startRun(t)
282 for _, verb := range []string{"", "cancel", "expire", "ack"} {
283 wantStatus, want := fx.unknownAnswer(t, runner, verb, id)
284 status, body := fx.followAs(t, runner, verb, id)
285 if status != wantStatus || body != want || status != http.StatusNotFound {
286 t.Errorf("service %q on a person's run = %d %q, want the unknown id's %d %q", verb, status, body, wantStatus, want)
287 }
288 }
289 // Nothing the service sent touched the person's run.
290 if rec, _ := provision.GetRecord(context.Background(), fx.rel, release.OpsAccount, "shop", runTestHost); rec.ActiveRun == nil || rec.ActiveRun.ID != id || rec.ActiveRun.State != provision.RunQueued {
291 t.Fatalf("record %+v", rec.ActiveRun)
292 }
293 // The member still sees it, and sees a service's run too.
294 if p := fx.page(t, id, 0); p.ID != id {
295 t.Fatalf("member page %+v", p)
296 }
297 fx.transition(t, provision.RunExpire(id))
298 sid := fx.startAs(t, runner, serviceMeta())
299 if p := fx.page(t, sid, 0); p.ID != sid || p.State != provision.RunQueued {
300 t.Fatalf("member's page of the service's run %+v", p)
301 }
302}
303
304func TestRunRemintedNameDoesNotInheritRuns(t *testing.T) {
305 fx := runFixture(t)
306 first := runServiceToken(t, fx.st, "ci", "shop", importClassic)
307 id := fx.startAs(t, first, serviceMeta())
308 if err := fx.st.RevokeServicePrincipal(OpsAccountID, "ci", time.Now()); err != nil {
309 t.Fatal(err)
310 }
311 second := runServiceToken(t, fx.st, "ci", "shop", importClassic)
312 for _, verb := range []string{"", "cancel", "expire", "ack"} {
313 wantStatus, want := fx.unknownAnswer(t, second, verb, id)
314 status, body := fx.followAs(t, second, verb, id)
315 if status != wantStatus || body != want || status != http.StatusNotFound {
316 t.Errorf("the reminted name's %q = %d %q, want the unknown id's %d %q", verb, status, body, wantStatus, want)
317 }
318 }
319 // Rotation keeps the credential, and so its runs.
320 third := runServiceToken(t, fx.st, "rotated", "shop", importClassic)
321 fx.transition(t, provision.RunExpire(id))
322 rid := fx.startAs(t, third, serviceMeta())
323 rotated, err := fx.st.RotateServicePrincipal(OpsAccountID, "rotated", time.Now())
324 if err != nil {
325 t.Fatal(err)
326 }
327 if p := fx.pageAs(t, rotated, rid); p.ID != rid {
328 t.Fatalf("the rotated credential's own run %+v", p)
329 }
330}
331
332func TestRunOpenRefusalRedactedForServices(t *testing.T) {
333 fx := runFixture(t)
334 runner := runServiceToken(t, fx.st, "ci", "shop", importClassic)
335 id := fx.startRun(t)
336 status, body := postRun(t, fx.runsURL(runTestHost), runner, serviceMeta(), nil)
337 if status != http.StatusConflict || codeOf(t, body) != "run_open" {
338 t.Fatalf("service create over a person's run: %d %s", status, body)
339 }
340 for _, leak := range []string{id, "alice@keymail.dev", "alice"} {
341 if strings.Contains(body, leak) {
342 t.Errorf("redacted refusal %s carries %q", body, leak)
343 }
344 }
345 // Its own open run, it is told about.
346 fx.transition(t, provision.RunExpire(id))
347 own := fx.startAs(t, runner, serviceMeta())
348 status, body = postRun(t, fx.runsURL(runTestHost), runner, serviceMeta(), nil)
349 if status != http.StatusConflict || codeOf(t, body) != "run_open" || !strings.Contains(errorOf(t, body), own) {
350 t.Fatalf("service create over its own run: %d %s, want run_open naming %s", status, body, own)
351 }
352 // Another service's run is redacted too.
353 other := runServiceToken(t, fx.st, "ci2", "shop", importClassic)
354 status, body = postRun(t, fx.runsURL(runTestHost), other, serviceMeta(), nil)
355 if status != http.StatusConflict || codeOf(t, body) != "run_open" || strings.Contains(body, own) || strings.Contains(body, "service:") {
356 t.Fatalf("service create over another service's run: %d %s", status, body)
357 }
358 // The member is told whose it is, in the house line: the service, and
359 // the person only as the service's statement.
360 status, body = postRun(t, fx.runsURL(runTestHost), fx.member, okMeta(), nil)
361 if want := runTestHost + " already has a run open: " + own + ", started by service ci for " + runServiceFor + " (as stated by the service)"; status != http.StatusConflict || errorOf(t, body) != want {
362 t.Fatalf("member over a service's run: %d %s, want %q", status, body, want)
363 }
364}
365
366// The admission's own refusal is redacted the same way: the rival arrives
367// after the door's checks passed.
368func TestRunOpenRefusalAtAdmissionRedactedForServices(t *testing.T) {
369 fx := runFixture(t)
370 runner := runServiceToken(t, fx.st, "ci", "shop", importClassic)
371 fx.srv.cfg.Releases = &rivalStore{Store: fx.rel, t: t}
372 status, body := postRun(t, fx.runsURL(runTestHost), runner, serviceMeta(), nil)
373 if status != http.StatusConflict || codeOf(t, body) != "run_open" || strings.Contains(body, "run_aaaaaaaaaaaaaaaaaaaaaaaaaa") || strings.Contains(body, "bob@keymail.dev") {
374 t.Fatalf("%d %s", status, body)
375 }
376}
377
378func TestRunPersonMaySetNoOnBehalfOf(t *testing.T) {
379 fx := runFixture(t)
380 meta := okMeta()
381 meta.OnBehalfOf = "bob@keymail.dev"
382 status, body := postRun(t, fx.runsURL(runTestHost), fx.member, meta, nil)
383 if status != http.StatusBadRequest || codeOf(t, body) != "on_behalf_of_invalid" {
384 t.Fatalf("%d %s, want 400 on_behalf_of_invalid", status, body)
385 }
386 fx.noRunObjects(t, "a person's on_behalf_of")
387}
388
389func TestRunOnBehalfOfShape(t *testing.T) {
390 fx := runFixture(t)
391 runner := runServiceToken(t, fx.st, "ci", "shop", importClassic)
392 long := strings.Repeat("a", 255-len("@example.com")) + "@example.com"
393 if len(long) != 255 {
394 t.Fatalf("long is %d bytes", len(long))
395 }
396 for _, v := range []string{"paul", "paul@", "@example.com", "Paul <paul@example.com>", "paul@example.com, bob@example.com", " paul@example.com", "paul @example.com", "paul@example.com\n", long} {
397 meta := serviceMeta()
398 meta.OnBehalfOf = v
399 status, body := postRun(t, fx.runsURL(runTestHost), runner, meta, nil)
400 if status != http.StatusBadRequest || codeOf(t, body) != "on_behalf_of_invalid" {
401 t.Errorf("%q: %d %s, want 400 on_behalf_of_invalid", v, status, body)
402 }
403 }
404 fx.noRunObjects(t, "a malformed on_behalf_of")
405 // 254 bytes is the most there may be. Membership is never asked.
406 meta := serviceMeta()
407 meta.OnBehalfOf = long[1:]
408 if status, body := postRun(t, fx.runsURL(runTestHost), runner, meta, nil); status != http.StatusAccepted {
409 t.Fatalf("254 bytes: %d %s", status, body)
410 }
411}
412
413// TestRunRefusalsCarryCodes: every refusal a run handler writes has a code
414// in runRefusalCodes; the shared gates before the handler do not, and keep
415// their bodies as every other door gives them.
416func TestRunRefusalsCarryCodes(t *testing.T) {
417 fx := runFixture(t)
418 ctx := context.Background()
419 runner := runServiceToken(t, fx.st, "ci", "shop", importClassic)
420 known := map[string]bool{}
421 for _, c := range runRefusalCodes {
422 if known[c] {
423 t.Errorf("runRefusalCodes names %s twice", c)
424 }
425 known[c] = true
426 }
427 seen := map[string]bool{}
428 check := func(what string, status int, body string) {
429 t.Helper()
430 if status < 400 {
431 t.Errorf("%s: %d %s, want a refusal", what, status, body)
432 return
433 }
434 code := codeOf(t, body)
435 if !known[code] || errorOf(t, body) == "" {
436 t.Errorf("%s: %d %s, want an error and a code from runRefusalCodes", what, status, body)
437 }
438 seen[code] = true
439 }
440 checkF := func(what string, f func() (int, string)) {
441 t.Helper()
442 status, body := f()
443 check(what, status, body)
444 }
445 post := func(what, host, bearer string, meta runs.Meta) {
446 t.Helper()
447 status, body := postRun(t, fx.runsURL(host), bearer, meta, nil)
448 check(what, status, body)
449 }
450 post("no record", "nobody.example.com", fx.member, okMeta())
451 post("bad host", "-bad-", fx.member, okMeta())
452 putShopRecord(t, fx.consoleFixture, provision.Record{Host: "static.example.com", Kind: "static"})
453 post("class", "static.example.com", fx.member, okMeta())
454 putShopRecord(t, fx.consoleFixture, provision.Record{Host: "import.example.com", Seed: &provision.SeedSource{Kind: seeds.KindImport, State: seeds.StatePlaced}})
455 post("import", "import.example.com", fx.member, okMeta())
456 post("meta", runTestHost, fx.member, runs.Meta{TimeoutS: 60})
457 post("files", runTestHost, fx.member, runs.Meta{Argv: []string{"a"}, TimeoutS: 60, Files: []runs.MetaFile{{Name: "a", Size: 65 << 20}}})
458 malformed := okMeta()
459 malformed.Files = []runs.MetaFile{{Name: "x.csv", Size: 5}}
460 post("malformed", runTestHost, fx.member, malformed)
461 ungranted := serviceMeta()
462 ungranted.Argv = []string{"db", "shell"}
463 post("ungranted", runTestHost, runner, ungranted)
464 person := okMeta()
465 person.OnBehalfOf = "bob@keymail.dev"
466 post("person on_behalf_of", runTestHost, fx.member, person)
467
468 id := fx.startRun(t)
469 post("open", runTestHost, fx.member, okMeta())
470 post("open, for a service", runTestHost, runner, serviceMeta())
471 checkF("unknown id", func() (int, string) { return fx.followAs(t, fx.member, "", "run_zzzzzzzzzzzzzzzzzzzzzzzzzz") })
472 checkF("not a service's run", func() (int, string) { return fx.followAs(t, runner, "", id) })
473 checkF("bad after", func() (int, string) {
474 status, _, body := rawReq(t, http.MethodGet, fx.runsURL(runTestHost)+"/"+id+"?after=x", fx.member, "")
475 return status, body
476 })
477 checkF("ack before the end", func() (int, string) { return fx.followAs(t, fx.member, "ack", id) })
478 checkF("ack malformed", func() (int, string) {
479 status, _, body := rawReq(t, http.MethodPost, fx.runsURL(runTestHost)+"/"+id+"/ack", fx.member, `not json`)
480 return status, body
481 })
482 fx.transition(t, provision.RunPick(id, "box-1", "boot-1", time.Now().Add(time.Hour)))
483 checkF("expire a picked run", func() (int, string) { return fx.followAs(t, fx.member, "expire", id) })
484 if err := runs.WriteResult(ctx, fx.rel, release.OpsAccount, "shop", id, runs.Result{Exit: 0, LastSeq: 3}); err != nil {
485 t.Fatal(err)
486 }
487 fx.transition(t, provision.RunFinish(id))
488 checkF("cancel an ended run", func() (int, string) { return fx.followAs(t, fx.member, "cancel", id) })
489 checkF("ack at the wrong seq", func() (int, string) { return fx.followAs(t, fx.member, "ack", id) })
490 fx.transition(t, provision.RunClear(id))
491
492 if _, err := provision.SetRecordMove(ctx, fx.rel, release.OpsAccount, "shop", runTestHost, provision.MoveRequest("uk", time.Now())); err != nil {
493 t.Fatal(err)
494 }
495 post("moving", runTestHost, fx.member, okMeta())
496
497 fx.srv.cli.instances = newRateLimiter(1, cliRateWindow)
498 fx.srv.cli.instances.allow(hashToken(fx.member), time.Now())
499 post("budget", runTestHost, fx.member, okMeta())
500 fx.srv.cli.runs = newRateLimiter(1, cliRateWindow)
501 fx.srv.cli.runs.allow(hashToken(fx.member), time.Now())
502 checkF("follow budget", func() (int, string) { return fx.followAs(t, fx.member, "", id) })
503 fx.srv.cli = newCLILimits()
504 fx.srv.cfg.HostClaim = true
505 post("host claim", runTestHost, fx.member, okMeta())
506 checkF("host claim follow", func() (int, string) { return fx.followAs(t, fx.member, "", id) })
507 fx.srv.cfg.HostClaim = false
508
509 for _, want := range []string{"not_found", "not_runnable", "import_uncommitted", "invalid_request", "files_too_large", "malformed",
510 "command_not_granted", "on_behalf_of_invalid", "run_open", "run_not_finished", "run_started", "run_ended", "last_seq_mismatch",
511 "moving", "rate_limited", "host_claim"} {
512 if !seen[want] {
513 t.Errorf("no refusal here carried %s; seen %v", want, seen)
514 }
515 }
516
517 // The shared gates: no code, and the bodies every door gives.
518 _, _, unsigned := rawReq(t, http.MethodGet, fx.ts.URL+"/api/cli/apps", "not-a-token", "")
519 if status, body := postRun(t, fx.runsURL(runTestHost), "not-a-token", okMeta(), nil); status != http.StatusUnauthorized || body != unsigned || strings.Contains(body, "code") {
520 t.Errorf("401 at the run door = %d %q, want %q", status, body, unsigned)
521 }
522 publish := serviceToken(t, fx.st, OpsAccountID, "pub", "shop", svcrole.Publish)
523 if status, body := postRun(t, fx.runsURL(runTestHost), publish, okMeta(), nil); status != http.StatusForbidden || strings.Contains(body, "code") {
524 t.Errorf("role 403 at the run door = %d %q, want no code", status, body)
525 }
526 stranger := cliToken(t, fx.consoleFixture, "mallory@keymail.dev", "code-run-codes-stranger")
527 status, h, body := postRunH(t, fx.runsURL(runTestHost), stranger, okMeta(), nil)
528 assertNotFound(t, "stranger", canonicalNotFound(t), status, h, body)
529 status, h, body = rawReq(t, http.MethodGet, fx.runsURL(runTestHost)+"/"+id, stranger, "")
530 assertNotFound(t, "stranger follow", canonicalNotFound(t), status, h, body)
531}
532
533// TestRunHandlersWriteOnlyCodedRefusals reads the run handlers' source: no
534// refusal there is an uncoded jsonError, and every code is a runCode
535// constant named in runRefusalCodes. The two http.NotFound calls are the
536// principal check, which answers as the membership gate does.
537func TestRunHandlersWriteOnlyCodedRefusals(t *testing.T) {
538 fset := token.NewFileSet()
539 consts := map[string]string{}
540 for _, file := range []string{"apiruns.go", "apirunsfollow.go"} {
541 f, err := parser.ParseFile(fset, file, nil, 0)
542 if err != nil {
543 t.Fatal(err)
544 }
545 ast.Inspect(f, func(n ast.Node) bool {
546 switch n := n.(type) {
547 case *ast.ValueSpec:
548 for i, name := range n.Names {
549 if strings.HasPrefix(name.Name, "runCode") && i < len(n.Values) {
550 if lit, ok := n.Values[i].(*ast.BasicLit); ok {
551 consts[name.Name] = strings.Trim(lit.Value, `"`)
552 }
553 }
554 }
555 case *ast.CallExpr:
556 id, ok := n.Fun.(*ast.Ident)
557 if !ok {
558 return true
559 }
560 switch id.Name {
561 case "jsonError":
562 t.Errorf("%s: an uncoded jsonError in a run handler", fset.Position(n.Pos()))
563 case "jsonErrorCode":
564 if len(n.Args) != 4 {
565 t.Errorf("%s: jsonErrorCode takes (w, status, code, msg)", fset.Position(n.Pos()))
566 return true
567 }
568 switch a := n.Args[2].(type) {
569 case *ast.Ident:
570 if !strings.HasPrefix(a.Name, "runCode") {
571 t.Errorf("%s: code %s is not a runCode constant", fset.Position(n.Pos()), a.Name)
572 }
573 case *ast.CallExpr:
574 if f, ok := a.Fun.(*ast.Ident); !ok || f.Name != "runMetaRefusalCode" {
575 t.Errorf("%s: code is computed by something other than runMetaRefusalCode", fset.Position(n.Pos()))
576 }
577 default:
578 t.Errorf("%s: code is not a runCode constant", fset.Position(n.Pos()))
579 }
580 }
581 }
582 return true
583 })
584 }
585 if len(consts) != len(runRefusalCodes) {
586 t.Errorf("%d runCode constants, %d in runRefusalCodes", len(consts), len(runRefusalCodes))
587 }
588 for name, v := range consts {
589 if !slices.Contains(runRefusalCodes, v) {
590 t.Errorf("%s = %q is not in runRefusalCodes", name, v)
591 }
592 }
593 for _, msg := range []string{runFilesTooLargeMsg, "anything else"} {
594 if !slices.Contains(runRefusalCodes, runMetaRefusalCode(msg)) {
595 t.Errorf("runMetaRefusalCode(%q) is not in runRefusalCodes", msg)
596 }
597 }
598}
599
600func TestServiceRunRendersAsTheServices(t *testing.T) {
601 fx := runFixture(t)
602 runner := runServiceToken(t, fx.st, "ci", "shop", importClassic)
603 id := fx.startAs(t, runner, serviceMeta())
604 fx.transition(t, provision.RunPick(id, "box-1", "boot-1", time.Now().Add(time.Hour)))
605 if err := runs.WriteResult(context.Background(), fx.rel, release.OpsAccount, "shop", id, runs.Result{Exit: 0}); err != nil {
606 t.Fatal(err)
607 }
608 fx.pageAs(t, runner, id) // writes the run-end row
609 line := "run by service ci for " + runServiceFor + " (as stated by the service)"
610 events, _ := fx.st.Events(OpsAccountID, "shop", 20)
611 rows := map[string]bool{}
612 for _, e := range events {
613 if !strings.HasPrefix(e.Action, "run-") {
614 continue
615 }
616 rows[e.Action] = true
617 if e.Address != "service:"+release.OpsAccount+"/ci" {
618 t.Errorf("%s row's actor is %q, want the service", e.Action, e.Address)
619 }
620 if !strings.Contains(e.Detail, line) {
621 t.Errorf("%s row %q does not say %q", e.Action, e.Detail, line)
622 }
623 if strings.Count(e.Detail, runServiceFor) != 1 {
624 t.Errorf("%s row %q names %s outside the service's statement", e.Action, e.Detail, runServiceFor)
625 }
626 }
627 if !rows["run-start"] || !rows["run-end"] {
628 t.Fatalf("run rows %v in %+v", rows, events)
629 }
630 // The console's history.
631 c := signInPaul(t, fx.consoleFixture)
632 status, _, page := get(t, c, fx.u("/apps/shop/activity"))
633 if status != http.StatusOK {
634 t.Fatalf("activity: %d", status)
635 }
636 if !strings.Contains(page, line) {
637 t.Errorf("the activity page does not say %q", line)
638 }
639 if strings.Contains(page, "<td>"+runServiceFor+"</td>") {
640 t.Errorf("the activity page shows %s as the actor", runServiceFor)
641 }
642 // A scheduled run.
643 fx.transition(t, provision.RunFinish(id))
644 fx.transition(t, provision.RunClear(id))
645 meta := serviceMeta()
646 meta.OnBehalfOf = ""
647 sid := fx.startAs(t, runner, meta)
648 req, err := runs.ReadRequest(context.Background(), fx.rel, release.OpsAccount, "shop", sid)
649 if err != nil {
650 t.Fatal(err)
651 }
652 if got := req.ServiceLine(); got != "run by service ci (scheduled)" {
653 t.Errorf("scheduled line %q", got)
654 }
655 // A person's run has no service line, and its rows are as before.
656 if got := (runs.Request{By: "alice@keymail.dev"}).ServiceLine(); got != "" {
657 t.Errorf("a person's run has a service line %q", got)
658 }
659}
660
661// The run's incarnation is the record's when the service created it, so a
662// delete and create again leaves it naming the old account (the box refuses
663// it, RefusedIncarnation).
664func TestRunServiceRunIsBoundToItsIncarnation(t *testing.T) {
665 fx := runFixture(t)
666 runner := runServiceToken(t, fx.st, "ci", "shop", importClassic)
667 id := fx.startAs(t, runner, serviceMeta())
668 ctx := context.Background()
669 rec, err := provision.GetRecord(ctx, fx.rel, release.OpsAccount, "shop", runTestHost)
670 if err != nil {
671 t.Fatal(err)
672 }
673 req, err := runs.ReadRequest(ctx, fx.rel, release.OpsAccount, "shop", id)
674 if err != nil {
675 t.Fatal(err)
676 }
677 inc, err := hostclaim.Incarnation(rec.Account, rec.App, rec.Host, rec.CreatedAt)
678 if err != nil || req.Incarnation != inc {
679 t.Fatalf("request incarnation %q, record's %q, %v", req.Incarnation, inc, err)
680 }
681 // Deleted, and created again under the same name.
682 if err := fx.rel.Delete(ctx, provision.RecordKey(release.OpsAccount, "shop", runTestHost)); err != nil {
683 t.Fatal(err)
684 }
685 putShopRecord(t, fx.consoleFixture, provision.Record{Host: runTestHost, Channel: "stable", CreatedAt: rec.CreatedAt.Add(time.Hour)})
686 again, _ := provision.GetRecord(ctx, fx.rel, release.OpsAccount, "shop", runTestHost)
687 if inc2, _ := hostclaim.Incarnation(again.Account, again.App, again.Host, again.CreatedAt); inc2 == req.Incarnation {
688 t.Fatal("the recreated instance has the run's incarnation")
689 }
690}
691
692func TestRunServiceRefusedWhileMoving(t *testing.T) {
693 fx := runFixture(t)
694 runner := runServiceToken(t, fx.st, "ci", "shop", importClassic)
695 if _, err := provision.SetRecordMove(context.Background(), fx.rel, release.OpsAccount, "shop", runTestHost, provision.MoveRequest("uk", time.Now())); err != nil {
696 t.Fatal(err)
697 }
698 status, body := postRun(t, fx.runsURL(runTestHost), runner, serviceMeta(), nil)
699 if status != http.StatusConflict || codeOf(t, body) != "moving" || errorOf(t, body) != runTestHost+" is moving. run the command once the move has finished" {
700 t.Fatalf("%d %s", status, body)
701 }
702 fx.noRunObjectsBut(t, "a moving instance")
703}
704
705// noRunObjectsBut is noRunObjects without the record check, for a record
706// that holds a move.
707func (fx *runFx) noRunObjectsBut(t *testing.T, what string) {
708 t.Helper()
709 if left, _ := fx.rel.List(context.Background(), runs.RunsPrefix(release.OpsAccount, "shop")); len(left) != 0 {
710 t.Errorf("%s left run objects %q", what, left)
711 }
712}
713
714// A service's open run holds the instance against a move, as a person's does.
715func TestMoveDoorRefusesWhileAServicesRunIsOpen(t *testing.T) {
716 fx := runFixture(t)
717 runner := runServiceToken(t, fx.st, "ci", "shop", importClassic)
718 id := fx.startAs(t, runner, serviceMeta())
719 if _, err := fx.rel.Put(context.Background(), "control/edges/uk-box.json",
720 []byte(`{"v":1,"box":"uk-box","eip":"1.2.3.4","placement":"uk","data_bucket":"data-uk","data_bucket_region":"eu-west-2","hosts":{}}`), ""); err != nil {
721 t.Fatal(err)
722 }
723 u := fx.ts.URL + "/api/cli/apps/bab/shop/instances/" + runTestHost + "/move"
724 status, _, body := rawReq(t, http.MethodPost, u, fx.member, `{"region":"uk"}`)
725 if status != http.StatusConflict || !strings.Contains(body, "a run is open on this instance: "+id) {
726 t.Fatalf("move over a service's run: %d %s", status, body)
727 }
728}
729
730// One run per instance holds across a person and a service: at once, one is
731// admitted and the other refused, and the loser's objects are gone.
732func TestRunPersonAndServiceRaceAdmitsOne(t *testing.T) {
733 fx := runFixture(t)
734 runner := runServiceToken(t, fx.st, "ci", "shop", importClassic)
735 var wg sync.WaitGroup
736 statuses := make([]int, 2)
737 bodies := make([]string, 2)
738 for i, c := range []struct {
739 bearer string
740 meta runs.Meta
741 }{{fx.member, okMeta()}, {runner, serviceMeta()}} {
742 wg.Add(1)
743 go func() {
744 defer wg.Done()
745 statuses[i], bodies[i] = postRun(t, fx.runsURL(runTestHost), c.bearer, c.meta, nil)
746 }()
747 }
748 wg.Wait()
749 won, lost := 0, 0
750 for i := range 2 {
751 switch statuses[i] {
752 case http.StatusAccepted:
753 won++
754 case http.StatusConflict:
755 lost++
756 if codeOf(t, bodies[i]) != "run_open" {
757 t.Errorf("loser said %s", bodies[i])
758 }
759 }
760 }
761 if won != 1 || lost != 1 {
762 t.Fatalf("statuses %v %v", statuses, bodies)
763 }
764 prefix := runs.RunsPrefix(release.OpsAccount, "shop")
765 keys, _ := fx.rel.List(context.Background(), prefix)
766 owners := map[string]bool{}
767 for _, k := range keys {
768 owners[strings.SplitN(strings.TrimPrefix(k, prefix), "/", 2)[0]] = true
769 }
770 if len(owners) != 1 {
771 t.Fatalf("runs with objects %v, want only the winner's", owners)
772 }
773}
774
775func TestCallerScopeNamesTheKind(t *testing.T) {
776 person := Principal{Kind: PrincipalUser, Address: "abc"}
777 svc := Principal{Kind: PrincipalService, CredentialID: "abc"}
778 if callerScope(person) == callerScope(svc) {
779 t.Fatalf("a person and a credential with the same string share a scope: %q", callerScope(person))
780 }
781 if callerScope(person) != "user:abc" || callerScope(svc) != "svc:abc" {
782 t.Fatalf("scopes %q %q", callerScope(person), callerScope(svc))
783 }
784 if callerScope(Principal{Kind: PrincipalService}) != "" || callerScope(Principal{Kind: PrincipalUser}) != "" {
785 t.Fatal("a principal with no identity has a scope")
786 }
787 s := &Server{}
788 if s.runVisibleTo(Principal{Kind: PrincipalService}, runs.Request{}) {
789 t.Fatal("a service with no id sees a run with no credential")
790 }
791 if !s.runVisibleTo(svc, runs.Request{ByCredential: "abc"}) || s.runVisibleTo(svc, runs.Request{ByCredential: "abd"}) {
792 t.Fatal("runVisibleTo does not key on the credential id")
793 }
794 if !s.runVisibleTo(person, runs.Request{ByCredential: "zzz"}) {
795 t.Fatal("a member does not see a service's run")
796 }
797}
798
799// A run credential minted in another account, for an app of the same name,
800// is a stranger at this account's run doors: the membership 404, and nothing
801// written.
802func TestRunServiceOfAnotherAccountRefused(t *testing.T) {
803 fx := runFixture(t)
804 const otherID = 4242
805 now := time.Now()
806 if err := fx.st.CreateAccount(otherID, "cdef", "Other", "paul@keymail.dev", now); err != nil {
807 t.Fatal(err)
808 }
809 if _, err := fx.st.CreateAppIn(otherID, "shop", "paul@keymail.dev", now); err != nil {
810 t.Fatal(err)
811 }
812 other, err := fx.st.NewServicePrincipalScoped(otherID, "ci", "shop", svcrole.Run, nil, importClassic, "paul@keymail.dev", now)
813 if err != nil {
814 t.Fatal(err)
815 }
816 runner := runServiceToken(t, fx.st, "ci", "shop", importClassic)
817 id := fx.startAs(t, runner, serviceMeta())
818 want := canonicalNotFound(t)
819 for _, d := range fx.runDoorURLs(id) {
820 status, h, body := rawReq(t, d.method, d.u, other, d.body)
821 assertNotFound(t, "another account's run service at "+d.method+" "+d.u, want, status, h, body)
822 }
823 status, h, body := postRunH(t, fx.runsURL(runTestHost), other, serviceMeta(), nil)
824 assertNotFound(t, "another account's run service at create", want, status, h, body)
825 if rec, _ := provision.GetRecord(context.Background(), fx.rel, release.OpsAccount, "shop", runTestHost); rec == nil || rec.ActiveRun == nil || rec.ActiveRun.ID != id || rec.ActiveRun.State != provision.RunQueued {
826 t.Fatalf("another account's run service changed the run: %+v", rec)
827 }
828}
829
830// A granted run service on a host-claim console gets the refusal a person
831// gets, at every run door.
832func TestRunGrantedServiceOnAHostClaimConsoleRefused(t *testing.T) {
833 fx := runFixture(t)
834 runner := runServiceToken(t, fx.st, "ci", "shop", importClassic)
835 id := fx.startAs(t, runner, serviceMeta())
836 fx.srv.cfg.HostClaim = true
837 for _, d := range fx.runDoorURLs(id) {
838 pStatus, _, pBody := rawReq(t, d.method, d.u, fx.member, d.body)
839 sStatus, _, sBody := rawReq(t, d.method, d.u, runner, d.body)
840 if pStatus != http.StatusConflict || codeOf(t, pBody) != runCodeHostClaim || errorOf(t, pBody) != claimRunUnsupportedMsg {
841 t.Errorf("a person at %s %s = %d %s, want the host-claim refusal", d.method, d.u, pStatus, pBody)
842 }
843 if sStatus != pStatus || sBody != pBody {
844 t.Errorf("the run service at %s %s = %d %q, want what a person gets, %d %q", d.method, d.u, sStatus, sBody, pStatus, pBody)
845 }
846 }
847 status, body := postRun(t, fx.runsURL(runTestHost), runner, serviceMeta(), nil)
848 if status != http.StatusConflict || errorOf(t, body) != claimRunUnsupportedMsg {
849 t.Errorf("a granted create on a host-claim console = %d %s", status, body)
850 }
851}
852
853// Every refusal code a run handler writes is listed in the run API section
854// of the CLI page: a caller with no person to read the sentence acts on the
855// code, so a code the docs do not name is a code nobody can act on.
856func TestRunRefusalCodesAreDocumented(t *testing.T) {
857 raw, err := os.ReadFile("../../docs/site/cli.md")
858 if err != nil {
859 t.Fatal(err)
860 }
861 _, section, ok := strings.Cut(string(raw), "\n#### The run API\n")
862 if !ok {
863 t.Fatal("docs/site/cli.md has no '#### The run API' section")
864 }
865 if i := strings.Index(section, "\n#"); i >= 0 {
866 section = section[:i]
867 }
868 for _, code := range runRefusalCodes {
869 if !strings.Contains(section, "- `"+code+"`:") {
870 t.Errorf("the run API docs do not list the code %s", code)
871 }
872 }
873}
874