Skip to content
Merged
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 go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
50 changes: 31 additions & 19 deletions pkg/cmd/imagecmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,10 @@ var imageCmd = cli.Command{
}

var imageCreateCmd = cli.Command{
Name: "create",
Usage: "Pull and convert an OCI image",
ArgsUsage: "<name>",
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: "<name>",
Flags: imageCreateFlags(),
Action: handleImageCreate,
HideHelpCommand: true,
}
Expand Down Expand Up @@ -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 <name>", "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 <name>")
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") {
Expand All @@ -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...)
Expand All @@ -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 {
Expand Down
23 changes: 23 additions & 0 deletions pkg/cmd/imagecmd_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
36 changes: 24 additions & 12 deletions pkg/cmd/pull.go
Original file line number Diff line number Diff line change
Expand Up @@ -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: "<image>",
Flags: imageCreateFlags(),
Action: handlePull,
HideHelpCommand: true,
}
Expand All @@ -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
}
Expand Down
38 changes: 32 additions & 6 deletions pkg/cmd/resourcecmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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{
Expand Down Expand Up @@ -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{
Expand All @@ -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
}
Expand All @@ -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)

Expand Down
31 changes: 31 additions & 0 deletions pkg/cmd/resourcecmd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading