From ac06edd470cf67bb14a2aa989be50647695fab2b Mon Sep 17 00:00:00 2001 From: Alec Thomas Date: Mon, 30 Mar 2026 21:05:10 +1100 Subject: [PATCH] feat: add Namespace type with validation and hardcode metadata S3 prefix Introduce cache.Namespace as a named string type with TextUnmarshaler-based validation. Names must match ^[a-zA-Z0-9][a-zA-Z0-9_-]*$, preventing collisions with the .metadata/ prefix used by the S3 metadata backend. - Replace string namespace parameters throughout the Cache interface and all implementations with the Namespace type - Remove configurable Prefix from metadatadb S3BackendConfig, hardcode .metadata as the prefix - Validate namespaces at API boundaries (HTTP handlers, strategy creation, CLI via Kong TextUnmarshaler) - Change Remote default namespace from "-" to "default" Co-authored-by: Claude Code --- cmd/cachew/main.go | 36 +++++++++++++++--------------- internal/cache/api.go | 37 ++++++++++++++++++++++++++++++- internal/cache/api_test.go | 40 ++++++++++++++++++++++++++++++++++ internal/cache/disk.go | 16 +++++++------- internal/cache/disk_metadb.go | 28 ++++++++++++------------ internal/cache/memory.go | 14 ++++++------ internal/cache/noop.go | 2 +- internal/cache/remote.go | 6 ++--- internal/cache/remote_test.go | 4 ++-- internal/cache/s3.go | 10 ++++----- internal/cache/tiered.go | 2 +- internal/metadatadb/s3.go | 21 ++++++++++-------- internal/metadatadb/s3_test.go | 2 -- internal/strategy/api.go | 9 +++++--- internal/strategy/apiv1.go | 24 ++++++++++++++++---- 15 files changed, 173 insertions(+), 78 deletions(-) create mode 100644 internal/cache/api_test.go diff --git a/cmd/cachew/main.go b/cmd/cachew/main.go index 903bcb4..97ff618 100644 --- a/cmd/cachew/main.go +++ b/cmd/cachew/main.go @@ -62,9 +62,9 @@ func main() { } type GetCmd struct { - Namespace string `arg:"" help:"Namespace for organizing cache objects."` - Key PlatformKey `arg:"" help:"Object key (hex or string)."` - Output *os.File `short:"o" help:"Output file (default: stdout)." default:"-"` + Namespace cache.Namespace `arg:"" help:"Namespace for organizing cache objects."` + Key PlatformKey `arg:"" help:"Object key (hex or string)."` + Output *os.File `short:"o" help:"Output file (default: stdout)." default:"-"` } func (c *GetCmd) Run(ctx context.Context, cache cache.Cache) error { @@ -88,8 +88,8 @@ func (c *GetCmd) Run(ctx context.Context, cache cache.Cache) error { } type StatCmd struct { - Namespace string `arg:"" help:"Namespace for organizing cache objects."` - Key PlatformKey `arg:"" help:"Object key (hex or string)."` + Namespace cache.Namespace `arg:"" help:"Namespace for organizing cache objects."` + Key PlatformKey `arg:"" help:"Object key (hex or string)."` } func (c *StatCmd) Run(ctx context.Context, cache cache.Cache) error { @@ -109,7 +109,7 @@ func (c *StatCmd) Run(ctx context.Context, cache cache.Cache) error { } type PutCmd struct { - Namespace string `arg:"" help:"Namespace for organizing cache objects."` + Namespace cache.Namespace `arg:"" help:"Namespace for organizing cache objects."` Key PlatformKey `arg:"" help:"Object key (hex or string)."` Input *os.File `arg:"" help:"Input file (default: stdin)." default:"-"` TTL time.Duration `help:"Time to live for the object."` @@ -142,8 +142,8 @@ func (c *PutCmd) Run(ctx context.Context, cache cache.Cache) error { } type DeleteCmd struct { - Namespace string `arg:"" help:"Namespace for organizing cache objects."` - Key PlatformKey `arg:"" help:"Object key (hex or string)."` + Namespace cache.Namespace `arg:"" help:"Namespace for organizing cache objects."` + Key PlatformKey `arg:"" help:"Object key (hex or string)."` } func (c *DeleteCmd) Run(ctx context.Context, cache cache.Cache) error { @@ -171,12 +171,12 @@ func (c *NamespacesCmd) Run(ctx context.Context, cache cache.Cache) error { } type SnapshotCmd struct { - Namespace string `arg:"" help:"Namespace for organizing cache objects."` - Key PlatformKey `arg:"" help:"Object key (hex or string)."` - Directory string `arg:"" help:"Directory to archive." type:"path"` - TTL time.Duration `help:"Time to live for the object."` - Exclude []string `help:"Patterns to exclude (tar --exclude syntax)."` - ZstdThreads int `help:"Threads for zstd compression (0 = all CPU cores)." default:"0"` + Namespace cache.Namespace `arg:"" help:"Namespace for organizing cache objects."` + Key PlatformKey `arg:"" help:"Object key (hex or string)."` + Directory string `arg:"" help:"Directory to archive." type:"path"` + TTL time.Duration `help:"Time to live for the object."` + Exclude []string `help:"Patterns to exclude (tar --exclude syntax)."` + ZstdThreads int `help:"Threads for zstd compression (0 = all CPU cores)." default:"0"` } func (c *SnapshotCmd) Run(ctx context.Context, cache cache.Cache) error { @@ -191,10 +191,10 @@ func (c *SnapshotCmd) Run(ctx context.Context, cache cache.Cache) error { } type RestoreCmd struct { - Namespace string `arg:"" help:"Namespace for organizing cache objects."` - Key PlatformKey `arg:"" help:"Object key (hex or string)."` - Directory string `arg:"" help:"Target directory for extraction." type:"path"` - ZstdThreads int `help:"Threads for zstd decompression (0 = all CPU cores)." default:"0"` + Namespace cache.Namespace `arg:"" help:"Namespace for organizing cache objects."` + Key PlatformKey `arg:"" help:"Object key (hex or string)."` + Directory string `arg:"" help:"Target directory for extraction." type:"path"` + ZstdThreads int `help:"Threads for zstd decompression (0 = all CPU cores)." default:"0"` } func (c *RestoreCmd) Run(ctx context.Context, cache cache.Cache) error { diff --git a/internal/cache/api.go b/internal/cache/api.go index f7788e5..de7a11a 100644 --- a/internal/cache/api.go +++ b/internal/cache/api.go @@ -8,12 +8,47 @@ import ( "io" "net/http" "os" + "regexp" "time" "github.com/alecthomas/errors" "github.com/alecthomas/hcl/v2" ) +var namespaceRe = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_-]*$`) + +// Namespace identifies a logical partition within a cache or metadata store. +// Valid names start with an alphanumeric character and contain only +// alphanumerics, hyphens, and underscores. +type Namespace string + +// ValidateNamespace checks that a namespace name is valid. +func ValidateNamespace(name string) error { + if !namespaceRe.MatchString(name) { + return errors.Errorf("invalid namespace %q: must match %s", name, namespaceRe) + } + return nil +} + +// ParseNamespace validates and returns a Namespace from a plain string. +func ParseNamespace(name string) (Namespace, error) { + if err := ValidateNamespace(name); err != nil { + return "", err + } + return Namespace(name), nil +} + +func (n *Namespace) String() string { return string(*n) } + +// UnmarshalText implements encoding.TextUnmarshaler with validation. +func (n *Namespace) UnmarshalText(text []byte) error { + if err := ValidateNamespace(string(text)); err != nil { + return err + } + *n = Namespace(text) + return nil +} + // ErrNotFound is returned when a cache backend is not found. var ErrNotFound = errors.New("cache backend not found") @@ -143,7 +178,7 @@ type Cache interface { String() string // Namespace creates a namespaced view of this cache. // All operations on the returned cache will use the given namespace prefix. - Namespace(namespace string) Cache + Namespace(namespace Namespace) Cache // Stat returns the headers of an existing object in the cache. // // Expired files MUST not be returned. diff --git a/internal/cache/api_test.go b/internal/cache/api_test.go new file mode 100644 index 0000000..b483d55 --- /dev/null +++ b/internal/cache/api_test.go @@ -0,0 +1,40 @@ +package cache_test + +import ( + "testing" + + "github.com/alecthomas/assert/v2" + + "github.com/block/cachew/internal/cache" +) + +func TestValidateNamespace(t *testing.T) { + tests := []struct { + name string + input string + valid bool + }{ + {name: "Simple", input: "git", valid: true}, + {name: "WithHyphen", input: "go-mod", valid: true}, + {name: "WithUnderscore", input: "go_mod", valid: true}, + {name: "WithNumbers", input: "v2cache", valid: true}, + {name: "UpperCase", input: "GitLFS", valid: true}, + {name: "Empty", input: "", valid: false}, + {name: "DotPrefix", input: ".metadata", valid: false}, + {name: "DotInMiddle", input: "go.mod", valid: false}, + {name: "Slash", input: "a/b", valid: false}, + {name: "Space", input: "a b", valid: false}, + {name: "HyphenPrefix", input: "-foo", valid: false}, + {name: "UnderscorePrefix", input: "_foo", valid: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := cache.ValidateNamespace(tt.input) + if tt.valid { + assert.NoError(t, err) + } else { + assert.Error(t, err) + } + }) + } +} diff --git a/internal/cache/disk.go b/internal/cache/disk.go index 79e7fca..31fc217 100644 --- a/internal/cache/disk.go +++ b/internal/cache/disk.go @@ -38,7 +38,7 @@ type DiskConfig struct { type Disk struct { logger *slog.Logger config DiskConfig - namespace string + namespace Namespace db *diskMetaDB size *atomic.Int64 runEviction chan struct{} @@ -287,12 +287,12 @@ func (d *Disk) Open(ctx context.Context, key Key) (io.ReadCloser, http.Header, e return f, headers, nil } -func (d *Disk) keyToPath(namespace string, key Key) string { +func (d *Disk) keyToPath(namespace Namespace, key Key) string { hexKey := key.String() // Use first two hex digits as directory, full hex as filename if namespace != "" { - return filepath.Join(namespace, hexKey[:2], hexKey) + return filepath.Join(string(namespace), hexKey[:2], hexKey) } return filepath.Join(hexKey[:2], hexKey) } @@ -320,7 +320,7 @@ func (d *Disk) evictionLoop(ctx context.Context) { } type evictFileInfo struct { - namespace string + namespace Namespace key Key path string size int64 @@ -329,7 +329,7 @@ type evictFileInfo struct { } type evictEntryKey struct { - namespace string + namespace Namespace key Key } @@ -338,7 +338,7 @@ func (d *Disk) evict() error { var expiredEntries []evictEntryKey now := time.Now() - err := d.db.walk(func(key Key, namespace string, expiresAt time.Time) error { + err := d.db.walk(func(key Key, namespace Namespace, expiresAt time.Time) error { path := d.keyToPath(namespace, key) fullPath := filepath.Join(d.config.Root, path) @@ -421,7 +421,7 @@ type diskWriter struct { disk *Disk file *os.File key Key - namespace string + namespace Namespace path string tempPath string expiresAt time.Time @@ -477,7 +477,7 @@ func (w *diskWriter) Close() error { } // Namespace creates a namespaced view of the disk cache. -func (d *Disk) Namespace(namespace string) Cache { +func (d *Disk) Namespace(namespace Namespace) Cache { // Create a shallow copy with the namespace set c := *d c.namespace = namespace diff --git a/internal/cache/disk_metadb.go b/internal/cache/disk_metadb.go index 47b28aa..ad384bf 100644 --- a/internal/cache/disk_metadb.go +++ b/internal/cache/disk_metadb.go @@ -22,16 +22,16 @@ var ( // diskMetaDB manages expiration times and headers for cache entries using bbolt. type diskMetaDB struct { db *bbolt.DB - namespacesCache sync.Map // map[string]bool - concurrent-safe + namespacesCache sync.Map // map[Namespace]bool - concurrent-safe } // compositeKey creates a unique database key from namespace and cache key. // Format: "namespace/hexkey" when namespace is set, or just "hexkey" when empty. -func compositeKey(namespace string, key Key) []byte { +func compositeKey(namespace Namespace, key Key) []byte { if namespace == "" { return []byte(key.String()) } - return []byte(namespace + "/" + key.String()) + return []byte(string(namespace) + "/" + key.String()) } // newDiskMetaDB creates a new bbolt-backed metadata storage for the disk cache. @@ -65,7 +65,7 @@ func newDiskMetaDB(dbPath string) (*diskMetaDB, error) { return ttlBucket.ForEach(func(k, _ []byte) error { namespace, _, found := bytes.Cut(k, []byte("/")) if found && len(namespace) > 0 { - metaDB.namespacesCache.Store(string(namespace), true) + metaDB.namespacesCache.Store(Namespace(namespace), true) } return nil }) @@ -77,7 +77,7 @@ func newDiskMetaDB(dbPath string) (*diskMetaDB, error) { return metaDB, nil } -func (s *diskMetaDB) setTTL(namespace string, key Key, expiresAt time.Time) error { +func (s *diskMetaDB) setTTL(namespace Namespace, key Key, expiresAt time.Time) error { ttlBytes, err := expiresAt.MarshalBinary() if err != nil { return errors.Errorf("failed to marshal TTL: %w", err) @@ -100,7 +100,7 @@ func (s *diskMetaDB) setTTL(namespace string, key Key, expiresAt time.Time) erro return nil } -func (s *diskMetaDB) set(key Key, namespace string, expiresAt time.Time, headers http.Header) error { +func (s *diskMetaDB) set(key Key, namespace Namespace, expiresAt time.Time, headers http.Header) error { ttlBytes, err := expiresAt.MarshalBinary() if err != nil { return errors.Errorf("failed to marshal TTL: %w", err) @@ -133,7 +133,7 @@ func (s *diskMetaDB) set(key Key, namespace string, expiresAt time.Time, headers return nil } -func (s *diskMetaDB) getTTL(namespace string, key Key) (time.Time, error) { +func (s *diskMetaDB) getTTL(namespace Namespace, key Key) (time.Time, error) { var expiresAt time.Time dbKey := compositeKey(namespace, key) err := s.db.View(func(tx *bbolt.Tx) error { @@ -147,7 +147,7 @@ func (s *diskMetaDB) getTTL(namespace string, key Key) (time.Time, error) { return expiresAt, errors.WithStack(err) } -func (s *diskMetaDB) getHeaders(namespace string, key Key) (http.Header, error) { +func (s *diskMetaDB) getHeaders(namespace Namespace, key Key) (http.Header, error) { var headers http.Header dbKey := compositeKey(namespace, key) err := s.db.View(func(tx *bbolt.Tx) error { @@ -161,7 +161,7 @@ func (s *diskMetaDB) getHeaders(namespace string, key Key) (http.Header, error) return headers, errors.WithStack(err) } -func (s *diskMetaDB) delete(namespace string, key Key) error { +func (s *diskMetaDB) delete(namespace Namespace, key Key) error { dbKey := compositeKey(namespace, key) return errors.WithStack(s.db.Update(func(tx *bbolt.Tx) error { ttlBucket := tx.Bucket(ttlBucketName) @@ -195,19 +195,19 @@ func (s *diskMetaDB) deleteAll(entries []evictEntryKey) error { })) } -func (s *diskMetaDB) walk(fn func(key Key, namespace string, expiresAt time.Time) error) error { +func (s *diskMetaDB) walk(fn func(key Key, namespace Namespace, expiresAt time.Time) error) error { return errors.WithStack(s.db.View(func(tx *bbolt.Tx) error { ttlBucket := tx.Bucket(ttlBucketName) if ttlBucket == nil { return nil } return ttlBucket.ForEach(func(k, v []byte) error { - var namespace string + var namespace Namespace var key Key before, hexKey, found := bytes.Cut(k, []byte("/")) if found { - namespace = string(before) + namespace = Namespace(before) } else { hexKey = k } @@ -251,8 +251,8 @@ func (s *diskMetaDB) close() error { func (s *diskMetaDB) listNamespaces() ([]string, error) { var namespaces []string s.namespacesCache.Range(func(key, _ any) bool { - if ns, ok := key.(string); ok { - namespaces = append(namespaces, ns) + if ns, ok := key.(Namespace); ok { + namespaces = append(namespaces, string(ns)) } return true }) diff --git a/internal/cache/memory.go b/internal/cache/memory.go index 10a553f..553733c 100644 --- a/internal/cache/memory.go +++ b/internal/cache/memory.go @@ -39,9 +39,9 @@ type memoryEntry struct { type Memory struct { config MemoryConfig - namespace string + namespace Namespace mu *sync.RWMutex - entries map[string]map[Key]*memoryEntry // namespace -> key -> entry + entries map[Namespace]map[Key]*memoryEntry // namespace -> key -> entry currentSize *atomic.Int64 } @@ -50,7 +50,7 @@ func NewMemory(ctx context.Context, config MemoryConfig) (*Memory, error) { return &Memory{ config: config, mu: &sync.RWMutex{}, - entries: make(map[string]map[Key]*memoryEntry), + entries: make(map[Namespace]map[Key]*memoryEntry), currentSize: &atomic.Int64{}, }, nil } @@ -169,7 +169,7 @@ func (m *Memory) Stats(_ context.Context) (Stats, error) { func (m *Memory) evictOldest(neededSpace int64) { type entryInfo struct { - namespace string + namespace Namespace key Key size int64 expiresAt time.Time @@ -209,7 +209,7 @@ func (m *Memory) evictOldest(neededSpace int64) { type memoryWriter struct { cache *Memory - namespace string + namespace Namespace key Key buf *bytes.Buffer expiresAt time.Time @@ -278,7 +278,7 @@ func (w *memoryWriter) Close() error { } // Namespace creates a namespaced view of the memory cache. -func (m *Memory) Namespace(namespace string) Cache { +func (m *Memory) Namespace(namespace Namespace) Cache { c := *m c.namespace = namespace return &c @@ -292,7 +292,7 @@ func (m *Memory) ListNamespaces(_ context.Context) ([]string, error) { namespaces := make([]string, 0, len(m.entries)) for ns := range m.entries { if ns != "" { - namespaces = append(namespaces, ns) + namespaces = append(namespaces, string(ns)) } } return namespaces, nil diff --git a/internal/cache/noop.go b/internal/cache/noop.go index c77fee1..26162a0 100644 --- a/internal/cache/noop.go +++ b/internal/cache/noop.go @@ -62,7 +62,7 @@ var _ Cache = (*noOpCache)(nil) var _ io.WriteCloser = (*noOpWriter)(nil) // Namespace creates a namespaced view (no-op for noop cache). -func (n *noOpCache) Namespace(_ string) Cache { +func (n *noOpCache) Namespace(_ Namespace) Cache { return n } diff --git a/internal/cache/remote.go b/internal/cache/remote.go index c2e8d09..f0ed89f 100644 --- a/internal/cache/remote.go +++ b/internal/cache/remote.go @@ -15,13 +15,13 @@ import ( "github.com/block/cachew/internal/httputil" ) -const defaultNamespace = "-" +const defaultNamespace Namespace = "default" // Remote implements Cache as a client for the remote cache server. type Remote struct { baseURL string client *http.Client - namespace string + namespace Namespace } var _ Cache = (*Remote)(nil) @@ -275,7 +275,7 @@ func (wc *writeCloser) Close() error { } // Namespace creates a namespaced view of the remote cache. -func (c *Remote) Namespace(namespace string) Cache { +func (c *Remote) Namespace(namespace Namespace) Cache { return &Remote{ baseURL: c.baseURL, client: c.client, diff --git a/internal/cache/remote_test.go b/internal/cache/remote_test.go index 942047f..d9d389d 100644 --- a/internal/cache/remote_test.go +++ b/internal/cache/remote_test.go @@ -33,7 +33,7 @@ func TestRemoteCache(t *testing.T) { t.Cleanup(ts.Close) client := cache.NewRemote(ts.URL, nil) - return client + return client.Namespace("test") }) } @@ -57,7 +57,7 @@ func TestRemoteCacheSoak(t *testing.T) { ts := httptest.NewServer(mux) defer ts.Close() - client := cache.NewRemote(ts.URL, nil) + client := cache.NewRemote(ts.URL, nil).Namespace("test") defer client.Close() cachetest.Soak(t, client, cachetest.SoakConfig{ diff --git a/internal/cache/s3.go b/internal/cache/s3.go index b314c29..5b66a6a 100644 --- a/internal/cache/s3.go +++ b/internal/cache/s3.go @@ -47,7 +47,7 @@ type S3Config struct { type S3 struct { logger *slog.Logger config S3Config - namespace string + namespace Namespace client *minio.Client } @@ -107,12 +107,12 @@ func (s *S3) Close() error { return nil } -func (s *S3) keyToPath(namespace string, key Key) string { +func (s *S3) keyToPath(namespace Namespace, key Key) string { hexKey := key.String() prefix := "" if namespace != "" { - prefix = namespace + "/" + prefix = string(namespace) + "/" } // Use first two hex digits as directory, full hex as filename @@ -336,7 +336,7 @@ func (s *S3) Stats(_ context.Context) (Stats, error) { type s3Writer struct { s3 *S3 key Key - namespace string + namespace Namespace pipe *io.PipeWriter expiresAt time.Time headers http.Header @@ -445,7 +445,7 @@ func (w *s3Writer) upload(pr *io.PipeReader, r io.Reader) { } // Namespace creates a namespaced view of the S3 cache. -func (s *S3) Namespace(namespace string) Cache { +func (s *S3) Namespace(namespace Namespace) Cache { c := *s c.namespace = namespace return &c diff --git a/internal/cache/tiered.go b/internal/cache/tiered.go index f911a73..dbd65f3 100644 --- a/internal/cache/tiered.go +++ b/internal/cache/tiered.go @@ -301,7 +301,7 @@ func (t tieredWriter) Write(p []byte) (n int, err error) { // Namespace creates a namespaced view of the tiered cache. // All underlying caches are also namespaced. -func (t Tiered) Namespace(namespace string) Cache { +func (t Tiered) Namespace(namespace Namespace) Cache { namespaced := make([]Cache, len(t.caches)) for i, c := range t.caches { namespaced[i] = c.Namespace(namespace) diff --git a/internal/metadatadb/s3.go b/internal/metadatadb/s3.go index cc87973..55dd396 100644 --- a/internal/metadatadb/s3.go +++ b/internal/metadatadb/s3.go @@ -32,7 +32,6 @@ func RegisterS3(r *Registry, clientProvider s3client.ClientProvider) { type S3Backend struct { client *minio.Client bucket string - prefix string lockTTL time.Duration syncInterval time.Duration mu sync.Mutex @@ -44,15 +43,16 @@ type S3Backend struct { // S3BackendConfig configures the S3 metadata backend. type S3BackendConfig struct { Bucket string `hcl:"bucket" help:"S3 bucket name."` - Prefix string `hcl:"prefix,optional" help:"Key prefix for metadata objects." default:"_meta"` LockTTL time.Duration `hcl:"lock-ttl,optional" help:"TTL for namespace locks." default:"30s"` SyncInterval time.Duration `hcl:"sync-interval,optional" help:"Interval between periodic syncs." default:"30s"` } +// s3MetadataPrefix is the fixed key prefix for all metadata objects in S3. +// It starts with "." to avoid collisions with cache namespaces, which are +// validated to not start with ".". +const s3MetadataPrefix = ".metadata" + func NewS3Backend(ctx context.Context, clientProvider s3client.ClientProvider, config S3BackendConfig) (*S3Backend, error) { - if config.Prefix == "" { - config.Prefix = "_meta" - } if config.LockTTL == 0 { config.LockTTL = 30 * time.Second } @@ -72,13 +72,12 @@ func NewS3Backend(ctx context.Context, clientProvider s3client.ClientProvider, c } logging.FromContext(ctx).InfoContext(ctx, "Constructing S3 metadata backend", - "bucket", config.Bucket, "prefix", config.Prefix, "lock-ttl", config.LockTTL, "sync-interval", config.SyncInterval) + "bucket", config.Bucket, "prefix", s3MetadataPrefix, "lock-ttl", config.LockTTL, "sync-interval", config.SyncInterval) ctx, cancel := context.WithCancel(ctx) return &S3Backend{ client: client, bucket: config.Bucket, - prefix: config.Prefix, lockTTL: config.LockTTL, syncInterval: config.SyncInterval, ns: make(map[string]*s3Namespace), @@ -139,8 +138,12 @@ func (s *S3Backend) Close(_ context.Context) error { // S3 object key helpers -func (s *S3Backend) stateKey(namespace string) string { return s.prefix + "/" + namespace + ".json" } -func (s *S3Backend) lockKey(namespace string) string { return s.prefix + "/" + namespace + ".lock" } +func (s *S3Backend) stateKey(namespace string) string { + return s3MetadataPrefix + "/" + namespace + ".json" +} +func (s *S3Backend) lockKey(namespace string) string { + return s3MetadataPrefix + "/" + namespace + ".lock" +} // S3 load/store/lock/unlock diff --git a/internal/metadatadb/s3_test.go b/internal/metadatadb/s3_test.go index 682ea04..e7f2654 100644 --- a/internal/metadatadb/s3_test.go +++ b/internal/metadatadb/s3_test.go @@ -25,7 +25,6 @@ func TestS3Backend(t *testing.T) { for i := range backends { b, err := metadatadb.NewS3Backend(ctx, s3client.ClientProvider(func() (*minio.Client, error) { return s3clienttest.Client(t), nil }), metadatadb.S3BackendConfig{ Bucket: bucket, - Prefix: "_meta-" + t.Name(), LockTTL: 5 * time.Second, SyncInterval: time.Hour, }) @@ -42,7 +41,6 @@ func TestS3BackendSoak(t *testing.T) { b, err := metadatadb.NewS3Backend(ctx, s3client.ClientProvider(func() (*minio.Client, error) { return s3clienttest.Client(t), nil }), metadatadb.S3BackendConfig{ Bucket: bucket, - Prefix: "_meta-soak", LockTTL: 5 * time.Second, SyncInterval: time.Hour, }) diff --git a/internal/strategy/api.go b/internal/strategy/api.go index 6994e72..ea52b1b 100644 --- a/internal/strategy/api.go +++ b/internal/strategy/api.go @@ -90,13 +90,16 @@ func (r *Registry) Create( ctx context.Context, name string, config *hcl.Block, - cache cache.Cache, + c cache.Cache, mux Mux, vars map[string]string, ) (Strategy, error) { + ns, err := cache.ParseNamespace(name) + if err != nil { + return nil, errors.Errorf("strategy %q: %w", name, err) + } if entry, ok := r.registry[name]; ok { - // Create a namespaced view of the cache for this strategy - namespacedCache := cache.Namespace(name) + namespacedCache := c.Namespace(ns) return errors.WithStack2(entry.factory(ctx, config, namespacedCache, mux, vars)) } return nil, errors.Errorf("%s: %w", name, ErrNotFound) diff --git a/internal/strategy/apiv1.go b/internal/strategy/apiv1.go index c1e075e..85e77d2 100644 --- a/internal/strategy/apiv1.go +++ b/internal/strategy/apiv1.go @@ -46,7 +46,11 @@ func NewAPIV1(ctx context.Context, _ struct{}, cache cache.Cache, mux Mux) (*API func (d *APIV1) String() string { return "default" } func (d *APIV1) statObject(w http.ResponseWriter, r *http.Request) { - namespace := r.PathValue("namespace") + namespace, err := cache.ParseNamespace(r.PathValue("namespace")) + if err != nil { + d.httpError(w, http.StatusBadRequest, err, "Invalid namespace") + return + } key, err := cache.ParseKey(r.PathValue("key")) if err != nil { d.httpError(w, http.StatusBadRequest, err, "Invalid key") @@ -69,7 +73,11 @@ func (d *APIV1) statObject(w http.ResponseWriter, r *http.Request) { } func (d *APIV1) getObject(w http.ResponseWriter, r *http.Request) { - namespace := r.PathValue("namespace") + namespace, err := cache.ParseNamespace(r.PathValue("namespace")) + if err != nil { + d.httpError(w, http.StatusBadRequest, err, "Invalid namespace") + return + } key, err := cache.ParseKey(r.PathValue("key")) if err != nil { d.httpError(w, http.StatusBadRequest, err, "Invalid key") @@ -99,7 +107,11 @@ func (d *APIV1) getObject(w http.ResponseWriter, r *http.Request) { } func (d *APIV1) putObject(w http.ResponseWriter, r *http.Request) { - namespace := r.PathValue("namespace") + namespace, err := cache.ParseNamespace(r.PathValue("namespace")) + if err != nil { + d.httpError(w, http.StatusBadRequest, err, "Invalid namespace") + return + } key, err := cache.ParseKey(r.PathValue("key")) if err != nil { d.httpError(w, http.StatusBadRequest, err, "Invalid key") @@ -138,7 +150,11 @@ func (d *APIV1) putObject(w http.ResponseWriter, r *http.Request) { } func (d *APIV1) deleteObject(w http.ResponseWriter, r *http.Request) { - namespace := r.PathValue("namespace") + namespace, err := cache.ParseNamespace(r.PathValue("namespace")) + if err != nil { + d.httpError(w, http.StatusBadRequest, err, "Invalid namespace") + return + } key, err := cache.ParseKey(r.PathValue("key")) if err != nil { d.httpError(w, http.StatusBadRequest, err, "Invalid key")