diff --git a/pymage/internal/cli/cli.go b/pymage/internal/cli/cli.go index a7fa8a2..b41ed19 100644 --- a/pymage/internal/cli/cli.go +++ b/pymage/internal/cli/cli.go @@ -72,6 +72,7 @@ type buildFlags struct { insecure bool cacheDir string noCache bool + buildSdists bool } func buildCmd() *cobra.Command { @@ -119,6 +120,7 @@ func buildCmd() *cobra.Command { fs.BoolVar(&f.insecure, "insecure", false, "use plain HTTP for the registry") fs.StringVar(&f.cacheDir, "cache-dir", "", "cache root directory (default: per-user cache dir)") fs.BoolVar(&f.noCache, "no-cache", false, "disable all build caches (layers, downloaded wheels, interpreter detection)") + fs.BoolVar(&f.buildSdists, "build-sdists", false, "allow building wheels from source distributions when no compatible wheel exists (runs the dependency's build code on this host; single-arch for compiled packages)") return cmd } @@ -256,7 +258,7 @@ func buildOne(ctx context.Context, f *buildFlags, lk *lock.Lock, platform *v1.Pl return nil, nil, err } - wheels, err := wheelhouse.ResolveContext(ctx, reqs, f.findLinks, target, wheelCache) + wheels, err := wheelhouse.ResolveContext(ctx, reqs, f.findLinks, target, wheelCache, f.buildSdists) if err != nil { return nil, nil, err } diff --git a/pymage/internal/cli/defaults.go b/pymage/internal/cli/defaults.go index 11f6b9c..e81f42c 100644 --- a/pymage/internal/cli/defaults.go +++ b/pymage/internal/cli/defaults.go @@ -75,6 +75,9 @@ func applyDefaults(cmd *cobra.Command, f *buildFlags) error { if !changed("package") && cfg.Package != "" { f.pkg = cfg.Package } + if !changed("build-sdists") && cfg.BuildSdists { + f.buildSdists = true + } if !changed("python") && cfg.Python != "" { f.pythonTag = cfg.Python } diff --git a/pymage/internal/project/project.go b/pymage/internal/project/project.go index 107269d..815ab5c 100644 --- a/pymage/internal/project/project.go +++ b/pymage/internal/project/project.go @@ -48,6 +48,7 @@ type Config struct { NoCache bool `toml:"no-cache"` Extras []string `toml:"extras"` Package string `toml:"package"` + BuildSdists bool `toml:"build-sdists"` } const defaultBase = "cgr.dev/chainguard/python:latest" diff --git a/pymage/internal/wheelhouse/sdist.go b/pymage/internal/wheelhouse/sdist.go index 090d603..dbcc67b 100644 --- a/pymage/internal/wheelhouse/sdist.go +++ b/pymage/internal/wheelhouse/sdist.go @@ -21,7 +21,9 @@ import ( // returns it as a ResolvedWheel. The built wheel is cached (keyed by the sdist // hash and target) so it isn't rebuilt on subsequent builds. // -// Building runs the host's `pip wheel`, so a Python toolchain must be present. +// This is only reached when the caller opted in (allowSdist), because building +// runs the dependency's own build code on the host with no isolation. Building +// runs the host's `pip wheel`, so a Python toolchain must be present. // A pure-python sdist yields a `py3-none-any` wheel usable on any target; a // compiled sdist is built for the host platform only, so cross-arch builds of // such packages will fail the compatibility check with a clear error. diff --git a/pymage/internal/wheelhouse/sdist_test.go b/pymage/internal/wheelhouse/sdist_test.go index 7d76886..82c0636 100644 --- a/pymage/internal/wheelhouse/sdist_test.go +++ b/pymage/internal/wheelhouse/sdist_test.go @@ -1,8 +1,10 @@ package wheelhouse import ( + "context" "os" "path/filepath" + "strings" "testing" "github.com/imjasonh/terraform-playground/pymage/internal/lock" @@ -56,6 +58,25 @@ func TestSelectBuiltWheelWrongVersion(t *testing.T) { } } +func TestSdistRequiresOptIn(t *testing.T) { + // An sdist-only requirement (no wheels) must error unless opted in, rather + // than silently building (which runs the dependency's build code). + req := lock.Requirement{ + Name: "timeout-decorator", + Version: "0.5.0", + Sdist: &lock.SdistRef{URL: "https://example/timeout_decorator-0.5.0.tar.gz", SHA256: "deadbeef", Filename: "timeout_decorator-0.5.0.tar.gz"}, + } + target := wheel.Target{OS: "linux", Arch: "amd64", PyMajor: 3, PyMinor: 12} + + _, err := ResolveContext(context.Background(), []lock.Requirement{req}, nil, target, t.TempDir(), false) + if err == nil { + t.Fatal("expected error when sdist build is not opted in") + } + if !strings.Contains(err.Error(), "--build-sdists") { + t.Fatalf("error should point at the opt-in flag, got: %v", err) + } +} + func TestBuiltPathKey(t *testing.T) { c := &wheelCache{dir: "/tmp/c"} target := wheel.Target{OS: "linux", Arch: "amd64", PyMajor: 3, PyMinor: 12} diff --git a/pymage/internal/wheelhouse/wheelhouse.go b/pymage/internal/wheelhouse/wheelhouse.go index 20461d6..4a64ef5 100644 --- a/pymage/internal/wheelhouse/wheelhouse.go +++ b/pymage/internal/wheelhouse/wheelhouse.go @@ -47,7 +47,7 @@ type candidate struct { // per-user cache directory. Results are sorted by (normalized name, version) // for deterministic ordering. func Resolve(reqs []lock.Requirement, dirs []string, target wheel.Target) ([]ResolvedWheel, error) { - return ResolveContext(context.Background(), reqs, dirs, target, defaultWheelCacheDir()) + return ResolveContext(context.Background(), reqs, dirs, target, defaultWheelCacheDir(), false) } // defaultWheelCacheDir returns the per-user wheel download cache, or "" if the @@ -62,8 +62,10 @@ func defaultWheelCacheDir() string { } // ResolveContext is Resolve with an explicit context and on-disk wheel cache -// directory for lock-file downloads. -func ResolveContext(ctx context.Context, reqs []lock.Requirement, dirs []string, target wheel.Target, wheelCacheDir string) ([]ResolvedWheel, error) { +// directory for lock-file downloads. When allowSdist is true, packages with no +// compatible wheel may be built from their source distribution (which executes +// the dependency's build code on this host); otherwise such packages error. +func ResolveContext(ctx context.Context, reqs []lock.Requirement, dirs []string, target wheel.Target, wheelCacheDir string, allowSdist bool) ([]ResolvedWheel, error) { var index map[string][]candidate if len(dirs) > 0 { var err error @@ -80,7 +82,7 @@ func ResolveContext(ctx context.Context, reqs []lock.Requirement, dirs []string, out := make([]ResolvedWheel, 0, len(reqs)) for _, req := range reqs { - rw, err := resolveOne(ctx, req, index, target, cache) + rw, err := resolveOne(ctx, req, index, target, cache, allowSdist) if err != nil { return nil, err } @@ -97,7 +99,7 @@ func ResolveContext(ctx context.Context, reqs []lock.Requirement, dirs []string, return out, nil } -func resolveOne(ctx context.Context, req lock.Requirement, index map[string][]candidate, target wheel.Target, cache *wheelCache) (ResolvedWheel, error) { +func resolveOne(ctx context.Context, req lock.Requirement, index map[string][]candidate, target wheel.Target, cache *wheelCache, allowSdist bool) (ResolvedWheel, error) { key := lock.NormalizeName(req.Name) + "\x00" + req.Version if index != nil { if cands := index[key]; len(cands) > 0 { @@ -141,6 +143,14 @@ func resolveOne(ctx context.Context, req lock.Requirement, index map[string][]ca if req.Sdist == nil { return ResolvedWheel{}, err } + // No compatible lock wheel; consider building from the sdist below. + } + + // Building from an sdist runs the dependency's own build code (setup.py / + // build backend) on this host with no container isolation, so it is gated + // behind explicit opt-in. + if !allowSdist { + return ResolvedWheel{}, fmt.Errorf("wheelhouse: %s==%s has no compatible pre-built wheel and would have to be built from its source distribution, which runs arbitrary build code from the dependency on this machine; pass --build-sdists (or set build-sdists in [tool.pymage]) to allow it, or supply a pre-built wheel via --find-links", req.Name, req.Version) } return buildFromSdist(ctx, req, target, cache) }