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:
parent
389bfca56b
commit
bec24b79ca
5 changed files with 210 additions and 11 deletions
|
|
@ -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`). |
|
||||
|
|
|
|||
|
|
@ -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)}})
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue