diff --git a/pymage/README.md b/pymage/README.md index cb71523..82e3dc1 100644 --- a/pymage/README.md +++ b/pymage/README.md @@ -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`). | diff --git a/pymage/internal/build/build_test.go b/pymage/internal/build/build_test.go index 1bacb57..4c23424 100644 --- a/pymage/internal/build/build_test.go +++ b/pymage/internal/build/build_test.go @@ -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)}}) diff --git a/pymage/internal/build/resolve.go b/pymage/internal/build/resolve.go index 8dce21a..32cf43a 100644 --- a/pymage/internal/build/resolve.go +++ b/pymage/internal/build/resolve.go @@ -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), diff --git a/pymage/internal/cli/cli.go b/pymage/internal/cli/cli.go index adec116..bdd40be 100644 --- a/pymage/internal/cli/cli.go +++ b/pymage/internal/cli/cli.go @@ -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 diff --git a/pymage/internal/cli/cli_test.go b/pymage/internal/cli/cli_test.go index 133696a..1115eb6 100644 --- a/pymage/internal/cli/cli_test.go +++ b/pymage/internal/cli/cli_test.go @@ -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