1
0
Fork 0
mirror of https://github.com/imjasonh/terraform-playground synced 2026-07-07 23:35:16 +00:00

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 <imjasonh@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-06-10 16:16:20 +00:00
parent f17ec18a0c
commit a8e0c59657
No known key found for this signature in database
3 changed files with 147 additions and 19 deletions

View file

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

View file

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

View file

@ -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,6 +73,74 @@ func InterpreterVersion(img v1.Image) (major, minor int, ok bool) {
if !found || k != "PYTHON_VERSION" {
continue
}
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
@ -63,5 +152,3 @@ func InterpreterVersion(img v1.Image) (major, minor int, ok bool) {
}
return maj, min, true
}
return 0, 0, false
}