carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1// End-to-end acceptance for `carlos db` (spec 2026-09-02): a REAL
2// console.Server whose door reads a fake S3 holding a real litestream
3// replica, through the production replica client (lease.ReadReplicaClient
4// with CARLOS_S3_ENDPOINT), a device-flow login, then the real runDB verbs
5// driven in-process. The member's whole path, with no box access and no
6// AWS credentials of their own. TestE2ELogsThroughConsole's shape.
7package main
8
9import (
10 "bytes"
11 "context"
12 "database/sql"
13 "encoding/json"
14 "encoding/xml"
15 "fmt"
16 "io/fs"
17 "net/http"
18 "net/http/httptest"
19 "os"
20 "path"
21 "path/filepath"
22 "strconv"
23 "strings"
24 "sync"
25 "testing"
26 "time"
27
28 "github.com/superfly/ltx"
29
30 "github.com/carlosframework/platform/internal/console"
31 "github.com/carlosframework/platform/internal/dbdoor/dbdoortest"
32 "github.com/carlosframework/platform/internal/provision"
33 "github.com/carlosframework/platform/internal/release"
34)
35
36// replicaS3 is just enough S3 for litestream's read path (ListObjectsV2 and
37// a ranged GetObject), serving file replicas under root in the S3 key
38// layout <prefix>/<level %04x>/<file>.ltx. It records every key asked for,
39// so a test can assert nothing outside the coordinates was ever requested.
40type replicaS3 struct {
41 t *testing.T
42 bucket string
43 root string
44 mu sync.Mutex
45 keys []string
46}
47
48func (s *replicaS3) ServeHTTP(w http.ResponseWriter, r *http.Request) {
49 rest := strings.TrimPrefix(r.URL.Path, "/"+s.bucket)
50 if r.Method != http.MethodGet {
51 s.t.Errorf("fake S3: %s %s; the door must only read", r.Method, r.URL.Path)
52 w.WriteHeader(http.StatusForbidden)
53 return
54 }
55 if rest == "" || rest == "/" {
56 s.list(w, r.URL.Query().Get("prefix"))
57 return
58 }
59 key := strings.TrimPrefix(rest, "/")
60 s.mu.Lock()
61 s.keys = append(s.keys, key)
62 s.mu.Unlock()
63 f, err := os.Open(s.file(key))
64 if err != nil {
65 w.Header().Set("Content-Type", "application/xml")
66 w.WriteHeader(http.StatusNotFound)
67 w.Write([]byte(`<Error><Code>NoSuchKey</Code></Error>`))
68 return
69 }
70 defer f.Close()
71 http.ServeContent(w, r, "", time.Time{}, f)
72}
73
74// file maps <p>/<level hex>/<name> to the file client's <p>/ltx/<level>/<name>.
75func (s *replicaS3) file(key string) string {
76 dir, name := path.Split(key)
77 prefix, levelHex := path.Split(strings.TrimSuffix(dir, "/"))
78 level, err := strconv.ParseInt(levelHex, 16, 32)
79 if err != nil {
80 return filepath.Join(s.root, "nonexistent")
81 }
82 return filepath.Join(s.root, filepath.FromSlash(prefix), "ltx", strconv.Itoa(int(level)), name)
83}
84
85func (s *replicaS3) list(w http.ResponseWriter, prefix string) {
86 type obj struct {
87 Key string
88 Size int64
89 LastModified string
90 }
91 var objs []obj
92 dir, _ := path.Split(prefix)
93 p, levelHex := path.Split(strings.TrimSuffix(dir, "/"))
94 if level, err := strconv.ParseInt(levelHex, 16, 32); err == nil {
95 local := filepath.Join(s.root, filepath.FromSlash(p), "ltx", strconv.Itoa(int(level)))
96 ents, _ := os.ReadDir(local)
97 for _, e := range ents {
98 if _, _, err := ltx.ParseFilename(e.Name()); err != nil {
99 continue
100 }
101 fi, err := e.Info()
102 if err != nil {
103 continue
104 }
105 objs = append(objs, obj{Key: prefix + e.Name(), Size: fi.Size(), LastModified: fi.ModTime().UTC().Format(time.RFC3339)})
106 }
107 }
108 type result struct {
109 XMLName xml.Name `xml:"ListBucketResult"`
110 Name string
111 Prefix string
112 KeyCount int
113 IsTruncated bool
114 Contents []obj
115 }
116 w.Header().Set("Content-Type", "application/xml")
117 xml.NewEncoder(w).Encode(result{Name: s.bucket, Prefix: prefix, KeyCount: len(objs), Contents: objs})
118}
119
120func TestE2EDBThroughConsole(t *testing.T) {
121 useTempCredentials(t)
122 clearDeploymentEnv(t)
123 checkNoRegistry := requireNoRegistryTouch(t)
124
125 tmp := t.TempDir()
126 const host = "acme.go.tito.io"
127 replicaRoot := filepath.Join(tmp, "data")
128 // Two batches, then a third in a later whole second, so --at can
129 // name a time between them in the fake S3's second-resolution listing.
130 replicaDir := filepath.Join(replicaRoot, "instances", host)
131 src := dbdoortest.Replicate(t, replicaDir, 2)
132 time.Sleep(time.Until(time.Now().Truncate(time.Second).Add(time.Second + 10*time.Millisecond)))
133 between := time.Now()
134 time.Sleep(time.Until(between.Truncate(time.Second).Add(time.Second + 10*time.Millisecond)))
135 {
136 db, err := sql.Open("sqlite", src)
137 if err != nil {
138 t.Fatal(err)
139 }
140 for i := 0; i < dbdoortest.Rows; i++ {
141 if _, err := db.Exec(`INSERT INTO t (body) VALUES (?)`, fmt.Sprintf("row-2-%d", i)); err != nil {
142 t.Fatal(err)
143 }
144 }
145 db.Close()
146 dbdoortest.Sync(t, src, replicaDir)
147 }
148 // lock.json sits under the same prefix in production. Nothing may fetch it.
149 if err := os.WriteFile(filepath.Join(replicaRoot, "instances", host, "lock.json"), []byte(`{"owner":"box"}`), 0o600); err != nil {
150 t.Fatal(err)
151 }
152 fake := &replicaS3{t: t, bucket: "data-test", root: replicaRoot}
153 s3srv := httptest.NewServer(fake)
154 t.Cleanup(s3srv.Close)
155 t.Setenv("CARLOS_S3_ENDPOINT", s3srv.URL)
156 t.Setenv("AWS_ACCESS_KEY_ID", "test-key")
157 t.Setenv("AWS_SECRET_ACCESS_KEY", "test-secret")
158 t.Setenv("AWS_EC2_METADATA_DISABLED", "true")
159
160 relStore := release.NewDirStore(filepath.Join(tmp, "store"))
161 seed, pubB64, err := release.GenerateKey()
162 if err != nil {
163 t.Fatal(err)
164 }
165 priv, err := release.ParseSigningKey(seed, "e2e")
166 if err != nil {
167 t.Fatal(err)
168 }
169 pub, err := release.ParsePublicKey(pubB64, "e2e")
170 if err != nil {
171 t.Fatal(err)
172 }
173 cst, err := console.Open(filepath.Join(tmp, "console.db"))
174 if err != nil {
175 t.Fatal(err)
176 }
177 t.Cleanup(func() { cst.Close() })
178 var srv *console.Server
179 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
180 srv.ServeHTTP(w, r)
181 }))
182 t.Cleanup(ts.Close)
183 srv, err = console.NewServer(console.Config{
184 Origin: ts.URL,
185 Keymail: "http://keymail.invalid",
186 Registry: filepath.Join(tmp, "registry.db"),
187 Releases: relStore,
188 ReleasePub: pub,
189 Key: priv,
190 AppScheme: "http",
191 HibernateBucket: "data-test",
192 HibernateRegion: "us-east-1",
193 HibernatePrefix: "instances",
194 }, cst)
195 if err != nil {
196 t.Fatal(err)
197 }
198 const addr = "paul@keymail.dev"
199 if err := cst.AddMember(console.OpsAccountID, addr, console.RoleOwner, time.Now()); err != nil {
200 t.Fatal(err)
201 }
202 if _, err := cst.CreateAppIn(console.OpsAccountID, "titogo", addr, time.Now()); err != nil {
203 t.Fatal(err)
204 }
205 yes := true
206 rec, _ := json.Marshal(provision.Record{Host: host, App: "titogo", Account: release.OpsAccount, Hibernate: &yes})
207 if _, err := relStore.Put(context.Background(), provision.RecordKey(release.OpsAccount, "titogo", host), rec, ""); err != nil {
208 t.Fatal(err)
209 }
210 token, expiresAt := e2eDeviceLogin(t, ts.URL, cst, addr, "e2e@laptop")
211 credFile, err := loadCredentials()
212 if err != nil {
213 t.Fatal(err)
214 }
215 credFile.put(credential{Console: ts.URL, Address: addr, Token: token, ExpiresAt: expiresAt})
216 if err := credFile.save(); err != nil {
217 t.Fatal(err)
218 }
219 t.Setenv("TMPDIR", t.TempDir())
220
221 // pull: the copy lands at --out, 0600, with every row, and the handover
222 // says it is a copy and names its newest transaction.
223 out := filepath.Join(t.TempDir(), "pulled.db")
224 buf := captureStdout(t)
225 runDB([]string{"pull", "--console", ts.URL, "--app", "titogo", "--account", "ops", "--instance", host, "--out", out})
226 said := buf.String()
227 for _, want := range []string{out, "copy", "transaction", host} {
228 if !strings.Contains(said, want) {
229 t.Errorf("pull said %q, want it to mention %q", said, want)
230 }
231 }
232 fi, err := os.Stat(out)
233 if err != nil {
234 t.Fatal(err)
235 }
236 if fi.Mode().Perm() != 0o600 {
237 t.Errorf("pulled copy is %04o, want 0600", fi.Mode().Perm())
238 }
239 var rows bytes.Buffer
240 if err := runQuery(&rows, out, "SELECT count(*) AS n FROM t"); err != nil || rows.String() != "n\n"+strconv.Itoa(3*dbdoortest.Rows)+"\n" {
241 t.Fatalf("pulled copy = %q %v", rows.String(), err)
242 }
243
244 // pull --at: the copy as of a time between the second and third
245 // batches holds the first two, and says which time it was asked for.
246 outAt := filepath.Join(t.TempDir(), "pulled-at.db")
247 buf.Reset()
248 runDB([]string{"pull", "--console", ts.URL, "--app", "titogo", "--account", "ops", "--instance", host, "--out", outAt, "--at", between.UTC().Format(time.RFC3339Nano)})
249 if want := "the last one replicated before " + dbTime(between); !strings.Contains(buf.String(), want) {
250 t.Errorf("pull --at said %q, want it to mention %q", buf.String(), want)
251 }
252 rows.Reset()
253 if err := runQuery(&rows, outAt, "SELECT count(*) AS n FROM t"); err != nil || rows.String() != "n\n"+strconv.Itoa(2*dbdoortest.Rows)+"\n" {
254 t.Fatalf("copy as of %v = %q %v, want %d rows", between, rows.String(), err, 2*dbdoortest.Rows)
255 }
256
257 // query: rows on stdout, nothing else.
258 buf.Reset()
259 runDB([]string{"query", "--console", ts.URL, "--app", "titogo", "--account", "ops", "--instance", host, "--sql", "SELECT body FROM t ORDER BY id LIMIT 2"})
260 if got := buf.String(); got != "body\nrow-0-0\nrow-0-1\n" {
261 t.Errorf("query stdout = %q, want the header and two rows only", got)
262 }
263
264 // Nothing private is left in the temp dir, and nothing but LTX files
265 // under this instance's prefix was ever asked of the bucket.
266 _ = filepath.WalkDir(os.Getenv("TMPDIR"), func(p string, d fs.DirEntry, err error) error {
267 if err == nil && strings.HasPrefix(d.Name(), "carlos-db-") {
268 t.Errorf("restore directory left behind: %s", p)
269 }
270 return nil
271 })
272 fake.mu.Lock()
273 defer fake.mu.Unlock()
274 if len(fake.keys) == 0 {
275 t.Fatal("the door never read the bucket")
276 }
277 for _, k := range fake.keys {
278 if !strings.HasPrefix(k, "instances/"+host+"/") || !strings.HasSuffix(k, ".ltx") {
279 t.Errorf("the door asked the bucket for %q", k)
280 }
281 }
282 checkNoRegistry()
283}
284