carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1// Store — the deployment bucket behind the release plane: S3 in production,
2// a plain directory in tests. CAS discipline (If-Match / If-None-Match)
3// ported from Eleven internal/idalloc/idalloc.go (the litestream leaser's
4// conditional-write pattern); not-found folding from Eleven s3blobstore.go.
5package release
6
7import (
8 "bytes"
9 "context"
10 "crypto/sha256"
11 "encoding/hex"
12 "errors"
13 "fmt"
14 "io"
15 "io/fs"
16 "net/http"
17 "os"
18 "path/filepath"
19 "sort"
20 "strings"
21 "sync"
22 "time"
23
24 "github.com/aws/aws-sdk-go-v2/aws"
25 awsconfig "github.com/aws/aws-sdk-go-v2/config"
26 awss3 "github.com/aws/aws-sdk-go-v2/service/s3"
27 "github.com/aws/smithy-go"
28 "github.com/carlosframework/platform/internal/rootaws"
29 "github.com/carlosframework/platform/internal/safefile"
30
31 "github.com/carlosframework/platform/internal/s3compat"
32)
33
34// ErrCASConflict is a conditional Put that lost: the object already existed
35// (ifMatch "") or moved on since the etag was read (ifMatch stale). Callers
36// re-read and retry; they never overwrite blind.
37var ErrCASConflict = errors.New("store: compare-and-swap conflict")
38
39// Store is the deployment bucket. Get's not-found is os.ErrNotExist-wrapped
40// so callers use errors.Is; Put with ifMatch "" is create-only
41// (If-None-Match: *), non-empty is replace-only (If-Match: <etag>), and a
42// lost race is ErrCASConflict either way. Delete is idempotent: deleting a
43// missing key is not an error.
44type Store interface {
45 Get(ctx context.Context, key string) ([]byte, string, error) // body, etag
46 Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) // new etag
47 List(ctx context.Context, prefix string) ([]string, error)
48 // ListDirs returns the immediate child "directories" of prefix — the
49 // delimiter-aware listing, so a caller that only wants the names one
50 // level down does not pay for every object beneath them. Each result is
51 // a FULL prefix ending in "/" (S3 CommonPrefixes semantics), never a
52 // bare name. A prefix that does not end in "/" is treated as though it
53 // did, so the two implementations answer identically. Objects at the
54 // level itself (files, not directories) are never included.
55 ListDirs(ctx context.Context, prefix string) ([]string, error)
56 Delete(ctx context.Context, key string) error
57}
58
59// ---- prefixStore -------------------------------------------------------------
60
61// prefixStore rebases every key a caller works with onto a fixed base before
62// it ever reaches the underlying store — the actual enforcement point for a
63// namespacing env var like CARLOS_STATUS_PREFIX (customer-fleets plan, Task
64// 5: a fleet box's own heartbeat, written through one of these with base
65// "fleets/<name>/", lands at "fleets/<name>/status/boxes/<label>.json"
66// instead of the shared "status/boxes/<label>.json" every platform box also
67// writes — outside the exact prefix status.BuildFleet's loadBoxes lists, and
68// so invisible to the platform's own fleet.json by construction, with no
69// change needed to the status package itself). List/ListDirs strip the base
70// back off their results, so a key one of these hands back still round-trips
71// through the SAME wrapper's Get/Put without doubling the prefix.
72//
73// Exported from internal/release (rather than living next to its first
74// caller in internal/host) because it is a property of Store itself, not of
75// any one reader/writer of one: the console's fleet page (customer-fleets
76// plan, Task 9) needs to read a fleet box's snapshots through this same
77// rebasing, and a console-side package cannot reach an unexported type in
78// internal/host.
79type prefixStore struct {
80 Store
81 base string // always "" or ends in "/"
82}
83
84// BucketListDirs is the optional half of Store that says ListDirs answers in
85// the bucket's own shape: ListDirs("apps/") is the account directories, and
86// ListDirs("apps/<acct>/") that account's app directories, exactly as an S3
87// delimiter listing of the deployment bucket would be. A reader may then walk
88// the tree by its directories instead of listing every key under it.
89//
90// Optional, because not every Store has that shape. The fleet door scopes a
91// box to its own account and answers ListDirs("apps/") with that account's
92// APP directories, and DirStore lists by walking its whole root, so walking
93// it per app is slower than one List. A Store that does not say so is read
94// the old way.
95type BucketListDirs interface {
96 BucketShapedListDirs() bool
97}
98
99// HasBucketListDirs reports whether s promises bucket-shaped ListDirs.
100func HasBucketListDirs(s Store) bool {
101 b, ok := s.(BucketListDirs)
102 return ok && b.BucketShapedListDirs()
103}
104
105// BucketShapedListDirs: rebasing keeps the shape of the store underneath.
106func (p prefixStore) BucketShapedListDirs() bool { return HasBucketListDirs(p.Store) }
107
108// NewPrefixStore wraps s so every key is rebased under prefix. An empty
109// prefix returns s UNCHANGED (not a prefixStore with an empty base) — every
110// deployment that never sets the namespacing env var (every platform box
111// today) must get back the exact same store, a pure no-op, verifiable by
112// interface equality.
113func NewPrefixStore(s Store, prefix string) Store {
114 if prefix == "" {
115 return s
116 }
117 if !strings.HasSuffix(prefix, "/") {
118 prefix += "/"
119 }
120 return prefixStore{Store: s, base: prefix}
121}
122
123func (p prefixStore) full(key string) string { return p.base + key }
124
125func (p prefixStore) Get(ctx context.Context, key string) ([]byte, string, error) {
126 return p.Store.Get(ctx, p.full(key))
127}
128
129// Open is written out, not promoted: the embedded field is the interface,
130// and a promoted Open would forward the UNREBASED key past the prefix.
131func (p prefixStore) Open(ctx context.Context, key string) (io.ReadCloser, string, error) {
132 return OpenObject(ctx, p.Store, p.full(key))
133}
134
135func (p prefixStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) {
136 return p.Store.Put(ctx, p.full(key), body, ifMatch)
137}
138
139// PutFrom is written out, not promoted, for the reason Open is: a promoted
140// method would forward the UNREBASED key.
141func (p prefixStore) PutFrom(ctx context.Context, key string, body io.ReadSeeker, size int64, ifMatch string) (string, error) {
142 return PutObjectFrom(ctx, p.Store, p.full(key), body, size, ifMatch)
143}
144
145func (p prefixStore) Delete(ctx context.Context, key string) error {
146 return p.Store.Delete(ctx, p.full(key))
147}
148
149// List rebases the query prefix the same way Get/Put rebase a key, then
150// strips its own base back off every result — the underlying store's keys
151// always start with p.base (that's what querying under p.full(prefix)
152// guarantees), so TrimPrefix here exactly undoes full() above, and the
153// returned keys round-trip through this wrapper's Get unchanged.
154func (p prefixStore) List(ctx context.Context, prefix string) ([]string, error) {
155 keys, err := p.Store.List(ctx, p.full(prefix))
156 if err != nil {
157 return nil, err
158 }
159 out := make([]string, len(keys))
160 for i, k := range keys {
161 out[i] = strings.TrimPrefix(k, p.base)
162 }
163 return out, nil
164}
165
166// ListDirs mirrors List's rebase-then-strip, for the delimiter-aware listing.
167func (p prefixStore) ListDirs(ctx context.Context, prefix string) ([]string, error) {
168 dirs, err := p.Store.ListDirs(ctx, p.full(prefix))
169 if err != nil {
170 return nil, err
171 }
172 out := make([]string, len(dirs))
173 for i, d := range dirs {
174 out[i] = strings.TrimPrefix(d, p.base)
175 }
176 return out, nil
177}
178
179// A prefixStore deliberately does NOT forward ListMod: it embeds the Store
180// INTERFACE, so only Store's methods are promoted, and a ModLister
181// type-assert on a wrapped store fails — callers fall back to List, which
182// this wrapper does rebase. No prefix-store caller ranks by recency today;
183// forward it here (rebase + strip, like List) before any does.
184
185// KeyMod pairs a listed key with the object's last-modified stamp.
186type KeyMod struct {
187 Key string
188 Mod time.Time
189}
190
191// ModLister is the optional Store extension for listings that carry each
192// object's last-modified time — information both real stores already have in
193// hand (S3 returns it with every ListObjectsV2 entry; DirStore has the file
194// mtime) and List throws away. It exists so a caller ranking objects by
195// recency can decide WHICH bodies to read before reading any: the app page's
196// release list was O(every version ever shipped) in bucket GETs without it.
197// Optional rather than a Store method so the many test fakes that implement
198// Store stay valid; callers type-assert and fall back to List.
199type ModLister interface {
200 ListMod(ctx context.Context, prefix string) ([]KeyMod, error)
201}
202
203// Both real stores answer ListMod; only fakes fall back.
204var (
205 _ ModLister = (*DirStore)(nil)
206 _ ModLister = (*S3Store)(nil)
207)
208
209// ConditionalGetter is the optional conditional read: GetIfChanged is Get
210// unless the object still carries etag, in which case it returns
211// nil, etag, false, nil without the body (S3 answers 304). An empty etag
212// behaves as Get. An absent object returns an os.ErrNotExist-wrapped error.
213// Optional and type-asserted, like ModLister: a store without it is read the
214// old way.
215type ConditionalGetter interface {
216 GetIfChanged(ctx context.Context, key, etag string) (body []byte, newETag string, changed bool, err error)
217}
218
219// KeyETag pairs a listed key with its ETag.
220type KeyETag struct {
221 Key string
222 ETag string
223}
224
225// ETagLister is the optional listing that carries each object's ETag, sorted
226// by key, in the same form Get returns it (S3 keeps its quoting, DirStore its
227// content hash), so a listed ETag can be compared with one read earlier.
228type ETagLister interface {
229 ListETags(ctx context.Context, prefix string) ([]KeyETag, error)
230}
231
232// Both real stores and the prefix wrapper answer both; only fakes fall back.
233var (
234 _ ConditionalGetter = (*DirStore)(nil)
235 _ ConditionalGetter = (*S3Store)(nil)
236 _ ConditionalGetter = prefixStore{}
237 _ ETagLister = (*DirStore)(nil)
238 _ ETagLister = (*S3Store)(nil)
239 _ ETagLister = prefixStore{}
240)
241
242// GetIfChanged rebases the key, like Get. A raw store without the extension
243// answers errors.ErrUnsupported, which callers read as "unknown".
244func (p prefixStore) GetIfChanged(ctx context.Context, key, etag string) ([]byte, string, bool, error) {
245 if g, ok := p.Store.(ConditionalGetter); ok {
246 return g.GetIfChanged(ctx, p.full(key), etag)
247 }
248 return nil, "", false, errors.ErrUnsupported
249}
250
251// ListETags rebases the query prefix and strips the base back off, like List.
252func (p prefixStore) ListETags(ctx context.Context, prefix string) ([]KeyETag, error) {
253 l, ok := p.Store.(ETagLister)
254 if !ok {
255 return nil, errors.ErrUnsupported
256 }
257 got, err := l.ListETags(ctx, p.full(prefix))
258 if err != nil {
259 return nil, err
260 }
261 for i := range got {
262 got[i].Key = strings.TrimPrefix(got[i].Key, p.base)
263 }
264 return got, nil
265}
266
267// KeySize is one object and its byte size.
268type KeySize struct {
269 Key string
270 Size int64
271}
272
273// SizeLister is the optional sized-listing extension the economics storage
274// sweep needs. Like ModLister it is NOT part of Store and NOT forwarded by
275// NewPrefixStore — callers type-assert and degrade ("unswept") when the
276// backing store cannot answer.
277type SizeLister interface {
278 ListSizes(ctx context.Context, prefix string) ([]KeySize, error)
279}
280
281// Both real stores answer ListSizes; only fakes fall back.
282var (
283 _ SizeLister = (*DirStore)(nil)
284 _ SizeLister = (*S3Store)(nil)
285)
286
287// Opener is the optional streaming read. Store.Get returns the whole object
288// in memory, which is fine for a pointer and wrong for a platform binary on
289// a box under MemoryHigh=70%. Implemented by every production store; a test
290// fake that only has Get still works through OpenObject. The etag may be ""
291// (DirStore cannot know it without reading the file).
292type Opener interface {
293 Open(ctx context.Context, key string) (io.ReadCloser, string, error)
294}
295
296var (
297 _ Opener = (*DirStore)(nil)
298
299 _ Opener = (*S3Store)(nil)
300 _ Opener = prefixStore{}
301)
302
303// PutStreamer is the optional streaming write: Store.Put takes the whole
304// body as []byte, which is wrong for a 130 MB GeoIP database on a box
305// under MemoryHigh. Implemented by every production store; a fake with only
306// Put still works through PutObjectFrom for bodies up to PutFromFallbackMax.
307// ifMatch has Put's exact semantics ("" = create-only).
308type PutStreamer interface {
309 PutFrom(ctx context.Context, key string, body io.ReadSeeker, size int64, ifMatch string) (string, error)
310}
311
312var (
313 _ PutStreamer = (*DirStore)(nil)
314 _ PutStreamer = (*S3Store)(nil)
315 _ PutStreamer = prefixStore{}
316)
317
318// PutFromFallbackMax bounds how much PutObjectFrom will buffer for a store
319// with no PutFrom.
320const PutFromFallbackMax = 64 << 20
321
322// ErrTooLargeForPut is PutObjectFrom refusing to buffer a large body.
323var ErrTooLargeForPut = errors.New("store cannot stream and the body is too large to buffer")
324
325// PutObjectFrom streams body into s at key.
326func PutObjectFrom(ctx context.Context, s Store, key string, body io.ReadSeeker, size int64, ifMatch string) (string, error) {
327 if ps, ok := s.(PutStreamer); ok {
328 return ps.PutFrom(ctx, key, body, size, ifMatch)
329 }
330 if size > PutFromFallbackMax {
331 return "", fmt.Errorf("%s: %w", key, ErrTooLargeForPut)
332 }
333 b, err := io.ReadAll(io.LimitReader(body, size+1))
334 if err != nil {
335 return "", err
336 }
337 if int64(len(b)) != size {
338 return "", fmt.Errorf("%s: body is %d bytes, want %d", key, len(b), size)
339 }
340 return s.Put(ctx, key, b, ifMatch)
341}
342
343type streamingReadKey struct{}
344
345// OpenObjectStreaming refuses Get fallbacks, including inside wrappers that
346// forward this context through OpenObject. Callers must still bound their reads.
347func OpenObjectStreaming(ctx context.Context, s Store, key string) (io.ReadCloser, string, error) {
348 return OpenObject(context.WithValue(ctx, streamingReadKey{}, true), s, key)
349}
350
351// OpenObject streams key from s, buffering through Get when s cannot stream.
352func OpenObject(ctx context.Context, s Store, key string) (io.ReadCloser, string, error) {
353 if o, ok := s.(Opener); ok {
354 return o.Open(ctx, key)
355 }
356 if required, _ := ctx.Value(streamingReadKey{}).(bool); required {
357 return nil, "", errors.ErrUnsupported
358 }
359 b, etag, err := s.Get(ctx, key)
360 if err != nil {
361 return nil, "", err
362 }
363 return io.NopCloser(bytes.NewReader(b)), etag, nil
364}
365
366// PageLister is the optional BOUNDED listing. List, ListMod and ListSizes all
367// return every key under a prefix, which is right for a sweep that wants the
368// whole picture and wrong for a queue drain that wants the oldest few.
369//
370// The scan queue is the caller. It caps how many items it handles per app per
371// tick, and without a bounded listing it would enumerate a flooded app's whole
372// queue every 30 seconds regardless of that cap — paying for the listing it
373// deliberately declined to act on.
374//
375// Optional and type-asserted, like ModLister and SizeLister above, NOT a Store
376// method: Store has eleven implementations across the real stores, the prefix
377// wrapper, fleetapi and seven test fakes, and a new method would break all of
378// them for one caller's benefit. Callers degrade to List.
379//
380// startAfter is EXCLUSIVE and keys come back sorted, so paging is
381// "startAfter = the last key I saw". limit <= 0 means no bound.
382type PageLister interface {
383 ListPage(ctx context.Context, prefix, startAfter string, limit int) ([]string, error)
384}
385
386// Both real stores answer ListPage; only fakes fall back.
387var (
388 _ PageLister = (*DirStore)(nil)
389 _ PageLister = (*S3Store)(nil)
390)
391
392// ---- DirStore ---------------------------------------------------------------
393
394// DirStore keeps objects as files under a root directory — the test double
395// and the single-box no-AWS deployment shape. The etag is the hex sha256 of
396// the content (content-derived, so it survives process restarts with no
397// sidecar files). CAS is enforced under one process-wide mutex plus
398// temp-file + rename writes: single-process fidelity, which is what the
399// tests and a one-box deployment need.
400type DirStore struct {
401 root string
402 mu sync.Mutex
403 changeIndex bool // EnableChangeIndex: bump control/changes.json after each mutation
404}
405
406func NewDirStore(root string) *DirStore { return &DirStore{root: root} }
407
408// path maps a bucket key onto the root dir, refusing traversal — a key is
409// bucket data, never a filesystem instruction.
410func (d *DirStore) path(key string) (string, error) {
411 if key == "" || strings.HasPrefix(key, "/") {
412 return "", fmt.Errorf("dirstore: invalid key %q", key)
413 }
414 for _, seg := range strings.Split(key, "/") {
415 if seg == "" || seg == "." || seg == ".." {
416 return "", fmt.Errorf("dirstore: invalid key %q", key)
417 }
418 }
419 return filepath.Join(d.root, filepath.FromSlash(key)), nil
420}
421
422// hashFile is etagOf over a file, streamed.
423func hashFile(p string) (string, error) {
424 f, err := safefile.OpenFile(p, os.O_RDONLY, 0)
425 if err != nil {
426 return "", err
427 }
428 defer f.Close()
429 h := sha256.New()
430 if _, err := io.Copy(h, f); err != nil {
431 return "", err
432 }
433 return hex.EncodeToString(h.Sum(nil)), nil
434}
435
436func etagOf(body []byte) string {
437 sum := sha256.Sum256(body)
438 return hex.EncodeToString(sum[:])
439}
440
441func (d *DirStore) Get(_ context.Context, key string) ([]byte, string, error) {
442 p, err := d.path(key)
443 if err != nil {
444 return nil, "", err
445 }
446 d.mu.Lock()
447 defer d.mu.Unlock()
448 b, err := safefile.ReadFile(p)
449 if err != nil {
450 if errors.Is(err, fs.ErrNotExist) {
451 return nil, "", fmt.Errorf("dirstore: %s: %w", key, os.ErrNotExist)
452 }
453 return nil, "", err
454 }
455 return b, etagOf(b), nil
456}
457
458func (d *DirStore) Open(_ context.Context, key string) (io.ReadCloser, string, error) {
459 p, err := d.path(key)
460 if err != nil {
461 return nil, "", err
462 }
463 f, err := safefile.OpenFile(p, os.O_RDONLY, 0)
464 if err != nil {
465 if errors.Is(err, os.ErrNotExist) {
466 return nil, "", fmt.Errorf("dirstore: %s: %w", key, os.ErrNotExist)
467 }
468 return nil, "", err
469 }
470 return f, "", nil
471}
472
473// EnableChangeIndex makes every successful Put, PutFrom, Delete and
474// DeleteIfMatch inside a change scope bump control/changes.json afterwards
475// (design §3.3). Call it before the store is shared; it is not synchronised.
476func (d *DirStore) EnableChangeIndex() { d.changeIndex = true }
477
478func (d *DirStore) bump(ctx context.Context, key string) {
479 bumpAfterMutation(ctx, d.changeIndex, key, d.Get, d.putRaw)
480}
481
482func (d *DirStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) {
483 etag, err := d.putRaw(ctx, key, body, ifMatch)
484 if err == nil {
485 d.bump(ctx, key)
486 }
487 return etag, err
488}
489
490func (d *DirStore) putRaw(_ context.Context, key string, body []byte, ifMatch string) (string, error) {
491 p, err := d.path(key)
492 if err != nil {
493 return "", err
494 }
495 d.mu.Lock()
496 defer d.mu.Unlock()
497 // The CAS gate, mirroring S3's semantics exactly: "" = create-only,
498 // non-empty = the object must still carry that etag. Stat and a
499 // streamed hash, as in PutFrom, so an oversized object at the key is
500 // never read into memory.
501 _, err = os.Stat(p)
502 exists := err == nil
503 if err != nil && !errors.Is(err, fs.ErrNotExist) {
504 return "", err
505 }
506 if ifMatch == "" && exists {
507 return "", fmt.Errorf("dirstore: %s exists: %w", key, ErrCASConflict)
508 }
509 if ifMatch != "" {
510 cur := ""
511 if exists {
512 if cur, err = hashFile(p); err != nil {
513 return "", err
514 }
515 }
516 if !exists || cur != ifMatch {
517 return "", fmt.Errorf("dirstore: %s moved on: %w", key, ErrCASConflict)
518 }
519 }
520 if err := safefile.MkdirAll(filepath.Dir(p), 0o755); err != nil {
521 return "", err
522 }
523 if err := safefile.WriteFile(p, body, 0o644); err != nil {
524 return "", err
525 }
526 return etagOf(body), nil
527}
528
529// PutFrom is Put streamed: the body goes to a temp file (hashed as it
530// goes, so the etag is the same content sha256 Put would give) and is
531// renamed into place under d.mu after the same CAS gate.
532func (d *DirStore) PutFrom(ctx context.Context, key string, body io.ReadSeeker, size int64, ifMatch string) (string, error) {
533 etag, err := d.putFromRaw(ctx, key, body, size, ifMatch)
534 if err == nil {
535 d.bump(ctx, key)
536 }
537 return etag, err
538}
539
540func (d *DirStore) putFromRaw(_ context.Context, key string, body io.ReadSeeker, size int64, ifMatch string) (string, error) {
541 p, err := d.path(key)
542 if err != nil {
543 return "", err
544 }
545 if err := safefile.MkdirAll(filepath.Dir(p), 0o755); err != nil {
546 return "", err
547 }
548 d.mu.Lock()
549 defer d.mu.Unlock()
550 // The CAS gate without reading the existing object into memory (it may
551 // be 1 GiB): Stat for create-only, a streamed hash for replace-only.
552 _, err = os.Stat(p)
553 exists := err == nil
554 if err != nil && !errors.Is(err, fs.ErrNotExist) {
555 return "", err
556 }
557 if ifMatch == "" && exists {
558 return "", fmt.Errorf("dirstore: %s exists: %w", key, ErrCASConflict)
559 }
560 if ifMatch != "" {
561 cur := ""
562 if exists {
563 if cur, err = hashFile(p); err != nil {
564 return "", err
565 }
566 }
567 if !exists || cur != ifMatch {
568 return "", fmt.Errorf("dirstore: %s moved on: %w", key, ErrCASConflict)
569 }
570 }
571 h := sha256.New()
572 err = safefile.Write(p, 0o644, func(w io.Writer) error {
573 n, err := io.Copy(io.MultiWriter(w, h), io.LimitReader(body, size+1))
574 if err != nil {
575 return err
576 }
577 if n != size {
578 return fmt.Errorf("dirstore: %s: body is %d bytes, want %d", key, n, size)
579 }
580 return nil
581 })
582 if err != nil {
583 return "", err
584 }
585 return hex.EncodeToString(h.Sum(nil)), nil
586}
587
588// List is ListMod minus the mtimes — one walk owns the prefix filter and the
589// .tmp exclusion, so the two listings cannot drift.
590func (d *DirStore) List(ctx context.Context, prefix string) ([]string, error) {
591 kms, err := d.ListMod(ctx, prefix)
592 out := make([]string, len(kms))
593 for i, km := range kms {
594 out[i] = km.Key
595 }
596 return out, err
597}
598
599// ListPage is the bounded listing. It leans on ListMod being key-sorted, so
600// "after" and "limit" are a filter and a slice rather than a second walk.
601func (d *DirStore) ListPage(ctx context.Context, prefix, startAfter string, limit int) ([]string, error) {
602 kms, err := d.ListMod(ctx, prefix)
603 if err != nil {
604 return nil, err
605 }
606 out := make([]string, 0, len(kms))
607 for _, km := range kms {
608 if km.Key <= startAfter {
609 continue
610 }
611 out = append(out, km.Key)
612 if limit > 0 && len(out) == limit {
613 break
614 }
615 }
616 return out, nil
617}
618
619// ListMod is the listing with each file's mtime alongside, key-sorted.
620func (d *DirStore) ListMod(_ context.Context, prefix string) ([]KeyMod, error) {
621 d.mu.Lock()
622 defer d.mu.Unlock()
623 var out []KeyMod
624 err := filepath.WalkDir(d.root, func(p string, de fs.DirEntry, err error) error {
625 if err != nil {
626 if errors.Is(err, fs.ErrNotExist) {
627 return nil // an empty store lists empty, like an empty bucket
628 }
629 return err
630 }
631 if de.IsDir() && strings.HasPrefix(de.Name(), ".carlos-write-") {
632 return filepath.SkipDir
633 }
634 if de.IsDir() || strings.HasSuffix(p, ".tmp") {
635 return nil
636 }
637 rel, err := filepath.Rel(d.root, p)
638 if err != nil {
639 return err
640 }
641 key := filepath.ToSlash(rel)
642 if !strings.HasPrefix(key, prefix) {
643 return nil
644 }
645 info, err := de.Info()
646 if err != nil {
647 // Vanished between readdir and stat — another process's delete
648 // racing this walk (in-process writers all hold d.mu). A file
649 // that is gone is not listed; it does not break the listing.
650 // Untestable deterministically, so pinned by comment alone.
651 if errors.Is(err, fs.ErrNotExist) {
652 return nil
653 }
654 return err
655 }
656 out = append(out, KeyMod{Key: key, Mod: info.ModTime()})
657 return nil
658 })
659 sort.Slice(out, func(i, j int) bool { return out[i].Key < out[j].Key })
660 return out, err
661}
662
663// GetIfChanged is Get, except an object whose content hash is etag is not
664// returned.
665func (d *DirStore) GetIfChanged(ctx context.Context, key, etag string) ([]byte, string, bool, error) {
666 b, e, err := d.Get(ctx, key)
667 if err != nil {
668 return nil, "", false, err
669 }
670 if etag != "" && e == etag {
671 return nil, etag, false, nil
672 }
673 return b, e, true, nil
674}
675
676// ListETags is ListMod's walk with each file's content hash alongside,
677// key-sorted. The hash is the one Get returns.
678func (d *DirStore) ListETags(_ context.Context, prefix string) ([]KeyETag, error) {
679 d.mu.Lock()
680 defer d.mu.Unlock()
681 var out []KeyETag
682 err := filepath.WalkDir(d.root, func(p string, de fs.DirEntry, err error) error {
683 if err != nil {
684 if errors.Is(err, fs.ErrNotExist) {
685 return nil // an empty store lists empty, like an empty bucket
686 }
687 return err
688 }
689 if de.IsDir() && strings.HasPrefix(de.Name(), ".carlos-write-") {
690 return filepath.SkipDir
691 }
692 if de.IsDir() || strings.HasSuffix(p, ".tmp") {
693 return nil
694 }
695 rel, err := filepath.Rel(d.root, p)
696 if err != nil {
697 return err
698 }
699 key := filepath.ToSlash(rel)
700 if !strings.HasPrefix(key, prefix) {
701 return nil
702 }
703 etag, err := hashFile(p)
704 if err != nil {
705 if errors.Is(err, fs.ErrNotExist) {
706 return nil // deleted by another process mid-walk: not listed
707 }
708 return err
709 }
710 out = append(out, KeyETag{Key: key, ETag: etag})
711 return nil
712 })
713 sort.Slice(out, func(i, j int) bool { return out[i].Key < out[j].Key })
714 return out, err
715}
716
717// ListSizes mirrors ListMod's walk, collecting each file's byte size instead
718// of its mtime.
719func (d *DirStore) ListSizes(_ context.Context, prefix string) ([]KeySize, error) {
720 d.mu.Lock()
721 defer d.mu.Unlock()
722 var out []KeySize
723 err := filepath.WalkDir(d.root, func(p string, de fs.DirEntry, err error) error {
724 if err != nil {
725 if errors.Is(err, fs.ErrNotExist) {
726 return nil // an empty store lists empty, like an empty bucket
727 }
728 return err
729 }
730 if de.IsDir() && strings.HasPrefix(de.Name(), ".carlos-write-") {
731 return filepath.SkipDir
732 }
733 if de.IsDir() || strings.HasSuffix(p, ".tmp") {
734 return nil
735 }
736 rel, err := filepath.Rel(d.root, p)
737 if err != nil {
738 return err
739 }
740 key := filepath.ToSlash(rel)
741 if !strings.HasPrefix(key, prefix) {
742 return nil
743 }
744 info, err := de.Info()
745 if err != nil {
746 // Vanished between readdir and stat — another process's delete
747 // racing this walk (in-process writers all hold d.mu). A file
748 // that is gone is not listed; it does not break the listing.
749 // Untestable deterministically, so pinned by comment alone.
750 if errors.Is(err, fs.ErrNotExist) {
751 return nil
752 }
753 return err
754 }
755 out = append(out, KeySize{Key: key, Size: info.Size()})
756 return nil
757 })
758 sort.Slice(out, func(i, j int) bool { return out[i].Key < out[j].Key })
759 return out, err
760}
761
762// ListDirs is DirStore's delimiter-aware listing: one directory read, no
763// walk. A missing directory lists empty, like a bucket prefix with nothing
764// under it. Only directories are returned, so a file sitting at this level
765// (ledgers.json among them) is never mistaken for a child prefix.
766func (d *DirStore) ListDirs(_ context.Context, prefix string) ([]string, error) {
767 if prefix != "" && !strings.HasSuffix(prefix, "/") {
768 prefix += "/"
769 }
770 dir := d.root
771 if trimmed := strings.TrimSuffix(prefix, "/"); trimmed != "" {
772 p, err := d.path(trimmed)
773 if err != nil {
774 return nil, err
775 }
776 dir = p
777 }
778 d.mu.Lock()
779 defer d.mu.Unlock()
780 ents, err := os.ReadDir(dir)
781 if err != nil {
782 if errors.Is(err, fs.ErrNotExist) {
783 return nil, nil
784 }
785 return nil, err
786 }
787 var out []string
788 for _, e := range ents {
789 if !e.IsDir() || strings.HasPrefix(e.Name(), ".carlos-write-") {
790 continue
791 }
792 out = append(out, prefix+e.Name()+"/")
793 }
794 sort.Strings(out)
795 return out, nil
796}
797
798func (d *DirStore) Delete(ctx context.Context, key string) error {
799 err := d.deleteRaw(ctx, key)
800 if err == nil {
801 d.bump(ctx, key)
802 }
803 return err
804}
805
806func (d *DirStore) deleteRaw(_ context.Context, key string) error {
807 p, err := d.path(key)
808 if err != nil {
809 return err
810 }
811 d.mu.Lock()
812 defer d.mu.Unlock()
813 if err := safefile.Remove(p); err != nil {
814 if errors.Is(err, os.ErrNotExist) {
815 return nil // idempotent: missing key is not an error
816 }
817 return err
818 }
819 return nil
820}
821
822// ---- S3Store ----------------------------------------------------------------
823
824// S3Store is the production bucket via aws-sdk-go-v2, with the conditional
825// headers doing the CAS work server-side. Compiles always; exercised live
826// only when CARLOS_TEST_S3_BUCKET is set (see release_test.go).
827type S3Store struct {
828 Client *awss3.Client
829 Bucket string
830
831 changeIndex bool // EnableChangeIndex: bump control/changes.json after each mutation
832}
833
834// NewS3Store uses the standard AWS chain for non-root callers. Root never loads
835// shared profiles; rootaws admits only safe static off-cloud credentials files.
836// Everything that makes a non-AWS store work — CARLOS_S3_ENDPOINT, path-style
837// addressing, when-required checksums, and any per-provider quirk — now lives
838// in internal/s3compat, so this store and the hibernation lease cannot drift
839// apart against the same bucket. See that package for the quirk table.
840func NewS3Store(ctx context.Context, bucket string) (*S3Store, error) {
841 return newS3Store(ctx, bucket)
842}
843
844// NewS3StoreIn is NewS3Store with the bucket's region pinned: a box's or a
845// console's data bucket is named with its region beside it
846// (CARLOS_HIBERNATE_REGION), and the default chain's region may be
847// another. An empty region is NewS3Store.
848func NewS3StoreIn(ctx context.Context, bucket, region string) (*S3Store, error) {
849 if region == "" {
850 return newS3Store(ctx, bucket)
851 }
852 return newS3Store(ctx, bucket, awsconfig.WithRegion(region))
853}
854
855func newS3Store(ctx context.Context, bucket string, opts ...func(*awsconfig.LoadOptions) error) (*S3Store, error) {
856 cfg, err := rootaws.LoadDefaultConfig(ctx, opts...)
857 if err != nil {
858 return nil, fmt.Errorf("s3store: load aws config: %w", err)
859 }
860 client := awss3.NewFromConfig(cfg, s3compat.Options(), func(o *awss3.Options) {
861 // s3compat sets these too, but only under a custom endpoint. This
862 // store has always set them on EVERY client including stock AWS, and
863 // the flagship runs on stock AWS — so the unconditional form stays
864 // here rather than becoming a silent behaviour change on the one
865 // deployment that cannot be tested from a compatible store.
866 o.RequestChecksumCalculation = aws.RequestChecksumCalculationWhenRequired
867 o.ResponseChecksumValidation = aws.ResponseChecksumValidationWhenRequired
868 })
869 return &S3Store{Client: client, Bucket: bucket}, nil
870}
871
872// s3NotFound folds the shapes a missing object answers with (NoSuchKey on
873// GET, NotFound on HEAD, bare 404s from S3-compatibles) into one test.
874func s3NotFound(err error) bool {
875 var apiErr smithy.APIError
876 if errors.As(err, &apiErr) {
877 switch apiErr.ErrorCode() {
878 case "NoSuchKey", "NotFound", "404":
879 return true
880 }
881 }
882 return false
883}
884
885// s3CASConflict reports whether an error is a conditional-write loss: 412
886// (If-Match / If-None-Match precondition failed) or 409 (S3's
887// ConditionalRequestConflict when two conditional writes race). Matched by
888// both the S3 error code and the raw HTTP status, so an S3-compatible that
889// only sets one of them still classifies correctly.
890func s3CASConflict(err error) bool {
891 var apiErr smithy.APIError
892 if errors.As(err, &apiErr) {
893 switch apiErr.ErrorCode() {
894 case "PreconditionFailed", "ConditionalRequestConflict":
895 return true
896 }
897 }
898 var httpErr interface{ HTTPStatusCode() int }
899 if errors.As(err, &httpErr) {
900 switch httpErr.HTTPStatusCode() {
901 case http.StatusPreconditionFailed, http.StatusConflict:
902 return true
903 }
904 }
905 return false
906}
907
908func (s *S3Store) Get(ctx context.Context, key string) ([]byte, string, error) {
909 out, err := s.Client.GetObject(ctx, &awss3.GetObjectInput{Bucket: &s.Bucket, Key: &key})
910 if err != nil {
911 if s3NotFound(err) {
912 return nil, "", fmt.Errorf("s3store: %s: %w", key, os.ErrNotExist)
913 }
914 return nil, "", err
915 }
916 defer out.Body.Close()
917 b, err := io.ReadAll(out.Body)
918 if err != nil {
919 return nil, "", err
920 }
921 return b, aws.ToString(out.ETag), nil
922}
923
924func (s *S3Store) Open(ctx context.Context, key string) (io.ReadCloser, string, error) {
925 out, err := s.Client.GetObject(ctx, &awss3.GetObjectInput{Bucket: &s.Bucket, Key: &key})
926 if err != nil {
927 if s3NotFound(err) {
928 return nil, "", fmt.Errorf("s3store: %s: %w", key, os.ErrNotExist)
929 }
930 return nil, "", err
931 }
932 return out.Body, aws.ToString(out.ETag), nil
933}
934
935// EnableChangeIndex makes every successful Put, PutFrom, Delete and
936// DeleteIfMatch inside a change scope bump control/changes.json afterwards
937// (design §3.3). Call it before the store is shared; it is not synchronised.
938func (s *S3Store) EnableChangeIndex() { s.changeIndex = true }
939
940func (s *S3Store) bump(ctx context.Context, key string) {
941 bumpAfterMutation(ctx, s.changeIndex, key, s.Get, s.putRaw)
942}
943
944// NewDeploymentS3Store is NewS3Store for the deployment bucket: the returned
945// store bumps the change index after each mutation. Stores over any other
946// bucket keep using NewS3Store.
947func NewDeploymentS3Store(ctx context.Context, bucket string) (*S3Store, error) {
948 s, err := NewS3Store(ctx, bucket)
949 if err != nil {
950 return nil, err
951 }
952 s.EnableChangeIndex()
953 return s, nil
954}
955
956// NewDeploymentS3StoreIn is NewS3StoreIn with the change index enabled.
957func NewDeploymentS3StoreIn(ctx context.Context, bucket, region string) (*S3Store, error) {
958 s, err := NewS3StoreIn(ctx, bucket, region)
959 if err != nil {
960 return nil, err
961 }
962 s.EnableChangeIndex()
963 return s, nil
964}
965
966func (s *S3Store) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) {
967 etag, err := s.putRaw(ctx, key, body, ifMatch)
968 if err == nil {
969 s.bump(ctx, key)
970 }
971 return etag, err
972}
973
974func (s *S3Store) putRaw(ctx context.Context, key string, body []byte, ifMatch string) (string, error) {
975 in := &awss3.PutObjectInput{
976 Bucket: &s.Bucket,
977 Key: &key,
978 Body: bytes.NewReader(body),
979 ContentLength: aws.Int64(int64(len(body))),
980 }
981 if ifMatch == "" {
982 in.IfNoneMatch = aws.String("*") // create-only: 412 if someone created it first
983 } else {
984 in.IfMatch = aws.String(ifMatch) // replace-only: 412 if the object moved on
985 }
986 out, err := s.Client.PutObject(ctx, in)
987 if err != nil {
988 // A replace-only write whose object has been DELETED under it comes
989 // back 404/NoSuchKey, not 412: there is no etag left to fail the
990 // precondition against. That is still a lost conditional write — the
991 // caller decided this body from bytes that no longer exist — so it
992 // classifies as a CAS conflict rather than escaping as an opaque
993 // transport error, which is what made a concurrently-deleted instance
994 // record answer 502 ("could not update — try again", forever) instead
995 // of the 409 the door already handles. Scoped to the ifMatch branch on
996 // purpose: on a create-only write (IfNoneMatch:*) a 404 is not an
997 // expected answer at all and must not be masked into a conflict.
998 if s3CASConflict(err) || (ifMatch != "" && s3NotFound(err)) {
999 return "", fmt.Errorf("s3store: %s: %w", key, ErrCASConflict)
1000 }
1001 return "", err
1002 }
1003 return aws.ToString(out.ETag), nil
1004}
1005
1006// PutFrom is Put with a streamed, seekable body and an explicit length.
1007func (s *S3Store) PutFrom(ctx context.Context, key string, body io.ReadSeeker, size int64, ifMatch string) (string, error) {
1008 etag, err := s.putFromRaw(ctx, key, body, size, ifMatch)
1009 if err == nil {
1010 s.bump(ctx, key)
1011 }
1012 return etag, err
1013}
1014
1015func (s *S3Store) putFromRaw(ctx context.Context, key string, body io.ReadSeeker, size int64, ifMatch string) (string, error) {
1016 in := &awss3.PutObjectInput{
1017 Bucket: &s.Bucket,
1018 Key: &key,
1019 Body: body,
1020 ContentLength: aws.Int64(size),
1021 }
1022 if ifMatch == "" {
1023 in.IfNoneMatch = aws.String("*")
1024 } else {
1025 in.IfMatch = aws.String(ifMatch)
1026 }
1027 out, err := s.Client.PutObject(ctx, in)
1028 if err != nil {
1029 if s3CASConflict(err) || (ifMatch != "" && s3NotFound(err)) {
1030 return "", fmt.Errorf("s3store: %s: %w", key, ErrCASConflict)
1031 }
1032 return "", err
1033 }
1034 return aws.ToString(out.ETag), nil
1035}
1036
1037// List is ListMod minus the stamps — one paginator loop serves both.
1038func (s *S3Store) List(ctx context.Context, prefix string) ([]string, error) {
1039 kms, err := s.ListMod(ctx, prefix)
1040 if err != nil {
1041 return nil, err
1042 }
1043 out := make([]string, len(kms))
1044 for i, km := range kms {
1045 out[i] = km.Key
1046 }
1047 return out, nil
1048}
1049
1050// ListPage is the bounded listing, and unlike its siblings it does NOT
1051// paginate: one request, at most limit keys, which is the whole point.
1052//
1053// S3 returns keys in lexicographic order and StartAfter is exclusive, so this
1054// maps onto the API directly.
1055func (s *S3Store) ListPage(ctx context.Context, prefix, startAfter string, limit int) ([]string, error) {
1056 in := &awss3.ListObjectsV2Input{Bucket: &s.Bucket, Prefix: &prefix}
1057 if startAfter != "" {
1058 in.StartAfter = &startAfter
1059 }
1060 if limit <= 0 {
1061 // "No bound" has to MEAN no bound. One ListObjectsV2 returns at most
1062 // a thousand keys and reports IsTruncated; a version of this that
1063 // ignored that would answer a caller asking for everything with the
1064 // first page and no hint there was more — and DirStore, which really
1065 // does return everything, would disagree with it silently. The
1066 // bounded case below is deliberately still one request: that is the
1067 // whole point of the interface.
1068 var keys []string
1069 p := awss3.NewListObjectsV2Paginator(s.Client, in)
1070 for p.HasMorePages() {
1071 page, err := p.NextPage(ctx)
1072 if err != nil {
1073 return nil, err
1074 }
1075 for _, o := range page.Contents {
1076 keys = append(keys, aws.ToString(o.Key))
1077 }
1078 }
1079 return keys, nil
1080 }
1081 n := int32(limit)
1082 in.MaxKeys = &n
1083 out, err := s.Client.ListObjectsV2(ctx, in)
1084 if err != nil {
1085 return nil, err
1086 }
1087 keys := make([]string, 0, len(out.Contents))
1088 for _, o := range out.Contents {
1089 keys = append(keys, aws.ToString(o.Key))
1090 }
1091 return keys, nil
1092}
1093
1094// ListMod is the listing keeping the LastModified S3 already sends with
1095// every entry.
1096func (s *S3Store) ListMod(ctx context.Context, prefix string) ([]KeyMod, error) {
1097 var out []KeyMod
1098 p := awss3.NewListObjectsV2Paginator(s.Client, &awss3.ListObjectsV2Input{
1099 Bucket: &s.Bucket,
1100 Prefix: &prefix,
1101 })
1102 for p.HasMorePages() {
1103 page, err := p.NextPage(ctx)
1104 if err != nil {
1105 return nil, err
1106 }
1107 for _, o := range page.Contents {
1108 out = append(out, KeyMod{Key: aws.ToString(o.Key), Mod: aws.ToTime(o.LastModified)})
1109 }
1110 }
1111 return out, nil
1112}
1113
1114// s3NotModified recognises the 304 a conditional GET answers with. The SDK
1115// surfaces it as an error: by HTTP status, or by the NotModified code an
1116// S3-compatible may set instead.
1117func s3NotModified(err error) bool {
1118 var httpErr interface{ HTTPStatusCode() int }
1119 if errors.As(err, &httpErr) && httpErr.HTTPStatusCode() == http.StatusNotModified {
1120 return true
1121 }
1122 var apiErr smithy.APIError
1123 if errors.As(err, &apiErr) {
1124 switch apiErr.ErrorCode() {
1125 case "NotModified", "304":
1126 return true
1127 }
1128 }
1129 return false
1130}
1131
1132// GetIfChanged sends If-None-Match, so an unchanged object costs a 304 and no
1133// body.
1134func (s *S3Store) GetIfChanged(ctx context.Context, key, etag string) ([]byte, string, bool, error) {
1135 in := &awss3.GetObjectInput{Bucket: &s.Bucket, Key: &key}
1136 if etag != "" {
1137 in.IfNoneMatch = &etag
1138 }
1139 out, err := s.Client.GetObject(ctx, in)
1140 if err != nil {
1141 if etag != "" && s3NotModified(err) {
1142 return nil, etag, false, nil
1143 }
1144 if s3NotFound(err) {
1145 return nil, "", false, fmt.Errorf("s3store: %s: %w", key, os.ErrNotExist)
1146 }
1147 return nil, "", false, err
1148 }
1149 defer out.Body.Close()
1150 b, err := io.ReadAll(out.Body)
1151 if err != nil {
1152 return nil, "", false, err
1153 }
1154 return b, aws.ToString(out.ETag), true, nil
1155}
1156
1157// ListETags pages ListObjectsV2 and keeps each ETag exactly as the listing
1158// sends it, which is the form GetObject reports.
1159func (s *S3Store) ListETags(ctx context.Context, prefix string) ([]KeyETag, error) {
1160 var out []KeyETag
1161 p := awss3.NewListObjectsV2Paginator(s.Client, &awss3.ListObjectsV2Input{
1162 Bucket: &s.Bucket,
1163 Prefix: &prefix,
1164 })
1165 for p.HasMorePages() {
1166 page, err := p.NextPage(ctx)
1167 if err != nil {
1168 return nil, err
1169 }
1170 for _, o := range page.Contents {
1171 out = append(out, KeyETag{Key: aws.ToString(o.Key), ETag: aws.ToString(o.ETag)})
1172 }
1173 }
1174 return out, nil
1175}
1176
1177// ListSizes mirrors ListMod's paginator loop, collecting each object's byte
1178// size instead of its LastModified stamp.
1179func (s *S3Store) ListSizes(ctx context.Context, prefix string) ([]KeySize, error) {
1180 var out []KeySize
1181 p := awss3.NewListObjectsV2Paginator(s.Client, &awss3.ListObjectsV2Input{
1182 Bucket: &s.Bucket,
1183 Prefix: &prefix,
1184 })
1185 for p.HasMorePages() {
1186 page, err := p.NextPage(ctx)
1187 if err != nil {
1188 return nil, err
1189 }
1190 for _, o := range page.Contents {
1191 out = append(out, KeySize{Key: aws.ToString(o.Key), Size: aws.ToInt64(o.Size)})
1192 }
1193 }
1194 return out, nil
1195}
1196
1197// BucketShapedListDirs: S3Store IS the deployment bucket, and its ListDirs
1198// is the delimiter listing of it.
1199func (s *S3Store) BucketShapedListDirs() bool { return true }
1200
1201// ListDirs is the Delimiter:"/" listing: S3 rolls everything below one level
1202// up into a CommonPrefix, so the response is bounded by the number of child
1203// prefixes rather than by the total object count beneath them. Objects AT
1204// this level come back in Contents, which this deliberately ignores.
1205func (s *S3Store) ListDirs(ctx context.Context, prefix string) ([]string, error) {
1206 if prefix != "" && !strings.HasSuffix(prefix, "/") {
1207 prefix += "/"
1208 }
1209 var out []string
1210 p := awss3.NewListObjectsV2Paginator(s.Client, &awss3.ListObjectsV2Input{
1211 Bucket: &s.Bucket,
1212 Prefix: &prefix,
1213 Delimiter: aws.String("/"),
1214 })
1215 for p.HasMorePages() {
1216 page, err := p.NextPage(ctx)
1217 if err != nil {
1218 return nil, err
1219 }
1220 for _, cp := range page.CommonPrefixes {
1221 out = append(out, aws.ToString(cp.Prefix))
1222 }
1223 }
1224 sort.Strings(out)
1225 return out, nil
1226}
1227
1228func (s *S3Store) Delete(ctx context.Context, key string) error {
1229 err := s.deleteRaw(ctx, key)
1230 if err == nil {
1231 s.bump(ctx, key)
1232 }
1233 return err
1234}
1235
1236func (s *S3Store) deleteRaw(ctx context.Context, key string) error {
1237 _, err := s.Client.DeleteObject(ctx, &awss3.DeleteObjectInput{
1238 Bucket: aws.String(s.Bucket),
1239 Key: aws.String(key),
1240 })
1241 if err != nil {
1242 return err
1243 }
1244 return nil
1245}
1246