diff --git a/go.mod b/go.mod index 7dc66d5..3517cb8 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/kernel/hypeman-cli go 1.25 require ( + github.com/c2h5oh/datasize v0.0.0-20231215233829-aa82cc1e6500 github.com/charmbracelet/bubbles v0.21.0 github.com/charmbracelet/bubbletea v1.3.6 github.com/charmbracelet/lipgloss v1.1.0 diff --git a/go.sum b/go.sum index cf9eb56..c460850 100644 --- a/go.sum +++ b/go.sum @@ -6,6 +6,8 @@ github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiE github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= +github.com/c2h5oh/datasize v0.0.0-20231215233829-aa82cc1e6500 h1:6lhrsTEnloDPXyeZBvSYvQf8u86jbKehZPVDDlkgDl4= +github.com/c2h5oh/datasize v0.0.0-20231215233829-aa82cc1e6500/go.mod h1:S/7n9copUssQ56c7aAgHqftWO4LTf4xY6CGWt8Bc+3M= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= diff --git a/pkg/cmd/imagecmd.go b/pkg/cmd/imagecmd.go index ffd57bf..cda0eaa 100644 --- a/pkg/cmd/imagecmd.go +++ b/pkg/cmd/imagecmd.go @@ -25,15 +25,10 @@ var imageCmd = cli.Command{ } var imageCreateCmd = cli.Command{ - Name: "create", - Usage: "Pull and convert an OCI image", - ArgsUsage: "", - Flags: []cli.Flag{ - &cli.StringSliceFlag{ - Name: "tag", - Usage: "Set image tag key-value pair (KEY=VALUE, can be repeated)", - }, - }, + Name: "create", + Usage: "Pull and convert an OCI image", + ArgsUsage: "", + Flags: imageCreateFlags(), Action: handleImageCreate, HideHelpCommand: true, } @@ -149,24 +144,21 @@ func handleImageList(ctx context.Context, cmd *cli.Command) error { } func handleImageCreate(ctx context.Context, cmd *cli.Command) error { + return handleImageCreateLike(ctx, cmd, "hypeman image create ", "image create") +} + +func handleImageCreateLike(ctx context.Context, cmd *cli.Command, usageLine, outputLabel string) error { args := cmd.Args().Slice() if len(args) < 1 { - return fmt.Errorf("image name required\nUsage: hypeman image create ") + return fmt.Errorf("image name required\nUsage: %s", usageLine) } client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) - params := hypeman.ImageNewParams{ - Name: args[0], - } - - tags, malformedTags := parseKeyValueSpecs(cmd.StringSlice("tag")) + params, malformedTags := buildImageNewParams(args[0], cmd.StringSlice("tag")) for _, malformed := range malformedTags { fmt.Fprintf(os.Stderr, "Warning: ignoring malformed tag: %s\n", malformed) } - if len(tags) > 0 { - params.Tags = tags - } var opts []option.RequestOption if cmd.Root().Bool("debug") { @@ -184,7 +176,7 @@ func handleImageCreate(ctx context.Context, cmd *cli.Command) error { return err } obj := gjson.ParseBytes(res) - return ShowJSON(os.Stdout, "image create", obj, format, transform) + return ShowJSON(os.Stdout, outputLabel, obj, format, transform) } result, err := client.Images.New(ctx, params, opts...) @@ -195,6 +187,26 @@ func handleImageCreate(ctx context.Context, cmd *cli.Command) error { return nil } +func imageCreateFlags() []cli.Flag { + return []cli.Flag{ + &cli.StringSliceFlag{ + Name: "tag", + Usage: "Set image tag key-value pair (KEY=VALUE, can be repeated)", + }, + } +} + +func buildImageNewParams(name string, tagSpecs []string) (hypeman.ImageNewParams, []string) { + params := hypeman.ImageNewParams{Name: name} + + tags, malformedTags := parseKeyValueSpecs(tagSpecs) + if len(tags) > 0 { + params.Tags = tags + } + + return params, malformedTags +} + func handleImageGet(ctx context.Context, cmd *cli.Command) error { args := cmd.Args().Slice() if len(args) < 1 { diff --git a/pkg/cmd/imagecmd_test.go b/pkg/cmd/imagecmd_test.go new file mode 100644 index 0000000..710df9d --- /dev/null +++ b/pkg/cmd/imagecmd_test.go @@ -0,0 +1,23 @@ +package cmd + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBuildImageNewParams(t *testing.T) { + params, malformed := buildImageNewParams("docker.io/library/alpine:latest", []string{ + "env=staging", + "team=cli", + "missing-delimiter", + }) + + require.Equal(t, "docker.io/library/alpine:latest", params.Name) + assert.Equal(t, map[string]string{ + "env": "staging", + "team": "cli", + }, params.Tags) + assert.Equal(t, []string{"missing-delimiter"}, malformed) +} diff --git a/pkg/cmd/pull.go b/pkg/cmd/pull.go index 212c420..6303355 100644 --- a/pkg/cmd/pull.go +++ b/pkg/cmd/pull.go @@ -7,13 +7,15 @@ import ( "github.com/kernel/hypeman-go" "github.com/kernel/hypeman-go/option" + "github.com/tidwall/gjson" "github.com/urfave/cli/v3" ) var pullCmd = cli.Command{ Name: "pull", - Usage: "Pull an image from a registry", + Usage: "Alias for `image create`", ArgsUsage: "", + Flags: imageCreateFlags(), Action: handlePull, HideHelpCommand: true, } @@ -25,25 +27,35 @@ func handlePull(ctx context.Context, cmd *cli.Command) error { } image := args[0] - - fmt.Fprintf(os.Stderr, "Pulling %s...\n", image) + params, malformedTags := buildImageNewParams(image, cmd.StringSlice("tag")) + for _, malformed := range malformedTags { + fmt.Fprintf(os.Stderr, "Warning: ignoring malformed tag: %s\n", malformed) + } client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) - params := hypeman.ImageNewParams{ - Name: image, - } - var opts []option.RequestOption if cmd.Root().Bool("debug") { opts = append(opts, debugMiddlewareOption) } - result, err := client.Images.New( - ctx, - params, - opts..., - ) + format := cmd.Root().String("format") + transform := cmd.Root().String("transform") + + if format != "auto" { + var res []byte + opts = append(opts, option.WithResponseBodyInto(&res)) + _, err := client.Images.New(ctx, params, opts...) + if err != nil { + return err + } + obj := gjson.ParseBytes(res) + return ShowJSON(os.Stdout, "pull", obj, format, transform) + } + + fmt.Fprintf(os.Stderr, "Pulling %s...\n", image) + + result, err := client.Images.New(ctx, params, opts...) if err != nil { return err } diff --git a/pkg/cmd/resourcecmd.go b/pkg/cmd/resourcecmd.go index ffa9baf..8d69018 100644 --- a/pkg/cmd/resourcecmd.go +++ b/pkg/cmd/resourcecmd.go @@ -3,9 +3,11 @@ package cmd import ( "context" "fmt" + "math" "os" "strings" + "github.com/c2h5oh/datasize" "github.com/kernel/hypeman-go" "github.com/kernel/hypeman-go/option" "github.com/tidwall/gjson" @@ -39,10 +41,15 @@ Examples: var resourcesReclaimMemoryCmd = cli.Command{ Name: "reclaim-memory", Usage: "Request guest memory reclaim from reclaim-eligible instances", + Description: `Request guest memory reclaim across eligible instances. + +Examples: + hypeman resources reclaim-memory --reclaim-bytes 512MB --dry-run + hypeman resources reclaim-memory --reclaim-bytes 1073741824 --hold-for 10m --reason "pack host before launch"`, Flags: []cli.Flag{ - &cli.Int64Flag{ + &cli.StringFlag{ Name: "reclaim-bytes", - Usage: "Total bytes of guest memory to reclaim across eligible VMs", + Usage: `Total guest memory to reclaim (e.g., "512MB", "2GB", or "1048576" for raw bytes)`, Required: true, }, &cli.BoolFlag{ @@ -93,9 +100,9 @@ func handleResources(ctx context.Context, cmd *cli.Command) error { func handleResourcesReclaimMemory(ctx context.Context, cmd *cli.Command) error { client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) - reclaimBytes := cmd.Int64("reclaim-bytes") - if reclaimBytes <= 0 { - return fmt.Errorf("reclaim-bytes must be greater than 0") + reclaimBytes, err := parseReclaimBytes(cmd.String("reclaim-bytes")) + if err != nil { + return err } request := hypeman.MemoryReclaimRequestParam{ @@ -122,7 +129,7 @@ func handleResourcesReclaimMemory(ctx context.Context, cmd *cli.Command) error { var res []byte opts = append(opts, option.WithResponseBodyInto(&res)) - _, err := client.Resources.ReclaimMemory(ctx, params, opts...) + _, err = client.Resources.ReclaimMemory(ctx, params, opts...) if err != nil { return err } @@ -143,6 +150,25 @@ func handleResourcesReclaimMemory(ctx context.Context, cmd *cli.Command) error { return ShowJSON(os.Stdout, "resources reclaim-memory", obj, format, transform) } +func parseReclaimBytes(raw string) (int64, error) { + if raw == "" { + return 0, fmt.Errorf("reclaim-bytes is required") + } + + var size datasize.ByteSize + if err := size.UnmarshalText([]byte(raw)); err != nil { + return 0, fmt.Errorf("invalid reclaim-bytes %q: %w", raw, err) + } + if size == 0 { + return 0, fmt.Errorf("reclaim-bytes must be greater than 0") + } + if size.Bytes() > math.MaxInt64 { + return 0, fmt.Errorf("reclaim-bytes %q exceeds the maximum supported size", raw) + } + + return int64(size.Bytes()), nil +} + func showResourcesTable(data []byte) error { obj := gjson.ParseBytes(data) diff --git a/pkg/cmd/resourcecmd_test.go b/pkg/cmd/resourcecmd_test.go index 4882adb..032a045 100644 --- a/pkg/cmd/resourcecmd_test.go +++ b/pkg/cmd/resourcecmd_test.go @@ -4,8 +4,39 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) +func TestParseReclaimBytes(t *testing.T) { + tests := []struct { + name string + input string + expected int64 + wantErr string + }{ + {name: "raw bytes", input: "1048576", expected: 1048576}, + {name: "megabytes", input: "512MB", expected: 512 * 1024 * 1024}, + {name: "gigabytes with space", input: "2 GB", expected: 2 * 1024 * 1024 * 1024}, + {name: "empty", input: "", wantErr: "reclaim-bytes is required"}, + {name: "zero", input: "0", wantErr: "reclaim-bytes must be greater than 0"}, + {name: "invalid", input: "nope", wantErr: "invalid reclaim-bytes \"nope\""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseReclaimBytes(tt.input) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + + require.NoError(t, err) + assert.Equal(t, tt.expected, got) + }) + } +} + func TestFormatBytes(t *testing.T) { tests := []struct { bytes int64