| 1 | package console |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "net/http" |
| 6 | "testing" |
| 7 | "time" |
| 8 | |
| 9 | "github.com/carlosframework/platform/internal/objstore" |
| 10 | "github.com/carlosframework/platform/internal/provision" |
| 11 | "github.com/carlosframework/platform/internal/seeds" |
| 12 | ) |
| 13 | |
| 14 | const bindHost = "other.shop.example.com" |
| 15 | |
| 16 | // plainInstance writes a plain record of shop on host in env. |
| 17 | func (f fromFixture) plainInstance(t *testing.T, host, env string) { |
| 18 | t.Helper() |
| 19 | if err := provision.CreateRecord(context.Background(), f.fx.rel, provision.Record{Schema: 1, Host: host, Slug: "other", |
| 20 | App: fromApp, Account: "bab", Channel: "stable", Environment: env, CreatedAt: time.Now().UTC()}); err != nil { |
| 21 | t.Fatal(err) |
| 22 | } |
| 23 | } |
| 24 | |
| 25 | // bindEnvironment patches host's environment through the update door. |
| 26 | func (f fromFixture) bindEnvironment(t *testing.T, host, env string) (int, string) { |
| 27 | t.Helper() |
| 28 | code, _, out := rawReq(t, http.MethodPatch, f.url+"/"+host, f.token, `{"environment":"`+env+`"}`) |
| 29 | return code, out |
| 30 | } |
| 31 | |
| 32 | // Final review F3: the finish's first look passes (the store is the |
| 33 | // seed's, and no other instance names canary), then another instance binds |
| 34 | // canary through the update door, then the finish goes on to its teardown. |
| 35 | // The binding took the store from the seed under the store's lock, so the |
| 36 | // teardown's locked re-read keeps it: the store and its bucket survive, |
| 37 | // with the mark cleared, and the record still goes. |
| 38 | func TestFinishSeedSparesAStoreBoundAfterItsFirstLook(t *testing.T) { |
| 39 | f := finishFixture(t) |
| 40 | id := newSeedID(t) |
| 41 | f.seedRecord(t, fromHost, "canary", id, seeds.KindImport, seeds.StateRolledBack) |
| 42 | f.markedStore(t, id) |
| 43 | f.plainInstance(t, bindHost, "staging") |
| 44 | bucket := storeBucketName(f.canaryStore(t)) |
| 45 | if f.fake.Buckets[bucket] == nil { |
| 46 | f.fake.Buckets[bucket] = map[string][]byte{} |
| 47 | } |
| 48 | bound := false |
| 49 | finishSeedCheckedHook = func(host string) { |
| 50 | if host != fromHost || bound { |
| 51 | return |
| 52 | } |
| 53 | bound = true |
| 54 | if code, out := f.bindEnvironment(t, bindHost, "canary"); code != http.StatusOK { |
| 55 | t.Errorf("bind canary = %d %s", code, out) |
| 56 | } |
| 57 | } |
| 58 | t.Cleanup(func() { finishSeedCheckedHook = nil }) |
| 59 | |
| 60 | if err := f.fx.srv.finishSeed(context.Background(), "bab", fromApp, fromHost, id); err != nil { |
| 61 | t.Fatal(err) |
| 62 | } |
| 63 | if !bound { |
| 64 | t.Fatal("the finish never reached its teardown checks") |
| 65 | } |
| 66 | store := f.canaryStore(t) |
| 67 | if store == nil || store.Grant == nil || store.Declared == nil || store.Declared.Seed != "" { |
| 68 | t.Fatalf("canary store after the finish = %+v; want it standing, its mark cleared", store) |
| 69 | } |
| 70 | if _, ok := f.fake.Buckets[bucket]; !ok { |
| 71 | t.Fatalf("bucket %s was torn down under an instance that bound it", bucket) |
| 72 | } |
| 73 | if rec, err := f.record(t, bindHost); err != nil || rec.Environment != "canary" { |
| 74 | t.Fatalf("the binder's record = %+v, %v", rec, err) |
| 75 | } |
| 76 | f.requireRecordGone(t, fromHost) |
| 77 | } |
| 78 | |
| 79 | // The update door's binding waits for the store's walk lock, which the |
| 80 | // finish's teardown holds, and clears another seed's mark in the same |
| 81 | // locked stretch as its record write. |
| 82 | func TestEnvironmentBindingWaitsForTheStoreLock(t *testing.T) { |
| 83 | f := finishFixture(t) |
| 84 | id := newSeedID(t) |
| 85 | f.seedRecord(t, fromHost, "canary", id, seeds.KindImport, seeds.StatePlaced) |
| 86 | f.markedStore(t, id) |
| 87 | f.plainInstance(t, bindHost, "staging") |
| 88 | unlock := f.fx.srv.storeWalks.lock("bab", fromApp, "canary") |
| 89 | done := make(chan int, 1) |
| 90 | go func() { |
| 91 | code, _, _ := rawReq(t, http.MethodPatch, f.url+"/"+bindHost, f.token, `{"environment":"canary"}`) |
| 92 | done <- code |
| 93 | }() |
| 94 | waitForStoreLockQueue(t, f, "canary", 2, unlock) |
| 95 | if store := f.canaryStore(t); store.Declared.Seed != id { |
| 96 | t.Fatalf("the binding cleared the mark without the lock: %+v", store.Declared) |
| 97 | } |
| 98 | if rec, _ := f.record(t, bindHost); rec.Environment != "staging" { |
| 99 | t.Fatalf("the binding landed without the lock: %+v", rec) |
| 100 | } |
| 101 | unlock() |
| 102 | if code := <-done; code != http.StatusOK { |
| 103 | t.Fatalf("bind canary = %d", code) |
| 104 | } |
| 105 | if store := f.canaryStore(t); store.Declared.Seed != "" { |
| 106 | t.Fatalf("canary store after the binding = %+v; want the mark cleared", store.Declared) |
| 107 | } |
| 108 | } |
| 109 | |
| 110 | // waitForStoreLockQueue waits until env's store lock has refs holders and |
| 111 | // waiters. On a timeout it unlocks and fails. |
| 112 | func waitForStoreLockQueue(t *testing.T, f fromFixture, env string, refs int, unlock func()) { |
| 113 | t.Helper() |
| 114 | deadline := time.Now().Add(3 * time.Second) |
| 115 | for { |
| 116 | f.fx.srv.storeWalks.mu.Lock() |
| 117 | n := 0 |
| 118 | if e := f.fx.srv.storeWalks.m[storeWalkKey("bab", fromApp, env)]; e != nil { |
| 119 | n = e.refs |
| 120 | } |
| 121 | f.fx.srv.storeWalks.mu.Unlock() |
| 122 | if n == refs { |
| 123 | return |
| 124 | } |
| 125 | if time.Now().After(deadline) { |
| 126 | unlock() |
| 127 | t.Fatalf("the store lock of %s has %d holders and waiters, want %d", env, n, refs) |
| 128 | } |
| 129 | time.Sleep(time.Millisecond) |
| 130 | } |
| 131 | } |
| 132 | |
| 133 | // A binding leaves alone: a store with no mark, the binder's own seed's |
| 134 | // store, and an unchanged environment. Only another seed's mark is cleared. |
| 135 | func TestEnvironmentBindingClearsOnlyAnotherSeedsMark(t *testing.T) { |
| 136 | for _, c := range []struct { |
| 137 | name string |
| 138 | mark func(own string) string |
| 139 | binder func(f fromFixture, t *testing.T, own string) |
| 140 | cleared bool |
| 141 | }{ |
| 142 | {"another seed's", func(string) string { return newSeedID(t) }, |
| 143 | func(f fromFixture, t *testing.T, _ string) { f.plainInstance(t, bindHost, "staging") }, true}, |
| 144 | {"no mark", func(string) string { return "" }, |
| 145 | func(f fromFixture, t *testing.T, _ string) { f.plainInstance(t, bindHost, "staging") }, false}, |
| 146 | {"the binder's own seed's", func(own string) string { return own }, |
| 147 | func(f fromFixture, t *testing.T, own string) { |
| 148 | f.seedRecord(t, bindHost, "staging", own, seeds.KindImport, seeds.StateCommitted) |
| 149 | }, false}, |
| 150 | } { |
| 151 | t.Run(c.name, func(t *testing.T) { |
| 152 | f := finishFixture(t) |
| 153 | own := newSeedID(t) |
| 154 | c.binder(f, t, own) |
| 155 | mark := c.mark(own) |
| 156 | f.markedStore(t, mark) |
| 157 | if code, out := f.bindEnvironment(t, bindHost, "canary"); code != http.StatusOK { |
| 158 | t.Fatalf("bind canary = %d %s", code, out) |
| 159 | } |
| 160 | got := f.canaryStore(t).Declared.Seed |
| 161 | if want := map[bool]string{true: "", false: mark}[c.cleared]; got != want { |
| 162 | t.Fatalf("mark after the binding = %q, want %q", got, want) |
| 163 | } |
| 164 | }) |
| 165 | } |
| 166 | // Re-sending the environment an instance already names takes nothing. |
| 167 | f := finishFixture(t) |
| 168 | f.plainInstance(t, bindHost, "canary") |
| 169 | mark := newSeedID(t) |
| 170 | f.markedStore(t, mark) |
| 171 | if code, out := f.bindEnvironment(t, bindHost, "canary"); code != http.StatusOK { |
| 172 | t.Fatalf("re-bind canary = %d %s", code, out) |
| 173 | } |
| 174 | if got := f.canaryStore(t).Declared.Seed; got != mark { |
| 175 | t.Fatalf("mark after an unchanged binding = %q, want %q", got, mark) |
| 176 | } |
| 177 | } |
| 178 | |
| 179 | // A create naming an environment, on an app that provisions no store per |
| 180 | // instance, takes the store from the seed that made it. |
| 181 | func TestEnvironmentCreateTakesASeedsStore(t *testing.T) { |
| 182 | f := newImportFixture(t) |
| 183 | mark := newSeedID(t) |
| 184 | at := time.Now().UTC() |
| 185 | putCanaryStoreOf(t, f.fx, fromApp, &objstore.Record{ |
| 186 | Declared: &objstore.Declared{EnvPrefix: "S3", At: at, By: "bob@keymail.dev", Seed: mark}, |
| 187 | Grant: &objstore.GrantBlock{Backend: "mem", Bucket: "carlos-bab-shop-canary", Source: objstore.GrantSourceAccountApproval, At: at, By: "operator@keymail.dev"}, |
| 188 | }) |
| 189 | if code, out := f.create(t, `{"host":"`+bindHost+`","environment":"canary"}`); code != http.StatusCreated { |
| 190 | t.Fatalf("create = %d %v", code, out) |
| 191 | } |
| 192 | if got := f.canaryStore(t).Declared.Seed; got != "" { |
| 193 | t.Fatalf("mark after the create = %q, want it cleared", got) |
| 194 | } |
| 195 | } |
| 196 | |