@@ 40,11 40,19 @@ var dockerExistsCmd = &cobra.Command{
},
}
+var flagDockerPull bool
+
var dockerDownloadCmd = &cobra.Command{
Use: "download <key> <image:tag>",
Short: "Pull a cached image from S3 into the local docker daemon",
- Long: `Streams s3://bucket/key through zstd-decode into ` + "`docker load`" + `.`,
- Args: cobra.ExactArgs(2),
+ Long: `Streams s3://bucket/key through zstd-decode into ` + "`docker load`" + `.
+
+With --pull, a cache miss falls back to ` + "`docker pull <image:tag>`" + ` then
+seeds the S3 cache via ` + "`docker save`" + `. This makes a single invocation
+into the full cache-or-fetch pattern callable from a CI manifest:
+
+ cacher docker download garage/v2.3.0.tar.zst dxflrs/garage:v2.3.0 --pull`,
+ Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
cli, cfg, err := client()
if err != nil {
@@ 62,22 70,37 @@ var dockerDownloadCmd = &cobra.Command{
if err != nil {
return err
}
- if !ok {
+ if ok {
+ fmt.Fprintf(cmd.ErrOrStderr(), "Cache HIT — %s → load %s\n", key, tag)
+ body, err := cli.Get(ctx, key)
+ if err != nil {
+ return err
+ }
+ defer body.Close()
+ dec, err := zstd.NewReader(body)
+ if err != nil {
+ return err
+ }
+ defer dec.Close()
+ return runDockerLoad(ctx, dec)
+ }
+ fmt.Fprintf(cmd.ErrOrStderr(), "Cache MISS — %s\n", key)
+ if !flagDockerPull {
return fmt.Errorf("%w: %s", ErrNotFound, key)
}
- fmt.Fprintf(cmd.ErrOrStderr(), "Cache HIT — %s → load %s\n", key, tag)
-
- body, err := cli.Get(ctx, key)
- if err != nil {
+ fmt.Fprintf(cmd.ErrOrStderr(), "Pulling %s\n", tag)
+ if err := runDockerPull(ctx, tag); err != nil {
return err
}
- defer body.Close()
- dec, err := zstd.NewReader(body)
- if err != nil {
- return err
+ // Image is now in the local daemon. Seed the S3 cache so the
+ // next run hits. Treat the upload as best-effort — the pull
+ // already gave us what we needed locally.
+ if err := saveTagToS3(ctx, cli, key, tag); err != nil {
+ fmt.Fprintf(cmd.ErrOrStderr(), "warn: seed upload failed: %v\n", err)
+ return nil
}
- defer dec.Close()
- return runDockerLoad(ctx, dec)
+ fmt.Fprintf(cmd.ErrOrStderr(), "Cached → %s\n", key)
+ return nil
},
}
@@ 120,10 143,24 @@ func init() {
addKeyFlags(c)
}
dockerUploadCmd.Flags().BoolVar(&flagForce, "force", false, "overwrite if key already exists")
+ dockerDownloadCmd.Flags().BoolVar(&flagDockerPull, "pull", false, "on cache miss, docker pull <image:tag> and seed S3")
dockerCmd.AddCommand(dockerExistsCmd, dockerDownloadCmd, dockerUploadCmd)
rootCmd.AddCommand(dockerCmd)
}
+// runDockerPull invokes `docker pull <tag>`, surfacing daemon output as
+// a single warn line per chunk so the test log isn't dominated by pull
+// progress bars.
+func runDockerPull(ctx context.Context, tag string) error {
+ pull := exec.CommandContext(ctx, "docker", "pull", tag)
+ pull.Stdout = newWarnWriter("docker pull")
+ pull.Stderr = newWarnWriter("docker pull")
+ if err := pull.Run(); err != nil {
+ return fmt.Errorf("docker pull %s: %w", tag, err)
+ }
+ return nil
+}
+
// saveTagToS3 wires `docker save` stdout → zstd encoder → io.Pipe →
// s3.Put. The encoder runs in a goroutine writing into the pipe; the
// uploader goroutine reads from the pipe. Both ends signal errors back
@@ 19,6 19,10 @@ var (
var rootCmd = &cobra.Command{
Use: "cacher",
Short: "S3-backed CI cache helper for builds.sr.ht — replaces .builds/lib/ci-lib.sh",
+ // Subcommands fail in normal, scriptable ways (cache miss, key absent).
+ // Dumping the cobra usage block on every such error is noise — the
+ // short error line is enough.
+ SilenceUsage: true,
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
level := slog.LevelInfo
switch flagLogLevel {