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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,8 +202,8 @@ cachew delete <namespace> <key>
cachew namespaces

# Directory snapshots
cachew snapshot <namespace> <key> <directory> [--ttl 1h] [--exclude pattern]
cachew restore <namespace> <key> <directory>
cachew save <namespace> <directory> [paths...] (--key <key> | -H <glob>) [--ttl 1h] [--exclude pattern]
cachew restore <namespace> <directory> (--key <key> | -H <glob>)

# Git
cachew git restore <repo-url> <directory> [--no-bundle]
Expand Down
113 changes: 77 additions & 36 deletions cmd/cachew/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ type CLI struct {
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:"`
Save SaveCmd `cmd:"" help:"Create compressed archive of directory and upload." group:"Snapshots:"`
Restore RestoreCmd `cmd:"" help:"Download and extract archive to directory." group:"Snapshots:"`

Git GitCmd `cmd:"" help:"Git-aware operations." group:"Git:"`
}
Expand Down Expand Up @@ -164,52 +164,106 @@ func (c *NamespacesCmd) Run(ctx context.Context, api *client.Client) error {
return nil
}

type SnapshotCmd struct {
Namespace client.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"`
type SaveCmd struct {
Namespace client.Namespace `arg:"" help:"Namespace for organizing cache objects."`
Directory string `arg:"" help:"Directory containing paths to archive." type:"path"`
Paths []string `arg:"" optional:"" help:"Paths within Directory to archive (default: \".\")."`

Key string `help:"Object key (hex or string)." xor:"cache-key" required:""`
HashFiles []string `short:"H" help:"Compute key from SHA256 of files matched by doublestar glob patterns (repeatable)." xor:"cache-key" required:""`

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, api *client.Client) error {
func (c *SaveCmd) Run(ctx context.Context, api *client.Client, cli *CLI) error {
key, display, err := resolveKey(cli, c.Key, c.HashFiles)
if err != nil {
return err
}
paths := c.Paths
if len(paths) == 0 {
paths = []string{"."}
}
fmt.Fprintf(os.Stderr, "Archiving %s...\n", c.Directory) //nolint:forbidigo
err := api.Namespace(c.Namespace).Save(ctx, c.Key.Key(), c.Directory, []string{"."},
err = api.Namespace(c.Namespace).Save(ctx, key, c.Directory, paths,
client.WithTTL(c.TTL),
client.WithExclude(c.Exclude...),
client.WithZstdThreads(c.ZstdThreads),
)
if err != nil {
return errors.Wrap(err, "failed to create snapshot")
return errors.Wrap(err, "failed to save")
}

fmt.Fprintf(os.Stderr, "Snapshot uploaded: %s\n", c.Key.String()) //nolint:forbidigo
fmt.Fprintf(os.Stderr, "Saved: %s\n", display) //nolint:forbidigo
return nil
}

type RestoreCmd struct {
Namespace client.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"`
Namespace client.Namespace `arg:"" help:"Namespace for organizing cache objects."`
Directory string `arg:"" help:"Target directory for extraction." type:"path"`

Key string `help:"Object key (hex or string)." xor:"cache-key" required:""`
HashFiles []string `short:"H" help:"Compute key from SHA256 of files matched by doublestar glob patterns (repeatable)." xor:"cache-key" required:""`

ZstdThreads int `help:"Threads for zstd decompression (0 = all CPU cores)." default:"0"`
}

func (c *RestoreCmd) Run(ctx context.Context, api *client.Client) error {
func (c *RestoreCmd) Run(ctx context.Context, api *client.Client, cli *CLI) error {
key, display, err := resolveKey(cli, c.Key, c.HashFiles)
if err != nil {
return err
}
fmt.Fprintf(os.Stderr, "Restoring to %s...\n", c.Directory) //nolint:forbidigo
hit, err := api.Namespace(c.Namespace).Restore(ctx, c.Key.Key(), c.Directory,
hit, err := api.Namespace(c.Namespace).Restore(ctx, key, c.Directory,
client.WithZstdThreads(c.ZstdThreads))
if err != nil {
return errors.Wrap(err, "failed to restore snapshot")
return errors.Wrap(err, "failed to restore")
}
if !hit {
return errors.Errorf("cache miss: %s", c.Key.String())
return errors.Errorf("cache miss: %s", display)
}

fmt.Fprintf(os.Stderr, "Snapshot restored: %s\n", c.Key.String()) //nolint:forbidigo
fmt.Fprintf(os.Stderr, "Restored: %s\n", display) //nolint:forbidigo
return nil
}

// resolveKey returns the final Key and a human-readable form for logging,
// using exactly one of key or hashFiles (enforced by kong's xor group), then
// applying any global --platform / --daily / --hourly prefixes.
func resolveKey(cli *CLI, key string, hashFiles []string) (client.Key, string, error) {
raw := key
if len(hashFiles) > 0 {
k, err := client.HashFiles(hashFiles...)
if err != nil {
return client.Key{}, "", errors.Wrap(err, "failed to hash files")
}
raw = k.String()
}
prefixed := applyKeyPrefixes(cli, raw)
var final client.Key
if err := final.UnmarshalText([]byte(prefixed)); err != nil {
return client.Key{}, "", errors.WithStack(err)
}
return final, prefixed, nil
}

func applyKeyPrefixes(cli *CLI, raw string) string {
prefixed := raw
if cli.Platform {
prefixed = fmt.Sprintf("%s-%s-%s", runtime.GOOS, runtime.GOARCH, prefixed)
}
now := time.Now()
switch {
case cli.Hourly:
prefixed = now.Format("2006-01-02-15-") + prefixed
case cli.Daily:
prefixed = now.Format("2006-01-02-") + prefixed
}
return prefixed
}

func getFilename(f *os.File) string {
info, err := f.Stat()
if err != nil {
Expand Down Expand Up @@ -243,18 +297,5 @@ func (pk *PlatformKey) String() string {
}

func (pk *PlatformKey) AfterApply(cli *CLI) error {
prefixed := pk.raw

if cli.Platform {
prefixed = fmt.Sprintf("%s-%s-%s", runtime.GOOS, runtime.GOARCH, prefixed)
}

now := time.Now()
if cli.Hourly {
prefixed = now.Format("2006-01-02-15-") + prefixed
} else if cli.Daily {
prefixed = now.Format("2006-01-02-") + prefixed
}

return errors.WithStack(pk.key.UnmarshalText([]byte(prefixed)))
return errors.WithStack(pk.key.UnmarshalText([]byte(applyKeyPrefixes(cli, pk.raw))))
}
55 changes: 55 additions & 0 deletions cmd/cachew/save_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package main

import (
"os"
"path/filepath"
"runtime"
"testing"
"time"

"github.com/alecthomas/assert/v2"

"github.com/block/cachew/client"
)

func TestResolveKey(t *testing.T) {
dir := t.TempDir()
sumPath := filepath.Join(dir, "go.sum")
assert.NoError(t, os.WriteFile(sumPath, []byte("v1"), 0o644))

hashKey, err := client.HashFiles(sumPath)
assert.NoError(t, err)

platform := runtime.GOOS + "-" + runtime.GOARCH + "-"
today := time.Now().Format("2006-01-02-")

tests := []struct {
name string
cli CLI
key string
hashFiles []string
wantDisplay string
}{
{name: "LiteralKey", key: "foo", wantDisplay: "foo"},
{name: "HashFiles", hashFiles: []string{sumPath}, wantDisplay: hashKey.String()},
{name: "LiteralKeyPlatformPrefix", cli: CLI{Platform: true}, key: "foo", wantDisplay: platform + "foo"},
{name: "HashFilesDailyPrefix", cli: CLI{Daily: true}, hashFiles: []string{sumPath}, wantDisplay: today + hashKey.String()},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
key, display, err := resolveKey(&tt.cli, tt.key, tt.hashFiles)
assert.NoError(t, err)
assert.Equal(t, tt.wantDisplay, display)

var want client.Key
assert.NoError(t, want.UnmarshalText([]byte(tt.wantDisplay)))
assert.Equal(t, want, key)
})
}
}

func TestResolveKeyHashFilesNoMatch(t *testing.T) {
_, _, err := resolveKey(&CLI{}, "", []string{filepath.Join(t.TempDir(), "missing-*")})
assert.Error(t, err)
}