mirror of
https://github.com/imjasonh/terraform-playground
synced 2026-07-07 23:35:16 +00:00
build: validate base interpreter against --python to catch floating-tag drift
Reads the base image's advertised PYTHON_VERSION (config env, no layer download) and fails the build when it doesn't match --python, so a base tag that slides to a new Python version can't silently produce a broken image. Documents base-pinning tradeoffs in the README. Adds unit + CLI tests. Co-authored-by: Jason Hall <imjasonh@users.noreply.github.com>
This commit is contained in:
parent
37a7e85893
commit
f17ec18a0c
5 changed files with 126 additions and 0 deletions
|
|
@ -59,6 +59,25 @@ Each platform selects its own compatible wheels (pure-python wheels are shared),
|
|||
so the wheelhouse must contain a compatible wheel per platform for any compiled
|
||||
dependency.
|
||||
|
||||
### Choosing a base image
|
||||
|
||||
The base is an input to the build, so it affects reproducibility just like the
|
||||
lock and source do. **Pin it by digest** (e.g.
|
||||
`cgr.dev/chainguard/python@sha256:…`) for stable, no-bytes rebuilds.
|
||||
|
||||
A floating tag such as `cgr.dev/chainguard/python:latest` works, but be aware:
|
||||
|
||||
- it makes the base an *uncontrolled input*, so rebuilds aren't reproducible and
|
||||
may push fresh base layers whenever the tag moves; and
|
||||
- `:latest` slides forward across Python **minor versions**. Pure-python wheels
|
||||
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.)
|
||||
|
||||
### Useful flags
|
||||
|
||||
| Flag | Description |
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import (
|
|||
|
||||
v1 "github.com/google/go-containerregistry/pkg/v1"
|
||||
"github.com/google/go-containerregistry/pkg/v1/empty"
|
||||
"github.com/google/go-containerregistry/pkg/v1/mutate"
|
||||
|
||||
"github.com/imjasonh/terraform-playground/pymage/internal/cache"
|
||||
"github.com/imjasonh/terraform-playground/pymage/internal/testwheel"
|
||||
|
|
@ -241,6 +242,26 @@ func TestBuildWithCacheIsConsistentAndPopulates(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestInterpreterVersion(t *testing.T) {
|
||||
withEnv := func(env []string) v1.Image {
|
||||
img, err := mutate.ConfigFile(empty.Image, &v1.ConfigFile{Config: v1.Config{Env: env}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return img
|
||||
}
|
||||
|
||||
if maj, min, ok := InterpreterVersion(withEnv([]string{"PYTHON_VERSION=3.12.11", "PATH=/usr/bin"})); !ok || maj != 3 || min != 12 {
|
||||
t.Errorf("got %d.%d ok=%v, want 3.12 ok=true", maj, min, ok)
|
||||
}
|
||||
if _, _, ok := InterpreterVersion(withEnv([]string{"PATH=/usr/bin"})); ok {
|
||||
t.Error("expected ok=false when no PYTHON_VERSION is advertised")
|
||||
}
|
||||
if maj, min, ok := InterpreterVersion(withEnv([]string{"PYTHON_VERSION=3.13"})); !ok || maj != 3 || min != 13 {
|
||||
t.Errorf("got %d.%d ok=%v, want 3.13 ok=true", maj, min, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSourceLayerIgnore(t *testing.T) {
|
||||
src := t.TempDir()
|
||||
must := func(p, content string) {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ package build
|
|||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/google/go-containerregistry/pkg/authn"
|
||||
"github.com/google/go-containerregistry/pkg/name"
|
||||
|
|
@ -34,3 +36,32 @@ 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.
|
||||
func InterpreterVersion(img v1.Image) (major, minor int, ok bool) {
|
||||
cf, err := img.ConfigFile()
|
||||
if err != nil || cf == nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
for _, kv := range cf.Config.Env {
|
||||
k, v, found := strings.Cut(kv, "=")
|
||||
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 0, 0, false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -208,6 +208,14 @@ func buildOne(ctx context.Context, f *buildFlags, reqs []lock.Requirement, platf
|
|||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
// If the base advertises its interpreter version, make sure it matches the
|
||||
// requested --python. This catches a floating base tag (e.g. ":latest")
|
||||
// sliding to a Python version that the selected wheels/layout don't target,
|
||||
// which would otherwise silently produce a broken image.
|
||||
if maj, min, ok := build.InterpreterVersion(base); ok && (maj != target.PyMajor || min != target.PyMinor) {
|
||||
return nil, nil, fmt.Errorf("base %q provides Python %d.%d but --python is python%d.%d; pass --python python%d.%d (or pin a matching base)",
|
||||
f.base, maj, min, target.PyMajor, target.PyMinor, maj, min)
|
||||
}
|
||||
img, err := build.Build(build.Options{
|
||||
Base: base,
|
||||
Wheels: wheels,
|
||||
|
|
|
|||
|
|
@ -129,6 +129,53 @@ func writeMultiArchBase(t *testing.T, ref string, arches []string) {
|
|||
}
|
||||
}
|
||||
|
||||
// TestBuildRejectsInterpreterMismatch ensures a base advertising a different
|
||||
// Python version than --python fails fast (the floating-tag drift guard).
|
||||
func TestBuildRejectsInterpreterMismatch(t *testing.T) {
|
||||
s := httptest.NewServer(registry.New())
|
||||
t.Cleanup(s.Close)
|
||||
host := strings.TrimPrefix(s.URL, "http://")
|
||||
|
||||
baseRef := host + "/base:py313"
|
||||
img, err := mutate.ConfigFile(empty.Image, &v1.ConfigFile{
|
||||
OS: "linux", Architecture: "amd64",
|
||||
Config: v1.Config{Env: []string{"PYTHON_VERSION=3.13.1", "PATH=/usr/bin"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, _ := name.ParseReference(baseRef)
|
||||
if err := remote.Write(r, img, remote.WithContext(context.Background())); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
wh := t.TempDir()
|
||||
_, sha := testwheel.Write(t, wh, testwheel.Spec{Name: "alpha", Version: "1.0", Modules: map[string]string{"alpha/__init__.py": "V='1'\n"}})
|
||||
reqFile := filepath.Join(t.TempDir(), "requirements.txt")
|
||||
if err := os.WriteFile(reqFile, []byte(fmt.Sprintf("alpha==1.0 --hash=sha256:%s\n", sha)), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cmd := Root()
|
||||
cmd.SetArgs([]string{
|
||||
"build",
|
||||
"--base", baseRef,
|
||||
"--lock", reqFile,
|
||||
"--find-links", wh,
|
||||
"--python", "python3.12", // mismatches the base's 3.13
|
||||
"--push=false",
|
||||
"--print-digest",
|
||||
})
|
||||
cmd.SetOut(os.Stderr)
|
||||
err = cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected build to fail on interpreter mismatch")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "Python 3.13") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePythonTag(t *testing.T) {
|
||||
maj, min, err := parsePythonTag("python3.12")
|
||||
if err != nil || maj != 3 || min != 12 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue