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
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ require (
github.com/google/go-containerregistry v0.20.7
github.com/gorilla/websocket v1.5.3
github.com/itchyny/json2yaml v0.1.4
github.com/kernel/hypeman-go v0.20.0
github.com/kernel/hypeman-go v0.22.1-0.20260804210000-b0c2df62be94
github.com/knadh/koanf/parsers/yaml v1.1.0
github.com/knadh/koanf/providers/env v1.1.0
github.com/knadh/koanf/providers/file v1.2.1
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,8 @@ github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnV
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs=
github.com/itchyny/json2yaml v0.1.4 h1:/pErVOXGG5iTyXHi/QKR4y3uzhLjGTEmmJIy97YT+k8=
github.com/itchyny/json2yaml v0.1.4/go.mod h1:6iudhBZdarpjLFRNj+clWLAkGft+9uCcjAZYXUH9eGI=
github.com/kernel/hypeman-go v0.20.0 h1:9kEMjtlko5oYSETwn9v829rJBv5GpcmoYjBjhjuwnBA=
github.com/kernel/hypeman-go v0.20.0/go.mod h1:guRrhyP9QW/ebUS1UcZ0uZLLJeGAAhDNzSi68U4M9hI=
github.com/kernel/hypeman-go v0.22.1-0.20260804210000-b0c2df62be94 h1:9lNZxwf6sWx9vScQ0lKAWkHKFLeGiMaoGBxBOhQcePE=
github.com/kernel/hypeman-go v0.22.1-0.20260804210000-b0c2df62be94/go.mod h1:of8qI/nef2OPLzt0EMlIRbMdJHEvuc4yWG8g/ioNg48=
github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co=
github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0=
github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo=
Expand Down
53 changes: 49 additions & 4 deletions pkg/cmd/autostandbycmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ var autoStandbyCmd = cli.Command{
Usage: "Inspect auto-standby configuration and status",
Commands: []*cli.Command{
&autoStandbyStatusCmd,
&autoStandbyHoldCmd,
},
HideHelpCommand: true,
}
Expand All @@ -31,10 +32,52 @@ var autoStandbyStatusCmd = cli.Command{
HideHelpCommand: true,
}

var autoStandbyHoldCmd = cli.Command{
Name: "hold",
Usage: "Hold off auto-standby for an instance",
ArgsUsage: "<instance>",
Description: `Place a hold that prevents the auto-standby controller from putting the instance
into standby before the returned HOLD UNTIL time, and cancel any queued standby attempt.

Use this before opening a connection to a candidate-idle instance: success means it
is safe to connect until HOLD UNTIL, while a conflict error means the instance is in
standby (or irrevocably entering it) and must be restored first.

Instances where auto-standby is disabled, unconfigured, or unsupported succeed and
report their current status, because no auto-standby will occur.

Examples:
hypeman auto-standby hold my-instance
hypeman auto-standby hold my-instance --format json`,
Action: handleAutoStandbyHold,
HideHelpCommand: true,
}

func handleAutoStandbyStatus(ctx context.Context, cmd *cli.Command) error {
return runAutoStandbyAction(ctx, cmd, "status", func(ctx context.Context, client *hypeman.Client, instanceID string, opts []option.RequestOption) error {
_, err := client.Instances.AutoStandby.Status(ctx, instanceID, opts...)
return err
})
}

func handleAutoStandbyHold(ctx context.Context, cmd *cli.Command) error {
return runAutoStandbyAction(ctx, cmd, "hold", func(ctx context.Context, client *hypeman.Client, instanceID string, opts []option.RequestOption) error {
_, err := client.Instances.AutoStandby.Hold(ctx, instanceID, opts...)
return err
})
}

// runAutoStandbyAction resolves the instance argument, invokes an auto-standby
// endpoint that returns an AutoStandbyStatus, and renders the response.
func runAutoStandbyAction(
ctx context.Context,
cmd *cli.Command,
subcommand string,
call func(ctx context.Context, client *hypeman.Client, instanceID string, opts []option.RequestOption) error,
) error {
args := cmd.Args().Slice()
if len(args) < 1 {
return fmt.Errorf("instance ID or name required\nUsage: hypeman auto-standby status <instance>")
return fmt.Errorf("instance ID or name required\nUsage: hypeman auto-standby %s <instance>", subcommand)
}

client := hypeman.NewClient(getDefaultRequestOptions(cmd)...)
Expand All @@ -50,8 +93,7 @@ func handleAutoStandbyStatus(ctx context.Context, cmd *cli.Command) error {

var res []byte
opts = append(opts, option.WithResponseBodyInto(&res))
_, err = client.Instances.AutoStandby.Status(ctx, instanceID, opts...)
if err != nil {
if err := call(ctx, &client, instanceID, opts); err != nil {
return err
}

Expand Down Expand Up @@ -92,10 +134,13 @@ func handleAutoStandbyStatus(ctx context.Context, cmd *cli.Command) error {
if nextStandby := obj.Get("next_standby_at").String(); nextStandby != "" {
fmt.Printf("%-14s %s\n", "NEXT STANDBY", nextStandby)
}
if holdUntil := obj.Get("hold_until").String(); holdUntil != "" {
fmt.Printf("%-14s %s\n", "HOLD UNTIL", holdUntil)
}
return nil
}

return ShowJSON(os.Stdout, "auto-standby status", obj, format, transform)
return ShowJSON(os.Stdout, "auto-standby "+subcommand, obj, format, transform)
}

func buildAutoStandbyPolicy(cmd *cli.Command, prefix string) (hypeman.AutoStandbyPolicyParam, bool, error) {
Expand Down
26 changes: 26 additions & 0 deletions pkg/cmd/autostandbycmd_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package cmd

import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestAutoStandbyCommandStructure(t *testing.T) {
assert.Equal(t, "auto-standby", autoStandbyCmd.Name)

subcommandNames := make([]string, len(autoStandbyCmd.Commands))
for i, cmd := range autoStandbyCmd.Commands {
subcommandNames[i] = cmd.Name
}

assert.Contains(t, subcommandNames, "status")
assert.Contains(t, subcommandNames, "hold")
}

func TestAutoStandbyHoldCmdStructure(t *testing.T) {
assert.Equal(t, "hold", autoStandbyHoldCmd.Name)
assert.Equal(t, "<instance>", autoStandbyHoldCmd.ArgsUsage)
require.NotNil(t, autoStandbyHoldCmd.Action)
}
60 changes: 60 additions & 0 deletions pkg/cmd/coveragecmd_test.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,73 @@
package cmd

import (
"bytes"
"context"
"strings"
"testing"

"github.com/kernel/hypeman-go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/urfave/cli/v3"
)

// TestSubcommandsAreListedInHelp guards against SDK surface that is wired up but
// unreachable in practice: urfave/cli silently renders the no-subcommand help
// template for a command that has exactly one subcommand and a hidden help
// command, which omits the COMMANDS section entirely.
func TestSubcommandsAreListedInHelp(t *testing.T) {
var walk func(t *testing.T, path []string, parent *cli.Command)
walk = func(t *testing.T, path []string, parent *cli.Command) {
subs := visibleSubcommands(parent)
if len(subs) == 0 {
return
}

help := renderHelp(t, path)
for _, sub := range subs {
assert.Contains(t, help, sub.Name,
"`hypeman %s --help` does not list subcommand %q",
strings.Join(path, " "), sub.Name)
}

for _, sub := range subs {
walk(t, append(append([]string{}, path...), sub.Name), sub)
}
}

walk(t, nil, Command)
}

func visibleSubcommands(parent *cli.Command) []*cli.Command {
var subs []*cli.Command
for _, sub := range parent.VisibleCommands() {
// Skip the generated "@manpages"/"@completion" style helper commands.
if strings.HasPrefix(sub.Name, "@") {
continue
}
subs = append(subs, sub)
}
return subs
}

func renderHelp(t *testing.T, path []string) string {
t.Helper()

var buf bytes.Buffer
origWriter, origErrWriter := Command.Writer, Command.ErrWriter
Command.Writer, Command.ErrWriter = &buf, &buf
defer func() {
Command.Writer, Command.ErrWriter = origWriter, origErrWriter
}()

args := append([]string{"hypeman"}, path...)
args = append(args, "--help")
require.NoError(t, Command.Run(context.Background(), args))

return buf.String()
}

func TestParseInstanceWaitState(t *testing.T) {
t.Run("accepts mixed-case state names", func(t *testing.T) {
state, err := parseInstanceWaitState("rUnNiNg")
Expand Down
34 changes: 11 additions & 23 deletions pkg/cmd/imagecmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,14 +69,9 @@ var imageDeleteCmd = cli.Command{
HideHelpCommand: true,
}

// platformFromRaw reads the top-level "platform" field from a raw server payload,
// defaulting to "-" when absent. SDK v0.20.0's Image/Instance models predate the
// platform field, so it is only reachable via the raw JSON.
//
// TODO(sdk-bump): drop this once the SDK exposes a typed platform field and read
// it directly alongside the other columns.
func platformFromRaw(raw string) string {
platform := gjson.Get(raw, "platform").String()
// platformOrDash renders a resolved platform for table output, falling back to
// "-" when the server did not report one.
func platformOrDash(platform string) string {
if platform == "" {
return "-"
}
Expand Down Expand Up @@ -147,7 +142,7 @@ func handleImageList(ctx context.Context, cmd *cli.Command) error {

table.AddRow(
img.Name,
platformFromRaw(img.RawJSON()),
platformOrDash(img.Platform),
string(img.Status),
digest,
size,
Expand All @@ -171,7 +166,7 @@ func handleImageCreateLike(ctx context.Context, cmd *cli.Command, usageLine, out

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

params, malformedTags := buildImageNewParams(args[0], cmd.StringSlice("tag"))
params, malformedTags := buildImageNewParams(args[0], cmd.StringSlice("tag"), cmd.String("platform"))
for _, malformed := range malformedTags {
fmt.Fprintf(os.Stderr, "Warning: ignoring malformed tag: %s\n", malformed)
}
Expand All @@ -180,7 +175,6 @@ func handleImageCreateLike(ctx context.Context, cmd *cli.Command, usageLine, out
if cmd.Root().Bool("debug") {
opts = append(opts, debugMiddlewareOption)
}
opts = append(opts, platformRequestOptions(cmd.String("platform"))...)

format := cmd.Root().String("format")
transform := cmd.Root().String("transform")
Expand Down Expand Up @@ -212,14 +206,18 @@ func imageCreateFlags() []cli.Flag {
},
&cli.StringFlag{
Name: "platform",
Usage: `Target platform as os/arch[/variant] (e.g., "linux/amd64")`,
Usage: `Target platform as os/arch[/variant] (e.g., "linux/amd64"). Defaults to the host platform`,
},
}
}

func buildImageNewParams(name string, tagSpecs []string) (hypeman.ImageNewParams, []string) {
func buildImageNewParams(name string, tagSpecs []string, platform string) (hypeman.ImageNewParams, []string) {
params := hypeman.ImageNewParams{Name: name}

if platform != "" {
params.Platform = hypeman.Opt(platform)
}

tags, malformedTags := parseKeyValueSpecs(tagSpecs)
if len(tags) > 0 {
params.Tags = tags
Expand All @@ -228,16 +226,6 @@ func buildImageNewParams(name string, tagSpecs []string) (hypeman.ImageNewParams
return params, malformedTags
}

// platformRequestOptions sets the Docker-style platform field on an image- or
// instance-create request. SDK v0.20.0 has no typed platform field, so we set it
// via WithJSONSet. TODO(sdk-bump): pass a typed field once the SDK exposes it.
func platformRequestOptions(platform string) []option.RequestOption {
if platform == "" {
return nil
}
return []option.RequestOption{option.WithJSONSet("platform", platform)}
}

func handleImageGet(ctx context.Context, cmd *cli.Command) error {
args := cmd.Args().Slice()
if len(args) < 1 {
Expand Down
24 changes: 15 additions & 9 deletions pkg/cmd/imagecmd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,22 +12,28 @@ func TestBuildImageNewParams(t *testing.T) {
"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)
assert.False(t, params.Platform.Valid())
}

// TestPlatformFromRaw guards the N4 image-list PLATFORM column: the value is read
// from the raw server payload (the SDK model predates the field) and falls back
// to "-" when absent.
func TestPlatformFromRaw(t *testing.T) {
assert.Equal(t, "linux/amd64", platformFromRaw(`{"name":"alpine","platform":"linux/amd64"}`))
assert.Equal(t, "-", platformFromRaw(`{"name":"alpine"}`))
assert.Equal(t, "-", platformFromRaw(`{"name":"alpine","platform":""}`))
assert.Equal(t, "-", platformFromRaw(""))
func TestBuildImageNewParamsPlatform(t *testing.T) {
params, malformed := buildImageNewParams("docker.io/library/alpine:latest", nil, "linux/amd64")

require.Empty(t, malformed)
require.True(t, params.Platform.Valid())
assert.Equal(t, "linux/amd64", params.Platform.Value)
}

// TestPlatformOrDash guards the image-list PLATFORM column, which falls back to
// "-" when the server reports no resolved platform.
func TestPlatformOrDash(t *testing.T) {
assert.Equal(t, "linux/amd64", platformOrDash("linux/amd64"))
assert.Equal(t, "-", platformOrDash(""))
}
5 changes: 2 additions & 3 deletions pkg/cmd/inspect.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,8 @@ func handleInspect(ctx context.Context, cmd *cli.Command) error {
return err
}

// Render from the raw server response rather than the typed struct: SDK
// v0.20.0's Instance model predates fields like `platform`, so marshaling the
// struct would silently drop them. RawJSON carries the full server payload.
// Render from the raw server response rather than the typed struct so fields
// the server adds ahead of an SDK bump still surface here.
raw := instance.RawJSON()
if raw == "" {
return fmt.Errorf("instance response did not include a raw payload")
Expand Down
3 changes: 1 addition & 2 deletions pkg/cmd/pull.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ func handlePull(ctx context.Context, cmd *cli.Command) error {
}

image := args[0]
params, malformedTags := buildImageNewParams(image, cmd.StringSlice("tag"))
params, malformedTags := buildImageNewParams(image, cmd.StringSlice("tag"), cmd.String("platform"))
for _, malformed := range malformedTags {
fmt.Fprintf(os.Stderr, "Warning: ignoring malformed tag: %s\n", malformed)
}
Expand All @@ -38,7 +38,6 @@ func handlePull(ctx context.Context, cmd *cli.Command) error {
if cmd.Root().Bool("debug") {
opts = append(opts, debugMiddlewareOption)
}
opts = append(opts, platformRequestOptions(cmd.String("platform"))...)

format := cmd.Root().String("format")
transform := cmd.Root().String("transform")
Expand Down
8 changes: 6 additions & 2 deletions pkg/cmd/resourcecmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,12 @@ Examples:
Commands: []*cli.Command{
&resourcesReclaimMemoryCmd,
},
Action: handleResources,
HideHelpCommand: true,
// urfave/cli falls back to the no-subcommand help template when a command has
// exactly one subcommand and the help command is hidden, which would drop
// reclaim-memory from `hypeman resources --help`.
CustomHelpTemplate: cli.SubcommandHelpTemplate,
Action: handleResources,
HideHelpCommand: true,
}

var resourcesReclaimMemoryCmd = cli.Command{
Expand Down
Loading
Loading