Skip to content
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.21.0
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.21.0 h1:R9UHGe5a4LWmd7/Xjw2QoEn7Fs5/H//JekubqgsXNaU=
github.com/kernel/hypeman-go v0.21.0/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)
}
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
16 changes: 10 additions & 6 deletions pkg/cmd/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ Examples:
},
&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`,
},
&cli.StringFlag{
Name: "memory",
Expand Down Expand Up @@ -212,9 +212,11 @@ func handleRun(ctx context.Context, cmd *cli.Command) error {
// Image not found, try to pull it
if isNotFoundError(err) {
fmt.Fprintf(os.Stderr, "Image not found locally. Pulling %s...\n", image)
imgInfo, err = client.Images.New(ctx, hypeman.ImageNewParams{
Name: image,
}, platformRequestOptions(platform)...)
imagePullParams := hypeman.ImageNewParams{Name: image}
if platform != "" {
imagePullParams.Platform = hypeman.Opt(platform)
}
imgInfo, err = client.Images.New(ctx, imagePullParams)
if err != nil {
return fmt.Errorf("failed to pull image: %w", err)
}
Expand Down Expand Up @@ -254,7 +256,9 @@ func handleRun(ctx context.Context, cmd *cli.Command) error {
OverlaySize: hypeman.Opt(cmd.String("overlay-size")),
HotplugSize: hypeman.Opt(cmd.String("hotplug-size")),
}
instanceCreateOpts := platformRequestOptions(platform)
if platform != "" {
params.Platform = hypeman.Opt(platform)
}

if len(env) > 0 {
params.Env = env
Expand Down Expand Up @@ -427,7 +431,7 @@ func handleRun(ctx context.Context, cmd *cli.Command) error {
result, err := client.Instances.New(
ctx,
params,
append(opts, instanceCreateOpts...)...,
opts...,
)
if err != nil {
return err
Expand Down
Loading