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 "bytes"
5 "context"
6 "encoding/json"
7 "errors"
8 "fmt"
9 "io"
10 "mime/multipart"
11 "net/http"
12 "net/http/httptest"
13 "os"
14 "strings"
15 "sync"
16 "testing"
17 "time"
18
19 "github.com/carlosframework/platform/internal/provision"
20 "github.com/carlosframework/platform/internal/release"
21 "github.com/carlosframework/platform/internal/runs"
22 "github.com/carlosframework/platform/internal/seeds"
23 "github.com/carlosframework/platform/internal/svcrole"
24)
25
26const runTestHost = "acme.example.com"
27
28type runFx struct {
29 *consoleFixture
30 member string
31}
32
33func runFixture(t *testing.T) *runFx {
34 t.Helper()
35 fx := newConsoleFixture(t)
36 now := time.Now()
37 if _, err := fx.st.CreateAppIn(OpsAccountID, "shop", "paul@keymail.dev", now); err != nil {
38 t.Fatal(err)
39 }
40 if err := fx.st.AddMember(OpsAccountID, "alice@keymail.dev", RoleMember, now); err != nil {
41 t.Fatal(err)
42 }
43 member := cliToken(t, fx, "alice@keymail.dev", "code-run")
44 putShopRecord(t, fx, provision.Record{Host: runTestHost, Channel: "stable", CreatedAt: time.Now().UTC()})
45 return &runFx{consoleFixture: fx, member: member}
46}
47
48// putShopRecord writes an instance record for the shop app with the fields
49// a hibernating exec instance has, overridden by whatever rec sets.
50func putShopRecord(t *testing.T, fx *consoleFixture, rec provision.Record) {
51 t.Helper()
52 rec.App, rec.Account = "shop", release.OpsAccount
53 if rec.Hibernate == nil {
54 yes := true
55 rec.Hibernate = &yes
56 }
57 body, err := json.Marshal(rec)
58 if err != nil {
59 t.Fatal(err)
60 }
61 if _, err := fx.rel.Put(context.Background(), provision.RecordKey(rec.Account, rec.App, rec.Host), body, ""); err != nil {
62 t.Fatal(err)
63 }
64}
65
66func (fx *runFx) runsURL(host string) string {
67 return fx.ts.URL + "/api/cli/apps/" + release.OpsAccount + "/shop/instances/" + host + "/runs"
68}
69
70// postRun sends the multipart create; parts lets a test send parts that
71// disagree with meta.
72func postRun(t *testing.T, u, bearer string, meta runs.Meta, parts []struct{ name, body string }) (int, string) {
73 t.Helper()
74 status, _, body := postRunH(t, u, bearer, meta, parts)
75 return status, body
76}
77
78// postRunH is postRun that also returns the response headers.
79func postRunH(t *testing.T, u, bearer string, meta runs.Meta, parts []struct{ name, body string }) (int, http.Header, string) {
80 t.Helper()
81 var buf bytes.Buffer
82 mw := multipart.NewWriter(&buf)
83 mp, err := mw.CreateFormField("meta")
84 if err != nil {
85 t.Fatal(err)
86 }
87 if err := json.NewEncoder(mp).Encode(meta); err != nil {
88 t.Fatal(err)
89 }
90 for _, p := range parts {
91 fp, err := mw.CreateFormFile("file", p.name)
92 if err != nil {
93 t.Fatal(err)
94 }
95 io.WriteString(fp, p.body)
96 }
97 mw.Close()
98 req, err := http.NewRequest(http.MethodPost, u, &buf)
99 if err != nil {
100 t.Fatal(err)
101 }
102 req.Header.Set("Content-Type", mw.FormDataContentType())
103 if bearer != "" {
104 req.Header.Set("Authorization", "Bearer "+bearer)
105 }
106 res, err := http.DefaultClient.Do(req)
107 if err != nil {
108 t.Fatal(err)
109 }
110 defer res.Body.Close()
111 out, _ := io.ReadAll(res.Body)
112 return res.StatusCode, res.Header, string(out)
113}
114
115func okMeta() runs.Meta { return runs.Meta{Argv: []string{"import", "x.csv"}, TimeoutS: 60} }
116
117func errorOf(t *testing.T, body string) string {
118 t.Helper()
119 var e struct{ Error string }
120 if err := json.Unmarshal([]byte(body), &e); err != nil {
121 t.Fatalf("body %q: %v", body, err)
122 }
123 return e.Error
124}
125
126// spoolDir points the door's spool files at a directory of the test's own
127// and returns it, so a test can see that none is left behind.
128func spoolDir(t *testing.T) string {
129 t.Helper()
130 dir := t.TempDir()
131 t.Setenv("TMPDIR", dir)
132 return dir
133}
134
135func assertNoSpool(t *testing.T, dir, when string) {
136 t.Helper()
137 if left, _ := os.ReadDir(dir); len(left) != 0 {
138 t.Errorf("%s left %d spool file(s) behind", when, len(left))
139 }
140}
141
142func TestAPIRunCreateAdmitsAndRecords(t *testing.T) {
143 fx := runFixture(t)
144 spool := spoolDir(t)
145 meta := okMeta()
146 meta.Files = []runs.MetaFile{{Name: "x.csv", Size: 5}}
147 status, body := postRun(t, fx.runsURL(runTestHost), fx.member, meta, []struct{ name, body string }{{"x.csv", "a,b,c"}})
148 if status != http.StatusAccepted {
149 t.Fatalf("status %d %s", status, body)
150 }
151 assertNoSpool(t, spool, "an admitted run")
152 var c runs.Created
153 if err := json.Unmarshal([]byte(body), &c); err != nil || !runs.ValidID(c.ID) || c.State != provision.RunQueued {
154 t.Fatalf("created %+v, %v", c, err)
155 }
156 ctx := context.Background()
157 rec, err := provision.GetRecord(ctx, fx.rel, release.OpsAccount, "shop", runTestHost)
158 if err != nil || rec.ActiveRun == nil || rec.ActiveRun.ID != c.ID || rec.ActiveRun.By != "alice@keymail.dev" {
159 t.Fatalf("record run %+v, %v", rec.ActiveRun, err)
160 }
161 if d := time.Until(rec.ActiveRun.Deadline); d < 50*time.Second || d > 61*time.Second {
162 t.Errorf("pickup deadline %s away, want about 60 s", d)
163 }
164 req, err := runs.ReadRequest(ctx, fx.rel, release.OpsAccount, "shop", c.ID)
165 if err != nil || req.Host != runTestHost || len(req.Incarnation) != 64 || req.TimeoutS != 60 || len(req.Files) != 1 || len(req.Files[0].SHA256) != 64 {
166 t.Fatalf("request %+v, %v", req, err)
167 }
168 raw, _, err := fx.rel.Get(ctx, runs.InputKey(release.OpsAccount, "shop", c.ID, "x.csv"))
169 if err != nil || string(raw) != "a,b,c" {
170 t.Fatalf("input %q, %v", raw, err)
171 }
172 events, _ := fx.st.Events(OpsAccountID, "shop", 10)
173 found := false
174 for _, e := range events {
175 if e.Action == "run-start" && e.Target == runTestHost && e.Address == "alice@keymail.dev" && strings.Contains(e.Detail, c.ID) && strings.Contains(e.Detail, "x.csv") {
176 found = true
177 }
178 }
179 if !found {
180 t.Errorf("no run-start audit row in %+v", events)
181 }
182}
183
184// Each refusal in spec §5.1's order: every case is valid in every later
185// respect, so the status and message show which check refused it.
186func TestAPIRunCreateRefusesInOrder(t *testing.T) {
187 fx := runFixture(t)
188 ctx := context.Background()
189 svc := serviceToken(t, fx.st, OpsAccountID, "ci", "shop", svcrole.Instance)
190 stranger := cliToken(t, fx.consoleFixture, "mallory@keymail.dev", "code-run-stranger")
191
192 if status, _ := postRun(t, fx.runsURL(runTestHost), svc, okMeta(), nil); status != http.StatusForbidden {
193 t.Errorf("service credential: %d, want 403", status)
194 }
195 status, h, body := postRunH(t, fx.runsURL(runTestHost), stranger, okMeta(), nil)
196 assertNotFound(t, "stranger", canonicalNotFound(t), status, h, body)
197 if status, body := postRun(t, fx.runsURL("nobody.example.com"), fx.member, okMeta(), nil); status != http.StatusNotFound || errorOf(t, body) != "nobody.example.com is not an instance of shop" {
198 t.Errorf("no record: %d %s", status, body)
199 }
200 for host, c := range map[string]struct {
201 rec provision.Record
202 msg string
203 }{
204 "static.example.com": {provision.Record{Kind: "static"}, "static.example.com is a static route. carlos run works on instances"},
205 "addr.example.com": {provision.Record{Addr: "127.0.0.1:9"}, "addr.example.com is served from its own address, so the platform does not run its process"},
206 "pool.example.com": {provision.Record{Pool: "shared"}, "pool.example.com is a pooled instance. carlos run works on instances with a process of their own"},
207 "fleet.example.com": {provision.Record{Region: "fleet/pi"}, "fleet.example.com runs on your fleet pi. carlos run works on platform boxes only"},
208 } {
209 c.rec.Host = host
210 putShopRecord(t, fx.consoleFixture, c.rec)
211 if status, body := postRun(t, fx.runsURL(host), fx.member, okMeta(), nil); status != http.StatusConflict || errorOf(t, body) != c.msg {
212 t.Errorf("%s: %d %s", host, status, body)
213 }
214 }
215 no := false
216 putShopRecord(t, fx.consoleFixture, provision.Record{Host: "awake.example.com", Hibernate: &no})
217 if status, body := postRun(t, fx.runsURL("awake.example.com"), fx.member, okMeta(), nil); status != http.StatusConflict || !strings.Contains(errorOf(t, body), "does not sleep") {
218 t.Errorf("always-on: %d %s", status, body)
219 }
220 if _, err := provision.SetRecordMove(ctx, fx.rel, release.OpsAccount, "shop", runTestHost, provision.MoveRequest("uk", time.Now())); err != nil {
221 t.Fatal(err)
222 }
223 if status, body := postRun(t, fx.runsURL(runTestHost), fx.member, okMeta(), nil); status != http.StatusConflict || errorOf(t, body) != runTestHost+" is moving. run the command once the move has finished" {
224 t.Errorf("moving: %d %s", status, body)
225 }
226 putShopRecord(t, fx.consoleFixture, provision.Record{Host: "busy.example.com", ActiveRun: &provision.RunState{ID: "run_aaaaaaaaaaaaaaaaaaaaaaaaaa", State: provision.RunPicked, By: "bob@keymail.dev"}})
227 if status, body := postRun(t, fx.runsURL("busy.example.com"), fx.member, okMeta(), nil); status != http.StatusConflict || errorOf(t, body) != "busy.example.com already has a run open: run_aaaaaaaaaaaaaaaaaaaaaaaaaa, started by bob@keymail.dev" {
228 t.Errorf("open run: %d %s", status, body)
229 }
230 putShopRecord(t, fx.consoleFixture, provision.Record{Host: "fresh.example.com"})
231 bad := []struct {
232 meta runs.Meta
233 msg string
234 }{
235 {runs.Meta{TimeoutS: 60}, "name the command to run after --"},
236 {runs.Meta{Argv: make([]string, 257), TimeoutS: 60}, "a run takes at most 256 arguments"},
237 {runs.Meta{Argv: []string{strings.Repeat("a", 64<<10+1)}, TimeoutS: 60}, "the arguments come to more than 64 KB"},
238 {runs.Meta{Argv: []string{"a\x00"}, TimeoutS: 60}, "an argument holds a NUL byte"},
239 {runs.Meta{Argv: []string{"a"}, TimeoutS: 6*3600 + 1}, "the timeout must be between 1 second and 6 hours"},
240 {runs.Meta{Argv: []string{"a"}, TimeoutS: 60, Files: []runs.MetaFile{{Name: "a", Size: 65 << 20}}}, "the files come to more than 64 MB"},
241 {runs.Meta{Argv: []string{"a"}, TimeoutS: 60, Files: []runs.MetaFile{{Name: "../x", Size: 1}}}, "../x is not a plain file name"},
242 {runs.Meta{Argv: []string{"a"}, TimeoutS: 60, Files: []runs.MetaFile{{Name: "a", Size: 1}, {Name: "a", Size: 1}}}, "two files are both named a"},
243 }
244 for i := range bad[1].meta.Argv {
245 bad[1].meta.Argv[i] = "x"
246 }
247 for _, c := range bad {
248 if status, body := postRun(t, fx.runsURL("fresh.example.com"), fx.member, c.meta, nil); status != http.StatusBadRequest || errorOf(t, body) != c.msg {
249 t.Errorf("%q: %d %s", c.msg, status, body)
250 }
251 }
252 putShopRecord(t, fx.consoleFixture, provision.Record{Host: "import.example.com", Seed: &provision.SeedSource{Kind: seeds.KindImport, State: seeds.StatePlaced}})
253 if status, body := postRun(t, fx.runsURL("import.example.com"), fx.member, okMeta(), nil); status != http.StatusConflict || errorOf(t, body) != "import.example.com is an import that is not committed yet. finish or abort the import first" {
254 t.Errorf("import: %d %s", status, body)
255 }
256}
257
258func TestAPIRunCreateSpendsTheInstanceBudget(t *testing.T) {
259 fx := runFixture(t)
260 // A window of one: the first hit in a window is always allowed
261 // (rateLimiter.allow, authcli.go:226), the second is over. The budget
262 // is refusal 3, so it comes before refusal 7 (the run the first opened).
263 fx.srv.cli.instances = newRateLimiter(1, cliRateWindow)
264 if status, body := postRun(t, fx.runsURL(runTestHost), fx.member, okMeta(), nil); status != http.StatusAccepted {
265 t.Fatalf("first: %d %s", status, body)
266 }
267 if status, body := postRun(t, fx.runsURL(runTestHost), fx.member, okMeta(), nil); status != http.StatusTooManyRequests || errorOf(t, body) != "too many run requests. wait a minute and try again" {
268 t.Fatalf("over budget: %d %s", status, body)
269 }
270}
271
272func TestAPIRunCreateRefusesAPartThatDisagreesWithMeta(t *testing.T) {
273 fx := runFixture(t)
274 spool := spoolDir(t)
275 meta := okMeta()
276 meta.Files = []runs.MetaFile{{Name: "x.csv", Size: 5}}
277 for name, parts := range map[string][]struct{ name, body string }{
278 "short": {{"x.csv", "abc"}},
279 "long": {{"x.csv", "abcdefg"}},
280 "wrong name": {{"y.csv", "abcde"}},
281 "missing": nil,
282 "extra": {{"x.csv", "abcde"}, {"y.csv", "abcde"}},
283 } {
284 status, body := postRun(t, fx.runsURL(runTestHost), fx.member, meta, parts)
285 if status != http.StatusBadRequest || errorOf(t, body) != "the run request is malformed" {
286 t.Errorf("%s: %d %s", name, status, body)
287 }
288 assertNoSpool(t, spool, name)
289 }
290 if left, _ := fx.rel.List(context.Background(), runs.RunsPrefix(release.OpsAccount, "shop")); len(left) != 0 {
291 t.Fatalf("a refused upload left %q", left)
292 }
293 if rec, _ := provision.GetRecord(context.Background(), fx.rel, release.OpsAccount, "shop", runTestHost); rec.ActiveRun != nil {
294 t.Fatal("a refused upload admitted a run")
295 }
296}
297
298// Two members at once: one run is admitted, the other is told who holds the
299// instance, and its objects are gone (codex R1-1).
300func TestAPIRunCreateRaceAdmitsOne(t *testing.T) {
301 fx := runFixture(t)
302 var wg sync.WaitGroup
303 statuses := make([]int, 2)
304 bodies := make([]string, 2)
305 for i := range 2 {
306 wg.Add(1)
307 go func() {
308 defer wg.Done()
309 statuses[i], bodies[i] = postRun(t, fx.runsURL(runTestHost), fx.member, okMeta(), nil)
310 }()
311 }
312 wg.Wait()
313 won, lost := 0, 0
314 for i := range 2 {
315 switch statuses[i] {
316 case http.StatusAccepted:
317 won++
318 case http.StatusConflict:
319 lost++
320 if !strings.Contains(errorOf(t, bodies[i]), "already has a run open") {
321 t.Errorf("loser said %s", bodies[i])
322 }
323 }
324 }
325 if won != 1 || lost != 1 {
326 t.Fatalf("statuses %v %v", statuses, bodies)
327 }
328 // DirStore keeps emptied directories, so count the runs that still own
329 // objects rather than the directories.
330 prefix := runs.RunsPrefix(release.OpsAccount, "shop")
331 keys, _ := fx.rel.List(context.Background(), prefix)
332 owners := map[string]bool{}
333 for _, k := range keys {
334 owners[strings.SplitN(strings.TrimPrefix(k, prefix), "/", 2)[0]] = true
335 }
336 if len(owners) != 1 {
337 t.Fatalf("runs with objects %v, want only the winner's", owners)
338 }
339}
340
341// rivalStore admits another run on the instance record the moment the door
342// writes request.json: after the door's own checks passed and its objects
343// began to land, before its admission. That is the race, made certain.
344type rivalStore struct {
345 release.Store
346 t *testing.T
347 once sync.Once
348}
349
350func (r *rivalStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) {
351 if strings.HasSuffix(key, "/request.json") {
352 r.once.Do(func() {
353 _, err := provision.SetRecordRun(ctx, r.Store, release.OpsAccount, "shop", runTestHost,
354 provision.RunAdmit(provision.RunAdmission{ID: "run_aaaaaaaaaaaaaaaaaaaaaaaaaa", By: "bob@keymail.dev", Deadline: time.Now().Add(time.Minute)}))
355 if err != nil {
356 r.t.Error(err)
357 }
358 })
359 }
360 return r.Store.Put(ctx, key, body, ifMatch)
361}
362
363// The loser of an admission that happens after its objects began to land is
364// told who holds the instance, and nothing of its own is left in the bucket.
365func TestAPIRunCreateLoserAfterUploadLeavesNothing(t *testing.T) {
366 fx := runFixture(t)
367 fx.srv.cfg.Releases = &rivalStore{Store: fx.rel, t: t}
368 meta := okMeta()
369 meta.Files = []runs.MetaFile{{Name: "x.csv", Size: 5}}
370 status, body := postRun(t, fx.runsURL(runTestHost), fx.member, meta, []struct{ name, body string }{{"x.csv", "a,b,c"}})
371 if want := "already has a run open: run_aaaaaaaaaaaaaaaaaaaaaaaaaa, started by bob@keymail.dev"; status != http.StatusConflict || !strings.HasSuffix(errorOf(t, body), want) {
372 t.Fatalf("%d %s", status, body)
373 }
374 if left, _ := fx.rel.List(context.Background(), runs.RunsPrefix(release.OpsAccount, "shop")); len(left) != 0 {
375 t.Fatalf("the loser left %q", left)
376 }
377}
378
379func TestAPIRunCreateOnAHostClaimConsole(t *testing.T) {
380 fx := runFixture(t)
381 fx.srv.cfg.HostClaim = true
382 if status, body := postRun(t, fx.runsURL(runTestHost), fx.member, okMeta(), nil); status != http.StatusConflict || errorOf(t, body) != claimRunUnsupportedMsg {
383 t.Fatalf("%d %s", status, body)
384 }
385 // RUN-C42 comes right after membership: a stranger still gets the
386 // byte-identical 404, and a member with no budget left gets RUN-C42.
387 stranger := cliToken(t, fx.consoleFixture, "mallory@keymail.dev", "code-run-stranger")
388 status, h, body := postRunH(t, fx.runsURL(runTestHost), stranger, okMeta(), nil)
389 assertNotFound(t, "stranger on a claim console", canonicalNotFound(t), status, h, body)
390 fx.srv.cli.instances = newRateLimiter(1, cliRateWindow)
391 fx.srv.cli.instances.allow(hashToken(fx.member), time.Now())
392 if status, body := postRun(t, fx.runsURL(runTestHost), fx.member, okMeta(), nil); status != http.StatusConflict || errorOf(t, body) != claimRunUnsupportedMsg {
393 t.Fatalf("over budget on a claim console: %d %s", status, body)
394 }
395 fx.srv.cfg.HostClaim = false
396 if status, _ := postRun(t, fx.runsURL(runTestHost), fx.member, okMeta(), nil); status != http.StatusTooManyRequests {
397 t.Fatalf("the same member off a claim console: %d, want the 429 the claim console skipped", status)
398 }
399}
400
401// The body cap (file bytes plus the meta allowance) ends a stream that grows
402// past it with the 64 MB refusal: here a full-size declared file behind a
403// meta part padded past the allowance.
404func TestAPIRunCreateRefusesABodyPastTheCap(t *testing.T) {
405 fx := runFixture(t)
406 spool := spoolDir(t)
407 meta := okMeta()
408 meta.Files = []runs.MetaFile{{Name: "big", Size: runs.MaxFileBytes}}
409 var mb bytes.Buffer
410 json.NewEncoder(&mb).Encode(meta)
411 pad := strings.Repeat(" ", runs.MetaAllowance) // trailing white space: valid JSON, counted by the cap
412 var buf bytes.Buffer
413 mw := multipart.NewWriter(&buf)
414 mp, _ := mw.CreateFormField("meta")
415 mp.Write(mb.Bytes())
416 mp.Write([]byte(pad))
417 fp, _ := mw.CreateFormFile("file", "big")
418 fp.Write(make([]byte, runs.MaxFileBytes))
419 mw.Close()
420 if buf.Len() <= runs.MaxFileBytes+runs.MetaAllowance {
421 t.Fatalf("body is %d bytes, under the cap; the test needs a longer pad", buf.Len())
422 }
423 req, _ := http.NewRequest(http.MethodPost, fx.runsURL(runTestHost), &buf)
424 req.Header.Set("Content-Type", mw.FormDataContentType())
425 req.Header.Set("Authorization", "Bearer "+fx.member)
426 res, err := http.DefaultClient.Do(req)
427 if err != nil {
428 t.Fatal(err)
429 }
430 out, _ := io.ReadAll(res.Body)
431 res.Body.Close()
432 if res.StatusCode != http.StatusRequestEntityTooLarge || errorOf(t, string(out)) != "the files come to more than 64 MB" {
433 t.Fatalf("%d %s", res.StatusCode, out)
434 }
435 assertNoSpool(t, spool, "a body past the cap")
436 if left, _ := fx.rel.List(context.Background(), runs.RunsPrefix(release.OpsAccount, "shop")); len(left) != 0 {
437 t.Fatalf("left %q", left)
438 }
439}
440
441// commitThenFailStore commits the first write to the instance record and then
442// answers err, as a lost response or a cancelled request would: the record
443// holds the run, the caller was told nothing.
444type commitThenFailStore struct {
445 release.Store
446 recordKey string
447 err error
448 once sync.Once
449}
450
451func (c *commitThenFailStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) {
452 etag, err := c.Store.Put(ctx, key, body, ifMatch)
453 if err == nil && key == c.recordKey {
454 c.once.Do(func() { err = c.err })
455 }
456 return etag, err
457}
458
459// An admission whose answer was lost is still an admission: the door says
460// 202 and keeps the run's objects, because the record holds the run and a
461// box will read them.
462func TestAPIRunCreateAdmissionWhoseAnswerWasLost(t *testing.T) {
463 for name, failure := range map[string]error{
464 // The SDK re-sent a committed write: 412, then updateRecord's retry
465 // finds this very run queued.
466 "retried write": fmt.Errorf("s3store: record: %w", release.ErrCASConflict),
467 // A transport error, or the request's context cancelled, after the
468 // write landed: the door asks the record.
469 "transport error": errors.New("connection reset by peer"),
470 } {
471 t.Run(name, func(t *testing.T) {
472 fx := runFixture(t)
473 fx.srv.cfg.Releases = &commitThenFailStore{Store: fx.rel, recordKey: provision.RecordKey(release.OpsAccount, "shop", runTestHost), err: failure}
474 meta := okMeta()
475 meta.Files = []runs.MetaFile{{Name: "x.csv", Size: 5}}
476 status, body := postRun(t, fx.runsURL(runTestHost), fx.member, meta, []struct{ name, body string }{{"x.csv", "a,b,c"}})
477 if status != http.StatusAccepted {
478 t.Fatalf("%d %s", status, body)
479 }
480 var c runs.Created
481 if err := json.Unmarshal([]byte(body), &c); err != nil || c.State != provision.RunQueued {
482 t.Fatalf("created %+v, %v", c, err)
483 }
484 ctx := context.Background()
485 rec, err := provision.GetRecord(ctx, fx.rel, release.OpsAccount, "shop", runTestHost)
486 if err != nil || rec.ActiveRun == nil || rec.ActiveRun.ID != c.ID {
487 t.Fatalf("record run %+v, %v", rec.ActiveRun, err)
488 }
489 keys, _ := fx.rel.List(ctx, runs.Prefix(release.OpsAccount, "shop", c.ID))
490 if len(keys) != 2 {
491 t.Fatalf("the admitted run's objects are %q, want request.json and in/x.csv", keys)
492 }
493 })
494 }
495}
496
497// leavingStore answers every call whose context has ended with that
498// context's error, as a real bucket client does, and ends the caller's
499// request once the admission's record write has landed: the client went
500// away right after its run was admitted.
501type leavingStore struct {
502 release.Store
503 recordKey string
504 leave context.CancelFunc
505}
506
507func (l *leavingStore) Get(ctx context.Context, key string) ([]byte, string, error) {
508 if err := ctx.Err(); err != nil {
509 return nil, "", err
510 }
511 return l.Store.Get(ctx, key)
512}
513
514func (l *leavingStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) {
515 if err := ctx.Err(); err != nil {
516 return "", err
517 }
518 etag, err := l.Store.Put(ctx, key, body, ifMatch)
519 if err == nil && key == l.recordKey {
520 l.leave()
521 }
522 return etag, err
523}
524
525// A run admitted for a client that then went away still bumps the serial,
526// so a box picks it up at once, and its run-start row still names the
527// version the instance follows: both run on the admission's own context.
528func TestAPIRunCreateFinishesTheAdmissionForAClientThatLeft(t *testing.T) {
529 fx := runFixture(t)
530 shipVersion(t, fx.consoleFixture, "shop", "v1")
531 seedChannel(t, fx.consoleFixture, "shop", "stable", "v1")
532 before, _, _ := release.ReadSerial(context.Background(), fx.rel)
533
534 var buf bytes.Buffer
535 mw := multipart.NewWriter(&buf)
536 mp, _ := mw.CreateFormField("meta")
537 if err := json.NewEncoder(mp).Encode(okMeta()); err != nil {
538 t.Fatal(err)
539 }
540 mw.Close()
541 ctx, leave := context.WithCancel(context.Background())
542 defer leave()
543 fx.srv.cfg.Releases = &leavingStore{Store: fx.rel, recordKey: provision.RecordKey(release.OpsAccount, "shop", runTestHost), leave: leave}
544 req := httptest.NewRequestWithContext(ctx, http.MethodPost, fx.runsURL(runTestHost), &buf)
545 req.Header.Set("Content-Type", mw.FormDataContentType())
546 req.Header.Set("Authorization", "Bearer "+fx.member)
547 w := httptest.NewRecorder()
548 fx.srv.ServeHTTP(w, req)
549 if w.Code != http.StatusAccepted {
550 t.Fatalf("%d %s", w.Code, w.Body.String())
551 }
552 if ctx.Err() == nil {
553 t.Fatal("the client never left: the test proves nothing")
554 }
555 if after, _, err := release.ReadSerial(context.Background(), fx.rel); err != nil || after.Serial <= before.Serial {
556 t.Errorf("serial %d -> %d, %v: not bumped", before.Serial, after.Serial, err)
557 }
558 events, _ := fx.st.Events(OpsAccountID, "shop", 10)
559 found := false
560 for _, e := range events {
561 if e.Action == "run-start" && strings.Contains(e.Detail, `"version":"v1"`) {
562 found = true
563 }
564 }
565 if !found {
566 t.Errorf("no run-start row naming v1 in %+v", events)
567 }
568}
569