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

cli: build multi-arch image index from multiple --platform values

- --platform is now repeatable/comma-separated; >1 platform builds one image
  per platform and assembles them into an OCI image index (remote.WriteIndex /
  OCI layout). SBOM aggregates wheels across platforms.
- adds TestBuildMultiArchIndex (2-platform index, reproducible) against an
  in-process registry; documents multi-arch in the README

Co-authored-by: Jason Hall <imjasonh@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-06-10 15:45:36 +00:00
parent 7290806232
commit c15f3e350c
No known key found for this signature in database
3 changed files with 261 additions and 39 deletions

View file

@ -40,6 +40,25 @@ The wheels referenced by the lock must be present in a `--find-links` directory
(e.g. produced by `pip download -r requirements.txt -d ./wheelhouse`). Resolution (e.g. produced by `pip download -r requirements.txt -d ./wheelhouse`). Resolution
is intentionally local so builds are hermetic and reproducible. is intentionally local so builds are hermetic and reproducible.
### Multi-arch
Pass multiple platforms to build 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:
```
pymage build \
--base python:3.12-slim \
--platform linux/amd64,linux/arm64 \
--lock requirements.txt --find-links ./wheelhouse --source ./ \
--entrypoint python --entrypoint -m --entrypoint myapp \
-t registry.example.com/me/myapp:latest
```
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.
### Useful flags ### Useful flags
| Flag | Description | | Flag | Description |
@ -49,7 +68,7 @@ is intentionally local so builds are hermetic and reproducible.
| `--print-digest` | Print only the resulting image digest (no push). | | `--print-digest` | Print only the resulting image digest (no push). |
| `--sbom PATH` | Write a CycloneDX SBOM of the resolved wheels. | | `--sbom PATH` | Write a CycloneDX SBOM of the resolved wheels. |
| `--layer-strategy` | `per-wheel` (default) or `single-deps-layer`. | | `--layer-strategy` | `per-wheel` (default) or `single-deps-layer`. |
| `--platform` | Target platform; selects compatible wheels and the base, e.g. `linux/amd64`. | | `--platform` | Target platform(s); selects compatible wheels and base. Repeatable / comma-separated (e.g. `linux/amd64,linux/arm64`) builds a multi-arch image index. |
| `--python` | Interpreter version / site-packages dir (default `python3.12`); also selects compatible wheels. | | `--python` | Interpreter version / site-packages dir (default `python3.12`); also selects compatible wheels. |
| `--cache-dir` | Content-addressed layer cache; reuses compressed layers across rebuilds. | | `--cache-dir` | Content-addressed layer cache; reuses compressed layers across rebuilds. |
| `--prefix` | install prefix / venv root (default `/app/.venv`). | | `--prefix` | install prefix / venv root (default `/app/.venv`). |

View file

