diff --git a/go.mod b/go.mod index 3517cb8..4a545dc 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.16.1-0.20260323172303-508a8c69feb3 + github.com/kernel/hypeman-go v0.17.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 c460850..c910977 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.16.1-0.20260323172303-508a8c69feb3 h1:g6qT9G/Qrxqqdl9gjqTnhDAHlePxV68OyQjlqXA6WX4= -github.com/kernel/hypeman-go v0.16.1-0.20260323172303-508a8c69feb3/go.mod h1:guRrhyP9QW/ebUS1UcZ0uZLLJeGAAhDNzSi68U4M9hI= +github.com/kernel/hypeman-go v0.17.0 h1:OaGS0pFUwXYaFtlXaIQleokgNM7Z+KO0mGhL953yiMQ= +github.com/kernel/hypeman-go v0.17.0/go.mod h1:guRrhyP9QW/ebUS1UcZ0uZLLJeGAAhDNzSi68U4M9hI= 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 new file mode 100644 index 0000000..eadb8ed --- /dev/null +++ b/pkg/cmd/autostandbycmd.go @@ -0,0 +1,174 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "strconv" + "strings" + + "github.com/kernel/hypeman-go" + "github.com/kernel/hypeman-go/option" + "github.com/tidwall/gjson" + "github.com/urfave/cli/v3" +) + +var autoStandbyCmd = cli.Command{ + Name: "auto-standby", + Aliases: []string{"autostandby"}, + Usage: "Inspect auto-standby configuration and status", + Commands: []*cli.Command{ + &autoStandbyStatusCmd, + }, + HideHelpCommand: true, +} + +var autoStandbyStatusCmd = cli.Command{ + Name: "status", + Usage: "Get auto-standby status for an instance", + ArgsUsage: "", + Action: handleAutoStandbyStatus, + HideHelpCommand: true, +} + +func handleAutoStandbyStatus(ctx context.Context, cmd *cli.Command) error { + args := cmd.Args().Slice() + if len(args) < 1 { + return fmt.Errorf("instance ID or name required\nUsage: hypeman auto-standby status ") + } + + client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) + instanceID, err := ResolveInstance(ctx, &client, args[0]) + if err != nil { + return err + } + + var opts []option.RequestOption + if cmd.Root().Bool("debug") { + opts = append(opts, debugMiddlewareOption) + } + + var res []byte + opts = append(opts, option.WithResponseBodyInto(&res)) + _, err = client.Instances.AutoStandby.Status(ctx, instanceID, opts...) + if err != nil { + return err + } + + format := cmd.Root().String("format") + transform := cmd.Root().String("transform") + + obj := gjson.ParseBytes(res) + + if format == "auto" { + status := obj.Get("status").String() + enabled := obj.Get("enabled").Bool() + configured := obj.Get("configured").Bool() + supported := obj.Get("supported").Bool() + idleTimeout := obj.Get("idle_timeout").String() + reason := obj.Get("reason").String() + trackingMode := obj.Get("tracking_mode").String() + connections := obj.Get("active_inbound_connections").Int() + + if idleTimeout == "" { + idleTimeout = "-" + } + if reason == "" { + reason = "-" + } + + fmt.Printf("%-14s %s\n", "STATUS", status) + fmt.Printf("%-14s %t\n", "ENABLED", enabled) + fmt.Printf("%-14s %t\n", "CONFIGURED", configured) + fmt.Printf("%-14s %t\n", "SUPPORTED", supported) + fmt.Printf("%-14s %s\n", "IDLE TIMEOUT", idleTimeout) + fmt.Printf("%-14s %s\n", "REASON", reason) + fmt.Printf("%-14s %s\n", "TRACKING", trackingMode) + fmt.Printf("%-14s %d\n", "CONNECTIONS", connections) + + if idleSince := obj.Get("idle_since").String(); idleSince != "" { + fmt.Printf("%-14s %s\n", "IDLE SINCE", idleSince) + } + if nextStandby := obj.Get("next_standby_at").String(); nextStandby != "" { + fmt.Printf("%-14s %s\n", "NEXT STANDBY", nextStandby) + } + return nil + } + + return ShowJSON(os.Stdout, "auto-standby status", obj, format, transform) +} + +func buildAutoStandbyPolicy(cmd *cli.Command, prefix string) (hypeman.AutoStandbyPolicyParam, bool, error) { + var policy hypeman.AutoStandbyPolicyParam + + enabledFlag := prefix + "enabled" + idleTimeoutFlag := prefix + "idle-timeout" + ignoreDestinationPortFlag := prefix + "ignore-destination-port" + ignoreSourceCIDRFlag := prefix + "ignore-source-cidr" + + enabledSet := cmd.IsSet(enabledFlag) + idleTimeout := cmd.String(idleTimeoutFlag) + ignoreSourceCIDRs := cleanStringValues(cmd.StringSlice(ignoreSourceCIDRFlag)) + ignoreDestinationPorts, err := parseAutoStandbyPorts(cmd.StringSlice(ignoreDestinationPortFlag), ignoreDestinationPortFlag) + if err != nil { + return hypeman.AutoStandbyPolicyParam{}, false, err + } + + if !enabledSet && idleTimeout == "" && len(ignoreDestinationPorts) == 0 && len(ignoreSourceCIDRs) == 0 { + return hypeman.AutoStandbyPolicyParam{}, false, nil + } + + if enabledSet { + policy.Enabled = hypeman.Opt(cmd.Bool(enabledFlag)) + } else { + policy.Enabled = hypeman.Opt(true) + } + + if idleTimeout != "" { + policy.IdleTimeout = hypeman.Opt(idleTimeout) + } + if len(ignoreDestinationPorts) > 0 { + policy.IgnoreDestinationPorts = ignoreDestinationPorts + } + if len(ignoreSourceCIDRs) > 0 { + policy.IgnoreSourceCidrs = ignoreSourceCIDRs + } + + return policy, true, nil +} + +func parseAutoStandbyPorts(rawPorts []string, flagName string) ([]int64, error) { + ports := make([]int64, 0, len(rawPorts)) + for _, rawPort := range rawPorts { + value := strings.TrimSpace(rawPort) + if value == "" { + continue + } + + port, err := strconv.ParseInt(value, 10, 64) + if err != nil { + return nil, fmt.Errorf("invalid %s value %q: %w", flagName, rawPort, err) + } + if port < 1 || port > 65535 { + return nil, fmt.Errorf("%s must be between 1 and 65535: %q", flagName, rawPort) + } + + ports = append(ports, port) + } + + return ports, nil +} + +func cleanStringValues(values []string) []string { + cleaned := make([]string, 0, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" { + continue + } + + cleaned = append(cleaned, value) + } + + return cleaned +} diff --git a/pkg/cmd/cmd.go b/pkg/cmd/cmd.go index fb2ddff..9eb5783 100644 --- a/pkg/cmd/cmd.go +++ b/pkg/cmd/cmd.go @@ -77,8 +77,10 @@ func init() { &psCmd, &statsCmd, &updateCmd, + &autoStandbyCmd, &inspectCmd, &logsCmd, + &waitCmd, &rmCmd, &stopCmd, &startCmd, diff --git a/pkg/cmd/coveragecmd_test.go b/pkg/cmd/coveragecmd_test.go new file mode 100644 index 0000000..a9a7377 --- /dev/null +++ b/pkg/cmd/coveragecmd_test.go @@ -0,0 +1,35 @@ +package cmd + +import ( + "testing" + + "github.com/kernel/hypeman-go" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseInstanceWaitState(t *testing.T) { + t.Run("accepts mixed-case state names", func(t *testing.T) { + state, err := parseInstanceWaitState("rUnNiNg") + require.NoError(t, err) + assert.Equal(t, hypeman.InstanceWaitParamsStateRunning, state) + }) + + t.Run("rejects unsupported state names", func(t *testing.T) { + _, err := parseInstanceWaitState("booting") + require.EqualError(t, err, "invalid state: booting (must be Created, Initializing, Running, Paused, Shutdown, Stopped, Standby, or Unknown)") + }) +} + +func TestParseAutoStandbyPorts(t *testing.T) { + t.Run("parses valid port values", func(t *testing.T) { + ports, err := parseAutoStandbyPorts([]string{"80", " 443 "}, "ignore-destination-port") + require.NoError(t, err) + assert.Equal(t, []int64{80, 443}, ports) + }) + + t.Run("rejects out-of-range ports", func(t *testing.T) { + _, err := parseAutoStandbyPorts([]string{"70000"}, "ignore-destination-port") + require.EqualError(t, err, `ignore-destination-port must be between 1 and 65535: "70000"`) + }) +} diff --git a/pkg/cmd/run.go b/pkg/cmd/run.go index 96cc6e3..f13a09e 100644 --- a/pkg/cmd/run.go +++ b/pkg/cmd/run.go @@ -113,6 +113,22 @@ Examples: Name: "network-egress-mode", Usage: `Egress enforcement mode: "all" or "http_https_only"`, }, + &cli.BoolFlag{ + Name: "auto-standby-enabled", + Usage: "Enable Linux-only automatic standby based on inbound TCP activity", + }, + &cli.StringFlag{ + Name: "auto-standby-idle-timeout", + Usage: `How long the instance must be idle before entering standby (e.g., "10m")`, + }, + &cli.StringSliceFlag{ + Name: "auto-standby-ignore-destination-port", + Usage: "TCP destination port that should not keep the instance awake (can be repeated)", + }, + &cli.StringSliceFlag{ + Name: "auto-standby-ignore-source-cidr", + Usage: "Client CIDR that should not keep the instance awake (can be repeated)", + }, // Boot option flags &cli.BoolFlag{ Name: "skip-guest-agent", @@ -231,6 +247,13 @@ func handleRun(ctx context.Context, cmd *cli.Command) error { } params.Credentials = credentials } + autoStandbyPolicy, autoStandbySet, err := buildAutoStandbyPolicy(cmd, "auto-standby-") + if err != nil { + return err + } + if autoStandbySet { + params.AutoStandby = autoStandbyPolicy + } // Network configuration networkEnabled := cmd.Bool("network") diff --git a/pkg/cmd/snapshotcmd.go b/pkg/cmd/snapshotcmd.go index 29931c3..cdcc543 100644 --- a/pkg/cmd/snapshotcmd.go +++ b/pkg/cmd/snapshotcmd.go @@ -20,6 +20,7 @@ var snapshotCmd = cli.Command{ Commands: []*cli.Command{ &snapshotCreateCmd, &snapshotRestoreCmd, + &snapshotScheduleCmd, &snapshotListCmd, &snapshotGetCmd, &snapshotDeleteCmd, diff --git a/pkg/cmd/snapshotschedulecmd.go b/pkg/cmd/snapshotschedulecmd.go new file mode 100644 index 0000000..136d4b4 --- /dev/null +++ b/pkg/cmd/snapshotschedulecmd.go @@ -0,0 +1,261 @@ +package cmd + +import ( + "context" + "fmt" + "os" + + "github.com/kernel/hypeman-go" + "github.com/kernel/hypeman-go/option" + "github.com/tidwall/gjson" + "github.com/urfave/cli/v3" +) + +var snapshotScheduleCmd = cli.Command{ + Name: "schedule", + Usage: "Manage scheduled snapshots for an instance", + Commands: []*cli.Command{ + &snapshotScheduleSetCmd, + &snapshotScheduleGetCmd, + &snapshotScheduleDeleteCmd, + }, + HideHelpCommand: true, +} + +var snapshotScheduleSetCmd = cli.Command{ + Name: "set", + Usage: "Create or update a snapshot schedule for an instance", + ArgsUsage: "", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "interval", + Usage: `Snapshot interval (Go duration format, minimum "1m")`, + Required: true, + }, + &cli.StringFlag{ + Name: "max-age", + Usage: `Delete scheduled snapshots older than this duration (e.g., "24h")`, + }, + &cli.IntFlag{ + Name: "max-count", + Usage: "Keep at most this many scheduled snapshots (0 disables count-based cleanup)", + }, + &cli.StringFlag{ + Name: "name-prefix", + Usage: "Prefix for generated scheduled snapshot names", + }, + &cli.StringSliceFlag{ + Name: "metadata", + Usage: "Set schedule metadata key-value pair (KEY=VALUE, can be repeated)", + }, + }, + Action: handleSnapshotScheduleSet, + HideHelpCommand: true, +} + +var snapshotScheduleGetCmd = cli.Command{ + Name: "get", + Usage: "Get the snapshot schedule for an instance", + ArgsUsage: "", + Action: handleSnapshotScheduleGet, + HideHelpCommand: true, +} + +var snapshotScheduleDeleteCmd = cli.Command{ + Name: "delete", + Aliases: []string{"rm"}, + Usage: "Delete the snapshot schedule for an instance", + ArgsUsage: "", + Action: handleSnapshotScheduleDelete, + HideHelpCommand: true, +} + +func handleSnapshotScheduleSet(ctx context.Context, cmd *cli.Command) error { + args := cmd.Args().Slice() + if len(args) < 1 { + return fmt.Errorf("instance ID or name required\nUsage: hypeman snapshot schedule set --interval ") + } + + client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) + instanceID, err := ResolveInstance(ctx, &client, args[0]) + if err != nil { + return err + } + + request, malformedMetadata, err := buildSnapshotScheduleRequest(cmd) + if err != nil { + return err + } + for _, malformed := range malformedMetadata { + fmt.Fprintf(os.Stderr, "Warning: ignoring malformed metadata entry: %s\n", malformed) + } + + params := hypeman.InstanceSnapshotScheduleUpdateParams{ + SetSnapshotScheduleRequest: request, + } + + var opts []option.RequestOption + if cmd.Root().Bool("debug") { + opts = append(opts, debugMiddlewareOption) + } + + var res []byte + opts = append(opts, option.WithResponseBodyInto(&res)) + _, err = client.Instances.SnapshotSchedule.Update(ctx, instanceID, params, opts...) + if err != nil { + return err + } + + format := cmd.Root().String("format") + transform := cmd.Root().String("transform") + + obj := gjson.ParseBytes(res) + + if format == "auto" { + printSnapshotScheduleSummary(obj) + return nil + } + + return ShowJSON(os.Stdout, "snapshot schedule set", obj, format, transform) +} + +func handleSnapshotScheduleGet(ctx context.Context, cmd *cli.Command) error { + args := cmd.Args().Slice() + if len(args) < 1 { + return fmt.Errorf("instance ID or name required\nUsage: hypeman snapshot schedule get ") + } + + client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) + instanceID, err := ResolveInstance(ctx, &client, args[0]) + if err != nil { + return err + } + + var opts []option.RequestOption + if cmd.Root().Bool("debug") { + opts = append(opts, debugMiddlewareOption) + } + + var res []byte + opts = append(opts, option.WithResponseBodyInto(&res)) + _, err = client.Instances.SnapshotSchedule.Get(ctx, instanceID, opts...) + if err != nil { + return err + } + + format := cmd.Root().String("format") + transform := cmd.Root().String("transform") + + obj := gjson.ParseBytes(res) + + if format == "auto" { + printSnapshotScheduleSummary(obj) + return nil + } + + return ShowJSON(os.Stdout, "snapshot schedule get", obj, format, transform) +} + +func handleSnapshotScheduleDelete(ctx context.Context, cmd *cli.Command) error { + args := cmd.Args().Slice() + if len(args) < 1 { + return fmt.Errorf("instance ID or name required\nUsage: hypeman snapshot schedule delete ") + } + + client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) + instanceID, err := ResolveInstance(ctx, &client, args[0]) + if err != nil { + return err + } + + var opts []option.RequestOption + if cmd.Root().Bool("debug") { + opts = append(opts, debugMiddlewareOption) + } + + if err := client.Instances.SnapshotSchedule.Delete(ctx, instanceID, opts...); err != nil { + return err + } + + fmt.Fprintf(os.Stderr, "Deleted snapshot schedule for %s\n", args[0]) + return nil +} + +func buildSnapshotScheduleRequest(cmd *cli.Command) (hypeman.SetSnapshotScheduleRequestParam, []string, error) { + if !cmd.IsSet("max-age") && !cmd.IsSet("max-count") { + return hypeman.SetSnapshotScheduleRequestParam{}, nil, fmt.Errorf("at least one of --max-age or --max-count is required") + } + + request := hypeman.SetSnapshotScheduleRequestParam{ + Interval: cmd.String("interval"), + Retention: hypeman.SnapshotScheduleRetentionParam{}, + } + + if maxAge := cmd.String("max-age"); maxAge != "" { + request.Retention.MaxAge = hypeman.Opt(maxAge) + } + if cmd.IsSet("max-count") { + request.Retention.MaxCount = hypeman.Opt(int64(cmd.Int("max-count"))) + } + if namePrefix := cmd.String("name-prefix"); namePrefix != "" { + request.NamePrefix = hypeman.Opt(namePrefix) + } + + metadata, malformedMetadata := parseKeyValueSpecs(cmd.StringSlice("metadata")) + if len(metadata) > 0 { + request.Metadata = metadata + } + + return request, malformedMetadata, nil +} + +func printSnapshotScheduleSummary(obj gjson.Result) { + instanceID := obj.Get("instance_id").String() + interval := obj.Get("interval").String() + maxAge := obj.Get("retention.max_age").String() + maxCount := obj.Get("retention.max_count").Int() + namePrefix := obj.Get("name_prefix").String() + nextRun := obj.Get("next_run_at").String() + createdAt := obj.Get("created_at").String() + + if maxAge == "" { + maxAge = "-" + } + if namePrefix == "" { + namePrefix = "-" + } + if nextRun == "" { + nextRun = "-" + } + + maxCountStr := "-" + if maxCount > 0 { + maxCountStr = fmt.Sprintf("%d", maxCount) + } + + fmt.Printf("%-14s %s\n", "INSTANCE", instanceID) + fmt.Printf("%-14s %s\n", "INTERVAL", interval) + fmt.Printf("%-14s %s\n", "MAX AGE", maxAge) + fmt.Printf("%-14s %s\n", "MAX COUNT", maxCountStr) + fmt.Printf("%-14s %s\n", "PREFIX", namePrefix) + fmt.Printf("%-14s %s\n", "NEXT RUN", nextRun) + fmt.Printf("%-14s %s\n", "CREATED", createdAt) + + metadata := obj.Get("metadata") + if metadata.Exists() && metadata.IsObject() { + fmt.Printf("%-14s", "METADATA") + first := true + metadata.ForEach(func(key, value gjson.Result) bool { + if first { + fmt.Printf(" %s=%s\n", key.String(), value.String()) + first = false + } else { + fmt.Printf("%-14s %s=%s\n", "", key.String(), value.String()) + } + return true + }) + if first { + fmt.Println(" -") + } + } +} diff --git a/pkg/cmd/update.go b/pkg/cmd/update.go index aaa0a8c..4cd1587 100644 --- a/pkg/cmd/update.go +++ b/pkg/cmd/update.go @@ -17,13 +17,41 @@ var updateCmd = cli.Command{ Description: `Update mutable instance settings that have dedicated update flows. Currently supported: + hypeman update auto-standby --enabled --idle-timeout 10m hypeman update egress-credentials --env KEY=VALUE`, Commands: []*cli.Command{ + &updateAutoStandbyCmd, &updateEgressCredentialsCmd, }, HideHelpCommand: true, } +var updateAutoStandbyCmd = cli.Command{ + Name: "auto-standby", + Usage: "Update the auto-standby policy for an instance", + ArgsUsage: "", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "enabled", + Usage: "Enable Linux-only automatic standby based on inbound TCP activity", + }, + &cli.StringFlag{ + Name: "idle-timeout", + Usage: `How long the instance must be idle before entering standby (e.g., "10m")`, + }, + &cli.StringSliceFlag{ + Name: "ignore-destination-port", + Usage: "TCP destination port that should not keep the instance awake (can be repeated)", + }, + &cli.StringSliceFlag{ + Name: "ignore-source-cidr", + Usage: "Client CIDR that should not keep the instance awake (can be repeated)", + }, + }, + Action: handleUpdateAutoStandby, + HideHelpCommand: true, +} + var updateEgressCredentialsCmd = cli.Command{ Name: "egress-credentials", Usage: "Rotate env-backed credentials for existing mediated egress bindings", @@ -39,6 +67,59 @@ var updateEgressCredentialsCmd = cli.Command{ HideHelpCommand: true, } +func handleUpdateAutoStandby(ctx context.Context, cmd *cli.Command) error { + args := cmd.Args().Slice() + if len(args) < 1 { + return fmt.Errorf("instance ID or name required\nUsage: hypeman update auto-standby [flags]") + } + + policy, policySet, err := buildAutoStandbyPolicy(cmd, "") + if err != nil { + return err + } + if !policySet { + return fmt.Errorf("at least one auto-standby flag is required") + } + + client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) + instanceID, err := ResolveInstance(ctx, &client, args[0]) + if err != nil { + return err + } + + params := hypeman.InstanceUpdateParams{ + AutoStandby: policy, + } + + var opts []option.RequestOption + if cmd.Root().Bool("debug") { + opts = append(opts, debugMiddlewareOption) + } + + format := cmd.Root().String("format") + transform := cmd.Root().String("transform") + + if format != "auto" { + var res []byte + opts = append(opts, option.WithResponseBodyInto(&res)) + _, err := client.Instances.Update(ctx, instanceID, params, opts...) + if err != nil { + return err + } + obj := gjson.ParseBytes(res) + return ShowJSON(os.Stdout, "update auto-standby", obj, format, transform) + } + + fmt.Fprintf(os.Stderr, "Updating auto-standby for %s...\n", args[0]) + + instance, err := client.Instances.Update(ctx, instanceID, params, opts...) + if err != nil { + return err + } + fmt.Println(instance.ID) + return nil +} + func handleUpdate(ctx context.Context, cmd *cli.Command) error { args := cmd.Args().Slice() if len(args) < 1 { diff --git a/pkg/cmd/wait.go b/pkg/cmd/wait.go new file mode 100644 index 0000000..150bbb9 --- /dev/null +++ b/pkg/cmd/wait.go @@ -0,0 +1,117 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "strings" + + "github.com/kernel/hypeman-go" + "github.com/kernel/hypeman-go/option" + "github.com/tidwall/gjson" + "github.com/urfave/cli/v3" +) + +var waitCmd = cli.Command{ + Name: "wait", + Usage: "Wait for an instance to reach a target state", + ArgsUsage: "", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "state", + Usage: `Target state: "Created", "Initializing", "Running", "Paused", "Shutdown", "Stopped", "Standby", or "Unknown"`, + Required: true, + }, + &cli.StringFlag{ + Name: "timeout", + Usage: `Maximum duration to wait (e.g., "30s", "2m")`, + }, + }, + Action: handleWait, + HideHelpCommand: true, +} + +func handleWait(ctx context.Context, cmd *cli.Command) error { + args := cmd.Args().Slice() + if len(args) < 1 { + return fmt.Errorf("instance ID or name required\nUsage: hypeman wait --state ") + } + + targetState, err := parseInstanceWaitState(cmd.String("state")) + if err != nil { + return err + } + + client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) + instanceID, err := ResolveInstance(ctx, &client, args[0]) + if err != nil { + return err + } + + params := hypeman.InstanceWaitParams{ + State: targetState, + } + if timeout := cmd.String("timeout"); timeout != "" { + params.Timeout = hypeman.Opt(timeout) + } + + var opts []option.RequestOption + if cmd.Root().Bool("debug") { + opts = append(opts, debugMiddlewareOption) + } + + var res []byte + opts = append(opts, option.WithResponseBodyInto(&res)) + _, err = client.Instances.Wait(ctx, instanceID, params, opts...) + if err != nil { + return err + } + + format := cmd.Root().String("format") + transform := cmd.Root().String("transform") + + obj := gjson.ParseBytes(res) + + if format == "auto" { + state := obj.Get("state").String() + timedOut := obj.Get("timed_out").Bool() + stateError := obj.Get("state_error").String() + + if timedOut { + fmt.Fprintf(os.Stderr, "Timed out waiting (last state: %s)\n", state) + return fmt.Errorf("timed out waiting for instance to reach state %s", cmd.String("state")) + } + if stateError != "" { + fmt.Printf("%-14s %s\n", "STATE", state) + fmt.Printf("%-14s %s\n", "STATE ERROR", stateError) + } else { + fmt.Println(state) + } + return nil + } + + return ShowJSON(os.Stdout, "instance wait", obj, format, transform) +} + +func parseInstanceWaitState(raw string) (hypeman.InstanceWaitParamsState, error) { + switch strings.ToLower(raw) { + case "created": + return hypeman.InstanceWaitParamsStateCreated, nil + case "initializing": + return hypeman.InstanceWaitParamsStateInitializing, nil + case "running": + return hypeman.InstanceWaitParamsStateRunning, nil + case "paused": + return hypeman.InstanceWaitParamsStatePaused, nil + case "shutdown": + return hypeman.InstanceWaitParamsStateShutdown, nil + case "stopped": + return hypeman.InstanceWaitParamsStateStopped, nil + case "standby": + return hypeman.InstanceWaitParamsStateStandby, nil + case "unknown": + return hypeman.InstanceWaitParamsStateUnknown, nil + default: + return "", fmt.Errorf("invalid state: %s (must be Created, Initializing, Running, Paused, Shutdown, Stopped, Standby, or Unknown)", raw) + } +}