| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "errors" |
| 7 | "net/http" |
| 8 | "net/http/httptest" |
| 9 | "os" |
| 10 | "os/exec" |
| 11 | "path/filepath" |
| 12 | "strings" |
| 13 | "sync/atomic" |
| 14 | "testing" |
| 15 | "time" |
| 16 | |
| 17 | "github.com/carlosframework/platform/internal/console" |
| 18 | "github.com/carlosframework/platform/internal/release" |
| 19 | ) |
| 20 | |
| 21 | // regionE2E is an in-process console with titogo claimed at appRegion, the |
| 22 | // signed-in CLI credentials saved, and a front that can fail or strip the |
| 23 | // /regions answer and counts every write reaching the console. |
| 24 | type regionE2E struct { |
| 25 | bin string // built before useTempCredentials changes directory |
| 26 | creds string |
| 27 | url string |
| 28 | store release.Store |
| 29 | mode atomic.Value // "", "fail", "legacy", "region-unknown", "placement-unknown" |
| 30 | writes atomic.Int64 |
| 31 | // creates counts instance-create requests (POST …/instances), so a |
| 32 | // test can tell a duplicate create from the other writes (Task 22). |
| 33 | creates atomic.Int64 |
| 34 | cst *console.Store |
| 35 | // failGet is a store key whose reads fail while the listing succeeds, |
| 36 | // the way one bucket GET can (Task 22, codex plan-R3 1). |
| 37 | failGet atomic.Value |
| 38 | } |
| 39 | |
| 40 | // flakyStore fails every read of one key. |
| 41 | type flakyStore struct { |
| 42 | release.Store |
| 43 | key *atomic.Value |
| 44 | } |
| 45 | |
| 46 | func (f flakyStore) Get(ctx context.Context, key string) ([]byte, string, error) { |
| 47 | if k, _ := f.key.Load().(string); k != "" && k == key { |
| 48 | return nil, "", errors.New("read timeout") |
| 49 | } |
| 50 | return f.Store.Get(ctx, key) |
| 51 | } |
| 52 | |
| 53 | // DeleteIfMatch forwards the conditional delete the wrapped DirStore has, |
| 54 | // as production's S3 and directory stores do: the embedded interface does |
| 55 | // not promote it, and the console's instance delete is conditional on the |
| 56 | // etag its import check read (final review F7), with no unconditional |
| 57 | // fallback. |
| 58 | func (f flakyStore) DeleteIfMatch(ctx context.Context, key, etag string) error { |
| 59 | return release.DeleteIfMatch(ctx, f.Store, key, etag) |
| 60 | } |
| 61 | |
| 62 | // regionE2EConsole starts the console with titogo claimed in appRegion. An |
| 63 | // empty appRegion is a home app, the way CreateAppIn records one: stored "". |
| 64 | func regionE2EConsole(t *testing.T, appRegion string) *regionE2E { |
| 65 | t.Helper() |
| 66 | bin := buildCarlos(t) // first: useTempCredentials chdirs out of the module |
| 67 | creds := useTempCredentials(t) |
| 68 | clearDeploymentEnv(t) |
| 69 | const addr, app = "paul@keymail.dev", "titogo" |
| 70 | tmp := t.TempDir() |
| 71 | e := ®ionE2E{bin: bin, creds: creds, store: release.NewDirStore(filepath.Join(tmp, "store"))} |
| 72 | e.mode.Store("") |
| 73 | e.failGet.Store("") |
| 74 | seed, pubB64, err := release.GenerateKey() |
| 75 | if err != nil { |
| 76 | t.Fatal(err) |
| 77 | } |
| 78 | priv, err := release.ParseSigningKey(seed, "e2e") |
| 79 | if err != nil { |
| 80 | t.Fatal(err) |
| 81 | } |
| 82 | pub, err := release.ParsePublicKey(pubB64, "e2e") |
| 83 | if err != nil { |
| 84 | t.Fatal(err) |
| 85 | } |
| 86 | e.cst, err = console.Open(filepath.Join(tmp, "console.db")) |
| 87 | if err != nil { |
| 88 | t.Fatal(err) |
| 89 | } |
| 90 | t.Cleanup(func() { e.cst.Close() }) |
| 91 | var srv *console.Server |
| 92 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 93 | if r.Method != http.MethodGet && r.Method != http.MethodHead && !strings.Contains(r.URL.Path, "/auth/") { |
| 94 | e.writes.Add(1) |
| 95 | } |
| 96 | if r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/instances") { |
| 97 | e.creates.Add(1) |
| 98 | } |
| 99 | if strings.HasSuffix(r.URL.Path, "/regions") { |
| 100 | switch e.mode.Load().(string) { |
| 101 | case "fail": |
| 102 | http.Error(w, `{"error":"boom"}`, http.StatusInternalServerError) |
| 103 | return |
| 104 | case "legacy", "region-unknown", "placement-unknown": |
| 105 | // A successful 200 whose body an older console, or a |
| 106 | // console that could not read one fact, would send. |
| 107 | rec := httptest.NewRecorder() |
| 108 | srv.ServeHTTP(rec, r) |
| 109 | var m map[string]any |
| 110 | json.Unmarshal(rec.Body.Bytes(), &m) |
| 111 | d, _ := m["app_default"].(map[string]any) |
| 112 | switch e.mode.Load().(string) { |
| 113 | case "legacy": |
| 114 | delete(m, "app_default") |
| 115 | case "region-unknown": |
| 116 | d["region_known"], d["region"], d["effective"] = false, "", "" |
| 117 | case "placement-unknown": |
| 118 | d["placement_known"], d["reason"] = false, "policy-unreadable" |
| 119 | } |
| 120 | w.Header().Set("Content-Type", "application/json") |
| 121 | json.NewEncoder(w).Encode(m) |
| 122 | return |
| 123 | } |
| 124 | } |
| 125 | srv.ServeHTTP(w, r) |
| 126 | })) |
| 127 | t.Cleanup(ts.Close) |
| 128 | e.url = ts.URL |
| 129 | srv, err = console.NewServer(console.Config{ |
| 130 | Origin: ts.URL, Keymail: "http://keymail.invalid", |
| 131 | Registry: filepath.Join(tmp, "registry.db"), Releases: flakyStore{e.store, &e.failGet}, |
| 132 | ReleasePub: pub, Key: priv, AppScheme: "http", AppsDomain: "oncarlos.test", |
| 133 | HomeRegion: "ie", |
| 134 | }, e.cst) |
| 135 | if err != nil { |
| 136 | t.Fatal(err) |
| 137 | } |
| 138 | if err := e.cst.AddMember(console.OpsAccountID, addr, console.RoleOwner, time.Now()); err != nil { |
| 139 | t.Fatal(err) |
| 140 | } |
| 141 | if appRegion == "" { |
| 142 | _, err = e.cst.CreateAppIn(console.OpsAccountID, app, addr, time.Now()) |
| 143 | } else { |
| 144 | _, err = e.cst.CreateAppInRegion(console.OpsAccountID, app, addr, appRegion, "chosen", time.Now()) |
| 145 | } |
| 146 | if err != nil { |
| 147 | t.Fatal(err) |
| 148 | } |
| 149 | token, expiresAt := e2eDeviceLogin(t, ts.URL, e.cst, addr, "e2e@laptop") |
| 150 | credFile, err := loadCredentials() |
| 151 | if err != nil { |
| 152 | t.Fatal(err) |
| 153 | } |
| 154 | credFile.put(credential{Console: ts.URL, Address: addr, Token: token, ExpiresAt: expiresAt}) |
| 155 | if err := credFile.save(); err != nil { |
| 156 | t.Fatal(err) |
| 157 | } |
| 158 | e.writes.Store(0) // the login above is setup, not the command under test |
| 159 | return e |
| 160 | } |
| 161 | |
| 162 | // run runs the built carlos binary as a signed-in user with no project |
| 163 | // config in reach. |
| 164 | func (e *regionE2E) run(t *testing.T, args ...string) (string, error) { |
| 165 | t.Helper() |
| 166 | cmd := exec.Command(e.bin, args...) |
| 167 | cmd.Env = carlosTestEnv(t, "CARLOS_CREDENTIALS="+e.creds) |
| 168 | cmd.Dir = t.TempDir() |
| 169 | out, err := cmd.CombinedOutput() |
| 170 | return string(out), err |
| 171 | } |
| 172 | |
| 173 | func (e *regionE2E) releases(t *testing.T) int { |
| 174 | t.Helper() |
| 175 | keys, err := e.store.List(context.Background(), release.AppPrefix(release.OpsAccount, "titogo")) |
| 176 | if err != nil { |
| 177 | t.Fatal(err) |
| 178 | } |
| 179 | n := 0 |
| 180 | for _, k := range keys { |
| 181 | if strings.Contains(k, "/releases/") { |
| 182 | n++ |
| 183 | } |
| 184 | } |
| 185 | return n |
| 186 | } |
| 187 | |
| 188 | func artifact(t *testing.T) string { |
| 189 | t.Helper() |
| 190 | p := filepath.Join(t.TempDir(), "app") |
| 191 | if err := os.WriteFile(p, []byte("#!/bin/sh\n"), 0o755); err != nil { |
| 192 | t.Fatal(err) |
| 193 | } |
| 194 | return p |
| 195 | } |
| 196 | |
| 197 | // deployAsking runs `carlos deploy --region asked` on titogo. |
| 198 | func (e *regionE2E) deployAsking(t *testing.T, asked string) (string, error) { |
| 199 | t.Helper() |
| 200 | return e.run(t, "deploy", "--app", "titogo", "--region", asked, "--console", e.url, |
| 201 | "--channel", "canary/e2e", "--version", "abc1234", "--timeout", "1s", artifact(t)) |
| 202 | } |
| 203 | |
| 204 | // Every refusal is the whole approved sentence, with the app and the place |
| 205 | // in their own slots, and happens before any write. |
| 206 | func TestDeployRegionRefusalsShipNothing(t *testing.T) { |
| 207 | for _, c := range []struct{ mode, want string }{ |
| 208 | // C15: the stored region is uk; with no edge publishing a name for |
| 209 | // it, the console's place is the code itself. |
| 210 | {"", "deploy: " + deployRegionMismatch("titogo", "uk")}, |
| 211 | // C15b: its %v is the console's own words, so the fixed lead and |
| 212 | // tail are asserted whole. |
| 213 | {"fail", "deploy: could not read titogo's region ("}, |
| 214 | {"region-unknown", "deploy: " + deployRegionUnknown("titogo")}, // C15c: a 200 that does not know |
| 215 | } { |
| 216 | // A subtest each: useTempCredentials chdirs, and only a subtest |
| 217 | // restores the directory before the next case builds the binary. |
| 218 | t.Run("mode="+c.mode, func(t *testing.T) { |
| 219 | e := regionE2EConsole(t, "uk") |
| 220 | e.mode.Store(c.mode) |
| 221 | out, err := e.deployAsking(t, "ie") |
| 222 | if err == nil || !strings.Contains(out, c.want) { |
| 223 | t.Fatalf("err=%v out=%s, want a refusal containing %q", err, out, c.want) |
| 224 | } |
| 225 | if c.mode == "fail" && !strings.Contains(out, "), so deploy stopped before shipping anything. Try again, or run without --region.") { |
| 226 | t.Fatalf("C15b tail missing: %s", out) |
| 227 | } |
| 228 | if n := e.writes.Load(); n != 0 { |
| 229 | t.Fatalf("%d write(s) reached the console before the refusal", n) |
| 230 | } |
| 231 | if n := e.releases(t); n != 0 { |
| 232 | t.Fatalf("%d release object(s) were shipped", n) |
| 233 | } |
| 234 | }) |
| 235 | } |
| 236 | } |
| 237 | |
| 238 | // An older console (a successful /regions without app_default) skips the |
| 239 | // check, and the deploy goes on to ship. |
| 240 | func TestDeployRegionSkipsAgainstAnOlderConsole(t *testing.T) { |
| 241 | e := regionE2EConsole(t, "uk") |
| 242 | e.mode.Store("legacy") |
| 243 | out, _ := e.deployAsking(t, "ie") |
| 244 | if strings.Contains(out, deployRegionMismatch("titogo", "")[:20]) || strings.Contains(out, "deploy stopped before shipping") { |
| 245 | t.Fatalf("an older console refused: %s", out) |
| 246 | } |
| 247 | if e.writes.Load() == 0 || e.releases(t) == 0 { |
| 248 | t.Fatalf("deploy never shipped: %s", out) |
| 249 | } |
| 250 | } |
| 251 | |
| 252 | // --region naming the home on an app stored as "" (the home) is the same |
| 253 | // region, so the deploy ships; naming the stored region on a placed app does |
| 254 | // too. |
| 255 | func TestDeployRegionNamingTheStoredRegionShips(t *testing.T) { |
| 256 | for _, c := range []struct{ name, stored, asked string }{ |
| 257 | {"home app, home named", "", "ie"}, |
| 258 | {"placed app, its region named", "uk", "uk"}, |
| 259 | } { |
| 260 | t.Run(c.name, func(t *testing.T) { |
| 261 | e := regionE2EConsole(t, c.stored) |
| 262 | out, _ := e.deployAsking(t, c.asked) |
| 263 | if strings.Contains(out, "already runs in") || strings.Contains(out, "deploy stopped before shipping") { |
| 264 | t.Fatalf("the matching region was refused: %s", out) |
| 265 | } |
| 266 | if e.writes.Load() == 0 || e.releases(t) == 0 { |
| 267 | t.Fatalf("deploy never shipped: %s", out) |
| 268 | } |
| 269 | }) |
| 270 | } |
| 271 | } |
| 272 | |