mirror of
https://github.com/imjasonh/terraform-playground
synced 2026-07-22 08:01:06 +00:00
cli: stdout is only the image ref; log blob push/skip to stderr
- output() writes the single by-digest reference (repo@sha256:...) to stdout so 'docker run "$(pymage build)"' works; per-tag pointers and ggcr's per-blob progress (pushed/existing/mounted blob) go to stderr via logs.Progress - adds TestBuildStdoutIsImageRef asserting the stdout contract and that blob logs land on stderr; documents it in the README - completes and removes todo.txt Co-authored-by: Jason Hall <imjasonh@users.noreply.github.com>
This commit is contained in:
parent
d587263759
commit
389bfca56b
5 changed files with 172 additions and 60 deletions
|
|
@ -44,7 +44,15 @@ pymage build ./example # build a different project directory (positional arg)
|
|||
The project directory is the first positional argument (default: the current
|
||||
directory).
|
||||
|
||||
`pymage` always publishes **by digest** and prints the resulting `repo@sha256:…`.
|
||||
`pymage` always publishes **by digest** and prints the resulting `repo@sha256:…`
|
||||
as the **only** thing on stdout — progress (per-blob `pushed`/`existing`/`mounted`
|
||||
logs, per-tag pointers, diagnostics) goes to stderr — so you can run the image
|
||||
directly:
|
||||
|
||||
```
|
||||
docker run "$(pymage build)"
|
||||
```
|
||||
|
||||
`-t/--tag` is the **tag component only**, never a full reference — the repo comes
|
||||
from `[tool.pymage] repo` (or `--repo`). If no `-t` is given it uses
|
||||
`[tool.pymage] tags`, defaulting to `latest`.
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import (
|
|||
"strings"
|
||||
|
||||
"github.com/google/go-containerregistry/pkg/authn"
|
||||
"github.com/google/go-containerregistry/pkg/logs"
|
||||
"github.com/google/go-containerregistry/pkg/name"
|
||||
v1 "github.com/google/go-containerregistry/pkg/v1"
|
||||
"github.com/google/go-containerregistry/pkg/v1/empty"
|
||||
|
|
@ -109,6 +110,12 @@ func buildCmd() *cobra.Command {
|
|||
|
||||
func runBuild(cmd *cobra.Command, f *buildFlags) error {
|
||||
ctx := cmd.Context()
|
||||
// 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())
|
||||
|
||||
if err := applyDefaults(cmd, f); err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -197,9 +204,9 @@ func runBuild(cmd *cobra.Command, f *buildFlags) error {
|
|||
|
||||
if len(platforms) > 1 {
|
||||
idx := mutate.AppendManifests(empty.Index, adds...)
|
||||
return output(ctx, f, nameOpts, nil, idx)
|
||||
return output(cmd, f, nameOpts, nil, idx)
|
||||
}
|
||||
return output(ctx, f, nameOpts, images[0], nil)
|
||||
return output(cmd, f, nameOpts, images[0], nil)
|
||||
}
|
||||
|
||||
// buildOne resolves the base and wheels for a single platform and builds the
|
||||
|
|
@ -272,76 +279,92 @@ func resolveInterpreter(f *buildFlags, base v1.Image) (major, minor int, pyTag s
|
|||
|
||||
// output writes/pushes either a single image or a multi-arch index (exactly one
|
||||
// of img/idx is non-nil).
|
||||
func output(ctx context.Context, f *buildFlags, nameOpts []name.Option, img v1.Image, idx v1.ImageIndex) error {
|
||||
var (
|
||||
digest v1.Hash
|
||||
err error
|
||||
)
|
||||
if idx != nil {
|
||||
digest, err = idx.Digest()
|
||||
} else {
|
||||
digest, err = img.Digest()
|
||||
}
|
||||
//
|
||||
// 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)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if f.ociLayout != "" {
|
||||
p, err := layout.Write(f.ociLayout, empty.Index)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if idx != nil {
|
||||
err = p.AppendIndex(idx)
|
||||
} else {
|
||||
err = p.AppendImage(img)
|
||||
}
|
||||
if err != nil {
|
||||
if err := writeLayout(f.ociLayout, img, idx); err != nil {
|
||||
return err
|
||||
}
|
||||
_, _ = fmt.Fprintf(stderr, "wrote OCI layout to %s\n", f.ociLayout)
|
||||
}
|
||||
|
||||
if f.printDigest {
|
||||
fmt.Println(digest.String())
|
||||
_, _ = fmt.Fprintln(stdout, digest.String())
|
||||
return nil
|
||||
}
|
||||
|
||||
if f.push {
|
||||
if f.repo == "" {
|
||||
return fmt.Errorf("no repo configured: set repo in [tool.pymage] or pass --repo (or use --push=false)")
|
||||
}
|
||||
tags := f.tags
|
||||
if len(tags) == 0 {
|
||||
tags = []string{"latest"}
|
||||
}
|
||||
opts := []remote.Option{
|
||||
remote.WithContext(ctx),
|
||||
remote.WithAuthFromKeychain(authn.DefaultKeychain),
|
||||
}
|
||||
// The image/index is pushed by content; each tag is just another
|
||||
// pointer to the same digest.
|
||||
for _, t := range tags {
|
||||
ref, err := name.NewTag(f.repo+":"+t, nameOpts...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if idx != nil {
|
||||
err = remote.WriteIndex(ref, idx, opts...)
|
||||
} else {
|
||||
err = remote.Write(ref, img, opts...)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("push %s: %w", ref, err)
|
||||
}
|
||||
fmt.Printf("pushed %s@%s (tag %s)\n", ref.Context().Name(), digest, t)
|
||||
}
|
||||
if !f.push {
|
||||
_, _ = fmt.Fprintln(stdout, digest.String())
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Println(digest.String())
|
||||
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)
|
||||
return nil
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// parsePlatforms parses the (comma-split, repeatable) --platform values.
|
||||
func parsePlatforms(specs []string) ([]v1.Platform, error) {
|
||||
var out []v1.Platform
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http/httptest"
|
||||
|
|
@ -282,6 +283,88 @@ func TestPushRequiresRepo(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// TestBuildStdoutIsImageRef checks the contract that `pymage build` prints
|
||||
// exactly the pullable by-digest reference to stdout (so `docker run
|
||||
// "$(pymage build)"` works), while progress/diagnostics — including per-blob
|
||||
// push/skip logs and per-tag lines — go to stderr.
|
||||
func TestBuildStdoutIsImageRef(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := httptest.NewServer(registry.New())
|
||||
t.Cleanup(s.Close)
|
||||
host := strings.TrimPrefix(s.URL, "http://")
|
||||
|
||||
baseRef := host + "/base:latest"
|
||||
base, err := mutate.ConfigFile(empty.Image, &v1.ConfigFile{
|
||||
OS: "linux", Architecture: "amd64",
|
||||
Config: v1.Config{Env: []string{"PYTHON_VERSION=3.12.7", "PATH=/usr/bin"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
br, _ := name.ParseReference(baseRef)
|
||||
if err := remote.Write(br, base, remote.WithContext(ctx)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
wh := t.TempDir()
|
||||
_, sha := testwheel.Write(t, wh, testwheel.Spec{Name: "alpha", Version: "1.0", Modules: map[string]string{"alpha/__init__.py": "V='1'\n"}})
|
||||
reqFile := filepath.Join(t.TempDir(), "requirements.txt")
|
||||
if err := os.WriteFile(reqFile, []byte(fmt.Sprintf("alpha==1.0 --hash=sha256:%s\n", sha)), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
src := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(src, "app.py"), []byte("print('hi')\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd := Root()
|
||||
cmd.SetArgs([]string{
|
||||
"build", src,
|
||||
"--base", baseRef,
|
||||
"--lock", reqFile,
|
||||
"--find-links", wh,
|
||||
"--entrypoint", "python", "--entrypoint", "/app/app.py",
|
||||
"--repo", host + "/app",
|
||||
"-t", "v1", "-t", "v2",
|
||||
})
|
||||
cmd.SetOut(&stdout)
|
||||
cmd.SetErr(&stderr)
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("build failed: %v\nstderr:\n%s", err, stderr.String())
|
||||
}
|
||||
|
||||
out := strings.TrimRight(stdout.String(), "\n")
|
||||
if strings.Contains(out, "\n") {
|
||||
t.Fatalf("stdout must be a single line, got:\n%q", out)
|
||||
}
|
||||
if !strings.HasPrefix(out, host+"/app@sha256:") {
|
||||
t.Fatalf("stdout = %q, want %q@sha256:...", out, host+"/app")
|
||||
}
|
||||
|
||||
// The printed ref must be pullable and its digest must match the image.
|
||||
ref, err := name.ParseReference(out)
|
||||
if err != nil {
|
||||
t.Fatalf("parse stdout ref %q: %v", out, err)
|
||||
}
|
||||
img, err := remote.Image(ref, remote.WithContext(ctx))
|
||||
if err != nil {
|
||||
t.Fatalf("pull %q: %v", out, err)
|
||||
}
|
||||
d, _ := img.Digest()
|
||||
if !strings.HasSuffix(out, d.String()) {
|
||||
t.Errorf("stdout ref %q does not end with image digest %s", out, d)
|
||||
}
|
||||
|
||||
// stderr should carry the per-tag pointers and ggcr's per-blob progress.
|
||||
se := stderr.String()
|
||||
for _, want := range []string{"tagged", "v1", "v2", "blob"} {
|
||||
if !strings.Contains(se, want) {
|
||||
t.Errorf("stderr missing %q; got:\n%s", want, se)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePythonTag(t *testing.T) {
|
||||
maj, min, err := parsePythonTag("python3.12")
|
||||
if err != nil || maj != 3 || min != 12 {
|
||||
|
|
|
|||
|
|
@ -14,10 +14,10 @@ type uvLockFile struct {
|
|||
}
|
||||
|
||||
type uvPackage struct {
|
||||
Name string `toml:"name"`
|
||||
Version string `toml:"version"`
|
||||
Source map[string]any `toml:"source"`
|
||||
Wheels []uvArtifact `toml:"wheels"`
|
||||
Name string `toml:"name"`
|
||||
Version string `toml:"version"`
|
||||
Source map[string]any `toml:"source"`
|
||||
Wheels []uvArtifact `toml:"wheels"`
|
||||
}
|
||||
|
||||
type uvArtifact struct {
|
||||
|
|
|
|||
|
|
@ -1,2 +0,0 @@
|
|||
- the only stdout should be just the full digest ref of the image, so folks can "docker run $(pymage build)"
|
||||
- log layers+blobs as they're pushed, or skipped
|
||||
Loading…
Add table
Add a link
Reference in a new issue