| 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. |
| 5 | package release |
| 6 | |
| 7 | import ( |
| 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. |
| 37 | var 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. |
| 44 | type 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. |
| 79 | type 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. |
| 95 | type BucketListDirs interface { |
| 96 | BucketShapedListDirs() bool |
| 97 | } |
| 98 | |
| 99 | // HasBucketListDirs reports whether s promises bucket-shaped ListDirs. |
| 100 | func 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. |
| 106 | func (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. |
| 113 | func 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 | |
| 123 | func (p prefixStore) full(key string) string { return p.base + key } |
| 124 | |
| 125 | func (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. |
| 131 | func (p prefixStore) Open(ctx context.Context, key string) (io.ReadCloser, string, error) { |
| 132 | return OpenObject(ctx, p.Store, p.full(key)) |
| 133 | } |
| 134 | |
| 135 | func (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. |
| 141 | func (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 | |
| 145 | func (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. |
| 154 | func (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. |
| 167 | func (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. |
| 186 | type 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. |
| 199 | type ModLister interface { |
| 200 | ListMod(ctx context.Context, prefix string) ([]KeyMod, error) |
| 201 | } |
| 202 | |
| 203 | // Both real stores answer ListMod; only fakes fall back. |
| 204 | var ( |
| 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. |
| 215 | type 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. |
| 220 | type 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. |
| 228 | type 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. |
| 233 | var ( |
| 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". |
| 244 | func (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. |
| 252 | func (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. |
| 268 | type 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. |
| 277 | type SizeLister interface { |
| 278 | ListSizes(ctx context.Context, prefix string) ([]KeySize, error) |
| 279 | } |
| 280 | |
| 281 | // Both real stores answer ListSizes; only fakes fall back. |
| 282 | var ( |
| 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). |
| 292 | type Opener interface { |
| 293 | Open(ctx context.Context, key string) (io.ReadCloser, string, error) |
| 294 | } |
| 295 | |
| 296 | var ( |
| 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). |
| 308 | type PutStreamer interface { |
| 309 | PutFrom(ctx context.Context, key string, body io.ReadSeeker, size int64, ifMatch string) (string, error) |
| 310 | } |
| 311 | |
| 312 | var ( |
| 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. |
| 320 | const PutFromFallbackMax = 64 << 20 |
| 321 | |
| 322 | // ErrTooLargeForPut is PutObjectFrom refusing to buffer a large body. |
| 323 | var ErrTooLargeForPut = errors.New("store cannot stream and the body is too large to buffer") |
| 324 | |
| 325 | // PutObjectFrom streams body into s at key. |
| 326 | func 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 | |
| 343 | type streamingReadKey struct{} |
| 344 | |
| 345 | // OpenObjectStreaming refuses Get fallbacks, including inside wrappers that |
| 346 | // forward this context through OpenObject. Callers must still bound their reads. |
| 347 | func 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. |
| 352 | func 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. |
| 382 | type 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. |
| 387 | var ( |
| 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. |
| 400 | type DirStore struct { |
| 401 | root string |
| 402 | mu sync.Mutex |
| 403 | changeIndex bool // EnableChangeIndex: bump control/changes.json after each mutation |
| 404 | } |
| 405 | |
| 406 | func 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. |
| 410 | func (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. |
| 423 | func 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 | |
| 436 | func etagOf(body []byte) string { |
| 437 | sum := sha256.Sum256(body) |
| 438 | return hex.EncodeToString(sum[:]) |
| 439 | } |
| 440 | |
| 441 | func (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 | |
| 458 | func (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. |
| 476 | func (d *DirStore) EnableChangeIndex() { d.changeIndex = true } |
| 477 | |
| 478 | func (d *DirStore) bump(ctx context.Context, key string) { |
| 479 | bumpAfterMutation(ctx, d.changeIndex, key, d.Get, d.putRaw) |
| 480 | } |
| 481 | |
| 482 | func (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 | |
| 490 | func (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. |
| 532 | func (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 | |
| 540 | func (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. |
| 590 | func (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. |
| 601 | func (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. |
| 620 | func (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. |
| 665 | func (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. |
| 678 | func (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. |
| 719 | func (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. |
| 766 | func (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 | |
| 798 | func (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 | |
| 806 | func (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). |
| 827 | type 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. |
| 840 | func 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. |
| 848 | func 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 | |
| 855 | func 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. |
| 874 | func 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. |
| 890 | func 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 | |
| 908 | func (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 | |
| 924 | func (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. |
| 938 | func (s *S3Store) EnableChangeIndex() { s.changeIndex = true } |
| 939 | |
| 940 | func (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. |
| 947 | func 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. |
| 957 | func 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 | |
| 966 | func (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 | |
| 974 | func (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. |
| 1007 | func (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 | |
| 1015 | func (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. |
| 1038 | func (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. |
| 1055 | func (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. |
| 1096 | func (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. |
| 1117 | func 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. |
| 1134 | func (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. |
| 1159 | func (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. |
| 1179 | func (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. |
| 1199 | func (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. |
| 1205 | func (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 | |
| 1228 | func (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 | |
| 1236 | func (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 | |