Skip to content
Closed
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
1 change: 1 addition & 0 deletions pkg/cmd/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ func init() {
&standbyCmd,
&restoreCmd,
&forkCmd,
&tagCmd,
&imageCmd,
&ingressCmd,
&snapshotCmd,
Expand Down
63 changes: 63 additions & 0 deletions pkg/cmd/tag.go
Original file line number Diff line number Diff line change
@@ -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: "<source> <target>",
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>")
}
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
}
71 changes: 71 additions & 0 deletions pkg/cmd/tag_test.go
Original file line number Diff line number Diff line change
@@ -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 <source> <target>")
}

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)
}
Loading