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

cli: default --platform to the base image's supported platforms

When no platform is given, inspect the base reference: a multi-arch base index
yields a multi-arch build (filtering out attestation/unknown entries), a
single-arch base yields one image. Explicit --platform still overrides. Adds
build.BasePlatforms with tests + a CLI default-platforms test; documents it.

Co-authored-by: Jason Hall <imjasonh@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-06-10 21:39:40 +00:00
parent 389bfca56b
commit bec24b79ca
No known key found for this signature in database
5 changed files with 210 additions and 11 deletions

View file

@ -67,7 +67,7 @@ the config value, which overrides the built-in default.
| `repo` | `--repo` | *(required to push)* |
| `tags` | `-t`/`--tag` (repeatable) | `["latest"]` |
| `base` | `--base` | `cgr.dev/chainguard/python:latest` |
| `platforms` | `--platform` | host registry default (single image) |
| `platforms` | `--platform` | the platforms the **base image** supports |
| `layer-strategy` | `--layer-strategy` | `per-wheel` |
| `python` | `--python` | auto-detected from the base |
| `prefix` | `--prefix` | `/app/.venv` |
@ -94,17 +94,21 @@ go run . build ./example --repo localhost:5000/example -t latest --insecure
### Multi-arch
Listing more than one platform (in config or via `--platform`) builds a
multi-arch image **index** (one image per platform, assembled into an OCI
index). Because no Docker daemon is involved, this works from any host OS —
Linux, macOS, or Windows:
When `--platform` is omitted, pymage builds for **exactly the platforms the base
image supports** — so a multi-arch base (e.g. `cgr.dev/chainguard/python`, which
ships `linux/amd64` + `linux/arm64`) produces a multi-arch image **index** with
no extra flags, and a single-arch base produces a single image. You can override
this by listing platforms explicitly (in config or via `--platform`):
```
pymage build # match the base's platforms
pymage build --platform linux/amd64,linux/arm64 -t latest
```
Each platform selects its own compatible wheels from `uv.lock` (pure-python
wheels are shared across arches).
Building more than one platform assembles one image per platform into an OCI
index. Because no Docker daemon is involved, this works from any host OS — Linux,
macOS, or Windows. Each platform selects its own compatible wheels from `uv.lock`
(pure-python wheels are shared across arches).
### Hashed requirements.txt (pip-compile / uv pip compile)
@ -151,7 +155,7 @@ base that advertises its version.
| `--print-digest` | Print only the resulting image digest (no push). |
| `--sbom PATH` | Write a CycloneDX SBOM of the resolved wheels. |
| `--layer-strategy` | `per-wheel` (default) or `single-deps-layer`. |
| `--platform` | Target platform(s); selects compatible wheels and base. Repeatable / comma-separated (e.g. `linux/amd64,linux/arm64`) builds a multi-arch image index. |
| `--platform` | Target platform(s); selects compatible wheels and base. Repeatable / comma-separated (e.g. `linux/amd64,linux/arm64`) builds a multi-arch image index. Defaults to the platforms the base image supports. |
| `--python` | Interpreter version, e.g. `python3.12`. Optional — **auto-detected from the base** when omitted; if set, must match the base. Drives wheel selection and the site-packages layout. |
| `--cache-dir` | Content-addressed layer cache; reuses compressed layers and downloaded wheels across rebuilds. |
| `--prefix` | install prefix / venv root (default `/app/.venv`). |

View file