@ -13,6 +13,7 @@ import (
v1 "github.com/google/go-containerregistry/pkg/v1" v1 "github.com/google/go-containerregistry/pkg/v1"
"github.com/google/go-containerregistry/pkg/v1/empty" "github.com/google/go-containerregistry/pkg/v1/empty"
"github.com/google/go-containerregistry/pkg/v1/layout" "github.com/google/go-containerregistry/pkg/v1/layout"
"github.com/google/go-containerregistry/pkg/v1/mutate"
"github.com/google/go-containerregistry/pkg/v1/remote" "github.com/google/go-containerregistry/pkg/v1/remote"
"github.com/spf13/cobra" "github.com/spf13/cobra"
@ -42,7 +43,7 @@ type buildFlags struct {
findLinks []string findLinks []string
source string source string
tag string tag string
platform string platforms []string
pythonTag string pythonTag string
prefix string prefix string
workingDir string workingDir string
@ -78,7 +79,7 @@ func buildCmd() *cobra.Command {
fs.StringArrayVar(&f.findLinks, "find-links", nil, "directory of wheels to resolve against (repeatable, required)") fs.StringArrayVar(&f.findLinks, "find-links", nil, "directory of wheels to resolve against (repeatable, required)")
fs.StringVar(&f.source, "source", "", "application source directory (optional)") fs.StringVar(&f.source, "source", "", "application source directory (optional)")
fs.StringVarP(&f.tag, "tag", "t", "", "image tag to push, e.g. registry/repo:tag") fs.StringVarP(&f.tag, "tag", "t", "", "image tag to push, e.g. registry/repo:tag")
fs.StringVar(&f.platform, "platform", "", "platform for base resolution, e.g. linux/amd64") fs.StringSliceVar(&f.platforms, "platform", nil, "target platform(s); repeatable or comma-separated, e.g. linux/amd64,linux/arm64 (multiple builds a multi-arch index)")
fs.StringVar(&f.pythonTag, "python", "python3.12", "python lib directory name for site-packages") fs.StringVar(&f.pythonTag, "python", "python3.12", "python lib directory name for site-packages")
fs.StringVar(&f.prefix, "prefix", "/app/.venv", "install prefix (venv-like root)") fs.StringVar(&f.prefix, "prefix", "/app/.venv", "install prefix (venv-like root)")
fs.StringVar(&f.workingDir, "workdir", "/app", "image working directory and source destination") fs.StringVar(&f.workingDir, "workdir", "/app", "image working directory and source destination")
@ -126,19 +127,7 @@ func runBuild(ctx context.Context, f *buildFlags) error {
return err return err
} }
var platform *v1.Platform platforms, err := parsePlatforms(f.platforms)
if f.platform != "" {
platform, err = v1.ParsePlatform(f.platform)
if err != nil {
return err
}
}
target, err := resolveTarget(platform, f.pythonTag)
if err != nil {
return err
}
wheels, err := wheelhouse.Resolve(reqs, f.findLinks, target)
if err != nil { if err != nil {
return err return err
} }
@ -148,11 +137,6 @@ func runBuild(ctx context.Context, f *buildFlags) error {
nameOpts = append(nameOpts, name.Insecure) nameOpts = append(nameOpts, name.Insecure)
} }
base, err := resolveBase(ctx, f.base, platform, nameOpts...)
if err != nil {
return err
}
var layerCache *cache.Cache var layerCache *cache.Cache
if f.cacheDir != "" { if f.cacheDir != "" {
layerCache, err = cache.New(f.cacheDir) layerCache, err = cache.New(f.cacheDir)
@ -161,6 +145,69 @@ func runBuild(ctx context.Context, f *buildFlags) error {
} }
} }
// Build one image per target platform. With more than one platform we
// assemble them into a multi-arch OCI image index; with zero or one we push
// a plain image manifest.
var (
images []v1.Image
adds []mutate.IndexAddendum
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
if p.OS != "" || p.Architecture != "" {
pp = &p
}
img, deps, err := buildOne(ctx, f, reqs, pp, labels, layerCache, nameOpts)
if err != nil {
return err
}
images = append(images, img)
allDeps = append(allDeps, deps...)
desc := v1.Descriptor{}
if pp != nil {
desc.Platform = pp
}
adds = append(adds, mutate.IndexAddendum{Add: img, Descriptor: desc})
}
if f.sbomOut != "" {
data, err := sbom.Generate(dedupeWheels(allDeps))
if err != nil {
return err
}
if err := os.WriteFile(f.sbomOut, data, 0o644); err != nil {
return err
}
}
if len(platforms) > 1 {
idx := mutate.AppendManifests(empty.Index, adds...)
return output(ctx, f, nameOpts, nil, idx)
}
return output(ctx, f, nameOpts, images[0], nil)
}
// buildOne resolves the base and wheels for a single platform and builds the
// image, returning the resolved wheels for SBOM aggregation.
func buildOne(ctx context.Context, f *buildFlags, reqs []lock.Requirement, platform *v1.Platform, labels map[string]string, layerCache *cache.Cache, nameOpts []name.Option) (v1.Image, []wheelhouse.ResolvedWheel, error) {
target, err := resolveTarget(platform, f.pythonTag)
if err != nil {
return nil, nil, err
}
wheels, err := wheelhouse.Resolve(reqs, f.findLinks, target)
if err != nil {
return nil, nil, err
}
base, err := resolveBase(ctx, f.base, platform, nameOpts...)
if err != nil {
return nil, nil, err
}
img, err := build.Build(build.Options{ img, err := build.Build(build.Options{
Base: base, Base: base,
Wheels: wheels, Wheels: wheels,
@ -177,33 +224,38 @@ func runBuild(ctx context.Context, f *buildFlags) error {
Cache: layerCache, Cache: layerCache,
}) })
if err != nil { if err != nil {
return err return nil, nil, err
} }
return img, wheels, nil
}
if f.sbomOut != "" { // output writes/pushes either a single image or a multi-arch index (exactly one
data, err := sbom.Generate(wheels) // of img/idx is non-nil).
if err != nil { func output(ctx context.Context, f *buildFlags, nameOpts []name.Option, img v1.Image, idx v1.ImageIndex) error {
return err var (
} digest v1.Hash
if err := os.WriteFile(f.sbomOut, data, 0o644); err != nil { err error
return err )
} if idx != nil {
digest, err = idx.Digest()
} else {
digest, err = img.Digest()
} }
digest, err := img.Digest()
if err != nil { if err != nil {
return err return err
} }
if f.ociLayout != "" { if f.ociLayout != "" {
if _, err := layout.Write(f.ociLayout, empty.Index); err != nil { p, err := layout.Write(f.ociLayout, empty.Index)
return err
}
p, err := layout.FromPath(f.ociLayout)
if err != nil { if err != nil {
return err return err
} }
if err := p.AppendImage(img); err != nil { if idx != nil {
err = p.AppendIndex(idx)
} else {
err = p.AppendImage(img)
}
if err != nil {
return err return err
} }
} }
@ -221,10 +273,16 @@ func runBuild(ctx context.Context, f *buildFlags) error {
if err != nil { if err != nil {
return err return err
} }
if err := remote.Write(ref, img, opts := []remote.Option{
remote.WithContext(ctx), remote.WithContext(ctx),
remote.WithAuthFromKeychain(authn.DefaultKeychain), remote.WithAuthFromKeychain(authn.DefaultKeychain),
); err != nil { }
if idx != nil {
err = remote.WriteIndex(ref, idx, opts...)
} else {
err = remote.Write(ref, img, opts...)
}
if err != nil {
return fmt.Errorf("push: %w", err) return fmt.Errorf("push: %w", err)
} }
fmt.Printf("pushed %s@%s\n", ref.Context().Name(), digest) fmt.Printf("pushed %s@%s\n", ref.Context().Name(), digest)
@ -235,6 +293,39 @@ func runBuild(ctx context.Context, f *buildFlags) error {
return nil return nil
} }
// parsePlatforms parses the (comma-split, repeatable) --platform values.
func parsePlatforms(specs []string) ([]v1.Platform, error) {
var out []v1.Platform
for _, s := range specs {
s = strings.TrimSpace(s)
if s == "" {
continue
}
p, err := v1.ParsePlatform(s)
if err != nil {
return nil, err
}
out = append(out, *p)
}
return out, nil
}
// dedupeWheels removes duplicate wheels (same name+version+sha) so a multi-arch
// SBOM doesn't list shared pure-python wheels multiple times.
func dedupeWheels(in []wheelhouse.ResolvedWheel) []wheelhouse.ResolvedWheel {
seen := map[string]bool{}
var out []wheelhouse.ResolvedWheel
for _, w := range in {
k := w.Name + "\x00" + w.Version + "\x00" + w.SHA256
if seen[k] {
continue
}
seen[k] = true
out = append(out, w)
}
return out
}
// resolveTarget derives the wheel-selection target (platform + interpreter) // resolveTarget derives the wheel-selection target (platform + interpreter)
// from the --platform and --python flags, defaulting to linux/amd64. // from the --platform and --python flags, defaulting to linux/amd64.
func resolveTarget(platform *v1.Platform, pythonTag string) (wheel.Target, error) { func resolveTarget(platform *v1.Platform, pythonTag string) (wheel.Target, error) {

View file

@ -11,12 +11,124 @@ import (
"github.com/google/go-containerregistry/pkg/name" "github.com/google/go-containerregistry/pkg/name"
"github.com/google/go-containerregistry/pkg/registry" "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/empty"
"github.com/google/go-containerregistry/pkg/v1/mutate"
"github.com/google/go-containerregistry/pkg/v1/remote" "github.com/google/go-containerregistry/pkg/v1/remote"
"github.com/imjasonh/terraform-playground/pymage/internal/testwheel" "github.com/imjasonh/terraform-playground/pymage/internal/testwheel"
) )
// TestBuildMultiArchIndex drives the CLI to build a linux/amd64 + linux/arm64
// image index from a single host, and verifies the pushed artifact is an index
// covering both platforms and is reproducible.
func TestBuildMultiArchIndex(t *testing.T) {
s := httptest.NewServer(registry.New())
t.Cleanup(s.Close)
host := strings.TrimPrefix(s.URL, "http://")
// A multi-arch base (linux/amd64 + linux/arm64), as a real registry would
// serve python:3.12-slim.
baseRef := host + "/base:latest"
writeMultiArchBase(t, baseRef, []string{"amd64", "arm64"})
// Pure-python wheels are compatible with every platform.
wh := t.TempDir()
_, shaA := testwheel.Write(t, wh, testwheel.Spec{Name: "alpha", Version: "1.0", Modules: map[string]string{"alpha/__init__.py": "V='1.0'\n"}})
reqDir := t.TempDir()
reqFile := filepath.Join(reqDir, "requirements.txt")
if err := os.WriteFile(reqFile, []byte(fmt.Sprintf("alpha==1.0 --hash=sha256:%s\n", shaA)), 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)
}
build := func(tag string) error {
cmd := Root()
cmd.SetArgs([]string{
"build",
"--base", baseRef,
"--lock", reqFile,
"--find-links", wh,
"--source", src,
"--platform", "linux/amd64,linux/arm64",
"--entrypoint", "python", "--entrypoint", "/app/app.py",
"-t", tag,
})
cmd.SetOut(os.Stderr)
return cmd.Execute()
}
if err := build(host + "/multi:v1"); err != nil {
t.Fatalf("multi-arch build failed: %v", err)
}
ref, _ := name.ParseReference(host + "/multi:v1")
idx, err := remote.Index(ref, remote.WithContext(context.Background()))
if err != nil {
t.Fatalf("pulled artifact is not an index: %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
}
}
for _, want := range []string{"linux/amd64", "linux/arm64"} {
if !got[want] {
t.Errorf("index missing platform %s; got %v", want, got)
}
}
if len(im.Manifests) != 2 {
t.Fatalf("expected 2 manifests, got %d", len(im.Manifests))
}
d1, _ := idx.Digest()
// Reproducible: a second build yields the same index digest.
if err := build(host + "/multi:v2"); err != nil {
t.Fatal(err)
}
ref2, _ := name.ParseReference(host + "/multi:v2")
idx2, err := remote.Index(ref2, remote.WithContext(context.Background()))
if err != nil {
t.Fatal(err)
}
d2, _ := idx2.Digest()
if d1 != d2 {
t.Fatalf("multi-arch index not reproducible: %s != %s", d1, d2)
}
}
func writeMultiArchBase(t *testing.T, ref string, arches []string) {
t.Helper()
var adds []mutate.IndexAddendum
for _, arch := range arches {
img, err := mutate.ConfigFile(empty.Image, &v1.ConfigFile{
OS: "linux",
Architecture: arch,
Config: v1.Config{Env: []string{"PATH=/usr/local/bin:/usr/bin:/bin"}},
})
if err != nil {
t.Fatal(err)
}
adds = append(adds, mutate.IndexAddendum{
Add: img,
Descriptor: v1.Descriptor{Platform: &v1.Platform{OS: "linux", Architecture: arch}},
})
}
idx := mutate.AppendManifests(empty.Index, adds...)
r, _ := name.ParseReference(ref)
if err := remote.WriteIndex(r, idx, remote.WithContext(context.Background())); err != nil {
t.Fatalf("write base index: %v", err)
}
}
func TestParsePythonTag(t *testing.T) { func TestParsePythonTag(t *testing.T) {
maj, min, err := parsePythonTag("python3.12") maj, min, err := parsePythonTag("python3.12")
if err != nil || maj != 3 || min != 12 { if err != nil || maj != 3 || min != 12 {