Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: better error messages on malformed option value #798

Merged
merged 4 commits into from
Jul 4, 2024
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 @@ -14,6 +14,7 @@ require (
github.com/guptarohit/asciigraph v0.7.1
github.com/hetznercloud/hcloud-go/v2 v2.10.1
github.com/jedib0t/go-pretty/v6 v6.5.9
github.com/spf13/cast v1.6.0
github.com/spf13/cobra v1.8.1
github.com/spf13/pflag v1.0.5
github.com/spf13/viper v1.19.0
Expand Down Expand Up @@ -46,7 +47,6 @@ require (
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.11.0 // indirect
github.com/spf13/cast v1.6.0 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
go.uber.org/atomic v1.9.0 // indirect
go.uber.org/multierr v1.9.0 // indirect
Expand Down
6 changes: 5 additions & 1 deletion internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,11 @@ func NewRootCommand(s state.State) *cobra.Command {
cmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error {
var err error
out := os.Stdout
if quiet := config.OptionQuiet.Get(s.Config()); quiet {
quiet, err := config.OptionQuiet.Get(s.Config())
if err != nil {
return err
}
if quiet {
out, err = os.Open(os.DevNull)
if err != nil {
return err
Expand Down
5 changes: 4 additions & 1 deletion internal/cmd/base/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,10 @@ func (cc *CreateCmd) CobraCommand(s state.State) *cobra.Command {
cmd.RunE = func(cmd *cobra.Command, args []string) error {
outputFlags := output.FlagsForCommand(cmd)

quiet := config.OptionQuiet.Get(s.Config())
quiet, err := config.OptionQuiet.Get(s.Config())
if err != nil {
return err
}

isSchema := outputFlags.IsSet("json") || outputFlags.IsSet("yaml")
if isSchema && !quiet {
Expand Down
6 changes: 5 additions & 1 deletion internal/cmd/config/get.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,11 @@ func runGet(s state.State, cmd *cobra.Command, args []string) error {
return fmt.Errorf("unknown key: %s", key)
}

val := opt.GetAsAny(s.Config())
val, err := opt.GetAsAny(s.Config())
if err != nil {
return err
}

if opt.HasFlags(config.OptionFlagSensitive) && !allowSensitive {
return fmt.Errorf("'%s' is sensitive. use --allow-sensitive to show the value", key)
}
Expand Down
6 changes: 5 additions & 1 deletion internal/cmd/config/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,11 @@ func runList(s state.State, cmd *cobra.Command, _ []string) error {

var options []option
for name, opt := range config.Options {
val := opt.GetAsAny(s.Config())
val, err := opt.GetAsAny(s.Config())
if err != nil {
return err
}

if opt.HasFlags(config.OptionFlagSensitive) && !allowSensitive {
val = "[redacted]"
}
Expand Down
5 changes: 4 additions & 1 deletion internal/cmd/server/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -356,7 +356,10 @@ func createOptsFromFlags(
}

if !flags.Changed("ssh-key") && config.OptionDefaultSSHKeys.Changed(s.Config()) {
sshKeys = config.OptionDefaultSSHKeys.Get(s.Config())
sshKeys, err = config.OptionDefaultSSHKeys.Get(s.Config())
if err != nil {
return
}
}

for _, sshKeyIDOrName := range sshKeys {
Expand Down
25 changes: 25 additions & 0 deletions internal/cmd/util/util.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"time"

"github.com/goccy/go-yaml"
"github.com/spf13/cast"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
Expand Down Expand Up @@ -302,3 +303,27 @@ func ParseBoolLenient(s string) (bool, error) {
return false, fmt.Errorf("invalid boolean value: %s", s)
}
}

// ToBoolE converts the provided value to a bool. It is more lenient than [cast.ToBoolE] (see [ParseBoolLenient]).
func ToBoolE(val any) (bool, error) {
switch v := val.(type) {
case bool:
return v, nil
case string:
return ParseBoolLenient(v)
default:
return cast.ToBoolE(val)
}
}

// ToStringSliceDelimited is like [AnyToStringSlice] but also accepts a string that is comma-separated.
func ToStringSliceDelimited(val any) []string {
switch v := val.(type) {
case []string:
return v
case string:
return strings.Split(v, ",")
default:
return AnyToStringSlice(val)
}
}
33 changes: 33 additions & 0 deletions internal/cmd/util/util_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -380,3 +380,36 @@ func TestParseBoolLenient(t *testing.T) {
b, err = util.ParseBoolLenient("")
assert.EqualError(t, err, "invalid boolean value: ")
}

func TestBoolFromAny(t *testing.T) {
b, err := util.ToBoolE(true)
assert.NoError(t, err)
assert.True(t, b)
b, err = util.ToBoolE("true")
assert.NoError(t, err)
assert.True(t, b)
b, err = util.ToBoolE("false")
assert.NoError(t, err)
assert.False(t, b)
b, err = util.ToBoolE("yes")
assert.NoError(t, err)
assert.True(t, b)
b, err = util.ToBoolE("no")
assert.NoError(t, err)
assert.False(t, b)
b, err = util.ToBoolE(1)
assert.NoError(t, err)
assert.True(t, b)
b, err = util.ToBoolE(0)
assert.NoError(t, err)
assert.False(t, b)
_, err = util.ToBoolE("invalid")
assert.EqualError(t, err, "invalid boolean value: invalid")
}

func TestToStringSliceDelimited(t *testing.T) {
assert.Equal(t, []string{"a", "b", "c"}, util.ToStringSliceDelimited([]string{"a", "b", "c"}))
assert.Equal(t, []string{"a", "b", "c"}, util.ToStringSliceDelimited([]any{"a", "b", "c"}))
assert.Equal(t, []string{"a", "b", "c"}, util.ToStringSliceDelimited("a,b,c"))
assert.Equal(t, []string{"0", "1", "2"}, util.ToStringSliceDelimited([]int{0, 1, 2}))
}
11 changes: 9 additions & 2 deletions internal/state/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,11 @@ func (cfg *config) Read(f any) error {
err error
)

cfg.path = OptionConfig.Get(cfg)
cfg.path, err = OptionConfig.Get(cfg)
if err != nil {
return err
}

cfgPath, ok := f.(string)
if cfgPath != "" && ok {
cfg.path = cfgPath
Expand Down Expand Up @@ -149,7 +153,10 @@ func (cfg *config) Read(f any) error {
}

// read active context from viper
activeContext := OptionContext.Get(cfg)
activeContext, err := OptionContext.Get(cfg)
if err != nil {
return err
}

cfg.contexts = cfg.schema.Contexts
for i, ctx := range cfg.contexts {
Expand Down
56 changes: 31 additions & 25 deletions internal/state/config/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@ package config
import (
"fmt"
"slices"
"strconv"
"strings"
"time"

"github.com/spf13/cast"
"github.com/spf13/pflag"

"github.com/hetznercloud/cli/internal/cmd/util"
Expand Down Expand Up @@ -48,7 +48,7 @@ type IOption interface {
// HasFlags returns true if the option has all the provided flags set
HasFlags(src OptionFlag) bool
// GetAsAny reads the option value from the config and returns it as an any
GetAsAny(c Config) any
GetAsAny(c Config) (any, error)
// OverrideAny sets the option value in the config to the provided any value
OverrideAny(c Config, v any)
// Changed returns true if the option has been changed from the default
Expand Down Expand Up @@ -152,41 +152,46 @@ type Option[T any] struct {
overrides *overrides
}

func (o *Option[T]) Get(c Config) T {
func (o *Option[T]) Get(c Config) (T, error) {
// val is the option value that we obtain from viper.
// Since viper uses multiple configuration sources (env, config, etc.) we need
// to be able to convert the value to the desired type.
val := c.Viper().Get(o.Name)
if val == nil {
return o.Default
if util.IsNil(val) {
return o.Default, nil
}

// t is a dummy variable to get the desired type of the option
var t T

switch any(t).(type) {
case time.Duration:
if v, ok := val.(string); ok {
d, err := time.ParseDuration(v)
if err != nil {
panic(err)
}
val = d
}
if v, ok := val.(int64); ok {
val = time.Duration(v)
// we can use the cast package included with viper here
d, err := cast.ToDurationE(val)
if err != nil {
return o.Default, fmt.Errorf("%s: %s", o.Name, err)
}
val = d

case bool:
if v, ok := val.(string); ok {
b, err := strconv.ParseBool(v)
if err != nil {
panic(err)
}
val = b
b, err := util.ToBoolE(val)
if err != nil {
return o.Default, fmt.Errorf("%s: %s", o.Name, err)
}
val = b

case []string:
if v, ok := val.([]any); ok {
val = util.ToStringSlice(v)
}
val = util.ToStringSliceDelimited(val)

case string:
val = fmt.Sprint(val)
}
return val.(T)

// now that val has the desired dynamic type, we can safely cast the static type to T
return val.(T), nil
}

func (o *Option[T]) GetAsAny(c Config) any {
func (o *Option[T]) GetAsAny(c Config) (any, error) {
return o.Get(c)
}

Expand Down Expand Up @@ -283,6 +288,7 @@ func (o *Option[T]) Parse(values []string) (any, error) {
if err != nil {
return nil, fmt.Errorf("invalid duration value: %s", value)
}

case []string:
newVal := values[:]
slices.Sort(newVal)
Expand Down
5 changes: 4 additions & 1 deletion internal/state/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@ func Wrap(s State, f func(State, *cobra.Command, []string) error) func(*cobra.Co
}

func (c *state) EnsureToken(_ *cobra.Command, _ []string) error {
token := config.OptionToken.Get(c.config)
token, err := config.OptionToken.Get(c.config)
if err != nil {
return err
}
if token == "" {
return errors.New("no active context or token (see `hcloud context --help`)")
}
Expand Down
43 changes: 35 additions & 8 deletions internal/state/state.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,11 @@ func New(cfg config.Config) (State, error) {
term: terminal.DefaultTerminal{},
}

s.client = s.newClient()
var err error
s.client, err = s.newClient()
if err != nil {
return nil, err
}
return s, nil
}

Expand All @@ -53,27 +57,50 @@ func (c *state) Terminal() terminal.Terminal {
return c.term
}

func (c *state) newClient() hcapi2.Client {
func (c *state) newClient() (hcapi2.Client, error) {
tok, err := config.OptionToken.Get(c.config)
if err != nil {
return nil, err
}

opts := []hcloud.ClientOption{
hcloud.WithToken(config.OptionToken.Get(c.config)),
hcloud.WithToken(tok),
hcloud.WithApplication("hcloud-cli", version.Version),
}

if ep := config.OptionEndpoint.Get(c.config); ep != "" {
if ep, err := config.OptionEndpoint.Get(c.config); err == nil && ep != "" {
opts = append(opts, hcloud.WithEndpoint(ep))
} else if err != nil {
return nil, err
}
if config.OptionDebug.Get(c.config) {
if filePath := config.OptionDebugFile.Get(c.config); filePath == "" {

debug, err := config.OptionDebug.Get(c.config)
if err != nil {
return nil, err
}

if debug {
filePath, err := config.OptionDebugFile.Get(c.config)
if err != nil {
return nil, err
}

if filePath == "" {
opts = append(opts, hcloud.WithDebugWriter(os.Stderr))
} else {
writer, _ := os.Create(filePath)
opts = append(opts, hcloud.WithDebugWriter(writer))
}
}
pollInterval := config.OptionPollInterval.Get(c.config)

pollInterval, err := config.OptionPollInterval.Get(c.config)
if err != nil {
return nil, err
}

if pollInterval > 0 {
opts = append(opts, hcloud.WithBackoffFunc(hcloud.ConstantBackoff(pollInterval)))
}

return hcapi2.NewClient(opts...)
return hcapi2.NewClient(opts...), nil
}
Loading