2026-06-10 13:30:05 +00:00
// Package cli implements the pymage command line.
2026-06-10 11:03:19 +00:00
package cli
import (
"context"
"fmt"
"os"
2026-06-10 14:07:25 +00:00
"strconv"
2026-06-10 11:03:19 +00:00
"strings"
"github.com/google/go-containerregistry/pkg/authn"
2026-06-10 21:28:10 +00:00
"github.com/google/go-containerregistry/pkg/logs"
2026-06-10 11:03:19 +00:00
"github.com/google/go-containerregistry/pkg/name"
v1 "github.com/google/go-containerregistry/pkg/v1"
"github.com/google/go-containerregistry/pkg/v1/empty"
"github.com/google/go-containerregistry/pkg/v1/layout"
2026-06-10 15:45:36 +00:00
"github.com/google/go-containerregistry/pkg/v1/mutate"
2026-06-10 11:03:19 +00:00
"github.com/google/go-containerregistry/pkg/v1/remote"
"github.com/spf13/cobra"
2026-06-10 13:30:05 +00:00
"github.com/imjasonh/terraform-playground/pymage/internal/build"
2026-06-10 14:07:25 +00:00
"github.com/imjasonh/terraform-playground/pymage/internal/cache"
2026-06-10 13:30:05 +00:00
"github.com/imjasonh/terraform-playground/pymage/internal/lock"
"github.com/imjasonh/terraform-playground/pymage/internal/sbom"
"github.com/imjasonh/terraform-playground/pymage/internal/wheel"
"github.com/imjasonh/terraform-playground/pymage/internal/wheelhouse"
2026-06-10 11:03:19 +00:00
)
// Root returns the root cobra command.
func Root ( ) * cobra . Command {
root := & cobra . Command {
2026-06-10 13:30:05 +00:00
Use : "pymage" ,
2026-06-10 11:03:19 +00:00
Short : "Docker-less, layer-aware Python container image builder" ,
SilenceUsage : true ,
SilenceErrors : true ,
}
root . AddCommand ( buildCmd ( ) )
return root
}
type buildFlags struct {
base string
lockFile string
findLinks [ ] string
source string
2026-06-10 17:05:36 -04:00
repo string
tags [ ] string
2026-06-10 15:45:36 +00:00
platforms [ ] string
2026-06-10 11:03:19 +00:00
pythonTag string
prefix string
workingDir string
entrypoint [ ] string
cmd [ ] string
env [ ] string
user string
labels [ ] string
ignore [ ] string
strategy string
push bool
ociLayout string
printDigest bool
sbomOut string
requireHash bool
2026-06-10 11:09:36 +00:00
insecure bool
2026-06-10 14:07:25 +00:00
cacheDir string
2026-06-10 11:03:19 +00:00
}
func buildCmd ( ) * cobra . Command {
f := & buildFlags { }
cmd := & cobra . Command {
2026-06-10 17:05:36 -04:00
Use : "build [source]" ,
2026-06-10 11:03:19 +00:00
Short : "Build a Python application image and push it" ,
2026-06-10 17:05:36 -04:00
Long : "Build a Python application image and push it.\n\n" +
"[source] is the uv project directory to build (default: current directory)." ,
Args : cobra . MaximumNArgs ( 1 ) ,
RunE : func ( cmd * cobra . Command , args [ ] string ) error {
if len ( args ) == 1 {
f . source = args [ 0 ]
}
return runBuild ( cmd , f )
2026-06-10 11:03:19 +00:00
} ,
}
fs := cmd . Flags ( )
2026-06-10 17:05:36 -04:00
fs . StringVar ( & f . base , "base" , "" , "base image reference (default: cgr.dev/chainguard/python:latest)" )
fs . StringVar ( & f . lockFile , "lock" , "" , "lock file (default: uv.lock in the source directory)" )
fs . StringArrayVar ( & f . findLinks , "find-links" , nil , "local wheel directory (optional; wheels are downloaded from the lock when omitted)" )
fs . StringVar ( & f . repo , "repo" , "" , "destination repository, pushed by digest, e.g. gcr.io/foo/bar (default: [tool.pymage] repo)" )
fs . StringArrayVarP ( & f . tags , "tag" , "t" , nil , "tag(s) to apply at --repo; tag component only, not a full reference (repeatable; default: [tool.pymage] tags or 'latest')" )
2026-06-10 15:45:36 +00:00
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)" )
2026-06-10 16:24:07 +00:00
fs . StringVar ( & f . pythonTag , "python" , "" , "interpreter version, e.g. python3.12 (default: auto-detected from the base image; if set, must match the base)" )
2026-06-10 11:03:19 +00:00
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 . StringArrayVar ( & f . entrypoint , "entrypoint" , nil , "entrypoint argv (repeatable)" )
fs . StringArrayVar ( & f . cmd , "cmd" , nil , "cmd argv (repeatable)" )
fs . StringArrayVar ( & f . env , "env" , nil , "extra KEY=VALUE env (repeatable)" )
fs . StringVar ( & f . user , "user" , "" , "image user, e.g. 65532" )
fs . StringArrayVar ( & f . labels , "label" , nil , "image label KEY=VALUE (repeatable)" )
fs . StringArrayVar ( & f . ignore , "ignore" , nil , "extra source ignore glob (repeatable)" )
fs . StringVar ( & f . strategy , "layer-strategy" , string ( build . PerWheel ) , "per-wheel | single-deps-layer" )
2026-06-10 17:05:36 -04:00
fs . BoolVar ( & f . push , "push" , true , "push the image to --repo (by digest)" )
2026-06-10 11:03:19 +00:00
fs . StringVar ( & f . ociLayout , "oci-layout" , "" , "also write the image to this OCI layout directory" )
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" )
2026-06-10 11:09:36 +00:00
fs . BoolVar ( & f . insecure , "insecure" , false , "use plain HTTP for the registry" )
2026-06-10 14:07:25 +00:00
fs . StringVar ( & f . cacheDir , "cache-dir" , "" , "directory for the content-addressed layer cache (speeds up rebuilds)" )
2026-06-10 11:03:19 +00:00
return cmd
}
2026-06-10 17:05:36 -04:00
func runBuild ( cmd * cobra . Command , f * buildFlags ) error {
ctx := cmd . Context ( )
2026-06-10 21:28:10 +00:00
// Keep stdout clean (only the final image ref): route go-containerregistry's
// per-blob progress ("existing blob", "mounted blob", "pushed blob") and
// warnings to stderr.
logs . Progress . SetOutput ( cmd . ErrOrStderr ( ) )
logs . Warn . SetOutput ( cmd . ErrOrStderr ( ) )
2026-06-10 17:05:36 -04:00
if err := applyDefaults ( cmd , f ) ; err != nil {
return err
2026-06-10 11:03:19 +00:00
}
2026-06-10 17:05:36 -04:00
if err := validateBuildFlags ( f ) ; err != nil {
return err
2026-06-10 11:03:19 +00:00
}
2026-06-10 14:07:25 +00:00
if _ , err := keyValues ( f . env ) ; err != nil {
return fmt . Errorf ( "--env: %w" , err )
}
labels , err := keyValues ( f . labels )
if err != nil {
return fmt . Errorf ( "--label: %w" , err )
}
2026-06-10 17:05:36 -04:00
reqs , err := lock . ParseAny ( f . lockFile )
2026-06-10 11:03:19 +00:00
if err != nil {
return err
}
if err := lock . Validate ( reqs , f . requireHash ) ; err != nil {
return err
}
2026-06-10 15:45:36 +00:00
platforms , err := parsePlatforms ( f . platforms )
2026-06-10 14:07:25 +00:00
if err != nil {
return err
}
2026-06-10 11:09:36 +00:00
var nameOpts [ ] name . Option
if f . insecure {
nameOpts = append ( nameOpts , name . Insecure )
}
2026-06-10 21:39:40 +00:00
// With no platform requested, default to whatever the base image supports
// (a multi-arch base yields a multi-arch build).
if len ( platforms ) == 0 {
platforms , err = defaultPlatformsFromBase ( ctx , f , nameOpts )
if err != nil {
return err
}
}
2026-06-10 14:07:25 +00:00
var layerCache * cache . Cache
2026-06-10 17:05:36 -04:00
wheelCache , err := wheelCacheDir ( f )
if err != nil {
return err
}
2026-06-10 14:07:25 +00:00
if f . cacheDir != "" {
layerCache , err = cache . New ( f . cacheDir )
if err != nil {
return err
}
2026-06-10 11:03:19 +00:00
}
2026-06-10 15:45:36 +00:00
// 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
for i := range targets {
p := targets [ i ]
var pp * v1 . Platform
if p . OS != "" || p . Architecture != "" {
2026-06-10 17:05:36 -04:00
pp = & targets [ i ]
2026-06-10 15:45:36 +00:00
}
2026-06-10 17:05:36 -04:00
img , deps , err := buildOne ( ctx , f , reqs , pp , labels , layerCache , wheelCache , nameOpts )
2026-06-10 15:45:36 +00:00
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 , 0 o644 ) ; err != nil {
return err
}
}
if len ( platforms ) > 1 {
idx := mutate . AppendManifests ( empty . Index , adds ... )
2026-06-10 21:28:10 +00:00
return output ( cmd , f , nameOpts , nil , idx )
2026-06-10 15:45:36 +00:00
}
2026-06-10 21:28:10 +00:00
return output ( cmd , f , nameOpts , images [ 0 ] , nil )
2026-06-10 15:45:36 +00:00
}
// buildOne resolves the base and wheels for a single platform and builds the
// image, returning the resolved wheels for SBOM aggregation.
2026-06-10 17:05:36 -04:00
func buildOne ( ctx context . Context , f * buildFlags , reqs [ ] lock . Requirement , platform * v1 . Platform , labels map [ string ] string , layerCache * cache . Cache , wheelCache string , nameOpts [ ] name . Option ) ( v1 . Image , [ ] wheelhouse . ResolvedWheel , error ) {
2026-06-10 16:24:07 +00:00
base , err := resolveBase ( ctx , f . base , platform , nameOpts ... )
2026-06-10 15:45:36 +00:00
if err != nil {
return nil , nil , err
}
2026-06-10 16:24:07 +00:00
// Determine the interpreter: honor --python (validated against the base) or
// auto-detect it from the base image.
major , minor , pyTag , err := resolveInterpreter ( f , base )
2026-06-10 15:45:36 +00:00
if err != nil {
return nil , nil , err
}
2026-06-10 16:24:07 +00:00
target := platformTarget ( platform )
target . PyMajor , target . PyMinor = major , minor
2026-06-10 17:05:36 -04:00
wheels , err := wheelhouse . ResolveContext ( ctx , reqs , f . findLinks , target , wheelCache )
2026-06-10 15:45:36 +00:00
if err != nil {
return nil , nil , err
}
2026-06-10 11:03:19 +00:00
img , err := build . Build ( build . Options {
Base : base ,
Wheels : wheels ,
2026-06-10 16:24:07 +00:00
Layout : wheel . Layout { Prefix : f . prefix , PythonTag : pyTag } ,
2026-06-10 11:03:19 +00:00
Strategy : build . LayerStrategy ( f . strategy ) ,
SourceDir : f . source ,
Ignore : f . ignore ,
WorkingDir : f . workingDir ,
Entrypoint : f . entrypoint ,
Cmd : f . cmd ,
Env : f . env ,
User : f . user ,
Labels : labels ,
2026-06-10 14:07:25 +00:00
Cache : layerCache ,
2026-06-10 11:03:19 +00:00
} )
if err != nil {
2026-06-10 15:45:36 +00:00
return nil , nil , err
2026-06-10 11:03:19 +00:00
}
2026-06-10 15:45:36 +00:00
return img , wheels , nil
}
2026-06-10 11:03:19 +00:00
2026-06-10 16:24:07 +00:00
// resolveInterpreter decides the target Python version and the site-packages
// directory tag. If --python is set it is authoritative but must match the
// base when the base advertises a version; otherwise the version is detected
// from the base image.
func resolveInterpreter ( f * buildFlags , base v1 . Image ) ( major , minor int , pyTag string , err error ) {
baseMaj , baseMin , baseOK := build . InterpreterVersion ( base )
if f . pythonTag != "" {
maj , min , err := parsePythonTag ( f . pythonTag )
if err != nil {
return 0 , 0 , "" , err
}
if baseOK && ( baseMaj != maj || baseMin != min ) {
return 0 , 0 , "" , fmt . Errorf ( "base %q provides Python %d.%d but --python is python%d.%d; pass --python python%d.%d (or pin a matching base)" ,
f . base , baseMaj , baseMin , maj , min , baseMaj , baseMin )
}
return maj , min , f . pythonTag , nil
}
if ! baseOK {
return 0 , 0 , "" , fmt . Errorf ( "could not determine the base image's Python version (no PYTHON_VERSION env or python package in /etc/apko.json); pass --python pythonX.Y" )
}
return baseMaj , baseMin , fmt . Sprintf ( "python%d.%d" , baseMaj , baseMin ) , nil
}
2026-06-10 15:45:36 +00:00
// output writes/pushes either a single image or a multi-arch index (exactly one
// of img/idx is non-nil).
2026-06-10 21:28:10 +00:00
//
// stdout carries exactly one line — the resulting image reference — so callers
// can run `docker run "$(pymage build)"`. All progress/diagnostic output goes
// to stderr.
func output ( cmd * cobra . Command , f * buildFlags , nameOpts [ ] name . Option , img v1 . Image , idx v1 . ImageIndex ) error {
ctx := cmd . Context ( )
stdout := cmd . OutOrStdout ( )
stderr := cmd . ErrOrStderr ( )
digest , err := artifactDigest ( img , idx )
2026-06-10 11:03:19 +00:00
if err != nil {
return err
}
if f . ociLayout != "" {
2026-06-10 21:28:10 +00:00
if err := writeLayout ( f . ociLayout , img , idx ) ; err != nil {
2026-06-10 11:03:19 +00:00
return err
}
2026-06-10 21:28:10 +00:00
_ , _ = fmt . Fprintf ( stderr , "wrote OCI layout to %s\n" , f . ociLayout )
2026-06-10 11:03:19 +00:00
}
if f . printDigest {
2026-06-10 21:28:10 +00:00
_ , _ = fmt . Fprintln ( stdout , digest . String ( ) )
2026-06-10 11:03:19 +00:00
return nil
}
2026-06-10 21:28:10 +00:00
if ! f . push {
_ , _ = fmt . Fprintln ( stdout , digest . String ( ) )
2026-06-10 11:03:19 +00:00
return nil
}
2026-06-10 21:28:10 +00:00
if f . repo == "" {
return fmt . Errorf ( "no repo configured: set repo in [tool.pymage] or pass --repo (or use --push=false)" )
}
repo , err := name . NewRepository ( f . repo , nameOpts ... )
if err != nil {
return err
}
tags := f . tags
if len ( tags ) == 0 {
tags = [ ] string { "latest" }
}
opts := [ ] remote . Option {
remote . WithContext ( ctx ) ,
remote . WithAuthFromKeychain ( authn . DefaultKeychain ) ,
}
// The artifact is pushed by content; each tag is just another pointer to
// the same digest.
for _ , t := range tags {
ref := repo . Tag ( t )
if err := writeArtifact ( ref , img , idx , opts ... ) ; err != nil {
return fmt . Errorf ( "push %s: %w" , ref , err )
}
_ , _ = fmt . Fprintf ( stderr , "tagged %s -> %s@%s\n" , ref , repo . Name ( ) , digest )
}
// The sole stdout line: the immutable by-digest reference.
_ , _ = fmt . Fprintf ( stdout , "%s@%s\n" , repo . Name ( ) , digest )
2026-06-10 11:03:19 +00:00
return nil
}
2026-06-10 21:28:10 +00:00
func artifactDigest ( img v1 . Image , idx v1 . ImageIndex ) ( v1 . Hash , error ) {
if idx != nil {
return idx . Digest ( )
}
return img . Digest ( )
}
func writeArtifact ( ref name . Reference , img v1 . Image , idx v1 . ImageIndex , opts ... remote . Option ) error {
if idx != nil {
return remote . WriteIndex ( ref , idx , opts ... )
}
return remote . Write ( ref , img , opts ... )
}
func writeLayout ( dir string , img v1 . Image , idx v1 . ImageIndex ) error {
p , err := layout . Write ( dir , empty . Index )
if err != nil {
return err
}
if idx != nil {
return p . AppendIndex ( idx )
}
return p . AppendImage ( img )
}
2026-06-10 21:39:40 +00:00
// defaultPlatformsFromBase returns the platforms to build for when --platform
// is not given: those advertised by the base image. A scratch base has no
// registry metadata, so it falls back to a single registry-default build.
func defaultPlatformsFromBase ( ctx context . Context , f * buildFlags , nameOpts [ ] name . Option ) ( [ ] v1 . Platform , error ) {
if f . base == "scratch" {
return [ ] v1 . Platform { { } } , nil
}
return build . BasePlatforms ( ctx , f . base , authn . DefaultKeychain , nameOpts ... )
}
2026-06-10 15:45:36 +00:00
// 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
}
2026-06-10 16:24:07 +00:00
// platformTarget derives the OS/arch part of the wheel-selection target from
// the --platform value, defaulting to linux/amd64. The interpreter fields are
// filled in by the caller.
func platformTarget ( platform * v1 . Platform ) wheel . Target {
2026-06-10 14:07:25 +00:00
t := wheel . Target { OS : "linux" , Arch : "amd64" }
if platform != nil {
if platform . OS != "" {
t . OS = platform . OS
}
if platform . Architecture != "" {
t . Arch = platform . Architecture
}
}
2026-06-10 16:24:07 +00:00
return t
2026-06-10 14:07:25 +00:00
}
// parsePythonTag parses "python3.12" into (3, 12).
func parsePythonTag ( tag string ) ( int , int , error ) {
s := strings . TrimPrefix ( tag , "python" )
majStr , minStr , ok := strings . Cut ( s , "." )
if ! ok {
return 0 , 0 , fmt . Errorf ( "--python %q must look like pythonX.Y" , tag )
}
maj , err := strconv . Atoi ( majStr )
if err != nil {
return 0 , 0 , fmt . Errorf ( "--python %q: bad major version" , tag )
}
min , err := strconv . Atoi ( minStr )
if err != nil {
return 0 , 0 , fmt . Errorf ( "--python %q: bad minor version" , tag )
}
return maj , min , nil
}
2026-06-10 11:09:36 +00:00
func resolveBase ( ctx context . Context , ref string , platform * v1 . Platform , nameOpts ... name . Option ) ( v1 . Image , error ) {
2026-06-10 11:03:19 +00:00
if ref == "scratch" {
return empty . Image , nil
}
2026-06-10 11:09:36 +00:00
return build . Base ( ctx , ref , platform , authn . DefaultKeychain , nameOpts ... )
2026-06-10 11:03:19 +00:00
}
func keyValues ( in [ ] string ) ( map [ string ] string , error ) {
if len ( in ) == 0 {
return nil , nil
}
out := map [ string ] string { }
for _ , kv := range in {
k , v , ok := strings . Cut ( kv , "=" )
2026-06-10 14:35:37 +00:00
if ! ok || k == "" {
return nil , fmt . Errorf ( "expected KEY=VALUE with a non-empty key, got %q" , kv )
2026-06-10 11:03:19 +00:00
}
out [ k ] = v
}
return out , nil
}