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:
parent
832184f740
commit
28337b0d62
5 changed files with 562 additions and 6 deletions
82
py-image-builder/README.md
Normal file
82
py-image-builder/README.md
Normal 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`.
|
||||
361
py-image-builder/e2e/e2e_test.go
Normal file
361
py-image-builder/e2e/e2e_test.go
Normal 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]
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
106
py-image-builder/internal/cli/cli_test.go
Normal file
106
py-image-builder/internal/cli/cli_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue