From 281a39d9599bb9a1d7145a8f145a4f7e7c162baf Mon Sep 17 00:00:00 2001 From: chruffins <23645059+chruffins@users.noreply.github.com> Date: Wed, 26 Aug 2026 15:47:48 +0000 Subject: [PATCH] Add tag command for existing local images --- pkg/cmd/cmd.go | 1 + pkg/cmd/tag.go | 63 ++++++++++++++++++++++++++++++++++++++++ pkg/cmd/tag_test.go | 71 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 135 insertions(+) create mode 100644 pkg/cmd/tag.go create mode 100644 pkg/cmd/tag_test.go diff --git a/pkg/cmd/cmd.go b/pkg/cmd/cmd.go index 14f266b..18243dc 100644 --- a/pkg/cmd/cmd.go +++ b/pkg/cmd/cmd.go @@ -88,6 +88,7 @@ func init() { &standbyCmd, &restoreCmd, &forkCmd, + &tagCmd, &imageCmd, &ingressCmd, &snapshotCmd, diff --git a/pkg/cmd/tag.go b/pkg/cmd/tag.go new file mode 100644 index 0000000..083502f --- /dev/null +++ b/pkg/cmd/tag.go @@ -0,0 +1,63 @@ +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: "Tag an existing local image without pulling or converting", + ArgsUsage: " ", + Action: handleTag, + HideHelpCommand: true, +} + +// tagImageBody is the POST /images/{name}/tag request body, defined locally +// until the generated SDK gains a typed method for the endpoint. +type tagImageBody struct { + Target string `json:"target"` +} + +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)) + path := fmt.Sprintf("images/%s/tag", url.PathEscape(source)) + if err := client.Post(ctx, path, tagImageBody{Target: target}, nil, opts...); err != nil { + return err + } + + 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 +} diff --git a/pkg/cmd/tag_test.go b/pkg/cmd/tag_test.go new file mode 100644 index 0000000..8a2b81d --- /dev/null +++ b/pkg/cmd/tag_test.go @@ -0,0 +1,71 @@ +package cmd + +import ( + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTagCommand_RequiresSourceAndTarget(t *testing.T) { + err := Command.Run(context.Background(), []string{"hypeman", "tag", "only-source"}) + require.EqualError(t, err, "source and target image references required\nUsage: hypeman tag ") +} + +func TestTagCommand_PostsToTagEndpoint(t *testing.T) { + var gotMethod, gotPath string + var gotBody []byte + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMethod = r.Method + gotPath = r.URL.EscapedPath() + gotBody, _ = io.ReadAll(r.Body) + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"name":"registry.example.com/app:v1","digest":"sha256:abc","status":"ready","created_at":"2026-01-01T00:00:00Z"}`) + })) + defer srv.Close() + + // Capture os.Stdout: handleTag prints the resolved image name directly. + origStdout := os.Stdout + r, w, err := os.Pipe() + require.NoError(t, err) + os.Stdout = w + + runErr := Command.Run(context.Background(), []string{ + "hypeman", "--base-url", srv.URL, + "tag", + "docker.io/library/alpine:latest", "registry.example.com/app:v1", + }) + + w.Close() + os.Stdout = origStdout + out, readErr := io.ReadAll(r) + require.NoError(t, readErr) + + require.NoError(t, runErr) + assert.Equal(t, http.MethodPost, gotMethod) + assert.Equal(t, "/images/docker.io%2Flibrary%2Falpine:latest/tag", gotPath) + assert.JSONEq(t, `{"target":"registry.example.com/app:v1"}`, string(gotBody)) + assert.Equal(t, "registry.example.com/app:v1\n", string(out)) +} + +func TestTagCommand_SurfacesServerError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + fmt.Fprint(w, `{"code":"not_found","message":"image not found"}`) + })) + defer srv.Close() + + err := Command.Run(context.Background(), []string{ + "hypeman", "--base-url", srv.URL, + "tag", + "docker.io/library/alpine:missing", "registry.example.com/app:v1", + }) + require.Error(t, err) +}