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
14 changes: 1 addition & 13 deletions CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,4 @@
# The format is described: https://github.blog/2017-07-06-introducing-code-owners/

# These owners will be the default owners for everything in the repo.
* @alecthomas @js-murph


# -----------------------------------------------
# BELOW THIS LINE ARE TEMPLATES, UNUSED
# -----------------------------------------------
# Order is important. The last matching pattern has the most precedence.
# So if a pull request only touches javascript files, only these owners
# will be requested to review.
# *.js @octocat @github/js

# You can also use email addresses if you prefer.
# docs/* docs@example.com
* @block/cachew-team
193 changes: 190 additions & 3 deletions cmd/cachew/main.go
Original file line number Diff line number Diff line change
@@ -1,15 +1,202 @@
package main

import (
"context"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"runtime"
"time"

"github.com/alecthomas/errors"
"github.com/alecthomas/kong"

"github.com/block/cachew/internal/cache"
"github.com/block/cachew/internal/logging"
"github.com/block/cachew/internal/snapshot"
)

var cli struct {
logging.Config
type CLI struct {
LoggingConfig logging.Config `embed:"" prefix:"log-"`

URL string `help:"Remote cache server URL." default:"http://127.0.0.1:8080"`
Platform bool `help:"Prefix keys with platform ($${os}-$${arch}-)."`

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:"`

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:"`
}

func main() {
kong.Parse(&cli)
cli := CLI{}
kctx := kong.Parse(&cli, kong.UsageOnError(), kong.HelpOptions{Compact: true}, kong.DefaultEnvars("CACHEW"), kong.Bind(&cli))
ctx := context.Background()
_, ctx = logging.Configure(ctx, cli.LoggingConfig)

remote := cache.NewRemote(cli.URL)
defer remote.Close()

kctx.BindTo(ctx, (*context.Context)(nil))
kctx.BindTo(remote, (*cache.Cache)(nil))
kctx.FatalIfErrorf(kctx.Run(ctx))
}

type GetCmd struct {
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())
if err != nil {
return errors.Wrap(err, "failed to open object")
}
defer rc.Close()

for key, values := range headers {
for _, value := range values {
fmt.Fprintf(os.Stderr, "%s: %s\n", key, value) //nolint:forbidigo
}
}

_, err = io.Copy(c.Output, rc)
return errors.Wrap(err, "failed to copy data")
}

type StatCmd struct {
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())
if err != nil {
return errors.Wrap(err, "failed to stat object")
}

for key, values := range headers {
for _, value := range values {
fmt.Printf("%s: %s\n", key, value) //nolint:forbidigo
}
}

return nil
}

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)."`
}

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

headers := make(http.Header)
for key, value := range c.Headers {
headers.Set(key, value)
}

if filename := getFilename(c.Input); filename != "" {
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)
if err != nil {
return errors.Wrap(err, "failed to create object")
}

if _, err := io.Copy(wc, c.Input); err != nil {
return errors.Join(errors.Wrap(err, "failed to copy data"), wc.Close())
}

return errors.Wrap(wc.Close(), "failed to close writer")
}

type DeleteCmd struct {
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")
}

type SnapshotCmd struct {
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)."`
}

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 {
return errors.Wrap(err, "failed to create snapshot")
}

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

