From 1f0ade97881fbd1e9beb82bc1a0bc36b929281be Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 11 Jun 2026 00:42:36 +0000 Subject: [PATCH] cache: make caching opt-out (default on) with --no-cache Caching (layer blobs, downloaded wheels, base interpreter detection) is now enabled by default at the per-user cache dir (or --cache-dir / $PYMAGE_CACHE_DIR). --no-cache disables it: layer/meta caches off and wheels downloaded into an ephemeral temp dir that's cleaned up after the build. - setupCaches centralizes cache wiring (one cache.Cache serves layers + meta); --no-cache uses a temp wheel dir with cleanup - DefaultCacheDir honors $PYMAGE_CACHE_DIR; cli tests point it at a temp dir so the on-by-default cache doesn't touch the user's cache - tests: setupCaches (default + --no-cache cleanup); README/config updated Co-authored-by: Jason Hall --- pymage/README.md | 4 ++- pymage/internal/cli/cli.go | 23 ++++---------- pymage/internal/cli/cli_test.go | 51 ++++++++++++++++++++++++++++++ pymage/internal/cli/defaults.go | 39 ++++++++++++++++------- pymage/internal/project/project.go | 7 +++- 5 files changed, 95 insertions(+), 29 deletions(-) diff --git a/pymage/README.md b/pymage/README.md index 18d0c1a..3cd9372 100644 --- a/pymage/README.md +++ b/pymage/README.md @@ -72,6 +72,7 @@ the config value, which overrides the built-in default. | `max-layers` | `--max-layers` | `127` | | `max-wheel-layers` | `--max-wheel-layers` | *(derived from `max-layers`)* | | `push-concurrency` | `--push-concurrency` | auto (≥ 4, scales with CPUs) | +| `no-cache` | `--no-cache` | `false` (caching is on by default) | | `python` | `--python` | auto-detected from the base | | `prefix` | `--prefix` | `/app/.venv` | | `workdir` | `--workdir` | `/app` | @@ -178,7 +179,8 @@ base that advertises its version. | `--push-concurrency` | Max concurrent layer uploads when pushing (0 = auto). | | `--platform` | Target platform(s); selects compatible wheels and base. Repeatable / comma-separated (e.g. `linux/amd64,linux/arm64`) builds a multi-arch image index. Defaults to the platforms the base image supports. | | `--python` | Interpreter version, e.g. `python3.12`. Optional — **auto-detected from the base** when omitted; if set, must match the base. Drives wheel selection and the site-packages layout. | -| `--cache-dir` | Content-addressed layer cache; reuses compressed layers and downloaded wheels across rebuilds. | +| `--cache-dir` | Cache root (default: `$PYMAGE_CACHE_DIR` or the per-user cache dir). Caches compressed layers, downloaded wheels, and base interpreter detection. | +| `--no-cache` | Disable all caching (layers, downloaded wheels, interpreter detection). | | `--prefix` | install prefix / venv root (default `/app/.venv`). | | `--workdir` | image working dir and source destination (default `/app`). | | `--user` | image user, e.g. `65532`. | diff --git a/pymage/internal/cli/cli.go b/pymage/internal/cli/cli.go index 5ec235c..9571abf 100644 --- a/pymage/internal/cli/cli.go +++ b/pymage/internal/cli/cli.go @@ -69,6 +69,7 @@ type buildFlags struct { requireHash bool insecure bool cacheDir string + noCache bool } func buildCmd() *cobra.Command { @@ -112,7 +113,8 @@ func buildCmd() *cobra.Command { fs.StringVar(&f.sbomOut, "sbom", "", "write a CycloneDX SBOM to this path") fs.BoolVar(&f.requireHash, "require-hashes", true, "require every requirement to carry --hash entries") fs.BoolVar(&f.insecure, "insecure", false, "use plain HTTP for the registry") - fs.StringVar(&f.cacheDir, "cache-dir", "", "directory for the content-addressed layer cache (speeds up rebuilds)") + fs.StringVar(&f.cacheDir, "cache-dir", "", "cache root directory (default: per-user cache dir)") + fs.BoolVar(&f.noCache, "no-cache", false, "disable all build caches (layers, downloaded wheels, interpreter detection)") return cmd } @@ -170,24 +172,13 @@ func runBuild(cmd *cobra.Command, f *buildFlags) error { platforms = baseSet.Platforms() } - var layerCache *cache.Cache - wheelCache, err := wheelCacheDir(f) + // Caching is on by default (layers, downloaded wheels, and interpreter + // detection); --no-cache disables it. + layerCache, metaCache, wheelCache, cleanup, err := setupCaches(f) if err != nil { return err } - if f.cacheDir != "" { - layerCache, err = cache.New(f.cacheDir) - if err != nil { - return err - } - } - // A small metadata cache (default per-user) remembers per-base interpreter - // detection so repeat builds don't re-download the base layer holding - // apko.json. Best-effort: a failure here just disables the cache. - var metaCache *cache.Cache - if dir, derr := metaCacheDir(f); derr == nil { - metaCache, _ = cache.New(dir) - } + defer cleanup() // Build one image per target platform. With more than one platform we // assemble them into a multi-arch OCI image index; with zero or one we push diff --git a/pymage/internal/cli/cli_test.go b/pymage/internal/cli/cli_test.go index 31c964e..573b317 100644 --- a/pymage/internal/cli/cli_test.go +++ b/pymage/internal/cli/cli_test.go @@ -24,6 +24,19 @@ import ( "github.com/imjasonh/terraform-playground/pymage/internal/testwheel" ) +// TestMain points the default cache dir at a throwaway location so the CLI +// tests (which use the on-by-default cache) don't write to the user's cache. +func TestMain(m *testing.M) { + tmp, err := os.MkdirTemp("", "pymage-test-cache-") + if err != nil { + panic(err) + } + _ = os.Setenv("PYMAGE_CACHE_DIR", tmp) + code := m.Run() + _ = os.RemoveAll(tmp) + os.Exit(code) +} + // TestBuildMultiArchIndex drives the CLI to build a linux/amd64 + linux/arm64 // image index from a single host, and verifies the pushed artifact is an index // covering both platforms and is reproducible. @@ -561,6 +574,44 @@ func TestCachedInterpreter(t *testing.T) { } } +func TestSetupCaches(t *testing.T) { + // Default: caching on; layer and meta share one cache; wheels under it. + root := t.TempDir() + layer, meta, wheelDir, cleanup, err := setupCaches(&buildFlags{cacheDir: root}) + if err != nil { + t.Fatal(err) + } + defer cleanup() + if layer == nil || meta == nil { + t.Fatal("expected caches to be enabled by default") + } + if layer != meta { + t.Error("layer and meta cache should share one instance") + } + if wheelDir != filepath.Join(root, "wheels") { + t.Errorf("wheel dir = %q, want %q", wheelDir, filepath.Join(root, "wheels")) + } + + // --no-cache: no persistent caches, ephemeral wheel dir removed by cleanup. + layer, meta, wheelDir, cleanup, err = setupCaches(&buildFlags{noCache: true}) + if err != nil { + t.Fatal(err) + } + if layer != nil || meta != nil { + t.Error("--no-cache should disable layer/meta caches") + } + if wheelDir == "" { + t.Fatal("expected an ephemeral wheel dir with --no-cache") + } + if _, err := os.Stat(wheelDir); err != nil { + t.Fatalf("ephemeral wheel dir should exist: %v", err) + } + cleanup() + if _, err := os.Stat(wheelDir); !os.IsNotExist(err) { + t.Errorf("cleanup should remove the ephemeral wheel dir, stat err = %v", err) + } +} + func TestPushConcurrency(t *testing.T) { if got := pushConcurrency(7); got != 7 { t.Errorf("explicit value not honored: got %d, want 7", got) diff --git a/pymage/internal/cli/defaults.go b/pymage/internal/cli/defaults.go index c90928e..dcc5f44 100644 --- a/pymage/internal/cli/defaults.go +++ b/pymage/internal/cli/defaults.go @@ -2,6 +2,7 @@ package cli import ( "fmt" + "os" "path" "path/filepath" "sort" @@ -10,6 +11,7 @@ import ( "github.com/spf13/cobra" "github.com/imjasonh/terraform-playground/pymage/internal/build" + "github.com/imjasonh/terraform-playground/pymage/internal/cache" "github.com/imjasonh/terraform-playground/pymage/internal/project" ) @@ -64,6 +66,9 @@ func applyDefaults(cmd *cobra.Command, f *buildFlags) error { if !changed("push-concurrency") && cfg.PushJobs != 0 { f.pushJobs = cfg.PushJobs } + if !changed("no-cache") && cfg.NoCache { + f.noCache = true + } if !changed("python") && cfg.Python != "" { f.pythonTag = cfg.Python } @@ -119,20 +124,32 @@ func applyDefaults(cmd *cobra.Command, f *buildFlags) error { return nil } -func wheelCacheDir(f *buildFlags) (string, error) { - if f.cacheDir != "" { - return filepath.Join(f.cacheDir, "wheels"), nil +// setupCaches resolves the build caches. Caching is on by default; --no-cache +// disables persistent caching (downloads still work, via an ephemeral temp dir +// that the returned cleanup removes). The layer cache also serves the small +// metadata cache (interpreter detection), so they share one directory. +func setupCaches(f *buildFlags) (layer, meta *cache.Cache, wheelDir string, cleanup func(), err error) { + noop := func() {} + if f.noCache { + tmp, err := os.MkdirTemp("", "pymage-wheels-") + if err != nil { + return nil, nil, "", noop, err + } + return nil, nil, tmp, func() { _ = os.RemoveAll(tmp) }, nil } - return project.DefaultWheelCacheDir() -} -// metaCacheDir is where small build metadata (e.g. detected base interpreter -// versions) is cached. -func metaCacheDir(f *buildFlags) (string, error) { - if f.cacheDir != "" { - return f.cacheDir, nil + root := f.cacheDir + if root == "" { + root, err = project.DefaultCacheDir() + if err != nil { + return nil, nil, "", noop, err + } } - return project.DefaultCacheDir() + c, err := cache.New(root) + if err != nil { + return nil, nil, "", noop, err + } + return c, c, filepath.Join(root, "wheels"), noop, nil } func validateBuildFlags(f *buildFlags) error { diff --git a/pymage/internal/project/project.go b/pymage/internal/project/project.go index 78ce2f9..25f985a 100644 --- a/pymage/internal/project/project.go +++ b/pymage/internal/project/project.go @@ -45,6 +45,7 @@ type Config struct { Env []string `toml:"env"` Labels map[string]string `toml:"labels"` FindLinks []string `toml:"find-links"` + NoCache bool `toml:"no-cache"` } const defaultBase = "cgr.dev/chainguard/python:latest" @@ -182,8 +183,12 @@ func srcModuleDir(root, pkg string) bool { return err == nil && st.IsDir() } -// DefaultCacheDir returns pymage's per-user cache root. +// DefaultCacheDir returns pymage's cache root: $PYMAGE_CACHE_DIR if set, +// otherwise a "pymage" directory under the per-user cache dir. func DefaultCacheDir() (string, error) { + if v := os.Getenv("PYMAGE_CACHE_DIR"); v != "" { + return v, nil + } dir, err := os.UserCacheDir() if err != nil { return "", err