Skip to content
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,13 @@ go run cmd/hypeman/main.go
# Pull an image
hypeman pull nginx:alpine

# Create a local tag without pulling the image again
hypeman tag nginx:alpine my-registry.example.com/myapp:latest

# Push it to the remote registry (prefers the local Docker tag,
# falls back to the cached Hypeman image)
hypeman push my-registry.example.com/myapp:latest

# Boot a new VM (auto-pulls image if needed)
hypeman run --name my-app nginx:alpine

Expand Down
1 change: 1 addition & 0 deletions pkg/cmd/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ func init() {
&execCmd,
&cpCmd,
&pullCmd,
&tagCmd,
&pushCmd,
&runCmd,
&psCmd,
Expand Down
84 changes: 84 additions & 0 deletions pkg/cmd/imagecmd_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
package cmd

import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"testing"

"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -37,3 +43,81 @@ func TestPlatformOrDash(t *testing.T) {
assert.Equal(t, "linux/amd64", platformOrDash("linux/amd64"))
assert.Equal(t, "-", platformOrDash(""))
}

func TestTagCommandPostsEscapedSourceAndTarget(t *testing.T) {
var method, path, target string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
method = r.Method
path = r.URL.EscapedPath()
var body struct {
Target string `json:"target"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
target = body.Target
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"name":"docker.io/library/myapp:latest"}`))
}))
defer server.Close()

err := Command.Run(context.Background(), []string{
"hypeman", "--base-url", server.URL, "--format", "json",
"tag", "builds/job:latest", "myapp:latest",
})
if err != nil {
t.Fatal(err)
}

assert.Equal(t, http.MethodPost, method)
assert.Equal(t, "/images/builds%2Fjob:latest/tag", path)
assert.Equal(t, "myapp:latest", target)

stdout := os.Stdout
reader, writer, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
os.Stdout = writer
err = Command.Run(context.Background(), []string{
"hypeman", "--base-url", server.URL,
"tag", "builds/job:latest", "myapp:latest",
})
_ = writer.Close()
os.Stdout = stdout
if err != nil {
t.Fatal(err)
}
output, err := io.ReadAll(reader)
if err != nil {
t.Fatal(err)
}
assert.Contains(t, string(output), "docker.io/library/myapp:latest")
}

func TestTagCommandPropagatesNotFound(t *testing.T) {
server := httptest.NewServer(http.NotFoundHandler())
defer server.Close()

err := Command.Run(context.Background(), []string{
"hypeman", "--base-url", server.URL,
"tag", "alpine:missing", "myapp:latest",
})
require.ErrorContains(t, err, "404")
}

func TestTagCommandPropagatesNotReady(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusConflict)
_, _ = w.Write([]byte(`{"code":"image_not_ready","message":"image is not ready"}`))
}))
defer server.Close()

