carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3import (
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.
24type 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.
41type flakyStore struct {
42 release.Store
43 key *atomic.Value
44}
45
46func (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.
58func (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 "".
64func 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 := &regionE2E{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.
164func (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
173func (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
188func 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.
198func (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.
206func 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.
240func 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.
255func 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