| 1 | package console |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "net/http" |
| 6 | "sync" |
| 7 | "testing" |
| 8 | "time" |
| 9 | |
| 10 | "github.com/carlosframework/platform/internal/objstore" |
| 11 | "github.com/carlosframework/platform/internal/provision" |
| 12 | "github.com/carlosframework/platform/internal/release" |
| 13 | ) |
| 14 | |
| 15 | // envInUse is the refusal of a seeded create whose derived environment |
| 16 | // another instance of the app already names and no store holds. |
| 17 | func envInUse(env string) string { |
| 18 | return "environment " + env + " is already used by another instance of " + fromApp + ". A seeded instance gets a new environment named after its host, so choose another host." |
| 19 | } |
| 20 | |
| 21 | // Final review F6: a source created with --environment canary before its |
| 22 | // app took private storage, then a --from seed of canary.<domain>, whose |
| 23 | // derived environment is canary. Both would have shared the environment |
| 24 | // and the seed's new store. The seed is refused before its record is |
| 25 | // reserved, and no store is made. |
| 26 | func TestSeededCreateRefusesAnEnvironmentAnotherInstanceNames(t *testing.T) { |
| 27 | f := newFromFixture(t, 3) |
| 28 | f.addSource(t, provision.Record{Host: "src.shop.example.com", Slug: "src", Environment: "canary"}, 3) |
| 29 | enableInstances(t, f.fx, fromApp, fromManifest(true)) |
| 30 | code, out := f.create(t, `{"host":"`+fromHost+`","from":{"host":"src.shop.example.com"}}`) |
| 31 | if code != http.StatusConflict || str(t, out, "error") != envInUse("canary") { |
| 32 | t.Fatalf("--from into a named environment = %d %v, want 409 %q", code, out, envInUse("canary")) |
| 33 | } |
| 34 | f.requireNoRecord(t, fromHost) |
| 35 | if store := f.canaryStore(t); store != nil { |
| 36 | t.Fatalf("a store was made for the refused seed: %+v", store) |
| 37 | } |
| 38 | |
| 39 | // The import door answers the same, for an instance of any shape. |
| 40 | code, out = f.create(t, importBody(fromHost, importSHA, 10, importAddr)) |
| 41 | if code != http.StatusConflict || str(t, out, "error") != envInUse("canary") { |
| 42 | t.Fatalf("an import into a named environment = %d %v, want 409 %q", code, out, envInUse("canary")) |
| 43 | } |
| 44 | f.requireNoRecord(t, fromHost) |
| 45 | } |
| 46 | |
| 47 | // recordOnRecordWrite writes another instance's record naming env right |
| 48 | // after the seed's own record lands: the binding made between the door's |
| 49 | // first check and its walk under the store lock. |
| 50 | type recordOnRecordWrite struct { |
| 51 | release.Store |
| 52 | key, env string |
| 53 | once sync.Once |
| 54 | } |
| 55 | |
| 56 | func (s *recordOnRecordWrite) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { |
| 57 | tag, err := s.Store.Put(ctx, key, body, ifMatch) |
| 58 | if err == nil && key == s.key && ifMatch == "" { |
| 59 | s.once.Do(func() { |
| 60 | // Another writer's own write, so its own context: ctx carries |
| 61 | // the record write this Put belongs to (provision.WriteRecord), |
| 62 | // which a second record written under it would be refused as. |
| 63 | if cerr := provision.CreateRecord(context.Background(), s.Store, provision.Record{Schema: 1, Host: bindHost, Slug: "other", App: fromApp, |
| 64 | Account: "bab", Channel: "stable", Environment: s.env, CreatedAt: time.Now().UTC()}); cerr != nil { |
| 65 | panic(cerr) |
| 66 | } |
| 67 | }) |
| 68 | } |
| 69 | return tag, err |
| 70 | } |
| 71 | |
| 72 | func (s *recordOnRecordWrite) DeleteIfMatch(ctx context.Context, key, etag string) error { |
| 73 | return release.DeleteIfMatch(ctx, s.Store, key, etag) |
| 74 | } |
| 75 | |
| 76 | // The walk's own check under the store lock refuses an environment another |
| 77 | // instance came to name after the door's first check: the in-use sentence, the import |
| 78 | // rolled back and its host free, and no store declared for it. |
| 79 | func TestImportBeginEnvironmentNamedUnderTheLock(t *testing.T) { |
| 80 | f := newImportFixture(t) |
| 81 | enableInstances(t, f.fx, fromApp, fromManifest(true)) |
| 82 | f.fx.srv.cfg.Releases = &recordOnRecordWrite{Store: f.fx.rel, key: provision.RecordKey("bab", fromApp, importHost), env: "old"} |
| 83 | code, out := beginImport(t, f, importHost, importSHA, 10) |
| 84 | if code != http.StatusConflict || str(t, out, "error") != envInUse("old") { |
| 85 | t.Fatalf("= %d %v, want 409 %q", code, out, envInUse("old")) |
| 86 | } |
| 87 | f.requireNoRecord(t, importHost) |
| 88 | if store, _, err := objstore.GetIn(context.Background(), f.fx.rel, "bab", fromApp, "old"); err != nil || store != nil { |
| 89 | t.Fatalf("old store = %+v, %v; want none declared", store, err) |
| 90 | } |
| 91 | } |
| 92 | |
| 93 | // A record of the app that will not decode may name the environment: the |
| 94 | // seeded create cannot tell, so it refuses with the door's retry sentence |
| 95 | // and writes nothing. |
| 96 | func TestSeededCreateRefusesWhenARecordCannotBeRead(t *testing.T) { |
| 97 | f := newImportFixture(t) |
| 98 | enableInstances(t, f.fx, fromApp, fromManifest(true)) |
| 99 | if _, err := f.fx.rel.Put(context.Background(), provision.RecordKey("bab", fromApp, bindHost), []byte("{not json"), ""); err != nil { |
| 100 | t.Fatal(err) |
| 101 | } |
| 102 | code, out := beginImport(t, f, importHost, importSHA, 10) |
| 103 | if code != http.StatusBadGateway || str(t, out, "error") != importFailed { |
| 104 | t.Fatalf("= %d %v, want 502 %q", code, out, importFailed) |
| 105 | } |
| 106 | f.requireNoRecord(t, importHost) |
| 107 | } |
| 108 | |