| 1 | package console |
| 2 | |
| 3 | // A keyed run create (service runs spec § 3): admitted once, settled by the |
| 4 | // instance record, and a retry with the same key gets the same run. |
| 5 | |
| 6 | import ( |
| 7 | "bytes" |
| 8 | "context" |
| 9 | "crypto/sha256" |
| 10 | "encoding/hex" |
| 11 | "encoding/json" |
| 12 | "errors" |
| 13 | "fmt" |
| 14 | "io" |
| 15 | "mime/multipart" |
| 16 | "net/http" |
| 17 | "net/http/httptest" |
| 18 | "net/url" |
| 19 | "slices" |
| 20 | "strings" |
| 21 | "sync" |
| 22 | "testing" |
| 23 | "time" |
| 24 | |
| 25 | "github.com/carlosframework/platform/internal/provision" |
| 26 | "github.com/carlosframework/platform/internal/release" |
| 27 | "github.com/carlosframework/platform/internal/runs" |
| 28 | "github.com/carlosframework/platform/internal/svcrole" |
| 29 | ) |
| 30 | |
| 31 | const testKey = "fleet:import:acct-42" |
| 32 | |
| 33 | // keyedFixture is runFixture with keyed creates turned on. |
| 34 | func keyedFixture(t *testing.T) *runFx { |
| 35 | t.Helper() |
| 36 | fx := runFixture(t) |
| 37 | fx.srv.cfg.RunKeysEnabled = true |
| 38 | return fx |
| 39 | } |
| 40 | |
| 41 | func sha(s string) string { |
| 42 | h := sha256.Sum256([]byte(s)) |
| 43 | return hex.EncodeToString(h[:]) |
| 44 | } |
| 45 | |
| 46 | // fileMeta is okMeta with one declared file, x.csv holding body. |
| 47 | func fileMeta(body string) runs.Meta { |
| 48 | m := okMeta() |
| 49 | m.Files = []runs.MetaFile{{Name: "x.csv", Size: int64(len(body)), SHA256: sha(body)}} |
| 50 | return m |
| 51 | } |
| 52 | |
| 53 | func xcsv(body string) []struct{ name, body string } { |
| 54 | return []struct{ name, body string }{{"x.csv", body}} |
| 55 | } |
| 56 | |
| 57 | // postKeyed is postRun with an Idempotency-Key header ("" sends none). |
| 58 | func postKeyed(t *testing.T, u, bearer, key string, meta runs.Meta, parts []struct{ name, body string }) (int, string) { |
| 59 | t.Helper() |
| 60 | var buf bytes.Buffer |
| 61 | mw := multipart.NewWriter(&buf) |
| 62 | mp, err := mw.CreateFormField("meta") |
| 63 | if err != nil { |
| 64 | t.Fatal(err) |
| 65 | } |
| 66 | if err := json.NewEncoder(mp).Encode(meta); err != nil { |
| 67 | t.Fatal(err) |
| 68 | } |
| 69 | for _, p := range parts { |
| 70 | fp, err := mw.CreateFormFile("file", p.name) |
| 71 | if err != nil { |
| 72 | t.Fatal(err) |
| 73 | } |
| 74 | io.WriteString(fp, p.body) |
| 75 | } |
| 76 | mw.Close() |
| 77 | req, err := http.NewRequest(http.MethodPost, u, &buf) |
| 78 | if err != nil { |
| 79 | t.Fatal(err) |
| 80 | } |
| 81 | req.Header.Set("Content-Type", mw.FormDataContentType()) |
| 82 | req.Header.Set("Authorization", "Bearer "+bearer) |
| 83 | if key != "" { |
| 84 | req.Header.Set("Idempotency-Key", key) |
| 85 | } |
| 86 | res, err := http.DefaultClient.Do(req) |
| 87 | if err != nil { |
| 88 | t.Fatal(err) |
| 89 | } |
| 90 | defer res.Body.Close() |
| 91 | out, _ := io.ReadAll(res.Body) |
| 92 | return res.StatusCode, string(out) |
| 93 | } |
| 94 | |
| 95 | // keyedCreated is the 202 body, with replayed. |
| 96 | type keyedCreated struct { |
| 97 | runs.Created |
| 98 | Replayed *bool `json:"replayed"` |
| 99 | } |
| 100 | |
| 101 | func createdOf(t *testing.T, status int, body string) keyedCreated { |
| 102 | t.Helper() |
| 103 | if status != http.StatusAccepted { |
| 104 | t.Fatalf("create: %d %s", status, body) |
| 105 | } |
| 106 | var c keyedCreated |
| 107 | if err := json.Unmarshal([]byte(body), &c); err != nil || !runs.ValidID(c.ID) { |
| 108 | t.Fatalf("created %s: %v", body, err) |
| 109 | } |
| 110 | return c |
| 111 | } |
| 112 | |
| 113 | func (c keyedCreated) replayed() bool { return c.Replayed != nil && *c.Replayed } |
| 114 | |
| 115 | // createKeyed is a keyed create that must answer 202. |
| 116 | func (fx *runFx) createKeyed(t *testing.T, bearer, key string, meta runs.Meta, parts []struct{ name, body string }) keyedCreated { |
| 117 | t.Helper() |
| 118 | status, body := postKeyed(t, fx.runsURL(runTestHost), bearer, key, meta, parts) |
| 119 | return createdOf(t, status, body) |
| 120 | } |
| 121 | |
| 122 | // runIDsWithObjects is every run id with objects under the shop app. |
| 123 | func (fx *runFx) runIDsWithObjects(t *testing.T) []string { |
| 124 | t.Helper() |
| 125 | prefix := runs.RunsPrefix(release.OpsAccount, "shop") |
| 126 | keys, err := fx.rel.List(context.Background(), prefix) |
| 127 | if err != nil { |
| 128 | t.Fatal(err) |
| 129 | } |
| 130 | var ids []string |
| 131 | for _, k := range keys { |
| 132 | id := strings.SplitN(strings.TrimPrefix(k, prefix), "/", 2)[0] |
| 133 | if !slices.Contains(ids, id) { |
| 134 | ids = append(ids, id) |
| 135 | } |
| 136 | } |
| 137 | return ids |
| 138 | } |
| 139 | |
| 140 | func (fx *runFx) instRecord(t *testing.T) *provision.Record { |
| 141 | t.Helper() |
| 142 | rec, err := provision.GetRecord(context.Background(), fx.rel, release.OpsAccount, "shop", runTestHost) |
| 143 | if err != nil { |
| 144 | t.Fatal(err) |
| 145 | } |
| 146 | return rec |
| 147 | } |
| 148 | |
| 149 | func (fx *runFx) runStarts(t *testing.T) int { |
| 150 | t.Helper() |
| 151 | events, _ := fx.st.Events(OpsAccountID, "shop", 100) |
| 152 | n := 0 |
| 153 | for _, e := range events { |
| 154 | if e.Action == "run-start" { |
| 155 | n++ |
| 156 | } |
| 157 | } |
| 158 | return n |
| 159 | } |
| 160 | |
| 161 | // finishAndAck takes id from queued to acknowledged through the doors a box |
| 162 | // and the CLI use. |
| 163 | func (fx *runFx) finishAndAck(t *testing.T, bearer, id string) { |
| 164 | t.Helper() |
| 165 | fx.transition(t, provision.RunPick(id, "box-1", "boot-1", time.Now().Add(time.Hour))) |
| 166 | if err := runs.WriteResult(context.Background(), fx.rel, release.OpsAccount, "shop", id, runs.Result{Exit: 0, LastSeq: 0}); err != nil { |
| 167 | t.Fatal(err) |
| 168 | } |
| 169 | fx.transition(t, provision.RunFinish(id)) |
| 170 | if status, body := fx.followAs(t, bearer, "ack", id); status != http.StatusOK { |
| 171 | t.Fatalf("ack: %d %s", status, body) |
| 172 | } |
| 173 | } |
| 174 | |
| 175 | func TestRunKeyedCreateThenRetryReturnsTheSameRun(t *testing.T) { |
| 176 | fx := keyedFixture(t) |
| 177 | first := fx.createKeyed(t, fx.member, testKey, fileMeta("a,b,c"), xcsv("a,b,c")) |
| 178 | if first.replayed() || first.State != provision.RunQueued { |
| 179 | t.Fatalf("a fresh admission: %+v", first) |
| 180 | } |
| 181 | again := fx.createKeyed(t, fx.member, testKey, fileMeta("a,b,c"), xcsv("a,b,c")) |
| 182 | if again.ID != first.ID || !again.replayed() || again.Host != runTestHost { |
| 183 | t.Fatalf("the retry: %+v, want %s replayed", again, first.ID) |
| 184 | } |
| 185 | if ids := fx.runIDsWithObjects(t); len(ids) != 1 || ids[0] != first.ID { |
| 186 | t.Fatalf("runs with objects %v, want only %s", ids, first.ID) |
| 187 | } |
| 188 | if n := fx.runStarts(t); n != 1 { |
| 189 | t.Errorf("%d run-start rows, want 1", n) |
| 190 | } |
| 191 | rec := fx.instRecord(t) |
| 192 | if len(rec.RunKeys) != 1 || rec.RunKeys[0].RunID != first.ID || rec.RunKeys[0].Key != runs.ScopedKey("user:alice@keymail.dev", testKey) { |
| 193 | t.Fatalf("run_keys %+v", rec.RunKeys) |
| 194 | } |
| 195 | req, err := runs.ReadRequest(context.Background(), fx.rel, release.OpsAccount, "shop", first.ID) |
| 196 | if err != nil || len(req.Files) != 1 || req.Files[0].SHA256 != sha("a,b,c") { |
| 197 | t.Fatalf("request %+v, %v", req, err) |
| 198 | } |
| 199 | // request.json links the run to its key by the scoped hash, never the |
| 200 | // caller's own words. |
| 201 | if req.IdempotencyKey != runs.ScopedKey("user:alice@keymail.dev", testKey) { |
| 202 | t.Fatalf("request.json idempotency_key %q, want the scoped hash", req.IdempotencyKey) |
| 203 | } |
| 204 | raw, _, err := fx.rel.Get(context.Background(), runs.RequestKey(release.OpsAccount, "shop", first.ID)) |
| 205 | if err != nil || strings.Contains(string(raw), testKey) { |
| 206 | t.Fatalf("request.json holds the raw key: %s, %v", raw, err) |
| 207 | } |
| 208 | } |
| 209 | |
| 210 | func TestRunKeyedRetryWhileOpenReturnsTheRun(t *testing.T) { |
| 211 | fx := keyedFixture(t) |
| 212 | runner := runServiceToken(t, fx.st, "ci", "shop", importClassic) |
| 213 | first := fx.createKeyed(t, runner, testKey, serviceMeta(), nil) |
| 214 | fx.transition(t, provision.RunPick(first.ID, "box-1", "boot-1", time.Now().Add(time.Hour))) |
| 215 | again := fx.createKeyed(t, runner, testKey, serviceMeta(), nil) |
| 216 | if again.ID != first.ID || !again.replayed() || again.State != provision.RunPicked { |
| 217 | t.Fatalf("the retry while open: %+v, want %s picked and replayed", again, first.ID) |
| 218 | } |
| 219 | if n := fx.runStarts(t); n != 1 { |
| 220 | t.Errorf("%d run-start rows, want 1", n) |
| 221 | } |
| 222 | } |
| 223 | |
| 224 | func TestRunKeyedRetryAfterAckStillReturnsTheRun(t *testing.T) { |
| 225 | fx := keyedFixture(t) |
| 226 | first := fx.createKeyed(t, fx.member, testKey, okMeta(), nil) |
| 227 | fx.finishAndAck(t, fx.member, first.ID) |
| 228 | again := fx.createKeyed(t, fx.member, testKey, okMeta(), nil) |
| 229 | if again.ID != first.ID || !again.replayed() || again.State != "" { |
| 230 | t.Fatalf("the retry after ack: %+v, want %s replayed, with no record state", again, first.ID) |
| 231 | } |
| 232 | // A later unkeyed run takes the record; the key still answers. |
| 233 | other := fx.startRun(t) |
| 234 | again = fx.createKeyed(t, fx.member, testKey, okMeta(), nil) |
| 235 | if again.ID != first.ID || !again.replayed() { |
| 236 | t.Fatalf("the retry after %s replaced it: %+v", other, again) |
| 237 | } |
| 238 | if ids := fx.runIDsWithObjects(t); len(ids) != 1 || ids[0] != other { |
| 239 | t.Fatalf("runs with objects %v, want only %s", ids, other) |
| 240 | } |
| 241 | } |
| 242 | |
| 243 | func TestRunKeyReusedWithADifferentRequest(t *testing.T) { |
| 244 | fx := keyedFixture(t) |
| 245 | first := fx.createKeyed(t, fx.member, testKey, okMeta(), nil) |
| 246 | before := fx.instRecord(t) |
| 247 | other := okMeta() |
| 248 | other.Argv = []string{"import", "y.csv"} |
| 249 | status, body := postKeyed(t, fx.runsURL(runTestHost), fx.member, testKey, other, nil) |
| 250 | if status != http.StatusConflict || codeOf(t, body) != runCodeKeyReused || errorOf(t, body) == "" { |
| 251 | t.Fatalf("the same key, another request: %d %s, want 409 %s", status, body, runCodeKeyReused) |
| 252 | } |
| 253 | if ids := fx.runIDsWithObjects(t); len(ids) != 1 || ids[0] != first.ID { |
| 254 | t.Fatalf("runs with objects %v, want only %s", ids, first.ID) |
| 255 | } |
| 256 | if after := fx.instRecord(t); after.RecordSeq != before.RecordSeq { |
| 257 | t.Fatalf("the refusal wrote the record: seq %d -> %d", before.RecordSeq, after.RecordSeq) |
| 258 | } |
| 259 | } |
| 260 | |
| 261 | // barrierStore holds every admission's record write until want request.json |
| 262 | // writes have landed, so that concurrent creates are all past the door's key |
| 263 | // lookup and have all written their objects before any is admitted. |
| 264 | type barrierStore struct { |
| 265 | release.Store |
| 266 | recordKey string |
| 267 | want int |
| 268 | mu sync.Mutex |
| 269 | requests int |
| 270 | ready chan struct{} |
| 271 | } |
| 272 | |
| 273 | func (b *barrierStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { |
| 274 | if key == b.recordKey { |
| 275 | select { |
| 276 | case <-b.ready: |
| 277 | case <-time.After(10 * time.Second): |
| 278 | return "", errors.New("barrier: the other create never wrote its objects") |
| 279 | } |
| 280 | } |
| 281 | etag, err := b.Store.Put(ctx, key, body, ifMatch) |
| 282 | if err == nil && strings.HasSuffix(key, "/request.json") { |
| 283 | b.mu.Lock() |
| 284 | b.requests++ |
| 285 | if b.requests == b.want { |
| 286 | close(b.ready) |
| 287 | } |
| 288 | b.mu.Unlock() |
| 289 | } |
| 290 | return etag, err |
| 291 | } |
| 292 | |
| 293 | func TestRunConcurrentIdenticalKeyedCreatesMakeOneRun(t *testing.T) { |
| 294 | fx := keyedFixture(t) |
| 295 | runner := runServiceToken(t, fx.st, "ci", "shop", importClassic) |
| 296 | fx.srv.cfg.Releases = &barrierStore{Store: fx.rel, recordKey: provision.RecordKey(release.OpsAccount, "shop", runTestHost), want: 2, ready: make(chan struct{})} |
| 297 | meta := serviceMeta() |
| 298 | meta.Files = []runs.MetaFile{{Name: "x.csv", Size: 5, SHA256: sha("a,b,c")}} |
| 299 | var wg sync.WaitGroup |
| 300 | statuses, bodies := make([]int, 2), make([]string, 2) |
| 301 | for i := range 2 { |
| 302 | wg.Add(1) |
| 303 | go func() { |
| 304 | defer wg.Done() |
| 305 | statuses[i], bodies[i] = postKeyed(t, fx.runsURL(runTestHost), runner, testKey, meta, xcsv("a,b,c")) |
| 306 | }() |
| 307 | } |
| 308 | wg.Wait() |
| 309 | a, b := createdOf(t, statuses[0], bodies[0]), createdOf(t, statuses[1], bodies[1]) |
| 310 | if a.ID != b.ID { |
| 311 | t.Fatalf("two runs for one key: %s and %s", a.ID, b.ID) |
| 312 | } |
| 313 | if a.replayed() == b.replayed() { |
| 314 | t.Fatalf("want one fresh admission and one replay: %s / %s", bodies[0], bodies[1]) |
| 315 | } |
| 316 | if ids := fx.runIDsWithObjects(t); len(ids) != 1 || ids[0] != a.ID { |
| 317 | t.Fatalf("runs with objects %v, want only the winner %s: the loser's objects must be gone", ids, a.ID) |
| 318 | } |
| 319 | if n := fx.runStarts(t); n != 1 { |
| 320 | t.Errorf("%d run-start rows, want 1", n) |
| 321 | } |
| 322 | if rec := fx.instRecord(t); rec.ActiveRun == nil || rec.ActiveRun.ID != a.ID || len(rec.RunKeys) != 1 { |
| 323 | t.Fatalf("record run %+v keys %+v", rec.ActiveRun, rec.RunKeys) |
| 324 | } |
| 325 | } |
| 326 | |
| 327 | // lookupKey is GET …/runs?key=<key>. |
| 328 | func (fx *runFx) lookupKey(t *testing.T, bearer, key string) (int, string) { |
| 329 | t.Helper() |
| 330 | status, _, body := rawReq(t, http.MethodGet, fx.runsURL(runTestHost)+"?key="+url.QueryEscape(key), bearer, "") |
| 331 | return status, body |
| 332 | } |
| 333 | |
| 334 | func TestRunKeyOfAnotherCallerIsInvisible(t *testing.T) { |
| 335 | fx := keyedFixture(t) |
| 336 | runner := runServiceToken(t, fx.st, "ci", "shop", importClassic) |
| 337 | person := fx.createKeyed(t, fx.member, testKey, okMeta(), nil) |
| 338 | if status, body := fx.lookupKey(t, runner, testKey); status != http.StatusNotFound || codeOf(t, body) != runCodeNotFound { |
| 339 | t.Fatalf("the service looking up the person's key: %d %s, want 404", status, body) |
| 340 | } |
| 341 | fx.finishAndAck(t, fx.member, person.ID) |
| 342 | svc := fx.createKeyed(t, runner, testKey, serviceMeta(), nil) |
| 343 | if svc.ID == person.ID || svc.replayed() { |
| 344 | t.Fatalf("the service's create with the person's key: %+v, want a fresh run", svc) |
| 345 | } |
| 346 | for _, c := range []struct { |
| 347 | bearer, want string |
| 348 | }{{fx.member, person.ID}, {runner, svc.ID}} { |
| 349 | status, body := fx.lookupKey(t, c.bearer, testKey) |
| 350 | var got struct { |
| 351 | RunID string `json:"run_id"` |
| 352 | } |
| 353 | if status != http.StatusOK || json.Unmarshal([]byte(body), &got) != nil || got.RunID != c.want { |
| 354 | t.Errorf("lookup: %d %s, want %s", status, body, c.want) |
| 355 | } |
| 356 | } |
| 357 | } |
| 358 | |
| 359 | func TestRunKeyedHashMismatchWritesNothing(t *testing.T) { |
| 360 | fx := keyedFixture(t) |
| 361 | dir := spoolDir(t) |
| 362 | status, body := postKeyed(t, fx.runsURL(runTestHost), fx.member, testKey, fileMeta("a,b,c"), xcsv("a,b,d")) |
| 363 | if status != http.StatusBadRequest || codeOf(t, body) != runCodeFileHashMismatch { |
| 364 | t.Fatalf("a file that is not its declared sha256: %d %s, want 400 %s", status, body, runCodeFileHashMismatch) |
| 365 | } |
| 366 | fx.noRunObjects(t, "a hash mismatch") |
| 367 | assertNoSpool(t, dir, "a hash mismatch") |
| 368 | if rec := fx.instRecord(t); len(rec.RunKeys) != 0 { |
| 369 | t.Fatalf("run_keys %+v", rec.RunKeys) |
| 370 | } |
| 371 | |
| 372 | // A keyed create must declare every file's sha256. |
| 373 | undeclared := fileMeta("a,b,c") |
| 374 | undeclared.Files[0].SHA256 = "" |
| 375 | status, body = postKeyed(t, fx.runsURL(runTestHost), fx.member, testKey, undeclared, xcsv("a,b,c")) |
| 376 | if status != http.StatusBadRequest || codeOf(t, body) != runCodeInvalidRequest { |
| 377 | t.Fatalf("a keyed file with no sha256: %d %s, want 400 %s", status, body, runCodeInvalidRequest) |
| 378 | } |
| 379 | // A declared sha256 is checked whether or not the create is keyed. |
| 380 | status, body = postKeyed(t, fx.runsURL(runTestHost), fx.member, "", fileMeta("a,b,c"), xcsv("a,b,d")) |
| 381 | if status != http.StatusBadRequest || codeOf(t, body) != runCodeFileHashMismatch { |
| 382 | t.Fatalf("an unkeyed mismatch: %d %s", status, body) |
| 383 | } |
| 384 | badShape := fileMeta("a,b,c") |
| 385 | badShape.Files[0].SHA256 = "ABC" |
| 386 | status, body = postKeyed(t, fx.runsURL(runTestHost), fx.member, "", badShape, xcsv("a,b,c")) |
| 387 | if status != http.StatusBadRequest || codeOf(t, body) != runCodeInvalidRequest { |
| 388 | t.Fatalf("a malformed sha256: %d %s", status, body) |
| 389 | } |
| 390 | fx.noRunObjects(t, "the hash refusals") |
| 391 | } |
| 392 | |
| 393 | // failAfter is a request body that serves head and then fails every read. |
| 394 | type failAfter struct { |
| 395 | head io.Reader |
| 396 | hit bool |
| 397 | } |
| 398 | |
| 399 | func (f *failAfter) Read(p []byte) (int, error) { |
| 400 | if n, err := f.head.Read(p); n > 0 || err != io.EOF { |
| 401 | return n, err |
| 402 | } |
| 403 | f.hit = true |
| 404 | return 0, errors.New("failAfter: the file part must not be read") |
| 405 | } |
| 406 | |
| 407 | // serveBrokenBody sends a create whose body is the meta part and the start |
| 408 | // of a file part, after which every read fails, straight to the handler. |
| 409 | func (fx *runFx) serveBrokenBody(t *testing.T, bearer, key string, meta runs.Meta) *httptest.ResponseRecorder { |
| 410 | t.Helper() |
| 411 | var buf bytes.Buffer |
| 412 | mw := multipart.NewWriter(&buf) |
| 413 | mp, _ := mw.CreateFormField("meta") |
| 414 | if err := json.NewEncoder(mp).Encode(meta); err != nil { |
| 415 | t.Fatal(err) |
| 416 | } |
| 417 | fp, _ := mw.CreateFormFile("file", "x.csv") |
| 418 | io.WriteString(fp, "a") |
| 419 | req := httptest.NewRequest(http.MethodPost, fx.runsURL(runTestHost), &failAfter{head: &buf}) |
| 420 | req.Header.Set("Content-Type", mw.FormDataContentType()) |
| 421 | req.Header.Set("Authorization", "Bearer "+bearer) |
| 422 | if key != "" { |
| 423 | req.Header.Set("Idempotency-Key", key) |
| 424 | } |
| 425 | w := httptest.NewRecorder() |
| 426 | fx.srv.ServeHTTP(w, req) |
| 427 | return w |
| 428 | } |
| 429 | |
| 430 | func TestRunKnownKeyAnsweredWithoutReadingFiles(t *testing.T) { |
| 431 | fx := keyedFixture(t) |
| 432 | first := fx.createKeyed(t, fx.member, testKey, fileMeta("a,b,c"), xcsv("a,b,c")) |
| 433 | w := fx.serveBrokenBody(t, fx.member, testKey, fileMeta("a,b,c")) |
| 434 | again := createdOf(t, w.Code, w.Body.String()) |
| 435 | if again.ID != first.ID || !again.replayed() { |
| 436 | t.Fatalf("the retry: %+v, want %s replayed", again, first.ID) |
| 437 | } |
| 438 | // The same broken body with a fresh key reaches the file part and fails. |
| 439 | fx.finishAndAck(t, fx.member, first.ID) |
| 440 | w = fx.serveBrokenBody(t, fx.member, "another-key", fileMeta("a,b,c")) |
| 441 | if w.Code != http.StatusBadRequest || codeOf(t, w.Body.String()) != runCodeMalformed { |
| 442 | t.Fatalf("a fresh key with a broken file part: %d %s, want 400 malformed", w.Code, w.Body.String()) |
| 443 | } |
| 444 | } |
| 445 | |
| 446 | func TestRunRefusedKeyIsReevaluated(t *testing.T) { |
| 447 | fx := keyedFixture(t) |
| 448 | open := fx.startRun(t) |
| 449 | status, body := postKeyed(t, fx.runsURL(runTestHost), fx.member, testKey, okMeta(), nil) |
| 450 | if status != http.StatusConflict || codeOf(t, body) != runCodeRunOpen { |
| 451 | t.Fatalf("a keyed create over an open run: %d %s, want 409 run_open", status, body) |
| 452 | } |
| 453 | fx.finishAndAck(t, fx.member, open) |
| 454 | c := fx.createKeyed(t, fx.member, testKey, okMeta(), nil) |
| 455 | if c.replayed() || c.ID == open { |
| 456 | t.Fatalf("the retry once the other run was acked: %+v, want a fresh run", c) |
| 457 | } |
| 458 | } |
| 459 | |
| 460 | func TestRunKeyLookup(t *testing.T) { |
| 461 | fx := keyedFixture(t) |
| 462 | if status, body := fx.lookupKey(t, fx.member, testKey); status != http.StatusNotFound || codeOf(t, body) != runCodeNotFound { |
| 463 | t.Fatalf("an unknown key: %d %s, want 404", status, body) |
| 464 | } |
| 465 | c := fx.createKeyed(t, fx.member, testKey, okMeta(), nil) |
| 466 | status, body := fx.lookupKey(t, fx.member, testKey) |
| 467 | if status != http.StatusOK || body != fmt.Sprintf("{\"run_id\":%q}\n", c.ID) { |
| 468 | t.Fatalf("a known key: %d %s, want %s", status, body, c.ID) |
| 469 | } |
| 470 | for _, bad := range []string{"", "a b", strings.Repeat("k", 129)} { |
| 471 | status, _, body := rawReq(t, http.MethodGet, fx.runsURL(runTestHost)+"?key="+url.QueryEscape(bad), fx.member, "") |
| 472 | if status != http.StatusBadRequest || codeOf(t, body) != runCodeKeyInvalid { |
| 473 | t.Errorf("key %q: %d %s, want 400 %s", bad, status, body, runCodeKeyInvalid) |
| 474 | } |
| 475 | } |
| 476 | status, _, body = rawReq(t, http.MethodGet, fx.ts.URL+"/api/cli/apps/"+release.OpsAccount+"/shop/instances/nobody.example.com/runs?key="+testKey, fx.member, "") |
| 477 | if status != http.StatusNotFound || codeOf(t, body) != runCodeNotFound { |
| 478 | t.Fatalf("an unknown host: %d %s", status, body) |
| 479 | } |
| 480 | // The shared gates hold here as at every run door. |
| 481 | stranger := cliToken(t, fx.consoleFixture, "mallory@keymail.dev", "code-run-key-stranger") |
| 482 | status, h, body := rawReq(t, http.MethodGet, fx.runsURL(runTestHost)+"?key="+testKey, stranger, "") |
| 483 | assertNotFound(t, "a stranger's lookup", canonicalNotFound(t), status, h, body) |
| 484 | fx.srv.cfg.HostClaim = true |
| 485 | if status, body := fx.lookupKey(t, fx.member, testKey); status != http.StatusConflict || codeOf(t, body) != runCodeHostClaim { |
| 486 | t.Fatalf("a lookup on a host-claim console: %d %s", status, body) |
| 487 | } |
| 488 | } |
| 489 | |
| 490 | // The key lookup is a run door: the run role reaches it, every other role |
| 491 | // gets the role gate's 403, Admin included. |
| 492 | func TestRunKeyLookupRoleMatrix(t *testing.T) { |
| 493 | fx := keyedFixture(t) |
| 494 | runner := runServiceToken(t, fx.st, "ci", "shop", importClassic) |
| 495 | u := fx.runsURL(runTestHost) + "?key=" + testKey |
| 496 | if status, _, body := rawReq(t, http.MethodGet, u, runner, ""); status != http.StatusNotFound || codeOf(t, body) != runCodeNotFound { |
| 497 | t.Fatalf("a run service: %d %s, want the lookup's own 404", status, body) |
| 498 | } |
| 499 | for _, r := range svcrole.All() { |
| 500 | if r == svcrole.Run { |
| 501 | continue |
| 502 | } |
| 503 | app, rungs := "shop", []string(nil) |
| 504 | if r == svcrole.Admin { |
| 505 | app = "" |
| 506 | } |
| 507 | if r == svcrole.Deploy { |
| 508 | rungs = []string{"edge"} |
| 509 | } |
| 510 | tok, err := fx.st.NewServicePrincipalScoped(OpsAccountID, "role-"+string(r), app, r, rungs, nil, "paul@keymail.dev", time.Now()) |
| 511 | if err != nil { |
| 512 | t.Fatalf("mint %s: %v", r, err) |
| 513 | } |
| 514 | raw, _ := json.Marshal(map[string]string{"error": fmt.Sprintf("this credential holds role %q; this door needs one of [run]", r)}) |
| 515 | if status, _, body := rawReq(t, http.MethodGet, u, tok, ""); status != http.StatusForbidden || body != string(raw)+"\n" { |
| 516 | t.Errorf("role %s: %d %q, want 403 %s", r, status, body, raw) |
| 517 | } |
| 518 | } |
| 519 | } |
| 520 | |
| 521 | // TestRunUnkeyedOrderUnchanged: an unkeyed create over an open run is |
| 522 | // refused before its body is read, as before keys. A keyed one reads its |
| 523 | // meta first, so the same broken body is malformed. |
| 524 | func TestRunUnkeyedOrderUnchanged(t *testing.T) { |
| 525 | fx := keyedFixture(t) |
| 526 | fx.startRun(t) |
| 527 | broken := func(key string) *httptest.ResponseRecorder { |
| 528 | req := httptest.NewRequest(http.MethodPost, fx.runsURL(runTestHost), &failAfter{head: strings.NewReader("")}) |
| 529 | req.Header.Set("Content-Type", "multipart/form-data; boundary=x") |
| 530 | req.Header.Set("Authorization", "Bearer "+fx.member) |
| 531 | if key != "" { |
| 532 | req.Header.Set("Idempotency-Key", key) |
| 533 | } |
| 534 | w := httptest.NewRecorder() |
| 535 | fx.srv.ServeHTTP(w, req) |
| 536 | return w |
| 537 | } |
| 538 | if w := broken(""); w.Code != http.StatusConflict || codeOf(t, w.Body.String()) != runCodeRunOpen { |
| 539 | t.Fatalf("unkeyed over an open run: %d %s, want 409 run_open before the body", w.Code, w.Body.String()) |
| 540 | } |
| 541 | if w := broken(testKey); w.Code != http.StatusBadRequest || codeOf(t, w.Body.String()) != runCodeMalformed { |
| 542 | t.Fatalf("keyed over an open run with a broken body: %d %s, want 400 malformed", w.Code, w.Body.String()) |
| 543 | } |
| 544 | } |
| 545 | |
| 546 | // The keyed form of TestAPIRunCreateAdmissionWhoseAnswerWasLost: the retry |
| 547 | // inside updateRecord finds this request's own key, which is a fresh |
| 548 | // admission, and the run's objects stay. |
| 549 | func TestAPIRunCreateAdmissionWhoseAnswerWasLostKeyed(t *testing.T) { |
| 550 | for name, failure := range map[string]error{ |
| 551 | "retried write": fmt.Errorf("s3store: record: %w", release.ErrCASConflict), |
| 552 | "transport error": errors.New("connection reset by peer"), |
| 553 | } { |
| 554 | t.Run(name, func(t *testing.T) { |
| 555 | fx := keyedFixture(t) |
| 556 | fx.srv.cfg.Releases = &commitThenFailStore{Store: fx.rel, recordKey: provision.RecordKey(release.OpsAccount, "shop", runTestHost), err: failure} |
| 557 | c := fx.createKeyed(t, fx.member, testKey, fileMeta("a,b,c"), xcsv("a,b,c")) |
| 558 | if c.replayed() || c.State != provision.RunQueued { |
| 559 | t.Fatalf("created %+v, want a fresh admission", c) |
| 560 | } |
| 561 | rec := fx.instRecord(t) |
| 562 | if rec.ActiveRun == nil || rec.ActiveRun.ID != c.ID || len(rec.RunKeys) != 1 || rec.RunKeys[0].RunID != c.ID { |
| 563 | t.Fatalf("record run %+v keys %+v", rec.ActiveRun, rec.RunKeys) |
| 564 | } |
| 565 | keys, _ := fx.rel.List(context.Background(), runs.Prefix(release.OpsAccount, "shop", c.ID)) |
| 566 | if len(keys) != 2 { |
| 567 | t.Fatalf("the admitted run's objects are %q, want request.json and in/x.csv", keys) |
| 568 | } |
| 569 | if n := fx.runStarts(t); n != 1 { |
| 570 | t.Errorf("%d run-start rows, want 1", n) |
| 571 | } |
| 572 | }) |
| 573 | } |
| 574 | } |
| 575 | |
| 576 | // admitRun itself answers the retry inside updateRecord that finds this |
| 577 | // request's own key: admitted, with the record's state, and no error for the |
| 578 | // handler to settle. |
| 579 | func TestAdmitRunOwnKeyReplayIsAdmitted(t *testing.T) { |
| 580 | fx := keyedFixture(t) |
| 581 | fx.srv.cfg.Releases = &commitThenFailStore{Store: fx.rel, recordKey: provision.RecordKey(release.OpsAccount, "shop", runTestHost), |
| 582 | err: fmt.Errorf("s3store: record: %w", release.ErrCASConflict)} |
| 583 | id, err := runs.MintID() |
| 584 | if err != nil { |
| 585 | t.Fatal(err) |
| 586 | } |
| 587 | state, err := fx.srv.admitRun(context.Background(), release.OpsAccount, "shop", runTestHost, id, "alice@keymail.dev", "K", "D", "C") |
| 588 | if err != nil || state != provision.RunQueued { |
| 589 | t.Fatalf("admitRun = %q, %v; want queued and no error", state, err) |
| 590 | } |
| 591 | } |
| 592 | |
| 593 | func TestKeyedCreateRefusedUntilKeysAreOn(t *testing.T) { |
| 594 | fx := runFixture(t) |
| 595 | status, body := postKeyed(t, fx.runsURL(runTestHost), fx.member, testKey, fileMeta("a,b,c"), xcsv("a,b,c")) |
| 596 | if status != http.StatusServiceUnavailable || codeOf(t, body) != runCodeKeysNotReady || errorOf(t, body) == "" { |
| 597 | t.Fatalf("a keyed create with keys off: %d %s, want 503 %s", status, body, runCodeKeysNotReady) |
| 598 | } |
| 599 | fx.noRunObjects(t, "a keyed create with keys off") |
| 600 | if rec := fx.instRecord(t); len(rec.RunKeys) != 0 { |
| 601 | t.Fatalf("run_keys %+v", rec.RunKeys) |
| 602 | } |
| 603 | // Even an invalid key is refused as not ready, never run unkeyed. |
| 604 | if status, body := postKeyed(t, fx.runsURL(runTestHost), fx.member, "a b", okMeta(), nil); status != http.StatusServiceUnavailable { |
| 605 | t.Fatalf("an invalid key with keys off: %d %s", status, body) |
| 606 | } |
| 607 | c := fx.createKeyed(t, fx.member, "", okMeta(), nil) |
| 608 | if c.replayed() { |
| 609 | t.Fatalf("an unkeyed create: %+v", c) |
| 610 | } |
| 611 | if req, err := runs.ReadRequest(context.Background(), fx.rel, release.OpsAccount, "shop", c.ID); err != nil || req.IdempotencyKey != "" { |
| 612 | t.Fatalf("an unkeyed request: %+v, %v", req, err) |
| 613 | } |
| 614 | if rec := fx.instRecord(t); len(rec.RunKeys) != 0 { |
| 615 | t.Fatalf("an unkeyed create recorded a key: %+v", rec.RunKeys) |
| 616 | } |
| 617 | // With keys on, an invalid key is refused by its shape. |
| 618 | fx.srv.cfg.RunKeysEnabled = true |
| 619 | if status, body := postKeyed(t, fx.runsURL(runTestHost), fx.member, "a/b", okMeta(), nil); status != http.StatusBadRequest || codeOf(t, body) != runCodeKeyInvalid { |
| 620 | t.Fatalf("an invalid key: %d %s, want 400 %s", status, body, runCodeKeyInvalid) |
| 621 | } |
| 622 | } |
| 623 | |
| 624 | // The codes this task adds are in the one list. |
| 625 | func TestRunKeyCodesAreListed(t *testing.T) { |
| 626 | for _, c := range []string{runCodeKeyReused, runCodeKeyInvalid, runCodeKeysNotReady, runCodeFileHashMismatch} { |
| 627 | if !slices.Contains(runRefusalCodes, c) { |
| 628 | t.Errorf("%s is not in runRefusalCodes", c) |
| 629 | } |
| 630 | } |
| 631 | for c, want := range map[string]string{ |
| 632 | runCodeKeyReused: "idempotency_key_reused", runCodeKeyInvalid: "idempotency_key_invalid", |
| 633 | runCodeKeysNotReady: "keys_not_ready", runCodeFileHashMismatch: "file_hash_mismatch", |
| 634 | } { |
| 635 | if c != want { |
| 636 | t.Errorf("code %s, want %s", c, want) |
| 637 | } |
| 638 | } |
| 639 | } |
| 640 | |
| 641 | // movedOnStore commits the admission's record write, then has the run picked, |
| 642 | // finished and cleared (a box and an ack, faster than the answer), and |
| 643 | // answers err: the record's ActiveRun no longer names the run when the door |
| 644 | // settles the lost answer. |
| 645 | type movedOnStore struct { |
| 646 | release.Store |
| 647 | recordKey string |
| 648 | err error |
| 649 | once sync.Once |
| 650 | t *testing.T |
| 651 | } |
| 652 | |
| 653 | func (m *movedOnStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { |
| 654 | etag, err := m.Store.Put(ctx, key, body, ifMatch) |
| 655 | if err != nil || key != m.recordKey { |
| 656 | return etag, err |
| 657 | } |
| 658 | m.once.Do(func() { |
| 659 | rec, gerr := provision.DecodeRecord(body) |
| 660 | if gerr != nil || rec.ActiveRun == nil { |
| 661 | m.t.Errorf("movedOnStore: %v %+v", gerr, rec.ActiveRun) |
| 662 | return |
| 663 | } |
| 664 | id := rec.ActiveRun.ID |
| 665 | for _, mut := range []func(*provision.Record) error{ |
| 666 | provision.RunPick(id, "box-1", "boot-1", time.Now().Add(time.Hour)), |
| 667 | provision.RunFinish(id), |
| 668 | provision.RunClear(id), |
| 669 | } { |
| 670 | if _, e := provision.SetRecordRun(context.Background(), m.Store, release.OpsAccount, "shop", runTestHost, mut); e != nil { |
| 671 | m.t.Errorf("movedOnStore: %v", e) |
| 672 | } |
| 673 | } |
| 674 | err = m.err |
| 675 | }) |
| 676 | return etag, err |
| 677 | } |
| 678 | |
| 679 | // A keyed admission whose answer was lost is settled by its key, not by |
| 680 | // ActiveRun: the record has moved on, the key still names this run, so the |
| 681 | // door answers it admitted and keeps its objects. |
| 682 | func TestRunKeyedLostAdmissionSettledByItsKey(t *testing.T) { |
| 683 | fx := keyedFixture(t) |
| 684 | fx.srv.cfg.Releases = &movedOnStore{Store: fx.rel, recordKey: provision.RecordKey(release.OpsAccount, "shop", runTestHost), |
| 685 | err: errors.New("connection reset by peer"), t: t} |
| 686 | c := fx.createKeyed(t, fx.member, testKey, fileMeta("a,b,c"), xcsv("a,b,c")) |
| 687 | if c.replayed() || c.State != "" { |
| 688 | t.Fatalf("created %+v, want a fresh admission whose run the record no longer names", c) |
| 689 | } |
| 690 | rec := fx.instRecord(t) |
| 691 | if rec.ActiveRun != nil || len(rec.RunKeys) != 1 || rec.RunKeys[0].RunID != c.ID { |
| 692 | t.Fatalf("record run %+v keys %+v", rec.ActiveRun, rec.RunKeys) |
| 693 | } |
| 694 | if keys, _ := fx.rel.List(context.Background(), runs.Prefix(release.OpsAccount, "shop", c.ID)); len(keys) != 2 { |
| 695 | t.Fatalf("the committed run's objects are %q, want request.json and in/x.csv", keys) |
| 696 | } |
| 697 | if n := fx.runStarts(t); n != 1 { |
| 698 | t.Errorf("%d run-start rows, want 1", n) |
| 699 | } |
| 700 | } |
| 701 | |
| 702 | // keysDroppedStore commits the admission's record write, then has a record |
| 703 | // writer drop run_keys (a box rolled back to code that does not know them) |
| 704 | // while the record still holds the run, and answers err. |
| 705 | type keysDroppedStore struct { |
| 706 | release.Store |
| 707 | recordKey string |
| 708 | err error |
| 709 | once sync.Once |
| 710 | t *testing.T |
| 711 | } |
| 712 | |
| 713 | func (m *keysDroppedStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { |
| 714 | etag, err := m.Store.Put(ctx, key, body, ifMatch) |
| 715 | if err != nil || key != m.recordKey { |
| 716 | return etag, err |
| 717 | } |
| 718 | m.once.Do(func() { |
| 719 | drop := func(r *provision.Record) error { r.RunKeys = nil; return nil } |
| 720 | if _, e := provision.SetRecordRun(context.Background(), m.Store, release.OpsAccount, "shop", runTestHost, drop); e != nil { |
| 721 | m.t.Errorf("keysDroppedStore: %v", e) |
| 722 | } |
| 723 | }) |
| 724 | return etag, m.err |
| 725 | } |
| 726 | |
| 727 | // A keyed admission whose answer was lost, and whose key is gone from the |
| 728 | // record while the record still names the run, is admitted: the objects stay |
| 729 | // and the answer names the run, as the unkeyed settle would have. |
| 730 | func TestRunKeyedSettleKeepsARunTheRecordHoldsWhenItsKeyIsGone(t *testing.T) { |
| 731 | fx := keyedFixture(t) |
| 732 | fx.srv.cfg.Releases = &keysDroppedStore{Store: fx.rel, recordKey: provision.RecordKey(release.OpsAccount, "shop", runTestHost), |
| 733 | err: errors.New("connection reset by peer"), t: t} |
| 734 | c := fx.createKeyed(t, fx.member, testKey, fileMeta("a,b,c"), xcsv("a,b,c")) |
| 735 | rec := fx.instRecord(t) |
| 736 | if rec.ActiveRun == nil || rec.ActiveRun.ID != c.ID || len(rec.RunKeys) != 0 { |
| 737 | t.Fatalf("answer %+v; record run %+v keys %+v, want the run held with no key", c, rec.ActiveRun, rec.RunKeys) |
| 738 | } |
| 739 | if c.State != provision.RunQueued { |
| 740 | t.Errorf("answered %+v, want the record's queued state", c) |
| 741 | } |
| 742 | if keys, _ := fx.rel.List(context.Background(), runs.Prefix(release.OpsAccount, "shop", c.ID)); len(keys) != 2 { |
| 743 | t.Fatalf("the held run's objects are %q, want request.json and in/x.csv", keys) |
| 744 | } |
| 745 | } |
| 746 | |
| 747 | // twinOnRetryStore loses both of updateRecord's attempts: the first record |
| 748 | // write is refused as a conflict without landing, and before the second a |
| 749 | // twin's keyed admission lands, so the second conflicts for real. |
| 750 | type twinOnRetryStore struct { |
| 751 | release.Store |
| 752 | recordKey string |
| 753 | twin provision.RunAdmission |
| 754 | puts int |
| 755 | t *testing.T |
| 756 | } |
| 757 | |
| 758 | func (s *twinOnRetryStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { |
| 759 | if key == s.recordKey { |
| 760 | s.puts++ |
| 761 | switch s.puts { |
| 762 | case 1: |
| 763 | return "", fmt.Errorf("s3store: record: %w", release.ErrCASConflict) |
| 764 | case 2: |
| 765 | if _, err := provision.SetRecordRun(context.Background(), s.Store, release.OpsAccount, "shop", runTestHost, provision.RunAdmit(s.twin)); err != nil { |
| 766 | s.t.Errorf("twin admission: %v", err) |
| 767 | } |
| 768 | } |
| 769 | } |
| 770 | return s.Store.Put(ctx, key, body, ifMatch) |
| 771 | } |
| 772 | |
| 773 | // A keyed create that loses both CAS attempts to a twin answers with the |
| 774 | // twin's run, replayed, and deletes only its own objects: not a 502. |
| 775 | func TestRunKeyedCreateLosingBothAttemptsAnswersTheTwin(t *testing.T) { |
| 776 | fx := keyedFixture(t) |
| 777 | scope := "user:alice@keymail.dev" |
| 778 | twinID, err := runs.MintID() |
| 779 | if err != nil { |
| 780 | t.Fatal(err) |
| 781 | } |
| 782 | st := &twinOnRetryStore{Store: fx.rel, recordKey: provision.RecordKey(release.OpsAccount, "shop", runTestHost), t: t, |
| 783 | twin: provision.RunAdmission{ID: twinID, By: "alice@keymail.dev", Deadline: time.Now().Add(time.Minute), |
| 784 | Key: runs.ScopedKey(scope, testKey), Digest: runs.MetaDigest(okMeta()), Caller: runs.CallerOf(scope)}} |
| 785 | fx.srv.cfg.Releases = st |
| 786 | c := fx.createKeyed(t, fx.member, testKey, okMeta(), nil) |
| 787 | if st.puts != 2 { |
| 788 | t.Fatalf("%d record writes, want the two lost attempts", st.puts) |
| 789 | } |
| 790 | if c.ID != twinID || !c.replayed() || c.State != provision.RunQueued { |
| 791 | t.Fatalf("created %+v, want the twin %s, replayed and queued", c, twinID) |
| 792 | } |
| 793 | if ids := fx.runIDsWithObjects(t); len(ids) != 0 { |
| 794 | t.Fatalf("runs with objects %v: the loser's objects must be gone", ids) |
| 795 | } |
| 796 | if n := fx.runStarts(t); n != 0 { |
| 797 | t.Errorf("%d run-start rows, want none from the loser", n) |
| 798 | } |
| 799 | } |
| 800 | |
| 801 | // A declared sha256 may be in either case: it is lowercased before it is |
| 802 | // compared or digested, so an uppercase declaration is not refused and a |
| 803 | // retry in the other case is the same request. |
| 804 | func TestRunDeclaredHashIsCaseInsensitive(t *testing.T) { |
| 805 | fx := keyedFixture(t) |
| 806 | upper := fileMeta("a,b,c") |
| 807 | upper.Files[0].SHA256 = strings.ToUpper(upper.Files[0].SHA256) |
| 808 | plain := fx.createKeyed(t, fx.member, "", upper, xcsv("a,b,c")) |
| 809 | fx.finishAndAck(t, fx.member, plain.ID) |
| 810 | first := fx.createKeyed(t, fx.member, testKey, upper, xcsv("a,b,c")) |
| 811 | again := fx.createKeyed(t, fx.member, testKey, fileMeta("a,b,c"), xcsv("a,b,c")) |
| 812 | if again.ID != first.ID || !again.replayed() { |
| 813 | t.Fatalf("a lowercase retry of an uppercase create: %+v, want %s replayed", again, first.ID) |
| 814 | } |
| 815 | req, err := runs.ReadRequest(context.Background(), fx.rel, release.OpsAccount, "shop", first.ID) |
| 816 | if err != nil || req.Files[0].SHA256 != sha("a,b,c") { |
| 817 | t.Fatalf("request %+v, %v", req, err) |
| 818 | } |
| 819 | } |
| 820 | |