carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package console
2
3import (
4 "bufio"
5 "context"
6 "crypto/sha256"
7 "encoding/hex"
8 "encoding/json"
9 "net/http"
10 "os"
11 "strconv"
12 "strings"
13 "sync"
14 "testing"
15 "time"
16
17 "github.com/aws/aws-sdk-go-v2/aws"
18 "github.com/aws/aws-sdk-go-v2/credentials"
19 awss3 "github.com/aws/aws-sdk-go-v2/service/s3"
20 "github.com/benbjohnson/litestream"
21 lsfile "github.com/benbjohnson/litestream/file"
22 lss3 "github.com/benbjohnson/litestream/s3"
23
24 "github.com/carlosframework/platform/internal/dbdoor"
25 "github.com/carlosframework/platform/internal/dbdoor/dbdoortest"
26 "github.com/carlosframework/platform/internal/dbreads"
27 "github.com/carlosframework/platform/internal/dbreads/dbreadstest"
28 "github.com/carlosframework/platform/internal/provision"
29 "github.com/carlosframework/platform/internal/release"
30)
31
32// uploadReplica writes a file replica's LTX files into an S3 replica the way
33// a box's litestream does (WriteLTXFile), level by level.
34func uploadReplica(t *testing.T, from *lsfile.ReplicaClient, to *lss3.ReplicaClient) {
35 t.Helper()
36 ctx := context.Background()
37 for level := 0; level <= litestream.SnapshotLevel; level++ {
38 itr, err := from.LTXFiles(ctx, level, 0, false)
39 if err != nil {
40 t.Fatal(err)
41 }
42 for itr.Next() {
43 f := itr.Item()
44 rc, err := from.OpenLTXFile(ctx, level, f.MinTXID, f.MaxTXID, 0, 0)
45 if err != nil {
46 t.Fatal(err)
47 }
48 if _, err := to.WriteLTXFile(ctx, level, f.MinTXID, f.MaxTXID, rc); err != nil {
49 t.Fatal(err)
50 }
51 rc.Close()
52 }
53 itr.Close()
54 }
55}
56
57// dbfGateStore holds the box's chunk and result creates, once armed, until
58// release closes, and records when each landed: T16 needs a box that
59// publishes only after the console has ended the request (plan review
60// finding 19).
61type dbfGateStore struct {
62 release.Store
63 mu sync.Mutex
64 open chan struct{}
65 reached chan struct{}
66 once *sync.Once
67 landed []time.Time
68}
69
70func (g *dbfGateStore) arm() {
71 g.mu.Lock()
72 defer g.mu.Unlock()
73 g.open, g.reached, g.once, g.landed = make(chan struct{}), make(chan struct{}), &sync.Once{}, nil
74}
75
76func (g *dbfGateStore) release() {
77 g.mu.Lock()
78 defer g.mu.Unlock()
79 close(g.open)
80}
81
82func (g *dbfGateStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) {
83 g.mu.Lock()
84 open, reached, once := g.open, g.reached, g.once
85 g.mu.Unlock()
86 if open == nil || !(strings.Contains(key, "/out/") || strings.HasSuffix(key, "/result.json")) {
87 return g.Store.Put(ctx, key, body, ifMatch)
88 }
89 once.Do(func() { close(reached) })
90 <-open
91 etag, err := g.Store.Put(ctx, key, body, ifMatch)
92 if err == nil {
93 g.mu.Lock()
94 g.landed = append(g.landed, time.Now())
95 g.mu.Unlock()
96 }
97 return etag, err
98}
99
100func (g *dbfGateStore) landedAfter(t time.Time) (n int, early bool) {
101 g.mu.Lock()
102 defer g.mu.Unlock()
103 for _, at := range g.landed {
104 if !at.After(t) {
105 early = true
106 }
107 }
108 return len(g.landed), early
109}
110
111// TestDBForwardMinio is T1 and T16 against a real S3 server: a console in ie
112// and a box in uk sharing one deployment bucket (versioned), each with its
113// own data bucket and its own credentials, on one endpoint (spec §12). The
114// console's credentials cannot read uk's bucket (a MinIO policy), so a
115// console that read it would fail. Two tenants in uk each get their own
116// rows. Neither the console nor the box ever deletes or replaces anything
117// under db-reads/, and a box's writes after the request ended change
118// nothing. It runs only where .amadan/ci.d/30-minio-contract stands MinIO
119// up; the box is plan 1's real code joined in process (dbreadstest).
120func TestDBForwardMinio(t *testing.T) {
121 if os.Getenv("CARLOS_MINIO_TEST") != "1" {
122 t.Skip("set CARLOS_MINIO_TEST=1 (with a running MinIO and the DBF_* variables) to run; see .amadan/ci.d/30-minio-contract")
123 }
124 need := func(name string) string {
125 v := os.Getenv(name)
126 if v == "" {
127 t.Fatalf("%s is not set", name)
128 }
129 return v
130 }
131 endpoint := need("MINIO_ENDPOINT")
132 rootKey, rootSecret := need("DBF_ROOT_KEY"), need("DBF_ROOT_SECRET")
133 consoleKey, consoleSecret := need("DBF_CONSOLE_KEY"), need("DBF_CONSOLE_SECRET")
134 boxKey, boxSecret := need("DBF_BOX_KEY"), need("DBF_BOX_SECRET")
135 store := func(key, secret, bucket string) *release.S3Store {
136 return &release.S3Store{Bucket: bucket, Client: awss3.New(awss3.Options{
137 Region: "us-east-1", Credentials: credentials.NewStaticCredentialsProvider(key, secret, ""),
138 BaseEndpoint: aws.String(endpoint), UsePathStyle: true,
139 RequestChecksumCalculation: aws.RequestChecksumCalculationWhenRequired,
140 ResponseChecksumValidation: aws.ResponseChecksumValidationWhenRequired,
141 })}
142 }
143 replica := func(key, secret, bucket, prefix string) *lss3.ReplicaClient {
144 c := lss3.NewReplicaClient()
145 c.AccessKeyID, c.SecretAccessKey, c.Region, c.Bucket, c.Path, c.Endpoint, c.ForcePathStyle =
146 key, secret, "us-east-1", bucket, prefix, endpoint, true
147 return c
148 }
149 run := strconv.FormatInt(time.Now().UnixNano(), 36)
150 shop, other := "shop-"+run+".example.com", "other-"+run+".example.com"
151 for host, batches := range map[string]int{shop: 3, other: 1} {
152 dir := t.TempDir()
153 dbdoortest.Replicate(t, dir, batches)
154 uploadReplica(t, lsfile.NewReplicaClient(dir), replica(rootKey, rootSecret, "dbf-data-uk", "instances/"+host))
155 }
156
157 // The policy, shown: the console's credentials cannot list uk's bucket.
158 itr, err := replica(consoleKey, consoleSecret, "dbf-data-uk", "instances/"+shop).LTXFiles(context.Background(), 0, 0, false)
159 if err == nil {
160 for itr.Next() {
161 }
162 err = itr.Err()
163 itr.Close()
164 }
165 if err == nil {
166 t.Fatal("the console's credentials read uk's data bucket: the test proves nothing")
167 }
168
169 deploy := store(consoleKey, consoleSecret, "dbf-deploy")
170 fx := newConsoleFixtureOn(t, true, strictStore{Store: deploy, t: t})
171 fx.srv.cfg.DBForward, fx.srv.cfg.Region, fx.srv.cfg.HibernateBucket = true, "ie", "dbf-data-ie"
172 fx.srv.dbReplica = func(_ context.Context, prefix string) (replicaReader, error) {
173 return replica(consoleKey, consoleSecret, "dbf-data-ie", prefix), nil // the only data bucket this console has
174 }
175 if _, err := fx.st.CreateAppIn(OpsAccountID, "shop", "paul@keymail.dev", time.Now()); err != nil {
176 t.Fatal(err)
177 }
178 if err := fx.st.AddMember(OpsAccountID, "member@example.com", RoleMember, time.Now()); err != nil {
179 t.Fatal(err)
180 }
181 member := cliToken(t, fx, "member@example.com", "code-minio")
182 for _, host := range []string{shop, other} {
183 putShopRecord(t, fx, provision.Record{Host: host, Region: "uk", CreatedAt: time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)})
184 }
185 putEdgesFile(t, fx.rel, "uk-"+run, "uk", []string{dbreads.Capability}, false)
186 putBoxStatus(t, fx.rel, "uk-"+run, time.Now())
187 gate := &dbfGateStore{Store: store(boxKey, boxSecret, "dbf-deploy")}
188 box := dbreadstest.Start(t, dbreadstest.Options{
189 Store: strictStore{Store: gate, t: t}, Region: "uk", Box: "uk-" + run,
190 Pub: fx.pub, HibernatePrefix: "instances",
191 OpenReplica: func(_ context.Context, prefix string) (dbdoor.ReplicaReader, error) {
192 return replica(boxKey, boxSecret, "dbf-data-uk", prefix), nil
193 },
194 })
195 runBox(t, box)
196 dfx := &dbfFixture{consoleFixture: fx, member: member}
197
198 // T1: a forwarded query end to end, for each tenant, each its own rows.
199 for host, want := range map[string]string{shop: "n\n30\n", other: "n\n10\n"} {
200 body, key := queryBody(t, "SELECT count(*) AS n FROM t")
201 res := dfx.openRead(t, host, member, body)
202 lines := readLines(t, res.Body)
203 res.Body.Close()
204 if rows, _ := openAnswer(t, key, lines); string(rows) != want {
205 t.Fatalf("%s: %q, want %q", host, rows, want)
206 }
207 }
208
209 // A pull through MinIO (plan 3): the planned files, sealed as the ltx
210 // stream, adding up to the summary, whose file list ends at its copy.
211 pkey, _ := dbreads.NewReplyKey()
212 pres := dfx.openRead(t, shop, member, dbreads.ReadBody{Op: dbreads.OpPull, ReplyKey: dbreads.EncodeReplyKey(pkey.PublicKey())})
213 plines := readLines(t, pres.Body)
214 pres.Body.Close()
215 presult := plines[len(plines)-1].Result
216 if presult == nil || presult.State != dbreads.StateDone {
217 t.Fatalf("pull: %+v", plines[len(plines)-1])
218 }
219 psum, err := dbreads.OpenSummary(pkey, plines[0].Admitted, presult.Sealed)
220 if err != nil || len(psum.Files) == 0 || psum.TXID != psum.Files[len(psum.Files)-1].MaxTXID {
221 t.Fatalf("pull summary %+v %v", psum, err)
222 }
223 ph := sha256.New()
224 var pn int64
225 for _, l := range plines {
226 if l.Chunk == 0 {
227 continue
228 }
229 plain, err := dbreads.OpenChunk(pkey, plines[0].Admitted, l.Chunk, dbreads.StreamLTX, l.Chunk == presult.LastSeq, l.Data)
230 if err != nil {
231 t.Fatalf("pull chunk %d: %v", l.Chunk, err)
232 }
233 ph.Write(plain)
234 pn += int64(len(plain))
235 }
236 if hex.EncodeToString(ph.Sum(nil)) != psum.SHA256 || pn != psum.Bytes {
237 t.Fatal("the pulled bytes do not add up to the summary")
238 }
239
240 // T16 (plan review finding 19): the real box reaches publication and is
241 // held there; the console's request ends, by disconnect and then by its
242 // bound; only then does the box publish. Its late chunks and result land
243 // after the console ended, change nothing the console sends, and the
244 // strict stores see no delete and no replacing write from either side.
245 late := func(t *testing.T, end func(res *http.Response, br *bufio.Reader)) {
246 gate.arm()
247 body, _ := queryBody(t, "SELECT count(*) AS n FROM t")
248 res := dfx.openRead(t, shop, member, body)
249 br := bufio.NewReader(res.Body)
250 line, _ := br.ReadBytes('\n')
251 var adm dbreads.Line
252 json.Unmarshal(line, &adm)
253 end(res, br)
254 // The handler's last act is the cancel: once it is there, the
255 // console has ended the request.
256 waitKey := func(key string) {
257 deadline := time.Now().Add(60 * time.Second)
258 for {
259 if _, _, err := deploy.Get(context.Background(), key); err == nil {
260 return
261 }
262 if time.Now().After(deadline) {
263 t.Fatalf("no %s", key)
264 }
265 time.Sleep(100 * time.Millisecond)
266 }
267 }
268 waitKey(dbreads.CancelKey(adm.Admitted))
269 consoleEnded := time.Now()
270 select {
271 case <-gate.reached:
272 case <-time.After(60 * time.Second):
273 t.Fatal("the box never reached publication")
274 }
275 if keys, _ := deploy.List(context.Background(), dbreads.OutPrefix(adm.Admitted)); len(keys) != 0 {
276 t.Fatalf("output published before the console ended: %v", keys)
277 }
278 gate.release()
279 waitKey(dbreads.ResultKey(adm.Admitted))
280 // At least the result lands late; a held chunk lands too unless the
281 // owner's cancel watch ended its create first (plan 1's watchCancel).
282 if n, early := gate.landedAfter(consoleEnded); n < 1 || early {
283 t.Fatalf("%d late writes, any before the console ended: %v", n, early)
284 }
285 }
286 t.Run("by disconnect", func(t *testing.T) {
287 late(t, func(res *http.Response, _ *bufio.Reader) {
288 select {
289 case <-gate.reached:
290 case <-time.After(60 * time.Second):
291 t.Fatal("the box never reached publication")
292 }
293 res.Body.Close()
294 })
295 })
296 t.Run("by timeout", func(t *testing.T) {
297 // The request's own bound, well past the box's restore and query of
298 // three batches, well short of the box's 20-minute owner deadline.
299 fx.srv.dbf.bound, fx.srv.dbf.grace = 20*time.Second, 0
300 late(t, func(res *http.Response, br *bufio.Reader) {
301 defer res.Body.Close()
302 lines := readLines(t, br)
303 if last := lines[len(lines)-1]; last.Ended != dbreads.EndedNoResult {
304 t.Fatalf("by timeout: %+v", last)
305 }
306 for _, l := range lines {
307 if l.Chunk != 0 || l.Result != nil {
308 t.Fatalf("a held box's output reached the console: %+v", l)
309 }
310 }
311 })
312 })
313}
314