err := Command.Run(context.Background(), []string{
"hypeman", "--base-url", server.URL,
"tag", "alpine:pending", "myapp:latest",
})
require.ErrorContains(t, err, "image is not ready")
}
10 changes: 7 additions & 3 deletions pkg/cmd/push.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ var pushCmd = cli.Command{
Description: `Push images between Docker, Hypeman, and remote registries.

hypeman push TARGET
Push a local Docker image tagged TARGET to its remote registry. The CLI
stages it in Hypeman first.
Push the local Docker image tagged TARGET to its remote registry. If
Docker does not have it, use the ready Hypeman image cached under TARGET.

hypeman push IMAGE TARGET
Push an image already in Hypeman to TARGET. Waits for completion.
Expand All @@ -44,11 +44,15 @@ Push jobs can be inspected while they run:
hypeman push inspect <id>

Examples:
# Retag and push a cached Hypeman image to ECR
hypeman tag alpine:latest 123456789.dkr.ecr.us-east-1.amazonaws.com/myapp:v1
hypeman push 123456789.dkr.ecr.us-east-1.amazonaws.com/myapp:v1

# Push a local Docker tag to ECR
docker tag alpine:latest 123456789.dkr.ecr.us-east-1.amazonaws.com/myapp:v1
hypeman push 123456789.dkr.ecr.us-east-1.amazonaws.com/myapp:v1

# Push a cached Hypeman image to ECR
# Push a cached Hypeman image directly to a different remote target
hypeman push alpine:latest 123456789.dkr.ecr.us-east-1.amazonaws.com/myapp:v1

# Push with credentials read from stdin
Expand Down
51 changes: 32 additions & 19 deletions pkg/cmd/pushcmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,29 +91,42 @@ func handleRemotePushTarget(ctx context.Context, cmd *cli.Command, target string
return err
}

// The one-argument form follows Docker's local-tag flow: TARGET must be
// present in the local Docker daemon before it can be staged and pushed.
// Cached Hypeman images use the explicit IMAGE TARGET form instead.
img, err := loadDockerImage(target)
if err != nil {
return fmt.Errorf("load local Docker image %q: %w; tag it first or use hypeman push <image> <target> for a cached Hypeman image", target, err)
}

fmt.Fprintf(os.Stderr, "Staging local image %s in Hypeman...\n", target)
if err := uploadLocalImage(ctx, cmd, target, img); err != nil {
return err
}

client := hypeman.NewClient(getDefaultRequestOptions(cmd)...)
imported, err := waitForImageRecord(ctx, &client, target)
if err != nil {
return err

img, loadErr := loadDockerImage(target)
if loadErr == nil {
fmt.Fprintf(os.Stderr, "Staging local image %s in Hypeman...\n", target)
if err := uploadLocalImage(ctx, cmd, target, img); err != nil {
return fmt.Errorf("upload local Docker image %q: %w", target, err)
}
imported, err := waitForImageRecord(ctx, &client, target)
if err != nil {
return err
}
if err := waitForImageReady(ctx, &client, imported); err != nil {
return err
}
return runRemotePush(ctx, cmd, target, target)
}

// Docker does not have TARGET, so fall back to a cached Hypeman image.
// This keeps `hypeman tag` followed by `hypeman push TARGET` working when
// the Docker daemon does not hold the same tag.
fmt.Fprintf(os.Stderr, "Docker image %s not available (%v); trying the Hypeman cache...\n", target, loadErr)
cachedImage, err := client.Images.Get(ctx, url.PathEscape(target))
if err == nil {
// A cached record that never became ready deserves a push-specific
// message; the shared helper's "image build failed" reads odd here.
if err := waitForImageReady(ctx, &client, cachedImage); err != nil {
return fmt.Errorf("cached image %s is not ready: %w", target, err)
}
return runRemotePush(ctx, cmd, target, target)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cached push skips newer Docker image

High Severity

A successful Images.Get now short-circuits staging, so one-arg hypeman push TARGET never reloads Docker when that tag already exists in Hypeman. Rebuilds that retag the same name and push again keep shipping the previous cached digest, including when the cached record is failed and waitForImageReady errors out. The still-documented docker tag then hypeman push TARGET loop is the common path this breaks.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 5010e06. Configure here.

}
if err := waitForImageReady(ctx, &client, imported); err != nil {
return err
if !isNotFoundError(err) {
return fmt.Errorf("get cached image %s: %w", target, err)
}

return runRemotePush(ctx, cmd, target, target)
return fmt.Errorf("image %q not found in local Docker or the Hypeman cache; use hypeman tag <source> <target> first", target)
}

func validateRemotePushTarget(target string) error {
Expand Down
68 changes: 68 additions & 0 deletions pkg/cmd/pushcmd_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
package cmd

import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"

"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -29,6 +34,69 @@ func TestPushRepository(t *testing.T) {
assert.Equal(t, "registry.example.com/app", pushRepository("registry.example.com/app:v1"))
}

func TestPushTargetFallsBackToCachedHypemanImage(t *testing.T) {
var pushImage, pushTarget string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/images/"):
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"name":"123456789.dkr.ecr.us-east-1.amazonaws.com/myapp:v1","digest":"sha256:test","status":"ready","created_at":"2026-08-19T00:00:00Z"}`))
case r.Method == http.MethodPost && r.URL.Path == "/pushes":
var body struct {
Image string `json:"image"`
Target string `json:"target"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
pushImage = body.Image
pushTarget = body.Target
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"id":"push-1","created_at":"2026-08-19T00:00:00Z","digest":"sha256:test","image":"123456789.dkr.ecr.us-east-1.amazonaws.com/myapp:v1","status":"pushed","target":"123456789.dkr.ecr.us-east-1.amazonaws.com/myapp:v1"}`))
default:
http.NotFound(w, r)
}
}))
defer server.Close()

