From 28337b0d62d5d8f544fb78908076c39bf57a2b3e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 10 Jun 2026 11:09:36 +0000 Subject: [PATCH] Add CLI insecure flag, e2e + CLI integration tests, functional import test, README Co-authored-by: Jason Hall --- py-image-builder/README.md | 82 +++++ py-image-builder/e2e/e2e_test.go | 361 +++++++++++++++++++++ py-image-builder/internal/build/resolve.go | 4 +- py-image-builder/internal/cli/cli.go | 15 +- py-image-builder/internal/cli/cli_test.go | 106 ++++++ 5 files changed, 562 insertions(+), 6 deletions(-) create mode 100644 py-image-builder/README.md create mode 100644 py-image-builder/e2e/e2e_test.go create mode 100644 py-image-builder/internal/cli/cli_test.go diff --git a/py-image-builder/README.md b/py-image-builder/README.md new file mode 100644 index 0000000..4bcd1dc --- /dev/null +++ b/py-image-builder/README.md @@ -0,0 +1,82 @@ +# `py-image-builder` + +A **docker-less**, layer-aware container image builder for Python applications, +in the spirit of [`ko`](https://github.com/ko-build/ko). It builds and pushes +OCI images without a Docker daemon by composing content-addressed layers with +[`go-containerregistry`](https://github.com/google/go-containerregistry). + +See [`DESIGN.md`](./DESIGN.md) for the full rationale. + +## What makes it efficient + +- **One deterministic layer per wheel.** Installing a wheel produces a + byte-identical layer every time, so its digest is stable. +- **No-bytes rebuilds.** Because layers are content-addressed and the builder is + reproducible, re-pushing an unchanged image transfers **zero** dependency + bytes — the registry already has every blob (verified by a `HEAD` check). +- **Only new dependencies upload.** Adding a dependency creates exactly one new + layer; every existing dependency layer keeps its digest and is skipped. +- **App code is a thin top layer**, so the common edit-rebuild loop only moves a + small layer (and the manifest). +- **Reproducible**: same lock + same source + same base ⇒ same image digest. + +## Usage + +``` +py-image-builder build \ + --base cgr.dev/chainguard/python:latest \ + --lock requirements.txt \ # pinned + hashed (pip-compile / uv pip compile --generate-hashes) + --find-links ./wheelhouse \ # directory of resolved .whl files + --source ./ \ # application source (optional) + --entrypoint python --entrypoint -m --entrypoint myapp \ + -t registry.example.com/me/myapp:latest +``` + +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 +is intentionally local so builds are hermetic and reproducible. + +### Useful flags + +| Flag | Description | +| --- | --- | +| `--push=false` | Build without pushing (combine with `--oci-layout`). | +| `--oci-layout DIR` | Also write the image to an OCI layout directory. | +| `--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` | Platform for base resolution, e.g. `linux/amd64`. | +| `--python` | site-packages interpreter dir (default `python3.12`). | +| `--prefix` | install prefix / venv root (default `/app/.venv`). | +| `--workdir` | image working dir and source destination (default `/app`). | +| `--user` | image user, e.g. `65532`. | +| `--insecure` | use plain HTTP for the registry. | +| `--require-hashes` | require `--hash` on every requirement (default true). | + +## Layout + +| Package | Responsibility | +| --- | --- | +| `internal/ptar` | Deterministic tar + OCI layer construction. | +| `internal/wheel` | Parse a wheel and lay it out into installed files. | +| `internal/lock` | Parse hashed `requirements.txt`. | +| `internal/wheelhouse` | Resolve requirements to local wheel files (hash-checked). | +| `internal/build` | Assemble base + per-wheel layers + app layer; rewrite config. | +| `internal/sbom` | Emit a deterministic CycloneDX SBOM. | +| `internal/cli` | The `build` command. | +| `e2e` | End-to-end tests against a local registry. | + +## Testing + +``` +go test ./... +``` + +Tests are hermetic (no network, no Docker): wheels are synthesized in-process +and pushes/pulls go to go-containerregistry's in-process registry served over +HTTP. The `e2e` package demonstrates: + +- **reproducibility** — two independent builds yield the same image digest; +- **no-bytes rebuild** — re-pushing an unchanged image uploads zero blobs, and + adding one dependency uploads exactly one new dependency layer; +- **correctness** — the installed packages import under a real `python3`. diff --git a/py-image-builder/e2e/e2e_test.go b/py-image-builder/e2e/e2e_test.go new file mode 100644 index 0000000..c045d5d --- /dev/null +++ b/py-image-builder/e2e/e2e_test.go @@ -0,0 +1,361 @@ +// Package e2e exercises py-image-builder end to end against a real local OCI +// registry (go-containerregistry's in-process registry served over HTTP — a +// genuine registry daemon, just in the test process). No Docker is required. +// +// These tests demonstrate the two headline properties: +// +// - Reproducibility: identical inputs produce an identical image digest. +// - No-bytes rebuild: re-pushing an unchanged image transfers zero blob +// bytes, and adding one new dependency uploads exactly one new dependency +// layer (existing dependency layers are never re-uploaded). +package e2e + +import ( + "archive/tar" + "context" + "io" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "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/py-image-builder/internal/build" + "github.com/imjasonh/terraform-playground/py-image-builder/internal/testwheel" + "github.com/imjasonh/terraform-playground/py-image-builder/internal/wheel" + "github.com/imjasonh/terraform-playground/py-image-builder/internal/wheelhouse" +) + +// uploadCounter records the set of blob digests that were actually uploaded +// (i.e. transferred bytes). Blobs that the registry already has are HEAD- +// deduped by the client and never reach a finalize request, so they don't show +// up here. +type uploadCounter struct { + mu sync.Mutex + uploaded map[string]bool +} + +func newCounter() *uploadCounter { return &uploadCounter{uploaded: map[string]bool{}} } + +func (c *uploadCounter) reset() { + c.mu.Lock() + defer c.mu.Unlock() + c.uploaded = map[string]bool{} +} + +func (c *uploadCounter) snapshot() map[string]bool { + c.mu.Lock() + defer c.mu.Unlock() + out := make(map[string]bool, len(c.uploaded)) + for k := range c.uploaded { + out[k] = true + } + return out +} + +func (c *uploadCounter) middleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, "/blobs/uploads/") && + (r.Method == http.MethodPut || r.Method == http.MethodPost) { + if d := r.URL.Query().Get("digest"); d != "" { + c.mu.Lock() + c.uploaded[d] = true + c.mu.Unlock() + } + } + next.ServeHTTP(w, r) + }) +} + +var layout = wheel.Layout{Prefix: "/app/.venv", PythonTag: "python3.12"} + +func mkWheel(t *testing.T, dir, name, version string) wheelhouse.ResolvedWheel { + t.Helper() + path, sha := testwheel.Write(t, dir, testwheel.Spec{ + Name: name, + Version: version, + Modules: map[string]string{name + "/__init__.py": "V = '" + version + "'\n"}, + }) + return wheelhouse.ResolvedWheel{Name: name, Version: version, Path: path, SHA256: sha} +} + +func startRegistry(t *testing.T) (host string, c *uploadCounter) { + t.Helper() + c = newCounter() + s := httptest.NewServer(c.middleware(registry.New())) + t.Cleanup(s.Close) + return strings.TrimPrefix(s.URL, "http://"), c +} + +func push(t *testing.T, ref string, img v1.Image) { + t.Helper() + r, err := name.ParseReference(ref, name.Insecure) + if err != nil { + t.Fatal(err) + } + if err := remote.Write(r, img, remote.WithContext(context.Background())); err != nil { + t.Fatalf("push %s: %v", ref, err) + } +} + +func pullDigest(t *testing.T, ref string) v1.Hash { + t.Helper() + r, err := name.ParseReference(ref, name.Insecure) + if err != nil { + t.Fatal(err) + } + img, err := remote.Image(r, remote.WithContext(context.Background())) + if err != nil { + t.Fatalf("pull %s: %v", ref, err) + } + d, err := img.Digest() + if err != nil { + t.Fatal(err) + } + return d +} + +func appOptions(base v1.Image, wheels []wheelhouse.ResolvedWheel) build.Options { + return build.Options{ + Base: base, + Wheels: wheels, + Layout: layout, + Strategy: build.PerWheel, + WorkingDir: "/app", + Entrypoint: []string{"python", "-m", "app"}, + } +} + +func layerDigests(t *testing.T, img v1.Image) []string { + t.Helper() + layers, err := img.Layers() + if err != nil { + t.Fatal(err) + } + var out []string + for _, l := range layers { + d, err := l.Digest() + if err != nil { + t.Fatal(err) + } + out = append(out, d.String()) + } + return out +} + +// TestE2EReproducibleAndNoBytesRebuild is the core demonstration. +func TestE2EReproducibleAndNoBytesRebuild(t *testing.T) { + host, counter := startRegistry(t) + ctx := context.Background() + + // Push an (empty) base image and resolve it back by reference, exercising + // the docker-less base-by-reference path. + baseRef := host + "/base:latest" + push(t, baseRef, empty.Image) + base, err := build.Base(ctx, baseRef, nil, nil) + if err != nil { + t.Fatalf("resolve base: %v", err) + } + + dir := t.TempDir() + a := mkWheel(t, dir, "alpha", "1.0") + b := mkWheel(t, dir, "beta", "2.0") + c := mkWheel(t, dir, "gamma", "3.0") + + // --- Build #1: deps [alpha, beta], push to app:v1. + img1, err := build.Build(appOptions(base, []wheelhouse.ResolvedWheel{a, b})) + if err != nil { + t.Fatal(err) + } + d1, _ := img1.Digest() + + counter.reset() + push(t, host+"/app:v1", img1) + firstUploads := counter.snapshot() + if len(firstUploads) == 0 { + t.Fatal("expected the first push to upload blobs") + } + + // --- Build #2: identical inputs, push to app:v2 (same repo). + img2, err := build.Build(appOptions(base, []wheelhouse.ResolvedWheel{a, b})) + if err != nil { + t.Fatal(err) + } + d2, _ := img2.Digest() + + // Reproducibility: identical image digest across independent builds. + if d1 != d2 { + t.Fatalf("build not reproducible: %s != %s", d1, d2) + } + + counter.reset() + push(t, host+"/app:v2", img2) + if up := counter.snapshot(); len(up) != 0 { + t.Fatalf("no-bytes rebuild violated: re-push uploaded %d blob(s): %v", len(up), up) + } + + // Pulled digests for both tags match the built digest. + if got := pullDigest(t, host+"/app:v1"); got != d1 { + t.Fatalf("pulled v1 digest %s != built %s", got, d1) + } + if got := pullDigest(t, host+"/app:v2"); got != d1 { + t.Fatalf("pulled v2 digest %s != built %s", got, d1) + } + + // --- Build #3: add ONE new dep [alpha, beta, gamma]; push to app:v3. + img3, err := build.Build(appOptions(base, []wheelhouse.ResolvedWheel{a, b, c})) + if err != nil { + t.Fatal(err) + } + + // Identify each wheel's layer digest by building the layers standalone. + wantUnchanged := map[string]bool{ + soleLayerDigest(t, a): true, + soleLayerDigest(t, b): true, + } + gammaLayer := soleLayerDigest(t, c) + + // Sanity: the new image actually contains the unchanged layers + gamma. + have := map[string]bool{} + for _, d := range layerDigests(t, img3) { + have[d] = true + } + for d := range wantUnchanged { + if !have[d] { + t.Fatalf("expected unchanged layer %s to be present in new image", d) + } + } + if !have[gammaLayer] { + t.Fatalf("expected new gamma layer %s in new image", gammaLayer) + } + + counter.reset() + push(t, host+"/app:v3", img3) + uploaded := counter.snapshot() + + // The previously-pushed dependency layers must NOT be re-uploaded. + for d := range wantUnchanged { + if uploaded[d] { + t.Errorf("existing dependency layer %s was re-uploaded; only new deps should upload", d) + } + } + // The new dependency layer MUST be uploaded. + if !uploaded[gammaLayer] { + t.Errorf("new dependency layer %s was not uploaded", gammaLayer) + } +} + +// TestE2EInstalledPackagesImportable flattens the built image's filesystem and +// imports the installed package with the real interpreter, proving the wheel +// install layout is correct (not just that digests are stable). +func TestE2EInstalledPackagesImportable(t *testing.T) { + py, err := exec.LookPath("python3") + if err != nil { + t.Skip("python3 not available") + } + tag := pythonTag(t, py) + lay := wheel.Layout{Prefix: "/app/.venv", PythonTag: tag} + + dir := t.TempDir() + a := mkWheel(t, dir, "alpha", "1.0") + + img, err := build.Build(build.Options{ + Base: empty.Image, + Wheels: []wheelhouse.ResolvedWheel{a}, + Layout: lay, + Strategy: build.PerWheel, + WorkingDir: "/app", + }) + if err != nil { + t.Fatal(err) + } + + root := t.TempDir() + extract(t, img, root) + + site := filepath.Join(root, "app/.venv/lib", tag, "site-packages") + cmd := exec.Command(py, "-c", "import alpha; print(alpha.V)") + cmd.Env = append(os.Environ(), "PYTHONPATH="+site) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("import failed: %v\n%s", err, out) + } + if strings.TrimSpace(string(out)) != "1.0" { + t.Fatalf("unexpected import output: %q", out) + } +} + +func pythonTag(t *testing.T, py string) string { + t.Helper() + out, err := exec.Command(py, "-c", "import sys;print('python%d.%d'%sys.version_info[:2])").Output() + if err != nil { + t.Fatalf("python version: %v", err) + } + return strings.TrimSpace(string(out)) +} + +func extract(t *testing.T, img v1.Image, dest string) { + t.Helper() + rc := mutate.Extract(img) + defer rc.Close() + tr := tar.NewReader(rc) + for { + h, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + t.Fatal(err) + } + target := filepath.Join(dest, filepath.Clean("/"+h.Name)) + switch h.Typeflag { + case tar.TypeDir: + if err := os.MkdirAll(target, 0o755); err != nil { + t.Fatal(err) + } + case tar.TypeReg: + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + t.Fatal(err) + } + f, err := os.Create(target) + if err != nil { + t.Fatal(err) + } + if _, err := io.Copy(f, tr); err != nil { + f.Close() + t.Fatal(err) + } + f.Close() + } + } +} + +// soleLayerDigest builds the wheel as a standalone single-layer image and +// returns that layer's digest, matching what Build produces per wheel. +func soleLayerDigest(t *testing.T, rw wheelhouse.ResolvedWheel) string { + t.Helper() + img, err := build.Build(build.Options{ + Base: empty.Image, + Wheels: []wheelhouse.ResolvedWheel{rw}, + Layout: layout, + Strategy: build.PerWheel, + }) + if err != nil { + t.Fatal(err) + } + ds := layerDigests(t, img) + if len(ds) != 1 { + t.Fatalf("expected 1 layer for %s, got %d", rw.Name, len(ds)) + } + return ds[0] +} diff --git a/py-image-builder/internal/build/resolve.go b/py-image-builder/internal/build/resolve.go index ffb6677..4e2ab75 100644 --- a/py-image-builder/internal/build/resolve.go +++ b/py-image-builder/internal/build/resolve.go @@ -13,8 +13,8 @@ import ( // Base resolves a base image reference to a v1.Image for the given platform. // Only the base manifest and config are fetched; its layer blobs are referenced // by digest and never downloaded. -func Base(ctx context.Context, ref string, platform *v1.Platform, kc authn.Keychain) (v1.Image, error) { - r, err := name.ParseReference(ref) +func Base(ctx context.Context, ref string, platform *v1.Platform, kc authn.Keychain, nameOpts ...name.Option) (v1.Image, error) { + r, err := name.ParseReference(ref, nameOpts...) if err != nil { return nil, fmt.Errorf("build: parse base ref %q: %w", ref, err) } diff --git a/py-image-builder/internal/cli/cli.go b/py-image-builder/internal/cli/cli.go index 81ca3a5..146cf45 100644 --- a/py-image-builder/internal/cli/cli.go +++ b/py-image-builder/internal/cli/cli.go @@ -57,6 +57,7 @@ type buildFlags struct { printDigest bool sbomOut string requireHash bool + insecure bool } func buildCmd() *cobra.Command { @@ -90,6 +91,7 @@ func buildCmd() *cobra.Command { fs.BoolVar(&f.printDigest, "print-digest", false, "print only the resulting image digest") fs.StringVar(&f.sbomOut, "sbom", "", "write a CycloneDX SBOM to this path") fs.BoolVar(&f.requireHash, "require-hashes", true, "require every requirement to carry --hash entries") + fs.BoolVar(&f.insecure, "insecure", false, "use plain HTTP for the registry") return cmd } @@ -124,7 +126,12 @@ func runBuild(ctx context.Context, f *buildFlags) error { } } - base, err := resolveBase(ctx, f.base, platform) + var nameOpts []name.Option + if f.insecure { + nameOpts = append(nameOpts, name.Insecure) + } + + base, err := resolveBase(ctx, f.base, platform, nameOpts...) if err != nil { return err } @@ -189,7 +196,7 @@ func runBuild(ctx context.Context, f *buildFlags) error { if f.tag == "" { return fmt.Errorf("--tag is required to push (or use --push=false)") } - ref, err := name.ParseReference(f.tag) + ref, err := name.ParseReference(f.tag, nameOpts...) if err != nil { return err } @@ -207,11 +214,11 @@ func runBuild(ctx context.Context, f *buildFlags) error { return nil } -func resolveBase(ctx context.Context, ref string, platform *v1.Platform) (v1.Image, error) { +func resolveBase(ctx context.Context, ref string, platform *v1.Platform, nameOpts ...name.Option) (v1.Image, error) { if ref == "scratch" { return empty.Image, nil } - return build.Base(ctx, ref, platform, authn.DefaultKeychain) + return build.Base(ctx, ref, platform, authn.DefaultKeychain, nameOpts...) } func keyValues(in []string) (map[string]string, error) { diff --git a/py-image-builder/internal/cli/cli_test.go b/py-image-builder/internal/cli/cli_test.go new file mode 100644 index 0000000..b783d19 --- /dev/null +++ b/py-image-builder/internal/cli/cli_test.go @@ -0,0 +1,106 @@ +package cli + +import ( + "context" + "fmt" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/google/go-containerregistry/pkg/name" + "github.com/google/go-containerregistry/pkg/registry" + "github.com/google/go-containerregistry/pkg/v1/empty" + "github.com/google/go-containerregistry/pkg/v1/remote" + + "github.com/imjasonh/terraform-playground/py-image-builder/internal/testwheel" +) + +// TestBuildCommandEndToEnd drives the cobra `build` command exactly as a user +// would: a hashed requirements file + a wheelhouse + source, pushed to a local +// registry, then verifies the pushed image is pullable and reproducible. +func TestBuildCommandEndToEnd(t *testing.T) { + s := httptest.NewServer(registry.New()) + t.Cleanup(s.Close) + host := strings.TrimPrefix(s.URL, "http://") // 127.0.0.1:port -> ggcr uses http + + // Seed a base image. + baseRef := host + "/base:latest" + br, err := name.ParseReference(baseRef) + if err != nil { + t.Fatal(err) + } + if err := remote.Write(br, empty.Image, remote.WithContext(context.Background())); err != nil { + t.Fatal(err) + } + + // Wheelhouse with two wheels. + 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"}}) + _, shaB := testwheel.Write(t, wh, testwheel.Spec{Name: "beta", Version: "2.0", Modules: map[string]string{"beta/__init__.py": "V='2.0'\n"}}) + + // Hashed requirements file. + reqDir := t.TempDir() + reqFile := filepath.Join(reqDir, "requirements.txt") + reqs := fmt.Sprintf("alpha==1.0 --hash=sha256:%s\nbeta==2.0 --hash=sha256:%s\n", shaA, shaB) + if err := os.WriteFile(reqFile, []byte(reqs), 0o644); err != nil { + t.Fatal(err) + } + + // App source. + src := t.TempDir() + if err := os.WriteFile(filepath.Join(src, "app.py"), []byte("print('hi')\n"), 0o644); err != nil { + t.Fatal(err) + } + + tag := host + "/myapp:v1" + run := func() error { + cmd := Root() + cmd.SetArgs([]string{ + "build", + "--base", baseRef, + "--lock", reqFile, + "--find-links", wh, + "--source", src, + "--entrypoint", "python", + "--entrypoint", "/app/app.py", + "-t", tag, + }) + cmd.SetOut(os.Stderr) + return cmd.Execute() + } + + if err := run(); err != nil { + t.Fatalf("build command failed: %v", err) + } + + // The pushed image is pullable and has the expected layer count + // (2 wheels + 1 source). + ref, _ := name.ParseReference(tag) + img, err := remote.Image(ref, remote.WithContext(context.Background())) + if err != nil { + t.Fatalf("pull pushed image: %v", err) + } + layers, err := img.Layers() + if err != nil { + t.Fatal(err) + } + if len(layers) != 3 { + t.Fatalf("got %d layers, want 3", len(layers)) + } + pushedDigest, _ := img.Digest() + + // Running the command again is reproducible: same digest. + if err := run(); err != nil { + t.Fatalf("second build failed: %v", err) + } + img2, err := remote.Image(ref, remote.WithContext(context.Background())) + if err != nil { + t.Fatal(err) + } + d2, _ := img2.Digest() + if d2 != pushedDigest { + t.Fatalf("re-running build produced a different digest: %s != %s", d2, pushedDigest) + } +}