Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ 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 the cached Hypeman tag to its matching remote registry
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
69 changes: 69 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,66 @@ 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 TestTagCommandFallsBackToDockerWhenHypemanMisses(t *testing.T) {
server := httptest.NewServer(http.NotFoundHandler())
defer server.Close()

err := Command.Run(context.Background(), []string{
"hypeman", "--base-url", server.URL,
"tag", "not a valid image", "myapp:latest",
})
require.ErrorContains(t, err, "was not found in Hypeman or Docker")
}
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 a ready Hypeman image tagged TARGET to its remote registry. If the
image is not cached in Hypeman, fall back to staging the local Docker tag.

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
57 changes: 40 additions & 17 deletions pkg/cmd/pushcmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,26 +91,23 @@ 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)
// Prefer an image already cached in Hypeman. This makes `hypeman tag` followed
// by `hypeman push TARGET` work without requiring a local Docker daemon.
client := hypeman.NewClient(getDefaultRequestOptions(cmd)...)
cachedImage, err := client.Images.Get(ctx, url.PathEscape(target))
if err == nil {
if err := waitForImageReady(ctx, &client, cachedImage); err != nil {
return 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.

}

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

client := hypeman.NewClient(getDefaultRequestOptions(cmd)...)
imported, err := waitForImageRecord(ctx, &client, target)
if err != nil {
return err
}
if err := waitForImageReady(ctx, &client, imported); err != nil {
return err
// If Hypeman does not have the image, preserve the Docker-daemon fallback.
if _, err := stageDockerImage(ctx, cmd, &client, target, target); 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)
}

return runRemotePush(ctx, cmd, target, target)
Expand All @@ -129,6 +126,32 @@ func validateRemotePushTarget(target string) error {
return nil
}

func stageDockerImage(ctx context.Context, cmd *cli.Command, client *hypeman.Client, source, target string) (*hypeman.Image, error) {
img, err := loadDockerImage(source)
if err != nil {
return nil, err
}

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

imported, err := waitForImageRecord(ctx, client, target)
if err != nil {
return nil, err
}
if err := waitForImageReady(ctx, client, imported); err != nil {
return nil, err
}

ready, err := client.Images.Get(ctx, url.PathEscape(target))
if err != nil {
return nil, fmt.Errorf("get staged image %s: %w", target, err)
}
return ready, nil
}

func waitForImageRecord(ctx context.Context, client *hypeman.Client, imageName string) (*hypeman.Image, error) {
ticker := time.NewTicker(300 * time.Millisecond)
defer ticker.Stop()
Expand Down
43 changes: 43 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,44 @@ func TestPushRepository(t *testing.T) {
assert.Equal(t, "registry.example.com/app", pushRepository("registry.example.com/app:v1"))
}

func TestPushTargetPrefersCachedHypemanImage(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 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
68 changes: 68 additions & 0 deletions pkg/cmd/tag.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package cmd

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

"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. If the source is not already
cached in Hypeman, fall back to the matching image in the local Docker daemon.`,
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]
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))
body := struct {
Target string `json:"target"`
}{Target: target}
path := "/images/" + url.PathEscape(source) + "/tag"
if err := client.Post(ctx, path, body, nil, opts...); err != nil {
if !isNotFoundError(err) {
return err
}
staged, stageErr := stageDockerImage(ctx, cmd, &client, source, target)
if stageErr != nil {
return fmt.Errorf("image %q was not found in Hypeman or Docker: %w", source, stageErr)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Tag errors claim image not found

Low Severity

Every stageDockerImage failure is wrapped as if the image was missing. Upload, wait, and follow-up GET errors are reported as not found in Hypeman or Docker for tag, and as a failed local Docker load for one-argument push, including cases where Docker load already succeeded.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 61d1aca. Configure here.

res = []byte(staged.RawJSON())
}

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

imageName := result.Get("name").String()
if imageName == "" {
imageName = target
}
fmt.Println(imageName)
return nil
}
Loading