package console import ( "context" "net/http" "testing" "time" "github.com/carlosframework/platform/internal/objstore" "github.com/carlosframework/platform/internal/provision" "github.com/carlosframework/platform/internal/seeds" ) const bindHost = "other.shop.example.com" // plainInstance writes a plain record of shop on host in env. func (f fromFixture) plainInstance(t *testing.T, host, env string) { t.Helper() if err := provision.CreateRecord(context.Background(), f.fx.rel, provision.Record{Schema: 1, Host: host, Slug: "other", App: fromApp, Account: "bab", Channel: "stable", Environment: env, CreatedAt: time.Now().UTC()}); err != nil { t.Fatal(err) } } // bindEnvironment patches host's environment through the update door. func (f fromFixture) bindEnvironment(t *testing.T, host, env string) (int, string) { t.Helper() code, _, out := rawReq(t, http.MethodPatch, f.url+"/"+host, f.token, `{"environment":"`+env+`"}`) return code, out } // Final review F3: the finish's first look passes (the store is the // seed's, and no other instance names canary), then another instance binds // canary through the update door, then the finish goes on to its teardown. // The binding took the store from the seed under the store's lock, so the // teardown's locked re-read keeps it: the store and its bucket survive, // with the mark cleared, and the record still goes. func TestFinishSeedSparesAStoreBoundAfterItsFirstLook(t *testing.T) { f := finishFixture(t) id := newSeedID(t) f.seedRecord(t, fromHost, "canary", id, seeds.KindImport, seeds.StateRolledBack) f.markedStore(t, id) f.plainInstance(t, bindHost, "staging") bucket := storeBucketName(f.canaryStore(t)) if f.fake.Buckets[bucket] == nil { f.fake.Buckets[bucket] = map[string][]byte{} } bound := false finishSeedCheckedHook = func(host string) { if host != fromHost || bound { return } bound = true if code, out := f.bindEnvironment(t, bindHost, "canary"); code != http.StatusOK { t.Errorf("bind canary = %d %s", code, out) } } t.Cleanup(func() { finishSeedCheckedHook = nil }) if err := f.fx.srv.finishSeed(context.Background(), "bab", fromApp, fromHost, id); err != nil { t.Fatal(err) } if !bound { t.Fatal("the finish never reached its teardown checks") } store := f.canaryStore(t) if store == nil || store.Grant == nil || store.Declared == nil || store.Declared.Seed != "" { t.Fatalf("canary store after the finish = %+v; want it standing, its mark cleared", store) } if _, ok := f.fake.Buckets[bucket]; !ok { t.Fatalf("bucket %s was torn down under an instance that bound it", bucket) } if rec, err := f.record(t, bindHost); err != nil || rec.Environment != "canary" { t.Fatalf("the binder's record = %+v, %v", rec, err) } f.requireRecordGone(t, fromHost) } // The update door's binding waits for the store's walk lock, which the // finish's teardown holds, and clears another seed's mark in the same // locked stretch as its record write. func TestEnvironmentBindingWaitsForTheStoreLock(t *testing.T) { f := finishFixture(t) id := newSeedID(t) f.seedRecord(t, fromHost, "canary", id, seeds.KindImport, seeds.StatePlaced) f.markedStore(t, id) f.plainInstance(t, bindHost, "staging") unlock := f.fx.srv.storeWalks.lock("bab", fromApp, "canary") done := make(chan int, 1) go func() { code, _, _ := rawReq(t, http.MethodPatch, f.url+"/"+bindHost, f.token, `{"environment":"canary"}`) done <- code }() waitForStoreLockQueue(t, f, "canary", 2, unlock) if store := f.canaryStore(t); store.Declared.Seed != id { t.Fatalf("the binding cleared the mark without the lock: %+v", store.Declared) } if rec, _ := f.record(t, bindHost); rec.Environment != "staging" { t.Fatalf("the binding landed without the lock: %+v", rec) } unlock() if code := <-done; code != http.StatusOK { t.Fatalf("bind canary = %d", code) } if store := f.canaryStore(t); store.Declared.Seed != "" { t.Fatalf("canary store after the binding = %+v; want the mark cleared", store.Declared) } } // waitForStoreLockQueue waits until env's store lock has refs holders and // waiters. On a timeout it unlocks and fails. func waitForStoreLockQueue(t *testing.T, f fromFixture, env string, refs int, unlock func()) { t.Helper() deadline := time.Now().Add(3 * time.Second) for { f.fx.srv.storeWalks.mu.Lock() n := 0 if e := f.fx.srv.storeWalks.m[storeWalkKey("bab", fromApp, env)]; e != nil { n = e.refs } f.fx.srv.storeWalks.mu.Unlock() if n == refs { return } if time.Now().After(deadline) { unlock() t.Fatalf("the store lock of %s has %d holders and waiters, want %d", env, n, refs) } time.Sleep(time.Millisecond) } } // A binding leaves alone: a store with no mark, the binder's own seed's // store, and an unchanged environment. Only another seed's mark is cleared. func TestEnvironmentBindingClearsOnlyAnotherSeedsMark(t *testing.T) { for _, c := range []struct { name string mark func(own string) string binder func(f fromFixture, t *testing.T, own string) cleared bool }{ {"another seed's", func(string) string { return newSeedID(t) }, func(f fromFixture, t *testing.T, _ string) { f.plainInstance(t, bindHost, "staging") }, true}, {"no mark", func(string) string { return "" }, func(f fromFixture, t *testing.T, _ string) { f.plainInstance(t, bindHost, "staging") }, false}, {"the binder's own seed's", func(own string) string { return own }, func(f fromFixture, t *testing.T, own string) { f.seedRecord(t, bindHost, "staging", own, seeds.KindImport, seeds.StateCommitted) }, false}, } { t.Run(c.name, func(t *testing.T) { f := finishFixture(t) own := newSeedID(t) c.binder(f, t, own) mark := c.mark(own) f.markedStore(t, mark) if code, out := f.bindEnvironment(t, bindHost, "canary"); code != http.StatusOK { t.Fatalf("bind canary = %d %s", code, out) } got := f.canaryStore(t).Declared.Seed if want := map[bool]string{true: "", false: mark}[c.cleared]; got != want { t.Fatalf("mark after the binding = %q, want %q", got, want) } }) } // Re-sending the environment an instance already names takes nothing. f := finishFixture(t) f.plainInstance(t, bindHost, "canary") mark := newSeedID(t) f.markedStore(t, mark) if code, out := f.bindEnvironment(t, bindHost, "canary"); code != http.StatusOK { t.Fatalf("re-bind canary = %d %s", code, out) } if got := f.canaryStore(t).Declared.Seed; got != mark { t.Fatalf("mark after an unchanged binding = %q, want %q", got, mark) } } // A create naming an environment, on an app that provisions no store per // instance, takes the store from the seed that made it. func TestEnvironmentCreateTakesASeedsStore(t *testing.T) { f := newImportFixture(t) mark := newSeedID(t) at := time.Now().UTC() putCanaryStoreOf(t, f.fx, fromApp, &objstore.Record{ Declared: &objstore.Declared{EnvPrefix: "S3", At: at, By: "bob@keymail.dev", Seed: mark}, Grant: &objstore.GrantBlock{Backend: "mem", Bucket: "carlos-bab-shop-canary", Source: objstore.GrantSourceAccountApproval, At: at, By: "operator@keymail.dev"}, }) if code, out := f.create(t, `{"host":"`+bindHost+`","environment":"canary"}`); code != http.StatusCreated { t.Fatalf("create = %d %v", code, out) } if got := f.canaryStore(t).Declared.Seed; got != "" { t.Fatalf("mark after the create = %q, want it cleared", got) } }