1
0
Fork 0
mirror of https://github.com/imjasonh/terraform-playground synced 2026-07-18 15:08:24 +00:00

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 <imjasonh@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-06-11 00:42:36 +00:00
parent 4100e12b20
commit 1f0ade9788
No known key found for this signature in database
5 changed files with 95 additions and 29 deletions

View file

@ -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`. |

View file

@ -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

View file

@ -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)

View file

@ -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 {

View file

@ -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