Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 50 additions & 18 deletions cmd/cachew/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,11 @@ type CLI struct {
Daily bool `help:"Prefix keys with date ($${YYYY}-$${MM}-$${DD}-). Mutually exclusive with --hourly." xor:"timeprefix"`
Hourly bool `help:"Prefix keys with date and hour ($${YYYY}-$${MM}-$${DD}-$${HH}-). Mutually exclusive with --daily." xor:"timeprefix"`

Get GetCmd `cmd:"" help:"Download object from cache." group:"Operations:"`
Stat StatCmd `cmd:"" help:"Show metadata for cached object." group:"Operations:"`
Put PutCmd `cmd:"" help:"Upload object to cache." group:"Operations:"`
Delete DeleteCmd `cmd:"" help:"Remove object from cache." group:"Operations:"`
Get GetCmd `cmd:"" help:"Download object from cache." group:"Operations:"`
Stat StatCmd `cmd:"" help:"Show metadata for cached object." group:"Operations:"`
Put PutCmd `cmd:"" help:"Upload object to cache." group:"Operations:"`
Delete DeleteCmd `cmd:"" help:"Remove object from cache." group:"Operations:"`
Namespaces NamespacesCmd `cmd:"" help:"List available namespaces in cache." group:"Operations:"`

Snapshot SnapshotCmd `cmd:"" help:"Create compressed archive of directory and upload." group:"Snapshots:"`
Restore RestoreCmd `cmd:"" help:"Download and extract archive to directory." group:"Snapshots:"`
Expand All @@ -50,14 +51,16 @@ func main() {
}

type GetCmd struct {
Key PlatformKey `arg:"" help:"Object key (hex or string)."`
Output *os.File `short:"o" help:"Output file (default: stdout)." default:"-"`
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:"-"`
}

func (c *GetCmd) Run(ctx context.Context, cache cache.Cache) error {
defer c.Output.Close()

rc, headers, err := cache.Open(ctx, c.Key.Key())
namespacedCache := cache.Namespace(c.Namespace)
rc, headers, err := namespacedCache.Open(ctx, c.Key.Key())
if err != nil {
return errors.Wrap(err, "failed to open object")
}
Expand All @@ -74,11 +77,13 @@ func (c *GetCmd) Run(ctx context.Context, cache cache.Cache) error {
}

type StatCmd struct {
Key PlatformKey `arg:"" help:"Object key (hex or string)."`
Namespace string `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 {
headers, err := cache.Stat(ctx, c.Key.Key())
namespacedCache := cache.Namespace(c.Namespace)
headers, err := namespacedCache.Stat(ctx, c.Key.Key())
if err != nil {
return errors.Wrap(err, "failed to stat object")
}
Expand All @@ -93,10 +98,11 @@ func (c *StatCmd) Run(ctx context.Context, cache cache.Cache) error {
}

type PutCmd struct {
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."`
Headers map[string]string `short:"H" help:"Additional headers (key=value)."`
Namespace string `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."`
Headers map[string]string `short:"H" help:"Additional headers (key=value)."`
}

func (c *PutCmd) Run(ctx context.Context, cache cache.Cache) error {
Expand All @@ -111,7 +117,8 @@ func (c *PutCmd) Run(ctx context.Context, cache cache.Cache) error {
headers.Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filepath.Base(filename))) //nolint:perfsprint
}

wc, err := cache.Create(ctx, c.Key.Key(), headers, c.TTL)
namespacedCache := cache.Namespace(c.Namespace)
wc, err := namespacedCache.Create(ctx, c.Key.Key(), headers, c.TTL)
if err != nil {
return errors.Wrap(err, "failed to create object")
}
Expand All @@ -124,14 +131,36 @@ func (c *PutCmd) Run(ctx context.Context, cache cache.Cache) error {
}

type DeleteCmd struct {
Key PlatformKey `arg:"" help:"Object key (hex or string)."`
Namespace string `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 {
return errors.Wrap(cache.Delete(ctx, c.Key.Key()), "failed to delete object")
namespacedCache := cache.Namespace(c.Namespace)
return errors.Wrap(namespacedCache.Delete(ctx, c.Key.Key()), "failed to delete object")
}

type NamespacesCmd struct{}

func (c *NamespacesCmd) Run(ctx context.Context, cache cache.Cache) error {
namespaces, err := cache.ListNamespaces(ctx)
if err != nil {
return errors.Wrap(err, "failed to list namespaces")
}

if len(namespaces) == 0 {
fmt.Println("No namespaces found") //nolint:forbidigo
return nil
}

for _, ns := range namespaces {
fmt.Println(ns) //nolint:forbidigo
}
return nil
}

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."`
Expand All @@ -140,7 +169,8 @@ type SnapshotCmd struct {

func (c *SnapshotCmd) Run(ctx context.Context, cache cache.Cache) error {
fmt.Fprintf(os.Stderr, "Archiving %s...\n", c.Directory) //nolint:forbidigo
if err := snapshot.Create(ctx, cache, c.Key.Key(), c.Directory, c.TTL, c.Exclude); err != nil {
namespacedCache := cache.Namespace(c.Namespace)
if err := snapshot.Create(ctx, namespacedCache, c.Key.Key(), c.Directory, c.TTL, c.Exclude); err != nil {
return errors.Wrap(err, "failed to create snapshot")
}

Expand All @@ -149,13 +179,15 @@ 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"`
}

func (c *RestoreCmd) Run(ctx context.Context, cache cache.Cache) error {
fmt.Fprintf(os.Stderr, "Restoring to %s...\n", c.Directory) //nolint:forbidigo
if err := snapshot.Restore(ctx, cache, c.Key.Key(), c.Directory); err != nil {
namespacedCache := cache.Namespace(c.Namespace)
if err := snapshot.Restore(ctx, namespacedCache, c.Key.Key(), c.Directory); err != nil {
return errors.Wrap(err, "failed to restore snapshot")
}

Expand Down
5 changes: 5 additions & 0 deletions internal/cache/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,9 @@ type Stats struct {
type Cache interface {
// String describes the Cache implementation.
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
// Stat returns the headers of an existing object in the cache.
//
// Expired files MUST not be returned.
Expand All @@ -173,6 +176,8 @@ type Cache interface {
Delete(ctx context.Context, key Key) error
// Stats returns health and usage statistics for the cache.
Stats(ctx context.Context) (Stats, error)
// ListNamespaces returns all unique namespaces in the cache in order.
ListNamespaces(ctx context.Context) ([]string, error)
// Close the Cache.
Close() error
}
130 changes: 130 additions & 0 deletions internal/cache/cachetest/suite.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package cachetest

import (
"context"
"errors"
"io"
"net/http"
"os"
Expand Down Expand Up @@ -55,6 +56,18 @@ func Suite(t *testing.T, newCache func(t *testing.T) cache.Cache) {
t.Run("LastModified", func(t *testing.T) {
testLastModified(t, newCache(t))
})

t.Run("NamespaceIsolation", func(t *testing.T) {
testNamespaceIsolation(t, newCache(t))
})

t.Run("ListNamespaces", func(t *testing.T) {
testListNamespaces(t, newCache(t))
})

t.Run("NamespaceDelete", func(t *testing.T) {
testNamespaceDelete(t, newCache(t))
})
}

func testCreateAndOpen(t *testing.T, c cache.Cache) {
Expand Down Expand Up @@ -329,3 +342,120 @@ func testLastModified(t *testing.T, c cache.Cache) {

assert.Equal(t, explicitTime.Format(http.TimeFormat), headers2.Get("Last-Modified"))
}

func testNamespaceIsolation(t *testing.T, c cache.Cache) {
defer c.Close()
ctx := t.Context()

// Create namespace views
gitCache := c.Namespace("git")
gomodCache := c.Namespace("gomod")

// Create entries in different namespaces with same key
key := cache.NewKey("same-key")

// Write to git namespace
w, err := gitCache.Create(ctx, key, nil, time.Hour)
assert.NoError(t, err)
_, err = w.Write([]byte("git data"))
assert.NoError(t, err)
assert.NoError(t, w.Close())

// Write to gomod namespace
w, err = gomodCache.Create(ctx, key, nil, time.Hour)
assert.NoError(t, err)
_, err = w.Write([]byte("gomod data"))
assert.NoError(t, err)
assert.NoError(t, w.Close())

// Verify isolation - each namespace returns its own data
r, _, err := gitCache.Open(ctx, key)
assert.NoError(t, err)
gitData, err := io.ReadAll(r)
assert.NoError(t, err)
assert.Equal(t, "git data", string(gitData))
assert.NoError(t, r.Close())

r, _, err = gomodCache.Open(ctx, key)
assert.NoError(t, err)
gomodData, err := io.ReadAll(r)
assert.NoError(t, err)
assert.Equal(t, "gomod data", string(gomodData))
assert.NoError(t, r.Close())
}

func testListNamespaces(t *testing.T, c cache.Cache) {
defer c.Close()
ctx := t.Context()

// Initially no namespaces
namespaces, err := c.ListNamespaces(ctx)
if errors.Is(err, cache.ErrStatsUnavailable) {
t.Skip("Cache does not support ListNamespaces")
}
assert.NoError(t, err)
assert.Equal(t, 0, len(namespaces))

// Create entries in different namespaces
gitCache := c.Namespace("git")
gomodCache := c.Namespace("gomod")
hermitCache := c.Namespace("hermit")

for i, cacheNS := range []cache.Cache{gitCache, gomodCache, hermitCache} {
w, err := cacheNS.Create(ctx, cache.NewKey(string(rune('a'+i))), nil, time.Hour)
assert.NoError(t, err)
_, err = w.Write([]byte("data"))
assert.NoError(t, err)
assert.NoError(t, w.Close())
}

// Verify all namespaces are listed
namespaces, err = c.ListNamespaces(ctx)
assert.NoError(t, err)
assert.Equal(t, 3, len(namespaces))

nsMap := make(map[string]bool)
for _, ns := range namespaces {
nsMap[ns] = true
}
assert.True(t, nsMap["git"])
assert.True(t, nsMap["gomod"])
assert.True(t, nsMap["hermit"])
}

func testNamespaceDelete(t *testing.T, c cache.Cache) {
defer c.Close()
ctx := t.Context()

gitCache := c.Namespace("git")
gomodCache := c.Namespace("gomod")

key := cache.NewKey("test-key")

// Create entry in git namespace
w, err := gitCache.Create(ctx, key, nil, time.Hour)
assert.NoError(t, err)
_, err = w.Write([]byte("git data"))
assert.NoError(t, err)
assert.NoError(t, w.Close())

// Create entry in gomod namespace
w, err = gomodCache.Create(ctx, key, nil, time.Hour)
assert.NoError(t, err)
_, err = w.Write([]byte("gomod data"))
assert.NoError(t, err)
assert.NoError(t, w.Close())

// Delete from git namespace
err = gitCache.Delete(ctx, key)
assert.NoError(t, err)

// Verify git entry is gone
_, _, err = gitCache.Open(ctx, key)
assert.IsError(t, err, os.ErrNotExist)

// Verify gomod entry still exists
r, _, err := gomodCache.Open(ctx, key)
assert.NoError(t, err)
assert.NoError(t, r.Close())
}
Loading