| 1 | package console |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "errors" |
| 7 | "fmt" |
| 8 | "net/http" |
| 9 | "slices" |
| 10 | "strings" |
| 11 | "testing" |
| 12 | "time" |
| 13 | |
| 14 | "github.com/carlosframework/platform/internal/provision" |
| 15 | "github.com/carlosframework/platform/internal/release" |
| 16 | "github.com/carlosframework/platform/internal/runs" |
| 17 | "github.com/carlosframework/platform/internal/svcrole" |
| 18 | ) |
| 19 | |
| 20 | // startRun admits one run through the door and returns its id. |
| 21 | func (fx *runFx) startRun(t *testing.T) string { |
| 22 | t.Helper() |
| 23 | status, body := postRun(t, fx.runsURL(runTestHost), fx.member, okMeta(), nil) |
| 24 | if status != http.StatusAccepted { |
| 25 | t.Fatalf("create: %d %s", status, body) |
| 26 | } |
| 27 | var c runs.Created |
| 28 | if err := json.Unmarshal([]byte(body), &c); err != nil { |
| 29 | t.Fatal(err) |
| 30 | } |
| 31 | return c.ID |
| 32 | } |
| 33 | |
| 34 | func (fx *runFx) transition(t *testing.T, mut func(*provision.Record) error) { |
| 35 | t.Helper() |
| 36 | if _, err := provision.SetRecordRun(context.Background(), fx.rel, release.OpsAccount, "shop", runTestHost, mut); err != nil { |
| 37 | t.Fatal(err) |
| 38 | } |
| 39 | } |
| 40 | |
| 41 | func (fx *runFx) chunk(t *testing.T, id string, seq int64, stream, data string) { |
| 42 | t.Helper() |
| 43 | if _, err := fx.rel.Put(context.Background(), runs.ChunkKey(release.OpsAccount, "shop", id, seq, stream), []byte(data), ""); err != nil { |
| 44 | t.Fatal(err) |
| 45 | } |
| 46 | } |
| 47 | |
| 48 | func (fx *runFx) page(t *testing.T, id string, after int64) runs.Page { |
| 49 | t.Helper() |
| 50 | status, _, body := rawReq(t, http.MethodGet, fmt.Sprintf("%s/%s?after=%d", fx.runsURL(runTestHost), id, after), fx.member, "") |
| 51 | if status != http.StatusOK { |
| 52 | t.Fatalf("GET page: %d %s", status, body) |
| 53 | } |
| 54 | var p runs.Page |
| 55 | if err := json.Unmarshal([]byte(body), &p); err != nil { |
| 56 | t.Fatal(err) |
| 57 | } |
| 58 | return p |
| 59 | } |
| 60 | |
| 61 | func (fx *runFx) post(t *testing.T, id, verb, body string) (int, string) { |
| 62 | t.Helper() |
| 63 | status, _, out := rawReq(t, http.MethodPost, fx.runsURL(runTestHost)+"/"+id+"/"+verb, fx.member, body) |
| 64 | return status, out |
| 65 | } |
| 66 | |
| 67 | func TestAPIRunGetPagesChunksAndTheResult(t *testing.T) { |
| 68 | fx := runFixture(t) |
| 69 | id := fx.startRun(t) |
| 70 | fx.transition(t, provision.RunPick(id, "box-1", "boot-1", time.Now().Add(time.Hour))) |
| 71 | fx.chunk(t, id, 1, runs.StreamOut, "hello ") |
| 72 | fx.chunk(t, id, 2, runs.StreamErr, "warn") |
| 73 | fx.chunk(t, id, 3, runs.StreamOut, "world") |
| 74 | p := fx.page(t, id, 0) |
| 75 | if p.State != provision.RunPicked || len(p.Chunks) != 3 || p.Result != nil || string(p.Chunks[1].Data) != "warn" || p.Chunks[1].Stream != runs.StreamErr { |
| 76 | t.Fatalf("page %+v", p) |
| 77 | } |
| 78 | if p := fx.page(t, id, 2); len(p.Chunks) != 1 || p.Chunks[0].Seq != 3 { |
| 79 | t.Fatalf("after 2: %+v", p) |
| 80 | } |
| 81 | } |
| 82 | |
| 83 | func TestAPIRunGetPagesSeveralPagesAfterTheResult(t *testing.T) { |
| 84 | fx := runFixture(t) |
| 85 | id := fx.startRun(t) |
| 86 | fx.transition(t, provision.RunPick(id, "box-1", "boot-1", time.Now().Add(time.Hour))) |
| 87 | for seq := int64(1); seq <= 3*runs.PageChunks; seq++ { |
| 88 | fx.chunk(t, id, seq, runs.StreamOut, "x") |
| 89 | } |
| 90 | if err := runs.WriteResult(context.Background(), fx.rel, release.OpsAccount, "shop", id, runs.Result{Exit: 0, LastSeq: 3 * runs.PageChunks}); err != nil { |
| 91 | t.Fatal(err) |
| 92 | } |
| 93 | var after int64 |
| 94 | pages := 0 |
| 95 | for { |
| 96 | p := fx.page(t, id, after) |
| 97 | pages++ |
| 98 | for _, c := range p.Chunks { |
| 99 | if c.Seq != after+1 { |
| 100 | t.Fatalf("seq %d after %d", c.Seq, after) |
| 101 | } |
| 102 | after = c.Seq |
| 103 | } |
| 104 | if p.Result == nil { |
| 105 | t.Fatal("every page must carry the result once it exists") |
| 106 | } |
| 107 | if !p.More { |
| 108 | break |
| 109 | } |
| 110 | } |
| 111 | if after != 3*runs.PageChunks || pages != 3 { |
| 112 | t.Fatalf("read to %d in %d pages", after, pages) |
| 113 | } |
| 114 | } |
| 115 | |
| 116 | func TestAPIRunGetReadsTheResultBeforeTheChunks(t *testing.T) { |
| 117 | fx := runFixture(t) |
| 118 | id := fx.startRun(t) |
| 119 | fx.transition(t, provision.RunPick(id, "box-1", "boot-1", time.Now().Add(time.Hour))) |
| 120 | fx.chunk(t, id, 1, runs.StreamOut, "a") |
| 121 | // The box writes the last chunk, then the result. A store that shows |
| 122 | // the result to the console's read must also show every chunk to the |
| 123 | // listing after it. |
| 124 | order := []string{} |
| 125 | fx.srv.cfg.Releases = &orderedStore{Store: fx.rel, seen: &order} |
| 126 | fx.page(t, id, 0) |
| 127 | result, list := -1, -1 |
| 128 | for i, call := range order { |
| 129 | if result < 0 && strings.HasSuffix(call, "/result.json") { |
| 130 | result = i |
| 131 | } |
| 132 | if list < 0 && strings.HasPrefix(call, "list ") && strings.HasSuffix(call, "/out/") { |
| 133 | list = i |
| 134 | } |
| 135 | } |
| 136 | if result < 0 || list < 0 || result > list { |
| 137 | t.Fatalf("store calls %q: the result must be read before the chunks are listed", order) |
| 138 | } |
| 139 | } |
| 140 | |
| 141 | // orderedStore records Get and List calls in order. |
| 142 | type orderedStore struct { |
| 143 | release.Store |
| 144 | seen *[]string |
| 145 | } |
| 146 | |
| 147 | func (o *orderedStore) Get(ctx context.Context, key string) ([]byte, string, error) { |
| 148 | *o.seen = append(*o.seen, "get "+key) |
| 149 | return o.Store.Get(ctx, key) |
| 150 | } |
| 151 | func (o *orderedStore) List(ctx context.Context, prefix string) ([]string, error) { |
| 152 | *o.seen = append(*o.seen, "list "+prefix) |
| 153 | return o.Store.List(ctx, prefix) |
| 154 | } |
| 155 | |
| 156 | func TestAPIRunAckChecksLastSeqThenCleansUp(t *testing.T) { |
| 157 | fx := runFixture(t) |
| 158 | id := fx.startRun(t) |
| 159 | if status, body := fx.post(t, id, "ack", `{"last_seq":0}`); status != http.StatusConflict || errorOf(t, body) != "run "+id+" has not finished yet" { |
| 160 | t.Fatalf("ack before the result: %d %s", status, body) |
| 161 | } |
| 162 | fx.transition(t, provision.RunPick(id, "box-1", "boot-1", time.Now().Add(time.Hour))) |
| 163 | fx.chunk(t, id, 1, runs.StreamOut, "a") |
| 164 | if err := runs.WriteResult(context.Background(), fx.rel, release.OpsAccount, "shop", id, runs.Result{Exit: 0, LastSeq: 1}); err != nil { |
| 165 | t.Fatal(err) |
| 166 | } |
| 167 | fx.transition(t, provision.RunFinish(id)) |
| 168 | if status, body := fx.post(t, id, "ack", `{"last_seq":0}`); status != http.StatusConflict || errorOf(t, body) != "run "+id+" ended at chunk 1, not 0" { |
| 169 | t.Fatalf("wrong last_seq: %d %s", status, body) |
| 170 | } |
| 171 | if left, _ := fx.rel.List(context.Background(), runs.Prefix(release.OpsAccount, "shop", id)); len(left) == 0 { |
| 172 | t.Fatal("a refused ack deleted the run") |
| 173 | } |
| 174 | if status, body := fx.post(t, id, "ack", `{"last_seq":1}`); status != http.StatusOK { |
| 175 | t.Fatalf("ack: %d %s", status, body) |
| 176 | } |
| 177 | if left, _ := fx.rel.List(context.Background(), runs.Prefix(release.OpsAccount, "shop", id)); len(left) != 0 { |
| 178 | t.Fatalf("ack left %q", left) |
| 179 | } |
| 180 | if rec, _ := provision.GetRecord(context.Background(), fx.rel, release.OpsAccount, "shop", runTestHost); rec.ActiveRun != nil { |
| 181 | t.Fatalf("ack left the run on the record: %+v", rec.ActiveRun) |
| 182 | } |
| 183 | } |
| 184 | |
| 185 | func TestAPIRunCancelBeforeAndAfterPickup(t *testing.T) { |
| 186 | fx := runFixture(t) |
| 187 | id := fx.startRun(t) |
| 188 | if status, body := fx.post(t, id, "cancel", ""); status != http.StatusOK || !strings.Contains(body, `"cancelled"`) { |
| 189 | t.Fatalf("queued cancel: %d %s", status, body) |
| 190 | } |
| 191 | if status, body := fx.post(t, id, "cancel", ""); status != http.StatusConflict || errorOf(t, body) != "run "+id+" has already ended" { |
| 192 | t.Fatalf("cancel a cancelled run: %d %s", status, body) |
| 193 | } |
| 194 | id2 := fx.startRun(t) |
| 195 | fx.transition(t, provision.RunPick(id2, "box-1", "boot-1", time.Now().Add(time.Hour))) |
| 196 | if status, body := fx.post(t, id2, "cancel", ""); status != http.StatusOK || !strings.Contains(body, `"cancel"`) { |
| 197 | t.Fatalf("picked cancel: %d %s", status, body) |
| 198 | } |
| 199 | events, _ := fx.st.Events(OpsAccountID, "shop", 20) |
| 200 | ends := 0 |
| 201 | for _, e := range events { |
| 202 | if e.Action == "run-end" && strings.Contains(e.Detail, id) && strings.Contains(e.Detail, `"cancelled"`) { |
| 203 | ends++ |
| 204 | } |
| 205 | } |
| 206 | if ends != 1 { |
| 207 | t.Errorf("a run cancelled before pickup must write one run-end row, got %d", ends) |
| 208 | } |
| 209 | } |
| 210 | |
| 211 | func TestAPIRunExpireRacesAPickup(t *testing.T) { |
| 212 | fx := runFixture(t) |
| 213 | id := fx.startRun(t) |
| 214 | if status, body := fx.post(t, id, "expire", ""); status != http.StatusOK || !strings.Contains(body, `"expired"`) { |
| 215 | t.Fatalf("expire: %d %s", status, body) |
| 216 | } |
| 217 | id2 := fx.startRun(t) |
| 218 | fx.transition(t, provision.RunPick(id2, "box-1", "boot-1", time.Now().Add(time.Hour))) |
| 219 | if status, body := fx.post(t, id2, "expire", ""); status != http.StatusConflict || errorOf(t, body) != "run "+id2+" has already started" { |
| 220 | t.Fatalf("expire after pickup: %d %s", status, body) |
| 221 | } |
| 222 | } |
| 223 | |
| 224 | func TestAPIRunRoutesNameAMissingRun(t *testing.T) { |
| 225 | fx := runFixture(t) |
| 226 | missing := "run_bbbbbbbbbbbbbbbbbbbbbbbbbb" |
| 227 | status, _, body := rawReq(t, http.MethodGet, fx.runsURL(runTestHost)+"/"+missing, fx.member, "") |
| 228 | if status != http.StatusNotFound || errorOf(t, body) != runTestHost+" has no run "+missing { |
| 229 | t.Fatalf("GET: %d %s", status, body) |
| 230 | } |
| 231 | for _, verb := range []string{"cancel", "expire", "ack"} { |
| 232 | if status, body := fx.post(t, missing, verb, `{"last_seq":0}`); status != http.StatusNotFound || errorOf(t, body) != runTestHost+" has no run "+missing { |
| 233 | t.Errorf("%s: %d %s", verb, status, body) |
| 234 | } |
| 235 | } |
| 236 | } |
| 237 | |
| 238 | func TestAPIRunGetWritesRunEndOnce(t *testing.T) { |
| 239 | fx := runFixture(t) |
| 240 | id := fx.startRun(t) |
| 241 | fx.transition(t, provision.RunPick(id, "box-1", "boot-1", time.Now().Add(time.Hour))) |
| 242 | if err := runs.WriteResult(context.Background(), fx.rel, release.OpsAccount, "shop", id, runs.Result{Exit: 7, Box: "box-1"}); err != nil { |
| 243 | t.Fatal(err) |
| 244 | } |
| 245 | fx.page(t, id, 0) |
| 246 | fx.page(t, id, 0) |
| 247 | events, _ := fx.st.Events(OpsAccountID, "shop", 20) |
| 248 | n := 0 |
| 249 | for _, e := range events { |
| 250 | if e.Action == "run-end" && strings.Contains(e.Detail, id) { |
| 251 | n++ |
| 252 | if !strings.Contains(e.Detail, `"exit":7`) || e.Address != "alice@keymail.dev" { |
| 253 | t.Errorf("run-end %+v", e) |
| 254 | } |
| 255 | } |
| 256 | } |
| 257 | if n != 1 { |
| 258 | t.Fatalf("run-end rows %d, want 1", n) |
| 259 | } |
| 260 | } |
| 261 | |
| 262 | func TestAPIRunAckKeepsTheObjectsOfALiveRun(t *testing.T) { |
| 263 | fx := runFixture(t) |
| 264 | id := fx.startRun(t) |
| 265 | fx.transition(t, provision.RunPick(id, "box-1", "boot-1", time.Now().Add(time.Hour))) |
| 266 | fx.chunk(t, id, 1, runs.StreamOut, "a") |
| 267 | // The box has written the result and not yet moved the run to done. |
| 268 | if err := runs.WriteResult(context.Background(), fx.rel, release.OpsAccount, "shop", id, runs.Result{Exit: 0, LastSeq: 1}); err != nil { |
| 269 | t.Fatal(err) |
| 270 | } |
| 271 | if status, body := fx.post(t, id, "ack", `{"last_seq":1}`); status != http.StatusConflict || errorOf(t, body) != "run "+id+" has not finished yet" { |
| 272 | t.Fatalf("ack of a picked run: %d %s", status, body) |
| 273 | } |
| 274 | if left, _ := fx.rel.List(context.Background(), runs.Prefix(release.OpsAccount, "shop", id)); len(left) == 0 { |
| 275 | t.Fatal("an ack deleted the objects of a run the record holds as picked") |
| 276 | } |
| 277 | if rec, _ := provision.GetRecord(context.Background(), fx.rel, release.OpsAccount, "shop", runTestHost); rec.ActiveRun == nil || rec.ActiveRun.State != provision.RunPicked { |
| 278 | t.Fatalf("record %+v", rec.ActiveRun) |
| 279 | } |
| 280 | } |
| 281 | |
| 282 | func TestAPIRunAckAfterTheRecordMovedOnDeletesTheObjects(t *testing.T) { |
| 283 | fx := runFixture(t) |
| 284 | id := fx.startRun(t) |
| 285 | fx.transition(t, provision.RunPick(id, "box-1", "boot-1", time.Now().Add(time.Hour))) |
| 286 | if err := runs.WriteResult(context.Background(), fx.rel, release.OpsAccount, "shop", id, runs.Result{Exit: 0}); err != nil { |
| 287 | t.Fatal(err) |
| 288 | } |
| 289 | fx.transition(t, provision.RunFinish(id)) |
| 290 | fx.transition(t, provision.RunClear(id)) |
| 291 | // A repeated ack, the record already clear: the objects go, the answer stands. |
| 292 | if status, body := fx.post(t, id, "ack", `{"last_seq":0}`); status != http.StatusOK { |
| 293 | t.Fatalf("ack: %d %s", status, body) |
| 294 | } |
| 295 | if left, _ := fx.rel.List(context.Background(), runs.Prefix(release.OpsAccount, "shop", id)); len(left) != 0 { |
| 296 | t.Fatalf("left %q", left) |
| 297 | } |
| 298 | } |
| 299 | |
| 300 | func TestAPIRunRoutesRefuseAnotherHostsRun(t *testing.T) { |
| 301 | fx := runFixture(t) |
| 302 | id := fx.startRun(t) |
| 303 | other := "other.example.com" |
| 304 | putShopRecord(t, fx.consoleFixture, provision.Record{Host: other, Channel: "stable", CreatedAt: time.Now().UTC()}) |
| 305 | if err := runs.WriteResult(context.Background(), fx.rel, release.OpsAccount, "shop", id, runs.Result{Exit: 0}); err != nil { |
| 306 | t.Fatal(err) |
| 307 | } |
| 308 | want := other + " has no run " + id |
| 309 | status, _, body := rawReq(t, http.MethodGet, fx.runsURL(other)+"/"+id, fx.member, "") |
| 310 | if status != http.StatusNotFound || errorOf(t, body) != want { |
| 311 | t.Errorf("GET: %d %s", status, body) |
| 312 | } |
| 313 | for _, verb := range []string{"cancel", "expire", "ack"} { |
| 314 | _, _, body := rawReq(t, http.MethodPost, fx.runsURL(other)+"/"+id+"/"+verb, fx.member, `{"last_seq":0}`) |
| 315 | if errorOf(t, body) != want { |
| 316 | t.Errorf("%s: %s", verb, body) |
| 317 | } |
| 318 | } |
| 319 | if left, _ := fx.rel.List(context.Background(), runs.Prefix(release.OpsAccount, "shop", id)); len(left) == 0 { |
| 320 | t.Fatal("another host's ack deleted the run") |
| 321 | } |
| 322 | } |
| 323 | |
| 324 | func TestAPIRunRoutesRefuseAServiceCredentialAndABadID(t *testing.T) { |
| 325 | fx := runFixture(t) |
| 326 | svc := serviceToken(t, fx.st, OpsAccountID, "ci", "shop", svcrole.Instance) |
| 327 | id := "run_aaaaaaaaaaaaaaaaaaaaaaaaaa" |
| 328 | if status, _, _ := rawReq(t, http.MethodGet, fx.runsURL(runTestHost)+"/"+id, svc, ""); status != http.StatusForbidden { |
| 329 | t.Errorf("service credential: %d, want 403", status) |
| 330 | } |
| 331 | status, _, body := rawReq(t, http.MethodGet, fx.runsURL(runTestHost)+"/not-a-run", fx.member, "") |
| 332 | if status != http.StatusNotFound || errorOf(t, body) != runTestHost+" has no run not-a-run" { |
| 333 | t.Errorf("bad id: %d %s", status, body) |
| 334 | } |
| 335 | } |
| 336 | |
| 337 | // faultStore injects the failures a bucket has: a delete that fails, a Put |
| 338 | // whose response is lost (landed or not), a Get that fails. |
| 339 | type faultStore struct { |
| 340 | release.Store |
| 341 | delete func(key string) error |
| 342 | put func(key string) (landed bool, err error) |
| 343 | get func(key string) error |
| 344 | } |
| 345 | |
| 346 | func (f *faultStore) Delete(ctx context.Context, key string) error { |
| 347 | if f.delete != nil { |
| 348 | if err := f.delete(key); err != nil { |
| 349 | return err |
| 350 | } |
| 351 | } |
| 352 | return f.Store.Delete(ctx, key) |
| 353 | } |
| 354 | |
| 355 | func (f *faultStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { |
| 356 | if f.put != nil { |
| 357 | if landed, err := f.put(key); err != nil { |
| 358 | if landed { |
| 359 | _, _ = f.Store.Put(ctx, key, body, ifMatch) |
| 360 | } |
| 361 | return "", err |
| 362 | } |
| 363 | } |
| 364 | return f.Store.Put(ctx, key, body, ifMatch) |
| 365 | } |
| 366 | |
| 367 | func (f *faultStore) Get(ctx context.Context, key string) ([]byte, string, error) { |
| 368 | if f.get != nil { |
| 369 | if err := f.get(key); err != nil { |
| 370 | return nil, "", err |
| 371 | } |
| 372 | } |
| 373 | return f.Store.Get(ctx, key) |
| 374 | } |
| 375 | |
| 376 | // runEnds is the details of the run-end rows written for id. |
| 377 | func (fx *runFx) runEnds(t *testing.T, id string) []string { |
| 378 | t.Helper() |
| 379 | events, err := fx.st.Events(OpsAccountID, "shop", 50) |
| 380 | if err != nil { |
| 381 | t.Fatal(err) |
| 382 | } |
| 383 | var out []string |
| 384 | for _, e := range events { |
| 385 | if e.Action == "run-end" && strings.Contains(e.Detail, id) { |
| 386 | out = append(out, e.Detail) |
| 387 | } |
| 388 | } |
| 389 | return out |
| 390 | } |
| 391 | |
| 392 | func (fx *runFx) left(id string) []string { |
| 393 | left, _ := fx.rel.List(context.Background(), runs.Prefix(release.OpsAccount, "shop", id)) |
| 394 | return left |
| 395 | } |
| 396 | |
| 397 | // finishedRun is a picked run with one chunk and a result, finished on the record. |
| 398 | func (fx *runFx) finishedRun(t *testing.T) string { |
| 399 | t.Helper() |
| 400 | id := fx.startRun(t) |
| 401 | fx.transition(t, provision.RunPick(id, "box-1", "boot-1", time.Now().Add(time.Hour))) |
| 402 | fx.chunk(t, id, 1, runs.StreamOut, "a") |
| 403 | if err := runs.WriteResult(context.Background(), fx.rel, release.OpsAccount, "shop", id, runs.Result{Exit: 0, LastSeq: 1}); err != nil { |
| 404 | t.Fatal(err) |
| 405 | } |
| 406 | fx.transition(t, provision.RunFinish(id)) |
| 407 | return id |
| 408 | } |
| 409 | |
| 410 | func TestAPIRunAckThatCannotDeleteKeepsTheMarkerSoTheRowIsWrittenOnce(t *testing.T) { |
| 411 | fx := runFixture(t) |
| 412 | id := fx.finishedRun(t) |
| 413 | failing := true |
| 414 | fx.srv.cfg.Releases = &faultStore{Store: fx.rel, delete: func(key string) error { |
| 415 | if failing && key == runs.ResultKey(release.OpsAccount, "shop", id) { |
| 416 | return errors.New("delete refused") |
| 417 | } |
| 418 | return nil |
| 419 | }} |
| 420 | if status, body := fx.post(t, id, "ack", `{"last_seq":1}`); status != http.StatusOK { |
| 421 | t.Fatalf("ack: %d %s", status, body) |
| 422 | } |
| 423 | left := fx.left(id) |
| 424 | for _, want := range []string{runs.ResultKey(release.OpsAccount, "shop", id), runs.RequestKey(release.OpsAccount, "shop", id), runs.AuditedKey(release.OpsAccount, "shop", id)} { |
| 425 | if !slices.Contains(left, want) { |
| 426 | t.Fatalf("after a failed delete %q is gone; left %q", want, left) |
| 427 | } |
| 428 | } |
| 429 | // A follower and the retried ack both find the result; neither writes a second row. |
| 430 | failing = false |
| 431 | if p := fx.page(t, id, 1); p.Result == nil { |
| 432 | t.Fatalf("page %+v", p) |
| 433 | } |
| 434 | if status, body := fx.post(t, id, "ack", `{"last_seq":1}`); status != http.StatusOK { |
| 435 | t.Fatalf("retried ack: %d %s", status, body) |
| 436 | } |
| 437 | if left := fx.left(id); len(left) != 0 { |
| 438 | t.Fatalf("retried ack left %q", left) |
| 439 | } |
| 440 | if rows := fx.runEnds(t, id); len(rows) != 1 { |
| 441 | t.Fatalf("run-end rows %q, want 1", rows) |
| 442 | } |
| 443 | } |
| 444 | |
| 445 | func TestAPIRunEndIsNotWrittenForARunAnAckHasDeleted(t *testing.T) { |
| 446 | fx := runFixture(t) |
| 447 | id := fx.finishedRun(t) |
| 448 | if status, body := fx.post(t, id, "ack", `{"last_seq":1}`); status != http.StatusOK { |
| 449 | t.Fatalf("ack: %d %s", status, body) |
| 450 | } |
| 451 | // A follower that read the result before the ack's delete reaches the |
| 452 | // audit after it. |
| 453 | fx.srv.auditRunEnd(context.Background(), &Account{ID: OpsAccountID, Sqid: release.OpsAccount}, "shop", runTestHost, id, "result", &runs.Result{LastSeq: 1}) |
| 454 | if left := fx.left(id); len(left) != 0 { |
| 455 | t.Fatalf("a late audit left %q", left) |
| 456 | } |
| 457 | if rows := fx.runEnds(t, id); len(rows) != 1 { |
| 458 | t.Fatalf("run-end rows %q, want 1", rows) |
| 459 | } |
| 460 | } |
| 461 | |
| 462 | func TestAPIRunCancelAndExpireBeforePickupLeaveAResult(t *testing.T) { |
| 463 | fx := runFixture(t) |
| 464 | cancelled := fx.startRun(t) |
| 465 | if status, body := fx.post(t, cancelled, "cancel", ""); status != http.StatusOK { |
| 466 | t.Fatalf("cancel: %d %s", status, body) |
| 467 | } |
| 468 | fx.transition(t, provision.RunClear(cancelled)) |
| 469 | expiredID := fx.startRun(t) |
| 470 | if status, body := fx.post(t, expiredID, "expire", ""); status != http.StatusOK { |
| 471 | t.Fatalf("expire: %d %s", status, body) |
| 472 | } |
| 473 | fx.transition(t, provision.RunClear(expiredID)) |
| 474 | // The record no longer names either run; a follower still learns how it ended. |
| 475 | if p := fx.page(t, cancelled, 0); p.State != "" || p.Result == nil || !p.Result.Cancelled || p.More || len(p.Chunks) != 0 { |
| 476 | t.Errorf("cancelled run page %+v", p) |
| 477 | } |
| 478 | if p := fx.page(t, expiredID, 0); p.State != "" || p.Result == nil || p.Result.Refused != runs.RefusedExpired { |
| 479 | t.Errorf("expired run page %+v", p) |
| 480 | } |
| 481 | if status, body := fx.post(t, cancelled, "ack", `{"last_seq":0}`); status != http.StatusOK { |
| 482 | t.Errorf("ack of a cancelled run: %d %s", status, body) |
| 483 | } |
| 484 | if left := fx.left(cancelled); len(left) != 0 { |
| 485 | t.Errorf("left %q", left) |
| 486 | } |
| 487 | } |
| 488 | |
| 489 | func TestAPIRunExpireWritesTheRunEndRow(t *testing.T) { |
| 490 | fx := runFixture(t) |
| 491 | id := fx.startRun(t) |
| 492 | if status, body := fx.post(t, id, "expire", ""); status != http.StatusOK { |
| 493 | t.Fatalf("expire: %d %s", status, body) |
| 494 | } |
| 495 | rows := fx.runEnds(t, id) |
| 496 | if len(rows) != 1 || !strings.Contains(rows[0], `"outcome":"expired"`) { |
| 497 | t.Fatalf("run-end rows %q", rows) |
| 498 | } |
| 499 | } |
| 500 | |
| 501 | // A cancel or expire whose CAS landed but whose response was lost is retried |
| 502 | // and finds its own terminal state; the result and the row are still written. |
| 503 | func TestAPIRunRetriedCancelAndExpireStillEndTheRun(t *testing.T) { |
| 504 | fx := runFixture(t) |
| 505 | for _, c := range []struct { |
| 506 | verb, outcome string |
| 507 | mut func(string) func(*provision.Record) error |
| 508 | }{ |
| 509 | {"cancel", "cancelled", provision.RunCancelTransition}, |
| 510 | {"expire", "expired", provision.RunExpire}, |
| 511 | } { |
| 512 | id := fx.startRun(t) |
| 513 | fx.transition(t, c.mut(id)) |
| 514 | if status, body := fx.post(t, id, c.verb, ""); status != http.StatusConflict || errorOf(t, body) != "run "+id+" has already ended" { |
| 515 | t.Fatalf("%s retry: %d %s", c.verb, status, body) |
| 516 | } |
| 517 | if res, err := runs.ReadResult(context.Background(), fx.rel, release.OpsAccount, "shop", id); err != nil || res == nil { |
| 518 | t.Errorf("%s retry left no result: %+v, %v", c.verb, res, err) |
| 519 | } |
| 520 | if rows := fx.runEnds(t, id); len(rows) != 1 || !strings.Contains(rows[0], `"outcome":"`+c.outcome+`"`) { |
| 521 | t.Errorf("%s retry rows %q", c.verb, rows) |
| 522 | } |
| 523 | fx.post(t, id, c.verb, "") |
| 524 | if rows := fx.runEnds(t, id); len(rows) != 1 { |
| 525 | t.Errorf("%s retried twice: rows %q", c.verb, rows) |
| 526 | } |
| 527 | fx.transition(t, provision.RunClear(id)) |
| 528 | } |
| 529 | } |
| 530 | |
| 531 | func TestAPIRunAckKeepsTheObjectsWhenTheClearIsNotSettled(t *testing.T) { |
| 532 | recordKey := provision.RecordKey(release.OpsAccount, "shop", runTestHost) |
| 533 | for _, c := range []struct { |
| 534 | name string |
| 535 | setup func(*faultStore, *bool) |
| 536 | wantStatus int |
| 537 | wantKept bool |
| 538 | }{ |
| 539 | {"clear refused", func(f *faultStore, _ *bool) { |
| 540 | f.put = func(key string) (bool, error) { |
| 541 | if key == recordKey { |
| 542 | return false, errors.New("put refused") |
| 543 | } |
| 544 | return false, nil |
| 545 | } |
| 546 | }, http.StatusBadGateway, true}, |
| 547 | {"clear landed, response lost", func(f *faultStore, _ *bool) { |
| 548 | f.put = func(key string) (bool, error) { |
| 549 | if key == recordKey { |
| 550 | return true, errors.New("response lost") |
| 551 | } |
| 552 | return false, nil |
| 553 | } |
| 554 | }, http.StatusOK, false}, |
| 555 | {"clear landed, re-read fails", func(f *faultStore, afterPut *bool) { |
| 556 | f.put = func(key string) (bool, error) { |
| 557 | if key == recordKey { |
| 558 | *afterPut = true |
| 559 | } |
| 560 | return false, nil |
| 561 | } |
| 562 | f.get = func(key string) error { |
| 563 | if key == recordKey && *afterPut { |
| 564 | return errors.New("get refused") |
| 565 | } |
| 566 | return nil |
| 567 | } |
| 568 | }, http.StatusBadGateway, true}, |
| 569 | } { |
| 570 | t.Run(c.name, func(t *testing.T) { |
| 571 | fx := runFixture(t) |
| 572 | id := fx.finishedRun(t) |
| 573 | f := &faultStore{Store: fx.rel} |
| 574 | var afterPut bool |
| 575 | c.setup(f, &afterPut) |
| 576 | fx.srv.cfg.Releases = f |
| 577 | status, body := fx.post(t, id, "ack", `{"last_seq":1}`) |
| 578 | if status != c.wantStatus { |
| 579 | t.Fatalf("ack: %d %s, want %d", status, body, c.wantStatus) |
| 580 | } |
| 581 | if kept := len(fx.left(id)) != 0; kept != c.wantKept { |
| 582 | t.Fatalf("objects kept = %v, want %v (left %q)", kept, c.wantKept, fx.left(id)) |
| 583 | } |
| 584 | }) |
| 585 | } |
| 586 | } |
| 587 | |