| new file mode 100644 |
| index 000000000..0719c5394 |
| --- /dev/null |
| +++ b/deploy_region_e2e_test.go |
| @@ -0,0 +1,262 @@ |
| +package main |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "net/http" |
| + "net/http/httptest" |
| + "os" |
| + "os/exec" |
| + "path/filepath" |
| + "strings" |
| + "sync/atomic" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/console" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// regionE2E is an in-process console with titogo claimed at appRegion, the |
| +// signed-in CLI credentials saved, and a front that can fail or strip the |
| +// /regions answer and counts every write reaching the console. |
| +type regionE2E struct { |
| + bin string // built before useTempCredentials changes directory |
| + creds string |
| + url string |
| + store release.Store |
| + mode atomic.Value // "", "fail", "legacy", "region-unknown", "placement-unknown" |
| + writes atomic.Int64 |
| + // creates counts instance-create requests (POST …/instances), so a |
| + // test can tell a duplicate create from the other writes (Task 22). |
| + creates atomic.Int64 |
| + cst *console.Store |
| + // failGet is a store key whose reads fail while the listing succeeds, |
| + // the way one bucket GET can (Task 22, codex plan-R3 1). |
| + failGet atomic.Value |
| +} |
| + |
| +// flakyStore fails every read of one key. |
| +type flakyStore struct { |
| + release.Store |
| + key *atomic.Value |
| +} |
| + |
| +func (f flakyStore) Get(ctx context.Context, key string) ([]byte, string, error) { |
| + if k, _ := f.key.Load().(string); k != "" && k == key { |
| + return nil, "", errors.New("read timeout") |
| + } |
| + return f.Store.Get(ctx, key) |
| +} |
| + |
| +// regionE2EConsole starts the console with titogo claimed in appRegion. An |
| +// empty appRegion is a home app, the way CreateAppIn records one: stored "". |
| +func regionE2EConsole(t *testing.T, appRegion string) *regionE2E { |
| + t.Helper() |
| + bin := buildCarlos(t) // first: useTempCredentials chdirs out of the module |
| + creds := useTempCredentials(t) |
| + clearDeploymentEnv(t) |
| + const addr, app = "paul@keymail.dev", "titogo" |
| + tmp := t.TempDir() |
| + e := ®ionE2E{bin: bin, creds: creds, store: release.NewDirStore(filepath.Join(tmp, "store"))} |
| + e.mode.Store("") |
| + e.failGet.Store("") |
| + seed, pubB64, err := release.GenerateKey() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + priv, err := release.ParseSigningKey(seed, "e2e") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + pub, err := release.ParsePublicKey(pubB64, "e2e") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + e.cst, err = console.Open(filepath.Join(tmp, "console.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { e.cst.Close() }) |
| + var srv *console.Server |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + if r.Method != http.MethodGet && r.Method != http.MethodHead && !strings.Contains(r.URL.Path, "/auth/") { |
| + e.writes.Add(1) |
| + } |
| + if r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/instances") { |
| + e.creates.Add(1) |
| + } |
| + if strings.HasSuffix(r.URL.Path, "/regions") { |
| + switch e.mode.Load().(string) { |
| + case "fail": |
| + http.Error(w, `{"error":"boom"}`, http.StatusInternalServerError) |
| + return |
| + case "legacy", "region-unknown", "placement-unknown": |
| + // A successful 200 whose body an older console, or a |
| + // console that could not read one fact, would send. |
| + rec := httptest.NewRecorder() |
| + srv.ServeHTTP(rec, r) |
| + var m map[string]any |
| + json.Unmarshal(rec.Body.Bytes(), &m) |
| + d, _ := m["app_default"].(map[string]any) |
| + switch e.mode.Load().(string) { |
| + case "legacy": |
| + delete(m, "app_default") |
| + case "region-unknown": |
| + d["region_known"], d["region"], d["effective"] = false, "", "" |
| + case "placement-unknown": |
| + d["placement_known"], d["reason"] = false, "policy-unreadable" |
| + } |
| + w.Header().Set("Content-Type", "application/json") |
| + json.NewEncoder(w).Encode(m) |
| + return |
| + } |
| + } |
| + srv.ServeHTTP(w, r) |
| + })) |
| + t.Cleanup(ts.Close) |
| + e.url = ts.URL |
| + srv, err = console.NewServer(console.Config{ |
| + Origin: ts.URL, Keymail: "http://keymail.invalid", |
| + Registry: filepath.Join(tmp, "registry.db"), Releases: flakyStore{e.store, &e.failGet}, |
| + ReleasePub: pub, Key: priv, AppScheme: "http", AppsDomain: "oncarlos.test", |
| + HomeRegion: "ie", |
| + }, e.cst) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := e.cst.AddMember(console.OpsAccountID, addr, console.RoleOwner, time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + if appRegion == "" { |
| + _, err = e.cst.CreateAppIn(console.OpsAccountID, app, addr, time.Now()) |
| + } else { |
| + _, err = e.cst.CreateAppInRegion(console.OpsAccountID, app, addr, appRegion, "chosen", time.Now()) |
| + } |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + token, expiresAt := e2eDeviceLogin(t, ts.URL, e.cst, addr, "e2e@laptop") |
| + credFile, err := loadCredentials() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + credFile.put(credential{Console: ts.URL, Address: addr, Token: token, ExpiresAt: expiresAt}) |
| + if err := credFile.save(); err != nil { |
| + t.Fatal(err) |
| + } |
| + e.writes.Store(0) // the login above is setup, not the command under test |
| + return e |
| +} |
| + |
| +// run runs the built carlos binary as a signed-in user with no project |
| +// config in reach. |
| +func (e *regionE2E) run(t *testing.T, args ...string) (string, error) { |
| + t.Helper() |
| + cmd := exec.Command(e.bin, args...) |
| + cmd.Env = carlosTestEnv(t, "CARLOS_CREDENTIALS="+e.creds) |
| + cmd.Dir = t.TempDir() |
| + out, err := cmd.CombinedOutput() |
| + return string(out), err |
| +} |
| + |
| +func (e *regionE2E) releases(t *testing.T) int { |
| + t.Helper() |
| + keys, err := e.store.List(context.Background(), release.AppPrefix(release.OpsAccount, "titogo")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + n := 0 |
| + for _, k := range keys { |
| + if strings.Contains(k, "/releases/") { |
| + n++ |
| + } |
| + } |
| + return n |
| +} |
| + |
| +func artifact(t *testing.T) string { |
| + t.Helper() |
| + p := filepath.Join(t.TempDir(), "app") |
| + if err := os.WriteFile(p, []byte("#!/bin/sh\n"), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + return p |
| +} |
| + |
| +// deployAsking runs `carlos deploy --region asked` on titogo. |
| +func (e *regionE2E) deployAsking(t *testing.T, asked string) (string, error) { |
| + t.Helper() |
| + return e.run(t, "deploy", "--app", "titogo", "--region", asked, "--console", e.url, |
| + "--channel", "canary/e2e", "--version", "abc1234", "--timeout", "1s", artifact(t)) |
| +} |
| + |
| +// Every refusal is the whole approved sentence, with the app and the place |
| +// in their own slots, and happens before any write. |
| +func TestDeployRegionRefusalsShipNothing(t *testing.T) { |
| + for _, c := range []struct{ mode, want string }{ |
| + // C15: the stored region is uk; with no edge publishing a name for |
| + // it, the console's place is the code itself. |
| + {"", "deploy: " + deployRegionMismatch("titogo", "uk")}, |
| + // C15b: its %v is the console's own words, so the fixed lead and |
| + // tail are asserted whole. |
| + {"fail", "deploy: could not read titogo's region ("}, |
| + {"region-unknown", "deploy: " + deployRegionUnknown("titogo")}, // C15c: a 200 that does not know |
| + } { |
| + // A subtest each: useTempCredentials chdirs, and only a subtest |
| + // restores the directory before the next case builds the binary. |
| + t.Run("mode="+c.mode, func(t *testing.T) { |
| + e := regionE2EConsole(t, "uk") |
| + e.mode.Store(c.mode) |
| + out, err := e.deployAsking(t, "ie") |
| + if err == nil || !strings.Contains(out, c.want) { |
| + t.Fatalf("err=%v out=%s, want a refusal containing %q", err, out, c.want) |
| + } |
| + if c.mode == "fail" && !strings.Contains(out, "), so deploy stopped before shipping anything. Try again, or run without --region.") { |
| + t.Fatalf("C15b tail missing: %s", out) |
| + } |
| + if n := e.writes.Load(); n != 0 { |
| + t.Fatalf("%d write(s) reached the console before the refusal", n) |
| + } |
| + if n := e.releases(t); n != 0 { |
| + t.Fatalf("%d release object(s) were shipped", n) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// An older console (a successful /regions without app_default) skips the |
| +// check, and the deploy goes on to ship. |
| +func TestDeployRegionSkipsAgainstAnOlderConsole(t *testing.T) { |
| + e := regionE2EConsole(t, "uk") |
| + e.mode.Store("legacy") |
| + out, _ := e.deployAsking(t, "ie") |
| + if strings.Contains(out, deployRegionMismatch("titogo", "")[:20]) || strings.Contains(out, "deploy stopped before shipping") { |
| + t.Fatalf("an older console refused: %s", out) |
| + } |
| + if e.writes.Load() == 0 || e.releases(t) == 0 { |
| + t.Fatalf("deploy never shipped: %s", out) |
| + } |
| +} |
| + |
| +// --region naming the home on an app stored as "" (the home) is the same |
| +// region, so the deploy ships; naming the stored region on a placed app does |
| +// too. |
| +func TestDeployRegionNamingTheStoredRegionShips(t *testing.T) { |
| + for _, c := range []struct{ name, stored, asked string }{ |
| + {"home app, home named", "", "ie"}, |
| + {"placed app, its region named", "uk", "uk"}, |
| + } { |
| + t.Run(c.name, func(t *testing.T) { |
| + e := regionE2EConsole(t, c.stored) |
| + out, _ := e.deployAsking(t, c.asked) |
| + if strings.Contains(out, "already runs in") || strings.Contains(out, "deploy stopped before shipping") { |
| + t.Fatalf("the matching region was refused: %s", out) |
| + } |
| + if e.writes.Load() == 0 || e.releases(t) == 0 { |
| + t.Fatalf("deploy never shipped: %s", out) |
| + } |
| + }) |
| + } |
| +} |