carlos / platform Public

objstore: Provisioner seam and in-memory fake

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 6b537aefe99434ed42e78d12cec9dc09d0e204b3 parent e846b3d
3 files changed, +267 −0
  • internal/objstore/memprovisioner.go +166 −0
  • internal/objstore/memprovisioner_test.go +72 −0
  • internal/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
+}