diff --git a/go.mod b/go.mod index 7e04831..5510bcd 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index bd63505..c7602db 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/pkg/cmd/autostandbycmd.go b/pkg/cmd/autostandbycmd.go index eadb8ed..979b20d 100644 --- a/pkg/cmd/autostandbycmd.go +++ b/pkg/cmd/autostandbycmd.go @@ -19,6 +19,7 @@ var autoStandbyCmd = cli.Command{ Usage: "Inspect auto-standby configuration and status", Commands: []*cli.Command{ &autoStandbyStatusCmd, + &autoStandbyHoldCmd, }, HideHelpCommand: true, } @@ -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: "", + 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 ") + return fmt.Errorf("instance ID or name required\nUsage: hypeman auto-standby %s ", subcommand) } client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) @@ -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 } @@ -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) { diff --git a/pkg/cmd/autostandbycmd_test.go b/pkg/cmd/autostandbycmd_test.go new file mode 100644 index 0000000..04b4c73 --- /dev/null +++ b/pkg/cmd/autostandbycmd_test.go @@ -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, "", autoStandbyHoldCmd.ArgsUsage) + require.NotNil(t, autoStandbyHoldCmd.Action) +} diff --git a/pkg/cmd/imagecmd.go b/pkg/cmd/imagecmd.go index 1db93c2..fe04aa1 100644 --- a/pkg/cmd/imagecmd.go +++ b/pkg/cmd/imagecmd.go @@ -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 "-" } @@ -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, @@ -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) } @@ -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") @@ -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 @@ -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 { diff --git a/pkg/cmd/imagecmd_test.go b/pkg/cmd/imagecmd_test.go index a20b77b..01f1995 100644 --- a/pkg/cmd/imagecmd_test.go +++ b/pkg/cmd/imagecmd_test.go @@ -12,7 +12,7 @@ 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{ @@ -20,14 +20,20 @@ func TestBuildImageNewParams(t *testing.T) { "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("")) } diff --git a/pkg/cmd/inspect.go b/pkg/cmd/inspect.go index d0ee82c..12a6a60 100644 --- a/pkg/cmd/inspect.go +++ b/pkg/cmd/inspect.go @@ -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") diff --git a/pkg/cmd/pull.go b/pkg/cmd/pull.go index 201fa8f..dd7c7f6 100644 --- a/pkg/cmd/pull.go +++ b/pkg/cmd/pull.go @@ -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) } @@ -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") diff --git a/pkg/cmd/run.go b/pkg/cmd/run.go index 2c39e14..0201bae 100644 --- a/pkg/cmd/run.go +++ b/pkg/cmd/run.go @@ -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", @@ -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) } @@ -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 @@ -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