From a8e0c59657511b8ea12c2b88c720ee49b9db3a66 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 10 Jun 2026 16:16:20 +0000 Subject: [PATCH] build: detect Chainguard base Python version from apko.json When the base doesn't set PYTHON_VERSION (Chainguard/Wolfi images), fall back to reading /etc/apko.json from the top-most layer and parsing the python-X.Y package, so --python validation works for those bases too. Env still takes precedence. Adds tests for the apko fallback and env precedence. Co-authored-by: Jason Hall --- pymage/README.md | 10 ++- pymage/internal/build/build_test.go | 39 ++++++++++ pymage/internal/build/resolve.go | 117 ++++++++++++++++++++++++---- 3 files changed, 147 insertions(+), 19 deletions(-) diff --git a/pymage/README.md b/pymage/README.md index 6dc2121..f243acf 100644 --- a/pymage/README.md +++ b/pymage/README.md @@ -73,10 +73,12 @@ A floating tag such as `cgr.dev/chainguard/python:latest` works, but be aware: keep working (they're matched by `py3` and found via `PYTHONPATH`), but version-specific compiled wheels (`cp312`…) break when the interpreter moves. -To prevent silent breakage, pymage reads the base's advertised `PYTHON_VERSION` -and **fails the build** if it doesn't match `--python`, telling you which -version to target. (Bases that don't advertise a version can't be validated — -another reason to pin.) +To prevent silent breakage, pymage detects the base's Python version and +**fails the build** if it doesn't match `--python`, telling you which version to +target. It looks at the `PYTHON_VERSION` env var (official python images) and, +when that's absent, the `python-X.Y` package in `/etc/apko.json` from the top +layer (Chainguard/Wolfi images). Bases that expose neither can't be validated — +another reason to pin. ### Useful flags diff --git a/pymage/internal/build/build_test.go b/pymage/internal/build/build_test.go index cf76f25..1bacb57 100644 --- a/pymage/internal/build/build_test.go +++ b/pymage/internal/build/build_test.go @@ -13,11 +13,17 @@ import ( "github.com/google/go-containerregistry/pkg/v1/mutate" "github.com/imjasonh/terraform-playground/pymage/internal/cache" + "github.com/imjasonh/terraform-playground/pymage/internal/ptar" "github.com/imjasonh/terraform-playground/pymage/internal/testwheel" "github.com/imjasonh/terraform-playground/pymage/internal/wheel" "github.com/imjasonh/terraform-playground/pymage/internal/wheelhouse" ) +func ptarLayer(t *testing.T, name, content string) (v1.Layer, error) { + t.Helper() + return ptar.Layer([]ptar.File{{Path: name, Data: []byte(content)}}) +} + func layerPaths(t *testing.T, l v1.Layer) map[string]bool { t.Helper() rc, err := l.Uncompressed() @@ -262,6 +268,39 @@ func TestInterpreterVersion(t *testing.T) { } } +func TestInterpreterVersionFromAPKO(t *testing.T) { + // A Chainguard-style apko.json (no PYTHON_VERSION env) in the top layer. + apko := `{"contents":{"packages":["ca-certificates-bundle=20260413-r0","py3-pip-wheel=26.1.2-r0","python-3.14-base=3.14.5-r2","python-3.14=3.14.5-r2","zlib=1.3.2-r3"]},"entrypoint":{"command":"/usr/bin/python"}}` + layer, err := ptarLayer(t, "etc/apko.json", apko) + if err != nil { + t.Fatal(err) + } + img, err := mutate.AppendLayers(empty.Image, layer) + if err != nil { + t.Fatal(err) + } + + maj, min, ok := InterpreterVersion(img) + if !ok || maj != 3 || min != 14 { + t.Fatalf("apko fallback: got %d.%d ok=%v, want 3.14 ok=true", maj, min, ok) + } + + // The PYTHON_VERSION env, when present, takes precedence over apko.json. + cf, err := img.ConfigFile() + if err != nil { + t.Fatal(err) + } + cf = cf.DeepCopy() + cf.Config.Env = []string{"PYTHON_VERSION=3.12.7"} + imgEnv, err := mutate.ConfigFile(img, cf) + if err != nil { + t.Fatal(err) + } + if maj, min, ok := InterpreterVersion(imgEnv); !ok || maj != 3 || min != 12 { + t.Fatalf("env precedence: got %d.%d ok=%v, want 3.12", maj, min, ok) + } +} + func TestSourceLayerIgnore(t *testing.T) { src := t.TempDir() must := func(p, content string) { diff --git a/pymage/internal/build/resolve.go b/pymage/internal/build/resolve.go index 65ba61e..8dce21a 100644 --- a/pymage/internal/build/resolve.go +++ b/pymage/internal/build/resolve.go @@ -1,8 +1,12 @@ package build import ( + "archive/tar" "context" + "encoding/json" "fmt" + "io" + "regexp" "strconv" "strings" @@ -37,12 +41,29 @@ func Base(ctx context.Context, ref string, platform *v1.Platform, kc authn.Keych return img, nil } -// InterpreterVersion reports the Python X.Y advertised by the base image via a -// PYTHON_VERSION env var (set by the official python images and others). When a -// base does not advertise one, ok is false and the caller cannot validate the -// interpreter — a reason to pin a known base rather than rely on a floating -// tag. It reads only the (already-fetched) config, never layer bytes. +// apkoMaxBytes caps how much of the apko.json file we read. +const apkoMaxBytes = 4 << 20 + +// pythonPkgRE matches an apko "python-X.Y" package name (with or without the +// "-base" suffix), as found in a Chainguard/Wolfi image's apko.json. +var pythonPkgRE = regexp.MustCompile(`^python-(\d+)\.(\d+)(?:-base)?$`) + +// InterpreterVersion reports the Python X.Y the base image provides. +// +// It first checks a PYTHON_VERSION env var (set by the official python images), +// which is free since the config is already fetched. If that is absent it falls +// back to reading /etc/apko.json from the top-most layer and parsing the +// "python-X.Y" package (Chainguard/Wolfi images, which don't set the env var); +// this fetches only that one layer. When neither is present, ok is false and +// the interpreter cannot be validated — a reason to pin a known base. func InterpreterVersion(img v1.Image) (major, minor int, ok bool) { + if maj, min, ok := interpreterFromEnv(img); ok { + return maj, min, ok + } + return interpreterFromAPKO(img) +} + +func interpreterFromEnv(img v1.Image) (major, minor int, ok bool) { cf, err := img.ConfigFile() if err != nil || cf == nil { return 0, 0, false @@ -52,16 +73,82 @@ func InterpreterVersion(img v1.Image) (major, minor int, ok bool) { if !found || k != "PYTHON_VERSION" { continue } - parts := strings.SplitN(v, ".", 3) - if len(parts) < 2 { - return 0, 0, false - } - maj, err1 := strconv.Atoi(parts[0]) - min, err2 := strconv.Atoi(parts[1]) - if err1 != nil || err2 != nil { - return 0, 0, false - } - return maj, min, true + return parseMajorMinor(v) } return 0, 0, false } + +// interpreterFromAPKO reads /etc/apko.json from the top-most layer and extracts +// the Python version from its package list. +func interpreterFromAPKO(img v1.Image) (major, minor int, ok bool) { + layers, err := img.Layers() + if err != nil || len(layers) == 0 { + return 0, 0, false + } + data, found := readFileFromLayer(layers[len(layers)-1], "etc/apko.json") + if !found { + return 0, 0, false + } + var doc struct { + Contents struct { + Packages []string `json:"packages"` + } `json:"contents"` + } + if err := json.Unmarshal(data, &doc); err != nil { + return 0, 0, false + } + for _, pkg := range doc.Contents.Packages { + // Entries look like "python-3.14=3.14.5-r2"; take the package name. + name := pkg + if i := strings.IndexAny(pkg, "=<>~ "); i >= 0 { + name = pkg[:i] + } + if m := pythonPkgRE.FindStringSubmatch(name); m != nil { + maj, err1 := strconv.Atoi(m[1]) + min, err2 := strconv.Atoi(m[2]) + if err1 == nil && err2 == nil { + return maj, min, true + } + } + } + return 0, 0, false +} + +// readFileFromLayer returns the contents of name (a slash path without a +// leading slash) from a layer's uncompressed tar, if present. +func readFileFromLayer(layer v1.Layer, name string) ([]byte, bool) { + rc, err := layer.Uncompressed() + if err != nil { + return nil, false + } + defer func() { _ = rc.Close() }() + tr := tar.NewReader(rc) + for { + h, err := tr.Next() + if err != nil { + return nil, false + } + entry := strings.TrimPrefix(strings.TrimPrefix(h.Name, "./"), "/") + if entry != name || h.Typeflag != tar.TypeReg { + continue + } + data, err := io.ReadAll(io.LimitReader(tr, apkoMaxBytes)) + if err != nil { + return nil, false + } + return data, true + } +} + +func parseMajorMinor(v string) (major, minor int, ok bool) { + parts := strings.SplitN(v, ".", 3) + if len(parts) < 2 { + return 0, 0, false + } + maj, err1 := strconv.Atoi(parts[0]) + min, err2 := strconv.Atoi(parts[1]) + if err1 != nil || err2 != nil { + return 0, 0, false + } + return maj, min, true +}