objstore: Provisioner seam and in-memory fake
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3 files changed,
+267
−0
internal/objstore/memprovisioner.go+166 −0internal/objstore/memprovisioner_test.go+72 −0internal/objstore/provisioner.go+29 −0
diff --git a/internal/objstore/memprovisioner.go b/internal/objstore/memprovisioner.go| new file mode 100644 |
| index 000000000..50ec61215 |
| --- /dev/null |
| +++ b/internal/objstore/memprovisioner.go |
| @@ -0,0 +1,166 @@ |
| +package objstore |
| + |
| +import ( |
| + "context" |
| + "fmt" |
| + "sync" |
| +) |
| + |
| +// MemUser is the in-memory fake's view of an IAM principal: its ARN and its |
| +// live access keys (id -> secret). Exported so tests in this package can |
| +// assert on p.Users["u1"].Keys directly. |
| +type MemUser struct { |
| + ARN string |
| + Keys map[string]string |
| +} |
| + |
| +// MemProvisioner is an in-memory Provisioner for walk tests: no AWS, no |
| +// network, deterministic key ids. It stays in this package (internal) — |
| +// nothing outside objstore needs to reach into its state. |
| +type MemProvisioner struct { |
| + mu sync.Mutex |
| + region string |
| + nextKey int |
| + |
| + // Buckets maps bucket name -> object key -> body. |
| + Buckets map[string]map[string][]byte |
| + // Users maps user name -> MemUser. |
| + Users map[string]MemUser |
| + // FailNext, keyed by method name, is consumed one-shot: the next call |
| + // to that method fails with the given error, then the map entry is |
| + // gone and subsequent calls succeed normally. |
| + FailNext map[string]error |
| +} |
| + |
| +// NewMemProvisioner returns an empty fake that answers region for every |
| +// EnsureBucket call. |
| +func NewMemProvisioner(region string) *MemProvisioner { |
| + return &MemProvisioner{ |
| + region: region, |
| + Buckets: map[string]map[string][]byte{}, |
| + Users: map[string]MemUser{}, |
| + FailNext: map[string]error{}, |
| + } |
| +} |
| + |
| +// takeFailure consumes and returns FailNext[method], if any, under the |
| +// caller's lock. Consumption happens before any state change so a failed |
| +// call leaves state untouched. |
| +func (p *MemProvisioner) takeFailure(method string) error { |
| + if err, ok := p.FailNext[method]; ok { |
| + delete(p.FailNext, method) |
| + return err |
| + } |
| + return nil |
| +} |
| + |
| +func (p *MemProvisioner) EnsureBucket(ctx context.Context, name string) (string, error) { |
| + p.mu.Lock() |
| + defer p.mu.Unlock() |
| + if err := p.takeFailure("EnsureBucket"); err != nil { |
| + return "", err |
| + } |
| + if p.Buckets[name] == nil { |
| + p.Buckets[name] = map[string][]byte{} |
| + } |
| + return p.region, nil |
| +} |
| + |
| +func (p *MemProvisioner) EnsureUser(ctx context.Context, name, path, bucketARN string) (string, error) { |
| + p.mu.Lock() |
| + defer p.mu.Unlock() |
| + if err := p.takeFailure("EnsureUser"); err != nil { |
| + return "", err |
| + } |
| + if u, ok := p.Users[name]; ok { |
| + return u.ARN, nil |
| + } |
| + arn := "arn:mem:iam::user" + path + name |
| + p.Users[name] = MemUser{ARN: arn, Keys: map[string]string{}} |
| + return arn, nil |
| +} |
| + |
| +func (p *MemProvisioner) MintKey(ctx context.Context, user string) (string, string, error) { |
| + p.mu.Lock() |
| + defer p.mu.Unlock() |
| + if err := p.takeFailure("MintKey"); err != nil { |
| + return "", "", err |
| + } |
| + u, ok := p.Users[user] |
| + if !ok { |
| + return "", "", fmt.Errorf("objstore: mem: no such user %q", user) |
| + } |
| + p.nextKey++ |
| + id := fmt.Sprintf("MEMKEY%d", p.nextKey) |
| + secret := fmt.Sprintf("MEMSEC%d", p.nextKey) |
| + u.Keys[id] = secret |
| + return id, secret, nil |
| +} |
| + |
| +func (p *MemProvisioner) DeleteKey(ctx context.Context, user, id string) error { |
| + p.mu.Lock() |
| + defer p.mu.Unlock() |
| + if err := p.takeFailure("DeleteKey"); err != nil { |
| + return err |
| + } |
| + if u, ok := p.Users[user]; ok { |
| + delete(u.Keys, id) |
| + } |
| + return nil |
| +} |
| + |
| +func (p *MemProvisioner) ReapKeys(ctx context.Context, user string, keep []string) error { |
| + p.mu.Lock() |
| + defer p.mu.Unlock() |
| + if err := p.takeFailure("ReapKeys"); err != nil { |
| + return err |
| + } |
| + u, ok := p.Users[user] |
| + if !ok { |
| + return nil |
| + } |
| + keepSet := make(map[string]bool, len(keep)) |
| + for _, id := range keep { |
| + keepSet[id] = true |
| + } |
| + for id := range u.Keys { |
| + if !keepSet[id] { |
| + delete(u.Keys, id) |
| + } |
| + } |
| + return nil |
| +} |
| + |
| +func (p *MemProvisioner) EmptyBucket(ctx context.Context, name string) error { |
| + p.mu.Lock() |
| + defer p.mu.Unlock() |
| + if err := p.takeFailure("EmptyBucket"); err != nil { |
| + return err |
| + } |
| + if b, ok := p.Buckets[name]; ok { |
| + for k := range b { |
| + delete(b, k) |
| + } |
| + } |
| + return nil |
| +} |
| + |
| +func (p *MemProvisioner) DeleteBucket(ctx context.Context, name string) error { |
| + p.mu.Lock() |
| + defer p.mu.Unlock() |
| + if err := p.takeFailure("DeleteBucket"); err != nil { |
| + return err |
| + } |
| + delete(p.Buckets, name) |
| + return nil |
| +} |
| + |
| +func (p *MemProvisioner) DeleteUser(ctx context.Context, user string) error { |
| + p.mu.Lock() |
| + defer p.mu.Unlock() |
| + if err := p.takeFailure("DeleteUser"); err != nil { |
| + return err |
| + } |
| + delete(p.Users, user) |
| + return nil |
| +} |
diff --git a/internal/objstore/memprovisioner_test.go b/internal/objstore/memprovisioner_test.go| new file mode 100644 |
| index 000000000..de6e319f2 |
| --- /dev/null |
| +++ b/internal/objstore/memprovisioner_test.go |
| @@ -0,0 +1,72 @@ |
| +package objstore |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "testing" |
| +) |
| + |
| +func TestMemProvisionerIdempotence(t *testing.T) { |
| + ctx := context.Background() |
| + p := NewMemProvisioner("eu-west-1") |
| + for i := 0; i < 2; i++ { // every verb is safe to repeat |
| + if region, err := p.EnsureBucket(ctx, "b1"); err != nil || region != "eu-west-1" { |
| + t.Fatalf("EnsureBucket #%d: %q %v", i, region, err) |
| + } |
| + if arn, err := p.EnsureUser(ctx, "u1", "/carlos-store/", "arn:aws:s3:::b1"); err != nil || arn == "" { |
| + t.Fatalf("EnsureUser #%d: %v", i, err) |
| + } |
| + } |
| + id1, sec1, err := p.MintKey(ctx, "u1") |
| + if err != nil || id1 == "" || sec1 == "" { |
| + t.Fatal(err) |
| + } |
| + id2, _, err := p.MintKey(ctx, "u1") |
| + if err != nil || id2 == id1 { |
| + t.Fatalf("second mint: %q vs %q, %v", id2, id1, err) |
| + } |
| + if err := p.DeleteKey(ctx, "u1", id1); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := p.DeleteKey(ctx, "u1", id1); err != nil { // already gone == success |
| + t.Fatal(err) |
| + } |
| + id3, _, err := p.MintKey(ctx, "u1") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := p.ReapKeys(ctx, "u1", []string{id3}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := len(p.Users["u1"].Keys); got != 1 { // id2 reaped, id3 kept |
| + t.Fatalf("after reap: %d keys", got) |
| + } |
| + if err := p.EmptyBucket(ctx, "b1"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := p.DeleteBucket(ctx, "b1"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := p.DeleteBucket(ctx, "b1"); err != nil { // already gone == success |
| + t.Fatal(err) |
| + } |
| + if err := p.DeleteUser(ctx, "u1"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := p.DeleteUser(ctx, "u1"); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func TestMemProvisionerFailNext(t *testing.T) { |
| + ctx := context.Background() |
| + p := NewMemProvisioner("eu-west-1") |
| + boom := errors.New("boom") |
| + p.FailNext["EnsureBucket"] = boom |
| + if _, err := p.EnsureBucket(ctx, "b1"); !errors.Is(err, boom) { |
| + t.Fatalf("want injected failure, got %v", err) |
| + } |
| + if _, err := p.EnsureBucket(ctx, "b1"); err != nil { // one-shot |
| + t.Fatalf("retry should succeed: %v", err) |
| + } |
| +} |
diff --git a/internal/objstore/provisioner.go b/internal/objstore/provisioner.go| new file mode 100644 |
| index 000000000..8294ed823 |
| --- /dev/null |
| +++ b/internal/objstore/provisioner.go |
| @@ -0,0 +1,29 @@ |
| +package objstore |
| + |
| +import "context" |
| + |
| +// Provisioner is the backend seam (§3): everything the console needs a |
| +// backend to do, and nothing an app ever calls. Every method is idempotent — |
| +// "already exists" and "already gone" are successes — because every walk |
| +// that drives one must be re-runnable after a half-failure (§5). |
| +// |
| +// v1 ships the AWS implementation and this package's in-memory fake. A |
| +// Hetzner/R2/Minio provisioner arrives here later without touching a walk. |
| +type Provisioner interface { |
| + // EnsureBucket creates the bucket if absent and answers its region. |
| + EnsureBucket(ctx context.Context, name string) (region string, err error) |
| + // EnsureUser creates the credential principal if absent, scoped to |
| + // exactly bucketARN (ListBucket on it; Get/Put/DeleteObject under it). |
| + EnsureUser(ctx context.Context, name, path, bucketARN string) (arn string, err error) |
| + MintKey(ctx context.Context, user string) (id, secret string, err error) |
| + DeleteKey(ctx context.Context, user, id string) error |
| + // ReapKeys deletes every access key of user NOT named in keep. The grant |
| + // walk's crash-safety depends on it: a key minted before a failed |
| + // delivery is recorded nowhere, so only list-and-delete can find it. |
| + ReapKeys(ctx context.Context, user string, keep []string) error |
| + // EmptyBucket deletes every object; paginated and resumable. |
| + EmptyBucket(ctx context.Context, name string) error |
| + DeleteBucket(ctx context.Context, name string) error |
| + // DeleteUser removes the user's keys and inline policy first. |
| + DeleteUser(ctx context.Context, user string) error |
| +} |