| 1 | package console |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "net/http" |
| 7 | "os" |
| 8 | "sync/atomic" |
| 9 | "testing" |
| 10 | "time" |
| 11 | |
| 12 | "github.com/carlosframework/platform/internal/provision" |
| 13 | "github.com/carlosframework/platform/internal/release" |
| 14 | "github.com/carlosframework/platform/internal/seeds" |
| 15 | ) |
| 16 | |
| 17 | // afterRecordRead runs then after each of the first n reads of key: what |
| 18 | // another request does between the delete door's import check and its |
| 19 | // delete. |
| 20 | type afterRecordRead struct { |
| 21 | release.Store |
| 22 | key string |
| 23 | n int32 |
| 24 | reads atomic.Int32 |
| 25 | then func() |
| 26 | } |
| 27 | |
| 28 | func (s *afterRecordRead) Get(ctx context.Context, key string) ([]byte, string, error) { |
| 29 | raw, tag, err := s.Store.Get(ctx, key) |
| 30 | if key == s.key && s.reads.Add(1) <= s.n { |
| 31 | s.then() |
| 32 | } |
| 33 | return raw, tag, err |
| 34 | } |
| 35 | |
| 36 | func (s *afterRecordRead) DeleteIfMatch(ctx context.Context, key, etag string) error { |
| 37 | return release.DeleteIfMatch(ctx, s.Store, key, etag) |
| 38 | } |
| 39 | |
| 40 | // plainRecord writes a plain record of shop on host. |
| 41 | func (f fromFixture) plainRecord(t *testing.T, host string) { |
| 42 | t.Helper() |
| 43 | if err := provision.CreateRecord(context.Background(), f.fx.rel, provision.Record{Schema: 1, Host: host, Slug: "old", |
| 44 | App: fromApp, Account: "bab", Channel: "stable", CreatedAt: time.Now().UTC()}); err != nil { |
| 45 | t.Fatal(err) |
| 46 | } |
| 47 | } |
| 48 | |
| 49 | // Final review F7: the delete door's check finds no record, and an import |
| 50 | // is begun before the delete would have run. Absence is the delete's own |
| 51 | // idempotent answer: the ordinary delete is not run, and the import's |
| 52 | // record stands. |
| 53 | func TestImportDeleteOfAnAbsentHostLeavesAnImportBegunSince(t *testing.T) { |
| 54 | f := newImportFixture(t) |
| 55 | id := newSeedID(t) |
| 56 | key := provision.RecordKey("bab", fromApp, importHost) |
| 57 | f.fx.srv.cfg.Releases = &afterRecordRead{Store: f.fx.rel, key: key, n: 1, then: func() { |
| 58 | f.seedRecord(t, importHost, "", id, seeds.KindImport, seeds.StateAwaitingUpload) |
| 59 | }} |
| 60 | if code, out := deleteInstance(t, f, importHost); code != http.StatusOK || out["result"] != "deleted" { |
| 61 | t.Fatalf("delete of an absent host = %d %v, want the idempotent 200", code, out) |
| 62 | } |
| 63 | rec, err := f.record(t, importHost) |
| 64 | if err != nil { |
| 65 | t.Fatalf("the import begun since was deleted: %v", err) |
| 66 | } |
| 67 | requireImportState(t, rec, id, seeds.StateAwaitingUpload, "") |
| 68 | } |
| 69 | |
| 70 | // The check reads a plain record; before the delete, that instance is |
| 71 | // deleted and an import of the host begins and swaps. The ordinary delete |
| 72 | // is bound to the plain record's etag, so it does not land: the door reads |
| 73 | // the host again and answers as it answers for the import, SD-N57, with |
| 74 | // the import's record standing. |
| 75 | func TestImportDeleteReclassifiesARecordThatBecameAnImport(t *testing.T) { |
| 76 | f := newImportFixture(t) |
| 77 | f.plainRecord(t, importHost) |
| 78 | id := newSeedID(t) |
| 79 | key := provision.RecordKey("bab", fromApp, importHost) |
| 80 | f.fx.srv.cfg.Releases = &afterRecordRead{Store: f.fx.rel, key: key, n: 1, then: func() { |
| 81 | if err := f.fx.rel.Delete(context.Background(), key); err != nil { |
| 82 | t.Error(err) |
| 83 | } |
| 84 | f.seedRecord(t, importHost, "", id, seeds.KindImport, seeds.StateSwapped) |
| 85 | }} |
| 86 | if code, out := deleteInstance(t, f, importHost); code != http.StatusConflict || str(t, out, "error") != importAbortFirst(importHost) { |
| 87 | t.Fatalf("delete = %d %v, want 409 SD-N57", code, out) |
| 88 | } |
| 89 | rec, err := f.record(t, importHost) |
| 90 | if err != nil { |
| 91 | t.Fatalf("the import was deleted the ordinary way: %v", err) |
| 92 | } |
| 93 | requireImportState(t, rec, id, seeds.StateSwapped, "") |
| 94 | if rows := importAudit(t, f, "instance-delete"); len(rows) != 0 { |
| 95 | t.Fatalf("a refused delete audited: %q", rows) |
| 96 | } |
| 97 | } |
| 98 | |
| 99 | // A record that changes under the delete twice is not chased: the second |
| 100 | // conflict answers main's retry sentence, and the record stands. |
| 101 | func TestImportDeleteGivesUpAfterASecondChange(t *testing.T) { |
| 102 | f := newImportFixture(t) |
| 103 | f.plainRecord(t, importHost) |
| 104 | key := provision.RecordKey("bab", fromApp, importHost) |
| 105 | f.fx.srv.cfg.Releases = &afterRecordRead{Store: f.fx.rel, key: key, n: 2, then: func() { |
| 106 | rec, etag, err := provision.GetRecordETag(context.Background(), f.fx.rel, "bab", fromApp, importHost) |
| 107 | if err != nil { |
| 108 | t.Error(err) |
| 109 | return |
| 110 | } |
| 111 | rec.Steering = map[string]string{"": "latency", "latency": ""}[rec.Steering] |
| 112 | if err := provision.UpdateRecord(context.Background(), f.fx.rel, *rec, etag); err != nil { |
| 113 | t.Error(err) |
| 114 | } |
| 115 | }} |
| 116 | if code, out := deleteInstance(t, f, importHost); code != http.StatusBadGateway || str(t, out, "error") != importDeleteRetry { |
| 117 | t.Fatalf("delete = %d %v, want 502 %q", code, out, importDeleteRetry) |
| 118 | } |
| 119 | if _, err := f.record(t, importHost); errors.Is(err, os.ErrNotExist) { |
| 120 | t.Fatal("a record that kept changing was deleted") |
| 121 | } |
| 122 | } |
| 123 | |
| 124 | // The ordinary case is unchanged: a plain record the check read is |
| 125 | // deleted, and a second delete of the gone host is the idempotent 200. |
| 126 | func TestImportDeleteOfAPlainRecord(t *testing.T) { |
| 127 | f := newImportFixture(t) |
| 128 | f.plainRecord(t, importHost) |
| 129 | for range 2 { |
| 130 | if code, out := deleteInstance(t, f, importHost); code != http.StatusOK || out["result"] != "deleted" { |
| 131 | t.Fatalf("delete = %d %v", code, out) |
| 132 | } |
| 133 | f.requireRecordGone(t, importHost) |
| 134 | } |
| 135 | if rows := importAudit(t, f, "instance-delete"); len(rows) != 2 { |
| 136 | t.Fatalf("instance-delete rows = %q, want one per delete", rows) |
| 137 | } |
| 138 | } |
| 139 | |