err := Command.Run(context.Background(), []string{
"hypeman", "--base-url", server.URL, "push",
"123456789.dkr.ecr.us-east-1.amazonaws.com/myapp:v1",
})
if err != nil {
t.Fatal(err)
}

assert.Equal(t, "123456789.dkr.ecr.us-east-1.amazonaws.com/myapp:v1", pushImage)
assert.Equal(t, pushImage, pushTarget)
}

func TestPushTargetErrorsWhenImageMissingEverywhere(t *testing.T) {
server := httptest.NewServer(http.NotFoundHandler())
defer server.Close()

err := Command.Run(context.Background(), []string{
"hypeman", "--base-url", server.URL, "push",
"123456789.dkr.ecr.us-east-1.amazonaws.com/myapp:v1",
})
require.ErrorContains(t, err, "not found in local Docker or the Hypeman cache")
}

func TestPushTargetPropagatesCachedImageFailure(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"name":"123456789.dkr.ecr.us-east-1.amazonaws.com/myapp:v1","digest":"sha256:test","status":"failed","error":"layer fetch failed","created_at":"2026-08-19T00:00:00Z"}`))
}))
defer server.Close()

err := Command.Run(context.Background(), []string{
"hypeman", "--base-url", server.URL, "push",
"123456789.dkr.ecr.us-east-1.amazonaws.com/myapp:v1",
})
require.ErrorContains(t, err, "layer fetch failed")
}

func TestValidateRemotePushReferences(t *testing.T) {
assert.NoError(t, validateRemotePushReferences("alpine:latest", "registry.example.com/app:v1"))
assert.ErrorContains(t, validateRemotePushReferences("alpine:latest", "registry.example.com/app"), "explicit tag")
Expand Down
66 changes: 66 additions & 0 deletions pkg/cmd/tag.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package cmd

import (
"context"
"fmt"
"net/url"
"os"

"github.com/google/go-containerregistry/pkg/name"
"github.com/kernel/hypeman-go"
"github.com/kernel/hypeman-go/option"
"github.com/tidwall/gjson"
"github.com/urfave/cli/v3"
)

var tagCmd = cli.Command{
Name: "tag",
Usage: "Create a local image tag",
ArgsUsage: "<source> <target>",
Description: "Create a local image tag in Hypeman without pulling or converting the image.",
Action: handleTag,
}

func handleTag(ctx context.Context, cmd *cli.Command) error {
args := cmd.Args().Slice()
if len(args) != 2 {
return fmt.Errorf("source and target image references required\nUsage: hypeman tag <source> <target>")
}
source, target := args[0], args[1]
for _, ref := range []struct{ label, value string }{{"source", source}, {"target", target}} {
if _, err := name.ParseReference(ref.value); err != nil {
return fmt.Errorf("invalid %s %q: %w", ref.label, ref.value, err)
}
}

client := hypeman.NewClient(getDefaultRequestOptions(cmd)...)

var opts []option.RequestOption
if cmd.Root().Bool("debug") {
opts = append(opts, debugMiddlewareOption)
}

var res []byte
opts = append(opts, option.WithResponseBodyInto(&res))
// TODO: switch to a typed Images.Tag once the tag API from kernel/hypeman#453
// is generated into hypeman-go.
body := struct {
Target string `json:"target"`
}{Target: target}
if err := client.Post(ctx, "/images/"+url.PathEscape(source)+"/tag", body, nil, opts...); err != nil {
return err
}

format := cmd.Root().String("format")
transform := cmd.Root().String("transform")
if format != "auto" {
return ShowJSON(os.Stdout, "tag", gjson.ParseBytes(res), format, transform)
}

imageName := gjson.GetBytes(res, "name").String()
if imageName == "" {
imageName = target
}
fmt.Println(imageName)
return nil
}
Loading