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

Add CLI insecure flag, e2e + CLI integration tests, functional import test, README

Co-authored-by: Jason Hall <imjasonh@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-06-10 11:09:36 +00:00
parent 832184f740
commit 28337b0d62
No known key found for this signature in database
5 changed files with 562 additions and 6 deletions

View file

@ -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`.

View file

@ -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]
}

View file

@ -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)
}

View file

@ -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) {

View file

@ -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)
}
}