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:
parent
f17ec18a0c
commit
a8e0c59657
3 changed files with 147 additions and 19 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue