diff --git a/pymage/README.md b/pymage/README.md index db657df..6dc2121 100644 --- a/pymage/README.md +++ b/pymage/README.md @@ -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 | diff --git a/pymage/internal/build/build_test.go b/pymage/internal/build/build_test.go index 90cf766..cf76f25 100644 --- a/pymage/internal/build/build_test.go +++ b/pymage/internal/build/build_test.go @@ -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) { diff --git a/pymage/internal/build/resolve.go b/pymage/internal/build/resolve.go index 4e2ab75..65ba61e 100644 --- a/pymage/internal/build/resolve.go +++ b/pymage/internal/build/resolve.go @@ -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 +} diff --git a/pymage/internal/cli/cli.go b/pymage/internal/cli/cli.go index 4db5dfc..8e393ba 100644 --- a/pymage/internal/cli/cli.go +++ b/pymage/internal/cli/cli.go @@ -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, diff --git a/pymage/internal/cli/cli_test.go b/pymage/internal/cli/cli_test.go index ca41c06..b27d3b1 100644 --- a/pymage/internal/cli/cli_test.go +++ b/pymage/internal/cli/cli_test.go @@ -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 {