From c538308606b18084643dbf6ca13a6302815584d7 Mon Sep 17 00:00:00 2001 From: chruffins <23645059+chruffins@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:13:54 +0000 Subject: [PATCH 1/5] Add image tag command --- README.md | 3 +++ pkg/cmd/cmd.go | 1 + pkg/cmd/tag.go | 54 ++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 58 insertions(+) create mode 100644 pkg/cmd/tag.go diff --git a/README.md b/README.md index 4a925d3..dd7f663 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,9 @@ 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 + # Boot a new VM (auto-pulls image if needed) hypeman run --name my-app nginx:alpine diff --git a/pkg/cmd/cmd.go b/pkg/cmd/cmd.go index 14f266b..e13df3e 100644 --- a/pkg/cmd/cmd.go +++ b/pkg/cmd/cmd.go @@ -73,6 +73,7 @@ func init() { &execCmd, &cpCmd, &pullCmd, + &tagCmd, &pushCmd, &runCmd, &psCmd, diff --git a/pkg/cmd/tag.go b/pkg/cmd/tag.go new file mode 100644 index 0000000..38fa8b3 --- /dev/null +++ b/pkg/cmd/tag.go @@ -0,0 +1,54 @@ +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: " ", + 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 := 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 { + 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) + } + + fmt.Println(target) + return nil +} From d647ee752aa3bc234ba70d1fe11ab8fc9603b5c1 Mon Sep 17 00:00:00 2001 From: chruffins <23645059+chruffins@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:29:33 +0000 Subject: [PATCH 2/5] Harden image tag command --- pkg/cmd/imagecmd_test.go | 58 ++++++++++++++++++++++++++++++++++++++++ pkg/cmd/tag.go | 9 +++++-- 2 files changed, 65 insertions(+), 2 deletions(-) diff --git a/pkg/cmd/imagecmd_test.go b/pkg/cmd/imagecmd_test.go index 01f1995..0587f41 100644 --- a/pkg/cmd/imagecmd_test.go +++ b/pkg/cmd/imagecmd_test.go @@ -1,6 +1,12 @@ package cmd import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" "testing" "github.com/stretchr/testify/assert" @@ -37,3 +43,55 @@ 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.Path + 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/job: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") +} diff --git a/pkg/cmd/tag.go b/pkg/cmd/tag.go index 38fa8b3..96a259d 100644 --- a/pkg/cmd/tag.go +++ b/pkg/cmd/tag.go @@ -45,10 +45,15 @@ func handleTag(ctx context.Context, cmd *cli.Command) error { format := cmd.Root().String("format") transform := cmd.Root().String("transform") + result := gjson.ParseBytes(res) if format != "auto" { - return ShowJSON(os.Stdout, "tag", gjson.ParseBytes(res), format, transform) + return ShowJSON(os.Stdout, "tag", result, format, transform) } - fmt.Println(target) + imageName := result.Get("name").String() + if imageName == "" { + imageName = target + } + fmt.Println(imageName) return nil } From 5010e06e6b8581d996a360c9e8fef88d4cb42a95 Mon Sep 17 00:00:00 2001 From: chruffins <23645059+chruffins@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:37:05 +0000 Subject: [PATCH 3/5] Prefer cached images for single-target pushes --- README.md | 3 +++ pkg/cmd/push.go | 10 +++++++--- pkg/cmd/pushcmd.go | 19 ++++++++++++++---- pkg/cmd/pushcmd_test.go | 43 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 68 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index dd7f663..8a40ce9 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,9 @@ 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 diff --git a/pkg/cmd/push.go b/pkg/cmd/push.go index 526fe77..c2170f6 100644 --- a/pkg/cmd/push.go +++ b/pkg/cmd/push.go @@ -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. @@ -44,11 +44,15 @@ Push jobs can be inspected while they run: hypeman push inspect 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 diff --git a/pkg/cmd/pushcmd.go b/pkg/cmd/pushcmd.go index 432c7a1..d918bb0 100644 --- a/pkg/cmd/pushcmd.go +++ b/pkg/cmd/pushcmd.go @@ -91,9 +91,21 @@ 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. + // 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) + } + if !isNotFoundError(err) { + return fmt.Errorf("get cached image %s: %w", target, err) + } + + // If Hypeman does not have the image, preserve the Docker-daemon fallback. img, err := loadDockerImage(target) if err != nil { return fmt.Errorf("load local Docker image %q: %w; tag it first or use hypeman push for a cached Hypeman image", target, err) @@ -104,7 +116,6 @@ func handleRemotePushTarget(ctx context.Context, cmd *cli.Command, target string return err } - client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) imported, err := waitForImageRecord(ctx, &client, target) if err != nil { return err diff --git a/pkg/cmd/pushcmd_test.go b/pkg/cmd/pushcmd_test.go index 7d19be2..7e4a211 100644 --- a/pkg/cmd/pushcmd_test.go +++ b/pkg/cmd/pushcmd_test.go @@ -1,6 +1,11 @@ package cmd import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -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") From 6d6d616515bb49762d2e06444169f3f572279e9c Mon Sep 17 00:00:00 2001 From: chruffins <23645059+chruffins@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:53:19 +0000 Subject: [PATCH 4/5] Assert escaped image tag paths --- pkg/cmd/imagecmd_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/cmd/imagecmd_test.go b/pkg/cmd/imagecmd_test.go index 0587f41..11ded73 100644 --- a/pkg/cmd/imagecmd_test.go +++ b/pkg/cmd/imagecmd_test.go @@ -48,7 +48,7 @@ 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.Path + path = r.URL.EscapedPath() var body struct { Target string `json:"target"` } @@ -71,7 +71,7 @@ func TestTagCommandPostsEscapedSourceAndTarget(t *testing.T) { } assert.Equal(t, http.MethodPost, method) - assert.Equal(t, "/images/builds/job:latest/tag", path) + assert.Equal(t, "/images/builds%2Fjob:latest/tag", path) assert.Equal(t, "myapp:latest", target) stdout := os.Stdout From 61d1acabcc9960f7223533b6207541ed47a8fe62 Mon Sep 17 00:00:00 2001 From: chruffins <23645059+chruffins@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:03:47 +0000 Subject: [PATCH 5/5] Fall back to Docker for image tags --- pkg/cmd/imagecmd_test.go | 11 +++++++++++ pkg/cmd/pushcmd.go | 42 ++++++++++++++++++++++++++-------------- pkg/cmd/tag.go | 13 +++++++++++-- 3 files changed, 49 insertions(+), 17 deletions(-) diff --git a/pkg/cmd/imagecmd_test.go b/pkg/cmd/imagecmd_test.go index 11ded73..e18d2e0 100644 --- a/pkg/cmd/imagecmd_test.go +++ b/pkg/cmd/imagecmd_test.go @@ -95,3 +95,14 @@ func TestTagCommandPostsEscapedSourceAndTarget(t *testing.T) { } 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") +} diff --git a/pkg/cmd/pushcmd.go b/pkg/cmd/pushcmd.go index d918bb0..5e5e337 100644 --- a/pkg/cmd/pushcmd.go +++ b/pkg/cmd/pushcmd.go @@ -106,24 +106,10 @@ func handleRemotePushTarget(ctx context.Context, cmd *cli.Command, target string } // If Hypeman does not have the image, preserve the Docker-daemon fallback. - img, err := loadDockerImage(target) - if err != nil { + 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 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 - } - - 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) } @@ -140,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() diff --git a/pkg/cmd/tag.go b/pkg/cmd/tag.go index 96a259d..12188f5 100644 --- a/pkg/cmd/tag.go +++ b/pkg/cmd/tag.go @@ -16,7 +16,9 @@ var tagCmd = cli.Command{ Name: "tag", Usage: "Create a local image tag", ArgsUsage: " ", - Action: handleTag, + 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 { @@ -40,7 +42,14 @@ func handleTag(ctx context.Context, cmd *cli.Command) error { }{Target: target} path := "/images/" + url.PathEscape(source) + "/tag" if err := client.Post(ctx, path, body, nil, opts...); err != nil { - return err + 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) + } + res = []byte(staged.RawJSON()) } format := cmd.Root().String("format")