From 95079f4336e5d1bbabc5af61d2746f26c7f1992f 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 01/11] 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 8229612567c70dbbb95f67bd473335ae3468ec8c 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 02/11] 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 6367458a080826ffc8f6149658e5ff5814e2f3ae 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 03/11] 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 49dd24c56172b83f36c7220972cf2921a3bcf148 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 04/11] 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 af1991b20759936df78c20896c23c191cdc85c61 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 05/11] 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") From c3fe38ede1562a08eff4e45b0bcc7fdf34b23092 Mon Sep 17 00:00:00 2001 From: chruffins <23645059+chruffins@users.noreply.github.com> Date: Thu, 27 Aug 2026 18:01:47 +0000 Subject: [PATCH 06/11] Align image tag CLI with local tag API --- pkg/cmd/imagecmd_test.go | 2 +- pkg/cmd/push.go | 3 ++- pkg/cmd/pushcmd.go | 2 +- pkg/cmd/tag.go | 27 ++++++++++++++++----------- 4 files changed, 20 insertions(+), 14 deletions(-) diff --git a/pkg/cmd/imagecmd_test.go b/pkg/cmd/imagecmd_test.go index e18d2e0..ef39213 100644 --- a/pkg/cmd/imagecmd_test.go +++ b/pkg/cmd/imagecmd_test.go @@ -104,5 +104,5 @@ func TestTagCommandFallsBackToDockerWhenHypemanMisses(t *testing.T) { "hypeman", "--base-url", server.URL, "tag", "not a valid image", "myapp:latest", }) - require.ErrorContains(t, err, "was not found in Hypeman or Docker") + require.ErrorContains(t, err, "was not found in Hypeman; stage it from Docker") } diff --git a/pkg/cmd/push.go b/pkg/cmd/push.go index c2170f6..5601bde 100644 --- a/pkg/cmd/push.go +++ b/pkg/cmd/push.go @@ -48,8 +48,9 @@ Examples: 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 + # Stage a local Docker tag, then push it to ECR docker tag alpine:latest 123456789.dkr.ecr.us-east-1.amazonaws.com/myapp:v1 + hypeman push local 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 directly to a different remote target diff --git a/pkg/cmd/pushcmd.go b/pkg/cmd/pushcmd.go index 5e5e337..33679bb 100644 --- a/pkg/cmd/pushcmd.go +++ b/pkg/cmd/pushcmd.go @@ -107,7 +107,7 @@ func handleRemotePushTarget(ctx context.Context, cmd *cli.Command, target string // 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 for a cached Hypeman image", target, err) + return fmt.Errorf("image %q was not found in Hypeman; stage it from Docker: %w", target, err) } return runRemotePush(ctx, cmd, target, target) diff --git a/pkg/cmd/tag.go b/pkg/cmd/tag.go index 12188f5..bc596b5 100644 --- a/pkg/cmd/tag.go +++ b/pkg/cmd/tag.go @@ -13,12 +13,16 @@ import ( ) var tagCmd = cli.Command{ - Name: "tag", - Usage: "Create a local image tag", - ArgsUsage: " ", - 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, + Name: "tag", + Usage: "Create a local image tag", + ArgsUsage: " ", + Description: "Create a local image tag in Hypeman without pulling or converting the image.", + Action: handleTag, + HideHelpCommand: true, +} + +type tagImageRequest struct { + Target string `json:"target"` } func handleTag(ctx context.Context, cmd *cli.Command) error { @@ -37,17 +41,18 @@ func handleTag(ctx context.Context, cmd *cli.Command) error { 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 err := client.Post(ctx, path, tagImageRequest{Target: target}, nil, opts...); err != nil { if !isNotFoundError(err) { return err } + + // Keep the Docker fallback for sources that have not been imported into + // Hypeman yet. Once staged, the image is available under the requested + // target and can be pushed through the normal cached-image flow. 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) + return fmt.Errorf("image %q was not found in Hypeman; stage it from Docker: %w", source, stageErr) } res = []byte(staged.RawJSON()) } From be0c5be488386c4807715ddc2196e6207370be58 Mon Sep 17 00:00:00 2001 From: chruffins <23645059+chruffins@users.noreply.github.com> Date: Thu, 27 Aug 2026 18:02:30 +0000 Subject: [PATCH 07/11] Test image tag readiness errors --- pkg/cmd/imagecmd_test.go | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/pkg/cmd/imagecmd_test.go b/pkg/cmd/imagecmd_test.go index ef39213..1d96605 100644 --- a/pkg/cmd/imagecmd_test.go +++ b/pkg/cmd/imagecmd_test.go @@ -106,3 +106,18 @@ func TestTagCommandFallsBackToDockerWhenHypemanMisses(t *testing.T) { }) require.ErrorContains(t, err, "was not found in Hypeman; stage it from Docker") } + +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") +} From 59b2acdf1cb2d8154c730675dfd5a35537a2c73a Mon Sep 17 00:00:00 2001 From: chruffins <23645059+chruffins@users.noreply.github.com> Date: Thu, 27 Aug 2026 18:15:41 +0000 Subject: [PATCH 08/11] Simplify image tag command --- pkg/cmd/imagecmd_test.go | 6 ++--- pkg/cmd/pushcmd.go | 1 - pkg/cmd/tag.go | 49 +++++++++++++++++++--------------------- 3 files changed, 26 insertions(+), 30 deletions(-) diff --git a/pkg/cmd/imagecmd_test.go b/pkg/cmd/imagecmd_test.go index 1d96605..fe6773d 100644 --- a/pkg/cmd/imagecmd_test.go +++ b/pkg/cmd/imagecmd_test.go @@ -96,15 +96,15 @@ func TestTagCommandPostsEscapedSourceAndTarget(t *testing.T) { assert.Contains(t, string(output), "docker.io/library/myapp:latest") } -func TestTagCommandFallsBackToDockerWhenHypemanMisses(t *testing.T) { +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", "not a valid image", "myapp:latest", + "tag", "alpine:missing", "myapp:latest", }) - require.ErrorContains(t, err, "was not found in Hypeman; stage it from Docker") + require.ErrorContains(t, err, "404") } func TestTagCommandPropagatesNotReady(t *testing.T) { diff --git a/pkg/cmd/pushcmd.go b/pkg/cmd/pushcmd.go index 33679bb..209417d 100644 --- a/pkg/cmd/pushcmd.go +++ b/pkg/cmd/pushcmd.go @@ -105,7 +105,6 @@ func handleRemotePushTarget(ctx context.Context, cmd *cli.Command, target string return fmt.Errorf("get cached image %s: %w", target, 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("image %q was not found in Hypeman; stage it from Docker: %w", target, err) } diff --git a/pkg/cmd/tag.go b/pkg/cmd/tag.go index bc596b5..9dd7272 100644 --- a/pkg/cmd/tag.go +++ b/pkg/cmd/tag.go @@ -13,16 +13,11 @@ import ( ) var tagCmd = cli.Command{ - Name: "tag", - Usage: "Create a local image tag", - ArgsUsage: " ", - Description: "Create a local image tag in Hypeman without pulling or converting the image.", - Action: handleTag, - HideHelpCommand: true, -} - -type tagImageRequest struct { - Target string `json:"target"` + Name: "tag", + Usage: "Create a local image tag", + ArgsUsage: " ", + 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 { @@ -33,30 +28,32 @@ func handleTag(ctx context.Context, cmd *cli.Command) error { source, target := args[0], args[1] client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) + res, err := tagImage(ctx, cmd, &client, source, target) + if err != nil { + return err + } + + return printTagResult(cmd, target, res) +} - var opts []option.RequestOption +func tagImage(ctx context.Context, cmd *cli.Command, client *hypeman.Client, source, target string) ([]byte, error) { + var res []byte + opts := []option.RequestOption{option.WithResponseBodyInto(&res)} if cmd.Root().Bool("debug") { opts = append(opts, debugMiddlewareOption) } - var res []byte - opts = append(opts, option.WithResponseBodyInto(&res)) path := "/images/" + url.PathEscape(source) + "/tag" - if err := client.Post(ctx, path, tagImageRequest{Target: target}, nil, opts...); err != nil { - if !isNotFoundError(err) { - return err - } - - // Keep the Docker fallback for sources that have not been imported into - // Hypeman yet. Once staged, the image is available under the requested - // target and can be pushed through the normal cached-image flow. - staged, stageErr := stageDockerImage(ctx, cmd, &client, source, target) - if stageErr != nil { - return fmt.Errorf("image %q was not found in Hypeman; stage it from Docker: %w", source, stageErr) - } - res = []byte(staged.RawJSON()) + body := struct { + Target string `json:"target"` + }{Target: target} + if err := client.Post(ctx, path, body, nil, opts...); err != nil { + return nil, err } + return res, nil +} +func printTagResult(cmd *cli.Command, target string, res []byte) error { format := cmd.Root().String("format") transform := cmd.Root().String("transform") result := gjson.ParseBytes(res) From efa1374c659ccab606d9d24b24fbd8c1b1856100 Mon Sep 17 00:00:00 2001 From: chruffins <23645059+chruffins@users.noreply.github.com> Date: Thu, 27 Aug 2026 18:27:15 +0000 Subject: [PATCH 09/11] Preserve Docker push precedence --- pkg/cmd/push.go | 7 +++---- pkg/cmd/pushcmd.go | 17 ++++++++++++++--- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/pkg/cmd/push.go b/pkg/cmd/push.go index 5601bde..be39919 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 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. + 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. @@ -48,9 +48,8 @@ Examples: 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 - # Stage a local Docker tag, then push it to ECR + # Push a local Docker tag to ECR docker tag alpine:latest 123456789.dkr.ecr.us-east-1.amazonaws.com/myapp:v1 - hypeman push local 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 directly to a different remote target diff --git a/pkg/cmd/pushcmd.go b/pkg/cmd/pushcmd.go index 209417d..2b1b797 100644 --- a/pkg/cmd/pushcmd.go +++ b/pkg/cmd/pushcmd.go @@ -10,6 +10,7 @@ import ( "time" "github.com/google/go-containerregistry/pkg/name" + v1 "github.com/google/go-containerregistry/pkg/v1" "github.com/kernel/hypeman-go" "github.com/kernel/hypeman-go/option" "github.com/tidwall/gjson" @@ -91,9 +92,16 @@ func handleRemotePushTarget(ctx context.Context, cmd *cli.Command, target string return 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)...) + if img, err := loadDockerImage(target); err == nil { + if _, err := stageLoadedDockerImage(ctx, cmd, &client, target, target, img); err != nil { + return fmt.Errorf("stage local Docker image %q: %w", target, err) + } + return runRemotePush(ctx, cmd, target, target) + } + + // A cached Hypeman image is used when Docker does not have TARGET. This + // makes `hypeman tag` followed by `hypeman push TARGET` daemon-independent. cachedImage, err := client.Images.Get(ctx, url.PathEscape(target)) if err == nil { if err := waitForImageReady(ctx, &client, cachedImage); err != nil { @@ -106,7 +114,7 @@ func handleRemotePushTarget(ctx context.Context, cmd *cli.Command, target string } if _, err := stageDockerImage(ctx, cmd, &client, target, target); err != nil { - return fmt.Errorf("image %q was not found in Hypeman; stage it from Docker: %w", target, err) + return fmt.Errorf("stage local Docker image %q: %w", target, err) } return runRemotePush(ctx, cmd, target, target) @@ -130,7 +138,10 @@ func stageDockerImage(ctx context.Context, cmd *cli.Command, client *hypeman.Cli if err != nil { return nil, err } + return stageLoadedDockerImage(ctx, cmd, client, source, target, img) +} +func stageLoadedDockerImage(ctx context.Context, cmd *cli.Command, client *hypeman.Client, source, target string, img v1.Image) (*hypeman.Image, error) { fmt.Fprintf(os.Stderr, "Staging local image %s in Hypeman...\n", source) if err := uploadLocalImage(ctx, cmd, target, img); err != nil { return nil, err From adea978fe40f5b47cccd6d22a52b814cf812c874 Mon Sep 17 00:00:00 2001 From: chruffins <23645059+chruffins@users.noreply.github.com> Date: Fri, 28 Aug 2026 14:09:54 +0000 Subject: [PATCH 10/11] Fix cached-push fallback and simplify tag command --- README.md | 3 ++- pkg/cmd/pushcmd.go | 58 +++++++++++++---------------------------- pkg/cmd/pushcmd_test.go | 27 ++++++++++++++++++- pkg/cmd/tag.go | 32 +++++++++-------------- 4 files changed, 59 insertions(+), 61 deletions(-) diff --git a/README.md b/README.md index 8a40ce9..1f4cf8d 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,8 @@ 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 +# 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) diff --git a/pkg/cmd/pushcmd.go b/pkg/cmd/pushcmd.go index 2b1b797..4d68bdb 100644 --- a/pkg/cmd/pushcmd.go +++ b/pkg/cmd/pushcmd.go @@ -10,7 +10,6 @@ import ( "time" "github.com/google/go-containerregistry/pkg/name" - v1 "github.com/google/go-containerregistry/pkg/v1" "github.com/kernel/hypeman-go" "github.com/kernel/hypeman-go/option" "github.com/tidwall/gjson" @@ -93,15 +92,27 @@ func handleRemotePushTarget(ctx context.Context, cmd *cli.Command, target string } client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) - if img, err := loadDockerImage(target); err == nil { - if _, err := stageLoadedDockerImage(ctx, cmd, &client, target, target, img); err != nil { - return fmt.Errorf("stage local Docker image %q: %w", target, 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) } - // A cached Hypeman image is used when Docker does not have TARGET. This - // makes `hypeman tag` followed by `hypeman push TARGET` daemon-independent. + // 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 { if err := waitForImageReady(ctx, &client, cachedImage); err != nil { @@ -113,11 +124,7 @@ func handleRemotePushTarget(ctx context.Context, cmd *cli.Command, target string return fmt.Errorf("get cached image %s: %w", target, err) } - if _, err := stageDockerImage(ctx, cmd, &client, target, target); err != nil { - return fmt.Errorf("stage local Docker image %q: %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 first", target) } func validateRemotePushTarget(target string) error { @@ -133,35 +140,6 @@ 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 - } - return stageLoadedDockerImage(ctx, cmd, client, source, target, img) -} - -func stageLoadedDockerImage(ctx context.Context, cmd *cli.Command, client *hypeman.Client, source, target string, img v1.Image) (*hypeman.Image, error) { - 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/pushcmd_test.go b/pkg/cmd/pushcmd_test.go index 7e4a211..96864c5 100644 --- a/pkg/cmd/pushcmd_test.go +++ b/pkg/cmd/pushcmd_test.go @@ -34,7 +34,7 @@ func TestPushRepository(t *testing.T) { assert.Equal(t, "registry.example.com/app", pushRepository("registry.example.com/app:v1")) } -func TestPushTargetPrefersCachedHypemanImage(t *testing.T) { +func TestPushTargetFallsBackToCachedHypemanImage(t *testing.T) { var pushImage, pushTarget string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { @@ -72,6 +72,31 @@ func TestPushTargetPrefersCachedHypemanImage(t *testing.T) { 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") diff --git a/pkg/cmd/tag.go b/pkg/cmd/tag.go index 9dd7272..ea87ef7 100644 --- a/pkg/cmd/tag.go +++ b/pkg/cmd/tag.go @@ -6,6 +6,7 @@ import ( "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" @@ -25,43 +26,36 @@ func handleTag(ctx context.Context, cmd *cli.Command) error { 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)...) - res, err := tagImage(ctx, cmd, &client, source, target) - if err != nil { - return err + 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) + } } - return printTagResult(cmd, target, res) -} + client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) -func tagImage(ctx context.Context, cmd *cli.Command, client *hypeman.Client, source, target string) ([]byte, error) { - var res []byte - opts := []option.RequestOption{option.WithResponseBodyInto(&res)} + var opts []option.RequestOption if cmd.Root().Bool("debug") { opts = append(opts, debugMiddlewareOption) } - path := "/images/" + url.PathEscape(source) + "/tag" + var res []byte + opts = append(opts, option.WithResponseBodyInto(&res)) body := struct { Target string `json:"target"` }{Target: target} - if err := client.Post(ctx, path, body, nil, opts...); err != nil { - return nil, err + if err := client.Post(ctx, "/images/"+url.PathEscape(source)+"/tag", body, nil, opts...); err != nil { + return err } - return res, nil -} -func printTagResult(cmd *cli.Command, target string, res []byte) error { 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) + return ShowJSON(os.Stdout, "tag", gjson.ParseBytes(res), format, transform) } - imageName := result.Get("name").String() + imageName := gjson.GetBytes(res, "name").String() if imageName == "" { imageName = target } From b6bbb8fffdf7d4ba280137d44da2efe43e12092e Mon Sep 17 00:00:00 2001 From: chruffins <23645059+chruffins@users.noreply.github.com> Date: Fri, 28 Aug 2026 14:14:59 +0000 Subject: [PATCH 11/11] Clarify cached-image readiness errors and note SDK follow-up --- pkg/cmd/pushcmd.go | 4 +++- pkg/cmd/tag.go | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/pkg/cmd/pushcmd.go b/pkg/cmd/pushcmd.go index 4d68bdb..a4f5e83 100644 --- a/pkg/cmd/pushcmd.go +++ b/pkg/cmd/pushcmd.go @@ -115,8 +115,10 @@ func handleRemotePushTarget(ctx context.Context, cmd *cli.Command, target string 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 err + return fmt.Errorf("cached image %s is not ready: %w", target, err) } return runRemotePush(ctx, cmd, target, target) } diff --git a/pkg/cmd/tag.go b/pkg/cmd/tag.go index ea87ef7..dfc6f38 100644 --- a/pkg/cmd/tag.go +++ b/pkg/cmd/tag.go @@ -42,6 +42,8 @@ func handleTag(ctx context.Context, cmd *cli.Command) error { 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}