type RestoreCmd struct {
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 {
return errors.Wrap(err, "failed to restore snapshot")
}

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

func getFilename(f *os.File) string {
info, err := f.Stat()
if err != nil {
return ""
}

if !info.Mode().IsRegular() {
return ""
}

return f.Name()
}

// PlatformKey wraps a cache.Key and stores the original input for platform prefixing.
type PlatformKey struct {
raw string
key cache.Key
}

func (pk *PlatformKey) UnmarshalText(text []byte) error {
pk.raw = string(text)
return errors.WithStack(pk.key.UnmarshalText(text))
}

func (pk *PlatformKey) Key() cache.Key {
return pk.key
}

func (pk *PlatformKey) String() string {
return pk.key.String()
}

func (pk *PlatformKey) AfterApply(cli *CLI) error {
if !cli.Platform {
return nil
}
prefixed := fmt.Sprintf("%s-%s-%s", runtime.GOOS, runtime.GOARCH, pk.raw)
return errors.WithStack(pk.key.UnmarshalText([]byte(prefixed)))
}
16 changes: 9 additions & 7 deletions internal/cache/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,14 +80,16 @@ func NewKey(url string) Key { return Key(sha256.Sum256([]byte(url))) }
func (k *Key) String() string { return hex.EncodeToString(k[:]) }

func (k *Key) UnmarshalText(text []byte) error {
bytes, err := hex.DecodeString(string(text))
if err != nil {
return errors.WithStack(err)
}
if len(bytes) != len(*k) {
return errors.New("invalid key length")
// Try to decode as SHA256 hex encoded string
if len(text) == 64 {
bytes, err := hex.DecodeString(string(text))
if err == nil && len(bytes) == len(*k) {
copy(k[:], bytes)
return nil
}
}
copy(k[:], bytes)
// If not valid hex, treat as string and SHA256 it
*k = NewKey(string(text))
return nil
}

Expand Down
140 changes: 140 additions & 0 deletions internal/snapshot/snapshot.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
// Package snapshot provides streaming directory archival and restoration using tar and zstd.
package snapshot

import (
"bytes"
"context"
"fmt"
"net/http"
"os"
"os/exec"
"path/filepath"
"time"

"github.com/alecthomas/errors"

"github.com/block/cachew/internal/cache"
)

// Create archives a directory using tar with zstd compression, then uploads to the cache.
//
// The archive preserves all file permissions, ownership, and symlinks.
// The operation is fully streaming - no temporary files are created.
// Exclude patterns use tar's --exclude syntax.
func Create(ctx context.Context, remote cache.Cache, key cache.Key, directory string, ttl time.Duration, excludePatterns []string) error {
// Verify directory exists
if info, err := os.Stat(directory); err != nil {
return errors.Wrap(err, "failed to stat directory")
} else if !info.IsDir() {
return errors.Errorf("not a directory: %s", directory)
}

headers := make(http.Header)
headers.Set("Content-Type", "application/zstd")
headers.Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filepath.Base(directory)+".tar.zst"))

wc, err := remote.Create(ctx, key, headers, ttl)
if err != nil {
return errors.Wrap(err, "failed to create object")
}

tarArgs := []string{"-cpf", "-", "-C", directory}
for _, pattern := range excludePatterns {
tarArgs = append(tarArgs, "--exclude", pattern)
}
tarArgs = append(tarArgs, ".")

tarCmd := exec.CommandContext(ctx, "tar", tarArgs...)
zstdCmd := exec.CommandContext(ctx, "zstd", "-c", "-T0")

tarStdout, err := tarCmd.StdoutPipe()
if err != nil {
return errors.Join(errors.Wrap(err, "failed to create tar stdout pipe"), wc.Close())
}

var tarStderr, zstdStderr bytes.Buffer
tarCmd.Stderr = &tarStderr

zstdCmd.Stdin = tarStdout
zstdCmd.Stdout = wc
zstdCmd.Stderr = &zstdStderr

if err := tarCmd.Start(); err != nil {
return errors.Join(errors.Wrap(err, "failed to start tar"), wc.Close())
}

if err := zstdCmd.Start(); err != nil {
return errors.Join(errors.Wrap(err, "failed to start zstd"), tarCmd.Wait(), wc.Close())
}

tarErr := tarCmd.Wait()
zstdErr := zstdCmd.Wait()
closeErr := wc.Close()

var errs []error
if tarErr != nil {
errs = append(errs, errors.Errorf("tar failed: %w: %s", tarErr, tarStderr.String()))
}
if zstdErr != nil {
errs = append(errs, errors.Errorf("zstd failed: %w: %s", zstdErr, zstdStderr.String()))
}
if closeErr != nil {
errs = append(errs, errors.Wrap(closeErr, "failed to close writer"))
}

return errors.Join(errs...)
}

// Restore downloads an archive from the cache and extracts it to a directory.
//
// The archive is decompressed with zstd and extracted with tar, preserving
// all file permissions, ownership, and symlinks.
// The operation is fully streaming - no temporary files are created.
func Restore(ctx context.Context, remote cache.Cache, key cache.Key, directory string) error {
rc, _, err := remote.Open(ctx, key)
if err != nil {
return errors.Wrap(err, "failed to open object")
}
defer rc.Close()

// Create target directory if it doesn't exist
if err := os.MkdirAll(directory, 0o750); err != nil {
return errors.Wrap(err, "failed to create target directory")
}

zstdCmd := exec.CommandContext(ctx, "zstd", "-dc", "-T0")
tarCmd := exec.CommandContext(ctx, "tar", "-xpf", "-", "-C", directory)

zstdCmd.Stdin = rc
zstdStdout, err := zstdCmd.StdoutPipe()
if err != nil {
return errors.Wrap(err, "failed to create zstd stdout pipe")
}

var zstdStderr, tarStderr bytes.Buffer
zstdCmd.Stderr = &zstdStderr

tarCmd.Stdin = zstdStdout
tarCmd.Stderr = &tarStderr

if err := zstdCmd.Start(); err != nil {
return errors.Wrap(err, "failed to start zstd")
}

if err := tarCmd.Start(); err != nil {
return errors.Join(errors.Wrap(err, "failed to start tar"), zstdCmd.Wait())
}

zstdErr := zstdCmd.Wait()
tarErr := tarCmd.Wait()

var errs []error
if zstdErr != nil {
errs = append(errs, errors.Errorf("zstd failed: %w: %s", zstdErr, zstdStderr.String()))
}
if tarErr != nil {
errs = append(errs, errors.Errorf("tar failed: %w: %s", tarErr, tarStderr.String()))
}

return errors.Join(errs...)
}
Loading