carlos / platform Public

plan: claim Plan 2 rev 2, after Codex's review

Region-bound versionedS3 (no lazy memo), external lease_test package,
AWS copy-source escaping, delete markers as ErrIsDeleteMarker (405),
complete locked helpers, fault injection across both implementations,
request-counted never-bare delete, OpenVersion streaming, sibling cleanup
left to the destination region, honest §13 coverage table.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 06d21bfd368d461c5b73cf4b5d7bbb3b0e702317 parent f2454b4
1 file changed, +894 −644
  • docs/superpowers/plans/2026-10-05-host-lifecycle-claim-2-versioned-storage.md +894 −644

CI passed — run details

diff --git a/docs/superpowers/plans/2026-10-05-host-lifecycle-claim-2-versioned-storage.md b/docs/superpowers/plans/2026-10-05-host-lifecycle-claim-2-versioned-storage.md
index db5b48f47..8332e02c5 100644
--- a/docs/superpowers/plans/2026-10-05-host-lifecycle-claim-2-versioned-storage.md
+++ b/docs/superpowers/plans/2026-10-05-host-lifecycle-claim-2-versioned-storage.md
@@ -2,91 +2,128 @@
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
-**Goal:** Give the data bucket a small, exact-version storage interface.
-It lists every version of every object, delete markers included and
-paginated, and copies, reads and deletes **by version id**. It also has a
-create-only put. That is everything Plans 3 to 8 need to park data, write
-certificates and read pinned versions, without ever issuing a bare-key
-destructive call (spec C3).
+**Goal:** give the data bucket a small storage interface that works on
+exact object versions. It can:
+
+- list every version of every object, delete markers included, across
+ pages;
+- read, stream, copy and delete **by version id**;
+- put an object only if none exists (create-only).
+
+That covers what Plans 3 to 8 need for parking data, writing
+certificates and reading pinned versions, and nothing in it ever deletes
+by bare key (spec C3).
**Architecture:**
-- **A new interface, `lease.VersionedStore`.** It sits beside the
- existing `MirrorStore`. `MirrorStore` and its two test fakes are left
- untouched.
-- **The production implementation is the existing `s3Mirror`.** It
- reuses the same regional client routing.
-- **One exported fake, `leasetest.VersionedBucket`,** models versions,
- delete markers and ETag rules, with failure injection. It is the
- reference every later plan tests against.
-- **An `http.Handler` that speaks the S3 wire protocol over that fake.**
- The real `s3Mirror` code is tested against it, using an actual AWS SDK
- client, over the wire. A single contract test suite runs against both
- the fake directly and the `s3Mirror`.
-- **IAM:** the box role gains the three S3 version actions on its own
- data bucket.
-
-**Tech Stack:** Go, aws-sdk-go-v2 `service/s3` v1.105.0 (already in
-go.mod), `net/http/httptest`, OpenTofu HCL.
-
-**Spec:** `docs/superpowers/specs/2026-10-02-host-lifecycle-claim-design.md`,
-rev 6.12. Read C3, §6.2 and §13 first. §13 lists the S3 behaviour already
-verified on real AWS, and this plan encodes exactly that behaviour in the
-fake.
-
-**Depends on:** nothing. It can be built in parallel with Plan 1's merge.
+- **One interface.** A new `lease.VersionedStore` sits beside the
+ existing `MirrorStore`, which stays untouched along with its fakes and
+ callers.
+- **The production type is a new `versionedS3`.** Every bucket is bound
+ explicitly to a region at construction, so no call can ever reach the
+ wrong region. It doesn't reuse `s3Mirror`'s lazy `byBucket` memo.
+ `CopyObject` runs on the destination bucket's client, because that is
+ where S3 executes a copy.
+- **One exported fake.** `leasetest.VersionedBucket` models versions,
+ delete markers and ETag rules, and lets tests inject failures. Beside
+ it, `leasetest.NewS3Server` speaks the S3 wire protocol over that fake
+ and counts requests.
+- **One shared contract suite.** It runs against the fake directly, and
+ against `versionedS3` over the wire with a real AWS SDK client. Those
+ tests live in the external `lease_test` package, which avoids an
+ import cycle.
+- **IAM.** The box role gains S3's version actions on its **own** data
+ bucket only. Cleaning up cross-region staging stays with the
+ destination region's box (spec N13), so the sibling grants don't
+ change.
+
+**Tech Stack:** Go; aws-sdk-go-v2 `service/s3` v1.105.0 (already in
+`go.mod`; `CopyObjectInput.IfNoneMatch` and `PutObjectInput.IfNoneMatch`
+exist); smithy-go `encoding/httpbinding` (already a transitive
+dependency); `net/http/httptest`; OpenTofu.
+
+**Spec:** `docs/superpowers/specs/2026-10-02-host-lifecycle-claim-design.md`
+rev 6.12. Read C3, §6.2, §6.5 and §13 before starting. Rev 2 of this
+plan answers Codex's review of rev 1: two P1s and seven P2s.
+
+**Depends on:** nothing.
## Global Constraints
-- **Every destructive call names a version id.** No method in this
- interface deletes or overwrites by bare key.
-- **Delete by version id is idempotent.** A version or key that is
- already gone is success, not an error (§13 row 3).
-- **Listings never return partial results.** A page error fails the
- whole listing, with no partial result. A listing returns every version
- **and** every delete marker, in key order, with versions newest first
- within a key.
-- **Copy semantics.**
- - Copy reads the source **by version id** (`CopySource` carries
- `?versionId=`).
- - A copy may also be made create-only at the destination: `If-None-Match:
- *` on the copy. A 412 then returns `ErrDestinationExists`.
- - A copy never needs the source ETag, but it records the destination's
- new version id and ETag.
- - A multipart source ETag becomes single-part on copy (§13 row 9). The
- fake models this.
+- **No bare-key destruction.** Every destructive call names a version
+ id. `DeleteVersion` refuses an empty version id **without sending a
+ request**. No method deletes or overwrites by bare key.
+- **Deletes are idempotent.** Deleting a version that is already gone,
+ or a version of a missing key, is success (§13 row 3).
+- **Listings are whole or nothing.** A page error fails the whole
+ listing; there is never a partial result. A listing returns every
+ version and every delete marker:
+ - in **key order**;
+ - **within a key, the entry with `IsLatest` comes first**;
+ - the remaining entries for that key come newest-`LastModified` first,
+ and **their relative order is unspecified when timestamps tie**.
+ Callers rely on `IsLatest`, never on position among non-latest
+ entries.
+- **Delete markers can't be read or copied.** `GetVersion`,
+ `OpenVersion` or `CopyVersion` on a version id that is a delete marker
+ returns `ErrIsDeleteMarker` (S3: 405 with `x-amz-delete-marker: true`).
+ A missing version returns `lease.ErrMirrorNotFound`.
+- **Copy rules.**
+ - The source is read by version id. `CopySource` is
+ `httpbinding.EscapePath(srcBucket+"/"+srcKey, false)` followed by
+ `"?versionId="+url.QueryEscape(id)`.
+ - A create-only copy uses `If-None-Match: *`. A 412 then means
+ `ErrDestinationExists`. A 409 is **never** read as "exists".
+ - A delete marker at the destination does not count as existing.
+ - A copy produces a single-part ETag (§13 row 9).
+ - After `ErrDestinationExists`, the caller recovers the destination's
+ id and size with `ListVersions` on the exact key.
- **`PutIfAbsent`** uses `If-None-Match: *`. A 412 returns `ErrExists`.
-- **The fake follows §13 exactly:**
- - a bare delete adds a delete marker;
- - deleting a version removes only that version;
- - deleting the current version makes the next newest version current
- (a delete marker counts);
- - versions are retained.
-- **IAM:** `s3:ListBucketVersions` on the data bucket ARN, and
- `s3:GetObjectVersion` and `s3:DeleteObjectVersion` on `<data bucket>/*`,
- in **both** `infra/modules/region-host/main.tf` and
- `infra/modules/region-remote-host/main.tf`. No other grants change.
+ A delete marker doesn't count as existing.
+- **Fault injection matches across implementations.** A `FailOn`
+ failure in the fake surfaces as a non-nil error from both the fake and
+ `versionedS3`; the server returns it as 500 `InternalError`. A
+ failure is never turned into not-found, and never into success.
+- **IAM.** The box role gets exactly these grants, in both
+ `infra/modules/region-host/main.tf` and
+ `infra/modules/region-remote-host/main.tf`, each using its own module's
+ data bucket ARN:
+ - `s3:ListBucketVersions` on the data bucket ARN;
+ - `s3:GetObjectVersion` and `s3:DeleteObjectVersion` on
+ `<data bucket ARN>/*`.
+
+ Leave the sibling `moves/*` statements unchanged.
- **Gate (AGENTS.md):** `go build ./...`, `go vet ./...`,
- `go test ./... -count=1` and `gofmt -l .` must all be clean. Use the
- default Go cache. In a read-only sandbox, use `GOCACHE=$TMPDIR/gocache`,
- never `/tmp`. CI is the authority for any test that only fails in the
- sandbox.
+ `go test ./... -count=1` and `gofmt -l .`, all clean. Use the default
+ Go cache. In a read-only sandbox, use `GOCACHE=$TMPDIR/gocache`, never
+ `/tmp`.
+
+## What this plan does and doesn't cover of §13
+
+| §13 row | Covered here |
+|---|---|
+| 1, distinct version ids | yes, fake and contract |
+| 2, copy by source version | yes |
+| 3, delete by version is exact | yes |
+| 4, a bare delete adds a marker | fake helper `BareDelete` only; the interface never issues one |
+| 5, `If-None-Match` on PUT | yes, `PutIfAbsent` |
+| 6, `PutObject If-Match` | **no**: claim CASes go through `internal/release.Store`, which already has it |
+| 7, `DeleteObject If-Match` | **no**: `release.DeleteIfMatch` already covers it |
+| 8, signature age | **no**: Plan 3/6's staging writer owns signing at the deadline check (CC19) |
+| 9, multipart to single-part ETag on copy | yes |
## Review Focus
-- **A listing over 1,000 objects**, where S3 paginates by key and version
- marker. Every version must come back exactly once (Task 3).
-- **Keys with characters that need escaping** (`+`, spaces, `%`, `?` in
- a key) in `CopySource` and in paths. The copy must hit the exact
- version of the exact key (Task 4).
-- **Deleting the current version when an older one exists.** The older
- one becomes current, in both the fake and S3. Callers must expect it
- (Tasks 2 and 5).
-- **A page error mid-listing** must surface as an error, never as a short
- list (Tasks 2 and 3).
-- **Copy create-only at a destination that only holds a delete marker.**
- S3 lets `If-None-Match: *` succeed there (§13 row 5's sibling), and the
- fake must match (Task 2).
+1. **A listing over many pages, with many versions of one key spanning
+ pages.** Every entry must come back exactly once (Task 3).
+2. **Keys containing `+`, space, `%`, `?` and `/`.** The `CopySource`
+ header must escape them the way AWS's encoder does (Task 4).
+3. **Deleting the current version when an older one exists.** The older
+ version becomes current (Tasks 2 and 5).
+4. **A page error mid-listing.** It is an error, never a short list
+ (Tasks 2 and 3).
+5. **Reading or copying a delete marker's version id** gives
+ `ErrIsDeleteMarker` from both implementations (Tasks 2 and 4).
---
@@ -94,28 +131,31 @@ fake.
| File | Responsibility |
|---|---|
-| `internal/lease/versioned.go` | `VersionedStore`, `ObjectVersion`, the errors, and the `s3Mirror` methods |
-| `internal/lease/leasetest/bucket.go` | `VersionedBucket`, the in-memory versioned fake (exported, for every later plan) |
-| `internal/lease/leasetest/s3server.go` | `NewS3Server(*VersionedBucket) http.Handler`, the S3 wire protocol over the fake |
-| `internal/lease/leasetest/contract.go` | `RunVersionedStoreContract(t, newStore)`, the shared behaviour suite |
-| `internal/lease/leasetest/bucket_test.go` | Runs the contract against the fake |
-| `internal/lease/versioned_test.go` | Runs the contract against `s3Mirror` over `NewS3Server`, plus wire-specific tests |
-| `infra/modules/region-host/main.tf`, `infra/modules/region-remote-host/main.tf` | IAM grants |
+| `internal/lease/versioned.go` | `VersionedStore`, `ObjectVersion`, errors, `versionedS3`, `NewVersionedS3` |
+| `internal/lease/export_test.go` | `NewVersionedS3WithClients`, a test-only constructor (package `lease`) |
+| `internal/lease/versioned_test.go` | package `lease_test`: the contract over the wire, plus wire-specific tests |
+| `internal/lease/leasetest/bucket.go` | `VersionedBucket`, the in-memory model |
+| `internal/lease/leasetest/s3server.go` | `NewS3Server`, the S3 wire protocol over the model, with request counting |
+| `internal/lease/leasetest/contract.go` | `RunVersionedStoreContract` |
+| `internal/lease/leasetest/bucket_test.go` | the contract against the model |
+| `infra/modules/region-host/main.tf`, `infra/modules/region-remote-host/main.tf` | IAM |
---
-### Task 1: The interface, types and errors
+### Task 1: The interface, types, errors and constructor
**Files:**
- Create: `internal/lease/versioned.go`
+- Create: `internal/lease/export_test.go`
**Interfaces:**
- Produces:
- - `type ObjectVersion struct { Key, VersionID, ETag string; Size int64; IsLatest, DeleteMarker bool; LastModified time.Time }`;
- - `type VersionedStore interface` (below);
- - `ErrExists` and `ErrDestinationExists`.
+ - `ObjectVersion`, `VersionedStore`;
+ - `ErrExists`, `ErrDestinationExists`, `ErrIsDeleteMarker`, `ErrNoVersionID`;
+ - `NewVersionedS3(ctx, regionByBucket map[string]string) (VersionedStore, error)`;
+ - test-only `NewVersionedS3WithClients(clients map[string]*awss3.Client) VersionedStore`.
-- [ ] **Step 1: Write the file**
+- [ ] **Step 1: Write `versioned.go` (the interface and stubs that return `errNotYet`)**
```go
package lease
@@ -123,7 +163,12 @@ package lease
import (
"context"
"errors"
+ "fmt"
+ "io"
"time"
+
+ awsconfig "github.com/aws/aws-sdk-go-v2/config"
+ awss3 "github.com/aws/aws-sdk-go-v2/service/s3"
)
// ObjectVersion is one entry of a versioned listing: an object version, or
@@ -139,55 +184,131 @@ type ObjectVersion struct {
}
var (
- // ErrExists is PutIfAbsent finding the key already holds a current
- // object version.
- ErrExists = errors.New("lease: object already exists")
- // ErrDestinationExists is a create-only CopyVersion whose destination
- // key already holds a current object version.
+ ErrExists = errors.New("lease: object already exists")
ErrDestinationExists = errors.New("lease: copy destination already exists")
+ ErrIsDeleteMarker = errors.New("lease: version is a delete marker")
+ ErrNoVersionID = errors.New("lease: a version id is required")
)
// VersionedStore is the data bucket addressed by exact object version (host
-// lifecycle claim spec C3, §6.2). No method deletes or overwrites by bare
-// key.
+// lifecycle claim spec C3, §6.2, §6.5). No method deletes or overwrites by
+// bare key.
type VersionedStore interface {
- // ListVersions returns every version and delete marker under prefix,
- // in key order, newest first within a key. It reads every page and
- // fails on any page error: never a partial listing.
+ // ListVersions returns every version and delete marker under prefix:
+ // key order; within a key the IsLatest entry first, the rest newest
+ // first with ties unordered. Any page error fails the whole call.
ListVersions(ctx context.Context, bucket, prefix string) ([]ObjectVersion, error)
- // GetVersion reads one exact version. A missing version is
- // ErrMirrorNotFound.
+ // OpenVersion streams one exact version. Missing: ErrMirrorNotFound.
+ // A delete marker: ErrIsDeleteMarker.
+ OpenVersion(ctx context.Context, bucket, key, versionID string) (io.ReadCloser, error)
+ // GetVersion reads one exact version whole (OpenVersion's errors).
GetVersion(ctx context.Context, bucket, key, versionID string) ([]byte, error)
- // CopyVersion copies one exact source version to dstKey. With
- // createOnly the copy carries If-None-Match: *, and an existing
- // current destination object gives ErrDestinationExists. It returns
- // the destination's new version id and its ETag.
+ // CopyVersion copies one exact source version to dstKey. createOnly
+ // adds If-None-Match: *; an existing current destination object gives
+ // ErrDestinationExists. It returns the destination's new version id
+ // and ETag.
CopyVersion(ctx context.Context, srcBucket, srcKey, srcVersionID, dstBucket, dstKey string, createOnly bool) (dstVersionID, dstETag string, err error)
- // DeleteVersion removes one exact version. A version or key already
- // gone is success.
+ // DeleteVersion removes one exact version; gone already is success.
+ // An empty versionID is ErrNoVersionID, without any request.
DeleteVersion(ctx context.Context, bucket, key, versionID string) error
- // PutIfAbsent writes body only if key holds no current object version
- // (If-None-Match: *); otherwise ErrExists. It returns the new version
- // id.
+ // PutIfAbsent writes body only if key holds no current object version.
PutIfAbsent(ctx context.Context, bucket, key string, body []byte) (versionID string, err error)
}
+
+// versionedS3 binds every bucket to its own regional client explicitly.
+// An unbound bucket is an error, never a guess.
+type versionedS3 struct {
+ clients map[string]*awss3.Client // bucket -> client for its region
+}
+
+// NewVersionedS3 builds one client per region from the default credential
+// chain (with s3compat options, as newLeaserClient does) and binds each
+// bucket to its region's client.
+func NewVersionedS3(ctx context.Context, regionByBucket map[string]string) (VersionedStore, error) {
+ byRegion := map[string]*awss3.Client{}
+ out := &versionedS3{clients: map[string]*awss3.Client{}}
+ for bucket, region := range regionByBucket {
+ c, ok := byRegion[region]
+ if !ok {
+ var err error
+ if c, err = newLeaserClient(ctx, region); err != nil {
+ return nil, fmt.Errorf("lease: versioned client for %s: %w", region, err)
+ }
+ byRegion[region] = c
+ }
+ out.clients[bucket] = c
+ }
+ return out, nil
+}
+
+func (s *versionedS3) client(bucket string) (*awss3.Client, error) {
+ c, ok := s.clients[bucket]
+ if !ok {
+ return nil, fmt.Errorf("lease: bucket %q is not bound to a region", bucket)
+ }
+ return c, nil
+}
+
+var errNotYet = errors.New("lease: not implemented yet")
+
+func (s *versionedS3) ListVersions(ctx context.Context, bucket, prefix string) ([]ObjectVersion, error) {
+ return nil, errNotYet
+}
+func (s *versionedS3) OpenVersion(ctx context.Context, bucket, key, versionID string) (io.ReadCloser, error) {
+ return nil, errNotYet
+}
+func (s *versionedS3) GetVersion(ctx context.Context, bucket, key, versionID string) ([]byte, error) {
+ return nil, errNotYet
+}
+func (s *versionedS3) CopyVersion(ctx context.Context, srcBucket, srcKey, srcVersionID, dstBucket, dstKey string, createOnly bool) (string, string, error) {
+ return "", "", errNotYet
+}
+func (s *versionedS3) DeleteVersion(ctx context.Context, bucket, key, versionID string) error {
+ return errNotYet
+}
+func (s *versionedS3) PutIfAbsent(ctx context.Context, bucket, key string, body []byte) (string, error) {
+ return "", errNotYet
+}
+
+var _ VersionedStore = (*versionedS3)(nil)
+var _ = awsconfig.LoadDefaultConfig // newLeaserClient's dependency; remove if unused
+```
+
+`newLeaserClient(ctx, region)` exists in `internal/lease/endpoint.go`
+(around lines 99-110). If its real signature differs, adapt the call and
+keep its semantics: default chain, `s3compat.Options()`, and the
+`CARLOS_S3_ENDPOINT` override. Delete the `awsconfig` placeholder line
+if the import isn't needed.
+
+- [ ] **Step 2: Write `export_test.go`**
+
+```go
+package lease
+
+import awss3 "github.com/aws/aws-sdk-go-v2/service/s3"
+
+// NewVersionedS3WithClients is the test-only constructor: tests bind
+// buckets to clients pointed at leasetest.NewS3Server.
+func NewVersionedS3WithClients(clients map[string]*awss3.Client) VersionedStore {
+ return &versionedS3{clients: clients}
+}
```
-- [ ] **Step 2: Build**
+- [ ] **Step 3: Build**
-Run: `go build ./internal/lease/`
+Run: `go build ./internal/lease/ && go vet ./internal/lease/`
Expected: success.
-- [ ] **Step 3: Commit**
+- [ ] **Step 4: Commit**
```bash
-git add internal/lease/versioned.go
-git commit -m "lease: the VersionedStore interface (exact-version data bucket access)"
+git add internal/lease/versioned.go internal/lease/export_test.go
+git commit -m "lease: the VersionedStore interface and an explicitly region-bound S3 type"
```
---
-### Task 2: The fake versioned bucket and the contract suite
+### Task 2: The model and the contract suite
**Files:**
- Create: `internal/lease/leasetest/bucket.go`, `internal/lease/leasetest/contract.go`, `internal/lease/leasetest/bucket_test.go`
@@ -195,177 +316,236 @@ git commit -m "lease: the VersionedStore interface (exact-version data bucket ac
**Interfaces:**
- Consumes: Task 1.
- Produces:
- - `type VersionedBucket struct` with `NewVersionedBucket() *VersionedBucket`;
- - the methods of `lease.VersionedStore`, with the bucket name argument
- checked against a set of known bucket names (`AddBucket(name string)`);
- - helpers for later plans:
- - `PutMultipart(bucket, key string, body []byte)`, which stores an
- ETag with a `-2` suffix;
- - `BareDelete(bucket, key string)`, which adds a delete marker;
- - `Current(bucket, key string) ([]byte, bool)`;
- - failure hooks: `FailListAfterPages int` (fail the listing after this
- many pages, 0 = never), `PageSize int` (default 1000), and
+ - `VersionedBucket` (implements `lease.VersionedStore`), built with
+ `NewVersionedBucket()`;
+ - setup: `AddBucket`;
+ - writes: `Put` and `PutMultipart` (each returns the version id) and
+ `BareDelete` (returns the marker id);
+ - reads: `LatestID`, `Current` and `Snapshot` (the full state as a
+ string, for before/after comparisons);
+ - knobs: `PageSize int` (default 1000), `FailListAfterPages int` and
`FailOn func(op, key string) error`;
- - `RunVersionedStoreContract(t *testing.T, mk func(t *testing.T) (lease.VersionedStore, *VersionedBucket, string))`.
- `mk` returns the store under test, the backing fake (for setup and
- inspection) and the bucket name.
-
-**Model:**
-- Each key holds a list of versions, newest first. Each version is
- `{id, body, etag, size, deleteMarker, modified}`.
-- Version ids are `v000001`, `v000002` and so on, from a counter, so
- ordering is deterministic.
-- The ETag of a put is the hex md5 of the body. For `PutMultipart` it is
- md5 + `"-2"`. A copy always gets the single-part md5 of the body.
-- "Current" means the newest entry, if it isn't a delete marker.
+ - the shared listing helpers `listAll` and `pageOf`, both run with the
+ mutex held;
+ - `RunVersionedStoreContract(t, mk)`, where `mk` returns
+ `(store lease.VersionedStore, model *VersionedBucket, bucket string, served func() int)`.
+ `served` counts HTTP requests for wire stores; for the model it is
+ `nil`.
-- [ ] **Step 1: Write the contract suite (it fails: nothing implements it yet)**
+- [ ] **Step 1: Write the contract suite**
```go
package leasetest
import (
- "bytes"
"context"
"errors"
"fmt"
+ "io"
+ "strings"
"testing"
"github.com/carlosframework/platform/internal/lease"
)
-// RunVersionedStoreContract is the behaviour every lease.VersionedStore must
-// have (spec §13). mk returns the store under test, the fake that backs it
-// (used to set up and inspect state) and the bucket name.
-func RunVersionedStoreContract(t *testing.T, mk func(t *testing.T) (lease.VersionedStore, *VersionedBucket, string)) {
+// Maker builds a store under test, the model backing it, the bucket name,
+// and (for wire stores) a request counter.
+type Maker func(t *testing.T) (st lease.VersionedStore, m *VersionedBucket, bucket string, served func() int)
+
+// RunVersionedStoreContract is the behaviour every lease.VersionedStore
+// must have (spec §13, as covered by Plan 2).
+func RunVersionedStoreContract(t *testing.T, mk Maker) {
ctx := context.Background()
- t.Run("versions and delete markers, newest first", func(t *testing.T) {
- st, fb, b := mk(t)
- fb.Put(b, "p/a", []byte("one"))
- fb.Put(b, "p/a", []byte("two"))
- fb.BareDelete(b, "p/a")
- fb.Put(b, "p/b", []byte("x"))
- fb.Put(b, "q/c", []byte("other prefix"))
+ t.Run("listing: key order, latest first, markers included, prefix bounded", func(t *testing.T) {
+ st, m, b, _ := mk(t)
+ m.Put(b, "p/a", []byte("one"))
+ m.Put(b, "p/a", []byte("two"))
+ m.BareDelete(b, "p/a")
+ m.Put(b, "p/b", []byte("x"))
+ m.Put(b, "q/c", []byte("other prefix"))
vs, err := st.ListVersions(ctx, b, "p/")
if err != nil {
t.Fatal(err)
}
- var got []string
- for _, v := range vs {
- got = append(got, fmt.Sprintf("%s:%v:%v", v.Key, v.DeleteMarker, v.IsLatest))
+ if len(vs) != 4 || vs[0].Key != "p/a" || !vs[0].IsLatest || !vs[0].DeleteMarker || vs[3].Key != "p/b" || !vs[3].IsLatest {
+ t.Fatalf("listing = %+v", vs)
}
- want := []string{"p/a:true:true", "p/a:false:false", "p/a:false:false", "p/b:false:true"}
- if fmt.Sprint(got) != fmt.Sprint(want) {
- t.Fatalf("ListVersions = %v, want %v", got, want)
+ for _, v := range vs[1:3] {
+ if v.Key != "p/a" || v.IsLatest || v.DeleteMarker || v.VersionID == "" {
+ t.Fatalf("non-latest entries = %+v", vs[1:3])
+ }
}
})
- t.Run("listing spans pages and fails whole on a page error", func(t *testing.T) {
- st, fb, b := mk(t)
- fb.PageSize = 3
- for i := 0; i < 10; i++ {
- fb.Put(b, fmt.Sprintf("p/k%02d", i), []byte{byte(i)})
+ t.Run("listing spans pages, including many versions of one key", func(t *testing.T) {
+ st, m, b, _ := mk(t)
+ m.PageSize = 3
+ for i := 0; i < 5; i++ {
+ m.Put(b, "p/same", []byte{byte(i)})
+ }
+ for i := 0; i < 4; i++ {
+ m.Put(b, fmt.Sprintf("p/k%02d", i), []byte{byte(i)})
}
vs, err := st.ListVersions(ctx, b, "p/")
- if err != nil || len(vs) != 10 {
- t.Fatalf("paged listing = %d, %v; want 10", len(vs), err)
+ if err != nil || len(vs) != 9 {
+ t.Fatalf("paged listing = %d, %v; want 9", len(vs), err)
+ }
+ seen := map[string]bool{}
+ for _, v := range vs {
+ if seen[v.Key+"@"+v.VersionID] {
+ t.Fatalf("duplicate %s@%s", v.Key, v.VersionID)
+ }
+ seen[v.Key+"@"+v.VersionID] = true
+ }
+ })
+
+ t.Run("listing fails whole on a page error", func(t *testing.T) {
+ st, m, b, _ := mk(t)
+ m.PageSize = 2
+ for i := 0; i < 6; i++ {
+ m.Put(b, fmt.Sprintf("p/k%d", i), []byte{byte(i)})
}
- fb.FailListAfterPages = 2
+ m.FailListAfterPages = 2
if vs, err := st.ListVersions(ctx, b, "p/"); err == nil {
- t.Fatalf("listing with a failing page returned %d entries and no error", len(vs))
+ t.Fatalf("got %d entries and no error", len(vs))
}
})
- t.Run("get and copy by exact version", func(t *testing.T) {
- st, fb, b := mk(t)
- fb.Put(b, "src/a b+c%", []byte("old"))
- v1 := fb.LatestID(b, "src/a b+c%")
- fb.Put(b, "src/a b+c%", []byte("new"))
- body, err := st.GetVersion(ctx, b, "src/a b+c%", v1)
- if err != nil || string(body) != "old" {
- t.Fatalf("GetVersion(v1) = %q, %v", body, err)
+ t.Run("read and stream by exact version; markers and missing", func(t *testing.T) {
+ st, m, b, _ := mk(t)
+ k := "dir/a b+c%?d"
+ v1 := m.Put(b, k, []byte("old"))
+ m.Put(b, k, []byte("new"))
+ if body, err := st.GetVersion(ctx, b, k, v1); err != nil || string(body) != "old" {
+ t.Fatalf("GetVersion = %q, %v", body, err)
}
- dv, etag, err := st.CopyVersion(ctx, b, "src/a b+c%", v1, b, "dst/x", true)
- if err != nil || dv == "" || etag == "" {
- t.Fatalf("CopyVersion = %q %q %v", dv, etag, err)
+ rc, err := st.OpenVersion(ctx, b, k, v1)
+ if err != nil {
+ t.Fatal(err)
+ }
+ body, _ := io.ReadAll(rc)
+ rc.Close()
+ if string(body) != "old" {
+ t.Fatalf("OpenVersion = %q", body)
}
- if cur, ok := fb.Current(b, "dst/x"); !ok || string(cur) != "old" {
- t.Fatalf("copied body = %q", cur)
+ mk1 := m.BareDelete(b, k)
+ if _, err := st.GetVersion(ctx, b, k, mk1); !errors.Is(err, lease.ErrIsDeleteMarker) {
+ t.Fatalf("GetVersion(marker) = %v, want ErrIsDeleteMarker", err)
}
- if _, _, err := st.CopyVersion(ctx, b, "src/a b+c%", v1, b, "dst/x", true); !errors.Is(err, lease.ErrDestinationExists) {
- t.Fatalf("create-only copy onto an existing object = %v", err)
+ if _, err := st.GetVersion(ctx, b, k, "v999999"); !errors.Is(err, lease.ErrMirrorNotFound) {
+ t.Fatalf("GetVersion(missing) = %v, want ErrMirrorNotFound", err)
}
- if _, err := st.GetVersion(ctx, b, "src/a b+c%", "v999999"); !errors.Is(err, lease.ErrMirrorNotFound) {
- t.Fatalf("missing version = %v", err)
+ // A version hidden behind a marker is still readable by id.
+ if body, err := st.GetVersion(ctx, b, k, v1); err != nil || string(body) != "old" {
+ t.Fatalf("GetVersion behind a marker = %q, %v", body, err)
}
})
- t.Run("create-only copy succeeds over a delete marker", func(t *testing.T) {
- st, fb, b := mk(t)
- fb.Put(b, "src/k", []byte("s"))
- sv := fb.LatestID(b, "src/k")
- fb.Put(b, "dst/k", []byte("gone"))
- fb.BareDelete(b, "dst/k")
- if _, _, err := st.CopyVersion(ctx, b, "src/k", sv, b, "dst/k", true); err != nil {
+ t.Run("copy by exact version; create-only; markers", func(t *testing.T) {
+ st, m, b, _ := mk(t)
+ k := "src/a b+c%?d"
+ v1 := m.Put(b, k, []byte("old"))
+ m.Put(b, k, []byte("new"))
+ dv, etag, err := st.CopyVersion(ctx, b, k, v1, b, "dst/x", true)
+ if err != nil || dv == "" || etag == "" || strings.Contains(etag, "-") {
+ t.Fatalf("CopyVersion = %q %q %v", dv, etag, err)
+ }
+ if cur, ok := m.Current(b, "dst/x"); !ok || string(cur) != "old" {
+ t.Fatalf("copied %q", cur)
+ }
+ if _, _, err := st.CopyVersion(ctx, b, k, v1, b, "dst/x", true); !errors.Is(err, lease.ErrDestinationExists) {
+ t.Fatalf("create-only copy onto existing = %v", err)
+ }
+ m.BareDelete(b, "dst/x")
+ if _, _, err := st.CopyVersion(ctx, b, k, v1, b, "dst/x", true); err != nil {
t.Fatalf("create-only copy over a delete marker = %v", err)
}
- })
-
- t.Run("multipart source etag becomes single-part on copy", func(t *testing.T) {
- st, fb, b := mk(t)
- fb.PutMultipart(b, "src/m", []byte("payload"))
- sv := fb.LatestID(b, "src/m")
- _, etag, err := st.CopyVersion(ctx, b, "src/m", sv, b, "dst/m", false)
- if err != nil || bytes.Contains([]byte(etag), []byte("-")) {
- t.Fatalf("copied etag = %q, %v; want single-part", etag, err)
+ mid := m.BareDelete(b, k)
+ if _, _, err := st.CopyVersion(ctx, b, k, mid, b, "dst/y", false); !errors.Is(err, lease.ErrIsDeleteMarker) {
+ t.Fatalf("copy of a marker = %v, want ErrIsDeleteMarker", err)
+ }
+ mpID := m.PutMultipart(b, "src/mp", []byte("payload"))
+ if _, etag, err := st.CopyVersion(ctx, b, "src/mp", mpID, b, "dst/mp", false); err != nil || strings.Contains(etag, "-") {
+ t.Fatalf("multipart copy etag = %q, %v; want single-part", etag, err)
}
})
- t.Run("delete by version is exact and idempotent", func(t *testing.T) {
- st, fb, b := mk(t)
- fb.Put(b, "k", []byte("one"))
- v1 := fb.LatestID(b, "k")
- fb.Put(b, "k", []byte("two"))
- v2 := fb.LatestID(b, "k")
+ t.Run("delete by version: exact, idempotent, never bare", func(t *testing.T) {
+ st, m, b, served := mk(t)
+ v1 := m.Put(b, "k", []byte("one"))
+ v2 := m.Put(b, "k", []byte("two"))
if err := st.DeleteVersion(ctx, b, "k", v2); err != nil {
t.Fatal(err)
}
- if cur, ok := fb.Current(b, "k"); !ok || string(cur) != "one" {
- t.Fatalf("after deleting the current version, current = %q %v; want the older version", cur, ok)
+ if cur, ok := m.Current(b, "k"); !ok || string(cur) != "one" {
+ t.Fatalf("after deleting the current version: %q %v; want the older one current", cur, ok)
}
if err := st.DeleteVersion(ctx, b, "k", v2); err != nil {
- t.Fatalf("deleting an already-deleted version = %v, want success", err)
+ t.Fatalf("re-delete = %v", err)
}
- if err := st.DeleteVersion(ctx, b, "k", v1); err != nil {
- t.Fatal(err)
+ if err := st.DeleteVersion(ctx, b, "never", "v000001"); err != nil {
+ t.Fatalf("delete on a missing key = %v", err)
}
- if _, ok := fb.Current(b, "k"); ok {
- t.Fatal("key still has a current version after deleting every version")
+ before, reqs := m.Snapshot(), 0
+ if served != nil {
+ reqs = served()
}
- if err := st.DeleteVersion(ctx, b, "never", "v000001"); err != nil {
- t.Fatalf("deleting a version of a missing key = %v, want success", err)
+ if err := st.DeleteVersion(ctx, b, "k", ""); !errors.Is(err, lease.ErrNoVersionID) {
+ t.Fatalf("empty version id = %v, want ErrNoVersionID", err)
+ }
+ if m.Snapshot() != before {
+ t.Fatal("state changed on an empty-version delete")
}
+ if served != nil && served() != reqs {
+ t.Fatal("a request was sent for an empty-version delete")
+ }
+ _ = v1
})
t.Run("put if absent", func(t *testing.T) {
- st, fb, b := mk(t)
- v, err := st.PutIfAbsent(ctx, b, "m/closed.json", []byte("{}"))
- if err != nil || v == "" {
- t.Fatalf("first PutIfAbsent = %q %v", v, err)
+ st, m, b, _ := mk(t)
+ if v, err := st.PutIfAbsent(ctx, b, "m/closed.json", []byte("{}")); err != nil || v == "" {
+ t.Fatalf("first = %q %v", v, err)
}
if _, err := st.PutIfAbsent(ctx, b, "m/closed.json", []byte("{}")); !errors.Is(err, lease.ErrExists) {
- t.Fatalf("second PutIfAbsent = %v, want ErrExists", err)
+ t.Fatalf("second = %v, want ErrExists", err)
}
- fb.BareDelete(b, "m/closed.json")
+ m.BareDelete(b, "m/closed.json")
if _, err := st.PutIfAbsent(ctx, b, "m/closed.json", []byte("{}")); err != nil {
- t.Fatalf("PutIfAbsent over a delete marker = %v, want success", err)
+ t.Fatalf("over a marker = %v", err)
+ }
+ })
+
+ t.Run("injected failures surface as errors from every operation", func(t *testing.T) {
+ st, m, b, _ := mk(t)
+ v := m.Put(b, "k", []byte("x"))
+ boom := errors.New("boom")
+ m.FailOn = func(op, key string) error { return boom }
+ if _, err := st.ListVersions(ctx, b, ""); err == nil {
+ t.Error("list: no error")
+ }
+ if _, err := st.GetVersion(ctx, b, "k", v); err == nil || errors.Is(err, lease.ErrMirrorNotFound) {
+ t.Errorf("get: %v", err)
+ }
+ if _, _, err := st.CopyVersion(ctx, b, "k", v, b, "k2", false); err == nil || errors.Is(err, lease.ErrMirrorNotFound) {
+ t.Errorf("copy: %v", err)
+ }
+ if err := st.DeleteVersion(ctx, b, "k", v); err == nil {
+ t.Error("delete: no error")
+ }
+ if _, err := st.PutIfAbsent(ctx, b, "new", []byte("x")); err == nil {
+ t.Error("put: no error")
+ }
+ m.FailOn = nil
+ if _, ok := m.Current(b, "k"); !ok {
+ t.Fatal("a failed delete removed data")
}
})
}
```
-- [ ] **Step 2: Write `bucket_test.go`, which runs the contract against the fake**
+- [ ] **Step 2: Write `bucket_test.go`**
```go
package leasetest
@@ -377,30 +557,32 @@ import (
)
func TestVersionedBucketContract(t *testing.T) {
- RunVersionedStoreContract(t, func(t *testing.T) (lease.VersionedStore, *VersionedBucket, string) {
- fb := NewVersionedBucket()
- fb.AddBucket("data")
- return fb, fb, "data"
+ RunVersionedStoreContract(t, func(t *testing.T) (lease.VersionedStore, *VersionedBucket, string, func() int) {
+ m := NewVersionedBucket()
+ m.AddBucket("data")
+ return m, m, "data", nil
})
}
```
Run: `go test ./internal/lease/leasetest/ -count=1`
-Expected: FAIL (`undefined: NewVersionedBucket`).
+Expected: FAIL, with `undefined: NewVersionedBucket`.
- [ ] **Step 3: Implement `bucket.go`**
```go
-// Package leasetest holds test doubles for internal/lease. Its
-// VersionedBucket is the reference model of a versioned S3 bucket that
-// every plan of the host lifecycle claim tests against (spec §13).
+// Package leasetest holds test doubles for internal/lease. VersionedBucket
+// is the reference model of a versioned S3 bucket that every plan of the
+// host lifecycle claim tests against (spec §13).
package leasetest
import (
+ "bytes"
"context"
"crypto/md5"
"encoding/hex"
"fmt"
+ "io"
"sort"
"strings"
"sync"
@@ -424,16 +606,9 @@ type VersionedBucket struct {
next int
clock time.Time
- // PageSize is the listing page size the S3 server and ListVersions
- // use (default 1000).
- PageSize int
- // FailListAfterPages fails a listing once this many pages have been
- // served (0: never).
- FailListAfterPages int
- // FailOn, when set, is consulted before every operation, with op one
- // of "list", "get", "copy", "delete" or "put" and the key; a non-nil
- // result fails the call.
- FailOn func(op, key string) error
+ PageSize int // listing page size; default 1000
+ FailListAfterPages int // fail a listing once this many pages have been served; 0 never
+ FailOn func(op, key string) error // op: list|get|copy|delete|put
}
func NewVersionedBucket() *VersionedBucket {
@@ -441,6 +616,8 @@ func NewVersionedBucket() *VersionedBucket {
clock: time.Date(2026, 10, 5, 0, 0, 0, 0, time.UTC)}
}
+func md5hex(b []byte) string { s := md5.Sum(b); return hex.EncodeToString(s[:]) }
+
func (f *VersionedBucket) AddBucket(name string) {
f.mu.Lock()
defer f.mu.Unlock()
@@ -449,17 +626,7 @@ func (f *VersionedBucket) AddBucket(name string) {
}
}
-func (f *VersionedBucket) newID() string { f.next++; return fmt.Sprintf("v%06d", f.next) }
-func (f *VersionedBucket) tick() time.Time { f.clock = f.clock.Add(time.Second); return f.clock }
-func md5hex(b []byte) string { s := md5.Sum(b); return hex.EncodeToString(s[:]) }
-
-func (f *VersionedBucket) objs(bucket string) (map[string][]version, error) {
- o, ok := f.buckets[bucket]
- if !ok {
- return nil, fmt.Errorf("leasetest: no bucket %q", bucket)
- }
- return o, nil
-}
+// --- locked helpers: callers hold f.mu ---
func (f *VersionedBucket) fail(op, key string) error {
if f.FailOn != nil {
@@ -468,35 +635,100 @@ func (f *VersionedBucket) fail(op, key string) error {
return nil
}
+func (f *VersionedBucket) known(bucket string) error {
+ if _, ok := f.buckets[bucket]; !ok {
+ return fmt.Errorf("leasetest: no bucket %q", bucket)
+ }
+ return nil
+}
+
func (f *VersionedBucket) push(bucket, key string, v version) string {
- o := f.buckets[bucket]
- v.id, v.modified = f.newID(), f.tick()
- o[key] = append([]version{v}, o[key]...)
+ f.next++
+ f.clock = f.clock.Add(time.Second)
+ v.id, v.modified = fmt.Sprintf("v%06d", f.next), f.clock
+ f.buckets[bucket][key] = append([]version{v}, f.buckets[bucket][key]...)
return v.id
}
-// Put stores a new current version (single-part ETag).
+func (f *VersionedBucket) hasCurrent(bucket, key string) bool {
+ vs := f.buckets[bucket][key]
+ return len(vs) > 0 && !vs[0].deleteMarker
+}
+
+func (f *VersionedBucket) find(bucket, key, id string) (version, bool) {
+ for _, v := range f.buckets[bucket][key] {
+ if v.id == id {
+ return v, true
+ }
+ }
+ return version{}, false
+}
+
+// listAll is the whole ordered listing under prefix (key order, newest
+// first within a key).
+func (f *VersionedBucket) listAll(bucket, prefix string) []lease.ObjectVersion {
+ o := f.buckets[bucket]
+ keys := make([]string, 0, len(o))
+ for k := range o {
+ if strings.HasPrefix(k, prefix) {
+ keys = append(keys, k)
+ }
+ }
+ sort.Strings(keys)
+ var all []lease.ObjectVersion
+ for _, k := range keys {
+ for i, v := range o[k] {
+ ov := lease.ObjectVersion{Key: k, VersionID: v.id, IsLatest: i == 0, DeleteMarker: v.deleteMarker, LastModified: v.modified}
+ if !v.deleteMarker {
+ ov.ETag, ov.Size = v.etag, int64(len(v.body))
+ }
+ all = append(all, ov)
+ }
+ }
+ return all
+}
+
+// pageOf returns page number page (0-based) of all, whether more follow,
+// and the failure FailListAfterPages injects.
+func (f *VersionedBucket) pageOf(all []lease.ObjectVersion, page int) ([]lease.ObjectVersion, bool, error) {
+ if f.FailListAfterPages > 0 && page >= f.FailListAfterPages {
+ return nil, false, fmt.Errorf("leasetest: injected listing failure on page %d", page+1)
+ }
+ size := f.PageSize
+ if size <= 0 {
+ size = 1000
+ }
+ start := page * size
+ if start > len(all) {
+ start = len(all)
+ }
+ end := start + size
+ if end >= len(all) {
+ return all[start:], false, nil
+ }
+ return all[start:end], true, nil
+}
+
+// --- exported helpers for tests ---
+
func (f *VersionedBucket) Put(bucket, key string, body []byte) string {
f.mu.Lock()
defer f.mu.Unlock()
return f.push(bucket, key, version{body: append([]byte(nil), body...), etag: md5hex(body)})
}
-// PutMultipart stores a new current version with a multipart-shaped ETag.
func (f *VersionedBucket) PutMultipart(bucket, key string, body []byte) string {
f.mu.Lock()
defer f.mu.Unlock()
return f.push(bucket, key, version{body: append([]byte(nil), body...), etag: md5hex(body) + "-2"})
}
-// BareDelete adds a delete marker, as a delete without a version id does.
func (f *VersionedBucket) BareDelete(bucket, key string) string {
f.mu.Lock()
defer f.mu.Unlock()
return f.push(bucket, key, version{deleteMarker: true})
}
-// LatestID is the id of the newest entry (version or marker) of key.
func (f *VersionedBucket) LatestID(bucket, key string) string {
f.mu.Lock()
defer f.mu.Unlock()
@@ -506,8 +738,6 @@ func (f *VersionedBucket) LatestID(bucket, key string) string {
return ""
}
-// Current returns the current object body: false when the key is absent or
-// its newest entry is a delete marker.
func (f *VersionedBucket) Current(bucket, key string) ([]byte, bool) {
f.mu.Lock()
defer f.mu.Unlock()
@@ -518,58 +748,39 @@ func (f *VersionedBucket) Current(bucket, key string) ([]byte, bool) {
return vs[0].body, true
}
-func (f *VersionedBucket) hasCurrent(bucket, key string) bool {
- vs := f.buckets[bucket][key]
- return len(vs) > 0 && !vs[0].deleteMarker
-}
-
-// listPage returns one page of the listing and whether it is truncated;
-// page counts served pages for FailListAfterPages.
-func (f *VersionedBucket) listPage(bucket, prefix string, start, page int) ([]lease.ObjectVersion, int, bool, error) {
- if f.FailListAfterPages > 0 && page >= f.FailListAfterPages {
- return nil, 0, false, fmt.Errorf("leasetest: injected listing failure on page %d", page+1)
- }
- if err := f.fail("list", prefix); err != nil {
- return nil, 0, false, err
- }
- o, err := f.objs(bucket)
- if err != nil {
- return nil, 0, false, err
+// Snapshot renders the whole state, for before/after comparison.
+func (f *VersionedBucket) Snapshot() string {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ var b strings.Builder
+ names := make([]string, 0, len(f.buckets))
+ for n := range f.buckets {
+ names = append(names, n)
}
- keys := make([]string, 0, len(o))
- for k := range o {
- if strings.HasPrefix(k, prefix) {
- keys = append(keys, k)
+ sort.Strings(names)
+ for _, n := range names {
+ for _, v := range f.listAll(n, "") {
+ fmt.Fprintf(&b, "%s/%s@%s:%v:%s\n", n, v.Key, v.VersionID, v.DeleteMarker, v.ETag)
}
}
- sort.Strings(keys)
- var all []lease.ObjectVersion
- for _, k := range keys {
- for i, v := range o[k] {
- ov := lease.ObjectVersion{Key: k, VersionID: v.id, IsLatest: i == 0, DeleteMarker: v.deleteMarker, LastModified: v.modified}
- if !v.deleteMarker {
- ov.ETag, ov.Size = v.etag, int64(len(v.body))
- }
- all = append(all, ov)
- }
- }
- size := f.PageSize
- if size <= 0 {
- size = 1000
- }
- end := start + size
- if end >= len(all) {
- return all[start:], len(all), false, nil
- }
- return all[start:end], end, true, nil
+ return b.String()
}
+// --- lease.VersionedStore ---
+
func (f *VersionedBucket) ListVersions(ctx context.Context, bucket, prefix string) ([]lease.ObjectVersion, error) {
f.mu.Lock()
defer f.mu.Unlock()
+ if err := f.known(bucket); err != nil {
+ return nil, err
+ }
+ if err := f.fail("list", prefix); err != nil {
+ return nil, err
+ }
+ all := f.listAll(bucket, prefix)
var out []lease.ObjectVersion
- for start, page := 0, 0; ; page++ {
- vs, next, more, err := f.listPage(bucket, prefix, start, page)
+ for page := 0; ; page++ {
+ vs, more, err := f.pageOf(all, page)
if err != nil {
return nil, err
}
@@ -577,89 +788,108 @@ func (f *VersionedBucket) ListVersions(ctx context.Context, bucket, prefix strin
if !more {
return out, nil
}
- start = next
}
}
-func (f *VersionedBucket) find(bucket, key, id string) (version, bool) {
- for _, v := range f.buckets[bucket][key] {
- if v.id == id {
- return v, true
- }
- }
- return version{}, false
-}
-
-func (f *VersionedBucket) GetVersion(ctx context.Context, bucket, key, versionID string) ([]byte, error) {
- f.mu.Lock()
- defer f.mu.Unlock()
- if err := f.fail("get", key); err != nil {
+func (f *VersionedBucket) read(bucket, key, versionID string) ([]byte, error) {
+ if err := f.known(bucket); err != nil {
return nil, err
}
- if _, err := f.objs(bucket); err != nil {
+ if err := f.fail("get", key); err != nil {
return nil, err
}
v, ok := f.find(bucket, key, versionID)
- if !ok || v.deleteMarker {
+ if !ok {
return nil, lease.ErrMirrorNotFound
}
+ if v.deleteMarker {
+ return nil, lease.ErrIsDeleteMarker
+ }
return append([]byte(nil), v.body...), nil
}
-func (f *VersionedBucket) CopyVersion(ctx context.Context, srcBucket, srcKey, srcVersionID, dstBucket, dstKey string, createOnly bool) (string, string, error) {
+func (f *VersionedBucket) GetVersion(ctx context.Context, bucket, key, versionID string) ([]byte, error) {
f.mu.Lock()
defer f.mu.Unlock()
- if err := f.fail("copy", srcKey); err != nil {
+ return f.read(bucket, key, versionID)
+}
+
+func (f *VersionedBucket) OpenVersion(ctx context.Context, bucket, key, versionID string) (io.ReadCloser, error) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ b, err := f.read(bucket, key, versionID)
+ if err != nil {
+ return nil, err
+ }
+ return io.NopCloser(bytes.NewReader(b)), nil
+}
+
+func (f *VersionedBucket) copyLocked(srcBucket, srcKey, srcVersionID, dstBucket, dstKey string, createOnly bool) (string, string, error) {
+ if err := f.known(srcBucket); err != nil {
return "", "", err
}
- if _, err := f.objs(srcBucket); err != nil {
+ if err := f.known(dstBucket); err != nil {
return "", "", err
}
- if _, err := f.objs(dstBucket); err != nil {
+ if err := f.fail("copy", srcKey); err != nil {
return "", "", err
}
v, ok := f.find(srcBucket, srcKey, srcVersionID)
- if !ok || v.deleteMarker {
+ if !ok {
return "", "", lease.ErrMirrorNotFound
}
+ if v.deleteMarker {
+ return "", "", lease.ErrIsDeleteMarker
+ }
if createOnly && f.hasCurrent(dstBucket, dstKey) {
return "", "", lease.ErrDestinationExists
}
- etag := md5hex(v.body) // a copy is always single-part (§13 row 9)
- id := f.push(dstBucket, dstKey, version{body: append([]byte(nil), v.body...), etag: etag})
- return id, etag, nil
+ etag := md5hex(v.body) // a copy is single-part (§13 row 9)
+ return f.push(dstBucket, dstKey, version{body: append([]byte(nil), v.body...), etag: etag}), etag, nil
}
-func (f *VersionedBucket) DeleteVersion(ctx context.Context, bucket, key, versionID string) error {
+func (f *VersionedBucket) CopyVersion(ctx context.Context, srcBucket, srcKey, srcVersionID, dstBucket, dstKey string, createOnly bool) (string, string, error) {
f.mu.Lock()
defer f.mu.Unlock()
- if err := f.fail("delete", key); err != nil {
+ return f.copyLocked(srcBucket, srcKey, srcVersionID, dstBucket, dstKey, createOnly)
+}
+
+func (f *VersionedBucket) deleteLocked(bucket, key, versionID string) error {
+ if versionID == "" {
+ return lease.ErrNoVersionID
+ }
+ if err := f.known(bucket); err != nil {
return err
}
- o, err := f.objs(bucket)
- if err != nil {
+ if err := f.fail("delete", key); err != nil {
return err
}
- vs := o[key]
+ vs := f.buckets[bucket][key]
for i, v := range vs {
if v.id == versionID {
- o[key] = append(vs[:i:i], vs[i+1:]...)
- if len(o[key]) == 0 {
- delete(o, key)
+ rest := append(vs[:i:i], vs[i+1:]...)
+ if len(rest) == 0 {
+ delete(f.buckets[bucket], key)
+ } else {
+ f.buckets[bucket][key] = rest
}
return nil
}
}
- return nil // already gone: success
+ return nil
}
-func (f *VersionedBucket) PutIfAbsent(ctx context.Context, bucket, key string, body []byte) (string, error) {
+func (f *VersionedBucket) DeleteVersion(ctx context.Context, bucket, key, versionID string) error {
f.mu.Lock()
defer f.mu.Unlock()
- if err := f.fail("put", key); err != nil {
+ return f.deleteLocked(bucket, key, versionID)
+}
+
+func (f *VersionedBucket) putIfAbsentLocked(bucket, key string, body []byte) (string, error) {
+ if err := f.known(bucket); err != nil {
return "", err
}
- if _, err := f.objs(bucket); err != nil {
+ if err := f.fail("put", key); err != nil {
return "", err
}
if f.hasCurrent(bucket, key) {
@@ -668,67 +898,50 @@ func (f *VersionedBucket) PutIfAbsent(ctx context.Context, bucket, key string, b
return f.push(bucket, key, version{body: append([]byte(nil), body...), etag: md5hex(body)}), nil
}
+func (f *VersionedBucket) PutIfAbsent(ctx context.Context, bucket, key string, body []byte) (string, error) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ return f.putIfAbsentLocked(bucket, key, body)
+}
+
var _ lease.VersionedStore = (*VersionedBucket)(nil)
```
-- [ ] **Step 4: Run the contract against the fake**
+- [ ] **Step 4: Run**
Run: `go test ./internal/lease/leasetest/ -count=1 -race`
-Expected: PASS (every subtest).
+Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add internal/lease/leasetest/
-git commit -m "leasetest: the versioned bucket model and the VersionedStore contract suite (spec §13)"
+git commit -m "leasetest: the versioned bucket model and the VersionedStore contract (spec §13)"
```
---
-### Task 3: The S3 server over the fake, and `s3Mirror.ListVersions`
+### Task 3: The S3 server, and `versionedS3.ListVersions`
**Files:**
- Create: `internal/lease/leasetest/s3server.go`, `internal/lease/versioned_test.go`
-- Modify: `internal/lease/versioned.go` (add `s3Mirror.ListVersions`)
+- Modify: `internal/lease/versioned.go`
**Interfaces:**
-- Consumes: Tasks 1 and 2, and the existing `s3Mirror` in
- `internal/lease/mirror.go` (`clientFor`, the `byBucket` memo,
- `normalizeETag`).
- Produces:
- - `leasetest.NewS3Server(f *VersionedBucket) http.Handler`;
- - `func (m *s3Mirror) ListVersions(ctx, bucket, prefix string) ([]ObjectVersion, error)`;
- - the test helper `newWireMirror(t) (*s3Mirror, *leasetest.VersionedBucket)`
- in `versioned_test.go`.
-
-**The server handles path-style requests, `/{bucket}/{key...}`:**
-- `GET /{bucket}?versions&prefix=&key-marker=&version-id-marker=`
- returns a `ListVersionsResult` XML page. The marker pair resumes after
- the given (key, version id).
-- `GET /{bucket}/{key}?versionId=` returns the body. A missing version
- gets 404 with `<Code>NoSuchVersion</Code>`.
-- `PUT /{bucket}/{key}` with `x-amz-copy-source: /{srcBucket}/{escaped key}?versionId=…`
- is a copy:
- - `If-None-Match: *` makes it create-only (412 `PreconditionFailed`
- when a current version exists);
- - it returns `CopyObjectResult` XML with ETag, and the header
- `x-amz-version-id`.
-- `PUT /{bucket}/{key}` without the copy header is a put, with
- `If-None-Match: *` honoured. It returns header `x-amz-version-id`.
-- `DELETE /{bucket}/{key}?versionId=` deletes that version and returns
- 204. A missing version also returns 204. A DELETE **without**
- `versionId` adds a delete marker; the interface never sends one, but
- the server models it.
-- Anything else gets 405.
-
-Paging is built on the fake's `listPage`. The server uses the fake's
-`PageSize` and `FailListAfterPages`, and an injected failure returns 500
-`<Code>InternalError</Code>`.
-
-- [ ] **Step 1: Write the failing wire test**
+ - `leasetest.NewS3Server(f *VersionedBucket) (http.Handler, func() int)`.
+ The second value counts the requests served.
+ - `versionedS3.ListVersions`.
+
+The server keeps `f.mu` held for each request, and calls only the
+model's locked helpers (`listAll`, `pageOf`, `read`, `copyLocked`,
+`deleteLocked`, `putIfAbsentLocked`). It never calls an exported method
+that would take the lock again.
+
+- [ ] **Step 1: Write the failing wire test (external test package)**
```go
-package lease
+package lease_test
import (
"context"
@@ -739,50 +952,55 @@ import (
"github.com/aws/aws-sdk-go-v2/credentials"
awss3 "github.com/aws/aws-sdk-go-v2/service/s3"
+ "github.com/carlosframework/platform/internal/lease"
"github.com/carlosframework/platform/internal/lease/leasetest"
)
-// newWireMirror is an s3Mirror whose clients talk to NewS3Server over the
-// wire, so the production request building and response parsing are what
-// is tested.
-func newWireMirror(t *testing.T) (*s3Mirror, *leasetest.VersionedBucket) {
+func wireClient(t *testing.T, url string) *awss3.Client {
t.Helper()
- fb := leasetest.NewVersionedBucket()
- fb.AddBucket("data")
- fb.AddBucket("other")
- srv := httptest.NewServer(leasetest.NewS3Server(fb))
- t.Cleanup(srv.Close)
- c := awss3.New(awss3.Options{
- Region: "eu-west-1", BaseEndpoint: aws.String(srv.URL), UsePathStyle: true,
+ return awss3.New(awss3.Options{
+ Region: "eu-west-1", BaseEndpoint: aws.String(url), UsePathStyle: true,
Credentials: credentials.NewStaticCredentialsProvider("k", "s", ""),
RetryMaxAttempts: 1,
})
- return &s3Mirror{src: c, dst: c, byBucket: map[string]*awss3.Client{}}, fb
}
-func TestS3MirrorVersionedContract(t *testing.T) {
- leasetest.RunVersionedStoreContract(t, func(t *testing.T) (VersionedStore, *leasetest.VersionedBucket, string) {
- m, fb := newWireMirror(t)
- return m, fb, "data"
+// wireStore is a versionedS3 whose buckets "data" and "other" are served
+// by two separate servers over one model, so a wrong-region routing is a
+// visible failure.
+func wireStore(t *testing.T) (lease.VersionedStore, *leasetest.VersionedBucket, func() int) {
+ t.Helper()
+ m := leasetest.NewVersionedBucket()
+ m.AddBucket("data")
+ m.AddBucket("other")
+ h1, n1 := leasetest.NewS3Server(m)
+ h2, n2 := leasetest.NewS3Server(m)
+ s1, s2 := httptest.NewServer(h1), httptest.NewServer(h2)
+ t.Cleanup(s1.Close)
+ t.Cleanup(s2.Close)
+ st := lease.NewVersionedS3WithClients(map[string]*awss3.Client{
+ "data": wireClient(t, s1.URL), "other": wireClient(t, s2.URL),
})
+ return st, m, func() int { return n1() + n2() }
}
-func TestS3MirrorListVersionsPagesAcrossMarkers(t *testing.T) {
- m, fb := newWireMirror(t)
- fb.PageSize = 2
- for i := 0; i < 3; i++ {
- fb.Put("data", "p/a", []byte{byte(i)}) // three versions of one key span pages
- }
- fb.Put("data", "p/b", []byte("b"))
- vs, err := m.ListVersions(context.Background(), "data", "p/")
- if err != nil || len(vs) != 4 {
- t.Fatalf("ListVersions over pages = %d, %v; want 4", len(vs), err)
+func TestVersionedS3Contract(t *testing.T) {
+ leasetest.RunVersionedStoreContract(t, func(t *testing.T) (lease.VersionedStore, *leasetest.VersionedBucket, string, func() int) {
+ st, m, served := wireStore(t)
+ return st, m, "data", served
+ })
+}
+
+func TestVersionedS3RefusesUnboundBucket(t *testing.T) {
+ st, _, _ := wireStore(t)
+ if _, err := st.ListVersions(context.Background(), "unbound", ""); err == nil {
+ t.Fatal("listing an unbound bucket did not fail")
}
}
```
-Run: `go test ./internal/lease/ -run 'TestS3MirrorVersioned|TestS3MirrorListVersions' -count=1`
-Expected: FAIL (`undefined: leasetest.NewS3Server`).
+Run: `go test ./internal/lease/ -run 'TestVersionedS3' -count=1`
+Expected: FAIL, with `undefined: leasetest.NewS3Server`.
- [ ] **Step 2: Implement `s3server.go`**
@@ -791,11 +1009,15 @@ package leasetest
import (
"encoding/xml"
+ "errors"
"io"
"net/http"
"net/url"
"strings"
+ "sync/atomic"
"time"
+
+ "github.com/carlosframework/platform/internal/lease"
)
type xmlVersion struct {
@@ -835,12 +1057,29 @@ func s3err(w http.ResponseWriter, status int, code string) {
io.WriteString(w, "<Error><Code>"+code+"</Code><Message>"+code+"</Message></Error>")
}
+// writeErr maps a model error onto the S3 response real S3 gives.
+func writeErr(w http.ResponseWriter, err error) {
+ switch {
+ case errors.Is(err, lease.ErrMirrorNotFound):
+ s3err(w, http.StatusNotFound, "NoSuchVersion")
+ case errors.Is(err, lease.ErrIsDeleteMarker):
+ w.Header().Set("x-amz-delete-marker", "true")
+ s3err(w, http.StatusMethodNotAllowed, "MethodNotAllowed")
+ case errors.Is(err, lease.ErrDestinationExists), errors.Is(err, lease.ErrExists):
+ s3err(w, http.StatusPreconditionFailed, "PreconditionFailed")
+ default:
+ s3err(w, http.StatusInternalServerError, "InternalError")
+ }
+}
+
// NewS3Server speaks the subset of the S3 wire protocol VersionedStore uses,
-// path-style, over f.
-func NewS3Server(f *VersionedBucket) http.Handler {
- return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- path := strings.TrimPrefix(r.URL.EscapedPath(), "/")
- bucket, rawKey, _ := strings.Cut(path, "/")
+// path-style, over f, holding f.mu for each request. The second result
+// counts requests served.
+func NewS3Server(f *VersionedBucket) (http.Handler, func() int) {
+ var n atomic.Int64
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ n.Add(1)
+ bucket, rawKey, _ := strings.Cut(strings.TrimPrefix(r.URL.EscapedPath(), "/"), "/")
key, err := url.PathUnescape(rawKey)
if err != nil {
s3err(w, http.StatusBadRequest, "InvalidURI")
@@ -848,9 +1087,8 @@ func NewS3Server(f *VersionedBucket) http.Handler {
}
q := r.URL.Query()
f.mu.Lock()
- _, known := f.buckets[bucket]
- f.mu.Unlock()
- if !known {
+ defer f.mu.Unlock()
+ if err := f.known(bucket); err != nil {
s3err(w, http.StatusNotFound, "NoSuchBucket")
return
}
@@ -858,9 +1096,9 @@ func NewS3Server(f *VersionedBucket) http.Handler {
case r.Method == http.MethodGet && key == "" && q.Has("versions"):
f.serveList(w, bucket, q)
case r.Method == http.MethodGet && key != "":
- body, err := f.GetVersion(r.Context(), bucket, key, q.Get("versionId"))
+ body, err := f.read(bucket, key, q.Get("versionId"))
if err != nil {
- s3err(w, http.StatusNotFound, "NoSuchVersion")
+ writeErr(w, err)
return
}
w.Write(body)
@@ -868,36 +1106,40 @@ func NewS3Server(f *VersionedBucket) http.Handler {
f.serveCopy(w, r, bucket, key)
case r.Method == http.MethodPut:
body, _ := io.ReadAll(r.Body)
- f.mu.Lock()
- if r.Header.Get("If-None-Match") == "*" && f.hasCurrent(bucket, key) {
- f.mu.Unlock()
- s3err(w, http.StatusPreconditionFailed, "PreconditionFailed")
+ if r.Header.Get("If-None-Match") != "*" {
+ s3err(w, http.StatusNotImplemented, "NotImplemented") // VersionedStore only puts create-only
+ return
+ }
+ id, err := f.putIfAbsentLocked(bucket, key, body)
+ if err != nil {
+ writeErr(w, err)
return
}
- id := f.push(bucket, key, version{body: body, etag: md5hex(body)})
- f.mu.Unlock()
w.Header().Set("x-amz-version-id", id)
w.Header().Set("ETag", `"`+md5hex(body)+`"`)
- case r.Method == http.MethodDelete && key != "":
- if v := q.Get("versionId"); v != "" {
- _ = f.DeleteVersion(r.Context(), bucket, key, v)
- } else {
- f.BareDelete(bucket, key)
+ case r.Method == http.MethodDelete && key != "" && q.Get("versionId") != "":
+ if err := f.deleteLocked(bucket, key, q.Get("versionId")); err != nil {
+ writeErr(w, err)
+ return
}
w.WriteHeader(http.StatusNoContent)
default:
s3err(w, http.StatusMethodNotAllowed, "MethodNotAllowed")
}
})
+ return h, func() int { return int(n.Load()) }
}
func (f *VersionedBucket) serveList(w http.ResponseWriter, bucket string, q url.Values) {
- prefix, km, vm := q.Get("prefix"), q.Get("key-marker"), q.Get("version-id-marker")
- f.mu.Lock()
- // Resume after (km, vm): find its position in the full ordered listing.
- all, _, _, err := f.listPageAll(bucket, prefix)
+ if err := f.fail("list", q.Get("prefix")); err != nil {
+ writeErr(w, err)
+ return
+ }
+ all := f.listAll(bucket, q.Get("prefix"))
+ km, vm := q.Get("key-marker"), q.Get("version-id-marker")
start := 0
- if err == nil && km != "" {
+ if km != "" {
+ start = len(all) // a marker not found resumes at the end
for i, v := range all {
if v.Key == km && v.VersionID == vm {
start = i + 1
@@ -905,21 +1147,20 @@ func (f *VersionedBucket) serveList(w http.ResponseWriter, bucket string, q url.
}
}
}
- page := 0
- if start > 0 {
- page = (start + f.pageSize() - 1) / f.pageSize()
+ size := f.PageSize
+ if size <= 0 {
+ size = 1000
}
- var vs []objectRow
- var more bool
- if err == nil {
- vs, more, err = f.slice(all, start, page)
+ page := (start + size - 1) / size // pages already served before this one
+ vs, more, err := f.pageOf(all[start:], 0)
+ if f.FailListAfterPages > 0 && page >= f.FailListAfterPages {
+ err = errors.New("leasetest: injected listing failure")
}
- f.mu.Unlock()
if err != nil {
- s3err(w, http.StatusInternalServerError, "InternalError")
+ writeErr(w, err)
return
}
- res := listVersionsResult{Name: bucket, Prefix: prefix, KeyMarker: km, VersionIdMarker: vm, MaxKeys: f.pageSize(), IsTruncated: more}
+ res := listVersionsResult{Name: bucket, Prefix: q.Get("prefix"), KeyMarker: km, VersionIdMarker: vm, MaxKeys: size, IsTruncated: more}
for _, v := range vs {
lm := v.LastModified.UTC().Format(time.RFC3339)
if v.DeleteMarker {
@@ -929,8 +1170,7 @@ func (f *VersionedBucket) serveList(w http.ResponseWriter, bucket string, q url.
}
}
if more && len(vs) > 0 {
- last := vs[len(vs)-1]
- res.NextKeyMarker, res.NextVersionIdMarker = last.Key, last.VersionID
+ res.NextKeyMarker, res.NextVersionIdMarker = vs[len(vs)-1].Key, vs[len(vs)-1].VersionID
}
w.Header().Set("Content-Type", "application/xml")
xml.NewEncoder(w).Encode(res)
@@ -939,53 +1179,61 @@ func (f *VersionedBucket) serveList(w http.ResponseWriter, bucket string, q url.
func (f *VersionedBucket) serveCopy(w http.ResponseWriter, r *http.Request, bucket, key string) {
src := strings.TrimPrefix(r.Header.Get("x-amz-copy-source"), "/")
srcPath, rawQ, _ := strings.Cut(src, "?")
- sq, _ := url.ParseQuery(rawQ)
- sb, sk, _ := strings.Cut(srcPath, "/")
- srcKey, err := url.PathUnescape(sk)
+ sq, err := url.ParseQuery(rawQ)
if err != nil {
s3err(w, http.StatusBadRequest, "InvalidArgument")
return
}
- id, etag, err := f.CopyVersion(r.Context(), sb, srcKey, sq.Get("versionId"), bucket, key, r.Header.Get("If-None-Match") == "*")
- switch {
- case err == nil:
- case strings.Contains(err.Error(), "already exists"):
- s3err(w, http.StatusPreconditionFailed, "PreconditionFailed")
+ unesc, err := url.PathUnescape(srcPath)
+ if err != nil {
+ s3err(w, http.StatusBadRequest, "InvalidArgument")
return
- default:
- s3err(w, http.StatusNotFound, "NoSuchVersion")
+ }
+ sb, sk, _ := strings.Cut(unesc, "/")
+ id, etag, err := f.copyLocked(sb, sk, sq.Get("versionId"), bucket, key, r.Header.Get("If-None-Match") == "*")
+ if err != nil {
+ writeErr(w, err)
return
}
w.Header().Set("x-amz-version-id", id)
w.Header().Set("Content-Type", "application/xml")
- io.WriteString(w, `<CopyObjectResult><ETag>"`+etag+`"</ETag><LastModified>`+time.Now().UTC().Format(time.RFC3339)+`</LastModified></CopyObjectResult>`)
+ io.WriteString(w, `<CopyObjectResult><ETag>"`+etag+`"</ETag><LastModified>2026-10-05T00:00:00Z</LastModified></CopyObjectResult>`)
}
```
-**Two small additions to `bucket.go`** support the server: `pageSize()`,
-and a `listPageAll` and `slice` pair that splits the ordered listing.
-Implement them so that `ListVersions` and the server share **one**
-ordering and **one** page-failure rule (`FailListAfterPages`), with
-`objectRow = lease.ObjectVersion`. Keep `listPage` (Task 2) as a thin
-wrapper over them.
+The server rejects any literal `+` or unescaped `?` in `x-amz-copy-source`
+before unescaping, so a client that fails to escape fails the test. Add
+this check at the top of `serveCopy`. Allow `/` literally, since it
+separates the bucket from the key, and accept everything else
+percent-encoded:
-- [ ] **Step 3: Implement `s3Mirror.ListVersions` in `versioned.go`**
+```go
+ if strings.ContainsAny(srcPath, "+? ") {
+ s3err(w, http.StatusBadRequest, "InvalidArgument") // AWS's encoder escapes these; a raw one is a client bug
+ return
+ }
+```
+
+- [ ] **Step 3: Implement `versionedS3.ListVersions`, replacing the stub**
```go
-func (m *s3Mirror) ListVersions(ctx context.Context, bucket, prefix string) ([]ObjectVersion, error) {
- c := m.clientFor(bucket, m.src)
+func (s *versionedS3) ListVersions(ctx context.Context, bucket, prefix string) ([]ObjectVersion, error) {
+ c, err := s.client(bucket)
+ if err != nil {
+ return nil, err
+ }
p := awss3.NewListObjectVersionsPaginator(c, &awss3.ListObjectVersionsInput{
Bucket: aws.String(bucket), Prefix: aws.String(prefix),
- })
+ }) // StopOnDuplicateToken stays off: a key legitimately repeats across version pages
var out []ObjectVersion
for p.HasMorePages() {
page, err := p.NextPage(ctx)
if err != nil {
- return nil, fmt.Errorf("lease: list versions %s/%s: %w", bucket, prefix, err) // never partial
+ return nil, fmt.Errorf("lease: list versions %s/%s: %w", bucket, prefix, err)
}
for _, v := range page.Versions {
out = append(out, ObjectVersion{Key: aws.ToString(v.Key), VersionID: aws.ToString(v.VersionId),
- ETag: normalizeETag(aws.ToString(v.ETag)), Size: aws.ToInt64(v.Size),
+ ETag: strings.Trim(aws.ToString(v.ETag), `"`), Size: aws.ToInt64(v.Size),
IsLatest: aws.ToBool(v.IsLatest), LastModified: aws.ToTime(v.LastModified)})
}
for _, d := range page.DeleteMarkers {
@@ -993,229 +1241,233 @@ func (m *s3Mirror) ListVersions(ctx context.Context, bucket, prefix string) ([]O
IsLatest: aws.ToBool(d.IsLatest), DeleteMarker: true, LastModified: aws.ToTime(d.LastModified)})
}
}
- sortVersions(out) // key order; newest first within a key (S3 returns versions and markers in separate lists)
- return out, nil
-}
-
-// sortVersions orders a listing by key, then newest first: LastModified
-// descending, then the IsLatest entry first.
-func sortVersions(vs []ObjectVersion) {
- sort.SliceStable(vs, func(i, j int) bool {
- if vs[i].Key != vs[j].Key {
- return vs[i].Key < vs[j].Key
+ sort.SliceStable(out, func(i, j int) bool {
+ if out[i].Key != out[j].Key {
+ return out[i].Key < out[j].Key
}
- if vs[i].IsLatest != vs[j].IsLatest {
- return vs[i].IsLatest
+ if out[i].IsLatest != out[j].IsLatest {
+ return out[i].IsLatest
}
- return vs[i].LastModified.After(vs[j].LastModified)
+ return out[i].LastModified.After(out[j].LastModified) // ties: unspecified (Global Constraints)
})
+ return out, nil
}
```
-Add `"fmt"`, `"sort"`, `"github.com/aws/aws-sdk-go-v2/aws"` and
-`awss3 "github.com/aws/aws-sdk-go-v2/service/s3"` to `versioned.go`'s
-imports. `clientFor` and `normalizeETag` already exist in `mirror.go`. If
-`clientFor`'s signature differs from `clientFor(bucket string, fallback
-*awss3.Client) *awss3.Client`, use the existing one and note the
-difference in the report.
-
-The other four methods are still missing, so the contract test doesn't
-compile yet. Add temporary stubs that return
-`errors.New("not implemented")`, so that this task's two tests can run
-(`TestS3MirrorListVersionsPagesAcrossMarkers`, and the contract's two
-listing subtests via `-run`).
+Add `"sort"`, `"strings"` and `"github.com/aws/aws-sdk-go-v2/aws"` to
+the imports.
-- [ ] **Step 4: Run**
+- [ ] **Step 4: Run (the listing subtests only; the other methods are still stubs)**
-Run: `go test ./internal/lease/ -run 'TestS3MirrorListVersions|TestS3MirrorVersionedContract/versions|TestS3MirrorVersionedContract/listing' -count=1`
+Run: `go test ./internal/lease/ -run 'TestVersionedS3Contract/listing|TestVersionedS3RefusesUnboundBucket' -count=1`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
-git add internal/lease/versioned.go internal/lease/versioned_test.go internal/lease/leasetest/
-git commit -m "lease: ListVersions over S3 (paginated, never partial), tested over the wire"
+git add internal/lease/ internal/lease/leasetest/
+git commit -m "lease: ListVersions over region-bound S3 clients, tested over the wire"
```
---
-### Task 4: `GetVersion` and `CopyVersion` over S3
+### Task 4: Reading, streaming and copying by exact version
**Files:**
- Modify: `internal/lease/versioned.go`, `internal/lease/versioned_test.go`
-**Interfaces:**
-- Produces: `s3Mirror.GetVersion` and `s3Mirror.CopyVersion`, replacing
- the stubs.
-
-- [ ] **Step 1: Write a failing wire test for escaping**
+- [ ] **Step 1: Write a failing wire test for escaping (the server rejects raw `+`, `?` and spaces)**
```go
-func TestS3MirrorCopyEscapesKeyAndPinsVersion(t *testing.T) {
- m, fb := newWireMirror(t)
+func TestVersionedS3CopyEscapesLikeAWS(t *testing.T) {
+ st, m, _ := wireStore(t)
key := "src/a b+c%?d"
- v1 := fb.Put("data", key, []byte("old"))
- fb.Put("data", key, []byte("new"))
- if _, _, err := m.CopyVersion(context.Background(), "data", key, v1, "other", "dst/k", true); err != nil {
+ v1 := m.Put("data", key, []byte("old"))
+ m.Put("data", key, []byte("new"))
+ if _, _, err := st.CopyVersion(context.Background(), "data", key, v1, "other", "dst/k", true); err != nil {
t.Fatal(err)
}
- if cur, ok := fb.Current("other", "dst/k"); !ok || string(cur) != "old" {
+ if cur, ok := m.Current("other", "dst/k"); !ok || string(cur) != "old" {
t.Fatalf("copied %q, want the pinned old version", cur)
}
}
```
-Run: `go test ./internal/lease/ -run TestS3MirrorCopyEscapes -count=1`
-Expected: FAIL (`not implemented`).
+Run: `go test ./internal/lease/ -run TestVersionedS3CopyEscapes -count=1`
+Expected: FAIL (the stub).
-- [ ] **Step 2: Implement**
+- [ ] **Step 2: Implement `OpenVersion`, `GetVersion` and `CopyVersion`**
```go
-func (m *s3Mirror) GetVersion(ctx context.Context, bucket, key, versionID string) ([]byte, error) {
- out, err := m.clientFor(bucket, m.src).GetObject(ctx, &awss3.GetObjectInput{
- Bucket: aws.String(bucket), Key: aws.String(key), VersionId: aws.String(versionID)})
+// apiCode returns the smithy error code and HTTP status of err.
+func apiCode(err error) (string, int) {
+ var ae smithy.APIError
+ code := ""
+ if errors.As(err, &ae) {
+ code = ae.ErrorCode()
+ }
+ var re *smithyhttp.ResponseError
+ status := 0
+ if errors.As(err, &re) {
+ status = re.HTTPStatusCode()
+ }
+ return code, status
+}
+
+func mapReadErr(err error) error {
+ code, status := apiCode(err)
+ switch {
+ case code == "NoSuchKey" || code == "NoSuchVersion" || code == "NotFound" || status == http.StatusNotFound:
+ return ErrMirrorNotFound
+ case status == http.StatusMethodNotAllowed:
+ return ErrIsDeleteMarker // S3 answers 405 for a delete marker's version id
+ }
+ return err
+}
+
+func (s *versionedS3) OpenVersion(ctx context.Context, bucket, key, versionID string) (io.ReadCloser, error) {
+ if versionID == "" {
+ return nil, ErrNoVersionID
+ }
+ c, err := s.client(bucket)
if err != nil {
- if isNotFound(err) {
- return nil, ErrMirrorNotFound
+ return nil, err
+ }
+ out, err := c.GetObject(ctx, &awss3.GetObjectInput{Bucket: aws.String(bucket), Key: aws.String(key), VersionId: aws.String(versionID)})
+ if err != nil {
+ if m := mapReadErr(err); m != err {
+ return nil, m
}
return nil, fmt.Errorf("lease: get %s/%s@%s: %w", bucket, key, versionID, err)
}
- defer out.Body.Close()
- return io.ReadAll(out.Body)
+ return out.Body, nil
}
-func (m *s3Mirror) CopyVersion(ctx context.Context, srcBucket, srcKey, srcVersionID, dstBucket, dstKey string, createOnly bool) (string, string, error) {
+func (s *versionedS3) GetVersion(ctx context.Context, bucket, key, versionID string) ([]byte, error) {
+ rc, err := s.OpenVersion(ctx, bucket, key, versionID)
+ if err != nil {
+ return nil, err
+ }
+ defer rc.Close()
+ return io.ReadAll(rc)
+}
+
+func (s *versionedS3) CopyVersion(ctx context.Context, srcBucket, srcKey, srcVersionID, dstBucket, dstKey string, createOnly bool) (string, string, error) {
+ if srcVersionID == "" {
+ return "", "", ErrNoVersionID
+ }
+ if _, err := s.client(srcBucket); err != nil {
+ return "", "", err
+ }
+ c, err := s.client(dstBucket) // a copy executes in the destination's region
+ if err != nil {
+ return "", "", err
+ }
in := &awss3.CopyObjectInput{
Bucket: aws.String(dstBucket),
Key: aws.String(dstKey),
- CopySource: aws.String(srcBucket + "/" + url.PathEscape(srcKey) + "?versionId=" + url.QueryEscape(srcVersionID)),
+ CopySource: aws.String(httpbinding.EscapePath(srcBucket+"/"+srcKey, false) + "?versionId=" + url.QueryEscape(srcVersionID)),
}
if createOnly {
in.IfNoneMatch = aws.String("*")
}
- out, err := m.dst.CopyObject(ctx, in)
+ out, err := c.CopyObject(ctx, in)
if err != nil {
- if isPreconditionFailed(err) {
+ code, status := apiCode(err)
+ switch {
+ case createOnly && (code == "PreconditionFailed" || status == http.StatusPreconditionFailed):
return "", "", ErrDestinationExists
- }
- if isNotFound(err) {
- return "", "", ErrMirrorNotFound
+ default:
+ if m := mapReadErr(err); m != err {
+ return "", "", m
+ }
}
return "", "", fmt.Errorf("lease: copy %s/%s@%s -> %s/%s: %w", srcBucket, srcKey, srcVersionID, dstBucket, dstKey, err)
}
- var etag string
+ etag := ""
if out.CopyObjectResult != nil {
- etag = normalizeETag(aws.ToString(out.CopyObjectResult.ETag))
+ etag = strings.Trim(aws.ToString(out.CopyObjectResult.ETag), `"`)
}
return aws.ToString(out.VersionId), etag, nil
}
```
-**Escaping a key that contains `/`.** `url.PathEscape` escapes `/` as
-`%2F`, but S3 copy sources keep `/` literal inside the key. Escape each
-path segment with `url.PathEscape`, then join them with `/`.
-
-**Helpers:**
-- `isNotFound` should recognise smithy codes `NoSuchKey`,
- `NoSuchVersion` and `NotFound`, and HTTP 404.
-- `isPreconditionFailed` should recognise code `PreconditionFailed` and
- HTTP 412.
-
-If equivalents exist in `mirror.go` or `internal/release` (for example
-`s3NotFound` or `s3CASConflict`), reuse their logic rather than copying
-it. Shared helpers must not change behaviour for existing callers.
+Add these imports: `"net/http"`, `"net/url"`,
+`"github.com/aws/smithy-go"`,
+`smithyhttp "github.com/aws/smithy-go/transport/http"` and
+`"github.com/aws/smithy-go/encoding/httpbinding"`.
-**`IfNoneMatch` on `CopyObjectInput`:** if this SDK version lacks the
-field, set the `If-None-Match` header with a smithy middleware option on
-that one call, and say so in the report.
+A 409 from S3 maps to none of the sentinel errors. It is returned
+wrapped, as the Global Constraints require.
- [ ] **Step 3: Run**
-Run: `go test ./internal/lease/ -run 'TestS3MirrorCopyEscapes|TestS3MirrorVersionedContract/get|TestS3MirrorVersionedContract/create-only|TestS3MirrorVersionedContract/multipart' -count=1`
+Run: `go test ./internal/lease/ -run 'TestVersionedS3CopyEscapes|TestVersionedS3Contract/read|TestVersionedS3Contract/copy' -count=1`
Expected: PASS.
- [ ] **Step 4: Commit**
```bash
git add internal/lease/versioned.go internal/lease/versioned_test.go
-git commit -m "lease: GetVersion and CopyVersion by exact source version, create-only copies"
+git commit -m "lease: read, stream and copy by exact version (AWS copy-source escaping, marker 405)"
```
---
-### Task 5: `DeleteVersion` and `PutIfAbsent` over S3
+### Task 5: Deleting by version, and the create-only put
**Files:**
-- Modify: `internal/lease/versioned.go`, `internal/lease/versioned_test.go`
-
-**Interfaces:**
-- Produces: `s3Mirror.DeleteVersion` and `s3Mirror.PutIfAbsent`.
+- Modify: `internal/lease/versioned.go`
-- [ ] **Step 1: Write a failing wire test (a bare delete must never be sent)**
+- [ ] **Step 1: Implement**
```go
-func TestS3MirrorDeleteVersionNeverBare(t *testing.T) {
- m, fb := newWireMirror(t)
- fb.Put("data", "k", []byte("one"))
- if err := m.DeleteVersion(context.Background(), "data", "k", ""); err == nil {
- t.Fatal("DeleteVersion with an empty version id must refuse rather than send a bare delete")
- }
- if fb.LatestID("data", "k") == "" {
- t.Fatal("a delete marker or deletion happened")
- }
-}
-```
-
-Run: `go test ./internal/lease/ -run TestS3MirrorDeleteVersionNeverBare -count=1`
-Expected: FAIL.
-
-- [ ] **Step 2: Implement**
-
-```go
-var errNoVersion = errors.New("lease: a version id is required")
-
-func (m *s3Mirror) DeleteVersion(ctx context.Context, bucket, key, versionID string) error {
+func (s *versionedS3) DeleteVersion(ctx context.Context, bucket, key, versionID string) error {
if versionID == "" {
- return errNoVersion // never a bare-key delete (spec C3)
+ return ErrNoVersionID // never a bare-key delete (spec C3), and no request is sent
+ }
+ c, err := s.client(bucket)
+ if err != nil {
+ return err
}
- _, err := m.clientFor(bucket, m.dst).DeleteObject(ctx, &awss3.DeleteObjectInput{
- Bucket: aws.String(bucket), Key: aws.String(key), VersionId: aws.String(versionID)})
- if err != nil && !isNotFound(err) {
+ if _, err := c.DeleteObject(ctx, &awss3.DeleteObjectInput{Bucket: aws.String(bucket), Key: aws.String(key), VersionId: aws.String(versionID)}); err != nil {
+ if mapReadErr(err) == ErrMirrorNotFound {
+ return nil // already gone
+ }
return fmt.Errorf("lease: delete %s/%s@%s: %w", bucket, key, versionID, err)
}
return nil
}
-func (m *s3Mirror) PutIfAbsent(ctx context.Context, bucket, key string, body []byte) (string, error) {
- out, err := m.clientFor(bucket, m.src).PutObject(ctx, &awss3.PutObjectInput{
- Bucket: aws.String(bucket), Key: aws.String(key), Body: bytes.NewReader(body),
- IfNoneMatch: aws.String("*")})
+func (s *versionedS3) PutIfAbsent(ctx context.Context, bucket, key string, body []byte) (string, error) {
+ c, err := s.client(bucket)
+ if err != nil {
+ return "", err
+ }
+ out, err := c.PutObject(ctx, &awss3.PutObjectInput{Bucket: aws.String(bucket), Key: aws.String(key),
+ Body: bytes.NewReader(body), IfNoneMatch: aws.String("*")})
if err != nil {
- if isPreconditionFailed(err) {
+ if code, status := apiCode(err); code == "PreconditionFailed" || status == http.StatusPreconditionFailed {
return "", ErrExists
}
return "", fmt.Errorf("lease: put-if-absent %s/%s: %w", bucket, key, err)
}
return aws.ToString(out.VersionId), nil
}
-
-var _ VersionedStore = (*s3Mirror)(nil)
```
-Also make the fake's `DeleteVersion` refuse an empty version id the same
-way, returning an error, and add that case to the contract suite (Task
-2's file), so both implementations agree.
+Add the `"bytes"` import. Remove `errNotYet` and every stub.
-- [ ] **Step 3: Run the full contract over the wire, and the fake**
+- [ ] **Step 2: Run the whole contract, over the wire and against the model**
Run: `go test ./internal/lease/... -count=1 -race`
-Expected: PASS (the fake contract, the wire contract and the wire tests).
+Expected: PASS (both contracts and every wire test).
-- [ ] **Step 4: Commit**
+- [ ] **Step 3: Commit**
```bash
-git add internal/lease/ internal/lease/leasetest/
-git commit -m "lease: DeleteVersion (never bare) and PutIfAbsent over S3"
+git add internal/lease/versioned.go
+git commit -m "lease: delete by version (never bare, no request on empty id) and create-only put"
```
---
@@ -1223,95 +1475,93 @@ git commit -m "lease: DeleteVersion (never bare) and PutIfAbsent over S3"
### Task 6: IAM grants
**Files:**
-- Modify: `infra/modules/region-host/main.tf` (the `host_buckets` policy,
- around lines 153-232) and `infra/modules/region-remote-host/main.tf`
- (around lines 85-134).
+- Modify: `infra/modules/region-host/main.tf` and `infra/modules/region-remote-host/main.tf`
-- [ ] **Step 1: Add the statements**
+- [ ] **Step 1: Add the two statements to each module's box policy**
-In both policies' `Statement` lists, beside the existing data-bucket
-object statement, add these two statements, using each module's own
-data bucket ARN variable:
+In `region-host` the data bucket ARN is `var.data_bucket_arn`. In
+`region-remote-host` it is `aws_s3_bucket.data.arn`; confirm this
+against the module and use what it actually names.
```hcl
+ # Host lifecycle claim, Plan 2 (spec C3, §6.2, §6.5): exact-version
+ # list, read and delete on this box's OWN data bucket. Sibling-region
+ # moves/* grants are unchanged: the destination region retires its
+ # own staging (spec N13).
{
Effect = "Allow"
Action = ["s3:GetObjectVersion", "s3:DeleteObjectVersion"]
- Resource = ["${var.data_bucket_arn}/*"]
+ Resource = ["<DATA_BUCKET_ARN>/*"]
},
{
Effect = "Allow"
Action = ["s3:ListBucketVersions"]
- Resource = [var.data_bucket_arn]
+ Resource = ["<DATA_BUCKET_ARN>"]
},
```
-Leave the deployment bucket, the sibling `moves/*` staging statements
-and the store policies unchanged. Above the statements, add a comment
-naming spec C3 and Plan 2.
+Replace `<DATA_BUCKET_ARN>` with the module's own expression:
+`"${var.data_bucket_arn}/*"` and `var.data_bucket_arn` in `region-host`,
+and `"${aws_s3_bucket.data.arn}/*"` and `aws_s3_bucket.data.arn` in
+`region-remote-host`.
- [ ] **Step 2: Format and validate**
-Run: `tofu fmt -check infra/modules/region-host infra/modules/region-remote-host`. Then run
-`tofu -chdir=infra/modules/region-host validate` and the same for
-`region-remote-host`, if `tofu` is available. If it isn't, run
-`terraform fmt -check` instead, and record which one you used.
-Expected: no formatting diff, and validation passes. Without either
-tool, inspect the diff for HCL syntax and say so in the report.
+Run `tofu fmt -check` on both module directories. Then run
+`tofu -chdir=<module> init -backend=false && tofu -chdir=<module> validate`
+on each, if `tofu` is available. Otherwise use the same commands with
+`terraform`, and record which you used. If neither tool is available,
+review the HCL diff by eye and say so in the report.
- [ ] **Step 3: Commit**
```bash
git add infra/modules/region-host/main.tf infra/modules/region-remote-host/main.tf
-git commit -m "infra: box role may list, read and delete data-bucket object versions (claim Plan 2)"
+git commit -m "infra: box role lists, reads and deletes its own data bucket's object versions (claim Plan 2)"
```
---
### Task 7: The repository gate
-- [ ] **Step 1: Run the full gate (AGENTS.md)**
-
-```bash
-go build ./... && go vet ./... && gofmt -l . && go test ./... -count=1
-```
-Expected: build and vet are clean, `gofmt -l .` prints nothing, and every
-package passes. Confirm sandbox-only failures through `amadan ci status`.
-
-- [ ] **Step 2: Commit any formatting fixes**
-
-```bash
-git add -A internal/lease infra/modules
-git commit -m "lease: Plan 2 gate" || true
-```
+- [ ] **Step 1:** `go build ./... && go vet ./... && gofmt -l . && go test ./... -count=1`.
+ Expected: everything is clean. Confirm sandbox-only failures through
+ `amadan ci status`.
+- [ ] **Step 2:** `git add -A internal/lease infra/modules && git commit -m "lease: Plan 2 gate" || true`
---
## Rollout note
-The IAM change has to reach each deployment before Plans 4 and 6 run
-anything that calls these methods. Dev tracks `main`. Flagship applies
-through `carloku-infrastructure` at a release tag, and that apply must
-**target** the policy resources only: a whole-root apply touches
-`user_data` and restarts every box (memory: carloku infra split). Plan 2
-itself calls nothing in production, so merging it is inert.
+The IAM grants must reach each deployment before Plans 4 and 6 run
+anything that calls these methods.
+
+- **Dev** tracks `main`.
+- **Flagship** applies through `carloku-infrastructure` at a release
+ tag, and must **target** only the policy resources. A whole-root apply
+ touches `user_data` and restarts every box.
+
+Plan 2 calls nothing in production, so merging it changes nothing that
+runs.
## Self-review
-- **Spec coverage:**
- - C3, exact-version destructive calls: Tasks 1, 4 and 5;
- - §6.2, list every version and archive by version: Tasks 2 to 4;
- - §6.4a certificates, create-only: Task 5 `PutIfAbsent`;
- - §6.5, pinned reads: Task 4 `GetVersion`;
- - §13 rows 1 to 9 in the fake: Task 2;
- - IAM for §6.2 and §6.5: Task 6.
-- **Placeholders:** none. Two conditional instructions are explicit: the
- SDK `IfNoneMatch` fallback, and reusing the existing not-found and
- precondition helpers.
-- **Types:**
- - `ObjectVersion` and `VersionedStore` are used consistently.
- - `VersionedBucket`'s exported helpers (`Put`, `PutMultipart`,
- `BareDelete`, `LatestID`, `Current`, `AddBucket`, `PageSize`,
- `FailListAfterPages`, `FailOn`) are used by the contract and later
- plans.
- - `newWireMirror` is test-only.
+Each finding from the rev 1 review, and where it's fixed:
+
+| Finding | Fix |
+|---|---|
+| P1, import cycle | external `lease_test` package plus `export_test.go` |
+| P1, routing | explicit bucket-to-client binding, unbound buckets refused, two servers in the wire tests |
+| P2, escaping | `httpbinding.EscapePath`, plus a server that rejects raw `+`, `?` and spaces |
+| P2, failure injection | locked helpers, `writeErr` maps errors to statuses, and a contract subtest over every operation |
+| P2, ordering | the contract states that ties are unspecified, and tests rely only on `IsLatest` |
+| P2, delete markers | `ErrIsDeleteMarker` (405) in both implementations, with marker reads and copies tested |
+| P2, the false-positive safety test | request count plus a full state snapshot |
+| P2, sibling cleanup | assigned to the destination region; no sibling grants change |
+| P2, missing helpers | complete helpers, with locking stated |
+| P3, §13 coverage | the coverage table |
+| P3, consumer contract | `NewVersionedS3`, `OpenVersion`, and destination recovery via `ListVersions` |
+
+The types are consistent throughout: `VersionedStore`, `ObjectVersion`,
+the `Maker` signature, `NewS3Server` returning `(Handler, counter)`, and
+`NewVersionedS3WithClients`.