Skip to content

Commit 27fc6d2

Browse files
authored
fix: align pull with image create and improve reclaim size input (#44)
* Unify pull with image create * Preserve pull auto output * Support human-readable reclaim sizes * Apply suggestion from @sjmiller609
1 parent b6783ad commit 27fc6d2

7 files changed

Lines changed: 144 additions & 37 deletions

File tree

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ module github.com/kernel/hypeman-cli
33
go 1.25
44

55
require (
6+
github.com/c2h5oh/datasize v0.0.0-20231215233829-aa82cc1e6500
67
github.com/charmbracelet/bubbles v0.21.0
78
github.com/charmbracelet/bubbletea v1.3.6
89
github.com/charmbracelet/lipgloss v1.1.0

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiE
66
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
77
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
88
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
9+
github.com/c2h5oh/datasize v0.0.0-20231215233829-aa82cc1e6500 h1:6lhrsTEnloDPXyeZBvSYvQf8u86jbKehZPVDDlkgDl4=
10+
github.com/c2h5oh/datasize v0.0.0-20231215233829-aa82cc1e6500/go.mod h1:S/7n9copUssQ56c7aAgHqftWO4LTf4xY6CGWt8Bc+3M=
911
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
1012
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
1113
github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=

pkg/cmd/imagecmd.go

Lines changed: 31 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -25,15 +25,10 @@ var imageCmd = cli.Command{
2525
}
2626

2727
var imageCreateCmd = cli.Command{
28-
Name: "create",
29-
Usage: "Pull and convert an OCI image",
30-
ArgsUsage: "<name>",
31-
Flags: []cli.Flag{
32-
&cli.StringSliceFlag{
33-
Name: "tag",
34-
Usage: "Set image tag key-value pair (KEY=VALUE, can be repeated)",
35-
},
36-
},
28+
Name: "create",
29+
Usage: "Pull and convert an OCI image",
30+
ArgsUsage: "<name>",
31+
Flags: imageCreateFlags(),
3732
Action: handleImageCreate,
3833
HideHelpCommand: true,
3934
}
@@ -149,24 +144,21 @@ func handleImageList(ctx context.Context, cmd *cli.Command) error {
149144
}
150145

151146
func handleImageCreate(ctx context.Context, cmd *cli.Command) error {
147+
return handleImageCreateLike(ctx, cmd, "hypeman image create <name>", "image create")
148+
}
149+
150+
func handleImageCreateLike(ctx context.Context, cmd *cli.Command, usageLine, outputLabel string) error {
152151
args := cmd.Args().Slice()
153152
if len(args) < 1 {
154-
return fmt.Errorf("image name required\nUsage: hypeman image create <name>")
153+
return fmt.Errorf("image name required\nUsage: %s", usageLine)
155154
}
156155

157156
client := hypeman.NewClient(getDefaultRequestOptions(cmd)...)
158157

159-
params := hypeman.ImageNewParams{
160-
Name: args[0],
161-
}
162-
163-
tags, malformedTags := parseKeyValueSpecs(cmd.StringSlice("tag"))
158+
params, malformedTags := buildImageNewParams(args[0], cmd.StringSlice("tag"))
164159
for _, malformed := range malformedTags {
165160
fmt.Fprintf(os.Stderr, "Warning: ignoring malformed tag: %s\n", malformed)
166161
}
167-
if len(tags) > 0 {
168-
params.Tags = tags
169-
}
170162

171163
var opts []option.RequestOption
172164
if cmd.Root().Bool("debug") {
@@ -184,7 +176,7 @@ func handleImageCreate(ctx context.Context, cmd *cli.Command) error {
184176
return err
185177
}
186178
obj := gjson.ParseBytes(res)
187-
return ShowJSON(os.Stdout, "image create", obj, format, transform)
179+
return ShowJSON(os.Stdout, outputLabel, obj, format, transform)
188180
}
189181

190182
result, err := client.Images.New(ctx, params, opts...)
@@ -195,6 +187,26 @@ func handleImageCreate(ctx context.Context, cmd *cli.Command) error {
195187
return nil
196188
}
197189

190+
func imageCreateFlags() []cli.Flag {
191+
return []cli.Flag{
192+
&cli.StringSliceFlag{
193+
Name: "tag",
194+
Usage: "Set image tag key-value pair (KEY=VALUE, can be repeated)",
195+
},
196+
}
197+
}
198+
199+
func buildImageNewParams(name string, tagSpecs []string) (hypeman.ImageNewParams, []string) {
200+
params := hypeman.ImageNewParams{Name: name}
201+
202+
tags, malformedTags := parseKeyValueSpecs(tagSpecs)
203+
if len(tags) > 0 {
204+
params.Tags = tags
205+
}
206+
207+
return params, malformedTags
208+
}
209+
198210
func handleImageGet(ctx context.Context, cmd *cli.Command) error {
199211
args := cmd.Args().Slice()
200212
if len(args) < 1 {

pkg/cmd/imagecmd_test.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package cmd
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
"github.com/stretchr/testify/require"
8+
)
9+
10+
func TestBuildImageNewParams(t *testing.T) {
11+
params, malformed := buildImageNewParams("docker.io/library/alpine:latest", []string{
12+
"env=staging",
13+
"team=cli",
14+
"missing-delimiter",
15+
})
16+
17+
require.Equal(t, "docker.io/library/alpine:latest", params.Name)
18+
assert.Equal(t, map[string]string{
19+
"env": "staging",
20+
"team": "cli",
21+
}, params.Tags)
22+
assert.Equal(t, []string{"missing-delimiter"}, malformed)
23+
}

pkg/cmd/pull.go

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,15 @@ import (
77

88
"github.com/kernel/hypeman-go"
99
"github.com/kernel/hypeman-go/option"
10+
"github.com/tidwall/gjson"
1011
"github.com/urfave/cli/v3"
1112
)
1213

1314
var pullCmd = cli.Command{
1415
Name: "pull",
15-
Usage: "Pull an image from a registry",
16+
Usage: "Alias for `image create`",
1617
ArgsUsage: "<image>",
18+
Flags: imageCreateFlags(),
1719
Action: handlePull,
1820
HideHelpCommand: true,
1921
}
@@ -25,25 +27,35 @@ func handlePull(ctx context.Context, cmd *cli.Command) error {
2527
}
2628

2729
image := args[0]
28-
29-
fmt.Fprintf(os.Stderr, "Pulling %s...\n", image)
30+
params, malformedTags := buildImageNewParams(image, cmd.StringSlice("tag"))
31+
for _, malformed := range malformedTags {
32+
fmt.Fprintf(os.Stderr, "Warning: ignoring malformed tag: %s\n", malformed)
33+
}
3034

3135
client := hypeman.NewClient(getDefaultRequestOptions(cmd)...)
3236

33-
params := hypeman.ImageNewParams{
34-
Name: image,
35-
}
36-
3737
var opts []option.RequestOption
3838
if cmd.Root().Bool("debug") {
3939
opts = append(opts, debugMiddlewareOption)
4040
}
4141

42-
result, err := client.Images.New(
43-
ctx,
44-
params,
45-
opts...,
46-
)
42+
format := cmd.Root().String("format")
43+
transform := cmd.Root().String("transform")
44+
45+
if format != "auto" {
46+
var res []byte
47+
opts = append(opts, option.WithResponseBodyInto(&res))
48+
_, err := client.Images.New(ctx, params, opts...)
49+
if err != nil {
50+
return err
51+
}
52+
obj := gjson.ParseBytes(res)
53+
return ShowJSON(os.Stdout, "pull", obj, format, transform)
54+
}
55+
56+
fmt.Fprintf(os.Stderr, "Pulling %s...\n", image)
57+
58+
result, err := client.Images.New(ctx, params, opts...)
4759
if err != nil {
4860
return err
4961
}

pkg/cmd/resourcecmd.go

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@ package cmd
33
import (
44
"context"
55
"fmt"
6+
"math"
67
"os"
78
"strings"
89

10+
"github.com/c2h5oh/datasize"
911
"github.com/kernel/hypeman-go"
1012
"github.com/kernel/hypeman-go/option"
1113
"github.com/tidwall/gjson"
@@ -39,10 +41,15 @@ Examples:
3941
var resourcesReclaimMemoryCmd = cli.Command{
4042
Name: "reclaim-memory",
4143
Usage: "Request guest memory reclaim from reclaim-eligible instances",
44+
Description: `Request guest memory reclaim across eligible instances.
45+
46+
Examples:
47+
hypeman resources reclaim-memory --reclaim-bytes 512MB --dry-run
48+
hypeman resources reclaim-memory --reclaim-bytes 1073741824 --hold-for 10m --reason "pack host before launch"`,
4249
Flags: []cli.Flag{
43-
&cli.Int64Flag{
50+
&cli.StringFlag{
4451
Name: "reclaim-bytes",
45-
Usage: "Total bytes of guest memory to reclaim across eligible VMs",
52+
Usage: `Total guest memory to reclaim (e.g., "512MB", "2GB", or "1048576" for raw bytes)`,
4653
Required: true,
4754
},
4855
&cli.BoolFlag{
@@ -93,9 +100,9 @@ func handleResources(ctx context.Context, cmd *cli.Command) error {
93100
func handleResourcesReclaimMemory(ctx context.Context, cmd *cli.Command) error {
94101
client := hypeman.NewClient(getDefaultRequestOptions(cmd)...)
95102

96-
reclaimBytes := cmd.Int64("reclaim-bytes")
97-
if reclaimBytes <= 0 {
98-
return fmt.Errorf("reclaim-bytes must be greater than 0")
103+
reclaimBytes, err := parseReclaimBytes(cmd.String("reclaim-bytes"))
104+
if err != nil {
105+
return err
99106
}
100107

101108
request := hypeman.MemoryReclaimRequestParam{
@@ -122,7 +129,7 @@ func handleResourcesReclaimMemory(ctx context.Context, cmd *cli.Command) error {
122129

123130
var res []byte
124131
opts = append(opts, option.WithResponseBodyInto(&res))
125-
_, err := client.Resources.ReclaimMemory(ctx, params, opts...)
132+
_, err = client.Resources.ReclaimMemory(ctx, params, opts...)
126133
if err != nil {
127134
return err
128135
}
@@ -143,6 +150,25 @@ func handleResourcesReclaimMemory(ctx context.Context, cmd *cli.Command) error {
143150
return ShowJSON(os.Stdout, "resources reclaim-memory", obj, format, transform)
144151
}
145152

153+
func parseReclaimBytes(raw string) (int64, error) {
154+
if raw == "" {
155+
return 0, fmt.Errorf("reclaim-bytes is required")
156+
}
157+
158+
var size datasize.ByteSize
159+
if err := size.UnmarshalText([]byte(raw)); err != nil {
160+
return 0, fmt.Errorf("invalid reclaim-bytes %q: %w", raw, err)
161+
}
162+
if size == 0 {
163+
return 0, fmt.Errorf("reclaim-bytes must be greater than 0")
164+
}
165+
if size.Bytes() > math.MaxInt64 {
166+
return 0, fmt.Errorf("reclaim-bytes %q exceeds the maximum supported size", raw)
167+
}
168+
169+
return int64(size.Bytes()), nil
170+
}
171+
146172
func showResourcesTable(data []byte) error {
147173
obj := gjson.ParseBytes(data)
148174

pkg/cmd/resourcecmd_test.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,39 @@ import (
44
"testing"
55

66
"github.com/stretchr/testify/assert"
7+
"github.com/stretchr/testify/require"
78
)
89

10+
func TestParseReclaimBytes(t *testing.T) {
11+
tests := []struct {
12+
name string
13+
input string
14+
expected int64
15+
wantErr string
16+
}{
17+
{name: "raw bytes", input: "1048576", expected: 1048576},
18+
{name: "megabytes", input: "512MB", expected: 512 * 1024 * 1024},
19+
{name: "gigabytes with space", input: "2 GB", expected: 2 * 1024 * 1024 * 1024},
20+
{name: "empty", input: "", wantErr: "reclaim-bytes is required"},
21+
{name: "zero", input: "0", wantErr: "reclaim-bytes must be greater than 0"},
22+
{name: "invalid", input: "nope", wantErr: "invalid reclaim-bytes \"nope\""},
23+
}
24+
25+
for _, tt := range tests {
26+
t.Run(tt.name, func(t *testing.T) {
27+
got, err := parseReclaimBytes(tt.input)
28+
if tt.wantErr != "" {
29+
require.Error(t, err)
30+
assert.Contains(t, err.Error(), tt.wantErr)
31+
return
32+
}
33+
34+
require.NoError(t, err)
35+
assert.Equal(t, tt.expected, got)
36+
})
37+
}
38+
}
39+
940
func TestFormatBytes(t *testing.T) {
1041
tests := []struct {
1142
bytes int64

0 commit comments

Comments
 (0)