mirror of
https://github.com/imjasonh/terraform-playground
synced 2026-07-07 23:35:16 +00:00
perf: speed up no-op builds (resolve base once, cache interpreter detection)
A no-op build's ~2-3s was almost all registry I/O: - the base index was fetched 3x (BasePlatforms + remote.Image per platform) - the base's top layer was downloaded once per platform every build to read /etc/apko.json for interpreter detection (Chainguard has no PYTHON_VERSION) Changes: - build.BaseSet resolves the base reference once and serves per-platform child images memoized (index fetched once; verified by TestBaseSet) - cache the detected interpreter version by base digest in a default per-user metadata cache (cache.GetText/PutText), so repeat builds skip the apko layer download - lazy wheel-cache dir creation; wheelhouse.Resolve defaults to the per-user cache dir Tests: TestBaseSet (index fetched once + per-platform memoized), TestCachedInterpreter (write + cache-hit short-circuit), cache text round-trip. Co-authored-by: Jason Hall <imjasonh@users.noreply.github.com>
This commit is contained in:
parent
a62051afb8
commit
4100e12b20
9 changed files with 334 additions and 97 deletions
|
|
@ -156,16 +156,9 @@ func layerDigests(t *testing.T, img v1.Image) []string {
|
|||
// TestE2EReproducibleAndNoBytesRebuild is the core demonstration.
|
||||
func TestE2EReproducibleAndNoBytesRebuild(t *testing.T) {
|
||||
host, counter := startRegistry(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Push an (empty) base image and resolve it back by reference, exercising
|
||||
// the docker-less base-by-reference path.
|
||||
baseRef := host + "/base:latest"
|
||||
push(t, baseRef, empty.Image)
|
||||
base, err := build.Base(ctx, baseRef, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve base: %v", err)
|
||||
}
|
||||
// An empty base keeps the focus on dependency/app layers.
|
||||
base := empty.Image
|
||||
|
||||
dir := t.TempDir()
|
||||
a := mkWheel(t, dir, "alpha", "1.0")
|
||||
|
|
|
|||
|
|
@ -5,10 +5,12 @@ import (
|
|||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-containerregistry/pkg/name"
|
||||
|
|
@ -25,10 +27,21 @@ import (
|
|||
"github.com/imjasonh/terraform-playground/pymage/internal/wheelhouse"
|
||||
)
|
||||
|
||||
func TestBasePlatforms(t *testing.T) {
|
||||
s := httptest.NewServer(registry.New())
|
||||
t.Cleanup(s.Close)
|
||||
host := strings.TrimPrefix(s.URL, "http://")
|
||||
func TestBaseSet(t *testing.T) {
|
||||
// Count GETs of the index manifest by tag to prove it's fetched only once.
|
||||
var mu sync.Mutex
|
||||
indexGets := 0
|
||||
reg := registry.New()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet && r.URL.Path == "/v2/multi/manifests/latest" {
|
||||
mu.Lock()
|
||||
indexGets++
|
||||
mu.Unlock()
|
||||
}
|
||||
reg.ServeHTTP(w, r)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
host := strings.TrimPrefix(srv.URL, "http://")
|
||||
ctx := context.Background()
|
||||
|
||||
mk := func(os, arch string) v1.Image {
|
||||
|
|
@ -39,8 +52,7 @@ func TestBasePlatforms(t *testing.T) {
|
|||
return img
|
||||
}
|
||||
|
||||
// A multi-arch index with an attestation-style "unknown" entry that must be
|
||||
// ignored.
|
||||
// A multi-arch index with an attestation-style "unknown" entry to ignore.
|
||||
idx := mutate.AppendManifests(empty.Index,
|
||||
mutate.IndexAddendum{Add: mk("linux", "amd64"), Descriptor: v1.Descriptor{Platform: &v1.Platform{OS: "linux", Architecture: "amd64"}}},
|
||||
mutate.IndexAddendum{Add: mk("linux", "arm64"), Descriptor: v1.Descriptor{Platform: &v1.Platform{OS: "linux", Architecture: "arm64"}}},
|
||||
|
|
@ -51,29 +63,50 @@ func TestBasePlatforms(t *testing.T) {
|
|||
t.Fatal(err)
|
||||
}
|
||||
|
||||
plats, err := BasePlatforms(ctx, host+"/multi:latest", nil)
|
||||
bs, err := ResolveBaseSet(ctx, host+"/multi:latest", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := map[string]bool{}
|
||||
for _, p := range plats {
|
||||
for _, p := range bs.Platforms() {
|
||||
got[p.OS+"/"+p.Architecture] = true
|
||||
}
|
||||
if len(plats) != 2 || !got["linux/amd64"] || !got["linux/arm64"] {
|
||||
t.Fatalf("index platforms = %v, want linux/amd64 + linux/arm64 (no unknown)", plats)
|
||||
if len(bs.Platforms()) != 2 || !got["linux/amd64"] || !got["linux/arm64"] {
|
||||
t.Fatalf("platforms = %v, want linux/amd64 + linux/arm64 (no unknown)", bs.Platforms())
|
||||
}
|
||||
|
||||
// A plain single-arch image returns its one platform.
|
||||
// Fetching each platform's image works and does not re-fetch the index.
|
||||
for _, arch := range []string{"amd64", "arm64", "amd64" /* memoized */} {
|
||||
img, err := bs.Image(&v1.Platform{OS: "linux", Architecture: arch})
|
||||
if err != nil {
|
||||
t.Fatalf("Image(%s): %v", arch, err)
|
||||
}
|
||||
cf, err := img.ConfigFile()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cf.Architecture != arch {
|
||||
t.Fatalf("got arch %q, want %q", cf.Architecture, arch)
|
||||
}
|
||||
}
|
||||
mu.Lock()
|
||||
n := indexGets
|
||||
mu.Unlock()
|
||||
if n != 1 {
|
||||
t.Fatalf("index manifest fetched %d times, want 1 (should be resolved once)", n)
|
||||
}
|
||||
|
||||
// A plain single-arch image yields its one platform.
|
||||
imgRef, _ := name.ParseReference(host + "/single:latest")
|
||||
if err := remote.Write(imgRef, mk("linux", "arm64"), remote.WithContext(ctx)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plats, err = BasePlatforms(ctx, host+"/single:latest", nil)
|
||||
bs2, err := ResolveBaseSet(ctx, host+"/single:latest", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(plats) != 1 || plats[0].OS != "linux" || plats[0].Architecture != "arm64" {
|
||||
t.Fatalf("single platforms = %v, want [linux/arm64]", plats)
|
||||
if ps := bs2.Platforms(); len(ps) != 1 || ps[0].Architecture != "arm64" {
|
||||
t.Fatalf("single platforms = %v, want [linux/arm64]", ps)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,34 +13,10 @@ import (
|
|||
"github.com/google/go-containerregistry/pkg/authn"
|
||||
"github.com/google/go-containerregistry/pkg/name"
|
||||
v1 "github.com/google/go-containerregistry/pkg/v1"
|
||||
"github.com/google/go-containerregistry/pkg/v1/empty"
|
||||
"github.com/google/go-containerregistry/pkg/v1/remote"
|
||||
)
|
||||
|
||||
// Base resolves a base image reference to a v1.Image for the given platform.
|
||||
// Only the base manifest and config are fetched; its layer blobs are referenced
|
||||
// by digest and never downloaded.
|
||||
func Base(ctx context.Context, ref string, platform *v1.Platform, kc authn.Keychain, nameOpts ...name.Option) (v1.Image, error) {
|
||||
r, err := name.ParseReference(ref, nameOpts...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build: parse base ref %q: %w", ref, err)
|
||||
}
|
||||
if kc == nil {
|
||||
kc = authn.DefaultKeychain
|
||||
}
|
||||
opts := []remote.Option{
|
||||
remote.WithContext(ctx),
|
||||
remote.WithAuthFromKeychain(kc),
|
||||
}
|
||||
if platform != nil {
|
||||
opts = append(opts, remote.WithPlatform(*platform))
|
||||
}
|
||||
img, err := remote.Image(r, opts...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build: fetch base %q: %w", ref, err)
|
||||
}
|
||||
return img, nil
|
||||
}
|
||||
|
||||
// apkoMaxBytes caps how much of the apko.json file we read.
|
||||
const apkoMaxBytes = 4 << 20
|
||||
|
||||
|
|
@ -48,11 +24,30 @@ const apkoMaxBytes = 4 << 20
|
|||
// "-base" suffix), as found in a Chainguard/Wolfi image's apko.json.
|
||||
var pythonPkgRE = regexp.MustCompile(`^python-(\d+)\.(\d+)(?:-base)?$`)
|
||||
|
||||
// BasePlatforms returns the platforms a base reference supports: the entries of
|
||||
// its image index (minus attestation/"unknown" placeholders), or the single
|
||||
// platform from a plain image's config. It is used to default the build's
|
||||
// target platforms to whatever the base provides.
|
||||
func BasePlatforms(ctx context.Context, ref string, kc authn.Keychain, nameOpts ...name.Option) ([]v1.Platform, error) {
|
||||
// BaseSet is a base image reference resolved once and reused across platforms.
|
||||
// It fetches the base index/manifest a single time, then serves per-platform
|
||||
// child images on demand (memoized), avoiding the redundant index re-fetch that
|
||||
// a separate remote.Image call per platform would incur.
|
||||
type BaseSet struct {
|
||||
ref string
|
||||
img v1.Image // set for a single-platform base (or scratch)
|
||||
idx v1.ImageIndex // set for a multi-platform base
|
||||
platforms []v1.Platform
|
||||
children map[string]v1.Image
|
||||
}
|
||||
|
||||
// ResolveBaseSet fetches ref once and returns a BaseSet. "scratch" resolves to
|
||||
// an empty base with a single (registry-default) platform and no network I/O.
|
||||
func ResolveBaseSet(ctx context.Context, ref string, kc authn.Keychain, nameOpts ...name.Option) (*BaseSet, error) {
|
||||
if ref == "scratch" {
|
||||
return &BaseSet{
|
||||
ref: ref,
|
||||
img: empty.Image,
|
||||
platforms: []v1.Platform{{}},
|
||||
children: map[string]v1.Image{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
r, err := name.ParseReference(ref, nameOpts...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build: parse base ref %q: %w", ref, err)
|
||||
|
|
@ -65,6 +60,7 @@ func BasePlatforms(ctx context.Context, ref string, kc authn.Keychain, nameOpts
|
|||
return nil, fmt.Errorf("build: inspect base %q: %w", ref, err)
|
||||
}
|
||||
|
||||
bs := &BaseSet{ref: ref, children: map[string]v1.Image{}}
|
||||
if desc.MediaType.IsIndex() {
|
||||
idx, err := desc.ImageIndex()
|
||||
if err != nil {
|
||||
|
|
@ -74,7 +70,7 @@ func BasePlatforms(ctx context.Context, ref string, kc authn.Keychain, nameOpts
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var plats []v1.Platform
|
||||
bs.idx = idx
|
||||
seen := map[string]bool{}
|
||||
for _, m := range im.Manifests {
|
||||
p := m.Platform
|
||||
|
|
@ -85,17 +81,17 @@ func BasePlatforms(ctx context.Context, ref string, kc authn.Keychain, nameOpts
|
|||
if p.OS == "unknown" || p.Architecture == "unknown" {
|
||||
continue
|
||||
}
|
||||
key := p.OS + "/" + p.Architecture + "/" + p.Variant
|
||||
key := platformKey(p)
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
plats = append(plats, v1.Platform{OS: p.OS, Architecture: p.Architecture, Variant: p.Variant})
|
||||
bs.platforms = append(bs.platforms, v1.Platform{OS: p.OS, Architecture: p.Architecture, Variant: p.Variant})
|
||||
}
|
||||
if len(plats) == 0 {
|
||||
if len(bs.platforms) == 0 {
|
||||
return nil, fmt.Errorf("build: base index %q advertises no usable platforms", ref)
|
||||
}
|
||||
return plats, nil
|
||||
return bs, nil
|
||||
}
|
||||
|
||||
img, err := desc.Image()
|
||||
|
|
@ -109,7 +105,55 @@ func BasePlatforms(ctx context.Context, ref string, kc authn.Keychain, nameOpts
|
|||
if cf.OS == "" || cf.Architecture == "" {
|
||||
return nil, fmt.Errorf("build: base image %q has no platform in its config", ref)
|
||||
}
|
||||
return []v1.Platform{{OS: cf.OS, Architecture: cf.Architecture, Variant: cf.Variant}}, nil
|
||||
bs.img = img
|
||||
bs.platforms = []v1.Platform{{OS: cf.OS, Architecture: cf.Architecture, Variant: cf.Variant}}
|
||||
return bs, nil
|
||||
}
|
||||
|
||||
// Platforms returns the platforms the base supports.
|
||||
func (b *BaseSet) Platforms() []v1.Platform { return b.platforms }
|
||||
|
||||
// Image returns the base image for the given platform (nil platform selects the
|
||||
// single available image). Child images are fetched at most once.
|
||||
func (b *BaseSet) Image(platform *v1.Platform) (v1.Image, error) {
|
||||
if b.img != nil {
|
||||
return b.img, nil
|
||||
}
|
||||
want := &v1.Platform{}
|
||||
if platform != nil {
|
||||
want = platform
|
||||
}
|
||||
key := platformKey(want)
|
||||
if img, ok := b.children[key]; ok {
|
||||
return img, nil
|
||||
}
|
||||
|
||||
im, err := b.idx.IndexManifest()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, m := range im.Manifests {
|
||||
if m.Platform == nil {
|
||||
continue
|
||||
}
|
||||
if m.Platform.OS == want.OS && m.Platform.Architecture == want.Architecture &&
|
||||
(want.Variant == "" || m.Platform.Variant == want.Variant) {
|
||||
img, err := b.idx.Image(m.Digest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b.children[key] = img
|
||||
return img, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("build: base %q has no image for platform %s", b.ref, key)
|
||||
}
|
||||
|
||||
func platformKey(p *v1.Platform) string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
return p.OS + "/" + p.Architecture + "/" + p.Variant
|
||||
}
|
||||
|
||||
// InterpreterVersion reports the Python X.Y the base image provides.
|
||||
|
|
|
|||
38
pymage/internal/cache/cache.go
vendored
38
pymage/internal/cache/cache.go
vendored
|
|
@ -50,6 +50,44 @@ func (c *Cache) Get(key string) (v1.Layer, bool) {
|
|||
return l, true
|
||||
}
|
||||
|
||||
// metaPath returns the on-disk path for a small text value keyed by key.
|
||||
func (c *Cache) metaPath(key string) string {
|
||||
sum := sha256.Sum256([]byte(key))
|
||||
return filepath.Join(c.dir, "meta", hex.EncodeToString(sum[:])+".txt")
|
||||
}
|
||||
|
||||
// GetText returns a small cached text value for key (e.g. a detected base
|
||||
// interpreter version), or ("", false) on a miss.
|
||||
func (c *Cache) GetText(key string) (string, bool) {
|
||||
data, err := os.ReadFile(c.metaPath(key))
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
return string(data), true
|
||||
}
|
||||
|
||||
// PutText stores a small text value for key.
|
||||
func (c *Cache) PutText(key, val string) error {
|
||||
p := c.metaPath(key)
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp, err := os.CreateTemp(filepath.Dir(p), ".tmp-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
defer func() { _ = os.Remove(tmpName) }()
|
||||
if _, err := tmp.WriteString(val); err != nil {
|
||||
_ = tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmpName, p)
|
||||
}
|
||||
|
||||
// Put stores the layer's compressed blob under key. Writes are atomic (temp
|
||||
// file + rename) so concurrent builders and interrupted writes are safe.
|
||||
func (c *Cache) Put(key string, l v1.Layer) error {
|
||||
|
|
|
|||
16
pymage/internal/cache/cache_test.go
vendored
16
pymage/internal/cache/cache_test.go
vendored
|
|
@ -6,6 +6,22 @@ import (
|
|||
"github.com/imjasonh/terraform-playground/pymage/internal/ptar"
|
||||
)
|
||||
|
||||
func TestTextRoundTrip(t *testing.T) {
|
||||
c, err := New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := c.GetText("k"); ok {
|
||||
t.Fatal("expected miss before PutText")
|
||||
}
|
||||
if err := c.PutText("k", "3.14"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v, ok := c.GetText("k"); !ok || v != "3.14" {
|
||||
t.Fatalf("GetText = %q,%v; want 3.14,true", v, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPutGetRoundTrip(t *testing.T) {
|
||||
c, err := New(t.TempDir())
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -157,13 +157,17 @@ func runBuild(cmd *cobra.Command, f *buildFlags) error {
|
|||
nameOpts = append(nameOpts, name.Insecure)
|
||||
}
|
||||
|
||||
// Resolve the base once (single index/manifest fetch) and reuse it for every
|
||||
// platform, rather than re-fetching it per platform.
|
||||
baseSet, err := build.ResolveBaseSet(ctx, f.base, authn.DefaultKeychain, nameOpts...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// With no platform requested, default to whatever the base image supports
|
||||
// (a multi-arch base yields a multi-arch build).
|
||||
if len(platforms) == 0 {
|
||||
platforms, err = defaultPlatformsFromBase(ctx, f, nameOpts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
platforms = baseSet.Platforms()
|
||||
}
|
||||
|
||||
var layerCache *cache.Cache
|
||||
|
|
@ -177,6 +181,13 @@ func runBuild(cmd *cobra.Command, f *buildFlags) error {
|
|||
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)
|
||||
}
|
||||
|
||||
// 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
|
||||
|
|
@ -193,7 +204,11 @@ func runBuild(cmd *cobra.Command, f *buildFlags) error {
|
|||
if p.OS != "" || p.Architecture != "" {
|
||||
pp = &targets[i]
|
||||
}
|
||||
img, deps, err := buildOne(ctx, f, reqs, pp, labels, layerCache, wheelCache, nameOpts)
|
||||
base, err := baseSet.Image(pp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
img, deps, err := buildOne(ctx, f, reqs, pp, base, labels, layerCache, metaCache, wheelCache)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -223,17 +238,12 @@ func runBuild(cmd *cobra.Command, f *buildFlags) error {
|
|||
return output(cmd, f, nameOpts, images[0], nil)
|
||||
}
|
||||
|
||||
// buildOne resolves the base and wheels for a single platform and builds the
|
||||
// image, returning the resolved wheels for SBOM aggregation.
|
||||
func buildOne(ctx context.Context, f *buildFlags, reqs []lock.Requirement, platform *v1.Platform, labels map[string]string, layerCache *cache.Cache, wheelCache string, nameOpts []name.Option) (v1.Image, []wheelhouse.ResolvedWheel, error) {
|
||||
base, err := resolveBase(ctx, f.base, platform, nameOpts...)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// buildOne resolves wheels for a single platform and builds the image from the
|
||||
// already-resolved base, returning the resolved wheels for SBOM aggregation.
|
||||
func buildOne(ctx context.Context, f *buildFlags, reqs []lock.Requirement, platform *v1.Platform, base v1.Image, labels map[string]string, layerCache, metaCache *cache.Cache, wheelCache string) (v1.Image, []wheelhouse.ResolvedWheel, error) {
|
||||
// Determine the interpreter: honor --python (validated against the base) or
|
||||
// auto-detect it from the base image.
|
||||
major, minor, pyTag, err := resolveInterpreter(f, base)
|
||||
major, minor, pyTag, err := resolveInterpreter(f, base, metaCache)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
|
@ -272,8 +282,8 @@ func buildOne(ctx context.Context, f *buildFlags, reqs []lock.Requirement, platf
|
|||
// directory tag. If --python is set it is authoritative but must match the
|
||||
// base when the base advertises a version; otherwise the version is detected
|
||||
// from the base image.
|
||||
func resolveInterpreter(f *buildFlags, base v1.Image) (major, minor int, pyTag string, err error) {
|
||||
baseMaj, baseMin, baseOK := build.InterpreterVersion(base)
|
||||
func resolveInterpreter(f *buildFlags, base v1.Image, metaCache *cache.Cache) (major, minor int, pyTag string, err error) {
|
||||
baseMaj, baseMin, baseOK := cachedInterpreter(base, metaCache)
|
||||
|
||||
if f.pythonTag != "" {
|
||||
maj, min, err := parsePythonTag(f.pythonTag)
|
||||
|
|
@ -386,14 +396,39 @@ func writeLayout(dir string, img v1.Image, idx v1.ImageIndex) error {
|
|||
return p.AppendImage(img)
|
||||
}
|
||||
|
||||
// defaultPlatformsFromBase returns the platforms to build for when --platform
|
||||
// is not given: those advertised by the base image. A scratch base has no
|
||||
// registry metadata, so it falls back to a single registry-default build.
|
||||
func defaultPlatformsFromBase(ctx context.Context, f *buildFlags, nameOpts []name.Option) ([]v1.Platform, error) {
|
||||
if f.base == "scratch" {
|
||||
return []v1.Platform{{}}, nil
|
||||
// cachedInterpreter returns the base image's Python version, consulting the
|
||||
// metadata cache (keyed by base digest) so the apko.json layer isn't
|
||||
// re-downloaded on repeat builds.
|
||||
func cachedInterpreter(base v1.Image, metaCache *cache.Cache) (major, minor int, ok bool) {
|
||||
if metaCache != nil {
|
||||
if d, err := base.Digest(); err == nil {
|
||||
key := "interp:v1:" + d.String()
|
||||
if v, hit := metaCache.GetText(key); hit {
|
||||
if maj, min, parsed := splitMajorMinor(v); parsed {
|
||||
return maj, min, true
|
||||
}
|
||||
}
|
||||
maj, min, found := build.InterpreterVersion(base)
|
||||
if found {
|
||||
_ = metaCache.PutText(key, fmt.Sprintf("%d.%d", maj, min))
|
||||
}
|
||||
return maj, min, found
|
||||
}
|
||||
}
|
||||
return build.BasePlatforms(ctx, f.base, authn.DefaultKeychain, nameOpts...)
|
||||
return build.InterpreterVersion(base)
|
||||
}
|
||||
|
||||
func splitMajorMinor(s string) (int, int, bool) {
|
||||
a, b, ok := strings.Cut(strings.TrimSpace(s), ".")
|
||||
if !ok {
|
||||
return 0, 0, false
|
||||
}
|
||||
maj, e1 := strconv.Atoi(a)
|
||||
min, e2 := strconv.Atoi(b)
|
||||
if e1 != nil || e2 != nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
return maj, min, true
|
||||
}
|
||||
|
||||
// pushConcurrency resolves the number of concurrent layer uploads. 0 means
|
||||
|
|
@ -482,13 +517,6 @@ func parsePythonTag(tag string) (int, int, error) {
|
|||
return maj, min, nil
|
||||
}
|
||||
|
||||
func resolveBase(ctx context.Context, ref string, platform *v1.Platform, nameOpts ...name.Option) (v1.Image, error) {
|
||||
if ref == "scratch" {
|
||||
return empty.Image, nil
|
||||
}
|
||||
return build.Base(ctx, ref, platform, authn.DefaultKeychain, nameOpts...)
|
||||
}
|
||||
|
||||
func keyValues(in []string) (map[string]string, error) {
|
||||
if len(in) == 0 {
|
||||
return nil, nil
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
|
@ -16,7 +18,9 @@ import (
|
|||
"github.com/google/go-containerregistry/pkg/v1/empty"
|
||||
"github.com/google/go-containerregistry/pkg/v1/mutate"
|
||||
"github.com/google/go-containerregistry/pkg/v1/remote"
|
||||
"github.com/google/go-containerregistry/pkg/v1/tarball"
|
||||
|
||||
"github.com/imjasonh/terraform-playground/pymage/internal/cache"
|
||||
"github.com/imjasonh/terraform-playground/pymage/internal/testwheel"
|
||||
)
|
||||
|
||||
|
|
@ -494,6 +498,69 @@ func TestSrcLayoutPythonPathFollowsWorkdir(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// apkoBase builds an in-memory base image whose top layer holds /etc/apko.json
|
||||
// (and which advertises no PYTHON_VERSION), like a Chainguard image.
|
||||
func apkoBase(t *testing.T, arch, apkoJSON string) v1.Image {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
tw := tar.NewWriter(&buf)
|
||||
if err := tw.WriteHeader(&tar.Header{Name: "etc/apko.json", Mode: 0o644, Size: int64(len(apkoJSON)), Typeflag: tar.TypeReg}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := tw.Write([]byte(apkoJSON)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := tw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw := buf.Bytes()
|
||||
layer, err := tarball.LayerFromOpener(func() (io.ReadCloser, error) {
|
||||
return io.NopCloser(bytes.NewReader(raw)), nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
img, err := mutate.ConfigFile(empty.Image, &v1.ConfigFile{OS: "linux", Architecture: arch})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
img, err = mutate.AppendLayers(img, layer)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return img
|
||||
}
|
||||
|
||||
// TestCachedInterpreter verifies the metadata cache is written on first detect
|
||||
// and consulted (short-circuiting apko detection) on subsequent calls.
|
||||
func TestCachedInterpreter(t *testing.T) {
|
||||
base := apkoBase(t, "amd64", `{"contents":{"packages":["python-3.14=3.14.5-r2"]}}`)
|
||||
mc, err := cache.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// First call detects from apko.json and populates the cache.
|
||||
maj, min, ok := cachedInterpreter(base, mc)
|
||||
if !ok || maj != 3 || min != 14 {
|
||||
t.Fatalf("detect: %d.%d ok=%v, want 3.14", maj, min, ok)
|
||||
}
|
||||
d, _ := base.Digest()
|
||||
key := "interp:v1:" + d.String()
|
||||
if v, hit := mc.GetText(key); !hit || v != "3.14" {
|
||||
t.Fatalf("cache after detect = %q,%v; want 3.14,true", v, hit)
|
||||
}
|
||||
|
||||
// A cache hit short-circuits detection: seed a different value and confirm
|
||||
// it's returned instead of re-reading apko.json (which says 3.14).
|
||||
if err := mc.PutText(key, "3.11"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if maj, min, ok := cachedInterpreter(base, mc); !ok || maj != 3 || min != 11 {
|
||||
t.Fatalf("cache hit: %d.%d ok=%v, want 3.11 (from cache)", maj, min, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushConcurrency(t *testing.T) {
|
||||
if got := pushConcurrency(7); got != 7 {
|
||||
t.Errorf("explicit value not honored: got %d, want 7", got)
|
||||
|
|
@ -539,28 +606,28 @@ func TestResolveInterpreter(t *testing.T) {
|
|||
bareBase := withVersion(nil)
|
||||
|
||||
// Auto-detect when --python is omitted.
|
||||
maj, min, tag, err := resolveInterpreter(&buildFlags{}, base313)
|
||||
maj, min, tag, err := resolveInterpreter(&buildFlags{}, base313, nil)
|
||||
if err != nil || maj != 3 || min != 13 || tag != "python3.13" {
|
||||
t.Fatalf("auto-detect: %d.%d %q err=%v", maj, min, tag, err)
|
||||
}
|
||||
|
||||
// Explicit --python that matches is accepted.
|
||||
if _, _, tag, err := resolveInterpreter(&buildFlags{pythonTag: "python3.13"}, base313); err != nil || tag != "python3.13" {
|
||||
if _, _, tag, err := resolveInterpreter(&buildFlags{pythonTag: "python3.13"}, base313, nil); err != nil || tag != "python3.13" {
|
||||
t.Fatalf("matching --python: %q err=%v", tag, err)
|
||||
}
|
||||
|
||||
// Explicit --python that mismatches the base is rejected.
|
||||
if _, _, _, err := resolveInterpreter(&buildFlags{pythonTag: "python3.12"}, base313); err == nil {
|
||||
if _, _, _, err := resolveInterpreter(&buildFlags{pythonTag: "python3.12"}, base313, nil); err == nil {
|
||||
t.Fatal("expected mismatch error")
|
||||
}
|
||||
|
||||
// No --python and an undetectable base is an error.
|
||||
if _, _, _, err := resolveInterpreter(&buildFlags{}, bareBase); err == nil {
|
||||
if _, _, _, err := resolveInterpreter(&buildFlags{}, bareBase, nil); err == nil {
|
||||
t.Fatal("expected error when base version is unknown and --python is unset")
|
||||
}
|
||||
|
||||
// Explicit --python works even when the base can't be validated.
|
||||
if _, _, tag, err := resolveInterpreter(&buildFlags{pythonTag: "python3.10"}, bareBase); err != nil || tag != "python3.10" {
|
||||
if _, _, tag, err := resolveInterpreter(&buildFlags{pythonTag: "python3.10"}, bareBase, nil); err != nil || tag != "python3.10" {
|
||||
t.Fatalf("explicit on bare base: %q err=%v", tag, err)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -126,6 +126,15 @@ func wheelCacheDir(f *buildFlags) (string, error) {
|
|||
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
|
||||
}
|
||||
return project.DefaultCacheDir()
|
||||
}
|
||||
|
||||
func validateBuildFlags(f *buildFlags) error {
|
||||
if f.lockFile == "" {
|
||||
return fmt.Errorf("no lock file found (expected uv.lock in the source directory, or pass --lock)")
|
||||
|
|
|
|||
|
|
@ -182,11 +182,20 @@ func srcModuleDir(root, pkg string) bool {
|
|||
return err == nil && st.IsDir()
|
||||
}
|
||||
|
||||
// DefaultWheelCacheDir returns the on-disk wheel download cache location.
|
||||
func DefaultWheelCacheDir() (string, error) {
|
||||
// DefaultCacheDir returns pymage's per-user cache root.
|
||||
func DefaultCacheDir() (string, error) {
|
||||
dir, err := os.UserCacheDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(dir, "pymage", "wheels"), nil
|
||||
return filepath.Join(dir, "pymage"), nil
|
||||
}
|
||||
|
||||
// DefaultWheelCacheDir returns the on-disk wheel download cache location.
|
||||
func DefaultWheelCacheDir() (string, error) {
|
||||
dir, err := DefaultCacheDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(dir, "wheels"), nil
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue