~bigbes/ci-cacher

d041811523ea4e4dc5aeb636ab3c252c19a3b5b8 — Eugene Blikh a day ago 8a662e2
cacher: add 'docker download --pull' and silence cobra usage on errors

* docker download --pull: on cache miss, falls back to docker pull
  + docker save + S3 seed, mirroring the --url fallback on file
  download. Collapses the if/else cache-or-pull bash dance in CI
  manifests to a single command.

* SilenceUsage on rootCmd: cache-miss / key-not-found exits aren't
  bad-usage errors, so the cobra Usage block dump on every error was
  noise. Short error line remains.
2 files changed, 54 insertions(+), 13 deletions(-)

M cmd/docker.go
M cmd/root.go
M cmd/docker.go => cmd/docker.go +50 -13
@@ 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

M cmd/root.go => cmd/root.go +4 -0
@@ 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 {