package cmd
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"net/http"
"os"
"strings"
"github.com/spf13/cobra"
)
var (
flagDownloadURL string
flagDownloadSHA256 string
flagForce bool
)
var downloadCmd = &cobra.Command{
Use: "download <key> <local-path>",
Short: "Download a cached file; fall back to --url on cache miss",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
cli, cfg, err := client()
if err != nil {
return err
}
key, err := resolveKey(args[0], cfg)
if err != nil {
return err
}
dest := args[1]
ctx := context.Background()
exists, err := cli.Exists(ctx, key)
if err != nil {
return err
}
if exists {
fmt.Fprintf(cmd.ErrOrStderr(), "Cache HIT — %s\n", key)
rc, err := cli.Get(ctx, key)
if err != nil {
return err
}
defer rc.Close()
return writeWithSHA(dest, rc, flagDownloadSHA256)
}
fmt.Fprintf(cmd.ErrOrStderr(), "Cache MISS — %s\n", key)
if flagDownloadURL == "" {
return fmt.Errorf("%w: %s", ErrMissNoFallback, key)
}
fmt.Fprintf(cmd.ErrOrStderr(), "Fetching %s\n", flagDownloadURL)
body, err := httpGet(flagDownloadURL)
if err != nil {
return err
}
defer body.Close()
// Tee to a tempfile so we can verify the checksum before re-reading
// for the upload step. Pure-streaming up to s3 + sha256 verify is
// possible but complicates rewind on mismatch — tempfile keeps it
// honest at the cost of one extra disk pass.
tmp, err := os.CreateTemp("", "cacher-dl-*")
if err != nil {
return err
}
defer os.Remove(tmp.Name())
defer tmp.Close()
if _, err := io.Copy(tmp, body); err != nil {
return err
}
if _, err := tmp.Seek(0, io.SeekStart); err != nil {
return err
}
if err := writeWithSHA(dest, tmp, flagDownloadSHA256); err != nil {
return err
}
// Re-open the verified destination file for upload to S3 so callers
// see the cached file on next run.
f, err := os.Open(dest)
if err != nil {
return err
}
defer f.Close()
if err := cli.Put(ctx, key, f); err != nil {
fmt.Fprintf(cmd.ErrOrStderr(), "warn: upload to S3 failed: %v\n", err)
// Local download succeeded — don't fail the build just because
// of an upload glitch.
return nil
}
fmt.Fprintf(cmd.ErrOrStderr(), "Cached → %s\n", key)
return nil
},
}
var uploadCmd = &cobra.Command{
Use: "upload <key> <local-path>",
Short: "Upload a local file to the cache",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
cli, cfg, err := client()
if err != nil {
return err
}
key, err := resolveKey(args[0], cfg)
if err != nil {
return err
}
ctx := context.Background()
if !flagForce {
exists, err := cli.Exists(ctx, key)
if err != nil {
return err
}
if exists {
fmt.Fprintf(cmd.ErrOrStderr(), "Skipped — %s already present (use --force to overwrite)\n", key)
return nil
}
}
f, err := os.Open(args[1])
if err != nil {
return err
}
defer f.Close()
if err := cli.Put(ctx, key, f); err != nil {
return err
}
fmt.Fprintf(cmd.ErrOrStderr(), "Uploaded → %s\n", key)
return nil
},
}
var existsCmd = &cobra.Command{
Use: "exists <key>",
Short: "Exit 0 if key exists, 1 if missing, 2 on error",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
cli, cfg, err := client()
if err != nil {
return err
}
key, err := resolveKey(args[0], cfg)
if err != nil {
return err
}
ok, err := cli.Exists(context.Background(), key)
if err != nil {
return err
}
fmt.Fprintln(cmd.OutOrStdout(), key)
if !ok {
return ErrNotFound
}
return nil
},
}
var listCmd = &cobra.Command{
Use: "list [sub-prefix]",
Short: "List cache keys under prefix (optionally narrowed by sub-prefix)",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
cli, _, err := client()
if err != nil {
return err
}
sub := ""
if len(args) == 1 {
sub = args[0]
}
keys, err := cli.List(context.Background(), sub)
if err != nil {
return err
}
out := cmd.OutOrStdout()
for _, k := range keys {
fmt.Fprintln(out, k)
}
return nil
},
}
var deleteCmd = &cobra.Command{
Use: "delete <key>",
Short: "Delete a cache key (no error if missing)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
cli, cfg, err := client()
if err != nil {
return err
}
key, err := resolveKey(args[0], cfg)
if err != nil {
return err
}
if err := cli.Delete(context.Background(), key); err != nil {
return err
}
fmt.Fprintf(cmd.ErrOrStderr(), "Deleted — %s\n", key)
return nil
},
}
var keyCmd = &cobra.Command{
Use: "key <template>",
Short: "Print the resolved key for a template (for shell scripting)",
Long: `Resolves a key template by substituting --hash-from values for {hash}
and appending the arch suffix when --arch-suffix=true. Useful when the
same key needs to appear in multiple subsequent invocations:
KEY=$(cacher key conformance/{hash}.tar.zst --hash-from docker/conformance.Dockerfile)
if ! cacher docker exists "$KEY"; then
docker build …
cacher docker upload "$KEY" tag:latest
fi`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := loadConfig()
if err != nil {
return err
}
key, err := resolveKey(args[0], cfg)
if err != nil {
return err
}
fmt.Fprintln(cmd.OutOrStdout(), key)
return nil
},
}
func init() {
downloadCmd.Flags().StringVar(&flagDownloadURL, "url", "", "fallback URL when key is missing in S3")
downloadCmd.Flags().StringVar(&flagDownloadSHA256, "sha256", "", "expected sha256 hex of the downloaded content")
uploadCmd.Flags().BoolVar(&flagForce, "force", false, "overwrite if key already exists")
for _, c := range []*cobra.Command{downloadCmd, uploadCmd, existsCmd, deleteCmd} {
addS3Flags(c)
addKeyFlags(c)
}
addS3Flags(listCmd)
addS3Flags(keyCmd)
addKeyFlags(keyCmd)
rootCmd.AddCommand(downloadCmd, uploadCmd, existsCmd, listCmd, deleteCmd, keyCmd)
}
// writeWithSHA streams r into path; if want is non-empty, verifies the
// sha256 matches. On mismatch, the partial file is removed.
func writeWithSHA(path string, r io.Reader, want string) error {
want = strings.ToLower(want)
if err := os.MkdirAll(parentDir(path), 0o755); err != nil {
return err
}
tmp, err := os.CreateTemp(parentDir(path), ".cacher-tmp-*")
if err != nil {
return err
}
tmpName := tmp.Name()
defer os.Remove(tmpName) // no-op once renamed
h := sha256.New()
mw := io.MultiWriter(tmp, h)
if _, err := io.Copy(mw, r); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
if want != "" {
got := hex.EncodeToString(h.Sum(nil))
if got != want {
return fmt.Errorf("sha256 mismatch: got %s want %s", got, want)
}
}
return os.Rename(tmpName, path)
}
func parentDir(path string) string {
for i := len(path) - 1; i >= 0; i-- {
if path[i] == '/' || path[i] == os.PathSeparator {
return path[:i]
}
}
return "."
}
func httpGet(url string) (io.ReadCloser, error) {
resp, err := http.Get(url)
if err != nil {
return nil, fmt.Errorf("GET %s: %w", url, err)
}
if resp.StatusCode/100 != 2 {
resp.Body.Close()
return nil, fmt.Errorf("GET %s: HTTP %d", url, resp.StatusCode)
}
return resp.Body, nil
}