@ -2,15 +2,20 @@ package build
import (
"archive/tar"
"context"
"io"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/google/go-containerregistry/pkg/name"
"github.com/google/go-containerregistry/pkg/registry"
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/google/go-containerregistry/pkg/v1/remote"
"github.com/imjasonh/terraform-playground/pymage/internal/cache"
"github.com/imjasonh/terraform-playground/pymage/internal/ptar"
@ -19,6 +24,58 @@ import (
"github.com/imjasonh/terraform-playground/pymage/internal/wheelhouse"
)
func TestBasePlatforms(t *testing.T) {
s := httptest.NewServer(registry.New())
t.Cleanup(s.Close)
host := strings.TrimPrefix(s.URL, "http://")
ctx := context.Background()
mk := func(os, arch string) v1.Image {
img, err := mutate.ConfigFile(empty.Image, &v1.ConfigFile{OS: os, Architecture: arch})
if err != nil {
t.Fatal(err)
}
return img
}
// A multi-arch index with an attestation-style "unknown" entry that must be
// ignored.
idx := mutate.AppendManifests(empty.Index,
mutate.IndexAddendum{Add: mk("linux", "amd64"), Descriptor: v1.Descriptor{Platform: &v1.Platform{OS: "linux", Architecture: "amd64"}}},
mutate.IndexAddendum{Add: mk("linux", "arm64"), Descriptor: v1.Descriptor{Platform: &v1.Platform{OS: "linux", Architecture: "arm64"}}},
mutate.IndexAddendum{Add: mk("unknown", "unknown"), Descriptor: v1.Descriptor{Platform: &v1.Platform{OS: "unknown", Architecture: "unknown"}}},
)
idxRef, _ := name.ParseReference(host + "/multi:latest")
if err := remote.WriteIndex(idxRef, idx, remote.WithContext(ctx)); err != nil {
t.Fatal(err)
}
plats, err := BasePlatforms(ctx, host+"/multi:latest", nil)
if err != nil {
t.Fatal(err)
}
got := map[string]bool{}
for _, p := range plats {
got[p.OS+"/"+p.Architecture] = true
}
if len(plats) != 2 || !got["linux/amd64"] || !got["linux/arm64"] {
t.Fatalf("index platforms = %v, want linux/amd64 + linux/arm64 (no unknown)", plats)
}
// A plain single-arch image returns its one platform.
imgRef, _ := name.ParseReference(host + "/single:latest")
if err := remote.Write(imgRef, mk("linux", "arm64"), remote.WithContext(ctx)); err != nil {
t.Fatal(err)
}
plats, err = BasePlatforms(ctx, host+"/single:latest", nil)
if err != nil {
t.Fatal(err)
}
if len(plats) != 1 || plats[0].OS != "linux" || plats[0].Architecture != "arm64" {
t.Fatalf("single platforms = %v, want [linux/arm64]", plats)
}
}
func ptarLayer(t *testing.T, name, content string) (v1.Layer, error) {
t.Helper()
return ptar.Layer([]ptar.File{{Path: name, Data: []byte(content)}})

View file

@ -48,6 +48,70 @@ const apkoMaxBytes = 4 << 20
// "-base" suffix), as found in a Chainguard/Wolfi image's apko.json.
var pythonPkgRE = regexp.MustCompile(`^python-(\d+)\.(\d+)(?:-base)?$`)
// BasePlatforms returns the platforms a base reference supports: the entries of
// its image index (minus attestation/"unknown" placeholders), or the single
// platform from a plain image's config. It is used to default the build's
// target platforms to whatever the base provides.
func BasePlatforms(ctx context.Context, ref string, kc authn.Keychain, nameOpts ...name.Option) ([]v1.Platform, error) {
r, err := name.ParseReference(ref, nameOpts...)
if err != nil {
return nil, fmt.Errorf("build: parse base ref %q: %w", ref, err)
}
if kc == nil {
kc = authn.DefaultKeychain
}
desc, err := remote.Get(r, remote.WithContext(ctx), remote.WithAuthFromKeychain(kc))
if err != nil {
return nil, fmt.Errorf("build: inspect base %q: %w", ref, err)
}
if desc.MediaType.IsIndex() {
idx, err := desc.ImageIndex()
if err != nil {
return nil, err
}
im, err := idx.IndexManifest()
if err != nil {
return nil, err
}
var plats []v1.Platform
seen := map[string]bool{}
for _, m := range im.Manifests {
p := m.Platform
if p == nil || p.OS == "" || p.Architecture == "" {
continue
}
// Skip attestation/SBOM placeholders (buildx uses unknown/unknown).
if p.OS == "unknown" || p.Architecture == "unknown" {
continue
}
key := p.OS + "/" + p.Architecture + "/" + p.Variant
if seen[key] {
continue
}
seen[key] = true
plats = append(plats, v1.Platform{OS: p.OS, Architecture: p.Architecture, Variant: p.Variant})
}
if len(plats) == 0 {
return nil, fmt.Errorf("build: base index %q advertises no usable platforms", ref)
}
return plats, nil
}
img, err := desc.Image()
if err != nil {
return nil, err
}
cf, err := img.ConfigFile()
if err != nil {
return nil, err
}
if cf.OS == "" || cf.Architecture == "" {
return nil, fmt.Errorf("build: base image %q has no platform in its config", ref)
}
return []v1.Platform{{OS: cf.OS, Architecture: cf.Architecture, Variant: cf.Variant}}, nil
}
// InterpreterVersion reports the Python X.Y the base image provides.
//
// It first checks a PYTHON_VERSION env var (set by the official python images),

View file

@ -149,6 +149,15 @@ func runBuild(cmd *cobra.Command, f *buildFlags) error {
nameOpts = append(nameOpts, name.Insecure)
}
// With no platform requested, default to whatever the base image supports
// (a multi-arch base yields a multi-arch build).
if len(platforms) == 0 {
platforms, err = defaultPlatformsFromBase(ctx, f, nameOpts)
if err != nil {
return err
}
}
var layerCache *cache.Cache
wheelCache, err := wheelCacheDir(f)
if err != nil {
@ -170,9 +179,6 @@ func runBuild(cmd *cobra.Command, f *buildFlags) error {
allDeps []wheelhouse.ResolvedWheel
)
targets := platforms
if len(targets) == 0 {
targets = []v1.Platform{{}} // single build using registry defaults
}
for i := range targets {
p := targets[i]
var pp *v1.Platform
@ -365,6 +371,16 @@ func writeLayout(dir string, img v1.Image, idx v1.ImageIndex) error {
return p.AppendImage(img)
}
// defaultPlatformsFromBase returns the platforms to build for when --platform
// is not given: those advertised by the base image. A scratch base has no
// registry metadata, so it falls back to a single registry-default build.
func defaultPlatformsFromBase(ctx context.Context, f *buildFlags, nameOpts []name.Option) ([]v1.Platform, error) {
if f.base == "scratch" {
return []v1.Platform{{}}, nil
}
return build.BasePlatforms(ctx, f.base, authn.DefaultKeychain, nameOpts...)
}
// parsePlatforms parses the (comma-split, repeatable) --platform values.
func parsePlatforms(specs []string) ([]v1.Platform, error) {
var out []v1.Platform

View file

@ -106,6 +106,64 @@ func TestBuildMultiArchIndex(t *testing.T) {
}
}
// TestBuildDefaultsToBasePlatforms verifies that, without --platform, the build
// targets exactly the platforms the base image advertises.
func TestBuildDefaultsToBasePlatforms(t *testing.T) {
ctx := context.Background()
s := httptest.NewServer(registry.New())
t.Cleanup(s.Close)
host := strings.TrimPrefix(s.URL, "http://")
// Multi-arch base (amd64 + arm64), advertising its Python version.
baseRef := host + "/base:latest"
writeMultiArchBase(t, baseRef, []string{"amd64", "arm64"})
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)
}
src := t.TempDir()
if err := os.WriteFile(filepath.Join(src, "app.py"), []byte("print('hi')\n"), 0o644); err != nil {
t.Fatal(err)
}
cmd := Root()
cmd.SetArgs([]string{
"build", src,
"--base", baseRef,
"--lock", reqFile,
"--find-links", wh,
"--entrypoint", "python", "--entrypoint", "/app/app.py",
"--repo", host + "/app",
// no --platform: should default to the base's amd64 + arm64
})
cmd.SetOut(os.Stderr)
if err := cmd.Execute(); err != nil {
t.Fatalf("build failed: %v", err)
}
ref, _ := name.ParseReference(host + "/app:latest")
idx, err := remote.Index(ref, remote.WithContext(ctx))
if err != nil {
t.Fatalf("expected a multi-arch index by default: %v", err)
}
im, err := idx.IndexManifest()
if err != nil {
t.Fatal(err)
}
got := map[string]bool{}
for _, m := range im.Manifests {
if m.Platform != nil {
got[m.Platform.OS+"/"+m.Platform.Architecture] = true
}
}
if len(im.Manifests) != 2 || !got["linux/amd64"] || !got["linux/arm64"] {
t.Fatalf("default index platforms = %v, want linux/amd64 + linux/arm64", got)
}
}
func writeMultiArchBase(t *testing.T, ref string, arches []string) {
t.Helper()
var adds []mutate.IndexAddendum